STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
flight.py
Go to the documentation of this file.
1"""Flight simulation endpoints."""
2
3from fastapi import APIRouter, HTTPException, Depends
4from pydantic import BaseModel, Field
5from typing import List, Optional, Literal
6import math
7import numpy as np
8import copy
9
10from starlette.concurrency import run_in_threadpool
11
12from backend.session import UserSession, get_session
13
14router = APIRouter(prefix="/api/flight", tags=["flight"])
15
16
17# Constants
18PSI_TO_PA = 6894.76
19PA_TO_PSI = 1.0 / PSI_TO_PA
20
21# Fallback fluid densities (kg/m³) used ONLY if the loaded config has no fluid density. The real
22# densities come from config.fluids so mass-capping follows the selected propellant (LOX/CH4, ethalox,
23# kerolox, …) — nothing is hardcoded to a specific fuel. See _propellant_densities().
24LOX_DENSITY_FALLBACK = 1141.0 # liquid oxygen at boiling point
25FUEL_DENSITY_FALLBACK = 800.0 # generic dense liquid fuel
26
27
28def _propellant_densities(config) -> tuple:
29 """(oxidizer, fuel) liquid densities [kg/m³] from the loaded config's fluids, so propellant
30 mass-capping follows the selected propellant rather than a hardcoded fuel. Falls back to generic
31 densities only if the config doesn't carry them."""
32 ox_rho = fuel_rho = None
33 try:
34 fluids = getattr(config, "fluids", None) or {}
35 ox_rho = getattr(fluids.get("oxidizer"), "density", None)
36 fuel_rho = getattr(fluids.get("fuel"), "density", None)
37 except Exception:
38 pass
39 return (float(ox_rho) if ox_rho else LOX_DENSITY_FALLBACK,
40 float(fuel_rho) if fuel_rho else FUEL_DENSITY_FALLBACK)
41
42
43def calculate_tank_capacity(height: float, radius: float, density: float, fill_factor: float = 0.95) -> float:
44 """Calculate max propellant mass for a cylindrical tank.
45
46 Args:
47 height: Tank height in meters
48 radius: Tank radius in meters
49 density: Fluid density in kg/m³
50 fill_factor: Fill factor (default 95% to avoid overfill)
51
52 Returns:
53 Maximum propellant mass in kg
54 """
55 import math
56 volume = math.pi * radius ** 2 * height
57 return volume * density * fill_factor
58
59
61 """Recursively convert numpy types to Python native types."""
62 if isinstance(obj, dict):
63 return {k: convert_numpy(v) for k, v in obj.items()}
64 elif isinstance(obj, (list, tuple)):
65 return [convert_numpy(item) for item in obj]
66 elif isinstance(obj, np.ndarray):
67 return obj.tolist()
68 elif isinstance(obj, np.floating):
69 # NaN/Inf are legal model outputs (a lag model that does not define K_v, a margin
70 # that could not be evaluated) but json.dumps rejects them outright --
71 # "Out of range float values are not JSON compliant" -- which surfaced as a blanket
72 # HTTP 500 on forward evaluation. Emit JSON null instead, so a missing number reads
73 # as missing rather than taking the whole response down.
74 v = obj.item()
75 return v if math.isfinite(v) else None
76 elif isinstance(obj, np.integer):
77 return obj.item()
78 elif isinstance(obj, float):
79 return obj if math.isfinite(obj) else None
80 elif isinstance(obj, np.bool_):
81 return bool(obj)
82 else:
83 return obj
84
85
86# ============================================================================
87# Request/Response Models
88# ============================================================================
89
90class EnvironmentConfig(BaseModel):
91 """Environment configuration for flight simulation."""
92 latitude: float = Field(default=35.0, ge=-90, le=90, description="Launch site latitude [deg]")
93 longitude: float = Field(default=-117.0, ge=-180, le=180, description="Launch site longitude [deg]")
94 elevation: float = Field(default=0.0, ge=-500, le=10000, description="Ground elevation [m]")
95 date: List[int] = Field(default=[2025, 1, 1, 12], min_length=4, max_length=4, description="Launch date [year, month, day, hour]")
96 atmosphere_model: Literal["standard_atmosphere", "forecast"] = Field(
97 default="standard_atmosphere",
98 description="'standard_atmosphere' (ISA, offline, deterministic) or 'forecast' (live GFS weather)")
99 # Unset: the config's environment values.
100 rail_length_m: Optional[float] = Field(default=None, gt=0, description="Launch rail length [m]")
101 launch_inclination_deg: Optional[float] = Field(default=None, gt=0, le=90, description="Rail elevation from horizontal [deg]")
102 launch_heading_deg: Optional[float] = Field(default=None, ge=0, lt=360, description="Rail azimuth [deg from north]")
103
104
105class FinsConfig(BaseModel):
106 """Fins configuration for flight simulation."""
107 no_fins: int = Field(default=3, ge=1, le=8, description="Number of fins")
108 root_chord: float = Field(default=0.2, gt=0, description="Root chord [m]")
109 tip_chord: float = Field(default=0.1, gt=0, description="Tip chord [m]")
110 fin_span: float = Field(default=0.3, gt=0, description="Fin span [m]")
111 fin_position: float = Field(default=0.1, ge=0, description="Fin position from tail [m]")
112
113
114class RocketConfig(BaseModel):
115 """Rocket configuration for flight simulation."""
116 airframe_mass: float = Field(default=78.72, gt=0, description="Airframe mass (no propulsion) [kg]")
117 engine_mass: float = Field(default=8.0, gt=0, description="Engine + plumbing mass [kg]")
118 lox_tank_structure_mass: float = Field(default=5.0, gt=0, description="Empty LOX tank mass [kg]")
119 fuel_tank_structure_mass: float = Field(default=3.0, gt=0, description="Empty fuel tank mass [kg]")
120 radius: float = Field(default=0.1015, gt=0, description="Rocket radius [m]")
121 rocket_length: float = Field(default=3.5, gt=0, description="Rocket total length [m]")
122 motor_position: float = Field(default=0.0, ge=0, description="Motor position from tail [m]")
123 inertia: List[float] = Field(default=[8.0, 8.0, 0.5], min_length=3, max_length=3, description="Inertia [Ixx, Iyy, Izz] [kg·m²]")
124 fins: Optional[FinsConfig] = Field(default=None, description="Fins configuration")
125 # Nosecone: von Kármán length derived from fineness ratio (length / diameter); ~4.5:1 is near-optimal.
126 nose_kind: str = Field(default="vonKarman", description="Nosecone profile (RocketPy kind)")
127 nose_fineness_ratio: float = Field(default=4.5, gt=0, description="Nose length / body diameter (von Kármán ~4.5:1)")
128 nose_length: Optional[float] = Field(default=None, gt=0, description="Explicit nose length [m] (overrides fineness ratio)")
129 avionics_payload_length_m: float = Field(default=4.0, ge=0, description="Avionics/payload/recovery length above propulsion, before the nose [m]")
130 copv_dry_mass: Optional[float] = Field(default=None, gt=0, description="Empty COPV mass [kg]")
131 surface_roughness_m: Optional[float] = Field(default=None, ge=0, description="Skin roughness for the drag build-up [m]")
132 fin_thickness_m: Optional[float] = Field(default=None, gt=0, description="Fin thickness for the drag build-up [m]")
133 fin_profile: Optional[Literal["square", "rounded", "airfoil"]] = Field(default=None, description="Fin edge profile for the drag build-up")
134
135
136class TankConfig(BaseModel):
137 """Tank configuration for flight simulation."""
138 mass: float = Field(..., gt=0, description="Initial propellant mass [kg]")
139 height: float = Field(default=1.0, gt=0, description="Tank height [m]")
140 radius: float = Field(default=0.0762, gt=0, description="Tank radius [m]")
141 position: float = Field(default=0.6, description="Tank position relative to motor [m]")
142 volume_m3: Optional[float] = Field(default=None, gt=0, description="Tank volume [m³]. If not provided, will be calculated from height and radius using π×r²×h")
143
144
145class FlightSimRequest(BaseModel):
146 """Request body for flight simulation (time-series mode only)."""
147 # Time-series data - arrays from time-series analysis
148 time_array: List[float] = Field(..., min_length=2, description="Time array [s]")
149 thrust_array: List[float] = Field(..., min_length=2, description="Thrust array [N]")
150 mdot_O_array: List[float] = Field(..., min_length=2, description="LOX mass flow array [kg/s]")
151 mdot_F_array: List[float] = Field(..., min_length=2, description="Fuel mass flow array [kg/s]")
152
153 # Propellant configuration
154 lox_mass_kg: float = Field(default=18.0, gt=0, description="Initial LOX mass [kg]")
155 fuel_mass_kg: float = Field(default=4.0, gt=0, description="Initial fuel mass [kg]")
156
157 # Tank geometry (optional)
158 lox_tank: Optional[TankConfig] = Field(default=None, description="LOX tank configuration")
159 fuel_tank: Optional[TankConfig] = Field(default=None, description="Fuel tank configuration")
160
161 # Environment configuration
162 environment: Optional[EnvironmentConfig] = Field(default=None, description="Environment configuration")
163
164 # Rocket configuration
165 rocket: Optional[RocketConfig] = Field(default=None, description="Rocket configuration")
166
167 reference_pressure_pa: Optional[float] = Field(
168 default=None, gt=0,
169 description="Ambient pressure the thrust curve was computed at [Pa]. Unset: the engine solver's "
170 "reference at the SESSION config's elevation, which is what the time series ran at.")
171
172
173class FlightTrajectory(BaseModel):
174 """Flight trajectory data."""
175 time: List[float] = Field(description="Time array [s]")
176 altitude: List[float] = Field(description="Altitude AGL array [m]")
177 velocity: List[float] = Field(description="Vertical velocity array [m/s]")
178
179
180class TruncationInfo(BaseModel):
181 """Information about burn truncation."""
182 truncated: bool = Field(default=False, description="Whether burn was truncated")
183 cutoff_time: Optional[float] = Field(default=None, description="Cutoff time [s]")
184 reason: Optional[str] = Field(default=None, description="Reason for truncation")
185
186
187class MassCapInfo(BaseModel):
188 """Propellant mass capped to tank volume."""
189 requested_kg: float
190 effective_kg: float
191 max_fill_kg: float
192 fill_factor: float
193 tank_volume_m3: float
194 was_capped: bool
195
196
197class PropellantDiagnostics(BaseModel):
198 """Propellant vs time-series requirements for flight iteration."""
199 regime: str = Field(description="truncated | full_burn | excess_propellant")
200 timeseries_burn_time_s: float
201 effective_burn_time_s: float
202 total_impulse_Ns: float
203 lox_required_kg: float
204 fuel_required_kg: float
205 lox_requested_kg: float
206 fuel_requested_kg: float
207 lox_effective_kg: float
208 fuel_effective_kg: float
209 lox_tank_max_kg: Optional[float] = None
210 fuel_tank_max_kg: Optional[float] = None
211 target_apogee_m: Optional[float] = None
212 propellant_tank_fill_factor: Optional[float] = Field(
213 default=None, description="Fill fraction used for tank volume mass caps"
214 )
215 mass_caps: Optional[dict[str, MassCapInfo]] = None
216 warnings: List[str] = Field(default_factory=list)
217
218
219class FlightSimResponse(BaseModel):
220 """Response for flight simulation."""
221 status: str
222 apogee_m: float = Field(description="Apogee AGL [m]")
223 apogee_ft: float = Field(description="Apogee AGL [ft]")
224 max_velocity_m_s: float = Field(description="Maximum velocity [m/s]")
225 flight_time_s: float = Field(description="Total flight time [s]")
226 trajectory: Optional[FlightTrajectory] = Field(default=None, description="Flight trajectory data")
227 truncation: Optional[TruncationInfo] = Field(default=None, description="Truncation info")
228 propellant: Optional[PropellantDiagnostics] = Field(default=None, description="Propellant diagnostics")
229 thrust_curve: Optional[dict] = Field(default=None, description="Thrust curve used (time, thrust arrays)")
230 rocket_diagram: Optional[str] = Field(default=None, description="Base64-encoded rocket diagram PNG")
231 error: Optional[str] = Field(default=None, description="Error message if failed")
232 apogee_msl_m: Optional[float] = Field(default=None, description="Apogee above sea level [m]")
233 max_mach: Optional[float] = Field(default=None, description="Maximum Mach number")
234 rail_exit_velocity_m_s: Optional[float] = Field(default=None, description="Velocity leaving the rail [m/s]")
235 static_margin_rail_exit_cal: Optional[float] = Field(default=None, description="Static margin at rail exit [cal]")
236 static_margin_burnout_cal: Optional[float] = Field(default=None, description="Static margin at burnout [cal]")
237 ceiling: Optional[dict] = Field(default=None, description="Apogee ceiling check (design_requirements.max_apogee_m); None when unset")
238 report: Optional[dict] = Field(default=None, description="Flight report: launch, stability, drag Cd(M) and its inputs, ullage, checks, warnings")
239
240
242 """Request for minimum-fuel burn-time optimization to a target apogee."""
243 target_apogee_m: float = Field(..., gt=0, description="Target apogee AGL [m]")
244 apogee_tolerance_m: float = Field(default=15.0, ge=0, description="Apogee undershoot tolerance [m]")
245 min_burn_time_s: Optional[float] = Field(default=None, gt=0, description="Optional lower burn-time bound [s]")
246 max_burn_time_s: Optional[float] = Field(default=None, gt=0, description="Optional upper burn-time bound [s]")
247
248
249class FlightOptimizeResponse(BaseModel):
250 """Minimum-fuel burn-time optimization result."""
251 status: str
252 success: bool
253 target_apogee_m: float
254 apogee_tolerance_m: float
255 optimal_burn_time_s: float
256 optimal_lox_kg: float
257 optimal_fuel_kg: float
258 achieved_apogee_m: float
259 apogee_error_m: float
260 total_impulse_Ns: float
261 simulations_run: int
262 infeasible_reason: Optional[str] = None
263 flight: Optional[FlightSimResponse] = Field(default=None, description="Full flight result at optimum")
264
265
266# ============================================================================
267# Helper Functions
268# ============================================================================
269
270def generate_rocket_diagram(flight_obj) -> Optional[str]:
271 """Generate rocket diagram as base64-encoded PNG.
272
273 Returns:
274 Base64-encoded PNG string, or None if generation fails.
275 """
276 try:
277 import matplotlib
278 matplotlib.use('Agg') # Non-interactive backend
279 import matplotlib.pyplot as plt
280 import io
281 import base64
282
283 plt.close('all')
284
285 rocket = getattr(flight_obj, 'rocket', None)
286 if rocket is None:
287 return None
288
289 # Call RocketPy's draw method
290 maybe_fig = rocket.draw()
291 fig = maybe_fig if hasattr(maybe_fig, 'savefig') else plt.gcf()
292
293 # Save to bytes buffer
294 buf = io.BytesIO()
295 fig.savefig(buf, format='png', dpi=150, bbox_inches='tight',
296 facecolor='white', edgecolor='none')
297 buf.seek(0)
298
299 # Encode as base64
300 img_base64 = base64.b64encode(buf.read()).decode('utf-8')
301
302 plt.close(fig)
303 buf.close()
304
305 return img_base64
306 except Exception as e:
307 print(f"Failed to generate rocket diagram: {e}")
308 return None
309
310
311def extract_flight_series(flight_obj, elevation: float = 0.0):
312 """Extract flight time series from RocketPy flight object.
313
314 Returns:
315 Tuple of (time_array, altitude_agl_array, velocity_array)
316 """
317 try:
318 # Get time array
319 t_final = flight_obj.t_final if hasattr(flight_obj, 't_final') else 100.0
320 time_array = np.linspace(0, t_final, 500)
321
322 # Get altitude (z coordinate) - subtract elevation for AGL
323 if hasattr(flight_obj, 'z'):
324 altitude_array = np.array([float(flight_obj.z(t)) - elevation for t in time_array])
325 else:
326 altitude_array = np.zeros_like(time_array)
327
328 # Get vertical velocity (vz)
329 if hasattr(flight_obj, 'vz'):
330 velocity_array = np.array([float(flight_obj.vz(t)) for t in time_array])
331 else:
332 velocity_array = np.zeros_like(time_array)
333
334 return time_array, altitude_array, velocity_array
335 except Exception:
336 return np.array([]), np.array([]), np.array([])
337
338
339def build_flight_config(base_config, request: FlightSimRequest):
340 """Build a flight-ready config by merging base config with request overrides."""
341 # Deep copy the base config
342 config_dict = copy.deepcopy(base_config.model_dump())
343
344 # Update LOX tank
345 if config_dict.get("lox_tank") is None:
346 config_dict["lox_tank"] = {}
347 config_dict["lox_tank"]["mass"] = request.lox_mass_kg
348
349 if request.lox_tank:
350 config_dict["lox_tank"]["lox_h"] = request.lox_tank.height
351 config_dict["lox_tank"]["lox_radius"] = request.lox_tank.radius
352 config_dict["lox_tank"]["ox_tank_pos"] = request.lox_tank.position
353 if request.lox_tank.volume_m3 is not None:
354 config_dict["lox_tank"]["tank_volume_m3"] = request.lox_tank.volume_m3
355
356 # Update fuel tank
357 if config_dict.get("fuel_tank") is None:
358 config_dict["fuel_tank"] = {}
359 config_dict["fuel_tank"]["mass"] = request.fuel_mass_kg
360
361 if request.fuel_tank:
362 config_dict["fuel_tank"]["rp1_h"] = request.fuel_tank.height
363 config_dict["fuel_tank"]["rp1_radius"] = request.fuel_tank.radius
364 config_dict["fuel_tank"]["fuel_tank_pos"] = request.fuel_tank.position
365 if request.fuel_tank.volume_m3 is not None:
366 config_dict["fuel_tank"]["tank_volume_m3"] = request.fuel_tank.volume_m3
367
368 # Update environment
369 if request.environment:
370 if config_dict.get("environment") is None:
371 config_dict["environment"] = {}
372 config_dict["environment"]["latitude"] = request.environment.latitude
373 config_dict["environment"]["longitude"] = request.environment.longitude
374 config_dict["environment"]["elevation"] = request.environment.elevation
375 config_dict["environment"]["date"] = request.environment.date
376 config_dict["environment"]["atmosphere_model"] = request.environment.atmosphere_model
377 for k in ("rail_length_m", "launch_inclination_deg", "launch_heading_deg"):
378 if getattr(request.environment, k) is not None:
379 config_dict["environment"][k] = getattr(request.environment, k)
380 elif config_dict.get("environment") is None:
381 # No launch site anywhere: the request model's defaults, stated once, up in EnvironmentConfig.
382 config_dict["environment"] = EnvironmentConfig().model_dump()
383
384 # Update rocket
385 if request.rocket:
386 if config_dict.get("rocket") is None:
387 config_dict["rocket"] = {}
388 r = config_dict["rocket"]
389 # Only what the request actually sends. setup_flight flies the component masses whenever
390 # engine_mass is set, so writing just their sum into propulsion_dry_mass (as this did) left
391 # every engine or tank mass edit on the Flight tab without effect; and a field the request
392 # omits must not replace the config's value with this model's default.
393 sent = request.rocket.model_fields_set
394 for k in ("airframe_mass", "engine_mass", "lox_tank_structure_mass", "fuel_tank_structure_mass",
395 "copv_dry_mass", "radius", "motor_position", "inertia", "nose_kind", "nose_fineness_ratio",
396 "nose_length", "avionics_payload_length_m", "surface_roughness_m", "fin_thickness_m",
397 "fin_profile"):
398 v = getattr(request.rocket, k)
399 if k in sent and v is not None:
400 r[k] = v
401 parts = [r.get(k) for k in ("engine_mass", "lox_tank_structure_mass", "fuel_tank_structure_mass", "copv_dry_mass")]
402 if any(p is not None for p in parts):
403 r["propulsion_dry_mass"] = sum(p or 0.0 for p in parts)
404
405 if request.rocket.fins:
406 config_dict["rocket"]["fins"] = {
407 "no_fins": request.rocket.fins.no_fins,
408 "root_chord": request.rocket.fins.root_chord,
409 "tip_chord": request.rocket.fins.tip_chord,
410 "fin_span": request.rocket.fins.fin_span,
411 "fin_position": request.rocket.fins.fin_position,
412 }
413 elif config_dict.get("rocket") is None:
414 # No vehicle anywhere: derive from the request model's defaults so this block cannot drift
415 # from RocketConfig (the literal copy that used to sit here said propulsion_dry_mass 24 kg
416 # while its own component defaults summed to 16).
417 rd = RocketConfig()
418 config_dict["rocket"] = {
419 "airframe_mass": rd.airframe_mass,
420 "propulsion_dry_mass": rd.engine_mass + rd.lox_tank_structure_mass + rd.fuel_tank_structure_mass,
421 "radius": rd.radius,
422 "motor_position": rd.motor_position,
423 "inertia": list(rd.inertia),
424 "nose_kind": rd.nose_kind,
425 "nose_fineness_ratio": rd.nose_fineness_ratio,
426 "avionics_payload_length_m": rd.avionics_payload_length_m,
427 "fins": FinsConfig().model_dump(),
428 }
429
430 return config_dict
431
432
433def _apply_propellant_mass_caps(config_dict: dict, base_config) -> tuple[dict, dict, float | None, float | None, float]:
434 """Cap LOX/fuel masses to tank capacity. Returns (mass_adjustments, lox_max, fuel_max, fill_factor)."""
435 from engine.pipeline.config_schemas import PintleEngineConfig
437 resolve_fuel_tank_limits,
438 resolve_lox_tank_limits,
439 resolve_propellant_tank_fill_factor,
440 )
441
442 ox_density, fuel_density = _propellant_densities(base_config)
443 fill_factor = resolve_propellant_tank_fill_factor(base_config)
444
445 try:
446 cap_config = PintleEngineConfig(**config_dict)
447 except Exception:
448 return {}, None, None, fill_factor
449
450 mass_adjustments: dict = {}
451 lox_tank_max = None
452 fuel_tank_max = None
453
454 if cap_config.lox_tank is not None:
455 lox_max, lox_vol, lox_ff, lox_explicit = resolve_lox_tank_limits(cap_config, ox_density)
456 lox_tank_max = lox_max
457 fill_factor = lox_ff
458 current_lox = float(config_dict.get("lox_tank", {}).get("mass", 0) or 0)
459 effective = min(current_lox, lox_max)
460 if current_lox > lox_max:
461 config_dict["lox_tank"]["mass"] = lox_max
462 cap_note = "explicit capacity" if lox_explicit else f"{lox_ff * 100:.0f}% fill"
463 print(f"[Flight] Capped LOX mass: {current_lox:.2f} -> {lox_max:.2f} kg ({cap_note}, vol {lox_vol * 1000:.1f}L)")
464 mass_adjustments["lox"] = {
465 "original": current_lox,
466 "capped": effective,
467 "max_fill_kg": lox_max,
468 "tank_volume_m3": lox_vol,
469 "fill_factor": lox_ff,
470 "was_capped": current_lox > lox_max + 1e-6,
471 "explicit_capacity_kg": lox_max if lox_explicit else None,
472 }
473
474 if cap_config.fuel_tank is not None:
475 fuel_max, fuel_vol, fuel_ff, fuel_explicit = resolve_fuel_tank_limits(cap_config, fuel_density)
476 fuel_tank_max = fuel_max
477 fill_factor = fuel_ff
478 current_fuel = float(config_dict.get("fuel_tank", {}).get("mass", 0) or 0)
479 effective = min(current_fuel, fuel_max)
480 if current_fuel > fuel_max:
481 config_dict["fuel_tank"]["mass"] = fuel_max
482 cap_note = "explicit capacity" if fuel_explicit else f"{fuel_ff * 100:.0f}% fill"
483 print(f"[Flight] Capped Fuel mass: {current_fuel:.2f} -> {fuel_max:.2f} kg ({cap_note}, vol {fuel_vol * 1000:.1f}L)")
484 mass_adjustments["fuel"] = {
485 "original": current_fuel,
486 "capped": effective,
487 "max_fill_kg": fuel_max,
488 "tank_volume_m3": fuel_vol,
489 "fill_factor": fuel_ff,
490 "was_capped": current_fuel > fuel_max + 1e-6,
491 "explicit_capacity_kg": fuel_max if fuel_explicit else None,
492 }
493
494 return mass_adjustments, lox_tank_max, fuel_tank_max, fill_factor
495
496
497def _integrate_series(times: np.ndarray, values: np.ndarray) -> float:
498 if len(times) < 2:
499 return 0.0
500 if hasattr(np, "trapezoid"):
501 return float(np.trapezoid(values, times))
502 return float(np.trapz(values, times))
503
504
506 branch: str,
507 requested_kg: float,
508 effective_kg: float,
509 tank_volume_m3: float,
510 max_fill_kg: float,
511 fill_factor: float,
512) -> MassCapInfo:
513 return MassCapInfo(
514 requested_kg=float(requested_kg),
515 effective_kg=float(effective_kg),
516 max_fill_kg=float(max_fill_kg),
517 fill_factor=fill_factor,
518 tank_volume_m3=float(tank_volume_m3),
519 was_capped=requested_kg > effective_kg + 1e-6,
520 )
521
522
524 *,
525 times: np.ndarray,
526 thrust_array: np.ndarray,
527 mdot_O_array: np.ndarray,
528 mdot_F_array: np.ndarray,
529 lox_requested: float,
530 fuel_requested: float,
531 lox_effective: float,
532 fuel_effective: float,
533 lox_tank_max: Optional[float],
534 fuel_tank_max: Optional[float],
535 truncation: Optional[TruncationInfo],
536 mass_caps: dict,
537 target_apogee_m: Optional[float],
538 propellant_tank_fill_factor: Optional[float] = None,
539) -> PropellantDiagnostics:
540 burn_time = float(times[-1] - times[0]) if len(times) > 1 else 0.0
541 lox_required = _integrate_series(times, mdot_O_array)
542 fuel_required = _integrate_series(times, mdot_F_array)
543 total_impulse = _integrate_series(times, thrust_array)
544
545 effective_burn = burn_time
546 truncated = bool(truncation and truncation.truncated)
547 if truncated and truncation.cutoff_time is not None:
548 effective_burn = float(truncation.cutoff_time)
549 # What was burned: to the cutoff itself, the last partial interval included
550 keep = times < effective_burn
551 t_eff = np.concatenate([times[keep], [effective_burn]])
552
553 def upto(values: np.ndarray) -> np.ndarray:
554 return np.concatenate([values[keep], [np.interp(effective_burn, times, values)]])
555
556 lox_required = _integrate_series(t_eff, upto(mdot_O_array))
557 fuel_required = _integrate_series(t_eff, upto(mdot_F_array))
558 total_impulse = _integrate_series(t_eff, upto(thrust_array))
559
560 warnings: List[str] = []
561 for branch, cap in mass_caps.items():
562 if cap.get("was_capped") or cap.get("original", cap.get("requested_kg", 0)) > cap.get("capped", cap.get("effective_kg", 0)) + 1e-6:
563 req = cap.get("original", cap.get("requested_kg"))
564 eff = cap.get("capped", cap.get("effective_kg"))
565 mx = cap.get("max_fill_kg", cap.get("capped"))
566 ff = cap.get("fill_factor", propellant_tank_fill_factor or 0.90)
567 if cap.get("explicit_capacity_kg"):
568 warnings.append(
569 f"{branch.upper()} mass capped: requested {req:.2f} kg → using {eff:.2f} kg "
570 f"(design_requirements capacity {mx:.2f} kg)"
571 )
572 else:
573 warnings.append(
574 f"{branch.upper()} mass capped: requested {req:.2f} kg → using {eff:.2f} kg "
575 f"(tank max {mx:.2f} kg at {ff * 100:.0f}% fill)"
576 )
577
578 if fuel_tank_max and fuel_required > fuel_tank_max + 1e-6:
579 warnings.append(
580 f"Fuel tank max fill ({fuel_tank_max:.2f} kg) is below full-burn requirement "
581 f"({fuel_required:.2f} kg) — burn will always truncate on fuel unless you shorten the time-series burn or enlarge the tank."
582 )
583 if lox_tank_max and lox_required > lox_tank_max + 1e-6:
584 warnings.append(
585 f"LOX tank max fill ({lox_tank_max:.2f} kg) is below full-burn requirement "
586 f"({lox_required:.2f} kg) — burn will always truncate on LOX unless you shorten the time-series burn or enlarge the tank."
587 )
588
589 if truncated:
590 regime = "truncated"
591 warnings.append(
592 f"Burn truncated at {effective_burn:.2f}s (need LOX {lox_required:.2f} kg, "
593 f"fuel {fuel_required:.2f} kg for this burn; loaded {lox_effective:.2f}/{fuel_effective:.2f} kg)"
594 )
595 elif lox_effective >= lox_required * 0.995 and fuel_effective >= fuel_required * 0.995:
596 if lox_effective > lox_required * 1.02 or fuel_effective > fuel_required * 1.02:
597 regime = "excess_propellant"
598 warnings.append(
599 "Excess propellant loaded: burn uses full time-series curve but extra mass lowers apogee. "
600 "Trim toward required amounts to optimize altitude."
601 )
602 else:
603 regime = "full_burn"
604 else:
605 regime = "truncated"
606
607 cap_models = {}
608 for branch, raw in mass_caps.items():
609 if isinstance(raw, MassCapInfo):
610 cap_models[branch] = raw
611 else:
612 cap_models[branch] = _build_mass_cap_info(
613 branch,
614 raw.get("original", raw.get("requested_kg", 0)),
615 raw.get("capped", raw.get("effective_kg", 0)),
616 raw.get("tank_volume_m3", 0),
617 raw.get("max_fill_kg", raw.get("capped", 0)),
618 raw.get("fill_factor", propellant_tank_fill_factor or 0.90),
619 )
620
622 regime=regime,
623 timeseries_burn_time_s=burn_time,
624 effective_burn_time_s=effective_burn,
625 total_impulse_Ns=total_impulse,
626 lox_required_kg=lox_required,
627 fuel_required_kg=fuel_required,
628 lox_requested_kg=lox_requested,
629 fuel_requested_kg=fuel_requested,
630 lox_effective_kg=lox_effective,
631 fuel_effective_kg=fuel_effective,
632 lox_tank_max_kg=lox_tank_max,
633 fuel_tank_max_kg=fuel_tank_max,
634 target_apogee_m=target_apogee_m,
635 mass_caps=cap_models or None,
636 warnings=warnings,
637 propellant_tank_fill_factor=propellant_tank_fill_factor,
638 )
639
640
641def _thrust_reference_pressure(base_config, request: FlightSimRequest) -> float:
642 """The ambient pressure the thrust curve was computed at [Pa].
643
644 The time series ran on the SESSION config, so its elevation (not an elevation the Flight tab
645 overrides afterwards) sets the reference; the request can state it outright.
646 """
647 if request.reference_pressure_pa is not None:
648 return float(request.reference_pressure_pa)
649 thrust = getattr(base_config, "thrust", None)
650 if thrust is not None and getattr(thrust, "reference_pressure_pa", None) is not None:
651 return float(thrust.reference_pressure_pa)
652 from engine.core.runner import compute_ambient_pressure_from_elevation
653
654 env = getattr(base_config, "environment", None)
655 elevation = float(env.elevation) if env is not None else EnvironmentConfig().elevation
656 return float(compute_ambient_pressure_from_elevation(elevation))
657
658
659def ceiling_agl_m(design_requirements, elevation_m: float) -> Optional[float]:
660 """design_requirements.max_apogee_m as metres above the pad, or None when no ceiling is set."""
661 if design_requirements is None:
662 return None
663 ceiling = design_requirements.get("max_apogee_m") if isinstance(design_requirements, dict) else getattr(design_requirements, "max_apogee_m", None)
664 if ceiling is None:
665 return None
666 datum = design_requirements.get("max_apogee_datum") if isinstance(design_requirements, dict) else getattr(design_requirements, "max_apogee_datum", None)
667 if datum not in ("AGL", "MSL"):
668 raise ValueError("design_requirements.max_apogee_m needs max_apogee_datum 'AGL' or 'MSL'")
669 return float(ceiling) - (float(elevation_m) if datum == "MSL" else 0.0)
670
671
672def _ceiling_check(flight_config, pressure_curves, burn_time: float, nominal_apogee_agl: float) -> Optional[dict]:
673 """Apogee against the ceiling at its high-apogee corner.
674
675 The corner is the most vertical, lowest-drag flight the config admits: the rail at 90 deg, the
676 windless ISA, and -- when drag is the build-up -- a hydraulically smooth skin (roughness 0), the
677 floor of turbulent friction. A user's drag table has no band and is flown as given.
678 """
679 from engine.optimizer.copv_flight_helpers import run_flight_simulation
680
681 dr = flight_config.design_requirements
682 elevation = float(flight_config.environment.elevation)
683 ceiling = ceiling_agl_m(dr, elevation)
684 if ceiling is None:
685 return None
686 buildup = getattr(flight_config.rocket, "drag_curve_power_off", None) is None
687 corner = []
688 if float(flight_config.environment.launch_inclination_deg) < 90.0:
689 corner.append("inclination 90 deg")
690 if buildup and float(flight_config.rocket.surface_roughness_m) > 0.0:
691 corner.append("surface_roughness_m 0")
692 if str(getattr(flight_config.environment, "atmosphere_model", "standard_atmosphere")) != "standard_atmosphere":
693 corner.append("standard atmosphere")
694 corner_apogee = nominal_apogee_agl
695 if corner:
696 cfg = copy.deepcopy(flight_config)
697 cfg.environment.launch_inclination_deg = 90.0
698 cfg.environment.atmosphere_model = "standard_atmosphere"
699 if buildup:
700 cfg.rocket.surface_roughness_m = 0.0
701 res = run_flight_simulation(cfg, pressure_curves, burn_time)
702 if not res.get("success", False):
703 raise ValueError(f"Ceiling corner flight failed: {res.get('error')}")
704 corner_apogee = float(res["apogee"])
705 out = {
706 "max_apogee_m": float(dr.max_apogee_m),
707 "datum": dr.max_apogee_datum,
708 "ceiling_agl_m": ceiling,
709 "nominal_apogee_agl_m": float(nominal_apogee_agl),
710 "nominal_margin_m": ceiling - float(nominal_apogee_agl),
711 "corner_apogee_agl_m": corner_apogee,
712 "corner_margin_m": ceiling - corner_apogee,
713 "corner": ", ".join(corner) if corner else "the nominal flight",
714 "violated": corner_apogee > ceiling,
715 }
716 return out
717
718
720 base_config,
721 request: FlightSimRequest,
722 *,
723 time_array: Optional[np.ndarray] = None,
724 thrust_array: Optional[np.ndarray] = None,
725 mdot_O_array: Optional[np.ndarray] = None,
726 mdot_F_array: Optional[np.ndarray] = None,
727 lox_mass_kg: Optional[float] = None,
728 fuel_mass_kg: Optional[float] = None,
729 check_ceiling: bool = True,
730) -> FlightSimResponse:
731 """Run one flight simulation (shared by /simulate and /optimize-altitude)."""
732 from engine.optimizer.copv_flight_helpers import run_flight_simulation
733 from engine.pipeline.config_schemas import PintleEngineConfig
734
735 sim_request = request
736 if any(v is not None for v in (time_array, thrust_array, mdot_O_array, mdot_F_array, lox_mass_kg, fuel_mass_kg)):
737 overrides = request.model_dump()
738 if time_array is not None:
739 overrides["time_array"] = time_array.tolist()
740 if thrust_array is not None:
741 overrides["thrust_array"] = thrust_array.tolist()
742 if mdot_O_array is not None:
743 overrides["mdot_O_array"] = mdot_O_array.tolist()
744 if mdot_F_array is not None:
745 overrides["mdot_F_array"] = mdot_F_array.tolist()
746 if lox_mass_kg is not None:
747 overrides["lox_mass_kg"] = float(lox_mass_kg)
748 if fuel_mass_kg is not None:
749 overrides["fuel_mass_kg"] = float(fuel_mass_kg)
750 sim_request = FlightSimRequest(**overrides)
751
752 config_dict = build_flight_config(base_config, sim_request)
753 mass_adjustments, lox_tank_max, fuel_tank_max, fill_factor = _apply_propellant_mass_caps(
754 config_dict, base_config
755 )
756
757 times = np.array(sim_request.time_array)
758 thrust_array = np.array(sim_request.thrust_array)
759 mdot_O_array = np.array(sim_request.mdot_O_array)
760 mdot_F_array = np.array(sim_request.mdot_F_array)
761
762 times = times - times[0]
763 burn_time = float(times[-1])
764
765 if config_dict.get("thrust") is None:
766 config_dict["thrust"] = {}
767 config_dict["thrust"]["burn_time"] = burn_time
768 config_dict["thrust"]["reference_pressure_pa"] = _thrust_reference_pressure(base_config, sim_request)
769
770 pressure_curves = {
771 "time": times,
772 "thrust": thrust_array,
773 "mdot_O": mdot_O_array,
774 "mdot_F": mdot_F_array,
775 }
776
777 try:
778 flight_config = PintleEngineConfig(**config_dict)
779 except Exception as e:
780 return FlightSimResponse(
781 status="error",
782 apogee_m=0,
783 apogee_ft=0,
784 max_velocity_m_s=0,
785 flight_time_s=0,
786 error=f"Invalid flight configuration: {e}",
787 )
788
789 result = run_flight_simulation(flight_config, pressure_curves, burn_time)
790 if not result.get("success", False):
791 return FlightSimResponse(
792 status="error",
793 apogee_m=result.get("apogee", 0),
794 apogee_ft=result.get("apogee", 0) * 3.28084,
795 max_velocity_m_s=result.get("max_velocity", 0),
796 flight_time_s=result.get("flight_time", 0),
797 error=result.get("error", "Flight simulation failed"),
798 )
799
800 apogee = result["apogee"]
801 max_velocity = result["max_velocity"]
802 flight_obj = result.get("flight_obj")
803 elevation = config_dict.get("environment", {}).get("elevation", 0.0)
804
805 flight_time_s = 0.0
806 if flight_obj is not None and hasattr(flight_obj, "t_final"):
807 flight_time_s = float(flight_obj.t_final)
808
809 trajectory = None
810 if flight_obj is not None:
811 flight_time_arr, flight_z, flight_vz = extract_flight_series(flight_obj, elevation)
812 if len(flight_time_arr) > 0:
813 trajectory = FlightTrajectory(
814 time=flight_time_arr.tolist(),
815 altitude=flight_z.tolist(),
816 velocity=flight_vz.tolist(),
817 )
818
819 trunc_info = result.get("truncation_info", {})
820 truncation = None
821 if trunc_info:
822 truncation = TruncationInfo(
823 truncated=trunc_info.get("truncated", False),
824 cutoff_time=trunc_info.get("cutoff_time"),
825 reason=trunc_info.get("reason"),
826 )
827
828 rocket_diagram = generate_rocket_diagram(flight_obj) if flight_obj is not None else None
829 report = getattr(flight_obj, "flight_report", None) if flight_obj is not None else None
830 ceiling = None
831 if check_ceiling:
832 try:
833 ceiling = _ceiling_check(flight_config, pressure_curves, burn_time, float(apogee))
834 except ValueError as e:
835 ceiling = {"error": str(e)}
836
837 lox_requested = float(sim_request.lox_mass_kg)
838 fuel_requested = float(sim_request.fuel_mass_kg)
839 lox_effective = float(config_dict.get("lox_tank", {}).get("mass", lox_requested))
840 fuel_effective = float(config_dict.get("fuel_tank", {}).get("mass", fuel_requested))
841
842 target_apogee_m = None
843 dr = config_dict.get("design_requirements") or {}
844 if isinstance(dr, dict) and dr.get("target_apogee") is not None:
845 target_apogee_m = float(dr["target_apogee"])
846
847 propellant_diag = _compute_propellant_diagnostics(
848 times=times,
849 thrust_array=thrust_array,
850 mdot_O_array=mdot_O_array,
851 mdot_F_array=mdot_F_array,
852 lox_requested=lox_requested,
853 fuel_requested=fuel_requested,
854 lox_effective=lox_effective,
855 fuel_effective=fuel_effective,
856 lox_tank_max=lox_tank_max,
857 fuel_tank_max=fuel_tank_max,
858 truncation=truncation,
859 mass_caps=mass_adjustments,
860 target_apogee_m=target_apogee_m,
861 propellant_tank_fill_factor=fill_factor,
862 )
863 if report:
864 propellant_diag.warnings.extend(report.get("warnings", []))
865 if ceiling and ceiling.get("violated"):
866 propellant_diag.warnings.append(
867 f"Apogee ceiling exceeded: {ceiling['corner_apogee_agl_m']:.0f} m AGL at the high-apogee corner "
868 f"({ceiling['corner']}) against {ceiling['ceiling_agl_m']:.0f} m AGL "
869 f"({ceiling['max_apogee_m']:.0f} m {ceiling['datum']})"
870 )
871 elif ceiling and ceiling.get("error"):
872 propellant_diag.warnings.append(ceiling["error"])
873
874 stab = (report or {}).get("stability", {})
875 return FlightSimResponse(
876 status="success",
877 apogee_m=apogee,
878 apogee_ft=apogee * 3.28084,
879 max_velocity_m_s=max_velocity,
880 flight_time_s=flight_time_s,
881 trajectory=trajectory,
882 truncation=truncation,
883 propellant=propellant_diag,
884 thrust_curve={
885 "time": times.tolist(),
886 "thrust_N": thrust_array.tolist(),
887 },
888 rocket_diagram=rocket_diagram,
889 apogee_msl_m=(report or {}).get("apogee_msl_m"),
890 max_mach=(report or {}).get("max_mach"),
891 rail_exit_velocity_m_s=(report or {}).get("launch", {}).get("rail_exit_velocity_m_s"),
892 static_margin_rail_exit_cal=stab.get("static_margin_rail_exit_cal"),
893 static_margin_burnout_cal=stab.get("static_margin_burnout_cal"),
894 ceiling=ceiling,
895 report=convert_numpy(report) if report else None,
896 )
897
898
899# ============================================================================
900# Endpoints
901# ============================================================================
902
903@router.post("/simulate", response_model=FlightSimResponse)
904async def simulate_flight(request: FlightSimRequest, session: UserSession = Depends(get_session)):
905 """Run flight simulation using time-series data.
906
907 Uses provided thrust/mdot arrays from time-series analysis.
908 Returns apogee, max velocity, and flight trajectory.
909
910 In the thread pool: a RocketPy flight is about a second of CPU, and inline on the event loop
911 it froze every other request (/api/health included) for as long. The config is read once.
912 """
913 config, _ = session.app_state.snapshot()
914 if config is None:
915 raise HTTPException(
916 status_code=400,
917 detail="No config loaded. Upload a config file first.",
918 )
919 return await run_in_threadpool(_simulate_flight, request, config)
920
921
922def _simulate_flight(request: FlightSimRequest, config) -> FlightSimResponse:
923
924 try:
925 try:
926 from engine.optimizer.copv_flight_helpers import run_flight_simulation # noqa: F401
927 except ImportError as e:
928 raise HTTPException(
929 status_code=500,
930 detail=f"RocketPy or flight simulation module not available: {e}",
931 )
932
933 return _execute_flight_simulation(config, request)
934
935 except HTTPException:
936 raise
937 except Exception as e:
938 raise HTTPException(
939 status_code=500,
940 detail=f"Flight simulation failed: {str(e)}",
941 )
942
943
944@router.post("/optimize-altitude", response_model=FlightOptimizeResponse)
945async def optimize_flight_altitude(request: FlightOptimizeRequest, session: UserSession = Depends(get_session)):
946 """Find minimum-fuel burn time to reach a target apogee for the loaded time-series curve.
947
948 In the thread pool: the search flies a dozen RocketPy flights (~7 s on the LE4 stand), and
949 inline on the event loop /api/health went unanswered for all of it. Every flight of the
950 search uses the one config read here.
951 """
952 config, _ = session.app_state.snapshot()
953 if config is None:
954 raise HTTPException(
955 status_code=400,
956 detail="No config loaded. Upload a config file first.",
957 )
958 return await run_in_threadpool(_optimize_flight_altitude, request, config)
959
960
961def _optimize_flight_altitude(request: FlightOptimizeRequest, config) -> FlightOptimizeResponse:
962
963 try:
964 try:
965 from engine.optimizer.copv_flight_helpers import run_flight_simulation # noqa: F401
966 except ImportError as e:
967 raise HTTPException(
968 status_code=500,
969 detail=f"RocketPy or flight simulation module not available: {e}",
970 )
971
973 BurnTimeEval,
974 optimize_minimum_fuel_burn_time,
975 required_propellant_at_burn_time,
976 truncate_time_series,
977 )
978
979 times = np.array(request.time_array, dtype=float)
980 thrust = np.array(request.thrust_array, dtype=float)
981 mdot_O = np.array(request.mdot_O_array, dtype=float)
982 mdot_F = np.array(request.mdot_F_array, dtype=float)
983
984 def simulate_at_burn_time(burn_time_s: float) -> BurnTimeEval:
985 lox_req, fuel_req, _ = required_propellant_at_burn_time(
986 times, thrust, mdot_O, mdot_F, burn_time_s
987 )
988 t_out, thrust_out, mdot_O_out, mdot_F_out = truncate_time_series(
989 times, thrust, mdot_O, mdot_F, burn_time_s
990 )
991 flight_result = _execute_flight_simulation(
992 config,
993 request,
994 time_array=t_out,
995 thrust_array=thrust_out,
996 mdot_O_array=mdot_O_out,
997 mdot_F_array=mdot_F_out,
998 lox_mass_kg=lox_req,
999 fuel_mass_kg=fuel_req,
1000 check_ceiling=False,
1001 )
1002 if flight_result.status != "success":
1003 return BurnTimeEval(
1004 burn_time_s=burn_time_s,
1005 lox_required_kg=lox_req,
1006 fuel_required_kg=fuel_req,
1007 apogee_m=flight_result.apogee_m,
1008 success=False,
1009 error=flight_result.error,
1010 )
1011 return BurnTimeEval(
1012 burn_time_s=burn_time_s,
1013 lox_required_kg=lox_req,
1014 fuel_required_kg=fuel_req,
1015 apogee_m=flight_result.apogee_m,
1016 success=True,
1017 )
1018
1019 # A target above the ceiling has no admissible answer; say so before searching.
1020 base_cfg = config
1021 pad = float(request.environment.elevation) if request.environment else float(base_cfg.environment.elevation)
1022 ceiling = ceiling_agl_m(getattr(base_cfg, "design_requirements", None), pad)
1023 if ceiling is not None and float(request.target_apogee_m) - float(request.apogee_tolerance_m) > ceiling:
1025 status="infeasible",
1026 success=False,
1027 target_apogee_m=float(request.target_apogee_m),
1028 apogee_tolerance_m=float(request.apogee_tolerance_m),
1029 optimal_burn_time_s=0.0,
1030 optimal_lox_kg=0.0,
1031 optimal_fuel_kg=0.0,
1032 achieved_apogee_m=0.0,
1033 apogee_error_m=0.0,
1034 total_impulse_Ns=0.0,
1035 simulations_run=0,
1036 infeasible_reason=(
1037 f"Target {float(request.target_apogee_m):.0f} m AGL (less {float(request.apogee_tolerance_m):.0f} m tolerance) "
1038 f"is above the apogee ceiling, {ceiling:.0f} m AGL"
1039 ),
1040 )
1041
1042 opt = optimize_minimum_fuel_burn_time(
1043 times=times,
1044 thrust=thrust,
1045 mdot_O=mdot_O,
1046 mdot_F=mdot_F,
1047 target_apogee_m=float(request.target_apogee_m),
1048 apogee_tolerance_m=float(request.apogee_tolerance_m),
1049 simulate_at_burn_time=simulate_at_burn_time,
1050 min_burn_time_s=request.min_burn_time_s,
1051 max_burn_time_s=request.max_burn_time_s,
1052 )
1053
1054 flight_at_optimum = None
1055 if opt.success:
1056 lox_req, fuel_req, _ = required_propellant_at_burn_time(
1057 times, thrust, mdot_O, mdot_F, opt.optimal_burn_time_s
1058 )
1059 t_out, thrust_out, mdot_O_out, mdot_F_out = truncate_time_series(
1060 times, thrust, mdot_O, mdot_F, opt.optimal_burn_time_s
1061 )
1062 flight_at_optimum = _execute_flight_simulation(
1063 config,
1064 request,
1065 time_array=t_out,
1066 thrust_array=thrust_out,
1067 mdot_O_array=mdot_O_out,
1068 mdot_F_array=mdot_F_out,
1069 lox_mass_kg=lox_req,
1070 fuel_mass_kg=fuel_req,
1071 )
1072
1073 success, reason = opt.success, opt.infeasible_reason
1074 if success and flight_at_optimum is not None and (flight_at_optimum.ceiling or {}).get("violated"):
1075 # The search returns the lowest apogee that reaches the target, so nothing admissible is lower.
1076 c = flight_at_optimum.ceiling
1077 success = False
1078 reason = (
1079 f"The shortest burn reaching the target flies {c['corner_apogee_agl_m']:.0f} m AGL at the "
1080 f"high-apogee corner ({c['corner']}), above the {c['ceiling_agl_m']:.0f} m AGL ceiling"
1081 )
1082
1084 status="success" if success else "infeasible",
1085 success=success,
1086 target_apogee_m=opt.target_apogee_m,
1087 apogee_tolerance_m=opt.apogee_tolerance_m,
1088 optimal_burn_time_s=opt.optimal_burn_time_s,
1089 optimal_lox_kg=opt.optimal_lox_kg,
1090 optimal_fuel_kg=opt.optimal_fuel_kg,
1091 achieved_apogee_m=opt.achieved_apogee_m,
1092 apogee_error_m=opt.apogee_error_m,
1093 total_impulse_Ns=opt.total_impulse_Ns,
1094 simulations_run=opt.simulations_run,
1095 infeasible_reason=reason,
1096 flight=flight_at_optimum,
1097 )
1098
1099 except HTTPException:
1100 raise
1101 except Exception as e:
1102 raise HTTPException(
1103 status_code=500,
1104 detail=f"Altitude optimization failed: {str(e)}",
1105 )
1106
1107
1108@router.get("/check")
1109async def check_rocketpy():
1110 """Check if RocketPy is available for flight simulation."""
1111 try:
1112 from rocketpy import Environment, Rocket, Flight
1113 return {
1114 "available": True,
1115 "message": "RocketPy is installed and available",
1116 }
1117 except ImportError as e:
1118 return {
1119 "available": False,
1120 "message": f"RocketPy not installed: {e}",
1121 "install_hint": "pip install rocketpy",
1122 }
1123
#define min(a, b)
Definition Arduino.h:42
float _thrust_reference_pressure(base_config, FlightSimRequest request)
Definition flight.py:641
FlightOptimizeResponse _optimize_flight_altitude(FlightOptimizeRequest request, config)
Definition flight.py:961
Optional[float] ceiling_agl_m(design_requirements, float elevation_m)
Definition flight.py:659
float calculate_tank_capacity(float height, float radius, float density, float fill_factor=0.95)
Definition flight.py:43
PropellantDiagnostics _compute_propellant_diagnostics(*, np.ndarray times, np.ndarray thrust_array, np.ndarray mdot_O_array, np.ndarray mdot_F_array, float lox_requested, float fuel_requested, float lox_effective, float fuel_effective, Optional[float] lox_tank_max, Optional[float] fuel_tank_max, Optional[TruncationInfo] truncation, dict mass_caps, Optional[float] target_apogee_m, Optional[float] propellant_tank_fill_factor=None)
Definition flight.py:539
simulate_flight(FlightSimRequest request, UserSession session=Depends(get_session))
Definition flight.py:904
FlightSimResponse _simulate_flight(FlightSimRequest request, config)
Definition flight.py:922
Optional[dict] _ceiling_check(flight_config, pressure_curves, float burn_time, float nominal_apogee_agl)
Definition flight.py:672
optimize_flight_altitude(FlightOptimizeRequest request, UserSession session=Depends(get_session))
Definition flight.py:945
tuple[dict, dict, float|None, float|None, float] _apply_propellant_mass_caps(dict config_dict, base_config)
Definition flight.py:433
tuple _propellant_densities(config)
Definition flight.py:28
build_flight_config(base_config, FlightSimRequest request)
Definition flight.py:339
float _integrate_series(np.ndarray times, np.ndarray values)
Definition flight.py:497
extract_flight_series(flight_obj, float elevation=0.0)
Definition flight.py:311
Optional[str] generate_rocket_diagram(flight_obj)
Definition flight.py:270
FlightSimResponse _execute_flight_simulation(base_config, FlightSimRequest request, *, Optional[np.ndarray] time_array=None, Optional[np.ndarray] thrust_array=None, Optional[np.ndarray] mdot_O_array=None, Optional[np.ndarray] mdot_F_array=None, Optional[float] lox_mass_kg=None, Optional[float] fuel_mass_kg=None, bool check_ceiling=True)
Definition flight.py:730
MassCapInfo _build_mass_cap_info(str branch, float requested_kg, float effective_kg, float tank_volume_m3, float max_fill_kg, float fill_factor)
Definition flight.py:512
static void decode(const disk_t *d)