1"""LE4 golden burn: Layer X's pad helium baseline must not move.
3The reference is ``docs/layerx/baseline-2026-10-07.json``, written by
4``scripts/layerx_baseline.py`` (case ``he_pad``: LE4 on the ``copv_study_he`` hot-fire drawing, on
5the pad, erosion replay on, every other ``LayerXSettings`` field at its default). The overnight rule
6is that a change moving the LE4 baseline by more than 1 % is reported; this test is what notices.
8Slow: one helium burn with the replay loop is ~35-100 s depending on load, so it is skipped unless
9``LAYERX_GOLDEN=1`` (the suite registers no ``slow`` marker). ``LAYERX_GOLDEN_BASELINE`` points it at
12What it holds fixed, and what it does not
13-----------------------------------------
14* **The design is the baseline's own.** The burn uses the config embedded in the baseline JSON,
15 not the live ``configs/ethalox_6800N.yaml``, so editing the design does not read as a physics
16 change. ``test_embedded_config_round_trips`` checks every field of it survives the schema
17 unchanged (a validator that rewrites a stored value *is* an input change).
18* **The drawing, the CEA table and the DAQ tables are the checkout's**, and
19 ``test_inputs_unchanged`` compares their hashes with the baseline's first. When one of them has
20 changed the figures are expected to move: re-run the script, review, and report the move.
21* **Everything else is code**: lib/feedtwin, engine/layerx, the engine card, the replay.
25Measured: the burn is bitwise reproducible across processes on one machine (three runs, every
26figure identical to the last digit), so on the baseline's own platform any non-zero move is a
27change. The bands are wider than that on purpose:
29* **0.5 % on the integral performance figures** (impulse, burn time, thrust, Pc, O/F): half the
30 1 % reporting threshold, so a change that must be reported cannot pass, with room for platform
31 drift (CI runs other numpy/CoolProp versions; that drift has not been measured here).
32* **0.3 % on Isp**: Isp is a ratio of two integrals that move together; a 0.3 % move in delivered
33 Isp is already a material engine change (0.7 s).
34* **1 % on the bottle** (pressure and gas used at burnout): the bottle integrates the whole burn's
35 gas demand and is a feed-side quantity the 1 % rule reads directly.
36* **Absolute bands** where a relative one means nothing: tank pressures 3 psi (0.5 % of the
37 ~618 psia peak), injector stiffness 0.003 (1.2 psi of the ~136 psi drop at a ~400 psia chamber,
38 ~0.9 % of the value), the other tank's residual 0.02 kg (what an O/F move of ~0.5 % strands),
39 the time of the lowest chug margin one 50 ms step.
40* **2 % on throat growth and recession**: erosion feeds thrust weakly (a 2 % change in a 4 % area
41 growth is ~0.08 % of area); tighter would flag erosion-model edits that cannot move a reported
43* **Exact** on the discrete outcomes: which tank runs dry, converged, no failed steps, no step
44 outside the engine card.
46Checked against changes made in-process, not in the code: halving the regulator's supply-pressure
47effect fails 8 of these (burn time +1.45 %, mean thrust -1.73 %, Pc -1.65 %, bottle +9.5 %, tank
48peaks -24 psi) while total impulse moves only -0.30 %: the load is fixed, so impulse alone is a weak
49witness to a feed-system change. Loosening the network solve from 1e-6 to 1e-4 passes all of them
50(impulse -0.018 %, stiffness -0.15 % of its value). Doubling or shortening the step is a scheme change
51and is held out by burning at the baseline's own ``dt`` (0.1 s: impulse +0.08 %; 0.02 s: -0.06 %,
52lowest chug margin -0.58 % because its minimum is the first firing sample).
55from __future__
import annotations
61from pathlib
import Path
62from typing
import Any, Dict, Tuple
66ROOT = Path(__file__).resolve().parents[1]
67BASELINE = Path(os.environ.get(
"LAYERX_GOLDEN_BASELINE")
or (ROOT /
"docs" /
"layerx" /
"baseline-2026-10-07.json"))
71 pytest.mark.skipif(os.environ.get(
"LAYERX_GOLDEN") !=
"1",
72 reason=
"slow LE4 golden burn: set LAYERX_GOLDEN=1 to run it"),
76TOLERANCES: Dict[str, Tuple[str, float]] = {
77 "total_impulse_Ns": (
"rel", 5e-3),
78 "burn_time_s": (
"rel", 5e-3),
79 "mean_thrust_N": (
"rel", 5e-3),
80 "min_thrust_N": (
"rel", 5e-3),
81 "max_thrust_N": (
"rel", 5e-3),
82 "pc_mean_psia": (
"rel", 5e-3),
83 "of_mean": (
"rel", 5e-3),
84 "isp_mean_s": (
"rel", 3e-3),
85 "propellant_used_kg": (
"rel", 5e-3),
86 "copv_end_psia": (
"rel", 1e-2),
87 "copv_used_kg": (
"rel", 1e-2),
88 "ox_tank.peak_psia": (
"abs", 3.0),
89 "fuel_tank.peak_psia": (
"abs", 3.0),
90 "ox_tank.min_psia": (
"abs", 3.0),
91 "fuel_tank.min_psia": (
"abs", 3.0),
92 "ox_stiffness_min": (
"abs", 3e-3),
93 "fuel_stiffness_min": (
"abs", 3e-3),
94 "chug_margin_min": (
"rel", 5e-3),
95 "chug_margin_min_t_s": (
"abs", 0.051),
96 "residual_other_kg": (
"abs", 0.02),
97 "throat_area_growth": (
"rel", 2e-2),
98 "throat_recession_mm": (
"rel", 2e-2),
99 "depleted_side": (
"eq", 0.0),
100 "converged": (
"eq", 0.0),
101 "failed_steps": (
"eq", 0.0),
102 "card_outside_steps": (
"eq", 0.0),
107 spec = importlib.util.spec_from_file_location(
"layerx_baseline", ROOT /
"scripts" /
"layerx_baseline.py")
108 mod = importlib.util.module_from_spec(spec)
109 spec.loader.exec_module(mod)
113def _get(d: Dict[str, Any], path: str) -> Any:
114 for part
in path.split(
"."):
119@pytest.fixture(scope="module")
121 if not BASELINE.is_file():
122 pytest.fail(f
"baseline {BASELINE} is missing; write it with scripts/layerx_baseline.py --out {BASELINE}")
123 data = json.loads(BASELINE.read_text())
124 assert CASE
in data[
"cases"], f
"{BASELINE} has no {CASE!r} case"
125 assert isinstance((data.get(
"inputs")
or {}).get(
"config"), dict),
"baseline does not embed its config"
129@pytest.fixture(scope="module")
130def burn(baseline) -> Dict[str, Any]:
131 pytest.importorskip(
"feedtwin", reason=
"lib/feedtwin is not installed")
133 config, _ = script.load_engine_config(BASELINE)
134 want = baseline[
"cases"][CASE]
135 return script.run_case(CASE, config=config, dt=float(want[
"settings"][
"dt"]))
139 """Every stored field of the baseline's design comes back out of the schema as it went in."""
141 config, _ = script.load_engine_config(BASELINE)
142 again = config.model_dump(mode=
"json")
145 def walk(a: Any, b: Any, path: str) ->
None:
146 if isinstance(a, dict):
147 if not isinstance(b, dict):
150 for k, v
in a.items():
151 walk(v, b.get(k,
"<missing>"), f
"{path}.{k}")
152 elif isinstance(a, list)
and isinstance(b, list)
and len(a) == len(b):
153 for i, (u, v)
in enumerate(zip(a, b)):
154 walk(u, v, f
"{path}[{i}]")
155 elif a != b
and not (isinstance(a, float)
and isinstance(b, float)
and math.isclose(a, b, rel_tol=1e-12)):
156 moved.append(f
"{path}: {a!r} -> {b!r}")
158 walk(baseline[
"inputs"][
"config"], again,
"config")
159 assert not moved,
"the schema rewrites the baseline's design: " +
"; ".join(moved[:10])
163 """The baseline is "today's defaults". A changed default is a changed answer for every run the
164 tab starts, so it is reported like a physics change; the burn below still uses the baseline's
165 own settings, so the figures isolate the code."""
166 pytest.importorskip(
"feedtwin", reason=
"lib/feedtwin is not installed")
167 from dataclasses
import asdict
171 now = {k: v
for k, v
in asdict(LayerXSettings(drawing_id=
"")).items()
if k !=
"drawing_id"}
172 was = baseline[
"layerx_settings_defaults"]
173 moved = {k: (was.get(k), now.get(k))
for k
in set(was) | set(now)
if was.get(k) != now.get(k)}
174 assert not moved, f
"LayerXSettings defaults changed since the baseline (was, now): {moved}"
178 """The drawing, CEA table and DAQ tables are the ones the baseline burned. If this fails, the
179 figures below are expected to move: re-baseline and report the move, do not widen a band."""
180 want, got = baseline[
"cases"][CASE][
"inputs"], burn[
"inputs"]
181 changed = [k
for k
in (
"drawing",
"cea_table")
if want[k][
"sha256"] != got[k][
"sha256"]]
182 if want.get(
"state_machines_sha256") != got.get(
"state_machines_sha256"):
183 changed.append(
"state_machines")
184 assert not changed, f
"inputs changed since the baseline: {changed}"
185 assert got[
"config_sha256"] == want[
"config_sha256"]
188@pytest.mark.parametrize("key", list(TOLERANCES))
190 kind, tol = TOLERANCES[key]
191 want =
_get(baseline[
"cases"][CASE][
"metrics"], key)
192 got =
_get(burn[
"metrics"], key)
194 assert got == want, f
"{key}: baseline {want!r}, now {got!r}"
196 assert want
is not None and got
is not None, f
"{key}: baseline {want!r}, now {got!r}"
198 moved = got / want - 1.0
199 assert abs(moved) <= tol, f
"{key}: baseline {want:.6g}, now {got:.6g} ({moved * 100:+.3f} %, band ±{tol * 100:g} %)"
202 assert abs(moved) <= tol, f
"{key}: baseline {want:.6g}, now {got:.6g} ({moved:+.4g}, band ±{tol:g})"
test_inputs_unchanged(baseline, burn)
Dict[str, Any] baseline()
test_he_pad_matches_baseline(baseline, burn, key)
Dict[str, Any] burn(baseline)
test_layerx_defaults_unchanged(baseline)
Any _get(Dict[str, Any] d, str path)
test_embedded_config_round_trips(baseline)