STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
flight_sim.py
Go to the documentation of this file.
1"""
2flight_sim.py
3--------------
4Reusable RocketPy-based liquid engine flight simulation module.
5"""
6
7import numpy as np
8import math
9import warnings
10import matplotlib.pyplot as plt
11from rocketpy import Environment, Rocket, Flight, Function, Fluid
12from rocketpy.motors import LiquidMotor, CylindricalTank
13from rocketpy.motors.tank import MassBasedTank, MassFlowRateBasedTank
14from pathlib import Path
15
16# Suppress RocketPy's harmless Function domain warnings during tank calculations
17# These occur when RocketPy internally composes Functions for liquid level vs time
18# and the discretization boundaries don't perfectly align (numerical precision issue)
19warnings.filterwarnings(
20 "ignore",
21 message=".*must be within the domain of the Function.*",
22 category=UserWarning,
23)
24
25g0 = 9.80665
26
27def detect_tank_underfill_time(mdot, m_initial, burn_time, n_samples=5000):
28 """
29 Detect when a tank would get underfilled by integrating mdot over time.
30
31 Parameters:
32 -----------
33 mdot : float or Function
34 Mass flow rate. Can be a constant float or a RocketPy Function.
35 m_initial : float
36 Initial tank mass [kg]
37 burn_time : float
38 Total burn time [s]
39 n_samples : int
40 Number of time samples for integration (default: 5000)
41
42 Returns:
43 --------
44 cutoff_time : float or None
45 Time at which tank would be depleted (None if it never depletes)
46 """
47 # Create time array for sampling with higher resolution
48 times = np.linspace(0, burn_time, n_samples)
49 dt = burn_time / (n_samples - 1) if n_samples > 1 else burn_time
50
51 # Sample mdot values
52 if isinstance(mdot, Function):
53 # It's a RocketPy Function - evaluate at each time
54 mdot_values = np.array([mdot(t) for t in times])
55 elif callable(mdot):
56 # It's a callable function (e.g., scipy.interpolate.interp1d) - evaluate at each time
57 mdot_values = np.array([float(mdot(t)) for t in times])
58 else:
59 # It's a constant float
60 mdot_values = np.full_like(times, float(mdot))
61
62 # Integrate mdot to get cumulative mass consumed
63 # Use trapezoidal integration
64 cumulative_mass = np.zeros_like(times)
65 for i in range(1, len(times)):
66 # Trapezoidal integration: ∫ mdot dt ≈ (mdot[i-1] + mdot[i]) * dt / 2
67 cumulative_mass[i] = cumulative_mass[i-1] + (mdot_values[i-1] + mdot_values[i]) * dt / 2.0
68
69 # Find where cumulative mass exceeds initial tank mass
70 # Find the first index where cumulative_mass >= m_initial
71 depletion_idx = np.where(cumulative_mass >= m_initial)[0]
72
73 if len(depletion_idx) > 0:
74 idx = depletion_idx[0]
75
76 # Interpolate to find the exact cutoff time between samples
77 if idx > 0:
78 # Linear interpolation: find t where cumulative_mass(t) = m_initial
79 mass_prev = cumulative_mass[idx - 1]
80 mass_curr = cumulative_mass[idx]
81 t_prev = times[idx - 1]
82 t_curr = times[idx]
83
84 # Linear interpolation: t = t_prev + (m_initial - mass_prev) * (t_curr - t_prev) / (mass_curr - mass_prev)
85 if mass_curr > mass_prev:
86 fraction = (m_initial - mass_prev) / (mass_curr - mass_prev)
87 cutoff_time = t_prev + fraction * (t_curr - t_prev)
88 else:
89 cutoff_time = t_curr
90 else:
91 # Depletion happens at the very first sample
92 cutoff_time = times[idx]
93
94 return float(cutoff_time)
95 else:
96 # Tank never depletes during the burn
97 return None
98
99def detect_lox_underfill_time(mdot_lox, m_lox0, burn_time, n_samples=5000):
100 """
101 Detect when LOX tank would get underfilled by integrating mdot_lox over time.
102
103 Parameters:
104 -----------
105 mdot_lox : float or Function
106 LOX mass flow rate. Can be a constant float or a RocketPy Function.
107 m_lox0 : float
108 Initial LOX mass [kg]
109 burn_time : float
110 Total burn time [s]
111 n_samples : int
112 Number of time samples for integration (default: 5000)
113
114 Returns:
115 --------
116 cutoff_time : float or None
117 Time at which LOX would be depleted (None if it never depletes)
118 """
119 return detect_tank_underfill_time(mdot_lox, m_lox0, burn_time, n_samples)
120
121def detect_fuel_underfill_time(mdot_fuel, m_fuel0, burn_time, n_samples=5000):
122 """
123 Detect when fuel tank would get underfilled by integrating mdot_fuel over time.
124
125 Parameters:
126 -----------
127 mdot_fuel : float or Function
128 Fuel mass flow rate. Can be a constant float or a RocketPy Function.
129 m_fuel0 : float
130 Initial fuel mass [kg]
131 burn_time : float
132 Total burn time [s]
133 n_samples : int
134 Number of time samples for integration (default: 5000)
135
136 Returns:
137 --------
138 cutoff_time : float or None
139 Time at which fuel would be depleted (None if it never depletes)
140 """
141 return detect_tank_underfill_time(mdot_fuel, m_fuel0, burn_time, n_samples)
142
143def truncate_thrust_curve(thrust_curve, cutoff_time):
144 """
145 Truncate thrust curve at cutoff_time, setting thrust to 0 after that point.
146
147 Parameters:
148 -----------
149 thrust_curve : list of (t, F) tuples or Function
150 Original thrust curve
151 cutoff_time : float
152 Time at which to cut off thrust
153
154 Returns:
155 --------
156 truncated_curve : list of (t, F) tuples
157 Thrust curve with thrust=0 after cutoff_time
158 """
159 # Extend domain slightly beyond cutoff_time to avoid RocketPy warnings
160 # about evaluating functions outside their domain during numerical integration
161 domain_buffer = max(0.01, cutoff_time * 0.02) # 2% buffer or 10ms minimum
162 extended_time = cutoff_time + domain_buffer
163
164 if isinstance(thrust_curve, Function) or callable(thrust_curve):
165 # Convert Function or callable (e.g., interp1d) to list of tuples by sampling with high resolution
166 # Use at least 500 samples per second for accurate representation
167 n_samples = max(int(cutoff_time * 500) + 1, 500)
168 times = np.linspace(0, cutoff_time, n_samples)
169 curve = [(float(t), float(thrust_curve(t))) for t in times]
170 # Ensure the last point is exactly at cutoff_time with thrust value
171 if curve[-1][0] != cutoff_time:
172 curve.append((cutoff_time, float(thrust_curve(cutoff_time))))
173 # Add cutoff point with 0 thrust (small epsilon after for sharp transition)
174 curve.append((cutoff_time + 1e-6, 0.0))
175 # Add extended endpoint with 0 thrust to avoid RocketPy domain warnings
176 curve.append((extended_time, 0.0))
177 return curve
178 elif isinstance(thrust_curve, list):
179 # It's already a list of (t, F) tuples
180 truncated = []
181 for t, F in thrust_curve:
182 if t < cutoff_time:
183 truncated.append((t, F))
184 elif t == cutoff_time:
185 # If we hit cutoff_time exactly, use that value then add 0
186 truncated.append((t, F))
187 break
188 else:
189 # We've passed cutoff_time - interpolate and add cutoff point
190 if len(truncated) > 0:
191 prev_t, prev_F = truncated[-1]
192 # Linear interpolation to cutoff_time
193 if t > prev_t:
194 F_cutoff = prev_F + (F - prev_F) * (cutoff_time - prev_t) / (t - prev_t)
195 else:
196 F_cutoff = prev_F
197 truncated.append((cutoff_time, F_cutoff))
198 else:
199 # No previous points, just add cutoff with 0
200 truncated.append((cutoff_time, 0.0))
201 break
202 # Ensure we end with 0 thrust at cutoff_time
203 if len(truncated) == 0:
204 truncated.append((cutoff_time, 0.0))
205 elif truncated[-1][0] < cutoff_time:
206 # Add cutoff point if we haven't reached it yet
207 if len(truncated) > 0:
208 prev_t, prev_F = truncated[-1]
209 truncated.append((cutoff_time, prev_F))
210 truncated.append((cutoff_time, 0.0))
211 elif truncated[-1][0] == cutoff_time and truncated[-1][1] != 0.0:
212 # We're at cutoff_time but thrust isn't 0, add a 0 point
213 truncated.append((cutoff_time, 0.0))
214 # Add extended endpoint with 0 thrust to avoid RocketPy domain warnings
215 truncated.append((extended_time, 0.0))
216 return truncated
217 else:
218 raise TypeError(f"Unsupported thrust_curve type: {type(thrust_curve)}")
219
220def truncate_mdot_function(mdot_func, cutoff_time, burn_time):
221 """
222 Create a new mdot function that is 0 after cutoff_time.
223
224 Parameters:
225 -----------
226 mdot_func : float or Function
227 Original mass flow rate
228 cutoff_time : float
229 Time at which to cut off mass flow
230 burn_time : float
231 Total burn time (for creating the function domain)
232
233 Returns:
234 --------
235 truncated_func : Function
236 Function that returns mdot_func(t) for t <= cutoff_time, 0 otherwise
237 """
238 # Use higher resolution sampling (500 points per second minimum)
239 n_samples = max(int(burn_time * 500) + 1, 1000)
240
241 # Extend domain slightly beyond burn_time to avoid RocketPy warnings
242 # about evaluating functions outside their domain during numerical integration
243 domain_buffer = max(0.01, burn_time * 0.02) # 2% buffer or 10ms minimum
244 extended_time = burn_time + domain_buffer
245
246 if isinstance(mdot_func, Function) or callable(mdot_func):
247 # Create base time samples with higher resolution, extending slightly beyond burn_time
248 times_base = np.linspace(0, extended_time, n_samples)
249
250 # Ensure cutoff_time and a point just after are explicitly included for sharp transition
251 # This prevents RocketPy from interpolating a gradual falloff
252 eps = 1e-6 # Small epsilon for sharp transition
253 critical_times = [cutoff_time, cutoff_time + eps, extended_time]
254
255 # Combine and sort all time points, removing duplicates
256 times_all = np.unique(np.concatenate([times_base, critical_times]))
257 times_all = times_all[times_all <= extended_time]
258
259 # Evaluate original function and apply cutoff (0 after cutoff_time)
260 values = np.array([float(mdot_func(t)) if t <= cutoff_time else 0.0 for t in times_all])
261
262 # RocketPy Function expects 2D array: [[x1, y1], [x2, y2], ...]
263 source = np.column_stack((times_all, values))
264 return Function(source)
265 else:
266 # It's a constant - create a function that's constant until cutoff, then 0
267 times_base = np.linspace(0, extended_time, n_samples)
268
269 # Ensure cutoff_time and a point just after are explicitly included for sharp transition
270 eps = 1e-6
271 critical_times = [cutoff_time, cutoff_time + eps, extended_time]
272
273 # Combine and sort all time points, removing duplicates
274 times_all = np.unique(np.concatenate([times_base, critical_times]))
275 times_all = times_all[times_all <= extended_time]
276
277 # Apply constant value before cutoff, 0 after
278 mdot_val = float(mdot_func)
279 values = np.array([mdot_val if t <= cutoff_time else 0.0 for t in times_all])
280
281 # RocketPy Function expects 2D array: [[x1, y1], [x2, y2], ...]
282 source = np.column_stack((times_all, values))
283 return Function(source)
284
285def _sample(source, times):
286 """Values of a thrust / mdot source (float, callable, RocketPy Function, (t, v) list) at times."""
287 if isinstance(source, (list, tuple)) and source and isinstance(source[0], (list, tuple)):
288 arr = np.asarray(source, dtype=float)
289 return np.interp(times, arr[:, 0], arr[:, 1], left=0.0, right=0.0)
290 if isinstance(source, Function) or callable(source):
291 return np.array([float(source(t)) for t in times])
292 return np.full_like(np.asarray(times, dtype=float), float(source))
293
294
295def _cumulative(times, rates):
296 return np.concatenate([[0.0], np.cumsum(0.5 * (rates[1:] + rates[:-1]) * np.diff(times))])
297
298
299def _depletion_time(times, cumulative, m0):
300 """First time the integral of mdot reaches m0, linear between samples; None if it never does."""
301 idx = np.nonzero(cumulative >= m0)[0]
302 if len(idx) == 0:
303 return None
304 i = int(idx[0])
305 if i == 0:
306 return float(times[0])
307 c0, c1 = cumulative[i - 1], cumulative[i]
308 return float(times[i - 1] + (m0 - c0) / (c1 - c0) * (times[i] - times[i - 1]))
309
310
311def _on_grid(times, values, t_end):
312 """(t, v) samples on [0, t_end], t_end included, for a RocketPy Function."""
313 keep = times < t_end
314 t = np.concatenate([times[keep], [t_end]])
315 v = np.concatenate([values[keep], [np.interp(t_end, times, values)]])
316 return np.column_stack((t, v))
317
318
319#: How a pressurant is written in tank names and printouts (display only; the physics uses the
320#: CoolProp name). A gas not listed here is written as its CoolProp name.
321_GAS_LABEL = {"nitrogen": "N₂", "n2": "N₂", "helium": "He", "he": "He"}
322
323
324def ullage_gas_density(tank_section):
325 """Pressurant density in a tank's ullage at T-0 [kg/m3], CoolProp at the tank's
326 initial_pressure_psi (absolute, as the engine solver reads it) and ullage_gas_temperature_K."""
327 import CoolProp.CoolProp as CP
328
329 P_psi = getattr(tank_section, "initial_pressure_psi", None)
330 if P_psi is None:
331 raise ValueError("Tank initial_pressure_psi is required to size the ullage gas")
332 P = float(P_psi) * 6894.757293168
333 T = float(tank_section.ullage_gas_temperature_K)
334 return float(CP.PropsSI("D", "P", P, "T", T, str(tank_section.ullage_gas))), P, T
335
336
337def flight_report(flight, config, drag_curves=None, extra=None):
338 """What a flight says about the vehicle beyond apogee: Mach, rail exit, static margin, the
339 ceiling datum, and the requirement checks the config declares. Pure: reads the flight."""
340 rocket = flight.rocket
341 elevation = float(config.environment.elevation)
342 apogee_msl = float(flight.apogee)
343 burnout = float(rocket.motor.burn_out_time)
344 t_rail = float(flight.out_of_rail_time)
345 buttons = getattr(config.rocket, "rail_button_upper_pos_m", None) is not None and getattr(config.rocket, "rail_button_lower_pos_m", None) is not None
346 report = {
347 "apogee_agl_m": apogee_msl - elevation,
348 "apogee_msl_m": apogee_msl,
349 "elevation_m": elevation,
350 "max_mach": float(flight.max_mach_number),
351 "max_speed_m_s": float(flight.max_speed),
352 "burnout_time_s": burnout,
353 "total_impulse_Ns": float(rocket.motor.total_impulse),
354 "launch": {
355 "rail_length_m": float(flight.rail_length),
356 "effective_rail_length_m": float(flight.effective_1rl),
357 "rail_buttons_declared": bool(buttons),
358 "inclination_deg": float(flight.inclination),
359 "heading_deg": float(flight.heading),
360 "rail_exit_velocity_m_s": float(flight.out_of_rail_velocity),
361 "rail_exit_time_s": t_rail,
362 },
363 "stability": {
364 # Mach-0 Barrowman CP against the moving CG, in body diameters
365 "static_margin_liftoff_cal": float(rocket.static_margin(0.0)),
366 "static_margin_rail_exit_cal": float(rocket.static_margin(t_rail)),
367 "static_margin_burnout_cal": float(rocket.static_margin(burnout)),
368 # CP at the flight Mach, to apogee
369 "min_stability_margin_cal": float(flight.min_stability_margin),
370 "min_stability_margin_time_s": float(flight.min_stability_margin_time),
371 "max_stability_margin_cal": float(flight.max_stability_margin),
372 "max_stability_margin_time_s": float(flight.max_stability_margin_time),
373 },
374 "checks": [],
375 "warnings": [],
376 }
377 if drag_curves is not None:
378 report["drag"] = drag_curves.to_dict()
379 dr = getattr(config, "design_requirements", None)
380 checks = report["checks"]
381 v_min = getattr(dr, "min_rail_exit_velocity_m_s", None) if dr is not None else None
382 if v_min is not None:
383 v = report["launch"]["rail_exit_velocity_m_s"]
384 checks.append({"name": "rail_exit_velocity", "value": v, "limit": float(v_min), "kind": "min", "passed": v >= v_min,
385 "note": None if buttons else "no rail buttons declared: full rail length flown, an upper bound"})
386 for key, kind in (("min_static_margin_cal", "min"), ("max_static_margin_cal", "max")):
387 lim = getattr(dr, key, None) if dr is not None else None
388 if lim is None:
389 continue
390 for where in ("rail_exit", "burnout"):
391 sm = report["stability"][f"static_margin_{where}_cal"]
392 checks.append({"name": f"static_margin_{where}", "value": sm, "limit": float(lim), "kind": kind,
393 "passed": sm >= lim if kind == "min" else sm <= lim})
394 for c in checks:
395 if not c["passed"]:
396 report["warnings"].append(
397 f"{c['name']} {c['value']:.2f} {'below' if c['kind'] == 'min' else 'above'} the required {c['limit']:.2f}"
398 )
399 if extra:
400 for k, v in extra.items():
401 if k == "warnings":
402 report["warnings"].extend(v)
403 else:
404 report[k] = v
405 return report
406
407
408def setup_flight(config, thrust_curve, mdot_lox, mdot_fuel, plot_results=False, liftoff_mass=None,
409 ullage_gas_kg=None):
410 """
411 Build and fly the vehicle in RocketPy from a thrust curve and the two propellant flows.
412
413 ``liftoff_mass`` [kg]: the vehicle as weighed on the rail, loaded and pressed. When given, the
414 airframe's mass is set so the vehicle lifts off at exactly this mass.
415
416 ``ullage_gas_kg`` [kg]: the gas in the tanks' ullages at T-0 when the caller knows it better
417 than this config's tanks do (Layer X burns the drawing's tanks). The difference from the
418 config-sized ullage is carried as a lumped mass with the airframe: it stays aboard all flight.
419
420 Returns a dict: apogee (AGL), apogee_asl, max_velocity (vertical), flight (RocketPy Flight),
421 truncation_info, mass_caps, mass_budget, flight_report (see flight_report), params (the config).
422 """
423 burn_time = float(config.thrust.burn_time)
424
425 # Densities from config
426 rho_lox = config.fluids['oxidizer'].density
427 rho_rp1 = config.fluids['fuel'].density
428
429 # Initial masses from config
430 m_lox0 = config.lox_tank.mass
431 m_rp10 = config.fuel_tank.mass
432
433 # Cap propellant to what the tank holds (explicit capacity, else volume x fill factor)
434 from engine.pipeline.tank_capacity import resolve_fuel_tank_limits, resolve_lox_tank_limits
435
436 mass_caps = {}
437 lox_max_mass, lox_tank_volume, lox_ff, _ = resolve_lox_tank_limits(config, rho_lox)
438 lox_requested = m_lox0
439 if m_lox0 > lox_max_mass:
440 print(
441 f"[flight_sim] Capping LOX mass: {m_lox0:.2f} -> {lox_max_mass:.2f} kg "
442 f"(tank vol: {lox_tank_volume * 1000:.1f}L, {lox_ff * 100:.0f}% fill)"
443 )
444 m_lox0 = lox_max_mass
445 mass_caps["lox"] = {
446 "requested_kg": lox_requested,
447 "effective_kg": m_lox0,
448 "max_fill_kg": lox_max_mass,
449 "tank_volume_m3": lox_tank_volume,
450 "was_capped": lox_requested > m_lox0 + 1e-6,
451 "fill_factor": lox_ff,
452 }
453
454 rp1_max_mass, rp1_tank_volume, fuel_ff, _ = resolve_fuel_tank_limits(config, rho_rp1)
455 fuel_requested = m_rp10
456 if m_rp10 > rp1_max_mass:
457 print(
458 f"[flight_sim] Capping Fuel mass: {m_rp10:.2f} -> {rp1_max_mass:.2f} kg "
459 f"(tank vol: {rp1_tank_volume * 1000:.1f}L, {fuel_ff * 100:.0f}% fill)"
460 )
461 m_rp10 = rp1_max_mass
462 mass_caps["fuel"] = {
463 "requested_kg": fuel_requested,
464 "effective_kg": m_rp10,
465 "max_fill_kg": rp1_max_mass,
466 "tank_volume_m3": rp1_tank_volume,
467 "was_capped": fuel_requested > m_rp10 + 1e-6,
468 "fill_factor": fuel_ff,
469 }
470
471 # DEPLETION. Thrust and both flows stop at the instant the first tank runs dry, with no margin.
472 #
473 # This used to chop 50 ms (or 1 % of the burn) off every truncated burn, because
474 # MassFlowRateBasedTank re-integrates mdot on 100 points and goes a hair negative at an exact
475 # depletion; and when that did not fire, a fuel-only loop shrank the burn to 98 % of the fuel
476 # whenever the load came within 1 mg of the integral. Which one fired was floating-point noise:
477 # loads of the exact integral x (1, 1+1e-9, 1+1e-6) flew 3941 / 3890 / 4033 m on the 6.5 kN
478 # vehicle. The tanks below are MassBasedTank on the liquid mass itself, clipped at 1e-9 kg
479 # (0.0 still trips RocketPy's inverse_volume domain), so an exact depletion is legal and the
480 # result is continuous in the load.
481 n_grid = max(5001, int(burn_time * 2000) + 1)
482 t_grid = np.linspace(0.0, burn_time, n_grid)
483 mdot_O_grid = _sample(mdot_lox, t_grid)
484 mdot_F_grid = _sample(mdot_fuel, t_grid)
485 cum_O = _cumulative(t_grid, mdot_O_grid)
486 cum_F = _cumulative(t_grid, mdot_F_grid)
487 t_dep = {"LOX": _depletion_time(t_grid, cum_O, m_lox0), "fuel": _depletion_time(t_grid, cum_F, m_rp10)}
488 t_dep = {k: v for k, v in t_dep.items() if v is not None and v < burn_time}
489 if t_dep:
490 cutoff_reason = min(t_dep, key=t_dep.get)
491 effective_burn_time = t_dep[cutoff_reason]
492 if effective_burn_time <= 0.0:
493 return {
494 "success": False,
495 "error": f"{cutoff_reason} tank is empty at ignition.",
496 "flight": None,
497 "flight_time": 0.0,
498 "apogee": 0.0,
499 "max_velocity": 0.0,
500 "truncation_info": {"truncated": True, "cutoff_time": 0.0, "reason": cutoff_reason},
501 }
502 truncation_msg = (
503 f"{cutoff_reason} tank runs dry at t={effective_burn_time:.4f} s of a {burn_time:.4f} s curve; "
504 "thrust and both flows stop there."
505 )
506 print(f"[flight_sim] {truncation_msg}")
507 thrust_curve = truncate_thrust_curve(thrust_curve, effective_burn_time)
508 truncation_info = {
509 "truncated": True,
510 "cutoff_time": effective_burn_time,
511 "reason": cutoff_reason,
512 "message": truncation_msg,
513 }
514 else:
515 effective_burn_time = burn_time
516 truncation_info = {"truncated": False}
517 # The same 500-per-second sampling the truncated branch gets. Handed a callable, RocketPy's
518 # Motor re-samples it at 50 points over the whole burn (72 ms apart on a 3.6 s curve), so
519 # an untruncated curve flew a coarser copy of itself: its tail aliased, and on the 6.8 kN
520 # vehicle the apogee held still for 200 N·s of impulse and then dropped 116 m.
521 if callable(thrust_curve) and not isinstance(thrust_curve, list):
522 thrust_curve = truncate_thrust_curve(thrust_curve, burn_time)
523
524 from engine.pipeline.config_schemas import ensure_chamber_geometry
525 cg = ensure_chamber_geometry(config)
526 A_e = cg.A_exit
527
528 # Check for required flight simulation config fields
529 if not config.environment:
530 raise ValueError("Flight simulation requires 'environment' configuration")
531 if not config.rocket:
532 raise ValueError("Flight simulation requires 'rocket' configuration")
533 if not config.lox_tank:
534 raise ValueError("Flight simulation requires 'lox_tank' configuration")
535 if not config.fuel_tank:
536 raise ValueError("Flight simulation requires 'fuel_tank' configuration")
537
538 # Rocket parameters from config - support both NEW and LEGACY formats
539 # NOTE: rocket_inertia is for AIRFRAME ONLY (without motor/propulsion)
540 # RocketPy adds motor inertia separately via LiquidMotor(dry_inertia=...)
541 rocket_inertia = config.rocket.inertia
542 rocket_radius = config.rocket.radius
543
544 # Check for NEW mass model (airframe_mass + propulsion_dry_mass)
545 has_new_model = (
546 hasattr(config.rocket, 'airframe_mass') and config.rocket.airframe_mass is not None and
547 hasattr(config.rocket, 'propulsion_dry_mass') and config.rocket.propulsion_dry_mass is not None
548 )
549
550 if has_new_model:
551 # NEW MODEL: Use detailed mass breakdown for proper RocketPy native handling
552 airframe_mass = config.rocket.airframe_mass
553 motor_position = getattr(config.rocket, 'motor_position', 0.5)
554
555 # Check if we have detailed component breakdown (preferred)
556 has_detailed_breakdown = (
557 hasattr(config.rocket, 'engine_mass') and config.rocket.engine_mass is not None
558 )
559
560 if has_detailed_breakdown:
561 # DETAILED MODEL: All propulsion dry mass goes to LiquidMotor
562 # This includes engine + tank structures, with proper CM and inertia calculations
563 engine_mass = config.rocket.engine_mass
564 engine_cm_offset = getattr(config.rocket, 'engine_cm_offset', 0.15)
565 lox_tank_structure_mass = getattr(config.rocket, 'lox_tank_structure_mass', None) or 0.0
566 fuel_tank_structure_mass = getattr(config.rocket, 'fuel_tank_structure_mass', None) or 0.0
567 copv_dry_mass = getattr(config.rocket, 'copv_dry_mass', None) or 0.0
568
569 # Get tank positions (relative to nozzle exit)
570 lox_tank_pos = config.lox_tank.ox_tank_pos
571 fuel_tank_pos = config.fuel_tank.fuel_tank_pos
572 copv_pos = config.press_tank.pres_tank_pos if config.press_tank else 0.0
573
574 # TOTAL motor dry mass includes engine + all tank structures
575 motor_dry_mass = engine_mass + lox_tank_structure_mass + fuel_tank_structure_mass + copv_dry_mass
576 propulsion_dry_mass = motor_dry_mass
577
578 # Compute weighted average CM of all dry components (relative to nozzle)
579 # CM = sum(m_i * x_i) / sum(m_i)
580 if motor_dry_mass > 0:
581 weighted_cm = (
582 engine_mass * engine_cm_offset +
583 lox_tank_structure_mass * lox_tank_pos +
584 fuel_tank_structure_mass * fuel_tank_pos +
585 copv_dry_mass * copv_pos
586 ) / motor_dry_mass
587 else:
588 weighted_cm = engine_cm_offset
589
590 # Compute composite inertia using parallel axis theorem
591 # I_total = sum(I_local_i + m_i * d_i^2) where d_i = distance from component CM to system CM
592 #
593 # For each component, approximate as solid cylinder:
594 # I_axial = (1/2) * m * r^2
595 # I_transverse = (1/12) * m * (3*r^2 + h^2) ≈ (1/4) * m * r^2 for short cylinders
596 #
597 # Parallel axis theorem adds m * d^2 to transverse inertias
598
599 def compute_component_inertia(mass, cm_pos, system_cm, radius, height=None):
600 """Compute inertia contribution of a cylindrical component."""
601 if mass <= 0:
602 return [0.0, 0.0, 0.0]
603
604 # Distance from component CM to system CM (for parallel axis)
605 d = cm_pos - system_cm
606
607 # Local inertias (solid cylinder approximation)
608 # Axial (Izz): I = (1/2) * m * r^2
609 I_local_axial = 0.5 * mass * radius**2
610
611 # Transverse (Ixx, Iyy): I = (1/12) * m * (3*r^2 + h^2)
612 # If height not specified, use simplified: I ≈ (1/4) * m * r^2
613 if height is not None and height > 0:
614 I_local_transverse = (1.0/12.0) * mass * (3 * radius**2 + height**2)
615 else:
616 I_local_transverse = 0.25 * mass * radius**2
617
618 # Apply parallel axis theorem to transverse inertias
619 # I_total = I_local + m * d^2
620 I_transverse_total = I_local_transverse + mass * d**2
621
622 return [I_transverse_total, I_transverse_total, I_local_axial]
623
624 # Engine inertia (compact cylinder)
625 engine_r = rocket_radius * 0.6 # Engine smaller than rocket body
626 engine_h = 0.3 # Approximate engine height
627 I_engine = compute_component_inertia(engine_mass, engine_cm_offset, weighted_cm, engine_r, engine_h)
628
629 # LOX tank structure inertia
630 lox_tank_r = config.lox_tank.lox_radius
631 lox_tank_h = config.lox_tank.lox_h
632 I_lox_tank = compute_component_inertia(lox_tank_structure_mass, lox_tank_pos, weighted_cm, lox_tank_r, lox_tank_h)
633
634 # Fuel tank structure inertia
635 fuel_tank_r = config.fuel_tank.rp1_radius
636 fuel_tank_h = config.fuel_tank.rp1_h
637 I_fuel_tank = compute_component_inertia(fuel_tank_structure_mass, fuel_tank_pos, weighted_cm, fuel_tank_r, fuel_tank_h)
638
639 # COPV structure inertia
640 if copv_dry_mass > 0 and config.press_tank:
641 copv_r = config.press_tank.press_radius
642 copv_h = config.press_tank.press_h
643 I_copv = compute_component_inertia(copv_dry_mass, copv_pos, weighted_cm, copv_r, copv_h)
644 else:
645 I_copv = [0.0, 0.0, 0.0]
646
647 # Total motor inertia (sum of all components)
648 motor_inertia = [
649 I_engine[0] + I_lox_tank[0] + I_fuel_tank[0] + I_copv[0], # Ixx (transverse)
650 I_engine[1] + I_lox_tank[1] + I_fuel_tank[1] + I_copv[1], # Iyy (transverse)
651 I_engine[2] + I_lox_tank[2] + I_fuel_tank[2] + I_copv[2], # Izz (axial)
652 ]
653
654 # Use the weighted CM as the motor's center of dry mass position
655 engine_cm_offset = weighted_cm
656
657 print(f"Using DETAILED mass model (RocketPy native):")
658 print(f" Engine + plumbing: {engine_mass:.2f} kg at {getattr(config.rocket, 'engine_cm_offset', 0.15):.2f}m above nozzle")
659 print(f" LOX tank structure: {lox_tank_structure_mass:.2f} kg at {lox_tank_pos:.2f}m (motor coords)")
660 print(f" Fuel tank structure: {fuel_tank_structure_mass:.2f} kg at {fuel_tank_pos:.2f}m (motor coords)")
661 if copv_dry_mass > 0:
662 print(f" COPV structure: {copv_dry_mass:.2f} kg at {copv_pos:.2f}m (motor coords)")
663 print(f" Combined dry mass CM: {weighted_cm:.3f}m above nozzle")
664 print(f" Motor dry inertia: [{motor_inertia[0]:.4f}, {motor_inertia[1]:.4f}, {motor_inertia[2]:.4f}] kg·m²")
665 print(f" Total propulsion dry: {propulsion_dry_mass:.2f} kg")
666 else:
667 # SIMPLE MODEL: All propulsion lumped together (backward compatible)
668 propulsion_dry_mass = config.rocket.propulsion_dry_mass
669 propulsion_cm_offset = getattr(config.rocket, 'propulsion_cm_offset', 0.3)
670 motor_dry_mass = propulsion_dry_mass
671 engine_cm_offset = propulsion_cm_offset
672
673 # Estimate motor inertia as solid cylinder (propulsion system)
674 prop_r = rocket_radius * 0.8
675 prop_h = 0.5 # Approximate propulsion system height
676 # Solid cylinder: I_axial = (1/2)*m*r^2, I_transverse = (1/12)*m*(3*r^2 + h^2)
677 I_transverse = (1.0/12.0) * motor_dry_mass * (3 * prop_r**2 + prop_h**2)
678 I_axial = 0.5 * motor_dry_mass * prop_r**2
679 motor_inertia = [I_transverse, I_transverse, I_axial]
680
681 print(f"Using SIMPLE propulsion model:")
682 print(f" Propulsion dry mass: {propulsion_dry_mass:.2f} kg (lumped)")
683 print(f" Propulsion CM offset: {propulsion_cm_offset:.2f} m above nozzle")
684 print(f" Propulsion inertia (estimated): [{motor_inertia[0]:.4f}, {motor_inertia[1]:.4f}, {motor_inertia[2]:.4f}] kg·m²")
685
686 rocket_mass = airframe_mass
687
688 # Calculate CM of airframe (without motor/propulsion)
689 cm_wo_motor = getattr(config.rocket, 'cm_wo_motor', None)
690 if cm_wo_motor is None:
691 # Estimate: airframe CM is above the motor, roughly 60% up the body
692 cm_wo_motor = motor_position + 1.5
693
694 total_dry_mass = airframe_mass + propulsion_dry_mass
695 print(f" Airframe mass: {airframe_mass:.2f} kg")
696 print(f" Airframe inertia: [{rocket_inertia[0]:.2f}, {rocket_inertia[1]:.2f}, {rocket_inertia[2]:.2f}] kg·m²")
697 print(f" Total dry mass: {total_dry_mass:.2f} kg")
698 else:
699 # LEGACY MODEL: mass + motor.dry_mass
700 if config.rocket.mass is None:
701 raise ValueError("Rocket configuration must include 'airframe_mass' + 'propulsion_dry_mass' (new) or 'mass' + 'motor' (legacy)")
702 rocket_mass = config.rocket.mass
703
704 if config.rocket.motor is None:
705 raise ValueError("Legacy config requires 'motor' section with 'dry_mass'")
706 motor_dry_mass = config.rocket.motor.dry_mass
707 motor_inertia = config.rocket.motor_inertia if config.rocket.motor_inertia else [0.1, 0.1, 0.1]
708
709 cm_wo_motor = config.rocket.cm_wo_motor if config.rocket.cm_wo_motor else 1.0
710 motor_position = getattr(config.rocket, 'motor_position', 0.5)
711 engine_cm_offset = 0.0 # Legacy: CM at nozzle
712
713 print(f"Using LEGACY mass model:")
714 print(f" Rocket mass (airframe): {rocket_mass:.2f} kg")
715 print(f" Motor dry mass: {motor_dry_mass:.2f} kg")
716 print(f" Total dry mass: {rocket_mass + motor_dry_mass:.2f} kg")
717
718
719 # Environment
720 env = Environment(
721 date=config.environment.date,
722 latitude=config.environment.latitude,
723 longitude=config.environment.longitude,
724 elevation=config.environment.elevation,
725 )
726 # Atmosphere model is a toggle: deterministic ISA (default, offline) vs live GFS forecast.
727 atmos = getattr(config.environment, 'atmosphere_model', 'standard_atmosphere') or 'standard_atmosphere'
728 if str(atmos).lower() == 'forecast':
729 try:
730 env.set_atmospheric_model(type='Forecast', file='GFS')
731 except Exception as e:
732 # Forecast needs internet + a near date; fall back to ISA rather than failing the flight.
733 print(f"[flight_sim] GFS forecast unavailable ({e}); falling back to standard atmosphere.")
734 env.set_atmospheric_model(type='standard_atmosphere')
735 else:
736 env.set_atmospheric_model(type='standard_atmosphere')
737 # GFS may override elevation with its terrain model - restore configured elevation
738 env.set_elevation(config.environment.elevation)
739
740 report_warnings = []
741
742 # One stack for the tanks, the nose and the drag: engine/pipeline/vehicle_drag.built_stack.
743 # The tank cylinders take their height from the same volume the mass caps use, so RocketPy's
744 # tank, the cap and the ullage below cannot disagree about how big the tank is.
745 from engine.pipeline.vehicle_drag import built_stack, resolve_drag_curves
746
747 stack = built_stack(config)
748 lox_geom = CylindricalTank(radius=config.lox_tank.lox_radius, height=stack["lox_h"], spherical_caps=False)
749 rp1_geom = CylindricalTank(radius=config.fuel_tank.rp1_radius, height=stack["fuel_h"], spherical_caps=False)
750 for name, section, h_attr in (("LOX", config.lox_tank, "lox_h"), ("fuel", config.fuel_tank, "rp1_h")):
751 h_cfg = float(getattr(section, h_attr))
752 h_used = stack["lox_h" if name == "LOX" else "fuel_h"]
753 if abs(h_used - h_cfg) > 1e-6 * h_cfg:
754 report_warnings.append(
755 f"{name} tank: tank_volume_m3 and pi r^2 {h_attr} disagree; flown at the volume "
756 f"(height {h_used:.4f} m, config {h_attr} {h_cfg:.4f} m)"
757 )
758
759 # Fluids and tanks — names/densities come from the loaded config so this follows the propellant
760 # switch (LOX/CH4, LOX/Ethanol, LOX/RP-1, …); nothing here is hardcoded to a specific propellant.
761 ox_name = getattr(config.fluids['oxidizer'], 'name', None) or "Oxidizer"
762 fuel_name = getattr(config.fluids['fuel'], 'name', None) or "Fuel"
763 lox = Fluid(name=ox_name, density=rho_lox)
764 rp1 = Fluid(name=fuel_name, density=rho_rp1)
765
766 # THE T-0 ULLAGE IS THE TANK'S OWN, AT TANK PRESSURE.
767 #
768 # This was Fluid("GN2", density=50) with initial_gas_mass=0.05 in both tanks: 1.000 L of gas
769 # whatever the tank. The 6.5 kN tanks, loaded to 90 %, have 0.644 L (LOX) and 0.620 L (fuel)
770 # of ullage, so RocketPy refused both tanks and the shipped vehicle could not be flown at all;
771 # copv_flight_helpers then blamed the propellant load. The gas that is really there fills
772 # V_tank - m/rho_liquid at the tank's pressure: 47.8 kg/m3 of N2 at 584.27 psia / 293.15 K,
773 # 30.8 g and 29.6 g. The 0.999 keeps the fluid just inside RocketPy's inverse_volume domain,
774 # which fails on float equality at a completely full tank.
775 ullage = {}
776 for name, section, m_liq, rho_liq, V in (
777 ("LOX", config.lox_tank, m_lox0, rho_lox, lox_geom.total_volume),
778 ("fuel", config.fuel_tank, m_rp10, rho_rp1, rp1_geom.total_volume),
779 ):
780 V_liq = m_liq / rho_liq
781 V_ull = V - V_liq
782 if V_ull <= 0.0:
783 raise ValueError(
784 f"{name} tank: {V_liq * 1e3:.4f} L of liquid in a {V * 1e3:.4f} L tank leaves no ullage"
785 )
786 rho_g, P_abs, T_g = ullage_gas_density(section)
787 ullage[name] = {
788 "tank_volume_L": V * 1e3,
789 "liquid_volume_L": V_liq * 1e3,
790 "ullage_volume_L": V_ull * 1e3,
791 "gas": str(section.ullage_gas),
792 "pressure_pa": P_abs,
793 "temperature_K": T_g,
794 "gas_density_kg_m3": rho_g,
795 "initial_gas_kg": 0.999 * V_ull * rho_g,
796 "fluid": Fluid(name=f"{section.ullage_gas} ullage", density=rho_g),
797 }
798 m_ullage_gas = ullage["LOX"]["initial_gas_kg"] + ullage["fuel"]["initial_gas_kg"]
799 # THE PRESSURANT IS THE ULLAGE GAS. The regulator fills the ullages from the COPV, so the bottle
800 # holds what the tanks' ullage_gas names: one species, priced as that species everywhere below
801 # (the refill, the isothermal lockup reserve, the labels). These read "N2" whatever the gas was,
802 # and Layer X's helium bottle was priced as nitrogen (docs/layerx/AUDIT.md 5.2).
803 gases = sorted({ullage["LOX"]["gas"], ullage["fuel"]["gas"]})
804 pressurant_gas = gases[0] if len(gases) == 1 else " / ".join(gases)
805 # The names RocketPy prints and the tank is looked up by: "Pressurant (N₂) Tank" stays what it was.
806 gas_label = " / ".join(_GAS_LABEL.get(g.strip().lower(), g) for g in gases)
807
808 # Pressurant (COPV) tank setup
809 m_pressurant = 0.0
810 if config.press_tank:
811 m_pressurant = getattr(config.press_tank, 'initial_gas_mass', None) or 0.0
812 if m_pressurant > 0:
813 # COPV VOLUME AND DENSITY COME FROM THE CONFIG, NOT FROM A CONSTANT.
814 #
815 # This used to build the tank from press_radius x press_h and fill it with
816 # Fluid(density=200), a number with no source on it. Real GN2 at 4500 psi / 293 K
817 # is 310 kg/m3 (CoolProp, Z = 1.150), so 200 under-states a charged COPV by 35 %
818 # and caps a 5 L bottle at 1.0 kg. A 5 L COPV actually holds 1.551 kg, and
819 # RocketPy then refused the tank outright as "overfilled".
820 #
821 # free_volume_L is the authoritative number: it is what the operator specifies and
822 # what a propellant-volume budget counts. Build the geometry to it and take the
823 # density as mass/volume, which is self-consistent by construction and assumes
824 # nothing about fill pressure or gas species.
825 free_L = getattr(config.press_tank, 'free_volume_L', None)
826 if free_L and free_L > 0:
827 V_copv = float(free_L)/1000.0
828 press_h_eff = V_copv/(np.pi*config.press_tank.press_radius**2)
829 else:
830 press_h_eff = config.press_tank.press_h
831 V_copv = np.pi*config.press_tank.press_radius**2*press_h_eff
832 # 0.1 % of solver ullage. RocketPy composes gas_height through
833 # geometry.inverse_volume, whose domain is exactly [0, V_copv], so a tank filled
834 # to precisely its own volume fails on float equality. Mass is conserved exactly;
835 # only the density carries the 0.1 %.
836 copv_gas = Fluid(name=f"{gas_label} COPV", density=m_pressurant/(V_copv*0.999))
837 press_geom = CylindricalTank(
838 radius=config.press_tank.press_radius,
839 height=press_h_eff,
840 spherical_caps=False
841 )
842
843 # PRESSURANT DOES NOT LEAVE THE VEHICLE.
844 #
845 # This used to drain the whole COPV over the burn
846 # (mdot = m_pressurant / burn_time), which RocketPy subtracts from vehicle mass --
847 # i.e. the gas was being flown as propellant. It is not: it moves from the COPV
848 # into the ullage the departing propellant leaves behind, and every gram of it is
849 # still on board at burnout.
850 #
851 # What it cost: on the 180 lb / 11 L point, 1.551 kg of N2 out of 81.647 kg wet.
852 # Burnout mass 69.36 kg instead of 70.90, so ln(m0/mf) went 0.1412 -> 0.1631 and
853 # ideal dv was over-stated by 51 m/s, about 13 %. It also drove the tank to
854 # exactly -0.000 kg at burnout, which RocketPy raises on, so the sim would
855 # intermittently fail outright rather than just answer wrongly.
856 #
857 # Nothing goes overboard. What the regulator moves into the ullages (below) leaves
858 # the COPV and arrives in the tanks, so the mass is conserved and the CG follows it.
859 mdot_pressurant_avg = 0.0
860
861 print(f" Pressurant ({gas_label}): {m_pressurant:.3f} kg in {V_copv*1000:.2f} L "
862 f"({m_pressurant/V_copv:.0f} kg/m3), carried as dead mass (not expelled)")
863
864 # REFILL. With a COPV on board the regulator holds tank pressure, so the ullage gas grows by
865 # rho_gas/rho_liquid per kg of liquid drained and the COPV loses the same. Without one it is a
866 # blowdown: the T-0 gas expands and its mass is fixed.
867 refilled = m_pressurant > 0
868 t_flux = np.concatenate([t_grid[t_grid < effective_burn_time], [effective_burn_time]])
869
870 def _tank_masses(name, m0, cum):
871 cum_f = np.interp(t_flux, t_grid, cum)
872 liquid = np.clip(m0 - cum_f, 1.0e-9, None)
873 gas = ullage[name]["initial_gas_kg"] + (
874 ullage[name]["gas_density_kg_m3"] / (rho_lox if name == "LOX" else rho_rp1) * (m0 - liquid) if refilled else 0.0
875 )
876 return Function(np.column_stack((t_flux, liquid))), Function(np.column_stack((t_flux, np.broadcast_to(gas, t_flux.shape)))), liquid
877
878 lox_liquid, lox_gas, lox_liq_arr = _tank_masses("LOX", m_lox0, cum_O)
879 fuel_liquid, fuel_gas, fuel_liq_arr = _tank_masses("fuel", m_rp10, cum_F)
880 oxidizer_tank = MassBasedTank(
881 name="LOX Tank",
882 geometry=lox_geom,
883 flux_time=effective_burn_time,
884 liquid=lox,
885 gas=ullage["LOX"]["fluid"],
886 liquid_mass=lox_liquid,
887 gas_mass=lox_gas,
888 discretize=100,
889 )
890 fuel_tank = MassBasedTank(
891 name="Fuel Tank",
892 geometry=rp1_geom,
893 flux_time=effective_burn_time,
894 liquid=rp1,
895 gas=ullage["fuel"]["fluid"],
896 liquid_mass=fuel_liquid,
897 gas_mass=fuel_gas,
898 discretize=100,
899 )
900 refill_kg = 0.0
901 if refilled:
902 refill_kg = sum(
903 ullage[n]["gas_density_kg_m3"] / rho * (m0 - liq[-1])
904 for n, rho, m0, liq in (("LOX", rho_lox, m_lox0, lox_liq_arr), ("fuel", rho_rp1, m_rp10, fuel_liq_arr))
905 )
906 if refill_kg > m_pressurant - 1.0e-4:
907 raise ValueError(
908 f"COPV holds {m_pressurant:.3f} kg; holding tank pressure over this burn needs "
909 f"{refill_kg:.3f} kg into the ullages. The regulated curve cannot be flown with this COPV."
910 )
911 lockup_kg = max(u["gas_density_kg_m3"] for u in ullage.values()) * V_copv
912 if refill_kg > m_pressurant - lockup_kg:
913 report_warnings.append(
914 f"COPV refill {refill_kg:.3f} kg exceeds the {m_pressurant - lockup_kg:.3f} kg above tank "
915 "pressure (isothermal): the regulator would lose lockup before burnout"
916 )
917
918 # Create pressurant tank if configured
919 pressurant_tank = None
920 if config.press_tank and m_pressurant > 0:
921 # What leaves the COPV is what arrives in the ullages, plus nothing overboard.
922 refill_rate = mdot_pressurant_avg + (
923 ullage["LOX"]["gas_density_kg_m3"] / rho_lox * np.interp(t_flux, t_grid, mdot_O_grid)
924 + ullage["fuel"]["gas_density_kg_m3"] / rho_rp1 * np.interp(t_flux, t_grid, mdot_F_grid)
925 )
926 mdot_pressurant = Function(np.column_stack((t_flux, refill_rate)))
927
928 pressurant_tank = MassFlowRateBasedTank(
929 name=f"Pressurant ({gas_label}) Tank",
930 geometry=press_geom,
931 flux_time=effective_burn_time,
932 liquid=copv_gas, # Using "liquid" field for gas (RocketPy limitation)
933 gas=copv_gas,
934 # The stub comes OUT OF the pressurant mass, not on top of it. It used to be a
935 # flat 0.01 kg added alongside initial_liquid_mass, so the tank held
936 # m_pressurant + 0.01 kg: with the density derived from m_pressurant/V that is
937 # 0.0050322 m3 in a 0.0050000 m3 bottle, and RocketPy rejected it outright.
938 initial_liquid_mass=max(0.0, m_pressurant - 1.0e-4),
939 initial_gas_mass=1.0e-4,
940 liquid_mass_flow_rate_in=0.0,
941 liquid_mass_flow_rate_out=mdot_pressurant, # into the ullages, still on board
942 gas_mass_flow_rate_in=0.0,
943 gas_mass_flow_rate_out=0.0,
944 discretize=100,
945 )
946
947 # PRESSURE THRUST. The curve was computed at one ambient pressure; in flight the same engine
948 # gives F + (p_ref - p(z)) * A_exit (Sutton & Biblarz eq. 3-21). Without reference_pressure
949 # RocketPy's pressure_thrust is zero and the curve flew unchanged to apogee: ~55 N short at
950 # 1.2 km MSL on the 6.5 kN engine.
951 p_ref = getattr(config.thrust, "reference_pressure_pa", None)
952 if p_ref is None:
953 from engine.core.runner import compute_ambient_pressure_from_elevation
954
955 p_ref = float(compute_ambient_pressure_from_elevation(float(config.environment.elevation)))
956
957 # Liquid motor - use effective_burn_time for burn_time
958 # engine_cm_offset: how far above nozzle the engine dry mass CM is
959 # (tank structures are added separately if using detailed model)
960 liquid_motor = LiquidMotor(
961 thrust_source=thrust_curve,
962 center_of_dry_mass_position=engine_cm_offset, # CM of engine (not tanks) above nozzle
963 dry_inertia=motor_inertia,
964 dry_mass=motor_dry_mass,
965 burn_time=(0.0, effective_burn_time),
966 nozzle_radius=math.sqrt(A_e / math.pi),
967 nozzle_position=0.0, # Nozzle at origin of motor coordinate system
968 coordinate_system_orientation="nozzle_to_combustion_chamber",
969 reference_pressure=float(p_ref),
970 )
971
972 # Rocket assembly - stack from bottom (tail) to top (nose)
973 # In "tail_to_nose" system: lower position = tail, higher position = nose
974 # motor_position: where the nozzle exit is, measured from rocket tail
975
976 # Add tanks relative to motor (nozzle) position
977 # Each tank tracks its own mass, CM, and inertia as propellant/gas depletes
978 liquid_motor.add_tank(fuel_tank, position=config.fuel_tank.fuel_tank_pos)
979 liquid_motor.add_tank(oxidizer_tank, position=config.lox_tank.ox_tank_pos)
980
981 # Add pressurant tank if configured
982 if pressurant_tank is not None:
983 liquid_motor.add_tank(pressurant_tank, position=config.press_tank.pres_tank_pos)
984 print(f" Added pressurant tank at position {config.press_tank.pres_tank_pos:.2f}m")
985
986 # DRAG. Cd(M) from the user's tables, or the Barrowman / OpenRocket component build-up of the
987 # vehicle assembled here (engine/pipeline/vehicle_drag.py). This was Cd 0.45 at every Mach,
988 # motor on and off, whatever the length, fins or finish: the build-up for this 7.76 m, 49:1
989 # vehicle is 0.64-0.91 depending on the finish, and friction alone is above 0.45.
990 drag_curves = resolve_drag_curves(config, A_e, stack)
991
992 # THE VEHICLE AS WEIGHED. Given the liftoff mass (on the rail, loaded and pressed), the
993 # airframe is what is left of it once the motor's dry mass, the propellants and the gases are
994 # booked, so the vehicle flies at exactly that mass whatever the config's airframe_mass says.
995 # The airframe keeps its shape: its inertia scales with its mass and its CM stays put.
996 # Gas the caller says is in the ullages beyond what these tanks hold: lumped with the airframe.
997 extra_gas = (float(ullage_gas_kg) - m_ullage_gas) if ullage_gas_kg is not None else 0.0
998 carried = motor_dry_mass + m_lox0 + m_rp10 + m_pressurant + m_ullage_gas + extra_gas
999 airframe_source = "config"
1000 if liftoff_mass is not None:
1001 airframe = float(liftoff_mass) - carried
1002 if airframe <= 0.0:
1003 raise ValueError(
1004 f"A liftoff mass of {float(liftoff_mass):.2f} kg leaves nothing for the airframe: the motor "
1005 f"dry mass, propellants and gases alone are {carried:.2f} kg"
1006 )
1007 if float(rocket_mass) > 0.0:
1008 rocket_inertia = [float(i) * airframe / float(rocket_mass) for i in rocket_inertia]
1009 print(f" Liftoff mass {float(liftoff_mass):.2f} kg given: airframe {airframe:.2f} kg "
1010 f"(config {float(rocket_mass):.2f} kg), inertia scaled with it")
1011 rocket_mass = airframe
1012 airframe_source = "liftoff mass"
1013 airframe_only = float(rocket_mass)
1014 if extra_gas:
1015 print(f" Ullage gas at T-0 {float(ullage_gas_kg):.3f} kg given ({m_ullage_gas:.3f} kg in these tanks): "
1016 f"{extra_gas:+.3f} kg carried with the airframe")
1017 rocket_mass = rocket_mass + extra_gas
1018 mass_budget = {
1019 "airframe_kg": airframe_only,
1020 "airframe_source": airframe_source,
1021 "motor_dry_kg": float(motor_dry_mass),
1022 "oxidizer_kg": float(m_lox0),
1023 "fuel_kg": float(m_rp10),
1024 "pressurant_kg": float(m_pressurant),
1025 "pressurant_gas": pressurant_gas,
1026 "ullage_gas_kg": float(m_ullage_gas + extra_gas),
1027 "liftoff_kg": float(airframe_only + carried),
1028 }
1029 rocket = Rocket(
1030 radius=rocket_radius,
1031 mass=rocket_mass,
1032 inertia=rocket_inertia,
1033 center_of_mass_without_motor=cm_wo_motor,
1034 coordinate_system_orientation="tail_to_nose",
1035 # As Functions, linear between the points and flat past the ends -- what the
1036 # tables say, and what DragCurves.at() reads. A bare list leaves the
1037 # interpolation to rocketpy's default, which 1.13 made a spline: a user's
1038 # 0.6 -> 0.8 table flew 0.728 at M 0.5.
1039 power_off_drag=Function(drag_curves.rocketpy(power_on=False), "Mach Number",
1040 "Drag Coefficient with Power Off", "linear", "constant"),
1041 power_on_drag=Function(drag_curves.rocketpy(power_on=True), "Mach Number",
1042 "Drag Coefficient with Power On", "linear", "constant"),
1043 )
1044 rocket_length_cfg = getattr(config.rocket, "rocket_length", None)
1045 if rocket_length_cfg is not None and abs(float(rocket_length_cfg) - stack["length"]) > 1e-3:
1046 report_warnings.append(
1047 f"rocket.rocket_length {float(rocket_length_cfg):.3f} m, but the stack flown is {stack['length']:.3f} m "
1048 "tail to nose tip (tank positions + avionics_payload_length_m); drag and inertia use the stack"
1049 )
1050
1051 # Fins at bottom (tail) - position 0.0. Optional in the schema: a finless vehicle flies without.
1052 fins = getattr(config.rocket, "fins", None)
1053 if fins is not None:
1054 rocket.add_trapezoidal_fins(
1055 n=fins.no_fins,
1056 root_chord=fins.root_chord,
1057 tip_chord=fins.tip_chord,
1058 span=fins.fin_span,
1059 position=fins.fin_position, # User-specified position from rocket tail
1060 )
1061 else:
1062 report_warnings.append("rocket.fins is not set: flown without fins, so the vehicle has no aerodynamic stability")
1063
1064 # Motor above fins
1065 rocket.add_motor(liquid_motor, position=motor_position)
1066
1067 # NOTE: Tank structure masses are now included in LiquidMotor.dry_mass
1068 # with proper CM and inertia calculations using parallel axis theorem.
1069 # This is the correct RocketPy approach - no need for separate point masses.
1070
1071 # Nose tip avionics_payload_length_m above the highest tank top (built_stack). Length from the
1072 # fineness ratio (nose length / body DIAMETER) unless nose_length is set.
1073 body_diameter = 2.0 * rocket_radius
1074 nose_kind = getattr(config.rocket, 'nose_kind', None) or "vonKarman"
1075 nose_length = stack["nose_length"]
1076 print(f" Nosecone: {nose_kind}, length {nose_length:.3f} m "
1077 f"(fineness {nose_length / body_diameter:.2f}:1 on Ø{body_diameter:.3f} m)")
1078 rocket.add_nose(length=nose_length, kind=nose_kind, position=stack["nose_tip"])
1079
1080 upper = getattr(config.rocket, "rail_button_upper_pos_m", None)
1081 lower = getattr(config.rocket, "rail_button_lower_pos_m", None)
1082 if upper is not None and lower is not None:
1083 rocket.set_rail_buttons(upper_button_position=float(upper), lower_button_position=float(lower))
1084
1085 # Compute initial thrust-to-weight ratio for validation
1086 # Sample thrust at t=0 from thrust curve
1087 if isinstance(thrust_curve, list):
1088 initial_thrust = thrust_curve[0][1] if thrust_curve else 0.0
1089 elif hasattr(thrust_curve, '__call__'):
1090 initial_thrust = float(thrust_curve(0.0))
1091 else:
1092 initial_thrust = float(thrust_curve)
1093
1094 # Total initial mass = airframe + motor dry (includes engine + tank structures) + propellants + pressurant gas
1095 # + the T-0 ullage gas, which ground pre-pressurisation puts there on top of the COPV charge
1096 total_initial_mass = rocket_mass + motor_dry_mass + m_lox0 + m_rp10 + m_pressurant + m_ullage_gas
1097 initial_twr = initial_thrust / (total_initial_mass * g0)
1098
1099 print(f"\nMass Summary:")
1100 print(f" Airframe: {rocket_mass:.2f} kg")
1101 print(f" Motor dry (engine + tank structures): {motor_dry_mass:.2f} kg")
1102 print(f" LOX propellant: {m_lox0:.2f} kg")
1103 print(f" Fuel propellant: {m_rp10:.2f} kg")
1104 if m_pressurant > 0:
1105 print(f" Pressurant gas ({gas_label}): {m_pressurant:.3f} kg")
1106 print(f" Ullage gas at T-0: {m_ullage_gas:.3f} kg")
1107 print(f" TOTAL: {total_initial_mass:.2f} kg")
1108 print(f"\nInitial thrust: {initial_thrust:.1f} N")
1109 print(f"Initial T/W ratio: {initial_twr:.3f}")
1110
1111 if initial_twr < 1.0:
1112 raise ValueError(
1113 f"Thrust-to-weight ratio ({initial_twr:.3f}) is less than 1.0! "
1114 f"The rocket cannot take off. Either increase thrust or reduce mass. "
1115 f"Current: thrust={initial_thrust:.1f} N, mass={total_initial_mass:.2f} kg, "
1116 f"requires thrust > {total_initial_mass * g0:.1f} N"
1117 )
1118
1119 # Flight simulation with timeout to prevent infinite loops
1120 # max_time limits simulation to prevent hangs if something goes wrong
1121 max_flight_time = max(300.0, effective_burn_time * 30) # At least 5 min, or 30x burn time
1122
1123 # Suppress RocketPy's internal Function domain warnings during flight simulation
1124 # These are numerical precision issues in tank level calculations, not real failures
1125 with warnings.catch_warnings():
1126 warnings.filterwarnings(
1127 "ignore",
1128 message=".*must be within the domain of the Function.*",
1129 category=UserWarning,
1130 )
1131 flight = Flight(
1132 rocket=rocket,
1133 environment=env,
1134 rail_length=float(config.environment.rail_length_m),
1135 inclination=float(config.environment.launch_inclination_deg),
1136 heading=float(config.environment.launch_heading_deg),
1137 max_time_step=0.02,
1138 max_time=max_flight_time,
1139 terminate_on_apogee=True,
1140 )
1141
1142 # RocketPy reports apogee as ASL (Above Sea Level) - convert to AGL for display
1143 elevation = float(config.environment.elevation)
1144 apogee_asl = float(flight.apogee)
1145 apogee_agl = apogee_asl - elevation
1146
1147 try:
1148 # flight.vz.get_source() returns (N, 2): column 0 = time, column 1 = velocity
1149 vz_source = flight.vz.get_source()
1150 max_v = float(np.max(vz_source[:, 1])) # Extract only the velocity column
1151 except Exception:
1152 max_v = None
1153
1154 if truncation_info.get("truncated"):
1155 truncation_info["cutoff_time"] = float(liquid_motor.burn_out_time)
1156
1157 report = flight_report(
1158 flight,
1159 config,
1160 drag_curves,
1161 extra={
1162 "reference_pressure_pa": float(p_ref),
1163 "nozzle_exit_area_m2": float(A_e),
1164 "wet_mass_kg": float(total_initial_mass),
1165 "stack_length_m": float(stack["length"]),
1166 "ullage": {k: {kk: vv for kk, vv in v.items() if kk != "fluid"} for k, v in ullage.items()},
1167 "copv_refill_kg": float(refill_kg),
1168 "warnings": report_warnings,
1169 },
1170 )
1171
1172 # Carried on the Flight too: copv_flight_helpers.run_flight_simulation keeps only the Flight.
1173 flight.flight_report = report
1174
1175 print(f"Apogee AGL [m]: {apogee_agl:.2f} (ASL: {apogee_asl:.2f}, elevation: {elevation:.2f})")
1176 if max_v is not None:
1177 print(f"Max velocity [m/s]: {max_v:.2f}")
1178
1179 return {
1180 "apogee": apogee_agl, # Return AGL for display
1181 "apogee_asl": apogee_asl, # Also provide ASL if needed
1182 "elevation": elevation,
1183 "max_velocity": max_v,
1184 "thrust_curve": thrust_curve,
1185 "flight": flight,
1186 "params": config,
1187 "truncation_info": truncation_info,
1188 "mass_caps": mass_caps,
1189 "mass_budget": mass_budget,
1190 "flight_report": report,
1191 }
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
detect_lox_underfill_time(mdot_lox, m_lox0, burn_time, n_samples=5000)
Definition flight_sim.py:99
detect_tank_underfill_time(mdot, m_initial, burn_time, n_samples=5000)
Definition flight_sim.py:27
truncate_mdot_function(mdot_func, cutoff_time, burn_time)
_sample(source, times)
detect_fuel_underfill_time(mdot_fuel, m_fuel0, burn_time, n_samples=5000)
setup_flight(config, thrust_curve, mdot_lox, mdot_fuel, plot_results=False, liftoff_mass=None, ullage_gas_kg=None)
flight_report(flight, config, drag_curves=None, extra=None)
ullage_gas_density(tank_section)
truncate_thrust_curve(thrust_curve, cutoff_time)
_cumulative(times, rates)
_depletion_time(times, cumulative, m0)
_on_grid(times, values, t_end)