STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_layerx_integration_le4.py
Go to the documentation of this file.
1"""LE4 burned with every diagnostic wired in: the baseline must not move, and every block must be there.
2
3Slow (three LE4 burns, ~6 min): skipped unless ``LAYERX_GOLDEN=1``, like tests/test_layerx_golden.py,
4whose baseline, config and tolerances these use.
5
6* **he_pad, diagnostics on** (as the router runs every burn): the golden figures are reproduced
7 within the golden bands (the diagnostics read the burn and change nothing in it), every
8 DATA-CONTRACT 3 block is present with its contract keys and finite numbers, the limits follow
9 the coordinator decisions, every event has a unique stable key, the run record lists every model,
10 and the diagnostics cost under 10 % of the burn.
11* **he_flight**: the helium hot-fire drawing flies now that the flight prices its ullage and refill
12 as helium. It reproduces the baseline's "He ullage" what-if (AUDIT 8 first gave 24,235.2 N·s, 3.4461 s, apogee
13 3,249 m AGL), which reached it by patching the config and flying the retired RocketPy outer loop.
14 The burn settles on the pad first -- the same burn as he_pad, figure for figure -- then flies
15 with the 1-DOF ascent stepped inside it (AUDIT D5-C), within 0.5 % of RocketPy's specific force.
16
17Every burn records the whole feed network (``result.network``, DATA-CONTRACT 2, wired 2026-10-03), so
18the blocks that read it (ladder, regulator, solenoids, saturation, the pressurant floor, the stability
19block's start window) must all be there, and agree with the AUDIT's own hand figures for this burn.
20
21* **The AUDIT's trip** (section 5, #1): TK-FUEL's MAWP restated (600 psi in the AUDIT, 585 psi since
22 the default lockup became the drawing's dome). Before the wiring Layer X
23 integrated the frozen stand to the 14 s horizon (99,478 N s, "Horizon reached", no trip). Now the
24 burn stops where the tank crosses 614.7 psia (~3.1 s): its impulse, delivered impulse and burn time
25 end there, it carries ``tripped``, a ``fail`` event keyed ``trip``, ``converged`` false, and a bad
26 ``vessel_trip`` limit, with every diagnostics block still built.
27"""
28
29from __future__ import annotations
30
31import copy
32import importlib.util
33import json
34import math
35import os
36from pathlib import Path
37from typing import Any, Dict
38
39import pytest
40
41ROOT = Path(__file__).resolve().parents[1]
42BASELINE = ROOT / "docs" / "layerx" / "baseline-2026-10-07.json"
43
44pytestmark = [
45 pytest.mark.skipif(os.environ.get("LAYERX_GOLDEN") != "1",
46 reason="slow LE4 burns: set LAYERX_GOLDEN=1 to run them"),
47]
48
49#: DATA-CONTRACT 3: the keys each block must carry when it is available.
50CONTRACT: Dict[str, tuple] = {
51 "stability": ("basis", "t", "margin", "frequency_hz", "worst", "settled_min", "start_window_s", "nyquist",
52 "tau_sweep", "acoustic", "other_basis", "model"),
53 "hardware": ("t", "throat_d_mm", "At_ratio", "eps", "Lstar_m", "contraction", "liner_min_mm", "insert_back_K",
54 "insert_back_basis", "contour", "separation", "isp", "soak", "heatmap", "model"),
55 "pressurant": ("species", "loaded_kg", "used_kg", "residual_kg", "required_kg", "margin_kg", "bottle_T_K", "jt_dT_K"),
56 "cavitation": ("ox", "fuel"),
57 "injector": ("t", "v_ox", "v_fuel", "momentum_ratio", "design_momentum_ratio", "resultant_angle_deg", "eta_cstar"),
58 "thrust_shape": ("mean_N", "dev_max_pct", "dev_rms_pct", "breakdown"),
59 "start": ("t", "pc_psia", "mdot_ox", "mdot_fuel", "mr", "fuel_lead_s", "valve_travel_s", "prime_ox_s",
60 "prime_fuel_s", "ignition_s", "impulse_deficit_Ns", "hard_start", "model"),
61 "shutdown": ("first_dry", "mode", "tail_mr_max", "model"),
62 "vv": ("mass", "pressurant", "energy", "convergence"),
63 "ladder": ("t", "ox", "fuel"),
64 "regulator": ("t", "inlet_psia", "outlet_psia", "mdot", "capacity_mdot", "use_frac", "droop_psi", "spe_psi",
65 "choked", "wide_open", "cv"),
66 "saturation": ("nodes",),
67}
68ROWS = {
69 "water_hammer": ("line", "side", "closure_s", "joukowsky_psi", "slow_close_psi", "peak_psia", "rating_psia", "ok"),
70 "outflow": ("tank", "side", "ingestion_onset_s", "residual_kg", "outlet_d_mm", "model"),
71 "ledger": ("key", "label", "design_value", "unit", "delivered", "series_ref", "replaced", "note"),
72 "solenoids": ("id", "side", "cv", "dp_psi", "share_of_reg_to_tank"),
73}
74#: Scalars that must be finite numbers when their block is available.
75FINITE = {
76 "start": ("fuel_lead_s", "valve_travel_s", "prime_ox_s", "prime_fuel_s", "ignition_s", "impulse_deficit_Ns"),
77 "shutdown": ("tail_mr_max",),
78 "pressurant": ("loaded_kg", "used_kg", "residual_kg"),
79 "thrust_shape": ("mean_N", "dev_max_pct", "dev_rms_pct"),
80 "stability": ("start_window_s",),
81}
82NEEDS_NETWORK = ("ladder", "regulator", "solenoids", "saturation")
83#: The full LE4 he_pad burn: 24,244.2 N s (docs/layerx/baseline-2026-10-07.json).
84FULL_IMPULSE_NS = 24244.24118762319
85#: The restated fuel-tank MAWP that trips he_pad late in its rise (the AUDIT's 600 psi, moved with the lockup).
86TRIP_MAWP_PSI = 585.0
87
88
89def _script():
90 spec = importlib.util.spec_from_file_location("layerx_baseline", ROOT / "scripts" / "layerx_baseline.py")
91 mod = importlib.util.module_from_spec(spec)
92 spec.loader.exec_module(mod)
93 return mod
94
95
96def _golden():
97 spec = importlib.util.spec_from_file_location("golden", ROOT / "tests" / "test_layerx_golden.py")
98 mod = importlib.util.module_from_spec(spec)
99 spec.loader.exec_module(mod)
100 return mod
101
102
103def _burn(case: str, overrides=(), **settings: Any) -> Dict[str, Any]:
104 """One LE4 burn as the router runs it: a private copy of the baseline's config, its own runner,
105 diagnostics on, the sidecar kept."""
106 import time
107
108 pytest.importorskip("feedtwin", reason="lib/feedtwin is not installed")
109 from engine.core.runner import PintleEngineRunner
110 from engine.layerx import LayerXSettings, prepare, run_prepared
111
112 lb = _script()
113 config, _ = lb.load_engine_config(BASELINE)
114 drawing = lb.find_drawing("copv_study_he")
115 cfg = copy.deepcopy(config)
116 runner = PintleEngineRunner(cfg)
117 settings.setdefault("tank_pressure_psia", lb.STUDY_LOCKUP_PSIA)
118 prep = prepare(cfg, runner, drawing, LayerXSettings(drawing_id=drawing.id, **settings), list(overrides))
119 assert prep.ok, [f"{c.key}: {c.detail}" for c in prep.checks if c.status == "fail"]
120 sidecars: Dict[str, Any] = {}
121 t0 = time.perf_counter()
122 result = run_prepared(prep, runner=runner, replay=prep.settings.replay, config=cfg, diagnostics=True,
123 sidecars=sidecars)
124 return {"result": result, "prep": prep, "sidecars": sidecars, "metrics": lb.metrics(result, prep),
125 "wall_s": time.perf_counter() - t0, "case": case}
126
127
128@pytest.fixture(scope="module")
129def he_pad():
130 return _burn("he_pad")
131
132
133@pytest.fixture(scope="module")
135 return _burn("he_flight", flight=True)
136
137
138@pytest.fixture(scope="module")
140 from engine.layerx.measurements import Override
141
142 # The AUDIT restated 600 psi against the design's 578 psia lockup (tank 578 -> 619 psia, trip at
143 # 614.7 psia ~3.1 s in). Since 2026-10-07 the default lockup is the drawing's dome, 564.7 psia here
144 # (tank 565 -> 603 psia), which 600 psi never trips: 585 psi trips at the same point of the rise.
145 restated = Override(target="node:FUT", parameter="MAWP", value=TRIP_MAWP_PSI, unit="psi",
146 source="AUDIT 5.2 what-if (not a measurement)")
147 return _burn("he_pad_fuel_mawp_585", overrides=[restated])
148
149
150# ---------------------------------------------------------------- he_pad
151
152
154 golden = _golden()
155 want = json.loads(BASELINE.read_text())["cases"]["he_pad"]["metrics"]
156 moved = []
157 for key, (kind, tol) in golden.TOLERANCES.items():
158 a, b = golden._get(want, key), golden._get(he_pad["metrics"], key)
159 if kind == "eq":
160 ok = a == b
161 elif kind == "rel":
162 ok = a is not None and b is not None and abs(b / a - 1.0) <= tol
163 else:
164 ok = a is not None and b is not None and abs(b - a) <= tol
165 if not ok:
166 moved.append(f"{key}: {a!r} -> {b!r}")
167 assert not moved, moved
168
169
171 from engine.layerx.analysis import DIAGNOSTIC_KEYS
172
173 r = he_pad["result"]
174 d = r["diagnostics"]
175 assert set(d) == set(DIAGNOSTIC_KEYS)
176 json.dumps(r, allow_nan=False) # strict JSON: no NaN or inf anywhere in the run
177 n = len(r["series"]["t"])
178 for name, keys in CONTRACT.items():
179 block = d[name]
180 assert block.get("available", True) is not False, (name, block.get("error"))
181 missing = [k for k in keys if k not in block]
182 assert not missing, (name, missing)
183 for k in FINITE.get(name, ()):
184 assert isinstance(block[k], (int, float)) and math.isfinite(block[k]), (name, k, block[k])
185 for name, keys in ROWS.items():
186 rows = d[name]
187 assert isinstance(rows, list) and rows, (name, rows)
188 for row in rows:
189 assert row.get("available", True) is not False, (name, row.get("error"))
190 assert not [k for k in keys if k not in row], (name, [k for k in keys if k not in row])
191 # The bottle's floor needs the regulator's capacity, which reads the recorded network.
192 pr = d["pressurant"]
193 assert math.isfinite(pr["required_kg"]) and math.isfinite(pr["margin_kg"]) and pr["margin_kg"] > 0
194 # Lists that follow the series are as long as it.
195 assert len(d["injector"]["t"]) == n and len(d["injector"]["momentum_ratio"]) == n
196 # The ones on the replay's points say which steps they are.
197 st = d["stability"]
198 assert len(st["t"]) == len(st["margin"]) == len(st["index"])
199 assert [r["series"]["t"][i] for i in st["index"]] == st["t"]
200 # The model blocks are complete.
201 for name in ("stability", "hardware", "start", "shutdown", "vv"):
202 m = d[name]["model"]
203 assert m.get("name") and m.get("source") and isinstance(m.get("assumptions"), list), name
204 # D7 (2026-10-03): the block is on the drawing's lines; the design's lumped feed is the other
205 # basis, whose minimum is the replay's own chug margin (to the start window's extra steps).
206 assert st["basis"] == "drawing" and st["other_basis"]["basis"] == "config"
207 assert st["other_basis"]["margin_min"] == pytest.approx(min(r["replay"]["chug_margin"]), rel=0.02)
208
209
211 r = he_pad["result"]
212 net, d = r["network"], r["diagnostics"]
213 assert net["t"] == r["series"]["t"]
214 assert set(net["paths"]) == {"ox", "fuel"}
215 # The ladder telescopes: bottle to chamber, rung by rung, on every step.
216 for side in ("ox", "fuel"):
217 assert d["ladder"][side]["closes"] and d["ladder"][side]["max_abs_residual_psi"] < 1e-6
218 # AUDIT 9.6 C: on helium the dome regulator runs at 5-18 % of its IEC choked capacity, and the
219 # 1.7 Cv press solenoids cost 1.3-3.0 psi per tank.
220 assert 0.05 < d["regulator"]["use_frac_max"] < 0.20 and not d["regulator"]["any_wide_open"]
221 for row in d["solenoids"]:
222 assert 1.0 < row["dp_max_psi"] < 3.5, row["id"]
223 # The start window opens on the main valves' recorded state, not on the travel-time rule.
224 assert d["stability"]["start"]["t_open_basis"] == "recorded valve state"
225 # The run record carries it: about 1 MB with the network (~0.67 MB without).
226 assert len(json.dumps(r, allow_nan=False)) < 2_000_000
227
228
230 """The team's answers of 2026-10-03: D7 (drawing lines, eroded engine, from the first full-flow
231 step), tank limits and the opening surge as information, the mains never closing."""
232 r = he_pad["result"]
233 g = {e["key"]: e for e in r["limits"]}
234 st = r["diagnostics"]["stability"]
235 assert g["chug_margin"]["value"] == pytest.approx(st["settled_min"]["margin"], rel=1e-12)
236 assert g["chug_margin"]["t_worst"] > 0.05 and "decision" not in g["chug_margin"]
237 assert g["chug_margin_other_basis"]["grade"] == "info"
238 assert g["chug_margin_other_basis"]["value"] == pytest.approx(st["other_basis"]["margin_min"])
239 # D7 raises the graded figure above the old whole-burn, lumped-feed one (1.398 -> ~1.47 on LE4).
240 assert g["chug_margin"]["value"] > g["chug_margin_other_basis"]["value"]
241 for key in ("water_hammer_l_fu1", "water_hammer_l_ox1", "tank_cap_ox", "tank_mawp_ox", "tank_mawp_fuel"):
242 assert g[key]["grade"] == "info", key
243 assert g["water_hammer_l_fu1"]["label"].startswith("Opening surge")
244 # LE4 on helium: nothing to look at.
245 assert not [e["key"] for e in r["limits"] if e["grade"] in ("bad", "warn")]
246
247
249 r = he_pad["result"]
250 keys = [e["key"] for e in r["events"]]
251 assert len(keys) == len(set(keys)) and all(keys)
252 assert {"t0", "fire", "ignition", "min_tank_ox", "min_tank_fuel", "min_chug", "dry_ox", "burnout"} <= set(keys)
253 ev = {e["key"]: e for e in r["events"]}
254 assert 0.0 < ev["ignition"]["t"] < 0.05 and ev["burnout"]["t"] == pytest.approx(r["summary"]["burn_time_s"])
255 assert r["test_mode"] == "hotfire"
256 assert r["provenance"]["derived"]["options"]["chug_basis"] == "drawing"
257 models = r["provenance"]["models"]
258 assert len(models) >= 10 and all(m["name"] and m["source"] for m in models)
259 assert {"diagnostics.stability", "diagnostics.hardware", "diagnostics.start", "diagnostics.vv"} <= {m["block"] for m in models}
260 ax = he_pad["sidecars"]["axial"]
261 assert len(ax["q_MW_m2"]) == len(ax["t"]) and all(len(row) == len(ax["x_mm"]) for row in ax["q_MW_m2"])
262 assert r["diagnostics"]["hardware"]["heatmap"] == "sidecar:axial"
263 assert r["diagnostics"]["hardware"]["soak"]["available"]
264 cost = r["provenance"]["diagnostics_wall_s"]
265 assert cost["diagnostics"] >= 0.0 and cost["burn"] > 0.0, cost # recorded, not timed here
266
267
268# ---------------------------------------------------------------- flight
269
270
272 r = he_flight["result"]
273 f = r["flight"]
274 assert f["ok"], f.get("error")
275 assert f["pressurant_gas"] == "Helium"
276 what_if = json.loads(BASELINE.read_text())["cases"]["he_flight_he_ullage"]["metrics"]
277 m = he_flight["metrics"]
278 assert m["total_impulse_Ns"] == pytest.approx(what_if["total_impulse_Ns"], rel=5e-4)
279 assert m["burn_time_s"] == pytest.approx(what_if["burn_time_s"], rel=5e-4)
280 assert f["apogee_agl_m"] == pytest.approx(what_if["apogee_agl_m"], abs=5.0)
281 assert r["converged"] and f["stability"]["available"]
282 # The flight's acceleration reaches the gas-ingestion diagnostic.
283 assert "flight" in r["diagnostics"]["outflow"][0]["model"]["inputs"]["accel"]["provenance"]
284
285
287 from engine.layerx import flight as flt
288
289 r = he_flight["result"]
290 f = r["flight"]
291 assert f["ok"] and f["coupling"] == "inline" and f["inline"]["available"]
292 assert f["inline"]["vs_rocketpy"] < flt.ACCEL_TOLERANCE
293 assert f["inline"]["liftoff_mass_kg"] == pytest.approx(f["liftoff_mass_kg"], abs=0.01)
294 passes = r["passes"]
295 pad = [p for p in passes if not p.get("accel_inline")]
296 # The pad's passes are he_pad's own, and its figures are he_pad's burn.
297 assert len(pad) == len(he_pad["result"]["passes"]) and len(passes) > len(pad)
298 assert all(p.get("accel_inline") for p in passes[len(pad):])
299 assert f["pad"]["total_impulse_Ns"] == pytest.approx(he_pad["metrics"]["total_impulse_Ns"], rel=1e-9)
300 assert f["pad"]["of_mean"] == pytest.approx(he_pad["result"]["summary"]["of_mean"], rel=1e-9)
301 assert f["in_flight"]["total_impulse_Ns"] == pytest.approx(he_flight["metrics"]["total_impulse_Ns"], rel=1e-9)
302
303
304# ---------------------------------------------------------------- the AUDIT's trip
305
306
308 r = he_trip["result"]
309 trip = r["tripped"]
310 assert trip["vessel"] == "FUT" and trip["kind"] == "tank"
311 assert trip["mawp_psia"] == pytest.approx(TRIP_MAWP_PSI + 101325.0 / 6894.757293168361, abs=1e-6)
312 assert 2.5 < trip["t"] < r["summary"]["burn_time_s"] + 1e-9 < 3.4
313 s, dv = r["summary"], r["delivered"]["summary"]
314 # Not the 4x horizon artefact (99,478 N s over 14.000 s): the impulse stops at the trip.
315 assert s["burn_time_s"] == pytest.approx(trip["t"], abs=1e-12)
316 assert r["series"]["t"][-1] == pytest.approx(trip["t"], abs=1e-12)
317 assert 0.8 * FULL_IMPULSE_NS < s["total_impulse_Ns"] < FULL_IMPULSE_NS
318 assert 0.8 * FULL_IMPULSE_NS < dv["total_impulse_Ns"] < FULL_IMPULSE_NS
319 assert r["delivered"]["t"][-1] == pytest.approx(trip["t"], abs=1e-12)
320 # Up to the trip it is the same burn as he_pad's first pass would be: the untripped burn's
321 # impulse to the same instant, within the throat schedule's (one replay pass vs settled) effect.
322 pad = he_pad["result"]["series"]
323 upto = sum(f * dt for f, dt, on, t in zip(pad["chamber"]["thrust_N"], pad["dt"], pad["firing"], pad["t"])
324 if on and t <= trip["t"] + 1e-9)
325 assert s["total_impulse_Ns"] == pytest.approx(upto, rel=0.01)
326 assert r["converged"] is False and len(r["passes"]) == 1 and r["passes"][0]["tripped"]
327 ev = {e["key"]: e for e in r["events"]}
328 assert ev["trip"]["kind"] == "fail" and ev["trip"]["t"] == pytest.approx(trip["t"])
329 assert not any(e["label"] in ("Horizon reached", "Burn did not settle") for e in r["events"])
330 g = {e["key"]: e for e in r["limits"]}
331 # The trip is the stand stopping, graded red; the tank limit itself is information (2026-10-03).
332 assert g["vessel_trip"]["grade"] == "bad" and g["tank_mawp_fuel"]["grade"] == "info"
333 for name, block in r["diagnostics"].items():
334 rows = block if isinstance(block, list) else [block]
335 assert all(row.get("available", True) is not False for row in rows), (name, rows[0].get("error"))
336 assert len(r["network"]["t"]) == len(r["series"]["t"])
#define min(a, b)
Definition Arduino.h:42
Dict[str, Any] _burn(str case, overrides=(), **Any settings)
test_the_audits_trip_stops_the_burn_at_the_trip(he_trip, he_pad)
test_every_diagnostics_block_is_there_with_its_contract_keys_and_finite_numbers(he_pad)
test_the_helium_drawing_flies_and_matches_the_audits_he_ullage_what_if(he_flight)
test_the_flight_settles_on_the_pad_first_then_flies_inline(he_flight, he_pad)