Skip to content

Changelog

aeolus1d has shipped in numbered phases since milestone 1. This page summarises each milestone; the README's bottom section has the same list with finer-grained per-phase notes.

Milestone numbers are not package versions

The v0.N headings below are milestones — internal development phases. They do not track the PyPI package version, which lives in pyproject.toml and moves far more slowly.

On PyPI: 0.4.2 — the current final release, cut and published 2026-10-13, a patch release on 0.4.1 whose fixes move some results (each says how much), with three security fixes. 0.4.1 (2026-10-10) changed no solver code: it linked the now-public docs from PyPI and carried two GUI changes. 0.4.0 (also 2026-10-10) was a minor release on 0.3.3 that dropped Python 3.9 and carried every solver, importer and GUI change since. 0.3.0rc1 2026-08-10 and 0.3.0rc2 2026-08-14 preceded 0.3.0 as pre-releases, which pip install aeolus1d skips without --pre. CITATION.cff, SECURITY.md and the README badge track the latest final release and therefore now name 0.4.2. docs/release-readiness.md carries the exit criteria; docs/release.md carries the gate waiver for the two amber W1 validation rows this release ships with as documented limitations.

Package 0.4.0 and milestone v0.4.0 are different things (decided 2026-10-09, EC-Q1). The milestone heading v0.4.0 (pre-release) — flow-physics closures further down predates the package release by months. The package release keeps the number and is told apart by its heading, which reads v0.4.0 (minor, tagged …); release links point at that heading.

v0.4.2 (patch, tagged 2026-10-13, published 2026-10-13) — the held-back fixes, and three security fixes

About 60 defects that the contract sweeps had held back are fixed, and some of them move results. The contract batches (2026-09-29 to 2026-10-06) wrote these into BACKLOG.md instead of fixing them, because a fix could change a number. Each fix below that moves a number on a bundled case says which case and by how much, measured before and after with AEOLUS_FASTMATH=0. What still needs a decision stays in BACKLOG.md.

Results that move:

  • The burnt-zone temperature no longer floors at 1200 K with burnt_dissociation = true: below the dissociation table it is now e/cv_b, and above it the value is no longer pinned at 6000 K. This moves the last_T_burnt diagnostic (the Zeldovich NOx input), not the bulk state. In examples/89_epw_diesel.toml 46,485 of 66,867 calls in the first 0.08 s read exactly 1200 K before, and none do now; the golden EPW case's three affected cylinders read 1030.7, 804.5 and 736.3 K instead of 1200 K.
  • One universal gas constant. Sixteen closure and solver modules used 8.314, 8.3145, 8.31446 or 8.314462618; all now use CODATA's 8.31446261815324 (aeolus1d.constants.R_UNIVERSAL), and the SCR three-pathway demo's NO2 molar mass is 46.0055 g/mol. Measured: DOC CO in examples 46 and 61 moves by 2.2e-4 and 1.8e-4 relative, SCR NH3 in 42, 43 and 45 by 1.9e-5, and 11 Cruise M imports with DOC kinetics by 4.5e-6 in A and Ea.
  • Cruise M DOC kinetics are paired per catalytic. C02011_Diesel and C02030_Transfer_Model_Pars now import their DOC Voltz kinetics (they fell back to the default before), and five projects with no DOC pipe no longer carry mis-paired values. Two urea dosers on one pipe are merged (flow-weighted) instead of the last one winning.
  • The electrified drive cycle (example 86) stops stepping one sample past the end: distance 785.150 -> 785.122 m, fuel 1.08703 -> 1.08706 L/100km. The conventional drive cycle had the same extra step; on example 85 it moved nothing, because the car had already braked to rest. cycle_kpis counts the wheel work the plant applied (each sample over the step after it), not every sample times the mean step.
  • CGNS output and the FMU's T_exhaust use the pipe's own temperature (EOS or mixture), not p/(rho R): at t = 0, example 17 reads 300.0 K instead of 250.1 K and example 58 298.8 K instead of 297.4 K.
  • Multi-stage Wiebe burn progress handles a window that crosses 720°, as the single-stage formula always did. A stage burning across the wrap released heat after it but read no progress there. All stages now reset together at the first stage's start + 360° (the single-stage rule) instead of at 720°. A first cut on this branch reset each stage at its own start
  • 360°. The 3-op EPW validation caught it at 1200 rpm, where the diffusion stage starts just after TDC: each cycle opened at x_b = 0.6 and MFB50 read −359.9° (main: 12.1°, AVL 11.2°). Fixed before landing. In that run every other EPW channel agreed with main within 5.3e-5 relative at all three ops. Op 1200 rpm re-run with the fix: MFB50 12.126° (main 12.126°), PASS, every channel within 5.3e-5 of main. All three ops re-run after the merge, on main d341a665: 3 of 3 PASS, every channel within 5.3e-5 of main before step 2. The measured figures are in docs/release-readiness.md.
  • Rounding only: the lumped cooler, the multi-zone NOx relaxation (no bundled case reaches it), and the engine-metrics wall heat (example 71: -113.975 -> -113.976 J per cycle, Python API only).

Refused now, with a message naming the input (no bundled case trips any): a surface-mechanism coverage pushed far outside [0, 1] or to NaN, a cylinder with no mass or no pressure, an unknown cubic EOS kind, an undeclared duplicate reaction in a CHEMKIN or Cantera file (or a non-boolean duplicate: flag), two different falloff colliders, turbo vane, bypass or surge-damping values outside [0, 1], a NaN cfl_ramp_duration or knock retard, a malformed OpenWAM file, an FMU input outside its range, and a non-finite t_end in analyze-cfl. The CLI's input errors exit with code 2 and one error: line instead of a traceback.

Security (the three decisions BACKLOG.md held for the maintainer):

  • User code is off unless the switch says on. AEOLUS_ALLOW_USER_CODE enables user closures only for 1, true, yes or on (any case). Any other value, such as n, none or disabled, used to turn them ON; it now leaves them off, and a case that names one is refused with the usual message. --allow-user-code is unchanged.
  • Imported XML has size and depth limits. A vendor XML document over 256 MiB, or nested deeper than 1000 elements, is refused before it is parsed, and a whole-file or zip-member read stops one byte past the limit, so a zip bomb in a Cruise M project or an FMU is no longer decompressed whole. No real file comes close (the largest corpus document is 59 MB, the deepest 190 levels); the Cruise M corpus imports unchanged. The depth check adds about 1 s to a 59 MB import.
  • GT-Suite imports stay inside the model's folder. A .gtsub sub-assembly or a compressor / turbine map that resolves outside the .gtm's folder (.., an absolute path, a symlink) is refused: the sub-assembly is skipped with a warning, the map keeps the synthetic map with a lossy note. A map is converted to an NPZ written beside it, so following one let a model write outside its own folder. Two GT example models (Acoustics/.../TL.gtm and Transmission_Loss-LNS.gtm) reference a sibling folder's muffler_subassy.gtsub and now import without it, with the warning; copy the file into the model's folder to keep it.

GUI:

  • A refused connection says why, at the cursor; ids containing :: can be wired; Save As keeps an upper-case .TOML; a successful Save removes its autosave; Cancel no longer freezes the window; two runs in the same second get their own folders.
  • Schedule import sorts by time and handles short rows, and an empty half shows blank instead of 0.0. Rate cells restore half-typed text. The engine wizard shows progress and can be cancelled.
  • Result views without a temperature trace use the gas's own R; the modes panel no longer opens on the 0 Hz rigid-body mode of a closed pipe; the docs viewer shows a "Missing" page for a broken link.
  • A corrupt stored "Show canvas" setting no longer hides the canvas.

Developer: scripts/hash_golden.py (the golden bit-identity check every sweep used) is tracked. tests/regression/refresh_snapshots.py refuses to freeze a golden reference on a CPU other than the one the rest of the set was frozen on (--new-host re-freezes the whole set when the bench host changes): four references frozen on a dev machine turned main red on 2026-10-12.

v0.4.1 (patch, tagged 2026-10-10, published 2026-10-10) — the docs are public, and id fields are drop-downs

No solver, importer or schema change: every case gives the same result as on 0.4.0.

  • GUI: View → Show canvas (Ctrl+Shift+C, or the toolbar's Canvas box) hides the editor canvas, so the panels fill the window (a plot or the network view at full size). The case, selection and camera are kept, and Fit view, Reset view, Probe mode and Organize are greyed out while it is hidden. The choice is remembered across sessions.
  • GUI: fields that name another component are drop-downs. A BC's pipe was an editable combo, whose type-in box read as a text field; it is now a plain drop-down of the case's pipes. The same holds for a port's, cylinder port's and probe's pipe, and is new for a turbo's inlet and outlet pipe and shaft and a controller's pipe fields, turbine (turbines only) and cylinder, which were typed by hand. An optional reference has a blank entry that unsets it. A reference to a component that no longer exists is shown and kept, so an old case loads unchanged.
  • The documentation has a public address, https://aeolus-8b65d9.gitlab.io/, and PyPI links to it (Documentation in [project.urls], shown from this release on). The site is the GitLab Pages build that main has deployed all along. Until now only project members could open it, because the project is private. The repository stays private; only Pages access is opened. The site has the same 111 pages as the Markdown the wheel already ships, so nothing new is published.
  • site_url and the link in SUPPORT.md named niklas.nordin.swe.gitlab.io/aeolus/, which never loads: the dots in the namespace break the *.gitlab.io certificate. Both now name the site's real address.
  • The docs site no longer loads polyfill.io. That domain changed hands in 2024 and served malware from this URL. MathJax does not need the polyfill.

v0.4.0 (minor, tagged 2026-10-10, published 2026-10-10) — Python 3.10+, beta, and fixes that move results

0.3.3 went out on 2026-08-24 and main moved 854 commits across 100 merges since. Unlike 0.3.3, this release changes results. Read the entries below before comparing a run against one made with 0.3.3; the ones that move a number on a bundled case say which case and by how much.

  • Python 3.9 is no longer supported. requires-python is >=3.10. The removal was announced in 0.3.3; pip on a 3.9 interpreter keeps resolving to 0.3.3.
  • Development status is 4 - Beta (was 3 - Alpha).
  • The gui extra caps PySide6 below 6.12. 6.12.0 came out two days before this release and splits WebEngine and Pdf into separate packages; 0.4.0 ships the 6.11 line CI tested. The cap is lifted once 6.12 is tested.
  • Results that move. The largest are the nine wrong answers of the pre-release pass (the vehicle no longer reverses at a stop, multizone_diesel burns, the DPF regeneration is capped by the oxidant it has, a supersonic mass-flow inlet chokes, GT-Suite °C pipe walls), radau_iia and bdf2 conserving mass and energy across junctions, and the review sweeps of every function in the package, which fixed what they found. The AVL EPW anchored T_EXH errors moved from −15.2 / −47.1 / −43.1 K (2026-09-07) to −10.5 / −35.9 / −29.3 K at the release tree; docs/release-readiness.md (EC-Q7) records the re-run.
  • Every function in the package has been reviewed (review/ledger.jsonl, 4248 functions) and every function in the solver core states its contract, with a test for each edge it names.
  • scripts/perf_nightly_history.py read the history it was given (found while reading the pre-tag perf numbers, 2026-10-10). --fetch --cache DIR fetched into .perf-nightly/ and then reported that DIR was missing; glab api exits 0 on a 404, so an expired artifact was cached as a measurement whose every harness field was empty; and the trend report then crashed sorting those empty fields against real ones.

Pre-release pass: nine silent wrong answers fixed before v0.4.0 (2026-10-09). Picked from the open backlog as the defects that silently gave a wrong result on a path users take; each fix has a test that fails without it, and each was measured over the bundled cases first.

  • kind = "multizone_diesel" now burns. It was parsed and validated and the cylinder then ran with no combustion. The packet multi-zone model is wired into the cylinder: the injector's orifice flow at rail pressure is sliced into packets, each packet becomes a zone, and the heat release is the zones' burn. examples/89_dd13_multizone.toml injects 70.6 mg and releases all of it (peak 123 bar) where it burned nothing. New field inj_n_holes (default 8). The fuel mass is the injector's, not the block's m_fuel, and the rail is regulated (droop, EGR in the zones and zone-resolved NOx are open in BACKLOG.md).
  • The vehicle no longer runs backwards at the end of a stop (examples 85 and 86 reached -0.28 and -0.023 m/s on every stop): engine braking, rolling resistance and drag can stop a vehicle, only the grade can roll it back. Moves both drive-cycle traces and coastdown's last step.
  • GT-Suite temperatures read their unit. A slot in unit 34 (deg C) was taken as K: a 30 deg C plenum started at 30 K. 6 of the 104 local corpus files move.
  • A mass-flow inlet holds its face sonic when asked for more flow than the end face passes subsonically, instead of a supersonic inflow state (four imported Cruise M cases, one at Mach 2.87 because its import asks 1 kg/s of a 30 mm pipe); it warns once per pipe end.
  • The DPF regen fold burns only the carbon the oxidant allows; the rest goes back to the soot with its heat. It used to floor O2 / NO2 at 0 and credit the carbon anyway (only round-off in the bundled cases).
  • The quasi-steady driver refuses an unconverged pipe solve before writing it back (none in the bundled cases).
  • Turbo maps, the catalyst mechanism and the learned junction model resolve beside the case file, as the species mechanism already did; a file found only under the working directory still loads, with a FutureWarning.
  • A Cruise M flow node that reaches fewer than two pipes is reported as dropped (39 of 143 corpus projects; it vanished silently).
  • GUI: toolbar and menu clicks work while a text field has focus (only the bare-key shortcut is swallowed), and "Discard unsaved changes?" defaults to No.

Golden cases 13 of 13 identical; CLI baseline 2 of 22 moved (the two drive cycles). Retracted on the way: "apply_fuel_step's clamp creates mass" (only rounding; 0 firings over 10 cylinder cases).

radau_iia now conserves mass and energy on a network (2026-10-08). A Radau pipe loses dt·Σ bᵢ·Fᵢ through a junction face (the three stage fluxes, ghost held fixed) while the junction gained dt·F₃, one point at the end state, so a closed network created mass at O(dt) each step: 2.6e-5 of the total on the two-pipe-plenum box, 1.1e-4 with a DPF wall exchange in the Newton residual, 1.5e-4 with a multi-layer wall. _newton_step_radau now keeps its converged stages and the implicit network driver evaluates each junction's accumulate_rates at them, weighted by the Radau bᵢ (the species and fuel inventories integrate the same quadrature), so both sides of a face apply one rule. Measured: 2.6e-8 and 3.7e-8 on the box (the implicit-Euler floor 1.9e-8), 6.5e-9 on the wall and cake families, 9.3e-9 with a catalyst, 2.0e-8 with a multi-layer wall, and 7e-11 with Picard tightened; the DPF soot audit's gas gain reads 1 + 6.7e-6 where it read 1.22. This moves every radau_iia network result by the size of that seam. No bundled example, golden case, GT-Suite or Cruise M project uses radau_iia (golden 13 of 13 identical, 0 of 104 and 0 of 143 digests moved, 0 of 22 CLI runs). It does not lift the order-1 cap on a network, which needs a stage-resolved junction volume. The audit's earlier reading that radau_iia overcounted the regen mass 1.22x was never the regen fold: a closed pipe conserves it to round-off under every implicit driver.

bdf2 network runs no longer amplify what the post-Newton operators add (2026-10-07). Under rk = "bdf2" the implicit network driver runs the catalyst, DPF (soot burn: the carbon folded into the gas and the regen heat), urea, chemistry and coolant operators AFTER the Newton solve, and BDF2's history level U_{n-1} never saw their increment, so the multistep recurrence settled at 1 / (1 - 1/3) = 1.5 times what the operator added: the gas gained 1.4999x the carbon the soot ledger lost, the catalyst's gas-side heat exchange read 9.5 % high (R_exch 0.6474 against implicit Euler's 0.5914) and the cake exchange 23 % high. The driver now adds the increment to the history level too. The audits that exposed it now read: soot gas-gain error 0.4999 -> 2.9e-6; the catalyst audit's bdf2 energy imbalance 2.25e-4 -> 7.5e-12 (its old "expected, a second forward-Euler term" explanation is retracted); the cake family's 1.9e-4 -> 2.4e-11. This moves results for a bdf2 network with a post-Newton source. The 13 golden cases are bit-identical (none runs bdf2 with one: the added increment is exactly zero then), examples 40 and 58 and the Cruise M imports c02007-c02009 and c02011 do not move in a 1 s run, c02005_dpf_back_diff moves 0.4 % in density, and c02004_dpf_ash_filt changes qualitatively: with the old history its regen heat ran away (164,878 steps and 6.6e6 J/kg of gas energy by t = 1 s), with the fix 29,294 steps and 4.96e5 J/kg. About 28 bundled Cruise M imports use solver = explicit with rk = "bdf2"; the DPF and catalyst ones will give different long-run numbers. radau_iia's apparent "1.22x" in the same audit is not a regen error: it is the old radau junction seam (a closed single pipe conserves the regen mass to round-off under every implicit driver), and the audit now says so.

Backlog sweep: 380 open items triaged, the safe ones fixed, 25 stale ones closed (2026-10-07). A read-only triage classified every open BACKLOG.md item (114 fix-now, 47 would move results, 81 need a decision, 41 need outside data, 45 are strategic, 52 stale); the sweep fixed the fix-now items it could prove result-neutral, each with a test that fails without the fix (33 new test files: test_backlog_*.py in tests/unit and the GUI tests, plus the integration audits below). 13 of 13 golden cases are bit-identical to the previous main, the CLI baseline (22 runs) prints the same, the GT-Suite (104) and Cruise M (143) corpus digests are unchanged and every bundled example saves from the GUI to the same bytes. What a user can see:

  • Bad numbers are refused instead of running. The adaptive tolerances and Picard band are checked at load and before a run; a controller with inverted or NaN limits is refused at construction and a NaN error or time stops the run with a named error (it used to lock the actuator at NaN for good); a negative or NaN eps_over_D, a non-positive hybrid / driveline efficiency, capacity or wheel radius, a zero crank speed in the FMU step, a non-positive junction port area, a NaN dt in the coolant, wall and filter steps, a repeated extras name, a bad surrogate file and a false-looking string reaction flag in a mechanism database. A schedule queried exactly on a repeated knot now returns the value after the step, as numpy.interp does (no bundled schedule repeats a knot).
  • The command line. aeolus1d run restores AEOLUS_ALLOW_USER_CODE and AEOLUS_QUIET_SETTINGS afterwards (they used to stay set for the whole process); drive-cycle prints error: <file>: <reason> and exits 2 for a bad vehicle case instead of a traceback; modes --out, drive-cycle --out and auci-compare --template equal to their input are refused. acoustics gains --probe-2, --probe-out-2, --probe-spacing and --c-sound (default 343): with all of them it prints one extra line with the four-probe wave-decomposition transmission loss (the naive line is unchanged; an incomplete set exits 2).
  • Silent failures now say so. A mode-solver BC kind with no acoustic mapping, a junction port on an unknown pipe, a per-cell gamma settle that runs out of passes, a flame temperature that is only the bracket bound, an EGR rate above 1 and a cylinder above m_fuel_max warn; AEOLUS_FASTMATH=flase warns instead of silently leaving fast math on. The live-control strings "true" and "false" are read as words (they were both true). In the GUI the seven view-refresh slots log a failure once instead of swallowing it, a theme-write error no longer escapes the Apply slot, the AUCI Select step checks the selection before it takes an undo checkpoint, "Load all" with every schedule on "(skip)" says so, a reader that catches a result file mid-flush clips to the time axis, and a staged run rebases a catalyst's relative mechanism_path. The GUI help shows a unit for 94 more fields, and a ratchet makes a new numeric field choose one.
  • Smaller. save_vehicle_case_dict writes through a temporary name; the XDMF sidecar names the file in its error; apply_selections is atomic; the Radau block Jacobian uses one einsum (bit-identical to the old loop on real inputs); continuation_run restores its parameters when a step raises; the mode solver for a network reads the reference state like the CLI and GUI (Python API only).
  • New audits and one new finding. Integration tests now audit mass and energy conservation for the DPF wall, cake and soot, the SCR NH3 storage and the multi-layer wall, plus two ground-truth transmission-loss sheets and the equilibrium table's grid. They found that implicit_network with bdf2 overcounts a DPF regen's mass 1.5x (pinned as strict xfails; no bundled example is known to be hit) and that the earlier "multi-layer wall leaks energy" lead was a spike that never attached the gas side (it closes to 0.0045 J). The perf records gain CPU pressure and steal fields; the false claim that cProfile self-time is insensitive to load is corrected.
  • Documentation. Stale module docstrings, the perf-budget pin contradiction in the runner runbook, the mypy file count (1,880) and a dozen stale EPW and validation notes are corrected, and 25 stale BACKLOG.md entries moved to BACKLOG_HISTORY.md with their evidence.

Contracts for the rest of the GUI, which completes the programme: 609 functions (2026-10-06). The editor canvas and its icons (editor/), the post-processor (post/), case and layout I/O and autosave (io/), the case model (model.py), the process runners and the controller mutations now state their inputs, outputs, refusals and edge cases, each edge pinned by a test (about 880 new test functions in 35 files plus a shared canvas stand-in). CONTRACT_SCOPE is all of src/aeolus1d/ and aeolus1d_gui/, so every shipped function states its contract, and a new one needs its contract in the same commit. Two more flow charts (the editor canvas, the post-processor). src/ is untouched. Every bundled example (235 TOML files) saves from the GUI to the same bytes as before (696 saves compared). What a user can see:

  • Saving cannot lose or corrupt your work as easily. A failed Save As restores the relative csv_paths it had rebased (they used to stay pointing at the folder you tried to save into). A case file that another editor re-saved in cp1252 after you opened it no longer makes every Save, Save As and autosave fail with "codec can't decode": its [optimize] / [report] blocks are read as cp1252 with a warning that names the file. A case whose number field holds text (a lenient load of p_back = "1e5 Pa") used to be written as p_back = 1e5 Pa, replacing a loadable file with one the GUI could not reopen; Save now refuses, naming the line, before it writes anything. Deleting a cylinder from a firing order that names a subset no longer leaves a stale interval that made the saved file unloadable. "Save as" in the TOML view and the CSV export write through a temporary file, so a failure keeps the previous file.
  • Start-up and opening. A stale autosave lock with an absurd pid crashed the launch every time; so did a read-only home directory. A node at 1e9 in a layout file hung the canvas on open (it tried to build a grid of billions of points); the grid is now capped (networks within 495 units are unchanged). NaN or infinity in a layout sidecar is dropped instead of poisoning every free node when you press Organize, and Organize no longer stacks every node on one point when two pinned nodes coincide.
  • The canvas. If the connection-legality check raised during a drag, the canvas stayed in connect mode and swallowed every mouse move; a component id containing < or & rendered as markup in its tooltip; an infinite snap size produced NaN positions. A node added with a bad position no longer leaves its spec half-added to the case.
  • Results and docks. A result file with no samples, a pipe with no cells or a temperature trace that is NaN for its early samples (a resumed run) no longer raises out of the plot dock or the network view's "Fix Y" colour range. The modes dock reports a zero gas constant and a fully pinned mesh instead of raising or keeping a stale plot, and a theme flip no longer redraws an old mode over an error. A bad [optimize] block or a malformed table now opens a box naming the file or the row instead of raising out of the slot or leaving the dock half-updated. Analyzer and build-failure messages are escaped before they go into rich-text labels.
  • Runners. A finished handler that started the next run had its new process freed immediately (an uncancellable orphan), and a failed start left the process pointer set; both fixed in the solver and optimizer runners. A malformed number in the optimizer's progress output no longer raises into the Qt slot.
  • Crash. A schematic node with a NaN or infinite size segfaulted the process inside the paint handler; it is now skipped (only tests and scripts open that view today).
  • Tests no longer touch your settings. The GUI test configuration points the per-user stores (gui.conf, theme.json, recent.json, welcome_seen.json) at a throw-away directory. Before, running the GUI tests rewrote the real theme and removed the saved window layout.

Contracts for the GUI shell and main window: 597 functions (2026-10-06). Every function in aeolus1d_gui/app.py (187) and aeolus1d_gui/shell/ (395), and in vehicle_panel.py, __main__.py and qt_platform.py (15), now states its inputs, outputs, refusals and edge cases, and for a Qt slot says what it does with an exception (a dialog, the status bar, the console, a log line, or "printed by Qt, nothing shown"). 974 new test functions in 30 files (and one shared window stub) sit beside the GUI suite; CONTRACT_SCOPE grows to those paths. Two more flow charts (open, edit, save a case; the run dock). src/aeolus1d/ is untouched. Code changed only where a user could lose work or see nothing; no bundled case, saved file or menu changes. What a user can see:

  • Things that failed silently now say so. A schedule import of a non-UTF-8 or corrupt Parquet file raised inside a Qt slot and nothing appeared; it now shows "Schedule import failed" naming the file. So does a vehicle-case save onto a locked or read-only target (it also left a .tmp file), a Connections check on a csv_path that cannot be resolved, and an engine-wizard run that raises ("Run failed"). A refused engine build used to leave the old engine in place, so "Run 3 cycles" ran a stale one.
  • No more half-written or half-restored state. The theme file is written to a temporary name and renamed, so a failed write keeps the previous choice. A schedule import whose value column is unreadable leaves the schedule as it was (it wrote an empty list). Opening a vehicle case with a non-table driver or drive_cycle block names the block and no longer leaves a half-built dialog.
  • Fields keep what you did not change. Tabbing through an unset optional text field (TurboSpec.shaft, a controller id) wrote "" over "unset" and marked the case changed; the builder reads "" as a shaft id. A vehicle case saved without edits rewrote 0.7455 as 0.746. NaN and infinity can no longer be committed into a float, list, schedule or rate-table field. An integer past 2^31 made a form unbuildable; it now shows clamped. A short Brinkmeier A, Ea or dH list no longer shows another row's default.
  • Docs viewer. A link that leaves the docs tree no longer renders any .md on disk or hands any file to the system's default program (all 451 bundled relative links stay inside). A non-UTF-8 page no longer stops the dialog opening, and one such file no longer kills search. $$ and \(...\) inside code are shown literally. Names read from a Cruise M project are escaped in the AUCI dialog. An unreadable config directory no longer crashes startup.
  • Smaller: the component-list filter no longer matches the count in a heading ("Pipes (2)"); the run bar no longer shows 100 % for an inf/inf progress line; the handshake dialog colours a link by its worst species check, not the last.

Contracts for everything else in the product package: the top-level modules, the command line, the benchmarks, the mode solver, the scaffolds and the live control, 427 functions (2026-10-05). Every function outside the GUI now states its inputs, outputs, refusals and edge cases, each edge pinned by a test (751 new test functions in 39 files), so CONTRACT_SCOPE is the whole of src/aeolus1d/. 22 helpers the sweep added have their own contracts; two more flow charts are drawn (aeolus1d run from arguments to exit code, and the vehicle system). Code changed only on inputs where no valid result moves: 13 of 13 golden cases are bit-identical to the previous main, and 22 command-line runs (examples, validate, expand, handshake, analyze-cfl, modes, drive-cycle, run, sensitivity, optimize, doctor and three error cases) print the same output. Everything that would move a result is filed with file:line in BACKLOG.md. What a user can see:

  • aeolus1d run says no before it spends the run. An --out or --cgns in a directory that does not exist is refused up front; the run used to finish and then lose its result. So are an output equal to the case file, --out equal to --cgns, a negative or NaN --progress-dt or --live-flush-interval, and --trace-max-snapshots 1. With --out equal to --resume the manifest now records the digest of the checkpoint that was read; it recorded the new file's.
  • Commands that read a file and then wrote over it. audio, export, export-fmu and import refuse an output that is their own input (exit 2). modes refuses a zero or non-integer --p, --n-elements or --n-modes, and sensitivity a zero --h (it printed NaN with exit 0). warmup says FAILED when every case failed instead of "kernel cache is populated". cycle-settle no longer certifies a cycle whose work is NaN as settled.
  • Pass criteria and the validation harness. A tolerance that is a string, NaN, infinite or negative, and a report_only that is not a boolean, are refused naming the quantity (report_only = "false" was truthy, so the criterion was silently ungated; log_tol = inf could not fail). The grid-convergence index names the study when its order iteration diverges (a bare OverflowError) and warns when it does not converge in 100 steps.
  • Audio export. NaN or infinite samples, a zero or NaN sample rate or duration, a probe position outside [0, 1] or with no pipe name, and a trace with decreasing time are refused naming the probe spec or file; a NaN waveform reports NaN, not "silent".
  • Optimiser, sensitivity, DOE, UQ. Reversed or NaN bounds, a start point outside its bounds, a typo in a parameter path (init-p was read as init.p), a typo in a dict key (it created the key), and a negative probe weight (the optimiser then maximised the error) are refused at set-up, not after a full run. write_optimize_block no longer deletes a table named [optimizer]. DOE refuses NaN bounds and an empty factor; UQ refuses a bad quantile before it evaluates and an evaluator that changes its output length; the homotopy schedule refuses an unknown name and NaN endpoints; engine post-processing refuses gamma <= 1 and mismatched series lengths.
  • Vehicle, hybrid and drive-cycle models. A NaN no longer turns into a number: a NaN battery request gave a full battery, a NaN clutch gave a fully engaged clutch, a NaN driver error poisoned the integrator for the rest of the run, a NaN cruise speed held the previous speed. Drive cycles refuse an infinite end time, and a bad CSV cell names the file and line. A gear that does not exist (1.7, or -2) is refused instead of truncated or wrong.
  • Benchmarks, mode solver, scaffolds, live control. sample_exact refuses t <= 0; a smooth-pulse run with dt <= 0 is refused (the drivers read it as "step by CFL" and a temporal-order study measured something else); the nozzle Mach inversion refuses a branch name that is not subsonic or supersonic; the mode solver refuses a non-positive rho, A or c and an unknown boundary type (it was silently Neumann). The engine scaffold refuses a compression ratio of 1 or less and the sizing wizards refuse NaN. Live control ignores a non-positive, infinite or NaN CFL override, and refuses poll_every_n < 1.
  • Documentation that was wrong. electrical_kwh in the electrified drive cycle is gross battery discharge (regen is counted separately), not net.

Contracts for case loading, the importers, the analysis tools and the controllers: 1,151 functions (2026-10-01). Every function in io/ (906), analysis/ (174) and control/ (71) now states its inputs, outputs, refusals and edge cases, each edge pinned by a test (2,056 new test functions), and the three packages joined CONTRACT_SCOPE. The three flow charts that were still planned are drawn (case load → build → run → write, building the network, resume), so every chart in the plan now exists. 150 existing functions had their code changed and 31 were added, all on inputs where no valid result moves: 13 of 13 golden cases are bit-identical to the previous main, and the Cruise M (143 projects) and GT-Suite (104 files) importers give the same digest on the whole local corpus. Everything that could move a result is filed with file:line in BACKLOG.md. What a user can see:

  • A case that cannot run now says so, and where. A string where a number belongs ([pipe.init] p = "1e5", a schedule entry "a") is named by the validator instead of ending in a traceback; a non-UTF-8 case file, and any error raised while parsing a [[section]] entry, name the file and the entry. An unknown limiter used to run as minmod, an unknown solver as explicit, an unknown EOS kind as an ideal gas, and an unknown mode-init end name took a default; all are refused. So are a negative pipe diameter (it built the same pipe as the positive one), a junction volume of zero or NaN, an empty regional init, a negative minor-loss coefficient (a gain), a second boundary condition on one pipe end (even with validation off), a NaN t_end (a "successful" run that did nothing), a non-finite cfl or chemistry tolerance, and a header-only or NaN boundary-condition CSV. A [[case_upgrade]] step that does not advance the version looped forever; it is refused.
  • Running a second time starts where the first started. run_electrified_case drained the case's battery, so a second run of the same case began at the first run's final state of charge; it now works on a copy.
  • Resume and the writers. A missing, truncated or non-HDF5 resume file ended in a traceback; it is a ResumeError naming the file, as is a junction checkpoint missing a field or a NaN t_final. The manifest and the XDMF sidecar are written to <name>.partial and renamed, so a failure keeps the earlier file. (A fresh HDF5 result is still written in place: see BACKLOG.md.)
  • Importers. A hostile .proj or .gtm could write XML outside the extraction folder through its object id (an absolute path worked); ids are now slugified. FMU export no longer overwrites a neighbouring AeolusSlave.fmu, and a file name with quotes in it can no longer break or inject into the generated slave. The species database refuses a NaN, zero or g/mol-sized molar mass and says when it cannot save. Cruise M import skips an empty or non-finite number instead of passing NaN on, and a gas link with a blank endpoint no longer nominates an inlet or outlet. GT-Suite, BTC and .ucp readers name the file, line and key of a bad value, refuse NaN and inf, and a directory named x.ucp no longer crashes the loader. btc_to_csv refuses an unusable PR axis before it opens (and so before it can destroy) the output file.
  • Analysis tools stop saying "fine" when nothing was measured. An energy audit with no fuel term printed 1e-30 percentages and a CLOSES verdict; it says NOT FORMABLE. The CFL inspector reported a healthy time step for a blown-up pipe (non-positive density or pressure); it refuses it, naming the pipe and cell. A NaN in the captured engine state left the core-zone G1 verdict with no failure, so it passed; it fails. A reversed time axis was silently re-sorted into a different NOx trajectory; it is refused. The vehicle tasks coastdown, full_load_acceleration and elasticity looped forever on dt_s <= 0. Linearisation restores the network when a probe fails instead of leaving it on the perturbed state; the acoustics functions return NaN or empty for a NaN sample rate; the drive-cycle report refuses a mis-spelled fuel_rate_kg_s instead of reading it as zero fuel.
  • Controllers. A NaN air mass lifted the smoke limit entirely and a NaN IMEP gave full fuel; both are refused. SpeedGovernor with droop = 0 and ki = 0 returned zero fuel at every speed and is refused. The torque-tracking loop went deaf once its accumulator started dropping old cycles. A step in a rate limiter's wrong direction, an inverted relay or saturation, and a NaN step response each fail with a name.
  • Documentation that was wrong. The energy-audit residual sign (positive means energy vanished, not appeared), the btc_to_csv claim that inverse_pr is "cosmetic only" (it trims each line to its compressor stable branch), and the cfl help text (exactly 1 is allowed) are corrected.

Contracts for the rest of the core: closures, components, chemistry and boundary conditions, 854 functions (2026-09-30). Every function in closures/ (378), components/ (279), chemistry/ (150) and bc/ (47) now states its inputs, outputs, refusals and edge cases, each edge pinned by a test (1,826 new test functions); with batch 1 the whole solver core is covered and MAX_WITHOUT_CONTRACT is 0. Five more flow charts (junctions and plenums, cylinders and combustion, catalysts / filters / surface chemistry, gas-phase chemistry, boundary conditions). Writing the contracts was a sweep: 126 functions had their code changed and seven were added, all on inputs where no valid result moves (13 of 13 golden cases are bit-identical to main, cold cache, AEOLUS_FASTMATH=0; the unit coverage floor goes 81 → 85). Everything that could move a result was left alone and is filed with file:line in BACKLOG.md. What a user can see:

  • A wrong input now stops the build with a reason, where it used to run. A negative A_max on the EGR valve or the throttle reversed the flow; a non-physical stagnation, outlet, mass-flow, velocity or valve boundary, an unknown ISO 5167 tapping, a lift table of unequal length, and a species schedule with a lone Y_t or unequal lengths are refused at build. A neck on a pipe it cannot match (real gas, per-cell or mixture gamma, low Mach, another gamma) used to build and leak mass and energy through its shared face (0.4 % to 5 % measured); it is refused. A cylinder with a bore, stroke or clearance of zero, a connecting rod shorter than the crank radius, or species_init outside [0, 1] is refused; so is a turbo map with a non-positive reference state, a coolant pipe with no size, and a pipe state with a non-positive density or pressure.
  • A NaN no longer reads as something healthy. In a compiled real-gas cell a NaN density or energy came back as 50 K (a diverged cell looked cold); a NaN housing heat load returned ambient, a NaN step set the turbo housing to 1 K, a NaN downstream pressure closed a valve, a NaN lift read as full lift, and one NaN query permanently poisoned an ISAT table. Each is now NaN or an error.
  • Importers. CHEMKIN reads Fortran D exponents in LOW/TROE/SRI/ PLOG/REV lines, and a coefficient like 2.0D0 (or 1.0E0) is no longer read as species D0 / E0; items it drops now warn. The Cantera importer skips a species or reaction it cannot read, with a warning, instead of inventing a midpoint switch or a zero Troe parameter, and the NASA-7 importer refuses a blank coefficient and a fractional element count. The turbo CSV importer names a byte-order mark, a blank cell, a duplicated point and a partly filled vane column. The species and mechanism databases refuse a name like ../x and write atomically.
  • Two files that did not exist. save_turbo_map_npz and both learned-model save_npz returned the name they were given, but numpy appends .npz; they now return the file they wrote.
  • A hang. An injector packet duration below the floating-point spacing at the current time never left the sub-step loop; it is refused.
  • User-supplied closures. A module that raises while importing, or a closure returning strings, a ragged list or a complex value, now fails as a UserCodeError naming the closure.
  • Documentation that was wrong. HelmholtzResonator.advance claimed stability for dt < 1/(2 π f_H); forward Euler on an undamped oscillator is unstable at any step (100 Pa became 251 Pa in one period). The premise that the njit PLOG scan returns 0.0 on an unsorted table was wrong (it returns the wrong rows' rate) and is retracted where it stood.

Solver contracts, batch 1: 508 functions, about 40 defects fixed (2026-09-29). Every function in solver/ now states its inputs (units, ranges), outputs, refusals and edge cases, each edge pinned by a test (docs/contracts.md); flow charts for the pipe step, the explicit and implicit network steps, the quasi-steady drivers and the chemistry split are under Flow charts. No bundled example's result moves, and the unit coverage floor goes 79 → 81 (81.09 % on pipeline 2895690659, which failed only the slack watchdog). What a user can see:

  • Runs that used to hang or quietly accept a bad step now stop with the reason: a single-pipe implicit run whose error control reached the cfl_min floor retried the same step up to max_steps times; a zero adaptive_atol made every step pass; an adaptive Picard band starting at 0 advanced time without a Newton pass; a dead state was not checked under dt_fixed; chem_substeps < 1, an unknown limiter code, a urea x_frac outside [0, 1] and a real-gas QS density outside its search range are refused.
  • Right answers where they were wrong: a run reaching t_end on its last allowed step no longer reports "max_steps exhausted"; stopping a run from the GUI mid chemistry super-cycle no longer drops the pending chemistry; Strang half-steps see the right time for transient boundaries; a filter drag of the wrong length is refused instead of dropped; the BS-Brinkmeier chemistry no longer commits small negative mass fractions; choosing bs_brinkmeier no longer skips other chemistry on a pipe; a failed BS step leaves the state as it was; a catalyst kind = "mechanism" on the explicit or quasi-steady driver is refused (it reacted nothing).
  • Error messages name the pipe (reverse flow, urea, a dead residual state) instead of '?'.

Twelve leftovers from the review sweeps fixed (2026-09-29).

  • Files that would not reopen now do. Switching a pipe's start state to regional seeds one region (and the last one cannot be removed); a controller switched to a kind whose pipe is not chosen yet saves and reopens, and validation names what is missing before a run.
  • A relative mechanism_file is found next to the case file, as the documentation always said. A path that only works from the working directory still loads, with a warning; that fallback will go. The run manifest hashes the file that was actually used, and a GUI run finds it, and the turbo-map files, from its staging folder.
  • Resume keeps controllers where they were. The EGR valve opening, DPF heater power, injected fuel, spark timing and DOC sulfur state, and each loop's last error, are saved and restored; before, they restarted from the case's initial values. Older files still resume and say so.
  • Cruise M: each inlet gets its own gas composition. Before, every inlet took the first one in the file (C06106's three mixer streams all came in as O2/N2; C06125's compressor inlet came in as the fuel's pure H2). 12 of 143 projects change, only in inlet composition or report text.
  • A negative-pressure cell stops the run with its name instead of being skipped in the time-step choice; a NaN energy on a tabulated-EOS pipe reads as NaN, not as a cold cell. mz_tzone_mode = 1 is refused (it had no code path and ran mode 2).
  • GUI: View -> Icon Style; a pipe with a multi-layer wall, a coolant link and a heater now shows all three rims; saved controller blocks carry only their own kind's keys.

Knock integral on the end gas by default; thermodynamic [O] in the Zeldovich NOx rate (2026-09-29). Two modelling decisions that move results:

  • The knock integral now runs through the burn on a two-zone cylinder. Cylinder.knock_through_burn defaults to on, so the Livengood-Wu integral follows the unburned end gas until the burn ends and falls when the spark is retarded; on the old onset window it rose with retard, and the default 2 deg/event retard pushed the wrong way. A single-zone cylinder is unchanged: it has no end-gas temperature, so its integral still stops at the spark. examples/71_ktm_510_si.toml now knocks (peak integral 1.70, was 0.0015) and retards 4 deg in its two cycles. The KTM OEM sweep gate reads 6/10 (was 7/10, floor 5/10).
  • Zeldovich NOx: [O] from NASA-7, k1 from Heywood. The ½O₂ ⇌ O constant gave [O] at 0.36-0.50x its thermodynamic value, which a k1 ~2.4x Heywood's had been hiding. Both defaults changed (in make_zeldovich_nox and make_zeldovich_nox_extended, so in analysis.nox_post too). The thermal rate is now 1.02-1.12x Heywood eq. 11.1 (was 0.69-0.91x). The N₂O channel is linear in [O] and had no compensating partner, so it is now 2.3-3.1x faster than before, and the total NO rate rises most in lean, cool burnt gas. On the EPW suite's three ops (report-only NOx) the compared core-zone value moves 873 / 602 / 1226 to 893 / 621 / 1229 ppm.

Three release items closed: long functions, backlog rules, typed tests (2026-09-28). Mostly internal (docs/release-readiness.md EC-C3, EC-C5, EC-C12), and no bundled example's result moves. What a user can see:

  • Case building says more. A pipe closure left at the zero defaults that make it do nothing now warns: a constant_twall wall with h or T_wall at 0 (a 0 K wall), a cooler with UA = 0, a darcy_forchheimer medium with no alpha or beta, a two_phase_heat catalyst with no rho_s_cs. These are refused instead: minor_loss friction with no L_loss (it ran frictionless), an unknown nu_correlation (it ran Hawthorn), a DPF frac_fast outside [0, 1]. Building with validation off no longer fails with KeyError('catalyst') on a two-phase catalyst.
  • GT-Suite import reports are honest about defaults. A pipe, junction, shaft or cylinder that falls back to a default length, volume, inertia or geometry is now named in the report. A wastegate controller whose turbine was dropped is dropped with it. The charge-air cooler message says the table's effectiveness is read but not applied (it never was). No imported model's numbers change.
  • Every test file is type-checked. mypy covers 1,611 files with no exclusions; the last 195 test files turned up several tests that could not fail, now fixed.

Pipe temperature is saved in the result file (2026-09-28). The file now holds each pipe's gas temperature as the solver holds it (pipes/<id>/T, and time_series/pipes/<id>/T with --trace-dt), and the GUI plots it. Before, the GUI worked T out as p/(ρR), which is wrong on a real-gas pipe (293.6 K shown for 300.1 K on examples/19) and on a multi-species pipe whose gas constant varies. Older files still open, with the old estimate. The start-state preview uses a real-gas pipe's equation of state too (Redlich-Kwong, Peng-Robinson; a tabulated EOS still previews as ideal gas). A kind = "mode" start state on a real-gas pipe is now refused: it seeded an ideal-gas state the EOS read as another temperature.

Inspector and menus (2026-09-28).

  • The pipe's Aftertreatment shortcut and the full sections below it now always agree: changing either updates the other.
  • An optional block that is set can be switched off again: a "" choice (e.g. area_profile back to a constant section), or unticking "enable" (ticking it again brings the values back).
  • New Tools menu: PID Tuning Playground… and Engine Wizard…. Both dialogs existed but nothing opened them.

Cases that loaded but ran something else now say so (2026-09-25). A sweep of 100 functions found 45 places where a case, or an imported project, ran differently from what it said, without a word. All are fixed. What a user sees:

  • Case files. These used to load and quietly mean something else; they are now refused, with the reason:
    • [pipe] written as one table (instead of [[pipe]]), a nameless [[species]], kind = "regional" with no regions;
    • a count written as 2.7 or true;
    • a typo in cd_tapping or in a [crankshaft] key, [[crankshaft]], or a cylinder listed twice in firing_order;
    • [[cylinder.injectors]] (the key is injector), or combustion = "wiebe" instead of a [cylinder.combustion] table;
    • CSV columns on a boundary kind that does not read them;
    • a closure block carrying another kind's fields ([pipe.porous] alpha = ... with no kind);
    • an area profile carrying another profile kind's keys.
  • Build. Also refused now:
    • cv_poly on an ideal-gas pipe (it did nothing);
    • a live wall on a pipe that already has a heat model (it replaced it), and coupled-wall fields the coupled wall ignores;
    • a throttle without p0/T0 (it was 1 bar, 298 K);
    • a biased plenum without a supply temperature (it was 300 K), or with a uniformity index outside (0, 1].

Some behaviour changed instead: * Pipe and junction initial states that give both T and rho now follow the cylinder rule: T wins, and a disagreement is a warning. * The mode-shape initial condition is isentropic, as documented. * The plenum wall law now sees a speed set only in [crankshaft], and fuel set in injector blocks. * Cruise M import. * Each boundary's RamFlow is read. An enabled one is named, and its pipe flagged for a manual p0, instead of being skipped. * Each catalyst brick is routed by its own mechanism name. * A volumetric inlet reads its own flow, not a coolant node's. * A 3-D solid element is reported instead of vanishing. * Validation. Two more checks: duplicate junction ids, and duplicate [[species]] names.

A second sweep of 100 functions (2026-09-25) — the cylinder, turbo, junction and validator code — found 23 more, all fixed:

  • Engines.
    • An injector rate table that did not end at zero kept injecting after its window: 36× the fuel for a simple rectangular table.
    • A fuel mass with no injection timing injected nothing, silently; it is now refused. The GT-Suite importer, which produced it, now puts that fuel into the Wiebe burn, the same energy as before.
    • Valve windows given past 720° or below 0° now open for the right span.
    • A spark or start of injection near 700° no longer loses its burn at the 720°→0° wrap.
  • Turbochargers.
    • eta_mech now takes its loss from the turbine's power, so it moves the shaft balance. examples/83 spins slightly slower.
    • Gas radiation in the housing can no longer push the gas past the wall temperature at low flow.
    • A turbo housing is refused without a shaft, and its temperature now survives a resume.
  • Junctions.
    • A neck on a MUSCL pipe no longer loses mass.
    • Real-gas and varying-γ pipes are read correctly at plenum ports and custom-BC ports.
    • LODI ports are refused on real-gas pipes.
  • Validation. It now catches:
    • a mass-flow inlet with no temperature (it ran with zero flow);
    • typos inside combustion stages;
    • bar written as Pa in shock-tube and cylinder start states;
    • out-of-range shaft inputs.

A third sweep of 100 functions (2026-09-26) — combustion models, engine metrics, NOx post-processing, the surface mechanism and the wall boundary — found 18; 17 are fixed, one filed:

  • Engine metrics. A predictive-diesel cylinder now reports its indicated efficiency and fuel consumption (both read 0, for example on examples/88, in aeolus1d cycle-settle and the GUI wizard).
  • NOx post-processing. A genuinely zero result (no oxygen, or a zero calibration multiplier) is no longer replaced by an equilibrium estimate of thousands of ppm. The "dry ppm" helper was wet; it now says so and takes the water fraction to convert.
  • Combustion inputs. Zero or negative burn durations, negative Wiebe stage fractions, an efficiency above 1, and an injection profile that does not carry the stated fuel mass are refused. A negative start of injection is the same angle in the cycle (it lost 12 % of the fuel). The Chmela law no longer burns during the ignition delay.
  • Surface mechanisms. orders = {} means zero order; a misspelt or out-of-range theta_init, or a site capacity of 0, is refused.
  • A closed pipe with fewer cells than ghost layers no longer reads past its far end.

No bundled example's result moves.

A fourth sweep of 100 functions (2026-09-26) — pipe boundary conditions, surface and SCR chemistry, friction, pollutants — found 23; 21 are fixed, two filed:

  • Backflow at an inlet. Gas leaving a pipe through a stagnation inlet (also transient_stagnation_inlet and valve_inlet) was treated as a lossless diffuser, so any backflow sped itself up: a pipe with the same pressure at both ends went from −5 to −246 m/s. The end now discharges into the reservoir at its pressure. This moves most imported Cruise M engines, whose pressure boundaries are such inlets with gas leaving through them, and the turbo examples at start-up. Runs where gas only ever flows in are unchanged to the last bit.
  • Throttle and EGR valve. A partly open valve barely restricted the flow: 2 % open passed a third of the full-open flow. They now pass their own orifice formula (checked to 1.00). This moves examples/62, 72 and the Cruise M throttle and EGR imports.
  • SCR. A nh3_slip_catalyst brick ran the Cu-zeolite constants, not its V2O5 ones (examples/45), and the surface steps counted the adsorption heat twice (20 % too much heat). An HNCO pipe that left out its molar mass took ammonia's.
  • CO oxidation by OH is 10–16× faster again: a 2026 "fix" to its rate's sign was wrong and is retracted (see below).
  • Refused instead of run wrong:
  • a friction law missing a field (Colebrook without nu had no friction);
  • a darcy_forchheimer_kc block without K (no pressure drop);
  • a transient inlet without its T0 (or p0) schedule;
  • a non-reflecting outlet without p_inf;
  • cd_reynolds without mu_gas;
  • a bend outside R/D ≥ 0.5 and 180°;
  • a surface mechanism naming an undeclared species, or using a third-body, falloff or PLOG reaction.

68 of 216 bundled examples move; each was traced to one of these. The Zeldovich O-atom constant is about 2.4× low but paired with a fast k1; fixing it needs an NOx recalibration, so it is filed in BACKLOG.md.

The last sweep: every remaining function (2026-09-27) — 563 functions, which leaves none unreviewed in the review ledger. 133 had a defect; all fixed except four filed in BACKLOG.md. What a user sees:

  • The network view's colours work. They were stuck on the final density whatever variable or time you picked, and cylinders never coloured at all.
  • The inspector no longer changes numbers by itself. A float field rounded to 6 digits when you tabbed through it, scrolling over a form changed values, an empty optional field wrote 0, and "Load all schedules" was undone by the old CSV reference on save.
  • The run panel shows progress (the bar never matched the solver's output), a trace interval of 0 is refused up front, and Resume no longer rewrites your CFL.
  • Optimizer and sensitivity show the solver's error text, keep a stopped run's best point, and catch NaN results instead of ranking them.
  • Mode frequencies match the command line in the plot and modes docks, crank-angle axes work for cylinders, and species plots show their unit.
  • Case files are checked harder: a vehicle case with zero mass or a negative wheel radius, a reference CSV with nan, and adaptive_cfl_min above adaptive_cfl_max are refused with a message.

A sweep of 512 GUI functions (2026-09-27) — the editor, its case model, dialogs and the TOML writer. 79 defects, all fixed, plus more found beside them. What a user sees:

  • Undo keeps your run. Ctrl+Z used to drop the loaded result, blank the plots and lose a running job's result. Undo also no longer drops the red warning outlines.
  • Saving no longer breaks the file. Windows paths, a " in an id, an engine's firing order, an automatically chosen ignition mode, an empty Riemann side, a relative CSV path saved to another folder and a comment on an [optimize] header all used to save a case that would not reopen, or reopened as something else.
  • The checker catches more before the run. A number written as text, gamma = 1.0 and a zero plenum volume now give a clear message instead of a traceback or a divide-by-zero mid-run.
  • Opening and recovering is safer. A crash-restored session is unsaved work (it used to open clean and be lost on close); the guided tour and a command-line case ask before discarding edits; Save As appends .toml instead of overwriting name.toml for name.1.
  • The canvas behaves. Boundary conditions keep their position after a reconnect; detail level follows zoom; dropping on a scaled screen lands under the cursor; the crank-angle axis works for cases that set only [crankshaft] rpm.

DD13 validation retuned, and a sweep of 100 more functions (2026-09-27).

  • DD13 diesel validation passes again (9/9). Its analysis inverted the rig's pressure with γ = 1.30 while the rig runs at 1.34 (about 3° of early CA50). The burn calibration had been tuned against that and the earlier smoothing bias, and is retuned: diffusion growth 0.22 → 0.12 per mg, combustion efficiency 0.97 → 0.95.
  • The Helmholtz benchmark was set up wrong. Its neck was closed at the far end, which is not a Helmholtz resonator. Opened, the 1-D neck + 0-D volume rings at the textbook frequency to 0.26 %. The long-standing "cannot be a Helmholtz resonator" note is retracted.
  • Cruise M imports are repeatable. A catalogue with two CO-NO redox reactions took one or the other at random; the first is used and named.
  • Your species molar-mass file is safe. A file with a typo was read as empty and then overwritten; it is now refused, and writes are atomic.
  • A mistyped --log-level is refused (it became INFO).
  • Benchmarks: the friction benches run to steady state and use the solver's own friction factor; every runner accepts MP5.

A sweep of 500 more functions (2026-09-27) — importers and exporters (GT-Suite, Cruise M, OpenWAM, Cantera, CHEMKIN, Burcat, FMU), the CLI, resume and tracing, validation, scaffolds — found 109; all are fixed, with nine more found next to them. Four gaps are filed:

  • GT-Suite import.
  • Turbo maps were read transposed and in the wrong units: GT's reduced speed was taken as rad/s and its reduced flow as kg/s, so the F2 compressor map came out about 12x short on flow. The map now matches GT's own rendered maps and its F2 operating point (0.443 kg/s for GT's 0.439). A part's map multipliers are applied.
  • The active case is the first one not switched off. It was case 0, so the F2 model ran at 600 rpm instead of 1200.
  • A turbo whose map file is missing now says so.
  • Chemistry files.
  • A Cantera file without an explicit activation-energy unit is read in Cantera's default (J/kmol in SI files); it was cal/mol, 4184x too high.
  • Cantera skips are visible warnings. Explicit reaction orders are refused, as in CHEMKIN.
  • CHEMKIN keeps OH* and applies each REACTIONS section's own units.
  • Burcat, NASA-7 and JANAF thermo readers keep ion charges, read the fifth element, and fit without losing precision.
  • Cruise M import.
  • Pipes, plenums, restrictions and heat exchangers read their own geometry. They could take a neighbour's: the MFR pipe took the TWC brick's size, and a 387.5 L plenum became 1 L.
  • Diameters kept in the diameter selector are read, where they fell to 50 mm (C02015's pipes are 150-300 mm).
  • DOC kinetics no longer shift when a parameter is FMU-exposed. Signal-driven urea dosers import under their dosing controller or say why not. A filter brick that borrows a neighbour's friction says so.
  • Resume and output.
  • A resumed trace keeps every series as long as its time axis. The last sample and the resume time are kept.
  • The CGNS writer works: it imported a module name that does not exist.
  • XDMF sidecars point back at their HDF5 from any folder and carry the species.
  • Turbos keep their case ids in the HDF5.
  • Command line.
  • aeolus1d validate runs the same pre-run check as run.
  • cycle-settle runs the case's own solver.
  • bench smooth runs.
  • modes uses the pipe's real temperature.
  • expand refuses a [case] typo instead of deleting it.
  • Case files.
  • Refused now: NaN port and turbo areas, a Riemann side with negative pressure, a port end typo, a quoted "false", and NaN schedule times.
  • TOML written by the GUI, expand and the vehicle panel escapes every control character.
  • The species-order Fix button keeps each inlet's schedule with its species.

A sweep of 502 more functions (2026-09-26) — thermo data, catalyst and surrogate closures, controllers, acoustics and CFL tools, the energy audit, linearisation, vehicle and hybrid models, the optimizer — found 79; all are fixed, with eight more found next to them (two filed):

  • Thermo and catalyst data.
  • The bundled H2 thermo data had two ×10 slips (its entropy read 155 for 130.7 J/(mol·K)); it is corrected.
  • Heats imported from an AVL .ucp catalogue were 1000× too large (the file is in J/kmol) and not per mole of the right reactant. examples/74 and the heliyon TWC case are corrected.
  • The TWC propene heat is its lower heating value. The Brinkmeier NO + hydrocarbon heats follow their own reactions.
  • Real-gas pipes start at the temperature you state. A stated 300 K started at 335.6 K on examples/09.
  • Controllers.
  • The spark-timing controller acts once per engine cycle and needs the two-zone model: examples/63 now sets two_zone = true.
  • Fuel controllers read the injectors, and refuse a cylinder without one or a probe pipe without the species.
  • Boost and wastegate control read the real-gas pressure.
  • PID auto-tune has PI rows (form = "pi"); the GUI playground uses them.
  • Coolant jackets under the quasi-steady solver no longer diverge (they reached 10²⁰ K). A jacket wall with no mass is refused.
  • Tools.
  • aeolus1d analyze-cfl matches the solver's time step, including neck pipes and real gas.
  • The acoustics CLI's octave bands add up to the printed SPL (they were +1.76 dB).
  • The turbo-map dock draws VGT and IGV maps.
  • The four-microphone TL takes the duct area ratio.
  • The analytic linearisation includes heat and friction.
  • The energy audit refuses terms it cannot measure and now counts turbo casing heat.
  • Vehicle and powertrain.
  • Driveline losses depend on which way the power flows, so regen is no longer 18 % optimistic.
  • The brake stops the car rather than reversing it.
  • An e-motor gives torque at standstill: motor_only on examples/86 now moves.
  • The FMU export turns its crank.
  • Optimizer. It refuses a typo in a parameter path, keeps partial bounds, writes valid TOML from the GUI, and rejects unknown [optimize] keys.

Examples 09, 17, 18, 19 and 90 (real-gas start state) and c02024 (propene heat) move. Filed for a decision: the knock integral's default window (it stops at the spark, so the built-in knock retard is inverted; making the through-the-burn window the default moves the KTM case).

A sweep of 200 more functions (2026-09-26) — gas models, heat transfer, the particulate filter, chemistry and the port-fuel injector — found 21. All are fixed:

  • Particulate filter.
    • filtration_efficiency is the whole filter's capture, on any mesh. It was applied per cell, so a finer mesh trapped more (0.74 at 2 cells, 0.9987 at 10, for 0.95).
    • The cake back-pressure and the regen heater count the fast soot fraction too; at frac_fast = 1 there was no cake drag and the heater never fired.
    • With regen_hrr_clamp, heat and soot burn are clamped together.
    • CSF oxidation releases heat only for the CO and HC actually burned.
    • The burned carbon no longer cools the gas, and a burned-out cell splits its carbon between O2 and NO2 like the others.
    • per_cell_gamma on a filter pipe is refused (its regeneration runs on one γ).
  • Gas models and heat.
    • A Redlich-Kwong or Peng-Robinson block without Tc or Pc is refused when read (it crashed later with a division by zero).
    • The tabulated gas model's sound speed below the table's density no longer grows without bound.
    • A flow_nu heat block without k_gas, mu_gas or Pr, and a dynamic wall without its mass, area or a layer thickness, are refused; they ran with no heat transfer, an absurd one, or a wall temperature of infinity.
  • Chemistry.
    • A CHEMKIN file with its PLOG rows in descending pressure is read in order (a rate was 18-100× off).
    • Surface species in a gas-phase mechanism, and species with no molecular weight, are refused.
    • A_boost speeds up every rate form (falloff, PLOG, explicit reverse), not only the plain Arrhenius constant.

Results move for the filter examples (examples/41, 43, 49, 50, 53, 55, 56, 76).

A sweep of 500 functions (2026-09-25) — solvers, pipes, junctions, turbos, chemistry and cylinders — found 62 more. All are fixed:

  • Start states and outputs.
    • A real-gas pipe now starts at the pressure and temperature it asks for (examples/19 started at 7.5 bar for 10), and a per-cell-γ pipe too (examples/87 started 6.7 % high).
    • The HDF5 pressure, wall and coolant temperatures now decode each cell the way the solver does. They used to hide the error above.
    • A cylinder that switches to caloric thermo on its first step keeps its temperature (a 1000 K charge dropped to 943 K).
  • Junctions.
    • The constant-pressure (Corberán) junction no longer creates mass: +0.8 % to +19 % in 8 ms before, 8e-6 now.
    • Pass-through joints layer their MUSCL ghosts cell by cell and re-encode across a change of gas model.
    • A merge in the quasi-steady driver mixes its feeders' temperatures instead of taking the first one's.
  • Turbochargers.
    • Every species crosses a turbo, not only the fuel.
    • Off-design gas corrects the mass flow with its own factor (γ enters the other way up from the speed correction).
    • A map with efficiencies in percent, NaN entries or negative flow is refused; so is a CSV map with no efficiency column, and a real-gas pipe on a turbo.
  • Solvers.
    • A network runs the Runge-Kutta scheme the case names, or refuses it (rk4 became ssp_rk2 silently).
    • Per-cell-γ pipes size their time step with each cell's sound speed.
    • Implicit Euler and BDF2 read transient boundaries at the right time after a step halving and in step doubling.
    • The adaptive Picard budget can grow again after shrinking to 1.
    • Real-gas pipes in a dense state are no longer refused every Newton step.
    • The analytic linearisation no longer reuses a boundary chain left by an earlier implicit run (A was 20 % off).
  • Chemistry and aftertreatment.
    • The bs_brinkmeier step uses the same gas constant as the rest of the Brinkmeier model (rates were 0.1–0.2 % off).
    • A surface mechanism whose species are listed in another order than the pipe's columns is refused; the rates used to land in the wrong columns.
    • A stopped urea dose stops cooling the gas in quasi-steady runs, and a multi-cell spray cools with the dose once, not once per cell.
    • darcy_forchheimer_kc porous drag reaches the quasi-steady solver (it had zero drag there).
    • hnco_frac > 0 without HNCO tracked is refused (the share was dropped).
  • Diagnostics.
    • The knock integral is gated for predictive SI and diesel burns; it used to fire every cycle on post-flame temperature.
    • The startup banner lists only the settings the chosen driver reads.
    • Newton and stiff-chemistry failure messages name the right pipe, the right cell's counts and the TOML knob to change.
  • Refused now: hllc_low_mach with per-cell γ, a neck port under the quasi-steady driver, chem_fixed_dt <= 0.

Results move for real-gas, per-cell-γ and caloric-cylinder cases (their start state was wrong), for engines on the synthetic turbine map (examples/38, 39, 60, 89: the map no longer passes flow at a pressure ratio of 1), and for a turbo running on non-air gas. Three golden snapshots were refreshed for these, each traced to its commit.

Cruise M reactions switched off in Cruise M are switched off here (2026-09-25). The importer used to report a switched-off reaction and run it anyway. Now each catalyst brick follows its own Cruise M model: a reaction switched off there gets a zero rate constant in aeolus1d, and the import report says, per pipe, what was switched off and what the closure does not model. A catalyst brick of Cruise M's generic kind now takes its chemistry from its own model's name, not from the project's last-edited one: C02013's SCR brick imported as a three-way catalyst until now. Two "ASC Cu" ammonia-slip bricks now import without kinetics, flagged, since no aeolus1d closure fits them yet.

Known bugs, batch 1 (2026-09-24). The open defects with a known cause and no modelling decision to make, fixed together. What a user sees:

  • Cruise M import. Each catalyst brick now takes its own cell geometry and its own heat/mass transfer settings, and each mass-flow inlet its own flow and temperature schedule, instead of the first one in the file. On the 143 local test projects: 7, 15 and 6 projects import differently; C02017's inlet was imported at the outlet's 298 K where its own boundary says 423 K. A two-hop boundary on a port such as EAS.Outlet lands on the right pipe end (6 projects). Junctions come out in file order, so importing a file twice gives the same case (14 projects moved once). A pressure schedule sets the starting pressure even without an inlet temperature (2 projects). An unreadable .ucp next to the .proj is reported instead of being named as the source. Cruise M on/off switches are read: a switched-off catalyst model's constants are no longer imported, and switched-off or data-bus reactions are named in the report (and in aeolus1d auci-compare).
  • Other importers. GT-Power SIF, AVL BOOST and Ricardo WAVE files that leave out a length, diameter, friction, volume, port area or boundary pressure now say which default was used. Values are unchanged.
  • Cases that now stop or warn instead of running wrong. A negative or NaN rpm / omega_crank is refused. A [cylinder.init] whose p, T and rho disagree by more than 2 % warns. A rho that gives an absurd temperature is refused. A loop-flow network with no pressure-driven pipe raises instead of returning every junction at the reference pressure. A negative intake-open angle no longer breaks the scaffold's injector.
  • Things that are parsed but do nothing now say so. filter_density warns that the solver does not use it. kind = "multizone_diesel" warns that the cylinder runs with no combustion: the model is not wired (examples/89_dd13_multizone.toml burns 0.0 kg). Both are open decisions in BACKLOG.md.
  • More that now stops or says so. A DPF whose pm_species_name is not a tracked extra species is refused (the trap caught nothing); examples/84_dpf_substrate_inertia.toml named its primary species and drops the line, running as before. A [cylinder.init] without p and one of T / rho is refused (it started at 1 atm / 300 K). An implicit single-pipe case refuses a CFL ramp it would have ignored. The startup banner names the driver the case actually runs on. The network energy audit works on a tapered pipe. A Cruise M network with inlets at different temperatures starts from one inlet's T0 and ṁ, and says which.
  • GUI. The bundled example cases cannot be saved over: Save on one opens Save As.
  • The energy-audit message names the pipe instead of <pipe>.

A connection handshake before every run (2026-09-24). Once a case is built, every gas connection is checked for what each side carries: species by name, and the gas model (gamma, R, equation of state). A connection where a species is dropped or builds up, or where the two sides read the handed-over state with different gas models, is printed before the run starts. [case] handshake = "strict" refuses to run instead, and "off" stays quiet. aeolus1d handshake CASE.toml prints the full ledger. In the GUI, Run → Connections… (Ctrl+F5) draws each connection as two columns with a line per species, and marks where one is lost with a cross. Clicking a connection there lights it on the canvas: its line, its pipe and the component it joins glow cyan until the window closes. Selecting a boundary condition on the canvas now plots, after a run, the chosen variable at the pipe-end cell it sits on, with what the BC was told to hold (p_back, or p0/T0 as totals, or a schedule) dashed on top. A transient boundary condition shows its time history even before a run: every schedule it carries (p0(t), T0(t), ṁ(t), species, valve lift), one panel per quantity, with the pipe-end value added after a traced run. transient_stagnation_inlet now lists its species-schedule fields in the inspector; the solver already read them. Over the 83 bundled examples, 5 cases had such a connection. Three bugs of the same kind turned up and are fixed below; what remains are two cases whose intake pipes do not carry every species their cylinders hold.

Boundary conditions hand a pipe the pressure they set (2026-09-24). Every gas boundary condition wrote its ghost cell as an ideal gas with the global [gas] gamma. A pipe whose gamma varies per cell, or that uses a real-gas equation of state, read that cell back as a different pressure. On 91_cylinder_species_transport the exhaust outlet delivered 9.4 % less than its p_back. Boundaries are now built with the pipe's own gamma and R, and on those pipes their ghost cells are re-encoded in the pipe's own model, so the pipe reads exactly what the boundary set. The plenum and EHR ports had the same defect on real-gas pipes: they used the ideal-gas temperature. That is fixed too. Only the four examples with such pipes change (17, 18, 90, 91); every other case runs as before.

Cruise M boundary conditions attach through gas links only (2026-09-23). The importer used to follow any connection from a boundary to a pipe, so a temperature signal or a shaft could carry a gas inlet onto a flange, or an exhaust boundary onto the intake air cleaner. Now only gas paths count, and a skipped boundary's warning names the routes it refused. Over the 143 AVL example projects, 21 import differently: 4 boundaries move to the pipe their gas link leads to, 49 are skipped (33 of them were duplicates that never reached the model), 10 stray flange pipes and 7 other pipes held only by misplaced boundaries are dropped, and 3 pipes a boundary now reaches are kept.

An import now says what the file held that aeolus1d did not use (2026-09-23). Importing a Cruise M .proj or a GT-Suite .gtm adds one line to the import report: how many of the file's fields were read, how many cannot affect the result (plot pages, layout, bookkeeping), how many describe physics aeolus1d does not import, and how many nobody has worked out yet -- with the element families they sit in. aeolus1d fields FILE lists every one with its reason. Built from all 247 local example files: of Cruise M's 7806 distinct fields, 143 are read and 3769 are known but not imported (the restriction and throttle flow coefficients among them). GT files name no attributes, so 6006 of GT's 6127 stay "not understood" for now.

Cruise M volumes in cubic millimetres import at the right size (2026-09-23). volume~mm3 was missing from the importer's unit table, so those values were read as cubic metres. 13 of the 143 AVL example projects imported absurd geometry: C06047's aftertreatment plenum as a pipe 519 m long and wide, its intercooler core 7.8e9 m long. A unit the importer does not know is now listed in the import report every time it is used; before, it was a Python warning shown once per session.

GT-Suite imports keep ambients, phase cylinders and read units (2026-09-23). Found by a review measured on 104 GT example models:

  • An ambient connected through an orifice to a flowsplit, not a pipe, was dropped and its network end became a wall (38 ambients). It now gets a short connector pipe. An ambient that still cannot be placed is named in the report.
  • Cylinders named Cylinder-01… all fired at the same angle, because the number was compared as text against the firing order (12 models). They now follow the file's firing order.
  • Flowsplit volumes ignored their unit: a 21 L muffler came in as 2e-8 m³. A volume in an unknown unit is no longer used, and the report says so. Known units give the same values as before, to the bit.
  • SOI and delta in an unknown unit are refused rather than guessed; valve diameters honour their unit.
  • Report messages that said ambient pressure, wall temperature and the boost target were "not resolved" now state the value used and its source.
  • Files are decoded as their XML says (UTF-8 by default), not always as ISO-8859-1.
  • The converted turbo map is written atomically, so two imports of one model at once no longer read a half-written file.

A quasi-steady run that goes non-finite now says which pipe (2026-09-22). The "QS advective-CFL: non-finite velocity" abort always printed pipe '?', because it looked the name up under an attribute pipes do not have. It now prints the pipe's id.

The quasi-steady junction solve no longer fails on a root it has just reached (2026-09-22). Both junction solvers checked for convergence at the start of each iteration, so the result of the last allowed step was never checked. A network that converged exactly on its last iteration stopped with "junction-pressure Picard failed to converge", while the error message itself showed an imbalance inside the tolerance (down to 0.000 kg/s). The final result is now checked before giving up. Runs that converged earlier are unchanged, bit for bit.

The single-pipe driver honours dt_fixed and live control on implicit solvers (2026-09-21). advance_single_pipe(..., rk="implicit_euler" | "bdf2" | "radau_iia") used to drop dt_fixed and live_control on the way to the implicit driver: a fixed-step request took CFL-driven steps, and stop requests were ignored. Both are now forwarded, and a CFL ramp — which the implicit drivers do not have — is refused instead of dropped. Runs from a case file were not affected; they already called the implicit drivers directly.

The same path now builds the right Newton Jacobian for walled pipes (2026-09-21). It left the implicit driver's boundary Jacobian at "transmissive" whatever the ends were. On a walled Sod tube that moved the result by up to 0.23 at CFL 4 and let mass drift. It now uses the transmissive form only for the pipe's own transmissive ends and a numerical one otherwise, which matches the exact wall Jacobian to round-off. Case-file runs already did this.

The Brinkmeier catalyst's bs_brinkmeier step refuses what it cannot model (2026-09-21). The OSC discharge-heat split, C3H6 oxidation and NH3 formation were refused only when a case file was checked. A closure built in Python and stepped with chem_solver='bs_brinkmeier' ran the legacy chemistry without a word; the step now raises and names what is missing.

A network with no pressure-driven pipe reports junction pressures it never solved (2026-09-20). The loop-flow steady solver checks that every junction can be reached from the pressure reference, and reports the ones that cannot — their pressure is undetermined. That check is skipped entirely when no pipe is pressure-driven, which is the case where every junction is undetermined rather than just one. What comes back instead is every junction sitting exactly at the reference pressure, with an iteration count of zero and no warning.

It needs a network whose every pipe carries a mass-flow inlet, so it is not a shape you hit by accident. Recorded in BACKLOG.md with both measurements — one unreachable junction is reported correctly, none at all is silent — rather than changed here: removing the gate makes a previously-quiet network start raising, which is its own decision.

The solver was also split into seven named pieces and is unchanged otherwise: checked against the old code on 168 network configurations — series, parallel, stiff, multi-loop, both error paths, cached and uncached — every one identical, down to the per-pipe fields.

The implicit solvers' shared driver got a test of its own (2026-09-20). Every implicit run — solver = "implicit_euler", "bdf2" or "radau_iia" — goes through one outer driver that chooses the timestep, retries a failed Newton solve, and runs the step-doubling error control. It was 282 lines and was only ever tested through the physics: a suite that checks "did it run, is the answer about right" cannot see a driver that picks a slightly different Δt or keeps the less accurate of two results.

Nothing about your runs changes. The split was checked against the old code on 135 configurations — all three solvers, fixed and CFL-driven timesteps, both adaptive modes, first-order and MUSCL, including a shock tube — and every one is identical, down to the final state, the times passed to your callbacks, and the wording of every error.

One documentation error was found and corrected: the driver's own docstring said the implicit-Euler entry point takes no fixed timestep. It does, and always has, as do all three. Nothing was broken by the claim, but it had misled the equivalence check being used to verify the split, which skipped eighteen valid configurations on the strength of it before the mistake was caught.

Importing the same Cruise M file twice can give you two different cases (2026-09-20). The importer decides which flow nodes become junctions by walking a Python set, and the order a set of strings walks in depends on a seed Python picks fresh for every process. So the junctions come out in a different order from one run to the next — same file, same version. Reading C06121_Dual_Fuel_NH3_Diesel.proj three times gave the intake manifold junction sixth twice and the exhaust manifold junction sixth once.

Two consequences are certain. Saving an imported case to TOML twice gives two files that differ, so a version-controlled case shows churn that is not a change. And the import report's warnings come out in a different order, which makes two reports awkward to compare.

A third is not established and this note does not claim it: junction order is the order the network is assembled in, so it could move the last digits of a result. Nobody has measured that yet.

Nothing is changed here. The fix is a one-line change, but it reorders junctions for every multi-junction Cruise M case, and whether the solver is sensitive to that order has to be measured before the reorder lands rather than after. BACKLOG.md carries the measurement and the two sites checked.

It surfaced the way these things usually do: a refactor of an unrelated function was being checked against the 143 example projects, 14 of them came out different, and none of the difference was the refactor.

Your Cruise M import has been dropping heat-transfer multipliers (2026-09-19). When an AVL Cruise M project gives its catalysts different AUCI heat or mass transfer correlations, aeolus1d imports the first one and applies it to every catalyst. The others are dropped. This is not a rare shape: of the 143 Cruise M example projects checked, 24 carry more than one — C02027_Cat_Heat_Exchange_Pin has seven, of which six are dropped.

The import report did say something about it, and what it said was wrong. It reported "no aeolus1d kernel for this transfer mechanism" — naming Sieder-Tate, which aeolus1d has had a kernel for since Phase A39. Anyone acting on that note would have gone and written a correlation that already exists. The real cause is that the importer honours one correlation per project.

Nothing is changed yet: both the loss and the message are recorded in BACKLOG.md with the projects that show them, because the fix is to teach the importer per-catalyst correlations and that needs its own measured change. Correcting the message alone would be worse than leaving it — the audit trail would then claim a multiplier landed when it did not. If you import a multi-catalyst project today, check catalyst.h_multiplier on each pipe against your project.

This came out of splitting the function that writes that audit trail, which had no test that it still speaks. Silencing a warning produces fewer warnings, so every existing Cruise M test would have stayed green with the whole audit deleted. There are 49 tests on it now.

The instrument that decides whether the core/zone split is admissible had no test (2026-09-19). probe_core_zone_partition is what produces the G1 verdict in the AVL EPW diesel validation run — it decides whether the cylinder's mass and element bookkeeping close well enough for a two-zone model to be written against it. It was 275 lines, and nothing in the tree called it. Sixteen deliberate breakages of its arithmetic — inverting the entrainment ratio, reading the charge composition from the wrong end of the window, dropping the fuel out of the surround, making the verdict never fail at all — every one passed. There is a test now, and all sixteen fail it.

Writing it turned up a real defect, filed rather than fixed: the probe's own default setting fails the probe's own verdict. Leaving phi_core unset selects the sharpest partition, where the surround mass is exactly zero — and two of the five G1 conditions test the sign against a bare zero while the other three take a tolerance. So rounding noise of order 1e-17 is reported as "the element bookkeeping does not close". If you have seen that message on a case you believed was healthy, pass an explicit phi_core a little below the trapped value. Choosing a tolerance here silently would turn a measured gate into a decorative one, so it needs its own change.

Two guards in the engine scaffold had never been checked either (2026-09-19). scaffold_single_cylinder_engine's injection schedule refuses a duration that wraps past the whole cycle, and warns when a fuel latent heat is given but no injected mass will carry it. Neither had a test. Both do now. One user-visible change came with it: warnings raised from the scaffold now point at your call line rather than at a line inside aeolus1d. A negative intake-open angle is recorded in BACKLOG.md rather than changed — it silently produces an injector whose window is outside the cycle.

A warning that had never once been checked (2026-09-19). Setting two_zone = true on a cylinder that also uses a predictive combustion model does nothing — the two-zone diagnostic only runs on the Wiebe burn. aeolus1d has warned you about that for a while, because when it happens the burnt-zone temperature stays at 0.0 all run and anything reading it, such as the NOx post-processor, quietly substitutes the bulk temperature instead — a difference of hundreds of kelvin in a quantity the Zeldovich rate is exponential in. Splitting _build_cylinders turned up that no test anywhere checked the warning still fires; grep -rl INERT tests/ found nothing. It fires, and now there is a test saying so.

The same sweep found the cylinder's valve-conservation guard reading its own private copy of the list of implicit solvers — one of five copies in the tree. That guard is what stops a cylinder running under an implicit network solver, where the valve mass and energy sources are never applied and the runner pipe silently stops conserving. Adding a sixth implicit scheme would have switched it off without a word. There is one canonical list now, and a test that fails if any accepted rk is classified as neither implicit nor explicit.

Four smaller silent fallbacks are recorded in BACKLOG.md rather than changed, because each would alter behaviour: a negative [crankshaft] rpm is ignored rather than rejected, and [cylinder.init] drops rho without comment when T is also given.

No case changes. The 460-line _build_cylinders is now 71 lines and thirteen helpers, verified by building 78 cases — every bundled engine example plus synthetic ones for each branch — and checking every cylinder attribute, every error and every warning came out identical.

The solver's routing tree got a test, twenty months in (2026-09-19). dispatch_advance decides which of seven time-integration drivers actually runs your case — every CLI run, every GUI run, every run_case call goes through it. It was 404 lines and nothing checked where it routed. Splitting it into one function per driver made that visible: swapping the quasi-steady and Strang arms, so a low-Mach aftertreatment case would have been run by the explicit compressible driver instead, left the whole quasi-steady test suite green. There is a routing test now, covering every arm. Nothing about your cases changes — the same driver runs, with the same settings, verified against the old code on ten configurations.

Separately, the GUI test suite now runs whenever any solver source file changes. Its path filter was a hand-maintained list that covered 8 of the 33 modules the GUI actually imports, so a change to a function the GUI calls could merge without the GUI suite running once.

Four more long functions split, and the split found a dead safety net (2026-09-18). linearize_network, the cylinder's two-zone diagnostics, the network mode solver and the case builder's controller chain came down from 264, 273, 252 and 271 lines; the ratchet fell 36/170 → 32/166. The controller chain was seven elif arms over 200 lines and is now one small builder per kind behind a lookup table — the same shape the TOML writer already uses. All three are numerical, where a careless extraction changes nothing visible and everything downstream, so each was hashed against the pre-split code first — 4, 10 and 8 configurations, byte-identical every time. Two of the three shrank by deleting duplication rather than moving it: one probe-reading line that existed in ten copies, one finite-difference loop written out twice, one index slice three loops each re-derived.

Then mutation-testing the two-zone split turned up something it had not caused. The caloric zone split — the P2 work that evaluates both zones with the gas's real heat capacity instead of a frozen one — could be deleted outright with the whole suite still green. Its tests check that the evaluator works and that the source says the right thing; none of them notices when nothing calls it. There is a test for that now.

Three of the longest functions became sixteen readable ones, and the one nobody could test got a test (2026-09-18). SettingsDialog.__init__ (225 lines), NetworkViewDock.__init__ (205) and the network view's chain packer (239) were split; the repo's long-function ratchet fell 39/173 → 36/170, and both counts moved by three because every child came out under 100 lines — the bar two earlier splits missed. The packer is the interesting one. It had no test of its own: the three existing network-view test modules each need a working OpenGL context, so on a headless runner nothing exercised it at all. Rather than trust the split, the pre-split function and the four new passes were run against the same synthetic network and produced identical positions; that case is now a Qt-free test module that runs everywhere the other three cannot. Nothing a user sees changed — this is the same dialog and the same schematic.

The coverage floor stopped going quietly slack, and the GUI is measured at all (2026-09-18). The unit job's coverage floor was 62 % against a real 65.9 % — it could have fallen by nearly four points without anything going red. That exact gap had been closed once already, a month earlier; what the earlier fix produced was a better number and nothing that watches it. The floor is now 65 and is checked from both sides on every pipeline, so a floor that has drifted below what the suite actually covers is a red build with the new number in the message. Separately, aeolus1d_gui now has a coverage figure at all — roughly a third of the product had never had one recorded, because the only coverage job measured the solver package.

The nightly perf runs are a trend now, not 19 files nobody read (2026-09-18). perf-budget and wallclock-budget have run every night since 2026-08-31 and each saved its measurements as a CI artifact. perf/nightly-baseline.json pins the band — min/median/max per gated quantity over all 19 nights, with the harness beside each number — and python scripts/perf_nightly_history.py --compare perf.jsonl reports where a local run sits in it. This is the last step of the release's perf-signal work: the gates' ceilings are ~1.8×, sized to catch an order-of-magnitude break, so a run can pass every gate while being 40 % slower than the machine's own recent nights. The comparison reports and does not gate. Two fixes came with it: a measurement now carries a stable name (cProfile keys a function by line number, so an unrelated edit used to rename its series and split its history), and the comparison prints the whole-run scale, which tells a slow host from a slow commit — the first thing the trend showed was every series moving together by 9 % for one night on a machine the load average called idle.

The passive-SCR validations read H₂ as a mole fraction, and the NH₃-formation rate was refit to match (2026-09-17). The two passive-SCR validation suites (examples/validation/ornl_passive_scr, examples/validation/passive_scr_system) specified their H₂ feed as a mass fraction — 8000 mass-ppm at the rich point, about 10 % H₂ by mole, far richer than the λ ≈ 0.96 exhaust it stands for — while reporting every outlet in mole-ppm. The feed is now read as a mole fraction. Because the NH₃-formation rate goes as H₂³, the calibrated A_nh3_form_brnk of 4e5 no longer reproduced the paper's trend (2.5 % rich selectivity against a gate of 80 %) and was refit to 1.0e8. Both suites pass against unchanged gates: 83.7 % rich and 25.9 % near-stoich selectivity (were 84.1 % and 24.1 %), 913 ppm rich NH₃ and 100 % system NOₓ conversion (were 801 ppm and 100 %). A case of your own that copied A_nh3_form_brnk = 4e5 from these files was calibrated against the old reading.

Ghost-cell width is decided in one place (2026-09-16). How many ghost cells a scheme needs was written down three times — in the explicit drivers' check, in the case builder, and in the implicit driver's own check — and nothing forced them to agree. They did agree, so no result changes. All three now read one table. The implicit driver's error for a MUSCL pipe built with too few ghost cells now uses the same wording as the explicit drivers; it is reachable only from the Python API.

per_cell_gamma on a trace-species pipe is refused instead of being wrong by −22 % (2026-09-16). Mixture properties are built from a pipe's tracked species columns and are not normalised, which is correct only when those columns are the whole gas. An aftertreatment pipe tracks NH3 / NO / O2 / CO2 over an untracked N2 balance — a few per cent of the mass — so per_cell_gamma = true there produced an R_mix an order of magnitude low and, on examples/42_scr_urea_injector.toml, a pipe pressure 22 % low and a peak NH3 mass fraction 68 % low, with no warning and a plausible-looking γ of 1.27. Two changes: the combination is now refused at load with a message naming the sum and both ways out, and the existing per_cell_gamma-plus-chemistry refusal was widened to recognise chemistry declared as [pipe.species] reaction = "..." — the form every bundled SCR, urea and DOC case uses, and the reason that guard had never been able to fire on one. No bundled case changes: the three that set per_cell_gamma track a complete mixture and carry no reaction.

One residual dispatch tree instead of two (2026-09-16). Which kernel a pipe's residual is evaluated with was decided by two hand-kept cascades — one in the single-pipe driver, one in the network driver — held together only by a test. They are now one function; the network driver calls it through a one-line adapter. Behaviour is unchanged except in one place: a pipe built through the Python API with an unrecognised scheme string now raises ValueError from the network driver instead of being evaluated first-order without comment. A case file cannot reach this — scheme is a validated literal there.

The compiled residual sweep no longer runs on a stale γ (2026-09-15). The struct-of-arrays sweep (ADR-0015, taken on networks with eight or more plain MUSCL pipes) cached each pipe's γ when the network was built and never read it again. A pipe with use_mixture_gamma has its γ refreshed every step from its evolving composition, so a swept one would have run the whole case on its initial γ while its unswept neighbours tracked theirs.

Latent, not shipped. Of the bundled examples only 39_4cyl_engine_turbo_scr and 89_epw_diesel carry enough eligible pipes for the sweep to be taken at all, and neither has a use_mixture_gamma pipe among them — so no shipped case produced a wrong number. A hand-built network could.

Found by gating the sweep against the dispatch cube (tests/unit/test_soa_selection_matches_the_dispatch_cube.py), which now pins that the sweep claims exactly the pipes the network driver would have sent to the plain MUSCL kernel — with the expected side read out of the real dispatcher rather than restated, so the rule cannot drift into a copy that agrees with itself.

A ghost width too small for the scheme is now refused by both drivers (2026-09-15). A Pipe built with scheme = "muscl" and Ng < 2 (or "mp5" and Ng < 3) used to raise on the single-pipe driver and run first order on the network driver — one pipe, two answers, and the quiet one returned a plausible array while the caller believed they had second- or fifth-order accuracy.

Both now raise, from one shared check_scheme_ng. Refused rather than degraded because a construction mistake has an exact fix — the message names it — while a silent order loss is invisible downstream: conservation does not notice.

Unreachable from TOML, so no case file is affected: the builder derives the ghost width from the scheme. It reaches hand-built Pipe objects from the Python API only.

A non-ideal EOS on a pipe carrying species is now refused at load time (2026-09-15). Pipe.eos_params is one array for the whole pipe, so a varying composition was evaluated with the wrong substance in every cell but one — silently, with mass and energy still conserved and only the pressure that drove them wrong.

Measured before deciding what to do about it: 36–55 % pressure error at engine conditions, where the real-gas correction itself is worth only 0.05–0.26 %. The dominant term is molar mass, an ideal-gas property, not real-gas mixing.

Refused rather than approximated, with the number in the message and two real escapes: drop the species block if the substance is single-component, or use kind = "ideal", whose p = (γ−1)ρe is composition-correct already through the per-cell γ. No bundled case is affected — 215 load, 0 trip the guard.

Lifting the restriction is filed in BACKLOG.md with the measurement; note that the obvious cheap fix (per-cell R only) is a ~99 % win exactly where a cubic EOS is pointless and leaves 18 % where it matters. See docs/scope-and-limitations.md.

One definition of the closure-kinds rule (2026-09-14). Internal; no behaviour change, and measured as none — the bit-identity regression tier passes with no golden number moved.

The rule that decides which friction / heat / porous kinds a pipe's kernel is handed (a Python override forces the corresponding native kind to NONE) was written twice, once per driver. Two further copies sat beside it: the "no porous state" array was a module constant on one side and an open-coded np.zeros(7) at eight call sites on the other — each one inside a residual closure, so a fresh allocation per stage per timestep on the hot path.

timeloop.closure_args is now the single definition and the network driver binds to it. _py_closure_kinds keeps its name, because six modules cite it as the policy. Held by tests/unit/test_one_closure_rule.py, which asserts the two names are the same object rather than that they agree today.

Real-gas MUSCL keeps its order under per-cell γ (2026-09-14). A pipe with scheme = "muscl", a real-gas EOS and a per-cell γ model was excluded from the network driver's variable-γ MUSCL block (which requires not has_real_gas) and from the block beneath it (which required the bare not use_per_cell_gamma), so it fell to the first-order tail. It now reaches the EOS MUSCL kernel. On the 16-cell PR-CO2 test harness the residual moves by 68–71 %.

The remedy previously advertised here and in BACKLOG.md — "add an EOS-aware variable-γ kernel" — was not needed, and the premise behind it was wrong. gamma is dead code once the EOS is non-ideal: primitives_from_U_eos_jit reads it only under kind == EOS_IDEAL, and the MUSCL reconstruction's W ↔ U conversions take no γ argument at all. Under Peng-Robinson, γ = 1.1 and γ = 5.0 give bit-identical pressure, sound speed and whole residuals; under ideal gas the same sweep changes pressure threefold. So dropping gamma_pad for these pipes is not an approximation — it is what the first-order EOS path has always done for the same pipe, and the fix is one guard rather than a new kernel. The retraction is in BACKLOG_HISTORY.md.

Affected: any run with scheme = "muscl", a real-gas EOS, and a per-cell γ model (species or mixture-averaged). Those runs get second-order accuracy where they were silently getting first. Numbers move. No pinned regression number changes — the bit-identity tier passes unchanged, because no regression case exercises the combination.

What is missing, now filed in BACKLOG.md with its scope: per-cell composition cannot reach the EOS at all. Pipe.eos_params is one array for the whole pipe, so a varying-composition real-gas pipe gets pipe-level EOS parameters everywhere. That is the real limitation the "missing kernel" was standing in front of.

The explicit single-pipe driver ran the wrong operator on real gas (2026-09-13). The same disease as the entry below, found immediately after it, and it had been there longer: advance_single_pipe and advance_network each decide which residual kernel a pipe needs, and only the network one was complete.

A real-gas pipe run through the Python API — advance_single_pipe called directly, which bench/ does, and analysis.linearize, which since 2026-09-13 shares the same dispatcher — was integrated with the ideal-gas residual. Measured on a 60-cell closed CO2 pipe (Peng-Robinson, ρ=50 kg/m³, 5 MPa with a 20 % Gaussian pulse, cfl=0.4, t_end=1e-4), the two drivers differed by 2.6 % on momentum; on ideal gas they were bit-identical. They are now bit-identical on both.

Behaviour changes, each "second order → first order, matching the network driver" where it is a loss:

  • Real-gas single-pipe runs through the Python API now use the EOS residual and an EOS-aware Δt. Numbers move; this is the fix.
  • linearize(method="fd") on a real-gas pipe now differentiates the real-gas operator. Every stability margin, pole and gain previously computed from such a Jacobian was the ideal-gas answer wearing a real-gas label. method="analytic" has no real-gas path and raises NotImplementedError rather than silently returning the ideal one.
  • hllc_low_mach now reaches the single-pipe driver. The flag was accepted, and the startup banner announced it, while the residual ignored it. Two bundled cases set it and are single-pipe, so both change: examples/24_hllc_lm_pulse.toml and examples/validation/doc_propylene_lightoff/case_c3h6_lightoff.toml.
  • First-order pipes built with Ng >= 2 now have their padded array sliced to the one-ghost window the first-order kernels assume. Previously the uniform path raised on shape and the quasi-1D path silently returned a correctly-shaped answer computed one cell off its stencil.
  • MUSCL + real-gas EOS + per-cell γ and MUSCL + quasi-1D + per-cell γ now degrade to first order on the single-pipe driver, matching the network driver, which has always done so. Both are losses on this driver and both are filed in BACKLOG.md with the remedy — the quasi-1D var-γ MUSCL kernel already exists and only needs wiring into the network driver's guard, which would lift both drivers at once. The second of those was fixed the next day — see the entry below. MUSCL + EOS + per-cell γ remains a loss, and will until an EOS-aware variable-γ kernel exists. That last sentence was wrong and is fixed below too — no such kernel is needed, because γ is dead code on the real-gas path.

MUSCL + quasi-1D + per-cell γ is second order again, on both drivers (2026-09-14). A variable-area per-cell-γ MUSCL pipe had been running first-order in the network driver since Phase 33 — its var-γ block carried a not needs_quasi1d guard, and the block beneath it is entered on the bare not use_per_cell_gamma, so such a pipe fell out of both and into the first-order tail. pipe_residual_quasi1d_muscl_var_gamma had existed in kernels.py the whole time with nothing dispatching to it.

The guard is gone. Both drivers now reach that kernel; on the 16-cell tapered test harness the residual differs from the first-order tail it was getting by 1.2e+09 max abs. No pinned regression number moves — the bit-identity tier passes unchanged, because no regression case exercises this combination, which is the same reason nothing caught the loss for a year.

Affected: any run with scheme = "muscl", a per-cell γ model (species or mixture-averaged), and a non-uniform area or a friction / heat / porous closure. Those runs get second-order accuracy where they were silently getting first. Numbers move; this is the fix.

Held closed by tests/unit/test_residual_dispatchers_agree.py: a 23-row cube over scheme × Ng × EOS × quasi-1D × per-cell-γ × low-Mach asserting the two dispatchers return np.array_equal residuals, plus rows pinning the packed kernels — a third implementation of the same dispatch, which the original filing missed.

analysis.linearize was linearising an operator the solver never runs (2026-09-13). Found by sweeping for the shape the last three fixes all had: one fact written down twice, with nothing forcing the copies to agree.

advance_single_pipe held ~150 inline lines deciding which of ten residual kernels a pipe needs — quasi-1D vs uniform, per-cell γ vs scalar, real-gas EOS, MUSCL vs MP5 vs first-order. That made "how is this pipe evaluated" the explicit driver's private knowledge. linearize needed exactly the same thing and called pipe_residual_first_order unconditionally instead — in two places, once for the single pipe and once inside the network assembly:

pipe method="fd" method="analytic"
first_order, uniform, ideal ok ok
muscl ValueError silently first-order
mp5 ValueError silently first-order
heat closure silently dropped silently dropped

The crashes are the padded-residual shape: a MUSCL pipe carries Ng=2, so the first-order kernel returns N+2 rows — 78 values into a 72-slot array at N=24. The dropped closure was exact rather than approximate: A came back bit-identical with and without it. Every pre-existing test in the module builds Pipe(..., Ng=1, A=1.0) with no closures — the one configuration where that call was right, and the module advertises its analytic path as "machine-precision-exact rather than FD-truncated".

The dispatch is now solver.timeloop.make_pipe_residual_fn, split into three per-scheme helpers, and both the driver and linearize call it. advance_single_pipe drops from 398 lines to 249. The analytic path threads the pipe's scheme and frozen MUSCL slopes through to the Jacobian assembler, and raises for MP5 — which has no analytic assembly — instead of returning the first-order matrix as exact.

Two rows I first reported as broken were my probe's fault, not the code's: a variable-area pipe needs A of shape (N,) (I passed (N+1,)), and heat_source reads params[3] for R_gas (I passed three). Both are recorded in tests/unit/test_linearize_uses_the_pipes_own_residual.py so the next reader does not repeat them.

The extraction also surfaced a separate defect, filed rather than fixed: network_timeloop dispatches real gas for first-order pipes and timeloop does not, so the two drivers disagree by 2.6 % in momentum on the same real-gas first-order case while being bit-identical on ideal. Four of the five bundled real-gas examples use the default first-order scheme. See BACKLOG.md — it changes the explicit driver's output and belongs in its own change with its own bench re-run.

The implicit driver's species transport was not in conservation form (2026-09-13). Found by asking which drivers the discrete-balance gate actually covers. It had a closed_species_pipe row for the explicit driver and none for the implicit one — and the implicit driver's only species test asserts that Y[0] > 0 and that reaction depletes it. Direction, never amount. The same shape as the cooler bug the day before: a test that asks whether something moved will say yes to something moving far too much.

Both implicit species paths advance the conserved ρ·Y in flux-difference form and then recover Y by dividing through by p.U[:, 0] — but p.U is already the converged Newton state for the step, so they formed the old species mass as ρⁿ⁺¹·Yⁿ when it is ρⁿ·Yⁿ. The species inherited every step's density redistribution with no matching species flux, and the domain total jumped by exactly

∫ (ρⁿ⁺¹ − ρⁿ) · Yⁿ  dV

per step. That identity holds to ratio 1.0000 on a single step. Over a closed wall-bounded run (N=100, muscl, cfl=0.8, t_end=2 ms, density ratio 1.45 across the initial discontinuity) the domain species mass drifted 6.7 % (primary) and 3.9 % (extra) on every rk, while mass and energy sat at 1e-16 throughout — which is precisely why nothing caught it. Mass fractions also left their initial [0.1, 0.4] bounds, reaching 0.4855.

Re-basing Y by ρⁿ/ρⁿ⁺¹ before transport makes ρⁿ⁺¹·Y the true old species mass, after which the conservative sub-steps telescope: 6.7e-02 → 4.4e-16 on all three rk kinds. Bit-identical wherever density does not move within a step. The explicit driver was always exact (1.1e-16) and is untouched.

The gate now carries the implicit species rows it was missing, and the real-gas rows it never had — closed Peng-Robinson CO2, single pipe and plenum network, explicit and all three implicit rk. Single pipes hold to roundoff on every driver; the junction rows carry IMPLICIT_JUNCTION_TOL like their ideal-gas siblings.

A claim that implicit + real gas + plenum does not conserve was filed against those junction rows and retracted the same day — the control was vacuous. It changed only eos_kind and read 0.0 on ideal against 1.1e-05 on PR, but the ideal case had no mass crossing the port at all: the initial state set a uniform pressure through the ideal-gas closed form, so ideal sat in equilibrium while PR, given a different and non-uniform pressure for the same conserved state, actually flowed. Re-measured with both gases driven and the drift normalised by port throughput, the coefficient is the same for both (~2e-3) — the ordinary implicit-pipes / explicit-plenum coupling, not an EOS defect. Recorded in BACKLOG.md under wrong premises rather than deleted.

The opt-in species_implicit_upwind path had two more defects on top, and both are fixed — 6.4e-02 → 2.1e-03 from the re-base above → 2.2e-16. It calls the QS driver's _qs_implicit_species, whose kernel docstring states its domain as forward flow with a uniform conserved flux; the implicit driver is transient, so neither held. The QS driver guards that precondition and raises on a reverse-flowing edge; the implicit driver called the same kernel with no guard — the same one-fix-two-call-sites shape as the importer work the day before.

  • The flux it marched on was never the flux it took. pipe._qs_mdot_face was never published by this driver, so all 301 calls of one closed run fell through to the branch the kernel's docstring reserves for "a direct kernel unit test" — a flux rebuilt from upwind cell momenta, which put real flow through the closed box's own wall faces on 238 of them and disagreed face-to-face by 16.1 kg/s where the kernel assumes one uniform value. The driver now publishes the flux its own step used, telescoped off the density change (F_{i+1} = F_i − V_i·Δρ_i/Δt) and anchored at _left_boundary_mass_flux. At a closed wall the anchor is zero and domain mass conservation lands the far wall on zero too — the leak closes by construction, not by a guard. Wall leakage: 3.3e-12 kg/s.
  • The sweep took the left neighbour as upwind unconditionally. Reverse flow fired on 301 of 301 calls. There is now a signed tridiagonal solve, strictly diagonally dominant by exactly the old mass (b − (|a|+|c|) = ρ_old·V/Δt), hence an M-matrix — unconditionally stable and bounded for any Δt, the same guarantee the forward sweep had without the sign precondition. Gated on any negative face flux, so the QS driver keeps the original arithmetic bit-for-bit.

The march is now strictly bounded as well as conservative: Y stays inside its initial [0.1, 0.4] to the last digit, where the default sub-stepping path undershoots to 0.099887. The balance rows moved from xfail(strict=True) to a plain gate on all four quantities.

Importers were guessing units they had already been told (2026-09-12). Found by sweeping the codebase for unit-consistency defects. Two importers, one shape: the declared unit was parsed, stored, and then not read.

GT-Suite as_K / as_rad_s. Audit 2026-06-10 A2.8 moved as_pa to declared-unit-first; its two siblings in the same class kept the magnitude heuristic as their only path. as_K's is wrong by exactly 273.15 K in both directions outside the ambient band — a cold-cell ambient written in K (WLTP cold start, 266.15 K) read as °C and came back at 539 K, hotter than the exhaust; an exhaust wall or turbine inlet written in °C read as K and came back 273 K cold. Both are reachable: as_K supplies the ambient BC and wall temperatures. The unit IDs are read off a real .gtm the same way A2.8 verified the pressure codes — 34 = °C, 32 = K, 139 = rpm — and the heuristic stays as the fallback, so every file that imported correctly before still does.

AVL Cruise M unit table. Audit A2.10b made an unknown unit warn instead of passing silently. It has been warning ever since, on bundled AVL examples, and the rows were never added:

  • volume~dm3 — the litre under its other name, and how Cruise M writes plenums. Six bundled examples declare them, one of them eight. Passed through unscaled, every plenum imported at 1000× its volume; because the equivalent pipe is sized D = L = (4V/π)^(1/3), that is exactly 10× on both length and diameter — C06067's 1 L plenums imported as 1 m³.
  • dimensionless~percent — the third spelling in the species codomain. ppm was in the table and percent was not. The damage is worse than the factor of 100, because only one of the two input bases was scale-invariant: a mass_fractions table renormalises to Σ=1 and came out right anyway, but a mole_fractions table computes the residual species as clip(1 − Σ others, 0, 1) — on a 0–100 table that clips to zero, so N2 was deleted and the rest renormalised to fill the stream. O2 arrived at 0.499 mass fraction instead of 0.151. Not a 100× slip; a different gas.
  • evap_heat~kJ_kg — this one was right, for the wrong reason, and adding the obvious row alone would have broken it. The decoder returned kJ/kg while claiming SI, stored it in a field named LHV_kJ_per_kg, and a consumer multiplied by 1000. Two errors cancelling. The row and the consumer moved together; _ClassicFuel now carries J/kg, as its name says.
  • density~kg_m3 and heat_transf_coeff~W_m2K were already SI. They change no value — they exist so the unknown-unit warning stays rare enough to mean something.

test_plenum_pipe_volume_matches_cruise_m_declared passed throughout the dm3 defect: it asserted max(volume) > 1e-4 while its docstring claimed a specific volume, and a 1 m³ plenum is also > 1e-4. It is now bounded on both sides. A one-sided bound cannot catch a unit error, only a zero.

The implicit driver was evaluating whole-pipe closures one cell at a time (2026-09-12). Found by asking, across the whole closure layer, which overlays change answer when called with a single cell — the question the cooler-Jacobian fix the day before had raised but not generalised.

_residual_full's Python-override fallback built its residual by looping cells and calling py_heat / py_friction / py_porous with one-element arrays. That is exact for a cell-local closure and silently wrong for any overlay that reads a whole-pipe quantity, and two shipped families do:

  • the lumped ε-NTU charge-air and EGR coolers divide the pipe duty by N, so a one-cell call returns a source exactly N times too large. An implicit run with either removed about N times the heat the explicit driver removed — 49x measured end to end at N = 40;
  • the dynamic-wall overlays multiply by a length-N inv_V and read a length-N T_wall, so the one-element call broadcast back to N and the caller took element 0. Every cell got cell zero's wall temperature and cell volume, reversing the sign of the net duty on a wall with an axial gradient.

Both were in the residual, so the implicit driver was solving different physics from the explicit one. Fixed by evaluating the overlays once on the full arrays, exactly as the explicit driver does; the implicit and explicit closure sources now agree to 7e-13. No bundled case was affected — none of the eight implicit examples carries a cooler or a dynamic wall, which is why nothing caught it.

tests/integration/test_driver_fold_parity.py covers this exact pair of closures across these exact drivers and asserted only that the gas cooled by more than 1 K — direction, not parity. It now compares how much.

Two implicit-solver defects, both found by asking what the other side of an interface was doing (2026-09-11).

The junction volume was integrating a different ODE from the pipes. Under rk = "bdf2" the pipe interiors advance on the variable-step BDF2 stencil while the junction volume across the same port face advanced on forward Euler. implicit_euler never showed it because there the two forms coincide — which is why it had always sat ~30x better on the conservation gate, a clue the recorded numbers had been carrying. New [case] flag junction_bdf2_history, default on: relative energy drift on the closed two-pipe + plenum network goes 4.106e-05 → 3.580e-07, putting bdf2 below implicit_euler. implicit_euler and radau_iia are bit-identical — neither has a volume-side history to read. radau_iia's own drift is unchanged and still open; the obvious extension to it was tried, measured 1.9–43x worse, and recorded as a negative rather than shipped.

The charge-air and EGR coolers had a Jacobian ~N times too stiff. The Newton Jacobian for a py_heat overlay falls back to per-cell finite differences, and it takes those differences by calling the overlay on a one-element array. The lumped ε-NTU coolers divide the pipe duty by N, so a one-cell call returns a source exactly N times too large — 500x on a 500-cell pipe. It never corrupted a result (the residual is the truth, and Newton converges to it regardless) but it cost convergence rate on every cooler pipe in every implicit case. Fixed by giving the closure an exact analytic block: the ε-NTU source turns out to be exactly −K·(T − T_coolant) per cell, because the ΔT_mean in it cancels. 13.96x faster on the A/B, and every cooler test and golden snapshot passes unchanged.

Documentation take-stock — a validation claim withdrawn, and four counts refreshed (2026-09-10). A sweep of README, CONTRIBUTING.md, MATURITY.md, docs/scope-and-limitations.md, docs/validation-dossier.md, docs/turbo-map-format.md and examples/plots/README.md against the tree.

The one that matters. docs/scope-and-limitations.md rated EPW diesel T_EXH "Yes — near-exact" and said the blind 2nd-operating-point check generalises (−5.6 % / −1.8 %). Those figures predate 2026-09-06. test_blind_operating_point_T_EXH_generalizes has been xfail(strict=True) at −9.91 % against a 6 % gate since 2026-09-07 (AVL 605.4 °C vs aeolus1d 545.44 °C, reproducing to 0.05 °C), two of the three calibration anchors sit outside the gated 40 K band, and a refit was measured and refused at a 109.9 % spread against a 25 % bound registered before any number existed. docs/validation-dossier.md separately read "✅ 3/3 ops PASS both modes" when the predictive mode — the one a user without the vendor CSV actually runs — does not gate T_EXH at all and reports it NOT VALIDATED at −44.9 / −113.4 / −80.9 K. Both corrected with dated annotations; the T_EXH claim is withdrawn, the breathing / IMEP_High / PFP claims beside it are unaffected. No code changed — the harness had been saying this since 2026-09-07; only the user-facing documents had not.

A local gate narrower than CI's. CONTRIBUTING.md told contributors to run mypy --strict src/aeolus1d aeolus1d_gui. Naming roots on the command line overrides [tool.mypy] files, so that recipe checked two roots of five and returned a green CI would reject. It now says bare mypy.

Counts, refreshed against the tree. README claimed 196 non-__init__ modules (148 + 48) and ~3900 tests; measured, 461 (285 + 176) and 12,173 across 743 files. MATURITY.md listed three mypy roots of five. The README badge and prose both said mypy --strict, which this repo deliberately does not enable (disallow_any_generics would force ndarray[Any, dtype] on every numeric signature) — the gate is the strict subset, and the reasoning was already in [tool.mypy]. That comment block had itself gone stale in the other direction, still saying "(tests excluded)".

Also. docs/turbo-map-format.md said gamma_ref / R_ref are "not persisted in the CSV or NPZ file" — true for a tabulated TurboMap, false for LearnedTurboMap since 2026-09-09, whose NPZ now carries both with a dry-air fallback for older files. examples/plots/README.md described plot_14's callable as an identity reproducing the default "bit-exactly" while it was in fact replicating the pre-2026-08-02 static ghost.

And one gate widened, found the same way. make lint — which is the CI ruff job — named six subdirectories and not the repository root, leaving conftest.py and hatch_build.py unlinted; ruff check . reported a real finding in conftest.py that no gate would ever have raised. LINT_PATHS now includes both. Exactly the failure class corrected in CONTRIBUTING.md one gate over, found within the hour: a command narrower than the tree returns a green you will believe.

Verified current, not changed: docs/theory.md §5.1 already documents the reservoir port coupling and its 2026-08-02 replacement (ADR-0012); CHANGELOG.md is a pointer to this file, not a rival copy; EC-C12's "251 files remaining" in docs/release-readiness.md matches the exclusion list exactly (251 entries).

plot_14_py_junction_bc replicated a port coupling that was replaced a month earlier (2026-09-10). The demo's identity_bc returned the static (ρ_V, 0, p_V) ghost and claimed to reproduce the default plenum port. That stopped being the default on 2026-08-02, when the reservoir coupling replaced it — writing u = 0 puts a velocity jump on the port face, so a Godunov face resolves a piston (ρ·a·Δu) instead of a contraction (½ρu²) and invents roughly 15× the correct loss at manifold Mach numbers. Measured divergence on the demo's own case: 4643 Pa on a 20 kPa drive (23 %), from the first recorded step, and 8× on the final mass flow. The demo's check could not report it — the two runs took a different number of adaptive steps, so it raised a numpy broadcast error, after savefig — and even with matching lengths it only printed the error and exited 0. Now transparent_bc, recomputing the coupling from the seam's own three arguments, matching the default to 3.64e-15 across all 621 steps against a gate that raises. resistive_bc carried the same u = 0 and now carries u_i, so the loss it adds is the halved port pressure it is named for. No solver change — the port coupling itself was already correct, and tests/unit/test_junction_py_bc.py had been pinning it since 2026-08-02. That file now also pins the demo's copy, across five flow branches, because nothing in CI runs the demos.

A learned turbo map crashed with gas_property_correction on (2026-09-09). [turbo.map] kind = "learned_npz" together with gas_property_correction = true raised AttributeError: 'LearnedTurboMap' object has no attribute 'gas_property_factor' on the very first step. Compressor.map and Turbine.map were annotated TurboMap, and LearnedTurboMap is not a subclass of it — it is a duck-typed sibling — so nothing in the type system could see that one of the two shipped map kinds was missing a member the driver calls. io.build._make_turbo_map is (spec: Any) -> Any, which is why the only typed assignment site in the whole tree was a plot script. Both fields now take a TurboMapLike protocol naming the real surface, the γ/R correction is a free function both maps delegate to rather than two copies of one formula, and LearnedTurboMap carries gamma_ref / R_ref — persisted to NPZ, defaulting to air when an older file omits them, so existing saved maps are unchanged.

Boundary-condition lists were rejected by their own solver (2026-09-09). FarBCs existed three times with three shapes — list[...] in network_timeloop, Iterable[...] in implicit_network, and a longhand Sequence[...] in analysis.linearize. Because list is invariant, ordinary correct code such as far_bcs = [(pipe, End.LEFT, wall_bc)] did not type-check against advance_network. There is now one alias, in aeolus1d.bc.base, and it is a Sequence: the drivers walk the list once per step, so the Iterable spelling would have accepted a generator that is exhausted after the first step and silently applies no boundary conditions thereafter. fit_fopdt and tune_from_step_response were widened for the same reason — they annotated Sequence[float] while calling np.asarray on the argument. BCSpec.kind's fifteen-arm Literal is now the named alias BCKind, so code that assigns a BC kind has a type to spell.

Five bundled example figures were broken and are fixed (2026-09-09). plot_07_catalyst_thermal_front — the two-phase catalyst demo — ran green and printed total heat into solid: 0 J: it set five flat pipe.catalyst_* / pipe.porous_* fields that had moved into sub-states, and Pipe has no __slots__, so every assignment succeeded while the kernel read none of them. plot_18_real_gas_catalyst, plot_39_4cyl_engine and plot_05_porous_plug all died with AttributeError on moved or legacy field names. plot_41_dpf_regen had stopped producing a figure when a zero t_end became an error. All five run again; the porous plug now matches its own analytical prediction to 0.0000 %. examples/ and scripts/ are fully inside the mypy gate, which now covers 1227 files.

Pre-flight validation no longer compiles the solver (2026-09-09). import aeolus1d.io.validate cost 457 ms and pulled numba, scipy and 1073 modules, because io/__init__.py eagerly re-exported the loader — so importing anything under aeolus1d.io paid for the whole solver stack. Now 25 ms, no numba, no scipy, 154 modules. load_case / build_network / run_case still pay it, as they must; what changed is that checking a case before a cluster submission no longer does. The re-exports are lazy (PEP 562), exactly as aeolus1d/__init__.py already was; the closure registrations stay eager, because a lazily registered tagged-union family is one that is missing until someone happens to touch the right attribute.

Two public type annotations were wrong and are widened (2026-09-09). OperatingPointReport.operating_point was dict[str, float] while seven bundled validation suites pass {"case": "..."} — a string — and the renderer has always carried an isinstance(v, (int, float)) branch, which nobody writes believing the values are floats. It is now Mapping[str, float | str]: read-only and covariant, so a caller passing a plain dict[str, float] is still accepted. Component.ports was list[object], which none of the five implementors named in its own docstring satisfied — list is invariant, so list[Port] is not a list[object], and Compressor/Turbine expose ports as a property against a settable member. Nothing failed at runtime (@runtime_checkable checks that a member exists, never its type), but a list[Component] did not mean what it said. Now a read-only Sequence[object]. Both are source-compatible for callers; a downstream project type-checking against aeolus1d may see fewer errors, not more.

parallel_pipes no longer races on its thread pool (2026-09-09). The shared executor was rebuilt larger on a bigger network and the old one dropped with shutdown(wait=False), with no lock over the module globals — so a second thread entering the implicit driver could be handed a pool that had just been shut down (RuntimeError: cannot schedule new futures after shutdown, reproduced on one run in three). One pool now, built once under a lock and never torn down. The sizing it lost was doing nothing anyway: ThreadPoolExecutor spawns threads on demand, so the pipe count only ever set a cap the executor already respected. The flag remains opt-in and still warns that it cannot help until a nogil kernel exists.

Nothing fails in silence any more (2026-09-07). Four best-effort paths used to swallow failures without a word, and each was a real way to lose work or data: the streaming HDF5 flush (a full disk let a run continue while the output quietly stopped growing), the GUI autosave (whose docstring stated the silence as policy), two Inspector union-kind selectors (which displayed a change that had not happened), and the AVL Cruise M importer (57 return None sites against one warning call). All four now say what happened, once — repeating a full-disk warning every flush interval would bury the run's own output under the report of the problem.

The importer's report is deliberately narrow. return None after a failed lookup means absent, an ordinary default; return None from an except means present but unreadable. Only the second is a loss, and there are 7 of those. Announcing the other 50 would be noise, and noise is how a report gets ignored. The skips ride the InteropReport the reader already returned and the GUI already surfaces.

Library output became the caller's to silence: four of five print sites now go through the aeolus1d logger, and aeolus1d -q / -v work globally, with --log-level still winning. Default output is byte-identical — the formatter emits each message verbatim.

Four one-way ratchets now hold the line on library prints, silent swallows, noqa and type: ignore. The two suppression budgets are deliberately asymmetric: noqa is held exactly, type: ignore only bounded, because mypy-strict genuinely needs those comments and an exact ratchet would be a tax on typing correctly.

The staleness guard that closed the EPW drift now covers every vendor-gated suite. Dead-code scanning was measured and declined: vulture at the confidence that can see unused functions reports 577 findings on src alone, dominated by structural false positives (PEP 562 module __getattr__, pytest fixture injection, string annotations), so the measurement is the deliverable and no CI job was added.

A case that cannot run may no longer pretend to (2026-09-07). Reproduced first: a one-pipe case with length = -1.0 made aeolus1d validate print "OK — 1 pipe(s), no fatal issues" and exit 0, and then aeolus1d run integrated ten million steps to t = −1440 s, stopped only by the --max-steps cap. Pipe computes dx = L / N, so the sign went straight through into a negative CFL step, and the t_end clamp could never catch it — t + dt > t_end is never true when dt is negative.

Closed in four layers, because each one is reachable without the others: the validator names every bad field at once; the six driver dt assemblies refuse a non-advancing step; Pipe.__post_init__ rejects degenerate geometry, which is the only layer a network assembled in Python reaches; and the GUI's float fields will not take the value. validate_case and CaseValidationError are now public, so a Python-assembled network can be pre-flighted at all.

Two carve-outs were found by running the corpus rather than by reading the code, and without them the change would have broken twelve shipped cases: a junction's init is read by only two of its five kinds, so p = 0 elsewhere means absent; and a case with no pipes cannot fail to advance a network, so the two drive-cycle cases keep their t_end = 0.0.

Three further defects surfaced while closing it. A negative dt_fixed was silently discarded — the driver takes the fixed-step branch only on dt_fixed > 0, so the user asked for a fixed step, got a CFL step, and was told nothing. A plenum junction with init.p = 0 built without complaint and died mid-run on ZeroDivisionError. And make lint and the CI ruff job had already drifted to different path sets, so a green local lint did not mean a green pipeline.

examples/ and scripts/ are now linted (2026-09-07). They never were — 32 findings, including a dead variable in the ~3900-line EPW validation harness that produces every number the suite reports. Both lists now come from one LINT_PATHS variable, compared as sets by a test, because two copies of one fact with nothing forcing them to agree is the shape of nearly every bug in this arc.

The EPW exhaust temperature had drifted 66 K, and nothing could say so (2026-09-06). The blind-op T_EXH check reads -9.9 % against a 6 % gate. Bisecting rather than assuming: the recorded +1.1 % reproduces at the commit that wrote it (8b04fef3, 611.8 °C), so the reference was sound and the model moved — a quarter of the drop before 2026-08-11, three quarters after. The 1200 anchor moved with it (+0.3 % -> -3.5 %).

The cause is a fix, not a regression. 975211ec had the HP-EGR reservoir fed one hardcoded composition and T0 = 600.0 K at every one of the AVL map's 217 rows — against AVL's own measured 365-544 K, and carrying 31-45 % too little CO2 and H2O, the species that cool the flame. Correcting it cooled the charge (engine-out NOx 3.16x AVL -> 1.52x), so the exhaust should have got colder — and wall_h_conv, whose only job is to make T_EXH match, was left identified against the old, hotter model. That is pass_criteria.toml's own 2026-08-06 event repeating, when the caloric refit forced h*A 850 -> 550.

The refit is deliberately not done here. Refreshing a reference number to clear a red gate is what CLAUDE.md forbids, and doing it before the gates were honest would have hidden this a second time.

What is fixed is the reason nobody noticed for a month. Both checks that would have caught it need a vendor CSV that is not in the repo, so they skip in CI — and a skip reads exactly like a pass. ci/skip_budget.toml catches a skip set that grows, not one that was always absent, and the gated abs_err_T_EXH band is 40 K against a stated basis of ~7 K rms, so it absorbed the drift silently. A new guard fails when the suite's physics is newer than the date its numbers were measured, reading that date from one owner (run_validation.NUMBERS_MEASURED); it needs no vendor data and no engine run. The blind test is now xfail(strict=True), so the failure is declared rather than discovered — and a refit that makes it pass will fail until the marker and the recorded figures move together.

Every EPW figure has been re-recorded with its harness, its tree and its date, including what was not measured: the 1500 and 1800 anchors, whose runs were killed by host memory pressure.

The tool that entry named for the refit was itself broken (2026-09-07). BACKLOG.md said "scripts/tune_plenum_wall_heat.py is the tool". Its --anchor-bcs path wrote the case, and the harness's anchoring surgery setattrs _ANCHORED_EXHAUST_PLENUM_WALL_HEAT over the plenum junction afterwards — so no candidate h ever reached a cell and Nelder-Mead searched a constant. It does not raise and it does not warn: it "converges" and reports the starting point as the optimum. Verified at the call site rather than read off the source — setting wall_h_conv = 12345 and running the surgery leaves the junction at 377.

The shipped calibration is not void, which was checked commit by commit rather than assumed: the flag and the override arrived together, so that commit's own search predated the override, and every later anchored value (800, 1222, 850, 550, 377) has a hand-fit provenance recorded beside it. What the bug spoiled was the tool, at exactly the moment the backlog pointed the next person at it. Three guards now pin it, none needing vendor data — the anchoring surgery reads three columns and all three can be invented.

This is the same defect as the one above wearing different clothes: a value written in two places, with the wrong copy winning silently.

The refit was then measured, and refused (2026-09-07). Ten anchored runs over five operating points at wall_h_conv 377 and 150. The two figures recorded the day before reproduced to 0.05 °C, so the drift is real and the runs are deterministic.

The first thing the runs said was that the problem is bigger than recorded: the 1500 and 1800 anchors — whose runs had been killed by host memory pressure, leaving them unmeasured — read −47.1 K and −43.1 K. Two of the three calibration anchors are outside the gated 40 K band, not just the blind op.

The second thing they said is that no single value of the coefficient is defensible. A rule was written into BACKLOG.md before any number existed: fit on the two blind ops, hold out the three anchors, and require the blind pair to agree on h within 25 %. They want 57.1 and 196.3 — a 109.9 % spread. The fit that produced today's 377 spread 19 %; the 2026-08-04 refit was rejected at 35 %.

Why, measured over the five ops rather than argued: the required h correlates +0.18 with mean piston speed — the abscissa this closure actually uses — −0.52 with exhaust mass flow, where Dittus-Boelter requires strongly positive, and −0.89 with load. A convection coefficient must rise with mass flow. This one falls. It is a load-proportional error sink, which is what "the closure absorbs 25–35 % of fuel power" has said since 2026-08-03, now on five points instead of three.

A flat h = 150 would have put every operating point inside its gate at 6.82 K anchor rms — better than this closure's best-ever 7.1 K, and five red gates green in one line. That is exactly why the bar was set in advance. The open item is no longer "refit the coefficient"; it is "the abscissa is wrong". The blind gate stays xfail(strict=True).

Two claims in pass_criteria.toml were corrected rather than deleted, since when they stopped being true is the useful part: that the channel accepts either tolerance (its rel_tol was deliberately dropped), and that it could fail on its own residual "in principle" at 1.95× slack — the residual is now 47.1 K against a 40 K band, so the band is narrower than the residual.

A silently mis-phased engine, and eleven hand-copied defaults that had drifted from the schema (2026-09-06). A second sweep, aimed at duplicated defaults rather than missing wiring — the same number written down in two places, with nothing forcing the copies to agree.

  • firing_order plus one explicit theta_init_deg left every cylinder at phase 0, silently. Phase derivation is deliberately all-or-nothing: any explicit theta_init_deg on a named cylinder disables it, which is what keeps 89_epw_diesel.toml's six hand-written phases from being overwritten. But pinning the reference cylinder at the value it would have been given anyway —

    [[cylinder]]
    id = "c1"
    theta_init_deg = 0.0                      # reads as a no-op
    [crankshaft]
    firing_order = ["c1","c5","c3","c6","c2","c4"]
    

    — disables derivation for all six, and the other five keep the schema default of 0.0. A six-cylinder engine then fires every cylinder simultaneously. Nothing errored and nothing warned; the case ran. The all-or-nothing rule is unchanged — it is load-bearing — but a UserWarning now fires when opting out leaves fewer distinct phases than cylinders. Phases are compared mod 720, so -120 and 600 correctly count as one. Silent on all 235 bundled cases.

  • Every DPF cake, regeneration and trap field rendered with no unit. unit_for_field looks up f"{type(spec).__name__}.{field}", and eleven keys were still spelled FilterSpec.cake_* / FilterSpec.regen_* / FilterSpec.filter_density from before the CakeSpec / RegenSpec / TrapSpec split. The fields moved to the sub-specs; the keys did not, so every lookup missed and the Inspector showed kg/m³, J/mol, Pa·s on none of them. Re-keyed onto the owning class. The help popups were unaffected — _HELP is correctly keyed, which is why nobody noticed.

  • aeolus1d validate cried wolf on a bundled example. Validating examples/86_electrified_drive_cycle.toml warned "unknown top-level key 'powertrain' — the parser will silently ignore it". It does not: io/vehicle_case.py reads [powertrain] and builds the hybrid from it. The validator hand-listed four of the five vehicle blocks and never gained the fifth. It now reads vehicle_case's own _VEHICLE_BLOCK_ORDER, so the copy is gone rather than corrected. A warning that fires on a shipped, correct example is how users learn to ignore warnings.

  • Two stale case counts. docs/roadmap.md quoted "230 bundled cases" as if current — it is a dated measurement that counted 22 files a user cannot run, and is now marked as such alongside the real figure (213, owned by tests/unit/test_bundled_case_count.py). A test docstring froze "229 bundled examples" that nothing checked; the walk now reports the count it actually made, so it cannot go stale again.

  • A diesel's explicit knock retard was deleted by a GUI save. CylinderSpec.knock_retard_deg_per_event has a schema default of 2.0, but its effective default is mode-dependent: default_knock_pair gives a compression-ignition cylinder (0.0, 0.0), because a diesel burns by autoignition and the Livengood-Wu integral would otherwise read as knock every cycle (measured on the EPW HD diesel: PFP −18…−25 %). The writer compared against the schema literal, so on a diesel it suppressed exactly the pair a user is most likely to have typed — (2.0, 10.0) — and the reload resolved the mode default of 0.0. Reproduced on an r_c = 19.5 cylinder: save, reload, knock retard off, no diagnostic. The writer now asks default_knock_pair rather than carrying its own copy.

    Worth recording why the 689-field sweep missed it: that sweep forces every field off its default before round-tripping, and this defect needs a value that equals the schema default while the effective default differs. Wherever a field's default is computed from another field, the two sweeps have a blind spot between them. There are exactly four such fields in the schema; the other two (Kp, always written; lift_cad, a rename alias that writes the canonical spelling) are clean.

  • Nine getattr(case.case, "field", FALLBACK) fallbacks contradicted CaseHeader. The attribute always exists on a dataclass, so each fallback is dead code today — which is exactly what makes it a landmine: rename or drop the field and Python stops raising AttributeError and quietly substitutes the wrong value instead. Three were on cylinder_orifice_valve_flow, falling back to False where the schema, ADR-0008 and the comment directly above the call all say default-on, and where that same comment calls the coupling conservation-critical. One had chem_atol a thousand times tighter than the schema. 61 of the 70 sites already agreed; the nine now do too, and a test keeps all of them honest.

  • dpf_cake_in_newton_residual's help text still called it opt-in. Its default was flipped to True in v0.9 (schema.py records the flip); the field-help popup — the copy a user actually reads — still said "Opt-in: … Default False preserves the legacy path byte-for-byte". Its sibling catalyst_in_newton_residual flipped in the same tier and was updated, so this was a missed edit rather than a policy.

Also pinned, against future drift rather than a present defect: every scheme and rk the schema admits must appear in the validator's order-cap tables. The reverse direction was already tested; this one was not, so adding a scheme would have quietly dropped it out of the accuracy warning.

Clean negatives from the same sweep, recorded in BACKLOG.md so they are not re-derived: exactly one dead knob remains in 602 fields (filter_density, already open); no parser default disagrees with its schema default; no GUI writer default-gate does either; and the 54 test functions that contain no assert are benchmarks or use simplefilter("error"), so none is vacuous.

Four field-wiring defects fixed, found by sweeping all 689 schema fields (2026-09-06). Three were save_case silent drops — the GUI wrote a case back without a setting the author had made, so it reverted to its default on the next load with no diagnostic:

  • [pipe.species] caloric_thermo = false was flipped back to true. The writer emitted the flag only when it was true, and the schema default is true — so the one value worth writing was the one it could not write. This selects how pressure is recovered from the conserved state, so a lost opt-out changes results, not just a log line.
  • [[junction.port]] lodi_sigma was never written, reverting a tuned LODI relaxation to 0.25 on every re-save.
  • [cylinder.combustion] caloric_zone_split was never written, reverting to false on every re-save of a two-zone cylinder.

The fourth: aeolus1d-gui.exe --version opened the GUI instead of printing a version, as did python -m aeolus1d_gui --version. Only the aeolus1d-gui console script honoured the flag — the argparse parser declaring it runs on that path alone. It is now answered before Qt is imported.

The sweep that found them forces every field off its default before round-tripping, which is what the existing guard cannot do: that one only exercises values a bundled example actually sets, so a field every example leaves at its default was invisible to it however badly the writer handled it. A fifth finding, TrapSpec.filter_density — parsed, documented, ?-popped, and read by no code at all — is recorded in BACKLOG.md rather than fixed, because wiring it or deleting it is a physics decision.

Every field in the Inspector now has a ? help popup (2026-09-05). The final sweep added the 73 that the per-class passes below had missed — [[pipe]]'s own sub-block pointers (friction, heat, porous, catalyst, filter, urea, species, eos, thermal_wall, init, area_profile), [[turbo]]'s VGT / waste-gate / surge and gas-correction fields, and the small nested blocks (Wiebe stages, wall layers, FMEP coefficients, crankshaft firing order, VVT port actuation). 292 fields across 38 classes in total on the day.

The guard changed shape with it: instead of a file per class, one test now walks every dataclass in the schema, so a block added later is covered by construction rather than by remembering to list it. The one exclusion is the top-level Case container, which no form binds.

Earlier that day — the Inspector's ? popups gained 219 fields. Closing out a day of back-filling: [pipe.eos]'s tabulated real-gas grid and cv_poly, [pipe.heat]'s EGR-cooler / charge-air-cooler / flow-Nusselt kinds, [pipe.filter.csf]'s CO + HC oxidation, and [pipe.filter.thermal]'s cake / substrate thermal masses. Across all eleven passes, 219 schema fields across 13 classes that previously rendered no ? button at all now have one. Those eleven passes each closed a class the backlog had named; re-measuring against the schema itself afterwards is what turned up the remaining 73 the entry above finishes.

Every [[cylinder]] field now has a GUI ? help popup (2026-09-05). Same registry, same day: 15 of CylinderSpec's 37 fields — the Woschni wall-heat set (T_wall plus the T_head/T_liner/T_piston zone split, woschni_c1/_c2), the species-coupling trio (species_init, combustion_products, fuel_H_C_ratio), and the sub-block pointers — rendered no ? at all. All 37 now resolve.

Every [[junction]] field now has a GUI ? help popup (2026-09-05). Same registry, same day: 16 of JunctionSpec's 19 fields — every biased_plenum-only distributor field, the initial- state and port-list pointers, and the plenum-wall heat-loss closure (including its two operating-point-dependence laws) — rendered no ? at all. All 19 now resolve.

Every [[bc]] field now has a GUI ? help popup (2026-09-05). Same registry, same day: 14 of BCSpec's 44 fields — the v0.4 orifice enhancements (Cd_reverse, n_holes, borda_carnot), the Reynolds- dependent-Cd trio, every csv_* transient-schedule import field, and the egr_valve/throttle constant supply-side composition pair (Y_supply, Y_extras_supply) — rendered no ? at all. All 44 now resolve.

Every [pipe.filter.regen] field now has a GUI ? help popup (2026-09-05). Same registry, same day: 8 of RegenSpec's 13 fields (the mass-transfer cap, two-population soot, the NO back-inhibition constant, the active-regen heater controls, the HRR stability clamp) rendered no ? at all. All 13 now resolve, plus a class-wide "Open Documentation" link into the reference page's DPF-regen section.

Every [pipe.thermal_wall] field now has a GUI ? help popup (2026-09-05). Same registry, same day: ThermalWallSpec measured at 0 of 16 fields covered — nothing in the per-pipe dynamic-wall block (the two kind modes, the ambient/coolant/adiabatic outside-BC choice, axial conduction, the multi-layer layers pointer) had a ? at all. All 16 now resolve, plus a class-wide "Open Documentation" link into the reference page's dynamic-wall section.

Every [pipe.urea] field now has a GUI ? help popup (2026-09-05). Same registry, same day: UreaSpec measured at 0 of 19 fields covered — nothing in the SCR urea-water doser block (kind, the mdot_t/mdot_v dosing schedule, the anr_feedforward closed-loop controls, the five charge_cooling thermal constants) had a ? at all. All 19 now resolve, plus a class-wide "Open Documentation" link into the reference page's urea-doser section.

Every case-level solver/driver setting now has a GUI ? help popup (2026-09-05). Same registry, same day: 20 of CaseHeader's 73 fields — the QS-driver Picard/loop-flow knobs, the v0.9 implicit-advance flags (shaft_implicit_advance, species_implicit_upwind, multilayer_wall_implicit, …), and the validation-sensitive breathing/ urea-spray flags (cylinder_orifice_valve_flow, valve_stagnation_recovery, urea_spray_conserve_energy/_mass) — rendered no ? at all. All 73 now resolve.

Every [pipe.species] field now has a GUI ? help popup (2026-09-05). Same gap as WiebeSpec below, same registry: 30 of SpeciesSpec's 86 fields (mechanism_file, the fast-SCR / NH3-over- oxidation parameters, DOC sulfur-poisoning, the Voltz DOC inhibition constants, the Brinkmeier passive-SCR and propene-oxidation channels, the three variable-γ knobs) rendered no ? at all. All 86 now resolve.

Every [cylinder.combustion] field now has a GUI ? help popup (2026-09-05). The Inspector's per-field help button reads a separate registry from the docs/schema-block-reference.md pages, and it was never back-filled for WiebeSpec — 47 of its 56 fields (every si_*, diesel_*/diesel_watson_*, mz_*, inj_* predictive-combustion parameter) rendered no ? at all. All 56 now resolve, plus a class-wide "Open Documentation" link into the reference page's combustion-models section and SI units for every field that has a clean one.

Two new bundled examples close a species-advection coverage gap (2026-09-05). 93_tapered_species_muscl.toml and 94_tapered_species_first_order.toml are the first bundled cases that combine a variable pipe area, species transport, and (for 93) a reconstruction scheme above first-order — the exact combination the area-weighted species kernels (species_advection_primary_from_mdotA_jit, species_advection_extras_from_mdotA_jit, species_advection_primary_quasi1d_jit) need to run. Bundled-example count is now 213 (was 211).

aeolus1d cycle-settle now applies the same settings as aeolus1d run (2026-09-05). It used to hand-thread 8 of the 12 arguments every network driver shares, silently dropping multilayer_wall_implicit (implicit vs. explicit multilayer wall conduction — the one with a real physical effect on the result), pause/cancel support, a custom start time, and the step cap. All four now reach the driver, along with the RK fallback, CFL ramp and Δt-cap settings aeolus1d run already applies.

The bundled-example count is 211, not 232 (2026-09-05). aeolus1d examples (and the docs that quote its count) were counting 19 validation-harness tolerance files and 2 chemistry-mechanism files as runnable cases — neither has a [case] block, so aeolus1d run on one fails. The count, and the accompanying "N of 211 bundled cases run a scalar gamma" claim, are corrected everywhere they're quoted.

A diesel combustion-analysis bug fixed: burn_duration_deg could read 6x too large (2026-09-05). engine_post.heat_release_analysis()'s 10-90 % burn-duration metric anchored its 0 % point at the start of the analysis window rather than at the cylinder pressure trace's own pre-combustion low point. On a real measured trace with any heat loss before ignition, this could pin the "10 %" crossing right at the window edge, reading a 150+ degree "burn duration" that was actually window-edge-to-CA90, not a real 10-90 % span. Fixed to anchor MFB zero at the true pre-combustion minimum.

The single-pipe implicit solvers (implicit_euler, bdf2, radau_iia) can now be paused (2026-09-05). Pressing pause in the GUI, or writing paused: true into the control file, used to do nothing under these three drivers — they polled for a stop request but never checked for a pause — while every other driver (explicit, Strang, implicit-network, quasi-steady) already blocked correctly. Routed through the same poll_live_control helper the other five call sites use.

A pipe with no gas-energy source now warns once if it's fed by a transient inlet under solver = "quasi_steady" (2026-09-05). A plain pipe — no species, no catalyst, no filter, no [pipe.heat] or [pipe.thermal_wall] — silently holds its initial temperature for the whole run under the default steady_energy = true, because there is nothing for the energy solve to act on. That's arguably correct, but it looked like a boundary condition not being applied. A one-shot RuntimeWarning now names the pipe when this happens on one fed directly by a transient inlet BC.

chemistry_ops's per-pipe chemistry kernels lost their leading underscore (2026-09-05). _chemistry_step_on_pipe, _surface_mechanism_step_on_pipe, _chemistry_step_surface_lsoda and _bs_jit_applicable are now chemistry_step_on_pipe, surface_mechanism_step_on_pipe, chemistry_step_surface_lsoda and bs_jit_applicable — they were imported across module boundaries despite the underscore, so the old spelling was misleading rather than private. The old names still work, unchanged, as module-level aliases for one release.

advance_network_strang is deprecated in favor of advance_network(..., strang=True) (2026-09-04, EC-C2 Phase D). The two explicit-compressible network drivers — plain SSP-RK and Strang-split reacting flow — are now one public entry point. advance_network gained a strang: bool = False parameter plus the Strang-only knobs (chem_substeps / chem_solver / chem_rtol / chem_atol / chem_every_n / chem_interval / chem_parallel); the old name still works unchanged but now emits a DeprecationWarning and forwards to the new call. Nothing about how either model runs changed — the previous advance_network_strang body moved verbatim to a private _advance_network_strang_impl, so this is a zero-behaviour-change refactor (bit-identity regression unaffected). Only code that imports advance_network_strang directly (rather than going through aeolus1d.io.case.run_case / the CLI / the GUI, none of which name either driver) will see the new warning.

A junction port's area is now optional — it auto-derives from the connected pipe's own cross-section (2026-09-04). Previously every [[junction.ports]] entry had to spell out an area, even for a passthrough junction, where the value plays no physical role beyond being checked against the pipe for a mismatch — tedious to keep in sync by hand whenever a pipe's diameter changed. PortSpec.area is now float | None = None; when omitted, build_network resolves it from the connected pipe's own built face area at that end (via the new build._resolve_port_area, mirroring the existing BCSpec.A_pipe auto-derive pattern), for every junction kind that reads it (plenum, corberan, ehr, passthrough). validate_case's port-area-mismatch check now skips ports left unset — there is nothing to mismatch against. The GT-Power/AVL Boost/WAVE XML schematic exporter resolves an omitted area from the pipe spec at export time too, falling back to reporting the gap as lossy (rather than exporting a literal None) for the one case it can't know before build: a tapered area_profile pipe. The GUI's connect-drag flow (canvas → new junction port) now leaves area unset instead of writing a hardcoded 1 mm² placeholder, and the TOML writer omits the line entirely for an unset port so it stays unset across a save/reload round trip. An explicit area = is still honored and still checked for a mismatch, so nothing changes for existing cases that already set it.

A "Validate" action, next to Run (2026-09-04). Checks the case for validation errors without running it — the same in-process check action_run's own pre-flight performs (stage a copy, load_case it), exposed standalone. View menu / toolbar / Shift+F5. A sound case prints a one-line "no issues" confirmation; a broken one flags the offending components with the canvas's red warning halo and logs the full curated message to the runner dock console — the same reporting a failed Run's pre-flight already gives, just without spending a subprocess launch (or a cluster allocation, for aeolus1d validate's CLI twin) to get there. Uses a throwaway temp directory rather than the user-configured run-output folder, since a validate check produces no output worth keeping.

ControllerSpec, TurboHeatSpec and CoolantPipeSpec now have full Inspector field help (2026-09-04). The ? popup next to each form field is sourced from schema_help.py's own registry, separate from the docs/*-reference.md pages — a field with no entry there renders no ? at all rather than an empty one, and these three classes had zero entries between them (57 fields). ControllerSpec's 34 entries are compressed from docs/schema-block-reference.md's own already- reviewed per-kind field table, plus a class-wide "Open Documentation" link into that section — no comparable reference page exists yet for TurboHeatSpec or CoolantPipeSpec, so neither carries a doc link. Their entries are drawn instead from their own already- precise schema.py / component docstrings (the convection/radiation split, the coolant-inlet-vs-initial-condition distinction). Guarded by a new tests/unit/test_schema_help_coverage_2026_09_04.py so these three specifically can't silently regress; most of the wider registry (measured at 51 % coverage overall) is still open and unguarded.

Opening a case that fails validation no longer locks you out of it (2026-09-04). CaseModel.load used to propagate a fatal CaseValidationError for any issue (a junction port-area mismatch, an unknown reference, …) and File → Open turned that into a dead-end dialog — the case never loaded, so the only way to fix it was a text editor outside the tool built for exactly that. It now loads regardless (load_case(..., validate=False)) and reports what a second, validating pass found instead of raising for that class of problem — the same red canvas highlight and console message a failed Run now gives. A genuine parse failure (bad TOML syntax, a missing file) still refuses to open — there's nothing loaded to fix in that case.

The Windows exe's splash screen now appears the instant the process starts, not after the import cost it exists to cover (2026-09-04). It was a QSplashScreen raised inside main() — which can only run after from aeolus1d_gui.app import main has already paid PyVista/VTK's multi-second import cost, so the splash appeared right as the real window was about to anyway. Frozen builds now use PyInstaller's own bootloader Splash() (aeolus1d_gui.spec), drawn by the C bootloader before Python has imported a single module; app.py's _splash_text()/_close_splash() push real stage words into it and drop it once the main window is up. The QSplashScreen path stays as the dev-mode (python -m aeolus1d_gui) fallback, where there's no bootloader to draw the native one.

A new aeolus1d validate <case.toml> (2026-09-04) loads and validates a case without running it — the same load_case / validate_case pass aeolus1d run performs before building the solver, exposed standalone so a pre-flight step (a cluster submission script, a CI gate over a directory of cases) doesn't have to spend solver time or a compute allocation to find out a case is broken. Exit 0 and a one-line pipe/junction/BC summary on a sound case; exit 2 and the same curated file:line message run would print on a fatal issue. docs/cylinder-reference.md already referenced aeolus1d validate in passing (documenting an auto-retard-disable warning) — that line is no longer aspirational.

A run that fails case validation now highlights the offending components on the canvas (2026-09-04). action_run validates the staged case in-process (the same aeolus1d.io.validate.validate_case check aeolus1d run runs inside the subprocess) before ever launching it, so a case already known to fail doesn't burn a subprocess round trip to say so. Every component an Issue.path resolves to — the junction/pipe/bc/cylinder/etc. itself, and for a junction[j].ports[p] / cylinder[c].ports[p] issue the pipe on that port too, since a port-area mismatch is a disagreement between two components — gets the canvas's existing persistent red warning halo (the one import flows already flag problem components with), so a failed run points straight at what's wrong instead of leaving "junction[0].ports[1]" to be cross-referenced against the canvas by hand. Clears itself the moment the user edits a flagged component.

The layout canvas can lock component positions (2026-09-04). A new "Lock positions" toggle (View menu + toolbar, persisted via QSettings) stops a left-drag from moving a component. Selection still works while locked — only CanvasController._on_left_press's drag-arm is gated — so clicking a node to inspect it, shift-clicking to multi-select, panning, probe mode and port-connect drags are all unaffected. Off by default.

The GUI has an application icon (2026-09-04). Previously zero image assets shipped anywhere under aeolus1d_gui/ — no window icon, no .ico in the Windows build. aeolus1d_gui/build/make_icon.py procedurally draws one ("Pulsed Span, Ringed": a three-node pipe-network diagonal whose second edge is a travelling pressure pulse, enclosed by a wavefront ring) in the app's own palette (shell/theme.py / editor/visual_style.py), wired into the PyInstaller spec's EXE(icon=...) and into QApplication.setWindowIcon() at runtime for the source-checkout case too.

The GUI shows a splash screen on launch (2026-09-04). Raised right after QApplication construction — before MainWindow(), the actual multi-second call (VTK/PyVista canvas, every dock) — so it has time to paint and be seen rather than flashing for a frame. The image is the app icon's own mark, drawn directly onto a radial-gradient backdrop (no separate raster paste, so the gradient is continuous behind it — aeolus1d_gui/build/make_icon.py's build_splash/_draw_mark) with the "aeolus1d" wordmark and tagline baked in. app.py:_show_splash closes it via splash.finish(win) exactly when the main window has something to show.

AdBlue dosing now cools the gas (2026-09-04). [pipe.urea] gained charge_cooling, an opt-in energy sink covering the three ways an injected urea-water solution takes heat out of the exhaust: sensible heat warming the liquid to gas temperature, latent heat of the carrier water, and the endothermic decomposition of the urea itself. Before this the spray added mass and — under urea_spray_conserve_energy — matching energy, so dosing changed the gas temperature by nothing at all; measured on a seven-pipe SCR line, UWS at 0, 2e-4 and 2e-3 kg/s gave an outlet temperature identical to the milli-kelvin. The constants are shared with closures.urea_decomposition, which has carried this physics for a reaction closure that no case can currently reach. Default off, so every existing case is bit-identical.

A new [pipe.catalyst] thermal_mass_multiplier (2026-09-04) scales the substrate volumetric heat capacity. The sibling of h_multiplier. It matters most on the monolith form (cpsi/ofa/substrate), where rho_s_cs is derived by resolve_monolith_params and was previously not adjustable at all — so a case that correctly describes its brick by datasheet had no knob for its thermal inertia, which is exactly what a measured warm-up lag identifies. Available on the explicit form too, so "scale the brick's thermal mass" has one spelling. 1.0 = no scaling.

Transient inlet BCs can read their schedules from a file (2026-09-04). [[bc]] csv_path (plus csv_t_col / csv_T0_col / csv_mdot_col and the csv_delimiter / csv_skiprows parse controls) fills T0_t/T0_v and mdot_t/mdot_v from an SI CSV instead of inline arrays, for transient_mass_flow_inlet and transient_velocity_inlet. Paths resolve relative to the case file. Intended for measured boundary data, where inline arrays would put someone else's trace in a tracked TOML and a ten-minute log at 100 ms would put 6000 rows in a diff. Saving a case emits the path, not the expanded samples.

The file must be plain SI. A raw instrument export — decimal commas, ms, °C, kg/h — is refused with a message saying so rather than converted silently: examples/validation/sete_calibration/prepare_data.py is a worked converter. Two other loader refusals, both cases that would otherwise produce a plausible wrong answer: csv_path with no column index, and csv_path mixed with inline arrays for the same channel.

A new module, aeolus1d.solver.chemistry_ops (2026-09-03). Internal, no behaviour change: the stiff-chemistry sub-step kernels moved out of aeolus1d.solver.network_timeloop, the explicit-driver module, into a module of their own — _chemistry_step_on_pipe, _surface_mechanism_step_on_pipe, _chemistry_step_surface_lsoda and _bs_jit_applicable, 635 lines. The Strang and implicit drivers had each written out their own loop dispatching to the first two; they now call the new chemistry_ops.chemistry_step_on_pipes. The names keep their leading underscore for now, and network_timeloop re-exports all four, so from aeolus1d.solver.network_timeloop import _chemistry_step_on_pipe still resolves.

If you monkeypatch those kernels, patch chemistry_ops, not network_timeloop. A re-export is a second binding, not the one the drivers read, so patching the old name no longer intercepts anything. This is the one visible consequence of the move, and it fails loudly rather than silently — the in-tree test that did it lost every recorded chemistry call and failed on the assertion that any had happened.

This is EC-C2 Phase B's fourth hoist, and it takes the duplicated-block count between the four network drivers to zero by that census's measure (7 at the start of the phase). The measure's limits are written down beside it: it cannot see blocks under five lines, duplication inside a single driver, or differences in the order operators are applied.

The heat-release-rate Δt controller moved to the shared solver layer (2026-09-02). No numerical change — the bit-identity regression tier is green, and the arithmetic is the same operations in the same order — but two symbols moved and lost their leading underscore. _hrr_measured_fraction and _hrr_step_factor were private helpers in aeolus1d.solver.network_timeloop; they are now hrr_measured_fraction and hrr_step_factor in aeolus1d.solver.solver_ops, alongside the new HRRSnapshot, hrr_species_fraction and hrr_next_dt_limit. The old names are gone rather than aliased: they were private, and the point of the move was that the quasi-steady and implicit drivers should stop reaching into the explicit driver's module for shared physics. network_timeloop re-exports the new names, so imports from there still resolve. This is EC-C2 Phase B's first hoist.

The live-control poll moved to the shared solver layer (2026-09-02). No behaviour change on any driver that had it. Five sites — the single-pipe timeloop, the explicit and Strang network drivers, the implicit network driver and the quasi-steady driver — wrote out the same "poll the control file, block while paused, break on checkpoint_and_stop, take a fresh CFL" block; they now call aeolus1d.solver.solver_ops.poll_live_control, which returns (stop, cfl). The quasi-steady driver's live_control parameter is annotated StopSignal | None rather than object | None — a protocol naming the three methods it actually uses, so a structural stand-in is still accepted, by precision rather than by accepting anything.

Known gap, filed rather than fixed: implicit_euler.py polls and honours a stop request but never blocks on pause, so a single-pipe implicit run cannot be paused from the GUI while every other driver can. It was left out of the hoist deliberately — including it would have changed behaviour under cover of a refactor. See BACKLOG.md.

The junction neck-pipe union has one builder (2026-09-02). Internal, no behaviour change: aeolus1d.solver.solver_ops.network_pipes replaces three spellings of "the user pipes plus every junction-owned neck pipe" in the explicit, Strang and implicit network drivers. The quasi-steady driver does not use it and is unchanged — it solves junctions as steady pressure nodes and advances no neck pipe.

regression-bitid can now be run on a merge request (2026-09-02). It is when: manual with allow_failure: true: an ordinary merge request shows the job greyed out and pays nothing, while a refactor that needs the exact-comparison tier before merging can click it. Previously the job was main / tag / schedule only, so the signal always arrived after the merge. A played-and-red run does not by itself block the merge — GitLab has no "optional but blocking when played" for a single job, and a manual job with allow_failure: false blocks its whole stage, stalling every later job on every merge request. The job still runs automatically and blocking on main right after the merge. The tier remains host-dependent — compare a manual run against a main run on the same runner tag.

A run now says which calibrated constants it used, and where they came from (2026-08-30). The solver banner gains a calibrated constants reached: block listing only the groups the run actually constructed — a case with no aftertreatment is told nothing. Each row says which constants the case set and which it inherited, followed by the provenance: the citation, and how this project departed from it. Some of those departures were only in docstrings before, including that the Watson premixed coefficients are re-fitted rather than SAE 800029's published values, and that the multi-zone lift-off constant is tuned rather than the published 7.04e8.

The registry (aeolus1d.calibration_provenance) refuses a declaration with a blank provenance, mirroring load_pass_criteria's refusal of a report_only criterion with no reason — an ungated result must say why, and an inherited constant must say where from. Nothing was re-derived and no value changed; this is reporting.

Python 3.9 removed; the floor is 3.10 (2026-08-30). Deprecated in v0.3.3 with the removal announced there, per the project's own rule that a floor move must be preceded by a release that names it. v0.3.3 stays installable on 3.9 and is the last release that is — pip resolves to it automatically on a 3.9 interpreter, because requires-python is index-enforced metadata.

What went with it, which is the point of the change: the unit-py39 CI job (9.5 min on every pipeline, measured on pipeline 2781797137), the lock-verify-py39 job, the requirements-ci-py39.lock dependency lock, the :py39 Docker image row, and the repo-wide Optional[X] constraint — which existed only because 3.9's eval cannot parse X | None inside typing.get_type_hints(). io/schema.py is now written X | None.

Two things surfaced rather than broke. Moving ruff's target-version to py310 woke B905 (zip() without an explicit strict=) at 135 call sites — none of them new, all of them previously invisible because ruff does not emit that rule below 3.10. It is ignored with a stated reason rather than auto-fixed, because ruff's fix writes strict=False and would record 135 unexamined judgments as a permanent answer; tracked in BACKLOG.md. And the ruff-ignore comment being deleted claimed "every module carries from __future__ import annotations", which was false for three of the four files it governed — harmless, since PEP 604 evaluates natively on 3.10, but the claim was load-bearing for the ignore it justified.

ADR-0015 phases 1-4 and 6a: the network state is a flat buffer and two sweeps are compiled (2026-08-29). Pipe.U_pad is now a view into one network-wide buffer, so a kernel can address every pipe's state through a single array with nothing to keep in sync. Two per-step Python loops became one compiled call each: the plenum-port ghost fill, and the plain-MUSCL residual. On engine networks that is +9.7 to +10.8 %; cases that adopt neither sweep are neutral.

Bit-identical to the Python path under AEOLUS_FASTMATH=0, ~2.6e-15 under the default fastmath build, for the reason Phase Perf-18 documented.

Four things the ADR did not predict, all now recorded in it. Numba charges ~160 ns per array argument, so the first draft's 13-array kernel cost 2066 ns of pure dispatch and regressed a 2-port network by 5 % — packing the columns into three blocks took it to 414 ns. Both sweeps need a minimum size, and neither crossover is resolved because the bundled corpus has nothing between 3 and 12 pipes or between 2 and 10 ports. An n_ports property read twice per step cost ~1.5 % on a case that adopts nothing — more than the increment Perf-18 rejected as too small to bother with. And a persistent "the sweep owns this port" flag broke every driver that fills ghosts without sweeping, including the implicit one; 15 tests caught it, and the skip is now scoped to the pass that actually swept.

Phase 5 (global species IDs) and the rest of phase 6 are not built. This banks roughly a tenth of the ADR's 5.2 × bound.

The engine step is 80 % Python, and the first fused kernel takes ~9 % of it back (2026-08-28). Asked whether a TensorFlow-style computational graph would speed the solver up, we measured instead of guessing. It would not — a 480-cell network is far below the size where a GPU's ~150 µs launch floor pays, and the time-stepping is strictly sequential. But the profile answered a better question: on 39_4cyl_engine_turbo_scr only 20 % of wall time is inside @njit kernels. The rest is Python orchestration, and the share tracks component count, not mesh size — a single pipe is 91 % compiled, a 17-pipe engine is 14 %.

So the win is not faster numerics; it is less Python between them. Two call sites reached the same boundary-face flux through five dispatcher round-trips each — junction._pipe_port_flux_muscl and the scalar-γ branch of network_timeloop._left_boundary_mass_flux. Both now call one new boundary_face_flux_muscl_jit, the ideal-gas sibling of two fused kernels that already existed for the variable-γ and real-gas paths.

Load-gated A/B on the 4-cylinder case, three interleaved replicates: ≈ 8 % for the first, ≈ 16-17 % for both, 1359-1430 → 1142-1156 µs/step.

The second one matters for a reason beyond speed. Those two call sites are the two consumers of "the mass flux at face 0", and _species_rate telescopes every interior face off that anchor — so any discrepancy between them is a constant DC bias on every face, faking a uniform advective drift of any non-uniform composition, invisible for uniform composition and on first-order pipes. They were two hand-kept copies of one reconstruction. They are now one kernel, agreeing exactly.

One caveat matters for anyone continuing this. The fused kernel is not bit-identical under the default build: W_to_U_single is inline="always", so compiling its caller lets LLVM reassociate under fastmath and 13 of 26 port fluxes move by ~2.8e-13. Under AEOLUS_FASTMATH=0 — the only build in which test_golden_bit_identity.py asserts equality — it is exact, 26 of 26. The strict tier therefore has to be run explicitly on changes of this kind; a plain pytest tests/regression skips it.

Both of these docstrings claim the boundary flux "equals the flux the pipe residual computes at the same face". Under fastmath that claim turns out not to be checkable: every separately-compiled reconstruction of a single face disagrees with the kernel at ~1e-13, including an njit one built to match its compilation, and the residual exposes only differences of adjacent faces. It holds exactly under AEOLUS_FASTMATH=0 and is unverifiable otherwise. docs/perf-history.md Phase Perf-18 carries the retraction, the root-cause isolation and the full harness.

A component-seam suite, built from the bug history rather than from the code (2026-08-28). A value written in a case file crosses four surfaces before it reaches the answer: parser, spec, builder, driver. Almost every expensive defect in this project has been a value that stopped at one of them, silently, because the next surface supplies a plausible default. CylinderSpec.T_wall was in the schema and in the case files and was never read by _parse_cylinder, so every engine case ran adiabatic. py_reaction_surface was packed onto the pipe and never read by the explicit driver, so three shipped SCR examples did no SCR and reported success.

Three modules now measure those seams by perturbation rather than by assertion, so they need no list of field names and cover a field added next year on the day it is written:

  • test_seam_written_key_is_parsed.py — change each written value, re-parse, require the spec tree to move. 3024 values across the 81 bundled examples, all of them read, so the gate ships with an empty exception list. 12 s.
  • test_seam_capability_is_consumed.py — null each closure the builder packs between build and advance, and require the computed state to move. One recorded exception, measured: 16_bs_chemistry's py_reaction is a redundant second representation the bs_jit kernel does not read.
  • test_seam_writer_roundtrip.py — load, save, load, and diff the two spec trees. One recorded exception, the deliberate flattening of a component ref.

Each probe was verified against the defect it exists for: reintroducing the T_wall drop turns the first module red on six cases and names every cylinder, and the second keeps the 2026-08-18 surface-chemistry defect as an executable self-test. That check is not ceremony. Two earlier versions of the ablation probe reported a clean bill while measuring nothing, once because the lookup key matched no component and once because the digest hashed the very attribute being nulled.

The dead-JIT register got its own CI job, because it was measuring the runner (2026-08-28). test_no_dead_jit drives a subprocess that compiles the whole @njit surface against a deliberately cold numba cache, under a 900 s timeout. Run inside integration-slow — the project-wide -n auto, one worker per CPU — that probe competes with every sibling for the same cores, so a timeout says more about the box than about the code. Main pipeline 2797879100 failed it twice, original and retry, with four setup errors from one starved probe; the same sha ran the module in 259 s serially on an idle box, and the probe alone in 270 s.

The 900 s budget had been sized against a 198 s figure measured 2026-08-13 — eight days before CI moved off the maintainer's laptop onto aeolus-docker, a 2012 Xeon with no AVX2 or FMA that runs this tier 1.17–1.76× slower. The budget was calibrated on a host that no longer runs the job, and without the concurrency it now runs under.

So the module is --ignored by integration-slow and owned by a new integration-dead-jit, which runs it with -n 0 on merge requests as well as main — the register now blocks a merge instead of reporting after one. The timeout was not raised: that would have recorded a starved run as the budget. tests/unit/test_dead_jit_has_a_serial_home.py fails if the --ignore ever loses the job on the other side of it, since an uncollected module is not a pass, a fail, or a countable skip.

The new job's first run measured 606 s on aeolus-docker — 2.34× the 259 s on the maintainer's box, against a tier ratio of 1.17–1.76× that this change had projected 230–350 s from. Serial still fits, with 1.49× of headroom rather than the ~3× projected; the retraction is recorded next to the timeout, because the whole point of the change is that nobody re-measured a number after a host move.

Five models imported from a 1980s filling-and-emptying code — as methods, never as source (2026-08-27). Each replaces a constant the user had to guess with something the simulation computes. All four are opt-in: every number recorded on this project was produced without them.

Where a model had moved on since the 1980s, the modern form was implemented instead of the one being read. That is the whole reason to check.

Cylinder wall temperature is now an output. CylinderWallThermal solves three surface nodes — head, liner, piston — behind gas-side resistances, through metal to coolant and oil sinks. The formulation is Torregrosa, Olmeda, Degraeuwe & Reyes (2006), not the 1985 network of the same shape: that one rebuilt its metal-to-coolant resistance from an undocumented empirical curve with hard floors, tuned per engine, while the 2006 version writes every resistance as a function of geometry, operating parameters and material properties, so it transfers. The gas temperature is h-weighted, not time-weighted — the gas is hottest exactly where h is largest — and the liner area is half the stroke, the cycle-average that pairs with a cycle-averaged coefficient. Friction into the liner is a separate free function on purpose, so the cheap hypothesis can be tested before the network is trusted. Its 0.25 ring split is a starting value, not a measurement.

Alongside it, ChangHeatTransfer (SAE 2004-01-2996) joins Woschni and Hohenberg: instantaneous chamber height rather than fixed bore, T exponent −0.73 rather than −0.55 (derived from measured surface flux rather than a cycle-averaged balance), and Woschni's combustion term divided by 6.

Heat release can be mixing-controlled. burn_law="chmela" implements SAE 1999-01-0186: for the diffusion phase the rate is set by how fast fuel and air mix, and both controlling quantities — the fuel available to burn and the local turbulent kinetic energy — come from the injection itself. It is the only one of the three burn laws that responds to the injection rate; Watson and the Wiebe give the same curve for a single injection, a pilot pair and a ramped needle. Supply an InjectionRate; without one it falls back to a rectangle, which runs but has no rate shape. C_mod and C_rate are calibration constants as in every published use; C_turb and C_diss govern the turbulence sub-model and were not verified against the source paper — exposed as parameters rather than buried for that reason.

The burn shape follows the ignition delay. burn_law="watson" evaluates SAE 800029 directly, so the premixed spike sharpens with τ_id·N and the diffusion rate falls with φ. The Wiebe path had a fixed 8° premixed duration, which cannot phase correctly as load moves the delay — the mechanism behind the recorded "MFB50 runs 2.4–2.7 CAD early". It is not a Wiebe and cannot be made into one: a Wiebe's half-mass point never falls below τ = 0.100 for m ≥ 0, and Watson's premixed spike sits at 0.030. A bridge helper was written, measured, found ill-posed, and deleted.

Fuelling can be governed and λ-capped. SpeedGovernor (droop or isochronous, with anti-windup) and SmokeLimiter — the standard ECU function: cap fuel at what the trapped air burns at a minimum λ. Not a mechanical rack governor; production diesels have not worked that way in decades. λ rather than AFR, because λ transfers across fuels. Fed air, not charge — recirculated exhaust is mass and is not oxygen, and feeding it charge licenses exactly the over-fuelling it prevents, worst at high EGR.

Components can be shared between cases. A TOML table may name a library part with ref instead of embedding it, and override only what differs. Local keys always win. Refs resolve case-directory-first, then $AEOLUS_COMPONENT_LIBRARY. Note that save_case flattens references — it warns when it does, and the gap is tracked.

Bundled parts live in examples/parts/ and ship in the wheel, but are not cases: aeolus1d examples does not list one and the bundled-case count does not include one. examples/92_component_library.toml is the worked example.

Documentation: Theory: wall temperature, burn shape and fuelling and Recipe: wall temperature, burn shape & fuelling.

The two 2026-08-26 entries below are superseded on the numbers

They report NOx at 1.85× and the 1500 rpm ratio at 0.99. Both were measured with the EGR valve still open-loop and over-delivering, and with a compensating pair still in the post-processor. The current figures are in the 2026-08-27 entry directly below. The mechanisms those entries describe are unchanged and still correct.

The EPW EGR valve is now closed-loop on the delivered rate, and the compensating pair in the NOx post-processor is gone (2026-08-27).

The reservoir fix above left one half open: egr_open = egr_rate * 2.0 was the command, not a measurement, and colder EGR is denser, so the valve over-delivered 17–34 %. It is now a seed only. Each cycle the harness measures what the intake actually carries — CO₂ is the tracer, because fresh air carries none — and trims the valve toward the commanded rate.

Closing a loop on a lagged plant is not a one-liner, and four designs were measured and discarded before this one. Each is recorded at the constant that replaced it, so nobody re-derives them: a fixed relaxation of 0.7 with no warm-up finished worse than the seed (+32.7 %); fixed damping of 0.5 stopped the ringing and was too slow to arrive; adaptive damping alone still rang (0.2929 / 0.2776 / 0.2709 / 0.2773); a secant gain estimate hunted with a ~9-cycle period; and a fixed warm-up read a transient of 0.24 while the steady value was 0.53 and therefore opened the valve the wrong way. What works is to assert the slope the physics gives (unit), wait for the measurement to go quiescent rather than for a fixed number of cycles, and adapt only the damping.

Separately, element_resolved_nox_ppm no longer hands the integrator the trajectory mean of x_O2/x_N2/x_OH. All the NO forms in a narrow window, 5–25 °aTDC, while the mean is taken over the whole expansion including its long cold tail — the radical pool was under-supplied ~2.8× exactly where it mattered. The equilibrium pool is now resolved in pressure as well as temperature, over the trajectory's own range instead of at one 60 bar reference.

op AVL before after EGR error state
1200 rpm / 780 N·m 293.1 444.2 821.6 +17 % → −0.1 % converged, 13 cyc
1500 rpm / 1473 N·m 240.4 437.9 630.7 +17 % → +1.5 % converged, 22 cyc
1800 rpm / 1769 N·m 509.4 1157.4 1126.9 +34 % → +6.4 % not converged

The level rose and that is the correct outcome. 1.85× was propped up by two errors pushing the other way: over-delivered EGR cooling the flame, and an under-supplied radical pool starving the rate. Removing both leaves 2.71× on the converged ops. What improved is the shape, which is the part that was never right: the 1500 rpm dip now runs 0.768 against AVL's 0.820, from 1.94 before any of this work.

1800 rpm refuses to converge, and that is a result, not a failure. The trim drove the valve from 0.4289 to 0.0040 — effectively shut — and the intake still carried 22.8 % burnt gas, drifting 0.0003 over the last three of 40 cycles. That is a steady state, so the external valve is not the source. Valve mass flow is linear in open, so at 0.0040 against a 0.4289 seed the external path was passing about 1 % of its seeded flow — and a 1 % path cannot supply 22.8 %. Substantially all of that reading is cylinder gas, put there by backflow during valve overlap. Before this change the same op reported a clean settled cycle while delivering +27 %.

Which makes the probe itself wrong, not only the engine. HP-EGR joins the intake at a junction, so every pipe downstream of the mixing point is a runner bolted to a cylinder port, and backflow reaches all of them. The trim therefore drives the runner's total burnt fraction onto AVL's HP_EGR_Rate, which is the external rate; the two agree only where overlap backflow is small, which is exactly the two ops that converge. There is no clean pipe to move the probe to. The fix is to drop the tracer and form AVL's own definition from mass flows — egr_supply carries only EGR, intake_inlet only fresh air — which is immune to backflow. It is not in this entry: landing it needs a ~2.5 h re-run and will move every figure here, including the −114 K below. BACKLOG.md carries the diagnosis.

What is left is 114 K, not 200 K. With the dilution right and the compensating pair gone, a uniform flame-temperature offset of −120 K and −108 K closes the two converged ops — mean −114 K, spread 12 K. The interesting part is the energy: that is 6.7 % of the fuel LHV out of the core zone at both, so the required energy per kg fuel is constant while the required temperature is not. The model's own Q_WHT shortfall against AVL accounts for essentially all of it at 1200 rpm (6.45 %) and only a quarter at 1500 rpm (1.87 %), so wall heat is part of it and cannot be all of it. It is a residual and absorbs every other error in the chain; read it as a size, not a mechanism. The previous 200 K figure is retracted — it was sized against an error three times larger.

The EPW validation harness was recirculating the exhaust of a different engine (2026-08-26). The HP-EGR pickup reservoir was fed one hardcoded composition — {N2 0.749, O2 0.113, CO2 0.107, H2O 0.041} — and one hardcoded temperature, 600 K, at every one of the AVL map's 217 operating points. Solve O2 = 0.113 for the equivalence ratio that produces it and the answer is λ = 2.02; the three validated ops run at λ = 1.72 / 1.32 / 1.48, every one of them richer. So the recirculated gas was too oxygen-rich and carried 31–45 % too little CO₂ + H₂O, worst where λ was lowest. The temperature was 180–220 K above AVL's own measured T_HP_EGR_O column.

Both are now built per operating point by egr_reservoir_state() from that row's own MF_IA, MF_FUEL and T_HP_EGR_O.

The oxygen was only the visible half. The binding half is the missing CO₂ and H₂O: those are what actually cool a stoichiometric flame, entering the enthalpy balance with a large negative formation enthalpy and a high heat capacity. A charge can carry very nearly the right O₂ and still burn far too hot — which is how this survived an earlier "trapped O₂ agrees within 3 %" check.

Engine-out NOx against AVL, --ops 3 predictive:

op AVL before after
1200 rpm / 780 N·m 293.1 927.0 (3.16×) 444.2 (1.52×)
1500 rpm / 1473 N·m 240.4 1802.6 (7.50×) 437.9 (1.82×)
1800 rpm / 1769 N·m 509.4 2198.7 (4.32×) 1157.4 (2.27×)

Geometric-mean error 4.68× → 1.85×, mean |error| 399 % → 87 %, with no fitted parameter. Flame temperature fell 2715 / 2814 / 2863 → 2625 / 2605 / 2737 K, and the 1500 rpm flame is now the coolest of the three — which is what its 28.4 % EGR demands, and why AVL's 1500 rpm NOx dip goes from unreproduced (a 1500/1200 ratio of 1.94 against AVL's 0.82) to nearly reproduced (0.99).

It cost a real regression, recorded rather than worked around: colder EGR is denser, so the harness's open-loop EGR valve now passes more mass and over-delivers 17–34 % against AVL's commanded rate. T_EXH, Q_WHT and PFP all moved further in the direction they were already biased. All three are report-only in predictive mode and the suite still passes; the open-loop valve is now the dominant EGR defect and is tracked in BACKLOG.md. Reverting a measured input to preserve a compensating error was not an option — the old delivery was already +27 % at 1800 rpm.

Diesel NOx now burns in a flame sheet, not in a well-mixed zone (2026-08-26). New module aeolus1d.analysis.core_zone_temperature. It tracks Heywood burned-gas elements: each burns at phi_core = 1, entraining r_ox = O2_stoich / (phi_core * Y_O2_IVC) kg of the actual trapped charge, forms at the constant-pressure adiabatic flame temperature from a twelve-species equilibrium enthalpy balance, and is then compressed isentropically by the pressure history — which is what gives burnt gas a temperature distribution instead of a mean.

This replaces integrating NO on Cylinder.last_T_burnt, which is a mass-average burnt-zone temperature. That quantity is right for the cylinder energy balance and wrong for NO: a well-mixed zone sits near the global equivalence ratio, so on the EPW diesel it swung 582 K (1995 / 2429 / 2577 K) across the load range, and exp(−38370/T) turned that into a 330× swing in reported NOx against a reference moving 2.12×.

EPW op AVL well-mixed core-zone
1200 rpm / 780 N·m 293.1 17.8 927.0
1500 rpm / 1473 N·m 240.4 3964.9 1802.6
1800 rpm / 1769 N·m 509.4 5875.2 2198.7
mean |error| — 899 % 399 %
spread 2.12× 330.66× 2.37×

Validated against a number that was not fitted: stoichiometric CH_1.9167 in dry air, reactants at 298.15 K and 1 atm, gives 2267 K against a published 2250–2300 K. n_elements is a discretisation, not a parameter — the answer moves 0.5 % over 6 → 48 elements. Setting phi_core = phi_trapped reproduces the well-mixed zone, so the new model contains the old one as a limiting case.

EGR reaches the flame, not just the dilution. The entrained charge is the real trapped gas, so recirculated CO₂ and H₂O enter both the element pool and the enthalpy balance: Y_O2_IVC 0.2334 → 0.150 cools the flame 2666 → 2273 K. That is the mechanism a diesel NOx map turns on, and the well-mixed zone had no access to it.

Nothing in the solve moves. This is a post-processor with no feedback; every non-NOx channel on the EPW suite is bit-identical. NOX remains report-only — the model over-predicts by 4.68× with zero fitted parameters, and docs/release-readiness.md records that, AVL's unreproduced 1500 rpm EGR dip, and the one upstream cause of the G1 gate's failure (volume.m and sum(m_species) differ by 0.178 % at IVC, before combustion).

The extended-Zeldovich N₂O pair is re-derived, and the EPW NOx channel stopped being a constant (2026-08-26). make_zeldovich_nox_extended shipped two wrong constants, both in the magnitude of the N₂O channel's R4:

constant was now why
k4_A 2.9e10 m³/(mol·s) 2.9e7 that is 2.9e16 cm³/(mol·s), 300× above the bimolecular collision limit; GRI-Mech 3.0 reads 2.9e13, and the shipped k4_Ta already matched GRI, so only the cm³→m³ conversion had slipped (×1e-3 for ×1e-6)
K_N2O_A / K_N2O_Ea 1.1e-2 / 0.0 9.339e-5 / 9.1125e4 zero enthalpy gave N₂ + ½O₂ ⇌ N₂O no temperature dependence at all; derived from ΔrH° = +82.05 kJ/mol, ΔrS° = −74.225 J/(mol·K), fitted to 1.83 % over 1600–3000 K

Neither alone is a fix: reverting either leaves the rate 10²–10⁵× Heywood eq. 11.1. Together the total moves from 9.5e5–1.3e8× Heywood to 0.99–1.81×, and the N₂O share becomes 8.5 % at 2400 K rising to 62 % at 1600 K — the lean, low-temperature dominance the path exists to carry.

Consequence: NO now freezes kinetically. Moving nox_post's T_active_min gate 1500 → 1900 K shifts the answer by 0.08 %; it used to shift it 9.5× on an unchanged trajectory, because NO tracked equilibrium all the way down and the gate decided where it stopped. The EPW NOX channel spanned 1.04× across a 2× load range (306 / 312 / 318 ppm) and now spans 330×.

integrate_zeldovich_nox no longer returns a silent zero. Outside the active window the rate is exactly zero, so at a cold first sample BDF proposed a step spanning most of the interval and could leap a ~1.3 ms burn entirely — reporting success = True with an all-zero history. max_step is now capped at the caller's own sample spacing, and a converged all-zero result across an active window falls back to the equilibrium-limited envelope with a warning.

If you depend on the old numbers: pass the previous constants explicitly to make_zeldovich_nox_extended. Nothing else in the solver moves — the NOx post-processor has no feedback into the network solve, and every other EPW channel is bit-identical across the change.

Two retractions came with it, both recorded rather than deleted: the 2026-08-08 "burnt zone above ~2500 K, two compensating errors" inference was read off two ops that were the integrator artefact above; and the 2026-05 "well-mixed burnt zone peaks at 1960 K, needs a local-φ model", marked superseded at the same time, was right about the temperature (1995 K re-measured at the same op) and is now the live diagnosis. NOX stays report-only: it reads 17.8 / 3964.9 / 5875.2 ppm against AVL 293 / 240 / 509 at peak burnt-zone temperatures of 1995 / 2429 / 2577 K.

Two multi-zone diesel defaults are now calibrated values (2026-08-25). mz_c_liftoff moves from the published 7.04e8 to 6.225e8, and mz_c_tau_id from a derived 25.14 to 16.7. Both are prefactors on correlations whose form is unchanged, so the effect is a clean scaling:

quantity at before after ratio
ignition delay 60 bar, 900 K, air 1.003 ms 0.666 ms ×0.664
lift-off length 1000 K, 30 kg/m³, 150 µm 7.52 mm 6.65 mm ×0.884

Both shorten, which shifts the burn toward mixing-controlled. If you rely on the previous values, set mz_c_liftoff and mz_c_tau_id explicitly in your [combustion] block — no case that sets them is affected.

This follows a policy change: calibrated reaction-rate and physical-property constants may now ship as defaults, while engine geometry and engine-specific hardware may not. mz_bowl_diameter therefore still defaults to 0.0 (⇒ 0.6·bore) rather than any calibrated value. Eight of the ten constants shared with the reference implementation already agreed before this change. Provenance is recorded in examples/validation/DATA_MANIFEST.md.

Species advection on a tapered pipe now uses the scheme's mass flux (2026-08-25). On a variable-area MUSCL or MP5 pipe, density advanced on the reconstructed face fluxes while the species advanced on a first-order HLLC of the raw cells, so the two disagreed about how much mass crossed each face — 14.95 % on the per-cell species divergence for a tapered test pipe.

Corrected 2026-08-25: this entry first said "one shipped case is affected: 39_4cyl_engine_turbo_scr.toml's scr_brick". No bundled case is affected. scr_brick sets needs_quasi1d but is uniform-area, so the uniform flux path handles it; of the 17 quasi-1D species pipes in examples/, 16 are first_order and excluded by design and the 17th is that one. The fix is reachable by a user case, not by a shipped one.

Variable-area pipes now telescope the area-weighted flux (G_{i+1}A_{i+1} = G_i A_i − A_cell·dx·R), which is exact because the quasi-1D mass row is a pure divergence. First-order pipes — sixteen of the seventeen shipped variable-area species pipes — keep the original kernel and stay byte-for-byte identical, because there the first-order flux already is the scheme's.

solve_housing now actually solves the housing (2026-08-25). Shaft reaches the integrator through accumulate_rates(), which carries no dt, so nothing stepped the turbocharger housing node: a case setting solve_housing = true got a node frozen at housing_T_init for the entire run. Both drivers now advance it once per step — the explicit driver's stage-1 junction loop and the implicit driver's per-step equivalent — and they agree on the result to better than 1e-6 K. Wiring only one would have made the knob behave differently under rk = "bdf2" than under ssp_rk2.

No shipped case sets solve_housing, so nothing else moves: the regression tier is green at default fastmath and bit-identical under AEOLUS_FASTMATH=0.

Three SCR examples were running with no SCR chemistry (2026-08-25). 42_scr_urea_injector, 43_scrf and 45_two_stage_scr set no solver key, so they took the schema default explicit — whose species source reads only the gas-phase reaction attributes and never py_reaction_surface. They ran, reported success, and did nothing: NH3 surface coverage stayed at exactly 0.0 for the whole run, against 2.18e-3 under strang. All three now set solver = "strang", chosen over quasi_steady because they are short acoustic transients whose acoustics the quasi-steady driver would drop.

The same release makes that configuration impossible to hit silently: solver = "explicit" now refuses a case carrying surface chemistry rather than running it without. Raising, not auto-routing — quietly substituting a different driver would fix the number and leave the user believing the explicit driver integrates surface coverage.

Multi-zone diesel combustion and zone-resolved NOx (2026-08-24). A packet-based, mixing-controlled DI-diesel model: each injection packet seeds a zone, zones entrain air as they penetrate, fuel evaporates then mixes, and the heat-release shape is an outcome rather than a prescribed Wiebe. Select it with kind = "multizone_diesel"; examples/89_dd13_multizone.toml is a worked case and the theory page carries the correlations, their sources and their validity ranges.

The reason for it is emissions. NO goes roughly as the fifth power of temperature, so a bulk-average cylinder temperature cannot predict it — the distribution is the point, and the test suite asserts it directly: a {2600, 1800} K zone pair must out-produce two zones at their 2200 K mean.

Ships with it: a reduced-order XPI injector and common-rail model (aeolus1d.closures.xpi_injector) that turns rail pressure and needle lift into the packets the zones need, and zone-resolved NO (aeolus1d.closures.multizone_nox) reading equilibrium radicals from the twelve-species solve rather than partial-equilibrium correlations.

Two shipped defaults were wrong by orders of magnitude, and only an order-of-magnitude check found them. The lift-off constant defaulted to 1.0 where the published Siebers value (SAE 2005-01-3843) is 7.04e8 — the correlation has no natural scale without it. The ignition-delay prefactor gave a 40 nanosecond delay where a diesel is about a millisecond. Every sensitivity test passed in both cases: the exponents, the pressure inverse and the activation temperature were all exactly right against a correlation whose absolute scale was meaningless. Both are now anchored to published values or to a stated physical target, and both carry the correction in their docstring.

The parser held a third copy of those two constants, and its copy won. Corrected 2026-08-25. _parse_cylinder read every mz_/inj_ key with a hand-transcribed fallback — cb.get("mz_c_liftoff", 1.0) — and because the parser passes each field explicitly, that fallback overrides the dataclass default for any case carrying a [cylinder.combustion] block. So both corrections above were silently reverted on every real case: the lift-off constant went back to 1.0 and the ignition delay back to 40 nanoseconds. The parser now reads its defaults off WiebeSpec itself, so each constant has one owner and the divergence cannot recur.

The path this was found by is worth recording, because it was not the test suite. scripts/gen_example_python.py emits only the fields that differ from the dataclass default, so the stale fallback made unrelated spark-ignition examples grow a mz_c_liftoff=1.0 line, and the CI docs job failed on eight "stale" generated files. Six of the eight were not stale at all — they were reporting the bug. make docs-check now runs that generator check too; it previously ran the reference gates and not the one the CI job actually fails on, so a green local run said nothing about the gate.

And the GUI writer's multizone_diesel branch dropped ten of the thirteen diesel_* fields. It emitted diesel_soi_deg, diesel_inj_duration_deg and diesel_afr_stoich and nothing else, so a multi-zone case carrying diesel_cetane_number = 48.0 re-saved as the 50.0 default — silent calibration loss on a GUI round-trip. Those fields are inert for the multi-zone physics, which is exactly why dropping them looked reasonable; round-trip fidelity is about what the case carries, not what the selected model reads. Both diesel branches now write one shared field tuple so they cannot drift apart again, and the mz_/inj_ list is derived from WiebeSpec rather than transcribed.

examples/89_dd13_multizone.toml also carried name = "dd13_diesel" from the case it was derived from — two examples writing results under one name — and its generated builder still said kind='diesel_predictive'. Both fixed.

K_N in the NOx path is now derived from NASA-7 Gibbs data for N₂ ⇌ 2N rather than correlated, closing a 2026-07 audit TODO. The shipped pair overstated [N] by 367× at 1800 K — and moves NO by under 0.02 %, because [N] enters the reduced rate only through a term proportional to 1/[N] that is already negligible. That is both why the wrong value survived an audit and why NO data cannot be used to calibrate it.

Equilibrium gained four species, and the pressure term was wrong (2026-08-24). chemistry/equilibrium.py now solves the twelve-species set {CO2, CO, H2O, H2, O2, OH, NO, N2, H, O, N, NO2} — four dissociation equilibria (½H2⇌H, ½O2⇌O, ½N2⇌N, NO+½O2⇌NO2) added to the four it already carried. Atomic N and NO2 entered the bundled NASA-7 database from Burcat/Goos; N reproduces ΔHf°(298.15) = 472.68 kJ/mol exactly.

Adding them exposed a pre-existing error in the same module: the pressure term in the log-form residual applied 0.5·ln(p/p_ref) where K_p = Π xᵢ^νᵢ·(p/p_ref)^Σν requires the full ln(p/p_ref). Compositions at 1 bar were unaffected; at engine pressure they were not. At 101 bar and 2400 K the minor species came out 2× high and atomic H and O 4.6×, while CO2, H2O and N2 stayed within 0.5 % — the signature of a weak pressure term rather than a thermo error.

Two independent sources confirm the correction. Against a 6,223,620-point external equilibrium tabulation the composition now agrees to a median 0.66 % across the sampled grid. And _DISSOC_DE — an independent Gibbs minimisation already in components/cylinder.py — had had an unexplained 1.3–1.9× disagreement pinned against it by test_dissociation_energy_agrees_in_shape_only, whose docstring said "both cannot be right". The ratio is now 0.996; that side was right all along.

The old pressure test asserted only the direction of Le Chatelier, which any positive exponent satisfies. It now asserts the p^(-Σν) magnitude on every reaction quotient.

The NOx path's [N]ₑ is derived rather than correlated (2026-08-24). closures/pollutants.py carried (K_N_A, K_N_Ea) with a 2026-07 audit note that the pair was unattributed and matched no recognisable dissociation enthalpy. With atomic N in the database it is now derived from NASA-7 Gibbs data for N₂ ⇌ 2N: K_N = 2.0616e7·exp(-9.4277e5/(R·T)) mol/m³, fitted Ea 942.8 kJ/mol against the ~945 kJ/mol dissociation — which settles which equilibrium the constant encodes. The shipped pair overstated [N] by 367× at 1800 K. It changes the NO rate by under 0.02 %, because [N] enters the reduced rate only through a term proportional to 1/[N] that is already negligible — which is both why the wrong value survived and why NO data cannot be used to calibrate it.

v0.3.3 (patch, tagged 2026-08-24, published 2026-08-24) — the canvas shows what a pipe contains, and the Wayland fix reaches the wheel

0.3.2 went out on 2026-08-19 and main moved 61 commits across 15 merges without a release. Four of those merges changed shipped GUI behaviour — !453, !457, !461 and !463 — and none of it reached a user. Everything below was already merged and tested; what this release does is ship it. The gap had a user-visible cost: pip install aeolus1d on a Wayland session gave a GUI that would not start, and aeolus1d doctor could not say why.

On PyPI since 2026-08-24, 11:11 UTC. Verified against the index rather than the job status: info.version is 0.3.3 on PyPI and TestPyPI, each listing ['0.3.0', '0.3.0rc1', '0.3.0rc2', '0.3.1', '0.3.2', '0.3.3']. The published wheel was then opened and checked to carry what this release exists to ship — aeolus1d_gui/qt_platform.py (the Wayland fix), the four icon-set modules (editor/icon_styles.py, roles.py, icon_geometry.py, visual_style.py), shell/form_factory.py and cli/doctor.py — 624 files including 85 doc pages and 15 tutorials, against 0.3.2's 84.

No solver change. Every golden reference and validation number is unchanged from 0.3.0. Re-verified against the tree rather than inherited: git diff v0.3.2^{commit}..main -- src/ touches two files, and one of them (_numba_flags.py) is a docstring retraction. The other is the doctor's new display section.

Why this is a patch although it carries a feature. The component icon set is a new default canvas style, which under most conventions would be a minor bump. This project's stated rule (docs/release.md, "Versioning") reserves minor for breaking changes until 1.0, and nothing here breaks: Legacy reproduces the previous canvas exactly, pinned by a golden test, and every .toml that loaded under 0.3.2 loads unchanged. Recorded because the trade was considered rather than defaulted into.

Python 3.9 is deprecated

Scheduled for removal in v0.4.0, which raises the floor to 3.10. This is the announcement release — nothing about 3.9 changes here, and the unit-py39 gate still runs before every tag. Removing a supported interpreter without a release that announces it first is the objection that stopped this once already; docs/installation.md carries the reasoning and the request: if you are 3.9-bound, say so on the issue tracker before v0.4.0. The 3.9 floor was adopted deliberately for HPC environments, and if that constituency cannot reach 3.10 the floor does not move.

Aftertreatment is reachable from the pipe inspector (2026-08-23). The four fields that decide what a pipe is — filter.kind, species.reaction, catalyst.kind, porous.kind — now appear together in an Aftertreatment group at the top of a pipe's Advanced settings. They were always editable, but not equally findable: filter sits 7th among 11 advanced fields and holds 7 fields, while species sits 9th and holds 86 — so setting a DPF was easy and setting an SCR was, in practice, not. Reported from real use. The full sub-forms still render below; the shortcut writes the same attributes.

The canvas now shows what a pipe contains (2026-08-23). A pipe carrying an SCR, a DPF, a DOC, a catalyst substrate or a porous medium is drawn with a family pattern — mesh for wall-flow filters, honeycomb for monoliths, waves for reduction chemistry, dots for porous media — over a member-specific tint. A plain pipe still looks plain. Urea injectors get a positioned band; dynamic walls, coolant coupling and DPF heaters get rims that differ by slot and dash rather than by colour. Patterns collapse to a solid tint when the network is zoomed out, so a whole layout stays readable.

The iconography is a selectable style: Schematic is the new default and Legacy reproduces the previous badges. Colours are theme-aware across all seven canvas backgrounds — the old grey filter badge was near-invisible on the light themes because it sits on the canvas background rather than the pipe body, and that is now fixed rather than reproduced.

Not in this release: junction, boundary-condition and turbine iconography. Those kinds carry no tint, so geometry alone would have to separate them at small sizes, and no safe form of that was found. They keep their current appearance.

Canvas iconography became a pluggable style (2026-08-23). The rules that decide what a component block draws moved out of editor/decorations.py and behind a registry: a style supplies pattern geometry, colour and precedence, and new styles are added by registering rather than by editing the renderer. Ships with Legacy, which reproduces the previous canvas exactly — pinned by a golden test, because a refactor of the only code that draws is otherwise indistinguishable from a redesign. No visual change in this release.

One deliberate exception to byte-identity: the catalyst and filter corner badges now darken on a light canvas. They sit at |y| = 0.145 while the pipe capsule ends at 0.09, so they are drawn on the canvas background rather than the pipe body — which is why the grey filter badge was near-invisible on the light themes. Reproducing that faithfully would have shipped a known bug.

The hover tooltip stopped claiming every pipe has a catalyst and a filter (2026-08-23). It tested is not None against PipeSpec.catalyst and .filter, which are field(default_factory=...) and therefore never None — so a default pipe hovered as "catalyst / filter / DPF". It now reports what the pipe actually contains, via a new classifier (aeolus1d_gui/editor/roles.py) shared with the canvas, so the tooltip and the block can no longer disagree. An SCRF pipe reads "SCRF — SCR-coated wall-flow filter" with its co-roles listed; a plain pipe says nothing extra. This is the first stage of the component icon set; no canvas visuals change yet.

The same commit closes a hole in CI: gui-tests' changes: filter did not list src/aeolus1d/closures/**, so the MR that registers a new closure kind — exactly the MR the new exhaustiveness test exists to catch — did not run the GUI suite at all.

The GUI launches on Wayland again (2026-08-22). aeolus1d gui died before painting with X Error ... BadWindow on any Wayland session. Qt selects its wayland plugin there, so winId() is a Wayland surface pointer, and VTK's X11/GLX build hands it to X as a window id. The GUI now selects Qt's xcb plugin before QApplication is built — only on Wayland, only when XWayland and libxcb-cursor0 are present, and never over an explicit QT_QPA_PLATFORM. When it cannot repair the situation it now says which library to install instead of failing with an X error, and aeolus1d doctor gained a display / GUI section reporting the session type, DISPLAY, the plugin the GUI will use and why — with a prominent block, naming the symptom and the fix, when the GUI cannot start at all. The doctor probe imports the GUI's own decision function rather than a copy, so the two cannot drift. Not a version problem: PySide6 6.11.1/6.11.2, pyvistaqt 0.11.2/0.12.0 and VTK 9.5.2/9.6.2 all behave identically.

Junction port areas in the species test cases are exact (2026-08-22). They carried a rounded 1.96e-3 against a true bore cross-section of 1.963495e-3 — 0.178 % out. The solver has been warning about it on every build, and the warning is not cosmetic: the junction volume credits flux x port area while the pipe debits the same face at its own cross-section, so the mismatch was a permanent mass/energy source leaking 0.178 % of the through-flow at each port on every step. Both cases now derive the area from the diameter. Tests only.

Solid-reservoir conservation: the catalyst substrate (C1b-1, 2026-08-21). The closed-network conservation audit now carries a solid reservoir, not just gas and junction volumes. Omitting the substrate from the inventory reads a ~60 % energy imbalance, so the gate has 3 to 15 decades of margin per driver. The non-vacuity criterion is on energy EXCHANGED rather than on a solid temperature rise: the substrate outweighs the gas it exchanges with by 1080x, so even complete equilibration moves it 0.56 K, and the inherited "20 K rise" criterion was unreachable by ~36x. Tests only; no solver behaviour changed.

The implicit block-split conservation imbalance is decomposed (2026-08-21). test_conservation_audit.py gates it and blamed frozen ghosts; that reading is confirmed (it is neither Picard- nor Newton-limited — tightening Newton four decades changes nothing) but does not explain the 580x spread between drivers. A second, driver-dependent quadrature term does, and the measured dt-scaling shows radau_iia is cleanly O(dt) while bdf2's mass and energy errors are governed by different terms. The junction re-weighting fix is declined, with reasons recorded. Documentation and one test docstring only; no solver behaviour and no reference number changed.

Cross-driver parity now covers a reacting-species channel (2026-08-21). The parity harness that landed with !443 compared the drivers on a cooled profile only, so it said nothing about the chemistry seam — the seam the species-misalignment bugs (!100 / !101) scrambled. tests/integration/test_driver_reacting_parity.py adds that channel: the converged reacting state across strang/ssp_rk3, strang/ssp_rk2, implicit/implicit_euler and quasi_steady, compared on Y_CO, T and theta_Ce separately, on the non-canonical extras layout the importer produces. Proven by a seeded-fault ladder — a CO↔CO2 stoichiometry column permutation, the faithful analogue of !100/!101, goes red at a 43x margin, and freezing coverage goes red on the coverage channel alone. Tests only; no solver behaviour changed.

Perf gates report their number on the passing path (2026-08-21). Every gate helper in tests/benchmarks/ used to report only by raising, so a failing perf run printed its measurement and a passing one printed nothing. On the v0.3.2 tag pipeline perf-budget passed and its whole 110-line log carried one number — pytest's own 8 passed, 78 deselected in 62.25s — never the 80.0 s total self-time it gates. Neither perf gate could be trended across green releases.

All three gate helpers now emit one JSON record per measurement, on every exit path, through _timer.emit_measurement: the gated value, the ceiling and reference it was judged against, the verdict, and the harness that produced it — host, CPU, numba build flags, marker selection, CI job, and the load average sampled at the measurement. perf-budget and wallclock-budget collect it as perf-measurements.jsonl with when: always, so a tripped gate keeps its number too. No reference number, ceiling or gate changed. See docs/perf-history.md for the schema and how to read it off a pipeline.

GUI — panels with no way back can no longer be closed (2026-08-20). Three docks were closable but unrecoverable: the Palette, the Component list and the Runner strip. View → Panels is what makes a close reversible, and its entries come from _right_pane_panels, which lists only the eleven right-pane docks — so the × on those three was a one-way door, and QMainWindow.saveState then persisted the hidden state into every later session. _lock_unrecoverable_panels now drops DockWidgetClosable from any dock the submenu does not list (Movable and Floatable are kept, so the layout is as rearrangeable as before), and _reopen_unclosable_panels runs after restoreState so a layout stored by an earlier build cannot keep them hidden. The set is derived, not named: a dock added later is un-closable until it is registered in _right_pane_panels.

Verified on the real MainWindow (not the test stub): of its fourteen docks, exactly PaletteDock, ComponentListDock and RunnerDock lose the close button and all fourteen stay movable; a layout saved with the palette and runner hidden reopens with both up. test_panels_menu.py covers both halves against the usual QMainWindow + QDockWidget stubs.

v0.3.2 (patch, tagged 2026-08-19, published 2026-08-19) — the GUI runs on Python 3.9 again, and the docs ship

Both items reported the same day by one user installing 0.3.1 into a Python 3.9 environment. Neither touches the solver: every golden reference and validation number is unchanged from 0.3.0.

On PyPI since 2026-08-19, 10:43 UTC. Verified against the index rather than the job status: info.version is 0.3.2 on PyPI and TestPyPI, and the published wheel carries 84 doc files (15 tutorials) where 0.3.1's carried none. Re-checked end to end by installing aeolus1d==0.3.2 from the index into a fresh Python 3.9.25 venv — the reported crash path runs clean and docs_dir() resolves into site-packages.

The GUI crashed at startup on Python 3.9

pyproject.toml promises requires-python = ">=3.9". Launching aeolus1d gui on 3.9 raised:

File ".../aeolus1d_gui/shell/form_factory.py", line 2198,
  in _classify_optional_primitive
    if origin not in (Union, types.UnionType):
AttributeError: module 'types' has no attribute 'UnionType'

types.UnionType is 3.10+. Four sites in form_factory.py used it unguarded. The tuple (Union, types.UnionType) is built before the membership test, so this fired on the first form field of any type — the GUI was unusable on 3.9, not merely limited.

src/aeolus1d already had the right pattern (io/validate.py, io/parsers.py both reach the name via getattr with a sentinel); only the GUI package did not. The fix also avoids the trap io/parsers.py records: a bare (Union, getattr(types, "UnionType", None)) is worse than the crash, because get_origin(float) is None and every non-generic hint would then compare equal to the None member.

pip install shipped no documentation or tutorials

The 0.3.1 wheel contained zero doc files. All 84 pages under docs/, including the 15 tutorials, were in the sdist only — and pip installs the wheel. In the GUI this surfaced as Help → Documentation showing "Documentation not found" and the Tutorials menu showing "(no tutorials found)", with no Documentation URL on PyPI to fall back on.

docs/**/*.md now ships in the wheel under aeolus1d/docs/, resolved at runtime by aeolus1d._resources.docs_dir() across the same three layouts as the bundled examples. The wheel grows 2.27 MB → 2.94 MB.

Guards added

  • tests/unit/test_python_floor_compat.py gained a post-floor stdlib name check. Its existing check catches new syntax; types.UnionType is a new name, which parses everywhere and fails only at runtime. Run against the published 0.3.1 wheel it reports all four sites, line 2198 among them.
  • tests/unit/test_docs_packaging.py checks the wheel actually carries the docs — previously this was asserted only in a pyproject.toml comment, and the assertion was false.
  • aeolus1d_gui/tests/test_py39_type_resolution.py pins the 3.9 semantics, including that plain types must not register as unions.

Also corrected

pyproject.toml justified having no Documentation URL by stating the docs "ship INSIDE the sdist and wheel". That was half false, in the half that mattered. The paragraph is corrected in place rather than rewritten.

The claim that GUI dependencies have no Python 3.9 build is retracted: PySide6 publishes cp39-abi3 wheels and the GUI imports fine on 3.9.25. A real py39 GUI CI lane — which would have caught this — is filed in BACKLOG.md.

v0.3.1 (patch, tagged 2026-08-17, published 2026-08-18) — two install-time fixes, no solver change

Both reported the same day by a user installing into a fresh environment. Neither touches the solver: every golden reference and validation number is unchanged from 0.3.0.

On PyPI since 2026-08-18, 08:21 UTC. release-pypi on tag pipeline 2765689646 uploaded aeolus1d-0.3.1-py3-none-any.whl and aeolus1d-0.3.1.tar.gz; info.version reads 0.3.1 on PyPI and TestPyPI alike, verified against the index rather than inferred from a green job. The tag pipeline went red on its first run and green on a plain retry of the same commit — an unrelated local OpenFOAM job was holding a third of the runner box; no reference number was changed. STATUS.md carries the before/after.

aeolus1d gui crashed on Python 3.9, which pyproject.toml promises (requires-python = ">=3.9"):

File ".../aeolus1d_gui/shell/inspector.py", line 30, in <module>
  IdChoicesProvider = Callable[[type, str], list[str] | None]
TypeError: unsupported operand type(s) for |: 'types.GenericAlias' and 'NoneType'

Two module-level type aliases used PEP 604 X | None, which needs 3.10. from __future__ import annotations defers annotations, not assignments, so an alias is evaluated at import and the two constructs are indistinguishable in a diff. Both now use Optional[...].

The gap was narrower than first written. Correction (2026-08-17): the release commit message for v0.3.1 says "CI has only ever run 3.10". That is wrong — CI runs unit-py39 and lock-verify-py39 on a dedicated :py39 image. The real hole is that the py39 image deliberately bakes only [dev,real-gas,chemistry-yaml], because there is no py3.9 build for the GUI and interop wheels, so aeolus1d_gui is never imported on 3.9 at all; gui-tests runs on :py310 only. The floor is exercised for src/aeolus1d and not for the GUI package. That is also why the new guard reads source rather than importing: while PySide6 has no py3.9 wheel a real py39 GUI job cannot exist, so a static check is the only thing that can cover it — and it needs only ast/pathlib/pytest, so it runs on the lean py39 image too. tests/unit/test_python_floor_compat.py closes that: it reads requires-python from pyproject.toml and asserts every file in src/ and aeolus1d_gui/ both parses at that floor and evaluates no PEP 604 union at import time. Source-based, so 3.10 CI catches a 3.9 break, and deliberately conservative — Qt flag ORs, set unions and numpy masks are legitimate runtime | and are not reported.

aeolus1d gui blamed the [gui] extra for a broken interpreter. A missing _lzma produced GUI dependencies missing (No module named '_lzma'). Install via: pip install -e ".[gui]" — wrong twice over: _lzma is a CPython standard-library module no extra can supply, and -e "." assumes a checkout the reporter did not have. aeolus1d.cli._import_diagnosis now separates an incomplete interpreter from a missing extra, naming the system package per platform; aeolus1d doctor gained a matching probe.

v0.14 (pre-release) — the burn that responds: predictive SI combustion goes multi-cycle

aeolus1d gui no longer blames the [gui] extra for a broken interpreter (2026-08-17). A user's fresh environment produced

GUI dependencies missing (No module named '_lzma').
Install via: pip install -e ".[gui]"

and both halves were wrong. _lzma is a CPython standard-library extension module, built only when liblzma headers are present when the interpreter is compiled, so no project extra contains it and the suggested command could not have worked; and pip install -e ".[gui]" assumes a source checkout, which someone installing from PyPI does not have. The cause was structural — one except ImportError around the whole transitive import of aeolus1d_gui.app, attributing every failure to the extra.

aeolus1d.cli._import_diagnosis now classifies the failure: a conditionally built stdlib module (_lzma, _bz2, _ssl, _sqlite3, _ctypes, _tkinter, _curses, readline, zlib, _hashlib, _uuid) reports that the interpreter is incomplete and names the system package per platform; anything else keeps the install hint, now offering the PyPI form first. aeolus1d doctor gained a matching probe that prints INCOMPLETE PYTHON BUILD when it finds one and stays silent otherwise. The solver itself does not need _lzma — a case loads and runs without it.

A pure-Python builder for every bundled example (2026-08-17). All 79 examples were TOML and none had a Python sibling, so the surface a user embedding aeolus1d in a script, an optimiser or a test harness actually composes against was taught nowhere. Every example now ships one under examples/python/, bundled into the wheel beside the cases.

They are generated, by an emitter (aeolus1d.io.pysource) that recurses on dataclasses.fields() and knows no schema block by name — so unlike the hand-enumerated GUI writer it cannot silently drop one, which is the defect class behind the quasi-steady knobs (MR !106), the urea block, the controller layer and the tabulated-EOS grid. Three guards per example: a builder exists, it is byte-identical to what scripts/gen_example_python.py emits, and executing it yields a Case equal field by field to the parsed TOML. A fourth asserts every spec class in the whole schema stays emittable, so a block carrying a callable is caught when it is added rather than when someone writes an example that uses it. --check runs in the docs CI job.

The assembly ladder grew one page per component family (2026-08-17): closures (friction / wall heat / porous, and writing your own), species and chemistry, aftertreatment, engines, and turbo + control. Every snippet and number on all six pages was executed before it was written, which is how two of them turned up findings rather than prose:

  • PipeState.D_h is geometric, FrictionSpec.D_h is configured, and they are unrelated. Reimplementing darcy_const as a UDF gave 7.903 Pa against the built-in's 14.098 Pa — a factor of 1.784, exactly sqrt(4A/π) / D_h. With the pipe area matched to the configured diameter both give 144.318782 Pa (residual 5.8e-11 Pa, @njit-vs-NumPy summation order). docs/udf/friction.md showed that snippet and called it the built-in model; corrected with the measurement, and the underlying name collision filed in BACKLOG.md.
  • The quasi-steady/explicit gap is an order of magnitude wider with porous drag — 19.895 K on a monolith brick against the 1.96 K the cross-driver parity harness measures on a heat-only duct, both converged (identical at t_end 0.5 s and 1.0 s). QS carries no momentum-flux term, which stops being a small omission once a brick drops tens of kPa.

The component surface — Pipe, Volume/Port/Junction, BC factories, advance_network — is taught by hand instead, in a new tutorial (docs/tutorials/python-assembly.md), because assembly order is a thing to learn rather than to transcribe 79 times. Every snippet on that page was executed before it was written, and it closes by building one model both ways: the hand-assembled network and the declarative Case agree bit for bit. New index page: docs/examples-python.md.

Cross-driver steady-state parity harness (2026-08-17). Three modules already pinned driver parity structurally — every driver calls each shared operator (AST), applies urea / coolant / walls / γ-refresh, and its heat fold has an effect. None compared the drivers to each other, so all three would pass if a driver applied a closure at half strength, folded it at the wrong stage weight, or capped its own split at order 1 — the B01 / B07a class. tests/integration/test_driver_steady_parity.py runs one network through ssp_rk2, ssp_rk3, implicit_euler, bdf2 and the quasi-steady driver and compares the converged states, where the time integrator has dropped out of the statement (rk2 and rk3 agree to 3.26e-06 K, three and a half decades below the nearest cross-driver difference). Bounds are 3× each measured offset.

The grid was chosen by measuring the gate's power rather than by taste: at N = 12 a driver applying the heat closure at 95 % strength slips through (10.81 K against an 11.08 K bound), at N = 24 it is caught, and the harness resolves sub-1 % errors on the implicit drivers. Verified by injection, not assumed — dropping the closure entirely reads 307.8 K, 90 % reads 17.0 K, 95 % reads 9.3 K, all red; 99 % on the quasi-steady driver is missed, which the module states rather than leaves to be discovered. Δp parity covers the compressible drivers only: the QS driver reports exactly 0.0 Pa because it solves a momentum balance with no momentum-flux term, and that omission is pinned as a known difference instead of papered over with a loose tolerance.

Two latent C1 defects, found by using the model in anger. The predictive SI entrainment model could only ever burn ONCE per simulation — its spark seed requires m_burned <= 0 and reset_cycle() had zero callers in the tree, so cycle 1 fired and every later cycle silently motored (all prior tests ran under one cycle). The cylinder's cycle-counter bump now re-arms the model, pinned by a multi-cycle regression test that fails with the fix removed. And CycleAccumulator reported eta_i = 0.0 on SI-model cylinders — neither its fuel-mass nor its LHV fallback knew si_combustion; both branches added and pinned.

scaffold_single_cylinder_engine(si_combustion=...) swaps the prescribed Wiebe for the C1 turbulent-flame entrainment model (the Wiebe is not built at all on that path — nothing dead carries a configured m_fuel·LHV).

Mendeley ethanol/water gains --predictive-burn — the dilution-sensitive burn the 2026-08-15 retraction filed as the real gap. Water slows the computed burn as Metghalchi-Keck S_L dilution and through φ (only the ethanol is fuel); trapped charge is measured per (rpm, CR) with a motored pre-pass; one anchor constant pins φ = 1 at the first zero-water row (the scaffold has no throttle). Calibrated on the zero-water column only, the water columns are untouched prediction: 43/60 vs the Wiebe baseline's 39/60, with the 50 %-water over-prediction cut from 9.27 to 7.39 bar (measured ≈ 5.7–6.6) and the healthy end reproduced identically. The remaining corner gap is combustion-EFFICIENCY loss (real dilution leaves fuel unburned; the entrainment model always finishes) — filed in BACKLOG.md. Off by default; the CI gate runs the baseline arm.

v0.13 (pre-release) — fuel as mass: inventory, charge cooling, and the ledger that survives a valve

The scaffold can now deliver fuel as MASS, not only as Wiebe energy. scaffold_single_cylinder_engine gained fuel_mass_inventory, h_vap_fuel, fuel_T, fuel_cv, and caloric_thermo kwargs. With the inventory on, m_fuel_per_cycle arrives as an injector trapezoid across the intake window (carburetted/port-fuelled shape) carrying sensible energy cv·T minus the latent heat h_vap_fuel its evaporation draws from the charge. All off by default — the historical energy-only scaffold is bit-identical. Passing h_vap_fuel without the inventory switch warns instead of silently doing nothing (the flag class that voided a measurement arm this same cycle). Wrap-around intake windows (IVO before TDC, e.g. open=700, close=190) now build correctly instead of crashing injector construction.

Fuel is conserved across valves whose pipes cannot carry it. Intake blowback used to debit the cylinder's fuel into a pipe with no fuel column, where the identity was dropped and never credited back — measured at 26.3 % of per-cycle fuel destroyed in the charge-cooling configuration, with m_fuel_burnt under-counting by 36 % while the Wiebe released full energy. Each such port now carries a mixed-slug "pocket" that hands the fuel back as the flow re-enters; the ledger closes to machine precision (tests/unit/test_fuel_port_pockets.py). A Wiebe drawing ahead of its injection schedule now leaves a readable diagnostic (the cumulative demand/supply accumulators) rather than silently nothing — deliberately not a warning: two warning designs were tried and both fired on the healthy EPW diesel, whose burn phase-leads its injection every cycle (see BACKLOG's "prescribed Wiebe + inventory" item for the falsified designs and the real fix's shape). Pressure traces are unaffected — the energy channel was and is prescribed.

caloric_thermo warnings are for people who asked. The cannot-engage RuntimeWarning (no tracked species → no NASA-7 table) now fires only when the flag was explicitly written — on the Cylinder constructor or in the case file — not for the TOML schema default that every species-less case inherits. The GUI writer also stops stamping caloric_thermo = true into every saved case, and no longer drops an explicit caloric_thermo = false on save (which used to reload as true).

Mendeley ethanol/water: charge-cooling diagnosis retracted, baseline drift caught. The suite gained --charge-cooling / --caloric A/B arms; measured over all 60 points, charge cooling moves the prediction away from the data (39/60 → 36/60) — the residual at 40–50 % water is combustion deterioration, not charge density (see the suite README's retraction). The reconciliation also surfaced that the documented 44/60 baseline (2026-06-09) scores 39/60 on the current tree — bisected the same day to 643fc3fb (2026-07-01): a knock-integral bug had silently retarded combustion 10° on this scaffold, so the 44/60 was bug-flattered and 39/60 is the honest score (closure in BACKLOG_HISTORY.md); the CI gate moved off its zero-margin setting (0.65 → 0.60) with both measurements recorded. Cooling-arm eta is now reported on the paper's liquid-basis convention (measured: zero gate outcomes change).

v0.12 (pre-release) — turbocharger casing heat became configurable

A closure existed that no case file could reach. TurboHeatTransfer and TurboHousing shipped in v0.11 but had no TOML surface, so the only way to attach non-adiabatic casing heat was to build the Compressor / Turbine by hand in Python. New [turbo.heat] block, parsed onto TurboSpec.heat and materialised in _build_turbos.

Absent from a [[turbo]] block, the machine stays adiabatic — every case that predates the field is bit-identical, and the two conductances default to zero so an empty [turbo.heat] is a no-op rather than a surprise.

One shaft is one housing. When solve_housing is set, the node lives on the Shaft and every machine bolted to it shares the same one. Building a node per machine would silently model two thermally isolated turbos on one shaft — identical in the config, a different system — and would lose the turbine -> compressor casing path that is the reason to solve the housing at all. A second [[turbo]] declaring conflicting housing_* fields warns and keeps the first.

TurboHousing.coupled_T solves the wall temperature where the gas-side supply equals the ambient-side loss, by bisection on a strictly monotone residual. It replaces an under-relaxed fixed point that converged with radiation on (loop gain 5.8) and diverged with it off (gain 25.2), where it reached a 1258 K pipe wall while still moving 531 K per iteration — and reported PASS, because a diverging loop still returns a number. The sibling steady_T remains for the "what temperature rejects this known heat" question.

Also [turbo.heat] is now emitted by save_case, whole rather than non-default-only: the presence of the block is itself meaningful (absent = adiabatic, present-but-zero = deliberately disabled), and round-tripping through omission would erase that distinction.

Fixed: Turbine.step / Compressor.step cleared every last_* field on their idle / backflow / choke early returns except last_heat_W, so a no-through-flow step reported the previous flowing step's casing heat. Harmless to a reader, wrong for anyone integrating it — a pulsating turbine feed spends many steps not flowing, and each contributed a stale copy to the housing balance.

v0.11 (pre-release) — the knock controller stopped retarding diesels

Two defaults were wrong in the same way: they described a spark-ignition engine and were applied to every engine.

CylinderSpec shipped knock_retard_deg_per_event = 2.0 / knock_retard_max_deg = 10.0 for every cylinder. The knock criterion is a Livengood–Wu autoignition integral, and a diesel burns by autoignition — so the integral crossed its threshold every cycle by design, was read as knock, and retarded until it saturated. Measured on the AVL EPW HD diesel: all six cylinders pinned at 10°, knock integrals 9–11.6 against a threshold of 1.0, recovery (0.5°/cycle) far slower than re-trigger, and the retard applied to every Wiebe stage — costing peak firing pressure −18…−25 %.

validate had warned about exactly this since 2026-08. A warning does not change a number.

New [[cylinder]] ignition field — "auto" (default), "spark", "compression". "auto" resolves at parse time from the same discriminator the validation rule uses (compression ratio ≥ 14 with a combustion kind other than si_turbulent), now shared in io/_ignition_mode.py so the two can never disagree. A compression-ignition cylinder defaults the knock controller off; writing either knob keeps your value on any engine. examples/88_dd13_diesel.toml moves — it had been taking the SI defaults. 89_epw_diesel.toml is unchanged: it already wrote explicit zeros.

SSP-RK3 component inventories now use the scheme's stage weights. Cylinder and plenum species/fuel inventories were integrated from an unweighted mean of the stage rates. That is exact for SSP-RK2, whose stages enter with equal coefficients, but SSP-RK3 in Shu–Osher form weights them (1, 1, 4)/6 — so the inventories were first-order accurate against a third-order-accurate conserved state, with an error exactly equal and opposite to the "integrate the last stage" approach it had replaced. The weight now rides on the accumulator rather than threading through the duck-typed accumulate_rates() protocol, so the protocol is untouched and the quasi-steady and implicit drivers — which never set a weight — stay bit-identical. Regression goldens unmoved.

A degenerate composition no longer yields γ = 1.0 (ADR-0014). Of every value the mixture helpers could return for an all-zero composition, 1.0 was the worst: p = (γ−1)ρe becomes identically zero, the sound speed collapses, and the failure surfaces as a bare ZeroDivisionError from inside the HLLC kernel. The policy is split by seam: the scalar mixture_gamma fails loud with a message naming the composition; the vectorised MixtureThermoTable.gamma — a per-cell hot path — stays finite at 1.4 and warns once per instance.

A wrong-length A_face is rejected before it reaches an @njit kernel. The general branch of solve_quasi_steady_single_pipe accepted any length and handed it to a kernel indexing A_face[i+1], compiled with boundscheck=False — a silent out-of-bounds read whose SIMPLE loop still reported convergence.

Three leaks. The env-gated QS debug JSONL sink held its descriptor for the life of the process (it was released only when the path changed); notebook.quick_pipe_plot registered a pyplot figure per call and never closed it; and the first-launch Welcome dialog was parented to the main window without WA_DeleteOnClose, so it survived the whole session.

New CI gates, each proven red when its fix is reverted: explicit encoding= on text I/O, Qt dialog disposal, a legacy-default-flag drift register, an Arrhenius-clamp register, and intra-docs anchor resolution (now wired into make docs-check, whose comment had claimed a gate it did not run).

numba now resolves to >=0.58,<0.68 (was <0.66), so 0.66 and 0.67 install. The project had been running three numba versions at once — 0.64.0 in the locks, 0.65.1 in the CI image, 0.67.0 on the dev box — because the image's build stage never copied hatch_build.py and so every rebuild had been failing since the hook was introduced, freezing the image at its last successful push with no error anyone saw. All three locks are regenerated (llvmlite 0.46.0 → 0.49.0 alongside).

Two consequences worth knowing if you type-check against this tree. numba 0.67 ships py.typed; its Dispatcher.__call__ therefore exposes the ordinary Python annotations on @njit kernels, which numba itself never enforces (it dispatches on runtime types). mypy consequently checks a contract that does not exist, so pyproject.toml declines numba's and pyvista's published types via follow_imports = "skip". Every aeolus1d module remains fully gated.

v0.10 (pre-release) — the charge model became caloric by default

caloric_thermo is now the default, and it closes a level error that no amount of burn recalibration could reach.

A cylinder's charge was calorically perfect: c_v fixed by [gas] gamma, with temperature dependence supplied by hand through gamma_burnt / gamma_unburnt. Those are per-case calibration, and on the AVL EPW diesel the legacy gamma_unburnt = 1.35 implies a c_v about 13 % too high for fresh air — under-predicting compression pressure at every operating point. Evaluating c_v(T, Y) from NASA-7 polynomials instead removes it.

Measured as a matched A/B on three anchor ops, anchored mode, with all three shipped edits reverted at runtime for the "before" column:

channel before after
IMEP_High mean |err| 16.01 % 2.86 % 5.6× better
BMEP mean |err| 23.84 % 6.05 % 3.9× better
Q_WHT mean |err| 24.22 % 9.66 % 2.5× better
T_EXH rms 11.72 K 7.36 K 1.6× better
PFP mean |err| 2.19 % 3.14 % worse
NOX 0 ppm 0 ppm unchanged

PFP degrading while IMEP_High improves 5.7× is recorded as a loss, not folded into the wins: a peak pressure and a work integral are not optimised by the same charge model. It sits against a 20 % band.

Two calibrations moved with it, because a model change invalidates anything fitted against the old model. The EPW exhaust-plenum h·A was re-identified 850 → 550 W/K (it had been fitted to the old charge), and the zone γ constants were re-derived from the caloric model itself — 240,342 samples of the energy-consistent γ over a converged cycle giving 1.3780 / 1.3424 against the hand-set 1.35 / 1.27. Note gamma_burnt = 1.27 lay outside the entire range the caloric model ever visits. Those constants are inert under the new default; they are the calibration for the caloric_thermo = false fallback.

The h·A refit is orthogonal, proven three ways. IMEP_High, PFP and Q_WHT come back identical at every h·A from 500 to 850 — so the refit closes a real gap rather than absorbing error from a channel it has no business touching.

NOx was re-diagnosed, and the new diagnosis is the more definite one. The old note blamed a "constant-γ burnt-zone peak-T artefact", implying the caloric model would fix it. It does not — NOx is bit-identically 0 both ways. The well-mixed burnt zone peaks at 1960 K against the 2200–2800 K a diesel flame reaches, so extended-Zeldovich correctly integrates to ~0. That is structural: a single burnt zone holding all the products sits near the global equivalence ratio, whereas diesel NOx forms in a stoichiometric flame sheet. It needs a local-φ / stoichiometric-core model, not kinetics tuning.

[Superseded 2026-08-08 — this paragraph is wrong.] The zero was the integration window, not the zone temperature: narrowed to [IVC, EVO) the same trajectory yields 306 / 312 / 318 ppm. Disabling the N₂O path then showed the burnt zone running above ~2500 K, so the temperature claim inverted as well. "More definite" was the tell — it was more definite, and less true. Left in place because a changelog is a record of what was believed; the correction belongs beside it, not instead of it.

The default is composition-gated, and that is why no golden snapshot moved: the NASA-7 table attaches lazily once a charge composition is resolved, so a species-less cylinder still runs the legacy path. Both golden cylinder cases are species-less. Pinned by a test asserting the spec says True and the built volume says False, so "the snapshots did not move" can never be confused with "the flag silently stopped working".

Costs: roughly 1.9× wall clock on a cylinder case (the temperature comes from a Newton inverse), and any fitted wall closure elsewhere needs re-identifying for the same reason the EPW one did.

v0.9 (pre-release) — validation reaches the Python API

The validator suite had exactly one call site, and moving it found a live mass leak.

validate_case — schedule ordering, junction and turbo port areas, BC static params, init plausibility, resource bounds, unknown keys — ran only inside the TOML text parser. A Case assembled in Python and handed to build_network got none of it, which is the route every importer, validation harness, notebook and test takes. It now runs at the build boundary (build_network(..., validate=True)); errors raise, warnings print once, and the error checks re-run even on a case loaded from TOML because mutating a loaded Case is the documented way to override a setting.

On the first run it caught the GT-Suite native importer shipping a 1e-3 m² port-area placeholder on 98 junction ports and all 4 turbo faces of the bundled F2 model — the unbounded-leak class, wrong by up to 96.8 % (the air-filter port read 1e-3 m² against a real 0.0314 m², a factor of 31). _add_port stamps the placeholder because the connected pipe is usually not parsed yet, and nothing replaced it. Now resolved in an order-independent post-pass that warns rather than silently keeping the placeholder.

The ascending-abscissa precondition now enforces itself. np.interp and _interp1d_scalar both require a non-decreasing abscissa and neither can detect a violation — they return a plausible number from the wrong bracket. The only guard was TOML-path-only. aeolus1d._tables.ascending_or_raise now checks at table-build time across every transient BC schedule, both valve lift tables, and four public-API sites that had nothing: RCNetworkBattery, hybrid.Battery, order_analysis and combustion_noise_db. The battery pair matters most — OEM open-circuit-voltage tables are conventionally published from full charge to empty, so a descending SoC_grid is not a typo, it is the format the data arrives in.

radau_iia no longer claims an order it cannot deliver on a network. The scheme is genuinely 5th order (4.86-4.99 on a single pipe, tableau verified against Hairer & Wanner II.7), but the network block-split freezes junction volume through the pipe Newton solves, so every implicit scheme measures order ~1 on any case with a junction. docs/numerics.md carries the measured table, and validate_case warns when it sees the gap — silently, on junction-free cases, which really do reach formal order.

parallel_pipes stopped churning threads. It built a ThreadPoolExecutor inside the Picard loop — up to three pools per step, ~1e6 thread creations on a long run — for a path that cannot execute in parallel because no Newton-path kernel is compiled nogil. The pool is now process-lifetime and enabling the flag warns that it measured ~2× slower than the serial loop.

Documentation. Six schema blocks ([[shaft]], [pipe.thermal_wall], [pipe.filter.regen], [pipe.urea], [[controller]], [cylinder.combustion]) now have a complete field reference with a coverage guard; BACKLOG.md shed 53 closed items to BACKLOG_HISTORY.md; and three dead doc anchors were fixed — an em-dash in a heading is deleted and its surrounding spaces collapse to one hyphen, which mkdocs --strict does not catch because unresolved anchors are reported at INFO.

v0.9 (pre-release) — QS solver hardening + chem-accel

Two silent-wrong-answer bugs closed, both in unguarded interpolation / conservation seams.

Junction port area is now checked against the connected pipe. Junction.accumulate_rates credits the 0-D volume F·A_port while the pipe residual debits the same face at the pipe's own cross-section; the two only balance when the areas agree, and nothing checked. Measured on a closed two-pipe + plenum network: matched areas conserve mass to 4e-16, a 2× mismatch drifts +3.1e-3 in 2 ms and keeps growing. PassthroughJunction has enforced this at 5 % since it shipped; the plenum / EHR / Corberán kinds — the defaults — went unguarded. Running the new _validate_junction_port_area over the bundled examples found the leftover of commit 30c2294f: that fix corrected a mm→m error in two EPW stubs' pipe geometry but left the junction port areas derived from the old millimetre diameter, so both shipped with a port/pipe area ratio of exactly 1e6 = (mm/m)². Fixed, along with 8 hand-rounded port areas in 39_4cyl_engine_turbo_scr. Errors above 5 %, warns above 0.1 %, and names the mm²/m² slip when the ratio is a clean power of 1e6.

The same defect in the injector, with a worse symptom. Auditing every other interpolation abscissa found Cylinder._fuel_injection_rates looking up inj["cad"]/inj["rate"] with the same wrapped theta. An injection event crossing the seam — a late post-injection at 700 deg -> 20 deg, entirely routine — makes the cycle normaliser trapezoid(rate, cad) come out negative (measured -690), and the runtime's norm <= 0 guard then skips the injector entirely: zero fuel, no error. Both crank-angle tables now share one seam-aware CyclicCadTable. Time-domain schedules can't wrap but can still be mis-ordered, and bc.transient._interp1d_scalar documents "xs must be sorted ascending" as an unenforced precondition — a descending table pins every query to the first value where np.interp would pin it to the last. _validate_schedule_ordering now checks every transient BC schedule, their per-species tables, and the urea doser. (Turbo maps were already guarded by TurboMap.__post_init__; the tabulated area profile is argsorted in build.py. Everything else interpolates a derived, monotone-by- construction abscissa.)

A wrap-around valve cam silently never opened. _is_open has always handled a window crossing the 720°/0° seam (open_deg > close_deg — every real exhaust valve), but the lift lookup is an np.interp, which needs an ascending abscissa and returns silently wrong numbers otherwise. A cam written in the natural cyclic order, lift_cad = [660, 690, 720, 30, 60], evaluated to zero lift at every crank angle. unwrap_cyclic_cad lifts the table and the lookup angle onto a monotone branch, so both encodings of one cam now agree; malformed tables (non-ascending after the wrap, more than one wrap, or spanning over 720°) raise instead of being "repaired". Bit-identical for the monotone 0..720 encoding every bundled engine case uses — a convention nothing enforced until _validate_cylinder_lift_tables, which also catches a lift_cad/lift_y length mismatch (previously a raw ValueError from inside np.interp, thousands of steps in) and a half-written schedule (silently degraded to binary windowing).

Variable-γ: 24× faster, and the fixed point now converges. update_per_cell_gamma was 99 % of total wall-clock on the shipped 200-cell variable-γ shock tube (3.52 s of 3.56 s) against 0.006 s for the flux kernel it feeds — a per-cell Python loop in which mixture_R rebuilt a constant molar-mass array 266 083 times per run. chemistry/mixture.py still described itself as "diagnostic-only … performance is not the constraint" years after phases 28d/28e wired it into the per-step solver loop; the new MixtureThermoTable hoists the per-species NASA-7 constants out and vectorises over cells (looping over species, so the summation order still matches the scalar reference bit-for-bit on R and to 1 ULP on γ). Total wall 3.558 s → 0.147 s. Separately, the γ fixed point capped at 12 iterations toward a 1e-12 tolerance and committed the last iterate unconditionally: it measurably took a mean of 9.69 iterations and 19/200 cells exhausted the cap without converging, silently, at exactly the γ-contact cells the feature exists to resolve. Cap raised (13 passes now suffice, 0 unconverged) and a miss warns once per pipe.

DPF regen_species_fold is @njit. It was the hottest closure on the DPF-regen path and the only pure-NumPy one left in closures/filter.py: 19 % of wall on 41_dpf_regen, more than the HLLC residual and species advection combined. 0.441 s → 0.086 s cumtime; bit-identical under IEEE-strict codegen, so the bit-identity tier is unchanged.

Explicit-driver bookkeeping. advance_network's own Python overhead was the largest single line item on real cases (21.6 % of wall on 45_two_stage_scr). The per-step snapshot lists now skip whole capability classes the network doesn't have; Pipe.n_extra_species is cached like has_species/U before it; and the species-coupling helpers (iter_pipe_species, extras_column, write_pipe_inflow_fuel) stopped re-deriving static answers and allocating on paths called millions of times per run.

DPF regen heat foldable into the implicit Newton (opt-in [case] dpf_regen_in_newton_residual). The direct-path DPF regeneration gas-energy source (dH·rate, Phase-8f.5 direct-to-gas Arrhenius soot oxidation) can now enter the implicit-network Newton residual + Jacobian instead of the post-step Lie split — completing the fold started with the cake back-pressure source. The Jacobian is finite-difference of the exact production rate kernel: the rate carries an Arrhenius × NO-inhibition × MT-cap × two-fraction chain (plus a non-smooth explicit HRR clamp the fold drops), so central- differencing is sign-correct by construction and exact-to-2nd-order, avoiding a hand-derived analytic chain rule. soot_loading + species are frozen during Newton (slow states); only the direct path folds (the thermal T_cake path's gas coupling is the separate linear cake↔gas cooling and stays on the Lie split). Implicit-driver only; default False ⇒ bit-identical (explicit / QS drivers always Lie-split).

EPW diesel validation — predictive combustion is now the harness default. examples/validation/avl_epw_diesel_steady/run_validation.py now defaults --predictive on: the ignition delay + premixed fraction come from the charge state at SOI (Hardenberg-Hase + Watson), not an AVL-SOC echo. --no-predictive is the anchored-SOC opt-out. The paired m-shape recalibration in the backlog was investigated and found unnecessary — an offline 217-op sweep of the prescribed-Wiebe MFB50 (the 0.5-crossing of the multi-stage-Wiebe curve, a pure function of the stage shape, so it isolates shape sensitivity) shows the current diffusion Wiebe (a=4.0, m=1.2) already minimizes the fleet MFB50 RMS; raising m only shifts the burn later and regresses it. So the shape was left unchanged and no library code changed — the in-cylinder closures are untouched and every shipped case stays bit-identical.

Convective-wall-heat Δt guard (opt-in [case] dt_heat_fraction). New explicit-driver stability cap so a constant_twall cell's forward-Euler / SSP-RK gas-temperature relaxation can't overshoot the wall in one step: Δt ≤ dt_heat_fraction·ρ·cv·D_h/(4h), recomputed each step from the current density and min'd with the acoustic-CFL Δt (a cheap guard for the low-ρ / high-h cells the CFL under-resolves). 1.0 is the exact no-overshoot limit; only the constant_twall closure is bounded (flow-Nu / EGR / CAC closures are self-limiting). Wired into the explicit network + Strang drivers; the QS (steady energy) and implicit (unconditionally stable) drivers ignore it. Default 0.0 ⇒ bit-identical timeloop. Sits alongside the HRR Δt controller (dt_hrr_fraction) in solver/network_timeloop.py.

Windows cp1252 robustness — explicit encoding="utf-8" on production text I/O. 22 bare read_text / write_text / open sites across src/aeolus1d and aeolus1d_gui (chemistry importers, mech DB, optimize case round-trip, GUI recent/theme/onboarding/autosave/layout) omitted encoding=, so a non-frozen Windows CLI/GUI (no -X utf8=1, cp1252 default) could crash on the UTF-8 bytes aeolus1d writes and reads. All now pass encoding="utf-8" (the GT map reader keeps its intentional iso-8859-1). A new AST guard test (tests/unit/test_text_io_encoding.py) fails CI on any new bare text I/O in production code. Behaviour-neutral on Linux (UTF-8 is already the default).

CO-oxidation exotherm in make_co_formation (opt-in). The Onorati CO-formation/oxidation closure returned zero heat release — fine in-cylinder where CO is a trace pollutant, but wrong when the closure acts as a DOC-region CO oxidiser on non-trace CO, where the ~10.1 MJ/kg is a real light-off heat source. New release_heat=True factory param returns s_E = −r_CO·M_CO·LHV_CO [W/m³] with the make_hc_oxidation sign convention (oxidation releases heat, the endothermic reverse absorbs it). Default off ⇒ bit-identical (the closure is library-only, used by no shipped case). New opt-in unit test pins both signs and the s_E = −ρ·dY_CO·LHV_CO identity.

Cylinder valve stagnation recovery (opt-in). New [case] valve_stagnation_recovery (default off) drives the orifice cylinder-valve flow from the runner cell's stagnation (total) state — T0 = T + u²/2cp, p0 = p·(T0/T)^(γ/(γ-1)) — instead of its static state, the physically-correct condition a poppet valve sees. It recovers the neglected u²/2 (a ~6% p0/p lift at ~100 m/s intake-runner speed) → more high-flow filling, and carries the total enthalpy (edot = ṁ·cp·T0). The 0-D cylinder plenum is quiescent so only the runner side gains the lift. Default off ⇒ bit-identical (u=0 ⇒ stagnation = static; golden regression + KTM/Mendeley unchanged). A candidate lever for the EPW high-load under-breathing gap. Full case-header → build → Cylinder wiring + 7 unit tests. Closes the valve_flow stagnation backlog item.

DPF soot-oxidation HRR stability clamp (Phase 8n). New opt-in [pipe.filter.regen] hrr_clamp bounds the per-step soot-oxidation rate so its forward-Euler heat release can't overshoot the gas / cake temperature at high soot load or active regen: the rate is capped at ρ·cp·T/(dH·dt) (direct-to-gas path) or Ω_cake·T_cake/(dH·dt) (cake-thermal path), scaling the slow + fast soot fractions together so mass and heat stay consistent. Both regen kernels take the step dt and all three drivers (explicit / QS / implicit) pass it; the flag defaults off ⇒ bit-identical. It complements the global dt_hrr_fraction adaptive-Δt controller (a per-cell rate backstop vs a step-size governor). 8 new unit tests. Also adds the missing regen_K_NO_inh units hint.

Predictive DI-diesel combustion — validated (2026-07-06). New opt-in [cylinder.combustion] kind="diesel_predictive" (aeolus1d.closures.diesel_predictive_combustion.DieselPredictiveCombustion) makes diesel combustion predictive rather than prescribed: it computes the start-of-combustion (Hardenberg-Hase ignition delay, cetane-aware; Heywood eq. 10.35) and the premixed fraction (Watson β = 1 − a·φ^b/τ^c) from the in-cylinder charge state, then drives the existing premixed+diffusion Wiebe. Wired onto Cylinder as a third combustion path (diesel → SI → Wiebe → pumper); default-off ⇒ every existing case is bit-identical (golden regression snapshots unchanged). Closes the BACKLOG.md:1662 Watson/Hardenberg-Hase item. Validated on the U-Mich DD13 matrix (umich_diesel --predictive): with zero per-point phasing tuning (only measured BOI/INJ_DUR/FM per point + one global correlation calibration) it passes 9 of 9 operating points — matching the hand-calibrated prescribed-Wiebe path on peak-p (~4.5 bar mean) and gross IMEP (~0.5 bar) and beating it on CA50 (~1.5° mean, ≤2.6° max vs ~4°). Full schema chain (schema/parser/build/GUI save_case) + an explicit unknown-kind guard; ~18 unit/integration/schema tests plus the DD13 @slow gate now parametrised over both combustion paths.

U-Michigan DD13 heavy-duty diesel — closed-cycle combustion validation (2026-07-06). New experimental validation case (examples/validation/ umich_diesel/) against measured cylinder-pressure traces from the U-Michigan DD13 6-cyl 12.8 L HD diesel dataset (Salehi & Stefanopoulou, 2015). The harness isolates the in-cylinder combustion + closed-cycle thermodynamics from the (unmodelled split-manifold twin-scroll) breathing: the cylinder is initialised at IVC from the measured trapped pressure (the trace is pegged to intake pressure at BDC) + intake-manifold temperature, valves held closed over IVC→EVO, and combustion is a premixed+diffusion double-Wiebe anchored on the measured start-of-injection + fuel mass. Under one global calibration across the 9-point matrix (750/1100/1400 rpm × 3–17 bar BMEP) it matches measured peak cylinder pressure to ~4 %, gross IMEP to ~5 %, and CA50 to ~1.7° — 8 of 9 points within band (the lowest-load/-speed corner over-predicts IMEP ~20 %, a documented closed-cycle-idealisation limit). The DD13 dataset is noncommercial no-derivatives vendor data (not redistributed); the harness + @slow CI gate (test_umich_dd13_diesel.py) read it from local_cases/ and skip gracefully when absent. Exercises Cylinder + multi-stage Wiebe + Woschni + engine_post CA50 inversion + imep_from_pv.

AVL Cruise M import: SCR dosing controller → closed-loop ANR (2026-06-26). The Cruise M importer now recognises an SCR urea dosing controller (a CFunction element labelled "Dosing 1"/"Dosing 2") and brings the doser in as [pipe.urea] control = "anr_feedforward" (closed-loop, dose tracks engine-out NOx) instead of the constant-rate fallback — so an imported SCR case doses sensibly across the drive cycle rather than over/under-dosing at a fixed rate. The controller's exact ANR setpoint lives in the AVL databus + constant blocks and is not auto-extracted (parsing arbitrary CFunction control logic is out of scope); anr_target defaults to 1.0 with a warning to set the calibrated value, and the constant-rate schedule is kept as the control = "fixed" fallback. On CAS_1.proj both SCR dosers now import as anr_feedforward. Covered by test_avl_cruise_m_anr_controller.py.

Closed-loop ANR urea dosing controller (2026-06-26). The SCR urea injector gained a feedback-free closed-loop dosing mode: [pipe.urea] control = "anr_feedforward" computes the UWS rate each step to hold a target Ammonia-to-NOx Ratio (anr_target, NH₃ : NOx molar) from the live engine-out NOx flow read at the brick inlet — the realistic SCR strategy (mirrors the AVL Cruise M dosing controller), replacing the hand-tuned fixed mdot_t/mdot_v schedule. Supports a light_off_T gate (no dosing below a brick gas temperature), a max_mdot_uws clamp, and a configurable nox_species list (default NO + NO₂). Runs under every network driver via the shared apply_urea_spray_to_pipe (the operator-split-parity work above). The default control = "fixed" keeps the schedule path bit-identical. Covered by test_urea_anr_controller.py.

Per-step operator-split parity across the network drivers (2026-06-26). An audit (prompted by the urea-under-QS fix) found three more per-step operations wired into the explicit RK timeloop but silently dropped by the quasi-steady and/or implicit drivers — the same bug class as urea:

  • Urea spray was missing from the implicit driver — an SCR case with a [pipe.urea] block run under an implicit RK injected zero NH₃.
  • Coolant system + standalone dynamic-wall thermal advance were missing from both QS and implicit, so a [coolant] / [[wall_thermal]] case ran with the coolant loop and wall thermal mass frozen at their ICs while the gas kept exchanging heat against them. The dispatcher even crashed the implicit path with a TypeError (it forwarded coolant_couplings to a driver that didn't accept it).
  • Mixture / per-cell γ refresh was missing from both — a use_mixture_gamma pipe kept a stale γ (wrong sound speed / cp / cv) as its composition evolved.

Fixed by extracting the shared refresh_network_gamma (the explicit closure is now bit-identical), threading coolant_couplings / wall_thermal_pairs through the QS and implicit driver signatures + the two dispatch sites, and calling the urea / coolant / wall / γ operator-splits each step in both drivers. Strang was already clean (it delegates its transport half-steps to the explicit step). New test_driver_operator_split_parity.py; golden snapshots unchanged (no-ops on cases without these features).

AVL Cruise M import auto-enables loop-flow on looped topologies (2026-06-25). A looped / parallel-branch junction network (e.g. dual SCR banks that rejoin) needs the robust loop-flow junction solver; the default nodal Picard/Newton diverges with junction-pressure Picard failed to converge. The importer now runs a union-find cycle check over the junction-to-junction pipes of the imported Case and sets qs_junction_loop_flow = true when it finds a loop, so the case runs as-is instead of crashing on the first run (previously the importer only warned to set it by hand). No-op for tree topologies (series chains / non-rejoining Y-splits) and for non-quasi-steady cases. Covered by test_avl_cruise_m_loop_flow.py.

Urea spray now fires under the quasi-steady driver (2026-06-25). The urea (AdBlue) injector operator-split lived only in the explicit RK timeloop, so SCR cases run under the quasi-steady driver — the default for every imported aftertreatment case — never injected any NH₃ and the catalyst ran dry. The injection is extracted into a shared apply_urea_spray_to_pipe(p, t, dt) (the explicit path stays bit-identical) and the QS macro step now calls it each step, before the per-pipe energy/species marches so the NH₃ advects through the bed and is available to the surface-chemistry sub-step. On the dual-bank cas1 SCR the dosers now feed NH₃ (0 → ~10–160 ppm at the brick inlets, consumed downstream by the catalyst's NH₃ storage); the steady outlet NH₃ balances the dosed rate (Y_NH₃·ṁ_gas = ṁ_urea·34.06/60.06). Covered by test_qs_urea_spray.py.

AVL Cruise M import: SCR urea (AdBlue) dosers (2026-06-25). The Cruise M importer now carries the SCR reductant injector. Cruise M attaches an AdBlue doser as a dosing property on the doser element (a Dosing object with the UWS mass_flow + a sibling DosingFluid giving the decomposed NH₃/CO₂/H₂O fractions); the importer previously dropped it, so an imported SCR case had no reductant feed and the catalyst ran dry (engine-out GeneralSpeciesTransport carries NOx/O₂ but not NH₃). Each doser is now emitted as a [pipe.urea] kind="urea_spray" block on its matching pipe, reproducing the Cruise M NH₃ delivery: uws_conc is backed out from the stated NH₃ mass fraction (uws_conc = f_NH₃·M_urea/(2·M_NH₃) with eta_conv=1, so the standard 0.185 fraction → 0.325 AdBlue), injected at the brick inlet (x_frac=0). The UWS water-carrier vapour is not modelled (urea_spray emits NH₃ + CO₂ only). Covered by test_avl_cruise_m_urea_doser.py.

QS steady-energy checkerboard fix — uniform conserved mass flux (2026-06-25). The quasi-steady steady-energy and species marches now divide by the pipe's converged uniform conserved mass flux ṁ instead of rebuilding a face flux from cell values. The old energy march built ṁ_f = ρ_up·½(u_a+u_b)·A_f — a centered face velocity that is not divergence-free when ρ varies, so on the collocated grid it amplified the odd/even (checkerboard) mode through T = F/(ṁ·cp), growing downstream as catalyst heat fed the enthalpy flux F. On a dual-bank SCR case (cas1) this produced a 2Δx temperature sawtooth with ~600 K of cell-to-cell roughness. At steady QS flow mass conservation makes ṁ = ρuA constant along the pipe, so _write_back now publishes the scalar ṁ as a uniform face flux (ṁ_w = ṁ_e exactly) that both marches consume, and the energy telescoping closes exactly. Measured: cas1 scr_1 2Δx-roughness 624 K → 0.35 K (smooth, monotonic profile); the inert / chemistry QS Joule budgets stay at ~0.84 % / ~1.16 % (< 2 % gate) and the C02006 DPF-regen validation gate still passes (1/1). The kernels keep the legacy cell-reconstruction as a bit-identical fallback for direct unit-test calls. This also resolves the audit Finding 403 tension: the upwind-momentum form 403 tried was dissipative (Joule ~6.9 %), whereas the conserved-ṁ flux is both divergence-free and energy-clean.

QS looped-network guard warning (2026-06-25). The quasi-steady driver now emits a one-shot, actionable warning when it is about to run a looped / parallel-branch junction network (two flow paths that rejoin between junctions — e.g. dual aftertreatment banks) with the default nodal junction solver and qs_junction_loop_flow off. That is exactly the configuration that ends in the cryptic junction-pressure Picard failed to converge; the warning names the fix (qs_junction_loop_flow = true) up front. Detection is a union-find loop test over junction-to-junction pipes, so pure series chains and non-rejoining Y-splits (which the nodal solver handles) stay silent. No-op when loop-flow is already enabled.

Per-cell variable-γ is now configurable from TOML (2026-06-25). Added [pipe.species] per_cell_gamma + gamma_T_from_energy so a case can opt into the per-cell γ(T, Y) machinery (previously reachable only from Python / the Abgrall bench). When per_cell_gamma = true the build assembles the pipe's mixture_species_db from the tracked species' NASA-7 thermo and enables the variable-γ HLLC; a species missing from the thermo DB fails fast with a clear, pipe-scoped error. Default off → bit-identical scalar-γ; the GUI inspector auto-renders both flags as check boxes. New bundled example examples/87_multicomponent_variable_gamma_shock_tube.toml (N₂/CO₂/H₂O exhaust mixture) gives the path actual build + run coverage beyond the synthetic Abgrall bench. Six-surface schema-extend walk; covered by test_per_cell_gamma_config.py.

Energy-consistent per-cell-γ recovery is now the default (2026-06-25). Promoted gamma_T_from_energy from opt-in to the Pipe default after a mesh-refinement study showed it converts the variable-γ interface oscillation from the legacy recovery's mesh-independent ~1.3 % (the true Karni O(1) defect) into convergent truncation error (p-error ≈1.3e-3 → 2.4e-4 over N=100 → 800). Because the energy-based and pressure-based T recoveries are algebraically identical for a consistent state (e = p/((γ−1)ρ)), scalar-γ and uniform-mixture results — including every golden snapshot — are bit-identical; only sharp multicomponent γ-contrasts shift (one composition-step test threshold retuned, no snapshot re-baseline). gamma_T_from_energy=False restores the legacy recovery bit-for-bit. The companion investigation into a fully quasi-conservative (Abgrall 1996) flux concluded it should not be built: reaching exact round-off requires advecting 1/(γ−1) as an independent non-thermodynamic field, which is incompatible with aeolus1d's physical γ = mixture_γ(T,Y) in every reacting cell (the pressure-equilibrium-vs- thermodynamic-consistency trade-off) and is numerically fragile — documented in scope-and-limitations.

Classic 1D Euler verification benchmarks — Shu-Osher, Woodward-Colella, Abgrall (2026-06-24). Fills the literature gap above the basic Sod/Lax tubes with three canonical reference problems: Shu-Osher shock/entropy-wave interaction (bench/shu_osher.py — pins that MP5 preserves ~3.8× more post-shock entropy-wave amplitude than first-order), Woodward-Colella interacting blast waves (bench/woodward_colella.py — density/pressure stay strictly positive through the collision; the ρ≈6 spike forms at x≈0.78), and the Abgrall moving γ-interface (bench/abgrall_interface.py — the Karni-1994 pressure-equilibrium test for the variable-γ machinery). The Abgrall case surfaced that aeolus1d's per-cell-γ HLLC was not Abgrall-clean — a bounded ~1.3 % pressure / ~3.3 % velocity oscillation at a sharp γ-contact (the Karni 1994 failure mode) — and motivated a fix: the new gamma_T_from_energy pipe flag recovers each cell's temperature self-consistently from the conserved internal energy (fixed point on T = (γ(T)-1)·e/R) instead of from the scalar-mean-γ pressure, cutting the interface oscillation ~20× to <0.1 % pressure / sub-% velocity. Shipped opt-in (default off) here; later promoted to the default (see the 2026-06-25 entry above, which also explains why the fully quasi-conservative Abgrall flux was evaluated and deliberately not built). Gated both ways by tests/integration/test_euler_verification_benchmarks.py.

MP5 var-γ / EOS-MP5 boundary-face kernels — complete the #719 anchor (2026-06-24). The scheme-consistent species mass-flux anchor (#719) and the junction port-flux coupling rebuild each pipe's boundary-face flux to match its residual kernel; two scheme combinations — variable-γ MP5 and real-gas-EOS MP5 — had no matching boundary-face helper and fell back to a first-order scalar-γ HLLC (a residual-inconsistent flux, the documented remainder of #719). Adds boundary_face_flux_mp5_var_gamma_jit (kernels_mp5) and boundary_face_flux_eos_mp5_jit (hllc_eos_njit), each mirroring its residual kernel's boundary-face reconstruction (U_to_W_var_gamma + cell-centred γ + hllc_flux_var_gamma; (ρ, u, T) EOS primitives + hllc_flux_eos_jit), verified to telescope to the scheme's true right-boundary flux to round-off (the removed DC bias was O(20) on a steep var-γ boundary, O(2) on Redlich-Kwong). Each reduces bit-exactly to its scalar sibling for uniform γ / ideal gas. Wired into both _left_boundary_mass_flux (the species anchor) and junction._pipe_port_flux (flux-conservative coupling), so every MUSCL and MP5 variant now anchors consistently — no scheme is left on the first-order fallback. Golden snapshots unchanged; test_species_scheme_consistency.py extended to all seven variants.

Scheme-consistent species mass-flux anchor — remove the boundary DC bias (#719, 2026-06-24). The A1.4 species transport recovers the spatial scheme's own per-face mass flux by telescoping the mass residual (R[i,0] = -(ṁ_{i+1}-ṁ_i)/dx), anchored at the LEFT boundary-face flux. That anchor used a first-order, scalar-γ, ideal HLLC of the raw ghost / first-interior cells — but on a MUSCL / MP5 / var-γ pipe the residual computed face 0 from reconstructed states (and the matching γ / EOS), so the anchor was off by a constant. The cumulative sum telescopes the interior faces exactly off whatever anchor it is handed, so that mismatch is a uniform DC bias on every face — faking a spurious advective drift of any non-uniform composition at a prescribed-inflow boundary. It is identically zero for uniform composition, for walls (ṁ = 0) and for first_order pipes, which is why it stayed hidden behind the uniform-scalar regression tests. _left_boundary_mass_flux now rebuilds the residual kernel's own face-0 flux (the same boundary_face_flux_* reconstructions junction._pipe_port_flux uses for flux-conservative coupling) for MUSCL (scalar / var-γ / EOS) and MP5-ideal, closing the telescoping to round-off (the var-γ DC bias was the largest, O(20) on a steep boundary). first_order keeps the original anchor (it is the scheme's face-0 flux → bit-identical) and MP5-var-γ / EOS-MP5 fall back to it unchanged (no matching boundary-face kernel yet — a documented backlog item, not worsened). All golden snapshots unchanged; pinned by new telescoping-consistency tests in test_species_scheme_consistency.py.

Quasi-steady species march hardened against a transient reaction-rate overflow (2026-06-24). The QS implicit-upwind species march is a convex combination that is bounded only while its reaction/trap source is finite. A regen / light-off ignition cell driven to an extreme composition can overflow an Arrhenius rate to +inf, and the per-cell positivity floor (val if val > 0 else 0) passes +inf through — the +inf then advects downstream and corrupts the whole gas field (NaN pressure-drop, >100 % apparent conversion). This surfaced on the C02006 DPF-CSF regeneration validation case, where the mass-conservation ΣY-dilution fix (audit batch, MR !282) shifted the outlet composition just enough to trip the overflow at the regen-ignition front (t ≈ 418 s) — turning a passing suite into a NaN-driven fail. Two guards close it: the march floor now also rejects non-finite values (val < 1e30), and the QS driver drops any non-finite reaction/trap source entry to 0 before the march (all-finite fast-pathed, so it is bit-identical and allocation-free for every well-behaved step). C02006 returns to a numeric PASS at its pre-audit channel values; all other QS suites and the golden snapshots are unchanged. The underlying ΣY mass-conservation fix is kept — the overflow was a latent gap in the march's boundedness contract, not a flaw in the dilution.

GIL-free per-cell chemistry via numbalsoda (chem_solver="numbalsoda", 2026-06-22). New opt-in integrator for the general reaction="mechanism" surface path (py_reaction_surface, a _CompiledSurfaceMechanism): each cell's stiff ODE is integrated through numbalsoda's numba-native LSODA, whose right-hand side is a @cfunc (C-callable address), so the per-cell solves release the GIL and run across threads in a prange (enable with chem_parallel = true). This is the GIL-free counterpart to the batched scipy LSODA path — the same shape as the Brinkmeier BS parallel kernel, but for arbitrary mass-action mechanisms. The per-cell RHS is factored into a shared @njit kernel (mechanism._mech_cell_dstate_jit) reused by both paths, so the numerics track (matched to ~1e-9 relative on the bundled CO-oxidation and NH3 adsorption/desorption surface mechanisms; a different LSODA implementation, so validated with assert_allclose, not bit-identity). Default-off and bit-identical when unselected; numbalsoda is an optional dependency (pip install aeolus1d[chem-fast]), lazy-imported, and any pipe the kernel does not handle (gas-only py_reaction_multi, a non-MechanismSpec surface reaction such as Brinkmeier) transparently falls back to scipy LSODA — so selecting it is always safe.

Species mass conservation at inflow boundaries — flush reaction products absent from the feed (MR !269, 2026-06-21). At a mass-inflow boundary the gas-species transport gave every species absent from the feed schedule a zero-gradient inlet ghost (ghost = edge-cell value). For a reaction product the feed omits (e.g. CO₂ / H₂O over a DOC/TWC oxidation brick) that is wrong: the advective in-flux ṁ·Y₀ exactly cancels the out-flux ṁ·Y₀, so the product cannot be flushed at the inflow face. Chemistry then injects product every step with zero advective removal — its mass fraction grows unbounded and ΣY drifts above 1. Continuity (ṁ = ρuA) stays correct and the mole-fraction sampler renormalises, so the corruption is invisible in conversion metrics: the propylene light-off brick reached ΣY ≈ 1.92 with outlet CO₂ ~145× the stoichiometric value while its T50 still looked plausible. Fix: at a prescribed inflow the ghost is built from the feed via a shared _inflow_ghost_extras helper — a complete feed (specified mass fractions sum ≳ 1, _FEED_COMPLETE_THRESHOLD = 0.5) treats omitted species as absent (ghost 0, products flushed); a partial trickle feed (a dilute-tracer pipe whose carrier is not in the feed) keeps the zero-gradient edge ghost. Applied in both species-transport paths — _species_rate (explicit / implicit-Newton) and _qs_implicit_species (quasi-steady + implicit-Newton march). Bit-identical for fully-listed feeds (heliyon OSC ΣY = 1.00000) and partial trickle feeds (passive-SCR underfloor); corrects (and keeps passing) the cases that omit products (ornl passive-SCR, DOC propylene). See ADR-0009. Regression: tests/unit/test_inflow_ghost_extras.py + tests/integration/test_qs_species_inflow_conservation.py.

DOC propylene (C₃H₆) light-off validation + plot (MRs !267 / !268 / !270, 2026-06-20…21). A new opt-in C₃H₆ total-oxidation channel was added to the twc_brinkmeier mechanism (C₃H₆ + 4.5 O₂ → 3 CO₂ + 3 H₂O, Voltz-Morgan- Liederman kinetics, Ea = 110 kJ/mol, gate A_c3h6_ox_brnk; default 0 → inert → bit-identical with the 11-reaction baseline). The legacy mechanism consumed propylene only via NO reduction, so it was near-inert on a lean / low-NO feed; this channel closes that gap. A code-to-literature validation suite examples/validation/doc_propylene_lightoff/ sweeps inlet temperature on a CO-free, oxygen-storage-off DOC-like brick and gates the light-off T50 ≈ 240 °C inside the 200–280 °C literature band; A_c3h6_ox_brnk = 1e13 is the recalibrated rate constant for the Brinkmeier inhibition denominator (same model form as A_nh3_form_brnk). Ships with run_validation.py, plot_results.py (two-panel light-off figure), pass_criteria.toml, and an @slow CI gate; registered in the validation index. The plot is what surfaced the inflow mass-conservation bug above (MR !269) — post-fix the outlet CO₂ is the physical stoichiometric ~1416 ppm.

Implicit driver guards first-order ↔ single-ghost coupling (MR !266, 2026-06-20). The first-order residual kernels assume one ghost cell while the Jacobian uses Ng-offset indexing, so a first-order pipe with Ng != 1 would silently compute the residual over the wrong cells. The implicit Euler / BDF2 drivers now raise a clear error (counterpart to the existing MUSCL↔Ng≥2 guard). Unreachable today (scheme→Ng is coupled at build) — a guard against a future per-pipe-scheme regression.

Solver-method audit — robustness, perf, and passive-SCR/QS fixes (2026-06-20). A literature-cross-checked audit of the solver methods (HLLC / HLLC-LM, SIMPLE / quasi-steady, GGA / loop-flow, LSODA, Brinkmeier / Voltz / Konstandopoulos) landed a batch of fixes, each bit-identical for healthy runs:

  • QS heat-release Δt control. A negative dt_hrr_fraction now disables the quasi-steady HRR Δt cap, letting Δt rise to the advective-CFL / qs_dt_max ceiling for a lit-off, steady brick (0 keeps the 0.1 safety floor, > 0 unchanged). The first cut froze Δt at the dt_init bootstrap — the HRR controller is also the step grower — fixed to ramp hrr_limit when the cap is off. On MFR_transition_SC630 this un-pins Δt 1e-4 → ~1.55e-3 s.
  • QS / explicit robustness. dt_adv floors density and raises on a non-finite velocity instead of silently propagating NaN through min; the per-step HRR snapshots are skipped when the cap is off (GC churn); the pre-stage γ refresh now covers junction / turbo neck pipes (stale γ → wrong sound speed); the species combiners floor density before ρY/ρ.
  • SCR surface coverage. A coverage-bound guard refuses to commit a grossly over-drawn θ. The rate law's inward field (consuming ∝ θ, adsorption ∝ (1−θ)) keeps LSODA / backward-Euler in [0, 1]; a coarse explicit step would otherwise reduce NO by adsorbed NH₃ that never existed (~96× over-draw, fabricated NOx conversion). New gated suite pins the inward-field + N-conservation invariants.
  • Passive-SCR NH₃ λ-gate, dimensionally closed. The NH₃-formation super-linear H₂ dependence is now the dimensionless gate (c_H2/c_H2_ref)^(order-1) (c_H2_ref = 1 mol/m³), so A_nh3_form_brnk is a proper bimolecular rate constant (m³/(mol·s)) like every other Brinkmeier constant, instead of carrying hidden (mol/m³)^(order-1) units. The TWC / passive-SCR goldens and the ORNL slow selectivity validation (rich > 80 %, stoich 7–40 %) pass unchanged.

Cruise-M aftertreatment imports default to the quasi-steady driver (2026-06-19). The Cruise-Q importer block now sets solver = "quasi_steady" (+ qs_cfl_adv = 5.0, qs_picard_tol = 1e-3) for any case with a catalyst or filter pipe, so a re-import lands on the low-Mach driver that makes a multi-second light-off / regen cycle feasible — rk = bdf2 alone never changed the driver, only the integration scheme, so the run stayed acoustic-CFL bound. rk = bdf2 / cfl = 5 are kept as explicit fallbacks; parallel / multi-bank topologies may also need qs_junction_loop_flow = true.

GUI — per-panel show / hide (2026-06-19). A View → Panels submenu toggles each right-pane dock (Inspector / Network / Plot / Modes / Optimize / … / TOML) individually; the hidden set persists across sessions.

Catalyst Nu laminar floor + aftertreatment-theory references (2026-06-15). Added the opt-in catalyst_nu_laminar_floor case flag (default off, bit-identical): floors the variable-Nu sieder_tate Nusselt at the square-channel laminar asymptote (2.98) so the gas↔solid heat path doesn't vanish at low Re (idle / soak / cool-down), where the bare turbulent correlation Nu = 0.027·Re^0.8·Pr^(1/3) → 0. Closes a standing BACKLOG item; validation-sensitive (15 shipped sieder_tate cases), so it lands flag-gated. Folded Millo et al. (2020) 1D Simulation Models for Aftertreatment Components into the theory tier (ref [41]) as the canonical reference for the 1+1D monolith model, the Graetz-Nusselt Nu/Sh asymptotics (Table 11.1), and the QSS limit-point / concentration-jump robustness technique (numerics.md). Captured the ORNL passive-SCR open dataset (CLEERS 2014 + SAE 2016-01-0934) as examples/validation/ornl_passive_scr/, documenting that twc_brinkmeier reduces NO only to N₂ (no NH₃-generation channel) — the scoped next physics step (BACKLOG).

Mendeley ethanol/water re-validated + switched to the shipped-default orifice coupling (2026-06-09). The 60-point ethanol/water SI suite was closed (2026-05-17) on the LEGACY valve coupling and had stayed there — it builds via the scaffold (orifice_valve_flow=False default), and the orifice flip (!188) only changed the TOML build path, never the scaffold. The harness now opts into the orifice coupling (cylinder_orifice_valve_flow=True), so the suite validates the coupling aeolus1d ships. 44/60 under both couplings — near-neutral, because the suite is FIXED-FUEL (prescribed Wiebe Q from the paper's measured fuel flow), so trapped air only shifts indicated work ~1 % (vs the fuel-from-air KTM suite where the same change was 8/10→6/10). The ~1 % BMEP rise reshuffles gate-edge points without changing the count: 0–30 % water reads 40/40 under orifice (37/40 legacy) — gate-edge realignment, not a model gain — and an equal number of high-water passes tip out (40–50 %: 7/20→4/20), the documented charge-cooling / constant-γ structural corner. No re-tuning. Also corrected false premises in the prior docs: the data was never "local-only / not CI-runnable" (operating_points.csv is committed and runs in CI), and the suite was on the legacy coupling, not "pre-orifice". Verified by a 3-lens pass (numbers / honesty / fixed-fuel physics).

KTM 510 cc breathing re-validated against the orifice coupling (docs only, 2026-06-09). Re-ran the Ricardo-WAVE KTM suite on current main and refreshed the validation docs to the measured numbers. With the default-on orifice valve coupling (ADR-0008) the recommended --throttle --wall-heat invocation lands 6/10 at the 25 % gate (bare default 3/10), down from the legacy coupling's 8/10 on the same invocation — but not a solver regression: bare-default reproduces the post-!188 numbers exactly (3/10, 52.2 N·m @ 1000 RPM), so breathing is unchanged across !189–!203 (audit fix #2's A_face→A_cell is bit-identical for KTM's uniform-area runners). The lower pass count is honest exposure — with mass-conservative breathing the idealised polydyne cam + untuned 460 mm runner over-admit air across the band (η_v 0.87–1.18, peaking 1.18 at 8000 RPM; every point over-predicts torque), which the legacy leak had been masking; a same-build motoring A/B measures that leak growing 1.05× → 1.56× from 5000 to 9000 RPM. Recovering the OEM match needs the proprietary OEM cam + a per-RPM fuel/spark table (owed, gated on OEM data). Refreshed the suite README, pass_criteria.toml header, the dossier, the scope/release tables, and the v0.3.0 W1a row; the numbers + the "not a regression" framing were cross-checked by a 3-lens verification pass (independent re-derivation, honesty-of-framing, regression-ruling). No code change.

Code-audit fix batch — 30 of 34 verified findings closed (!203, 2026-06-09). A full-source audit (adversarially verified findings) fixed in one 12-commit MR. Highlights: DPF cake-thermal conjugate heat debit + 2-fraction fast-soot decrement + q_rxn HDF5 time-series alignment; orifice-valve cell volume on tapered runners (A_face → A_cell) + open-port stash desync; Bader-Deuflhard Neville extrapolation off-diagonal fix (both the Python and @njit chemistry kernels) + HNCO hydrolysis T clamp; QS regula-falsi converged flag + SIMPLE continuity residual; implicit-network mid-loop dt-halving consistency, chord line-search rejected-state commit, Radau block-Thomas soft-fail; transient-schedule/BC input-validation guards; O(N)→O(1) windowing for the EOS-MUSCL + MP5 boundary-face helpers; odd-N rfft Nyquist doubling (6 acoustics sites); GUI dialog/plotter lifecycle leaks. #36 reclassified not-a-bug; #3/#25 deferred with rationale.

Implicit 4-cyl integration smoke vs the orifice guard (!202, 2026-06-09). The @slow implicit four-cylinder smoke test now opts out of the orifice valve coupling explicitly — !195's loud build-refusal of orifice-under-implicit had turned it into a hard fail.

aeolus1d warmup (!201, 2026-06-09). New CLI subcommand that pre-compiles the Numba solver kernels (a few solver steps over a small bundled case spread, or your own TOMLs) so the one-time ~140 s cold-cache compile happens out loud instead of inside the first real run.

QS HRR Δt-controller light-off stall fix (!200, 2026-06-09). The quasi-steady heat-release-rate Δt controller could deadlock at catalyst light-off — the bang-bang clamp re-triggered every step and pinned the macro step at the floor. Replaced with a damped deadbeat _hrr_step_factor + a 1e-4 species denominator floor; MFR_transition Δt 3e-8 → 2e-6 s at light-off with the c02001/c02002 validation gates unchanged.

Cruise-M import round-trip drops closed (!199, 2026-06-08). The importer stamped fields outside KIND_FIELDS[kind], so the SSOT codec silently dropped them on the TOML round-trip — friction.D_h on 124/142 imported files + wall-BC placeholders on 104/142. Both run-neutral on the shipped examples but now preserved and regression-guarded.

Cruise-M stale "no PM tracking" warning corrected (!198, 2026-06-08). The import warning claimed PM was untracked; the PM species column lands in the import (the remaining PM follow-ups stay in BACKLOG.md). Plus a dead-string scrub.

Docs sync (!197, 2026-06-08). Documentation-lag sweep across the docs tier after the !188–!196 run — changelog / concepts / reference pages realigned with shipped behaviour.

Post-validation robustness batch — IO round-trip, GUI lifetime, a conservation guard, and an O(1) hot-path (MRs !193–!196). Four hardening follow-ons after the validation-reporting pass: - GUI save round-trip no longer drops fields (!193). The hand-maintained GUI writer (aeolus1d_gui/io/case_io.py) had drifted from the parser and silently dropped blocks on a save→run round-trip — cylinder wall-heat (T_wall/T_head/T_liner/T_piston/woschni_c1), Chen-Flynn FMEP, multi-injector and multi-Wiebe lists, turbo vane_open/bypass_open, generic [[reaction]] wiring, the coolant conjugate-heat loop, EGR/throttle orifice BCs, junction wall-heat, and the CSF DPF kinetics — so e.g. a re-saved EPW diesel reverted to adiabatic walls and BMEP == IMEP. All 211 loadable example TOMLs now round-trip with deep dataclass equality, pinned by test_save_case_block_coverage.py (upgraded from a shallow block-key check to full dataclasses.asdict equality). The CHEMKIN importer also stopped leaking an inline END token into the element/species set for the ELEMENTS … END idiom (GRI-Mech), which had corrupted the mechanism's species set. - GUI QObject leaks closed (!194). Three unbounded-growth leaks in long-running sessions — the Example/Recent menus leaked ~80 actions + ~12 submenus per open, finished QProcess runners stayed parented to the runner, and the doc-viewer dialog rebuilt instead of reusing the cached instance. Pinned by a menu-rebuild count regression. - Conservation guard on the implicit driver (!195). The default-on orifice valve coupling (ADR-0008) walls off the runner-pipe face and applies the matching valve mass/energy sources only on the explicit advance_network path; the implicit network drivers (implicit_euler / bdf2 / radau_iia) never applied them, so a cylinder under an implicit rk would silently gain the mass the walled pipe lost. build_network now fails loud with a clear ValueError (use an explicit rk, or set cylinder_orifice_valve_flow = false) rather than run quietly-wrong physics; full orifice-under-implicit support is a separate feature. - O(1) windowed MUSCL at the boundary face (!196). _pipe_port_flux_muscl rebuilt U_to_W + muscl_slopes over the whole padded array on every call only to read the two cells straddling the boundary face; a 4-cell window reproduces the limited slopes bit-for-bit while dropping the per-call cost from O(N) to O(1) (~190 K calls on a case-50). Bit-identity verified across a 3000-case old-vs-new reconstruction sweep; the golden-snapshot net stays green.

Honest validation reporting — frozen citable report + reference-type relabelling (plan 4.6 / 4.8 / 4.9). Added a frozen, citable per-release docs/validation-report-v0.3.0.md — the non-moving snapshot to cite (the dossier stays the living companion). Relabelled W2/W4/W5 by reference type (W2 turbo + W5 electrified are self-consistency checks, not external validation; W4 acoustics is analytic + code-to-code). Re-baselined the W1 engine-breathing numbers for the ADR-0008 orifice coupling: KTM is 3/10 under the corrected default (was 7–8/10; the single-point cam over-breathes across RPM — a per-RPM re-cal is owed, gated on OEM cam data) and Mendeley's 44/60 is flagged pre-orifice / re-validation owed (data is local-only). Added a blind-predictable vs structurally-blocked classification of every gate (C02006 co_conv_cat is structurally blocked — no CSF oxidation kinetics), and GCI-not-applicable — calibrated banners on the lumped-closure pass_criteria.toml files (DOC Voltz, SCR Temkin, C02006 regen), where per ASME V&V-20 the model-form term u_D dominates u_num.

Maturity hardening — closure-seam SSOT, supply-chain, and the test net (plan 4.3 / 4.10 / 4.13 / 4.14). Three follow-on MRs after the validation-reporting pass: - Closure seam → single source of truth (4.3). _parse_species now validates the reaction kind against the closed closure registry (valid_kind_names) instead of a hand-maintained literal set — the last duplicated kind list is gone, so adding a reaction kind no longer needs a parser edit. A new round-trip test covers all registered reaction kinds through the GUI writer + parser (the example sweep only hit a few), pinning the per-field silent-drop hazard across every kind. - Supply-chain + input hardening (4.10, partial). A CycloneDX SBOM CI job (from requirements.lock), a curated THIRD_PARTY_NOTICES.md (all runtime deps permissive; PySide6-LGPL is GUI-only), a central examples/validation/DATA_MANIFEST.md provenance index, a closed-registry enforcement test (a kind string naming a real module is rejected, not imported; no eval/exec in the IO path; no unsafe yaml.load; learned NPZ refuses pickled payloads — np.load(..., allow_pickle=False) made explicit), and a parser fuzz corpus (14 malformed inputs → controlled errors, never a crash). Deferred (needs the maintainer): PyPI Trusted-Publishing OIDC + the Claude-attribution decision. - Test net (4.13 / 4.14). Hypothesis property tests for the EOS/Euler-flux kernels (primitive↔conservative round-trip, flux consistency, CFL wave-speed bound), an all-example build-smoke (every pipe-bearing examples/*.toml must build a finite, positive initial state — load+build coverage from ~14 % to all 71), and a coverage floor (--cov-fail-under=50, measured ~55 %, with a cobertura report on the MR widget) piggybacked on the unit job. The determinism / restart-checkpoint / memory-ceiling regressions named by 4.13 already exist and are cited rather than duplicated.

Cylinder breathing — conservative orifice valve coupling, fixing a mass-conservation bug (MR !188, ADR-0008). The legacy cylinder↔pipe valve coupling destroyed (1 − Cd·lift) of the mass at every valve (the cylinder was credited F·A_eff while the runner pipe drained the full F·A_pipe), starving the cylinder to ~6 % volumetric efficiency. Replaced with a compressible isentropic-orifice valve flow (aeolus1d.components.valve_flow, Heywood §C.2/§6.3) applied symmetrically — the same ṁ/Ė removed from the runner boundary cell as added to the cylinder, so mass + energy conserve by construction — and made the default ([case] cylinder_orifice_valve_flow, opt out with false). On the AVL EPW Diesel this lifts volumetric efficiency 6 % → ~74 % (trapped mass matches AVL's air consumption to ~10 %), and IMEP_High / PFP go from −24/−16/+1 % and −6/+4/−12 % to −1/−6/−10 % (near-exact) with a now- physical BMEP; the exhaust-plenum wall-heat closure was re-tuned (the legacy h_conv≈4750 was compensating for the bug). A new test_cylinder_valve_conservation.py pins cylinder-port mass conservation (none existed). The flag-off path is bit-identical; the validated KTM/Mendeley SI suites are re-baselined against the fix. See ADR-0008.

Versioned TOML case format with a named upgrade path (MR !186, plan 4.1). The human-authored .toml case file now carries a schema_version marker (the first key of [case]), mirroring the HDF5 checkpoint's schema_version + upgrade framework. load_case reads it before constructing any dataclass: refuses a forward-version file with an "upgrade aeolus1d" message, identity-loads a current-version file, and walks any registered CaseUpgradeSteps to migrate an older parsed dict — the marker is consumed on load so it never reaches the runtime CaseHeader. The format is at v1 and the upgrade chain is empty; the framework (src/aeolus1d/io/case_upgrades.py) is in place so the next backward-incompatible change has a tested migration path instead of a silent break. Unversioned files are treated as v1 (bit-identical load), so existing cases keep working; new writes (aeolus1d expand, GUI save, convert) emit schema_version = 1 via the one shared [case]-block formatter. See ADR-0007 and tests/unit/test_case_upgrades.py.

Standalone binary: bundle the examples + a turnkey Windows build (MRs !166 + !167). The PyInstaller bundle is now self-contained for end users. !166 ships the entire examples/ tree into the frozen bundle at <bundle>/_internal/examples, resolved across layouts (dev checkout / wheel / frozen) by the new aeolus1d._resources.examples_dir() (via sys._MEIPASS), so the GUI example browser, guided tour, and aeolus1d examples all work offline with no source checkout. The same MR excludes the [ml]-only deps (torch / sklearn) from the spec — PyInstaller's torch hook was segfaulting the build, and the ML chem surrogate (MR !114) was closed-negative anyway, so nothing in the shipped path needs them. !167 adds aeolus1d_gui/build/build_windows.ps1: PyInstaller cannot cross-compile, so a Windows .exe must be built on Windows — the script creates an isolated .venv, installs .[gui,build], and drives the same aeolus1d_gui.spec as the Linux build.sh. The packaging contract (console-script entry point, [gui] / [build] extras, spec bundles examples, Windows wrapper exists) is pinned by aeolus1d_gui/tests/test_packaging.py — a static check that deliberately does not invoke PyInstaller (a 3–8 min build needing a desktop env to validate). See docs/installation.md and aeolus1d_gui/build/README.md for the build recipe per platform.

Minimal [case] block — default-fill loader + compact diff-against-default writer (MR !164). Inverts the MR !122 fully-specified default: the loader already fills every absent CaseHeader field from its schema default, so the writer now emits only the fields that differ from default (plus the name / t_end anchors). A typical [case] block drops from ~68 lines to ~6, with zero behaviour change — both forms reload to a byte-identical CaseHeader. GUI saves (save_case) and CLI aeolus1d expand both write the compact form by default; aeolus1d expand --full is the escape hatch that re-emits every field explicitly (the v0.9 fully-specified form — this supersedes the "save_case emits a fully-specified block by default" note in the MR !122 entry below). Typo-safety is unchanged: an unknown [case] key still raises with a did you mean? suggestion, pinned by test_unknown_key_still_rejected. The 71 affected bundled examples were regenerated to the compact form.

doc_oxidation_voltz DOC light-off closure + SpeciesContract handshake (MRs !152 → !155). New reaction = "doc_oxidation_voltz" surface closure (reg code 13): Voltz/Pattas CO + HC + NO oxidation kinetics with the classic CO-inhibition denominator and a mass-transfer-limited rate cap, calibrated from AVL-extracted DOC kinetics and gated by a c02001 DOC light-off T50 validation case. Landing it surfaced — and MR !155 closed — a whole bug class: surface mechanisms previously copied species in/out by position in the per-pipe extras layout, which scrambled tracers on pipes whose species ordering differed from the canonical one (the "SCR primary = C3H6" mismatch). The new SpeciesContract handshake resolves every reaction kind's reactants / products by name against each pipe's actual species vector at build time, so a misaligned layout now fails loudly at construction instead of silently mis-routing mass. Closes the misalignment bug class by construction.

Temkin coverage-dependent NH₃ desorption for nh3_scr_kinetics (MRs !161 + !163). The Langmuir storage model desorbed at a coverage- independent activation energy, giving a too-soft NH₃ isotherm vs the AVL SCRT ads/des reference. New optional alpha_des field makes the desorption activation energy coverage-dependent — Ea_des · (1 − α·θ) — which sharpens the isotherm toward rectangular (strong chemisorption) and closes most of the remaining model-form gap on the c02002 SCR validation arc (no-NO mean |Δθ| 0.048 → 0.026). alpha_des = 0 (default) recovers the coverage-independent Langmuir form bit-identically. MR !163 backfills a calibrated alpha_des = 0.2 into the three generic Cu-zeolite SCR demos.

Solver-optionality reduction: drop the JFNK suite + collapse the duplicate QS chem knobs (MR !165). HARD CUT — no aliases. Removes the dead matrix-free Jacobian-Free Newton-Krylov path and folds the QS-specific chemistry super-cycle knobs into their canonical twins.

Removed [case] keys (now rejected as an unknown key with a did you mean? suggestion): use_jfnk, jfnk_tol, jfnk_max_iters, jfnk_precondition, jfnk_precondition_ilu0, qs_chem_every_n, qs_chem_interval. Migration: delete the use_jfnk / jfnk_* lines outright (they only ever held defaults); rename qs_chem_every_n → chem_every_n and qs_chem_interval → chem_interval (the QS driver now reads the canonical fields directly — defaults 1 / 0.0 and gate logic are identical, so QS runs are bit-identical). All 125 bundled full-form TOMLs were stripped of the seven default-valued keys (a behaviour-preserving line strip — every other line is byte-identical).

Why JFNK went: its own schema docstrings recorded it as ~1.8× slower than the direct block-tridiagonal solver at every N tested (block-Jacobi precond 10× slower on the long-pipe bench), and it was never set non-default in any bundled case. The direct + chord-Jacobian path is the right cost shape for ≤ a few thousand cells × 3 unknowns. Full surface chain: CaseHeader (schema) + NewtonConfig (kernel) + deletion of _jfnk_step / _block_jacobi_apply_jit + Newton-dispatch simplified to the direct path (_block_thomas_solve retained — it also backs Radau / implicit-Euler) + case.py wiring + schema_help + startup banner + GUI runner-dock widget + docs. Tests: deleted test_jfnk_phase_i8.py; reworked the chem-interval / startup-banner / CLI-expand / AVL-roundtrip assertions to the canonical fields. The baseline + implicit-network + QS-fused-RHS regression nets confirm no numerics changed.

Substrate-thermal routing for q_rxn (MR !145). New opt-in QS-driver flag qs_surface_q_rxn_into_substrate (default False). The lagged surface-chemistry reaction HRR pipe.q_rxn is physically released on the catalyst substrate, not the gas; with the flag on, the QS driver adds it to the solid temperature (dT_s += q_rxn/(ρ_s·c_s)) instead of folding it straight into the gas-energy source. The gas then picks the heat up through the already-semi-implicit gas↔solid fold (q = K·(T_s − T_g) in _qs_steady_energy_kernel), so the rise is bounded by the substrate heat capacity by construction — the gas-side capacitance limiter qs_q_rxn_max_dT_per_step is no longer needed for this term (it still guards the remaining prescribed gas sources: regen, CSF, substrate-wall). Only fires on pipes carrying a two-phase catalyst (two_phase_heat / two_phase_heat_var_nu); a q_rxn pipe without one falls back to the gas overlay with a one-time warning, so the heat is never silently dropped. Default False keeps every existing case bit-identical (C02006 unaffected — it carries no py_reaction_surface). Full surface chain: schema field + QSTransientConfig + case wiring + schema_help + startup banner row, all 200 bundled [case] blocks expanded, and tests/integration/test_qs_q_rxn_substrate_routing.py (off ≡ default; on warms T_solid more than the gas overlay and stays bounded; no-catalyst fallback warns). Closes the "Substrate-thermal routing for q_rxn" backlog item.

Nested-specs BASIC_FIELDS rollout (MR !136). Extends the BASIC_FIELDS / "Advanced settings…" two-tier presentation introduced on CaseHeader (MR !132) and rolled to top-level pipe/junction/BC specs (MR !135) down to nine nested closure subforms: FrictionSpec, HeatSpec, PorousSpec, CatalystSpec, FilterSpec, UreaSpec, SpeciesSpec, EOSSpec, ThermalWallSpec. Each subform now declares a BASIC_FIELDS frozenset; the form factory renders those fields inline and tucks the remainder under a collapsed "Advanced settings…" expander. A typical closure form (HeatSpec, CatalystSpec) now opens with the kind discriminant + 2-4 core knobs visible, the long-tail tuning fields hidden by default. TOML on disk is unchanged — only the GUI presentation differs, schema field names + defaults + parser strict gate are identical. test_form_factory_basic_advanced.py grew to 107 tests covering every adopter. See docs/gui.md Inspector section for the field-count table per spec.

Phase A.5 dy-buffer hoist + top-level BASIC_FIELDS rollout (MR !135). Two unrelated wins in one MR. Perf: Phase A.5 hoists the per-call dy allocation in the @njit Brinkmeier RHS out of the inner loop into a reusable buffer threaded through _qs_chem_brinkmeier_rhs, saving one ndarray malloc/free per chemistry sub-step. ~1.026× wall-clock on AVL Cruise-M MFR_transition_SC630 (47.0 s → ~45.8 s on the 12 s sim, on top of the !132 fused-rhs 2.08×). GUI: extends the BASIC_FIELDS / Advanced split from CaseHeader to PipeSpec, JunctionSpec, and BCSpec so a typical pipe form now shows id / length / n_cells / diameter / init (5 basic) up-front with the 11 advanced knobs (area_var, gas, friction, heat, porous, catalyst, filter, urea, species, eos, thermal_wall, reaction_demo, py_reaction) collapsed. The form factory also gains kind-dispatch for closure subforms so PipeSpec.friction / .heat / .porous render the right discriminated dataclass per kind.

Default qs_chem_fused_rhs = True (MR !134). Bakes the !132 fused @njit Brinkmeier RHS win into the default path: schema.py:349 now reads qs_chem_fused_rhs: bool = True # default flipped 2026-05-31, 2.08× win, so a fresh case authored against v0.9 picks up the 2.08× AVL Cruise-M speedup without an opt-in line. The bit-equivalence regression (tests/integration/test_qs_chem_fused_rhs.py) gates the flip: fused_rhs = True and False produce byte-identical state trajectories on the AVL MFR_transition_SC630 reference, so the default flip is observable only as wall-clock. Set qs_chem_fused_rhs = false per case to recover the legacy non-fused path (debug / bisect).

Fused @njit Brinkmeier RHS (MR !132) — 2.08× AVL Cruise-M. The quasi-steady Brinkmeier surface-chemistry RHS was previously assembled by composing several small @njit kernels (rate evaluator, coverage balance, source folder) across separate Numba dispatch boundaries. MR !132 fuses them into a single @njit _qs_chem_brinkmeier_rhs_fused function (one kernel per Picard step) so the per-cell hot path stays in a single Numba context — no inter-kernel dispatch overhead, no boxing/unboxing of intermediate arrays. Validated bit-equivalent against the legacy composed path on the AVL Cruise-M MFR_transition_SC630 reference via tests/integration/test_qs_chem_fused_rhs.py (every state field byte-identical at every output step). Wall-clock: 97.7 s → 47.0 s on the 12 s AVL sim (single-threaded, repeat-of-3 median), i.e. 8.15× realtime → 3.92× realtime, a 2.08× speedup on the chemistry-dominant inner loop. Schema flag introduced (qs_chem_fused_rhs, default False in this MR; flipped to True two MRs later in !134). Diagnostic ships in scripts/profile_post_fused.py (re-profile the fused path against the legacy on a checked-in case). New GUI feature shipped alongside: CaseHeader form split into Basic + collapsed "Advanced settings…" expander — 10 BASIC_FIELDS (name, t_end, cfl, scheme, limiter, rk, solver, adaptive_dt, dt_fixed, chem_solver) visible up-front, the remaining ~57 case-header knobs collapsed.

CI artifact retention default 24 h (MR !133). Every CI job now defaults to expire_in: 24h on its artifacts (wheels, test logs, benchmark traces). A one-shot purge cleared 28.6 GiB of historical drift accumulated on the self-hosted runner; future jobs auto-expire unless overridden per-job (e.g. release-pypi artifacts stay forever — they are signed + published).

QS junction-Picard sign-bracket-bisection guard (MR !131). Root-causes and fixes the algorithmic open question behind the qs_picard_relax knob (MR !130): on AVL Cruise-M MFR_transition_SC630 at step 479 (t=10.957 s), default relax=0.7 locks into a bit-exact stale-secant damped-Newton 2-cycle with |Δjp| = 1.935e-4 Pa < dp_fd = 1e-3 Pa, |net| = 3.178e-4 kg/s, frozen σ_used = -1.150, local S_true = -3.286 → contraction factor λ = 1 - relax · S/σ = -1.000 (perfectly reflecting Newton map). The FD-refresh gate |djp| > dp_fd prevents the secant from updating once the trajectory localises, so the underestimate-driven straddle is self-perpetuating. Fix: when consecutive Picard iters have opposite-sign net at a junction, the two iterates form a finite bracket; any damped Newton step landing outside the bracket OR below the FD-refresh gate is replaced by the bisection midpoint. Iter-local scope keeps the bracket coherent under off-diagonal multi-junction coupling. Permanent diagnostic ships in scripts/diagnose_qs_picard_2cycle.py + scripts/analyze_qs_picard_trace.py; reach for it on any future QS-Picard non-convergence. qs_picard_relax from MR !130 stays as belt-and-braces — default 0.7 now safe on the AVL inlet-drop case (was: required user-set 0.3 workaround). No schema / CLI / TOML changes.

QS junction-Picard relax knob qs_picard_relax (MR !130). Diagnosed on AVL Cruise-M MFR_transition_SC630: a 10× inlet mass-flow drop over 1 s drove the junction-pressure Picard into a non-zero fixed point — converged in the |Δjp| sense but with a stuck ~7e-3 relative imbalance that no qs_picard_tol could satisfy and no extra iteration budget could fix (500 iters returned the identical residual as 100). The Newton-damping factor in jp_new = jp − relax · net / slope was hard-coded at 0.7 on QSNetworkConfig and not exposed to the TOML. Lowering it to 0.3 breaks the fixed-point bias and lets the secant land on the true zero-imbalance solution at a small convergence-cost penalty. Default kept at 0.7 for bit-exact legacy behaviour; opt in per case (recommended: 0.3 for sharp inlet transients, down to 0.1 for stiff regimes). Banner surfaces the active value in the QS robustness section.

Substrate-wall fold: hot-patch + audit-closure (MRs !127 → !137). The post-merge full validation sweep flagged what looked like a ricardo_ktm SI engine regression after MRs !125 (Tier B-3 substrate wall fold) and !126 (Tier B-2 + C-1 + C-2 + C-3): pre-arc main had 5/10 operating points passing, MR !125 read as 3/10 with NaN at high RPM, MR !126 read as 0/10 all NaN. MR !127 hot-patched the suspected trigger by flipping the new substrate_wall_in_newton_residual default from True back to False, restoring ricardo_ktm to its 5/10 baseline. MR !137 closes the audit properly: the flag is innocent. A 5-SHA bisect plus a per-RPM bit-identity sweep over the non-DPF validation cases showed the substrate-wall fold contributes exactly zero to the Newton residual and Jacobian whenever no substrate wall is configured (the closure exits on Ω_wall = 0 / h_wall = 0, which is every non-DPF case in the repo); the original ricardo_ktm regression was misattributed to !125/!126 and traced to a separate concurrent merge. Default flipped back to True on 2026-05-31 (schema.py:475 — substrate_wall_in_newton_residual: bool = True, with the in-file comment "Default flipped back to True (2026-05-31) — innocence proved by bit-identity + 5-SHA bisect"). ricardo_ktm stays at its 5/10 baseline with the True default restored; the Tier-A precedent (catalyst + DPF cake folds default ON, MR !124) is now consistent across all three Newton folds. Set false per-case to recover the pre-v0.9 implicit-driver behaviour (substrate wall unhandled).

Implicitness audit: Tier B-2 + Tier C-1 / C-2 / C-3 (MR !126). Closes the remaining items from the implicitness audit (workflow wxwyu09j7) — companion to MR !125 (Tier B-3 substrate wall) below:

  • Tier B-2 — shaft_implicit_advance (default False): solves the shaft / EHR-neck port_u_n ODE I·dω/dt = τ_net implicitly per macro step via fixed-point backward-Euler iteration over post-Newton converged ghosts. Shaft timescale (~50-500 ms) is already 100× decoupled from acoustic Δt by physics; gain is transient-load sharpness on fast step-changes / surge transitions, not stability. Default-off preserves legacy bit-exact path.
  • Tier C-1 — species_implicit_upwind (default True): replaces the implicit-network driver's explicit upwind species sub-stepping (acoustic-CFL bound, O(100) sub-steps at idle / low velocity) with the QS driver's implicit-upwind backward-Euler march. Single sweep per macro step, unconditionally stable, ~100× cheaper at idle. Chemistry stays operator-split (next sub-step) — no double-counting.
  • Tier C-2 — junction_volume_implicit_advance (default False): same fixed-point pattern as B-2 applied to junction volume state (m, E_internal). Combines with shaft_implicit_advance in a single fixed-point loop when both flags are set. Default-off preserves legacy.
  • Tier C-3 — multilayer_wall_implicit (default False): Crank-Nicolson semi-implicit advance for MultiLayerWall radial diffusion via a per-cell tridiagonal solve (Thomas algorithm). Folds the linear radial parts (cap, UA, h_gas, h_ext); radiation (T^4) and axial conduction stay explicit as right-hand-side forcing. Kills the radial-diffusion stability constraint at thick insulation / aggressive Δt. Routed via the explicit driver's advance_standalone_walls.

Audit verdict on Tier B-1 (heat-Nu fold): already done — constant-h analytic Jacobian + variable-Nu FD Jacobian both ship via _augment_jacobian_with_closures. A memory file captures the verdict on a full A·x=b refactor (NO-GO; incremental sympy auto-diff helper for closure Jacobians is the standalone low-risk follow-up).

DPF substrate-wall implicit fold (MR !125 — Tier B-3 of the implicitness audit). Closes a pre-v0.9 correctness gap: the implicit- network driver did not handle substrate_wall_into at all (only the explicit + QS drivers did), so implicit-driver runs with a configured substrate wall silently left T_wall frozen at its initial value. The fold puts q_wall = h_wall · (T_wall − T_gas) into the Newton residual + Jacobian (analytic, ideal-gas closed form via substrate_wall_jacobian), with T_wall advanced post-Newton as the slow state — mirroring the catalyst T_solid and DPF T_cake post-Newton advances. New opt-in flag substrate_wall_in_newton_residual defaults to True (Tier-A precedent); set False to keep substrate wall unhandled in the implicit driver (the pre-v0.9 behaviour). Ignored by explicit + QS drivers (they continue to call substrate_wall_into directly). No example TOML intersects implicit-RK with a configured substrate wall, so zero behaviour change on the validation suite.

Implicit-folds default ON: catalyst + DPF cake (MR !124 — Tier A of the implicitness audit). Two pre-existing semi-implicit folds for the implicit-network driver had been opt-in / default-off since they shipped (MRs !76 + !77). The implicitness audit confirmed they are the lowest- risk highest-ROI win: zero example TOML in the repo intersects implicit- RK with a catalyst or DPF closure (every AT case routes through the QS driver, where the catalyst fold is already always-on via MR !120), so flipping the defaults to True does not change observable behaviour on any validation case. New cases authored with implicit-RK + catalyst / DPF now get the cleaner Newton-folded path by default — no manual opt- in line per case. Set catalyst_in_newton_residual = false or dpf_cake_in_newton_residual = false to recover the legacy Lie-split path per case. All 195 existing TOMLs were swept so they now carry = true explicitly (still strict-load-compliant).

QS chemistry wall-clock-time gate qs_chem_interval (MR !123). The existing qs_chem_every_n (step counter) ignored the size of the macro Δt, so when |u| dropped — idle / cold-start ramp-down / HRR controller releasing — N · Δt ballooned past the chemistry timescale, smearing transients and overshooting stiff sources. New qs_chem_interval (seconds, default 0) caps the absolute simulated-time window per chem call: chemistry fires whenever Σ Δt ≥ qs_chem_interval, regardless of how few macro steps got there. Companion to the existing settle / step gates — fires on EITHER (step counter OR interval OR settle). qs_chem_interval = 0 (default) preserves legacy step-counter behaviour bit-exact.

Strict [case] section + aeolus1d expand migration (MR !122). The TOML loader now requires every CaseHeader field to appear under [case] — missing keys raise CaseParseError with the full omission list and a pointer to aeolus1d expand. The new aeolus1d expand <case.toml> subcommand reads the legacy lenient form, fills every absent field with the schema default, and rewrites a fully-specified TOML (all other sections + comments + whitespace preserved verbatim); the 200 example / validation TOMLs in the repo were migrated in this MR via the same tool. Per-spec dataclasses (GasSpec, pipe / junction / BC / closure specs) still default-fill missing fields but emit a one-shot warning naming each filled field. aeolus1d_gui case writes (save_case) now emit a fully-specified [case] block by default — saved files strict-load without an aeolus1d expand step. AEOLUS_LENIENT_CASE_HEADER=1 waives the strict gate (used by the test suite for legacy compatibility; never set in production).

Solver-settings startup banner (MR !121). Every CLI / library / GUI run now prints a one-shot banner via aeolus1d.solver.startup_banner from the single dispatch_advance funnel — plus the aeolus1d cycle-settle subcommand, which bypasses dispatch but funnels through advance_network directly. The banner labels the resolved driver (explicit network (ssp_rk2) / implicit (bdf2) / quasi_steady (low-Mach AT) / etc.), the topology (pipes / junctions / coolant / wall-thermal pairs), and the solver knobs the active driver actually reads from CaseHeader, grouped by topic (timestep / cfl / spatial / energy / chemistry / Newton / robustness). Values that differ from the schema default carry a trailing *. QS-only chemistry knobs (chem_fixed_dt, chem_be_iters, chem_linop_tol, chem_linop_model) are suppressed from the explicit and implicit banners since dispatch_advance does not thread them through those drivers; hllc_low_mach is suppressed from the implicit banner for the same reason. The QS dt_hrr_fraction row carries an inline (0 → 0.1 in QS) annotation so the implicit coercion in dispatch_advance is visible to the reader. Suppressible via AEOLUS_QUIET_SETTINGS=1.

QS catalyst gas-energy semi-implicit fold (MR !120). The catalyst gas-side coupling q_cat = h·a_v·(T_s − T_g) was added explicitly to R[:,2] and the QS steady-energy march integrated it unchanged, so a hot- bed cold-gas startup at qs_cfl_adv=5 (e.g. AVL Cruise-M with T_solid_init = 973 K, init.T = 273 K) drove the per-cell update past the t_floor = 250 K clamp on the first sweep and the sign-flipping overshoot compounded to ~4500 K. Folded K·(T_s − T_g) semi-implicitly into the per-cell upwind balance — exactly linear in the unknown T_g, so the fold is unconditionally stable for K ≥ 0 and contracts T_g toward T_s without overshoot. Capacitance limiter (q_rxn_max_dT_per_step) bounds the remaining prescribed sources (lagged q_rxn, regen heat, gas py_reaction) so a transient HRR spike can't drive a single-cell ΔT past the gas thermal capacity (was a measured ~2e5 K hazard with qs_surface_q_rxn_into_energy=True). New catalyst_K_apply_cells njit kernel + catalyst_K_into helper + _clamp_q_to_capacity. Validated: AVL with 273 K gas / 973 K solid runs to T_max ≈ 974 K (bounded near T_solid).

QS junction-pressure Picard zero-gradient bootstrap (MR !119). The junction-pressure solve stalled when a case initialised at the outlet pressure (init == outlet): the junction plenum seeds below the back- pressure, the downstream pipe carries no flow, the sub-Pa FD probe is blind, dnet=0 forever. Detect the dead zone (dnet=0 exactly) and bracket-bisect the monotone net(jp) in the imbalance direction.

QS coverage-aware super-cycle gate (MR !118). The energy-based settled-gate is blind to the θ transient on a hot catalyst; the coverage gate (qs_chem_coverage_settle_tol) forces chemistry every step while max |Δθ| over the last fire exceeds the tol. On AVL every_n=8: 2.7× speedup (vs the energy gate's 3.0×) at 3.6 % θ_Ce error (vs 21 %) and 20 % NO (vs 41 %). Independent of the energy tol — fires on either.

QS species advection — implicit-upwind march (MR !117). Explicit forward-Euler upwind was CFL-unstable above the advective CFL 1; with qs_cfl_adv > 1 (a perf setting) every species tracer grew geometrically (N2 from 0.768 to ~14 900 on AVL). Replaced with a backward-Euler upwind march on the divergence-free upwind momentum ρu·A_face — unconditionally stable and bounded by the maximum principle for any Δt. Default path byte-identical for cfl ≤ 1.

Opt-in fixed-step backward-Euler chem solver (MR !116, chem_solver="fixed_step"). Fixed-step BE integrator (chem_be_iters Newton iters per sub-step of chem_fixed_dt) for non-stiff / mild surface mechanisms — matches LSODA to machine precision at a few-x speedup. Deliberately scoped OUT of stiff TWC (OSC overshoots; documented).

Generic mechanism builder (MR !115, reaction="mechanism"). Schema- driven MechanismSpec (gas + reversible + surface coverage) compiles to an @njit _mech_xi_jit kernel. One TOML file defines a full mechanism; attaches via reaction = "mechanism" + mechanism_file = "...".

ML linop chem surrogate (MR !114) — closed-negative. Two-stage linearized surrogate with relaxation-fraction validity gate. Re-evaluated post species-advection fix: 0 % gate acceptance on stiff Brinkmeier (single-rate model saturates), ~8 % slower than LSODA, closed like ISAT — keep LSODA for stiff catalysts.

Settled-gate for super-cycle (MR !113) + ML/ISAT framework (MR !112). Energy-activity gate (qs_chem_settle_tol) that forces chemistry every step during light-off and only super-cycles once settled; default 0 = legacy unconditional cycle.

v0.9 (pre-release) — GUI visual refresh + FMEP

Editor canvas — per-kind silhouettes (P0+1+fix, MR !71). Every component on the editor canvas now renders as a kind-specific extruded silhouette — pipe = horizontal capsule, junction = diamond, cylinder = vertical pill, BC = chevron, shaft = thin bar, compressor / turbine = bowtie — instead of the generic 0.40 × 0.40 flat square. Each silhouette inscribes in the same bbox so port-offset / selection-halo / picking maths stays unchanged. New aeolus1d_gui/editor/visual_style.py consolidates every visual constant (was scattered across nodes.py, canvas.py, schematic_style.py, theme.py) and new aeolus1d_gui/editor/silhouettes.py owns the per-kind outline geometry. A dark polyline outline traces the silhouette boundary so the dark border doesn't drag along internal triangulation edges.

Canvas atmosphere (Phase 2–4). Soft drop shadow under every block (theme-aware opacity: subtler on dark, crisper on light); cone arrowhead at the bezier midpoint of every connection line pointing along physical flow; vertical gradient background (indigo tint on dark, near-white at top on light) with a subtler grid hierarchy and an aeolus1d · 2D editor corner watermark.

Hover tooltip + typography pass (Phase 5–6). Hovering any node pops a QToolTip with the component id + 2–3 kind-specific spec values (pipe length + n_cells + catalyst/filter presence; junction kind + volume; cylinder bore × stroke + ω rad/s + rpm; BC kind + end; shaft ω₀; turbo kind + vane). Six prior label call sites in nodes.py collapse to a single _add_canvas_label helper; font size + anchor pad live in visual_style constants.

Opt-in flow animation (Phase 7). A new "Animate flow on edges" checkbox in Preferences → Canvas (default off) drives a ~30 fps QTimer that walks a bright cream-yellow (dark theme) / deep-amber (light theme) dot along every connection bezier. The dot uses a theme-aware accent rather than the edge's family colour so it pops off the line. Direction is geometry-consistent: edges whose to-endpoint ends in pipe::right (exhaust runners, exhaust BCs, exhaust plenum returns) and compressor-shaft edges (power flows shaft → compressor) automatically reverse the polyline so the dot + arrowhead point with actual flow regardless of how case_connections enumerated the edge.

Data-driven decorations (Phase 8). New aeolus1d_gui/editor/decorations.py adds spec-encoding glyphs on top of the silhouettes: pipe.n_cells → tick marks across the top edge (capped at 10), [catalyst] present → green corner badge (top-right), [filter] / [dpf] present → grey corner badge (top-left). Other kinds surface key fields via the hover tooltip instead — the right venue for fields that need units.

Vehicle-case TOML editor (MR !70). Opening a v0.7 vehicle-system TOML ([vehicle] / [driver] / [drive_cycle] / [report]) now pops a modal form-editor dialog with tooltips on every field (vehicle mass, drag, rolling resistance, driver PI gains, drive-cycle source, BSFC / fuel-density / CO₂ post-processing). Powertrain blocks + segments lists are preserved verbatim on save. A sister read-only TOML viewer dock on the right shows the canonical serialisation in real time. aeolus1d.io.vehicle_case exposes load_vehicle_case_dict / save_vehicle_case_dict / is_vehicle_case_dict for the routing.

Vehicle-system top-level keys recognised (MR !69). Loading a vehicle case from the example browser no longer prints unknown top-level key 'vehicle' / 'driver' / 'drive_cycle' / 'report' warnings — they're whitelisted in aeolus1d.io.validate alongside the gas-network keys.

FMEP correlation — predicted BMEP (MR !72). Engine cylinders now optionally carry a [fmep] block (Chen-Flynn coefficients A, B, C, D) so cycle-end metrics report a predicted FMEP_Pa and BMEP_Pa = IMEP_Pa - FMEP_Pa instead of dropping the brake side. Default coefficients are the heavy-duty diesel values from Heywood; tunable per cylinder. src/aeolus1d/analysis/engine_metrics.py:FMEPCoeffs is the new dataclass; the schema parser + build wiring round-trip it.

v0.8 (pre-release) — electrified drive cycle

W5 — electrified (parallel-hybrid) drive cycle. A [powertrain] block in a vehicle-system case turns it into a parallel hybrid: aeolus1d drive-cycle splits the driver's per-step torque demand between engine and e-motor, coulomb-counts the battery, and reports the W5 deliverable — SoC trajectory, electrical energy used vs regen recovered, and engine-share fuel + tailpipe CO₂. Reuses the existing aeolus1d.hybrid powertrain (envelope e-motor + OCV/1-R battery + power-split controller) wired through the v0.7 aeolus1d.io.vehicle_case layer (run_electrified_case). New example 86_electrified_drive_cycle.toml; see vehicle-system.md. This adds W5 to the validation dossier — the parity workflow set is now W1–W5.

v0.7 (pre-release) — vehicle system

W2 turbo matching banked. A new examples/validation/turbo_match/ suite validates turbo matching as a physics-conservation + map-fidelity check (like W4's analytic transmission loss, not an external-dataset comparison): at steady state the solver must find the shaft-speed equilibrium where turbine power equals compressor power, with the compressor above surge and inside its PR map. Passes 3/3 across fixed wastegate settings (power balance to ~1e-7 relative; matched power monotone in the wastegate). This flips W2 from pending to ✅ in the validation dossier and the release gate — W1–W4 are now all banked.

Drive cycles from text — aeolus1d drive-cycle. The vehicle-system layer (longitudinal dynamics, closed-loop PI driver, drive-cycle profiles, cycle KPIs) is now runnable from a TOML case without writing Python. A new self-contained [vehicle] / [driver] / [drive_cycle] / [report] case format (aeolus1d.io.vehicle_case) feeds the aeolus1d drive-cycle <case.toml> [--out trace.csv] subcommand, which runs the driver over the cycle and reports distance, tracking RMSE, positive wheel work, and — with a [report] BSFC — fuel and CO₂. The cycle source is builtin (illustrative), csv (official WLTP/NEDC/FTP-75/JC08 tables you supply), or segments (exact synthetic cycles). Regulatory tables are public but long empirical traces and are deliberately not bundled — point source = "csv" at the official file. See vehicle-system.md. New example 85_vehicle_drive_cycle.toml.

v0.6 (pre-release) — acoustics / NVH

Validation dossier. A new validation dossier collects the four parity workflows (W1 engine breathing, W2 turbo matching, W3 aftertreatment drive cycle, W4 acoustics/NVH) in one place: the checked-in case that exercises each, its reference dataset, the current pass/fail verdict (including the corners that fail and why), and the wall-clock budget. It is the citable credibility artefact for the v1.0 parity release — every claim points at a file you can run. The maintainer release readiness gate is refreshed against it (KTM now 8/10 at the 25 % gate with physical flow_nu runner heat; C02006 drive cycle runs end-to-end in ~6.5 min).

Stiff-chemistry health in the run summary. aeolus1d run now prints a per-pipe chemistry-telemetry line for any pipe running the Brinkmeier BS solver — mean substeps/cell, the most-fought cell, and the reject ratio — flagging "⚠ struggling" at reject ratio ≥ 0.3. The retry telemetry was already computed each step and reported on the failure path (with ranked excess factors via chem_diagnostics); it is now visible on the success path too, so a run fighting the stiffness shows up before it becomes a blow-up.

Octave-band analyzer roll-off. octave_band_levels gains an optional rolloff_order — instead of the default ideal brick-wall bands it applies a Butterworth band-pass analyzer filter of that order (butterworth_band_weight: exactly −3 dB at the nominal band edges, asymptotic skirt 6·order dB/octave per side), so adjacent bands overlap and out-of-band energy leaks in like a real real-time analyzer. Exposed on the CLI as aeolus1d acoustics --octave-rolloff N. Implemented as a general configurable filter order (standard filter theory) rather than hardcoded analyzer presets.

Documentation. Full-text docs search is now declared explicitly with an identifier-aware separator ([\s\-\.\_]+) so symbol-heavy pages are searchable by their parts (flow_nu, T_wall, make_urea_one_step), plus search-as-you-type suggestions. The same search now works inside the GUI: the Help → Documentation viewer (Ctrl+H) gains a search box (Ctrl+F) that runs an AND-of-terms full-text search over the bundled pages — ranked results with context snippets, click-to-navigate, and match highlighting on the page, using the same identifier-aware tokeniser as the published site. Added a "Choosing a time integrator" guidance table to docs/numerics.md mapping the seven rk schemes (ssp_rk2/3, rk4/5, implicit_euler, bdf2, radau_iia) to flow regimes by order, stability, and numerical damping (GT Mechanical §1.7.8–9).

Transfer-matrix side-branch elements (acoustics_tm). The four-pole muffler solver gains shunt (side-branch) resonators: shunt_four_pole ([[1,0],[1/Z,1]]), helmholtz_resonator_impedance (Z = j(ωM − 1/ωC), resonance f₀ = (c/2π)√(S/(V·L′)) with neck end-correction) and quarter_wave_tube_impedance (Z = −jz·cot(kL), f = c/4L), plus a tl_from_abcd helper. The TL notch lands at the analytic resonance for both branches (unit-tested). Lets the TM solver model reactive silencer elements, not just expansion chambers.

aeolus1d acoustics CLI. Post-processes a stored pressure-probe trace (from aeolus1d run --trace-dt) into NVH metrics without a Python script: overall SPL, dB(A) and dB(C), and 1/1- or 1/3-octave band levels, plus the transmission loss between two probes (--probe-out). Reuses the audio command's probe-string parser and sub-cell extraction.

Campbell diagram (campbell_diagram). Builds the order-line grid (f = rpm/60 · order) from an OrderTrack RPM sweep and finds where order lines cross supplied structural / acoustic resonances — the candidate critical speeds (rpm = 60·f_r/order), each with the order amplitude interpolated at the crossing speed. Returns CampbellDiagram / CampbellCrossing. Unit-tested + mypy-strict.

C / Z frequency weighting (spl_db_c / spl_db_z). Completes the IEC 61672 weighting family beside the existing A-weighting: C-weighting (nearly flat, for peaky / low-frequency-rich noise like exhaust boom; normalised to 0 dB at 1 kHz) and Z-weighting (flat / unweighted). Same per-bin spectral method as spl_db_a. Unit-tested (1 kHz normalisation, extreme roll-off, dB(C) > dB(A) at low frequency) + mypy-strict.

Four-pole transfer-matrix acoustic solver (analysis.acoustics_tm). The frequency-domain muffler-design workflow: chain the 2×2 ABCD transfer matrices of duct elements and read transmission loss directly (transmission_loss_tm), far faster than a time-domain run. Validated to 1e-6 against the closed-form expansion_chamber_tl (Munjal §4). Unit-tested + mypy-strict.

Combustion-noise prediction (acoustics.combustion_noise_db). The AVL combustion-noise-meter method on an in-cylinder pressure trace (GT Acoustics §1.2.5) — aeolus1d uniquely has the cylinder-pressure trace. The pressure spectrum is filtered by a frequency-dependent structure attenuation (cylinder pressure → radiated surface noise; configurable, with a documented generic engine-block low-pass default generic_structure_attenuation) and collapsed to an overall level. Rises with the rate of pressure rise (knock / sharp Diesel premixed burn). Unit-tested + mypy-strict.

Free-field microphone — monopole radiation (acoustics.freefield_monopole_pressure). Far-field pressure from a pulsating orifice modelled as a compact monopole: p(r,t) = ρ₀·S/(4π·r)·du/dt at retarded time t − r/c — the bridge from in-pipe velocity to a listener's SPL/spectrum (GT Acoustics §1.2.1). Follows the inverse-distance law and the source-strength time derivative; feed it to spl_db / power_spectrum for the radiated level. Unit-tested + mypy-strict.

Transmission-loss correctness fix: two-microphone wave decomposition. The old transmission_loss was a naive 20·log10(|P_in|/|P_out|) ratio of total probe pressures — wrong wherever a reflection exists (the upstream probe sees incident + reflected superposed). Added decompose_waves (Chung-Blaser / Seybert-Soenarko forward/backward plane-wave split from two probes spaced s, valid for f < c/2s) and transmission_loss_decomposed (four-microphone TL = 20·log10(|A_incident|/|A_transmitted|) from the upstream + downstream forward waves) — correct in the presence of reflections / non-anechoic terminations. The naive function is kept but now carries a warning pointing at the decomposed version. Unit tests (forward/backward recovery, known attenuation, reflection case where the naive ratio disagrees) + mypy-strict.

v0.8 (pre-release) — electrified drive cycle

Power-split planetary device (aeolus1d.planetary). The third hybrid topology (Toyota-THS-style e-CVT): a PlanetaryGear splits engine power between a generator (sun) and the driven ring via the lever relations (1+k)·ω_c = ω_s + k·ω_r and torque split T_s:T_c:T_r = 1:(1+k):k (k = N_ring/N_sun), conserving mechanical power across the set. Varying the generator speed continuously varies engine speed at fixed output (the e-CVT). Unit-tested + mypy-strict.

Regenerative-braking split (aeolus1d.regen). Splits a brake-force demand between the e-machine (recovering energy to the battery, bounded by its regen-force envelope and faded out at low speed) and the friction brakes; reports the recovered electrical power for the energy-management layer. Unit-tested + mypy-strict.

ECMS energy-management strategy (energy_management.ECMSGenset). An equivalent-consumption-minimization controller beside the thermostat: picks the genset power minimizing actual fuel + battery throughput priced through an equivalence factor, with SoC feedback raising the price when the pack is low (charge-sustaining without a hard band). Unit-tested + mypy-strict.

Series hybrid + charge-sustaining EMS (aeolus1d.energy_management). The series topology (engine never drives the wheels — a genset feeds the traction motor + battery) composing the v0.8 components: SeriesHybrid routes wheel power through a MappedEMotor and buffers the difference in an RCNetworkBattery, with a ThermostatEMS charge-sustaining controller (genset on below soc_lo, off above soc_hi). Reports fuel + SoC per step. The RCNetworkBattery also gained pack scaling (n_series / n_parallel → pack voltage / capacity) for vehicle-scale power. Unit-tested (hysteresis, charge-sustaining band, fuel-only-when-on, regen) + mypy-strict.

DC-DC converter loss (emotor.DCDCConverter). Completes the v0.8 power electronics: a battery↔DC-link converter with a no-load + switching-proportional + conduction-quadratic loss model (P_loss = P_noload + k_sw·|P| + R_eq·(P/V)²), efficiency and sign-preserving output_power_W. Unit-tested + mypy-strict.

Map-based e-motor + inverter losses (aeolus1d.emotor). MappedEMotor replaces the flat-scalar motor efficiency with a 2-D bilinear η(|T|, ω) map + a peak-torque / constant-power envelope; electrical_power_W draws P_mech/η motoring and recovers P_mech·η regenerating. Inverter adds a simplified six-switch IGBT/MOSFET conduction (2·(V_ce0·I + R_on·I²)) + switching (6·f_sw·E_sw·(I/I_ref)·(V_dc/V_ref)) loss model. Unit-tested + mypy-strict.

Multi-RC equivalent-circuit battery (aeolus1d.battery_ecm). A Randles ECM — ohmic R0 in series with N parallel RC branches — upgrading the OCV+single-resistance hybrid.Battery. Each RC branch captures one dynamic process (charge transfer ~sub-second, diffusion ~seconds) via τ = R·C (exact exponential update, stable for any dt); V = OCV(SoC) − I·R0 − ΣVᵢ. Coulomb-counted SoC with charge (Coulombic) efficiency; current- or power-driven (step / step_power). Unit-tested + mypy-strict.

flow_nu constant-flux laminar floor. The flow_nu wall-heat closure gained a laminar_nu parameter (default 3.66, the constant-wall-temperature value); pass 4.36 for a constant-wall-heat-flux duct. Threaded through the full surface chain (registry/schema/parser/build + GUI). Unit-tested.

v0.7 (pre-release) — vehicle-system depth

AMT shift sequencing + elastic flange (aeolus1d.transmission_shift). AMTShiftController drives a Clutch + Gearbox through the four-phase automated-manual gear change — disengage → speed synchronisation → engage → end — as a state machine (clutch ramps down, gear swaps with the clutch open, clutch ramps back up). ElasticFlange adds torsional driveline compliance (two-inertia spring+damper) with its natural-frequency property, for shift-quality / torque-dip studies. Unit-tested + mypy-strict.

Vehicle analysis tasks (aeolus1d.analysis.vehicle_tasks). The standalone manoeuvres + cycle KPIs a powertrain study quotes: coastdown (road-load check — time/distance to stop), full_load_ acceleration (WOT time to each speed, e.g. 0–100 km/h), elasticity (in-gear pull, e.g. 80→120 km/h), max_gradeability_rad (analytic max sustainable grade at speed under full load), and cycle_kpis (distance / avg+max speed / positive wheel work, with optional BSFC-based fuel-l/100km + CO₂-g/km). Unit-tested + mypy-strict.

Engine BSFC fuel map (aeolus1d.fuel_map). A bilinear BSFC(T, ω) [g/kWh] map → fuel_rate_kg_s (with over-run fuel cut for non-positive brake power) + best_bsfc, replacing the single scalar consumption in cycle KPIs and feeding energy management. constant_bsfc_map drop-in. Unit-tested + mypy-strict.

Closed-loop driver model (aeolus1d.driver). Tracks a DriveCycle target speed and emits the actuator demands a real driver provides — an engine-torque request (accelerator) + a brake force — via a PI on the velocity error plus a road-load feed-forward (so the integrator only trims the residual), with anti-windup + engine/brake saturation. follow_cycle(cycle, vehicle, ...) runs the driver ↔ vehicle.step_vehicle loop and returns the speed/torque/brake traces + distance + tracking RMSE — the W3 vehicle-cycle driver. (Throttle-BC coupling into the gas engine + multi-gear driveline shift scheduling are the AMT follow-ups.) Unit-tested + mypy-strict.

Drive-cycle framework (aeolus1d.drive_cycles). A DriveCycle speed-vs-time profile — the input a closed-loop driver tracks (distinct from the engine-side RPM/torque DriveCyclePoint). Built three ways: from_csv (load the official WLTP / NEDC / FTP-75 / JC08 regulatory tables — public data, dropped in rather than bundled), from_segments (construct exactly from idle/accelerate/cruise/decelerate phases, how NEDC and synthetic cycles are defined), or the bundled urban_illustrative / highway_illustrative demo profiles (clearly not regulatory cycles). Provides distance integral, interpolation, and uniform resampling. Unit-tested + mypy-strict.

Control / signal-processing toolbox (aeolus1d.control.signal_toolbox). A reusable block library generalising the domain-specific PI controllers — the building blocks for a driver model and energy-management strategies (the aeolus1d analogue of AVL Cruise M's Function Toolbox). First-order LowPassFilter (exact piecewise-constant lag, stable for any Δt) / HighPassFilter / ExponentialAverageFilter / MovingAverageFilter; RateLimiter (asymmetric slew) / Saturation / Relay (Schmitt hysteresis) / DiscreteDelay / TransferFunction (discrete IIR difference-equation); stateless sources (step / ramp / sine / sawtooth / exponential) and rms / rmse statistics. Pure-Python, allocation-free per update(x, dt); unit-tested + mypy-strict.

v0.5 (pre-release) — turbo robustness + aftertreatment depth

The W2 (turbo-matching) arc per the GT-POWER parity roadmap: harden the turbocharger model so off-design and off-test-gas operation stay trustworthy.

DPF substrate-wall thermal inertia. The diesel particulate filter gains a per-cell substrate-monolith temperature T_wall with its own volumetric heat capacity Omega_wall and gas↔wall coefficient h_wall ([pipe.filter.thermal]), seeded from FilterSpec.T_solid_init. Previously the filter's only solid-phase energy state was the soot cake (T_cake); the substrate ran in gas-equilibrium, so a DPF couldn't lag the gas thermally or act as the thermal-inertia element AVL Cruise M relies on for WHTC / cold-start (e.g. C02013). The new substrate_wall_into closure is a pure linear two-phase exchange (no chemistry) — R_E += h_wall·(T_wall − T_gas), dT_wall/dt = h_wall·(T_gas − T_wall)/Ω_wall — integrated as a slow state in both drivers: the quasi-steady transient solver (the drive-cycle path, mirroring the validated catalyst-T_solid / cake-T_cake integration) and the explicit SSP-RK timeloop (snapshot + RK2/RK3 combiner at every stage, parallel to T_cake). Both Omega_wall and h_wall default to 0 (substrate in gas-equilibrium → bit-identical to before; verified against the full regen / HRR / QS regression suite). Demonstrated by examples/84_dpf_substrate_inertia.toml (cold substrate, hot inlet: the wall warms but lags the gas; a larger Ω_wall lags more).

Turbocharger robustness pack. Three independently-toggled knobs, each off by default so existing turbo cases are bit-identical:

  • γ/R off-test-gas correction (gas_property_correction, GT Flow §3.10). A compressor / turbine map is measured on one gas (usually dry air) but runs on another — EGR-diluted intake, rich-SI or diesel part-load exhaust — whose γ·R differs. TurboMap.gas_property_factor returns √(γ_ref·R_ref / (γ·R)); this single factor scales both the corrected speed entering the map and the physical mass flow recovered from it (both non-dimensional groups carry √(γ·R·T0), while the legacy corrected definitions folded √(γ·R) into the implicit reference gas). The map's reference gas is set via map_gamma_ref / map_R_ref.
  • Near-surge mass-flow damping (surge_damping / surge_margin_ref, Compressor). The map's constant-edge clip turns a surge-line excursion into a hard ṁ discontinuity that can drive a boundary limit-cycle. Inside the surge-margin band the per-step ṁ is under-relaxed toward the previous value with a weight ramping from 0 at the band edge to surge_damping at the surge line — the steady operating point is unchanged.
  • Shaft mechanical losses (Shaft.eta_mech + bearing_friction). eta_mech debits the net driving (turbine-positive) power for bearing / seal losses; bearing_friction adds a viscous windage drag P = k·ω² that opposes rotation even at zero load, so a spun-up shaft coasts down.

Full surface chain (schema → parser → build → GUI writer round-trip), bundled examples/83_turbo_robustness.toml, unit (test_turbo_robustness) + build-round-trip tests; ruff + mypy-strict clean.

External gas↔surface film mass-transfer (film_transfer). A monolith reaction is two resistances in series — bulk-to-surface film transport then surface kinetics. New sherwood_hawthorn / sherwood_sieder_tate (Chilton–Colburn mass-transfer analogues of the catalyst Nu correlations), mass_transfer_coeff (k_m = Sh·D_mol/d_h), and film_limited_rate_constant (1/k_eff = 1/k_v + 1/(k_m·a_v)) give the high-T mass-transfer plateau GT Aftertreatment §2.1 eq. 5 describes, which the bulk-concentration kinetics path misses. Composes with the Thiele internal-diffusion effectiveness factor. Unit-tested + mypy-strict. (Wiring k_eff into the monolith/TWC/SCR kernels is the remaining follow-up.)

Species-resolved washcoat pore diffusion (thiele). The diffusion-limitation effectiveness factor can now be driven by a per-species effective diffusivity instead of a single scalar: knudsen_diffusivity (D_K ∝ d_pore·√(T/M)), bosanquet_diffusivity (combined molecular + Knudsen), and effective_diffusivity (D_eff = ε/τ · D_pore, GT Aftertreatment §3.5). The new apply_effectiveness_pore decorator computes the Thiele modulus from this temperature- and molar-mass-dependent D_eff, so two reactions consuming different species (NO vs NH₃) acquire different effectiveness factors under the same diffusion limitation — the multi-reaction selectivity a scalar D_eff cannot represent. Prerequisite for dual-layer catalysts. The scalar apply_effectiveness is retained. Unit-tested + mypy-strict.

Urea (AdBlue) decomposition (urea_decomposition). SCR systems dose aqueous urea upstream of the catalyst; it must evaporate and decompose to NH₃ before it can reduce NO (GT Aftertreatment §5.2). make_urea_one_step is the lumped approach 2 (urea + H₂O → 2 NH₃ + CO₂); make_urea_thermolysis is approach 3's thermolysis step (urea → NH₃ + HNCO) feeding the existing make_hnco_hydrolysis. Both optionally fold in the charge-cooling latent-heat sink of evaporating the AdBlue carrier water (water_mol_ratio × L_water) plus the endothermic decomposition enthalpy — so the gas cools as urea converts, closing the charge-cooling gap flagged in the Mendeley ethanol/water validation closure. Unit-tested + mypy-strict.

In-cylinder CO formation (make_co_formation). The Onorati 2-reaction non-equilibrium CO model (carried over into v0.5), relaxing CO toward its equilibrium concentration with the BOOST/Cruise-M Arrhenius constants.

Extended-Zeldovich NOx with N₂O path (make_zeldovich_nox_extended). Upgrades the forward-only thermal-NO model to the AVL BOOST / Cruise-M non-equilibrium reduced form: the three thermal reactions (R1–R3) collapse under the N-atom QSS to d[NO]/dt = 2·R₁·(1 − β²)/(1 + β·R₁/(R₂ + R₃)) with β = [NO]/[NO]_eq, so NO relaxes back toward equilibrium when it overshoots (the old model could only form NO). The N₂O intermediate path (R4–R5) adds an analogous term that dominates in lean, lower-temperature burnt gas. Partial- equilibrium [O] and [N] radicals are computed internally; [OH] and [NO]_eq are supplied columns. C_post_proc_mult is BOOST's C_PostProcMult calibration scalar. Unit-tested (Arrhenius scaling, overshoot relaxation, N₂O contribution, post-proc-mult linearity) + mypy-strict.

v0.4.0 (pre-release) — flow-physics closures

The post-keystone arc per the GT-POWER parity roadmap: make the plain-pipe flow model credible without hand-tuned constants (→ strengthens the W1 engine-breathing workflow).

Flow-correlation wall heat transfer ([pipe.heat] kind = "flow_nu"). A plain pipe's wall heat-transfer coefficient is now computed per cell from the local flow — h = Nu(Re, Pr)·k_gas/D_h with Re = |ρ·u|·D_h/μ — instead of a hand-supplied constant h (constant_twall). Four selectable turbulent-pipe Nusselt correlations — Dittus-Boelter (with the heating/cooling exponent split), Colburn, Sieder-Tate, Gnielinski — over a fully-developed laminar floor (Nu = 3.66 below Re ≈ 2300); the wall flux s_E = h·(T_wall − T)·4/D_h then heats or cools the gas. The Nusselt pattern is the one already proven inside the catalyst closure, lifted to a general py_heat overlay (it needs the cell velocity, which the @njit heat_source(ρ, p) path doesn't carry). Bundled example 77_pipe_flow_heat.toml (a hot exhaust runner losing heat to a cooled wall). Full surface chain (schema HeatSpec → build → GUI writer round-trip), closures-reference doc regenerated, unit tests pin each correlation against its textbook closed form.

Lumped minor (K-method) loss ([pipe.friction] kind = "minor_loss"). A fitting — bend, contraction, expansion — imposes a minor pressure loss Δp = K·½ρ|u|u; the closure distributes it over L_loss as a momentum sink s_mom = −(K/(2·L_loss))·ρ|u|u (energy s_E = u·s_mom). It is the lumped-fitting complement to the distributed wall friction (darcy_const / colebrook), additive in GT's K_total = K_bend + K_friction. Structurally identical to Darcy friction (A = K/(2·L_loss) in place of f/(2·D_h)), so it reuses the closed-form ∂S/∂U Jacobian verbatim — verified against finite differences. Supply K_loss from a Miller / Idelchik chart (geometric K(R/D, angle, Re) auto-lookup is a documented follow-up). Bundled example 78_pipe_bend_loss.toml (a 90° long-radius elbow); full surface chain + unit tests.

Geometric bend-loss lookup (minor_loss from bend geometry). A minor_loss closure can now derive its K directly from the bend geometry instead of a hand-looked-up coefficient: leave K_loss unset and supply bend_r_over_d (centre-line radius / hydraulic diameter) and bend_angle_deg (turn angle), and the build computes K from the Idelchik smooth-bend correlation (aeolus1d.closures.bend_loss; Handbook of Hydraulic Resistance, Diagram 6-1, K = A1·B1 with the turn-angle and relative-radius coefficients). A 90° elbow at R/D = 1 gives K ≈ 0.21; R/D = 0.5 → ≈ 1.2; R/D = 2 → ≈ 0.15. An explicit K_loss still wins when both are given. The derived K packs into the same K-method kernel slot, so the source / Jacobian are unchanged. Bundled example 81_bend_geometric.toml; full surface chain + unit/build/GUI tests.

Discharge-coefficient orifice model (valve_orifice enhancements). The Saint-Venant choked/subsonic throat core is factored into a shared, unit-tested module (aeolus1d.bc.orifice_flow) and the orifice valve BC gains four opt-in, backward-compatible features. (1) Area-ratio default Cd — pass Cd <= 0 to derive the discharge coefficient from the throat-to-pipe area ratio β via the vena-contracta + velocity-of-approach closed form Cd(β) = Cc/√(1−(Cc·β)²) (Cc = 0.61; β→0 ⇒ 0.61, β→1 ⇒ ≈0.77). (2) Forward/reverse Cd — when the pipe interior sits above the reservoir the BC now vents outward through the same orifice formula (reservoir as the downstream sink) using Cd_reverse instead of collapsing to a wall. (3) Borda-Carnot recovery — borda_carnot = true adds the sudden-expansion static-pressure rise Δp = ρ·u_th²·σ(1−σ) to the ghost pressure. (4) Perforated plate — n_holes scales the effective throat area (and the β feeding the default-Cd correlation). Bundled example 79_orifice_plate.toml (a 4-hole restrictor plate with area-ratio Cd + recovery); full surface chain + unit/integration tests.

Reynolds-dependent discharge coefficient (ISO 5167). The valve_orifice BC gains an opt-in cd_reynolds flag (+ mu_gas): when set, the discharge coefficient is recomputed every step from the pipe Reynolds number via the ISO 5167-2 Reader-Harris/Gallagher correlation (orifice_flow.iso5167_cd, corner tappings) instead of a fixed / area-ratio Cd. Recovers C∞ = 0.5961 at high Re and matches the ISO reference ≈ 0.605 at β = 0.6, Re = 10⁶; Re is floored at the ISO validity bound (5×10³). Most appropriate for the orifice-/ perforated-plate use of the BC. Full surface chain + unit (ISO anchors) and integration tests. A cd_tapping selector ("corner" default / "flange" / "d_d2") adds the tapping-position + small-bore (D < 71.12 mm) RHG terms, using the bore derived from A_pipe — the corner path (no bore) stays the canonical two-correction form, byte-identical.

Orifice-family consolidation. The egr_valve and throttle BCs each inlined their own copy of the Saint-Venant choked/subsonic mass-flux dispatch; both now call the shared orifice_flow.orifice_mass_flux_per_area (verified bit-equivalent — its ṁ/A equals the throat ρ·u of orifice_throat_state). Both also gain the area-ratio default Cd (pass Cd <= 0 to derive it from the throat-to-pipe ratio). Pure de-duplication — no behaviour change for existing cases.

Standalone dynamic pipe wall with external convection + radiation ([pipe.thermal_wall] kind = "dynamic" without coolant). The Coolant track already advanced a per-cell wall thermal mass between a gas pipe and a coolant jacket; a bare exhaust runner had no such path. Now a dynamic wall with no [[coolant_coupling]] builds a standalone wall advanced by m_w·c_w·dT_w/dt = q̇_gas − q̇_ext, where the external loss q̇_ext = h_ext·A_ext·(T_w − T_amb) + ε·σ·A_ext·(T_w⁴ − T_amb⁴) combines convection (h_ext) and radiation (eps_rad). The gas-side sink is two-way coupled through the py_heat seam (aeolus1d.closures. wall_thermal): the overlay reads the wall's live per-cell temperature each step and returns s_E = h_gas·(T_w − T_gas)·A_wall/V_cell, so the same h_gas·A_wall·(T_gas − T_w) that heats the wall cools the gas — energy is conserved between gas, wall, and ambient by construction. This generalises the catalyst T_solid two-phase pattern to a plain pipe (GT Flow §2.1.7). With h_ext = ε = 0 the wall is adiabatic-outside (pure warm-up); non-zero values let it reach a steady balance. Bundled example 80_pipe_wall_warmup.toml; new schema fields (h_ext, T_amb, eps_rad, A_ext_per_cell) round-trip through the GUI; unit (energy-balance / radiation / convection) + integration tests. The wall also supports axial conduction along the pipe (k_wall + A_cross_wall): a zero-flux-end 1-D Laplacian k_wall·A_cross/dx·∂²T that redistributes heat between wall cells (conserving total wall energy — it relaxes a profile toward its mean and smooths hot spots). Off by default (both coefficients 0); well below the explicit-diffusion stability limit at acoustic Δt.

Multi-layer radial wall (steel + insulation + shield). A dynamic wall can now carry a stack of radial layers via [[pipe.thermal_wall.layers]] (each with thickness / k / rho / cp, inner → outer). The build switches to a radial multi-layer solver (MultiLayerWall): the gas couples to the inner layer through the same py_heat tie, adjacent layers exchange heat by a series half-thickness conductance 1/(t_l/2k_l + t_{l+1}/2k_{l+1}), and the external convection / radiation BC acts on the outer layer. Each layer carries its own thermal mass, so a thin steel liner heats fast while an insulating mat keeps the outer shield lagging — a transient a single lumped wall cannot represent. Energy is conserved gas ↔ layers ↔ ambient by construction. Bundled example 82_multilayer_wall.toml (steel / insulation / shield); GUI round-trip + energy-balance / steady-series-resistance / warm-up-ordering tests. An opt-in axial_conduction toggle adds per-layer conduction along the pipe (each layer's conductance derived from its own k / thickness), giving the wall a full 2-D (axial × radial) network — off by default, energy-conserving (zero-flux ends), below the explicit-stability limit at acoustic Δt.

Two-way gas coupling for coolant-coupled walls (opt-in). Historically a wall sandwiched between a gas pipe and a coolant jacket heated up from the gas but the gas was read-only — energy-conservative only if the user added a matching CONSTANT_TWALL HEAT closure by hand. A new [pipe.thermal_wall] gas_coupled = true wires the same energy-conservative gas↔wall py_heat tie the standalone wall uses, so the gas sheds exactly the heat the wall gains (gas ↔ wall ↔ coolant) and the overlay supersedes any HEAT closure. Off by default — existing coolant cases are byte-for-byte unchanged.

v0.3.0 (pre-release) — state-space linearization + engine warm-up arc

In flight on main since the v0.2.0 tag; tag cut pending. Headline themes since v0.2.0:

EPW diesel: exhaust gamma(T, Y) adopted, with a deliberate two-mode h·A. The AVL EPW validation suite now ships per_cell_gamma = true on its exhaust pipes, reversing a recorded NO-GO. The reversal is the interesting part: the old verdict (14 worse / 2 better) was correct for the model it was measured on — a pumping loop then running −2.3 to −3.3 bar against AVL's −0.3 to −0.9, where a lower exhaust gamma slowed blowdown and made an already-bad channel worse. caloric_thermo becoming the cylinder default closed that gap (IMEP_Low now 0.011 / 0.18 / 0.39 bar), so the harming mechanism no longer has room to act. Re-measured as matched A/Bs on one commit, gamma(T, Y) is net better in both anchored and predictive mode.

It could not be adopted alone: h·A = 550 W/K was identified at gamma = 1.4, and flipping one half of a fitted pair sent the blind T_EXH gate from 0.10 % to 6.94 % (band 6 %), uniformly cold. Re-fit on the two blind ops gives 377 W/K, validating at +4.70 / +1.21 / +0.69 % on the three anchor ops the fit never saw.

That fit ships to anchored mode only, and the reason is a defect this work uncovered in the calibration itself: h·A is a clean lever only where it was fitted. The same 550 → 377 change moves PFP by 0.007 bar anchored and 4.01 bar in the full model — 570× — because exhaust-plenum heat loss reaches turbine inlet temperature, the shared main_shaft torque balance, the compressor, and hence boost. Anchored mode deletes the turbo and the shaft, so that path is invisible there. Both values are now pinned by tests/unit/test_epw_plenum_wall_heat_consistency.py, which exempts exactly one field from its three-copy equality check by name.

Two supporting corrections: the harness's --per-cell-gamma became tri-state (None = whatever the TOML ships) so the banner and --json payload report the resolved caloric model rather than a CLI default that would have labelled a gamma(T, Y) run as constant-1.4; and the docs' "the turbo under-boosts" premise is marked inverted — it now over-boosts at +24.9 / +17.0 / +2.7 %, and 84–98 % of the remaining PFP error is boost rather than combustion. Recorded as a negative result alongside it: Chen-Flynn FMEP cannot be calibrated against this dataset — 10 of 217 AVL rows imply negative friction, and the unconstrained fit wants friction to fall with peak pressure.

Truth-in-documentation pass. Three places where a document asserted something the code did not do, each fixed and each given a guard so it cannot drift back.

The TOML version key. docs/scope-and-limitations.md published the case-format stability contract and named the version marker format_version. The loader reads schema_version (io/case_upgrades.py, and ADR-0007 says so throughout), so a user following the published contract wrote a key the loader ignores — the deck loaded as unversioned instead of being migrated or rejected, which is the exact silent mis-parse ADR-0007 exists to prevent. (format_version is a real field on the GT map-file header, which is why it was easy to write by mistake.) tests/unit/test_docs_case_version_key.py now pins the contract docs to CASE_VERSION_KEY.

The chemistry-integrator citation. ADR-0010 excused the catalyst source from the spatial-MMS suite on the grounds that "its discretisation is the ODE integrator, whose order test_temporal_order.py already pins." That test advances a chemistry-free MUSCL wave and pins the seven advection integrators; it never touches chemistry. The conclusion was right — chemistry is operator-split (ADR-0001) and never enters the spatial residual, unlike the MMS-verified wall-heat/friction/area sources — but the evidence did not exist. It does now: tests/integration/test_chem_integrator_order.py measures the fixed-step backward-Euler chem path's observed order as 1 on dy/dt = -k y² (closed form, reference-free), with a control that the estimator can reject second order. The adaptive defaults (LSODA, Bader-Deuflhard) have no fixed order, so accuracy — not order — is their meaningful check, already covered by test_chem_bs_neville_accuracy.py. The ADR now also names a gap it had obscured: the Lie/Strang splitting error has no measured order.

The unfailable gates. Maturity item V2 removed two always-pass validation gates in 2026-07; the pattern had already returned. avl_epw_diesel_steady gated engine-out NOx and wall-heat at rel_tol = 1.0. Because ScalarComparison.passes() accepts either tolerance, that admits any prediction in [0, 2×measured] — including 0, which is precisely how the stiff Zeldovich solve reports when it fails. Both are now report_only = true: printed with their error, excluded from the verdict, and carrying a mandatory reason. OperatingPointReport gained is_gated / gated / reported, tallies only gated comparisons, and refuses to pass a point that gates nothing at all. tests/unit/test_pass_criteria_are_failable.py rejects rel_tol >= 1.0 across every suite — a one-time cleanup could not hold, because an unfailable gate looks exactly like a passing one from the outside. Wide absolute ceilings stay allowed: they are legitimate regression bounds and can always be exceeded.

Golden-net bit-identity tier (maturity R2). _numba_flags claimed that a default build is bit-identical to the historical hardcoded fastmath=True, and cited the golden-snapshot net as proof. It wasn't: that net compares with assert_allclose(rtol=1e-6), which a 1-ULP shift (5.6e-17) passes untouched. So an exact tier was built — tests/regression/test_golden_bit_identity.py, comparing full-case final states with assert_array_equal — and it promptly disproved the claim it was meant to pin.

A fastmath build is not bit-reproducible. Two independent effects, both measured rather than assumed. It is host-CPU dependent: numba compiles for the host CPU, and fastmath lets LLVM reassociate and contract to FMA using the target's features, so a tigerlake build differs from a generic one on 3 of sod's 4 fields. And — the one that decides the design — it is compilation- order dependent: even on one machine the bits depend on which kernels numba compiled earlier in the process, so running 03_two_pipes_junction before 23_mp5_sod shifts the latter's pressure by ~1e-14. A fastmath snapshot tier would therefore pass or fail according to pytest's test selection (pytest -k mp5_sod compiles a different kernel set than CI running the whole net). Under AEOLUS_FASTMATH=0, IEEE-strict semantics forbid both transformations and the bits are stable across CPU targets and compilation orders — measured on the chaotic cylinder transient, on MP5, and on the stiff LSODA rows. Both effects sit ~1e-14, far below the measured tier's atol=1e-9, which is why nothing noticed for a year. The exact tier consequently runs only in IEEE-strict mode, and that is the mode CI hard-gates; the default build's end-to-end reproducibility is guarded by the measured tier instead.

Supporting machinery: references are kept per fastmath mode (snapshots/ and snapshots/fastmath0/), each recording the environment that froze it — numba / llvmlite / numpy / scipy version, arch, fastmath flag, and now the resolved LLVM target (numba_target, manifest v2). On a mismatch the tier skips with the drifted key named (numba: reference=0.64.0 running=0.65.0) rather than asserting an equality nothing guarantees; AEOLUS_REGRESSION_REQUIRE_BITID=1 turns any such skip into a failure, so a drifted pin cannot hide behind a green job. Making the gate real also closed a long-standing environment gap: the CI image resolves numba against pyproject's <0.66 bound rather than requirements.lock, so the net had been gating in a different environment than the one each reference's provenance recorded. ci/pin_numerics.py now reinstalls the four bit-relevant packages at their locked versions (hashes and all) and verifies the pin took. With the environment pinned, the measured tier tightened from rtol=1e-6 to 1e-8 — sized from the measured cross-codegen spread (≤ 8.5e-13), not guessed. qs_dpf_regen stays at 1e-5: LSODA is adaptive, so a perturbed environment can change its step sequence and produce an error at the integrator's own chem_rtol, not at the FP-noise floor.

Two findings fell out of the refresh. Six of the seven original cases are bit-identical to their 2026-06-06 baselines — the opt-in physics flags merged since really are default-off no-ops, down to the bit — while cylinder_motored had drifted by ~1e-12 relative, four orders below the net's ~1e-4 design point and invisible to it until now. The net also grew 7 → 12 cases, closing the plan's "Sod → C02006 → TWC light-off" span: each new row is the only bundled example exercising a shipped path the net was blind to — MP5 5th-order reconstruction, the Strang split driver, the turbocharger + shaft network, DPF heater-driven active regen, and the twc_brinkmeier cerium oxygen-storage seam.

Whole-codebase bug-audit fix batch (51 findings). A multi-agent audit of the full src/ + GUI tree (23 domains, every finding adversarially verified) surfaced and fixed 51 defects. The correctness-critical cluster: multi-injector cylinders injected zero fuel (the CAD-injection gate keyed off the legacy cylinder-level _inject_norm, never set for a [[cylinder.injector]] block, so split-injection / dual-fuel cylinders ran as pure air-breathers while Wiebe still released heat); the bs_brinkmeier stiff kernel dropped the water-gas-shift H₂ term (+ r8 missing → H atoms not conserved, diverging from the LSODA path); three ΣY mass-conservation leaks where a sub-step bumped ρ directly but only rescaled its own source columns (urea spray rescaled NH₃/CO₂/HNCO but left NO/O₂ undiluted; the DPF regen fold added burned-soot carbon to ρ but only rescaled O₂/NO₂/NO/CO₂ and ran the oxidation balance in reverse where trapping outpaced regen; the explicit driver also double-counted trapped carbon by netting trap into the regen gas-credit) — all now dilute every species column by ρ_old/ρ_new and credit regen-burned carbon only; the CO-formation R1 Arrhenius sign (exp(+T/1102) → exp(−1102/T), ~order-of-magnitude at flame temps) [retracted 2026-09-26: exp(+T/1102) is Heywood's deliberate fit to CO + OH, within 0.71-0.94x of GRI-Mech 3.0; the "corrected" form was 10-16x too slow and has been reverted]; a per-pipe eos.R_gas override silently dropped from pipe.R_gas (non-air pipes ran T-recovery / cv / chemistry temps at air R=287.05); the QS gas-energy march used a non-conserved face mass flux (now the same upwind ρu·A the species march uses) and collapsed a near-zero-flow pipe's temperature to t_floor every quiescent macro step; and an EOS MUSCL/MP5 reconstruction positivity floor (the ideal-gas path clamps ρ,p to keep √(γp/ρ) real; the EOS kernels did not, so a strong expansion could poison the field with NaN). The remainder are validation guards (n_cells ≤ 0, per-extra-species inlet-schedule length, parabolic-throat-at-end, importer malformations that now fail loudly instead of silently producing wrong numbers), cycle-settle --rpm applying to every cylinder (not just [0]), AVL fuel-carrier reselection (no longer matching CO/CO₂ via startswith("C")), and GUI crash/clobber/leak fixes (duplicate-id rename guard, orphan-restore stat() race, CAD-axis & T-colour resolution via the shared crank/R_gas helpers, plus several Qt signal/shortcut/observer/QProcess lifetime fixes). Several physics fixes legitimately shift validated numbers (marked in the MR); one finding — a constant DC bias in the scheme-consistent species mass-flux anchor on 2nd-order / EOS / var-γ pipes — was documented in-place and deferred, as a correct fix requires new per-scheme @njit boundary-face kernels.

Low-Mach / quasi-steady aftertreatment solver (solver = "quasi_steady"). A pressure-projection (SIMPLE) flow solver for aftertreatment networks (catalyst / DPF with a prescribed inlet ṁ + outlet p, Mach ≪ 1), which drops acoustics from the equation set so the macro step is bounded by the slow thermal/chemical timescale instead of the acoustic CFL. Single-pipe SIMPLE with Rhie-Chow on the collocated grid (solver/quasi_steady.py), EOS density (ideal + Redlich-Kwong / Peng-Robinson) and quasi-1D area, both ṁ-driven and pressure-driven per-pipe modes; a junction-pressure Picard network coupling (solver/quasi_steady_network.py); and a transient macro loop advancing the slow states (species / soot / cake & solid temperature) at the heat-release-rate Δt. On the bundled 41_dpf_regen physics the regeneration (soot drain 36 % + cake +21 K, matching the explicit solver's ~40 %/~18 K) runs in 22 steps vs the explicit solver's ~10 049 — ~457× fewer. Phased design in docs/low-mach-at-plan.md; bundled example 56_dpf_regen_lowmach.toml. v1 handles static inlet ṁ / outlet p; the cake soot-drag in the QS momentum is a documented follow-up.

QS drive-cycle enablers (toward the full AVL C02006 cycle). Transient inlet/outlet schedules (transient_mass_flow_inlet / transient_pressure_outlet evaluated at the macro clock) carry the C02006 ṁ(t) / T(t) ramps; the SIMPLE kernel was JIT'd (_simple_solve_ideal_jit, ~3.3 ms → 0.044 ms/solve, ~75×); and the gas-phase energy is now solved as a steady spatial advection-reaction balance (_qs_steady_energy — ∂E/∂t→0, the gas residence ≪ all other timescales, à la GT's quasi-steady catalyst gas phase) rather than forward-Euler advected. Only the slow solid states (soot / cake-T / solid-T) integrate in time, which removes both the advective-CFL limit on the energy update and the regen-ignition noise that the explicit update + temperature-floor left behind — under a strong exotherm the gas temperature peaks smoothly (~1325 K vs the explicit path's ragged 1912 K) in ~4.5× fewer macro steps, with identical 100 % soot burn-down. On by default; [case] qs_steady_energy = false restores the explicit advection. This was the sole remaining numerical blocker for the full C02006 drive cycle — which now runs end-to-end: the bundled avl_cruise_m/c02006_dpf_csf_regen_trans.toml (single DPF, soot 10 kg/m³, inlet T ramp 373 → 693 K over 0–100 s) under solver = "quasi_steady" completes the 500 s cycle in ~6.5 min wall / ~730 k macro steps, lighting off cleanly at t ≈ 100 s (the soot exotherm rides ~25 K above the inlet to a 730 K peak) and draining soot 10 → 7 kg/m³ (30 % passive regen) over a smooth 693–720 K field with no divergence — where the density-based explicit solver blew up at t ≈ 116 s.

v0.3 tail (completes the keystone). Three closing increments: (1) QS orchestration speed-up — with the SIMPLE solve + gas energy now cheap, the per-macro-step cost had moved into the transient-BC schedule evaluation (numpy.interp fired ~546 k× on a 30 s slice; replaced by a searchsorted scalar interp, schedules pre-converted once) and the gas energy (now a single-pass @njit kernel). Physics bit-identical. (2) A second W3 artifact — examples/76_wltp_lite_dpf.toml, a DPF over a 4-phase WLTP-style drive cycle (ṁ + T co-vary by speed phase): the soot regeneration tracks the drive-cycle temperature — quiescent through the cold low-speed phases, burning down in the hot motorway phase (soot 6.0 → 5.2 kg/m³ over the 600 s cycle, ~5 min wall) — distinct from C02006's single hot active regen. (3) True local-error adaptive Δt for the one-step implicit stencils (rk = "radau_iia" / "implicit_euler", opt-in [case] adaptive_error_control): step doubling (one step of Δt vs two of Δt/2) gives a Richardson local-truncation-error estimate that drives Δt to hold atol + rtol·|U| — accept/reject with a safety·err^(−1/(p+1)) controller — instead of the Newton-difficulty heuristic. Validated by a tolerance-convergence test (tightening rtol 1e-3 → 1e-7 drops the error vs a fine reference monotonically to <1e-6 relative). Activates the accuracy controller GT Mechanical §1.7 calls for; raises on multi-step BDF2.

GT-POWER parity roadmap + aftertreatment time-stepping (v0.3 keystone). A strategic roadmap (BACKLOG.md → "GT-POWER parity roadmap"), derived from analysing four GT-SUITE manuals, frames parity as validated competence on four core workflows at a wall-clock budget rather than feature-count parity, and sets the critical path: performance → robustness → validation. First increments: (1) a profiling spike (scripts/profile_at_spike.py) that quantified the existential constraint — a 500 s aftertreatment drive cycle is ~83.7 M acoustic-CFL steps (~4.6 h), of which a low-Mach Δt would remove ~167×, while per-step cost is ~74 % Python and only ~11 % JIT kernels (so a compiled core is not the lever). (2) Stiff-chemistry failure diagnostics — the Bader-Deuflhard Brinkmeier kernel no longer fails silently: it returns per-cell [n_steps, n_rejects, converged] telemetry and, on non-convergence, raises ChemistryConvergenceError naming the pipe, time, worst cell, and the stiffest state variable (excess-factor probe), à la GT Aftertreatment §3.7. (3) Heat-release- rate adaptive-Δt criterion — opt-in [case] dt_hrr_fraction caps the explicit network macro step so the per-step fractional change in gas energy / soot / cake temperature stays bounded, min'd with the acoustic-CFL Δt; it proactively shrinks Δt through a regeneration ignition (the C02006 blow-up class) and relaxes when quiet. Bundled example 55_dpf_regen_hrr.toml.

Cruise M interop (Cruise-S). Aftertreatment-brick fidelity fixes for the EAT example suite, found by auditing the converted TOMLs against the AVL example docs. Geometry: Cruise M specifies monolith bricks by Volume + Length and keeps a stale template diameter (a 56.42 mm leftover) alongside; the importer was reading that stale diameter — or a 0.05 m fallback — so catalyst / filter volumes were wrong by 1.4×–150×. The diameter is now re-derived from the Geometry's geometry_input_selection (volume → D = sqrt(4V/(πL)), cross_sectional_area → D = sqrt(4A/π)); a stored diameter is trusted only when the brick was input by diameter. Catalyst / filter bricks now pair only with commons_aftertreatment.Geometry objects (never a pipe's) and by nearest-preceding object id, so a brick can no longer inherit a pipe's length / diameter and adjacent bricks no longer collide onto one geometry (fixes e.g. C02011 SCR 13.3 → 35 L, C02012 TWCs, C02013 DOC/DPF/SCR). Initialization: a catalyst's solid_temperature now lands on CatalystSpec.T_solid_init (was a hardcoded 300 K), a filter's substrate solid_temperature on the new FilterSpec.T_solid_init field (seeds the soot-cake temperature when the cake thermal model is active — the DPF's only solid thermal mass), and a filter's initial soot mass on FilterSpec.trap.soot_loading_init (C02004 = 2.5, C02006 = 10 kg/m³) so the regeneration / CSF cases start loaded. Inlet schedules: the temperature extractor prefers a transient table ramp over an alternate model's flat const (C02001's 100 → 350 °C light-off ramp was being discarded), and mass-fraction inlets stored on a 0–100 percent scale (C02005) are renormalised to sum to 1. All bundled EAT TOMLs regenerated. New examples/avl_cruise_m/compare/ harness plots each converted case beside its AVL "Results" screenshot (qualitative cross-code check; the full AVL transients are wall-clock-impractical in aeolus1d's density-based, acoustic-CFL-bound solver vs Cruise M's low-Mach segregated solver).

Cruise M interop (Cruise-R). Type-class-first BC dispatch + always-transient BC emission. The old role-keyword matcher that silently dropped BCs whose AVL subsystem-nested role names (e.g. "Air Cleaner Assembly.Restriction 3.Gas Flow IN") didn't contain one of the expected keywords is replaced by an explicit element-class → direction dispatch table. Pressure-based classes (the new pressure_boundary / PressureBoundaryTopology defensive classes) disambiguate via pipe-end with role wording as final tiebreaker; the legacy boundary class stays outlet-only to preserve the bundled 117-case behaviour. Adds defensive handlers for pressure_boundary, flow_boundary (mass-flow inlet), and volumetric_flow_boundary (velocity inlet via u = V̇ / A_pipe) so user .proj files with those classes land cleanly instead of silently dropping. The pressure-outlet schedule is now fully transient: a tabulated outlet BoundaryConditions::pressure promotes to transient_pressure_outlet carrying the whole (t, p) array, not just the first sample with a "queued" warning. The inlet-side equivalent (a PressureBoundary on a pipe's left end) routes to transient_stagnation_inlet with the p0 + T0 schedule. All BC kinds emitted by the importer are now transient_* — a constant inlet ṁ / V̇ / p / T0 or a constant outlet p_back collapses to a single-row schedule (t=[0.0], v=[const]), eliminating the dual const/transient code paths the old importer carried. New all-117 BC-kind sentinel test asserts every bundled TOML's BC kinds fall in the schema-recognised inlet / outlet / wall categories, catching silent-drop regressions before they ship. 458/458 Cruise M tests pass on the bundled corpus.

Cruise M interop — end-to-end smoke verification. A fresh- process smoke test that loads each of the 119 bundled examples/avl_cruise_m/ TOMLs and advances 0.1 ms via run_case confirms every imported case validates + builds + runs in aeolus1d (119/119). Same-end junction pairs now fall back to plenum instead of passthrough, and BC emission skips pipe ends already owned by a junction port — the dual-owner conflicts that subsystem-nested role names produced in C06058 / C06059 / C06060 / etc. import cleanly. Aeolus's Cruise M coverage is now 134/229 demo .proj files total (30 EAT + 94 ICE + a few odd holdouts), each running end-to-end as a connected aeolus1d network.

Cruise M interop (Cruise-O). Turbomachinery passthroughs + framework noise silencing. Compressor / turbine / turbo_charger / generic_transfer emit as topology-closing pass-through pipes (30 mm × 50 mm); the PR / efficiency / map data drops to lossy_components since the map shape in Cruise M's cruise.props.compressor.FullModelWithTCTool differs substantially from aeolus1d's TurboMapSpec contract (future Cruise-O+ wires it properly). Cleans up lossy_components by silencing ~25 framework / control / plumbing classes that have no aeolus1d topology equivalent (FMU databus in/out, PID controllers, MassFlowConnections, PortInjector / DirectInjector, solid_wall / Piston / Liner thermal masses, vehicle / driveline classes, combustion_chamber + super_cylinder, etc.) — these have their own aeolus1d analogues at the closure / component layer that future phases will wire. ICE non-empty imports 79/111 → 94/111 (Crank_Resolved 22→33, TDE_Vehicle 5→7 — full vehicle/ powertrain coverage, Gas_Path 28→29). A representative sample of turbocharged-engine TOMLs now both validates + runs in aeolus1d.

Cruise M interop (Cruise-N). Gas-side heat-exchanger UA wiring. Lifts the actual total_volume + diameter Amounts from cruise.props.heat_exchange.GeneralGeometry so the pipe carries the exact heat-exchanger volume (not the 5 L default). Lifts Friction.turbulent_coef onto pipe.friction.f with kind="darcy_const". Auto-attaches a constant_twall HeatSpec with literature defaults (h = 500 W/m²K, T_coolant = 350 K) so the imported case runs with gas-side heat loss instead of adiabatic. Boundary keywords extended with inflow / outflow so HX demos (C06093 Gas-Gas HE, C06091 cabin air recirc, C06092 engine coolant radiator) which label boundaries Gas Inflow 1 / Gas Outflow 1 wire correctly. Lights up 4/5 Heat_Exchanger projects (was 1/5) — the gas-side heat-rejection path now imports as actual aeolus1d physics. Bundled in examples/avl_cruise_m/ice/heat_exchanger/.

Cruise M interop (Cruise-M). Pass-through-equivalent flow element coverage. Throttle bodies (cruise.elements.throttle), flanges + elastic flanges, air cleaners, gas-side heat exchangers (intercoolers / CACs / EGR coolers), and fans all import as short or volume-matched aeolus1d pipes. Throttle body diameter is lifted from cruise.props.throttle.Geometry; air cleaners + heat exchangers default to plenum-equivalent volumes (3 L / 5 L respectively); flanges become thin 5 mm pass-through pipes. The closures these elements carry (throttle discharge-coefficient vs angle, heat-exchanger UA, fan PR curve) still drop to lossy_components; Cruise-N will wire them properly. Outcome: ICE non-empty imports 61/111 → 83/111 (Crank_Resolved 15→27, TDE_Vehicle 1→4, Heat_Exchanger 1→3, EPW 1→2, Gas_Path 26→28). 32 new bundled TOML examples in examples/avl_cruise_m/ice/{crank_resolved, heat_exchanger,tde_vehicle}/.

Cruise M interop (Cruise-L). Plenum / restriction / gas-flow- loss / GasNode coverage. Cruise M's cruise.elements.{plenum,restriction,gas_flow_loss}.Topology classes (plus plenum.GasNodeTopology) now import as volume-matched / throat-matched aeolus1d pipes: plenums get a cubic-root pipe shape with the right volume; restrictions + gas-flow losses become 10 mm-long pipes with the throat diameter derived from ref_cross_section. First-order 1D approximation of the 0-D mixing + pressure-drop semantics — suitable for steady-state breathing studies. Lights up 26/31 Gas_Path ICE projects (was 0/31) and pushes ICE total non-empty imports from 6/111 → 61/111. 25 bundled TOML examples land in examples/avl_cruise_m/ice/gas_path/.

Cruise M interop (Cruise-K). First slice of ICE suite coverage — cruise.elements.gas_flow_pipe.Topology now maps onto an aeolus1d pipe (same Geometry schema as aftertreatment_pipe). Lights up 6/111 ICE projects in the Gas_Exchange subfolder — 2 L 4-cylinder NA gasoline, 2.6 L V6 turbo-diesel, 6 L 6-cylinder turbo-diesel (incl. dual-fuel + NH3/diesel variants), and a small 2-stroke single-cylinder. Cylinders / ports / turbomachinery / plenums / restrictions / fuel injectors etc. still drop to lossy_components — the resulting Cases run as inert flow between boundary BCs until further importer phases land. Six bundled TOML examples in examples/avl_cruise_m/ice/; full ICE roadmap tracked in BACKLOG (Cruise-K continuation).

Cruise M interop (Cruise-G/H/I/J). Full EAT_System suite (C02011..C02029) now imports as runnable, validating aeolus1d TOMLs. Cruise-G silences nox / temperature / lambda / pressure / flow / humidity sensors as framework noise (they were lossy_components until now). Cruise-H extends the catalyst topology set with ASCTopology (Ammonia Slip Catalyst, mapped to doc_oxidation for the NH3-ox step) and the Embedded Element family (EEDOC / EESCR / EETWC / EEASC / EEDCPF / EECGPF) which Cruise M's compact / coupled- systems studies use; the closure dispatch becomes class-first (model_name only refines within the class family) so a multi-catalyst chain doesn't collapse onto a single closure when the project's lone AUCI model_name is shared. Cruise-I adds a boundary-class fallback that picks up engine-vocabulary boundary names (EngineOut / TailPipe / EAS Inlet) via flow-graph adjacency: a boundary touching a pipe's Inlet becomes a mass_flow_boundary; one on a pipe's Outlet becomes a pressure_outlet. Cruise-J auto-attaches wall BCs to pipe ends left free by Cruise M components the importer doesn't translate yet (E-Heater, DEF Doser, Air Gap, restriction, plenum) — the case validates, the user gets a warning naming each free end. BC dedup tightened to (pipe, end)-only so a later boundary translation can't shadow an earlier conflicting BC on the same face. 16/18 EAT_System examples land as multi-pipe, multi-junction aeolus1d cases; the two heat-exchanger-coupled studies need future QuasiDimensionalTopology / LHT support to land.

Cruise M interop (Cruise-F). DPF / particulate-filter topology coverage extended from the legacy DPFTopology / DieselParticulateFilterTopology substring match to the current cruise.elements.particulate_filter.TransientFilterTopology class used by every modern AVL EAT/Filter project. Adds a parallel _FILTER_TOPOLOGY_CLASSES constant + filter keyword bucket in _assign_role_names, a flow-gas-restricted file-order fallback that picks unconventionally named filter bricks (e.g. C02010's "Standard" / "Approximated" comparison pair) without disturbing catalyst orphan-dedup, and a post-pass that trims pipes with no boundary / junction attachment so DPF comparison .proj files import as single-DPF cases. Eight bundled TOML examples land in examples/avl_cruise_m/c02004..c02022 covering ash filtration, back-diffusion, CSF transient regen, soot size classes, layered filters, multi-collector filters, approximated pressure drop, and user-defined filters.

Cruise M interop (Cruise-E). Single-catalyst topology coverage extended from TWCTopology only to the full AVL EAT/Catalyst class set: DOCTopology + SCRTopology (delivered via AdvancedModelTopology for the SCR Standard mechanism) + PNATopology + AdvancedModelTopology. All five share the same Cruise M property schema for Geometry, Friction, GasToWallHeatTransfer, AUCI catalogues, and CellStructure, so the translation pass + the _assign_role_names keyword bucket + the porous / Brinkmeier / reaction-kind helpers all collapse onto a single _CATALYST_TOPOLOGY_CLASSES constant. Adds an orphan- catalyst dedup pass so multi-Modeller .proj files (the AVL EAT examples typically bundle a baseline + a coldstart variant) import the canonical single-catalyst model instead of every alternate's ghost pipes. Species catalogue is deduped by name across the multiple Modeller models. The four EAT/Catalyst demo projects (C02001 DOC Light-Off, C02002 SCRT Ads/Des, C02003 PNA Heatup, C02030 Transfer Model Pars) now import into clean, runnable aeolus1d TOML — bundled in examples/avl_cruise_m/ for reference.

Phase L — state-space linearization. The implicit-solver Jacobian assembly is now reachable from the top of the stack.

  • L1 (already covered in v0.2.0). Single pipe, central-FD on the pipe residual; InputHandle / OutputProbe / StateSpace.
  • L2 — multi-pipe + junction-coupled networks via linearize_network. Each junction's apply_port_bcs runs between FD perturbations so the cross-block coupling is captured. Volume + u_n state initially held frozen.
  • L2b — volumes promoted to packed state. (m, E_internal) per junction joins the state vector; rates from accumulate_rates(). Full bidirectional pipe ↔ plenum coupling.
  • L2c — port-level u_n state (turbo shaft ω, EHR slug velocity) packed via the same port_u_rates duck-typing contract the network timeloop uses. Cylinder θ deliberately out of scope (kinematic, not an integrable ODE).
  • L3 — analytic A from the implicit solver's block-tridiagonal Jacobian (_assemble_jacobian_first_order). One assembly call replaces 2n residual sweeps; machine-precision-exact rather than FD-truncated. Pipe-only / pure-far-BC networks.
  • L3b — extends L3 to junction-coupled networks via a hybrid: analytic pipe-on-pipe diagonal overlaid onto FD-derived A; cross- blocks remain FD. Pure-analytic cross-blocks (L3c) still backlog.

Phase E5 — Variable-Valve-Timing. CylinderPort grows two actuation hooks: phase_offset_deg (cam phaser, positive = advance) and lift_scale (variable-lift multiplier; 0 = effective deactivation). Both default to identity so every pre-E5 cylinder case stays bit-equal. TOML-configurable via [pipe.thermal_wall]- style block on each port; mutable at runtime so a controller can dial them per step.

Phase Coolant (tier C) — 1-D engine cooling. New domain layered on top of the existing 0-D aeolus1d.coolant.CoolantState (Phase E3 lumped model).

  • Coolant — aeolus1d.components.coolant_pipe.CoolantPipe (per- cell T_cool(x), prescribed u_cool / ρ_cool / cp_cool) and ThermalWall (per-cell T_wall(x) ODE, m_wall · cp_wall thermal mass, h_gas / h_cool). advance_coolant_system forward-Euler step driver over a list of (gas_pipe, wall, coolant_pipe) triples.
  • Coolant-2 — coolant_couplings parameter on advance_network and advance_network_strang. Forward-Euler step once per outer gas step (coolant time constants are seconds; acoustic dt is microseconds — first order is well within the gas-side discretisation error).
  • Coolant-3 — full TOML / parser / build / run_case wiring. New [pipe.thermal_wall] block, [[coolant_pipe]] and [[coolant_coupling]] top-level blocks. Schema-sync test preserved. Bundled example at examples/75_coolant_pipe.toml.

Infra & ops.

  • First green-CI pipeline on a self-hosted GitLab runner (Docker executor on WSL2). Concurrent jobs bumped from 1 to 4 once the queue backed up — ~4× throughput on parallel pipelines without oversubscription on a 16-core box.
  • MR-only main + MWPS auto-merge workflow established. Every phase goes through a green-CI gate.
  • Mypy-strict module list extended for every new module; the test_mypy_strict_modules.py / pyproject overrides dual-source contract stays honoured.
  • Workflow-rules CI gate-hole closed (top-level workflow: block in .gitlab-ci.yml). Before the fix, MR-event pipelines only ran the two jobs with explicit merge_request_event rules — leaving unit / fast / smoke / lint unmeasured on MRs. Symptom: a unit-test regression slipped through to main. Fix shipped in MR !12.
  • Open-source maturity remediation, PyPI release prep. Two new CI jobs: build-smoke: runs python -m build + twine check on every MR (catches broken pyproject.toml or a missing-from-the-wheel module before release time; artefacts uploaded with 1-week expiry), and release-pypi: runs on vX.Y.Z tag push only — builds + uploads to PyPI via $PYPI_API_TOKEN. Tag regex accepts v0.3.0 + pre-release suffixes (a1 / b1 / rc1 / .post1). Optional manual release-pypi-test: for TestPyPI dry-runs. New top-level CI release stage. Makefile gains make build (smoke), make release-test (TestPyPI), make release (PyPI). Full release walk-through in the new docs/release.md page (mkdocs nav under Reference). Production release is now git tag vX.Y.Z && git push origin vX.Y.Z — everything else is automatic.
  • Python 3.9 floor — requires-python lowered to >=3.9 so HPC environments (and any other 3.9-bound users) can install aeolus1d without a Python upgrade. Code changes were narrow: drop zip(..., strict=False) (default behaviour anyway, 49 sites), route the four strict=True call-sites through aeolus1d._compat.zip_strict, and stack one parenthesized with block. New CI matrix row unit-py39 runs the unit suite on python:3.9 to keep the floor enforced. mypy keeps its python_version = "3.10" analysis target — recent mypy releases dropped 3.9 analysis support; runtime 3.9 compatibility is what the CI row gates.

v0.2.0 — standalone repo + validation closures + CI

Released 2026-05-17. First tagged release on the standalone aeolus1d repo (split out of the bitbucket monorepo on 2026-04-30). 441 commits, mostly Niklas Nordin + Claude. Headline themes:

Validation suites (Phase H4a-H4d). Re-runnable infrastructure for cross-checking aeolus1d against published engine data — per-suite TOML operating-point grids, pass-criteria YAML, scalar comparison metrics (MAPE / RMSE / max-abs / settled-cycle reduction). Two suites closed quantitatively: Ricardo WAVE KTM 510 cc SI (Phase H4c — 7/10 at 25 % gate after a CylinderSpec parser-bug fix, a burnt-fraction-weighted γ in the cylinder Volume, and open-loop AFR / throttle / wall-heat flags) and Torres-Mendes-Albuquerque 2024 ethanol/water blends (Phase H4d — 44/60 across 0/10/20/30/40/50 % w/w; 92 % pass-rate on 0-30 % blends, 35 % on 40-50 % where charge-cooling-from-water and bulk-γ matter).

CLI surface (CP7-CP12). aeolus1d cycle-settle (run N cycles, report settled regime + IMEP / W_cycle drift), aeolus1d sensitivity (forward-sensitivity FD over runtime parameters with tornado output), aeolus1d linearize_network scaffold for Phase L2, MultiStageWiebe helper wired into Cylinder + TOML for HCCI / RCCI / diesel two-phase combustion, FMU metadata reader (scaffolded — full FMI import deferred as backlog item I3).

Cruise M interop (post-1 → post-9, Cruise-A/D). Full .ucp + .proj import path with honest layered-count reporting, SimulationTime / SolverSettings round-trip via case.case, neutral-junction port area defaulted from connected pipe cross-section, CAC wiring + NASA species detection, valveconn port-area + Inj_Case shape fix.

GUI. Sensitivity dock with tornado-style gradient plot (CP10-2), inline tooltips on inspector form fields (US16), three task-based tutorial recipes, network inspector + canvas locked to top-down 2D view via a three-layer guard (camera snap-back + image style + pre-render observer), engine plot demos bumped to 10 cycles with settled-cycle mean, organize-components polish (undo + ~250 ms ease-out tween + auto-fit + BC placement based on bc.end), and a PID-tuning playground.

Performance (Perf-2 + SP19-SP24). JIT FD closure Jacobian for the native-only path, JIT closure-overlay loop in _residual_full, vectorised surface-mechanism orchestration, prange-parallel overlay for large N pipes, auto-enable parallel_pipes for large networks, batched solve_ivp for surface kinetics (~2× speedup), async live-flush with atomic temp-file rename, sub-block A/B partitioning for the implicit Picard outer iter (Phase I-Y).

Scania roadmap audit closure. §5.1 homotopy continuation, §6.2 forward sensitivities (now exposed via the CLI), §4.3 XDMF time-series writer. Audit completed — only I3 (full FMI import) and J1 (live Cantera FFI) remain on the deferred list.

CI. First runner-backed pipeline on the standalone repo: self-hosted GitLab Runner with Docker executor, GIT_DEPTH: 0 so the perf-history-refs check can resolve commits >50 deep, pytestmark = xdist_group("numba_parallel_serial") on test_packed_pipes.py so the parallel-kernel JIT cache stops racing across workers, -n 0 + xvfb-run -a wrapping for the gui-tests job so VTK gets a real GL context instead of SIGABRTing on offscreen render, packed-vs-sequential cross-check loosened from assert_array_equal (bit-exact) to assert_allclose(rtol=1e-12) (FMA-reorder-tolerant).

Tooling alignment. Python floor declared at >=3.10 (was >=3.8 in metadata but already 3.10-only in ruff target, mypy python_version, the CI image, and PEP-604 union syntax across the codebase). 59 ruff errors cleared (16 I001 unsorted imports, 12 SIM300 Yoda conditions, 5 F401 unused imports, plus E702 / E402 / B009 / F541 / UP037 / B905 / F841 / E741 / N812 / N814 / SIM103 / SIM115 / SIM118 long-tail). 14 mypy-strict errors cleared (10 stale # type: ignore comments removed after mypy 2.x tightened warn_unused_ignores against deeper Qt-attribute and import-untyped cases, plus 4 Returning Any boolean returns wrapped with bool(...)).

1262 unit tests pass, 940 integration / validation tests pass, 636 GUI tests pass.

Milestone 1 — pipe-network core

Shipped the HLLC + MUSCL + SSP-RK2 baseline plus the BC set, quasi-1D (variable area), friction / heat / porous closure protocols, junctions + plenum-ghost T-junction, TOML + CLI + HDF5 writer + docs. 57 tests + 1 xfail.

Milestone 2 — network capabilities

Phase 7 Helmholtz exploration (xfail documented), Phase 8 MUSCL + quasi-1D kernel, Phase 9 transient BCs, Phase 10 Lax + shock-reflection benches, Phase 11 time-series HDF5 + --trace-dt, Phase 12 SSP-RK3 wiring, Phase 13 Colebrook friction + ML closure seam + Darcy-Forchheimer porous.

Milestone 3 — engine / powertrain scaffolding

Phases 14–19 covered junctions (neck-pipe, Corberán CP+h with Bassett K_loss, Extended Helmholtz Resonator), the two-phase catalyst, species transport + py_reaction seam + Arrhenius demo, the quasi-2D Helmholtz that closes the textbook resonance to 0.01 %, the py_junction_bc ML seam, Strang-split chemistry (scipy LSODA), the real-gas EOS library (RK / PR), and the @njit-compiled EOS + HLLC fast path.

Milestone 4 — chemistry + variable-γ + 2nd-order networks

Phase 19d shipped a hand-tuned Bader-Deuflhard semi-implicit BS chemistry solver compiled with @njit. Phases 20 / 20b plumbed EOS through quasi-1D + closures + the two-phase catalyst. Phases 21 / 21b made junction ghosts and 0-D volumes EOS-aware. Phase 22 generalised species transport to a (Y, Y_extras) stack; 22b added kinetics on that stack. Phase 23 added the LODI / Poinsot-Lele characteristic port BC (and confirmed the Helmholtz xfail is a 0-D-plenum limitation, not a port-BC one). Phase 24 added MP5 reconstruction; Phase 24b added the Thornber HLLC-LM low-Mach correction. Phases 25 through 25k brought the engine-side stack online: Chalet-Chesse-Hetet valve effective-area BC, Winterbone-Pearson orifice valve, quasi-steady compressor / turbine maps, shaft-inertia ODE, learned NN surrogates for port-state and turbo-map, the reciprocating-cylinder volume BC, Wiebe combustion, continuous lift schedules, two-zone combustion diagnostic with per-zone γ tracking. Phase 26 / 26b added the species thermo DB (NASA-7 + JANAF importer). Phases 27 / 27b / 27c / 27d covered the CHEMKIN parser + falloff / PLOG / three-body kinetics (numpy + @njit). Phases 28 / 28b / 28c / 28d / 28e / 28f plumbed the variable-γ stack from a diagnostic into the HLLC kernel, the MUSCL / MP5 reconstruction, and per-cell γ tracking through each. Phases 29 / 29b shipped manufacturer turbo-map ingest + extended species DB and the CoolProp-friendly tabulated EOS.

Milestone 5 — 2nd-order networks + real-gas networks

Phase 30 plumbed MUSCL through the network timeloop's pipe residual AND the volume's port-flux helper, with both sides reconstructing from the same U_pad so junction fluxes match exactly and mass is conserved. Phase 31 extended the same path to the EOS dispatch: the new pipe_residual_eos_muscl_jit + quasi-1D variant operate on the EOS-friendly primitive set W = (ρ, u, T), so 2nd-order network simulations now work for refrigerant / cryo / CO2 pipework under RK, PR, or tabulated EOS.

Milestone 6 — MP5 dispatch closes + optimization driver

Phase 24c/d/e/f — MP5 closes the dispatch corners. Phase 24 shipped MP5 only on uniform-area, closure-free, ideal-gas pipes. This set brings MP5 to feature-parity with MUSCL: 24c adds pipe_residual_quasi1d_mp5 (variable area + friction / heat / porous closures); 24d adds the per-cell-γ companion; 24e extends the network-timeloop dispatch to use those kernels (and catalyst comes for free since _catalyst_rates_into overlays its source on top of any scheme's residual); 24f adds pipe_residual_eos_mp5_jit / pipe_residual_quasi1d_eos_mp5_jit for real-gas. After this set the GUI's pre-flight scheme- compatibility helper has nothing left to flag.

Phase 43 — Higher-order RK time integrators. RK4 and Cash-Karp RK5 alongside the existing SSP-RK2/3 set. Generic _butcher_step helper walks any explicit Butcher tableau row by row. Non-SSP, so they're appropriate for smooth-flow benchmarks; the network path clamps to SSP-RK2/3.

Phase 44 — Case-driven optimization driver. A thin Python API (aeolus1d.optimize.optimize_case) wrapping scipy.optimize.minimize with path-based parameter binding into the Case dataclass tree (e.g. "pipes[0].init.p" or 'pipes["tube"].length') and probe-based objectives that compare a traced pipe time-series against reference data (ProbeObjective + make_probe_match_objective + load_reference_csv). Ships with a TOML [optimize] block, an aeolus1d optimize <case.toml> CLI subcommand that prints the per-step (theta, J) trajectory and substitutes the optimum into a sibling TOML, a worked examples/optimize/tune_initial_pressure.toml demo (recovers the truth in ~32 evaluations), and a GUI dock with a live convergence plot, inline-editable parameter / probe tables, a Browse-CSV picker, and a Save button that rewrites the [optimize] block in place.

GUI run-settings strip + Phase 24 pre-flight. The Runner dock gained an inline strip for t_end / cfl / trace-dt / scheme / limiter / RK that writes back to the live case on edit. action_run runs a pre-flight scheme-compatibility check that catches known unsupported combinations up front and surfaces a clear QMessageBox instead of a subprocess NotImplementedError.

GUI track (parallel to milestone 4 / 5)

A 9-phase visual editor — GUI-1 PySide6 + PyVista scaffold, GUI-2a palette + junctions + BCs, GUI-2b drag-to-connect ports, GUI-3 auto-generated inspector forms, GUI-4 subprocess runner, GUI-5 engine palette, GUI-7 post-processing network view, GUI-8 undo / redo / delete / recent files, GUI-9 pip + PyInstaller packaging. Plus follow-up polish: onboarding (Welcome dialog, Help menu, empty-state hints), light / dark theme switcher with per-canvas contrast, drag-and-drop palette, square-block canvas with per-kind symbols, locked 2D view with grid overlay.

Engine fuel-transport chain (Phases 25k → 25q + 25o + 26)

Built up the full cylinder → manifold → catalyst fuel pipeline so DI-engine / blowback / aftertreatment cases are reachable from a single TOML:

  • 25k — CAD-domain fuel injection on Cylinder. inject_cad / inject_rate / m_fuel_per_cycle / T_fuel / cv_fuel on CylinderSpec; the runtime normalises against the cycle- integral so total mass per 720° equals exactly m_fuel_per_cycle.
  • 25l — fuel inventory. Volume.m_fuel and m_fuel_burnt; Wiebe consumes from the inventory at Q̇/LHV; the apply_fuel_step hook on Cylinder integrates per timestep.
  • 25m — port-side blowback. Cylinder.last_fuel_outflow_rate
  • per-port split tracks fuel mass leaving via open valves at ṁ_out · Y_fuel.
  • 25n — pipe coupling. Cylinder writes Y_fuel to pipe.species_inflow_* so the species advection convects fuel into the manifold; reverse flow reads pipe-side Y back as last_fuel_inflow_rate.
  • 25o — HC oxidation closure. First-order Arrhenius r = A · exp(−Ea/RT) · Y_HC on a catalyst pipe with heat release s_E = r · ρ · LHV. New species.reaction = "hc_oxidation" kind.
  • 25p — junction fuel tracking. Plenums now carry volume.m_fuel via per-port mass flux × upwind Y_fuel, with the matching apply_fuel_step integration. case.py also enables species transport when only a name is set (Y_init=0 with name="fuel" was previously a no-op).
  • 25q — turbo species pass-through. Compressor + Turbine each copy the inlet pipe's boundary Y_fuel into the outlet pipe's species_inflow_* so fuel rides through the device.

Phase 26 — case-level [[species]] metadata table. Per- species metadata (M, LHV, Y_init_default) declared once at the top of a TOML; per-pipe [pipe.species] blocks reference by name and inherit the rest. Per-pipe overrides preserved as a hard win. Case.species: list[SpeciesMetadataSpec] defaults to empty so every pre-existing TOML round-trips identically.

End-to-end verification on examples/39_4cyl_engine_turbo_scr.toml (50 ms run): cylinders inject 50 mg/cycle × 4 → blowback to runners (Y ≈ 5 %) → exhaust plenum accumulates 102 mg → through turbine to silencer (2.4 mg) → SCR brick inlet 1.8 mg/g → outlet 1.1 mg/g (38 % HC oxidation conversion, substrate warming 550 → 551 K). One test_engine_fuel_chain_system regression pins this whole chain.

Audio probe + parallel runner

  • aeolus1d.audio module + aeolus1d audio <run.h5> --probe … CLI subcommand: sub-cell pressure interpolation → high-pass DC removal → resample to 44.1 kHz → 16-bit PCM WAV. WSL2-aware playback fallback on the GUI plot dock (QSoundEffect or QDesktopServices depending on environment).
  • aeolus1d.parallel module + aeolus1d parallel CLI subcommand: multi-process runner via ProcessPoolExecutor for parameter sweeps / Monte-Carlo / batch calibration.
  • Cached _pipe_residual dispatch — first call per pipe selects the kernel and stashes a no-arg closure on pipe._cached_residual_fn. Saves ~1 µs per pipe per call.

Multi-species plenum transport (Phase 51d)

Earlier the 0-D plenum routed only one species column — the one matching Junction.fuel_species_name (default "fuel"). Every other species column was dropped at the port: gas leaving the volume reached the downstream pipe with that species at zero, masking real composition transport as a sink at every junction.

Phase 51d generalises the routing:

  • Junction.m_species: dict[str, float] — inventory by species name for every non-fuel column on connected pipes. Integrated forward in lockstep with volume.m_fuel and volume.m via apply_fuel_step.
  • apply_port_bcs writes each plenum-side Y[name] to every connected pipe's species_inflow_* ghost so gas leaving the volume carries the well-mixed composition into the downstream pipe.
  • __post_init__ seeds m_species from the connected pipes' boundary Y so the plenum starts in equilibrium with its surroundings — without seeding, the first apply_port_bcs would write Y_vol = 0 to the downstream ghost on the very first step and clip a non-zero pipe-side Y as a step-down at t = 0.

Regression test: tests/integration/test_two_pipes_plenum_mass_flow.py — two pipes joined by a plenum, mass-flow inlet driving primary "fuel" + extras "NO" / "tracer" from a quiescent start, asserts every channel matches its inlet feed at the downstream outlet.

CLI Cruise M import

aeolus1d import avl_cruise_m project.proj out.toml now works. The unified aeolus1d.io.interop.import_case was schematic-only (gt_power / avl_boost / wave are symmetric Schematic-based read+write); Cruise M's read returns Case directly because its component set is richer than the neutral Schematic. Split the registry into _TOOLS_SCHEMATIC and _TOOLS_DIRECT; import_case dispatches accordingly. Cruise M is import-only — export_case rejects it with a clear "doesn't support export" message.

Side fix: the GUI's _action_import previously did __import__(...) and called mod.read(path) directly, expecting (Case, report) everywhere. That contract is asymmetric across the four interop modules, so importing GT-Power / AVL Boost / WAVE through the GUI menu was assigning a Schematic where a Case was expected. Routing through CaseModel.import_from → import_case (the unified entry point) silently fixes that.

GUI MVC rewrite

Eight-phase refactor (commits MVC phase 1 through MVC phase 8 on master) lifting the GUI from a "MainWindow owns everything" layout to a clean Model-View-Controller split. End state:

  • aeolus1d_gui/model.py::CaseModel(QObject) — owns the active case, layout, on-disk path, dirty flag, and undo/redo history. Methods: new_untitled / load(path) / save(path) / import_from(path, tool) for file I/O; add_pipe / add_junction / add_bc / add_turbo / add_shaft / add_cylinder / add_probe / remove_probe / delete_component / set_layout_position for structural mutations; push_undo / undo / redo / clear_history / revert_pending_checkpoint for history. Signals caseReplaced / caseMutated / pathChanged / dirtyChanged / canUndoChanged / canRedoChanged so views subscribe instead of being manually re-synced after every action.
  • RunModel(QObject) — companion model for run-result state. set_path / load_now / adopt / clear with a runLoaded(run, layout) signal the plot dock + network view subscribe to.
  • MainWindow — the controller. Wires user input (palette drops, inspector edits, menu actions) → model API; routes model signals → views (canvas rebuild on caseReplaced; title-bar refresh on pathChanged / dirtyChanged; undo / redo enable state on canUndoChanged / canRedoChanged). No longer holds case / layout / history / last_run_h5 directly — those forward to the models via read-only properties for cheap call-site compatibility.
  • No view backrefs — docks (canvas, inspector, plot, network, modes, runner, palette, component-list) receive what they need via constructor args + method calls and emit signals back to the controller.

User-facing behaviour is unchanged. 455 GUI tests pass + 32 skipped (no Qt display) at every phase.

The GUI section of architecture.md describes the layout in detail.

Implicit-network maturation (Phases I-X / I-Y / B15 → B22)

After the implicit-Euler / BDF2 / Radau IIA(3) scaffolding shipped across phases I-1 → I-9 (see implicit-solver-plan.md), the implicit driver matured through two parallel strands.

Network coupling (Phase I-X / I-Y). Phase I-X (commit 3b119bd, 2026-05-01) opened the implicit driver to every Component Protocol type — Compressor / Turbine / Cylinder / LODI / py_junction_bc / EHR neck pipes — by dispatching ports polymorphically. The remaining limitation was an O(Δt) splitting error on Compressor/Turbine port ghosts: the driver froze them across the pipe Newton solve. Phase I-Y (commit 2bce33e, 2026-05-06) closes that by wrapping the pipe Newton + ghost refresh in a Picard outer iteration with NewtonConfig.picard_max_iter (default 3) and picard_tol (default 1e-6). Plenum-only networks naturally exit at iter 2; tight turbo coupling typically settles at 2-3 iters. Phase I-Y-adaptive added a controller that grows / shrinks the budget across a run; the GUI runner dock surfaces picard = N/M from the CLI step printout.

See phase-iy-picard.md for the full derivation and the validation gate.

Closed-form closure Jacobians (Phases B15.4 → B22). The implicit driver originally walked closure sources via central-FD on _cell_closure_source. Phases B15.4 → B22 brought every shipped closure kind onto an analytic path:

Phase Coverage
B15.3 / B15.4 HLLC analytic real-gas Jacobian end-to-end with EOS-derivative helpers
B16 / B16-ext DARCY_CONST / COLEBROOK friction
B16-real-gas Friction is EOS-agnostic; pin tests on real-gas pipes
B17 / B17-real-gas HEAT_CONSTANT_TWALL (ideal + real gas)
B17-gui / B17-gui-live Solver Internals dock — per-pipe analytic-vs-FD routing view
B18 DARCY_FORCHHEIMER / _KC + constant μ (both EOSes)
B18-ext Sutherland μ on ideal-gas pipes
B18-ext-real-gas Sutherland μ on real-gas pipes via EOS-derivative chain
B19 Consolidated analytic-vs-FD bench across all 7 scenarios (@pytest.mark.slow)
B20 Closed-form ∂a/∂T, ∂a/∂ρ for cubic EOS (RK + PR); ~4× microbench speedup
B21 Shared T_app chain across real-gas heat + Sutherland porous on the same cell
B22 Wiebe combustion + knock-controller TOML support; new 52_knock_event.toml

Bench numbers at N=200 cells (B19): ideal-gas analytic paths run ~7-7.5× faster than the FD fallback; real-gas analytic paths run ~2.7-3.2× faster (the EOS T-solve and ∂p/∂T/∂p/∂ρ calls dominate). See closures.md for the per-kind table and the Solver Internals dock (GUI) for the live routing view.

Phase Perf-6 / Perf-7 — chemistry advection JIT + packed-kernel dispatcher (2026-05-14)

Two performance phases on top of the audit-driven Perf-1 → I-8-ilu0 series. The full per-phase narrative lives in perf-history.md; the headline:

  • Phase Perf-6 (53f9bab) — JIT-ified the per-cell Python loop in network_timeloop._species_rate. Two new @njit kernels in aeolus1d/solver/species_advection_njit.py; reaction callable
  • species-inflow BC overlay + DPF filter-trap stay in the Python wrapper. 41_dpf_regen.toml wall: 4.63 s → 1.74 s (2.66 ×).
  • Phase Perf-7 (bfd3146) — empirical full-app benches showed the existing @njit(parallel=True) packed kernels with default NUMBA_NUM_THREADS = cpu_count (16) oversubscribe at typical aeolus1d pipe counts. Added serial twin kernels + a Python dispatcher that picks parallel vs serial by fast-pipe count. Engine cases: 50 16.78 s → 6.24 s (2.69 ×), 52 2.07 s → 0.95 s (2.18 ×). Override via set_parallel_threshold(P).

Phase 7c arc — wastegate, twin-turbo, surge/choke, DOC sulfur (2026-05-14)

Closes the four open Phase 7c roadmap items. See roadmap.md for surrounding context.

  • Phase 7c-wastegate (e93c2d1) — WastegateController in aeolus1d.control. Velocity-form PI driving Turbine.bypass_open; sign-inverted vs BoostController (more bypass → less boost). [[controller]] kind = "wastegate" in TOML. examples/59_wastegate_boost_ctrl.toml + plot.
  • Phase twin-turbo (73f76f5) — bundled examples/60_twin_sequential_turbo.toml exercising HP and LP turbo-shaft pairs in series with two independent shafts. Pure topology phase — kernels already supported N×N turbos.
  • Phase 7c-surge (e180224) — operating-regime diagnostics on Compressor: last_regime flag ("normal" / "idle" / "backflow" / "surge" / "choke") plus normalised last_surge_margin / last_choke_margin. Replaces silent off-design fall-through with informative state.
  • Phase 7c-doc-S (041a88d) — DOC sulfur-poisoning model in aeolus1d/closures/doc_sulfur.py. DOCSulfurOxidation (drop-in for make_doc_oxidation with f_S = 1 − alpha_S · theta_S deactivation factor) + DOCSulfurController (lumped sulfate storage / reduction integrator). New TOML kinds species.reaction = "doc_oxidation_S" and [[controller]] kind = "doc_sulfur". Bundled examples/61_doc_sulfur_poisoning.toml + plot.

HCCI / RCCI deferred to BACKLOG.md per user direction (low priority relative to the immediate-roadmap arc).

Phase 7g.2 — engine + acoustics + system-reports post-processing (2026-05-14)

GT-POST / Cruise-M-grade analysis layer on top of any tracer / HDF5 output. Full reference in post-processing.md.

  • Tier 1 — engine cycle diagnostics (766ac15) extends aeolus1d.engine_post with heat_release_analysis (Rassweiler- Withrow / first-law inverse → CA10 / CA50 / CA90 + MFB curve + polytropic n on compression and expansion); IVC / EVO partition populating imep_gross_pa + pmep_pa; volumetric_efficiency and cov_imep standalone helpers; 4 new fields on EngineMetrics.
  • Tier 2 — acoustics (6024e5b) — new aeolus1d.analysis.acoustics module with the frequency-domain / NVH machinery commercial 1-D codes ship for muffler / intake- runner tuning: SPL / dB(A) (IEC 61672 weighting), power spectrum, IEC 61260 1/1 + 1/3 octave bands, engine-order analysis, transmission / insertion loss for muffler design.
  • Tier 3 — system reports (8a446a2) — new aeolus1d.analysis.system_reports module: turbo_operating_points + turbo_map_envelope, energy_balance + EnergyBalance (Sankey-ready), cumulative_emissions, drive_cycle_runner (NEDC / WLTP / FTP harness), is_steady_state (COV-based periodic-limit-cycle detector).
  • Documentation + demo plots (00209a4) — new post-processing.md reference page; three bundled standalone demos under examples/plots/ exercising the heat-release / acoustics / turbo-overlay paths against synthetic data so they run in milliseconds without an aeolus1d simulation.

Documentation overhaul (2026-05-14)

  • README rewrite (c196f19) — replaced the 4725-line milestone- log README with a 289-line capabilities-focused document. Full per-phase technical history moved to docs/phase-log.md.
  • Theory + closures + tutorials sweep — refreshed docs/theory.md §6.3 with new VGT 3-D-map / wastegate-bypass / surge-choke paragraphs; added "Aftertreatment closures" + "Closed- loop controllers" + "Compressor surge / choke diagnostics" sections to docs/closures.md; extended docs/tutorials/engine.md with new "Modify and rerun" bullets.
  • Architecture module-tree refresh — docs/architecture.md updated to reflect the actual current layout (control/ / runtime/ / analysis/ / modes/ / chemistry/ sub-packages added).
  • Theory pmatrix → array fix (3a9ebc8) and GUI doc-viewer matrix layout fix (e8ec772) — the Euler-flux + S_geom display equations needed both source-side restructuring (\begin{ pmatrix} → \left(\begin{array}{c} for the served-MathJax site) and viewer-side rewrite of _render_matrix_html (the GUI viewer uses matplotlib mathtext + an HTML-table fallback for matrix environments — different stack from the served site's MathJax). User-reported via screenshots.

Ruff cleanup arc — full codebase ruff-clean (2026-05-14)

Five-phase progressive cleanup taking the codebase from 542 ruff errors to 0 across src/ + tests/ + aeolus1d_gui/:

  • Phase 1 (d364d6d) — src/aeolus1d/io/case.py __all__ extended to cover the 45 back-compat re-exports it pulls from build.py / parsers.py. Same pattern applied to src/aeolus1d/solver/implicit_euler.py in phase 3b (30 re-exports from implicit_kernels.py).
  • Phase 2 (0fb87eb) — F821 forward-reference false positives cleared. 5 lazy-import patterns got # noqa: F821 with comments naming the lazy import; 1 case got a proper if TYPE_CHECKING: block.
  • Phases 3a / 3b / 3c / 3d — auto-fix safe categories (I001 / UP037 / SIM300 / E702), F401 unused-import sweep, F841 / SIM* / B007 / E701 / B009 manual + unsafe-fixes audit. pyproject.toml gained an explicit ignore list with rationales for SIM102 / SIM105 / SIM108 (stylistic preferences that hurt readability in scientific code) plus per-file E402 ignores for the closures modules + Cruise M core.
  • Phase 4 (dffe205) — same playbook applied to aeolus1d_gui/ (72 → 0). Bumped ruff target-version from py38 to py310 to match what the codebase actually requires (parenthesized-with in test files, X | Y union annotations across the source). Bump enabled B905 (auto-fix added strict=False preserving prior behaviour) and UP035 (typing → collections.abc Callable).
  • Phase 5 (6c9d9a1) — added .pre-commit-config.yaml running ruff on staged files (same scope as the GitLab CI ruff gate that's been silently broken until this arc).

Two real bugs surfaced and fixed along the way:

  1. "doc_oxidation_S" was in the schema's Literal[...] but never registered in the closure registry — test_reaction_kinds_match_schema_literal caught it loudly.
  2. Compressor.apply_port_bcs directly accessed self.map.pr_grid for the Phase 7c-surge regime detection, but LearnedTurboMap (NN surrogate) doesn't expose that attribute — guarded with getattr(..., None).

GUI doc viewer + scalar-bar polish (2026-05-14)

  • Matrix-rendering fix (e8ec772) — the GUI's DocViewerDialog uses matplotlib mathtext + an HTML-table fallback for \begin{Xmatrix}-style equations (QTextBrowser can't run JavaScript). Original _render_matrix_html placed the brackets as separate inline-block spans next to an inline-table grid, which QTextBrowser's CSS subset doesn't compose reliably — brackets rendered tiny and detached from the matrix rows. Rewrote to a single outer <table> with three cells (left bracket / inner grid / right bracket); bracket size now scales with row count.
  • Scalar-bar flicker fix (67820fe) — NetworkView.refresh_colours was unconditionally remove_scalar_bar + add_scalar_bar on every animation frame. PyVista's bar reads mapper.scalar_range lazily so in-place mutation of actor.mapper.scalar_range already updates the bar without a rebuild — cached _scalar_bar_var so the remove / add cycle only fires on a variable change or when the bar doesn't exist yet.

Test state

~1700 tests across the solver + GUI suites, ruff-clean across src/ + tests/ + aeolus1d_gui/ (gated by both the GitLab CI ruff job and the optional .pre-commit-config.yaml hook). The Helmholtz first-order plenum-ghost test is the one strict-xfail (a 0-D-plenum limitation documented in tests/integration/test_helmholtz.py and resolved in practice by switching to the quasi-2D bench). Two recurring xdist contention flakes pinned to single workers via @pytest.mark.xdist_group (test_packed_dispatcher_serial_and_parallel_agree on numba JIT cache races; test_scrf_no_depletes on scipy LSODA fortran- callback races).