STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_layerx_flight.py
Go to the documentation of this file.
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.
3
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``).
6
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.
9"""
10
11from __future__ import annotations
12
13import contextlib
14import io
15import math
16from pathlib import Path
17
18import numpy as np
19import pytest
20
21from engine.layerx import flight as flt
22
23G0 = 9.80665
24PSI = 6894.757293168
25LE4 = Path(__file__).resolve().parents[1] / "configs" / "ethalox_6800N.yaml"
26#: The site's ambient as Layer X burns it (FAR, 626.67 m, ISA): what the thrust curve is computed at.
27SITE_PA = 94069.72225005485
28
29#: LE4's delivered curve, (t [s], thrust [kN], mdot_O [kg/s], mdot_F [kg/s]) on the burn's clock,
30#: as Layer X flew it: run 20261002-231658-7e47d1 (copv_study_gn2, 50 ms steps, eroded throat),
31#: the run AUDIT 9.3 re-flew (E1, E4). Rounded to 6 decimals.
32LE4_SCHEDULE = [
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),
57]
58
59
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]
64
65
67 t, F, mO, mF = le4_arrays()
68 return {"data": {"time": t.tolist(), "thrust_kN": (F / 1e3).tolist(),
69 "mdot_O_kg_s": mO.tolist(), "mdot_F_kg_s": mF.tolist()}}
70
71
73 """What the curve burns, plus a gram: the flight caps each load to its tank as it would."""
74 t, _, mO, mF = le4_arrays()
75 return {"oxidiser": float(np.trapezoid(mO, t)) + 1e-3, "fuel": float(np.trapezoid(mF, t)) + 1e-3}
76
77
79 from engine.pipeline.io import load_config
80
81 return load_config(str(LE4))
82
83
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
87
88 return float(CP.PropsSI("D", "P", copv_psia * PSI, "T", 293.15, "Helium")) * volume_L * 1e-3
89
90
91def built_flight(config, payload, loads, **kw):
92 """What ``flight.fly`` builds, keeping RocketPy's Flight: ``(flight config, setup_flight result)``."""
93 from scipy.interpolate import interp1d
94 from ui.flight_sim import setup_flight
95
96 notes = []
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)
101
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)
104
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"))
107 return cfg, res
108
109
110# ---------------------------------------------------------------------- fixtures
111
112
113@pytest.fixture(scope="module")
114def config():
115 return le4_config()
116
117
118@pytest.fixture(scope="module")
119def he_derived(config):
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
123 from engine.layerx.sources import shipped_drawings_dir
124
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))
129 return prep.derived
130
131
132@pytest.fixture(scope="module")
133def he_kw(he_derived):
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"]}
136
137
138@pytest.fixture(scope="module")
139def he_flown(config, he_kw):
140 return flt.fly(config, le4_payload(), le4_loads(), SITE_PA, **he_kw)
141
142
143@pytest.fixture(scope="module")
144def he_built(config, he_kw):
145 return built_flight(config, le4_payload(), le4_loads(), **he_kw)
146
147
148# ---------------------------------------------------------------------- 1. the drawing's pressurant
149
150
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) # AUDIT 9.3 7.1 hand check
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"])
161
162
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
168
169 cfg, res = he_built
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))
178 for gas in hand:
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)
185 # The T-0 ullage too: helium at tank pressure, not nitrogen.
186 assert res["mass_budget"]["ullage_gas_kg"] < 0.02
187
188
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)
193 out = flt.fly(config, le4_payload(), le4_loads(), SITE_PA, **kw)
194 assert not out["ok"]
195 assert "needs 0.5" in out["error"] and "COPV holds 0.209 kg" in out["error"]
196
197
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)."""
201 loads, payload = le4_loads(), le4_payload()
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"
212
213
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
224
225
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
230
231
232# ---------------------------------------------------------------------- 2. no thrust in the coast
233
234
235def _ungated(flight, t):
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))
242 # The drag as axial_acceleration reads it (linear between RocketPy's nodes since 2026-11-02, not its
243 # ringing spline): this test isolates the pressure-thrust and rail gate, not the drag's reading.
244 return (thrust + flt._axial_drag(flight, t)) / float(flight.rocket.total_mass(t))
245
246
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."""
251 _, res = he_built
252 fl = res["flight"]
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 # what the gate removes
259
260
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)."""
264 _, res = he_built
265 fl = res["flight"]
266 tb = float(fl.rocket.motor.burn_out_time)
267 t, *_ = le4_arrays()
268 burning = [float(x) for x in t if x <= tb] + [tb]
269 assert len(burning) == len(t) # the last sample sits 1.2 ms after RocketPy's cutoff
270 for x in burning:
271 assert flt.axial_acceleration(fl, x) == _ungated(fl, x), x
272
273
275 traj = he_flown["trajectory"]
276 assert abs(traj["accel_axial_g"][-1]) < 1e-3 # apogee
277 assert all(a < 1e-3 for a, t in zip(traj["accel_axial_g"], traj["t"]) if t > 3.5) # drag decelerates
278
279
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 # what it read before: T/m, half a g
295 assert a[0.05] == G0 and a[0.25] == G0 # held: exactly the pad's 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
298
299
300# ---------------------------------------------------------------------- 3. vehicle stability
301
302
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
312 # flight_report's scalars, which the Flight tab reads, are still there.
313 assert {"static_margin_liftoff_cal", "static_margin_burnout_cal", "min_stability_margin_cal"} <= set(s)
314 model = s["model"]
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
318 # Unmeasured vehicle numbers say they are schema defaults (AUDIT 9.3 3).
319 assert "schema default" in model["inputs"]["rail_length_m"]["provenance"]
320
321
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"]
327 d = s["diameter_m"]
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 # FAR-OUT's floor, AUDIT 9.3 4
333
334
335def test_cp_is_barrowmans_by_hand(he_built, he_flown):
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."""
343 from engine.pipeline.vehicle_drag import built_stack
344
345 cfg, _ = he_built
346 s = he_flown["stability"]
347 r = float(cfg.rocket.radius)
348 d = 2.0 * r
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)
351 sweep = cr - ct
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)
366
367
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)."""
372 from engine.pipeline.vehicle_drag import isa_troposphere
373
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)
378 # And it is the maximum: no point of the flown trajectory has more.
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)
383
384
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"]
389 t, F, mO, mF = le4_arrays()
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"]
398
399
401 class Broken:
402 rocket = None
403
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
#define min(a, b)
Definition Arduino.h:42
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)