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)")
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]")
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")
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")
146 """Request body for flight simulation (time-series mode only)."""
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]")
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]")
158 lox_tank: Optional[TankConfig] = Field(default=
None, description=
"LOX tank configuration")
159 fuel_tank: Optional[TankConfig] = Field(default=
None, description=
"Fuel tank configuration")
162 environment: Optional[EnvironmentConfig] = Field(default=
None, description=
"Environment configuration")
165 rocket: Optional[RocketConfig] = Field(default=
None, description=
"Rocket configuration")
167 reference_pressure_pa: Optional[float] = Field(
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.")
220 """Response for flight simulation."""
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")
312 """Extract flight time series from RocketPy flight object.
315 Tuple of (time_array, altitude_agl_array, velocity_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)
323 if hasattr(flight_obj,
'z'):
324 altitude_array = np.array([float(flight_obj.z(t)) - elevation
for t
in time_array])
326 altitude_array = np.zeros_like(time_array)
329 if hasattr(flight_obj,
'vz'):
330 velocity_array = np.array([float(flight_obj.vz(t))
for t
in time_array])
332 velocity_array = np.zeros_like(time_array)
334 return time_array, altitude_array, velocity_array
336 return np.array([]), np.array([]), np.array([])
340 """Build a flight-ready config by merging base config with request overrides."""
342 config_dict = copy.deepcopy(base_config.model_dump())
345 if config_dict.get(
"lox_tank")
is None:
346 config_dict[
"lox_tank"] = {}
347 config_dict[
"lox_tank"][
"mass"] = request.lox_mass_kg
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
357 if config_dict.get(
"fuel_tank")
is None:
358 config_dict[
"fuel_tank"] = {}
359 config_dict[
"fuel_tank"][
"mass"] = request.fuel_mass_kg
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
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:
386 if config_dict.get(
"rocket")
is None:
387 config_dict[
"rocket"] = {}
388 r = config_dict[
"rocket"]
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",
398 v = getattr(request.rocket, k)
399 if k
in sent
and v
is not None:
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)
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,
413 elif config_dict.get(
"rocket")
is None:
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,
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,
434 """Cap LOX/fuel masses to tank capacity. Returns (mass_adjustments, lox_max, fuel_max, fill_factor)."""
437 resolve_fuel_tank_limits,
438 resolve_lox_tank_limits,
439 resolve_propellant_tank_fill_factor,
443 fill_factor = resolve_propellant_tank_fill_factor(base_config)
446 cap_config = PintleEngineConfig(**config_dict)
448 return {},
None,
None, fill_factor
450 mass_adjustments: dict = {}
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
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,
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,
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,
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,
494 return mass_adjustments, lox_tank_max, fuel_tank_max, fill_factor
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],
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
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)
550 keep = times < effective_burn
551 t_eff = np.concatenate([times[keep], [effective_burn]])
553 def upto(values: np.ndarray) -> np.ndarray:
554 return np.concatenate([values[keep], [np.interp(effective_burn, times, values)]])
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"):
569 f
"{branch.upper()} mass capped: requested {req:.2f} kg → using {eff:.2f} kg "
570 f
"(design_requirements capacity {mx:.2f} kg)"
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)"
578 if fuel_tank_max
and fuel_required > fuel_tank_max + 1e-6:
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."
583 if lox_tank_max
and lox_required > lox_tank_max + 1e-6:
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."
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)"
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"
599 "Excess propellant loaded: burn uses full time-series curve but extra mass lowers apogee. "
600 "Trim toward required amounts to optimize altitude."
608 for branch, raw
in mass_caps.items():
609 if isinstance(raw, MassCapInfo):
610 cap_models[branch] = raw
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),
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,
637 propellant_tank_fill_factor=propellant_tank_fill_factor,
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.
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.
681 dr = flight_config.design_requirements
682 elevation = float(flight_config.environment.elevation)
686 buildup = getattr(flight_config.rocket,
"drag_curve_power_off",
None)
is None
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
696 cfg = copy.deepcopy(flight_config)
697 cfg.environment.launch_inclination_deg = 90.0
698 cfg.environment.atmosphere_model =
"standard_atmosphere"
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"])
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,
721 request: FlightSimRequest,
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)."""
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)
754 config_dict, base_config
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)
762 times = times - times[0]
763 burn_time = float(times[-1])
765 if config_dict.get(
"thrust")
is None:
766 config_dict[
"thrust"] = {}
767 config_dict[
"thrust"][
"burn_time"] = burn_time
772 "thrust": thrust_array,
773 "mdot_O": mdot_O_array,
774 "mdot_F": mdot_F_array,
778 flight_config = PintleEngineConfig(**config_dict)
779 except Exception
as e:
786 error=f
"Invalid flight configuration: {e}",
789 result = run_flight_simulation(flight_config, pressure_curves, burn_time)
790 if not result.get(
"success",
False):
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"),
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)
806 if flight_obj
is not None and hasattr(flight_obj,
"t_final"):
807 flight_time_s = float(flight_obj.t_final)
810 if flight_obj
is not None:
812 if len(flight_time_arr) > 0:
814 time=flight_time_arr.tolist(),
815 altitude=flight_z.tolist(),
816 velocity=flight_vz.tolist(),
819 trunc_info = result.get(
"truncation_info", {})
823 truncated=trunc_info.get(
"truncated",
False),
824 cutoff_time=trunc_info.get(
"cutoff_time"),
825 reason=trunc_info.get(
"reason"),
829 report = getattr(flight_obj,
"flight_report",
None)
if flight_obj
is not None else None
833 ceiling =
_ceiling_check(flight_config, pressure_curves, burn_time, float(apogee))
834 except ValueError
as e:
835 ceiling = {
"error": str(e)}
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))
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"])
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,
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']})"
871 elif ceiling
and ceiling.get(
"error"):
872 propellant_diag.warnings.append(ceiling[
"error"])
874 stab = (report
or {}).get(
"stability", {})
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,
885 "time": times.tolist(),
886 "thrust_N": thrust_array.tolist(),
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"),
903@router.post("/simulate", response_model=FlightSimResponse)
966 except ImportError
as e:
969 detail=f
"RocketPy or flight simulation module not available: {e}",
974 optimize_minimum_fuel_burn_time,
975 required_propellant_at_burn_time,
976 truncate_time_series,
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)
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
988 t_out, thrust_out, mdot_O_out, mdot_F_out = truncate_time_series(
989 times, thrust, mdot_O, mdot_F, burn_time_s
995 thrust_array=thrust_out,
996 mdot_O_array=mdot_O_out,
997 mdot_F_array=mdot_F_out,
999 fuel_mass_kg=fuel_req,
1000 check_ceiling=
False,
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,
1009 error=flight_result.error,
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,
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",
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,
1031 optimal_fuel_kg=0.0,
1032 achieved_apogee_m=0.0,
1034 total_impulse_Ns=0.0,
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"
1042 opt = optimize_minimum_fuel_burn_time(
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,
1054 flight_at_optimum =
None
1056 lox_req, fuel_req, _ = required_propellant_at_burn_time(
1057 times, thrust, mdot_O, mdot_F, opt.optimal_burn_time_s
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
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,
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"):
1076 c = flight_at_optimum.ceiling
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"
1084 status=
"success" if success
else "infeasible",
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,
1099 except HTTPException:
1101 except Exception
as e:
1102 raise HTTPException(
1104 detail=f
"Altitude optimization failed: {str(e)}",
1108@router.get("/check")
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)