STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_layerx_limits.py
Go to the documentation of this file.
1"""One graded list of limits for a Layer X burn (engine/layerx/diag/limits.py).
2
3What these check, and against what:
4
5* **Today's UI grades.** ``verdictItems`` in ``frontend/src/components/layerx/LayerXResult.tsx`` is
6 transcribed below, line for line, as an independent oracle (``_ui_verdict``), and every saved burn
7 in ``.userdata`` (read only) must grade the same through :func:`grade`. The saved runs are the
8 user's; when none is present the parity test skips and the hand-built results below still run.
9* **Worst-case times, by hand.** For the 2026-10-02 flown GN2 run ``7e47d1`` the audit (AUDIT 9.9 D)
10 worked out each limit's worst time from the series by hand: LOX dP/Pc 0.3082 at 0.75 s, fuel
11 0.2858 at 0.20 s, both tank peaks at burnout (3.466 s), the sag at 0.40 s (fuel), chug 1.2046 at
12 0.80 s. Those numbers are asserted.
13* **The index is the time.** For every series-backed limit on every saved run, the series value at
14 ``index_worst`` is the graded value and ``series.t[index_worst]`` is ``t_worst``.
15* **Each new grader on a hand-built result**: thresholds at, just inside and just outside their
16 edges, so a flipped comparison or a wrong default goes red.
17"""
18
19from __future__ import annotations
20
21import copy
22import glob
23import json
24import math
25from pathlib import Path
26
27import pytest
28
29from engine.layerx.diag.limits import THRESHOLDS, breaks, grade, overall, requirements
30
31RUNS = Path(__file__).resolve().parents[1] / ".userdata" / "local" / "engine" / "layerx" / "runs"
32REFERENCE = RUNS / "20261002-231658-7e47d1.json"
33PSI = 6894.757293168361
34
35
37 out = []
38 for f in sorted(glob.glob(str(RUNS / "*.json"))):
39 try:
40 d = json.loads(Path(f).read_text())
41 except Exception: # noqa: BLE001 - a half-written run is not a test input
42 continue
43 if d.get("kind") == "run" and isinstance(d.get("result"), dict) and d["result"].get("series"):
44 out.append((Path(f).name, d["result"]))
45 return out
46
47
48SAVED = _saved_burns()
49needs_saved = pytest.mark.skipif(not SAVED, reason="no saved Layer X burns in .userdata (the user's runs)")
50
51
52def _by_key(entries):
53 return {e["key"]: e for e in entries}
54
55
56# ---------------------------------------------------------------- the UI, transcribed
57
58
59VERDICT = {"copvHeadroomPsi": 100, "droopPsi": (30, 60), "residualKg": 0.2, "stiffnessFloor": 0.15,
60 "chugMarginWarn": 1.2, "ratingUse": 0.8, "depletionTie": 0.03, "replayAgreement": (0.005, 0.02)}
61
62
63def _ui_verdict(result):
64 """``verdictItems`` + the context ``LayerXResultView`` builds, as the UI ran on 2026-10-02.
65 Returns {my key: grade} for the items the UI would show."""
66 s = result["summary"]
67 prov = result.get("provenance") or {}
68 d = prov.get("derived") or {}
69 band = d.get("stiffness_band") or {}
70 roles = d.get("roles") or {}
71 mawps = d.get("tank_mawp_psi") or {}
72 ambient = d["ambient_pa"] / PSI if isinstance(d.get("ambient_pa"), (int, float)) else 14.6959
73 dvs = (result.get("delivered") or {}).get("summary")
74 out = {}
75
76 def stiff(v, b):
77 if v is None:
78 return "warn"
79 if b:
80 return "bad" if v < b[0] else "warn" if v > b[1] else "ok"
81 return "bad" if v < VERDICT["stiffnessFloor"] else "ok"
82
83 if dvs and dvs.get("chug_margin_min") is not None and math.isfinite(dvs["chug_margin_min"]):
84 m = dvs["chug_margin_min"]
85 out["chug_margin"] = "bad" if m < 1 else "warn" if m < VERDICT["chugMarginWarn"] else "ok"
86 out["stiffness_ox"] = stiff(s["ox"].get("stiffness_min"), band.get("oxidiser"))
87 out["stiffness_fuel"] = stiff(s["fuel"].get("stiffness_min"), band.get("fuel"))
88 for side, short in (("oxidiser", "ox"), ("fuel", "fuel")):
89 peak = s[short].get("peak_psia")
90 rating = mawps.get(roles.get(side))
91 if peak is None or rating is None:
92 continue
93 use = (peak - ambient) / rating
94 # Information since 2026-10-03 (the team: "don't worry about tank limits for now").
95 out[f"tank_mawp_{short}"] = "info"
96 lockup = max(s["ox"]["t0_psia"], s["fuel"]["t0_psia"])
97 margin = s["copv_end_psia"] - lockup if s.get("copv_end_psia") is not None else None
98 h = VERDICT["copvHeadroomPsi"]
99 out["bottle_margin"] = "warn" if margin is None else "bad" if margin < h else "warn" if margin < 2 * h else "ok"
100 droop = max(s["ox"]["t0_psia"] - (s["ox"].get("min_psia") if s["ox"].get("min_psia") is not None else s["ox"]["t0_psia"]),
101 s["fuel"]["t0_psia"] - (s["fuel"].get("min_psia") if s["fuel"].get("min_psia") is not None else s["fuel"]["t0_psia"]))
102 out["tank_sag"] = "bad" if droop > VERDICT["droopPsi"][1] else "warn" if droop > VERDICT["droopPsi"][0] else "ok"
103 side = s.get("depleted_side")
104 residual = s["fuel"]["residual_kg"] if side == "oxidiser" else s["ox"]["residual_kg"] if side == "fuel" else None
105 if residual is not None:
106 tie = VERDICT["depletionTie"] * (s["ox"]["loaded_kg"] + s["fuel"]["loaded_kg"])
107 # Information since 2026-10-03 (the team: one tank always runs dry first).
108 out["depletion_tie"] = "info"
109 if residual > VERDICT["residualKg"]:
110 out["residual"] = "warn"
111 if result.get("converged") is False:
112 out["unsettled"] = "warn"
113 if s.get("card_outside_steps"):
114 out["card_outside"] = "warn"
115 ec = result.get("engine_check") or {}
116 if ec.get("available") and ec.get("worst"):
117 w = ec["worst"]
118 gap = max(w["pc"], w["mdot_O"], w["mdot_F"], 0 if ec.get("against") == "replay" else w["thrust"])
119 if gap >= VERDICT["replayAgreement"][0]:
120 out["engine_fit"] = "warn" if gap < VERDICT["replayAgreement"][1] else "bad"
121 if s.get("failed_steps") or not s.get("t0_settled"):
122 out["solver"] = "warn"
123 return out
124
125
126@pytest.mark.skipif(not REFERENCE.is_file(), reason="the 2026-10-02 reference run 7e47d1 is not in .userdata")
128 """AUDIT 9.9 D, worked by hand from the series of run 7e47d1."""
129 result = json.loads(REFERENCE.read_text())["result"]
130 g = _by_key(grade(result))
131 assert g["chug_margin"]["value"] == pytest.approx(1.2046, abs=1e-4)
132 assert g["chug_margin"]["t_worst"] == pytest.approx(0.80, abs=1e-9)
133 assert g["chug_margin"]["grade"] == "ok"
134 assert g["stiffness_ox"]["value"] == pytest.approx(0.3082, abs=1e-4)
135 assert g["stiffness_ox"]["t_worst"] == pytest.approx(0.75, abs=1e-9)
136 assert g["stiffness_fuel"]["value"] == pytest.approx(0.2858, abs=1e-4)
137 assert g["stiffness_fuel"]["t_worst"] == pytest.approx(0.20, abs=1e-9)
138 # The ignition minimum the summary leaves out: 0.2860 at 0.15 s, reported, not graded.
139 assert g["stiffness_fuel_ignition"]["value"] == pytest.approx(0.2860, abs=1e-4)
140 assert g["stiffness_fuel_ignition"]["t_worst"] == pytest.approx(0.15, abs=1e-9)
141 assert g["stiffness_fuel_ignition"]["grade"] == "info"
142 for short in ("ox", "fuel"):
143 assert g[f"tank_mawp_{short}"]["t_worst"] == pytest.approx(3.4663, abs=1e-4)
144 # Across the wall on the gauge zero the drawing rates the tank on (the trip is absolute), not the site.
145 assert g["tank_mawp_ox"]["value"] == pytest.approx(580.28 - 101325.0 / PSI, abs=0.01)
146 assert g["bottle_margin"]["value"] == pytest.approx(1405.71 - 578.05, abs=0.01)
147 assert g["tank_sag"]["value"] == pytest.approx(578.0486 - 552.0311, abs=1e-3)
148 assert g["tank_sag"]["t_worst"] == pytest.approx(0.40, abs=1e-9)
149 assert g["depletion_tie"]["grade"] == "info" and g["depletion_tie"]["value"] == pytest.approx(0.0177, abs=1e-4)
150 # The design cap (600 psi) is read from the YAML the run recorded.
151 assert g["tank_cap_fuel"]["limit"] == 600.0 and g["tank_cap_fuel"]["grade"] == "info"
152 # Its only amber was which tank runs dry first, information since 2026-10-03. Its one red is the
153 # rail exit, 21.7 m/s against FAR-OUT's 85 ft/s (rule 5.1.4), which the design does not state.
154 assert [k for k, e in g.items() if e["grade"] == "bad"] == ["rail_exit"]
155 assert overall([e for k, e in g.items() if k != "rail_exit"]) == "ok"
156
157
158SERIES_BACKED = {"stiffness_ox": "ox.stiffness", "stiffness_fuel": "fuel.stiffness",
159 "tank_mawp_ox": "ox.tank_psia", "tank_mawp_fuel": "fuel.tank_psia",
160 "tank_cap_ox": "ox.tank_psia", "tank_cap_fuel": "fuel.tank_psia"}
161
162
163@needs_saved
164@pytest.mark.parametrize("name,result", SAVED, ids=[n for n, _ in SAVED])
166 series = result["series"]
167 d = result["provenance"].get("derived") or {}
168 zero = d.get("gauge_zero_pa", 101325.0) / PSI # the tank rating is graded across the wall on the gauge zero
169 t = series["t"]
170 for e in grade(result):
171 i = e["index_worst"]
172 if i is not None:
173 assert 0 <= i < len(t)
174 if e["group"] == "flight":
175 # Flight limits are timed on RocketPy's trajectory samples (max-Q at 3.4334 s on the
176 # 2026-10-03 He flights), not on the twin's steps: the index is the step nearest them.
177 nearest = min(range(len(t)), key=lambda k: abs(t[k] - e["t_worst"]))
178 assert i == nearest, f"{name}: {e['key']}"
179 else:
180 assert t[i] == pytest.approx(e["t_worst"], abs=1e-9), f"{name}: {e['key']}"
181 path = SERIES_BACKED.get(e["key"])
182 if path is None:
183 continue
184 side, col = path.split(".")
185 v = series[side][col][i]
186 expect = v - zero if e["key"].startswith("tank_mawp") else v
187 assert expect == pytest.approx(e["value"], rel=1e-12), f"{name}: {e['key']} at index {i}"
188
189
190# ---------------------------------------------------------------- hand-built results
191
192
193def _result(n=8, dt=0.05):
194 """A small burn: two lead-in samples, ``n`` firing steps, dull numbers."""
195 t = [-0.05, 0.0] + [dt * (k + 1) for k in range(n)]
196 firing = [False, False] + [True] * n
197 flat = lambda v: [v] * len(t) # noqa: E731
198 side = lambda: {"tank_psia": flat(578.0), "stiffness": [0.0, 0.0] + [0.30] * n, # noqa: E731
199 "liquid_kg": flat(5.0)}
200 s = {"ox": side(), "fuel": side()}
201 summary = {
202 "depleted_side": "oxidiser", "burn_time_s": t[-1], "copv_end_psia": 1500.0, "copv_t0_psia": 4500.0,
203 "ox": {"t0_psia": 578.0, "min_psia": 570.0, "stiffness_min": 0.30, "peak_psia": 578.0,
204 "loaded_kg": 6.0, "residual_kg": 0.0},
205 "fuel": {"t0_psia": 578.0, "min_psia": 572.0, "stiffness_min": 0.30, "peak_psia": 578.0,
206 "loaded_kg": 4.0, "residual_kg": 0.5},
207 "failed_steps": 0, "t0_settled": True, "card_outside_steps": 0, "steps": len(t),
208 }
209 return {
210 "series": {"t": t, "dt": [dt] * len(t), "firing": firing, "converged": [True] * len(t),
211 "copv_psia": flat(1500.0), **s},
212 "summary": summary,
213 "events": [],
214 "converged": True,
215 "provenance": {"derived": {"stiffness_band": {"oxidiser": [0.2, 0.4], "fuel": [0.2, 0.4]},
216 "roles": {"oxidiser": "OXT", "fuel": "FUT"},
217 "tank_mawp_psi": {"OXT": 1000.0, "FUT": 1000.0},
218 "ambient_pa": 101325.0}},
219 }
220
221
223 assert grade({}) == []
224 assert grade({"series": {"t": []}}) == []
225
226
228 r = _result()
229 r["diagnostics"] = {"stability": {
230 "basis": "drawing", "t": r["series"]["t"], "margin": [None, None] + [1.15, 1.3, 1.31, 1.32, 1.33, 1.34, 1.35, 1.36],
231 "worst": {"t": 0.05, "index": 2, "margin": 1.15, "frequency_hz": 34.6},
232 "settled_min": {"t": 0.10, "index": 3, "margin": 1.30, "frequency_hz": 22.0}, "start_window_s": 0.1,
233 "other_basis": {"basis": "config", "margin_min": 1.25, "t": 0.1}}}
234 g = _by_key(grade(r))
235 assert g["chug_margin"]["value"] == 1.30 and g["chug_margin"]["t_worst"] == 0.10
236 assert g["chug_margin"]["index_worst"] == 3
237 # The frequency travels with the graded point, not with the start window's worst (AUDIT 5.1).
238 assert g["chug_margin"]["frequency_hz"] == 22.0 and g["chug_margin_start"]["frequency_hz"] == 34.6
239 assert g["chug_margin"]["series_ref"] == "diagnostics.stability.margin"
240 assert g["chug_margin"]["grade"] == "ok" and "drawing feed basis" in g["chug_margin"]["basis"]
241 assert "after the first 0.100 s" in g["chug_margin"]["basis"]
242 # The start window is a solved time: printed to the millisecond, not as '0.0556569 s'.
243 r["diagnostics"]["stability"]["start_window_s"] = 0.0556569
244 assert "after the first 0.056 s" in _by_key(grade(r))["chug_margin"]["basis"]
245 assert g["chug_margin_start"]["grade"] == "info" and g["chug_margin_start"]["value"] == 1.15
246 assert g["chug_margin_start"]["index_worst"] == 2
247 assert g["chug_margin_other_basis"]["grade"] == "info"
248
249
250@pytest.mark.parametrize("margin,expect", [(0.99, "bad"), (1.0, "warn"), (1.19, "warn"), (1.2, "ok"), (1.5, "ok")])
252 r = _result()
253 r["delivered"] = {"t": r["series"]["t"][2:], "chug_margin": [margin] * 8,
254 "summary": {"chug_margin_min": margin, "chug_margin_min_t": 0.05}}
255 g = _by_key(grade(r))
256 assert g["chug_margin"]["grade"] == expect
257 assert g["chug_margin"]["limit"] == 1.0 and g["chug_margin"]["warn"] == 1.2
258
259
261 r = _result()
262 r["delivered"] = {"t": r["series"]["t"][2:], "chug_margin": [1.3] * 8,
263 "summary": {"chug_margin_min": 1.3, "chug_margin_min_t": 0.05}}
264
265 class Cfg:
266 class design_requirements: # noqa: N801 - mimics the config attribute
267 min_stability_margin = 1.05
268
269 assert _by_key(grade(r, config=Cfg))["chug_margin"]["warn"] == 1.2
270 Cfg.design_requirements.min_stability_margin = 1.5
271 g = _by_key(grade(r, config=Cfg))["chug_margin"]
272 assert g["warn"] == 1.5 and g["grade"] == "warn"
273 assert "1.5" in g["hint"]
274
275
277 """A run graded on the drawing's feed (``options.chug_basis``, decided 2026-10-03) that carries no
278 stability block -- every set-point, Hardware, reconcile and sweep burn -- has only the replay's
279 margin, on EngineDesign's feed_system. It was graded as "the" chug margin: 1.21 'ok' on LE4 (3),
280 where the drawing basis grades 1.004 'warn'. It is now information, labelled with its basis. A run
281 saved before the decision (no options, or ``config``) grades it as it always did."""
282 r = _result()
283 r["delivered"] = {"t": r["series"]["t"][2:], "chug_margin": [0.9] * 8,
284 "summary": {"chug_margin_min": 0.9, "chug_margin_min_t": 0.05}}
285 assert _by_key(grade(r))["chug_margin"]["grade"] == "bad"
286 r["provenance"]["derived"]["options"] = {"chug_basis": "config"}
287 assert _by_key(grade(r))["chug_margin"]["grade"] == "bad"
288 r["provenance"]["derived"]["options"] = {"chug_basis": "drawing"}
289 g = _by_key(grade(r))["chug_margin"]
290 assert g["grade"] == "info" and g["value"] == 0.9 and g["feed_basis"] == "config"
291 assert "config basis" in g["label"] and "not graded" in g["label"]
292
293
295 r = _result()
296 col = [1.4, 1.35, 1.3, 1.25, 1.21, 1.3, 1.4, 1.5]
297 r["delivered"] = {"t": r["series"]["t"][2:], "chug_margin": col,
298 "summary": {"chug_margin_min": 1.21, "chug_margin_min_t": 0.25}}
299 g = _by_key(grade(r))["chug_margin"]
300 assert g["index_worst"] == 2 + 4 and r["series"]["t"][g["index_worst"]] == pytest.approx(0.25)
301
302
304 r = _result()
305 r["summary"]["ox"]["stiffness_min"] = 0.199
306 r["summary"]["fuel"]["stiffness_min"] = 0.401
307 g = _by_key(grade(r))
308 assert g["stiffness_ox"]["grade"] == "bad" and g["stiffness_fuel"]["grade"] == "warn"
309 assert g["stiffness_ox"]["band"] == [0.2, 0.4]
310 r["provenance"]["derived"]["stiffness_band"] = {"oxidiser": None, "fuel": None}
311 r["summary"]["ox"]["stiffness_min"] = 0.149
312 r["summary"]["fuel"]["stiffness_min"] = 0.9
313 g = _by_key(grade(r))
314 assert g["stiffness_ox"]["grade"] == "bad" and g["stiffness_ox"]["limit"] == THRESHOLDS["stiffness_floor"]["value"]
315 assert g["stiffness_fuel"]["grade"] == "ok"
316
317
319 r = _result()
320 st = r["series"]["ox"]["stiffness"]
321 st[2] = 0.10 # t = 0.05: the start, left out of the graded minimum
322 st[6] = 0.25 # t = 0.25: the graded minimum
323 r["summary"]["ox"]["stiffness_min"] = 0.25
324 g = _by_key(grade(r))["stiffness_ox"]
325 assert g["index_worst"] == 6 and g["t_worst"] == pytest.approx(0.25)
326
327
329 r = _result()
330 r["series"]["fuel"]["tank_psia"][5] = 619.0
331 r["summary"]["fuel"]["peak_psia"] = 619.0
332 ambient = 101325.0 / PSI
333
334 class Cfg:
335 class design_requirements: # noqa: N801
336 max_lox_tank_pressure_psi = 600.0
337 max_fuel_tank_pressure_psi = 600.0
338
339 g = _by_key(grade(r, config=Cfg, meop_psi={"fuel": 600.0}))
340 assert g["tank_mawp_fuel"]["value"] == pytest.approx(619.0 - ambient)
341 assert g["tank_mawp_fuel"]["index_worst"] == 5
342 # The tank limits are reported, not graded (the team, 2026-10-03: "don't worry about tank limits
343 # for now"): MAWP, MEOP (619 - 14.7 = 604.3 psi > 600) and the design cap are all information.
344 assert {g[k]["grade"] for k in ("tank_mawp_fuel", "tank_cap_fuel", "tank_cap_ox", "tank_meop_fuel")} == {"info"}
345 assert "tank_meop_ox" not in g
346 r["provenance"]["derived"]["tank_mawp_psi"]["FUT"] = 600.0
347 assert _by_key(grade(r))["tank_mawp_fuel"]["grade"] == "info"
348
349
351 """``max_*_tank_pressure_psi`` default to 700 / 850 psi in the schema with no source. A design that
352 states its caps (LE4: 600 / 600) is graded against them; one that does not gets the default
353 reported as information, and says so, rather than amber against a number nobody chose."""
354 from engine.pipeline.config_schemas import DesignRequirementsConfig
355
356 r = _result()
357 r["series"]["ox"]["tank_psia"][5] = 710.0
358 r["summary"]["ox"]["peak_psia"] = 710.0
359
360 class Stated:
361 design_requirements = DesignRequirementsConfig(max_lox_tank_pressure_psi=700.0,
362 max_fuel_tank_pressure_psi=850.0, min_stability_margin=1.05)
363
364 class Unstated:
365 design_requirements = DesignRequirementsConfig()
366
367 stated = _by_key(grade(r, config=Stated))["tank_cap_ox"]
368 assert stated["limit"] == 700.0 and stated["grade"] == "info" and stated["cap_source"] == "design_requirements"
369 unstated = _by_key(grade(r, config=Unstated))["tank_cap_ox"]
370 assert unstated["limit"] == 700.0 and unstated["grade"] == "info" and unstated["cap_source"] == "schema default"
371 assert "schema's default" in unstated["basis"]
372 # The amber edge of chug does not move (max(1.2, 1.2)), but the hint names the default as one.
373 r["delivered"] = {"t": r["series"]["t"][2:], "chug_margin": [1.3] * 8,
374 "summary": {"chug_margin_min": 1.3, "chug_margin_min_t": 0.05}}
375 assert "does not state" in _by_key(grade(r, config=Unstated))["chug_margin"]["hint"]
376 assert "does not state" not in _by_key(grade(r, config=Stated))["chug_margin"]["hint"]
377
378
380 r = _result()
381 r["summary"]["copv_end_psia"] = 578.0 + 99.0
382 r["summary"]["fuel"]["min_psia"] = 578.0 - 31.0
383 r["series"]["fuel"]["tank_psia"][4] = 578.0 - 31.0
384 r["summary"]["fuel"]["residual_kg"] = 0.29 # tie = 0.03 * 10 kg = 0.30
385 g = _by_key(grade(r))
386 assert g["bottle_margin"]["grade"] == "bad" and g["bottle_margin"]["value"] == pytest.approx(99.0)
387 assert g["tank_sag"]["grade"] == "warn" and g["tank_sag"]["index_worst"] == 4
388 assert g["depletion_tie"]["grade"] == "info" and g["residual"]["grade"] == "warn"
389 assert "LOX runs dry first" in g["depletion_tie"]["detail"]
390 r["summary"]["copv_end_psia"] = 578.0 + 201.0
391 r["summary"]["fuel"]["residual_kg"] = 0.2
392 g = _by_key(grade(r))
393 assert g["bottle_margin"]["grade"] == "ok" and g["residual"]["grade"] == "ok"
394
395
397 r = _result()
398 r["summary"]["depleted_side"] = ""
399 g = _by_key(grade(r))
400 assert g["depletion"]["grade"] == "warn" and "depletion_tie" not in g
401
402
404 r = _result()
405 r["tripped"] = {"vessel": "FUT", "t": 0.2, "p_psia": 764.7, "mawp_psia": 764.7}
406 r["converged"] = False
407 g = _by_key(grade(r))
408 assert g["vessel_trip"]["grade"] == "bad" and g["vessel_trip"]["index_worst"] == 5
409 assert g["unsettled"]["grade"] == "warn"
410 assert any("Vessel trip" in b for b in breaks(list(g.values())))
411
412
414 r = _result()
415 base = {e["key"] for e in grade(r)}
416 assert not any(k.startswith(("saturation_", "cavitation_", "water_hammer_", "regulator_", "conservation_"))
417 for k in base)
418 n = len(r["series"]["t"])
419 r["diagnostics"] = {
420 "saturation": {"nodes": [{"id": "OXT.out", "label": "LOX tank outlet", "side": "ox",
421 "margin_psi": [None, 30.0] + [20.0, 9.0, 12.0, 15.0, 16.0, 17.0, 18.0, 19.0],
422 "min_psi": 9.0, "t_min": 0.10}]},
423 "cavitation": {"ox": {"margin": [None, None, 2.0, 1.9, 1.8, 1.1, 1.5, 1.6, 1.7, 1.8], "min_margin": 1.1,
424 "min_K": 1.76, "K_incipient": 1.6, "t_min": 0.2, "flip_risk": False},
425 "fuel": {"margin": [None] * n, "min_margin": 0.9, "min_K": 1.4, "K_incipient": 1.6,
426 "t_min": 0.2, "flip_risk": True}},
427 "water_hammer": [{"line": "l_ox1", "side": "ox", "closure_s": 0.05, "peak_psia": 2900.0, "rating_psia": 1014.7,
428 "ok": False, "opening": {"peak_psia": 900.0}}],
429 "regulator": {"t": r["series"]["t"], "use_frac": [0.1] * (n - 1) + [0.85], "wide_open": [False] * n},
430 "vv": {"mass": {"ox": {"error_pct": 1e-7}, "fuel": {"error_pct": 0.5}},
431 "pressurant": {"error_pct": 3.0}, "energy": {"error_pct": 12.0, "basis": "x"}},
432 }
433 g = _by_key(grade(r))
434 assert g["saturation_OXT.out"]["grade"] == "warn" and g["saturation_OXT.out"]["index_worst"] == 3
435 assert g["cavitation_ox"]["grade"] == "warn" and g["cavitation_ox"]["index_worst"] == 5
436 assert g["cavitation_fuel"]["grade"] == "bad" and g["cavitation_fuel"]["index_worst"] == 5
437 # The mains never close (the team, 2026-10-03): only the opening surge is listed, as information,
438 # and the closing figure (2900 here) is not graded.
439 assert g["water_hammer_l_ox1"]["grade"] == "info" and g["water_hammer_l_ox1"]["value"] == 900.0
440 assert g["regulator_wide_open"]["grade"] == "warn" and g["regulator_wide_open"]["index_worst"] == n - 1
441 assert g["conservation_mass_ox"]["grade"] == "ok"
442 assert g["conservation_mass_fuel"]["grade"] == "warn"
443 assert g["conservation_pressurant"]["grade"] == "bad"
444 assert g["conservation_energy"]["grade"] == "info"
445 r["diagnostics"]["regulator"]["wide_open"][-2] = True
446 assert _by_key(grade(r))["regulator_wide_open"]["grade"] == "bad"
447
448
449@pytest.mark.parametrize("ratio,expect", [(0.99, "bad"), (1.0, "warn"), (1.19, "warn"), (1.2, "ok")])
451 r = _result()
452 r["diagnostics"] = {"cavitation": {"ox": {"min_margin": ratio, "t_min": 0.1, "flip_risk": False}}}
453 assert _by_key(grade(r))["cavitation_ox"]["grade"] == expect
454
455
457 r = _result()
458 node = {"id": "MVO.in", "side": "ox", "min_psi": 40.0, "t_min": 0.1, "min_static_psi": 20.0, "t_min_static": 0.15}
459 r["diagnostics"] = {"saturation": {"nodes": [node]}}
460 g = _by_key(grade(r))["saturation_MVO.in"]
461 # Graded on the static margin (what boils a moving liquid), at its own time.
462 assert g["value"] == 20.0 and g["t_worst"] == 0.15 and g["grade"] == "warn" and g["warn"] == 25.0
463 r["diagnostics"]["saturation"]["flag_margin_psi"] = 15.0
464 assert _by_key(grade(r))["saturation_MVO.in"]["grade"] == "ok"
465
466
468 r = _result()
469 r["diagnostics"] = {"hardware": {"t": [0.05, 0.2, 0.4], "separation": {
470 "ratio": [1.1, 1.03, 1.2], "summerfield": [False] * 3, "schmucker": [False, False, False]}}}
471 g = _by_key(grade(r))["separation"]
472 assert g["value"] == 1.03 and g["t_worst"] == 0.2 and g["index_worst"] == 5 and g["grade"] == "ok"
473 r["diagnostics"]["hardware"]["separation"]["schmucker"][1] = True
474 assert _by_key(grade(r))["separation"]["grade"] == "warn"
475
476
478 r = _result()
479 r["delivered"] = {"t": r["series"]["t"][2:], "p_exit_psia": [15.0, 14.0, 6.0, 15.0, 15.0, 15.0, 15.0, 15.0],
480 "ambient_psia": [14.0] * 8, "summary": {}}
481 g = _by_key(grade(r))["separation"]
482 assert g["value"] == pytest.approx(6.0 / 14.0) and g["grade"] == "ok" and g["index_worst"] == 4
483 r["delivered"]["p_exit_psia"][2] = 5.5
484 assert _by_key(grade(r))["separation"]["grade"] == "bad"
485
486
488 """A design that states no rail-exit or static-margin requirement is graded against FAR-OUT's
489 (2024-25 rules 5.1.4 and 5.1.5), and says so: a 21.7 m/s rail exit used to drop out of the
490 limits altogether and leave the Overview reading "Within limits" (D-59)."""
491 r = _result()
492 r["flight"] = {"ok": True, "apogee_agl_m": 3000.0, "rail_exit_velocity_m_s": 21.7, "rail_exit_time_s": 0.3,
493 "stability": {"liftoff_static_margin_cal": 1.4, "min_static_margin_cal": 1.3,
494 "min_static_margin_t": 0.2, "max_q_pa": 36800.0, "max_q_t": 0.4}}
495 g = _by_key(grade(r))
496 assert g["rail_exit"]["grade"] == "bad" and g["rail_exit"]["limit"] == pytest.approx(85 * 0.3048)
497 assert "FAR-OUT" in g["rail_exit"]["hint"] and "5.1.4" in g["rail_exit"]["hint"]
498 assert g["static_margin_min"]["grade"] == "bad" and g["static_margin_min"]["limit"] == 1.5
499 assert "5.1.5" in g["static_margin_min"]["hint"]
500 assert g["max_q"]["grade"] == "info" and g["max_q"]["index_worst"] == 9
501 fast = copy.deepcopy(r)
502 fast["flight"]["rail_exit_velocity_m_s"] = 26.0
503 fast["flight"]["stability"].update(liftoff_static_margin_cal=7.7, min_static_margin_cal=7.3)
504 g = _by_key(grade(fast))
505 assert g["rail_exit"]["grade"] == "ok" and g["static_margin_min"]["grade"] == "ok"
506
507 class Cfg:
508 class design_requirements: # noqa: N801
509 min_rail_exit_velocity_m_s = 25.91
510 min_static_margin_cal = 1.5
511
512 g = _by_key(grade(r, config=Cfg))
513 assert g["rail_exit"]["grade"] == "bad" and g["rail_exit"]["t_worst"] == 0.3
514 assert g["static_margin_min"]["grade"] == "bad"
515
516 class Own:
517 class design_requirements: # noqa: N801
518 min_rail_exit_velocity_m_s = 20.0
519 min_static_margin_cal = 1.0
520
521 # The design's own numbers win over the competition's.
522 g = _by_key(grade(r, config=Own))
523 assert g["rail_exit"]["grade"] == "ok" and g["rail_exit"]["limit"] == 20.0 and "FAR-OUT" not in g["rail_exit"]["hint"]
524 assert g["static_margin_min"]["grade"] == "ok" and g["static_margin_min"]["limit"] == 1.0
525
526
528 r = _result()
529 r["provenance"]["reproduce"] = {"design_yaml": "design_requirements:\n max_fuel_tank_pressure_psi: 650\n"}
530 assert requirements(r)["max_fuel_tank_pressure_psi"] == 650
531 assert _by_key(grade(r))["tank_cap_fuel"]["limit"] == 650.0
532
533
535 r = _result()
536 r["diagnostics"] = {"saturation": {"nodes": [{"id": "x", "margin_psi": "not a list", "min_psi": object()}]}}
537 keys = {e["key"] for e in grade(copy.deepcopy(r))}
538 assert "bottle_margin" in keys and "stiffness_ox" in keys
539
540
542 r = _result()
543 want = {"key", "label", "group", "value", "unit", "limit", "warn", "direction", "grade", "t_worst",
544 "index_worst", "series_ref", "basis", "hint"}
545 groups = {"stability", "injector", "tanks", "pressurant", "propellant", "flight", "hardware", "model"}
546 for e in grade(r):
547 assert want <= set(e), e["key"]
548 assert e["group"] in groups and e["grade"] in ("ok", "warn", "bad", "info")
549 assert e["direction"] in ("min", "max") and e["hint"]
550 json.dumps(e)
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
test_tank_peak_against_mawp_meop_and_the_design_cap()
test_diagnostics_blocks_are_graded_when_present_and_skipped_when_absent()
test_the_chug_amber_edge_follows_a_requirement_above_one_point_two_but_never_below_it()
test_separation_from_the_hardware_block_and_schmucker_as_amber()
test_chug_margin_is_red_below_one_and_amber_below_one_point_two(margin, expect)
test_the_delivered_chug_minimum_maps_onto_the_series_by_firing_step()
test_a_drawing_basis_run_without_its_stability_block_does_not_grade_the_config_margin()
test_stiffness_worst_time_ignores_the_first_fifth_of_a_second()
test_chug_from_the_stability_diagnostics_grades_the_settled_minimum_and_reports_the_start()
test_flight_limits_fall_back_to_the_competition_rules()
test_cavitation_is_red_once_the_orifice_cavitates_and_amber_near_it(ratio, expect)
test_the_worst_index_points_at_the_graded_value(name, result)
test_a_design_cap_the_design_never_stated_is_reported_not_graded()
test_a_stated_flag_margin_moves_the_saturation_amber_edge()
test_nozzle_separation_from_the_delivered_exit_pressure()
test_the_reference_run_matches_the_audits_hand_worked_worst_times()
test_stiffness_band_edges_and_the_rule_of_thumb_without_a_band()
test_requirements_are_read_from_the_recorded_yaml_without_a_config()
Everything a case may vary.