1"""Layer X: the feed twin and this engine, burned together (engine/layerx).
3What these check, and against what:
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.
23Physics in the twin itself is covered by lib/feedtwin's suite and by
24scripts/physics_benchmark.py. These tests cover the seam.
27from __future__
import annotations
32from pathlib
import Path
36feedtwin = pytest.importorskip(
"feedtwin", reason=
"lib/feedtwin is not installed (pip install -e ../lib/feedtwin)")
39from engine.layerx import DrawingStore, LayerXSettings, prepare, run_prepared
45FIXTURE = Path(__file__).parent /
"fixtures" /
"ethalox_6500N_doublet_cad_2026-09-28.yaml"
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",
56@pytest.fixture(scope="module")
58 return load_config(str(FIXTURE))
61@pytest.fixture(scope="module")
66@pytest.fixture(scope="module")
72@pytest.fixture(scope="module")
74 return prepare(config, runner, drawing,
LayerXSettings(drawing_id=drawing.id, ack_gn2_condensation=
True, tank_pressure_psia=578.0))
78 return next(c
for c
in prep.checks
if c.key == key)
85 """lockup = dome + atmosphere + bias - S x p_bottle (gauge), all in psi.
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.
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)
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)
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")
120 prep = prepare(config, runner, drawing,
LayerXSettings(drawing_id=drawing.id, ack_gn2_condensation=
True, tank_pressure_psia=578.0,
121 engine_model=
"calibrated"))
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)
128 inlet = prep.inlet_nodes
129 net = prep.model.built.network
131 def fluid_at(side, pressure):
132 cond = net.conditions(inlet[side], pressure)
133 return cond.rho, cond.mu
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
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."""
150 cfg = line_exit_config(config)
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
163 from engine.layerx.card import ENVELOPE_LEVELS, ENVELOPE_RATIOS, TOLERANCE, EngineSampler, _random, check_card
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])
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)
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
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
196 def fluid_at(side, pressure):
197 cond = net.conditions(inlet[side], pressure)
198 return cond.rho, cond.mu
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"
209 heavy = copy.deepcopy(config)
210 heavy.lox_tank.mass = 40.0
211 prep = prepare(heavy, runner, drawing,
LayerXSettings(drawing_id=drawing.id, ack_gn2_condensation=
True))
212 assert _check(prep,
"load_oxidiser").status ==
"fail"
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
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
237@pytest.fixture(scope="module")
239 """The default: card engine, closed against EngineDesign's eroding-chamber replay."""
240 return run_prepared(prepared, runner=runner)
243@pytest.fixture(scope="module")
245 """The twin's pass alone, as-built throat: the engine card and nothing else."""
246 return run_prepared(prepared, replay=
False)
250 s = burned[
"summary"]
251 series = burned[
"series"]
253 loads = {
"ox": float(config.lox_tank.mass),
"fuel": float(config.fuel_tank.mass)}
254 for side, key
in ((
"ox",
"ox"), (
"fuel",
"fuel")):
257 assert s[key][
"loaded_kg"] == pytest.approx(loads[side], abs=1e-3)
258 left = s[key][
"loaded_kg"] - s[key][
"residual_kg"]
260 burned_kg = sum(m * h
for m, h, f
in zip(series[side][
"mdot"], series[
"dt"], series[
"firing"])
if f)
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)
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
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
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
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
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
315 d = burned[
"delivered"]
316 series = burned[
"series"]
317 lengths = [h
for h, f
in zip(series[
"dt"], series[
"firing"])
if f]
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)
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)
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)
337 assert len(data[key]) == n, key
338 assert summary[
"total_impulse_kNs"] == pytest.approx(burned[
"delivered"][
"summary"][
"total_impulse_Ns"] / 1e3)
343 t = np.asarray(data[
"time"])
344 impulse = np.trapezoid(np.asarray(data[
"thrust_kN"]) * 1e3, t)
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])
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
366 series = burned[
"series"]
367 for i, firing
in enumerate(series[
"firing"]):
368 if not firing
or series[
"t"][i] < 0.2:
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"}
381 monkeypatch.setenv(
"USERDATA_DIR", str(tmp_path))
383 from fastapi.testclient
import TestClient
384 except RuntimeError
as exc:
385 pytest.skip(str(exc))
390 client = TestClient(app)
391 assert client.get(
"/api/layerx/status").
json()[
"feedtwin"][
"available"]
392 session = registry.get(
"local")
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))
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"]
405 started = client.post(
"/api/layerx/runs", json={
"drawing_id": drawing.id,
"dt": 0.1,
"ack_gn2_condensation":
True}).
json()
408 view = client.get(f
"/api/layerx/runs/{started['id']}").
json()
409 if view[
"status"]
in (
"done",
"failed",
"cancelled")
or time.time() > deadline:
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
a class to store JSON values
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)
test_dome_dial_matches_the_regulator_relation_by_hand(prepared, drawing)
test_a_burn_conserves_propellant(burned, prepared, 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)
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)
test_native_engine_is_not_enginedesign(config, runner, drawing)
TOML_NODISCARD_CTOR time() noexcept=default