1"""Layer X flight: the drawing's pressurant flies, the coast carries no thrust, a held vehicle
2reads one g, and the vehicle's stability comes back with the flight.
4docs/layerx/AUDIT.md 5.1 (in-flight head row), 5.2 (He drawing cannot be flown), 9.3;
5docs/layerx/DATA-CONTRACT.md 4 (``result.flight.stability``).
7Every check is against something other than the code under test: CoolProp's densities, RocketPy's
8own kinematics, the 1976 standard atmosphere, or the formula the flight used before the change.
11from __future__
import annotations
16from pathlib
import Path
25LE4 = Path(__file__).resolve().parents[1] /
"configs" /
"ethalox_6800N.yaml"
27SITE_PA = 94069.72225005485
33 (0, 6.972301, 1.917587, 1.251566), (0.05, 6.972301, 1.917587, 1.251566), (0.1, 6.927351, 1.906825, 1.245252),
34 (0.15, 6.882402, 1.896063, 1.238938), (0.2, 6.837452, 1.885301, 1.232625), (0.25, 6.819932, 1.879127, 1.231964),
35 (0.3, 6.802411, 1.872952, 1.231303), (0.35, 6.795583, 1.869783, 1.231821), (0.4, 6.788755, 1.866614, 1.232339),
36 (0.45, 6.781927, 1.863445, 1.232857), (0.5, 6.780529, 1.861768, 1.233808), (0.55, 6.779131, 1.860091, 1.234759),
37 (0.6, 6.781210, 1.859660, 1.235894), (0.65, 6.783289, 1.859228, 1.237028), (0.7, 6.785368, 1.858797, 1.238163),
38 (0.75, 6.789630, 1.859217, 1.239412), (0.8, 6.793892, 1.859637, 1.240661), (0.85, 6.799634, 1.860632, 1.241996),
39 (0.9, 6.805376, 1.861626, 1.243331), (0.95, 6.811118, 1.862621, 1.244666), (1, 6.818007, 1.864031, 1.246074),
40 (1.05, 6.824896, 1.865441, 1.247483), (1.1, 6.832595, 1.867150, 1.248954), (1.15, 6.840294, 1.868859, 1.250426),
41 (1.2, 6.847994, 1.870568, 1.251898), (1.25, 6.856368, 1.872529, 1.253432), (1.3, 6.864742, 1.874491, 1.254967),
42 (1.35, 6.873116, 1.876453, 1.256501), (1.4, 6.881859, 1.878559, 1.258073), (1.45, 6.890601, 1.880665, 1.259645),
43 (1.5, 6.899553, 1.882863, 1.261237), (1.55, 6.908505, 1.885062, 1.262830), (1.6, 6.917457, 1.887261, 1.264422),
44 (1.65, 6.926531, 1.889520, 1.266029), (1.7, 6.935604, 1.891779, 1.267636), (1.75, 6.944710, 1.894070, 1.269244),
45 (1.8, 6.953816, 1.896362, 1.270853), (1.85, 6.962922, 1.898653, 1.272461), (1.9, 6.972011, 1.900959, 1.274067),
46 (1.95, 6.981100, 1.903265, 1.275673), (2, 6.990123, 1.905570, 1.277269), (2.05, 6.999145, 1.907875, 1.278865),
47 (2.1, 7.008168, 1.910180, 1.280461), (2.15, 7.017106, 1.912470, 1.282042), (2.2, 7.026043, 1.914761, 1.283622),
48 (2.25, 7.034854, 1.917036, 1.285188), (2.3, 7.043664, 1.919312, 1.286753), (2.35, 7.052475, 1.921587, 1.288318),
49 (2.4, 7.061127, 1.923831, 1.289859), (2.45, 7.069779, 1.926075, 1.291399), (2.5, 7.078431, 1.928319, 1.292940),
50 (2.55, 7.086927, 1.930535, 1.294459), (2.6, 7.095422, 1.932751, 1.295979), (2.65, 7.103661, 1.934933, 1.297475),
51 (2.7, 7.111901, 1.937115, 1.298971), (2.75, 7.120140, 1.939297, 1.300467), (2.8, 7.128213, 1.941444, 1.301938),
52 (2.85, 7.136287, 1.943592, 1.303409), (2.9, 7.144188, 1.945702, 1.304854), (2.95, 7.152089, 1.947812, 1.306299),
53 (3, 7.159990, 1.949923, 1.307744), (3.05, 7.167719, 1.951993, 1.309163), (3.1, 7.175447, 1.954062, 1.310582),
54 (3.15, 7.182995, 1.956094, 1.311974), (3.2, 7.190543, 1.958126, 1.313367), (3.25, 7.198090, 1.960158, 1.314760),
55 (3.3, 7.205426, 1.962141, 1.316122), (3.35, 7.212762, 1.964123, 1.317485), (3.4, 7.219837, 1.966055, 1.318798),
56 (3.45, 7.226912, 1.967988, 1.320111), (3.4663, 7.229216, 1.968617, 1.320539),
61 """(t, thrust [N], mdot_O, mdot_F) of the saved LE4 curve."""
62 rows = np.asarray(LE4_SCHEDULE, dtype=float)
63 return rows[:, 0], rows[:, 1] * 1e3, rows[:, 2], rows[:, 3]
68 return {
"data": {
"time": t.tolist(),
"thrust_kN": (F / 1e3).tolist(),
69 "mdot_O_kg_s": mO.tolist(),
"mdot_F_kg_s": mF.tolist()}}
73 """What the curve burns, plus a gram: the flight caps each load to its tank as it would."""
75 return {
"oxidiser": float(np.trapezoid(mO, t)) + 1e-3,
"fuel": float(np.trapezoid(mF, t)) + 1e-3}
81 return load_config(str(LE4))
84def he_bottle(copv_psia: float, volume_L: float) -> float:
85 """The He drawing's bottle at T-0, CoolProp at 293.15 K (room temperature, the audit's hand check)."""
86 import CoolProp.CoolProp
as CP
88 return float(CP.PropsSI(
"D",
"P", copv_psia * PSI,
"T", 293.15,
"Helium")) * volume_L * 1e-3
92 """What ``flight.fly`` builds, keeping RocketPy's Flight: ``(flight config, setup_flight result)``."""
93 from scipy.interpolate
import interp1d
97 cfg = flt._flight_config(config, payload, loads, SITE_PA, kw.get(
"pressurant_kg"), kw.get(
"copv_volume_L"),
98 notes, pressurant_gas=kw.get(
"pressurant_gas"))
99 data = payload[
"data"]
100 t = np.asarray(data[
"time"], dtype=float)
102 def f(key, scale=1.0):
103 return interp1d(t - t[0], np.asarray(data[key], dtype=float) * scale, bounds_error=
False, fill_value=0.0)
105 with contextlib.redirect_stdout(io.StringIO()):
106 res = setup_flight(cfg, f(
"thrust_kN", 1e3), f(
"mdot_O_kg_s"), f(
"mdot_F_kg_s"))
113@pytest.fixture(scope="module")
118@pytest.fixture(scope="module")
120 """The hot-fire drawing as Layer X reads it: its pressurant species and its bottle."""
121 pytest.importorskip(
"feedtwin", reason=
"lib/feedtwin is not installed")
122 from engine.layerx import DrawingStore, LayerXSettings, prepare
125 if not (shipped_drawings_dir() /
"copv_study_he.json").is_file():
126 pytest.skip(
"feed-twin's shipped drawings are not next to this checkout")
127 drawing = {d.name: d
for d
in DrawingStore(
None).list()}[
"copv_study_he"]
128 prep = prepare(config,
None, drawing, LayerXSettings(drawing_id=drawing.id, flight=
True))
132@pytest.fixture(scope="module")
134 return {
"pressurant_kg":
he_bottle(he_derived[
"copv_psia"], he_derived[
"copv_volume_L"]),
135 "copv_volume_L": he_derived[
"copv_volume_L"],
"pressurant_gas": he_derived[
"pressurant_gas"]}
138@pytest.fixture(scope="module")
143@pytest.fixture(scope="module")
152 """AUDIT 5.2: the He drawing's bottle (0.209 kg) was priced as the config's nitrogen, needed
153 0.52 kg and was refused. Flown as the drawing's gas it flies, and says which gas it flew."""
154 assert he_derived[
"pressurant_gas"].lower() ==
"helium"
155 assert he_kw[
"pressurant_kg"] == pytest.approx(0.209, abs=1e-3)
156 assert he_flown[
"ok"], he_flown.get(
"error")
157 budget = he_flown[
"mass_budget"]
158 assert budget[
"pressurant_gas"] ==
"Helium" and he_flown[
"pressurant_gas"] ==
"Helium"
159 assert budget[
"pressurant_kg"] == pytest.approx(he_kw[
"pressurant_kg"], rel=1e-12)
160 assert any(
"flown as Helium" in n
for n
in he_flown[
"notes"])
164 """The COPV's loss is the gas that fills the volume the liquid leaves, at tank pressure and the
165 ullage temperature: sum of rho_He(p, T) / rho_liquid x liquid burned (CoolProp), and nothing
166 else. Priced as nitrogen it would be ~7x larger."""
167 import CoolProp.CoolProp
as CP
170 rocket = res[
"flight"].rocket
171 tb = float(rocket.motor.burn_out_time)
172 tanks = {p[
"tank"].name: p[
"tank"]
for p
in rocket.motor.positioned_tanks}
173 copv = tanks[
"Pressurant (He) Tank"]
174 hand = {
"Helium": 0.0,
"Nitrogen": 0.0}
175 for name, section, rho_liq
in ((
"LOX Tank", cfg.lox_tank, cfg.fluids[
"oxidizer"].density),
176 (
"Fuel Tank", cfg.fuel_tank, cfg.fluids[
"fuel"].density)):
177 burned = float(tanks[name].liquid_mass(0.0) - tanks[name].liquid_mass(tb))
179 rho_g = CP.PropsSI(
"D",
"P", section.initial_pressure_psi * PSI,
"T", section.ullage_gas_temperature_K, gas)
180 hand[gas] += rho_g / float(rho_liq) * burned
181 refill = float(copv.fluid_mass(0.0) - copv.fluid_mass(tb))
182 assert refill == pytest.approx(hand[
"Helium"], rel=2e-3)
183 assert hand[
"Nitrogen"] > 5.0 * hand[
"Helium"]
184 assert res[
"flight_report"][
"copv_refill_kg"] == pytest.approx(refill, rel=2e-3)
186 assert res[
"mass_budget"][
"ullage_gas_kg"] < 0.02
190 """``pressurant_gas=None`` is the previous behaviour exactly: the config's nitrogen prices the
191 refill and the 0.209 kg helium bottle cannot hold it (AUDIT 9.3 7.1: 'needs 0.521 kg')."""
192 kw = dict(he_kw, pressurant_gas=
None)
195 assert "needs 0.5" in out[
"error"]
and "COPV holds 0.209 kg" in out[
"error"]
199 """Turned on with the gas the config already says, nothing moves: same config, same flight,
200 bit for bit (the GN2 drawing's flights are unchanged by this fix)."""
202 notes_a, notes_b = [], []
203 a = flt._flight_config(config, payload, loads, SITE_PA,
None,
None, notes_a)
204 b = flt._flight_config(config, payload, loads, SITE_PA,
None,
None, notes_b, pressurant_gas=
"nitrogen")
205 assert a.model_dump() == b.model_dump()
and notes_a == notes_b == []
206 plain = flt.fly(config, payload, loads, SITE_PA)
207 named = flt.fly(config, payload, loads, SITE_PA, pressurant_gas=
"N2")
208 assert plain[
"ok"]
and named[
"ok"]
209 assert named[
"schedule"] == plain[
"schedule"]
210 assert named[
"apogee_agl_m"] == plain[
"apogee_agl_m"]
211 assert named[
"mass_budget"] == plain[
"mass_budget"]
and plain[
"mass_budget"][
"pressurant_gas"] ==
"Nitrogen"
215 """The mass the inline ascent starts from, without building RocketPy: the same booking as the
216 flight's own (RocketPy's total_mass(0)), helium bottle and all; a weighed vehicle wins."""
217 mass = flt.liftoff_mass(config,
le4_loads(), ambient_pa=SITE_PA, **he_kw)
218 assert mass[
"value"] == pytest.approx(he_flown[
"liftoff_mass_kg"], abs=1e-3)
219 assert mass[
"parts"][
"pressurant_gas"] ==
"Helium"
220 assert mass[
"parts"][
"ullage_gas_kg"] == pytest.approx(he_flown[
"mass_budget"][
"ullage_gas_kg"], rel=1e-3)
221 given = flt.liftoff_mass(config,
le4_loads(), ambient_pa=SITE_PA, ullage_gas_kg=0.05, **he_kw)
222 assert given[
"value"] - mass[
"value"] == pytest.approx(0.05 - mass[
"parts"][
"ullage_gas_kg"], abs=1e-9)
223 assert flt.liftoff_mass(config,
le4_loads(), weighed_kg=86.18)[
"value"] == 86.18
227 assert flt.coolprop_gas(
"helium") ==
"Helium"
228 assert flt.coolprop_gas(
"N2") ==
"Nitrogen"
229 assert flt.coolprop_gas(
None)
is None and flt.coolprop_gas(
"")
is None
236 """``axial_acceleration`` as it was before the gate (flight.py at the start of 2026-10-02)."""
237 motor = flight.rocket.motor
238 area = math.pi * float(motor.nozzle_radius) ** 2
239 reference = float(getattr(motor,
"reference_pressure",
None)
or 0.0)
240 pressure = float(flight.env.pressure(flight.z(t)))
if reference
else 0.0
241 thrust = float(motor.thrust(t)) + (reference - pressure) * area
if reference
else float(motor.thrust(t))
244 return (thrust + flt._axial_drag(flight, t)) / float(flight.rocket.total_mass(t))
248 """After burnout the motor gives nothing: the specific force is the drag over the mass, which
249 is RocketPy's own vertical acceleration plus gravity (no wind, vertical rail: the axis stays
250 vertical), and zero at apogee where the vehicle stops. Before the gate it read +0.40 g there."""
253 tb = float(fl.rocket.motor.burn_out_time)
254 for t
in (tb + 0.2, 10.0, 20.0, float(fl.apogee_time)):
255 kinematic = float(fl.az(t)) + float(fl.env.gravity(fl.z(t)))
256 assert flt.axial_acceleration(fl, t) == pytest.approx(kinematic, abs=1e-3 * G0), t
257 assert abs(flt.axial_acceleration(fl, float(fl.apogee_time))) < 1e-4 * G0
258 assert _ungated(fl, float(fl.apogee_time)) > 0.3 * G0
262 """The gate must not touch the burn: every firing instant, both ends included, is the old
263 formula bit for bit (the burn uses these through Setup.body_acceleration)."""
266 tb = float(fl.rocket.motor.burn_out_time)
268 burning = [float(x)
for x
in t
if x <= tb] + [tb]
269 assert len(burning) == len(t)
271 assert flt.axial_acceleration(fl, x) ==
_ungated(fl, x), x
275 traj = he_flown[
"trajectory"]
276 assert abs(traj[
"accel_axial_g"][-1]) < 1e-3
277 assert all(a < 1e-3
for a, t
in zip(traj[
"accel_axial_g"], traj[
"t"])
if t > 3.5)
281 """A curve whose thrust dips below the weight before the vehicle leaves the rail: RocketPy holds
282 it (u_dot_rail1 clamps the axial acceleration to zero), the rail carries the difference, and
283 the liquid columns feel g0 sin(90 deg) = g0, the pad's value, not T/m. Once thrust exceeds the
284 weight again it reads T/m as before."""
285 t = [0.0, 0.04, 0.05, 0.25, 0.26, 3.0]
286 F = [1000.0, 1000.0, 400.0, 400.0, 6500.0, 6500.0]
287 payload = {
"data": {
"time": t,
"thrust_kN": [f / 1e3
for f
in F],
"mdot_O_kg_s": [1.8] * len(t),
288 "mdot_F_kg_s": [1.2] * len(t)}}
289 out = flt.fly(config, payload, {
"oxidiser": 6.0,
"fuel": 4.0}, SITE_PA)
290 assert out[
"ok"], out.get(
"error")
291 assert out[
"rail_exit_time_s"] > 0.26
292 a = dict(zip(out[
"schedule"][
"t"], out[
"schedule"][
"accel_m_s2"]))
293 m0 = out[
"liftoff_mass_kg"]
294 assert 400.0 / m0 < 0.6 * G0
295 assert a[0.05] == G0
and a[0.25] == G0
296 assert a[0.0] == pytest.approx(1000.0 / m0, rel=1e-3)
and a[0.0] > G0
297 assert a[0.26] > 7.0 * G0
304 s = he_flown[
"stability"]
305 assert s[
"available"]
is True
306 n = len(LE4_SCHEDULE)
307 assert s[
"t"] == [row[0]
for row
in LE4_SCHEDULE]
308 for key
in (
"static_margin_cal",
"cg_m",
"cp_m"):
309 assert len(s[key]) == n
and all(math.isfinite(v)
for v
in s[key]), key
310 for key
in (
"max_q_pa",
"max_q_t",
"rail_exit_m_s"):
311 assert math.isfinite(s[key]), key
313 assert {
"static_margin_liftoff_cal",
"static_margin_burnout_cal",
"min_stability_margin_cal"} <= set(s)
315 assert model[
"name"]
and "Barrowman" in model[
"source"]
and model[
"assumptions"]
316 for name, row
in model[
"inputs"].items():
317 assert set(row) == {
"value",
"unit",
"provenance"}
and row[
"provenance"], name
319 assert "schema default" in model[
"inputs"][
"rail_length_m"][
"provenance"]
323 """Positions from the tail: CG ahead of CP is stable, margin = (CG - CP) / D. As the LOX tank
324 (aft of the fuel) drains the CG moves forward and the margin grows (AUDIT 9.3 3: 7.7 -> 8.7
325 cal on the GN2 run)."""
326 s = he_flown[
"stability"]
328 for cg, cp, sm
in zip(s[
"cg_m"], s[
"cp_m"], s[
"static_margin_cal"]):
329 assert sm == pytest.approx((cg - cp) / d, abs=1e-4)
330 assert s[
"cg_m"][-1] > s[
"cg_m"][0]
and s[
"static_margin_cal"][-1] > s[
"static_margin_cal"][0]
331 assert s[
"min_static_margin_cal"] ==
min(s[
"static_margin_cal"]) == s[
"liftoff_static_margin_cal"]
332 assert s[
"min_static_margin_cal"] > 1.5
336 """The CP the block reports, by hand from the flown layout (Barrowman 1967, Mach 0, slender
337 body): nose normal-force slope 2 at X_N = L - V/A_base from its tip, V integrated here from the
338 von Karman (Haack C = 0) shape rather than taken as 0.5 L; fins
339 CN = (1 + r/(s + r)) 4N (s/d)^2 / (1 + sqrt(1 + (2 l_m/(Cr + Ct))^2)), l_m the mid-chord line, at
340 X = m (Cr + 2Ct)/(3(Cr + Ct)) + (Cr + Ct - Cr Ct/(Cr + Ct))/6 aft of the root's leading edge
341 (sweep m = Cr - Ct, RocketPy's default: a square trailing edge). Positions from the tail. A fin
342 set placed by its trailing edge instead of its leading edge, or a nose CP off by 1 cm, fails."""
346 s = he_flown[
"stability"]
347 r = float(cfg.rocket.radius)
349 fins = cfg.rocket.fins
350 cr, ct, span, n = float(fins.root_chord), float(fins.tip_chord), float(fins.fin_span), int(fins.no_fins)
352 l_mid = math.hypot(span, sweep + ct / 2 - cr / 2)
353 cn_f = (1 + r / (span + r)) * 4 * n * (span / d) ** 2 / (1 + math.sqrt(1 + (2 * l_mid / (cr + ct)) ** 2))
354 x_f = float(fins.fin_position) - (sweep * (cr + 2 * ct) / (3 * (cr + ct)) + (cr + ct - cr * ct / (cr + ct)) / 6)
355 stack = built_stack(cfg)
356 length, tip = float(stack[
"nose_length"]), float(stack[
"nose_tip"])
357 assert str(cfg.rocket.nose_kind).lower().replace(
" ",
"") ==
"vonkarman"
358 theta = np.linspace(0.0, math.pi, 200001)
359 x = length / 2 * (1 - np.cos(theta))
360 y = r / math.sqrt(math.pi) * np.sqrt(theta - np.sin(2 * theta) / 2)
361 volume = float(np.trapezoid(math.pi * y * y, x))
362 x_n = tip - (length - volume / (math.pi * r * r))
363 cp = (2.0 * x_n + cn_f * x_f) / (2.0 + cn_f)
364 assert s[
"cp_m"][0] == pytest.approx(cp, abs=1e-4)
365 assert s[
"liftoff_static_margin_cal"] == pytest.approx((s[
"cg_m"][0] - cp) / d, abs=1e-3)
369 """Hand check: q = 1/2 rho v^2 with rho from the 1976 standard atmosphere at the reported
370 altitude and the reported speed (no wind: the free stream is the vehicle's speed). Max-Q comes
371 at burnout on LE4 (fastest, still low)."""
374 s = he_flown[
"stability"]
375 _, _, rho, _, _ = isa_troposphere(config.environment.elevation + s[
"max_q_altitude_agl_m"])
376 assert s[
"max_q_pa"] == pytest.approx(0.5 * rho * s[
"max_q_speed_m_s"] ** 2, rel=1e-3)
377 assert s[
"max_q_t"] == pytest.approx(he_flown[
"truncation"][
"cutoff_time"], abs=0.01)
379 traj = he_flown[
"trajectory"]
380 for z, v
in zip(traj[
"altitude_m"], traj[
"velocity_m_s"]):
381 _, _, rho_z, _, _ = isa_troposphere(config.environment.elevation + z)
382 assert 0.5 * rho_z * v * v <= s[
"max_q_pa"] * (1 + 1e-3)
386 """The 1-DOF ascent (not RocketPy) on the same curve, mass and drag reaches the rail's length
387 at the speed RocketPy reports at rail exit (no rail buttons declared: the full rail is flown)."""
388 s = he_flown[
"stability"]
390 fine = np.linspace(0.0, 0.5, 501)
391 ascent = flt.inline_specific_force(config, np.interp(fine, t, F), np.interp(fine, t, mO + mF), fine,
392 he_flown[
"liftoff_mass_kg"], SITE_PA, dt_max=1e-3)
393 z, v = np.asarray(ascent[
"altitude_m"]), np.asarray(ascent[
"velocity_m_s"])
394 rail = config.environment.rail_length_m
395 assert s[
"rail_exit_m_s"] == pytest.approx(float(np.interp(rail, z, v)), rel=2e-3)
396 assert s[
"rail_exit_t"] == pytest.approx(float(np.interp(rail, z, fine)), abs=2e-3)
397 assert s[
"rail_exit_m_s"] == he_flown[
"rail_exit_velocity_m_s"]
404 out = flt._stability_or_failure(Broken(),
None, [0.0, 1.0], {
"static_margin_liftoff_cal": 7.0})
405 assert out[
"available"]
is False and out[
"error"]
and out[
"static_margin_liftoff_cal"] == 7.0
test_the_fly_trajectory_coast_is_drag_only(he_flown)
test_without_the_species_the_helium_bottle_is_refused_as_before(config, he_kw)
test_the_burn_reads_exactly_as_before(he_built)
test_liftoff_mass_books_the_vehicle_as_the_flight_does(config, he_kw, he_flown)
test_a_stability_block_that_cannot_be_made_says_why()
test_the_configs_own_gas_changes_nothing(config)
test_a_vehicle_held_on_the_rail_reads_one_g(config)
test_the_refill_and_the_ullage_are_priced_as_helium(config, he_built)
test_the_helium_drawing_flies_and_its_budget_is_helium(he_derived, he_kw, he_flown)
built_flight(config, payload, loads, **kw)
test_cp_is_barrowmans_by_hand(he_built, he_flown)
test_stability_has_the_contract_shape(he_flown)
test_static_margin_is_cg_less_cp_over_the_diameter(he_flown)
float he_bottle(float copv_psia, float volume_L)
test_rail_exit_against_an_independent_ascent(config, he_flown)
test_max_q_is_half_rho_v_squared_at_its_time(config, he_flown)
test_the_coast_reads_drag_only(he_built)