STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_layerx_phase6.py
Go to the documentation of this file.
1"""Layer X phase 6: the burn flown, and the flight's acceleration fed back into it.
2
3Checked by hand or by direction, never against a stored answer:
4
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.
12"""
13
14from __future__ import annotations
15
16import math
17from pathlib import Path
18
19import pytest
20
21pytest.importorskip("feedtwin", reason="lib/feedtwin is not installed")
22
23from engine.layerx import DrawingStore, LayerXSettings, prepare, run_prepared # noqa: E402
24from engine.layerx import flight as flt # noqa: E402
25from engine.layerx.measurements import Override # noqa: E402
26from engine.layerx.sources import shipped_drawings_dir # noqa: E402
27from engine.pipeline.io import load_config # noqa: E402
28
29FIXTURE = Path(__file__).parent / "fixtures" / "ethalox_6500N_doublet_cad_2026-09-28.yaml"
30GN2 = "copv_study_gn2"
31# The GN2 drawing is a nitrogen-over-LOX hot fire, refused since 2026-10-03 unless acknowledged
32# (engine/layerx/prepare.py gn2_on_lox); these tests study its burn, so their settings acknowledge it.
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")
35G0 = 9.80665
36
37
38@pytest.fixture(scope="module")
39def config():
40 return load_config(str(FIXTURE))
41
42
43@pytest.fixture(scope="module")
44def drawing():
45 return {d.name: d for d in DrawingStore(None).list()}[GN2]
46
47
48def _line_head(prep, edge_id: str) -> float:
49 branch = prep.model.built.network.branches[edge_id]
50 return float(branch.component.p.get("elevation_change", 0.0))
51
52
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)
56 assert _line_head(prep, "l_fu2") == 0.0 and _line_head(prep, "l_ox2") == 0.0
57
58
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))
64 assert prep.ok
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"):
67 assert _line_head(prep, edge) == 0.0
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) # 36 in + the valve outlet, as drawn
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
76
77
78def _with_heights(drawing, heights):
79 import copy
80 from dataclasses import replace
81
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)
88
89
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)
98
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)
104
105
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
113
114
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."""
118 n = 61
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")
123 # Flown at the site, the thrust reference is the site's ambient, so the first instant is 6.5 kN.
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
129
130
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)
137 # The last sample (the depletion instant) does not count.
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
140
141
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)
149 f = res["flight"]
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
158
159
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."""
165 n = 61
166 apogees = []
167 for cut in (0.0, 0.01, 0.02, 0.03):
168 t = [3.0 * k / (n - 1) for k in range(n)]
169 t[-1] -= cut
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
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
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)