STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_layerx.py
Go to the documentation of this file.
1"""Layer X: the feed twin and this engine, burned together (engine/layerx).
2
3What these check, and against what:
4
5* The dome dial Layer X solves for is checked against the regulator relation
6 written out by hand from the drawing's own numbers: bias, supply-pressure
7 effect, gauge zero. It is not checked against the twin's solver.
8* The engine card (phase 2) has to reproduce EngineDesign on solves it was not
9 fitted to, through the whole burn the twin runs on it, and has to fail its
10 check against an engine it was not built from.
11* The boundary between the twin's lines and the card is the line exit. What
12 EngineDesign loses between the line exit and its manifold is exactly the
13 Borda dump of the line's velocity head, checked by hand.
14* The T-0 fit (phase 1, kept for comparison) has to make the twin reproduce
15 EngineDesign exactly at the calibration point: chamber pressure, thrust, and
16 the line-exit-to-chamber drop at EngineDesign's flows.
17* A burn has to conserve propellant. Mass that left the tanks equals mass the
18 chamber burned, step by step. The tank that runs dry first has to be the one
19 the load ratio and the delivered O/F say it is.
20* Preflight has to refuse a load that does not fit and a drawing that carries the
21 wrong propellant, naming the problem rather than raising.
22
23Physics in the twin itself is covered by lib/feedtwin's suite and by
24scripts/physics_benchmark.py. These tests cover the seam.
25"""
26
27from __future__ import annotations
28
29import copy
30import json
31import time
32from pathlib import Path
33
34import pytest
35
36feedtwin = pytest.importorskip("feedtwin", reason="lib/feedtwin is not installed (pip install -e ../lib/feedtwin)")
37
38from engine.core.runner import PintleEngineRunner # noqa: E402
39from engine.layerx import DrawingStore, LayerXSettings, prepare, run_prepared # noqa: E402
40from engine.layerx.link import twin_injector_check # noqa: E402
41from engine.layerx.prepare import PSI, lockup_for_dome # noqa: E402
42from engine.layerx.sources import Drawing, drawing_id, normalise, sha256, shipped_drawings_dir # noqa: E402
43from engine.pipeline.io import load_config # noqa: E402
44
45FIXTURE = Path(__file__).parent / "fixtures" / "ethalox_6500N_doublet_cad_2026-09-28.yaml"
46GN2 = "copv_study_gn2"
47# The GN2 drawing is a nitrogen-over-LOX hot fire, refused since 2026-10-03 unless acknowledged
48# (engine/layerx/prepare.py gn2_on_lox); these tests study its burn, so their settings acknowledge it.
49
50pytestmark = pytest.mark.skipif(
51 not (shipped_drawings_dir() / f"{GN2}.json").is_file(),
52 reason="feed-twin's shipped drawings are not next to this checkout",
53)
54
55
56@pytest.fixture(scope="module")
57def config():
58 return load_config(str(FIXTURE))
59
60
61@pytest.fixture(scope="module")
62def runner(config):
63 return PintleEngineRunner(copy.deepcopy(config))
64
65
66@pytest.fixture(scope="module")
67def drawing() -> Drawing:
68 found = {d.name: d for d in DrawingStore(None).list()}
69 return found[GN2]
70
71
72@pytest.fixture(scope="module")
73def prepared(config, runner, drawing):
74 return prepare(config, runner, drawing, LayerXSettings(drawing_id=drawing.id, ack_gn2_condensation=True, tank_pressure_psia=578.0))
75
76
77def _check(prep, key):
78 return next(c for c in prep.checks if c.key == key)
79
80
81# ------------------------------------------------------------------ the dome dial
82
83
85 """lockup = dome + atmosphere + bias - S x p_bottle (gauge), all in psi.
86
87 Every number is read off the drawing JSON directly, not through feedtwin:
88 PR-DOME's dome_bias 50 psi and supply_coefficient 17 psi/1000 psi. The
89 supply effect is measured from zero inlet (the drawing's inlet_reference is
90 not read). The bottle is 4500 psig, the gauge zero is 101325 Pa, and
91 PR-CTRL adds no bias of its own.
92 """
93 nodes = {n["id"]: n for n in drawing.payload["nodes"]}
94 dome_params = nodes["PR_D"]["data"]["params"]
95 bias = dome_params["dome_bias"]["value"]
96 spe = dome_params["supply_coefficient"]["value"] / 1000.0
97 atmosphere = 101325.0 / PSI
98 by_hand = 578.0 - atmosphere - bias + spe * 4500.0
99 assert prepared.derived["dome_psig"] == pytest.approx(by_hand, abs=1e-6)
100 # And the relation run forward through the drawing's own components lands on the target.
101 from feedtwin.session.gauge import from_psig
102
103 got = lockup_for_dome(prepared.model, prepared.derived["dome_psig"], from_psig(4500.0))
104 assert got / PSI == pytest.approx(578.0, abs=1e-6)
105
106
108 """The card's reference is EngineDesign solved with the line exit at the lockup itself, so LOX and
109 fuel read the same drop by construction (153.1 / 153.1 psi on LE4 (3)) where the burn, after the
110 drawing's lines, gives ~114 / 108. It was printed as the drop "at T-0"."""
111 detail = _check(prepared, "engine_boundary").detail
112 assert "at T-0" not in detail
113 assert detail.startswith("EngineDesign at the 578.0 psia lockup with the drawing's lines taken as lossless")
114
115
116# ------------------------------------------------------------------ the T-0 calibration
117
118
119def test_t0_fit_reproduces_enginedesign_at_t0(config, runner, drawing):
120 prep = prepare(config, runner, drawing, LayerXSettings(drawing_id=drawing.id, ack_gn2_condensation=True, tank_pressure_psia=578.0,
121 engine_model="calibrated"))
122 link = prep.link
123 ref = link.reference
124 got = link.chamber.evaluate(ref["mdot_O"], ref["mdot_F"])
125 assert got.pressure == pytest.approx(ref["Pc"], rel=1e-6)
126 assert got.thrust == pytest.approx(ref["F"], rel=1e-6)
127
128 inlet = prep.inlet_nodes
129 net = prep.model.built.network
130
131 def fluid_at(side, pressure):
132 cond = net.conditions(inlet[side], pressure)
133 return cond.rho, cond.mu
134
135 for side, (twin_dp, ed_dp) in twin_injector_check(link, fluid_at).items():
136 assert twin_dp == pytest.approx(ed_dp, rel=1e-9), side
137
138
139# ------------------------------------------------------------------ the engine card
140
141
143 """EngineDesign at the line exit (its line losses zeroed) drops, between the line exit
144 and its still manifold, exactly K_exit rho v^2/2 at the exit bore -- by hand from the
145 config's own density and bore."""
146 import math as _m
147
148 from engine.layerx.card import line_exit_config
149
150 cfg = line_exit_config(config)
151 res = PintleEngineRunner(cfg).evaluate(578.0 * PSI, 578.0 * PSI, silent=True)
152 d = res["diagnostics"]
153 for side, key, mdot in (("oxidizer", "O", res["mdot_O"]), ("fuel", "F", res["mdot_F"])):
154 feed = cfg.feed_system[side]
155 rho = cfg.fluids[side].density
156 area = _m.pi * (feed.d_exit / 2) ** 2 if feed.d_exit else feed.A_hydraulic
157 by_hand = feed.K_exit * 0.5 * rho * (mdot / (rho * area)) ** 2
158 assert d[f"delta_p_feed_{key}"] == pytest.approx(by_hand, rel=1e-9), side
159 assert by_hand > 5.0 * PSI # it is not a rounding error: ~26 psi LOX on this engine
160
161
163 from engine.layerx.card import ENVELOPE_LEVELS, ENVELOPE_RATIOS, TOLERANCE, EngineSampler, _random, check_card
164
165 card = prepared.link.card
166 assert card.provenance["within_tolerance"]
167 sampler = EngineSampler(config, prepared.ambient_pa)
168 fresh = _random(sampler, 578.0 * PSI, 25, ENVELOPE_LEVELS, ENVELOPE_RATIOS, seed=1234)
169 errors = check_card(card, fresh, prepared.ambient_pa)
170 for key in ("closed_pc", "closed_thrust", "closed_mdot", "chamber_pc", "chamber_thrust", "dp_O", "dp_F"):
171 assert errors[key] < TOLERANCE, (key, errors[key])
172
173
175 """A card is a sampled engine. Check it against a different one -- a 3 % larger throat --
176 and the check has to say so; served for the new config it is not even the same card."""
177 from engine.layerx.card import (ENVELOPE_LEVELS, ENVELOPE_RATIOS, TOLERANCE, EngineSampler, _random,
178 card_for, check_card)
179
180 other = copy.deepcopy(config)
181 other.chamber_geometry.A_throat *= 1.03
182 sampler = EngineSampler(other, prepared.ambient_pa)
183 points = _random(sampler, 578.0 * PSI, 10, ENVELOPE_LEVELS, ENVELOPE_RATIOS, seed=99)
184 errors = check_card(prepared.link.card, points, prepared.ambient_pa)
185 assert errors["closed_pc"] > TOLERANCE
186 assert card_for(other, center_pa=578.0 * PSI, ambient_pa=prepared.ambient_pa) is not prepared.link.card
187
188
189def test_native_engine_is_not_enginedesign(config, runner, drawing):
190 """The gap the calibration closes is real: the twin's own Reynolds-law Cd
191 drops a different pressure across the injector at EngineDesign's flows."""
192 prep = prepare(config, runner, drawing, LayerXSettings(drawing_id=drawing.id, ack_gn2_condensation=True, engine_model="native"))
193 inlet = prep.inlet_nodes
194 net = prep.model.built.network
195
196 def fluid_at(side, pressure):
197 cond = net.conditions(inlet[side], pressure)
198 return cond.rho, cond.mu
199
200 gaps = twin_injector_check(prep.link, fluid_at)
201 assert any(abs(tw / ed - 1.0) > 0.2 for tw, ed in gaps.values())
202 assert _check(prep, "engine_link").status == "warn"
203
204
205# ------------------------------------------------------------------ preflight refusals
206
207
209 heavy = copy.deepcopy(config)
210 heavy.lox_tank.mass = 40.0 # 35 L of LOX into a 15.1 L tank
211 prep = prepare(heavy, runner, drawing, LayerXSettings(drawing_id=drawing.id, ack_gn2_condensation=True))
212 assert _check(prep, "load_oxidiser").status == "fail"
213 assert not prep.ok
214
215
217 payload = copy.deepcopy(drawing.payload)
218 for node in payload["nodes"]:
219 if node["id"] == "FUT":
220 node["data"]["fluid"] = "water"
221 raw = normalise(payload)
222 wrong = Drawing(id=drawing_id(raw), name="wrong fuel", source="test", sha256=sha256(raw), payload=json.loads(raw))
223 prep = prepare(config, runner, wrong, LayerXSettings(drawing_id=wrong.id))
224 assert _check(prep, "tank_fuel").status == "fail"
225 assert "ethanol" in _check(prep, "tank_fuel").detail
226 assert not prep.ok
227
228
230 s = LayerXSettings.from_dict({"drawing_id": "x", "tank_pressure_psia": None, "dt": 0.02, "bogus": 1})
231 assert s.drawing_id == "x" and s.tank_pressure_psia is None and s.dt == 0.02
232
233
234# ------------------------------------------------------------------ a burn
235
236
237@pytest.fixture(scope="module")
238def burned(prepared, runner):
239 """The default: card engine, closed against EngineDesign's eroding-chamber replay."""
240 return run_prepared(prepared, runner=runner)
241
242
243@pytest.fixture(scope="module")
244def burned_static(prepared):
245 """The twin's pass alone, as-built throat: the engine card and nothing else."""
246 return run_prepared(prepared, replay=False)
247
248
249def test_a_burn_conserves_propellant(burned, prepared, config):
250 s = burned["summary"]
251 series = burned["series"]
252 dt = s["dt"]
253 loads = {"ox": float(config.lox_tank.mass), "fuel": float(config.fuel_tank.mass)}
254 for side, key in (("ox", "ox"), ("fuel", "fuel")):
255 # The feed twin's propellant vapour (on since 2026-10-03, the twin's own Setup) boils a few
256 # tens of milligrams off the LOX over the 300 s hold: the T-0 load is the config's within a gram.
257 assert s[key]["loaded_kg"] == pytest.approx(loads[side], abs=1e-3)
258 left = s[key]["loaded_kg"] - s[key]["residual_kg"]
259 # Each step at its own length (the last is cut to land on depletion).
260 burned_kg = sum(m * h for m, h, f in zip(series[side]["mdot"], series["dt"], series["firing"]) if f)
261 # Within one step's flow of what left the tank: the tank integrates within the step, the
262 # sum holds the step's end value across it.
263 one_step = max(series[side]["mdot"]) * dt
264 assert abs(burned_kg - left) < 0.5 * one_step, (side, burned_kg, left)
265 assert s["propellant_used_kg"] == pytest.approx(
266 sum(s[k]["loaded_kg"] - s[k]["residual_kg"] for k in ("ox", "fuel")), rel=1e-12)
267 assert s["isp_mean_s"] == pytest.approx(s["total_impulse_Ns"] / (s["propellant_used_kg"] * 9.80665), rel=1e-12)
268
269
271 """Load ratio below the delivered O/F means LOX runs out first, and above
272 it, fuel. Independent of how either was computed."""
273 s = burned["summary"]
274 load_ratio = float(config.lox_tank.mass) / float(config.fuel_tank.mass)
275 expected = "oxidiser" if s["of_mean"] > load_ratio else "fuel"
276 assert s["depleted_side"] == expected
277 assert s["burn_time_s"] > 0.0
278
279
281 """At instants across the burn, the twin's chamber and flows against EngineDesign solved at
282 the same injector-inlet pressures and the same (as-built) geometry. The card's own error is
283 ~0.01 %; the twin's network solve (its 1e-4 tolerance) adds a few hundredths. 0.2 % is the
284 phase-2 tolerance."""
285 check = burned_static["engine_check"]
286 assert check["available"] and check["mode"] == "card" and check.get("against") != "replay"
287 assert len([r for r in check["rows"] if r["available"]]) >= 8
288 for key, worst in check["worst"].items():
289 assert worst < 2.0e-3, (key, worst)
290 assert burned_static["summary"]["card_outside_steps"] == 0
291 assert "replay" not in burned_static
292
293
294# ------------------------------------------------------------------ phase 3: the replay
295
296
298 """EngineDesign's replay erodes the throat; the twin's first pass, at the as-built throat,
299 is percents off it. Fed the replay's throat history the twin agrees to a few tenths of a
300 percent, and the history stops moving. A loop that never applied the history would fail
301 the second half."""
302 passes = burned["passes"]
303 assert len(passes) >= 2 and burned["converged"]
304 first, last = passes[0]["agreement"]["worst"], passes[-1]["agreement"]["worst"]
305 assert passes[0]["throat_growth"] > 0.01 # the config's graphite throat and liner recede
306 assert max(first.values()) > 0.01
307 assert max(last.values()) < 5.0e-3
308 assert passes[-1]["schedule_change"] < 2.0e-4
309 check = burned["engine_check"]
310 assert check["against"] == "replay"
311 assert max(check["worst"]["pc"], check["worst"]["mdot_O"], check["worst"]["mdot_F"]) < 5.0e-3
312
313
315 d = burned["delivered"]
316 series = burned["series"]
317 lengths = [h for h, f in zip(series["dt"], series["firing"]) if f]
318 # Each step at its own length: the burn ends on depletion, so the last is short.
319 assert lengths[-1] < burned["summary"]["dt"] - 1e-9
320 assert d["summary"]["total_impulse_Ns"] == pytest.approx(sum(f * h for f, h in zip(d["thrust_N"], lengths)), rel=1e-12)
321 assert d["summary"]["throat_area_growth"] == pytest.approx(burned["replay"]["throat_area_ratio"][-1] - 1.0)
322 # The replay's thrust at its own points is what the delivered curve passes through.
323 rp = burned["replay"]
324 fire = [i for i, f in enumerate(burned["series"]["firing"]) if f]
325 for k, i in enumerate(rp["index"]):
326 assert d["thrust_N"][fire.index(i)] == pytest.approx(rp["thrust_N"][k], rel=1e-12)
327
328
330 ts = burned["timeseries"]
331 data, summary = ts["data"], ts["summary"]
332 need = ("time", "P_tank_O_psi", "P_tank_F_psi", "Pc_psi", "thrust_kN", "Isp_s", "MR", "mdot_O_kg_s",
333 "mdot_F_kg_s", "mdot_total_kg_s", "cstar_actual_m_s", "gamma")
334 n = len(data["time"])
335 assert n > 10 and data["time"][0] == pytest.approx(0.0, abs=1e-9)
336 for key in need:
337 assert len(data[key]) == n, key
338 assert summary["total_impulse_kNs"] == pytest.approx(burned["delivered"]["summary"]["total_impulse_Ns"] / 1e3)
339 # Readers integrate the curve from its first sample (backend/routers/flight.py re-zeroes time
340 # there). Without the sample at Fire, the first step was lost: 1.4 % of impulse.
341 import numpy as np
342
343 t = np.asarray(data["time"])
344 impulse = np.trapezoid(np.asarray(data["thrust_kN"]) * 1e3, t)
345 # It ends where the burn ended: on depletion, the last step cut to land there.
346 assert t[-1] == pytest.approx(burned["summary"]["burn_time_s"])
347 assert impulse == pytest.approx(burned["delivered"]["summary"]["total_impulse_Ns"], rel=3e-3)
348 burned_kg = np.trapezoid(np.asarray(data["mdot_total_kg_s"]), t)
349 assert burned_kg == pytest.approx(burned["summary"]["propellant_used_kg"], rel=1e-2)
350 assert data["lox_mass_remaining_kg"][0] == pytest.approx(burned["summary"]["ox"]["loaded_kg"])
351 assert summary["burn_time_s"] == burned["summary"]["burn_time_s"]
352 assert data["thrust_kN"][-1] * 1e3 == pytest.approx(burned["delivered"]["thrust_N"][-1])
353
354
356 s = burned["summary"]
357 assert s["t0_settled"]
358 assert s["ox"]["t0_psia"] == pytest.approx(578.0, abs=4.0)
359 assert s["fuel"]["t0_psia"] == pytest.approx(578.0, abs=4.0)
360 p = burned["provenance"]
361 assert p["drawing"]["sha256"] == drawing.sha256
362 assert len(p["config_sha256"]) == 64
363 assert p["calibration"]["mode"] == "card"
364 assert p["phase"] == 3
365 # The pressure ladder is ordered while firing: tank above inlet above chamber.
366 series = burned["series"]
367 for i, firing in enumerate(series["firing"]):
368 if not firing or series["t"][i] < 0.2:
369 continue
370 for side in ("ox", "fuel"):
371 assert series[side]["tank_psia"][i] > series[side]["inlet_psia"][i] > series["chamber"]["pc_psia"][i]
372 cc = burned["cross_check"]
373 assert cc["available"] and {r["key"] for r in cc["rows"]} >= {"pc_psia", "thrust_N", "inlet_O_psia"}
374
375
376# ------------------------------------------------------------------ the API
377
378
379def test_the_api_runs_a_burn(config, drawing, monkeypatch, tmp_path):
380 # pytest's own temp dir: mkdtemp left one behind per run.
381 monkeypatch.setenv("USERDATA_DIR", str(tmp_path))
382 try:
383 from fastapi.testclient import TestClient
384 except RuntimeError as exc: # starlette's test client needs an HTTP client package
385 pytest.skip(str(exc))
386
387 from backend.main import app
388 from backend.session import registry
389
390 client = TestClient(app)
391 assert client.get("/api/layerx/status").json()["feedtwin"]["available"]
392 session = registry.get("local")
393 # The shared "local" session: put its design back afterwards for whatever test runs next.
394 before = (session.app_state.config, getattr(session.app_state, "config_path", None)) if session.app_state.has_config() else None
395 if before is not None:
396 monkeypatch.setattr(session.app_state, "config", before[0])
397 session.app_state.set_config(copy.deepcopy(config), str(FIXTURE))
398
399 ids = {d["name"]: d["id"] for d in client.get("/api/layerx/drawings").json()}
400 assert ids[GN2] == drawing.id
401 assert client.post("/api/layerx/preflight", json={"drawing_id": "nope"}).status_code == 404
402 pf = client.post("/api/layerx/preflight", json={"drawing_id": drawing.id, "ack_gn2_condensation": True}).json()
403 assert pf["ok"], [c for c in pf["checks"] if c["status"] == "fail"]
404
405 started = client.post("/api/layerx/runs", json={"drawing_id": drawing.id, "dt": 0.1, "ack_gn2_condensation": True}).json()
406 deadline = time.time() + 300
407 while True:
408 view = client.get(f"/api/layerx/runs/{started['id']}").json()
409 if view["status"] in ("done", "failed", "cancelled") or time.time() > deadline:
410 break
411 time.sleep(0.25)
412 assert view["status"] == "done", view.get("error")
413 assert view["result"]["summary"]["burn_time_s"] > 0
414 listed = client.get("/api/layerx/runs").json()
415 assert listed[0]["id"] == started["id"] and listed[0]["summary"]["total_impulse_Ns"] > 0
#define max(a, b)
Definition Arduino.h:43
a class to store JSON values
Definition json.hpp:19401
test_settings_ignore_unknown_keys_and_nulls()
test_the_card_matches_enginedesign_on_solves_it_never_saw(prepared, config)
prepared(config, runner, drawing)
test_the_hand_off_is_what_forward_and_flight_read(burned)
burned_static(prepared)
burned(prepared, runner)
test_dome_dial_matches_the_regulator_relation_by_hand(prepared, drawing)
test_a_burn_conserves_propellant(burned, prepared, config)
runner(config)
test_the_api_runs_a_burn(config, drawing, monkeypatch, tmp_path)
test_preflight_refuses_a_drawing_without_the_engines_fuel(config, runner, drawing)
test_the_line_exit_loses_exactly_the_borda_dump(config)
test_the_tank_that_runs_dry_is_the_one_the_load_says(burned, config)
_check(prep, key)
test_the_replay_loop_needs_its_feedback_and_closes(burned)
test_a_burn_starts_at_its_lockup_and_reports_its_provenance(burned, drawing)
test_delivered_numbers_are_the_replays(burned)
test_a_card_for_another_engine_fails_its_check(prepared, config)
test_a_card_burn_runs_enginedesigns_engine_all_the_way(burned_static)
test_the_engine_boundary_says_its_drop_is_at_lossless_lines(prepared)
test_t0_fit_reproduces_enginedesign_at_t0(config, runner, drawing)
test_preflight_refuses_a_load_that_does_not_fit(config, runner, drawing)
Drawing drawing()
test_native_engine_is_not_enginedesign(config, runner, drawing)
TOML_NODISCARD_CTOR time() noexcept=default