Concepts — runtime data flow¶
A single prose narrative walking the data flow from a TOML case
file through network construction, the timeloop, the HDF5
output, and the GUI's post-processing display. Cross-references
the more detailed per-component docs (theory.md,
architecture.md, closures.md).
The goal is one place to read that explains how an aeolus1d run works end-to-end. New contributors should be able to read this in ~30 minutes and locate the right module to add a feature.
The big picture¶
TOML case file
│
│ load_case()
▼
Case dataclass tree (aeolus1d.io.schema)
│
│ build_network()
▼
Live network: pipes, junctions, cylinders, turbos, shafts
│
│ advance_single_pipe / advance_network
▼
Timeloop iterates RK stages → state evolves
│
│ Tracer captures snapshots every Δt
▼
write_hdf5() → out.h5
│
│ load_run()
▼
GUI plot dock + network view
Every arrow is a function in aeolus1d.io or aeolus1d.solver. No
hidden state, no implicit globals — the network is built in one
place, advanced in one place, and serialised in one place.
Stage 1 — TOML to Case¶
aeolus1d.io.case.load_case(path) parses a TOML file into a
Case dataclass tree (aeolus1d.io.schema). The schema is a
nested set of @dataclass definitions:
Case
├── case: CaseHeader (~70 fields: 10 BASIC + ~60 Advanced — see below)
├── gas: GasSpec
├── pipes: list[PipeSpec]
│ ├── id, length, n_cells, area
│ ├── init: UniformInit | RiemannInit | RegionalInit | ModeInit
│ ├── friction: FrictionSpec
│ ├── heat: HeatSpec
│ ├── porous: PorousSpec
│ ├── catalyst: CatalystSpec
│ ├── species: SpeciesSpec
│ └── eos: EOSSpec
├── junctions: list[JunctionSpec]
├── bcs: list[BCSpec]
├── turbos / shafts / cylinders / species (engine-side)
└── modes / area_profile / scr_uniformity (analysis cases)
CaseHeader is the heaviest node in the tree: it carries ~70
fields spanning five concerns (numerical scheme + adaptive dt
control + implicit-solver knobs + QS driver + chemistry). To
keep the GUI form scannable, the dataclass declares a
BASIC_FIELDS: ClassVar[frozenset[str]] of the 10 routine
knobs (name, t_end, cfl, scheme, limiter, rk,
solver, adaptive_dt, dt_fixed, chem_solver); the other
~60 are exposed under a collapsed "Advanced settings…"
expander. The same BASIC / Advanced pattern was rolled out
to PipeSpec, JunctionSpec, BCSpec (MR !135) and to nine
nested closure specs — FrictionSpec, HeatSpec,
PorousSpec, CatalystSpec, FilterSpec, UreaSpec,
SpeciesSpec, EOSSpec, ThermalWallSpec (MR !136). On disk
the loader default-fills any omitted field, so a [case] block
need only list the knobs that differ from default — aeolus1d expand
writes that minimal compact block (--full for the every-field
form), and the GUI saves the compact block too. See
gui.md → The four panels → Inspector
for the UI side; a dedicated run-settings reference chapter
will land in a follow-up sync.
The schema is polymorphic via Literal["..."] "kind"
fields. Each polymorphic spec class declares a KIND_FIELDS:
ClassVar[dict[str, list[str]]] that lists the fields each
kind consumes — used by the GUI's form factory to reveal /
hide fields when the kind dropdown changes. Phase A1 made the
closure-kind families (friction / heat / porous / catalyst /
EOS / reaction) registry-driven: each closure module
registers its kinds at import time via
aeolus1d.closures.registry.register_closure(...). The schema's
Literal[...] arms still need hand-maintenance for static
type checking, but a unit test (test_closure_registry.py)
catches drift between registry and schema.
Stage 2 — Case to network¶
aeolus1d.io.case.build_network(case) walks the Case dataclass
tree and constructs the live runtime objects:
-
For each
PipeSpec→ aPipe(aeolus1d.components.pipe) with allocatedU_pad(conservative state with ghost cells), geometry arrays (x,A_cell,A_face), per-cell closure parameter arrays (friction_params,heat_params,porous_params,catalyst_params), and species arrays (Y,Y_extras,surface_state). -
For each
JunctionSpec→ aJunction(aeolus1d.components.junction) with aVolume(m, E_internal, optionalm_fuel/m_species) and a list ofPorts pointing at pipe ends. Phase 51d addedm_species: dict[str, float]to track every non-fuel species column on connected pipes. -
For each
BCSpec→ aBCFncallable closing over the spec's parameters. The callable fills the relevant pipe's ghost cells when invoked. -
For each
TurboSpec/ShaftSpec/CylinderSpec→ the matching component class. ACylindercarries the samem_speciesinventory as a plenum, plus a combustion-product source that turns burnt fuel into CO₂ + H₂O with the matching O₂ debit (see cylinder-reference.md). All are duck-typed as "junction-shaped" so the timeloop treats them uniformly (Phase 26.5 component protocol; Phase A15 promoted the consumer-side type hints fromlist[Junction]tolist[Component]and tightened the Protocol'svolumeattribute so mypy strict catches drift).
The build is one-shot — no state is shared between Case and
network after build_network returns. Mutating the Case
dataclass post-build has no effect on the running solver.
Stage 3 — Timeloop¶
aeolus1d.solver.timeloop.advance_single_pipe (single-pipe
shortcut) or aeolus1d.solver.network_timeloop.advance_network
(coupled network) marches the network from t_start to
case.t_end. Each iteration:
- Apply BCs — fill every pipe's ghost cells (far-field BCs + junction-port BCs).
- Compute dt — minimum over all pipes of
cfl * dx / max(|u| + a). CFL can be live-tuned via the Phase-A8LiveControlhook. - RK stages — SSP-RK2 / SSP-RK3 (or RK4/5 single-pipe
only). Each stage:
- Reconstruct cell-face values (1st-order / MUSCL / MP5) and compute fluxes (HLLC).
- Evaluate closure source terms (friction, heat, porous, reaction).
- Combine stages per the RK scheme.
- Junction state — accumulate volume rates from every
port flux; integrate
volume.m,volume.E_internal, optionalvolume.m_fuel,junction.m_species. - Tracer — if a
Traceris attached and the next sample is due, snapshot every pipe + junction. - on_step callback — fires once per iteration. The CLI
wires this to
--progress(simulated-time cadence),--info-every-n(iteration cadence), the live-flush trigger, and the live-control file watcher. - Live-control checks — every
live_control.poll_every_niterations the timeloop re-reads the on-disk control file. CFL /adaptive_cfl_max/pausedupdates take effect on the next iteration. Phase A17 addedcheckpoint_and_stop— when set, the timeloop breaks out cleanly at the next poll, the CLI writes a resumable HDF5, and the operator can resume the run withaeolus1d run --resume <prior.h5>(the "Done when" criterion for Phase 8 of the refactor plan).
The hot kernels (HLLC flux, MUSCL slopes, pipe residual,
EOS) are @njit-compiled. The Python orchestration (RK stage
combine, BC fill, closure source dispatch) is plain Python.
Closures dispatch on small integer codes (friction_kind,
heat_kind, …) so the JIT can inline the right kernel arm
cheaply. ML closures (pipe.py_friction,
pipe.py_reaction_multi, …) bypass the integer dispatch and
run as Python callables — slower but flexible enough to plug
a learned model without solver edits.
After the gas-side stage advance, the timeloop also advances auxiliary slow-time-scale ODEs:
- Junction fuel inventory (
apply_fuel_step, Phase 25l) — fuel mass on cylinder volumes. - Coolant + dynamic wall (Phase Coolant-2) — forward-Euler
step on each
(gas_pipe, ThermalWall, CoolantPipe)triple incoolant_couplings. Time constants are seconds; doing this once per outer gas step (µs scale) means first-order accuracy is well within the gas-side discretisation error.
Within a step the cylinder ports also evaluate any VVT
(Phase E5) settings — the per-port phase_offset_deg and
lift_scale are read each time the area fraction is recomputed,
so a controller can write to them between steps and the next
sample sees the updated cam.
Stage 4 — Snapshot to disk¶
aeolus1d.io.writer.write_hdf5(path, case_name, pipes,
junctions, t_final, tracer) serialises the run to one HDF5
file. The layout:
out.h5
├── attrs: case_name, t_final, schema_version, aeolus_version
├── pipes/
│ └── <id>/
│ ├── attrs: L, N, gamma, R_gas, Ng, friction_kind,
│ │ heat_kind, species_names
│ ├── x, A_cell, A_face (geometry datasets)
│ ├── rho, u, p, U (final-state primitives + conservatives)
│ ├── T (gas T the solver holds: EOS / mixture-R
│ │ aware; since 2026-09-28, also traced)
│ ├── Y_<species> (one dataset per species column)
│ ├── T_solid (catalyst wall T, when populated)
│ └── surface/<theta_*> (catalyst surface coverage)
├── junctions/
│ └── junction_<NN>/
│ ├── attrs: V, gamma, R_gas, m, E_internal, p, T, rho,
│ │ n_ports, m_fuel, m_fuel_burnt
│ └── m_species/<name> (Phase-51d non-fuel inventory)
└── time_series/ (only with --trace-dt)
├── attrs: dt_sample
├── t (time axis)
├── pipes/<id>/ (one (n_samples, N) array per field)
└── junctions/<id>/ (per-volume scalar trajectories)
Phase A2 unified the per-pipe / per-junction field
enumeration into aeolus1d.io.snapshot::pipe_state_dict /
junction_state_dict. Phase A12 (Phase 2 of the refactor
plan) wrapped them in a frozen Snapshot dataclass
(t, pipes, junctions) plus a single
take_snapshot(t, pipes, junctions) walker that the writer
(one final-state Snapshot) and the tracer (one Snapshot per
sample, in a list[Snapshot]) both consume. The tracer's
prior nine parallel per-field accumulator dicts are gone;
accessor methods (pipe_rho / pipe_Y /
pipe_surface_state / junction_p ...) stack the relevant
slice on demand. Adding a new field of state is one edit in
snapshot.py — both consumers pick it up automatically.
Phase A3 added schema_version to the root attrs. Phase A13
(Phase 3 of the refactor plan) promoted the version dispatch
to a real subsystem in aeolus1d.io.schema_upgrades:
CURRENT_SCHEMA_VERSION constant (now v2),
MIN_SUPPORTED_VERSION floor (v1), UpgradeStep frozen
dataclass, and a resolve_upgrades(from, to) resolver that
walks the UPGRADES chain. The chain holds one step today —
UpgradeStep(1, 2, ...), recording that v1 lacked the
kind / id attrs on the junctions/junction_NN (and
time_series/junctions/junction_NN) groups; the v2 reader
treats missing kind as 'junction' and missing id as the
group name, so resuming from a v1 checkpoint is fully back-
compatible (it loses only the cylinder ↔ junction distinction
in the GUI display, never simulator state). When the next
breaking layout change lands it appends one more UpgradeStep
and a unit test, and the resume reader picks up the migration
automatically. The reader accumulates version_notes on the
returned ResumeState (no-attr fallback, applied upgrade
step descriptions); the CLI runner echoes them so the
operator sees what the upgrade reader did.
Stage 5 — Resume from checkpoint¶
Every saved HDF5 doubles as a resume checkpoint. aeolus1d run
case.toml --resume <prior.h5>:
- Reads the prior's final-state groups via
aeolus1d.io.resume.read_resume_state. - Builds a fresh network from the (unchanged) Case TOML.
- Seeds each pipe / junction's interior state from the
checkpoint via
seed_pipes_from_prior/seed_junctions_from_prior— primitives + species + T_solid - surface + volume scalars + fuel + multi-species inventory.
- Sets
t_start = prior.t_final. - If the prior also has a
time_series/group, preloads it into the new tracer viaTracer.preload_from_priorso the merged HDF5's time axis covers[0, t_end]as one continuous curve (otherwise plots start from the resume time). - Runs the timeloop from
t_starttocase.t_end.
Cylinders / shafts / turbos aren't checkpointed yet — their schema initial conditions reset on resume. Logged per-component so the user sees what was reset.
Stage 6 — GUI display¶
The GUI (aeolus1d_gui) follows MVC:
- Models (
aeolus1d_gui.model): CaseModel— the active case + layout + on-disk path + dirty flag + undo/redo history. SignalscaseReplaced,caseStructureChanged,caseFieldChanged,initFieldChanged,probesChanged,pathChanged,dirtyChanged,canUndoChanged,canRedoChanged.-
RunModel— the most-recent run's HDF5 path + cachedRunResult. SignalsrunLoaded,cleared,runPathChanged. Owns a 1 Hz file watcher (Phase A6 wide-net, before the architecture refactor's Phase 8 — name collision; the GUIRunModelwatcher dates back to that earlier work) that firesrunLoadedon any change tolast_h5_path(live-trace flush, post- run completion, external writer). -
Views — canvas (PyVista 3D editor), inspector form, palette, component list, runner dock, plot dock, network view, modes dock. Pure Qt + PyVista; receive what they need via constructor args + method calls; emit user-action signals.
-
Controller —
aeolus1d_gui.app.MainWindowwires user signals → model API; routes model signals → view-side refresh slots. Spec construction with dynamic defaults is delegated toaeolus1d_gui.controller.mutations; the case → canvas walk lives inaeolus1d_gui.editor.case_renderer(Phase A4). The CLI runner's god-function_cmd_runwas decomposed in Phase A14a into a thin orchestrator over six composable helpers (_read_resume,_build_live_control,_build_eager_network,_build_tracer,_StepHooks,_print_run_summary,_write_run_outputs) — same organisational shape as the GUI controller.
When a run finishes, the runner-dock receives
QProcess.finished → _auto_load_last_run → RunModel.load_now
→ runLoaded → plot dock + network view repaint. When the
user changes the case (add a pipe, edit a field), the model
emits the right granular signal and the relevant subset of
the views refresh.
Adding new things¶
| What you want to add | Where to edit |
|---|---|
| A new closure kind (friction / heat / porous / catalyst / EOS / reaction) | aeolus1d/closures/<family>.py — add the @njit branch + register with aeolus1d.closures.registry. Schema Literal[...] arm + KIND_FIELDS follow the registry. |
| A new BC kind | aeolus1d/bc/<kind>.py — make_<kind>_bc(...) -> BCFn. Add to BCSpec.kind Literal + KIND_FIELDS + the loader / saver dispatch. |
| A new junction kind | aeolus1d/components/junction_<kind>.py — implement apply_port_bcs + accumulate_rates. Add to JunctionSpec.kind Literal + KIND_FIELDS + the case loader's branch. |
| A new field of pipe state to checkpoint | aeolus1d.io.snapshot::pipe_state_dict — one new dict entry; writer + tracer pick it up automatically. Bump schema_version if the field becomes required for resume. |
| A new CLI subcommand | Recommended (Phase A10): write a small module that calls @register_subcommand("foo", help="...") from aeolus1d.cli.registry; import it from aeolus1d/cli/main.py. The framework + one demo subcommand are shipped; the historical 11 subcommands still live as inline add_parser blocks + _cmd_<name> functions (queued for migration). |
| A new GUI component palette button | aeolus1d_gui/shell/palette.py — add the button entry. MainWindow._on_palette_request dispatches to aeolus1d_gui.controller.mutations.build_<kind>_spec — add a factory there. |
| A new model signal | aeolus1d_gui/model.py::CaseModel — add the signal + the emit helper. Connect in MainWindow.__init__. |
| A new GUI menu action | aeolus1d_gui/shell/actions.py — add an ActionSpec to ACTIONS (attr, text, slot, optional shortcut / tooltip / checkable). Write the action_<name> handler on MainWindow and add the matching act_<name>: QAction annotation to its class body, then place it in a menu in MainWindow._make_menus. Do not build the QAction by hand in _make_actions: test_shell_actions.py fails on that, because an action outside the table is outside the duplicate-shortcut and missing-slot guards. |
| A new GUI dock panel | aeolus1d_gui/shell/docks.py — add a DockSpec to DOCKS (attr, factory, area, object_name, and a panel_key if the panel should appear in View → Panels), and add the matching <attr>: <DockClass> annotation to MainWindow's class body. Wire its signals in a _wire_* method on MainWindow, not in the table. The dock class must set its own objectName — Qt matches saved layouts on it, and a duplicate is silently renamed rather than rejected. Omitting panel_key is not cosmetic: _lock_unrecoverable_panels then removes the panel's close button, because nothing could reopen it. test_shell_docks.py checks all of that, and aeolus1d_gui/shell/window_state.py is where the panel's layout is saved, restored and made recoverable. |
CFL and time stepping¶
The Courant-Friedrichs-Lewy condition bounds the dt of the explicit schemes:
dt ≤ cfl · dx / max_i(|u_i| + a_i)
with cfl ≤ 1 for stability of the explicit Riemann-solver
update. SSP-RK2/3 and RK4/5 all sit under this acoustic ceiling.
The implicit schemes (implicit_euler, bdf2, radau_iia) are
A-stable and unconditionally stable for any cfl > 0. Real
engineering runs use cfl = 5..50 with adaptive_dt = true, which
runs a Newton-iteration-driven controller that picks the per-step
dt inside [adaptive_cfl_min, adaptive_cfl_max] · dt_acoustic_unit.
A common foot-gun is implicit + adaptive_dt + a sharp startup transient — the controller ramps dt up before the wave reaches the mesh, then Newton fails. The CFL ramp keeps adaptive at bay during the warmup window:
cfl = 5.0
cfl_ramp_start = 0.5 # cfl at t = t_start
cfl_ramp_duration = 0.01 # ramp linearly over 10 ms
The effective CFL interpolates linearly from cfl_ramp_start to
cfl across the ramp window, then holds at cfl. When
adaptive_dt = true the same factor scales the controller's
adaptive_cfl_max ceiling so the dt-ramp can't run past the
allowed CFL during warmup.
Spatial reconstruction¶
scheme and limiter together select the spatial reconstruction
used at every face:
first_order— piecewise-constant cell averages. Most diffusive; most stable.limiteris ignored.muscl— piecewise-linear primitive reconstruction with a TVD limiter.limiter∈ {minmod,van_leer,none}; the first two preserve monotonicity.mp5— fifth-order monotonicity-preserving polynomial. Smooth- flow benchmarks only — non-TVD, will produce spurious oscillation at shocks.
For implicit time integration the slopes are frozen at the start of each Newton iteration to keep the Jacobian sane — mathematically equivalent to fully-coupled MUSCL at the converged fixed point but ~1 extra Newton iteration on average.
Testing¶
Three nested cadences, picked via pytest markers (Phase A16):
pytest -m smoke— ≤ 10 s, ~8 canonical happy-path tests. Sod shock-tube signature, mass-flow inlet steady-state, two-pipes plenum init, CLI run-and-write-HDF5, closure- registry consistency, Component protocol runtime-check, schema-version emit, and the mypy strict gate. Curated intests/conftest_smoke_marker.py::SMOKE_TEST_IDS. Run this inside a tight refactor loop.pytest -m fast— ≤ 60 s, every test undertests/unit/andaeolus1d_gui/tests/plus the smoke tier (smoke ⊆ fast). Auto-applied via repo-rootconftest.py. Run this before committing.pytest(no marker) — ~6 minutes, full repo + GUI suite. Run this in CI / pre-push.
The --strict-markers setting in pyproject.toml rejects
typos on @pytest.mark.<foo>; new markers register there
first. See the "Test tiers" section in
architecture.md for the curation
criteria.
Cross-references¶
- Theory (Riemann solver, MUSCL/MP5, time integrators, BCs, state-space linearization, coolant heat balance): theory.md
- High-level module tree + GUI MVC layout: architecture.md
- Closure protocols + ML seam: closures.md
- Closure / TOML reference table (incl. dynamic wall, coolant pipe, coolant coupling, VVT): closures-reference.md
- Interop (Cantera YAML, CGNS, GT-Power / AVL Boost / WAVE / AVL Cruise M): interop.md
- Active refactor plan + speculative bets: architecture-refactor-plan.md
- Driving these stages from Python —
load_case/build_network/run_case/dispatch_advance, thePipestate, the closure registry: API reference