Solver structure ================ A NeoFOAM solver is **a sequence of custom operations that act on a shared Context**. The solver author declares a standard order of operations through the ``execution_graph``; plugin models contribute additional operations that modify that order. The framework merges them and runs the result. This page is the conceptual overview. The detail behind each piece — the data model, the merger algorithm, the decorators — lives in :doc:`operations-and-the-dag`. Operations change the Context ----------------------------- The ``Context`` is a small data class that holds the state shared by all operations. It is the only coupling mechanism between them — there is no other shared state. .. code-block:: python # src/neofoam/framework/context.py class Context(BaseModel): fields: dict[str, Any] # e.g. fields["U"], fields["p"], fields["phi"] models: dict[str, Any] # e.g. models["turbulence"], models["pressure_velocity"] mesh: Any = None runtime: Any = None # OpenFOAM runtime (time, mesh handle) Every operation either takes the Context directly or asks for a specific entry from it. Two examples from ``src/neofoam/solver/incompressibleFluid/incompressibleFluid.py``: .. code-block:: python # from incompressibleFluid.py @incompressibleFluid.operation() def increment_time(self: Any, ctx: Context) -> None: Info(f"Time = {ctx.runtime.timeName()}") ctx.runtime.increment() # from models/pressure_velocity/pimpleAlgorithm.py @pimple.operation(operation_number="2.1") @fvSchemes.add(ddt="ddt(U)", div="div(phi,U)", grad="grad(U)", laplacian="laplacian(nuEff,U)") @fvSolution.add("U") def momentum( U: volVectorField, phi: surfaceScalarField, p: volScalarField, turbulence: Annotated[TurbulenceModel, "models"], pimple_control: Annotated[PimpleControl, "models"], ) -> FieldUpdates: UEqn = fvVectorMatrix(fvm.ddt(U) + fvm.div(phi, U) + turbulence.divDevReff(U)) UEqn.relax() if pimple_control.momentumPredictor(): pyf.solve(UEqn + fvc.grad(p)) return FieldUpdates({"UEqn": UEqn, "U": U}) Two ways of touching the Context show up here: - **Direct read** — ``increment_time`` takes ``ctx: Context`` and reaches into ``ctx.runtime`` to ask the OpenFOAM runtime for the current time and to advance it. - **Parameter injection** — ``momentum`` doesn't take ``ctx`` at all. The framework inspects the function signature and fills each parameter from the Context by name: bare typed parameters (``U``, ``phi``, ``p``) come from ``ctx.fields``; parameters tagged ``Annotated[..., "models"]`` (``turbulence``, ``pimple_control``) come from ``ctx.models``. If the name is not there, the parameter arrives as ``None``. The full rules live in :doc:`parameter-injection`. Operations that mutate fields return a ``FieldUpdates`` dict. The framework writes the entries back into ``ctx.fields`` after the operation returns — so the ``momentum`` operation above publishes the updated velocity ``U`` and the momentum-equation matrix ``UEqn`` under those names in the Context, where the next operation (``continuity``) will pick them up. The decorators ~~~~~~~~~~~~~~ Three decorators wrap the operations in the snippet above. Each one solves a different *"how does this function plug into the framework?"* problem: - ``@solver.operation(...)`` / ``@model.operation(...)`` — registers the function as a named operation on the solver or the plugin model. The ``execution_graph`` later looks it up by name (e.g. ``ops["increment_time"]``); without this decorator the function is just a function, invisible to the framework. Optional keyword arguments (``depends_on``, ``before``, ``operation_number``) tell the merger where the operation should land relative to others (used in section 3). - ``@fvSchemes.add(...)`` — declares which finite-volume scheme entries the operation will use from ``system/fvSchemes`` (the ``ddt``, ``div``, ``grad``, ``laplacian`` terms it assembles). The framework aggregates these declarations across every active operation and verifies the case's ``fvSchemes`` file *before* the solver runs. A missing entry is caught at startup, not mid-loop. - ``@fvSolution.add("U")`` — declares which fields the operation solves for, so the framework can check that ``system/fvSolution`` carries the matching linear-solver settings. Same validation story as ``fvSchemes``. Read together, these decorators are how an operation announces *what it needs from the case* — placement metadata for the merger, scheme requirements, solver settings. The validation that falls out of these declarations is covered in :doc:`schema-introspection`. For the full Context API, the parameter-injection contract, and the ``FieldUpdates`` write-back pattern, see :doc:`operations-and-the-dag`. The execution graph defines the standard operations --------------------------------------------------- The solver declares its loop topology in ``@solver.execution_graph_step``. This is the solver's *standard* ordering — what the solver does when no plugin models are loaded. Reading the ``execution_graph`` of ``incompressibleFluid`` is the easiest way to see what the solver does. From ``src/neofoam/solver/incompressibleFluid/incompressibleFluid.py``: .. code-block:: python @incompressibleFluid.execution_graph_step def execution_graph(self, ...): ops = self.operations builder = StepBuilder() # Pressure-velocity algorithm (pimple/simple/piso) chosen at LOAD algorithm_model = self.state.core_models[0] algo_ops = Operations( algorithm_model._build_operations_for(algorithm_model) ) time_loop_op = Operation( func=IterativeOp(TimeLoop()), metadata=OperationMetadata(op_name="time_loop"), ) with builder.loop(time_loop_op) as time_builder: time_builder.step(ops["set_time_step"]) time_builder.step(ops["increment_time"]) with time_builder.loop(algo_ops["inner_loop"]) as inner: inner.step(algo_ops["momentum"]) inner.step(algo_ops["continuity"]) inner.step(ops["turbulence_correction"]) time_builder.step(ops["write_output"]) # Collect ops contributed by optional plugin models model_ops = Operations() for model in self.state.optional_models: model_ops.add(model.operations) return builder, model_ops The nested ``with builder.loop(...)`` blocks read top-to-bottom as the solver's standard graph: .. mermaid:: flowchart TB subgraph TIME ["time_loop"] direction TB ST["set_time_step"] IT["increment_time"] subgraph INNER ["inner_loop (algorithm)"] direction TB M["momentum"] C["continuity"] T["turbulence_correction"] M --> C --> T end W["write_output"] ST --> IT --> INNER --> W end style TIME fill:#E3F2FD style INNER fill:#BBDEFB ``self.state`` exposes the models resolved at LOAD time: ``core_models`` (mandatory variants like the pressure-velocity algorithm — exactly one was picked from ``pimple`` / ``simple`` / ``piso``) and ``optional_models`` (plugin contributions discovered at LOAD; see :doc:`discovery-mechanisms`). ``execution_graph`` returns *two* things — the structural ``builder`` above and ``model_ops`` (the contributions gathered from plugin models). The framework merges them, which is what the next section is about. For the decorator API (``@solver.operation``, ``@solver.execution_graph_step``, ``@solver.initializer``) and the full walkthrough, see :doc:`operations-and-the-dag`. Additional models modify the operations --------------------------------------- Plugin models contribute their own operations and declare where those should land via ``depends_on`` / ``before`` / ``operation_number`` constraints. The DAG resolver merges the contributions into the solver's standard ordering, producing the actual run-time graph. The two plugins below ship with the framework. Boussinesq inserts *two* operations inside the algorithm's inner loop, between ``momentum`` and ``continuity``. From ``src/neofoam/solver/incompressibleFluid/models/boussinesq.py``: .. code-block:: python boussinesq = Model("boussinesq").register_with(incompressibleFluidModel) @boussinesq.operation(operation_number="2.5", depends_on=["momentum"]) @fvSchemes.add(ddt="default", div="div(phi,T)", laplacian="default") @fvSolution.add("T") def solve_energy(self, T, phi, turbulence, alphat): ... return FieldUpdates({"T": T, "alphat": alphat}) @boussinesq.operation(operation_number="2.7", depends_on=["solve_energy"]) def update_rhok(self, T, rhok): ... return FieldUpdates({"rhok": rhok}) A passive-scalar transport plugin adds a single operation that runs *after* ``continuity``. The plugin lives outside the solver source tree (see :doc:`/auto_tutorials/example_02_passive_scalar_plugin`) but the registration pattern is the same: .. code-block:: python passive_scalar = Model("passive_scalar").register_with(incompressibleFluidModel) @passive_scalar.operation(depends_on=["continuity"]) @fvSchemes.add(ddt="ddt(s)", div="div(phi,s)", laplacian="laplacian(D,s)") @fvSolution.add("s") def solve_s(self, s, phi): cfg: PassiveScalarConfig = self.config D = pyf.dimensionedScalar("D", pyf.dimViscosity, cfg.D) sEqn = fvScalarMatrix(fvm.ddt(s) + fvm.div(phi, s) - fvm.laplacian(D, s)) sEqn.solve() return FieldUpdates({"s": s}) With both plugins active, the merger produces this graph: .. mermaid:: flowchart TB subgraph TIME ["time_loop (with boussinesq + passive_scalar)"] direction TB ST["set_time_step"] IT["increment_time"] subgraph INNER ["inner_loop"] direction TB M["momentum"] SE["solve_energy ★
boussinesq
op_number=2.5
depends_on=momentum"] UR["update_rhok ★
boussinesq
op_number=2.7
depends_on=solve_energy"] C["continuity"] SS["solve_s ★
passive_scalar
depends_on=continuity"] T["turbulence_correction"] M --> SE --> UR --> C --> SS --> T end W["write_output"] ST --> IT --> INNER --> W end style TIME fill:#E3F2FD style INNER fill:#BBDEFB style SE fill:#FCE4EC style UR fill:#FCE4EC style SS fill:#E0F7FA ★ = inserted by a plugin model. The placement comes entirely from the operation metadata on each plugin's decorators — the ``incompressibleFluid`` source has no idea ``solve_energy``, ``update_rhok``, or ``solve_s`` exist. This is the payoff of the core abstraction: a third party adds a plugin model and the solver picks up the new operations at the correct point in the loop, without source edits. For the merger algorithm (topological sort, ``operation_number`` tie-breaks, scope inference) see :doc:`operations-and-the-dag`. For how plugin models are discovered at LOAD time, see :doc:`discovery-mechanisms`. Where to go next ---------------- - :doc:`operations-and-the-dag` — data model and merger algorithm. - :doc:`spec-runtime` and :doc:`three-stage-init` — how operations and Context are set up before the loop runs. - :doc:`discriminated-unions` and :doc:`discovery-mechanisms` — how plugin models register and how the solver picks them up. - :doc:`schema-introspection` — what the typed configs buy you.