Skip to content

Closures

Physics that is not gas-dynamic transport (friction, wall heat transfer, equation of state, junction loss) lives behind a thin dispatch layer. Each closure is identified by an integer kind code plus a small float parameter array, so the @njit residual kernel can branch on the kind without calling back into Python.

For the implicit drivers (Implicit Euler / BDF2 / Radau IIA(3)), closure sources also contribute a closed-form Jacobian block to the Newton matrix — see Analytic Jacobians below. Phases B16 → B22 (2026-05-06) brought every kind shipped today onto the analytic path; the FD fallback only fires for Python-callable overrides.

Looking for a specific component?

The Component index maps every closure kind to its model, theory section, a runnable example, and its tutorial — all in one table.

Friction

s_mom = -τ_w · P_w / A

For a circular cross-section of hydraulic diameter D_h, P_w / A = 4 / D_h and Darcy-Weisbach gives τ_w = (1/8) ρ |u| u f with Darcy friction factor f. The resulting momentum source is

s_mom = -(f / (2 D_h)) · ρ · |u| · u
s_E   = 0                (adiabatic Fanno flow — see below)

The total-energy source is zero. The wall is stationary, so it does no work on the fluid and (adiabatically) carries away no heat; in the total-energy formulation the kinetic energy removed by s_mom is converted to internal energy automatically by conservation (adiabatic Fanno flow, stagnation enthalpy preserved). An earlier s_E = u · s_mom sink double-debited that energy, silently shedding the frictional heat and driving the gas below the Fanno prediction — that was corrected in the 2026-07 closures audit. Deliberate non-adiabatic wall heat loss belongs in the heat closure, not here.

kind code params analytic Jacobian
NONE 0 unused n/a
DARCY_CONST 1 (f, D_h) Phase B16
COLEBROOK 2 (eps_over_D, D_h, nu) Phase B16-ext
MINOR_LOSS 3 (K_loss, L_loss, …) Phase B16 (distributes K over L)

MINOR_LOSS models a discrete minor loss (bend, junction, sudden area change) as a distributed Darcy-equivalent friction spread over a length L_loss. Dropping ½ K ρ u² over L_loss gives the per-volume momentum sink s_mom = -(K_loss/(2·L_loss))·ρ·|u|·u — structurally identical to constant-f Darcy friction with the coefficient K_loss/(2·L_loss) in place of f/(2·D_h), so it reuses that momentum form and its analytic Jacobian. If K_loss <= 0 at build time, K_loss is derived from the geometry inputs bend_r_over_d / bend_angle_deg via the Idelchik bend correlation (aeolus1d.closures.bend_loss); those two geometry fields round-trip through the TOML/GUI but are not kernel params.

COLEBROOK uses the Haaland explicit approximation of the Colebrook-White equation in the turbulent regime (Re > ~4000) with a laminar f = 64/Re branch below Re ~ 2300 and a linear blend in the transition zone — accurate to ~1-2 % vs the implicit Colebrook-White.

Friction depends only on (ρ, u) — never on T or p — so the analytic Jacobian is EOS-agnostic (Phase B16-real-gas pinned this contract): the closed form fires unchanged on real-gas pipes (Peng- Robinson, Redlich-Kwong) at the same (ρ, u).

Wall heat transfer

q_w = h · (T_wall - T)
s_E = q_w · 4 / D_h            (per unit volume, circular pipe)

with T = p/(ρ·R_gas) recovered from the live cell state.

kind code params analytic Jacobian
NONE 0 unused n/a
CONSTANT_TWALL 1 (h, T_wall, D_h, R_gas) Phase B17 (ideal) / Phase B17-real-gas
EGR_COOLER 2 (T_coolant, UA, C_coolant) py_heat override → FD
CHARGE_AIR_COOLER 3 (T_coolant, UA, C_coolant) py_heat override → FD
FLOW_NU 4 (T_wall, D_h, k_gas, mu_gas, Pr, correlation, laminar_nu) py_heat overlay → FD

The EGR_COOLER kind is implemented as a Python callable (make_egr_cooler_py_heat) using the lumped counter-flow ε-NTU model across the whole pipe; it always routes through the FD Jacobian path. CHARGE_AIR_COOLER (make_charge_air_cooler_py_heat) is the same ε-NTU model under a sister name for the intake side.

FLOW_NU is a py_heat overlay (the integer kernel stays HEAT_NONE): instead of a hand-tuned h, it computes the wall coefficient per cell from the local flow via h = Nu · k_gas / D_h, Re = |ρ·u|·D_h/μ, s_E = h·(T_wall − T)·4/D_h. Below Re ≈ 2300 the flow is laminar (Nu = laminar_nu, default 3.66); above it the selected turbulent correlation applies, floored at the laminar value. The change at Re = 2300 is a step (for dittus_boelter at Pr = 0.7, Nu jumps from 3.66 to 9.75), not a blend. Supported correlations (correlation): dittus_boelter, colburn, sieder_tate, gnielinski. Because it needs the local velocity, it routes through the FD Jacobian path like the other py_heat overrides.

For the CONSTANT_TWALL analytic path on real-gas pipes (Phase B17-real-gas), the chain rule differentiates T_app = p_eos/(ρ·R) (the apparent T the heat kernel uses, NOT the EOS-converged temperature) via the EOS-derivative helpers from Phase B15.4.

Plenum wall heat (junctions)

Separate from the pipe closures above: a plenum junction can drain heat from its 0-D volume into surrounding metal. Junction.accumulate_rates subtracts a Newton-cooling term from dE/dt,

Q_wall = h · A · (T_gas − T_wall)

and the term is skipped entirely unless both wall_h_conv and wall_A are > 0. Only the product h·A is identifiable — the two never appear separately — so calibrations conventionally set wall_A = 1.0 and carry the whole lumped conductance in wall_h_conv (which then reads in W/K, not W/m²K).

Unlike the pipe-side FLOW_NU overlay, a junction has no local Reynolds number to work from, so h cannot be computed from the flow. Instead the two coefficients may be declared as weakly-linear functions of the engine operating point:

h·A|eff    = wall_h_conv · (1 + wall_h_conv_speed_slope · (S_p/S_p_ref − 1))
T_wall|eff = wall_T      +  wall_T_load_slope · (φ/φ_ref − 1)
field units default meaning
wall_h_conv W/(m²·K) 0.0 convective coefficient at the reference point; 0 disables the closure
wall_A m² 0.0 internal surface area; 0 disables the closure
wall_T K 400.0 metal temperature at the reference point
wall_h_conv_speed_ref m/s 0.0 mean piston speed S_p_ref; 0 disables the speed law
wall_h_conv_speed_slope — 0.0 local Nusselt exponent; 0.8 reproduces Dittus-Boelter to first order
wall_T_load_ref kg 0.0 fuel per cycle per cylinder φ_ref; 0 disables the load law
wall_T_load_slope K 0.0 kelvin per unit relative load

Both laws are identities at the reference point, so wall_h_conv and wall_T keep their plain meaning. A power law x^a linearised about x = 1 is 1 + a(x−1), which is why the speed slope is the local Nusselt exponent rather than a free fitting constant.

The drivers are read from the case's cylinder specs (stroke, omega_crank, and m_fuel_per_cycle or combustion.m_fuel), averaged over cylinders, and the laws are evaluated once at build time — the hot loop is untouched. Two consequences worth knowing:

  • This is a steady-operating-point closure. On a transient the drivers are frozen at their declared values; the laws do not track a changing speed or fuelling within a run.
  • A nonzero _ref with no cylinder to read (or zero fuelling) is a build error, not a silent fallback. A linear law extrapolated far enough also goes negative, so a non-positive effective h·A or T_wall is likewise a build error rather than a clamp.

These coefficients are calibrated, not derived

h·A and T_wall are not jointly identifiable from an exhaust temperature. Matching a measured T_EXH pins the total heat removed, Q = ṁ·c_p·(T_ad − T_meas), leaving h·A·(T_gas − T_wall) = const — one equation in two unknowns, where every hotter wall simply buys a larger h·A. An optimiser handed both will run to whatever bound it is given, and the fit residual will not object. Pin one of them on physical grounds (a Nusselt exponent, a defensible metal temperature) and fit the other. The AVL EPW diesel case documents a worked example of this trap in examples/validation/avl_epw_diesel_steady/base_diesel.toml.

Porous media (Darcy-Forchheimer)

A porous region (catalyst brick, DPF, muffler, flow straightener) opposes the gas flow with a per-volume momentum sink built from two empirical coefficients — viscous (Darcy) and inertial (Forchheimer). aeolus1d uses the AVL Cruise M / GT-Power tabulated form,

|dP/dx| = α · ½ · ρ · u²  +  β · μ(T) · u

so the volumetric source (sign-aware via |u| · u) is

s_mom = -(α · ½ · ρ · |u|  +  β · μ(T)) · u
s_E   = 0                (adiabatic: the pressure-drop work is
                          dissipated to internal energy automatically
                          by total-energy conservation — stagnation
                          enthalpy is conserved across an adiabatic
                          restriction, like J-T throttling)

α (1/m) is the Forchheimer coefficient — the kernel multiplies it by the live ρ at each cell. β (1/m²) is the Darcy coefficient — the kernel multiplies it by μ(T). Both are pure geometry once the operating-temperature viscosity is fixed; they're typically read off the porous-media catalogue of the upstream commercial tool.

kind code params (7 floats)
NONE 0 unused
DARCY_FORCHHEIMER 1 (α, β, μ_ref, viscosity_model, T_ref, S, R_gas)
DARCY_FORCHHEIMER_KC 2 (K, C_F, μ_ref, viscosity_model, T_ref, S, R_gas)

DARCY_FORCHHEIMER_KC is an alternate parameterisation matching the permeability-and-Forchheimer-coefficient convention used by some porous-media catalogues; it is converged-state-equivalent to DARCY_FORCHHEIMER with β = 1/K and α = 2·C_F/√K.

viscosity_model is 0 for constant μ (= μ_ref everywhere) or 1 for Sutherland (μ scales with cell T).

Analytic Jacobian coverage

(kind, viscosity_model) analytic path
DARCY_FORCHHEIMER / DARCY_FORCHHEIMER_KC + constant μ Phase B18 (ideal + real gas; same closed form)
DARCY_FORCHHEIMER / DARCY_FORCHHEIMER_KC + Sutherland μ + ideal-gas EOS Phase B18-ext (μ(T) chain via ideal-gas T)
DARCY_FORCHHEIMER / DARCY_FORCHHEIMER_KC + Sutherland μ + real-gas EOS Phase B18-ext-real-gas (EOS-derivative chain)

Constant-μ porous reads only (ρ, u) from the cell state — like friction, the closed form is EOS-agnostic. Sutherland-μ porous adds the μ(T) chain rule; on ideal-gas pipes this routes through the ideal-gas pressure recovery T = (γ−1)/R · (E_total − u²/2), while real-gas pipes route through the EOS chain (matching the same T_app = p_eos/(ρ·R) convention used by Phase B17-real-gas heat).

Mapping from older conventions

aeolus1d's pre-α/β storage is no longer accepted, but the migration formulas are simple and the parser raises a clean error pointing at them:

Pre-2026-04-29 v2 New
K (m²) β = 1 / K
inertial_length (m) α = 2 / inertial_length
Pre-2026-04-29 v1 New
mu_over_K (Pa·s/m²) β = mu_over_K / μ_ref
C_F (1/m) α = 2 · C_F

Sutherland viscosity

Setting viscosity_model = "sutherland" activates a per-cell viscosity correction that follows the textbook Sutherland law

μ(T) = μ_ref · (T / T_ref)^(3/2) · (T_ref + S) / (T + S)

with T = p / (ρ · R_gas) recovered from the live cell state. μ_ref is then μ at the reference temperature T_ref, not the operating-T value. For air the textbook constants T_ref = 273.15 K, S = 110.4 K reproduce experimental viscosity to ~1 % from 200 K to 1500 K — useful for hot catalysts where μ rises ~T0.7 across the brick.

Sutherland constants for common gases:

gas T_ref (K) S (K) μ_ref (Pa·s, T_ref)
air 273.15 110.4 1.716e-5
nitrogen 273.15 111.0 1.663e-5
oxygen 273.15 127.0 1.919e-5
water vapour 350.0 659.0 1.16e-5

When viscosity_model = "constant" (the default) the four Sutherland-related slots are ignored and the Darcy term reads β · μ_ref everywhere — fastest, fine when the brick runs near the reference temperature.

Gas EOS

aeolus1d ships four EOS implementations under the same dispatch contract (ideal + two cubics + a tabulated lookup):

kind code params source
EOS_IDEAL 0 (R_gas, _, _, _, cv) textbook ideal gas (default)
EOS_REDLICH_KWONG 1 (R_gas, Tc, Pc, _, cv) RK two-parameter cubic
EOS_PENG_ROBINSON 2 (R_gas, Tc, Pc, ω, cv) PR three-parameter cubic
EOS_TABULATED 3 11-float header + 5 flat (n_T × n_rho) panels bilinear interpolation on (T, ρ)

The tabulated path is the ML-substitution / NIST-quality EOS pattern: generate a precomputed (T, ρ) table and load it; everything else in the residual stays unchanged. The pack (closures/eos_tabulated.py) reuses the first five cubic-EOS slots ([R_gas, Tc=0, Pc=0, omega=0, cv_ref]) so generic callers keep working, then adds a header describing the grid (n_T, n_rho, T_min, T_max, rho_min, rho_max, slots 5–10) and five row-major (n_T × n_rho) panels in order: p, e, ∂p/∂T, ∂p/∂ρ, cv. The cv panel holds the local EOS-corrected cv = ∂e/∂T|_ρ (not the scalar cv_ref header slot), because the thermodynamic sound speed needs the true local cv — a single scalar biases a by several percent at dense / near-critical states (the tabulated analog of audit finding #12). cv_ref is retained only as the Newton initial-guess scale for the T(ρ, e) inversion. The default 64×64 grid packs to 11 + 5·4096 = 20 491 float64 (~160 kB per pipe). Panels are built at network-construction time from CoolProp via make_table_from_coolprop (optional [real-gas] extra) or from user callables via make_table_from_callables.

Real-gas sound speed (audit finding #12)

The thermodynamic sound speed for a real gas with frozen composition is

\[ a^2 = \left.\frac{\partial p}{\partial \rho}\right|_T + \frac{T}{\rho^2\, c_v^{\text{real}}} \left(\left.\frac{\partial p}{\partial T}\right|_\rho\right)^2 \]

where the specific heat is the EOS-corrected cv_real = ∂e/∂T|_ρ = cv_ideal + departure, not the ideal-gas cv_ideal header value. The departure is small at moderate pressure but comparable to cv_ideal at dense / near-critical states, so using cv_ideal biased the correction term (and hence a) by several percent — corrected in the 2026-07 closures/EOS audit (#12). For the cubics cv_real is a closed form (RK: + 0.75·a/(b·T^1.5)·ln(1+b·ρ); PR: an analogous second-α-derivative term); for the tabulated EOS it is read from the dedicated cv panel. The pure-Python eos_sound_speed and the @njit eos_sound_speed_jit mirror each other bit-for-bit.

Real-gas analytic Jacobians (Phase B15.4 + B20)

The implicit Newton solver chains pressure and sound-speed gradients through the EOS using closed-form helpers:

  • eos_dpdT_dpdrho_jit(T, ρ) — first partials (∂p/∂T|ρ, ∂p/∂ρ|T). Closed form for ideal / RK / PR; for tabulated it reads the precomputed ∂p/∂T / ∂p/∂ρ panels directly (bilinear lookup — the Phase Perf-1 direct-panel path replaced the earlier 4-call central-FD fallback).
  • eos_dedT_dedrho_jit(T, ρ) — internal-energy partials. Same pattern; the tabulated branch differentiates the bilinear interpolant of the energy panel (Phase Perf-1) rather than finite-differencing.
  • eos_dadT_dadrho_jit(T, ρ) — sound-speed partials. Phase B20 added closed form for RK and PR (the messy second-derivative algebra of the cubic α(T) factor); Phase B23 added closed form for tabulated EOS by differentiating the bilinear interpolant of the precomputed ∂p/∂T and ∂p/∂ρ panels.

These get plumbed through the HLLC analytic Jacobian (hllc_jac._eos_primitive_gradients) so the Newton matrix is closed-form on every face for ideal, RK, and PR runs.

Analytic Jacobians

The implicit drivers (Implicit Euler / BDF2 / Radau IIA(3)) augment the Newton matrix with a closed-form ∂S_closure/∂U block per cell for every closure family that has analytic coverage. The fallback is a 6-point central-FD perturbation of _cell_closure_source (roughly 7-8× slower per cell — see the Phase B19 bench).

Phase Coverage
Phase B16 DARCY_CONST friction (closed form in (ρ, u))
Phase B16-ext COLEBROOK friction (Haaland chain through Re-blend)
Phase B16-real-gas Friction is EOS-agnostic; same closed form on real-gas pipes
Phase B17 HEAT_CONSTANT_TWALL on ideal-gas pipes
Phase B17-real-gas HEAT_CONSTANT_TWALL on real-gas pipes via EOS-derivative chain
Phase B18 DARCY_FORCHHEIMER / KC + constant μ (both ideal + real gas)
Phase B18-ext DARCY_FORCHHEIMER / KC + Sutherland μ on ideal-gas pipes
Phase B18-ext-real-gas DARCY_FORCHHEIMER / KC + Sutherland μ on real-gas pipes
Phase B19 Consolidated analytic-vs-FD bench (@pytest.mark.slow); 7 scenarios
Phase B20 Closed-form ∂a/∂T, ∂a/∂ρ for cubic EOS (RK + PR)
Phase B21 Shared T_app chain across real-gas heat + Sutherland porous (saves one Newton solve / cell)
Phase B22 Wiebe combustion + closed-loop knock-controller TOML support; new 52_knock_event.toml
Phase B23 Closed-form ∂a/∂T, ∂a/∂ρ for tabulated EOS via differentiated bilinear interpolant
Phase Perf-1 EOS / solver hot-path optimization sweep — wires B23 panels into the Jacobian directly (replaces 4-call FD on eos_dpdT_dpdrho_jit); closed-form ∂e/∂T, ∂e/∂ρ from the bilinear interpolant of the energy panel; batched _bilinear_two / _bilinear_grad_two helpers share the (T, ρ) cell-index pass; fused eos_a_and_derivs_jit returns (a, ∂p, ∂a) in one dispatch; fused tabulated (p, a) recovery in primitives_from_U_eos_jit; block-Thomas _inv3x3_into writes through a hoisted scratch buffer (zero per-cell allocs); Picard-loop snapshot bookkeeping skipped on picard_max_iter == 1; Tikhonov regularisation on the chemistry-BS 2×2 Cramer solve.

The Solver Internals dock in the GUI (under View → Solver Internals) shows a per-pipe table with a ✓ marker on any closure cell handled by the analytic path and fd on cells that fall through. Hover any cell for the phase-by-phase tooltip.

Aftertreatment closures

Beyond the per-cell physics closures above, several aftertreatment brick models ship as py_reaction_multi-shaped closures that the TOML schema attaches via the species.reaction selector on a pipe. These run on the chemistry sub-step (Strang-split) rather than the advection Newton step, so they are independent of the analytic-vs-FD Jacobian routing.

species.reaction Module What it models
arrhenius_scr_demo closures/reaction_demo.py Single-Arrhenius standard-SCR demo
arrhenius_scr_multi_demo closures/reaction_demo.py NH₃ + NO consumed in M_NO/M_NH3 ratio
nh3_ads_des closures/scr_kinetics.py Cu-zeolite NH₃ Langmuir adsorption / desorption
nh3_scr_kinetics closures/scr_kinetics.py Above + NH₃ oxidation + standard-SCR with NO
nh3_slip_catalyst closures/scr_kinetics.py Same mechanism with V₂O₅-WO₃/TiO₂ defaults
hc_oxidation closures/hc_oxidation.py First-order Arrhenius HC oxidation on Pt-coated DOC
doc_oxidation closures/hc_oxidation.py Two-branch HC + CO oxidation on Pt/Pd DOC (CO lights off ~150 °C, HC ~250 °C)
doc_oxidation_S closures/doc_sulfur.py DOC oxidation + sulfate-poisoning deactivation factor f_S = 1 − α_S · θ_S. Pair with [[controller]] kind = "doc_sulfur" to integrate θ_S
doc_oxidation_voltz closures/hc_oxidation.py DOC oxidation with the Voltz/Pattas surface-inhibition denominator (the AVL Cruise-M DOC form): same HC (primary) + CO (extras[0]) Arrhenius numerators as doc_oxidation, divided by a shared coverage-inhibition term G(T, c) built from three adsorption groups, giving a steep light-off knee (high apparent activation energy) plain doc_oxidation cannot
hnco_hydrolysis closures/hnco_hydrolysis.py Urea-water HNCO → NH₃ hydrolysis on a urea-injector brick
twc closures/twc.py Three-Way Catalyst, Voltz-Morgan-Liederman three-pathway (CO + ½O₂, C₃H₆ + 4½O₂, NO + CO) with Langmuir-Hinshelwood CO-inhibition
twc_brinkmeier closures/twc_brinkmeier.py Full Brinkmeier (Stuttgart 2006) 11-reaction TWC + cerium O₂ storage, plus two opt-in appended channels (both default-off → inert → bit-identical with the 11-reaction baseline): (1) passive-SCR NH₃ generation (A_nh3_form_brnk > 0; NO + 2.5 H₂ → NH₃ + H₂O), whose super-linear H₂ dependence is the dimensionless richness gate (c_H2/c_H2_ref)^(order-1) (c_H2_ref = 1 mol/m³), so A_nh3_form_brnk is a proper bimolecular rate constant (m³/(mol·s)) like the other Brinkmeier constants; selectivity validated against the ORNL CLEERS λ-trend; and (2) C₃H₆ (propene) total oxidation (A_c3h6_ox_brnk > 0; C₃H₆ + 4.5 O₂ → 3 CO₂ + 3 H₂O, Voltz-Morgan-Liederman kinetics, Ea = 110 kJ/mol) — the legacy mechanism consumed propylene only via NO reduction, so it was near-inert on lean / low-NO feeds; the channel's light-off T50 is calibrated for the Brinkmeier inhibition denominator (A_c3h6_ox_brnk = 1e13 → T50 ≈ 240 °C, validated in examples/validation/doc_propylene_lightoff/). Neither channel is modelled on the bs_brinkmeier chem solver (build refuses that combination).
mechanism closures/mechanism.py Generic spec-driven mass-action mechanism: mechanism_file (TOML/YAML) declares species + [[reaction]] blocks (+ optional molar_mass), compiled to a py_reaction_multi callable by make_mechanism. The mechanism's species[0] must be the block's primary name and species[1:] its extras (by name)

DPF (diesel particulate filter) physics is wired through pipe.filter (a FilterState sub-spec on the pipe), not the species-reaction selector, because it carries per-cell soot-loading + cake-thermal state outside the gas species: the trap kernel (closures/filter.py), the cake-Δp pressure-drop overlay, the active-O₂ regen rate, and the Phase 8g cake solid-phase thermal mass T_cake field all live there. A [[controller]] kind = "dpf_heater" block can drive pipe.filter.heater_power_w in closed loop on mean cake T (DPFHeaterController).

By default the cake-Darcy Δp (μ_cake·soot·resistance) only enters the explicit / implicit drivers' source overlay, not the quasi-steady pressure balance. Setting qs_drag = true on the cake sub-spec folds it into the QS driver's junction/flow solve so a DPF running under solver = "quasi_steady" reproduces an AVL-comparable back-pressure (e.g. the C02006 drive cycle); default false keeps the pre-feature QS behaviour.

Closed-loop controllers

aeolus1d.control ships a family of velocity-form PI controllers as on_step= callables; seven of them are wireable via [[controller]] kind = … blocks in TOML:

Kind Class Actuates Reads
boost_pressure BoostController Turbine.vane_open (VGT vane) mean p in a manifold pipe
wastegate WastegateController Turbine.bypass_open (parallel-bypass fraction) mean p in a manifold pipe
dpf_heater DPFHeaterController pipe.filter.heater_power_w mean DPF cake T
doc_sulfur DOCSulfurController the bound DOCSulfurOxidation closure's theta_S mean DOC gas T
egr_valve EGRValveController EGRValve.open on the egr_valve-kind BC mean species fraction (default O₂) at a probe pipe
fuel_injector FuelInjectorController Cylinder.m_fuel_per_cycle (per-injector ratios preserved) mean species fraction (λ feedback, default O₂) at a probe pipe
spark_timing SparkTimingController WiebeCombustion.theta_start_deg (retards on knock) Cylinder.last_knock_integral (Livengood-Wu)

WastegateController is sign-inverted relative to BoostController: when boost overshoots target, the wastegate opens to bleed exhaust around the turbine; when underboost, it closes. The two coexist on the same Turbine (a VGT-with-wastegate stage uses both controllers on the same turbine instance, actuating two independent surfaces).

DOCSulfurController integrates a lumped sulfate balance against the bound DOC pipe's mean gas T:

\[ \frac{\mathrm{d}\theta_S}{\mathrm{d}t} = A_\text{store}\,\exp\!\left(-\frac{E_\text{store}}{R\,T}\right)\, Y_{\text{SO}_2}^\text{in}\,(1 - \theta_S) - A_\text{reduce}\,\exp\!\left(-\frac{E_\text{reduce}}{R\,T}\right)\,\theta_S \]

Storage Ea defaults are low (proceeds at exhaust T); reduction Ea defaults are high (meaningful only above ~870 K) — this matches the literature DOC desulfation window. The closure's deactivation factor f_S = 1 − α_S · θ_S is shared with the HC and CO oxidation rate constants so the brick gradually loses activity as sulfate accumulates.

Compressor surge / choke diagnostics

Compressor.last_regime exposes the operating-point regime as one of "normal" / "idle" / "flow_through" / "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 sibling Turbine.last_regime uses "backflow" for its own PR < 1 branch.)

Condition Regime
omega <= 0 "idle"
PR < 1.0 "flow_through" (un-boosted / windmilling — PR clamped to the choke-most edge, flow passed through with zero compression work)
PR > pr_grid[-1] "surge" (operator demands more PR than the map covers)
PR < pr_grid[0] "choke" (PR clipped to right edge of speed line)
else "normal"

Only the idle branch wall-closes both pipe ends. The PR < 1 (under-boost) case is not wall-closed: an earlier version blocked all airflow through the compressor whenever boost had not yet built, starving the engine; the compressor now clamps the map lookup to the choke-most PR and passes the flow through at T0_out = T0_in (audit 2026-06-10 A1.10), reporting "flow_through". The margin fields are pure diagnostics that controllers and post-processors read to detect when an operating point has crossed an envelope edge.

ML substitution pattern

A learned closure replaces an @njit kernel with a NumPy-mode Python callable that has the identical signature. The solver detects the substitution via a per-pipe flag (pipe.py_friction / pipe.py_heat / pipe.py_porous) and routes the affected pipe through a Python residual wrapper — interior cells are still evaluated in bulk so the NumPy path is not ruinously slow.

In live mode of the Solver Internals dock (the "Refresh with built network" button), the dock scans pipe.py_* after build_network runs and shows which cells have been overridden. Python-callable closures always route through the FD path on the implicit Jacobian — that's the design intent (the closure can be anything; the solver shouldn't assume differentiability).