STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_layerx_integration.py
Go to the documentation of this file.
1"""Layer X integration: the wave-1 diagnostics wired into the burn, the run record and the API.
2
3What these hold (fast; the LE4 burns are tests/test_layerx_integration_le4.py, LAYERX_GOLDEN=1):
4
5* **Defaults are today's.** Every new setting is ``None`` and resolves to the behaviour before it
6 existed; the router mirrors them with the same defaults; the baseline's list of defaults is
7 unchanged by their arrival.
8* **GN2 on LOX** is refused for a hot fire on the physical criterion (nitrogen's saturation pressure
9 at the LOX temperature, CoolProp), checked by hand, and runs with the acknowledgement.
10* **The tanks at burnout** (preflight ``tank_rise``) follow the drawing's regulator law at the
11 bottle's isentropic end pressure, re-derived here independently.
12* **Limits** keep today's chug grade on the default basis with the settled and drawing-basis
13 margins beside it (D7), and cap the new diagnostics at amber without touching the old limits.
14* **Events** carry unique stable keys; **models** are flattened into the run record.
15* **Router**: sidecars are written beside the run, served, pruned and deleted with it; exports;
16 trade and optimise runs are listed as legacy.
17"""
18
19from __future__ import annotations
20
21import importlib
22import io
23import json
24import math
25import os
26import sys
27import zipfile
28from dataclasses import asdict, fields
29from pathlib import Path
30from types import SimpleNamespace
31
32import pytest
33
34sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
35
36ROOT = Path(__file__).resolve().parents[1]
37BASELINE = ROOT / "docs" / "layerx" / "baseline-2026-10-07.json"
38PSI = 6894.757293168361
39
40NEW_SETTINGS = ("chug_basis", "chug_eroded", "card_eroded_nozzle", "flight_coupling", "fuel_lead_s",
41 "valve_travel_s", "outlet_d_mm", "ack_gn2_condensation", "test_mode")
42
43
44# ---------------------------------------------------------------- settings
45
46
48 """Unset resolves to the default; for the chug that is D7 as the user decided it on 2026-10-03
49 (the drawing's lines, the eroded engine, graded from the first full-flow step)."""
50 from engine.layerx.prepare import LayerXSettings, options
51
52 s = LayerXSettings(drawing_id="d")
53 for name in NEW_SETTINGS:
54 assert getattr(s, name) is None, name
55 assert options(s) == {
56 "chug_basis": "drawing", "chug_eroded": True, "card_eroded_nozzle": True, "flight_coupling": "inline",
57 "fuel_lead_s": 0.0, "valve_travel_s": None, "outlet_d_mm": [None, None], "ack_gn2_condensation": False,
58 "test_mode": "hotfire"}
59 # The baseline's defaults (tests/test_layerx_golden.py) do not move: every old field is as recorded
60 # and every new one is None, which is what a missing key reads as.
61 was = json.loads(BASELINE.read_text())["layerx_settings_defaults"]
62 now = {k: v for k, v in asdict(s).items() if k != "drawing_id"}
63 # The thermal models are the feed twin's own since 2026-10-03 (None runs its Setup).
64 twin = ("ullage_collapse", "ullage_vapour", "chilldown", "line_walls")
65 assert all(now[k] is None for k in twin)
66 assert {k: v for k, v in now.items() if k in was and k not in twin} == {k: v for k, v in was.items() if k not in twin}
67 assert all(now[k] is None for k in now if k not in was)
68
69
71 """2026-10-03: the eroded nozzle's thrust, the eroded chug, the inline ascent are the physics;
72 the valve opening and the tank outlet bore are the drawing's. Saved settings that carry them
73 are accepted and change nothing."""
74 from engine.layerx.prepare import LayerXSettings, options
75
76 want = options(LayerXSettings(drawing_id="d"))
77 old = LayerXSettings(drawing_id="d", chug_basis="config", chug_eroded=False, card_eroded_nozzle=False,
78 flight_coupling="outer", valve_travel_s=0.2, outlet_d_mm=9.5)
79 assert options(old) == want
80
81
82@pytest.mark.parametrize("bad", [{"chug_basis": "both"}, {"flight_coupling": "sideways"}, {"test_mode": "coldflow_water"},
83 {"fuel_lead_s": -1.0}, {"outlet_d_mm": [1.0, 2.0, 3.0]}, {"outlet_d_mm": 0.01}])
85 from engine.layerx.prepare import LayerXSettings, prepare
86
87 drawing = SimpleNamespace(payload={"nodes": [], "edges": []}, name="x", id="x", sha256="")
88 prep = prepare(SimpleNamespace(model_dump=lambda **_: {}), None, drawing, LayerXSettings(drawing_id="x", **bad))
89 assert not prep.ok
90 assert [c.key for c in prep.checks] == ["options"]
91
92
94 from backend.routers.layerx import Settings
95 from engine.layerx.prepare import LayerXSettings
96
97 # card_center_psia is the optimiser's, set in-process for its candidates; a person never sends it.
98 engine = {f.name: f.default for f in fields(LayerXSettings) if f.name not in ("drawing_id", "card_center_psia")}
99 router = Settings(drawing_id="d").model_dump()
100 for name, default in engine.items():
101 assert name in router, name
102 assert router[name] == default, (name, router[name], default)
103 # And the router refuses what the engine would.
104 for bad in ({"chug_basis": "both"}, {"flight_coupling": "x"}, {"test_mode": "coldflow_ln2"},
105 {"fuel_lead_s": -0.1}, {"outlet_d_mm": [1.0, 2.0, 3.0]}, {"outlet_d_mm": 0.1}):
106 with pytest.raises(Exception):
107 Settings(drawing_id="d", **bad)
108 ok = Settings(drawing_id="d", chug_basis="drawing", flight_coupling="inline", outlet_d_mm=[9.0, None],
109 ack_gn2_condensation=True, test_mode="hotfire")
110 from engine.layerx.prepare import options
111
112 assert options(LayerXSettings.from_dict(ok.model_dump()))["outlet_d_mm"] == [None, None]
113
114
115# ---------------------------------------------------------------- GN2 on LOX
116
117
118def _tank(T=None, fluid="oxygen", source="measured"):
119 params = {}
120 if T is not None:
121 params["temperature"] = SimpleNamespace(si=T, source=SimpleNamespace(value=source))
122 return SimpleNamespace(id="OXT", fluid=fluid, params=params)
123
124
126 import CoolProp.CoolProp as CP
127
128 from engine.layerx.prepare import gn2_on_lox
129
130 T = 90.0
131 psat_n2 = CP.PropsSI("P", "T", T, "Q", 0, "Nitrogen")
132 pv_o2 = CP.PropsSI("P", "T", T, "Q", 0, "Oxygen")
133 assert 51.5 < psat_n2 / PSI < 53.0 # "~52 psia at 90 K" (the coordinator's figure)
134 edge = psat_n2 + pv_o2 # the lockup at which the ullage's N2 reaches saturation
135 below = gn2_on_lox("nitrogen", _tank(T), edge - 0.1 * PSI)
136 above = gn2_on_lox("nitrogen", _tank(T), edge + 0.1 * PSI)
137 # Flagged, never refused: helium and nitrogen are compared on the same drawing.
138 assert below["status"] == "info" and above["status"] == "warn"
139 assert f"{psat_n2 / PSI:.1f} psia" in above["detail"] and "optimistic" in above["detail"]
140 # Words a person reads: the tank, never the graph's node id or a settings key.
141 assert "OXT" not in above["detail"] and "ack_gn2" not in above["detail"] and "twin" not in above["detail"]
142 # The old acknowledgement still loads and changes nothing.
143 assert gn2_on_lox("nitrogen", _tank(T), 578.0 * PSI, acknowledged=True) == gn2_on_lox("nitrogen", _tank(T), 578.0 * PSI)
144 # Not a nitrogen bottle, not LOX, not a hot fire: the check does not apply.
145 assert gn2_on_lox("helium", _tank(T), 578.0 * PSI) is None
146 assert gn2_on_lox("nitrogen", _tank(T, fluid="ethanol"), 578.0 * PSI) is None
147 assert gn2_on_lox("nitrogen", _tank(T), 578.0 * PSI, test_mode="coldflow_water") is None
148 # Above nitrogen's critical temperature nothing condenses, whatever the pressure.
149 assert gn2_on_lox("nitrogen", _tank(130.0), 2000.0 * PSI)["status"] == "info"
150 # No temperature on the drawing: LOX's normal boiling point, said so.
151 nbp = gn2_on_lox("nitrogen", _tank(None), 578.0 * PSI)
152 assert nbp["status"] == "warn" and "normal boiling point" in nbp["detail"]
153
154
156 p = importlib.import_module("engine.layerx.prepare") # the package re-exports a function of that name
157
158 assert not hasattr(p, "N2_CRITICAL_PSIA")
159 assert "492.5" not in Path(p.__file__).read_text().replace("(492.5 psia)", "")
160
161
162# ---------------------------------------------------------------- tanks at burnout
163
164
166 """An affine regulator law (the drawing's: outlet rises ``k`` per psi the supply falls) and a
167 helium bottle, solved here by a separate fixed point."""
168 import CoolProp.CoolProp as CP
169
170 p = importlib.import_module("engine.layerx.prepare") # the package re-exports a function of that name
171
172 k, lock0, p0, T0, Vb = 0.017, 578.0 * PSI, 4514.7 * PSI, 293.15, 4.6871e-3
173 ull, exp = 3.6e-3, 11.37e-3
174 law = lambda model, dome, supply: lock0 + k * (p0 - supply) # noqa: E731
175 orig = p.lockup_for_dome
176 p.lockup_for_dome = law
177 try:
178 got = p.expected_tank_rise(None, dome_psig=513.0, gas="Helium", bottle_pa=p0, bottle_K=T0, bottle_m3=Vb,
179 lockup_pa=lock0, ullage_m3=ull, expelled_m3=exp)
180 finally:
181 p.lockup_for_dome = orig
182 s0 = CP.PropsSI("S", "P", p0, "T", T0, "Helium")
183 m0 = CP.PropsSI("D", "P", p0, "T", T0, "Helium") * Vb
184 rho_lock = CP.PropsSI("D", "P", lock0, "T", T0, "Helium")
185 end = lock0
186 for _ in range(50):
187 need = CP.PropsSI("D", "P", end, "T", T0, "Helium") * (ull + exp) - rho_lock * ull
188 pb = CP.PropsSI("P", "D", (m0 - need) / Vb, "S", s0, "Helium")
189 end = lock0 + k * (p0 - pb)
190 # The module stops when the end pressure moves under 0.01 psi; the hand loop runs to the bitter end.
191 assert got["converged"]
192 assert abs(got["end_pa"] - end) < 0.01 * PSI
193 assert abs(got["bottle_end_pa"] - pb) < 0.1 * PSI
194 assert abs(got["rise_pa"] - k * (p0 - pb)) < 0.01 * PSI
195 assert 40.0 < got["rise_pa"] / PSI < 60.0 # the order of the LE4 burn's +40 psi
196 # The isentrope is the larger drop (and so the larger rise) than holding the bottle's temperature.
197 pb_isothermal = CP.PropsSI("P", "D", (m0 - need) / Vb, "T", T0, "Helium")
198 assert pb < pb_isothermal
199
200
202 p = importlib.import_module("engine.layerx.prepare") # the package re-exports a function of that name
203
204 orig = p.lockup_for_dome
205 p.lockup_for_dome = lambda model, dome, supply: 578.0 * PSI
206 try:
207 got = p.expected_tank_rise(None, dome_psig=513.0, gas="Helium", bottle_pa=1000.0 * PSI, bottle_K=293.15,
208 bottle_m3=1e-3, lockup_pa=578.0 * PSI, ullage_m3=1e-3, expelled_m3=20e-3)
209 finally:
210 p.lockup_for_dome = orig
211 assert got["end_pa"] is None and got["bottle_end_pa"] is None and got["gas_kg"] > 0
212
213
214# ---------------------------------------------------------------- limits
215
216
217def _result(n=8, dt=0.05):
218 t = [-0.05, 0.0] + [dt * (k + 1) for k in range(n)]
219 firing = [False, False] + [True] * n
220 flat = lambda v: [v] * len(t) # noqa: E731
221 side = lambda: {"tank_psia": flat(578.0), "stiffness": [0.0, 0.0] + [0.30] * n, "liquid_kg": flat(5.0)} # noqa: E731
222 summary = {
223 "depleted_side": "oxidiser", "burn_time_s": t[-1], "copv_end_psia": 1500.0, "copv_t0_psia": 4500.0,
224 "ox": {"t0_psia": 578.0, "min_psia": 570.0, "stiffness_min": 0.30, "peak_psia": 578.0, "loaded_kg": 6.0, "residual_kg": 0.0},
225 "fuel": {"t0_psia": 578.0, "min_psia": 572.0, "stiffness_min": 0.30, "peak_psia": 1100.0, "loaded_kg": 4.0, "residual_kg": 0.5},
226 "failed_steps": 0, "t0_settled": True, "card_outside_steps": 0, "steps": len(t),
227 }
228 return {
229 "series": {"t": t, "dt": [dt] * len(t), "firing": firing, "converged": [True] * len(t), "copv_psia": flat(1500.0),
230 "ox": side(), "fuel": side()},
231 "summary": summary, "events": [], "converged": True,
232 "delivered": {"t": t[2:], "chug_margin": [1.31, 1.4, 1.41, 1.42, 1.43, 1.44, 1.45, 1.46],
233 "summary": {"chug_margin_min": 1.31, "chug_margin_min_t": 0.05}},
234 "provenance": {"derived": {"stiffness_band": {"oxidiser": [0.2, 0.4], "fuel": [0.2, 0.4]},
235 "roles": {"oxidiser": "OXT", "fuel": "FUT"},
236 "tank_mawp_psi": {"OXT": 1000.0, "FUT": 1000.0}, "ambient_pa": 101325.0}},
237 "diagnostics": {
238 "stability": {"basis": "config", "t": t, "margin": [None, None] + [1.20, 1.30, 1.31, 1.32, 1.33, 1.34, 1.35, 1.36],
239 "worst": {"t": 0.05, "index": 2, "margin": 1.20, "frequency_hz": 34.6},
240 "settled_min": {"t": 0.10, "index": 3, "margin": 1.30, "frequency_hz": 22.0},
241 "start_window_s": 0.1, "other_basis": {"basis": "drawing", "margin_min": 1.45, "t": 0.1}},
242 "water_hammer": [{"line": "l_fu1", "side": "fuel", "peak_psia": 2906.0, "rating_psia": 1014.7, "ok": False}],
243 },
244 }
245
246
247def _by_key(entries):
248 return {e["key"]: e for e in entries}
249
250
252 from engine.layerx.analysis import grade_limits
253
254 r = _result()
255 g = _by_key(grade_limits(r, None, None, {"chug_basis": "config"}))
256 # Today's: the delivered minimum, start included, on the config basis.
257 assert g["chug_margin"]["value"] == 1.31 and g["chug_margin"]["series_ref"] == "delivered.chug_margin"
258 assert g["chug_margin"]["decision"] == "D7" and "D7" in g["chug_margin"]["hint"]
259 # Alongside, not graded: the settled minimum and the other basis.
260 assert g["chug_margin_settled"]["value"] == 1.30 and g["chug_margin_settled"]["grade"] == "info"
261 assert g["chug_margin_settled"]["frequency_hz"] == 22.0
262 assert g["chug_margin_other_basis"]["value"] == 1.45 and g["chug_margin_other_basis"]["grade"] == "info"
263 assert "chug_margin_start" not in g # the graded minimum already includes the start
264 # Exactly the grade the result had before the stability block existed.
265 from engine.layerx.diag.limits import grade
266
267 before = {**r, "diagnostics": {k: v for k, v in r["diagnostics"].items() if k != "stability"}}
268 old = _by_key(grade(before))["chug_margin"]
269 assert {k: g["chug_margin"][k] for k in ("value", "grade", "t_worst", "index_worst", "basis")} == \
270 {k: old[k] for k in ("value", "grade", "t_worst", "index_worst", "basis")}
271
272
274 from engine.layerx.analysis import grade_limits
275
276 r = _result()
277 r["diagnostics"]["stability"]["basis"] = "drawing"
278 g = _by_key(grade_limits(r, None, None, {"chug_basis": "drawing"}))
279 assert g["chug_margin"]["value"] == 1.30 and g["chug_margin"]["series_ref"] == "diagnostics.stability.margin"
280 assert g["chug_margin_start"]["grade"] == "info" and "chug_margin_settled" not in g
281
282
284 from engine.layerx.analysis import grade_limits
285
286 r = _result()
287 r["diagnostics"]["saturation"] = {"nodes": [{"id": "FUT.out", "label": "Fuel tank outlet", "side": "fuel",
288 "margin_psi": [-5.0] * len(r["series"]["t"]), "min_psi": -5.0, "t_min": 0.1}]}
289 g = _by_key(grade_limits(r, None, None, {"chug_basis": "config"}))
290 sat = g["saturation_FUT.out"]
291 assert sat["grade"] == "warn" and sat["capped_from"] == "bad" and sat["review_pending"]
292 assert "reviewed" in sat["hint"]
293 # The mains never close (2026-10-03): the closing surge is not listed; this fixture has no opening one.
294 assert "water_hammer_l_fu1" not in g
295 # The tank limits are information for now (2026-10-03), the 1100 psia peak included.
296 assert g["tank_mawp_fuel"]["grade"] == "info" and "capped_from" not in g["tank_mawp_fuel"]
297
298
300 from engine.layerx.analysis import grade_limits
301 from engine.layerx.diag.limits import grade
302
303 r = _result()
304 r.pop("diagnostics")
305 assert [e["key"] for e in grade_limits(r, None, None, {"chug_basis": "config"})] == [e["key"] for e in grade(r)]
306
307
308# ---------------------------------------------------------------- events and models
309
310
312 from engine.layerx.analysis import _key_events
313
314 r = _result()
315 r["events"] = [
316 {"t": -0.05, "kind": "t0", "key": "t0", "label": "T-0 state", "detail": ""},
317 {"t": 0.0, "kind": "fire", "key": "fire", "label": "Fire", "detail": ""},
318 {"t": 0.05, "kind": "min", "key": "min_tank_ox", "label": "LOX tank lowest", "detail": ""},
319 {"t": 0.4, "kind": "end", "key": "dry_ox", "label": "LOX tank dry", "detail": ""},
320 {"t": 0.4, "kind": "warn", "label": "Unconverged steps", "detail": ""},
321 {"t": 0.1, "kind": "warn", "label": "Something earlier", "detail": ""},
322 ]
323 r["diagnostics"]["start"] = {"available": True, "fuel_lead_s": 0.3, "ignition_s": 0.017, "prime_ox_s": 0.0096,
324 "prime_fuel_s": 0.0173}
325 r["limits"] = [{"key": "chug_margin", "value": 1.31, "t_worst": 0.05, "basis": "config"}]
326 r["tripped"] = {"vessel": "FUT", "t": 0.3, "p_psia": 1020.0, "mawp_psia": 1014.7, "message": "FUT over MAWP"}
327 _key_events(r, None, {})
328 keys = [e["key"] for e in r["events"]]
329 assert len(keys) == len(set(keys))
330 assert {"t0", "fire", "fuel_lead", "ignition", "min_tank_ox", "min_chug", "dry_ox", "burnout", "trip"} <= set(keys)
331 assert [e["key"] for e in r["events"] if e["key"].startswith("warn:")] == ["warn:0", "warn:1"]
332 assert next(e for e in r["events"] if e["key"] == "warn:0")["label"] == "Something earlier"
333 assert next(e for e in r["events"] if e["key"] == "fuel_lead")["t"] == -0.3
334 assert [e["t"] for e in r["events"]] == sorted(e["t"] for e in r["events"])
335
336
338 from engine.layerx.analysis import model_record
339
340 m = lambda n: {"name": n, "source": "s", "assumptions": ["a"], "inputs": {"x": {"value": 1}}} # noqa: E731
341 r = {"diagnostics": {"stability": {"model": m("chug")}, "water_hammer": [{"model": m("moc")}, {"available": False}],
342 "hardware": {"soak": {"model": m("soak")}, "model": m("hw")}},
343 "flight": {"stability": {"model": m("barrowman")}}, "replay": {"soak": {"model": m("soak")}}}
344 got = {(x["block"], x["name"]) for x in model_record(r)}
345 assert got == {("diagnostics.stability", "chug"), ("diagnostics.water_hammer[0]", "moc"), ("diagnostics.hardware", "hw"),
346 ("diagnostics.hardware.soak", "soak"), ("flight.stability", "barrowman")}
347
348
350 from engine.layerx import analysis
351 from engine.layerx.diag import stability, vv
352
353 def boom(*a, **k):
354 raise RuntimeError("broken on purpose")
355
356 monkeypatch.setattr(stability, "stability_block", boom)
357 monkeypatch.setattr(vv, "check", lambda result, prep=None: {"mass": {}, "model": {"name": "vv"}})
358 prep = SimpleNamespace(link=None, settings=None, roles={}, derived={}, ambient_pa=101325.0)
359 out, wall = analysis.diagnostics_of(prep, _result(), None, None, {
360 "chug_basis": "config", "chug_eroded": False, "fuel_lead_s": 0.0, "valve_travel_s": None, "outlet_d_mm": [None, None]})
361 assert out["stability"] == {"available": False, "error": "RuntimeError: broken on purpose"}
362 assert out["vv"]["model"]["name"] == "vv"
363 assert set(out) == set(analysis.DIAGNOSTIC_KEYS)
364 assert set(wall) >= {"stability", "hardware", "feed", "start", "outflow", "shutdown", "water_hammer", "vv"}
365
366
367# ---------------------------------------------------------------- router
368
369
370@pytest.fixture()
371def api(tmp_path, monkeypatch):
372 from fastapi import FastAPI
373 from fastapi.testclient import TestClient
374
375 from backend.routers import layerx as lx
376 from backend.session import UserSession, get_session
377
378 monkeypatch.setenv("USERDATA_DIR", str(tmp_path))
379 session = UserSession(f"integ-{tmp_path.name}")
380 app = FastAPI()
381 app.include_router(lx.router)
382 app.dependency_overrides[get_session] = lambda: session
383 yield TestClient(app), session, lx
384 with lx._JOBS_LOCK:
385 for job_id in [j.id for j in lx._JOBS.values() if j.user == session.user]:
386 lx._JOBS.pop(job_id, None)
387
388
390 """A small finished burn the exporters can read (as tests/test_layerx_export.py builds it)."""
391 t = [-0.1, -0.05, 0.0, 0.05, 0.1, 0.15, 0.2, 0.25]
392 n = len(t)
393 fire = [False, False, False, True, True, True, True, True]
394 ramp = lambda a, b: [a + (b - a) * k / (n - 1) for k in range(n)] # noqa: E731
395 side = lambda p: {"tank_psia": ramp(p, p + 3.0), "stiffness": [0.0] * 3 + [0.31, 0.32, 0.33, 0.34, 0.35], # noqa: E731
396 "mdot": [0.0] * 3 + [1.9] * 5}
397 return {
398 "series": {"t": t, "dt": [0.05] * n, "firing": fire, "converged": [True] * n, "copv_psia": ramp(4500.0, 4300.0),
399 "ox": side(578.0), "fuel": side(578.1),
400 "chamber": {"pc_psia": [0.0] * 3 + [395.0, 396.0, 397.0, 398.0, 399.0], "thrust_N": [0.0] * 3 + [6900.0] * 5}},
401 "summary": {"burn_time_s": 0.25, "total_impulse_Ns": 1725.0},
402 "provenance": {"config_sha256": "abc", "drawing": {"name": "stand", "sha256": "def"}},
403 }
404
405
406def _save(lx, session, run_id, kind="run", sidecars=None, result=None):
407 job = lx.Job(session.user, lx.Settings(drawing_id="d"), "design", kind=kind)
408 job.id = run_id
409 job.status, job.finished = "done", 1.0
410 job.result = result or {"summary": {"burn_time_s": 3.5, "total_impulse_Ns": 24000.0}, "provenance": {"drawing": {"name": "stand"}}}
411 job.sidecars = sidecars or {}
412 lx._persist(lx._runs_dir(session), job)
413 return job
414
415
416AXIAL = {"x_mm": [-10.0, 0.0, 10.0], "t": [0.05, 0.25], "q_MW_m2": [[1.0, 9.0, 3.0], [1.1, 9.5, 3.1]],
417 "T_wall_K": [[500.0, 2000.0, 900.0], [520.0, 2100.0, 950.0]]}
418
419
421 client, session, lx = api
422 rid = "20261003-000001-aaaaaa"
423 _save(lx, session, rid, sidecars={"axial": AXIAL, "unknown": {"x": 1}})
424 runs_dir = lx._runs_dir(session)
425 assert (runs_dir / "_sidecar" / f"{rid}.axial.json").is_file()
426 assert not (runs_dir / "_sidecar" / f"{rid}.unknown.json").exists()
427 assert client.get(f"/api/layerx/runs/{rid}/sidecar/axial").json() == AXIAL
428 assert client.get(f"/api/layerx/runs/{rid}/sidecar/unknown").status_code == 404
429 assert client.get("/api/layerx/runs/20261003-000002-bbbbbb/sidecar/axial").status_code == 404
430 assert client.get("/api/layerx/runs/..%2F..%2Fetc/sidecar/axial").status_code == 404
431 # The listing never reads a sidecar as a run.
432 assert [r["id"] for r in client.get("/api/layerx/runs").json()] == [rid]
433 assert client.delete(f"/api/layerx/runs/{rid}").json()["deleted"]
434 assert not (runs_dir / "_sidecar" / f"{rid}.axial.json").exists()
435
436
438 client, session, lx = api
439 monkeypatch.setattr(lx, "KEEP_RUNS", 1)
440 _save(lx, session, "20261003-000001-aaaaaa", sidecars={"axial": AXIAL})
441 _save(lx, session, "20261003-000002-bbbbbb", sidecars={"axial": AXIAL})
442 left = sorted(p.name for p in (lx._runs_dir(session) / "_sidecar").iterdir())
443 assert left == ["20261003-000002-bbbbbb.axial.json"]
444
445
447 client, session, lx = api
448 job = lx.Job(session.user, lx.Settings(drawing_id="d"), "design")
449 with lx._JOBS_LOCK:
450 lx._JOBS[job.id] = job
451 lx._execute(job, lambda progress, cancelled: {**_export_run(), "sidecars": ["axial"], "_sidecars": {"axial": AXIAL}},
452 lx._runs_dir(session))
453 assert job.status == "done" and "_sidecars" not in job.result
454 assert client.get(f"/api/layerx/runs/{job.id}/sidecar/axial").json() == AXIAL
455
456
458 client, session, lx = api
459 rid = "20261003-000001-aaaaaa"
460 _save(lx, session, rid, sidecars={"axial": AXIAL}, result=_export_run())
461 csv = client.get(f"/api/layerx/runs/{rid}/export/csv")
462 assert csv.status_code == 200 and csv.headers["content-type"].startswith("text/csv")
463 assert f'filename="layerx-{rid}.csv"' in csv.headers["content-disposition"]
464 assert csv.text.splitlines()[0].startswith("t [s],dt [s],firing [bool]")
465 pq = client.get(f"/api/layerx/runs/{rid}/export/parquet")
466 try:
467 import pyarrow # noqa: F401
468
469 assert pq.status_code == 200 and pq.content[:4] == b"PAR1"
470 except ImportError:
471 assert pq.status_code == 501
472 fea = client.get(f"/api/layerx/runs/{rid}/export/fea")
473 names = zipfile.ZipFile(io.BytesIO(fea.content)).namelist()
474 assert fea.status_code == 200 and {"pc_t.csv", "thrust_t.csv", "heatflux_xt.csv", "loads.json"} <= set(names)
475 assert client.get(f"/api/layerx/runs/{rid}/export/xls").status_code == 404
476 _save(lx, session, "20261003-000002-bbbbbb", kind="setpoint", result={"mode": "setpoint"})
477 assert client.get("/api/layerx/runs/20261003-000002-bbbbbb/export/csv").status_code == 400
478
479
481 client, session, lx = api
482 for i, kind in enumerate(("run", "trade", "optimize", "setpoint", "hardware", "reconcile", "uncertainty")):
483 _save(lx, session, f"20261003-00000{i}-aaaaa{i}", kind=kind)
484 listed = {r["kind"]: bool(r.get("legacy")) for r in client.get("/api/layerx/runs").json()}
485 assert listed == {"run": False, "trade": True, "optimize": True, "setpoint": False, "hardware": False,
486 "reconcile": False, "uncertainty": False}
487 assert client.get("/api/layerx/runs/20261003-000002-aaaaa2").json()["legacy"] is True
488
489
491 client, session, lx = api
492 body = client.get("/api/layerx/catalog").json()
493 assert body["problems"] == [] and body["drills"]
494 shipped = {k: v for k, v in body.items() if k not in ("drills", "problems")}
495 assert shipped and all(r["origin"] == "shipped" for rows in shipped.values() for r in rows)
496 kind, rows = next((k, v) for k, v in shipped.items() if v)
497 udir = lx._user_dir(session) / "layerx" / "catalog"
498 udir.mkdir(parents=True, exist_ok=True)
499 (udir / f"{kind}.json").write_text("not json")
500 again = client.get("/api/layerx/catalog").json()
501 assert again["problems"] and again[kind] == rows # reported, and the shipped rows stand
502
503
505 """The helium study drawing as LE4 has it: the dome loaded from the cart (its loader, PR-CTRL,
506 gone with the GSE) and a transducer on the dome line, clipped to a junction there."""
507 import copy as _copy
508
509 from engine.layerx.sources import shipped_drawings_dir
510
511 payload = json.loads((shipped_drawings_dir() / "copv_study_he.json").read_text())
512 out = _copy.deepcopy(payload)
513 out["nodes"] = [n for n in out["nodes"] if n["id"] != "PR_C"]
514 out["edges"] = [e for e in out["edges"] if "PR_C" not in (e["source"], e["target"])]
515 for n in out["nodes"]:
516 if n["id"] == "PR_D":
517 n["data"].setdefault("options", {})["domeLoaded"] = "yes"
518 out["nodes"] += [
519 {"id": "JD", "position": {"x": 250, "y": 380}, "data": {"componentType": "JUNCTION", "label": "JD"}},
520 {"id": "PT_DOME", "position": {"x": 250, "y": 420}, "data": {"componentType": "PT", "label": "DP-PT"}},
521 ]
522 out["edges"] += [{"id": "PR_D-JD", "source": "PR_D", "sourceHandle": "dome", "target": "JD", "targetHandle": "l"},
523 {"id": "JD-PT_DOME", "source": "JD", "sourceHandle": "b", "target": "PT_DOME", "targetHandle": "b"}]
524 return out
525
526
528 """LE4's DP-PT-R sits on the dome line of DPR_HP, whose loader is on the cart: the burn left the
529 line at atmosphere and the Stand page predicted 0.0 psig under a 550 psia dome (D-27). The
530 line's transducers read the dome signal; every other one reads the network as before."""
531 pytest.importorskip("feedtwin")
532 from feedtwin.pid import read_diagram
533 from feedtwin.pid.network import build_network
534
535 from engine.layerx.analysis import _instrument_series, dome_line_instruments
536
537 diagram = read_diagram(_study_with_a_cut_dome_line(), name="study, cut dome")
538 built = build_network(diagram)
539 domes = dome_line_instruments(diagram, built)
540 pt = next(i for i in built.instruments if i.id == "PT_DOME")
541 assert domes == {pt.node: "PR-DOME.dome"}
542 # With its loader drawn (the study as shipped), the session reads the line: nothing to add.
543 from engine.layerx.sources import shipped_drawings_dir
544
545 shipped = read_diagram(json.loads((shipped_drawings_dir() / "copv_study_he.json").read_text()), name="study")
546 assert dome_line_instruments(shipped, build_network(shipped)) == {}
547
548 other = next(i for i in built.instruments if i.id != "PT_DOME")
549 trace = SimpleNamespace(t=[0.0, 0.05], pressure={pt.node: [101325.0] * 2, other.node: [3e6, 2.9e6]}, temperature={},
550 dome_readings={pt.node: [101325.0 + 535 * PSI] * 2})
551 prep = SimpleNamespace(model=SimpleNamespace(built=built))
552 series = _instrument_series(prep, trace)
553 assert series["PT_DOME"]["values"] == pytest.approx([549.6959] * 2, abs=1e-3)
554 assert series[other.id]["values"] == pytest.approx([3e6 / PSI, 2.9e6 / PSI])
a class to store JSON values
Definition json.hpp:19401
test_pruning_takes_a_runs_sidecar_with_it(api, monkeypatch)
test_gn2_on_lox_is_flagged_above_nitrogens_saturation_pressure_by_hand()
test_a_runs_sidecar_is_written_beside_it_served_and_deleted_with_it(api)
_tank(T=None, fluid="oxygen", source="measured")
test_a_failing_diagnostic_is_reported_and_the_rest_stand(monkeypatch)
_save(lx, session, run_id, kind="run", sidecars=None, result=None)
test_expected_tank_rise_is_the_regulator_law_at_the_isentropic_bottle_by_hand()
test_new_diagnostics_grade_amber_at_worst_and_old_limits_keep_their_grades()
test_the_default_chug_grade_is_todays_whole_burn_minimum_with_the_new_margins_beside_it()
test_an_option_it_cannot_take_fails_preflight_before_anything_is_built(bad)
test_the_run_job_hands_its_sidecars_to_the_job_not_the_result(api)