ADR-0014 — A degenerate composition fails loud on the scalar seam and stays finite on the vectorised one¶
Status: Accepted
Context¶
mixture γ = cp / (cp − R_mix) is undefined when the composition is
degenerate — an all-zero or negative Y column, for which cv = cp − R ≤ 0.
Three seams computed it and all three answered the same way: return exactly
1.0.
1.0 is the worst available answer. The solver recovers pressure as
p = (γ − 1)ρe, so γ == 1 makes pressure identically zero, hence
a² = γp/ρ == 0, hence S_L == S_R in the HLLC fan, hence a bare
ZeroDivisionError raised from inside the flux kernel. The failure surfaces
far from its cause, and the diagnostic that fires alongside it truthfully
reports that every cell is physically valid in (ρ, p) terms. The poison is
in gamma_pad.
This was found on 2026-08-01 while measuring whether the EPW diesel suites
should adopt temperature-dependent γ; the full narrative is preserved in
tests/unit/test_mixture_gamma_degenerate_composition.py. The upstream
trigger — the cylinder's fuel-only species coupling handing a valve-fed pipe
an all-zero composition — was fixed then. The proximate fatality was
deliberately left open, with the trade recorded rather than settled, because
fail-loud and stay-finite serve opposite goals and the choice had not been
made.
ADR-0011 settled the same trade for non-positive density in favour of fail-loud. This ADR does not simply inherit that answer, because the two γ seams have materially different costs.
Decision¶
The policy is split by seam, on call-site economics rather than taste.
Scalar mixture_gamma — fail loud¶
Raises ValueError naming T, sum(Y) and the largest component, plus an
optional caller-supplied context string. It has exactly two callers
(bench/abgrall_interface.py and mixture_gamma_array), neither on a hot
path, so it can afford to stop. A loud failure at the composition is worth
much more than a ZeroDivisionError three frames deeper, which is precisely
the failure mode this ADR exists to remove.
mixture_gamma_array delegates per element and therefore inherits fail-loud.
It has no caller inside src/aeolus1d; the policy statement covers test and
bench code only.
Vectorised MixtureThermoTable.gamma — stay finite, warn once¶
Returns GAMMA_DEGENERATE_FALLBACK = 1.4 for the degenerate cells and emits
one RuntimeWarning per table instance, naming the degenerate-cell count and
the first offending flat index.
This is a per-cell hot path reached every step from components/pipe.py.
Raising here would kill an entire run over one transient cell — and a
transient all-zero column is genuinely producible: quasi_steady_network's
overflow guard zeroes a cell. 1.4 keeps 1/(γ − 1) bounded, so a run with a
bad cell degrades visibly rather than dividing by zero, and the warning tells
the user which cell to look at.
Warning once per instance rather than once per cell or per call is what makes this affordable on a path that runs every step.
What is deliberately unchanged¶
A tiny composition (1e-12) is physical and passes through untouched. The guard is on degeneracy, not magnitude — a distinction the existing tests already pinned and which stays pinned.
Implementation note¶
MixtureThermoTable is a @dataclass(frozen=True), so the warn-once latch
cannot be a plain attribute write. It is declared as
_degenerate_warned: bool = field(default=False, init=False, compare=False, repr=False)
and written through object.__setattr__, the documented escape hatch for
frozen dataclasses. init=False keeps build_mixture_table's construction
call unchanged; compare=False keeps __eq__ unchanged, so two tables built
from the same inputs stay equal whether or not either has warned. The class
declares no __slots__, so instances carry a __dict__ and the write is
legal.
A module-level WeakSet of already-warned tables was considered and rejected:
the dataclass has ndarray fields and is therefore unhashable, so it cannot be
a set member.
Consequences¶
- A degenerate composition can no longer produce a zero sound speed, so the
ZeroDivisionError-from-inside-HLLC failure mode is closed at the source as well as upstream. - Two tests that pinned
γ == 1.0had their premise updated in place rather than being deleted, as the original file instructed:test_mixture_gamma_degenerate_composition.pyandtest_mixture_thermo_table.py. mixture_gammagained a keyword-onlycontextparameter. It is optional and defaults to"", so existing calls are unaffected.- Callers that relied on the scalar seam returning a number for a degenerate
input now get an exception. No such caller exists in
src/aeolus1d.