Skip to content

Case load, build, run, write

The path a case file takes from text to result: aeolus1d run case.toml reads the TOML, turns it into a Case, builds the network, advances it with the driver the case asks for, and writes the HDF5 result. The contract of every function named below is in its docstring (the standard is Function contracts and flow charts). This chart draws the seams and says where each refusal happens; two stages have a chart of their own and are only linked here: the build (Build a network from a case) and resuming from a result file (Resume from a checkpoint).

Everything between "TOML text" and "a Case" is aeolus1d.io.parsers.load_case and is pure parsing: no pipe is created, no kernel runs. Everything a refusal names is a file, a table and a key, as far as the stage can know them (see "Where each refusal happens").

Load: text to a Case

What enters: a path, strict_case_header (False for every production caller) and validate (True by default; the GUI's File, Open passes False so that a broken case can still be opened and fixed). What leaves: a Case.

flowchart TD
    IN["path, strict_case_header, validate"] --> EX{"file exists?"}
    EX -- no --> X1["CaseParseError: case file not found"]
    EX --> RD{"read as UTF-8"}
    RD -- "unreadable, a directory, not UTF-8" --> X2["CaseParseError naming the path"]
    RD --> TL{"tomllib.loads"}
    TL -- "syntax error" --> X3["CaseParseError: TOML syntax error"]
    TL --> UP{"apply_case_upgrades: pop schema_version, walk the upgrade chain"}
    UP -- "newer, malformed or sub-floor version" --> X4["CaseParseError, section case"]
    UP --> CR{"resolve_component_refs: expand every ref table"}
    CR -- "missing, cyclic or invalid part" --> X5["CaseParseError naming the path"]
    CR --> HD{"_construct_section for case, then gas"}
    HD -- "wrong type, unknown key, missing key" --> X6["CaseParseError: key, line, suggestion"]
    HD --> ST{"strict_case_header?"}
    ST -- "yes, and a field not written" --> X7["CaseParseError listing every missing key"]
    ST --> SL["_parse_section_list for each array of tables"]
    SL --> BC["bc: _expand_bc_csv_schedules reads csv_path next to the case file"]
    BC --> CS["_parse_crankshaft_section: parse, then derive firing phases"]
    CS --> CA["Case: all sections, component_refs, case_dir"]
    SL -- "not an array, a missing key, a bad value" --> X8["CaseParseError naming file and entry"]
    BC -- "bad or missing csv, a column the kind ignores" --> X9["CaseParseError, section bc"]
    CS -- "bad firing order or intervals" --> X10["CaseParseError, section crankshaft"]
    CA --> V{"validate?"}
    V -- no --> OUT["Case, as parsed"]
    V -- yes --> VC{"validate_case with a TomlSourceMap for line numbers"}
    VC -- "any error issue" --> X11["CaseValidationError listing every error"]
    VC --> OUT

The order inside the load matters in three places. The [case] block is built before the strict check, so a typo such as cf1 is reported with its suggestion instead of being buried in a list of 50 "missing" keys. Component references are expanded straight after the version upgrade and before any section is built, because both are dict to dict transforms on the raw parse; once expanded, the document is exactly what would have been written inline, so no parser knows references exist. The CSV schedules are read at load, where the case file's directory is known, so every later stage sees an ordinary inline schedule.

One table: _parse_pipe and its blocks

Each [[pipe]] table goes through _parse_pipe, which hands every sub-table to its own parser. The closure families (friction, heat, porous, catalyst, filter sub-specs, reaction names) read through one registry-driven codec (parse_kind_block, parse_flat_spec), so the kinds and fields a block accepts are the registry's, not a list in the parser.

flowchart TD
    P["one pipe table"] --> RQ["id, length, n_cells (a whole number) are required"]
    RQ --> AP{"area_profile"}
    AP -- "unknown kind, a key of another kind, a bad table" --> E1["ValueError or CaseParseError"]
    AP --> IN["_parse_init: uniform, riemann, regional, mode"]
    IN -- "unknown key, empty regions, a missing key" --> E2["CaseParseError naming the block"]
    IN --> CL["_parse_friction, _parse_heat, _parse_porous: parse_kind_block"]
    CL -- "unknown kind, a field of another kind, wrong type" --> E3["ValueError"]
    CL --> CAT["_parse_catalyst: kind, then cpsi monolith resolver"]
    CAT -- "cpsi beside explicit geometry, bad substrate" --> E4["ValueError"]
    CAT --> SP["_parse_species, _parse_eos, _parse_filter, _parse_urea, _parse_thermal_wall"]
    SP -- "unknown reaction, cubic EOS without Tc and Pc" --> E5["ValueError"]
    SP --> PS["PipeSpec"]

Every ValueError in that chart reaches the user as a CaseParseError that names the file and the entry (pipe[2]): _parse_section_list re-raises it through _anchored, with the original text first.

Pre-flight, build, run

The CLI calls load_case, applies its overrides, then validate_case_pre_run before anything is built (_load_and_preflight); run_case calls validate_case_pre_run itself, so the check holds for the library and the GUI subprocess too. build_network validates again by default (a case edited in code is checked at the seam), then builds; see Build a network from a case for what it refuses.

flowchart TD
    CA["Case"] --> PR{"validate_case_pre_run"}
    PR -- "NaN t_end, non-finite cfl or tolerance" --> X1["ValueError"]
    PR -- "t_end <= 0 with no resume and no step cap" --> X2["ValueError: nothing to advance"]
    PR -- "explicit rk with cfl > 1, bad ramp start" --> X3["ValueError: use an implicit rk"]
    PR -- "bad chem knob, bs_brinkmeier channel, species contract" --> X4["ValueError naming the pipe"]
    PR --> BN["build_network (own chart)"]
    BN --> RN["run_case: controllers chained into on_step"]
    RN --> DA["dispatch_advance"]

run_case is validate_case_pre_run, a Clock when a BC is time-dependent, build_network, controllers chained ahead of the caller's on_step, and dispatch_advance. The CLI's --resume, --trace-dt and controller paths build the network first (_advance_eager) so they can seed or trace it, then call the same dispatch_advance with the same arguments; the seeding is drawn in Resume from a checkpoint.

dispatch_advance: which driver

flowchart TD
    D["case, built network, far BCs, run arguments"] --> SV{"solver in explicit, strang, quasi_steady?"}
    SV -- no --> X1["ValueError: solver is not one of"]
    SV --> KN{"_DispatchKnobs.build: limiter name, dt knobs, ramp, pass-through dicts"}
    KN -- "unknown limiter" --> X2["ValueError"]
    KN --> NT["chem-parallel notice, solver banner"]
    NT --> NP{"_needs_network_path: junction, species, catalyst, real gas, coolant, wall pairs, quasi_steady"}
    NP -- no --> SG["_advance_single_pipe_case"]
    SG -- "not one pipe, no BC on an end" --> X3["ValueError"]
    SG -- "implicit rk with a CFL ramp" --> X4["NotImplementedError"]
    SG --> OUT["final time"]
    NP -- yes --> RS["NetworkRunSpec: the arguments every network driver takes"]
    RS --> Q{"solver"}
    Q -- quasi_steady --> QS["_advance_quasi_steady"]
    Q -- strang --> ST["_advance_strang"]
    Q -- "explicit, implicit rk" --> IM["_advance_implicit_network"]
    Q -- "explicit, explicit rk" --> EX["_advance_explicit_network"]
    QS -- "a surface mechanism catalyst" --> X5["ValueError"]
    EX -- "a surface reaction or mechanism catalyst" --> X6["ValueError"]
    QS --> OUT
    ST --> OUT
    IM --> OUT
    EX --> OUT

A single pipe with nothing but gas takes the shortcut; anything the shortcut cannot honour forces the network path, even with no junction. The solver decides before the scheme: solver = "strang" with an implicit rk still runs Strang, and quasi_steady ignores rk and cfl for stepping (the rk still selects the CFL-ceiling branch in the pre-flight). The drivers themselves are drawn in Explicit network step, Implicit network step and Quasi-steady drivers; the single-pipe loop is in Pipe time step.

Write

Nothing is written until the run returns. The CLI's _finish_run then prints the summary and _write_run_outputs writes what --out and --cgns ask for.

flowchart TD
    R["run returned: pipes, junctions, t_final, tracer, controllers"] --> O{"--out given?"}
    O -- no --> C
    O -- yes --> H["write_hdf5: truncates the file, then writes pipes, junctions, time_series, controllers"]
    H --> M["build_manifest and write_manifest: out.manifest.json"]
    M --> C{"--cgns given?"}
    C -- yes --> G["write_cgns"]
    C -- no --> END["done"]
    G --> END

The HDF5 file holds the final state of every pipe (pipes/<id>), every junction (junctions/junction_<ii>, with its kind and id), the controllers' checkpoint state, and, when a tracer ran, the time_series group; the root carries case_name, aeolus_version, schema_version, and t_final, written last. A run stopped early by a live-control request or a step cap writes the same file with t_final below t_end, which is what a later --resume continues from. The manifest is a JSON sidecar: aeolus1d version, a hash of the case file, the dependency set and the host. A failure part-way through write_hdf5 leaves a partial file (it opens the target for writing before it can fail); see its contract.

Where each refusal happens

Stage Function Raises What the message names
read load_case CaseParseError the path; for a directory or non-UTF-8 bytes, the reason
TOML syntax load_case CaseParseError the path and the parser's line and column
version apply_case_upgrades CaseVersionError, re-raised as CaseParseError the version found and the highest this build reads
references resolve_component_refs ComponentRefError, re-raised as CaseParseError the ref, every path searched, the chain on a cycle
[case], [gas] _construct_section CaseParseError file, section, key, its line when unambiguous, a suggestion
[case] completeness _check_case_header_strict CaseParseError every missing key (only with the opt-in strict flag)
each array of tables _parse_section_list CaseParseError file, pipe[2], the missing key or the original message
CSV schedules _expand_bc_csv_schedules CaseParseError file, bc[i], the csv path and what is in its directory
crankshaft _parse_crankshaft_section CaseParseError file, [crankshaft], the offending list
whole case validate_case CaseValidationError every error with its path and source line
pre-flight validate_case_pre_run ValueError the setting, its value, the way out
build build_network ValueError, CaseValidationError the pipe, junction, controller or turbo (own chart)
dispatch dispatch_advance ValueError, NotImplementedError the solver, limiter, pipe or catalyst concerned

Unknown keys and defaults

An unknown key is refused in two different places. In [case] and [gas] it is ALWAYS refused at parse (_construct_section), whatever validate says. In every other table the parsers read the keys they know with d.get and ignore the rest, so an unknown key there (a typo such as lenght) is refused only by validate_case; with validate=False it is silently dropped, and the field keeps its default. A key that is a field of the spec but belongs to another kind of a tagged block (alpha on a porous block with no kind) is refused at parse by the codec.

Every default lives on its schema dataclass. The parsers restate many of them as d.get(key, literal); a test per parser builds the spec from the smallest table and compares it field by field with the dataclass, so a literal that drifts is found. The exceptions are deliberate and stated in the contracts: the cylinder's ignition is resolved at parse from the geometry and the combustion kind (a compression-ignition cylinder gets knock retard 0.0, a spark-ignition one the dataclass 2.0 and 10.0, and a key the user wrote always wins), and a [[junction]] without init gets a placeholder pressure of 0 Pa that validate_case refuses. The GUI's help popup (schema_help) quotes some defaults in prose; one test compares every number it quotes with the dataclass.

Not checked at load

Three things a bad case can carry past load_case and validate_case_pre_run and fail on later, listed so that nobody reads a green load as a check of them. (A fourth, the adaptive tolerances, is closed: since 2026-10-07 adaptive_atol and adaptive_rtol are validated at load while adaptive_error_control is on, and the adaptive-Picard band while adaptive_picard is on; with the switch off they are not read.) Values of nested numeric fields are taken as written: [pipe.species] A = "abc" loads (the validator does not type-check nested specs), and fails only for a reaction kind whose kernel reads it. An unknown key is ignored with validate=False, as above. And t_end = inf is not refused: with a step cap or a live stop it is a way to run until stopped.

Functions

  • src/aeolus1d/io/parsers.py::load_case
  • src/aeolus1d/io/parsers.py::_construct_section
  • src/aeolus1d/io/parsers.py::_check_primitive_types
  • src/aeolus1d/io/parsers.py::_check_case_header_strict
  • src/aeolus1d/io/parsers.py::_warn_spec_defaults
  • src/aeolus1d/io/parsers.py::_parse_section_list
  • src/aeolus1d/io/parsers.py::_anchored
  • src/aeolus1d/io/parsers.py::_parse_pipe
  • src/aeolus1d/io/parsers.py::_parse_init
  • src/aeolus1d/io/parsers.py::_parse_friction
  • src/aeolus1d/io/parsers.py::_parse_heat
  • src/aeolus1d/io/parsers.py::_parse_porous
  • src/aeolus1d/io/parsers.py::_parse_catalyst
  • src/aeolus1d/io/parsers.py::_parse_species
  • src/aeolus1d/io/parsers.py::_parse_eos
  • src/aeolus1d/io/parsers.py::_parse_filter
  • src/aeolus1d/io/parsers.py::_parse_urea
  • src/aeolus1d/io/parsers.py::_parse_thermal_wall
  • src/aeolus1d/io/parsers.py::_parse_junction
  • src/aeolus1d/io/parsers.py::_parse_bc
  • src/aeolus1d/io/parsers.py::_expand_bc_csv_schedules
  • src/aeolus1d/io/parsers.py::_parse_cylinder
  • src/aeolus1d/io/parsers.py::_parse_crankshaft_section
  • src/aeolus1d/io/_kind_codec.py::parse_kind_block
  • src/aeolus1d/io/_kind_codec.py::parse_flat_spec
  • src/aeolus1d/io/case_upgrades.py::apply_case_upgrades
  • src/aeolus1d/io/component_library.py::resolve_component_refs
  • src/aeolus1d/io/source_pos.py::TomlSourceMap.from_text
  • src/aeolus1d/io/validate.py::validate_case
  • src/aeolus1d/io/case.py::validate_case_pre_run
  • src/aeolus1d/io/case.py::run_case
  • src/aeolus1d/io/case.py::dispatch_advance
  • src/aeolus1d/io/case.py::_DispatchKnobs.build
  • src/aeolus1d/io/case.py::_needs_network_path
  • src/aeolus1d/io/case.py::_advance_single_pipe_case
  • src/aeolus1d/io/case.py::_advance_quasi_steady
  • src/aeolus1d/io/case.py::_advance_strang
  • src/aeolus1d/io/case.py::_advance_implicit_network
  • src/aeolus1d/io/case.py::_advance_explicit_network
  • src/aeolus1d/io/build.py::build_network
  • src/aeolus1d/cli/commands/run.py::_load_and_preflight
  • src/aeolus1d/cli/commands/run.py::_advance_plain
  • src/aeolus1d/cli/commands/run.py::_advance_eager
  • src/aeolus1d/cli/commands/run.py::_finish_run
  • src/aeolus1d/cli/commands/run.py::_write_run_outputs
  • src/aeolus1d/io/writer.py::write_hdf5
  • src/aeolus1d/io/manifest.py::build_manifest
  • src/aeolus1d/io/manifest.py::write_manifest
  • src/aeolus1d/io/cgns_writer.py::write_cgns
  • src/aeolus1d/io/resume.py::read_resume_state