1"""Phase 6: the burn, flown, and the flight fed back into the burn.
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).
12The coupling is closed by iteration, the same way the throat history is (replay.py):
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
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.
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.
34from __future__
import annotations
37from typing
import Any, Dict, List, Optional, Sequence, Tuple
56TRAJECTORY_POINTS = 240
62_TO_M = {
"m": 1.0,
"mm": 1e-3,
"cm": 1e-2,
"in": 0.0254,
"ft": 0.3048}
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):
71 return float(p[
"value"]) * _TO_M[str(p.get(
"unit",
"m")).strip()]
72 except (KeyError, TypeError, ValueError):
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}
83 def is_tank(node: str) -> bool:
84 return node == tank
or node.split(
".", 1)[0] == tank
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:
92 parent[br.upstream] = (here, bid)
93 if is_tank(br.upstream):
96 frontier.append(br.upstream)
99 out, node = [], reached
101 node, bid = parent[node]
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)
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"])
118 return total
if stated
else None
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]
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)]
135 "side": side,
"lines": lines,
"length_m": length,
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,
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."""
153 import CoolProp.CoolProp
as CP
156 return str(CP.get_fluid_param_string(str(name),
"name"))
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:
166 raw = config.model_dump()
167 raw[
"lox_tank"][
"mass"] = float(loads[
"oxidiser"])
168 raw[
"fuel_tank"][
"mass"] = float(loads[
"fuel"])
170 if species
is not None:
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
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:
189 was = press.get(
"initial_gas_mass")
or press.get(
"mass")
190 press[
"initial_gas_mass"] = float(pressurant_kg)
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:
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 {})
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
213 raw[
"thrust"][
"reference_pressure_pa"] = float(ambient_pa)
214 cfg = PintleEngineConfig(**raw)
219 cfg.thrust.burn_time = burn
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)
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)
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
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}
251 """RocketPy's axial aerodynamic force ``R3`` at ``t`` [N], linear between its solution nodes.
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."""
261 x, y = np.asarray(r3.x_array, dtype=float), np.asarray(r3.y_array, dtype=float)
262 except (AttributeError, TypeError, ValueError):
264 if x.size < 2
or x.shape != y.shape:
266 return float(np.interp(t, x, y))
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.
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.
280 Two conditions follow RocketPy's own equations of motion (``rocketpy/simulation/flight.py``
281 1.11, ``u_dot_rail1`` and ``u_dot_generalized``):
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.
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
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)))
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:
323 same = float(value) == float(info.default)
324 except (TypeError, ValueError):
325 same = value == info.default
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):
332 return {
"value": value,
"unit": unit,
"provenance": provenance}
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"
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``).
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``.
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)
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"),
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)"},
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")
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
404 "static_margin_cal": margin,
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]),
425 "name":
"rocketpy_barrowman_static_margin",
426 "source": STABILITY_SOURCE,
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 "
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)"),
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
449 base = dict(scalars
or {})
452 base[
"available"] =
True
453 except Exception
as exc:
454 base.update({
"available":
False,
"error": f
"{type(exc).__name__}: {exc}"})
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)"
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).
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
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.
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}")
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)
498 self.
p_ref =
None if reference_pressure_pa
is None else float(reference_pressure_pa)
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
511 self.
m = float(mass_kg)
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)."""
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)
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
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)
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
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
549 """The axial specific force at the current sample [m/s^2]: exactly ``G0`` while held."""
551 return G0
if held
else specific
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:
565 raise ValueError(f
"InlineAscent: time runs backwards ({self.t} -> {t})")
573 def _integrate(self, t0: float, t1: float, f0: float, f1: float, md0: float, md1: float) ->
None:
575 n =
max(1, int(math.ceil(span / self.
dt_max - 1e-9)))
577 z, v, m = self.
z, self.
v, self.
m
579 def at(tau: float) -> Tuple[float, float]:
580 frac = (tau - t0) / span
581 return f0 + (f1 - f0) * frac, md0 + (md1 - md0) * frac
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])
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
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."""
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)
611 drag = resolve_drag_curves(config, a_e, built_stack(config))
612 return a_e, a_ref, elevation, drag
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'``).
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).
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")
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)))
646 held.append(bool(ascent.held))
647 env = config.environment
648 inclination = float(getattr(env,
"launch_inclination_deg", 90.0)
or 90.0)
650 if abs(inclination - 90.0) > 1e-9:
651 notes.append(f
"The rail is inclined {inclination:g} deg; the inline ascent flies vertical.")
655 "accel_g": [a / G0
for a
in accel],
660 "liftoff_time_s": ascent.liftoff_time_s,
663 "name":
"inline_vertical_1dof",
664 "source": INLINE_SOURCE,
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",
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"},
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.
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}``."""
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)
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)"
732 stack = built_stack(cfg)
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
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)},
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.
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.
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.
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."""
770 from scipy.interpolate
import interp1d
772 data = timeseries[
"data"]
773 clock = np.asarray(data[
"time"], dtype=float)
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)
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)
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:
789 return {
"ok":
False,
"error": str(exc),
"notes": pre_notes}
790 except Exception
as exc:
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")
795 return {
"ok":
False,
"error": result.get(
"error")
or "the flight simulation did not fly",
796 "notes": pre_notes + said}
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)
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],
811 i_max = int(np.argmax(accel))
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]),
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,
834 "schedule": {
"t": clock.tolist(),
"accel_m_s2": accel},
835 "truncation": result.get(
"truncation_info")
or {},
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:
848 a = list(new[
"accel_m_s2"])
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)))
Optional[float] liftoff_time_s
Tuple[float, bool] _specific(self, float z, float v, float m, float thrust)
"InlineAscent" from_config(cls, Any config, float liftoff_mass_kg, float ambient_pa, *, Any drag=None, float dt_max=0.01)
Tuple[float, float, float] _rhs(self, float z, float v, float m, float thrust, float mdot)
None _integrate(self, float t0, float t1, float f0, float f1, float md0, float md1)
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)
float advance(self, float t, float thrust_N, float mdot_kg_s)
float specific_force(self)
T & at(const key_type &key)
Dict[str, Any] _stability_or_failure(Any flight, Any config, Sequence[float] clock, Optional[Dict[str, Any]] scalars)
Tuple[float, float, float, Any] _vehicle_terms(Any config, Any drag=None)
Optional[float] _drawn_length_m(Dict[str, Any] edge)
Optional[Dict[str, Any]] _ceiling(Any config, float apogee_agl, float elevation)
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)
Optional[float] _elevation_m(Dict[str, Any] edge)
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)
List[str] _path_to_tank(Any model, str inlet, str tank)
Optional[str] coolprop_gas(Optional[str] name)
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)
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)
Optional[float] config_dry_kg(Any config)
Dict[str, Any] vehicle_stability(Any flight, Any config, Sequence[float] clock)
List[Dict[str, Any]] line_paths(Dict[str, Any] payload, Any model, Dict[str, str] inlets, Dict[str, str] tanks, Sequence[str] restated)
Dict[str, Any] _cfg_input(Any section, str field, str unit, str where)
float schedule_change(Optional[Dict[str, Any]] old, Optional[Dict[str, Any]] new)
float axial_acceleration(Any flight, float t)
float _axial_drag(Any flight, float t)