Skip to content

Roadmap

The named-phase backlog (Milestones 1–6), engine-domain enhancements, and FEM follow-ons are all closed. What remains splits into three buckets: polish (small, mostly empty), Milestone 7 — feature parity with state-of-the-art commercial 1D engine tools (the substantive next chunk), and speculative heavier work. This page owns the feature-level answer to "what's left". Two sibling documents in the repository root own the other two: STATUS.md for where the code is right now, what is in flight and what to do next, and BACKLOG.md for subsystem-level open items. (Named rather than linked — they live outside the docs tree, so a link here would not resolve in the built site.)

Each item below carries a rough effort estimate and a short rationale. Feel free to pick anything — there's no hard ordering beyond what's noted.

0. What v0.4.0 is, decided 2026-08-22

v0.4.0 ships the fixes for all twelve items of the 2026-08-22 project critique — not a feature chunk from Milestone 7. The release's definition of done, per-item effort, and the two sections that were cut on verified blockers (the docs-only CI fast path, the driver Mach sweep) live in release-readiness.md, which owns release exit criteria; they are not duplicated here.

What this page owes that decision is the feature-level consequence:

  • Milestone 7 does not advance in v0.4.0. The substantive next chunk below stays queued. 7f and 7i remain blocked on external infrastructure regardless.
  • Two items are structural and touch this page's territory. Critique item 2 unifies the three network drivers onto one shared physics layer under three retained integrators — no driver is deleted, because quasi_steady is a low-Mach SIMPLE solver, a different physical model and the W3 anchor. Critique item 6 decomposes the GUI's MainWindow god class. Together they are 5–8 and 4–6 weeks, parallelisable, and they dominate the release. Item 6's five planned steps all landed 2026-08-31. Step 1: case_io.save_case, 818 lines, split into thirteen pure per-block writers plus per-block helpers, output byte-identical across all 230 bundled cases that loaded when that was measured. Read 230 as a dated figure, not the current corpus: it counted 22 files a user cannot actually run (the miscount is retracted in BACKLOG_HISTORY.md), and the runnable count is 213 — tests/unit/test_bundled_case_count.py owns that number. Step 2: MainWindow._make_actions, 241 lines, became a 29-row ActionSpec table in aeolus1d_gui/shell/actions.py, with all 29 actions verified identical against a live window. Step 3: the fourteen docks became a DockSpec table in aeolus1d_gui/shell/docks.py and MainWindow.__init__ fell 484 → 125 lines, split into thirteen _wire_* methods; the dock layout is byte-identical on a fresh launch, on a restore, and when a layout saved by the old code is restored by the new. Step 4: the eight dock-layout methods became free functions in aeolus1d_gui/shell/window_state.py taking the window, which retires the unbound-method borrowing test_panels_menu.py had to do to test them. Step 5: RunnerDock.__init__, 559 lines, became 31 lines over five per-row builders and 23 per-widget ones, verified against a widget-tree golden. MainWindow is 3,723 → 3,454 lines and the function-length ratchet is at 39 over 200 and 174 over 100, from 43 / 177 when it was seeded. The row stays part-done: every god function in the GUI is gone, but the item is the god class, and a 156-method MainWindow is still one. (2026-09-29: EC-C6 marked met by decision — the criterion is the five steps; the class overhaul is deferred to BACKLOG.md until the GUI's functionality settles.)
  • Critique item 1 (experimental validation) is scoped to its engineering half only. Acquisition is out of scope: closing W1/KTM needs proprietary OEM cam data that is not public.

Expected size ~3 months. If that needs to shrink, the lever is moving items 2 and 6 to v0.5.0; nothing else in the list is large.

0d. Connection handshake — DONE 2026-09-24

Before a run, every gas connection is checked for what each side carries: species by name, and the gas model it computes with. A connection that drops a species or reads the state with a different gas model is printed. [case] handshake = "strict" stops the run instead. The CLI prints the ledger (aeolus1d handshake), and the GUI draws it under Run → Connections…, lighting the picked connection on the canvas. A coupling that reads the pipe's own gas model is credited only where a named function does it, and a test pins each such claim to its code.

Not covered, and why: thermal couplings (gas pipe to wall to coolant) and the mechanical turbo-to-shaft link. They pass heat and torque, not gas, so they need a different contract. Neither are internal plenum neck pipes, which carry no species of their own. The first run found three code defects of one kind, and all three were fixed the same day: ideal-gas BC ghosts on real-gas pipes, a fixed-γ BC on per-cell-γ pipes, and an ideal-gas temperature in the plenum port. The record is in BACKLOG_HISTORY.md.

0c. Multi-zone diesel NOx port — P0–P7 DONE 2026-08-24

The GT user-model corpus is ported: packet-based multi-zone combustion, a reduced-order XPI injector, zone-resolved NOx on a twelve-species equilibrium basis, wired through the TOML schema with a worked example and a theory page. Plan and outcome in .loop-review/gt-nox-multizone-port/plan_v1.md.

Not closed, and recorded rather than implied: tier-2 parity against the reference results is written and skipped. The staged results file carries no usable NOx oracle — CYL1.noxc is a deterministic monotone function of lambda (rank correlation exactly +1.000 over 223 cases) and ppmnoxeng is identically zero. The parity test enables itself the moment a results file with live NOx output is staged. Tier 3 (test-bed protocol data) is untouched. Both are in BACKLOG.md.

0b. Component icon set — DONE 2026-08-23

The 2D editor canvas now shows what a pipe contains — pattern for the family, tint for the member, with patterns collapsing to a solid tint at network zoom. Iconography is a selectable style (Schematic default, Legacy reproducing the old badges, Minimal for dense networks), added by registering rather than by editing the renderer. The key is icon-reference.md, which is generated from the classifier so it cannot drift.

Not shipped, and the reason recorded: junction, boundary-condition and turbine iconography. Those kinds carry no tint, so geometry alone would have to separate them at small sizes, and every form tried collided with the fixed-size kind glyph, the device-pixel-ratio bound, or the zoom sampling. A future attempt should give them a tint channel or their own silhouette treatment rather than smaller marks. The full reasoning is in .loop-review/component-icon-set/.

1. Polish

EC-C2 — one shared physics layer, three retained integrators

Status 2026-09-22: all four phases are closed, and EC-C2 is met. Phase C was closed by measurement — see "Phase C, characterised" below. (Was: "Status 2026-09-04: Phase A landed, Phase B is complete by its own measure, and Phase D has landed too — see its own subsection below. Phase C is not started.") Phase A is the shared run contract — src/aeolus1d/solver/_step.py holds NetworkRunSpec, the twelve parameters common to all four drivers, built once in io/case.py and splatted into all four call sites. No driver signature moved, so every number in the table below is unchanged; what moved is how the arguments get there. Phase B then hoists the shared physics, one operator at a time: the heat-release-rate Δt controller, the live-control poll and the network-pipes builder into solver/solver_ops.py, and then the chemistry dispatch plus its two stiff kernels into a new solver/chemistry_ops.py. The duplicated-block count across the four drivers went 7 → 5 → 4 → 3 → 0. Phase C is not started; Phase D landed 2026-09-04 (its own subsection below).

What the zero is and is not. It is the census's own measure — statement shapes of ≥ 5 source lines appearing in two or more of the four drivers — and by that measure the drivers now share no duplicated block. It is not "the drivers are one". The instrument has three blind spots, all measured rather than assumed, and they are written down beside REMAINING_DUPLICATION in tests/unit/test_ec_c2_phase_b.py: blocks shorter than five lines (the _COVERAGE_VIOLATION_TOL triplication, now in BACKLOG.md), duplication inside a single driver (the Strang driver carried the chemistry dispatch twice, once per branch of chem_every_n, and the census saw one), and differences in the order operators are applied. Phase D turned out to be an entry-point consolidation, not a further hoist — see its subsection for why the "different instrument" this paragraph used to promise turned out to be reading through the collapsed entry point to the body it still is.

Phase A is a structural fix, not a physics one. Before it, the twelve shared arguments reached the four drivers by three different mechanisms — named keywords, a conditionally-populated **extra, and a second conditionally-populated **extra_with_lc — and nothing checked that all four got all twelve. Twice they did not, and both times the defect was found by someone reading the dispatch rather than by a test (the two drops recorded below). A call site can now no longer thread eleven of twelve: tests/unit/test_ec_c2_step_contract.py fails if any of the four stops splatting the spec, if the spec and the measured intersection disagree in either direction, or if a field is passed twice.

The critique called this "driver duplication" and implied a driver should go. Review B4 corrected that and no driver is deleted: quasi_steady is a low-Mach pressure-projection SIMPLE mode with Rhie–Chow, a different physical model, and it is the anchor of the W3 wedge and of wallclock-budget's only case. It cannot reproduce a compressible-MMS convergence order by construction, so there is no survivor that could accept a deprecation redirect.

Driver Model Unique capability Anchored by
advance_network explicit compressible, SSP-RK2/3 acoustics, wave dynamics, shock capture regression goldens, MMS order suite
advance_network(strang=True)¹ explicit compressible, Strang split reacting flow at acoustic CFL regression, parity harness
advance_network_implicit implicit compressible (implicit Euler / BDF2 / Radau IIA) stiff chemistry + large dt past the acoustic CFL test_chem_integrator_order.py, parity harness
advance_network_quasi_steady_transient low-Mach pressure-projection SIMPLE + Rhie–Chow 500 s aftertreatment cycles in ~6.5 min W3, wallclock-budget c02006, avl_c02001 / c02002

¹ Pre-Phase-D this row was a separate function, advance_network_strang. That name still works — it is now a deprecated alias that warns and forwards to advance_network(..., strang=True) — but the model it names is reached through the collapsed entry point in new code.

Three integrator classes, four entry points — landed as three entry points, 2026-09-04. The plan's table listed three; the dispatch had four, because the two explicit-compressible drivers were separate functions. Phase D's "three public entry points" meant collapsing advance_network and advance_network_strang, not retiring a model, and that is what happened — see the Phase D subsection below for what "collapsing" turned out to mean once the two were actually looked at together.

Where the dispatch lives. One site chooses all three: src/aeolus1d/io/case.py:745–959. solver="quasi_steady" → QS transient; solver="strang" → advance_network(strang=True, ...); rk in _IMPLICIT_RK_NETWORK_KINDS → implicit; otherwise advance_network(strang=False, ...) (the default), which silently downgrades RK4/5 to ssp_rk2. Two of those four branches now call the same function with a different flag, which is the sense in which the dispatch chooses among three, not four.

The duplication, measured 2026-09-01 and re-measured 2026-09-04 after Phase D (ast over the three defs; harness: parameter names of posonlyargs + args + kwonlyargs):

params unique to it
advance_network 30 7
advance_network_implicit 36 12
advance_network_quasi_steady_transient 13 0

43 distinct parameter names across the three; 12 common to all three.

Re-measured 2026-09-11: 42 → 43, 35 → 36. junction_bdf2_history was added to advance_network_implicit alone. It is driver-specific by construction rather than by oversight — it selects the junction volume's time discretisation to match the pipes' BDF2 history, and BDF2 exists only on the implicit driver — so this widening is the legitimate case the count is here to distinguish from the illegitimate one described below. advance_network's own count rose 22 → 30: it absorbed advance_network_strang's former parameters (chem_substeps / chem_solver / chem_rtol / chem_atol / chem_every_n / chem_interval / chem_parallel) plus the new strang flag itself. The union barely moved (41 → 42, +1 for strang) because Phase D didn't add parameters, it stopped counting the same seven names twice under two different function identities. That is the item restated as a number: a physics knob added to one driver has to be threaded to the others by hand, and the dispatch's own comments record two occasions when it was not — coolant_couplings and wall_thermal_pairs silently dropped on the Strang path so "coolant pipes and dynamic walls stayed frozen at their ICs while the gas exchanged heat against them" (audit 2026-06-10 A1.9), and later live_control, cfl_ramp, dt_fixed and the HRR Δt cap dropped on the same path. Both are fixed; both are the same defect class, and neither was found by a test.

Both halves of the Phase A contract already exist in the tree, each applied exactly once. This is a pattern to extend, not to invent:

  • Shared physics under every integrator — advance_thermal_couplings (solver/solver_ops.py:286) is called from all four drivers (implicit_network.py:1807, network_timeloop.py:2193 and :3142, quasi_steady_network.py:3334). One function, four call sites, no divergence. It is the template the plan names.
  • A config object instead of a kwarg list — QSNetworkConfig (10 fields) and QSTransientConfig (23) are why the QS driver takes 13 parameters and has zero unique to it, against advance_network_implicit's 35 and 11. The driver with the most knobs has the smallest signature.

Not yet reconciled. The plan's Phase C proposes a descending Mach sweep to decide whether the 1.8 K parity spread is model or bug. docs/release-readiness.md's EC-C2 row records that sweep as cut by review B2 — it walks the parity case out of the calibration window its own harness depends on. Phase C therefore needs a different instrument before it can start, and the plan text in .loop-review/ still describes the cut version. That file is historical and is not edited; this note is the reconciliation.

Phase C, characterised (2026-09-22). The instrument is grid refinement at fixed physics, because it varies nothing the calibration window depends on. Every harness precondition was re-checked at every level. Measured on Python 3.10.12, numpy 2.2.6, numba 0.67.0, max |Δ| over the reactive pipe, QS against strang/ssp_rk3:

N X_CO band T [K] band Y_CO band θ_Ce settle θ
10 0.4031 0.582 5.49e-05 1.28e-04 1.5e-05
20 0.4024 0.297 3.04e-05 6.61e-05 1.5e-05
40 0.4021 0.151 1.57e-05 3.38e-05 1.5e-05

Ratios 1.8–1.97, extrapolating to ~0: first-order grid error from QS's upwind march (quasi_steady_network.py:462). It is not the low-Mach model and not a bug. The "1.8 K" the plan set out to adjudicate is the band's bound (3x the 0.58 K measurement), not the spread.

QS's own knobs, at N = 10: picard_tol 1e-4 → 1e-6 and dt_max ÷4 move nothing (T by ≤ 1.4e-03 K). cfl_adv ÷2 halves Y and θ, so those two channels carry a time-step share too, but T only goes 0.582 → 0.559 K.

The Mach sweep was run where review B2 allowed it, on the non-reacting test_driver_steady_parity.py case. The inlet is scaled ×1 … ×1/8, with t_end and h co-scaled so the profile shape and settledness hold:

inlet × M band N = 48 [K] band N = 96 [K]
1 0.032 1.010 0.513
1/2 0.016 1.059 0.538
1/4 0.008 1.084 0.550
1/8 0.004 1.097 0.557

It is flat in M and halves in N (N = 12 … 192 at ×1: 3.693, 1.960, 1.010, 0.513, 0.259 K). So the M² term is below 0.02 K. The plan's decision rule would have misread this: "flat in M ⇒ bug" is wrong when the flat part is grid error. The right test is whether the band shrinks with the mesh, and that is what is now pinned: test_quasi_steady_band_is_first_order_discretisation requires the N = 24 → 48 ratio to sit in [1.8, 2.2] (measured 1.940). Scaling QS's h alone by ×0.99 / ×1.01 / ×0.95 gives 1.376 / 1.253 / 1.100, all red. The 5.9 K band bound passes both 1 % cases.

Three corrections to the plan's Phase A itself, all from measuring the tree on 2026-09-02 rather than copying the plan. The plan specifies _step.py as StepInputs (state arrays, dt, closures, network topology) → StepOutputs, "explicit dataclasses"; the plan file is historical and is not edited, so this is the reconciliation.

  1. It is a run contract, not a step contract. The twelve common names are pipes / junctions / far_bcs, t_start / t_end / max_steps, coolant_couplings / wall_thermal_pairs / multilayer_wall_implicit, and on_step / clock / live_control. No dt, no state array: these functions are whole time loops and each derives its own dt. StepInputs names the wrong altitude, so the class is NetworkRunSpec.
  2. There is no output object. All four drivers are annotated -> float and return the simulation time they reached, so StepOutputs has nothing to hold and is not written.
  3. No driver takes **kwargs. ast reports kwarg is None for all four. The **extra catch-alls were at the call sites, on three of the four — and Phase A has replaced them, so there are now none of those either. This corrects the harness note in tests/unit/test_ec_c2_driver_census.py, which claimed the drivers took one. No census number moves; there was never anything to exclude.

One thing Phase A deliberately did not unify. The twelve share a name; max_steps does not share a default. Three drivers declare max_steps: int = 10_000_000 and spend it as range(max_steps) (network_timeloop.py:1692, :2967); the quasi-steady driver declares int | None = None and guards with is not None (quasi_steady_network.py:2757). So None is one driver's documented "no cap" and a TypeError in the other three, and NetworkRunSpec.as_kwargs() omits the key when unset — exactly what the old extra dict did by hand, which is what makes Phase A a no-behaviour-change refactor. Unifying the default is a Phase B question; the test re-measures all four defaults so that unifying them fails loudly rather than leaving a dead branch.

Two corrections to the plan's own counts, both measured here: the advance_* surface is 11 functions, not 10 — nine driver-level entry points plus two physics helpers (advance_thermal_couplings, advance_shaft_housing) that are not drivers and are not Phase D's to collapse. None of the eleven is exported from any __init__.py; the public surface is reached through io/case.py.

Phase B — hoisting the shared physics (started 2026-09-02)

The plan's Phase B list was two-thirds already done, and the third part was somewhere else. The plan names "boundary-condition application, thermal couplings, source terms, species update". Measured against the tree on 2026-09-02 (ast over the four driver bodies, call targets collected per driver and intersected):

  • Boundary-condition application is already shared — fill_network_ghosts (bc/base.py:186), one definition, called directly by three of the four drivers. The implicit driver imports it too but drives the port BCs through _fill_component_ghosts (implicit_network.py:418), which its own docstring calls the component-side twin of fill_network_ghosts.
  • Thermal couplings are already shared — advance_thermal_couplings (solver_ops.py), called by all four. This is the item's own stated template and it was already in place.
  • Source terms and the species update are shared as a function but not as a home. _species_rate has one definition and every leaf driver routes through it — tests/unit/test_driver_operator_parity.py has pinned that since before EC-C2 — but it is defined at network_timeloop.py:847, in the explicit-driver module, and the implicit and quasi-steady drivers import it from there. That is the same arrangement the HRR controller was in before this phase's first hoist, and it is the obvious next one.

So the operators were largely hoisted already. What was not hoisted was the plumbing around them, which is the half that gets forgotten — and the measurement says where it is. An AST shape scan over the four driver bodies (identifiers normalised, so all_p / all_pipes / pipes collapse to one shape; docstrings excluded) found 7 statement shapes of ≥ 5 lines appearing in two or more drivers. That count, not the plan's list, is Phase B's work queue, and it is pinned by tests/unit/test_ec_c2_phase_b.py::test_driver_duplication_census.

That number read 9 for most of 2026-09-02, and the correction is worth recording rather than quietly applying. The scan normalised every constant to one token — which is right for a number, since a block differing only in a threshold is the same structure, and wrong for a string, because it made any multi-line raise SomeError("…") match any other. Two of the nine pairs were not duplication at all: the chem_every_n guards in the Strang and implicit drivers, which correctly name their own driver in their messages and should differ, and the implicit driver's cylinder-valve refusal paired against the quasi-steady driver's non-finite-velocity abort — entirely different code. The per-hoist deltas were never wrong; each hoist removed exactly two blocks either way. The totals were inflated, and an inflated total makes the remaining work look larger than it is. The normaliser now keeps strings, and test_the_census_does_not_pair_two_unrelated_raise_blocks is the guard on the instrument.

Hoist 1: the heat-release-rate Δt controller. 7 → 5. The two shapes that went were the start-of-step snapshot comprehension (in three drivers) and the end-of-step controller update (in three). The two kernels were already shared — but they lived in solver/network_timeloop.py, the explicit-driver module, and the quasi-steady and implicit drivers reached in and imported them. That is precisely the arrangement solver_ops.py's own docstring names as the cause of the silent-drop class fixed in MR !301, and the HRR cap had already been dropped once on the Strang path (with live_control, cfl_ramp and dt_fixed). The controller now lives in solver_ops.py as HRRSnapshot + hrr_measured_fraction / hrr_species_fraction / hrr_step_factor / hrr_next_dt_limit, and it is registered in the driver-parity operator set — where it had never been, despite being exactly that defect class.

One thing was deliberately not unified: the gas-energy snapshot. All four drivers source it differently, and two of them source it from copies they already hold for another purpose — the explicit driver's pipe_Un feeds the post-RK mass balance, the implicit driver's dict[id(p)] feeds its Picard re-iterations. Routing those through a snapshot helper that always copies would add a per-step (N,3) copy per pipe to two drivers for no behavioural gain, so HRRSnapshot.take(..., U=...) accepts a caller-supplied one and the explicit driver uses the plain constructor. Pinned as a decision by test_the_explicit_driver_reuses_its_own_snapshots.

Hoist 2: the live-control poll. 5 → 4. Five sites wrote out the same "poll the control file, block while paused, break on checkpoint_and_stop, take a fresh CFL" block — timeloop.py:557, network_timeloop.py:1627 and :2905, implicit_network.py:991, quasi_steady_network.py:2775. The first three are identical; the implicit one adds apply_to_adaptive_dt and the quasi-steady one omits effective_cfl, having no acoustic CFL to override. This is the block whose absence is the recorded defect twice over: the Strang and quasi-steady drivers each ignored live_control entirely at some point, so Ctrl-C and GUI-cancel produced no clean checkpoint — and the quasi-steady driver is the longest-running one, where a cancel matters most.

solver_ops.poll_live_control returns (stop, cfl) rather than hiding the whole block: it contains a break, which cannot be hoisted into a function, so each caller keeps a two-line if stop: break. timeloop.py's single-pipe driver is included even though it is not one of EC-C2's four — a helper that leaves an identical sixth copy behind is not a single home.

The hoist nearly shipped a crash, and the type checker caught it. The first draft called apply_to_adaptive_dt unconditionally, on the correct grounds that the method no-ops on None. It does — but only if it exists, and callers pass structural stand-ins: the quasi-steady driver's live_control parameter was annotated object | None precisely because tests/integration/test_solver_audit_parity_2026_07.py hands it a _StopAfter with three methods and nothing else. That draft would have raised AttributeError on the first macro step of that test. The fix is that both optional calls are guarded on their argument, and the contract is now in the type system rather than in a comment: a StopSignal protocol names the three methods always used, and two overloads say that passing a CFL requires a full LiveControl while passing none does not. The quasi-steady signature reads StopSignal | None instead of object | None, which accepts the same stand-in by being precise rather than by accepting anything, and three # type: ignore[attr-defined] went with it.

One site was deliberately left out. implicit_euler.py:457 polls and honours should_stop() but never calls wait_while_paused(), so the single-pipe implicit drivers cannot be paused while every other driver can. Routing it through the shared helper would have silently fixed that, which is a behaviour change and not a hoist. It is filed in BACKLOG.md and pinned as an open gap by a test that fails when the fix lands.

Hoist 3: the network-pipes builder. 4 → 3. Hidden neck pipes owned by junctions must be advanced alongside the user pipes, so every per-step loop iterates the union — and three drivers built that list three ways. The explicit and Strang drivers each defined an identical _all_pipes() closure; the implicit driver spelled it pipes + [p for j in junctions for p in j.neck_pipes]. Same list, same order, three definitions, now solver_ops.network_pipes. Removing the implicit driver's local neck_pipes showed it had no other reader at all.

The quasi-steady driver was not made a caller: it references no neck_pipes anywhere, because it solves junctions as steady pressure nodes rather than advancing a control volume, so handing it the union would be a behaviour change. Pinned as a decision by test_the_quasi_steady_driver_is_still_not_a_caller.

One duplication was left at the end of hoist 3 — three shapes by the census, because the block nests — the chemistry / surface-mechanism step dispatch shared by the Strang and implicit drivers. Hoist 4 is that block.

Hoist 4: the chemistry dispatch. 3 → 0. Landed 2026-09-03. It could not be done the way the first three were. The duplicated loop dispatches to _surface_mechanism_step_on_pipe and _chemistry_step_on_pipe, which lived in network_timeloop.py; hoisting only the loop into solver_ops.py would make the shared layer import from a driver module, which is a circular import (network_timeloop imports solver_ops at module level) as well as the exact anti-pattern this phase removes. Python agrees, and not merely in principle: inserting that import fails collection with ImportError: cannot import name '_bs_jit_applicable' from partially initialized module. So the chemistry step functions moved too — costed before the work, and the estimate held:

lines to move 635 — _surface_mechanism_step_on_pipe (154), _chemistry_step_surface_lsoda (215), _bs_jit_applicable (12), _chemistry_step_on_pipe (254)
destination a new solver/chemistry_ops.py, not solver_ops.py — absorbing 635 lines of chemistry into the operator-split module makes it a grab-bag, and timeloop.py now imports solver_ops at module level
import sites to update estimated 9 — implicit_network.py, quasi_steady_network.py, and seven test modules. Measured: 2 that had to change. quasi_steady_network.py names the kernels only in prose, and the four test modules that import them go through network_timeloop, which re-exports. So the forced set is implicit_network.py (the lazy import) and test_strang_refreshes_gamma_before_chemistry.py (which monkeypatches, and a re-export is not a patch point). Five further files named the old module in comments — chem_numbalsoda.py, chem_bs_jit.py, quasi_steady_network.py, test_twc_brinkmeier.py, test_quasi_steady_network.py — plus four now-wrong network_timeloop.py:NNNN line references in test_driver_reacting_parity.py's header. Corrected, since a comment naming the wrong module is how the next reader relearns this the hard way
perf gates touched none. tests/benchmarks/test_profile_regression.py attributes by file and function, and it names network_timeloop.py (_species_rate), not these

Doing it inside the merge request that carried hoists 1–3 would have been a 635-line move riding along with three small ones. Hoisting only the loop and leaving the callees behind would have moved the census number without fixing what the number measures, which is the same error as refreshing a red reference.

Four things the hoist measured that the estimate did not.

  1. The loop was written out three times, not two. The census reports shapes across drivers, and it buckets by shape with a set of driver names — so N copies inside one driver collapse to one. advance_network_strang carried the dispatch twice, once per branch of chem_every_n, and the census saw "strang". The count 3 was three nesting levels of one shape in two drivers; the actual copies were three — two in Strang, one in the implicit driver. This blind spot is now recorded next to the constant, and teeth-tested: re-inlining one of the Strang copies is invisible to the census and caught only by test_neither_driver_calls_the_kernels_directly_any_more.
  2. The census cannot reach 0 by hoisting alone, and 3 → 0 was true only after the instrument was corrected. A successful hoist does not delete code from the drivers; it replaces a duplicated block with an identical call in each of them, which test_driver_operator_parity.py then requires to be there. The raw census read 1 after this hoist, and the 1 was chemistry_step_on_pipes(...), five lines wide in both drivers. Hoists 1–3 escaped only by formatting luck — poll_live_control fits on two lines, network_pipes on one. A metric that counts the residue of a successful hoist rewards hoisting less, so _resolves_to_shared_op now excludes a statement that is nothing but a call to a shared-layer operator. The exclusion is narrow: wrap that call in a loop or a guard and the enclosing shape is counted again, which is its own teeth-test.
  3. The implicit driver's alias had to be resolved, not listed. It binds the dispatch as _chem_steps_fn by a lazy import inside the driver body, because the whole chemistry block is gated on the network carrying a reaction at all. A census that only looked at module globals would have excluded the Strang call and counted the implicit one, paired nothing, and reported a false zero — so _shared_op_aliases reads both binding mechanisms out of the AST rather than from a hand-kept list.
  4. _COVERAGE_VIOLATION_TOL is defined three times — chemistry_ops.py, quasi_steady_network.py:2222, chem_numbalsoda.py:201 — same value, same reasoning, no import between them. Under MIN_BLOCK_LINES, so the census has never seen it. Filed in BACKLOG.md, not fixed here.

What the hoist deliberately did not do. The chem_substeps loop stayed in the Strang driver: it is the only driver that has one, and pulling it into the shared function would have invented a knob for the implicit driver and made the shared dt mean two different things. The four moved kernels kept their leading underscore, because a twelve-site rename riding along with a 635-line move is how a provable move stops being provable — filed in BACKLOG.md with the mechanical recipe. And the destination is a new module rather than solver_ops.py, so the operator-split layer does not become a grab-bag and timeloop.py, which imports solver_ops at module level for every single-pipe run, does not pay for 635 lines of chemistry.

One call site broke, loudly, and that is the design working. tests/unit/test_strang_refreshes_gamma_before_chemistry.py monkeypatched network_timeloop._chemistry_step_on_pipe. The re-export keeps that import resolving, but a re-export is a second binding, not the one the driver reads — so the patch stopped intercepting. It did not degrade quietly: seen came back with no chemistry events and both tests failed on assert "chem" in seen. The spy now patches chemistry_ops, and the module's own docstring says re-exports are not patch points.

And _species_rate — the other function shared-but-housed-in-a-driver — is a third case again, because it is named by a perf gate (network_timeloop.py (_species_rate), ceiling 0.7 s). Moving it changes the file half of that attribution, so the gate must move with it in the same commit, and the gate runs only on tag and schedule pipelines.

The sequencing prerequisite is in place. Phase B's tripwire is "the goldens do not move", and regression-bitid — the exact-comparison tier — was main / tag / schedule only, so that signal arrived after each merge. For a multi-MR refactor that is a stack of merges to unpick rather than one MR to fix. .gitlab-ci.yml now gives it a merge_request_event rule with when: manual, allow_failure: true: an ordinary MR pays nothing and the bench host stays out of the merge path, while a refactor MR opts in by clicking it. The tier stays host-dependent — a manual MR run is only comparable to a main run on the same runner tag.

A correction, measured on this merge request's own first pipeline. The rule first read allow_failure: false, as the plan specified — and a when: manual job with allow_failure: false blocks its stage. Pipeline 2814486224 showed it: every other test job green, regression-bitid at manual, all three integration jobs stuck at created, pipeline status manual rather than success. That version would have stalled every merge request behind a click on the bench host — the opposite of what it was written to do. It is now allow_failure: true on the merge-request rule only, so the branch / tag / schedule rules keep the default and a red bit-identity run still reddens main. What is lost is that a played-and-red MR run no longer blocks the merge by itself; GitLab has no "optional but blocking when played" for one job.

Phase D — consolidating entry points (landed 2026-09-04)

What "collapsing advance_network and advance_network_strang" turned out to mean. Read literally, the plan's Phase D ("reduce the 10 advance_* entry points to three public ones... by making the remaining seven thin, deprecated aliases with DeprecationWarning, redirecting within the same model") sounds like it wants the two functions' bodies merged. They cannot be, and not for lack of trying: advance_network and advance_network_strang are different algorithms in the same model class — plain SSP-RK vs. Strang operator splitting (T(Δt/2) ∘ C(Δt) ∘ T(Δt/2)) — and advance_network_strang already called advance_network internally for both transport half-steps before this phase, which is composition, not duplication, and is exactly why the Phase B census (above) found nothing left to hoist between them. There was no duplicated body to merge. What the plan's own words describe — "thin, deprecated aliases... redirecting within the same model" — is an entry-point change, and that is what landed.

The mechanic. advance_network gained a keyword-only-in-spirit strang: bool = False parameter (still positional-or-keyword, matching the rest of its signature) plus the seven parameters advance_network_strang used to carry alone (chem_substeps / chem_solver / chem_rtol / chem_atol / chem_every_n / chem_interval / chem_parallel). The very first statement of its body — before any of its own bookkeeping runs — is if strang: return _advance_network_strang_impl(...), where _advance_network_strang_impl is the old advance_network_strang body, renamed and otherwise byte-for-byte unchanged. The old public name, advance_network_strang, is now a ~25-line wrapper: warnings.warn(..., DeprecationWarning, stacklevel=2) followed by return advance_network(..., strang=True, ...) with every original parameter forwarded. Nothing was deleted — the plan's own instruction — and nothing in either code path was rewritten, so this is a zero-behaviour-change refactor in the same sense Phase A was: AEOLUS_FASTMATH=0 python -m pytest -n 0 -m regression tests/regression/ passed unchanged (31/31) after the change, because the statements that actually run are identical to before, just reached through one more layer of dispatch.

The one product call site that mattered. io/case.py's solver == "strang" branch called advance_network_strang(...) directly; it now calls advance_network(strang=True, ...), so the last piece of first-party code that chose between the two names now goes through the collapsed one. The deprecated alias exists for external callers and the existing test suite, not for this repository's own dispatch.

Three test-harness corrections, because the shape of the code changed even though its behaviour did not. All three are re-measurements, not loosened assertions — tests/unit/test_ec_c2_phase_b.py, test_ec_c2_step_contract.py and test_ec_c2_driver_census.py all failed immediately after the mechanical change above, and each failure was the instrument correctly noticing a real shape change:

  1. The duplication census (Phase B's instrument) was reading the wrong function. DRIVERS["strang"] pointed at the public name; once that name became a 25-line wrapper, inspect.getsource on it returned the wrapper, not the Strang body, so every assertion that grepped the Strang driver's source for a specific pattern (HRRSnapshot.take(, network_pipes(, the two chem_substeps loops) started failing — correctly, because those patterns are genuinely no longer in advance_network_strang's own source. Fixed by pointing DRIVERS at _advance_network_strang_impl, which is where that source still lives, unchanged.
  2. The step-contract census assumed one call site per driver name. io/case.py now calls advance_network twice (strang=True and plain), which the old _driver_calls() treated as an error ("advance_network is called twice"). _driver_calls() now returns a list of call sites per name instead of asserting uniqueness, and a new test_advance_network_has_exactly_the_two_expected_call_sites pins the new shape (one strang=True site, one without) so a third site, or a regression back to a direct advance_network_strang call, is caught.
  3. The parameter census's DRIVERS set and expected numbers were measuring four callables that are now three. Re-measured 2026-09-04: advance_network 22 → 30 params (7 unique, up from 4, because it absorbed advance_network_strang's 7); the three-driver union is 42 (was 41 across four — see the table above); the three-driver intersection is still 12, unchanged, because Phase D added a flag and folded in parameters that were already present somewhere in the old four-driver set, and did not remove anything any driver used to accept.

The same root cause — a test reading the public advance_network_strang name for its source, which is now a wrapper rather than the Strang body — also broke two tests outside the EC-C2 files: test_strang_refreshes_gamma_before_chemistry.py (a monkeypatch fixture that patches the module-global advance_network to fake the transport half-steps; going through the public alias means that patch also fakes the alias's own forwarding call, so the real Strang loop never runs) and test_driver_operator_parity.py::test_strang_driver_owns_outer_operators (the same inspect.getsource-on-the-wrong-function pattern as correction 1). Both were repointed at _advance_network_strang_impl for the same reason as DRIVERS["strang"] above.

What Phase D did not touch. The three retained integrator classes are still three, and still not merged with each other — advance_network_implicit and advance_network_quasi_steady_transient were never candidates, per the "no driver is deleted" decision above. The single-pipe entry points (advance_single_pipe, advance_single_pipe_implicit_euler, advance_single_pipe_bdf2, advance_single_pipe_radau_iia) and the two non-driver physics helpers (advance_thermal_couplings, advance_shaft_housing) are outside this phase's scope, as recorded when the advance_* surface was first counted at eleven functions (above): they are not part of the four-callable io/case.py network dispatch this phase collapsed.

  • Phase 27 — Numba intra-step parallelism. The hot kernels (HLLC, EOS, MUSCL, MP5) already JIT to native SIMD via @njit; what's left on the table is the per-step orchestration that loops sequentially over 10–15 pipes × 3 RK stages × millions of timesteps. Empirical scoping (2026-04-27) ruled out 27a and 27b for typical aeolus1d workloads — only 27c remains viable, and it's the expensive option. Sub-phases:

    • 27a — Python ThreadPoolExecutor over pipes. ❌ NOT VIABLE. Benchmarked on the 4-cyl example: 13 pipes × ~5 µs/pipe = 65 µs sequential per RK stage. Pool submission overhead alone (~5 µs/submit × 13 submits = 65 µs) wipes out the parallelism, and 4 workers measured 4× slower than sequential (0.25× speedup). Python's thread-pool primitive is too heavy for sub-µs work units. Even with @njit(nogil=True) released the GIL, the submit overhead dominates. Verdict: don't ship.

    • 27b — Intra-kernel prange over interior cells. ⚠ NOT VIABLE FOR TYPICAL AEOLUS WORKLOADS. Numba's prange break-even for fastmath HLLC-style work is around N ≈ 200 cells per kernel call. Below that the thread-spawn overhead dominates and parallel runs slower than serial. Measured on a synthetic equivalent:

    N cells serial parallel speedup
    30 8.7ms 261.9ms 0.03×
    60 18.5ms 259.4ms 0.07×
    200 53.4ms 15.2ms 3.52×
    1000 251 ms 42.6ms 5.89×
    5000 1.22 s 207 ms 5.92×

    Example 39's pipes are 30–60 cells. The audio-demo workload is exactly the regime prange makes worse, not better. 27b is only worth shipping for cases that use long pipes (≥ 200 cells) — combustion-tube flames, shock-tube validation, large catalyst monoliths.

    • 27c — Packed multi-pipe master kernel with prange. The only viable path for typical aeolus1d networks. Corrected 2026-08-29: this read "(still open)" and it has shipped — solver/packed_pipes.py, dispatched from solver/network_timeloop.py:1444, on by default. What is open is narrower and had not been written down: the prange variant is selected only at P >= 12 fast pipes, and measured over the 81 buildable top-level examples/*.toml, 42 (52 %) have at least one packable pipe and 0 reach P >= 12 — corpus maximum is 6. The dispatch-reduction half is live; the thread-parallel half is compiled and never selected. The threshold is right (it guards the regime 27b measured as slower); the silence about it was not. Note also that _is_packable requires first_order and Ng == 1, so the MUSCL engine cases score 0 packable — which is the population ADR-0015 covers instead. Original text follows. Pre-pack all pipes' state into homogeneous ragged-array form (U_pad_all[n_pipes, max_cells, 3], dx_all[n_pipes], kernel-dispatch index) and run a single @njit(parallel=True) orchestrator that prange-loops over pipes. For example 39: 13 pipes × 50 cells = 650 effective parallel work-units per call — well above the prange break-even regardless of per-pipe size. Combines automatically with cell-level parallelism since one kernel handles everything. Estimated 3–5× wall on a 4-core box for the audio demo. Effort: ~2 weeks because the timeloop's per-stage state combine (Un + dt · R) also needs packing to keep all the work in one Numba kernel; per- pipe variants (MUSCL/MP5/first-order, EOS/no-EOS, quasi1d/uniform) need a unified packed representation with kernel-id dispatch.

Decision rule: only commit 27c when (a) someone has measured the audio-demo wall time post-optimization and the user wants > 4× more, and (b) the 2-week scope is acceptable. Sequential aeolus1d + the existing aeolus1d.parallel for case sweeps already covers most workflows.

Decision logged 2026-04-27: Rust alternative considered + rejected (Numba kernels already compete with hand-written Rust at the LLVM-SIMD level; the closures-as-callables pattern would force PyO3 callbacks per cell × per stage, reabsorbing the speedup the rewrite was supposed to gain). 27a tested empirically and confirmed non-viable. 27b tested empirically and confirmed non-viable for example 39 sizes. 27c is the standing offer for someone serious about performance.

  • Implicit-network maturation (Phases I-X / I-Y / B15 → B22). Closed 2026-05-06. Phase I-Y wraps the pipe Newton + ghost refresh in a Picard outer iteration so Compressor / Turbine / LODI ghost coupling is no longer split across the step (default picard_max_iter = 3). Phases B16 → B22 brought every shipped closure kind onto an analytic Jacobian path: friction (DARCY_CONST / COLEBROOK, EOS-agnostic), heat (HEAT_CONSTANT_TWALL ideal + real-gas), porous (Darcy- Forchheimer constant + Sutherland μ on both ideal + real-gas pipes), plus closed-form ∂a/∂U for cubic EOS (RK / PR) and a shared T_app chain that halves the per-cell EOS work when both real-gas heat + Sutherland porous fire on the same cell. Phase B22 finalised TOML support for Wiebe combustion and the closed-loop knock-retard controller. Bench (tests/benchmarks/test_bench_closures.py @pytest.mark.slow): ideal-gas analytic ≈ 7-7.5× faster than the FD fallback; real-gas analytic ≈ 2.7-3.2×.

Open follow-ups in this area: nothing of substance — Phase B23 closed the tabulated EOS sound-speed Jacobian (the last remaining FD branch) and example 57 ships the adaptive_picard tuning sweep with both tuning signals visible. The closure-Jacobian programme is now exhaustively closed.

  • Pure-Python assembly path for every example — ✅ DONE 2026-08-17 (requested 2026-08-15, first work toward v0.4.0). All 79 bundled examples now ship a Python builder under examples/python/, and the embedding surface has a tutorial.

The scope decision the item demanded up front — all 79 versus a representative subset — was taken as both, layered, because the two halves want opposite treatments. The 79 declarative builders are generated by scripts/gen_example_python.py from an emitter that is generic over dataclasses.fields(), so it knows no block names and a new schema block cannot be forgotten the way the hand-enumerated TOML writer has forgotten four. The component surface — Pipe, Volume/Port/ Junction, BC factories, advance_network — is hand-written as a tutorial, because assembly order is a thing to learn rather than to transcribe 79 times.

Guarded three ways per example (a builder exists; it is byte-identical to what the generator emits; executing it yields a Case equal field by field to the parsed TOML) plus a schema-wide check that every spec class stays emittable. The drift class this closes was verified by injection, not assumed. Builders ship in the wheel beside the cases. See Examples in Python and the assembly tutorial.

2. Milestone 7 — feature parity with commercial 1D engine tools

Distilled from a 2026-04-27 literature scan of GT-POWER / AVL BOOST / Ricardo WAVE feature sets, recent SCR / aftertreatment research, and the alternative-fuels surge (H2, NH3, dual-fuel). Items ranked by user-visible impact relative to effort.

Shipped 2026-04-27 as MVPs and grown since: 7a (fuel presets), 7b (DOC HC+CO closure → grown into the full aftertreatment depth: DPF passive + active regen + DPFHeaterController, SCRF, two-stage SCR, urea injector), 7c (VGT vane → grown into 3-D TurboMap + boost controller + LearnedTurboMap surrogate), 7d (Livengood-Wu knock integral → grown into 7d-3 closed-loop retard, 7d-4 cycle-accumulator overlay, B22 TOML support), 7e (FMI export scaffolding), 7g (cycle-resolved engine post), 7h (PINN closure adapter). Each landed in one focused commit + tests; the full sweep across Milestone 7 + Phase 25/26/8/B stays green. Deferred: 7f and 7i need external infrastructure aeolus1d alone can't supply (see notes below). Sub-phases tagged X.2 are full-fidelity follow-ups identified during the MVP work.

7a — Alternative-fuel kinetics (high value)

Hydrogen and ammonia engines are an active industry direction. aeolus1d has the closure seams (py_reaction, py_reaction_surface, Wiebe combustion, fuel injection) but defaults are diesel-shaped. What's actually missing:

  • H2 / NH3 / NH3-H2 blend Wiebe parameters as named presets. Laminar flame speed of NH3 is ~5× slower than gasoline; a realistic NH3 burn needs longer duration_deg and shifted θ_start_deg (Heywood / recent SI-engine NH3 papers report ~70-90° burn duration). Ship as [[fuel_preset]] blocks (or expand the new [[species]] table with combustion parameters).
  • Multi-fuel cylinders — port + direct injection of two different fuels on the same cylinder (ammonia pilot + diesel main, gasoline + ethanol). Need: a list of injection schedules per cylinder instead of the single one Phase 25k exposes.
  • NOx / N2O kinetics for NH3 combustion — current SCR closures consume NOx but don't produce it from NH3 fuel combustion. Big value if the user wants to model NH3 engines with downstream SCR.

Effort: ~1 week for presets + multi-injector; ~2 weeks for NH3 NOx production kinetics.

Update 2026-07: Two of the three items have landed. Multi-injector cylinders shipped — Cylinder.injectors is a list of per-injector schedule dicts (Phase 7a), so ammonia- pilot + diesel-main and gasoline + ethanol dual-injection are now expressible. Engine-out pollutant production closures also shipped in aeolus1d.closures.pollutants (make_zeldovich_nox / make_zeldovich_nox_extended extended-Zeldovich thermal NO + N₂O, make_co_formation, make_hiroyasu_soot soot, make_hc_slip HC), all on the py_reaction_multi seam, with aeolus1d.analysis.nox_post wrapping the Zeldovich rate into a cycle-trajectory NOx integrator. Still open: the H2 / NH3 / NH3-H2 named Wiebe presets and the NH3 fuel-bound-nitrogen NOx pathway specifically (the shipped closures are thermal-route).

7b — Aftertreatment depth (mostly shipped)

Generic two-phase catalyst + the hc_oxidation / nh3_ads_des / nh3_scr_kinetics closures cover the common cases. The specialised components in this section have all shipped through 2026-05:

  • ✅ Diesel Oxidation Catalyst (DOC) preset (46_doc.toml, Phase 8b/8c) — Pt/Pd kinetics for HC + CO oxidation with light-off behaviour, plus separate HC and CO branches.
  • ✅ Diesel Particulate Filter (DPF) with soot loading + regeneration (41_dpf_regen.toml, 49_dpf_no_inhibition.toml, 50_dpf_active_regen.toml, 53_dpf_heater_ctrl.toml). Phase 8j shipped passive (NO2-driven) regen; Phase 8k added the Langmuir NO back-inhibition correction; Phase 8m added the active-regen heater + Phase 8m-ctrl added a closed-loop PI controller (DPFHeaterController) that drives mean cake temperature to a target by modulating heater power.
  • ✅ Urea-water spray + decomposition (42_scr_urea_injector.toml, 48_hnco_hydrolysis.toml, Phase 8e/8f) — UWS injection BC, fast-equilibrium NH3 yield, HNCO hydrolysis branch.
  • ✅ Combined SCR + filter (SCRF) (43_scrf.toml).
  • ✅ Two-stage SCR (45_two_stage_scr.toml).

Remaining: nothing on the original list. Open candidates if the user revisits the area would be:

  • DOC sulfur-poisoning model (sulfate storage / reduction cycles for high-S markets).
  • Ash accumulation in DPF (separate from soot, irreversible).
  • CHA / SSZ-13 zeolite-specific NH3 storage curves vs the current generic site-density model.

7c — Turbocharger depth (mostly shipped)

  • ✅ Variable Geometry Turbine (VGT) — Phase VGT-3D shipped the 3-D TurboMap (vane_open axis on top of PR_inv / N_corr). 27_compressor.toml, 51_vgt_boost_ctrl.toml, 54_adaptive_picard_turbo.toml, 55_vgt_3d_turbo.toml exercise the chain. Phase VGT-ctrl.2 shipped a BoostController that drives mean intake pressure to a target by modulating vane position. Learned VGT maps (Phase Learned-Turbo) ship a LearnedTurboMap MLP surrogate alongside the analytic and CSV / NPZ loaders.
  • ✅ Wastegate — Phase 7c-wastegate shipped a velocity-form PI WastegateController that drives Turbine.bypass_open to a target manifold pressure. Sign-inverted vs BoostController (more bypass → less boost). Reuses the existing bypass_open actuator on Turbine that scales the extracted shaft power and blends T0_out toward T0_in. [[controller]] kind = "wastegate" exposes the controller in TOML; 59_wastegate_boost_ctrl.toml + plot_59_wastegate.py are the bundled demo. The original "parallel pipe with controlled- area orifice BC" topology is a different, more invasive design that has not been pursued — the bypass-fraction model captures the boost-pressure controllability that the Phase 7c task required.
  • ✅ Twin / sequential turbo — Phase twin-turbo shipped examples/60_twin_sequential_turbo.toml + matching plot: HP and LP turbo-shaft pairs plumbed in series (intake_atm → LP_compressor → intake_mid → HP_compressor → manifold; exhaust cascades the other way through HP_turbine → LP_turbine). Two independent shafts with HP carrying smaller inertia for transient response. The kernels (Turbine / Compressor / Shaft) already supported N turbomachines + N shafts before this phase — the work was the bundled topology template + a regression test pinning the cascade direction. Parallel-twin (two compressors feeding the same manifold) is the same TOML pattern with different pipe wiring; left as a user-extension exercise.
  • ✅ DOC sulfur-poisoning model — Phase 7c-doc-S shipped a Tier-1 lumped-parameter sulfate-storage / reduction model in aeolus1d.closures.doc_sulfur. DOCSulfurOxidation is a drop-in replacement for the base make_doc_oxidation closure with a multiplicative deactivation factor f_S = 1 − alpha_S · theta_S on both the HC and CO branches; DOCSulfurController integrates the lumped theta_S balance against mean DOC gas temperature using Arrhenius storage (low Ea — proceeds at exhaust T) and reduction (high Ea — meaningful only above ~870 K). Pair species.reaction = "doc_oxidation_S" with [[controller]] kind = "doc_sulfur" in TOML. Bundled examples/61_doc_sulfur_poisoning.toml + plot_61_doc_sulfur.py exercise the storage + active- regen cycle.
  • ✅ Compressor surge / choke detection — Phase 7c-surge added a Compressor.last_regime flag (one of "normal" / "idle" / "backflow" / "surge" / "choke") plus normalised last_surge_margin / last_choke_margin fractional distances to the top / bottom edges of the map's PR grid (positive when inside the envelope, ≤ 0 at or past the edge). The off-design fall-through still wall-closes both pipe ends — the new fields are diagnostic-only — so no existing callers regress.

Remaining effort: topology cases are example-file work (wastegate landed 2026-05-14 in Phase 7c-wastegate).

7d — Knock + advanced SI combustion (mostly shipped)

  • ✅ Livengood-Wu integral for knock-onset prediction (Phase 7d / 7d-2). Per-cycle knock_integral_max + knock_fired flag emerge from the unburned-zone Douaud- Eyzat ignition-delay correlation.
  • ✅ Closed-loop spark retard controller (Phase 7d-3) — spark advance walks back by knock_retard_deg_per_event per fired cycle, capped at knock_retard_max_deg, recovers at knock_advance_deg_per_cycle per knock-free cycle. timing_retard_deg aggregated per cycle.
  • ✅ CycleAccumulator overlay (Phase 7d-4) — EngineMetrics gains knock_integral_max / knock_fired / timing_retard_deg; cycle_summary aggregates knock_fired_rate / max_knock_integral_max / mean_timing_retard_deg; fleet_summary rolls them up across multi-cylinder runs.
  • ✅ TOML support (Phase B22, 2026-05-06) — Wiebe combustion + all six knock-controller fields are now TOML-exposed via [cylinder.combustion] and the flat knock_* keys. Bundled 52_knock_event.toml exercises the full chain.
  • ✅ Cooled EGR loop (44_egr_cooler.toml, Phase 7d-2) — ε-NTU shell-and-tube cooler with cold-side BC.
  • ✅ Predictive SI burn, multi-cycle + scaffold-wired (2026-08-15) — the C1 entrainment model now re-arms every cycle (it burned once per simulation before; reset_cycle had no callers), reports fuel-based metrics, and plugs into scaffold_single_cylinder_engine(si_combustion=...). First validation use: the Mendeley ethanol/water --predictive-burn arm, 43/60 with the water columns unfitted. Remaining physics: combustion-efficiency loss under dilution (partial burn / quench) — see BACKLOG.
  • Multi-mode combustion (HCCI, RCCI) — premixed auto-ignition that doesn't fit single Wiebe. Needs a multi-zone or 0-D detailed-chemistry combustion option. (Open; multi-week project.)
  • Tumble / swirl turbulence model (Phase 7d-tumble) — drives the burn-rate Wiebe m, a parameters from intake- port-induced turbulence intensity. Currently m, a are user-set per cycle. (Open; 1-2 weeks.)

7e — Co-simulation interfaces (medium value)

Open-source 1D engine simulation only earns its keep in the broader powertrain ecosystem if it can plug into the rest of the toolchain:

  • FMI 2.0 / 3.0 export (was Phase 40, deferred — promote here). Wrap an aeolus1d case as an FMU so other 1D simulators (GT-SUITE, Simulink, Modelica tooling) can co-simulate against it. pythonfmu is the obvious binding. Real value-add: expose the engine torque / fuel rate / emissions as outputs and accept throttle / load as inputs from a vehicle-dynamics or driver model.
  • Simulink coupling via FMI or directly via the Python bridge.
  • GT-Power / AVL BOOST / WAVE structural import (also deferred Phase 39). Best-effort via a neutral-schematic intermediate. Useful for users migrating cases from commercial tools.

Effort: ~2 weeks for FMI 2.0 round-trip; commercial-tool import is its own multi-week project per format.

Update 2026-07: Both halves have substantially landed. FMI 2.0 export is wired end-to-end — aeolus1d.fmi builds the FMU I/O contract (throttle_norm / vgt_open / wastegate_open inputs, engine outputs) and the aeolus1d export-fmu <case.toml> <out.fmu> CLI packages it via aeolus1d.io.interop.fmu (needs the optional pythonfmu dep to emit the archive). Best-effort structural importers now exist for every listed format under aeolus1d.io.interop: GT-Power SIF (gt_power) + native .gtm (gt_suite_native, diagnostic inspect-gtm), AVL BOOST (avl_boost), AVL Cruise M (avl_cruise_m, import- only), and Ricardo WAVE (wave), all routed through the shared import_case / export_case neutral-schematic layer.

7f — Hardware-in-the-loop / real-time execution (deferred)

❌ Not shipped in the Milestone 7 sweep — needs external real-time infrastructure aeolus1d alone can't supply. Concretely: a real-time OS (RT-Linux / VxWorks / QNX) on the consumer side, a deterministic ECU emulator, and an UDP / shared-memory bridge with bounded jitter. Without that the "real-time" qualifier is meaningless. Logged here so a future scoping pass doesn't re-derive the architecture, and so users with that stack already in place know we'll meet them halfway.

The HIL test-rig market for ECU calibration is a major commercial use case. Currently aeolus1d runs at 0.0025× real- time on the audio demo (6 hours per simulated minute), so HIL is impossible without major performance work. The path:

  1. Land Phase 27c (packed multi-pipe Numba kernel) — prerequisite. ~3-5× wall.
  2. Implement fixed-step integrator option with a chosen dt (skip CFL adaptation) for deterministic real-time pacing.
  3. Add an ECU-side I/O interface — typically UDP / shared memory with a real-time operating system on the other end.
  4. Validate on a representative engine case meeting the 1-2 ms HIL deadline budget.

Effort: ~6-10 weeks total. Real-time aeolus1d would change the product's market positioning entirely; only commit if there's explicit demand.

7g — Engine-side post-processing (medium value)

Already partially in the speculative bucket; promoted here because it's directly user-visible:

  • Cycle-resolved averages — IMEP, BSFC, η_thermal, NOx-specific (g/kWh), volumetric efficiency, mean effective pressure components (PMEP, IMEP, BMEP).
  • Mean p–V indicator diagrams — average over many cycles to extract the "real" loop shape free of cycle-to-cycle variation.
  • Knock margin / detonation index plots.
  • Emissions integrators — accumulate cumulative NOx / HC / CO / soot mass to standardised drive cycles (WLTC / RDE / FTP-75) once the right inlet/outlet probes are defined.

Effort: ~1 week for the basic cycle averages + GUI panel; drive-cycle support is another ~1 week per cycle definition.

Update 2026-07: Drive-cycle support landed. aeolus1d.drive_cycles.DriveCycle is a speed-vs-time framework with a from_csv loader for regulatory cycles (WLTP class 3 / NEDC / FTP-75 / JC08 — the official speed-time CSVs load in but are not bundled), driving the aeolus1d.driveline / aeolus1d.vehicle closed-loop driver; examples/85_vehicle_drive_cycle.toml and 86_electrified_drive_cycle.toml are the bundled demos.

7h — Physics-informed ML closures (research-grade)

The py_reaction / py_friction / py_porous / py_junction_bc seams already let users plug in arbitrary Python callables for closures. Recent literature shows growing adoption of physics-informed neural networks (PINNs) for combustion + reaction kinetics. What aeolus1d could ship:

  • PINN-based combustion closure preset — a wrapper on top of py_reaction_multi that loads a trained PINN (PyTorch / JAX / ONNX) and exposes its outputs as the rate function. The closure is already there; what's missing is a worked example + training script for a small-network surrogate of a published mechanism.
  • Surrogate closure for catalyst kinetics — same idea for py_reaction_surface.

Effort: ~2 weeks for the PINN preset + a training-script example. Bigger value when paired with the AD/JAX speculative item below — backprop through the solver enables joint PINN-plus-physics calibration.

7i — 1D / 3D CFD coupling (deferred)

❌ Not shipped in the Milestone 7 sweep — needs an external 3D solver to couple against. The 1D side of the protocol is straightforward (BC handoff, aggregated-quantity returns over ZMQ / shared memory) but a useful integration test requires a running ParaSCADE / CONVERGE / OpenFOAM instance on the same machine. Logged for the same reason as 7f: future-proof scoping; users already running 3D get a clear partner spec when they're ready.

Recent literature emphasises hybrid 1D-system + 3D-CFD simulations for the cases where a particular component (cylinder, manifold collector, EGR mixer) needs full 3D fidelity while the rest of the engine stays in 1D. The 1D side provides BCs to the 3D component; the 3D component returns aggregated quantities (ṁ, T0, p0) back to the 1D system. Implementations typically use ParaSCADE / CONVERGE on the 3D side coupled via ZMQ or direct memory.

Effort: ~6-10 weeks for a basic coupling protocol with one external 3D solver. Specialised, expensive — only commit if a specific case demands it.

7j — What the 2026-08-29 maturity sweep says is missing

A whole-tree sweep on 2026-08-29 ranked the gaps by what each one would change about a stranger's ability to trust a number this tool emits. Most of what it found is already listed above and is not restated; this subsection exists to record the ranking and to add the three items that were on no list at all.

Ranked, highest leverage first. The first is worth more than the rest combined, and the first two are days rather than weeks.

Both of the top two are now closed (2026-08-30), and neither took the work this ranking budgeted for it. #2 was built. #1 was found: half of it had been in the tree, CI-gated and passing, since before the sweep ran. The ranking was assembled from docs/validation-dossier.md's W1-W5 summary table, and the suite that satisfied it lives in prose below that table. The lesson generalises past this list, and is the third instance in this repository: a gap list built from a summary measures the summary.

# Gap Where it already lives
1 ~~Two obtainable experimental datasets — a measured muffler TL set, an open synthetic-gas-bench DOC/SCR set~~ — DONE, and the synthetic-gas-bench half already existed when this was ranked (2026-08-30). The muffler TL set landed 2026-08-29 (gt_acoustics_tl, though its data is proprietary); the bench set is heliyon_twc_co_oxidation, which had been CI-gated and passing all along and was missed because it sits below the dossier's W1-W5 table under a heading that called it code-to-literature. EC-Q12 is met EC-Q12 in docs/release-readiness.md
2 ~~Calibration provenance surfaced at runtime~~ — DONE 2026-08-30 calibration_provenance.py + the banner; EC-Q11 met
3 Emissions closures exist but the calibration loop is not closed (EPW NOx uncalibrated, BMEP ~30 % low on a pumping-loop residual) 7d, and the EPW arc in BACKLOG.md
4 Per-step orchestration cost — only 20 % of wall is in kernels on the 4-cylinder case 27c above; ADR-0015 phases 5–6
5 Coupled DOC + DPF + SCR train unvalidated — the locked wedge's headline claim W3, docs/validation-dossier.md
6 Vehicle-system layer: standard cycles, closed-loop driver, control toolbox, AMT depth BACKLOG.md's phased roadmap, v0.7
7 Electrification depth: ECM battery, inverter losses, e-motor maps, power-split BACKLOG.md's phased roadmap, v0.8
8 H2 / NH3 named Wiebe presets + the fuel-bound-nitrogen NOx pathway 7a above — the one third of it still open
9 Multi-rate / per-circuit solver assignment (each circuit its own solver + Δt) BACKLOG.md's phased roadmap, v0.3, still open
10 Tumble / swirl turbulence driving the Wiebe m, a 7d above

The three that were on no list. Two are product-surface, one is a document-ownership observation:

  • ~~Calibration provenance at runtime.~~ Closed 2026-08-30. src/aeolus1d/calibration_provenance.py holds the registry; solver/startup_banner.py prints the groups a run actually constructed. The guard mirrors load_pass_criteria: a declaration with a blank provenance is refused, the way an ungated pass criterion with no reason is. Nine groups across five closures at landing; thirteen across six since 2026-10-11 (the two SCR storage variants and the two TWC VML demos). One correction to this item's own text: it said calibrated constants "are labelled in docstrings throughout closures/", which is true and was the wrong place to look — the module it named for SCR (scr_storage.py) has no production call site, and the one a case reaches (scr_kinetics.py) was found only by tracing io/build.py. Docstring text is not reachability.
  • The reflection-correct transmission loss is API-only. transmission_loss_decomposed (Chung–Blaser two-microphone) exists and is correct where reflections are present, but aeolus1d acoustics --probe-out still reports the naïve anechoic-only amplitude ratio, because the decomposed form needs two probes per side and a spacing. A wiring job on a metric the tool already advertises. This also corrects a stale word in BACKLOG.md's strategic overview, which still calls it a "wave-decomposition bug"; the bug was fixed, the wiring was not.
  • Two documents both carry a feature-level roadmap. CLAUDE.md names this file the owner of "feature-level what's left", and BACKLOG.md's "GT-POWER parity roadmap" carries a phased v0.3–v0.8 plan that is feature-level too — the vehicle-system and electrification tracks in the table above live only there, which is why this file had no hit for "battery" or "multi-rate" before today. Recorded, not resolved: merging them is a real edit with a real risk of losing the priority order that section deliberately carries, and it should be picked up as its own item rather than as a side effect of a sweep.

3. Speculative — multi-week, defer until needed

Applications beyond the engine product (muffler design, emissions compliance, pipelines, HVAC, and others) are listed in the extension plan. An idea moves here when someone decides to build it.

These are real research directions, listed for completeness so nobody re-derives them. Don't pick one without scoping first.

  • AD through the @njit kernels. For design optimisation / closure calibration without finite-differencing. README's Phase-36 commentary already notes this is "its own project" — Numba + autograd is limited; a JAX-mirror solver is the natural path.
  • AMR for shock tracking + chemistry hot spots. Significant reorganisation of the Pipe class + timeloop.
  • 3D extension. Real engine pipework can be 3D (especially bends / collectors). A 3D-Euler core is its own project; a pragmatic stepping stone is 2-D axisymmetric for round geometries.
  • Adaptive cycle-resolved post-processing in the GUI. Average results over crank-angle cycles, render mean p–V loops, BSFC / IMEP / log-mean-effective values.