STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_layerx_cockpit_parity.py
Go to the documentation of this file.
1"""Layer X and the feed-twin cockpit fire the same engine, and get the same burn.
2
3On 2026-10-06 the two gave "entirely different thrust curves" on LE4: the cockpit 7,193 N at
4O/F 1.92, Layer X 6,205 N at O/F 1.59. Their solver agreed to a newton given the same inputs;
5the inputs had drifted apart:
6
7* the cockpit fired a three-week-old copy of the design (a different plate and throat), +1,606 N;
8* through feedtwin's simplified engine, not EngineDesign's: -5 % thrust, +11 % impulse;
9* from the operator's T-0 (dome, bottle, a 95 % fill), not Layer X's: a different burn, rightly.
10
11The cockpit now fires EngineDesign's engine card, built from the engine's own config by the
12endpoint it calls (``engine.layerx.card.card_for_config_text``), sent as JSON and installed the
13one way a card goes on an engine (``EngineCard.install``). This checks the contract that buys:
14put at the same T-0, the cockpit's way of firing (command Fire, tick at the console's step,
15total the burn from the session's samples) reproduces Layer X's burn. And that the check can
16fail: the simplified engine, on the same path, does not.
17"""
18
19from __future__ import annotations
20
21import copy
22import json
23from dataclasses import replace
24from pathlib import Path
25
26import pytest
27
28feedtwin = pytest.importorskip("feedtwin", reason="lib/feedtwin is not installed (pip install -e ../lib/feedtwin)")
29
30import yaml # noqa: E402
31
32from engine.core.runner import PintleEngineRunner # noqa: E402
33from engine.layerx import DrawingStore, LayerXSettings, prepare # noqa: E402
34from engine.layerx.card import card_for_config_text # noqa: E402
35from engine.layerx.sources import cea_table_path, machines_dir, shipped_drawings_dir # noqa: E402
36from engine.pipeline.io import load_config # noqa: E402
37
38FIXTURE = Path(__file__).parent / "fixtures" / "ethalox_6500N_doublet_cad_2026-09-28.yaml"
39GN2 = "copv_study_gn2"
40
41pytestmark = pytest.mark.skipif(
42 not (shipped_drawings_dir() / f"{GN2}.json").is_file(),
43 reason="feed-twin's shipped drawings are not next to this checkout",
44)
45
46#: How close the cockpit has to land on Layer X. The two cards differ only in where they were
47#: centred (each fits EngineDesign to ~0.1 %), and the cockpit steps at 20 ms against Layer X's
48#: 50 ms. Measured on LE4 (3): thrust +0.02 %, O/F +0.04 %. The simplified engine is 5 % off.
49AGREE = 3e-3
50
51
52@pytest.fixture(scope="module")
53def config():
54 return load_config(str(FIXTURE))
55
56
57@pytest.fixture(scope="module")
58def prepared(config):
59 drawing = {d.name: d for d in DrawingStore(None).list()}[GN2]
60 prep = prepare(config, PintleEngineRunner(copy.deepcopy(config)), drawing,
61 LayerXSettings(drawing_id=drawing.id, ack_gn2_condensation=True, flight=False, replay=False))
62 assert prep.ok, [(c.label, c.detail) for c in prep.checks if c.status == "fail"]
63 return prep
64
65
66def _totals(trace):
67 from feedtwin.session.report import summarise
68
69 firing = [i for i, f in enumerate(trace.firing) if f]
70 ch = trace.chamber
71 return summarise([trace.t[i] for i in firing], [ch["thrust_N"][i] for i in firing],
72 [ch["pressure_Pa"][i] for i in firing], [ch["mdot_oxidiser"][i] for i in firing],
73 [ch["mdot_fuel"][i] for i in firing], before=trace.t[firing[0] - 1])
74
75
76@pytest.fixture(scope="module")
77def layer_x(prepared):
78 """Layer X's own pass: its model, its setup, its plan."""
79 from feedtwin.session import load_machine
80 from feedtwin.session.burn import open_session, run_burn
81
82 session = open_session(prepared.assembler(), load_machine(tables=machines_dir()), setup=prepared.setup)
83 return _totals(run_burn(session, prepared.plan))
84
85
86def _cockpit(prepared, config, *, card: bool):
87 """The cockpit's way: the engine from its YAML, the card as JSON off the wire, the cockpit's
88 own Setup, put at Layer X's T-0, Fire commanded, ticked at the console's step."""
89 from feedtwin.engine import EngineCard
90 from feedtwin.engine.importer import engine_from_config
91 from feedtwin.pid import read_diagram
92 from feedtwin.session import Session, Setup, assemble_model, bind, load_machine
93 from feedtwin.session.burn import find_probes, prime_at_t0
94 from feedtwin.session.core import LIVE_STEP
95 from feedtwin.session.report import burns
96
97 text = yaml.safe_dump(config.model_dump(mode="json"), sort_keys=False)
98 engine = engine_from_config(yaml.safe_load(text), name="engine")
99 chamber = None
100 if card:
101 wire = json.loads(json.dumps(card_for_config_text(text)))
102 engine, chamber = EngineCard.from_dict(wire["card"]).install(engine)
103 cea = cea_table_path(config)
104 model = assemble_model(read_diagram(prepared.drawing.payload, name=GN2), diagram_id=GN2, engine=engine,
105 chamber=chamber, cea_cache=str(cea) if cea else "")
106 machine = load_machine(tables=machines_dir())
107 labels = {n.id: n.label or n.id for n in model.diagram.nodes if n.id in model.built.actuators}
108 session = Session(model, machine, bind(machine, labels, roles=model.built.valve_roles),
109 setup=replace(Setup(), dome_psi=prepared.setup.dome_psi, auto_vent=False))
110 plan = prepared.plan
111 assert prime_at_t0(session, plan)
112 for _ in range(round(plan.lead_in_s / plan.dt)):
113 session.step(plan.dt)
114 session.command_state(plan.fire_state)
115 while session.t < 30.0 and min(t.state.liquid_mass for t in session.tanks.values()) >= plan.dry_kg:
116 session.step(LIVE_STEP)
117 (burn,) = burns(list(session.history), find_probes(session).injector_inlet)
118 return burn
119
120
121def _table(rows):
122 """The two burns side by side, printed (``pytest -s``): a parity test that passes in silence
123 tells nobody how close the tools are (D-66)."""
124 print("\n quantity Layer X cockpit difference")
125 for name, unit, a, b in rows:
126 print(f" {name:<14} {a:>12.4f} {b:>12.4f} {unit:<4} {100.0 * (b / a - 1.0):+8.4f} %")
127
128
130 from feedtwin.session.core import LIVE_STEP
131
132 cockpit = _cockpit(prepared, config, card=True)
133 psi = 6894.757293168361
134 names = (("thrust_mean_N", "N", 1.0), ("pc_mean_Pa", "psia", psi), ("of_mean", "", 1.0), ("isp_s", "s", 1.0),
135 ("impulse_Ns", "N·s", 1.0), ("duration_s", "s", 1.0))
136 _table([(n.replace("_Pa", ""), u, getattr(layer_x, n) / k, getattr(cockpit, n) / k) for n, u, k in names])
137 for name in ("thrust_mean_N", "pc_mean_Pa", "of_mean", "isp_s"):
138 assert getattr(cockpit, name) == pytest.approx(getattr(layer_x, name), rel=AGREE), name
139 # Layer X ends on the step that empties a tank; the cockpit on the tick after it.
140 tick = prepared.plan.dt + 0.02
141 assert cockpit.duration_s == pytest.approx(layer_x.duration_s, abs=tick)
142 # The impulse: the agreement tolerance plus at most one end tick's share at the mean thrust
143 # (measured: -0.26 %, all of it the 10 ms the cockpit's burn ends early) ...
144 gap = cockpit.impulse_Ns - layer_x.impulse_Ns
145 assert abs(gap) <= AGREE * layer_x.impulse_Ns + layer_x.thrust_mean_N * max(prepared.plan.dt, LIVE_STEP)
146 # ... and, the end tick's share taken out, within the agreement tolerance itself.
147 assert abs(gap - layer_x.thrust_mean_N * (cockpit.duration_s - layer_x.duration_s)) <= AGREE * layer_x.impulse_Ns
148
149
151 """The check can fail: it is the card that makes the cockpit agree, not the path."""
152 simplified = _cockpit(prepared, config, card=False)
153 assert abs(simplified.thrust_mean_N / layer_x.thrust_mean_N - 1.0) > 0.02
154 assert abs(simplified.isp_s / layer_x.isp_s - 1.0) > 0.05
155
156
158 """Layer X keeps its own summary (engine/layerx/analysis.py); the totals in it are the same
159 arithmetic as feedtwin.session.report's -- right-endpoint impulse, O/F and Isp from what was
160 burned -- so a number quoted by either tool is the same number."""
161 from engine.layerx import run_prepared
162
163 s = run_prepared(prepared, replay=False)["summary"]
164 assert s["total_impulse_Ns"] == pytest.approx(layer_x.impulse_Ns, rel=1e-6)
165 assert s["mean_thrust_N"] == pytest.approx(layer_x.thrust_mean_N, rel=1e-6)
166 # Layer X takes O/F and Isp from the tanks, the report from the chamber's flows: equal to the
167 # propellant bookkeeping, which is conserved step by step.
168 assert s["of_mean"] == pytest.approx(layer_x.of_mean, rel=1e-3)
169 assert s["isp_mean_s"] == pytest.approx(layer_x.isp_s, rel=1e-3)
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
@ Setup
Definition main.cpp:72
_cockpit(prepared, config, *, bool card)
test_and_the_simplified_engine_on_the_same_path_is_not(prepared, config, layer_x)
test_the_cockpit_firing_enginedesigns_card_is_layer_xs_burn(prepared, config, layer_x)
test_layer_x_totals_its_own_burn_as_the_report_does(prepared, layer_x)