Three-stage initialization¶
The three-stage init pipeline exists to populate the Context that
the core abstraction reads from. Operations described in
Operations and the DAG cannot run until ctx["fields.U"],
ctx["models.turbulence"], and the rest are in place; they also
cannot run until cross-model dependencies (for example Boussinesq
flipping use_boussinesq=True on the pressure-velocity algorithm)
are wired. LOAD / RESOLVE / BUILD is the pipeline that delivers both,
in the right order, before the first operation fires.
See also
For the lifecycle overview that places this page in context, see Solver structure. For what happens after BUILD finishes, see Operations and the DAG.
When a NeoFOAM solver starts, initialization is split into three explicit stages plus an implicit verification step:
LOAD — read configuration files, detect active models, instantiate
ModelRuntimeobjects. No interaction between models. No mesh, no field allocation.RESOLVE — wire inter-model dependencies through
ConfigContext; models adapt their configuration to what other models exist.VERIFY (implicit) — check that every active operation’s
@fvSchemes.add/@fvSolution.addrequirements are satisfied by the loadedsystem/fvSchemes/system/fvSolution. Raises before any allocation.BUILD — produce
InitStepobjects that describe how to create runtime objects (fields, operators, models). The framework topologically sorts them and executes them in dependency order, producing theContext.
sequenceDiagram
autonumber
participant Caller
participant Init as StagedInit
participant Files as Case files<br/>(system/, constant/, 0/)
participant Models as ModelRuntime(s)
participant Cfg as ConfigContext
participant Verify as Verifier
participant Ctx as Context
rect rgb(227, 242, 253)
Note over Caller,Models: LOAD — read files, instantiate models in isolation
Caller->>Init: run() / validate()
Init->>Files: read fvSchemes, fvSolution,<br/>turbulenceProperties, …
Files-->>Init: raw config dicts
Init->>Models: instantiate per active model
Models-->>Init: loaded configs<br/>(no cross-model interaction)
end
rect rgb(227, 242, 253)
Note over Init,Cfg: RESOLVE — models adapt configs to peers
Init->>Cfg: register loaded models by name
Init->>Models: @resolve(config_context)
Models->>Cfg: read peer configs,<br/>mutate own (e.g. use_boussinesq=True)
end
rect rgb(255, 243, 224)
Note over Init,Verify: VERIFY (implicit) — fail fast before BUILD
Init->>Verify: collect @fvSchemes.add /<br/>@fvSolution.add requirements
Verify->>Files: cross-check against<br/>system/fvSchemes, system/fvSolution
Verify-->>Init: list[VerificationError]
alt validate() only
Init-->>Caller: errors (skip BUILD)
end
end
rect rgb(232, 245, 233)
Note over Init,Ctx: BUILD — produce InitSteps and execute them
Init->>Models: @build → list[InitStep]
Init->>Init: topological_sort(init_steps)
Init->>Ctx: execute steps<br/>(allocate mesh, fields, operators)
Ctx-->>Caller: Context (solver-ready)
end
What makes this hard¶
OpenFOAM’s solver main() reads the dictionary, allocates the mesh,
and constructs models in one pass — validation means running the
solver. That works when the only consumers are “the solver” and “the
case author who is about to run the solver.” Three forces push against
the single-pass design once the surface goes Python:
Cross-model dependencies are order-sensitive. The Boussinesq model needs to flip
use_boussinesq=Trueon whichever pressure-velocity algorithm was selected — but only after the algorithm is loaded and before either of them allocates fields. With a single init step, the ordering rules end up encoded inside whichever model happens to run last, and adding a new model means revisiting that ordering.Field allocation cycles.
p_rgh(Boussinesq) depends onmeshandpandrhok;rhokdepends onT;Tdepends onmesh. The dependency graph is fine, but it has to be expressed somewhere, and “construct everything in the right order” buries that expression inside imperative code.Validation that needs the resolved config but not the allocated fields. A user wants to know “does my case satisfy the solver’s requirements?” without paying the cost of mesh I/O. That implies LOAD + RESOLVE need to be runnable independently of BUILD — exactly what OpenFOAM’s “validate by running” pattern cannot offer.
Mechanism: what each stage cannot do¶
The constraints below are easy to break by accident, and the framework relies on them holding:
Stage |
Forbidden |
Why it matters |
|---|---|---|
LOAD |
Depending on other models’ loaded state |
Each |
RESOLVE |
Allocating fields or touching the mesh |
No mesh exists yet, no |
BUILD |
Reading configuration files |
It receives the resolved config from RESOLVE; reading files at this point bypasses verification and breaks the schema-introspection contract. |
Hooked into incompressibleFluid¶
The three stages are not framework abstractions floating in the
abstract — they are exactly the three decorators in
src/neofoam/solver/incompressibleFluid/create_fields.py:
Decorator in |
Stage |
What |
|---|---|---|
|
LOAD |
|
|
RESOLVE |
Each loaded model’s |
|
BUILD |
|
Reading those three decorators side-by-side is the easiest way to see the lifecycle on real code; everything else on this page is the rationale for the split.
Trade-offs: why three, not two or four¶
Why not two (LOAD + BUILD)? Without RESOLVE, every cross-model dependency has to fit inside one of the two stages. LOAD cannot contain it (a model cannot see configs that other models have not loaded yet); BUILD cannot contain it cleanly (by then field allocations are already happening). Splitting LOAD and BUILD with a RESOLVE step in the middle is what gives “configs influence other configs” a place to live.
Why not four (separate VERIFY)? Verification is a check, not an action — it does not produce state, it just rejects bad input. Hiding it as an automatic step between RESOLVE and BUILD means the user-facing surface stays at three stages while the framework still gets to fail fast.
The honest cost is that solver authors and model authors have to know which stage their code lives in, and respect the constraints. A single constructor is harder to misuse and harder to extend; three stages is easier to extend and easier to misuse. The framework’s lint and tests are what keep the constraints honest.
When this matters in practice¶
With the constraints above in place, three user-visible capabilities become cheap:
StagedInit.validate()runs LOAD + RESOLVE + VERIFY without BUILD. Case validation costs file I/O plus config wiring — no mesh, no fields.StagedInit.solver_inputs()introspects every config class without running anything. See Schema introspection.StagedInit.scheme_inputs()introspects every required scheme without parsing a case.
In a single-stage init, each of those would require parsing the entire pipeline. In a two-stage init, only the first would be possible.