Skip to content

Driving the solver

The four functions that take you from a TOML file to an advanced network. load_case → build_network → dispatch_advance is the explicit three-step path; run_case collapses build-and-advance into a single call. See Overview for when to use which.

Field references live elsewhere

These functions move a Case dataclass around. Every field on it — the [case] knobs, the per-pipe / BC / closure specs — is documented in Run / case settings and Closures — reference table. This page documents the calls, not the data.

Parsing a case

aeolus1d.io.parsers.load_case

load_case(
    path: str | Path,
    *,
    strict_case_header: bool = False,
    validate: bool = True,
) -> Case

Parse a TOML file into a Case dataclass.

Phase B10 — runs aeolus1d.io.validate.validate_case before returning so spec-level mistakes (typos in pipe references, pipes with no area, etc.) surface as a single aggregated error message instead of crashing deep inside the builder pipeline.

Phase 2026-05-26 — wraps the early parse path so the user gets file-anchored diagnostics for: missing files, malformed TOML syntax, unknown / mistyped keys in top-level sections, and missing required keys. The downstream validate_case pass already handles cross-reference issues (BC referencing a non-existent pipe, etc.); this layer protects everything upstream of that.

Case-header policy — any CaseHeader field omitted from [case] fills from its schema default (a minimal [case] listing only the handful of knobs that differ from default is the idiomatic form; aeolus1d expand writes that minimal block). Unknown / mistyped keys are always rejected with a fuzzy-match suggestion — that typo-safety lives in _construct_section below and is independent of this policy. strict_case_header=True is the opt-in lint that also requires every field be written explicitly (the v0.9 fully-specified form); production runners leave it False. [gas] likewise default-fills missing fields and logs a one-shot warning naming each fill — see :func:_warn_spec_defaults. The array sections (pipe / junction / BC / closure specs) default-fill without that warning; only their required keys and, per block, their unknown keys are checked. (Corrected 2026-09-25: this claimed the warning for every per-spec dataclass; only _construct_section emits it, and only [case]/[gas] go through that.)

validate=False skips the validate_case call entirely and always returns the parsed Case, even one with fatal issues — for a caller that wants to load a broken case anyway (the GUI's File → Open, so a case that fails validation can still be opened and fixed in place instead of being locked out) and will run its own validate_case pass afterward to learn what's wrong. Production callers (aeolus1d run, aeolus1d validate) leave this True.

Inputs

path: a TOML case file. strict_case_header: False (default): a [case] that omits fields takes the dataclass defaults. True: every CaseHeader field must be written (an opt-in lint; also skipped while AEOLUS_LENIENT_CASE_HEADER=1). validate: True (default): run validate_case before returning. False: return whatever parsed, even a case with fatal issues (the GUI's File -> Open).

Returns: a Case: case (CaseHeader) and gas built by _construct_section; pipes, junctions, bcs (CSV schedules expanded, relative to the case file), turbos, shafts, cylinders (firing phases derived), reactions, species, controllers, crankshaft, coolant_pipes, coolant_couplings; component_refs (location -> ref) and case_dir (the file's absolute directory). Order: exists -> read UTF-8 -> parse TOML -> apply_case_upgrades (pops schema_version) -> resolve_component_refs -> sections -> validate_case. Nothing is written. DEFAULT OWNERS: every default lives on its schema dataclass; the parser literals that restate them (species, bc, junction, cylinder, ...) agree with them, and schema_help prose restates a few (see its contracts). UNKNOWN KEYS: a key the dataclass does not declare in [case] or [gas] is ALWAYS refused (with a suggestion). In every other table the parsers read named keys with d.get and ignore the rest, so an unknown key there is refused only by validate_case: with validate=False it is silently dropped. Refuses: all CaseParseError with the file set unless noted: missing file; unreadable file, a directory, or bytes that are not UTF-8; TOML syntax error; a schema_version that is not an integer, is below 1 or is newer than this build (section case); a component ref that is missing, cyclic or not TOML; a wrong-typed or unknown [case]/[gas] key; [pipe] for [[pipe]] and the other table shape errors; a missing required key or a bad value in any table (named by file and entry); [[crankshaft]]; a bad CSV; then, with validate=True, CaseValidationError listing every validator error. NOT checked here, though a bad value is a run-time failure: a NaN t_end (refused by validate_case_pre_run). A bad adaptive_atol / adaptive_rtol (under error control) and a bad Picard band (while adaptive_picard is on) ARE refused by validate_case (_validate_adaptive_controls). Edges: the smallest case and its case_dir (test_the_smallest_case_loads_with_its_directory_and_no_refs). missing, directory and non-UTF-8 files (test_a_missing_file_a_directory_and_bad_bytes_name_the_path). syntax errors (test_a_toml_syntax_error_names_the_file). forward version, marker not reaching the header (test_a_forward_schema_version_is_refused_and_the_marker_never_reaches_the_header). unknown [case] key with and without validation (test_an_unknown_case_key_is_refused_even_without_validation). unknown key elsewhere (test_an_unknown_key_elsewhere_is_ignored_by_the_parser_but_refused_by_the_validator). errors name file and entry (test_a_value_error_in_a_pipe_block_names_the_file_and_entry, test_a_missing_required_key_in_a_table_names_the_table_and_key, test_a_single_pipe_table_is_refused_with_the_array_spelling). validate=False (test_validate_false_returns_a_case_the_validator_would_refuse). the strict option (test_the_strict_header_option_requires_every_case_key). crankshaft (test_a_crankshaft_array_and_a_bad_crankshaft_are_refused_naming_the_file). component refs (test_a_component_reference_is_expanded_and_recorded). CSV relative to the case file (test_a_bc_csv_path_is_resolved_against_the_case_directory).

Building the network

build_network materialises the parsed Case into live Pipe, junction, and boundary-condition objects, returning a NetworkBundle (documented under Runtime objects). Pass a Clock if the case uses any transient boundary condition — the helper aeolus1d.io.case._case_has_transient_bcs(case) tells you whether one is needed.

aeolus1d.io.build.build_network

build_network(
    case: Case,
    clock: Clock | None = None,
    *,
    validate: bool = True,
) -> NetworkBundle

Materialise a Case into Pipe/Junction/BC objects.

Returns a NetworkBundle (pipes_by_id, junctions, far_bcs, controllers). Supports legacy 3-tuple unpack pipes, junctions, bcs = build_network(case) via iter. far_bcs is the list of (pipe, End, BCFn) triples expected by advance_network. If the case uses any transient BC, pass a Clock — otherwise the BC factories will refuse to build.

Validation. validate_case used to have exactly one call site — inside the TOML text parser — so every check it performs (schedule ordering, junction/turbo port areas, BC static params, init plausibility, unknown keys, …) applied only to cases loaded from a file. A Case assembled in Python and passed straight here got none of it, which is the path notebooks, harnesses and tests all take, and the path a newly-guarded bug is most likely to come back on.

It now runs at this boundary, which is the one every route to a live network passes through. Errors raise CaseValidationError; warnings print once (load_case stamps the Case, so the TOML path does not print them twice — but the error checks DO re-run here, because mutating a loaded Case before building is the documented way to override a setting and can invalidate it).

Pass validate=False to skip. That exists for callers that build a deliberately partial Case — some tests construct one field-by-field to exercise a single builder — and is not a recommendation.

Inputs

case: a Case (from load_case or built in code). clock: the Clock the time-dependent BCs, cylinders and user closures read t from, or None. With None and a case that needs one (_case_has_transient_bcs) a fresh Clock is created INSIDE the build and is not returned: BCs built on it stay at t = 0 unless the caller advances it, so a caller that runs the network passes its own (run_case, the CLI, optimize do; the FMU and the CFL inspector do not). validate: True (the default) runs validate_case first; False skips it, for deliberately partial cases and for tests of the build-time guards.

Returns: a NetworkBundle: pipes, junctions (junctions, turbos, cylinders, then shafts), bcs (biased-plenum BCs first, then the [[bc]] specs), controllers and controllers_by_id, the coolant pipes / couplings / standalone walls, the typed mirror arrays and the connection handshake ledger. It unpacks as the legacy (pipes, junctions, bcs). The process-wide throttle registry is cleared on entry and refilled by this build's throttles. Order: validate, ghost width from the scheme, limiter, species-order check (spec only, so its repair hint is what the user sees), pipes, BC-fed species columns, [[reaction]] blocks, junctions, shafts, turbos, cylinders, far BCs, end coverage, controllers, coolant system, bundle, handshake. Refuses: CaseValidationError from validate_case (errors; warnings print once per case); an unknown scheme or limiter -> ValueError (the limiter used to fall back to minmod); every refusal of the stages above, as ValueError; and, in [case] handshake = "strict" with validation on, CaseValidationError for a connection that loses a species or mismatches a gas model. Edges: a built network and its parts (test_a_built_network_carries_pipes_bcs_a_handshake_and_the_typed_mirror). validation on by default and skippable (test_a_python_built_case_is_validated, test_validation_can_be_skipped_explicitly, test_errors_are_rechecked_even_on_an_already_validated_case, test_warnings_are_not_printed_twice_on_the_toml_path). a misspelt limiter (test_a_misspelt_limiter_is_refused_not_run_as_minmod). a clocked case builds without a caller clock (test_a_case_with_clocked_bcs_builds_without_a_clock_from_the_caller). the throttle registry is cleared on entry (test_the_throttle_registry_is_cleared_on_entry). the species-order check comes first (test_the_species_ordering_refusal_comes_before_anything_is_built). every bundled example builds (test_example_builds_network_with_sane_initial_state).

Advancing: one-call path

run_case is the simplest driver — it builds the network, wires any controllers into the per-step callback, and advances to case.case.t_end, returning (pipes_by_id, junctions, t_final).

aeolus1d.io.case.run_case

run_case(
    case: Case,
    on_step: Callable[[float], None] | None = None,
    max_steps: int | None = None,
    t_start: float = 0.0,
    live_control: object | None = None,
) -> tuple[dict[str, Pipe], list[Component], float]

Build the network from case and advance it to case.case.t_end.

Returns (pipes_by_id, junctions, t_final). The optional on_step callback is forwarded to the timeloop and fires once per completed step — used by the CLI's --trace-dt sampler.

max_steps is the safety cap on solver iterations. When None each timeloop applies its own default (10M for both single-pipe and network); the GUI's Preferences dialog and the CLI's --max-steps flag both flow through here when the user wants a tighter or looser cap.

t_start defaults to 0.0 for fresh runs; the CLI's --resume path passes the prior run's t_final so the timeloop continues from there to case.case.t_end. Callers are expected to seed the network state (pipes, junctions) themselves before calling — this entry point doesn't open the prior HDF5.

Inputs

case: a Case. on_step: called with the simulation time [s] after each completed step, or None. max_steps: the cap on solver iterations (each timeloop's own default, 10 million, when None). t_start: the start time [s]; 0.0 for a fresh run, the prior t_final for a resume. The caller seeds the network state; this function does not open a checkpoint. live_control: a LiveControl polled by the drivers that support it, or None.

Returns: (pipes_by_id, junctions, t_final): the built, advanced network (state advanced in place) and the time reached [s]. Refuses: anything validate_case_pre_run refuses (has_resume is t_start > 0), before anything is built; whatever build_network and dispatch_advance refuse. Edges: a refusal happens before the build (test_run_case_refuses_before_it_builds_anything). t_start > 0 legalises t_end = 0; the controllers run before the caller's on_step on every step; a clock exists only for transient BCs (test_run_case_treats_a_positive_t_start_as_a_resume_and_chains_controller_callbacks). a real run reaches t_end (test_run_case_runs_a_real_tube_to_t_end). flow chart: docs/flows/case-load-build-run-write.md.

Advancing: explicit dispatch

When you need the built network in hand before it runs — to attach a sampler via on_step, seed state for a --resume, or trace per step — advance a pre-built NetworkBundle with dispatch_advance. It is the single point that inspects the case (solver, the rk time scheme, junction presence) and routes to the correct timeloop driver, so prefer it over calling a driver such as advance_network directly. A solver = "quasi_steady" case routes to the low-Mach QS driver, a solver = "strang" case to advance_network_strang, and an implicit rk (implicit_euler / bdf2 / radau_iia) to advance_network_implicit; everything else takes the explicit path. The network path is forced even without junctions whenever a pipe carries species, catalyst, or real-gas state, or the case wires coolant couplings / wall-thermal pairs.

aeolus1d.io.case.dispatch_advance

dispatch_advance(
    case: Case,
    pipes: dict[str, Pipe],
    junctions: list[Component],
    far_bcs: FarBCs,
    *,
    clock: Clock | None,
    on_step: Callable[[float], None] | None = None,
    max_steps: int | None = None,
    t_start: float = 0.0,
    live_control: object | None = None,
    coolant_couplings: list[tuple[Pipe, object, object]]
    | None = None,
    wall_thermal_pairs: list[WallThermalPair] | None = None,
    soa_arrays: NetworkArrays | None = None,
) -> float

Decide which timeloop driver to call given a built network + case knobs, and run it to case.case.t_end.

Single point of dispatch for every CLI / GUI / library entry point that needs to advance a pre-built network — run_case is the simplest caller (build then advance), while the CLI's --trace-dt and resume branches need to keep the pipes / junctions handles for tracing / seeding before calling this. Centralising the (solver, rk, junctions) dispatch tree here means a new driver / scheme pulls into one place rather than the three near-duplicates we used to maintain in cli/main.py.

Returns the final simulation time the timeloop reached.

Inputs

case: the Case. pipes: built pipes by id. junctions: built junctions and components. far_bcs: the (pipe, end, bc) triples. clock: a Clock or None (keyword). on_step, max_steps, t_start, live_control, coolant_couplings, wall_thermal_pairs, soa_arrays: as for run_case (keywords).

Returns: the final time [s]. The pipes and junctions are advanced in place; on_step runs once per step. Order: refuse an unknown solver; build the knobs; the chem-parallel notice; the one-shot solver banner (AEOLUS_QUIET_SETTINGS=1 silences it); then the choice: _needs_network_path false -> the single-pipe driver; else quasi_steady -> _advance_quasi_steady; strang -> _advance_strang; an implicit rk -> _advance_implicit_network; otherwise _advance_explicit_network. Refuses: a solver outside explicit/strang/quasi_steady -> ValueError (it ran as explicit); an unknown limiter (_DispatchKnobs.build); plus each branch's refusals. Edges: junction cases take the explicit network driver (test_a_junction_case_goes_to_the_explicit_network_driver). unknown solver (test_an_unknown_solver_name_is_refused_not_run_as_explicit). strang and implicit routing (test_a_strang_case_passes_the_rk_and_chemistry_knobs, test_an_implicit_rk_goes_to_the_implicit_network_driver_with_its_configs). quasi-steady (test_a_quasi_steady_case_goes_to_the_quasi_steady_driver). the shortcut (test_a_plain_single_pipe_takes_the_single_pipe_shortcut). solver = "strang" with an implicit rk goes to Strang, the solver winning (test_the_strang_solver_wins_over_an_implicit_rk). flow chart: docs/flows/case-load-build-run-write.md.