STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_layerx_golden.py
Go to the documentation of this file.
1"""LE4 golden burn: Layer X's pad helium baseline must not move.
2
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.
7
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
10another baseline file.
11
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.
22
23Tolerances
24----------
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:
28
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
42 figure by 1 %.
43* **Exact** on the discrete outcomes: which tank runs dry, converged, no failed steps, no step
44 outside the engine card.
45
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).
53"""
54
55from __future__ import annotations
56
57import importlib.util
58import json
59import math
60import os
61from pathlib import Path
62from typing import Any, Dict, Tuple
63
64import pytest
65
66ROOT = Path(__file__).resolve().parents[1]
67BASELINE = Path(os.environ.get("LAYERX_GOLDEN_BASELINE") or (ROOT / "docs" / "layerx" / "baseline-2026-10-07.json"))
68CASE = "he_pad"
69
70pytestmark = [
71 pytest.mark.skipif(os.environ.get("LAYERX_GOLDEN") != "1",
72 reason="slow LE4 golden burn: set LAYERX_GOLDEN=1 to run it"),
73]
74
75#: key path -> ("rel" | "abs" | "eq", tolerance)
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),
103}
104
105
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)
110 return mod
111
112
113def _get(d: Dict[str, Any], path: str) -> Any:
114 for part in path.split("."):
115 d = d[part]
116 return d
117
118
119@pytest.fixture(scope="module")
120def baseline() -> Dict[str, Any]:
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"
126 return data
127
128
129@pytest.fixture(scope="module")
130def burn(baseline) -> Dict[str, Any]:
131 pytest.importorskip("feedtwin", reason="lib/feedtwin is not installed")
132 script = _script()
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"]))
136
137
139 """Every stored field of the baseline's design comes back out of the schema as it went in."""
140 script = _script()
141 config, _ = script.load_engine_config(BASELINE)
142 again = config.model_dump(mode="json")
143 moved = []
144
145 def walk(a: Any, b: Any, path: str) -> None:
146 if isinstance(a, dict):
147 if not isinstance(b, dict):
148 moved.append(path)
149 return
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}")
157
158 walk(baseline["inputs"]["config"], again, "config")
159 assert not moved, "the schema rewrites the baseline's design: " + "; ".join(moved[:10])
160
161
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
168
169 from engine.layerx import LayerXSettings
170
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}"
175
176
177def test_inputs_unchanged(baseline, burn):
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"]
186
187
188@pytest.mark.parametrize("key", list(TOLERANCES))
189def test_he_pad_matches_baseline(baseline, burn, key):
190 kind, tol = TOLERANCES[key]
191 want = _get(baseline["cases"][CASE]["metrics"], key)
192 got = _get(burn["metrics"], key)
193 if kind == "eq":
194 assert got == want, f"{key}: baseline {want!r}, now {got!r}"
195 return
196 assert want is not None and got is not None, f"{key}: baseline {want!r}, now {got!r}"
197 if kind == "rel":
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} %)"
200 else:
201 moved = got - want
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)