STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
flight.py
Go to the documentation of this file.
1"""Phase 6: the burn, flown, and the flight fed back into the burn.
2
3On the stand every liquid column feels one g. In the vehicle it feels the proper acceleration
4along the axis (the specific force: thrust less drag over mass, no gravity in it): 8.0-8.25 g at
5liftoff and 8.7-9.2 g at burnout on LE4, as flown on 2026-10-02 (docs/layerx/AUDIT.md 9.3). The
6tank heads grow with it, and so would the column in each feed line between its tank and the
7injector -- but only by the height the drawing gives that line, and no drawing gives any line a
8height yet. Flown today, only the tanks' own liquid feels it: +1.1 to +1.6 psi at the injector
9inlets. The team's vehicle lines (fuel 4.5 ft, LOX 1 ft) taken as vertical, an upper bound that is
10on no drawing, would add +10.7 / +4.1 psi and move O/F 1.518 -> 1.485 (AUDIT 9.3, 7.2).
11
12The coupling is closed by iteration, the same way the throat history is (replay.py):
13
141. burn on the pad (1 g) until the eroding throat settles: this is the ordinary Layer X result;
152. fly the delivered curve in EngineDesign's flight simulation (RocketPy, ``ui.flight_sim``), and
16 read the proper acceleration along the axis at every firing step;
173. burn again with that acceleration applied to every liquid column at each step
18 (``Setup.body_acceleration``), fly again, until neither the acceleration history nor the
19 throat history moves.
20
21**The feed lines.** The drawing is the feed system: every line's length, bore, fittings and
22height come from it, and the feed twin computes the losses from them. Flight adds one thing, the
23acceleration on each liquid column, and a line's column is its ``elevation_change`` as drawn (or
24as the person restated it). Layer X never writes geometry into the drawing. :func:`line_paths`
25reports each tank-to-injector path, its drawn length and the height it states; a path that states
26none carries no line head in flight, and the preflight says so.
27
28**The vehicle's tanks.** These come from the drawing. When the drawing and the config disagree
29on tank volume, preflight already says so. The drawing's volumes are measured, and the
30config's are sized to the load, so the measured ones are used for the feed system. The flight
31simulation keeps the config's tanks for the mass and inertia it flies.
32"""
33
34from __future__ import annotations
35
36import math
37from typing import Any, Dict, List, Optional, Sequence, Tuple
38
39import numpy as np
40
41G0 = 9.80665
42
43#: The acceleration history has settled when no firing step moves by more than this, relative.
44#: 0.5 % of the largest head flight adds at an injector inlet (+10.7 psi, the team's 4.5 ft fuel
45#: line taken as vertical: an upper bound, on no drawing) is 0.05 psi on a ~140 psi injector drop,
46#: under 0.02 % of the flow. Measured on LE4 (AUDIT 5.1): pass 3 moved the acceleration 0.48 %,
47#: pass 4 0.003 %.
48ACCEL_TOLERANCE = 5e-3
49
50#: Flown passes after the pad's burn has settled. Two is typical: the first flight's
51#: acceleration moves the burn a percent, the second confirms it.
52MAX_FLIGHT_PASSES = 4
53
54#: Points kept from the flight for the trajectory plots (the burn's acceleration is sampled at
55#: every firing step instead).
56TRAJECTORY_POINTS = 240
57
58
59# ---------------------------------------------------------------------- vehicle layout
60
61
62_TO_M = {"m": 1.0, "mm": 1e-3, "cm": 1e-2, "in": 0.0254, "ft": 0.3048}
63
64
65def _drawn_length_m(edge: Dict[str, Any]) -> Optional[float]:
66 """A drawn line's length [m] from its ``length`` parameter, or None if it states none."""
67 p = ((edge.get("data") or {}).get("params") or {}).get("length")
68 if not isinstance(p, dict):
69 return None
70 try:
71 return float(p["value"]) * _TO_M[str(p.get("unit", "m")).strip()]
72 except (KeyError, TypeError, ValueError):
73 return None
74
75
76def _path_to_tank(model: Any, inlet: str, tank: str) -> List[str]:
77 """Branch ids from the tank down to the injector inlet: a breadth-first walk upstream, so a
78 fill line or vent tee that also meets the path is not mistaken for the feed."""
79 net = model.built.network
80 parent: Dict[str, Tuple[str, str]] = {}
81 frontier, seen = [inlet], {inlet}
82 # The lines leave the tank at its ports ("OXT.out"), not at the tank node itself.
83 def is_tank(node: str) -> bool:
84 return node == tank or node.split(".", 1)[0] == tank
85
86 reached = None
87 while frontier and reached is None:
88 here = frontier.pop(0)
89 for bid, br in net.branches.items():
90 if br.downstream == here and br.upstream not in seen:
91 seen.add(br.upstream)
92 parent[br.upstream] = (here, bid)
93 if is_tank(br.upstream):
94 reached = br.upstream
95 break
96 frontier.append(br.upstream)
97 if reached is None:
98 return []
99 out, node = [], reached
100 while node != inlet:
101 node, bid = parent[node]
102 out.append(bid)
103 return out
104
105
106def _elevation_m(edge: Dict[str, Any]) -> Optional[float]:
107 """The height a drawn line climbs in its drawn direction [m], or None if it states none."""
108 data = edge.get("data") or {}
109 total, stated = 0.0, False
110 p = (data.get("params") or {}).get("elevation_change")
111 if isinstance(p, dict) and p.get("value") is not None:
112 total += float(p["value"]) * _TO_M.get(str(p.get("unit", "m")).strip(), 1.0)
113 stated = True
114 for seg in data.get("segments") or []:
115 if isinstance(seg, dict) and seg.get("elevation_change") is not None:
116 total += float(seg["elevation_change"])
117 stated = True
118 return total if stated else None
119
120
121def line_paths(payload: Dict[str, Any], model: Any, inlets: Dict[str, str], tanks: Dict[str, str],
122 restated: Sequence[str]) -> List[Dict[str, Any]]:
123 """Per side, the drawing's path from the tank to the injector: its lines, their drawn length
124 and the height they state. Read only: the drawing is the feed system."""
125 edges = {str(e.get("id")): e for e in payload.get("edges") or [] if isinstance(e, dict)}
126 rows: List[Dict[str, Any]] = []
127 for side in ("oxidiser", "fuel"):
128 path = _path_to_tank(model, inlets.get(side, ""), tanks.get(side, ""))
129 lines = [b for b in path if b in edges]
130 length = sum(_drawn_length_m(edges[b]) or 0.0 for b in lines)
131 rises = {b: _elevation_m(edges[b]) for b in lines}
132 stated = [b for b, v in rises.items() if v is not None]
133 too_steep = [b for b in stated if (_drawn_length_m(edges[b]) or 0.0) + 1e-9 < abs(rises[b] or 0.0)]
134 rows.append({
135 "side": side, "lines": lines, "length_m": length,
136 # Positive: the tank sits above the injector, so the column adds pressure under thrust.
137 "drop_m": -sum(v for v in rises.values() if v is not None),
138 "used": ("restated" if any(f"edge:{b}.elevation_change" in restated for b in stated)
139 else "drawing" if stated else "none"),
140 "too_steep": too_steep,
141 })
142 return rows
143
144
145# ---------------------------------------------------------------------- the flight
146
147
148def coolprop_gas(name: Optional[str]) -> Optional[str]:
149 """CoolProp's own name for a drawing's fluid (``"helium"`` -> ``"Helium"``); ``None`` for none.
150 A name CoolProp does not know is passed through, and the flight's density call says so."""
151 if not name:
152 return None
153 import CoolProp.CoolProp as CP
154
155 try:
156 return str(CP.get_fluid_param_string(str(name), "name"))
157 except Exception: # noqa: BLE001 - unknown to CoolProp: the flight's own PropsSI raises with the name
158 return str(name)
159
160
161def _flight_config(config: Any, timeseries: Optional[Dict[str, Any]], loads: Dict[str, float], ambient_pa: float,
162 pressurant_kg: Optional[float], copv_volume_L: Optional[float], notes: List[str],
163 pressurant_gas: Optional[str] = None) -> Any:
164 from engine.pipeline.config_schemas import PintleEngineConfig
165
166 raw = config.model_dump()
167 raw["lox_tank"]["mass"] = float(loads["oxidiser"])
168 raw["fuel_tank"]["mass"] = float(loads["fuel"])
169 species = coolprop_gas(pressurant_gas)
170 if species is not None:
171 # THE GAS IS THE DRAWING'S, NOT THE CONFIG'S (AUDIT 5.2, D2). The flight sizes the T-0
172 # ullage and the COPV's refill of it with the tanks' ullage_gas. The config says Nitrogen;
173 # the hot-fire drawing's bottle holds helium. Priced as N2 (46-48 kg/m3 at tank pressure)
174 # the refill was 0.521 kg against a 0.208 kg He bottle and the flight was refused; as
175 # helium (6.4 kg/m3) it is 0.073 kg.
176 was = {raw[k].get("ullage_gas") for k in ("lox_tank", "fuel_tank") if raw.get(k)}
177 for key in ("lox_tank", "fuel_tank"):
178 if raw.get(key) is not None:
179 raw[key]["ullage_gas"] = species
180 stated = {coolprop_gas(w) for w in was if w}
181 if stated and stated != {species}:
182 notes.append(f"Ullage and COPV gas flown as {species}, the drawing's pressurant "
183 f"(the config's tanks say {', '.join(sorted(str(w) for w in was))}).")
184 press = raw.get("press_tank")
185 if press is not None:
186 if pressurant_kg is not None and pressurant_kg > 0:
187 # The bottle the twin primed, not the config's sizing estimate. The gas it pushes into the
188 # ullage stays aboard, so the bottle's T-0 mass is the vehicle's pressurant mass.
189 was = press.get("initial_gas_mass") or press.get("mass")
190 press["initial_gas_mass"] = float(pressurant_kg)
191 press["mass"] = None
192 if was and abs(float(was) - pressurant_kg) > 0.01:
193 notes.append(f"Pressurant flown at the twin's T-0 bottle mass, {pressurant_kg:.3f} kg "
194 f"(the config's sizing says {float(was):.3f} kg).")
195 cfg_L = press.get("free_volume_L")
196 if copv_volume_L and cfg_L and abs(copv_volume_L / float(cfg_L) - 1.0) > 0.01:
197 # A different bottle: same construction, so its structure scales with its volume.
198 scale = copv_volume_L / float(cfg_L)
199 press["free_volume_L"] = float(copv_volume_L)
200 rocket = raw.get("rocket") or {}
201 if rocket.get("copv_dry_mass"):
202 rocket["copv_dry_mass"] = float(rocket["copv_dry_mass"]) * scale
203 notes.append(f"COPV of {copv_volume_L:.2f} L flown with its structure scaled by volume from the "
204 f"config's {float(cfg_L):.2f} L: {rocket['copv_dry_mass']:.2f} kg (estimate).")
205 raw.setdefault("thrust", {})
206 raw["thrust"] = dict(raw["thrust"] or {})
207 burn = None
208 if timeseries is not None:
209 t = np.asarray(timeseries["data"]["time"], dtype=float)
210 burn = float(t[-1] - t[0])
211 raw["thrust"]["burn_time"] = burn
212 # The curve was computed against the site's ambient; the flight corrects it with altitude.
213 raw["thrust"]["reference_pressure_pa"] = float(ambient_pa)
214 cfg = PintleEngineConfig(**raw)
215 if burn is not None:
216 # Set again after construction: the model's validator syncs thrust.burn_time back to the
217 # design requirement (3.99 s on the 6.8 kN design), and the flight must stop where this
218 # burn stops.
219 cfg.thrust.burn_time = burn
220 return cfg
221
222
223def config_dry_kg(config: Any) -> Optional[float]:
224 """The vehicle empty, as the config states it and ui.flight_sim books it: airframe plus engine,
225 tank structures and COPV. ``None`` when the config states no vehicle."""
226 r = getattr(config, "rocket", None)
227 if r is None:
228 return None
229 if r.airframe_mass is not None and r.engine_mass is not None:
230 return float(r.airframe_mass + r.engine_mass + (r.lox_tank_structure_mass or 0.0)
231 + (r.fuel_tank_structure_mass or 0.0) + (r.copv_dry_mass or 0.0))
232 if r.airframe_mass is not None and r.propulsion_dry_mass is not None:
233 return float(r.airframe_mass + r.propulsion_dry_mass)
234 if r.mass is not None and r.motor is not None:
235 return float(r.mass + r.motor.dry_mass)
236 return None
237
238
239def _ceiling(config: Any, apogee_agl: float, elevation: float) -> Optional[Dict[str, Any]]:
240 req = getattr(config, "design_requirements", None)
241 cap = getattr(req, "max_apogee_m", None) if req is not None else None
242 if cap is None:
243 return None
244 datum = getattr(req, "max_apogee_datum", None) or "AGL"
245 cap_agl = float(cap) - (elevation if datum == "MSL" else 0.0)
246 return {"limit_m": float(cap), "datum": datum, "limit_agl_m": cap_agl, "margin_m": cap_agl - apogee_agl,
247 "passed": apogee_agl <= cap_agl}
248
249
250def _axial_drag(flight: Any, t: float) -> float:
251 """RocketPy's axial aerodynamic force ``R3`` at ``t`` [N], linear between its solution nodes.
252
253 RocketPy's own ``Flight.R3`` is a cubic spline over those nodes (rocketpy 1.11 and 1.13 alike),
254 and the force steps at burnout, where the drag switches from the power-on to the power-off Cd:
255 the spline rings across the step. On the LE4 helium flight it read -423 N 36 ms before cutoff,
256 between -646 N (50 ms earlier) and -747 N (at cutoff), while the speed rose: a +3.4 % spike in
257 the specific force (inline 94.50 m/s^2 against RocketPy's 97.81), which also set the reported
258 max acceleration. Linear between nodes is bounded by them; elsewhere the two agree to ~0.1 N."""
259 r3 = flight.R3
260 try:
261 x, y = np.asarray(r3.x_array, dtype=float), np.asarray(r3.y_array, dtype=float)
262 except (AttributeError, TypeError, ValueError):
263 return float(r3(t))
264 if x.size < 2 or x.shape != y.shape:
265 return float(r3(t))
266 return float(np.interp(t, x, y))
267
268
269def axial_acceleration(flight: Any, t: float) -> float:
270 """Proper acceleration along the vehicle axis at ``t`` [m/s^2]: what an accelerometer on it
271 reads, and what a liquid column feels. The specific force along the axis: net thrust (the
272 motor's, corrected from its reference pressure to the ambient at the vehicle's altitude) plus
273 the axial aerodynamic force, over the vehicle's mass. Gravity is not in it, by definition.
274
275 Read this way rather than by differentiating the trajectory. The two agree to 0.01 % through
276 the burn on the 6.8 kN vehicle, but at the thrust cutoff the interpolated trajectory reads a
277 spurious 14.8 g. That spike landed on the burn's last step through the feedback. RocketPy's
278 own ``net_thrust`` reads zero at t = 0, so the motor's is used.
279
280 Two conditions follow RocketPy's own equations of motion (``rocketpy/simulation/flight.py``
281 1.11, ``u_dot_rail1`` and ``u_dot_generalized``):
282
283 * **The pressure thrust stops with the motor.** RocketPy adds ``(p_ref - p(z)) A_e`` only while
284 ``burn_start_time < t < burn_out_time``. Added in the coast as well, it read +0.34 g at
285 apogee where the vehicle is weightless (AUDIT 5.1, 9.3): 251 N of thrust from a cold nozzle.
286 Inside the burn (both ends included, so liftoff reads as before) nothing changes.
287 * **A vehicle held on the rail reads the rail's reaction.** On the rail RocketPy clamps the
288 axial acceleration to zero while thrust and drag are below the weight's component along it;
289 the rail then carries the difference, so the specific force is ``g0 sin(inclination)``: one
290 g0 on a vertical rail, the pad's own value (``Setup.body_acceleration`` default). LE4 lifts
291 off at T/W 8, so this never acts on it; a curve with a start ramp would read T/m < 1 g.
292 """
293 motor = flight.rocket.motor
294 area = math.pi * float(motor.nozzle_radius) ** 2
295 reference = float(getattr(motor, "reference_pressure", None) or 0.0)
296 burning = float(getattr(motor, "burn_start_time", 0.0)) <= t <= float(motor.burn_out_time)
297 if reference and burning:
298 pressure = float(flight.env.pressure(flight.z(t)))
299 thrust = float(motor.thrust(t)) + (reference - pressure) * area
300 else:
301 thrust = float(motor.thrust(t))
302 specific = (thrust + _axial_drag(flight, t)) / float(flight.rocket.total_mass(t))
303 if t < float(flight.out_of_rail_time):
304 held = G0 * math.sin(math.radians(float(flight.inclination)))
305 if specific < held:
306 return held
307 return specific
308
309
310# ---------------------------------------------------------------------- inputs with provenance
311
312
313def _cfg_input(section: Any, field: str, unit: str, where: str) -> Dict[str, Any]:
314 """A config value for a ``model`` block: ``{value, unit, provenance}``. A value equal to its
315 schema default says so, with the schema's own description of that default: "the config says
316 3.35 m" and "nobody measured the rail" are different statements."""
317 value = getattr(section, field, None) if section is not None else None
318 provenance = f"config {where}.{field}"
319 fields = getattr(type(section), "model_fields", {}) if section is not None else {}
320 info = fields.get(field)
321 if info is not None and value is not None and info.default is not None:
322 try:
323 same = float(value) == float(info.default)
324 except (TypeError, ValueError):
325 same = value == info.default
326 if same:
327 provenance += f" = the schema default ({info.description or 'no source given'})"
328 if isinstance(value, (list, tuple)):
329 value = [float(v) for v in value]
330 elif isinstance(value, (int, float)) and not isinstance(value, bool):
331 value = float(value)
332 return {"value": value, "unit": unit, "provenance": provenance}
333
334
335# ---------------------------------------------------------------------- vehicle stability
336
337
338#: Barrowman's centre of pressure, the static margin's basis, and RocketPy's implementation of it.
339STABILITY_SOURCE = (
340 "Barrowman, J. S., 'The Practical Calculation of the Aerodynamic Characteristics of Slender Finned "
341 "Vehicles', M.S. thesis, Catholic University of America, 1967 (CP of nose and fins); as implemented in "
342 "RocketPy 1.11 (Ceotto et al., 'RocketPy: Six Degree-of-Freedom Rocket Trajectory Simulator', J. Aerospace "
343 "Engineering 34(6), 2021): Rocket.static_margin, Rocket.center_of_mass, Rocket.cp_position, "
344 "Flight.max_dynamic_pressure, Flight.out_of_rail_velocity"
345)
346
347
348def vehicle_stability(flight: Any, config: Any, clock: Sequence[float]) -> Dict[str, Any]:
349 """The vehicle's static stability over the burn, its max-Q and its rail exit, read off the
350 RocketPy flight (DATA-CONTRACT 4, ``result.flight.stability``).
351
352 ``clock`` is the burn's own clock, one value per sample, Fire first (flight time is
353 ``clock - clock[0]``); every time this function returns is on that clock (the ``*_time_s``
354 scalars :func:`_stability_or_failure` keeps from ``flight_report`` are flight time; Layer X
355 puts Fire at clock 0, so the two agree there). Positions are from the tail
356 (RocketPy ``tail_to_nose``), so ``static_margin_cal = (cg_m - cp_m) / diameter``. ``cp_m`` is
357 Barrowman's CP at Mach 0, the static margin's own basis; the flight-Mach margin RocketPy
358 tracks to apogee is ``min_stability_margin_cal``.
359
360 The CG is the config's vehicle: tank positions and cylinders from ``lox_tank``/``fuel_tank``,
361 not the drawing's tanks, which the flight does not lay out (AUDIT 9.3, 3)."""
362 rocket = flight.rocket
363 clock = np.asarray(clock, dtype=float)
364 t_flight = clock - clock[0]
365 diameter = 2.0 * float(rocket.radius)
366 cp0 = float(rocket.cp_position(0.0))
367 cg = [float(rocket.center_of_mass(float(x))) for x in t_flight]
368 margin = [float(rocket.static_margin(float(x))) for x in t_flight]
369 i_min = int(np.argmin(margin))
370 t_q = float(flight.max_dynamic_pressure_time)
371 elevation = float(getattr(config.environment, "elevation", 0.0) or 0.0)
372 t_rail = float(flight.out_of_rail_time)
373 req = getattr(config, "design_requirements", None)
374 rk, env = config.rocket, config.environment
375 fins = getattr(rk, "fins", None)
376 inputs = {
377 "rail_length_m": _cfg_input(env, "rail_length_m", "m", "environment"),
378 "launch_inclination_deg": _cfg_input(env, "launch_inclination_deg", "deg", "environment"),
379 "body_radius_m": _cfg_input(rk, "radius", "m", "rocket"),
380 "cm_wo_motor_m": _cfg_input(rk, "cm_wo_motor", "m", "rocket"),
381 "motor_position_m": _cfg_input(rk, "motor_position", "m", "rocket"),
382 "ox_tank_pos_m": _cfg_input(config.lox_tank, "ox_tank_pos", "m", "lox_tank"),
383 "fuel_tank_pos_m": _cfg_input(config.fuel_tank, "fuel_tank_pos", "m", "fuel_tank"),
384 "avionics_payload_length_m": _cfg_input(rk, "avionics_payload_length_m", "m", "rocket"),
385 # The nose sets half the CP (normal-force slope 2 at 0.5 L from its tip); the finish sets the
386 # drag, hence the speed at max-Q and at rail exit.
387 "nose_kind": _cfg_input(rk, "nose_kind", "", "rocket"),
388 "nose_fineness_ratio": _cfg_input(rk, "nose_fineness_ratio", "", "rocket"),
389 "nose_length_m": _cfg_input(rk, "nose_length", "m", "rocket"),
390 "surface_roughness_m": _cfg_input(rk, "surface_roughness_m", "m", "rocket"),
391 "liftoff_mass_kg": {"value": float(rocket.total_mass(0.0)), "unit": "kg",
392 "provenance": "the flight's own mass budget (flight.mass_budget)"},
393 }
394 if getattr(config, "press_tank", None) is not None:
395 inputs["pres_tank_pos_m"] = _cfg_input(config.press_tank, "pres_tank_pos", "m", "press_tank")
396 if fins is not None:
397 for key, unit in (("no_fins", ""), ("root_chord", "m"), ("tip_chord", "m"), ("fin_span", "m"),
398 ("fin_position", "m")):
399 inputs[f"fin_{key}" if not key.startswith("fin") else key] = _cfg_input(fins, key, unit, "rocket.fins")
400 buttons = getattr(rk, "rail_button_upper_pos_m", None) is not None and \
401 getattr(rk, "rail_button_lower_pos_m", None) is not None
402 return {
403 "t": clock.tolist(),
404 "static_margin_cal": margin,
405 "cg_m": cg,
406 "cp_m": [cp0] * len(cg),
407 "diameter_m": diameter,
408 "datum": "from the tail (RocketPy tail_to_nose)",
409 "liftoff_static_margin_cal": margin[0],
410 "min_static_margin_cal": margin[i_min],
411 "min_static_margin_t": float(clock[i_min]),
412 "max_q_pa": float(flight.max_dynamic_pressure),
413 "max_q_t": t_q + float(clock[0]),
414 "max_q_mach": float(flight.mach_number(t_q)),
415 "max_q_speed_m_s": float(flight.free_stream_speed(t_q)),
416 "max_q_altitude_agl_m": float(flight.z(t_q)) - elevation,
417 "rail_exit_m_s": float(flight.out_of_rail_velocity),
418 "rail_exit_t": t_rail + float(clock[0]),
419 "rail_exit_required_m_s": (float(req.min_rail_exit_velocity_m_s)
420 if req is not None and getattr(req, "min_rail_exit_velocity_m_s", None) else None),
421 "rail_exit_static_margin_cal": float(rocket.static_margin(t_rail)),
422 "min_stability_margin_cal": float(flight.min_stability_margin),
423 "min_stability_margin_t": float(flight.min_stability_margin_time) + float(clock[0]),
424 "model": {
425 "name": "rocketpy_barrowman_static_margin",
426 "source": STABILITY_SOURCE,
427 "assumptions": [
428 "static margin = (CG - CP at Mach 0) / body diameter; CP from the nose (normal-force slope 2, "
429 "at 0.5 of its length from the tip for a von Karman nose) and the fins (Barrowman's fin CP; "
430 "at Mach 0 RocketPy's Diederich fin slope times the fin-body interference factor "
431 "1 + r/(s + r) equals Barrowman's 4N(s/d)^2 form); body lift and any tail not counted, as RocketPy",
432 "CG from the config's vehicle layout (tank positions and cylinders), not the drawing's tanks",
433 "propellant leaves at the delivered mdot; pressurant moves from the COPV into the ullages and "
434 "stays aboard",
435 "max-Q over RocketPy's solution points; no wind; the config's atmosphere model",
436 "rail exit: " + ("between the declared rail buttons" if buttons else
437 "no rail buttons declared, so the full rail length is flown (an upper bound)"),
438 ],
439 "inputs": inputs,
440 },
441 }
442
443
444def _stability_or_failure(flight: Any, config: Any, clock: Sequence[float],
445 scalars: Optional[Dict[str, Any]]) -> Dict[str, Any]:
446 """The contract block, merged over ``flight_report``'s scalar margins (liftoff, rail exit,
447 burnout), which the Flight tab reads. A block that cannot be computed says why; the flight
448 stands."""
449 base = dict(scalars or {})
450 try:
451 base.update(vehicle_stability(flight, config, clock))
452 base["available"] = True
453 except Exception as exc: # noqa: BLE001 - DATA-CONTRACT: a failed block never fails the run
454 base.update({"available": False, "error": f"{type(exc).__name__}: {exc}"})
455 return base
456
457
458# ---------------------------------------------------------------------- inline 1-DOF ascent
459
460
461#: Where the inline ascent's equations come from.
462INLINE_SOURCE = (
463 "Vertical point-mass ascent as engine/pipeline/flight_1dof.py: dv/dt = (F + (p_ref - p(z)) A_e - D)/m - g0, "
464 "dm/dt = -mdot (Sutton & Biblarz, Rocket Propulsion Elements, 9th ed., 2017, ch. 4); drag D = 1/2 rho v|v| "
465 "Cd(M) A from engine/pipeline/vehicle_drag (Niskanen, OpenRocket technical documentation, 2013, sec. 3.4); "
466 "atmosphere: U.S. Standard Atmosphere, 1976; the specific force (T + pressure thrust - D)/m is what an "
467 "accelerometer on the axis reads (Titterton & Weston, Strapdown Inertial Navigation Technology, 2nd ed., "
468 "2004); the held vehicle follows RocketPy 1.11's rail clamp (u_dot_rail1)"
469)
470
471
473 """A vertical 1-DOF ascent advanced sample by sample, for a burn pass to read the specific force
474 a liquid column feels at each step without flying the whole burn first (D5-C).
475
476 Same equations, drag and atmosphere as ``engine.pipeline.flight_1dof`` (which matches RocketPy's
477 specific force to 0.002 % on LE4, AUDIT 9.3, 5). Thrust and mass flow are linear between the
478 samples given to :meth:`advance`; the state is integrated by RK4 in substeps of at most
479 ``dt_max``. Mass leaves only as propellant: the pressurant moves from the COPV into the ullages
480 and stays aboard.
481
482 **Held.** At rest on the pad (z = 0, v = 0) with thrust, pressure thrust and drag below the
483 weight, the stand (or rail) carries the rest and the vehicle does not move. Its specific force
484 is then exactly ``G0``: the pad's one g, ``Setup.body_acceleration``'s default. Pressure thrust
485 acts only while the thrust sample is positive, so a lead-in sample with no thrust is held.
486 """
487
488 def __init__(self, *, mass_kg: float, reference_area_m2: float, nozzle_exit_area_m2: float,
489 elevation_m: float, reference_pressure_pa: Optional[float], drag: Any = None,
490 dt_max: float = 0.01) -> None:
491 if not mass_kg > 0.0:
492 raise ValueError(f"InlineAscent: the vehicle's mass must be positive, not {mass_kg!r}")
493 if not dt_max > 0.0:
494 raise ValueError("InlineAscent: dt_max must be positive")
495 self.A_ref = float(reference_area_m2)
496 self.A_e = float(nozzle_exit_area_m2)
497 self.elevation = float(elevation_m)
498 self.p_ref = None if reference_pressure_pa is None else float(reference_pressure_pa)
499 self.dt_max = float(dt_max)
500 self.drag = drag
501 from engine.pipeline.vehicle_drag import isa_troposphere
502
503 self._isa = isa_troposphere
504 if drag is not None:
505 self._mach = np.asarray(drag.mach, dtype=float)
506 self._cd_on = np.asarray(drag.cd_power_on, dtype=float)
507 self._cd_off = np.asarray(drag.cd_power_off, dtype=float)
508 self.t: Optional[float] = None
509 self.z = 0.0
510 self.v = 0.0
511 self.m = float(mass_kg)
512 self.thrust = 0.0
513 self.mdot = 0.0
514 self.liftoff_time_s: Optional[float] = None
515
516 @classmethod
517 def from_config(cls, config: Any, liftoff_mass_kg: float, ambient_pa: float, *, drag: Any = None,
518 dt_max: float = 0.01) -> "InlineAscent":
519 """The vehicle the RocketPy flight flies: body radius, nozzle exit, pad elevation and drag
520 from ``config``; ``ambient_pa`` is the pressure the thrust curve was computed against (the
521 site's, as Layer X burns it)."""
522 a_e, a_ref, elevation, drag = _vehicle_terms(config, drag)
523 return cls(mass_kg=liftoff_mass_kg, reference_area_m2=a_ref, nozzle_exit_area_m2=a_e,
524 elevation_m=elevation, reference_pressure_pa=ambient_pa, drag=drag, dt_max=dt_max)
525
526 def _specific(self, z: float, v: float, m: float, thrust: float) -> Tuple[float, bool]:
527 """(the specific force the motor and the air give [m/s^2], held) at this state."""
528 thrusting = thrust > 0.0
529 force = thrust
530 drag = 0.0
531 need_air = (thrusting and self.p_ref is not None and self.A_e > 0.0) or \
532 (self.drag is not None and self.A_ref > 0.0 and v != 0.0)
533 if need_air:
534 _, p, rho, a, _ = self._isa(self.elevation + max(z, 0.0))
535 if thrusting and self.p_ref is not None:
536 force += (self.p_ref - p) * self.A_e
537 if self.drag is not None and v != 0.0:
538 cd = float(np.interp(abs(v) / a, self._mach, self._cd_on if thrusting else self._cd_off))
539 drag = 0.5 * rho * v * abs(v) * cd * self.A_ref
540 specific = (force - drag) / m
541 held = z <= 0.0 and v <= 0.0 and specific <= G0
542 return specific, held
543
544 def _rhs(self, z: float, v: float, m: float, thrust: float, mdot: float) -> Tuple[float, float, float]:
545 specific, held = self._specific(z, v, m, thrust)
546 return v, (0.0 if held else specific - G0), -mdot
547
548 def specific_force(self) -> float:
549 """The axial specific force at the current sample [m/s^2]: exactly ``G0`` while held."""
550 specific, held = self._specific(self.z, self.v, self.m, self.thrust)
551 return G0 if held else specific
552
553 @property
554 def held(self) -> bool:
555 return self._specific(self.z, self.v, self.m, self.thrust)[1]
556
557 def advance(self, t: float, thrust_N: float, mdot_kg_s: float) -> float:
558 """Move the ascent to ``t`` with the thrust and total propellant flow sampled there, linear
559 from the previous sample, and return the specific force at ``t`` [m/s^2]. The first call
560 only places the vehicle on the pad at ``t``."""
561 t, f1, md1 = float(t), float(thrust_N), float(mdot_kg_s)
562 if self.t is not None:
563 span = t - self.t
564 if span < -1e-12:
565 raise ValueError(f"InlineAscent: time runs backwards ({self.t} -> {t})")
566 if span > 0.0:
567 self._integrate(self.t, t, self.thrust, f1, self.mdot, md1)
568 self.t, self.thrust, self.mdot = t, f1, md1
569 if self.liftoff_time_s is None and not self.held:
570 self.liftoff_time_s = t # released at this sample (thrust over weight at rest)
571 return self.specific_force()
572
573 def _integrate(self, t0: float, t1: float, f0: float, f1: float, md0: float, md1: float) -> None:
574 span = t1 - t0
575 n = max(1, int(math.ceil(span / self.dt_max - 1e-9)))
576 h = span / n
577 z, v, m = self.z, self.v, self.m
578
579 def at(tau: float) -> Tuple[float, float]:
580 frac = (tau - t0) / span
581 return f0 + (f1 - f0) * frac, md0 + (md1 - md0) * frac
582
583 for i in range(n):
584 ta = t0 + i * h
585 k1 = self._rhs(z, v, m, *at(ta))
586 k2 = self._rhs(z + h / 2 * k1[0], v + h / 2 * k1[1], m + h / 2 * k1[2], *at(ta + h / 2))
587 k3 = self._rhs(z + h / 2 * k2[0], v + h / 2 * k2[1], m + h / 2 * k2[2], *at(ta + h / 2))
588 k4 = self._rhs(z + h * k3[0], v + h * k3[1], m + h * k3[2], *at(ta + h))
589 was_resting = z <= 0.0 and v <= 0.0
590 z += h / 6 * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0])
591 v += h / 6 * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1])
592 m += h / 6 * (k1[2] + 2 * k2[2] + 2 * k3[2] + k4[2])
593 if was_resting and v > 0.0 and self.liftoff_time_s is None:
594 self.liftoff_time_s = ta # to the substep: the vehicle was at rest at its start
595 if m <= 0.0:
596 raise ValueError(f"InlineAscent: the propellant flows have burned the vehicle's whole mass by t = {t1}")
597 self.z, self.v, self.m = z, v, m
598
599
600def _vehicle_terms(config: Any, drag: Any = None) -> Tuple[float, float, float, Any]:
601 """(nozzle exit area [m^2], body reference area [m^2], pad elevation [m], Cd(M)) of the vehicle
602 ``ui.flight_sim.setup_flight`` builds from ``config``: the same functions, so the inline ascent
603 and RocketPy fly one vehicle."""
604 from engine.pipeline.config_schemas import ensure_chamber_geometry
605 from engine.pipeline.vehicle_drag import built_stack, resolve_drag_curves
606
607 a_e = float(ensure_chamber_geometry(config).A_exit)
608 a_ref = math.pi * float(config.rocket.radius) ** 2
609 elevation = float(config.environment.elevation)
610 if drag is None:
611 drag = resolve_drag_curves(config, a_e, built_stack(config))
612 return a_e, a_ref, elevation, drag
613
614
615def inline_specific_force(config: Any, thrust_N: Sequence[float], mdot_total: Sequence[float], t: Sequence[float],
616 liftoff_mass_kg: float, ambient_pa: float, *, drag: Any = None,
617 dt_max: float = 0.01) -> Dict[str, Any]:
618 """The axial specific force at each of the burn's samples, from a vertical 1-DOF ascent on
619 this thrust and total propellant flow (D5-C, ``settings.flight_coupling = 'inline'``).
620
621 ``t`` is the burn's clock (any origin; samples before Fire carry zero thrust and read 1 g0);
622 ``thrust_N`` and ``mdot_total`` are sampled at ``t`` and taken linear in between;
623 ``liftoff_mass_kg`` is the vehicle at ``t[0]`` (:func:`liftoff_mass`); ``ambient_pa`` is the
624 pressure the thrust was computed against. The vehicle (body, nozzle exit, drag, pad) is
625 ``config``'s, as the RocketPy flight builds it; ``drag`` overrides its Cd(M).
626
627 Returns ``{t, accel_m_s2, ...}``: ``{t, accel_m_s2}`` is the shape :func:`fly`'s ``schedule``
628 has, so it drops into the burn loop unchanged. Causal: the value at ``t[k]`` uses samples up to
629 ``t[k]`` only, so a burn pass can also drive :class:`InlineAscent` one step at a time."""
630 t_arr = np.asarray(t, dtype=float)
631 f_arr = np.asarray(thrust_N, dtype=float)
632 md_arr = np.asarray(mdot_total, dtype=float)
633 if not (t_arr.shape == f_arr.shape == md_arr.shape) or t_arr.ndim != 1 or len(t_arr) == 0:
634 raise ValueError("inline_specific_force: t, thrust_N and mdot_total must be 1-D and of one length")
635 if np.any(np.diff(t_arr) < 0.0):
636 raise ValueError("inline_specific_force: t must not decrease")
637 a_e, a_ref, elevation, drag = _vehicle_terms(config, drag)
638 ascent = InlineAscent(mass_kg=liftoff_mass_kg, reference_area_m2=a_ref, nozzle_exit_area_m2=a_e,
639 elevation_m=elevation, reference_pressure_pa=ambient_pa, drag=drag, dt_max=dt_max)
640 accel, z, v, m, held = [], [], [], [], []
641 for tk, fk, mk in zip(t_arr, f_arr, md_arr):
642 accel.append(ascent.advance(float(tk), float(fk), float(mk)))
643 z.append(ascent.z)
644 v.append(ascent.v)
645 m.append(ascent.m)
646 held.append(bool(ascent.held))
647 env = config.environment
648 inclination = float(getattr(env, "launch_inclination_deg", 90.0) or 90.0)
649 notes = []
650 if abs(inclination - 90.0) > 1e-9:
651 notes.append(f"The rail is inclined {inclination:g} deg; the inline ascent flies vertical.")
652 return {
653 "t": t_arr.tolist(),
654 "accel_m_s2": accel,
655 "accel_g": [a / G0 for a in accel],
656 "altitude_m": z,
657 "velocity_m_s": v,
658 "mass_kg": m,
659 "held": held,
660 "liftoff_time_s": ascent.liftoff_time_s,
661 "notes": notes,
662 "model": {
663 "name": "inline_vertical_1dof",
664 "source": INLINE_SOURCE,
665 "assumptions": [
666 "vertical flight from the pad: the rail's inclination, wind and attitude are ignored",
667 "standard gravity g0 = 9.80665 m/s^2 throughout (Somigliana at the site is ~0.11 % less)",
668 "a vehicle at rest whose thrust, pressure thrust and drag are below its weight is held and "
669 "reads exactly 1 g0",
670 "thrust and propellant flow linear between the given samples; RK4 in substeps of at most "
671 f"{dt_max * 1e3:g} ms",
672 "pressure thrust (p_ref - p(z)) A_e only while the thrust sample is positive; power-on Cd while "
673 "thrusting, power-off otherwise",
674 "mass leaves as propellant only; the pressurant stays aboard",
675 ],
676 "inputs": {
677 "liftoff_mass_kg": {"value": float(liftoff_mass_kg), "unit": "kg",
678 "provenance": "caller (weighed, or liftoff_mass() from the config and the twin)"},
679 "reference_pressure_pa": {"value": float(ambient_pa), "unit": "Pa",
680 "provenance": "caller: the ambient the thrust curve was computed at"},
681 "nozzle_exit_area_m2": {"value": a_e, "unit": "m^2", "provenance": "config chamber geometry (A_exit)"},
682 "reference_area_m2": {"value": a_ref, "unit": "m^2", "provenance": "config rocket.radius"},
683 "elevation_m": _cfg_input(env, "elevation", "m", "environment"),
684 "launch_inclination_deg": _cfg_input(env, "launch_inclination_deg", "deg", "environment"),
685 "surface_roughness_m": _cfg_input(config.rocket, "surface_roughness_m", "m", "rocket"),
686 "drag_model": {"value": getattr(drag, "model", None), "unit": "",
687 "provenance": getattr(drag, "source", "caller")},
688 "dt_max_s": {"value": float(dt_max), "unit": "s", "provenance": "numerical setting"},
689 },
690 },
691 }
692
693
694def liftoff_mass(config: Any, loads: Dict[str, float], *, ambient_pa: float = 101325.0,
695 pressurant_kg: Optional[float] = None, copv_volume_L: Optional[float] = None,
696 ullage_gas_kg: Optional[float] = None, pressurant_gas: Optional[str] = None,
697 weighed_kg: Optional[float] = None) -> Dict[str, Any]:
698 """The vehicle's mass at Fire [kg], booked as ``ui.flight_sim.setup_flight`` books it, without
699 building the RocketPy flight: for :func:`inline_specific_force` inside a burn pass.
700
701 ``weighed_kg`` (the vehicle on the rail) wins when given. Otherwise: the config's airframe and
702 motor dry mass (the COPV structure scaled to the twin's bottle as :func:`fly` scales it), the
703 loads (capped to what the config's tanks hold, as the flight caps them), the bottle's gas, and
704 the T-0 ullage gas (``ullage_gas_kg``, the drawing's; else the config tanks' own at
705 ``initial_pressure_psi``). Returns ``{value, source, parts, notes}``."""
706 from ui.flight_sim import ullage_gas_density
707 from engine.pipeline.tank_capacity import resolve_fuel_tank_limits, resolve_lox_tank_limits
708 from engine.pipeline.vehicle_drag import built_stack
709
710 notes: List[str] = []
711 if weighed_kg is not None:
712 return {"value": float(weighed_kg), "source": "weighed (settings.liftoff_mass_kg)", "parts": {}, "notes": notes}
713 cfg = _flight_config(config, None, loads, ambient_pa, pressurant_kg, copv_volume_L, notes,
714 pressurant_gas=pressurant_gas)
715 dry = config_dry_kg(cfg)
716 if dry is None:
717 raise ValueError("liftoff_mass: the config states no vehicle (rocket airframe and motor masses)")
718 rho_o, rho_f = float(cfg.fluids["oxidizer"].density), float(cfg.fluids["fuel"].density)
719 cap_o = resolve_lox_tank_limits(cfg, rho_o)[0]
720 cap_f = resolve_fuel_tank_limits(cfg, rho_f)[0]
721 m_o = min(float(loads["oxidiser"]), float(cap_o))
722 m_f = min(float(loads["fuel"]), float(cap_f))
723 for side, load, cap in (("LOX", loads["oxidiser"], cap_o), ("fuel", loads["fuel"], cap_f)):
724 if float(load) > float(cap):
725 notes.append(f"{side} load {float(load):.3f} kg capped to the config tank's {float(cap):.3f} kg, as the flight caps it.")
726 press = getattr(cfg, "press_tank", None)
727 m_p = float((getattr(press, "initial_gas_mass", None) or 0.0) if press is not None else 0.0)
728 if ullage_gas_kg is not None:
729 m_u = float(ullage_gas_kg)
730 u_source = "caller (the drawing's tanks at Fire)"
731 else:
732 stack = built_stack(cfg)
733 m_u = 0.0
734 for section, m_liq, rho_liq, h, r in ((cfg.lox_tank, m_o, rho_o, stack["lox_h"], cfg.lox_tank.lox_radius),
735 (cfg.fuel_tank, m_f, rho_f, stack["fuel_h"], cfg.fuel_tank.rp1_radius)):
736 v_ull = math.pi * float(r) ** 2 * float(h) - m_liq / rho_liq
737 m_u += 0.999 * max(v_ull, 0.0) * ullage_gas_density(section)[0]
738 u_source = "the config's tanks at initial_pressure_psi, CoolProp"
739 total = dry + m_o + m_f + m_p + m_u
740 return {
741 "value": float(total),
742 "source": "config airframe + motor dry mass, loads, bottle gas and T-0 ullage gas",
743 "parts": {"dry_kg": float(dry), "oxidizer_kg": m_o, "fuel_kg": m_f, "pressurant_kg": m_p,
744 "ullage_gas_kg": m_u, "ullage_gas_source": u_source,
745 "pressurant_gas": str(cfg.lox_tank.ullage_gas)},
746 "notes": notes,
747 }
748
749
750def fly(config: Any, timeseries: Dict[str, Any], loads: Dict[str, float], ambient_pa: float, *,
751 pressurant_kg: Optional[float] = None, copv_volume_L: Optional[float] = None,
752 liftoff_mass_kg: Optional[float] = None, ullage_gas_kg: Optional[float] = None,
753 pressurant_gas: Optional[str] = None) -> Dict[str, Any]:
754 """Fly a Layer X burn (``timeseries`` in the Time-Series shape, ``replay.timeseries_payload``)
755 in EngineDesign's flight simulation. Returns the flight's figures, its trajectory, the
756 vehicle's stability over the burn (``stability``, DATA-CONTRACT 4), and the proper
757 acceleration at every firing step on the burn's own clock.
758
759 ``pressurant_kg`` is the bottle's gas at T-0 and ``copv_volume_L`` its volume, both from the
760 twin. When they are given, they replace the config's COPV sizing in the vehicle's mass.
761
762 ``pressurant_gas`` is the drawing's pressurant (``prep.derived["pressurant_gas"]``, e.g.
763 ``"helium"``). When given, the T-0 ullage gas and the COPV's refill of the ullages are priced
764 as that gas instead of the config tanks' ``ullage_gas``; ``None`` keeps the config's.
765
766 ``liftoff_mass_kg`` is the vehicle as weighed on the rail. When given, the airframe is what is
767 left of it after the motor, propellants and gases, so the vehicle lifts off at that mass.
768 ``ullage_gas_kg`` is the gas already in the drawing's tanks at Fire, which flies with them."""
769 from ui.flight_sim import setup_flight
770 from scipy.interpolate import interp1d
771
772 data = timeseries["data"]
773 clock = np.asarray(data["time"], dtype=float)
774 t = clock - clock[0]
775 pre_notes: List[str] = []
776 cfg = _flight_config(config, timeseries, loads, ambient_pa, pressurant_kg, copv_volume_L, pre_notes,
777 pressurant_gas=pressurant_gas)
778
779 def curve(key: str, scale: float = 1.0) -> Any:
780 return interp1d(t, np.asarray(data[key], dtype=float) * scale, kind="linear", bounds_error=False, fill_value=0.0)
781
782 from engine.layerx.capture import thread_stdout
783
784 try:
785 with thread_stdout() as printed:
786 result = setup_flight(cfg, curve("thrust_kN", 1e3), curve("mdot_O_kg_s"), curve("mdot_F_kg_s"),
787 liftoff_mass=liftoff_mass_kg, ullage_gas_kg=ullage_gas_kg)
788 except ValueError as exc: # a vehicle that cannot fly (no airframe left, T/W under 1) is a finding
789 return {"ok": False, "error": str(exc), "notes": pre_notes}
790 except Exception as exc: # noqa: BLE001 - a config the flight cannot build fails the flight, not the burn
791 return {"ok": False, "error": f"{type(exc).__name__}: {exc}", "notes": pre_notes}
792 said = [line.split("] ", 1)[-1] for line in printed.getvalue().splitlines() if line.startswith("[flight_sim]")]
793 flight = result.get("flight")
794 if flight is None:
795 return {"ok": False, "error": result.get("error") or "the flight simulation did not fly",
796 "notes": pre_notes + said}
797
798 accel = [axial_acceleration(flight, float(tt)) for tt in t]
799 report = result.get("flight_report") or {}
800 elevation = float(report.get("elevation_m", getattr(config.environment, "elevation", 0.0)))
801 apogee_agl = float(result["apogee"])
802 t_end = float(flight.apogee_time)
803 grid = np.linspace(0.0, t_end, TRAJECTORY_POINTS)
804 trajectory = {
805 "t": grid.tolist(),
806 "altitude_m": [float(flight.z(x)) - elevation for x in grid],
807 "velocity_m_s": [float(flight.vz(x)) for x in grid],
808 "mach": [float(flight.mach_number(x)) for x in grid],
809 "accel_axial_g": [axial_acceleration(flight, float(x)) / G0 for x in grid],
810 }
811 i_max = int(np.argmax(accel))
812 out = {
813 "ok": True,
814 "apogee_agl_m": apogee_agl,
815 "apogee_msl_m": float(result.get("apogee_asl", apogee_agl + elevation)),
816 "apogee_time_s": t_end,
817 "max_velocity_m_s": float(result["max_velocity"]),
818 "max_mach": float(report.get("max_mach", flight.max_mach_number)),
819 "rail_exit_velocity_m_s": float(flight.out_of_rail_velocity),
820 "rail_exit_time_s": float(flight.out_of_rail_time),
821 "liftoff_accel_g": accel[0] / G0,
822 "max_accel_g": accel[i_max] / G0,
823 "max_accel_time_s": float(t[i_max]),
824 "stability": _stability_or_failure(flight, cfg, clock, report.get("stability")),
825 "pressurant_gas": str(cfg.lox_tank.ullage_gas),
826 "checks": report.get("checks") or [],
827 "ceiling": _ceiling(config, apogee_agl, elevation),
828 "liftoff_mass_kg": float(flight.rocket.total_mass(0.0)),
829 "burnout_mass_kg": float(flight.rocket.total_mass(float(t[-1]))),
830 "mass_budget": result.get("mass_budget"),
831 "notes": pre_notes + said + list(report.get("warnings") or []),
832 "trajectory": trajectory,
833 # On the burn's clock, one value per firing step.
834 "schedule": {"t": clock.tolist(), "accel_m_s2": accel},
835 "truncation": result.get("truncation_info") or {},
836 }
837 return out
838
839
840def schedule_change(old: Optional[Dict[str, Any]], new: Optional[Dict[str, Any]]) -> float:
841 """Largest relative move of the acceleration between two histories, on the newer one's
842 clock. ``inf`` when there was no previous history. The last sample is left out: it sits at
843 the depletion instant, which moves by a fraction of a step each pass, so it compares the
844 tail-off of one burn against full thrust of the other and only ever governs a partial step."""
845 if old is None or new is None:
846 return math.inf
847 t = list(new["t"])
848 a = list(new["accel_m_s2"])
849 if len(t) > 2:
850 t, a = t[:-1], a[:-1]
851 a_new = np.asarray(a, dtype=float)
852 a_old = np.interp(t, old["t"], old["accel_m_s2"])
853 return float(np.max(np.abs(a_new - a_old) / np.maximum(np.abs(a_new), G0)))
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
Optional[float] liftoff_time_s
Definition flight.py:514
Tuple[float, bool] _specific(self, float z, float v, float m, float thrust)
Definition flight.py:526
"InlineAscent" from_config(cls, Any config, float liftoff_mass_kg, float ambient_pa, *, Any drag=None, float dt_max=0.01)
Definition flight.py:518
Tuple[float, float, float] _rhs(self, float z, float v, float m, float thrust, float mdot)
Definition flight.py:544
None _integrate(self, float t0, float t1, float f0, float f1, float md0, float md1)
Definition flight.py:573
None __init__(self, *, float mass_kg, float reference_area_m2, float nozzle_exit_area_m2, float elevation_m, Optional[float] reference_pressure_pa, Any drag=None, float dt_max=0.01)
Definition flight.py:490
float advance(self, float t, float thrust_N, float mdot_kg_s)
Definition flight.py:557
T & at(const key_type &key)
Definition json.hpp:19110
Dict[str, Any] _stability_or_failure(Any flight, Any config, Sequence[float] clock, Optional[Dict[str, Any]] scalars)
Definition flight.py:445
Tuple[float, float, float, Any] _vehicle_terms(Any config, Any drag=None)
Definition flight.py:600
Optional[float] _drawn_length_m(Dict[str, Any] edge)
Definition flight.py:65
Optional[Dict[str, Any]] _ceiling(Any config, float apogee_agl, float elevation)
Definition flight.py:239
Dict[str, Any] inline_specific_force(Any config, Sequence[float] thrust_N, Sequence[float] mdot_total, Sequence[float] t, float liftoff_mass_kg, float ambient_pa, *, Any drag=None, float dt_max=0.01)
Definition flight.py:617
Optional[float] _elevation_m(Dict[str, Any] edge)
Definition flight.py:106
Dict[str, Any] fly(Any config, Dict[str, Any] timeseries, Dict[str, float] loads, float ambient_pa, *, Optional[float] pressurant_kg=None, Optional[float] copv_volume_L=None, Optional[float] liftoff_mass_kg=None, Optional[float] ullage_gas_kg=None, Optional[str] pressurant_gas=None)
Definition flight.py:753
List[str] _path_to_tank(Any model, str inlet, str tank)
Definition flight.py:76
Optional[str] coolprop_gas(Optional[str] name)
Definition flight.py:148
Any _flight_config(Any config, Optional[Dict[str, Any]] timeseries, Dict[str, float] loads, float ambient_pa, Optional[float] pressurant_kg, Optional[float] copv_volume_L, List[str] notes, Optional[str] pressurant_gas=None)
Definition flight.py:163
Dict[str, Any] liftoff_mass(Any config, Dict[str, float] loads, *, float ambient_pa=101325.0, Optional[float] pressurant_kg=None, Optional[float] copv_volume_L=None, Optional[float] ullage_gas_kg=None, Optional[str] pressurant_gas=None, Optional[float] weighed_kg=None)
Definition flight.py:697
Optional[float] config_dry_kg(Any config)
Definition flight.py:223
Dict[str, Any] vehicle_stability(Any flight, Any config, Sequence[float] clock)
Definition flight.py:348
List[Dict[str, Any]] line_paths(Dict[str, Any] payload, Any model, Dict[str, str] inlets, Dict[str, str] tanks, Sequence[str] restated)
Definition flight.py:122
Dict[str, Any] _cfg_input(Any section, str field, str unit, str where)
Definition flight.py:313
float schedule_change(Optional[Dict[str, Any]] old, Optional[Dict[str, Any]] new)
Definition flight.py:840
float axial_acceleration(Any flight, float t)
Definition flight.py:269
float _axial_drag(Any flight, float t)
Definition flight.py:250