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,
38 update_progress: Callable,
40 run_flight_simulation_func: Callable,
43 Run Layer 4: Flight Simulation with tank-fill iteration.
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
50 result: Dict[str, Any] = {
55 "flight_candidate_valid":
False,
58 "best_iteration":
None,
59 "best_apogee_error": float(
"inf"),
61 "actual_burn_time":
None,
62 "truncation_info": {},
67 config_for_flight = copy.deepcopy(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))
74 if initial_lox_mass <= 0.0
or initial_fuel_mass <= 0.0:
76 "Layer 4: Flight Candidate",
78 "Skipping flight sim: tank masses not configured",
83 current_lox_mass = initial_lox_mass
84 current_fuel_mass = initial_fuel_mass
87 mass_adjustment_step = 0.05
89 best_error = float(
"inf")
90 best_result: Optional[Dict[str, Any]] =
None
92 best_iteration_idx: Optional[int] =
None
96 lox_mass_above = initial_lox_mass
98 fuel_mass_above = initial_fuel_mass
99 fuel_mass_below =
None
101 last_direction =
None
103 for i
in range(1, max_iterations + 1):
104 progress = 0.75 + 0.10 * (i / max_iterations)
107 config_for_flight.lox_tank.mass = current_lox_mass
108 config_for_flight.fuel_tank.mass = current_fuel_mass
111 "Layer 4: Flight Candidate",
113 f
"Iteration {i}: LOX={current_lox_mass:.2f} kg, Fuel={current_fuel_mass:.2f} kg "
114 f
"(target apogee {target_apogee:.0f} m)",
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")
124 if time_array_local
is not None and hasattr(time_array_local,
'__call__'):
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)
131 time_array_local = np.asarray(time_array, dtype=float)
133 if mdot_O_raw
is not None and hasattr(mdot_O_raw,
'__call__'):
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)))
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)
143 mdot_O_array = np.array([], dtype=float)
145 if mdot_F_raw
is not None and hasattr(mdot_F_raw,
'__call__'):
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)))
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)
155 mdot_F_array = np.array([], dtype=float)
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))
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]
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)
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)
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
190 if earliest_cutoff
is not None and earliest_cutoff < target_burn_time:
193 safety_margin =
max(target_burn_time * 0.001, 0.01)
194 truncated_burn_time =
max(0.1, earliest_cutoff - safety_margin)
202 config_for_flight.thrust.burn_time = target_burn_time
204 sim = run_flight_simulation_func(
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)
216 truncation_info = sim.get(
"truncation_info", {})
217 if truncation_info.get(
"truncated",
False)
and "cutoff_time" in truncation_info:
219 actual_burn_time = float(truncation_info[
"cutoff_time"])
222 actual_burn_time = float(sim.get(
"flight_time", truncated_burn_time)
or truncated_burn_time)
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])
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))
257 iteration_data.append(
260 "burn_time": actual_burn_time,
262 "apogee_error_pct": 100.0,
263 "max_velocity": max_velocity,
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",
""),
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))
279 if target_apogee > 0.0:
280 error_frac = abs(apogee - target_apogee) / target_apogee
284 iteration_data.append(
287 "burn_time": actual_burn_time,
289 "apogee_error_pct": error_frac * 100.0,
290 "max_velocity": max_velocity,
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",
""),
303 if error_frac < best_error:
304 best_error = error_frac
305 best_iteration_idx = i
309 "max_velocity": max_velocity,
311 "flight_candidate_valid": error_frac < apogee_tol,
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", {}),
321 if error_frac < apogee_tol:
323 "Layer 4: Flight Candidate",
325 f
"Apogee {apogee:.0f} m within {error_frac * 100:.1f}% of target "
326 f
"{target_apogee:.0f} m (iteration {i}/{max_iterations}).",
331 current_direction =
None
332 if apogee > target_apogee:
334 lox_mass_above = current_lox_mass
335 fuel_mass_above = current_fuel_mass
338 if lox_mass_below
is not None:
340 current_lox_mass = (lox_mass_above + lox_mass_below) / 2.0
341 current_fuel_mass = (fuel_mass_above + fuel_mass_below) / 2.0
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))
347 current_direction =
'down'
350 lox_mass_below = current_lox_mass
351 fuel_mass_below = current_fuel_mass
354 if lox_mass_above
is not None and lox_mass_above > current_lox_mass:
356 current_lox_mass = (lox_mass_above + lox_mass_below) / 2.0
357 current_fuel_mass = (fuel_mass_above + fuel_mass_below) / 2.0
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))
363 current_direction =
'up'
366 if i > 2
and last_direction
is not None:
367 if last_direction != current_direction:
369 mass_adjustment_step *= 0.8
370 mass_adjustment_step =
max(0.01, mass_adjustment_step)
372 last_direction = current_direction
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
383 result[
"success"] =
False
384 result[
"flight_candidate_valid"] =
False
386 except Exception
as exc:
388 "Layer 4: Flight Candidate",
390 f
"Flight sim error: {exc}",
398 "flight_candidate_valid":
False,