Configuration File IO

NeoFOAM simulations rely on structured data/dictionaries to define solver settings, boundary conditions, and physical models. Manually parsing, validating, and writing these files is repetitive and error-prone.

The neofoam.io module builds on Pydantic to solve this. Pydantic was chosen because it provides:

  • Declarative field definitions — types and constraints (e.g. gt=0, le=100) are specified inline, not in a separate validation logic.

  • Automatic type coercion and error reporting — invalid data produces clear, structured error messages with field names and expected types.

  • Serialisation built inmodel_dump() / model_validate() handle conversion between Python objects and dictionaries with no boilerplate.

  • Ecosystem compatibility — Pydantic models integrate naturally with FastAPI, CLI tools, and other libraries.

On top of Pydantic, neofoam.io adds file-backed IO with a simple three-step workflow:

  1. Define a Pydantic model with the fields you need.

  2. Decorate it with @IOStrategy(...) to bind a file format and an optional subdict path.

  3. Use load() / save() — the framework handles reading, writing, subdict isolation, and validation.

Quick Start

from pydantic import Field
from neofoam.io import BaseConfig, YAML, IOStrategy

@IOStrategy(YAML("solver_config.yaml"))
class SolverConfig(BaseConfig):
    tolerance: float = Field(gt=0)
    maxIterations: int = Field(gt=0, le=10000)
    solver: str

# Load, modify, save
cfg = SolverConfig.load(case_dir="path/to/case")
cfg.maxIterations = 500
cfg.save(case_dir="path/to/case")

Loading

case_dir defaults to "." (current working directory), so load() with no arguments reads from the current directory.

# Load from current directory
cfg = SolverConfig.load()

# From a case directory (uses the registered filename)
cfg = SolverConfig.load(case_dir="path/to/case")

# Override filename at load time
cfg = SolverConfig.load(case_dir="path/to/case", file="alternate.yaml")

# Skip validation (useful for inspection)
cfg = SolverConfig.load(case_dir="path/to/case", validate=False)

Saving

case_dir defaults to "." as well.

# Save to current directory
cfg.save()

# Save to a specific case directory
cfg.save(case_dir="path/to/case")

# Override filename at save time
cfg.save(case_dir="path/to/case", file="alternate.yaml")

When using subdicts, save() performs a partial update — it merges only the relevant section and preserves all other sections in the file.

Validation

On load (default)load() validates and raises pydantic.ValidationError on failure.

Deferred — load without validation, then check later:

cfg = SolverConfig.load(case_dir="case", validate=False)
errors = cfg.check_validation()  # returns list[ValidationErrors]

Batch — validate multiple configs at once:

from neofoam.io import validate_models

errors = validate_models([cfg1, cfg2, cfg3])

Non-raisingcollect_errors() catches all exceptions and returns them as ValidationErrors:

errors = SolverConfig.collect_errors(case_dir="case")

IO Strategies

The @IOStrategy decorator accepts a strategy configuration. Each strategy implements the ReadingStrategy and WritingStrategy interface.

Helper

Format

Example

YAML(file)

YAML

YAML("config.yaml")

YAML(file, subdict=...)

YAML

YAML("fvSolution.yaml", subdict="PIMPLE")

JSON(file)

JSON

JSON("config.json")

JSON(file, subdict=...)

JSON

JSON("config.json", subdict="services.db")

Both @IOStrategy: YAML and JSON support three subdict modes:

  1. No subdict — the entire file maps to the model.

  2. Flat subdict (subdict="PIMPLE") — a top-level key.

  3. Nested subdict (subdict="solvers.p") — dot-separated path for deeper nesting.

# fvSolution.yaml
PIMPLE:
  nOuterCorrectors: 2
solvers:
  p:
    solver: PCG
    tolerance: 1e-6
@IOStrategy(YAML("fvSolution.yaml", subdict="PIMPLE"))
class PIMPLEConfig(BaseConfig):
    nOuterCorrectors: int

@IOStrategy(YAML("fvSolution.yaml", subdict="solvers.p"))
class PSolverConfig(BaseConfig):
    solver: str
    tolerance: float

Extending — Adding a New Format

To add support for a new file format, implement the ReadingStrategy and WritingStrategy protocols:

@runtime_checkable
class ReadingStrategy(Protocol):
    def read(self, path: Any, encoding: str = "utf-8") -> dict[str, Any]: ...

@runtime_checkable
class WritingStrategy(Protocol):
    def write(self, data: dict[str, Any], path: Any, encoding: str = "utf-8") -> None: ...

Example — TOML strategy:

from pathlib import Path
from typing import Any

class TOMLStrategy:
    """TOML reading and writing strategy."""

    def read(self, path: Path, encoding: str = "utf-8") -> dict[str, Any]:
        import tomllib
        if not path.exists():
            raise FileNotFoundError(f"Configuration file not found: {path}")
        with open(path, "rb") as f:
            return tomllib.load(f)

    def write(self, data: dict[str, Any], path: Path, encoding: str = "utf-8") -> None:
        import tomli_w
        path.parent.mkdir(parents=True, exist_ok=True)
        with open(path, "wb") as f:
            tomli_w.dump(data, f)

Create a helper function and use it with @IOStrategy:

def TOML(file):
    strategy = TOMLStrategy()
    return {
        "input_file": file,
        "reading_strategy": strategy,
        "writing_strategy": strategy,
    }

@IOStrategy(TOML("settings.toml"))
class AppConfig(BaseConfig):
    debug: bool
    log_level: str