1"""Layer X records the whole feed network on every burn, and a vessel trip ends the burn.
3Two lib/feedtwin features wired into ``engine/layerx/analysis.py`` (DATA-CONTRACT 2 and 4):
5* **The network recorder** (``Probes(network=True)``, ``network_dict``). Recording only: a GN2 burn
6 with it and one without give the same series and summary to the last bit, and the block it writes
7 telescopes -- each side's rungs add up, step by step, to its first node minus the chamber.
8* **The vessel trip** (``BurnEnd.tripped``, ``trip_record``). The AUDIT's case is a tank whose MAWP
9 is restated below the pressure the burn reaches (AUDIT section 5, #1: Layer X then integrated the
10 frozen frame to the 14 s horizon, 99,478 N s for a ~24,240 N s load). Here TK-FUEL is restated to
11 566.3 psi on the helium drawing, which trips 0.575 s after Fire: the result must stop at the trip
12 (series, burn time, impulse), say so (``tripped``, a ``fail`` event keyed ``trip``, ``converged``
13 false, ``vessel_trip`` graded bad), and every tool that grades burns must count it as failing.
14 The same with the AUDIT's own 600 psi, through the replay and the diagnostics, is in
15 tests/test_layerx_integration_le4.py (LAYERX_GOLDEN=1).
17Trips in the settle and in the lead-in cannot be reached from a drawing that passes preflight (the
18T-0 lockup is checked against the MAWP), so those two paths are driven by tripping the session
19directly, the way ``Session._check_limits`` does.
22from __future__
import annotations
33STUDY_LOCKUP_PSIA = 564.7
35sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
37feedtwin = pytest.importorskip(
"feedtwin", reason=
"lib/feedtwin is not installed")
39from engine.layerx import DrawingStore, LayerXSettings, StandTripped, prepare, run_prepared
44ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
45CFG = os.path.join(ROOT,
"configs",
"ethalox_6800N.yaml")
46PSI = 6894.757293168361
47ATM_PSI = 101325.0 / PSI
53pytestmark = pytest.mark.skipif(
54 not (shipped_drawings_dir() /
"copv_study_he.json").is_file()
55 or not (shipped_drawings_dir() /
"copv_study_gn2.json").is_file(),
56 reason=
"feed-twin's shipped drawings are not next to this checkout")
60 return {d.name: d
for d
in DrawingStore(
None).list()}[name]
63def _low_mawp(psi: float = LOW_MAWP_PSI) -> Override:
64 return Override(target=
"node:FUT", parameter=
"MAWP", value=psi, unit=
"psi",
65 source=
"test what-if (not a measurement)")
68@pytest.fixture(scope="module")
70 return load_config(CFG)
73@pytest.fixture(scope="module")
78@pytest.fixture(scope="module")
80 """One GN2 burn as Layer X runs it (network recorded) and the same burn with the recorder off."""
84 settings =
LayerXSettings(drawing_id=drawing.id, tank_pressure_psia=STUDY_LOCKUP_PSIA, ack_gn2_condensation=
True, replay=
False)
85 prep = prepare(cfg,
None, drawing, settings, [])
87 on = run_prepared(prep, replay=
False)
88 original = fb.BurnTrace.recorder
90 def without_network(self, session):
91 self.probes = dataclasses.replace(self.probes, network=
False)
92 return original(self, session)
94 with pytest.MonkeyPatch.context()
as mp:
95 mp.setattr(fb.BurnTrace,
"recorder", without_network)
96 off = run_prepared(prepare(cfg,
None, drawing, settings, []), replay=
False)
97 return {
"on": on,
"off": off,
"prep": prep}
100@pytest.fixture(scope="module")
102 """The early trip as the router runs a burn: erosion replay, flight asked for, diagnostics on.
103 The flight is a stub that counts its calls: a tripped burn must not be flown."""
107 runner = PintleEngineRunner(cfg)
108 prep = prepare(cfg, runner, he,
LayerXSettings(drawing_id=he.id, tank_pressure_psia=STUDY_LOCKUP_PSIA, flight=
True), [
_low_mawp()])
109 assert prep.ok, [c.detail
for c
in prep.checks
if c.status ==
"fail"]
111 with pytest.MonkeyPatch.context()
as mp:
112 mp.setattr(flt,
"fly",
lambda *a, **k: calls.append(1)
or {
"ok":
False,
"error":
"flown"})
113 result = run_prepared(prep, runner=runner, config=cfg, diagnostics=
True)
114 return {
"prep": prep,
"result": result,
"fly_calls": len(calls)}
117@pytest.fixture(scope="module")
119 """The same trip as the sweep burns it: no replay, no flight, so no passes decide ``converged``."""
120 prep = prepare(cfg,
None, he,
LayerXSettings(drawing_id=he.id, tank_pressure_psia=STUDY_LOCKUP_PSIA, replay=
False), [
_low_mawp()])
121 return run_prepared(prep, replay=
False)
128 on, off = gn2_pair[
"on"], gn2_pair[
"off"]
129 assert "network" in on
and "network" not in off
131 assert on[
"series"] == off[
"series"]
132 assert on[
"summary"] == off[
"summary"]
133 assert [(e[
"t"], e[
"key"])
for e
in on[
"events"]] == [(e[
"t"], e[
"key"])
for e
in off[
"events"]]
138 net, series = r[
"network"], r[
"series"]
140 assert net[
"t"] == series[
"t"]
141 kinds = {nd[
"kind"]
for nd
in net[
"nodes"].values()}
142 assert {
"bottle",
"tank",
"tank_outlet",
"chamber",
"injector_inlet"} <= kinds
143 for nid, nd
in net[
"nodes"].items():
144 assert {
"label",
"kind",
"side",
"phase",
"p_psia",
"T_K"} <= set(nd), nid
145 assert len(nd[
"p_psia"]) == n
and len(nd[
"T_K"]) == n
146 for bid, br
in net[
"branches"].items():
147 assert {
"label",
"kind",
"from",
"to",
"side",
"mdot",
"dp_psi"} <= set(br), bid
148 assert br[
"from"]
in net[
"nodes"]
and br[
"to"]
in net[
"nodes"]
149 assert len(br[
"mdot"]) == n
and len(br[
"dp_psi"]) == n
150 chamber = next(k
for k, nd
in net[
"nodes"].items()
if nd[
"kind"] ==
"chamber")
151 for side, key
in ((
"ox",
"ox"), (
"fuel",
"fuel")):
152 path = net[
"paths"][side]
153 first = net[
"branches"][path[0]]
154 assert net[
"nodes"][first[
"from"]][
"kind"] ==
"bottle"
155 assert net[
"branches"][path[-1]][
"kind"] ==
"injector" and net[
"branches"][path[-1]][
"to"] == chamber
156 assert any(net[
"branches"][b][
"kind"] ==
"tank_head" for b
in path)
158 rungs = sum(net[
"branches"][b][
"dp_psi"][i]
for b
in path)
159 whole = net[
"nodes"][first[
"from"]][
"p_psia"][i] - net[
"nodes"][chamber][
"p_psia"][i]
160 assert rungs == pytest.approx(whole, abs=1e-6), (side, i)
162 inj = net[
"branches"][path[-1]][
"mdot"]
163 for i, on
in enumerate(series[
"firing"]):
165 assert inj[i] == pytest.approx(series[key][
"mdot"][i], rel=1e-9, abs=1e-12)
171 r, prep = gn2_pair[
"on"], gn2_pair[
"prep"]
172 d, _ = diagnostics_of(prep, r, cfg,
None)
173 for name
in (
"ladder",
"regulator",
"saturation"):
174 assert d[name].get(
"available",
True)
is not False, (name, d[name].get(
"error"))
175 assert d[
"ladder"][
"ox"][
"closes"]
and d[
"ladder"][
"fuel"][
"closes"]
176 assert isinstance(d[
"solenoids"], list)
and d[
"solenoids"]
177 assert all(row.get(
"available",
True)
is not False for row
in d[
"solenoids"])
179 assert math.isfinite(d[
"pressurant"][
"required_kg"])
and math.isfinite(d[
"pressurant"][
"margin_kg"])
180 assert "network record" in d[
"vv"][
"mass"][
"ox"][
"basis"]
182 bare = {k: v
for k, v
in r.items()
if k !=
"network"}
183 d0, _ = diagnostics_of(prep, bare, cfg,
None)
184 for name
in (
"ladder",
"regulator",
"saturation"):
185 assert d0[name].get(
"available")
is False and "network" in d0[name][
"error"], name
192 r, prep = tripped[
"result"], tripped[
"prep"]
194 assert trip[
"vessel"] ==
"FUT" and trip[
"kind"] ==
"tank"
195 assert trip[
"mawp_psia"] == pytest.approx(LOW_MAWP_PSI + ATM_PSI, abs=1e-6)
196 assert trip[
"p_psia"] > trip[
"mawp_psia"]
197 s, series = r[
"summary"], r[
"series"]
198 assert 0.0 < trip[
"t"] < 2.0
200 assert series[
"t"][-1] == pytest.approx(trip[
"t"], abs=1e-12)
and series[
"firing"][-1]
201 assert s[
"burn_time_s"] == pytest.approx(trip[
"t"], abs=1e-12)
206 assert series[
"t"][-2] < trip[
"t"] <= series[
"t"][-2] + prep.plan.dt + 1e-9
207 assert series[
"dt"][-1] == pytest.approx(series[
"t"][-1] - series[
"t"][-2], abs=1e-12)
208 impulse = sum(f * dt
for f, dt, on
in zip(series[
"chamber"][
"thrust_N"], series[
"dt"], series[
"firing"])
if on)
209 assert s[
"total_impulse_Ns"] == pytest.approx(impulse, rel=1e-12)
210 assert s[
"total_impulse_Ns"] < 1.1 * s[
"peak_thrust_N"] * trip[
"t"]
211 assert s[
"depleted_side"] ==
"" and s[
"impulse_to_depletion_Ns"]
is None
212 assert r[
"converged"]
is False
213 assert len(r[
"network"][
"t"]) == len(series[
"t"])
214 assert any(
"Burn stopped at t =" in note
for note
in r[
"provenance"][
"notes"])
216 assert len(r[
"passes"]) == 1
and r[
"passes"][0][
"tripped"]
218 assert dv[
"t"][-1] == pytest.approx(trip[
"t"], abs=1e-12)
219 assert 0.0 < dv[
"summary"][
"total_impulse_Ns"] < 1.1 * s[
"peak_thrust_N"] * trip[
"t"]
220 assert tripped[
"fly_calls"] == 0
221 assert r[
"flight"][
"ok"]
is False and "vessel trip" in r[
"flight"][
"error"]
226 assert "passes" not in r
and r[
"converged"]
is False
227 assert r[
"tripped"][
"t"] == pytest.approx(tripped[
"result"][
"tripped"][
"t"], abs=1e-12)
228 assert r[
"series"][
"t"][-1] == pytest.approx(r[
"tripped"][
"t"], abs=1e-12)
232 d = tripped[
"result"][
"diagnostics"]
233 for name, block
in d.items():
234 rows = block
if isinstance(block, list)
else [block]
235 assert isinstance(block, list)
or block.get(
"available",
True)
is not False, (name, block.get(
"error"))
236 assert all(row.get(
"available",
True)
is not False for row
in rows), name
239 assert "vessel trip" in sd[
"first_dry_basis"]
240 assert sd[
"valve_command_s"] == pytest.approx(tripped[
"result"][
"tripped"][
"t"])
244 r = tripped[
"result"]
246 ev = {e[
"key"]: e
for e
in r[
"events"]}
247 assert ev[
"trip"][
"kind"] ==
"fail" and ev[
"trip"][
"t"] == pytest.approx(trip[
"t"])
248 assert "TK-FUEL" in ev[
"trip"][
"label"]
249 assert not any(e[
"label"] ==
"Horizon reached" for e
in r[
"events"])
250 assert "dry_ox" not in ev
and "dry_fuel" not in ev
251 assert ev[
"burnout"][
"t"] == pytest.approx(trip[
"t"])
and "trip" in ev[
"burnout"][
"detail"]
252 keys = [e[
"key"]
for e
in r[
"events"]]
253 assert len(keys) == len(set(keys))
254 g = {e[
"key"]: e
for e
in r[
"limits"]}
255 assert g[
"vessel_trip"][
"grade"] ==
"bad" and g[
"vessel_trip"][
"t_worst"] == pytest.approx(trip[
"t"])
256 assert "vessel trip" in g[
"depletion"][
"hint"]
262 out = _listing_summary(tripped[
"result"])
263 assert out[
"tripped"][
"vessel"] ==
"FUT" and out[
"tripped"][
"t"] == tripped[
"result"][
"tripped"][
"t"]
264 assert "tripped" not in _listing_summary({k: v
for k, v
in tripped[
"result"].items()
if k !=
"tripped"})
274 monkeypatch.setattr(A,
"run_prepared",
lambda prep, **kw: tripped[
"result"])
275 p = sp.burn_point({
"config": cfg,
"drawing": he,
"settings":
LayerXSettings(drawing_id=he.id, tank_pressure_psia=STUDY_LOCKUP_PSIA),
276 "overrides": [
_low_mawp()],
"replay":
False})
277 assert p[
"ok"]
and p[
"tripped"][
"vessel"] ==
"FUT"
278 assert not sp.usable(p)
and not sp.feasible(p)
279 assert next(r
for r
in p[
"limits"]
if r[
"key"] ==
"vessel_trip")[
"grade"] ==
"bad"
286 monkeypatch.setattr(A,
"run_prepared",
lambda prep, **kw: tripped[
"result"])
287 case = U._burn_case((cfg, he,
LayerXSettings(drawing_id=he.id, tank_pressure_psia=STUDY_LOCKUP_PSIA), [], {
"id":
"x|high"}))
288 assert case[
"ok"]
is False and case[
"tripped"][
"vessel"] ==
"FUT"
289 assert case[
"error"].startswith(
"vessel trip: TK-FUEL")
292 nominal = {
"ok":
True,
"case":
"nominal",
"metrics": {k: 1.0
for k
in U.METRICS},
"thrust": [],
"depleted":
"oxidiser",
293 "lockup_psia": 578.0}
299 if cid.endswith(
"|high"):
300 return {
"ok":
False,
"case": cid,
"tripped": tripped[
"result"][
"tripped"],
301 "error": U._trip_words(tripped[
"result"][
"tripped"])}
302 return {**nominal,
"case": cid}
304 monkeypatch.setattr(U,
"_burn_case", fake_case)
305 out = U.run_sweep(cfg, he,
LayerXSettings(drawing_id=he.id, tank_pressure_psia=STUDY_LOCKUP_PSIA), [], workers=1)
306 trips = [c
for c
in out[
"crossings"]
if c.get(
"tripped")]
307 assert trips
and all(c[
"side"] ==
"high" for c
in trips)
308 assert all(c[
"breaks"][0].startswith(
"vessel trip")
for c
in trips)
309 for f
in out[
"factors"]:
310 for side, cs
in f[
"cases"].items():
311 assert cs[
"ok"]
is (side !=
"high")
313 assert cs[
"tripped"][
"vessel"] ==
"FUT" and "delta" not in cs
314 assert any(
"tripped the stand" in n
for n
in out[
"notes"])
315 assert not any(
"failed:" in n
for n
in out[
"notes"])
317 monkeypatch.setattr(U,
"_burn_case",
lambda args: {
"ok":
False,
"case": args[4][
"id"],
318 "tripped": tripped[
"result"][
"tripped"],
319 "error": U._trip_words(tripped[
"result"][
"tripped"])})
320 with pytest.raises(RuntimeError, match=
"vessel trip"):
321 U.run_sweep(cfg, he,
LayerXSettings(drawing_id=he.id, tank_pressure_psia=STUDY_LOCKUP_PSIA), [], workers=1)
325 """Sweep 20261102-122930-2c5489 on LE4 (3): the nominal emptied LOX first, two Cd cases emptied fuel
326 first with 0.003 kg of LOX left, and the page said '2 inputs break a limit: measure these first'. A
327 run grades which tank empties first as information and the propellant left over at 0.2 kg (limits.py);
328 the sweep grades a flip by the same residual."""
332 metrics = {**{k: 1.0
for k
in U.METRICS},
"ox_stiffness_min": 0.3,
"fuel_stiffness_min": 0.3,
"copv_end_psia": 1200.0}
333 nominal = {
"ok":
True,
"case":
"nominal",
"metrics": metrics,
"thrust": [],
"depleted":
"oxidiser",
334 "lockup_psia": 578.0,
"residual_kg": 0.004}
341 return {**nominal,
"case": cid,
"depleted":
"fuel",
"residual_kg": 0.003
if cid.endswith(
"|low")
else 0.35}
343 monkeypatch.setattr(U,
"_burn_case", fake_case)
344 out = U.run_sweep(cfg, he,
LayerXSettings(drawing_id=he.id, tank_pressure_psia=STUDY_LOCKUP_PSIA), [], workers=1)
345 assert out[
"crossings"],
"a flip that strands 0.35 kg is a crossing"
346 assert not [c
for c
in out[
"crossings"]
if c[
"side"] ==
"low"]
347 assert {tuple(c[
"breaks"])
for c
in out[
"crossings"]} == {(
"LOX-first becomes fuel-first, 0.35 kg of LOX left > 0.2 kg",)}
348 lows = [f[
"cases"][
"low"]
for f
in out[
"factors"]
if "low" in f[
"cases"]]
349 assert lows
and all(cs[
"breaks"] == []
for cs
in lows)
357 f = opt._figures(tripped[
"result"])
358 assert f[
"tripped"][
"vessel"] ==
"FUT"
359 g = opt.grade({
"ok":
True,
"preflight": [],
"figures": f,
"x": {
"lockup_psia": 578.0}},
360 opt.OptimizeRequest(), {},
None)
361 keys = {c[
"key"]: c
for c
in g[
"constraints"]}
362 assert not keys[
"vessel_trip"][
"ok"]
and "depletion" not in keys
363 assert g[
"violation"] >= 1.0
365 monkeypatch.setattr(A,
"run_prepared",
lambda prep, **kw: tripped[
"result"])
366 with pytest.raises(ValueError, match=
"tripped"):
367 R.run_reconcile(cfg, he,
LayerXSettings(drawing_id=he.id, tank_pressure_psia=STUDY_LOCKUP_PSIA, replay=
False), [], R.ReconcileRequest(max_passes=1))
376 sim = session.tanks[vessel]
377 session.trip = Trip(vessel, sim.label,
"tank", session.t, sim.pressure, sim.pressure * 0.99)
378 session.tripped = f
"{sim.label} over its MAWP (test)."
386 original = fb.prime_at_t0
388 def prime(session, plan):
389 settled = original(session, plan)
393 monkeypatch.setattr(fb,
"prime_at_t0", prime)
394 prep = prepare(cfg,
None, he,
LayerXSettings(drawing_id=he.id, tank_pressure_psia=STUDY_LOCKUP_PSIA, replay=
False), [])
395 with pytest.raises(StandTripped)
as caught:
396 run_prepared(prep, replay=
False)
397 assert caught.value.record[
"vessel"] ==
"FUT" and "settle" in str(caught.value)
398 p = sp.burn_point({
"config": cfg,
"drawing": he,
"settings":
LayerXSettings(drawing_id=he.id, tank_pressure_psia=STUDY_LOCKUP_PSIA),
"overrides": [],
400 assert p[
"ok"]
is False and p[
"tripped"][
"vessel"] ==
"FUT" and not sp.usable(p)
401 case = U._burn_case((cfg, he,
LayerXSettings(drawing_id=he.id, tank_pressure_psia=STUDY_LOCKUP_PSIA), [], {
"id":
"nominal"}))
402 assert case[
"ok"]
is False and case[
"tripped"][
"vessel"] ==
"FUT"
409 original = fb.prime_at_t0
411 def prime(session, plan):
412 settled = original(session, plan)
423 session.step = stepped
426 monkeypatch.setattr(fb,
"prime_at_t0", prime)
427 runner = PintleEngineRunner(cfg)
428 prep = prepare(cfg, runner, he,
LayerXSettings(drawing_id=he.id, tank_pressure_psia=STUDY_LOCKUP_PSIA), [])
429 assert prep.link
is not None and prep.link.sampler
is not None
430 r = run_prepared(prep, runner=runner, config=cfg)
432 assert trip[
"t"] < 0.0
and not any(r[
"series"][
"firing"])
433 assert r[
"summary"][
"total_impulse_Ns"] == 0.0
and r[
"summary"][
"burn_time_s"] == 0.0
434 assert r[
"converged"]
is False and len(r[
"passes"]) == 1
and r[
"passes"][0][
"tripped"]
435 assert r[
"replay"][
"available"]
is False and "before Fire" in r[
"replay"][
"error"]
436 labels = [e[
"label"]
for e
in r[
"events"]]
437 assert "Erosion replay failed" not in labels
and "Burn did not settle" not in labels
438 assert next(e
for e
in r[
"events"]
if e[
"key"] ==
"trip")[
"kind"] ==
"fail"
439 assert "burnout" not in {e[
"key"]
for e
in r[
"events"]}
test_a_trip_is_unconverged_even_with_nothing_to_iterate(tripped_bare, tripped)
test_the_legacy_optimiser_and_reconcile_refuse_a_tripped_burn(tripped, cfg, he, monkeypatch)
test_recording_the_network_moves_no_number(gn2_pair)
_trip_now(session, vessel="FUT")
test_a_trip_in_the_lead_in_fires_nothing_and_is_not_replayed(cfg, he, monkeypatch)
test_a_trip_in_the_settle_raises_with_its_record(cfg, he, monkeypatch)
test_the_set_point_and_hardware_tools_do_not_use_a_tripped_burn(tripped, cfg, he, monkeypatch)
test_the_sweep_leaves_a_tripped_case_out_of_the_swings_and_lists_it(tripped, cfg, he, monkeypatch)
test_a_trip_is_a_fail_event_and_a_bad_limit_not_a_horizon(tripped)
test_the_sweep_counts_a_flip_of_the_tank_that_runs_dry_first_only_when_it_strands_propellant(cfg, he, monkeypatch)
test_the_feed_diagnostics_build_from_the_recorded_network(gn2_pair, cfg)
test_the_diagnostics_read_a_tripped_burn(tripped)
test_the_network_block_is_the_contracts_and_its_rungs_telescope(gn2_pair)
test_the_listing_marks_a_tripped_run(tripped)
test_a_trip_ends_the_burn_where_it_happened(tripped)
Override _low_mawp(float psi=LOW_MAWP_PSI)