Skip to content

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.0 had their premise updated in place rather than being deleted, as the original file instructed: test_mixture_gamma_degenerate_composition.py and test_mixture_thermo_table.py.
  • mixture_gamma gained a keyword-only context parameter. 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.