STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_numba_ab_parity.py
Go to the documentation of this file.
1"""Live A/B parity for the Numba accelerator against the authoritative Python physics.
2
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.
6
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.
13
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.
20
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).
27"""
28from __future__ import annotations
29
30import os
31from contextlib import contextmanager
32from pathlib import Path
33
34import numpy as np
35import pytest
36
37ROOT = Path(__file__).resolve().parents[1]
38PSI_TO_PA = 6894.76
39PA_AMBIENT = 101325.0
40
41# The chamber-level classes are slow (whole Python chamber solves per point), so they run in the
42# accel-parity CI job or with ED_AB_PARITY=1. The injector-solve, chug and default-path checks
43# below them are cheap and run in every suite: those kernels sit on the DEFAULT Python path too
44# (closure.flows -> accel.solve, stability._chug_fast -> accel.chug_margin_fast), so a drift there
45# changes what every user sees, not just the optimizer.
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",
49)
50
51
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())
56
57POINTS_PSI = [(563.467, 567.644), (518.4, 550.6), (597.3, 584.7)]
58
59RTOL = 1e-6
60
61CONFIGS = [
62 ("configs/canonical/impinging.yaml", True), # impinging, ring manifold, ablative ON
63 ("configs/impinging_lox_ch4_8000N.yaml", False), # impinging, plenum, ablative off
64 ("configs/canonical/pintle.yaml", True), # pintle, ablative ON
65 # the 6.5 kN LOX/ethanol doublet as drawn (ring manifold, CoolProp heat-up of both liquids)
66 ("tests/fixtures/ethalox_6500N_doublet_cad_2026-09-28.yaml", True),
67]
68
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"]
72
73# momentum_ratio_R is impinging-only (absent for pintle); the .get() guards skip it.
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"]
78
79
80@contextmanager
82 """Force the authoritative Python path for the reference computation.
83
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.
89 """
90 from engine import accel
91 real_enabled, real_require = accel.enabled, accel.require
92 accel.enabled = lambda: False
93 accel.require = lambda: False
94 try:
95 yield
96 finally:
97 accel.enabled, accel.require = real_enabled, real_require
98
99
100def _rel(got, want):
101 return abs(float(got) - float(want)) / max(abs(float(want)), 1e-12)
102
103
104def _assert_close(name, got, want, rtol=RTOL):
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}"
108
109
110def _py_field(ref, key):
111 if key in ref and ref[key] is not None:
112 return ref[key]
113 return (ref.get("diagnostics") or {}).get(key)
114
115
116_RIGS = {}
117
118
119def _rig(cfg_rel):
120 """Config + runner + per-point pure-Python reference, built once."""
121 if cfg_rel in _RIGS:
122 return _RIGS[cfg_rel]
123 from engine import accel
124 from engine.core.runner import PintleEngineRunner
125 from engine.pipeline.io import load_config
126
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")
131
132 config = load_config(str(ROOT / cfg_rel))
133 # With the chamber gate closed, can_handle_chamber is False BY DESIGN (the kernels would
134 # compute other physics); the classes below then pin the fallback instead of the kernel.
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]
139 with _python_only():
140 reference = {p: runner.evaluate(p[0], p[1], P_ambient=PA_AMBIENT, silent=True)
141 for p in points}
142 rig = {"config": config, "runner": runner, "cache": runner.cea_cache,
143 "points": points, "reference": reference}
144 _RIGS[cfg_rel] = rig
145 return rig
146
147
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)
154 if got is None and _chamber_gate():
155 got = r["runner"].evaluate(p[0], p[1], P_ambient=PA_AMBIENT, silent=True)
156 return got
157
158
159@_ab_parity
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."""
163
164 def test_wrapper_fields(self, cfg_rel, ablative):
165 from engine import accel
166 r = _rig(cfg_rel)
167 for p in r["points"]:
168 ref = r["reference"][p]
169 if _chamber_gate():
170 # Gate closed: the chamber kernels must refuse (not compute other physics) ...
171 assert accel.evaluate(r["config"], r["cache"], p[0], p[1], PA_AMBIENT) is None
172 got = _default_path(r, p)
173 assert got is not None, f"accelerator bailed where Python converged at {p}"
174 for k in CORE_FIELDS:
175 want = _py_field(ref, k)
176 if want:
177 _assert_close(f"{k}@{p[0]:.0f}/{p[1]:.0f}", got[k], want)
178
179 def test_diagnostics(self, cfg_rel, ablative):
180 from engine import accel
181 r = _rig(cfg_rel)
182 for p in r["points"]:
183 pd = (r["reference"][p].get("diagnostics") or {})
184 gd = _default_path(r, p)["diagnostics"]
185 for k in DIAG_FIELDS:
186 if pd.get(k) and k in gd:
187 _assert_close(f"diag[{k}]", gd[k], pd[k])
188
189 def test_kernel_level_raw_tuple(self, cfg_rel, ablative):
190 """Kernel level: chamber.evaluate_core's own result vector, no wrapper in the way.
191
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.
195 """
196 from engine.accel import chamber, kernels, params
197 r = _rig(cfg_rel)
198 if _chamber_gate():
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"])
204 R = chamber._R
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}"
209 _assert_close("kernel Pc", raw[R["PC"]], _py_field(ref, "Pc"))
210 _assert_close("kernel F", raw[R["F"]], _py_field(ref, "F"))
211 _assert_close("kernel Isp", raw[R["ISP"]], _py_field(ref, "Isp"))
212 _assert_close("kernel MR", raw[R["MR"]], _py_field(ref, "MR"))
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"])
217 _assert_close("kernel P_exit", raw[R["P_EXIT"]], _py_field(ref, "P_exit"))
218 if ablative:
219 _assert_close("kernel liner heat", raw[R["Q_ABL"]],
220 ref["diagnostics"]["cooling"]["ablative"]["heat_removed"])
221
222
223@_ab_parity
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."""
227
228 N = 60
229
230 def test_sweep(self, cfg_rel, ablative):
231 r = _rig(cfg_rel)
232 rng = np.random.default_rng(20260904)
233 matched = 0
234 worst = 0.0
235 # Gate closed: both sides are Python chamber solves, so fewer points buy the same breadth
236 # of injector/chug coverage at a fraction of the cost.
237 n = self.N if not _chamber_gate() else 12
238 for _ in range(n):
239 p_o = float(rng.uniform(3.0e6, 5.5e6)); p_f = float(rng.uniform(3.0e6, 5.5e6))
240 try:
241 got = _default_path(r, (p_o, p_f))
242 except Exception:
243 got = None
244 with _python_only():
245 try:
246 ref = r["runner"].evaluate(p_o, p_f, P_ambient=PA_AMBIENT, silent=True)
247 except Exception:
248 ref = None
249 py_ok = ref is not None and np.isfinite(ref.get("F", np.nan))
250 if not py_ok:
251 continue
252 # Python converged, so the accelerated path must too: same physics,
253 # same inputs. A one-sided bail is a bug, not a tolerance question.
254 assert got is not None, f"accelerator bailed where Python converged at {p_o:.0f}/{p_f:.0f}"
255 matched += 1
256 for k in CORE_FIELDS:
257 want = _py_field(ref, k)
258 if want:
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"
262
263
264@_ab_parity
266 """Pins the Tc_ideal / Tc_effective distinction.
267
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.
272 """
273
275 from engine import accel
276 from engine.accel import chamber, kernels, params
277 if _chamber_gate():
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."
290 )
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})"
295 )
296
297
298@_ab_parity
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.
302
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.
307 """
308
309 def test_delivered_below_ceiling(self, cfg_rel, ablative):
310 g0 = 9.80665
311 r = _rig(cfg_rel)
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
319 # Ambient thrust <= vacuum thrust, so this bound holds a fortiori.
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"
323 )
324
325
326# ---------------------------------------------------------------------------------------------
327# Cheap checks, run in every suite (no ED_AB_PARITY needed).
328# ---------------------------------------------------------------------------------------------
329
331 from engine import accel
332 if not accel.available():
333 pytest.skip("numba unavailable")
334
335
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."""
340
341 # STAB-5 counterexample (tests/test_combustion_stability_audit.py): the worst crossing is the
342 # -3pi one at ~102 Hz. The -pi-only kernel read GM 4.27 (stable) on a loop that is unstable.
343 PC = 3.61e6
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)
346
347 def _case(self, O, F, Z_hf=0.0):
348 from engine.pipeline.stability.chug import ChugChamber, ChugStream, Regulator
349 mk = lambda n, d: ChugStream(n, mdot=d["mdot"], eta_inj=d["eta"], Pc=self.PC, # noqa: E731
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)
353
354 @staticmethod
355 def _same(a, b):
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):
359 continue
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)
363
366 from engine.accel.stability import chug_margin_fast as fast
367 from engine.pipeline.stability.chug import chug_margin_fast as ref
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"] # the case is what it claims
371 self._same(got, want)
372
375 from engine.accel.stability import chug_margin_fast as fast
376 from engine.pipeline.stability.chug import chug_margin_fast as ref
377 rng = np.random.default_rng(20260926)
378 for i in range(150):
379 O, F = dict(self.O), dict(self.F)
380 for d in (O, 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))
386
387
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"]
391_INJ_CORE_KEYS = 18 # the first 18 are published by both injector types
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",
396 # spray/doublet geometry Layer 1 reads out of the diagnostics (geometry-fit term,
397 # final report); None on the accelerated path until the extras were added
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",
401 # the back-channel ring, hole by hole (impinging._manifold_diagnostics)
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",
405 "Cd_eff_manifold_F"]
406
407
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
415 from engine.core.injectors import get_injector_model
416 from engine.pipeline.io import load_config
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)
425 _assert_close("mdot_O", got[0], mO, rtol=1e-9)
426 _assert_close("mdot_F", got[1], mF, rtol=1e-9)
427 keys = _INJ_KEYS if config.injector.type == "impinging" else _INJ_KEYS[:_INJ_CORE_KEYS]
428 for k in 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"
431 w = float(want[k])
432 if np.isnan(w):
433 assert np.isnan(float(got[2][k])), f"diag[{k}]: python NaN, accel {got[2][k]}"
434 continue
435 _assert_close(f"diag[{k}]", float(got[2][k]), w, rtol=1e-9)
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)
442
443
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
449 from engine.core.runner import PintleEngineRunner
450 from engine.pipeline.io import load_config
451 config = load_config(str(ROOT / "configs/ethalox_6500N.yaml"))
452 runner = PintleEngineRunner(config)
453 P = 584.27 * PSI_TO_PA
454 Pa = 94070.0
455 got = accel.evaluate(config, runner.cea_cache, P, P, Pa)
456 if got is None:
457 got = runner.evaluate(P, P, P_ambient=Pa, silent=True)
458 with _python_only():
459 want = runner.evaluate(P, P, P_ambient=Pa, silent=True)
460 for k in ("F", "Pc", "MR", "Isp", "mdot_O", "mdot_F"):
461 _assert_close(k, got[k], want[k])
462 _assert_close("eta_cstar", _py_field(got, "eta_cstar"), _py_field(want, "eta_cstar"))
463 # The chug verdict runs the chug kernel on the accelerated side (stability._chug_fast).
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"
467 _assert_close(f"chug {k}", st_got[k], st_want[k])
468
469
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
475 from engine.core.runner import PintleEngineRunner
476 from engine.pipeline.io import load_config
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}"
484
485
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."""
492 from engine.accel import chamber
493 from engine.pipeline.combustion_physics import liquid_heatup_properties, saturation_state
494 from engine.pipeline.io import load_config
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
504 answered = 0
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,
508 Tcrit or None)[0]
509 if not Ts > T0:
510 continue
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)
513 if st < 0:
514 continue
515 assert (st == 1) == (want is not None), f"{fl.name} at {P:.4g} Pa: None-ness differs"
516 if want is not None:
517 answered += 1
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"
#define max(a, b)
Definition Arduino.h:43
static std::vector< string_t > split(const string_t &reference_string)
split the string input to reference tokens
Definition json.hpp:14471
_assert_close(name, got, want, rtol=RTOL)