STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_layerx_ratings.py
Go to the documentation of this file.
1"""Layer X holds each vessel to the pressure the feed twin trips the stand at.
2
3The twin trips a vessel when its absolute pressure is over ``_trip_limit``: the drawing's burst
4pressure over ``Setup.burst_safety_factor`` (across the wall) when one is drawn, else its MAWP --
5both read as the drawing reads them, a bare psi being gauge, so ``.si`` is already absolute.
6
7Before 2026-11-02 Layer X read the MAWP as a pressure *across* the wall: preflight passed a lockup
8up to one atmosphere plus the site's over the trip (``target - ambient > MAWP.si``), the optimiser
9and set point capped the lockup at ``MAWP.si + ambient``, the water-hammer rating added another
10atmosphere, and the limits graded ``peak - ambient`` against the absolute figure. And it read no
11burst pressure at all, which is how LE4 (3) rates every vessel: preflight said "nothing checks
12those vessels" while the twin tripped its tanks at 764.7 psia.
13
14Worked by hand: copv_study_gn2's tanks are drawn MAWP 1000 psi -> 1014.70 psia. LE4 (3)-style burst
151500 psi over a factor of 2 -> (1500 / 2) psig = 764.70 psia; 15000 psi -> 7514.70 psia.
16"""
17
18from __future__ import annotations
19
20import copy
21from dataclasses import replace
22from pathlib import Path
23from types import SimpleNamespace
24
25import pytest
26
27pytest.importorskip("feedtwin", reason="lib/feedtwin is not installed")
28
29from engine.layerx import DrawingStore, LayerXSettings, prepare # noqa: E402
30from engine.layerx import optimize as opt # noqa: E402
31from engine.layerx import setpoint as sp # noqa: E402
32from engine.layerx.diag.limits import grade # noqa: E402
33from engine.layerx.sources import shipped_drawings_dir # noqa: E402
34from engine.pipeline.io import load_config # noqa: E402
35
36PSI = 6894.757293168361
37ATM_PSI = 101325.0 / PSI
38GN2 = "copv_study_gn2"
39CFG = Path(__file__).resolve().parent / "fixtures" / "ethalox_6500N_doublet_cad_2026-09-28.yaml"
40
41needs_drawing = pytest.mark.skipif(not (shipped_drawings_dir() / f"{GN2}.json").is_file(),
42 reason="feed-twin's shipped drawings are not next to this checkout")
43
44
45@pytest.fixture(scope="module")
46def config():
47 return load_config(str(CFG))
48
49
50@pytest.fixture(scope="module")
51def drawing():
52 return {d.name: d for d in DrawingStore(None).list()}[GN2]
53
54
55def _prep(config, drawing, **settings):
56 # The native engine: these checks are the drawing's and the plan's, not the engine card's.
57 return prepare(config, None, drawing, LayerXSettings(drawing_id=drawing.id, replay=False, engine_model="native",
58 **settings))
59
60
61def _burst_rated(drawing, tank_psi=1500.0, bottle_psi=15000.0, relief_psi=None):
62 """``drawing`` rated as LE4 (3) is: burst pressures, no MAWP; optionally a relief on the bottle's manifold."""
63 payload = copy.deepcopy(drawing.payload)
64 for node in payload["nodes"]:
65 params = (node.get("data") or {}).get("params") or {}
66 if node["id"] in ("OXT", "FUT", "KB1"):
67 params.pop("MAWP", None)
68 params["burst_pressure"] = {"value": bottle_psi if node["id"] == "KB1" else tank_psi, "unit": "psi",
69 "source": "estimated"}
70 if relief_psi is not None:
71 payload["nodes"].append({"id": "RV9", "position": {"x": 150, "y": 380}, "data": {
72 "componentType": "RV", "label": "COPV-RV", "fluid": "nitrogen", "options": {"flowCoefficient": "Cd"},
73 "params": {"set_pressure": {"value": relief_psi, "unit": "psi", "source": "estimated"}}}})
74 payload["edges"].append({"id": "l_rv9", "source": "MF1", "target": "RV9", "targetHandle": "r",
75 "data": {"lineType": "pipe", "params": {}}})
76 return replace(drawing, id=drawing.id + "-burst", payload=payload)
77
78
79def _fails(prep):
80 return {c.key: c.detail for c in prep.checks if c.status == "fail"}
81
82
83@needs_drawing
85 """1000 psi MAWP trips at 1014.70 psia. 1012 passes; 1022 -- under MAWP.si plus the site's 13.64
86 psia, which the old check allowed -- fails, naming the trip. The session holds the same limit."""
87 from feedtwin.session.burn import open_session
88
89 ok = _prep(config, drawing, tank_pressure_psia=1012.0)
90 assert not any(k.startswith("mawp_") for k in _fails(ok))
91 assert ok.derived["tank_mawp_psi"] == pytest.approx({"OXT": 1000.0 + ATM_PSI, "FUT": 1000.0 + ATM_PSI})
92 session = open_session(ok.assembler(), ok.machine, setup=ok.setup)
93 assert {k: s.mawp / PSI for k, s in session.tanks.items()} == pytest.approx(ok.derived["tank_mawp_psi"])
94
95 over = _prep(config, drawing, tank_pressure_psia=1022.0)
96 fails = _fails(over)
97 assert {"mawp_OXT", "mawp_FUT"} <= set(fails)
98 assert "1014.7 psia (MAWP 1000 psig" in fails["mawp_OXT"]
99
100
101@needs_drawing
103 from feedtwin.session.burn import open_session
104
105 burst = _burst_rated(drawing)
106 prep = _prep(config, burst, tank_pressure_psia=600.0)
107 assert prep.ok, _fails(prep)
108 trip = 750.0 + ATM_PSI
109 assert prep.derived["tank_mawp_psi"] == pytest.approx({"OXT": trip, "FUT": trip})
110 assert prep.derived["copv_mawp_psi"] == pytest.approx(7500.0 + ATM_PSI)
111 assert prep.derived["vessel_ratings"]["OXT"]["basis"] == "burst"
112 ratings = next(c for c in prep.checks if c.key == "ratings")
113 assert "nothing checks" not in ratings.detail
114 assert "TK-LOX trips at 764.7 psia (burst 1500 psig over a safety factor of 2, estimated)" in ratings.detail
115 session = open_session(prep.assembler(), prep.machine, setup=prep.setup)
116 assert {k: s.mawp / PSI for k, s in session.tanks.items()} == pytest.approx(prep.derived["tank_mawp_psi"])
117 assert [b.mawp / PSI for b in session.bottles.values()] == pytest.approx([prep.derived["copv_mawp_psi"]])
118
119 assert {"mawp_OXT", "mawp_FUT"} <= set(_fails(_prep(config, burst, tank_pressure_psia=770.0)))
120 assert "mawp_KB1" in _fails(_prep(config, burst, tank_pressure_psia=600.0, copv_pressure_psia=7600.0))
121
122
123@needs_drawing
125 """A relief on the bottle's manifold at 4000 psi lifts at 4014.7 psia: a 4500 psia fill vents in
126 the settle, so preflight warns; a 3900 psia one does not."""
127 burst = _burst_rated(drawing, relief_psi=4000.0)
128 over = _prep(config, burst, tank_pressure_psia=600.0, copv_pressure_psia=4500.0)
129 relief = next((c for c in over.checks if c.key == "relief"), None)
130 assert relief is not None and relief.status == "warn"
131 assert "COPV-RV's 4000 psi set pressure on COPV-GN2 (4014.7 psia)" in relief.detail
132 under = _prep(config, burst, tank_pressure_psia=600.0, copv_pressure_psia=3900.0)
133 assert not any(c.key == "relief" for c in under.checks)
134
135
136@needs_drawing
138 """The 4500 psia fill vents through the 4000 psi relief in the settle (to ~3895 psia) and the tanks
139 follow the regulator's supply effect: T-0 is not the burn asked for. The run said only "T-0 did not
140 settle" and stood as converged; it now says what it started from and is not converged."""
141 from engine.layerx.analysis import run_prepared
142
143 burst = _burst_rated(drawing, relief_psi=4000.0)
144 prep = _prep(config, burst, tank_pressure_psia=600.0, copv_pressure_psia=4500.0, horizon_s=1.0)
145 result = run_prepared(prep, replay=False)
146 assert result["summary"]["t0_settled"] is False
147 assert result["converged"] is False
148 event = next(e for e in result["events"] if e["label"] == "T-0 did not settle")
149 assert "Asked for a 600.0 psia lockup from a 4500 psia bottle; T-0 is LOX" in event["detail"]
150 assert result["summary"]["copv_t0_psia"] < 4100.0
151 # The clock's float noise is not a time: T-0 is at Fire exactly (it read -6.9e-17 s in the events and the CSV).
152 t0 = next(e for e in result["events"] if e["kind"] == "t0")["t"]
153 assert t0 == 0.0 and repr(t0) == "0.0" # not -0.0, which prints as "-0.0 s"
154 assert 0.0 in result["series"]["t"]
155
156
157def _burn(peak_ox: float, peak_fuel: float):
158 """Two lead-in samples and four firing steps, flat but for each tank's peak."""
159 t = [-0.05, 0.0, 0.05, 0.10, 0.15, 0.20]
160 tank = lambda peak: [600.0, 600.0, 600.0, peak, 600.0, 600.0] # noqa: E731
161 side = lambda peak: {"tank_psia": tank(peak), "stiffness": [0.0, 0.0, 0.3, 0.3, 0.3, 0.3], # noqa: E731
162 "liquid_kg": [5.0] * 6}
163 block = lambda peak: {"t0_psia": 600.0, "min_psia": 600.0, "stiffness_min": 0.3, "peak_psia": peak, # noqa: E731
164 "loaded_kg": 5.0, "residual_kg": 0.0}
165 return {
166 "series": {"t": t, "dt": [0.05] * 6, "firing": [False, False, True, True, True, True], "converged": [True] * 6,
167 "copv_psia": [1500.0] * 6, "ox": side(peak_ox), "fuel": side(peak_fuel)},
168 "summary": {"depleted_side": "oxidiser", "burn_time_s": 0.2, "copv_end_psia": 1500.0, "copv_t0_psia": 4500.0,
169 "ox": block(peak_ox), "fuel": block(peak_fuel), "failed_steps": 0, "t0_settled": True,
170 "card_outside_steps": 0, "steps": 6},
171 "events": [], "converged": True,
172 "provenance": {"derived": {"roles": {"oxidiser": "OXT", "fuel": "FUT"}, "ambient_pa": 94069.72,
173 "gauge_zero_pa": 101325.0,
174 "tank_mawp_psi": {"OXT": 750.0 + ATM_PSI, "FUT": 750.0 + ATM_PSI},
175 "vessel_ratings": {"OXT": {"basis": "burst", "label": "TK-LOX",
176 "words": "764.7 psia (burst 1500 psig "
177 "over a safety factor of 2, estimated)"}}}},
178 }
179
180
182 """``tank_mawp_psi`` is absolute: a 770 psia peak against a 764.70 psia trip is 755.30 psig of 750.00,
183 100.7 % -- over the red line, where reading the trip as across the wall (peak less the site's
184 13.64 psia, against 764.70) put it at 756.36, under. 700 psia is 685.30 of 750.00, 91 %: amber."""
185 rows = {r["key"]: r for r in grade(_burn(770.0, 700.0))}
186 ox, fuel = rows["tank_mawp_ox"], rows["tank_mawp_fuel"]
187 assert ox["limit"] == pytest.approx(750.0) and ox["value"] == pytest.approx(770.0 - ATM_PSI)
188 assert ox["value"] > ox["limit"]
189 assert fuel["value"] == pytest.approx(700.0 - ATM_PSI) and ox["warn"] < fuel["value"] < ox["limit"]
190 assert ox["grade"] == "info" # the team, 2026-10-03: tank limits are information; the trip itself is graded
191 assert "burst 1500 psig" in ox["basis"] and ox["rating_source"] == "burst"
192 # Across the wall is a difference: "psi", which the page draws as a gap (contract.limitKindOf). It was
193 # "psig", which the page has no kind for: a bare number with the suffix glued on, outside the units.
194 assert ox["unit"] == fuel["unit"] == "psi"
195 # The vessel by its name on the drawing, not its node id.
196 assert ox["basis"].endswith("TK-LOX trips the stand at")
197 assert fuel["basis"].endswith("FUT trips the stand at") # no rating words: the id is all there is
198
199
201 """The optimiser's lockup ceiling, the set point's MAWP cap and the set point's fallback grade all
202 stop at the trip itself; each used to add the site's atmosphere (13.64 psia) on top."""
203 # +15 % of 700 is 805, over the 764.70 psia trip: the trip binds.
204 d = {"target_lockup_psia": 700.0, "ambient_pa": 94069.72, "copv_psig": 4500.0, "copv_drawn_psig": 4500.0,
205 "tank_mawp_psi": {"OXT": 764.6959, "FUT": 764.6959}, "copv_mawp_psi": 7514.6959}
206 variables = {v.key: v for v in opt.default_variables(SimpleNamespace(derived=d), SimpleNamespace(design_requirements=None))}
207 assert variables["lockup_psia"].hi == pytest.approx(764.7)
208 assert sp.lockup_ceiling_psia(d) == pytest.approx(764.6959)
209 assert sp.lockup_ceiling_psia({"tank_mawp_psi": {}}) == float("inf")
210 res = {"summary": {"ox": {"peak_psia": 770.0}, "fuel": {"peak_psia": 700.0}}, "converged": True}
211 prep = SimpleNamespace(derived=d, roles={"oxidiser": "OXT", "fuel": "FUT"})
212 rows = {r["key"]: r for r in sp._fallback_limits(res, prep, SimpleNamespace(design_requirements=None), {})}
213 assert rows["tank_peak_mawp.oxidiser"]["limit"] == pytest.approx(764.6959)
214 assert rows["tank_peak_mawp.oxidiser"]["grade"] == "bad" and rows["tank_peak_mawp.fuel"]["grade"] == "ok"
_burst_rated(drawing, tank_psi=1500.0, bottle_psi=15000.0, relief_psi=None)
test_burst_pressure_is_the_rating_when_drawn(config, drawing)
_burn(float peak_ox, float peak_fuel)
test_a_fill_over_the_bottles_relief_is_said(config, drawing)
test_a_burn_that_starts_off_its_datum_is_not_a_settled_answer(config, drawing)
_prep(config, drawing, **settings)
test_a_lockup_over_the_mawp_trip_fails_preflight(config, drawing)