Skip to content

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 → a Pipe (aeolus1d.components.pipe) with allocated U_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 → a Junction (aeolus1d.components.junction) with a Volume (m, E_internal, optional m_fuel / m_species) and a list of Ports pointing at pipe ends. Phase 51d added m_species: dict[str, float] to track every non-fuel species column on connected pipes.

  • For each BCSpec → a BCFn callable 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. A Cylinder carries the same m_species inventory 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 from list[Junction] to list[Component] and tightened the Protocol's volume attribute 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:

  1. Apply BCs — fill every pipe's ghost cells (far-field BCs + junction-port BCs).
  2. Compute dt — minimum over all pipes of cfl * dx / max(|u| + a). CFL can be live-tuned via the Phase-A8 LiveControl hook.
  3. 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.
  4. Junction state — accumulate volume rates from every port flux; integrate volume.m, volume.E_internal, optional volume.m_fuel, junction.m_species.
  5. Tracer — if a Tracer is attached and the next sample is due, snapshot every pipe + junction.
  6. 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.
  7. Live-control checks — every live_control.poll_every_n iterations the timeloop re-reads the on-disk control file. CFL / adaptive_cfl_max / paused updates take effect on the next iteration. Phase A17 added checkpoint_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 with aeolus1d 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 in coolant_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>:

  1. Reads the prior's final-state groups via aeolus1d.io.resume.read_resume_state.
  2. Builds a fresh network from the (unchanged) Case TOML.
  3. Seeds each pipe / junction's interior state from the checkpoint via seed_pipes_from_prior / seed_junctions_from_prior — primitives + species + T_solid
  4. surface + volume scalars + fuel + multi-species inventory.
  5. Sets t_start = prior.t_final.
  6. If the prior also has a time_series/ group, preloads it into the new tracer via Tracer.preload_from_prior so the merged HDF5's time axis covers [0, t_end] as one continuous curve (otherwise plots start from the resume time).
  7. Runs the timeloop from t_start to case.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. Signals caseReplaced, caseStructureChanged, caseFieldChanged, initFieldChanged, probesChanged, pathChanged, dirtyChanged, canUndoChanged, canRedoChanged.
  • RunModel — the most-recent run's HDF5 path + cached RunResult. Signals runLoaded, cleared, runPathChanged. Owns a 1 Hz file watcher (Phase A6 wide-net, before the architecture refactor's Phase 8 — name collision; the GUI RunModel watcher dates back to that earlier work) that fires runLoaded on any change to last_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.MainWindow wires user signals → model API; routes model signals → view-side refresh slots. Spec construction with dynamic defaults is delegated to aeolus1d_gui.controller.mutations; the case → canvas walk lives in aeolus1d_gui.editor.case_renderer (Phase A4). The CLI runner's god-function _cmd_run was 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. limiter is 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 in tests/conftest_smoke_marker.py::SMOKE_TEST_IDS. Run this inside a tight refactor loop.
  • pytest -m fast — ≤ 60 s, every test under tests/unit/ and aeolus1d_gui/tests/ plus the smoke tier (smoke ⊆ fast). Auto-applied via repo-root conftest.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, the Pipe state, the closure registry: API reference