1"""Live A/B parity for the Numba accelerator against the authoritative Python physics.
3Runs both implementations on the same inputs at test time and diffs them field by
4field, so a drift on EITHER side fails regardless of which one moved. This is the
5sole numeric guard on the accelerated path now that the C port is gone.
7TOLERANCE. RTOL is 1e-6, not the 2e-3 the retired C suite used. Measured agreement
8on 120 randomized points across both configs is ~2.5e-9 -- the accelerated and
9Python paths run the same physics and differ only by Brent's convergence
10tolerance, so 1e-6 leaves ~400x headroom while still being three orders tighter
11than the old contract. If a change pushes this above 1e-6, that is a physics
12divergence, not rounding: fix it rather than widening the bound.
14THE REFERENCE MUST BE FORCED TO PYTHON. With the accelerator enabled,
15runner.evaluate reaches chamber_solver._accel_chamber_pc -> accel.chamber_solve
16and closure.flows -> accel.solve, so an unguarded "Python" reference is largely
17the same numba kernels and the comparison is self-referential (it reads as ~1e-15
18agreement, which is the tell). _python_only() below disables the accelerator for
19the reference computation; without it this suite proves nothing.
21THE CONFIG LIST IS DELIBERATE. canonical/impinging.yaml has ablative cooling ON and
22a back-channel ring manifold, impinging_lox_ch4_8000N.yaml has neither (plenum, no
23liner), canonical/pintle.yaml exercises the pintle injector -- a different solve
24(kernels.injector_solve_pintle) and a different mixing input: no Rupe M, so E_m is
25rupe_Em_opt flat and the drops leave axially -- and the 6.5 kN ethalox fixture is the
26design as drawn, where both liquids heat up on CoolProp tables (engine.accel.chamber).
28from __future__
import annotations
31from contextlib
import contextmanager
32from pathlib
import Path
37ROOT = Path(__file__).resolve().parents[1]
46_ab_parity = pytest.mark.skipif(
47 os.environ.get(
"ED_REQUIRE_ACCEL") !=
"1" and os.environ.get(
"ED_AB_PARITY") !=
"1",
48 reason=
"A/B parity runs in the accel-parity CI job; set ED_AB_PARITY=1 to run locally",
53 """Python chamber physics the kernels do not mirror yet (empty => chamber kernels in use)."""
54 from engine
import accel
55 return tuple(accel.chamber_physics_not_mirrored())
57POINTS_PSI = [(563.467, 567.644), (518.4, 550.6), (597.3, 584.7)]
62 (
"configs/canonical/impinging.yaml",
True),
63 (
"configs/impinging_lox_ch4_8000N.yaml",
False),
64 (
"configs/canonical/pintle.yaml",
True),
66 (
"tests/fixtures/ethalox_6500N_doublet_cad_2026-09-28.yaml",
True),
69CORE_FIELDS = [
"Pc",
"F",
"Isp",
"MR",
"cstar_actual",
"eta_cstar",
70 "mdot_total",
"mdot_O",
"mdot_F",
"Cf_actual",
71 "P_exit",
"T_exit",
"v_exit"]
74DIAG_FIELDS = [
"D32_O",
"D32_F",
"Cd_O",
"Cd_F",
"momentum_ratio_R",
75 "delta_p_feed_O",
"delta_p_feed_F",
"delta_p_injector_O",
76 "delta_p_injector_F",
"A_geom_O",
"A_geom_F",
"A_eff_O",
"A_eff_F",
77 "turbulence_intensity_mix",
"u_O",
"u_F",
"We_O",
"We_F"]
82 """Force the authoritative Python path for the reference computation.
84 Disables strict mode as well as the accelerator. Under ED_REQUIRE_ACCEL=1 (the
85 CI parity job) closure._try_native_flows treats "disabled + strict" as a
86 genuine accelerator failure and raises -- which is exactly right in
87 production, and exactly wrong here, where the accelerator is off ON PURPOSE.
88 Without this the whole suite fails under CI's env while passing locally.
90 from engine
import accel
91 real_enabled, real_require = accel.enabled, accel.require
92 accel.enabled =
lambda:
False
93 accel.require =
lambda:
False
97 accel.enabled, accel.require = real_enabled, real_require
101 return abs(float(got) - float(want)) /
max(abs(float(want)), 1e-12)
105 assert got
is not None and want
is not None, f
"{name}: missing value ({got} vs {want})"
106 rel =
_rel(got, want)
107 assert rel <= rtol, f
"{name}: accel={float(got):.10g} python={float(want):.10g} rel={rel:.3e} > {rtol:g}"
111 if key
in ref
and ref[key]
is not None:
113 return (ref.get(
"diagnostics")
or {}).get(key)
120 """Config + runner + per-point pure-Python reference, built once."""
122 return _RIGS[cfg_rel]
123 from engine
import accel
127 if not accel.available():
128 if os.environ.get(
"ED_REQUIRE_ACCEL") ==
"1":
129 pytest.fail(
"ED_REQUIRE_ACCEL=1 but numba is unavailable")
130 pytest.skip(
"numba unavailable")
132 config = load_config(str(ROOT / cfg_rel))
135 assert accel.can_handle_chamber(config) == (
not _chamber_gate()), (
136 f
"accelerator chamber routing for {cfg_rel} disagrees with the mirror gate")
137 runner = PintleEngineRunner(config)
138 points = [(po * PSI_TO_PA, pf * PSI_TO_PA)
for po, pf
in POINTS_PSI]
140 reference = {p: runner.evaluate(p[0], p[1], P_ambient=PA_AMBIENT, silent=
True)
142 rig = {
"config": config,
"runner": runner,
"cache": runner.cea_cache,
143 "points": points,
"reference": reference}
149 """What Layer 1 actually consumes: accel.evaluate, else (None) the runner on the DEFAULT,
150 accelerator-enabled path -- which still routes the injector through accel.solve and the chug
151 scan through the kernel."""
152 from engine
import accel
153 got = accel.evaluate(r[
"config"], r[
"cache"], p[0], p[1], PA_AMBIENT)
155 got = r[
"runner"].evaluate(p[0], p[1], P_ambient=PA_AMBIENT, silent=
True)
160@pytest.mark.parametrize("cfg_rel,ablative", CONFIGS, ids=lambda v: str(v).
split(
"/")[-1])
162 """The contract: what the optimizer consumes must match the authoritative path."""
165 from engine
import accel
167 for p
in r[
"points"]:
168 ref = r[
"reference"][p]
171 assert accel.evaluate(r[
"config"], r[
"cache"], p[0], p[1], PA_AMBIENT)
is None
173 assert got
is not None, f
"accelerator bailed where Python converged at {p}"
174 for k
in CORE_FIELDS:
180 from engine
import accel
182 for p
in r[
"points"]:
183 pd = (r[
"reference"][p].get(
"diagnostics")
or {})
185 for k
in DIAG_FIELDS:
186 if pd.get(k)
and k
in gd:
190 """Kernel level: chamber.evaluate_core's own result vector, no wrapper in the way.
192 The retired C suite kept this level because a wrapper override once hid a
193 kernel computing retired momentum-method thrust. The property is
194 backend-agnostic and worth keeping: a wrapper cannot paper over the kernel.
199 pytest.xfail(
"chamber kernels do not mirror: " +
" | ".join(
_chamber_gate()))
200 P = params.extract_params(r[
"config"])
201 CH = chamber.chamber_inputs(r[
"config"], r[
"cache"])
202 assert CH
is not None,
"chamber_inputs refused a config the accelerator claims"
203 arr = kernels.cea_arrays(r[
"cache"])
205 for p
in r[
"points"]:
206 ref = r[
"reference"][p]
207 ok, raw = chamber.evaluate_core(P, CH, arr, p[0], p[1], PA_AMBIENT)
208 assert ok, f
"kernel did not converge at {p}"
213 ce = ref[
"diagnostics"][
"cstar_efficiency"]
214 _assert_close(
"kernel eta_vap", raw[R[
"ETA_VAP"]], ce[
"eta_vaporization"])
215 _assert_close(
"kernel eta_mix", raw[R[
"ETA_MIX"]], ce[
"eta_mixing"])
216 _assert_close(
"kernel eta_HL", raw[R[
"ETA_HL"]], ce[
"eta_heat_loss"])
220 ref[
"diagnostics"][
"cooling"][
"ablative"][
"heat_removed"])
224@pytest.mark.parametrize("cfg_rel,ablative", CONFIGS, ids=lambda v: str(v).
split(
"/")[-1])
226 """Breadth the three fixed points cannot give. Fixed seed, so failures repeat."""
232 rng = np.random.default_rng(20260904)
239 p_o = float(rng.uniform(3.0e6, 5.5e6)); p_f = float(rng.uniform(3.0e6, 5.5e6))
246 ref = r[
"runner"].evaluate(p_o, p_f, P_ambient=PA_AMBIENT, silent=
True)
249 py_ok = ref
is not None and np.isfinite(ref.get(
"F", np.nan))
254 assert got
is not None, f
"accelerator bailed where Python converged at {p_o:.0f}/{p_f:.0f}"
256 for k
in CORE_FIELDS:
259 worst =
max(worst,
_rel(got[k], want))
260 assert matched > n // 2, f
"only {matched}/{n} points converged"
261 assert worst <= RTOL, f
"worst accel-vs-Python divergence {worst:.3e} over {matched} points"
266 """Pins the Tc_ideal / Tc_effective distinction.
268 evaluate_core returns the ideal Tc at index 7 and the cooling-adjusted one at
269 index 21; the wrapper must expose the EFFECTIVE value, because that is what
270 reaches comprehensive_stability_analysis. Returning the ideal one is a silent
271 ~0.8% error that lands in stability rather than crashing.
275 from engine
import accel
278 pytest.skip(
"chamber kernels gated off; the wrapper this pins does not run")
279 r =
_rig(
"configs/canonical/impinging.yaml")
280 P = params.extract_params(r[
"config"])
281 CH = chamber.chamber_inputs(r[
"config"], r[
"cache"])
282 arr = kernels.cea_arrays(r[
"cache"])
283 p_o, p_f = r[
"points"][0]
284 ok, raw = chamber.evaluate_core(P, CH, arr, p_o, p_f, PA_AMBIENT)
285 assert ok,
"kernel did not converge"
286 tc_ideal, tc_eff = float(raw[chamber._R[
"TC_IDEAL"]]), float(raw[chamber._R[
"TC_EFF"]])
287 assert tc_eff < tc_ideal - 1.0, (
288 f
"cooling not applied: Tc_ideal={tc_ideal:.2f} Tc_effective={tc_eff:.2f}. "
289 "If ablative is genuinely inactive for this config the test is vacuous."
291 reported = accel.evaluate(r[
"config"], r[
"cache"], p_o, p_f, PA_AMBIENT)[
"Tc"]
292 assert _rel(reported, tc_eff) < 1e-12, (
293 f
"wrapper reported Tc={reported:.4f}, expected the EFFECTIVE {tc_eff:.4f} "
294 f
"(not the ideal {tc_ideal:.4f})"
299@pytest.mark.parametrize("cfg_rel,ablative", CONFIGS, ids=lambda v: str(v).
split(
"/")[-1])
301 """Delivered Isp must sit at/below eta_cstar * the ideal ceiling.
303 Physics invariant, not a cross-implementation diff: this is what the original
304 momentum-method thrust bug violated (model Isp >= the ideal equilibrium
305 ceiling). Carried over from the retired C parity suite; it depends on no
306 backend, so it outlives both.
312 for p
in r[
"points"]:
313 ref = r[
"reference"][p]
314 diag = ref.get(
"diagnostics")
or {}
315 eta = float(diag.get(
"eta_cstar", ref.get(
"eta_cstar", np.nan)))
316 cstar_ideal = float(diag.get(
"cstar_ideal", ref.get(
"cstar_ideal", np.nan)))
317 cf_vac = r[
"cache"].eval_cf_vac(ref[
"MR"], ref[
"Pc"], ref[
"eps"])
318 ceiling = eta * cf_vac * cstar_ideal / g0
320 assert ref[
"Isp"] <= ceiling * (1.0 + 1e-6), (
321 f
"delivered Isp {ref['Isp']:.2f}s exceeds eta_cstar*Isp_vac_ideal "
322 f
"{ceiling:.2f}s — an efficiency term has been dropped from the thrust path"
331 from engine
import accel
332 if not accel.available():
333 pytest.skip(
"numba unavailable")
337 """accel.chug_margin_fast is what stability._chug_fast runs whenever the accelerator is on,
338 so it must be chug.chug_margin_fast to rounding -- including every negative-real-axis
339 crossing (phases -pi, -3pi, ...), not only the unwrapped -pi one."""
344 O = dict(mdot=0.9907, eta=0.3745, dPf=648654.0, L=0.6093, A=1.534e-4, tau=1.638e-3)
345 F = dict(mdot=0.7122, eta=0.1775, dPf=625208.0, L=0.9913, A=3.534e-4, tau=13.85e-3)
349 mk =
lambda n, d: ChugStream(n, mdot=d[
"mdot"], eta_inj=d[
"eta"], Pc=self.
PC,
350 dP_feed=d[
"dPf"], feed_length=d[
"L"], feed_area=d[
"A"],
351 tau_conv=d[
"tau"], regulator=Regulator(Z_hf=Z_hf))
352 return [mk(
"O", O), mk(
"F", F)], ChugChamber(1872.7, 4.353e-4, 1.093, 1.178)
356 for k
in (
"gain_margin",
"f_chug_hz",
"phase_margin_deg"):
357 x, y = float(a[k]), float(b[k])
358 if np.isnan(x)
and np.isnan(y):
360 assert _rel(x, y) <= 1e-9, f
"{k}: accel={x!r} python={y!r}"
361 assert a[
"stable"] == b[
"stable"]
362 assert np.allclose(a[
"crossings_hz"], b[
"crossings_hz"], rtol=1e-9, atol=0.0)
368 streams, ch = self.
_case(self.
O, self.
F)
369 got, want = fast(streams, ch), ref(streams, ch)
370 assert want[
"gain_margin"] < 1.0
and not want[
"stable"]
371 self.
_same(got, want)
377 rng = np.random.default_rng(20260926)
379 O, F = dict(self.
O), dict(self.
F)
381 d[
"eta"] *= rng.uniform(0.3, 2.0)
382 d[
"tau"] *= rng.uniform(0.2, 5.0)
383 d[
"L"] *= rng.uniform(0.2, 5.0)
384 streams, ch = self.
_case(O, F, Z_hf=float(rng.choice([0.0, 2.0e5])))
385 self.
_same(fast(streams, ch), ref(streams, ch))
388_INJ_CONFIGS = [
"configs/ethalox_6500N.yaml",
"configs/canonical/impinging.yaml",
389 "configs/impinging_lox_ch4_8000N.yaml",
"configs/canonical/pintle.yaml",
390 "tests/fixtures/ethalox_6500N_doublet_cad_2026-09-28.yaml"]
392_INJ_KEYS = [
"Cd_O",
"Cd_F",
"delta_p_feed_O",
"delta_p_feed_F",
"delta_p_injector_O",
393 "delta_p_injector_F",
"D32_O",
"D32_F",
"x_star",
"u_O",
"u_F",
"We_O",
"We_F",
394 "momentum_ratio_R",
"rupe_M",
"u_axial_spray",
"iterations",
395 "constraints_satisfied",
398 "L_imp",
"D_pitch_O",
"D_pitch_F",
"element_gap_O",
"element_gap_F",
"s_pair",
399 "vaporization_length_total",
"L_sheet_breakup",
"k_evap_O",
"k_evap_F",
400 "tau_evap_O",
"tau_evap_F",
"J",
"TMR",
"theta",
"Oh_O",
"Oh_F",
"rho_gas_breakup",
402 "element_flow_ratio_min_O",
"element_flow_ratio_max_O",
"element_flow_ratio_min_F",
403 "element_flow_ratio_max_F",
"element_mixture_ratio_min",
"element_mixture_ratio_max",
404 "manifold_branch_velocity_O",
"manifold_branch_velocity_F",
"Cd_eff_manifold_O",
408@pytest.mark.parametrize("cfg_rel", _INJ_CONFIGS, ids=lambda v: v.split("/")[-1])
410 """closure.flows runs accel.solve on the DEFAULT path, on every residual of the Python chamber
411 solve. It must reproduce the Python injector: one closure pass with Cd untouched (impinging),
412 the feed exit dump K_exit on d_exit, the passage at A_hydraulic, pintle's fixed-K x*."""
414 from engine
import accel
417 config = load_config(str(ROOT / cfg_rel))
418 assert accel.can_handle(config)
419 model = get_injector_model(config)
420 for P_tank
in (584.27 * PSI_TO_PA, 520.0 * PSI_TO_PA, 650.0 * PSI_TO_PA):
421 for Pc
in (2.0e6, 2.6e6, 3.0e6):
422 got = accel.solve(config, P_tank, P_tank, Pc)
423 assert got
is not None, f
"accel.solve bailed at {P_tank:.0f}/{Pc:.0f}"
424 mO, mF, want = model.solve(P_tank, P_tank, Pc)
427 keys = _INJ_KEYS
if config.injector.type ==
"impinging" else _INJ_KEYS[:_INJ_CORE_KEYS]
429 if k
in want
and want[k]
is not None:
430 assert k
in got[2], f
"accel diagnostics lack {k!r}, which Python publishes"
433 assert np.isnan(float(got[2][k])), f
"diag[{k}]: python NaN, accel {got[2][k]}"
436 assert list(got[2].get(
"violations", [])) == list(want.get(
"violations", []))
or \
437 "violations" not in want
438 assert got[2].get(
"manifold_model") == want.get(
"manifold_model")
439 for k
in (
"element_mixture_ratios",
"element_mass_flows",
"element_mass_flows_O"):
440 if want.get(k)
is not None:
441 np.testing.assert_allclose(got[2][k], want[k], rtol=1e-9, err_msg=k)
445 """The shipped 6.5 kN at its tank pressure and pad ambient: what the default (accelerated)
446 path returns -- the thing Layer 1 and the UI read -- must be the ED_ACCEL=off answer."""
448 from engine
import accel
451 config = load_config(str(ROOT /
"configs/ethalox_6500N.yaml"))
452 runner = PintleEngineRunner(config)
453 P = 584.27 * PSI_TO_PA
455 got = accel.evaluate(config, runner.cea_cache, P, P, Pa)
457 got = runner.evaluate(P, P, P_ambient=Pa, silent=
True)
459 want = runner.evaluate(P, P, P_ambient=Pa, silent=
True)
460 for k
in (
"F",
"Pc",
"MR",
"Isp",
"mdot_O",
"mdot_F"):
464 st_got, st_want = got[
"stability"][
"chugging"], want[
"stability"][
"chugging"]
465 for k
in (
"chug_gain_margin",
"stability_margin",
"frequency"):
466 assert np.isfinite(st_want[k]), f
"reference chug {k} is not finite"
471 """Every chamber piece is mirrored, so the shipped design and the one drawn from CAD run on
472 the kernel (NOT_HANDLED here would put Layer 1 back on the ~60 ms Python solve)."""
474 from engine
import accel
477 assert accel.chamber_physics_not_mirrored() == ()
478 for rel
in (
"configs/ethalox_6500N.yaml",
"tests/fixtures/ethalox_6500N_doublet_cad_2026-09-28.yaml"):
479 config = load_config(str(ROOT / rel))
480 runner = PintleEngineRunner(config)
481 P = 560.0 * PSI_TO_PA
482 res, oc = accel.evaluate_ex(config, runner.cea_cache, P, P, PA_AMBIENT)
483 assert oc
is accel.Outcome.OK, f
"{rel}: {oc}"
487 """The one tabulation in the chamber kernel: CoolProp's liquid cp(T) and density at the
488 chamber pressure (combustion_physics.liquid_heatup_properties), for both liquids of the 6.5 kN
489 engine over the whole Pc window. A point the table refuses is allowed (the kernel returns no
490 solution and the caller runs Python); a point it answers must answer right."""
495 cfg = load_config(str(ROOT /
"tests/fixtures/ethalox_6500N_doublet_cad_2026-09-28.yaml"))
496 rng = np.random.default_rng(20260929)
497 for side
in (
"oxidizer",
"fuel"):
498 fl = cfg.fluids[side]
499 Tcrit = fl.critical_temperature
or 0.0
500 T0 = 293.0
if fl.temperature
is None else fl.temperature
501 tabs = chamber.heatup_tables(fl.name, T0, fl.boiling_point, fl.latent_heat,
502 fl.molecular_weight, Tcrit)
503 assert tabs
is not None
505 for P
in np.exp(rng.uniform(np.log(2.1e5), np.log(1.5e7), 400)):
506 Tc = float(rng.uniform(2800.0, 3500.0))
507 Ts = saturation_state(P, fl.boiling_point, fl.latent_heat, fl.molecular_weight,
511 want = liquid_heatup_properties(fl.name, P, T0, Ts, Tc)
512 st, I, rho = chamber._heatup(np.zeros(1), 0, *tabs, P, T0, Ts, Tc)
515 assert (st == 1) == (want
is not None), f
"{fl.name} at {P:.4g} Pa: None-ness differs"
518 assert abs(I / want[
"heatup_integral"] - 1.0) < 5e-6, f
"{fl.name} I at {P:.4g}"
519 assert abs(rho / want[
"rho_l"] - 1.0) < 5e-7, f
"{fl.name} rho at {P:.4g}"
520 assert answered > 300, f
"{fl.name}: the table answered only {answered} of 400 points"
test_diagnostics(self, cfg_rel, ablative)
test_kernel_level_raw_tuple(self, cfg_rel, ablative)
test_wrapper_fields(self, cfg_rel, ablative)
test_minus_3pi_crossing(self)
test_randomised_loops(self)
_case(self, O, F, Z_hf=0.0)
test_effective_tc_differs_and_is_reported(self)
test_delivered_below_ceiling(self, cfg_rel, ablative)
test_sweep(self, cfg_rel, ablative)
static std::vector< string_t > split(const string_t &reference_string)
split the string input to reference tokens
test_chamber_kernel_serves_the_designs()
test_heatup_tables_match_coolprop()
test_default_path_matches_python_on_the_shipped_engine()
test_injector_solve_matches_python(cfg_rel)
_assert_close(name, got, want, rtol=RTOL)