ADR-0013 — Cross a component boundary in conserved form, not primitives-plus-a-γ¶
Status: Accepted
Context¶
A Cylinder carries two γ's. It has to.
- The advection γ (
[gas] gamma) keeps the connected runner's HLLC Riemann problem well-posed and sets the isentropic-orifice relations. It is a process parameter. gamma_effis what the volume's own(E ↔ T, E → p)mapping runs on — a burnt-fraction blend, and undercaloric_thermonot a single γ at all, sincee = ∫c_v dT ≠ c_v(T)·Tand no γ reproduces botheandh.
Between 2026-08-04 and 2026-08-05, five defects were found in the seam between them. Every one had the same signature:
The conserved quantities balanced exactly. The same
mdot/edotwas credited to one side and debited from the other; the sameUwas written and read. Mass and energy closed to machine precision the entire time.
| defect | error | what it did |
|---|---|---|
| outflow enthalpy from the advection γ | 26 % of heat release | residual 3.4× too hot and too light |
x_burnt = burn progress |
p, T +29.6 % at fixed (m,E,V) |
every cycle, at exhaust TDC |
| valve mass flux from the neighbour's γ | ṁ +3.5 % choked | |
| runner cell decoded with the cylinder's γ | p +20–35 % | set flow direction, ṁ, and inflow enthalpy |
two-zone m_b from progress |
T_burnt +537 K |
×18.6 on Zeldovich NO |
No golden snapshot, energy balance, StageRateAccumulator, or ADR-0009 species
closure detected any of them. That is not an oversight in those checks.
Decision¶
1. Conservation constrains totals. It says nothing about attribution.¶
A transfer can move the wrong amount and still balance, because the same wrong number appears on both sides. Every safety net in this repo is a totals net, so this entire class is invisible to all of them, and tightening one will never help — it is the wrong instrument.
The dual constraint is: for every transfer, the co-transported quantity divided
by the mass rate must equal the owner's own specific value. That is what
AEOLUS_PHYSICS_ASSERTS checks (solver/physics_asserts.py), together with the
statement that a derived quantity may not move while its arguments stand
still.
2. γ-as-process-parameter is not γ-as-state-conversion.¶
Both are legitimate and they are not interchangeable.
- Process: HLLC wave structure, orifice choking, an isentropic compressor
ramp. These need a nominal constant γ and always will. Read
Volume.gamma_advection, whose name makes ac_pbuilt from it read as obviously wrong. - State conversion:
E ↔ T,T → h,e → p. These belong to whoever owns the state. ReadVolume.h/.e/.cp/.cv/.R_eff/.gamma_eff.
valve_flow needs both at once, which is why it takes per-side gamma_a/
gamma_b, R_a/R_b and h0_a/h0_b.
3. Cross a component boundary in conserved / specific-extensive form.¶
Never primitives-plus-a-coefficient. The corrected expression is
h = (E + pV)/m
which names no thermodynamic coefficient at all — no γ, no c_p, no R —
and is therefore exact under gamma_eff, under the caloric inverse, and under
a real-gas EOS simultaneously. Any c_p·T form is exact only while the γ it
was built from happens to equal the owner's.
This is not a new idea in this codebase; it is what the kernels already do:
hllc.py writes F[2] = u*(U[2] + p), hllc_jac.py writes H = (U[2]+p)/rho,
quasi_steady_network.py writes h = (E + p_gas)/rho. All γ-free,
conserved-variable form. The lapse was confined to the components layer,
where a 0-D volume hands state to a 1-D pipe in primitives and the receiver
rebuilds thermo from a γ.
4. Anything that is a ratio or product of two owner-derived numbers must come from the owner.¶
The failure signature is a consumer holding two of an owner's numbers and
combining them algebraically. junction.py computed
gamma_eff·R_eff/(gamma_eff−1) and was arithmetically correct — but by luck
of pairing the matched γ and R from one owner, not by construction. It is now
volume.cp.
Enforced by tests/unit/test_no_specific_heat_from_a_volumes_advection_gamma.py,
an allow-listed AST guard: reading a Volume's .gamma requires an entry
justifying it as the process parameter. The justification sentence is the point;
the lint only forces it to be written.
Consequences¶
Volumegainse,cp,cv,gamma_advectionbeside the existingh,p,T,rho,R_eff,gamma_eff.cv := cp − R_eff, socp/cvequalsgamma_effidentically in every mode rather than approximately — pinned bytest_volume_thermo_surface.py, which also sweeps the two-γ state space with hypothesis.- Bit-identical wherever one gas is shared across a boundary, which is every single-γ network. All 12 golden regression cases were unchanged under both CI invocations across !396, !397 and this change.
- A residual, honest gap: with two γ, an energy-conserving transfer is not
a temperature-preserving one.
edotarrives at the runner asρEand is read back asTthrough the pipe's γ, so gas changes temperature crossing the valve. That is a property of the two-γ formulation, not of its implementation. It is named here rather than papered over. - Under a real-gas EOS,
Volume.cpreturns the ideal-gas value implied bygamma_eff; the EOS's ownc_pis not exposed. Boundary crossings should useh, which is exact.
Alternatives considered¶
A GasState object threaded through every component boundary. Rejected, and
worth recording why, because it is the intuitive proposal.
- It would not have prevented any of the five defects. A consumer holding
gs.gamma,gs.R,gs.Tcan still writegs.gamma*gs.R/(gs.gamma-1)*gs.T. The bug was never missing access —helmholtz_resonator.pyhad every field and computed the same enthalpy two different ways in the two branches of oneif. The bug is thatcp·Tlooks reasonable at the call site. A richer object makes it look more reasonable. - Cost. ~385 ns for a
NamedTuple, ~1135 ns for a frozen dataclass, against ~35 ns for a bare attribute, on a path evaluated per-port per-RK stage. (Relative figures; per ADR-0006 this host cannot measure absolutes.)_fill_plenum_ghosthas already absorbed a documented +9 % regression from a single slice assignment. - The zero-allocation form already exists and is better: owner-derived
named scalars, hoisted out of the loop —
h_cyl = self.volume.hbefore the port loop. That is the exchange type.
Unifying the two γ (making the pipe carry gamma_eff). This is the "real"
fix and it is a rewrite. HLLC needs a well-posed constant γ per Riemann problem;
the per-cell hllc_flux_var_gamma path exists but is not the default. Deferred,
not dismissed.
A units library or dimensional-analysis mypy plugin. Would catch this class
and much else. Also numba-hostile, mypy --strict-hostile in numeric code, and
a multi-thousand-site migration on a large working codebase. Same verdict, and
for the same reason, as the unified-assembly rewrite.
A stricter conservation check. Circular for this class: the cylinder's own
energy balance dE/dt = Σ ṁᵢhᵢ − p dV/dt + Q̇ was satisfied exactly, because
the same wrong h appeared on both sides. Only an independent reference
breaks the circle.
What this does not fix¶
Roughly half of this class is sequencing, not representation, and no
interface catches it. While implementing the two-zone fix, reading the burnt
inventory from a diagnostic gave the right type, the right owner and the wrong
moment — the inventory advances in apply_fuel_step, which runs after the
diagnostics in accumulate_rates. It put T_burnt 20 K high and broke the
two-zone energy identity by 5× its band. The rule there is separate: a
diagnostic reading an accumulator must know where it sits in the stage order.
References¶
- !396 — outflow enthalpy,
x_burntas inventory - !397 — valve mass flux, runner decode, two-zone
m_b,AEOLUS_PHYSICS_ASSERTS - ADR-0006 — why this host cannot benchmark micro-optimisations
- ADR-0008 — the cylinder orifice valve coupling this seam lives in
- ADR-0009 — inflow ghost species conservation (a totals net, by contrast)
docs/cylinder-reference.md— "Two γ's, and the seam between them"docs/troubleshooting.md— "Everything conserves, but the answer is still wrong"