STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
layer4_flight_simulation.py
Go to the documentation of this file.
1"""Layer 4: Flight Simulation and Validation
2
3Run flight simulation to validate trajectory performance and adjust tank fills
4(propellant masses) to hit apogee targets.
5
6This layer:
71. Starts from the current optimized engine configuration (Layers 1–3)
82. Runs flight simulation with **full** pressure curves
93. Lets `flight_sim.py` handle truncation when tanks run out
104. Iteratively reduces propellant masses if apogee is too high
115. Accepts the best match if apogee is below target and masses cannot be
12 increased beyond their initial values
13
14We optimize tank fills, not burn time. Burn time naturally follows from
15propellant mass and the thrust / mdot curves.
16"""
17
18from __future__ import annotations
19
20from typing import Dict, Any, Optional, Tuple, Callable
21import numpy as np
22import copy
23
24from engine.pipeline.config_schemas import PintleEngineConfig
25from ui.flight_sim import detect_tank_underfill_time
26from scipy.interpolate import interp1d
27
28
30 optimized_config: PintleEngineConfig,
31 pressure_curves: Dict[str, np.ndarray],
32 time_array: np.ndarray,
33 P_tank_O_array: np.ndarray,
34 P_tank_F_array: np.ndarray,
35 target_burn_time: float,
36 target_apogee: float,
37 apogee_tol: float,
38 update_progress: Callable,
39 log_status: Callable,
40 run_flight_simulation_func: Callable,
41 ) -> Dict[str, Any]:
42 """
43 Run Layer 4: Flight Simulation with tank-fill iteration.
44
45 We:
46 - Keep the full time history of thrust / mdot curves
47 - Let the flight sim detect when tanks run out and truncate internally
48 - Iteratively reduce LOX/fuel masses if apogee is too high
49 """
50 result: Dict[str, Any] = {
51 "success": False,
52 "apogee": 0.0,
53 "max_velocity": 0.0,
54 "layer": 4,
55 "flight_candidate_valid": False,
56 # Optional extras mirrored from the full optimizer / ui_app flight sim
57 "iteration_data": [],
58 "best_iteration": None,
59 "best_apogee_error": float("inf"),
60 "flight_obj": None,
61 "actual_burn_time": None,
62 "truncation_info": {},
63 }
64
65 try:
66 # Copy config so we can safely tweak tank masses
67 config_for_flight = copy.deepcopy(optimized_config)
68
69 # Initial propellant masses from the optimized config
70 initial_lox_mass = float(getattr(config_for_flight.lox_tank, "mass", 0.0))
71 initial_fuel_mass = float(getattr(config_for_flight.fuel_tank, "mass", 0.0))
72
73 # If tanks are not configured, just bail out gracefully
74 if initial_lox_mass <= 0.0 or initial_fuel_mass <= 0.0:
75 update_progress(
76 "Layer 4: Flight Candidate",
77 0.80,
78 "Skipping flight sim: tank masses not configured",
79 )
80 return result
81
82 # Start from full propellant, then adjust bidirectionally to find optimal
83 current_lox_mass = initial_lox_mass
84 current_fuel_mass = initial_fuel_mass
85
86 # Adaptive step size for bidirectional search
87 mass_adjustment_step = 0.05 # 5% adjustment per iteration (can be positive or negative)
88 max_iterations = 20
89 best_error = float("inf")
90 best_result: Optional[Dict[str, Any]] = None
91 iteration_data = []
92 best_iteration_idx: Optional[int] = None
93
94 # Track search direction and bounds for binary search-like behavior
95 # Keep track of masses that give apogee above and below target
96 lox_mass_above = initial_lox_mass # Mass that gives apogee > target
97 lox_mass_below = None # Mass that gives apogee < target
98 fuel_mass_above = initial_fuel_mass
99 fuel_mass_below = None
100 last_apogee = None
101 last_direction = None # 'up' or 'down'
102
103 for i in range(1, max_iterations + 1):
104 progress = 0.75 + 0.10 * (i / max_iterations)
105
106 # Apply current masses
107 config_for_flight.lox_tank.mass = current_lox_mass
108 config_for_flight.fuel_tank.mass = current_fuel_mass
109
110 update_progress(
111 "Layer 4: Flight Candidate",
112 progress,
113 f"Iteration {i}: LOX={current_lox_mass:.2f} kg, Fuel={current_fuel_mass:.2f} kg "
114 f"(target apogee {target_apogee:.0f} m)",
115 )
116
117 # CRITICAL: Detect tank underfill BEFORE running flight sim to prevent errors
118 # Extract mdot arrays from pressure_curves (handle both arrays and interp1d objects)
119 time_array_local = pressure_curves.get("time", time_array)
120 mdot_O_raw = pressure_curves.get("mdot_O")
121 mdot_F_raw = pressure_curves.get("mdot_F")
122
123 # Convert to arrays if needed (handle interp1d objects by sampling)
124 if time_array_local is not None and hasattr(time_array_local, '__call__'):
125 # It's a callable (interp1d), sample it
126 time_samples = np.linspace(0, target_burn_time, max(100, int(target_burn_time * 100)))
127 time_array_local = np.asarray([float(time_array_local(t)) for t in time_samples], dtype=float)
128 elif time_array_local is not None:
129 time_array_local = np.asarray(time_array_local, dtype=float)
130 else:
131 time_array_local = np.asarray(time_array, dtype=float)
132
133 if mdot_O_raw is not None and hasattr(mdot_O_raw, '__call__'):
134 # It's a callable (interp1d), sample it using the same time array
135 if not isinstance(time_array_local, np.ndarray) or len(time_array_local) == 0:
136 time_samples = np.linspace(0, target_burn_time, max(100, int(target_burn_time * 100)))
137 else:
138 time_samples = time_array_local
139 mdot_O_array = np.asarray([float(mdot_O_raw(t)) for t in time_samples], dtype=float)
140 elif mdot_O_raw is not None:
141 mdot_O_array = np.asarray(mdot_O_raw, dtype=float)
142 else:
143 mdot_O_array = np.array([], dtype=float)
144
145 if mdot_F_raw is not None and hasattr(mdot_F_raw, '__call__'):
146 # It's a callable (interp1d), sample it using the same time array
147 if not isinstance(time_array_local, np.ndarray) or len(time_array_local) == 0:
148 time_samples = np.linspace(0, target_burn_time, max(100, int(target_burn_time * 100)))
149 else:
150 time_samples = time_array_local
151 mdot_F_array = np.asarray([float(mdot_F_raw(t)) for t in time_samples], dtype=float)
152 elif mdot_F_raw is not None:
153 mdot_F_array = np.asarray(mdot_F_raw, dtype=float)
154 else:
155 mdot_F_array = np.array([], dtype=float)
156
157 # Ensure arrays are same length
158 if len(time_array_local) > 0 and len(mdot_O_array) > 0 and len(mdot_F_array) > 0:
159 min_len = min(len(time_array_local), len(mdot_O_array), len(mdot_F_array))
160 if min_len > 0:
161 time_array_local = time_array_local[:min_len]
162 mdot_O_array = mdot_O_array[:min_len]
163 mdot_F_array = mdot_F_array[:min_len]
164
165 # Create interpolation functions for underfill detection
166 mdot_O_func = interp1d(time_array_local, mdot_O_array, kind='linear', fill_value=0, bounds_error=False)
167 mdot_F_func = interp1d(time_array_local, mdot_F_array, kind='linear', fill_value=0, bounds_error=False)
168
169 # Detect underfill times
170 lox_cutoff = detect_tank_underfill_time(mdot_O_func, current_lox_mass, target_burn_time)
171 fuel_cutoff = detect_tank_underfill_time(mdot_F_func, current_fuel_mass, target_burn_time)
172 else:
173 lox_cutoff = None
174 fuel_cutoff = None
175 else:
176 lox_cutoff = None
177 fuel_cutoff = None
178
179 # Find earliest cutoff and truncate burn_time slightly (subtract 0.1% or 0.01s, whichever is larger)
180 truncated_burn_time = target_burn_time
181 if lox_cutoff is not None or fuel_cutoff is not None:
182 earliest_cutoff = None
183 if lox_cutoff is not None and fuel_cutoff is not None:
184 earliest_cutoff = min(lox_cutoff, fuel_cutoff)
185 elif lox_cutoff is not None:
186 earliest_cutoff = lox_cutoff
187 elif fuel_cutoff is not None:
188 earliest_cutoff = fuel_cutoff
189
190 if earliest_cutoff is not None and earliest_cutoff < target_burn_time:
191 # Truncate slightly before the cutoff to prevent negative mass errors
192 # Use 0.1% of burn time or 0.01s, whichever is larger, as safety margin
193 safety_margin = max(target_burn_time * 0.001, 0.01)
194 truncated_burn_time = max(0.1, earliest_cutoff - safety_margin)
195
196 # Run flight simulation
197 # CRITICAL: Pass the ORIGINAL target_burn_time, not truncated_burn_time.
198 # This ensures setup_flight uses the full time range for accurate truncation detection.
199 # The pre-truncation check above was just for validation - setup_flight will do
200 # its own truncation detection with the actual masses, which is more accurate.
201 # Setting config burn_time ensures consistency.
202 config_for_flight.thrust.burn_time = target_burn_time
203
204 sim = run_flight_simulation_func(
205 config_for_flight,
206 pressure_curves,
207 target_burn_time, # Use original burn_time - setup_flight will handle truncation
208 )
209
210 success = bool(sim.get("success", False))
211 apogee = float(sim.get("apogee", 0.0) or 0.0)
212 max_velocity = float(sim.get("max_velocity", 0.0) or 0.0)
213 flight_obj = sim.get("flight_obj", None)
214
215 # Get actual burn time from truncation info if available, otherwise use truncated_burn_time
216 truncation_info = sim.get("truncation_info", {})
217 if truncation_info.get("truncated", False) and "cutoff_time" in truncation_info:
218 # Use the actual cutoff time from flight sim (more accurate)
219 actual_burn_time = float(truncation_info["cutoff_time"])
220 else:
221 # Use the truncated burn time we calculated, or fallback to target
222 actual_burn_time = float(sim.get("flight_time", truncated_burn_time) or truncated_burn_time)
223
224 # Basic thrust diagnostics from the pressure_curves input
225 thrust_array = pressure_curves.get("thrust")
226 time_array_local = pressure_curves.get("time")
227 if thrust_array is not None and time_array_local is not None:
228 thrust_array = np.asarray(thrust_array, dtype=float)
229 time_array_local = np.asarray(time_array_local, dtype=float)
230 min_len_local = min(len(thrust_array), len(time_array_local))
231 if min_len_local > 0:
232 thrust_array = thrust_array[:min_len_local]
233 time_array_local = time_array_local[:min_len_local]
234 max_thrust = float(np.max(thrust_array))
235 initial_thrust = float(thrust_array[0])
236 # Guard against zero division if time range is degenerate
237 try:
238 total_impulse = float(np.trapezoid(thrust_array, time_array_local) if hasattr(np, "trapezoid") else np.trapz(thrust_array, time_array_local))
239 avg_thrust = float(total_impulse / max(actual_burn_time, 1e-6))
240 except Exception:
241 total_impulse = 0.0
242 avg_thrust = 0.0
243 else:
244 max_thrust = 0.0
245 initial_thrust = 0.0
246 avg_thrust = 0.0
247 total_impulse = 0.0
248 else:
249 max_thrust = 0.0
250 initial_thrust = 0.0
251 avg_thrust = 0.0
252 total_impulse = 0.0
253
254 if not success:
255 # If sim fails, try again with slightly less propellant; if it keeps failing,
256 # we still keep the best attempt seen so far.
257 iteration_data.append(
258 {
259 "iteration": i,
260 "burn_time": actual_burn_time,
261 "apogee": apogee,
262 "apogee_error_pct": 100.0,
263 "max_velocity": max_velocity,
264 "success": False,
265 "max_thrust": max_thrust,
266 "initial_thrust": initial_thrust,
267 "avg_thrust": avg_thrust,
268 "total_impulse": total_impulse,
269 "adjusted_lox_mass": current_lox_mass,
270 "adjusted_fuel_mass": current_fuel_mass,
271 "error": sim.get("error", ""),
272 }
273 )
274 current_lox_mass = max(0.1, current_lox_mass * (1.0 - mass_adjustment_step))
275 current_fuel_mass = max(0.1, current_fuel_mass * (1.0 - mass_adjustment_step))
276 continue
277
278 # Compute fractional apogee error
279 if target_apogee > 0.0:
280 error_frac = abs(apogee - target_apogee) / target_apogee
281 else:
282 error_frac = 1.0
283
284 iteration_data.append(
285 {
286 "iteration": i,
287 "burn_time": actual_burn_time,
288 "apogee": apogee,
289 "apogee_error_pct": error_frac * 100.0,
290 "max_velocity": max_velocity,
291 "success": True,
292 "max_thrust": max_thrust,
293 "initial_thrust": initial_thrust,
294 "avg_thrust": avg_thrust,
295 "total_impulse": total_impulse,
296 "adjusted_lox_mass": current_lox_mass,
297 "adjusted_fuel_mass": current_fuel_mass,
298 "error": sim.get("error", ""),
299 }
300 )
301
302 # Track the best candidate
303 if error_frac < best_error:
304 best_error = error_frac
305 best_iteration_idx = i
306 best_result = {
307 "success": True,
308 "apogee": apogee,
309 "max_velocity": max_velocity,
310 "layer": 4,
311 "flight_candidate_valid": error_frac < apogee_tol,
312 "iterations": i,
313 "adjusted_lox_mass": current_lox_mass,
314 "adjusted_fuel_mass": current_fuel_mass,
315 "flight_obj": flight_obj,
316 "actual_burn_time": actual_burn_time,
317 "truncation_info": sim.get("truncation_info", {}),
318 }
319
320 # If we're within tolerance, we can stop early (but still track for best result)
321 if error_frac < apogee_tol:
322 update_progress(
323 "Layer 4: Flight Candidate",
324 progress,
325 f"Apogee {apogee:.0f} m within {error_frac * 100:.1f}% of target "
326 f"{target_apogee:.0f} m (iteration {i}/{max_iterations}).",
327 )
328 # Don't break - continue to see if we can find an even better match
329
330 # Bidirectional adjustment: increase or decrease masses based on apogee vs target
331 current_direction = None
332 if apogee > target_apogee:
333 # Apogee too high - reduce masses
334 lox_mass_above = current_lox_mass
335 fuel_mass_above = current_fuel_mass
336
337 # If we have a lower bound, use binary search; otherwise use step reduction
338 if lox_mass_below is not None:
339 # Binary search between above and below bounds
340 current_lox_mass = (lox_mass_above + lox_mass_below) / 2.0
341 current_fuel_mass = (fuel_mass_above + fuel_mass_below) / 2.0
342 else:
343 # No lower bound yet - reduce by step
344 current_lox_mass = max(0.1, current_lox_mass * (1.0 - mass_adjustment_step))
345 current_fuel_mass = max(0.1, current_fuel_mass * (1.0 - mass_adjustment_step))
346
347 current_direction = 'down'
348 else:
349 # Apogee too low - increase masses
350 lox_mass_below = current_lox_mass
351 fuel_mass_below = current_fuel_mass
352
353 # If we have an upper bound, use binary search; otherwise use step increase
354 if lox_mass_above is not None and lox_mass_above > current_lox_mass:
355 # Binary search between above and below bounds
356 current_lox_mass = (lox_mass_above + lox_mass_below) / 2.0
357 current_fuel_mass = (fuel_mass_above + fuel_mass_below) / 2.0
358 else:
359 # No upper bound or already at initial - increase by step (up to initial)
360 current_lox_mass = min(initial_lox_mass, current_lox_mass * (1.0 + mass_adjustment_step))
361 current_fuel_mass = min(initial_fuel_mass, current_fuel_mass * (1.0 + mass_adjustment_step))
362
363 current_direction = 'up'
364
365 # Prevent oscillation: if we're bouncing back and forth, reduce step size
366 if i > 2 and last_direction is not None:
367 if last_direction != current_direction:
368 # We're oscillating - reduce step size
369 mass_adjustment_step *= 0.8
370 mass_adjustment_step = max(0.01, mass_adjustment_step) # Don't go below 1%
371
372 last_direction = current_direction
373 last_apogee = apogee
374
375 # Finalize result from the best candidate, if any
376 result["iteration_data"] = iteration_data
377 if best_result is not None:
378 result.update(best_result)
379 result["best_iteration"] = best_iteration_idx
380 result["best_apogee_error"] = best_error
381 else:
382 # No successful sim; keep default structure but mark failure
383 result["success"] = False
384 result["flight_candidate_valid"] = False
385
386 except Exception as exc:
387 update_progress(
388 "Layer 4: Flight Candidate",
389 0.85,
390 f"Flight sim error: {exc}",
391 )
392 result.update(
393 {
394 "success": False,
395 "error": str(exc),
396 "apogee": 0.0,
397 "max_velocity": 0.0,
398 "flight_candidate_valid": False,
399 }
400 )
401
402 return result
403
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
Dict[str, Any] run_layer4_flight_simulation(PintleEngineConfig optimized_config, Dict[str, np.ndarray] pressure_curves, np.ndarray time_array, np.ndarray P_tank_O_array, np.ndarray P_tank_F_array, float target_burn_time, float target_apogee, float apogee_tol, Callable update_progress, Callable log_status, Callable run_flight_simulation_func)