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.7fand7iremain 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_steadyis a low-Mach SIMPLE solver, a different physical model and the W3 anchor. Critique item 6 decomposes the GUI'sMainWindowgod 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 inBACKLOG_HISTORY.md), and the runnable count is 213 —tests/unit/test_bundled_case_count.pyowns that number. Step 2:MainWindow._make_actions, 241 lines, became a 29-rowActionSpectable inaeolus1d_gui/shell/actions.py, with all 29 actions verified identical against a live window. Step 3: the fourteen docks became aDockSpectable inaeolus1d_gui/shell/docks.pyandMainWindow.__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 inaeolus1d_gui/shell/window_state.pytaking the window, which retires the unbound-method borrowingtest_panels_menu.pyhad 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.MainWindowis 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-methodMainWindowis still one. (2026-09-29: EC-C6 marked met by decision — the criterion is the five steps; the class overhaul is deferred toBACKLOG.mduntil 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:2193and: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) andQSTransientConfig(23) are why the QS driver takes 13 parameters and has zero unique to it, againstadvance_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.
- 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, andon_step/clock/live_control. Nodt, no state array: these functions are whole time loops and each derives its owndt.StepInputsnames the wrong altitude, so the class isNetworkRunSpec. - There is no output object. All four drivers are annotated
-> floatand return the simulation time they reached, soStepOutputshas nothing to hold and is not written. - No driver takes
**kwargs.astreportskwarg is Nonefor all four. The**extracatch-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 intests/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 offill_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_ratehas one definition and every leaf driver routes through it —tests/unit/test_driver_operator_parity.pyhas pinned that since before EC-C2 — but it is defined atnetwork_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.
- 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_strangcarried the dispatch twice, once per branch ofchem_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 bytest_neither_driver_calls_the_kernels_directly_any_more. - 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.pythen requires to be there. The raw census read 1 after this hoist, and the 1 waschemistry_step_on_pipes(...), five lines wide in both drivers. Hoists 1–3 escaped only by formatting luck —poll_live_controlfits on two lines,network_pipeson one. A metric that counts the residue of a successful hoist rewards hoisting less, so_resolves_to_shared_opnow 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. - The implicit driver's alias had to be resolved, not listed. It binds
the dispatch as
_chem_steps_fnby 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_aliasesreads both binding mechanisms out of the AST rather than from a hand-kept list. _COVERAGE_VIOLATION_TOLis defined three times —chemistry_ops.py,quasi_steady_network.py:2222,chem_numbalsoda.py:201— same value, same reasoning, no import between them. UnderMIN_BLOCK_LINES, so the census has never seen it. Filed inBACKLOG.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:
- 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.getsourceon 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 twochem_substepsloops) started failing — correctly, because those patterns are genuinely no longer inadvance_network_strang's own source. Fixed by pointingDRIVERSat_advance_network_strang_impl, which is where that source still lives, unchanged. - The step-contract census assumed one call site per driver name.
io/case.pynow callsadvance_networktwice (strang=Trueand plain), which the old_driver_calls()treated as an error ("advance_networkis called twice")._driver_calls()now returns a list of call sites per name instead of asserting uniqueness, and a newtest_advance_network_has_exactly_the_two_expected_call_sitespins the new shape (onestrang=Truesite, one without) so a third site, or a regression back to a directadvance_network_strangcall, is caught. - The parameter census's
DRIVERSset and expected numbers were measuring four callables that are now three. Re-measured 2026-09-04:advance_network22 → 30 params (7 unique, up from 4, because it absorbedadvance_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
ThreadPoolExecutorover 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
prangeover interior cells. ⚠ NOT VIABLE FOR TYPICAL AEOLUS WORKLOADS. Numba'sprangebreak-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
prangemakes 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 fromsolver/network_timeloop.py:1444, on by default. What is open is narrower and had not been written down: theprangevariant is selected only atP >= 12fast pipes, and measured over the 81 buildable top-levelexamples/*.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_packablerequiresfirst_orderandNg == 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 thatprange-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_TWALLideal + real-gas), porous (Darcy- Forchheimer constant + Sutherland μ on both ideal + real-gas pipes), plus closed-form∂a/∂Ufor cubic EOS (RK / PR) and a sharedT_appchain 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_degand 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_openaxis on top ofPR_inv/N_corr).27_compressor.toml,51_vgt_boost_ctrl.toml,54_adaptive_picard_turbo.toml,55_vgt_3d_turbo.tomlexercise the chain. Phase VGT-ctrl.2 shipped aBoostControllerthat drives mean intake pressure to a target by modulating vane position. Learned VGT maps (Phase Learned-Turbo) ship aLearnedTurboMapMLP surrogate alongside the analytic and CSV / NPZ loaders. - ✅ Wastegate — Phase 7c-wastegate shipped a velocity-form PI
WastegateControllerthat drivesTurbine.bypass_opento a target manifold pressure. Sign-inverted vsBoostController(more bypass → less boost). Reuses the existingbypass_openactuator on Turbine that scales the extracted shaft power and blendsT0_outtowardT0_in.[[controller]] kind = "wastegate"exposes the controller in TOML;59_wastegate_boost_ctrl.toml+plot_59_wastegate.pyare 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.DOCSulfurOxidationis a drop-in replacement for the basemake_doc_oxidationclosure with a multiplicative deactivation factorf_S = 1 − alpha_S · theta_Son both the HC and CO branches;DOCSulfurControllerintegrates the lumpedtheta_Sbalance against mean DOC gas temperature using Arrhenius storage (low Ea — proceeds at exhaust T) and reduction (high Ea — meaningful only above ~870 K). Pairspecies.reaction = "doc_oxidation_S"with[[controller]] kind = "doc_sulfur"in TOML. Bundledexamples/61_doc_sulfur_poisoning.toml+plot_61_doc_sulfur.pyexercise the storage + active- regen cycle. - ✅ Compressor surge / choke detection — Phase 7c-surge
added a
Compressor.last_regimeflag (one of"normal"/"idle"/"backflow"/"surge"/"choke") plus normalisedlast_surge_margin/last_choke_marginfractional 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_firedflag 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_eventper fired cycle, capped atknock_retard_max_deg, recovers atknock_advance_deg_per_cycleper knock-free cycle.timing_retard_degaggregated per cycle. - ✅ CycleAccumulator overlay (Phase 7d-4) —
EngineMetricsgainsknock_integral_max/knock_fired/timing_retard_deg;cycle_summaryaggregatesknock_fired_rate/max_knock_integral_max/mean_timing_retard_deg;fleet_summaryrolls 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 flatknock_*keys. Bundled52_knock_event.tomlexercises 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_cyclehad no callers), reports fuel-based metrics, and plugs intoscaffold_single_cylinder_engine(si_combustion=...). First validation use: the Mendeley ethanol/water--predictive-burnarm, 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, aparameters from intake- port-induced turbulence intensity. Currentlym, aare 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.
pythonfmuis 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:
- Land Phase 27c (packed multi-pipe Numba kernel) — prerequisite. ~3-5× wall.
- Implement fixed-step integrator option with a chosen dt (skip CFL adaptation) for deterministic real-time pacing.
- Add an ECU-side I/O interface — typically UDP / shared memory with a real-time operating system on the other end.
- 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_multithat 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.pyholds the registry;solver/startup_banner.pyprints the groups a run actually constructed. The guard mirrorsload_pass_criteria: a declaration with a blank provenance is refused, the way an ungated pass criterion with noreasonis. 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 throughoutclosures/", 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 tracingio/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, butaeolus1d acoustics --probe-outstill 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 inBACKLOG.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.mdnames this file the owner of "feature-level what's left", andBACKLOG.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
Pipeclass + 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.