Skip to content

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_eff is what the volume's own (E ↔ T, E → p) mapping runs on — a burnt-fraction blend, and under caloric_thermo not a single γ at all, since e = ∫c_v dT ≠ c_v(T)·T and no γ reproduces both e and h.

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/edot was credited to one side and debited from the other; the same U was 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 a c_p built from it read as obviously wrong.
  • State conversion: E ↔ T, T → h, e → p. These belong to whoever owns the state. Read Volume.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

  • Volume gains e, cp, cv, gamma_advection beside the existing h, p, T, rho, R_eff, gamma_eff. cv := cp − R_eff, so cp/cv equals gamma_eff identically in every mode rather than approximately — pinned by test_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. edot arrives at the runner as ρE and is read back as T through 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.cp returns the ideal-gas value implied by gamma_eff; the EOS's own c_p is not exposed. Boundary crossings should use h, which is exact.

Alternatives considered

A GasState object threaded through every component boundary. Rejected, and worth recording why, because it is the intuitive proposal.

  1. It would not have prevented any of the five defects. A consumer holding gs.gamma, gs.R, gs.T can still write gs.gamma*gs.R/(gs.gamma-1)*gs.T. The bug was never missing access — helmholtz_resonator.py had every field and computed the same enthalpy two different ways in the two branches of one if. The bug is that cp·T looks reasonable at the call site. A richer object makes it look more reasonable.
  2. 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_ghost has already absorbed a documented +9 % regression from a single slice assignment.
  3. The zero-allocation form already exists and is better: owner-derived named scalars, hoisted out of the loop — h_cyl = self.volume.h before 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_burnt as 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"