1"""Layer X phase 6: the burn flown, and the flight's acceleration fed back into it.
3Checked by hand or by direction, never against a stored answer:
5* the drawing is the feed system: flying adds no geometry to it. A line's head in flight is its
6 elevation_change as drawn or restated; a line with none carries none, and the preflight says so;
7* the proper acceleration read off the flight is thrust over mass at liftoff, F/m;
8* flown with the fuel line falling further than the LOX line, the fuel injector inlet gains more
9 than LOX, so the burn runs fuel-richer than on the pad. A burn that ignored the acceleration
10 would show none of it;
11* off by default: a run that does not ask to fly is the phase 3 run.
14from __future__
import annotations
17from pathlib
import Path
21pytest.importorskip(
"feedtwin", reason=
"lib/feedtwin is not installed")
23from engine.layerx import DrawingStore, LayerXSettings, prepare, run_prepared
29FIXTURE = Path(__file__).parent /
"fixtures" /
"ethalox_6500N_doublet_cad_2026-09-28.yaml"
33pytestmark = pytest.mark.skipif(
not (shipped_drawings_dir() / f
"{GN2}.json").is_file(),
34 reason=
"feed-twin's shipped drawings are not next to this checkout")
38@pytest.fixture(scope="module")
40 return load_config(str(FIXTURE))
43@pytest.fixture(scope="module")
45 return {d.name: d
for d
in DrawingStore(
None).list()}[GN2]
49 branch = prep.model.built.network.branches[edge_id]
50 return float(branch.component.p.get(
"elevation_change", 0.0))
54 prep = prepare(config,
None, drawing,
LayerXSettings(drawing_id=drawing.id, ack_gn2_condensation=
True))
55 assert not prep.vehicle_lines
and not any(o[
"parameter"] ==
"elevation_change" for o
in prep.overrides)
60 """The stand drawing states no line heights: flown, its lines stay as drawn (no head, no
61 length changed) and the preflight names the gap instead of guessing it from the config."""
62 pad = prepare(config,
None, drawing,
LayerXSettings(drawing_id=drawing.id, ack_gn2_condensation=
True))
63 prep = prepare(config,
None, drawing,
LayerXSettings(drawing_id=drawing.id, ack_gn2_condensation=
True, flight=
True))
65 assert not any(o.get(
"origin") ==
"vehicle" for o
in prep.overrides)
66 for edge
in (
"l_fu1",
"l_fu2",
"l_ox1",
"l_ox2"):
68 assert prep.model.built.network.branches[edge].component.p.get(
"length") == \
69 pad.model.built.network.branches[edge].component.p.get(
"length")
70 rows = {r[
"side"]: r
for r
in prep.vehicle_lines}
71 assert rows[
"fuel"][
"lines"] == [
"l_fu1",
"l_fu2"]
and rows[
"oxidiser"][
"lines"] == [
"l_ox1",
"l_ox2"]
72 assert rows[
"fuel"][
"length_m"] == pytest.approx(0.9144 + 0.05)
73 assert {r[
"used"]
for r
in rows.values()} == {
"none"}
74 check = next(c
for c
in prep.checks
if c.key ==
"vehicle_lines")
75 assert check.status ==
"warn" and "no height" in check.detail
80 from dataclasses
import replace
82 payload = copy.deepcopy(drawing.payload)
83 for edge_id, value
in heights.items():
84 edge = next(e
for e
in payload[
"edges"]
if e[
"id"] == edge_id)
85 edge.setdefault(
"data", {}).setdefault(
"params", {})[
"elevation_change"] = {
86 "value": value,
"unit":
"m",
"source":
"measured",
"reference":
"test"}
87 return replace(drawing, payload=payload)
91 mine =
Override(target=
"edge:l_fu1", parameter=
"elevation_change", value=-0.8, unit=
"m",
92 source=
"tape measure on the vehicle, 2026-10-02")
93 prep = prepare(config,
None, drawing,
LayerXSettings(drawing_id=drawing.id, ack_gn2_condensation=
True, flight=
True), [mine])
94 rows = {r[
"side"]: r
for r
in prep.vehicle_lines}
95 assert rows[
"fuel"][
"used"] ==
"restated" and rows[
"fuel"][
"drop_m"] == pytest.approx(0.8)
96 assert rows[
"oxidiser"][
"used"] ==
"none"
97 assert _line_head(prep,
"l_fu1") == pytest.approx(-0.8)
99 prep = prepare(config,
None,
_with_heights(drawing, {
"l_ox1": -0.07}),
100 LayerXSettings(drawing_id=drawing.id, ack_gn2_condensation=
True, flight=
True))
101 rows = {r[
"side"]: r
for r
in prep.vehicle_lines}
102 assert rows[
"oxidiser"][
"used"] ==
"drawing" and rows[
"oxidiser"][
"drop_m"] == pytest.approx(0.07)
103 assert _line_head(prep,
"l_ox1") == pytest.approx(-0.07)
107 prep = prepare(config,
None,
_with_heights(drawing, {
"l_fu2": -1.0}),
108 LayerXSettings(drawing_id=drawing.id, ack_gn2_condensation=
True, flight=
True))
109 rows = {r[
"side"]: r
for r
in prep.vehicle_lines}
110 assert rows[
"fuel"][
"too_steep"] == [
"l_fu2"]
111 check = next(c
for c
in prep.checks
if c.key ==
"vehicle_lines")
112 assert check.status ==
"warn" and "l_fu2 falls further than it is drawn long" in check.detail
116 """A flat 6.5 kN, 3 s burn. At the first instant the vehicle has not moved and drag is zero,
117 so the proper acceleration along the axis is F/m exactly; it then grows as mass burns off."""
119 t = [3.0 * k / (n - 1)
for k
in range(n)]
120 payload = {
"data": {
"time": t,
"thrust_kN": [6.5] * n,
"mdot_O_kg_s": [1.8] * n,
"mdot_F_kg_s": [1.2] * n}}
121 out = flt.fly(config, payload, {
"oxidiser": 6.0,
"fuel": 4.0}, 94_070.0)
122 assert out[
"ok"], out.get(
"error")
124 assert out[
"liftoff_accel_g"] * G0 == pytest.approx(6500.0 / out[
"liftoff_mass_kg"], rel=2e-3)
125 a = out[
"schedule"][
"accel_m_s2"]
126 assert len(a) == n
and out[
"schedule"][
"t"] == t
127 assert a[n // 2] > a[1]
and out[
"max_accel_g"] * G0 >= a[n // 2]
128 assert out[
"apogee_agl_m"] > 1000.0
132 old = {
"t": [0.0, 1.0, 2.0],
"accel_m_s2": [80.0, 85.0, 90.0]}
133 assert flt.schedule_change(
None, old) == math.inf
134 assert flt.schedule_change(old, old) == 0.0
135 new = {
"t": [0.0, 1.0, 2.0],
"accel_m_s2": [80.0, 85.9, 90.0]}
136 assert flt.schedule_change(old, new) == pytest.approx(0.9 / 85.9)
138 tail = {
"t": [0.0, 1.0, 2.0],
"accel_m_s2": [80.0, 85.0, 70.0]}
139 assert flt.schedule_change(old, tail) == 0.0
143 """The whole loop, on drawn heights: the fuel line falls 0.9 m, the LOX line 0.07 m (each
144 within its drawn length). Under ~9 g the fuel inlet gains far more than LOX, so the burn runs
145 fuel-rich of the pad, and the loop settles."""
146 drawn =
_with_heights(drawing, {
"l_fu1": -0.9,
"l_ox1": -0.07})
147 prep = prepare(config,
None, drawn,
LayerXSettings(drawing_id=drawing.id, ack_gn2_condensation=
True, flight=
True))
148 res = run_prepared(prep, config=config)
150 assert f[
"ok"]
and res[
"converged"]
151 assert res[
"passes"][-1][
"accel_inline"]
and not res[
"passes"][0].get(
"accel_inline")
152 pad, air = f[
"pad"], f[
"in_flight"]
153 gain_fuel = air[
"fuel_manifold_mean_psia"] - pad[
"fuel_manifold_mean_psia"]
154 gain_ox = air[
"ox_manifold_mean_psia"] - pad[
"ox_manifold_mean_psia"]
155 assert gain_fuel > 5.0
and gain_fuel > 2.0 * gain_ox
156 assert air[
"of_mean"] < pad[
"of_mean"] - 0.01
157 assert 7.0 < f[
"liftoff_accel_g"] < f[
"max_accel_g"] < 11.0
161 """Cut a flat burn short by 10, 20, 30 ms: every cut is impulse lost, so every cut is apogee
162 lost, by about the same amount each time. Before ui/flight_sim.py sampled an untruncated
163 curve itself, RocketPy re-sampled it at 50 points; the apogee held still across these cuts
164 and then fell in one step."""
167 for cut
in (0.0, 0.01, 0.02, 0.03):
168 t = [3.0 * k / (n - 1)
for k
in range(n)]
170 payload = {
"data": {
"time": t,
"thrust_kN": [6.5] * n,
"mdot_O_kg_s": [1.8] * n,
"mdot_F_kg_s": [1.2] * n}}
171 out = flt.fly(config, payload, {
"oxidiser": 6.0,
"fuel": 4.0}, 94_070.0)
172 assert out[
"ok"], out.get(
"error")
173 apogees.append(out[
"apogee_agl_m"])
174 steps = [a - b
for a, b
in zip(apogees, apogees[1:])]
175 assert all(s > 0.5
for s
in steps), apogees
176 assert max(steps) < 2.0 *
min(steps), apogees
test_a_line_that_falls_further_than_it_is_long_is_flagged(config, drawing)
float _line_head(prep, str edge_id)
_with_heights(drawing, heights)
test_flying_adds_no_geometry_to_the_drawing(config, drawing)
test_flown_the_fuel_side_gains_the_head(config, drawing)
test_off_by_default_no_line_heads(config, drawing)
test_the_flight_follows_the_end_of_the_curve(config)
test_a_drawn_or_restated_height_is_the_head(config, drawing)
test_the_acceleration_at_liftoff_is_thrust_over_mass(config)
test_change_between_histories()