STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
layer2_pressure.py
Go to the documentation of this file.
1"""Layer 2: Pressure Curve Optimization
2
3This layer optimizes fuel and oxidizer pressure curves to input into the time series solver.
4The pressure curves are 200-point arrays broken into N segments (1-20).
5
6Each segment has:
7- Segment length (in terms of points/200)
8- Region type: linear OR blowdown
9- Start pressure (matches previous region's end pressure)
10- End pressure
11- k-variable for blowdown profile between those 2 points
12
13Pressure curves are always decreasing.
14
15NOTE (not addressed here -- recorded for whoever picks this up):
16This layer assumes a BLOWDOWN system. A regulated / flat-pressure engine is only
17representable as a degenerate case: ``end_pressure_ratio = 1.0``, which is the exact
18UPPER BOUND of that search variable, and ``generate_pressure_curve_from_segments``
19force-clamps ``P_end = 0.95 * P_start`` if a segment ever tries to rise. So a constant
20600 psi design is reachable in principle but sits on a bound, which this optimizer has a
21documented history of handling badly (penalties with ~0 gradient at a band edge park
22candidates just outside it). Layer 1 is unaffected -- it is time-independent by contract
23and optimizes a single operating point, so a flat-pressure design is exactly what its
24static solution already represents. If flat/regulated feed becomes a supported mode
25rather than an edge case, this layer needs an explicit constant-pressure segment type
26instead of a boundary value.
27"""
28
29from __future__ import annotations
30
31from typing import Dict, Any, Optional, Tuple, Callable, List
32import numpy as np
33import copy
34import logging
35import time
36from datetime import datetime
37from pathlib import Path
38import pandas as pd
39import matplotlib
40matplotlib.use('Agg') # Non-interactive backend
41import matplotlib.pyplot as plt
42
43from engine.pipeline.config_schemas import PintleEngineConfig, PressureCurvesConfig, PressureSegmentConfig
44from engine.core.runner import PintleEngineRunner
45from copv.copv_solve_both import size_or_check_copv_for_polytropic_N2
46
47# Path to N2 compressibility lookup table for COPV solver
48# Use absolute path from the project root
49# layer2_pressure.py -> layers/ -> optimizer/ -> engine/ -> project_root/
50N2_Z_LOOKUP_CSV = Path(__file__).resolve().parent.parent.parent.parent / "copv" / "n2_Z_lookup.csv"
51
52
54 segments: List[Dict[str, Any]],
55 n_points: int = 200,
56 *,
57 allow_rise: bool = False,
58 exact_ends: bool = False,
59) -> np.ndarray:
60 """
61 Generate a 200-point pressure curve from segments.
62
63 Each segment specifies:
64 - length_ratio: fraction of total points (0-1)
65 - type: 'linear' or 'blowdown'
66 - start_pressure: pressure at start [Pa]
67 - end_pressure: pressure at end [Pa] (must be <= start_pressure)
68 - k: blowdown parameter (only for blowdown type)
69
70 Args:
71 segments: List of segment dicts
72 n_points: Total number of points (default 200)
73 allow_rise: keep a segment whose end is above its start (any feed system whose tank
74 pressure climbs: a regulator's supply-pressure effect, a pressurant schedule). Off: such
75 a segment is forced to fall 5 %, which is what Layer 2's search space assumes.
76 exact_ends: a blowdown segment ends AT its end pressure,
77 P = P_end + (P_start - P_end)(e^(-k t) - e^(-k))/(1 - e^(-k)) -- the curve the Time-Series
78 builder draws. Off: P_end + (P_start - P_end) e^(-k t), which stops short of P_end
79 (61 % of the way back at k = 0.5); Layer 2 has always used it.
80
81 Returns:
82 pressure_array: Array of pressures [Pa] of length n_points
83 """
84 if not segments:
85 # Default constant pressure
86 return np.full(n_points, 5e6) # 5 MPa default
87
88 pressure_array = np.zeros(n_points, dtype=float)
89
90 # Calculate cumulative point indices for each segment
91 point_idx = 0
92 for i, seg in enumerate(segments):
93 # Robustly interpret the segment parameters. If any are non-finite, fall
94 # back to safe defaults so that a bad segment description cannot crash
95 # the optimizer; upstream logic should already treat such cases as low
96 # quality via the objective.
97 raw_lr = seg.get("length_ratio", 1.0 / max(len(segments), 1))
98 try:
99 length_ratio = float(raw_lr)
100 except (TypeError, ValueError):
101 length_ratio = 1.0 / max(len(segments), 1)
102 # Clamp and repair non-finite values.
103 if not np.isfinite(length_ratio):
104 length_ratio = 1.0 / max(len(segments), 1)
105 length_ratio = float(np.clip(length_ratio, 0.01, 1.0))
106 seg_type = seg.get("type", "linear")
107 P_start = float(seg["start_pressure"])
108 P_end = float(seg["end_pressure"])
109 k = float(seg.get("k", 0.3)) # Blowdown parameter
110 # Guard against any non-finite pressures or k slipping through.
111 if not np.isfinite(P_start) or not np.isfinite(P_end):
112 # If either endpoint is invalid, skip this segment entirely.
113 continue
114 if not np.isfinite(k):
115 k = 0.3
116
117 # Ensure end <= start (decreasing pressure), unless a rising segment is asked for
118 if P_end > P_start and not allow_rise:
119 P_end = P_start * 0.95 # Force decrease
120
121 # Calculate number of points for this segment
122 if i == len(segments) - 1:
123 # Last segment takes remaining points
124 n_seg_points = n_points - point_idx
125 else:
126 # Ensure the product is finite before casting to int.
127 n_seg_float = length_ratio * float(n_points)
128 if not np.isfinite(n_seg_float):
129 # If this ever happens, just give the segment a single point so
130 # that the optimizer receives a very "flat" and low-quality
131 # pressure curve instead of crashing.
132 n_seg_float = 1.0
133 n_seg_points = int(round(n_seg_float))
134 n_seg_points = max(1, min(n_seg_points, n_points - point_idx))
135
136 if n_seg_points <= 0:
137 continue
138
139 # Generate local indices for this segment
140 seg_indices = np.arange(point_idx, point_idx + n_seg_points)
141 if len(seg_indices) == 0:
142 continue
143
144 # Normalized position within segment (0 to 1)
145 if n_seg_points > 1:
146 t_norm = np.linspace(0.0, 1.0, n_seg_points)
147 else:
148 t_norm = np.array([0.0])
149
150 if seg_type == "linear":
151 # Linear interpolation: P(t) = P_start + (P_end - P_start) * t
152 pressure_array[seg_indices] = P_start + (P_end - P_start) * t_norm
153 elif seg_type == "blowdown":
154 # Blowdown profile: P(t) = P_end + (P_start - P_end) * exp(-k * t)
155 # k controls the decay rate
156 if exact_ends and abs(1.0 - np.exp(-k)) > 1e-6:
157 ek = np.exp(-k)
158 pressure_array[seg_indices] = P_end + (P_start - P_end) * (np.exp(-k * t_norm) - ek) / (1.0 - ek)
159 else:
160 pressure_array[seg_indices] = P_end + (P_start - P_end) * np.exp(-k * t_norm)
161 else:
162 # Default to linear
163 pressure_array[seg_indices] = P_start + (P_end - P_start) * t_norm
164
165 point_idx += n_seg_points
166 if point_idx >= n_points:
167 break
168
169 # Fill any remaining points with last value
170 if point_idx < n_points:
171 if point_idx > 0:
172 pressure_array[point_idx:] = pressure_array[point_idx - 1]
173 else:
174 pressure_array[:] = segments[-1]["end_pressure"] if segments else 5e6
175
176 return pressure_array
177
178
180 x_segments: np.ndarray,
181 n_segments: int,
182 initial_pressure_pa: float,
183 min_pressure_pa: float = 1e6, # 1 MPa minimum
184) -> List[Dict[str, Any]]:
185 """
186 Convert optimizer variables to segment list for pressure curves.
187
188 For each segment, optimizer provides:
189 - length_ratio (0-1, fraction of total 200 points)
190 - end_pressure_ratio (0-1, ratio relative to initial pressure)
191 - k (0-2, blowdown parameter)
192
193 Start pressure is automatically set to match previous segment's end pressure.
194 First segment starts at initial_pressure_pa.
195
196 Args:
197 x_segments: Array of optimizer variables for segments
198 n_segments: Number of segments (1-20)
199 initial_pressure_pa: Initial pressure from Layer 1 [Pa]
200 min_pressure_pa: Minimum pressure [Pa]
201
202 Returns:
203 List of segment dicts
204 """
205 segments = []
206 vars_per_segment = 3 # length_ratio, end_pressure_ratio, k
207
208 # Ensure n_segments doesn't exceed available array size
209 max_available_segments = len(x_segments) // vars_per_segment
210 n_segments = min(n_segments, max_available_segments)
211 if n_segments < 1:
212 n_segments = 1
213
214 # Normalize length ratios so they sum to 1.0
215 length_ratios = []
216 for i in range(n_segments):
217 idx_base = i * vars_per_segment
218 if idx_base >= len(x_segments):
219 break
220 length_ratio = float(np.clip(x_segments[idx_base], 0.01, 1.0))
221 length_ratios.append(length_ratio)
222
223 # Normalize so sum = 1.0
224 total_ratio = sum(length_ratios) if length_ratios else 1.0
225 if total_ratio > 0:
226 length_ratios = [lr / total_ratio for lr in length_ratios]
227
228 # Build segments
229 prev_end_pressure = initial_pressure_pa # First segment starts at initial pressure from Layer 1
230
231 for i in range(n_segments):
232 idx_base = i * vars_per_segment
233 if idx_base + 2 >= len(x_segments):
234 break
235
236 length_ratio = length_ratios[i] if i < len(length_ratios) else 1.0 / n_segments
237
238 # All segments are modeled as blowdown; k can make them effectively linear when small.
239 seg_type = "blowdown"
240
241 # End pressure ratio (relative to initial pressure, but must be <= start)
242 end_ratio_raw = float(np.clip(x_segments[idx_base + 1], 0.1, 1.0))
243 # Ensure end <= start (decreasing)
244 start_ratio = prev_end_pressure / initial_pressure_pa
245 end_ratio = min(end_ratio_raw, start_ratio * 0.99) # Slight margin
246 end_pressure = initial_pressure_pa * end_ratio
247 end_pressure = max(min_pressure_pa, min(end_pressure, prev_end_pressure))
248
249 # k parameter for blowdown
250 k = float(np.clip(x_segments[idx_base + 2] if len(x_segments) > idx_base + 2 else 0.3, 0.1, 2.0))
251
252 seg = {
253 "length_ratio": length_ratio,
254 "type": seg_type,
255 "start_pressure": prev_end_pressure,
256 "end_pressure": end_pressure,
257 "k": k,
258 }
259
260 segments.append(seg)
261 prev_end_pressure = end_pressure # Next segment starts where this one ends
262
263 return segments
264
265
266def layer2_flight_vehicle(config: PintleEngineConfig) -> Dict[str, Any]:
267 """What the required-impulse flight needs from the config, built once per run: the flight
268 sim's Cd(M) and body area (engine/pipeline/vehicle_drag), the pad, the thrust curve's
269 reference pressure and nozzle exit, and the pressurant carried on top of the dry mass.
270
271 A config with no vehicle to fly (no rocket, tanks or environment) gets no drag, and the
272 returned "model" says so, so the summary can show it.
273 """
274 from engine.pipeline.config_schemas import ensure_chamber_geometry
275 from engine.pipeline.vehicle_drag import resolve_drag_curves
276 from engine.core.runner import compute_ambient_pressure_from_elevation
277
278 gas = 0.0
279 press = getattr(config, "press_tank", None)
280 if press is not None and getattr(press, "initial_gas_mass", None):
281 gas = float(press.initial_gas_mass) # on board, not propellant
282 if any(getattr(config, k, None) is None for k in ("rocket", "lox_tank", "fuel_tank", "environment")):
283 return {"flight": {}, "gas_on_board_kg": gas, "model": "flight_1dof, no drag: the config has no vehicle"}
284 A_e = float(ensure_chamber_geometry(config).A_exit)
285 drag = resolve_drag_curves(config, A_e)
286 elevation = float(config.environment.elevation)
287 p_ref = getattr(getattr(config, "thrust", None), "reference_pressure_pa", None)
288 if p_ref is None:
289 p_ref = float(compute_ambient_pressure_from_elevation(elevation))
290 return {
291 "flight": {
292 "area_m2": float(np.pi * float(config.rocket.radius) ** 2),
293 "drag": drag,
294 "elevation_m": elevation,
295 "reference_pressure_pa": float(p_ref),
296 "nozzle_exit_area_m2": A_e,
297 },
298 "gas_on_board_kg": gas,
299 "model": f"flight_1dof, drag {drag.model}",
300 }
301
302
304 target_apogee_m: float,
305 rocket_dry_mass_kg: float,
306 total_propellant_mass_kg: float,
307 target_burn_time_s: float,
308 *,
309 time_s: Optional[np.ndarray] = None,
310 mdot_kg_s: Optional[np.ndarray] = None,
311 thrust_n: Optional[np.ndarray] = None,
312 vehicle: Optional[Dict[str, Any]] = None,
313) -> float:
314 """Total impulse [N s] that reaches target_apogee_m (above the pad) with this propellant, burn
315 time and mass-flow shape: the vertical 1-DOF of engine/pipeline/flight_1dof with the flight
316 sim's drag (``vehicle`` from layer2_flight_vehicle; none given, no drag).
317
318 This was (1.15 sqrt(2 g h) + 0.5 g t_b) (m_dry + m_p): delta-v times the lift-off mass where
319 the rocket equation gives c m_p, half the gravity loss, burnout altitude ignored and a flat
320 15 % for drag. The errors happened to cancel near 6.5 kN (27,094 N s against 25,025 N s at Cd
321 0.45) and not elsewhere: +18 % at 40 kg / 40 kg / 15 s. With no drag this reduces to Sutton &
322 Biblarz's vertical flight exactly.
323
324 rocket_dry_mass_kg is airframe + engine + tank and COPV structures; the pressurant gas is
325 added from the vehicle (press_tank.initial_gas_mass).
326 """
327 from engine.pipeline.flight_1dof import required_impulse
328
329 vehicle = vehicle or {}
330 return required_impulse(
331 target_apogee_m,
332 rocket_dry_mass_kg + float(vehicle.get("gas_on_board_kg", 0.0)),
333 total_propellant_mass_kg,
334 target_burn_time_s,
335 time_s=time_s,
336 mdot_kg_s=mdot_kg_s,
337 thrust_n=thrust_n,
338 **vehicle.get("flight", {}),
339 )
340
341
342# Fixed segment configuration for Layer 2 optimization
343# We use a shared-segment parameterization:
344# - Shared length ratios across LOX and fuel
345# - LOX end pressures and k are optimized directly
346# - Fuel end pressures are derived from LOX end pressures and a bounded
347# LOX/Fuel pressure-ratio factor per segment, keeping segment-end
348# pressure ratios within ±25% of the initial ratio.
349N_SEGMENTS = 8
350VARS_PER_SEGMENT = 5 # length_ratio, lox_end_pressure_ratio, lox_k, fuel_ratio_factor, fuel_k
351
352
354 optimized_config: PintleEngineConfig,
355 initial_lox_pressure_pa: float,
356 initial_fuel_pressure_pa: float,
357 peak_thrust: float,
358 target_apogee_m: float,
359 rocket_dry_mass_kg: float,
360 max_lox_tank_capacity_kg: float,
361 max_fuel_tank_capacity_kg: float,
362 target_burn_time: float,
363 n_time_points: int = 100,
364 update_progress: Optional[Callable] = None,
365 log_status: Optional[Callable] = None,
366 min_pressure_pa: float = 1e6,
367 optimal_of_ratio: Optional[float] = None,
368 min_stability_margin: Optional[float] = None,
369) -> Tuple[float, float, Dict[str, Any], bool]:
370 """
371 Layer 2a: Find minimum allowable LOX and fuel tank pressures.
372
373 This helper layer searches for the lowest *flat* tank pressures (same value
374 over the full burn) that still satisfy the key constraints:
375 - Required impulse to reach target apogee
376 - Tank capacity limits
377 - Stability margin
378 - O/F ratio tolerance
379
380 The LOX/Fuel tank pressure ratio is kept fixed to the initial ratio from
381 Layer 1 by scaling both tanks by a common factor.
382
383 Returns:
384 Tuple of (min_lox_pressure_pa, min_fuel_pressure_pa, summary, success)
385 """
386 from scipy.integrate import cumulative_trapezoid
387
388 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
389 # Ensure output/logs directory exists
390 output_logs_dir = Path(__file__).resolve().parents[3] / "output" / "logs"
391 output_logs_dir.mkdir(parents=True, exist_ok=True)
392 log_file_path = output_logs_dir / f"layer2a_min_pressure_{timestamp}.log"
393
394 layer2a_logger = logging.getLogger("layer2a_min_pressure")
395 layer2a_logger.setLevel(logging.INFO)
396 layer2a_logger.handlers.clear()
397
398 file_handler = logging.FileHandler(log_file_path, mode="w", encoding="utf-8")
399 file_handler.setLevel(logging.INFO)
400 file_formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
401 file_handler.setFormatter(file_formatter)
402 layer2a_logger.addHandler(file_handler)
403 layer2a_logger.propagate = False
404
405 layer2a_logger.info("=" * 70)
406 layer2a_logger.info("Layer 2a: Minimum Tank Pressure Search")
407 layer2a_logger.info("=" * 70)
408 layer2a_logger.info(f"Log file: {log_file_path}")
409 layer2a_logger.info(
410 f"Initial LOX pressure: {initial_lox_pressure_pa/1e6:.2f} MPa "
411 f"({initial_lox_pressure_pa/6894.76:.1f} psi)"
412 )
413 layer2a_logger.info(
414 f"Initial Fuel pressure: {initial_fuel_pressure_pa/1e6:.2f} MPa "
415 f"({initial_fuel_pressure_pa/6894.76:.1f} psi)"
416 )
417 layer2a_logger.info(f"Target burn time: {target_burn_time:.2f} s")
418 layer2a_logger.info(f"Time points (Layer 2a): {n_time_points}")
419 layer2a_logger.info("")
420
421 # Disable ablative and graphite for this layer
422 config_layer2a = copy.deepcopy(optimized_config)
423 if hasattr(config_layer2a, "ablative_cooling") and config_layer2a.ablative_cooling:
424 config_layer2a.ablative_cooling.enabled = False
425 if hasattr(config_layer2a, "graphite_insert") and config_layer2a.graphite_insert:
426 config_layer2a.graphite_insert.enabled = False
427
428 runner_layer2a = PintleEngineRunner(config_layer2a)
429 time_array = np.linspace(0.0, target_burn_time, n_time_points)
430 flight_vehicle = layer2_flight_vehicle(optimized_config)
431
432 initial_ratio = initial_lox_pressure_pa / max(initial_fuel_pressure_pa, 1e-9)
433
434 def _evaluate_scale(scale: float) -> Tuple[bool, Dict[str, Any]]:
435 """Return (passes_constraints, metrics) for a given common pressure scale."""
436 P_lox = np.full(n_time_points, initial_lox_pressure_pa * scale)
437 P_fuel = np.full(n_time_points, initial_fuel_pressure_pa * scale)
438
439 # Enforce absolute minimum clamp
440 P_lox = np.maximum(P_lox, min_pressure_pa)
441 P_fuel = np.maximum(P_fuel, min_pressure_pa)
442
443 try:
444 results = runner_layer2a.evaluate_arrays_with_time(
445 time_array,
446 P_lox,
447 P_fuel,
448 track_ablative_geometry=False,
449 use_coupled_solver=False,
450 )
451 except Exception as e:
452 msg = f"Time-series solver failed at scale={scale:.3f}: {repr(e)}"
453 layer2a_logger.error(msg)
454 if log_status:
455 log_status("Layer 2a Error", msg)
456 return False, {}
457
458 thrust_hist = np.atleast_1d(results.get("F", np.full(n_time_points, peak_thrust)))
459 thrust_hist = thrust_hist[: n_time_points]
460 time_hist = time_array[: thrust_hist.shape[0]]
461 if thrust_hist.shape[0] < 2:
462 return False, {}
463
464 mdot_O_hist = np.atleast_1d(results.get("mdot_O", np.zeros_like(time_hist)))
465 mdot_F_hist = np.atleast_1d(results.get("mdot_F", np.zeros_like(time_hist)))
466 mdot_O_hist = mdot_O_hist[: time_hist.shape[0]]
467 mdot_F_hist = mdot_F_hist[: time_hist.shape[0]]
468
469 total_lox_mass = float(np.trapezoid(mdot_O_hist, time_hist))
470 total_fuel_mass = float(np.trapezoid(mdot_F_hist, time_hist))
471 total_propellant_mass = total_lox_mass + total_fuel_mass
472
473 required_impulse = calculate_required_impulse_from_mass(
474 target_apogee_m,
475 rocket_dry_mass_kg,
476 total_propellant_mass,
477 target_burn_time,
478 time_s=time_hist,
479 mdot_kg_s=mdot_O_hist + mdot_F_hist,
480 thrust_n=thrust_hist,
481 vehicle=flight_vehicle,
482 )
483 total_impulse = float(np.trapezoid(thrust_hist, time_hist))
484
485 # Constraint 1: Impulse
486 passes_impulse = total_impulse >= required_impulse
487
488 # Constraint 2: Tank capacities
489 passes_lox_capacity = total_lox_mass <= max_lox_tank_capacity_kg * 1.001
490 passes_fuel_capacity = total_fuel_mass <= max_fuel_tank_capacity_kg * 1.001
491
492 # Constraint 3: Stability
493 chugging_margins = results.get("chugging_stability_margin", None)
494 if chugging_margins is not None:
495 min_chugging = float(np.min(np.atleast_1d(chugging_margins)))
496 else:
497 min_chugging = 1.0
498
499 if min_stability_margin is not None:
500 passes_stability = min_chugging >= min_stability_margin
501 else:
502 passes_stability = min_chugging >= 0.7
503
504 # Constraint 4: O/F ratio
505 if optimal_of_ratio is not None:
506 MR_hist = np.atleast_1d(results.get("MR", np.full_like(time_hist, optimal_of_ratio)))
507 MR_hist = MR_hist[: time_hist.shape[0]]
508 # Pointwise relative error
509 MR_errors = np.abs(MR_hist - optimal_of_ratio) / max(optimal_of_ratio, 1e-9)
510 # Check maximum deviation (must be within 20% at all points)
511 max_MR_error = float(np.max(MR_errors))
512 passes_of = max_MR_error <= 0.20
513 else:
514 raise ValueError("optimal_of_ratio must be provided")
515
516 passes = passes_impulse and passes_lox_capacity and passes_fuel_capacity and passes_stability and passes_of
517
518 metrics = {
519 "scale": scale,
520 "P_lox_flat_pa": float(P_lox[0]),
521 "P_fuel_flat_pa": float(P_fuel[0]),
522 "total_impulse": total_impulse,
523 "required_impulse": required_impulse,
524 "total_lox_mass": total_lox_mass,
525 "total_fuel_mass": total_fuel_mass,
526 "min_chugging": min_chugging,
527 "initial_ratio": initial_ratio,
528 }
529 return passes, metrics
530
531 # Search bounds: keep above absolute minimum and below initial pressures.
532 scale_high = 1.0
533 # Ensure neither tank drops below min_pressure_pa
534 scale_low_lox = min_pressure_pa / max(initial_lox_pressure_pa, 1e-9)
535 scale_low_fuel = min_pressure_pa / max(initial_fuel_pressure_pa, 1e-9)
536 scale_low = max(scale_low_lox, scale_low_fuel)
537 scale_low = min(scale_low, scale_high)
538
539 # First check if even the initial pressures meet constraints
540 passes_high, high_metrics = _evaluate_scale(scale_high)
541 if not passes_high:
542 layer2a_logger.warning(
543 "Initial pressures do not satisfy constraints; using initial pressures as minima."
544 )
545 summary = {
546 "min_lox_pressure_pa": float(initial_lox_pressure_pa),
547 "min_fuel_pressure_pa": float(initial_fuel_pressure_pa),
548 "scale": 1.0,
549 "success": False,
550 }
551 layer2a_logger.handlers.clear()
552 return initial_lox_pressure_pa, initial_fuel_pressure_pa, summary, False
553
554 # If the lowest feasible scale also passes, we can use that directly.
555 passes_low, low_metrics = _evaluate_scale(scale_low)
556 if passes_low:
557 min_lox = low_metrics["P_lox_flat_pa"]
558 min_fuel = low_metrics["P_fuel_flat_pa"]
559 summary = {
560 "min_lox_pressure_pa": min_lox,
561 "min_fuel_pressure_pa": min_fuel,
562 "scale": scale_low,
563 "success": True,
564 }
565 layer2a_logger.handlers.clear()
566 return min_lox, min_fuel, summary, True
567
568 # Otherwise, binary search between scale_low and 1.0
569 left = scale_low
570 right = scale_high
571 best_scale = scale_high
572 best_metrics = high_metrics
573
574 for _ in range(10):
575 mid = 0.5 * (left + right)
576 passes_mid, mid_metrics = _evaluate_scale(mid)
577 if passes_mid:
578 best_scale = mid
579 best_metrics = mid_metrics
580 right = mid
581 else:
582 left = mid
583
584 min_lox = best_metrics["P_lox_flat_pa"]
585 min_fuel = best_metrics["P_fuel_flat_pa"]
586
587 summary = {
588 "min_lox_pressure_pa": min_lox,
589 "min_fuel_pressure_pa": min_fuel,
590 "scale": best_scale,
591 "success": True,
592 }
593
594 # Clean up logger handlers
595 layer2a_logger.handlers.clear()
596
597 return min_lox, min_fuel, summary, True
598
599
601 optimized_config: PintleEngineConfig,
602 initial_lox_pressure_pa: float,
603 initial_fuel_pressure_pa: float,
604 peak_thrust: float, # Initial/peak thrust target
605 target_apogee_m: float, # Target apogee for impulse calculation
606 rocket_dry_mass_kg: float, # Rocket dry mass (no propellant) = airframe + engine + lox_tank_structure + fuel_tank_structure + copv_structure
607 max_lox_tank_capacity_kg: float, # Maximum LOX tank capacity [kg]
608 max_fuel_tank_capacity_kg: float, # Maximum fuel tank capacity [kg]
609 target_burn_time: float,
610 n_time_points: int = 200,
611 update_progress: Optional[Callable] = None,
612 log_status: Optional[Callable] = None,
613 min_pressure_pa: float = 1e6, # Legacy absolute minimum clamp (~150 psi)
614 optimal_of_ratio: Optional[float] = None, # Target O/F ratio for validation
615 min_stability_margin: Optional[float] = None, # Minimum stability margin
616 max_iterations: int = 20, # Maximum optimization iterations (reduced from 30 for faster convergence)
617 max_evaluations: Optional[int] = None, # Maximum function evaluations (None = unlimited)
618 save_evaluation_plots: bool = False, # Save PNG plots of each evaluation's pressure curves
619 min_lox_pressure_floor_pa: Optional[float] = None,
620 min_fuel_pressure_floor_pa: Optional[float] = None,
621 min_pressure_slope_psi_per_sec: float = -25.0, # Minimum pressure decrease rate [psi/s] (negative = decreasing)
622 objective_callback: Optional[Callable[[int, float, float], None]] = None, # Optional hook for streaming objective history
623 pressure_curve_callback: Optional[Callable[[np.ndarray, np.ndarray, np.ndarray, Optional[np.ndarray], Optional[np.ndarray]], None]] = None, # Optional hook for streaming best pressure curves (time, P_lox, P_fuel, copv_pressure, copv_time)
624 stop_event: Optional[Any] = None, # threading.Event for stop signal
625 de_maxiter: int = 5, # Reduced from 10 for faster execution
626 de_popsize: int = 2, # Reduced from 5 for faster execution
627 de_n_time_points: int = 25, # Reduced from 50 for faster execution
628 use_controller_simulation: bool = False, # Toggle for robust DDP controller feasibility penalty
629 controller_config: Optional[Any] = None, # ControllerConfig instance if available
630) -> Tuple[PintleEngineConfig, np.ndarray, np.ndarray, np.ndarray, Dict[str, Any], bool]:
631 """
632 Run Layer 2: Pressure Curve Optimization.
633
634 Optimizes fuel and oxidizer pressure curves (200-point arrays) for time series solver.
635 - Uses a fixed number of segments per tank (N_SEGMENTS)
636 - Each segment can be linear or blowdown with parameters:
637 - length_ratio, type, end_pressure_ratio, k
638 - First performs a global search (differential_evolution), then local polish (L-BFGS-B)
639
640 Returns:
641 Tuple of (optimized_config, time_array, P_tank_O_array, P_tank_F_array, summary, success)
642 """
643 from scipy.optimize import minimize as scipy_minimize, differential_evolution
644 from scipy.integrate import cumulative_trapezoid
645
646 # Resolve per-tank minimum pressure floors.
647 # If not provided, fall back to the legacy shared min_pressure_pa.
648 if min_lox_pressure_floor_pa is None:
649 min_lox_pressure_floor_pa = float(min_pressure_pa)
650 if min_fuel_pressure_floor_pa is None:
651 min_fuel_pressure_floor_pa = float(min_pressure_pa)
652
653 # Set up Layer 2 logging
654 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
655 # Ensure output/logs directory exists
656 output_logs_dir = Path(__file__).resolve().parents[3] / "output" / "logs"
657 output_logs_dir.mkdir(parents=True, exist_ok=True)
658 log_file_path = output_logs_dir / f"layer2_pressure_{timestamp}.log"
659
660 # Create logger for Layer 2
661 layer2_logger = logging.getLogger('layer2_pressure')
662 layer2_logger.setLevel(logging.INFO)
663
664
665 layer2_logger.handlers.clear()
666
667 # File handler
668 file_handler = logging.FileHandler(log_file_path, mode='w', encoding='utf-8')
669 file_handler.setLevel(logging.INFO)
670 file_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
671 file_handler.setFormatter(file_formatter)
672 layer2_logger.addHandler(file_handler)
673
674 # Prevent propagation to root logger
675 layer2_logger.propagate = False
676
677 layer2_logger.info("="*70)
678 layer2_logger.info("Layer 2: Pressure Curve Optimization")
679 layer2_logger.info("="*70)
680 layer2_logger.info(f"Log file: {log_file_path}")
681 layer2_logger.info(f"Initial LOX pressure: {initial_lox_pressure_pa/1e6:.2f} MPa ({initial_lox_pressure_pa/6894.76:.1f} psi)")
682 layer2_logger.info(f"Initial Fuel pressure: {initial_fuel_pressure_pa/1e6:.2f} MPa ({initial_fuel_pressure_pa/6894.76:.1f} psi)")
683 layer2_logger.info(f"Peak thrust target: {peak_thrust:.1f} N")
684 layer2_logger.info(f"Target apogee: {target_apogee_m:.0f} m")
685 layer2_logger.info(f"Rocket dry mass: {rocket_dry_mass_kg:.2f} kg")
686 layer2_logger.info(f"Target burn time: {target_burn_time:.2f} s")
687 layer2_logger.info(f"Time points: {n_time_points}")
688 layer2_logger.info(f"DE parameters: maxiter={de_maxiter}, popsize={de_popsize}, n_time_points={de_n_time_points}")
689 layer2_logger.info(f"Minimum pressure slope: {min_pressure_slope_psi_per_sec:.1f} psi/s")
690 if optimal_of_ratio is not None:
691 layer2_logger.info(f"Target O/F ratio: {optimal_of_ratio:.2f}")
692 if min_stability_margin is not None:
693 layer2_logger.info(f"Min stability margin: {min_stability_margin:.3f}")
694 layer2_logger.info("")
695
696 # Set up evaluation plot file if requested
697 evaluation_plot_file = None
698 if save_evaluation_plots:
699 evaluation_plot_file = Path(f"layer2_evaluation_plot_{timestamp}.png")
700 layer2_logger.info(f"Saving evaluation plots to: {evaluation_plot_file}")
701
702 # Generate time arrays (Layer 1 doesn't provide this)
703 # - Full-resolution array for local optimization and final evaluation
704 # - Coarser array for the global DE search to speed up evaluations
705 time_array = np.linspace(0.0, target_burn_time, n_time_points)
706 n_time_points_de = min(de_n_time_points, n_time_points)
707 time_array_de = np.linspace(0.0, target_burn_time, n_time_points_de)
708
709 def save_evaluation_plot(
710 eval_num: int,
711 time_arr: np.ndarray,
712 P_lox: np.ndarray,
713 P_fuel: np.ndarray,
714 objective: Optional[float] = None,
715 n_seg_lox: Optional[int] = None,
716 n_seg_fuel: Optional[int] = None,
717 ):
718 """Save a plot of the pressure curves for this evaluation (overwrites previous plot)."""
719 if evaluation_plot_file is None:
720 return
721
722 fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8))
723
724 # Plot 1: Pressure in PSI
725 ax1.plot(time_arr, P_lox / 6894.76, 'b-', linewidth=2, label='LOX Tank')
726 ax1.plot(time_arr, P_fuel / 6894.76, 'r-', linewidth=2, label='Fuel Tank')
727 ax1.set_xlabel('Time [s]')
728 ax1.set_ylabel('Tank Pressure [psi]')
729 ax1.set_title(f'Evaluation #{eval_num} Pressure Curves')
730 ax1.grid(True, alpha=0.3)
731 ax1.legend()
732
733 # Plot 2: Pressure in MPa
734 ax2.plot(time_arr, P_lox / 1e6, 'b-', linewidth=2, label='LOX Tank')
735 ax2.plot(time_arr, P_fuel / 1e6, 'r-', linewidth=2, label='Fuel Tank')
736 ax2.set_xlabel('Time [s]')
737 ax2.set_ylabel('Tank Pressure [MPa]')
738
739 # Add info text
740 info_text = f"Eval #{eval_num}"
741 if n_seg_lox is not None:
742 info_text += f" | LOX: {n_seg_lox} seg"
743 if n_seg_fuel is not None:
744 info_text += f" | Fuel: {n_seg_fuel} seg"
745 if objective is not None:
746 info_text += f"\nObjective: {objective:.6f}"
747
748 ax2.text(0.02, 0.98, info_text, transform=ax2.transAxes,
749 verticalalignment='top', bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5),
750 fontsize=9, family='monospace')
751
752 ax2.grid(True, alpha=0.3)
753 ax2.legend()
754
755 plt.tight_layout()
756
757 # Save plot (overwrites previous)
758 plt.savefig(evaluation_plot_file, dpi=100, bbox_inches='tight')
759 plt.close(fig) # Close to free memory
760
761 # Disable ablative and graphite for this layer
762 config_layer2 = copy.deepcopy(optimized_config)
763 if hasattr(config_layer2, 'ablative_cooling') and config_layer2.ablative_cooling:
764 config_layer2.ablative_cooling.enabled = False
765 if hasattr(config_layer2, 'graphite_insert') and config_layer2.graphite_insert:
766 config_layer2.graphite_insert.enabled = False
767
768 # Create a single PintleEngineRunner instance for this layer and reuse it
769 runner_layer2 = PintleEngineRunner(config_layer2)
770 flight_vehicle = layer2_flight_vehicle(optimized_config)
771
772 # Calculate initial chamber pressure to establish stability floor
773 try:
774 res_init = runner_layer2.evaluate(
775 initial_lox_pressure_pa,
776 initial_fuel_pressure_pa,
777 silent=True
778 )
779 Pc_initial_ref = res_init.get("Pc", 0.0)
780 except Exception:
781 Pc_initial_ref = 0.0
782
783 # Enforce stability constraint (75% of initial Pc) on pressure generation
784 safe_pressure_floor = 0.75 * Pc_initial_ref
785
786 # Update floors if the stability requirement is stricter (higher) than the default
787 local_min_lox_floor = max(min_lox_pressure_floor_pa, safe_pressure_floor)
788 local_min_fuel_floor = max(min_fuel_pressure_floor_pa, safe_pressure_floor)
789
790 layer2_logger.info(f"Stability Constraint: Pc_initial={Pc_initial_ref/1e6:.2f} MPa, Min Safe Pressure={safe_pressure_floor/1e6:.2f} MPa")
791 if local_min_lox_floor > min_lox_pressure_floor_pa:
792 layer2_logger.info(f" -> Adjusted LOX floor to {local_min_lox_floor/1e6:.2f} MPa")
793 if local_min_fuel_floor > min_fuel_pressure_floor_pa:
794 layer2_logger.info(f" -> Adjusted Fuel floor to {local_min_fuel_floor/1e6:.2f} MPa")
795
796 from engine.optimizer.feed_pressure_model import get_feed_pressure_model, dome_regulated_tank_pair
797 skip_de_optimization = get_feed_pressure_model(optimized_config) == "dome_regulated"
798
799 # Optimization variables:
800 # Shared-segment parameterization with fixed N_SEGMENTS segments:
801 # - Shared length ratios across LOX and fuel
802 # - LOX end pressures and k are optimized directly
803 # - Fuel end pressures are derived from LOX end pressures and a bounded
804 # LOX/Fuel pressure-ratio factor per segment, keeping segment-end
805 # pressure ratios within ±25% of the initial ratio.
806 #
807 # x_layer2 format (per segment, in order):
808 # - length_ratio (0-1, shared LOX/Fuel segment length)
809 # - lox_end_pressure_ratio (0-1, relative to initial LOX tank pressure)
810 # - lox_k (0-2, LOX blowdown parameter)
811 # - fuel_ratio_factor (~0.75-1.25, multiplies initial LOX/Fuel ratio)
812 # - fuel_k (0-2, fuel blowdown parameter)
813
814 def build_x0_compact() -> np.ndarray:
815 x: list[float] = []
816 initial_ratio = initial_lox_pressure_pa / max(initial_fuel_pressure_pa, 1e-9)
817 # Initialize segments with shared lengths, gently decaying LOX pressure,
818 # and fuel following the initial LOX/Fuel ratio (ratio_factor ~= 1.0).
819 for i in range(N_SEGMENTS):
820 length_ratio = 1.0 / N_SEGMENTS
821 lox_end_ratio = 0.9 - 0.3 * (i / max(N_SEGMENTS - 1, 1)) # monotone decreasing guess
822 lox_k = 0.3
823 fuel_ratio_factor = 1.0 # start near initial pressure ratio
824 fuel_k = 0.3
825 x.extend([length_ratio, lox_end_ratio, lox_k, fuel_ratio_factor, fuel_k])
826 return np.array(x, dtype=float)
827
828 x0 = build_x0_compact()
829
830 # Bounds for compact vector (per segment)
831 # Bounds for compact vector (per segment)
832 bounds: list[tuple[float, float]] = []
833
834 # Calculate minimum LOX pressure ratio to respect stability constraint
835 # We want P_lox_end >= safe_pressure_floor
836 # implies ratio >= safe_pressure_floor / initial_lox
837 min_lox_ratio_bound = 0.1
838 if initial_lox_pressure_pa > 0:
839 min_lox_ratio_bound = max(0.1, safe_pressure_floor / initial_lox_pressure_pa)
840
841 # Clamp to ensure it doesn't exceed 1.0 (though that would mean impossible constraint)
842 min_lox_ratio_bound = min(min_lox_ratio_bound, 0.99)
843
844 for _ in range(N_SEGMENTS):
845 bounds.append((0.01, 1.0)) # length_ratio (shared LOX/Fuel)
846 bounds.append((min_lox_ratio_bound, 1.0)) # lox_end_pressure_ratio (constrained)
847 bounds.append((0.1, 2.0)) # lox_k
848 bounds.append((0.75, 1.25)) # fuel_ratio_factor (keeps segment-end ratios within ±25%)
849 bounds.append((0.1, 2.0)) # fuel_k
850
851 # Track optimization progress
852 layer2_state = {
853 "iter": 0,
854 "max_iter": max_iterations,
855 "best_obj": float("inf"),
856 "best_x": None, # Track the X vector corresponding to best_obj
857 "last_obj": None,
858 "prev_obj": None, # Track previous objective for rate of change
859 "eval_count": 0,
860 "start_time": time.time(),
861 "converged": False, # Track if we've effectively converged
862 "no_improvement_count": 0, # Count iterations without significant improvement
863 "small_change_count": 0, # Count iterations with very small rate of change
864 "identical_obj_count": 0, # Count consecutive evaluations with identical objective
865 "last_identical_obj": None, # Track the objective value that's repeating
866 "de_candidates": [], # Store candidates found during DE for re-scoring
867 }
868
869 def layer2_callback(xk):
870 # Determine which optimizer phase we're in ("DE" or "local").
871 # NOTE: SciPy callbacks only receive xk, so we persist phase in layer2_state.
872 phase = layer2_state.get("phase", "local")
873
874 # Check if stop was requested
875 if stop_event is not None and stop_event.is_set():
876 layer2_state["converged"] = True
877 layer2_state["stopped_by_user"] = True
878 if not layer2_state.get("stop_logged", False):
879 layer2_logger.info("⚠ Stop requested by user during local optimization")
880 layer2_state["stop_logged"] = True
881 for handler in layer2_logger.handlers:
882 handler.flush()
883 return True # Signal optimizer to stop
884
885 layer2_state["iter"] += 1
886 frac = min(layer2_state["iter"] / max(layer2_state["max_iter"], 1), 1.0)
887 progress_pct = int(frac * 100)
888 elapsed = time.time() - layer2_state["start_time"]
889
890 # Check for convergence based on rate of change (regardless of absolute objective value)
891 if layer2_state["last_obj"] is not None and layer2_state["prev_obj"] is not None:
892 # Calculate relative rate of change
893 current_obj = layer2_state["last_obj"]
894 prev_obj = layer2_state["prev_obj"]
895
896 # Check for EXACT identical values (common when stuck in flat region)
897 if abs(current_obj - prev_obj) < 1e-10:
898 # Exact match or extremely close (within numerical precision)
899 if layer2_state["last_identical_obj"] is not None:
900 if abs(current_obj - layer2_state["last_identical_obj"]) < 1e-10:
901 layer2_state["identical_obj_count"] += 1
902 else:
903 # Different identical value - reset counter
904 layer2_state["identical_obj_count"] = 1
905 layer2_state["last_identical_obj"] = current_obj
906 else:
907 layer2_state["identical_obj_count"] = 1
908 layer2_state["last_identical_obj"] = current_obj
909
910 # If we've had 5+ consecutive identical evaluations, we're stuck
911 if layer2_state["identical_obj_count"] >= 5:
912 layer2_state["converged"] = True
913 layer2_logger.info(
914 f"✓ Early convergence detected: objective is identical across "
915 f"{layer2_state['identical_obj_count']} consecutive evaluations "
916 f"(obj={current_obj:.6f}). Optimizer appears stuck in flat region."
917 )
918 else:
919 # Not identical - reset identical counter
920 layer2_state["identical_obj_count"] = 0
921 layer2_state["last_identical_obj"] = None
922
923 # Relative change: |current - previous| / max(|current|, |previous|, small_epsilon)
924 abs_change = abs(current_obj - prev_obj)
925 max_magnitude = max(abs(current_obj), abs(prev_obj), 1e-9)
926 relative_change = abs_change / max_magnitude
927
928 # If relative change is very small (< 0.1% or < 1e-4 absolute), count it
929 if relative_change < 1e-3 or abs_change < 1e-4:
930 layer2_state["small_change_count"] += 1
931 else:
932 layer2_state["small_change_count"] = 0 # Reset if we see meaningful change
933
934 # If we've had 3+ consecutive iterations with very small rate of change, we've converged
935 # Only apply this aggressive check during DE. Local optimizer needs to probe close points.
936 if phase == "DE" and layer2_state["small_change_count"] >= 3 and not layer2_state["converged"]:
937 layer2_state["converged"] = True
938 layer2_logger.info(
939 f"✓ Early convergence detected: objective rate of change is negligible "
940 f"(relative change < 0.1% for {layer2_state['small_change_count']} iterations). "
941 f"Best objective: {layer2_state['best_obj']:.6f}, "
942 f"Current: {current_obj:.6f}, Change: {abs_change:.2e} ({relative_change*100:.4f}%)"
943 )
944
945 # Also check for convergence: if objective is very low and hasn't improved much (legacy check)
946 if layer2_state["last_obj"] is not None and not layer2_state["converged"]:
947 # If objective is already very good (< 0.1), check if we've converged
948 if layer2_state["best_obj"] < 2.0:
949 # Check if improvement in last iteration was negligible
950 if layer2_state["last_obj"] is not None and layer2_state["best_obj"] is not None:
951 improvement = layer2_state["best_obj"] - layer2_state["last_obj"]
952 if improvement < 1e-5: # Very small improvement
953 layer2_state["no_improvement_count"] += 1
954 else:
955 layer2_state["no_improvement_count"] = 0
956
957 # If we've had 3+ iterations with no significant improvement and objective is already very low
958 if layer2_state["no_improvement_count"] >= 3 and layer2_state["best_obj"] < 2.0:
959 layer2_state["converged"] = True
960 layer2_logger.info(
961 f"✓ Early convergence detected: objective {layer2_state['best_obj']:.6f} "
962 f"has not improved significantly in {layer2_state['no_improvement_count']} iterations"
963 )
964
965 # Log progress
966 if layer2_state["last_obj"] is not None:
967 layer2_logger.info(f"[{progress_pct}%] Iteration {layer2_state['iter']}/{layer2_state['max_iter']} "
968 f"({elapsed:.1f}s elapsed) - "
969 f"Objective: {layer2_state['last_obj']:.6f} (Best: {layer2_state['best_obj']:.6f})")
970 else:
971 layer2_logger.info(f"[{progress_pct}%] Iteration {layer2_state['iter']}/{layer2_state['max_iter']} "
972 f"({elapsed:.1f}s elapsed)")
973
974 # Flush log to ensure it's written immediately
975 for handler in layer2_logger.handlers:
976 handler.flush()
977
978 # Call external progress callback if provided
979 if update_progress:
980 progress = 0.60 + 0.04 * frac
981 update_progress(
982 "Layer 2: Pressure Curve Optimization",
983 progress,
984 f"Layer 2 optimization {layer2_state['iter']}/{layer2_state['max_iter']} ({progress_pct}%)",
985 )
986
987 def decode_segments_from_x(
988 x_layer2: np.ndarray,
989 n_segments: int,
990 initial_lox_p: float,
991 initial_fuel_p: float,
992 min_lox_p: float,
993 min_fuel_p: float,
994 ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
995 """
996 Decode the compact optimization vector into LOX and fuel segment lists.
997
998 - Shared length ratios across LOX and fuel
999 - LOX end pressures and k are optimized directly
1000 - Fuel end pressures are derived from LOX end pressures and a bounded
1001 LOX/Fuel pressure-ratio factor per segment, keeping segment-end
1002 pressure ratios within ±25% of the initial ratio.
1003 """
1004 segments_lox: List[Dict[str, Any]] = []
1005 segments_fuel: List[Dict[str, Any]] = []
1006
1007 # Ensure we don't request more segments than available in x
1008 max_segments = len(x_layer2) // VARS_PER_SEGMENT
1009 n_segments = min(n_segments, max_segments)
1010 if n_segments < 1:
1011 n_segments = 1
1012
1013 # Normalize shared length ratios
1014 raw_lengths: List[float] = []
1015 for i in range(n_segments):
1016 base = i * VARS_PER_SEGMENT
1017 if base >= len(x_layer2):
1018 break
1019 lr_raw = float(x_layer2[base])
1020 if not np.isfinite(lr_raw):
1021 # Non-finite length variables are treated as a neutral default.
1022 # This keeps the decode step numerically robust; the associated
1023 # candidate will still be scored poorly in the objective.
1024 lr_raw = 1.0
1025 lr = float(np.clip(lr_raw, 0.01, 1.0))
1026 raw_lengths.append(lr)
1027
1028 total_lr = float(sum(raw_lengths)) if raw_lengths else 1.0
1029 # Guard against numerical edge cases (e.g., all zeros or non-finite sum).
1030 if not np.isfinite(total_lr) or total_lr <= 0:
1031 total_lr = 1.0
1032 length_ratios = [lr / total_lr for lr in raw_lengths] if raw_lengths else [1.0]
1033
1034 # Build LOX and fuel segments
1035 prev_lox_end = initial_lox_p
1036 prev_fuel_end = initial_fuel_p
1037 initial_ratio = initial_lox_p / max(initial_fuel_p, 1e-9)
1038
1039 for i in range(n_segments):
1040 base = i * VARS_PER_SEGMENT
1041 if base + 4 >= len(x_layer2):
1042 break
1043
1044 length_ratio = length_ratios[i] if i < len(length_ratios) else 1.0 / n_segments
1045
1046 # Calculate segment time duration for slope enforcement
1047 segment_time_duration = length_ratio * target_burn_time # seconds
1048
1049 # LOX end pressure ratio and k
1050 lox_end_ratio_raw = float(np.clip(x_layer2[base + 1], 0.1, 1.0))
1051 start_ratio_lox = prev_lox_end / max(initial_lox_p, 1e-9)
1052
1053 # Enforce minimum slope constraint (time-based)
1054 # Calculate minimum pressure drop based on time and slope requirement
1055 # min_pressure_slope_psi_per_sec is negative (pressure decreasing), so we use abs()
1056 min_pressure_drop_pa = abs(min_pressure_slope_psi_per_sec) * 6894.76 * segment_time_duration
1057
1058 # Calculate maximum allowed end pressure (start - minimum drop)
1059 max_allowed_end_pressure_lox = prev_lox_end - min_pressure_drop_pa
1060
1061 # Convert to ratio relative to initial pressure
1062 max_allowed_end_ratio_lox = max_allowed_end_pressure_lox / max(initial_lox_p, 1e-9)
1063
1064 # Enforce both the optimizer's choice and the minimum slope
1065 # Also keep the old 0.99 constraint as an upper bound (less restrictive fallback)
1066 lox_end_ratio = min(lox_end_ratio_raw, start_ratio_lox * 0.99, max_allowed_end_ratio_lox)
1067
1068 lox_end_p = initial_lox_p * lox_end_ratio
1069 # Ensure monotonic decrease and enforce LOX minimum floor.
1070 lox_end_p = max(min_lox_p, min(lox_end_p, prev_lox_end))
1071 lox_k = float(np.clip(x_layer2[base + 2], 0.1, 2.0))
1072
1073 # Fuel ratio factor (keeps segment-end ratio within ±25% of initial)
1074 fuel_ratio_factor = float(np.clip(x_layer2[base + 3], 0.75, 1.25))
1075 seg_ratio = initial_ratio * fuel_ratio_factor
1076 fuel_end_p = lox_end_p / max(seg_ratio, 1e-9)
1077
1078 # Apply same minimum slope constraint to fuel
1079 max_allowed_end_pressure_fuel = prev_fuel_end - min_pressure_drop_pa
1080 # Ensure fuel end pressure respects both the ratio-based calculation and minimum slope
1081 fuel_end_p = min(fuel_end_p, max_allowed_end_pressure_fuel)
1082
1083 # Ensure monotonic decrease and enforce fuel minimum floor.
1084 fuel_end_p = max(min_fuel_p, min(fuel_end_p, prev_fuel_end))
1085 fuel_k = float(np.clip(x_layer2[base + 4], 0.1, 2.0))
1086
1087
1088 seg_lox = {
1089 "length_ratio": length_ratio,
1090 "type": "blowdown",
1091 "start_pressure": prev_lox_end,
1092 "end_pressure": lox_end_p,
1093 "k": lox_k,
1094 }
1095 seg_fuel = {
1096 "length_ratio": length_ratio,
1097 "type": "blowdown",
1098 "start_pressure": prev_fuel_end,
1099 "end_pressure": fuel_end_p,
1100 "k": fuel_k,
1101 }
1102 segments_lox.append(seg_lox)
1103 segments_fuel.append(seg_fuel)
1104 prev_lox_end = lox_end_p
1105 prev_fuel_end = fuel_end_p
1106
1107 return segments_lox, segments_fuel
1108
1109 def layer2_objective(
1110 x_layer2,
1111 time_eval_override: Optional[np.ndarray] = None,
1112 n_points: Optional[int] = None,
1113 phase: str = "local",
1114 ):
1115 """Optimize pressure curves after initial point. Initial pressures are fixed from Layer 1.
1116
1117 Args:
1118 x_layer2: Optimization variable vector.
1119 time_eval_override: Optional time array to use for this evaluation
1120 (e.g., coarse grid for DE). If None, uses the full-resolution
1121 `time_array` defined above.
1122 n_points: Optional number of time points. If None, uses the global
1123 `n_time_points` defined above.
1124 phase: Short label for logging (e.g., "DE" or "local").
1125 """
1126 # Persist phase for any external callbacks (SciPy callback only gets xk).
1127 layer2_state["phase"] = phase
1128
1129 # Check if stop was requested before starting evaluation
1130 if stop_event is not None and stop_event.is_set():
1131 # User requested stop - return best objective found so far to signal convergence
1132 best_obj = layer2_state.get("best_obj", 1e6)
1133 layer2_state["converged"] = True
1134 layer2_state["stopped_by_user"] = True
1135 if not layer2_state.get("stop_logged", False):
1136 layer2_logger.info("⚠ Stop requested by user - halting optimization")
1137 layer2_state["stop_logged"] = True
1138 for handler in layer2_logger.handlers:
1139 handler.flush()
1140 return float(best_obj) if np.isfinite(best_obj) else 1e6
1141
1142 eval_start_time = time.time()
1143 layer2_state["eval_count"] += 1
1144 eval_num = layer2_state["eval_count"]
1145
1146 def finish_evaluation(final_obj: float) -> float:
1147 """Helper to finalize evaluation: update state, call callbacks, and return value."""
1148 # Save previous values before updating
1149 prev_obj = layer2_state.get("prev_obj")
1150 last_obj = layer2_state.get("last_obj")
1151
1152 # Update best objective if this is an improvement
1153 # (Note: failed evaluations with 1e6 usually won't be improvements, but we check anyway)
1154 improvement = None
1155 if final_obj < layer2_state["best_obj"]:
1156 improvement = layer2_state["best_obj"] - final_obj
1157 layer2_state["best_obj"] = final_obj
1158 # Save the X vector if it's an improvement (and finite)
1159 # Note: x_layer2 comes from the outer scope
1160 if np.all(np.isfinite(np.asarray(x_layer2, dtype=float))):
1161 layer2_state["best_x"] = np.array(x_layer2, copy=True)
1162
1163 # Reset convergence counters if we made significant progress
1164 if improvement > 1e-5:
1165 layer2_state["no_improvement_count"] = 0
1166 layer2_state["small_change_count"] = 0
1167 layer2_state["identical_obj_count"] = 0
1168 layer2_state["last_identical_obj"] = None
1169
1170 # Update last objective for logging/progress tracking
1171 layer2_state["prev_obj"] = last_obj
1172 layer2_state["last_obj"] = final_obj
1173
1174 # Check for convergence (works for both DE and local phases)
1175 # This check happens after updating state so we can compare current vs previous
1176 if not layer2_state.get("converged", False) and last_obj is not None:
1177 current_obj = final_obj
1178
1179 # Check for EXACT identical values (common when stuck in flat region)
1180 if abs(current_obj - last_obj) < 1e-10:
1181 if layer2_state["last_identical_obj"] is not None:
1182 if abs(current_obj - layer2_state["last_identical_obj"]) < 1e-10:
1183 layer2_state["identical_obj_count"] += 1
1184 else:
1185 layer2_state["identical_obj_count"] = 1
1186 layer2_state["last_identical_obj"] = current_obj
1187 else:
1188 layer2_state["identical_obj_count"] = 1
1189 layer2_state["last_identical_obj"] = current_obj
1190
1191 # If we've had 5+ consecutive identical evaluations, we're stuck
1192 if layer2_state["identical_obj_count"] >= 5:
1193 layer2_state["converged"] = True
1194 layer2_logger.info(
1195 f"✓ Early convergence detected [{phase}]: objective is identical across "
1196 f"{layer2_state['identical_obj_count']} consecutive evaluations "
1197 f"(obj={current_obj:.6f}). Optimizer appears stuck in flat region."
1198 )
1199 for handler in layer2_logger.handlers:
1200 handler.flush()
1201 else:
1202 # Not identical - reset identical counter
1203 layer2_state["identical_obj_count"] = 0
1204 layer2_state["last_identical_obj"] = None
1205
1206 # Relative change: |current - previous| / max(|current|, |previous|, small_epsilon)
1207 abs_change = abs(current_obj - last_obj)
1208 max_magnitude = max(abs(current_obj), abs(last_obj), 1e-9)
1209 relative_change = abs_change / max_magnitude
1210
1211 # If relative change is very small (< 0.1% or < 1e-4 absolute), count it
1212 if relative_change < 1e-3 or abs_change < 1e-4:
1213 layer2_state["small_change_count"] += 1
1214 else:
1215 layer2_state["small_change_count"] = 0 # Reset if we see meaningful change
1216
1217 # If we've had 3+ consecutive iterations with very small rate of change, we've converged
1218 if layer2_state["small_change_count"] >= 3:
1219 layer2_state["converged"] = True
1220 layer2_logger.info(
1221 f"✓ Early convergence detected [{phase}]: objective rate of change is negligible "
1222 f"(relative change < 0.1% for {layer2_state['small_change_count']} iterations). "
1223 f"Best objective: {layer2_state['best_obj']:.6f}, "
1224 f"Current: {current_obj:.6f}, Change: {abs_change:.2e} ({relative_change*100:.4f}%)"
1225 )
1226 for handler in layer2_logger.handlers:
1227 handler.flush()
1228
1229 # Also check for convergence: if objective is very low and hasn't improved much
1230 # Only apply this aggressive check during DE (Global Search).
1231 # Local optimization (L-BFGS-B) needs to probe worse points for gradient estimation,
1232 # so we shouldn't kill it just because it hasn't improved in 3 evaluations.
1233 if phase == "DE" and layer2_state["best_obj"] < 2.0:
1234 # Check if best hasn't improved relative to previous best
1235 if improvement is None or improvement < 1e-5: # Very small or no improvement
1236 layer2_state["no_improvement_count"] += 1
1237 else:
1238 layer2_state["no_improvement_count"] = 0
1239
1240 # If we've had 3+ iterations with no significant improvement and objective is already very low
1241 if layer2_state["no_improvement_count"] >= 3:
1242 layer2_state["converged"] = True
1243 layer2_logger.info(
1244 f"✓ Early convergence detected [{phase}]: objective {layer2_state['best_obj']:.6f} "
1245 f"has not improved significantly in {layer2_state['no_improvement_count']} iterations"
1246 )
1247 for handler in layer2_logger.handlers:
1248 handler.flush()
1249
1250 # Check if we've already converged (from callback or objective function) - log if this happens
1251 if layer2_state.get("converged", False):
1252 # This evaluation happened after convergence was detected
1253 # The optimizer should stop soon, but we'll let it finish this evaluation
1254 pass
1255
1256 # Capture candidates during Global Search (DE) phase for Top-K re-scoring
1257 if phase == "DE" and np.isfinite(final_obj) and final_obj < 1e5:
1258 # Store copy of x to avoid mutation issues
1259 if np.all(np.isfinite(np.asarray(x_layer2, dtype=float))):
1260 layer2_state.setdefault("de_candidates", []).append((float(final_obj), np.array(x_layer2, copy=True)))
1261
1262 # Update stream of objective history
1263 if objective_callback is not None:
1264 try:
1265 objective_callback(
1266 int(layer2_state["eval_count"]),
1267 float(final_obj),
1268 float(layer2_state["best_obj"]),
1269 )
1270 except Exception:
1271 # Never let UI/consumer callback break the optimizer loop
1272 pass
1273
1274 return float(final_obj)
1275
1276 # Ensure the optimization vector is finite. If any component is NaN or
1277 # Inf, treat this candidate as invalid and return a large penalty so
1278 # the optimizer moves away from this region instead of propagating
1279 # non-finite values into the segment decode / pressure generation.
1280 x_layer2 = np.asarray(x_layer2, dtype=float)
1281 if not np.all(np.isfinite(x_layer2)):
1282 layer2_logger.warning(
1283 f" → Evaluation #{eval_num} [{phase}] received non-finite x_layer2; "
1284 "returning large penalty objective."
1285 )
1286 for handler in layer2_logger.handlers:
1287 handler.flush()
1288 return finish_evaluation(1e6)
1289
1290 # Early return if we've already converged (rate of change is negligible)
1291 # This helps the optimizer stop faster by returning a constant value
1292 # Works for both DE and local phases
1293 if layer2_state.get("converged", False):
1294 best_obj = layer2_state.get("best_obj", 1e6)
1295 if np.isfinite(best_obj):
1296 # Return the best objective we've seen - this constant value will
1297 # trigger the tolerance-based stopping in L-BFGS-B (for local phase)
1298 # For DE, returning constant values helps it recognize convergence
1299 return finish_evaluation(float(best_obj))
1300
1301 # Select resolution for this objective call
1302 if n_points is None:
1303 n_points = n_time_points
1304 if time_eval_override is None:
1305 time_eval = time_array.copy()
1306 else:
1307 time_eval = time_eval_override.copy()
1308
1309 try:
1310 # Log every evaluation (important for long-running evaluations)
1311 layer2_logger.info(
1312 f" → Evaluation #{eval_num} [{phase}]: {N_SEGMENTS} shared segments (LOX/Fuel, {n_points} pts)"
1313 )
1314 for handler in layer2_logger.handlers:
1315 handler.flush()
1316
1317 # Decode shared parameterization into LOX and fuel segments
1318 lox_segments, fuel_segments = decode_segments_from_x(
1319 x_layer2,
1320 N_SEGMENTS,
1321 initial_lox_pressure_pa,
1322 initial_fuel_pressure_pa,
1323 local_min_lox_floor,
1324 local_min_fuel_floor,
1325 )
1326
1327 # Generate pressure curves at the chosen resolution (already floored to minima)
1328 P_tank_O_array = generate_pressure_curve_from_segments(lox_segments, n_points)
1329 P_tank_F_array = generate_pressure_curve_from_segments(fuel_segments, n_points)
1330
1331 # Ensure first point is exactly the initial pressure (fixed from Layer 1)
1332 P_tank_O_array[0] = initial_lox_pressure_pa
1333 P_tank_F_array[0] = initial_fuel_pressure_pa
1334
1335 # Hard constraint for global search: reject pressure curves where LOX/Fuel
1336 # pressure ratio deviates too far from the initial ratio during the
1337 # physically relevant part of the burn (well above the min-pressure clamp).
1338 # This keeps differential_evolution from wasting evaluations on clearly
1339 # infeasible LOX/Fuel pressure relationships while ignoring tail-end
1340 # artifacts when both tanks are clamped near min_pressure_pa.
1341 initial_pressure_ratio = initial_lox_pressure_pa / max(initial_fuel_pressure_pa, 1e-9)
1342 active_min_floor = min(local_min_lox_floor, local_min_fuel_floor)
1343 active_mask = (P_tank_O_array > 1.1 * active_min_floor) & (P_tank_F_array > 1.1 * active_min_floor)
1344 if np.any(active_mask):
1345 pressure_ratio_array = P_tank_O_array[active_mask] / np.maximum(P_tank_F_array[active_mask], 1e-9)
1346 ratio_error_array = np.abs(pressure_ratio_array - initial_pressure_ratio) / max(
1347 initial_pressure_ratio, 1e-9
1348 )
1349 if np.any(ratio_error_array > 0.25):
1350 max_error = float(np.max(ratio_error_array))
1351 max_excess = float(np.max(ratio_error_array - 0.25))
1352 # Large objective value so DE moves away from this region, but keep it finite
1353 # so the optimizer remains numerically well-behaved.
1354 hard_ratio_penalty = 5e4 + max_excess * 1e4
1355 layer2_logger.info(
1356 f" Skipping time series solve for eval #{eval_num}: "
1357 f"pressure ratio error {max_error:.3f} (> 0.25 allowed) in active burn window"
1358 )
1359 for handler in layer2_logger.handlers:
1360 handler.flush()
1361 return finish_evaluation(hard_ratio_penalty)
1362
1363 # Save plot if requested (before running expensive solver)
1364 if save_evaluation_plots:
1365 save_evaluation_plot(
1366 eval_num=eval_num,
1367 time_arr=time_eval,
1368 P_lox=P_tank_O_array,
1369 P_fuel=P_tank_F_array,
1370 n_seg_lox=N_SEGMENTS,
1371 n_seg_fuel=N_SEGMENTS,
1372 )
1373
1374 # Run time series evaluation (ablative/graphite disabled in config)
1375 layer2_logger.info(f" Running time series solver ({n_points} points)...")
1376 for handler in layer2_logger.handlers:
1377 handler.flush()
1378
1379 ts_start = time.time()
1380 results_layer2 = runner_layer2.evaluate_arrays_with_time(
1381 time_eval,
1382 P_tank_O_array,
1383 P_tank_F_array,
1384 track_ablative_geometry=False, # Disable ablative tracking
1385 use_coupled_solver=False, # Use simpler solver
1386 )
1387 ts_time = time.time() - ts_start
1388 layer2_logger.info(f" Time series solver completed in {ts_time:.2f}s")
1389 for handler in layer2_logger.handlers:
1390 handler.flush()
1391
1392 # Get thrust history
1393 thrust_hist = np.atleast_1d(results_layer2.get("F", np.full(n_points, peak_thrust)))
1394 available_n = min(thrust_hist.shape[0], n_points)
1395 if available_n < 1:
1396 layer2_logger.warning(f" No valid time points in results for eval #{eval_num}; returning large penalty.")
1397 for handler in layer2_logger.handlers:
1398 handler.flush()
1399 return finish_evaluation(1e6)
1400
1401 thrust_hist = thrust_hist[:available_n]
1402 time_hist = time_eval[:available_n]
1403
1404 # CRITICAL: Validate that results are not mostly NaN/invalid
1405 # Check if too many time steps failed (more than 50% NaN is unacceptable)
1406 finite_thrust_mask = np.isfinite(thrust_hist)
1407 finite_thrust_count = np.sum(finite_thrust_mask)
1408 finite_thrust_ratio = finite_thrust_count / available_n if available_n > 0 else 0.0
1409
1410 if finite_thrust_ratio < 0.5:
1411 # More than 50% of time steps failed - reject this solution
1412 layer2_logger.warning(
1413 f" Too many failed time steps for eval #{eval_num}: "
1414 f"only {finite_thrust_count}/{available_n} ({finite_thrust_ratio*100:.1f}%) have valid thrust. "
1415 f"Returning large penalty."
1416 )
1417 for handler in layer2_logger.handlers:
1418 handler.flush()
1419 return finish_evaluation(1e6)
1420
1421 # Check if critical metrics (thrust, OF) are valid
1422 # If average thrust is NaN or zero, reject
1423 valid_thrust_values = thrust_hist[finite_thrust_mask]
1424 if len(valid_thrust_values) == 0:
1425 layer2_logger.warning(
1426 f" No valid thrust values for eval #{eval_num}; returning large penalty."
1427 )
1428 for handler in layer2_logger.handlers:
1429 handler.flush()
1430 return finish_evaluation(1e6)
1431
1432 avg_thrust = float(np.mean(valid_thrust_values))
1433 if not np.isfinite(avg_thrust) or avg_thrust <= 0:
1434 layer2_logger.warning(
1435 f" Invalid average thrust ({avg_thrust}) for eval #{eval_num}; returning large penalty."
1436 )
1437 for handler in layer2_logger.handlers:
1438 handler.flush()
1439 return finish_evaluation(1e6)
1440
1441 # Check OF ratio validity
1442 MR_hist = np.atleast_1d(results_layer2.get("MR", np.full(available_n, optimal_of_ratio if optimal_of_ratio else 2.3)))
1443 MR_hist = MR_hist[:available_n]
1444 finite_MR_mask = np.isfinite(MR_hist) & (MR_hist > 0) & (MR_hist < 100) # Reasonable bounds
1445 finite_MR_count = np.sum(finite_MR_mask)
1446
1447 if finite_MR_count < available_n * 0.5:
1448 # More than 50% of OF ratios are invalid - reject
1449 layer2_logger.warning(
1450 f" Too many invalid OF ratios for eval #{eval_num}: "
1451 f"only {finite_MR_count}/{available_n} ({finite_MR_count/available_n*100:.1f}%) are valid. "
1452 f"Returning large penalty."
1453 )
1454 for handler in layer2_logger.handlers:
1455 handler.flush()
1456 return finish_evaluation(1e6)
1457
1458 # Now safely replace NaN values with defaults for remaining calculations
1459 # (but we've already validated that we have enough valid data)
1460 thrust_hist = np.nan_to_num(
1461 thrust_hist,
1462 nan=0.0,
1463 posinf=1e6,
1464 neginf=-1e6,
1465 )
1466
1467 # Note: Initial pressures are fixed from Layer 1 and assumed to produce peak_thrust
1468 # We do not optimize or check initial thrust - it's already correct from Layer 1
1469
1470 # Calculate actual propellant mass consumed by integrating mass flow rates
1471 mdot_O_hist = np.atleast_1d(results_layer2.get("mdot_O", np.zeros(available_n)))
1472 mdot_F_hist = np.atleast_1d(results_layer2.get("mdot_F", np.zeros(available_n)))
1473 mdot_O_hist = mdot_O_hist[:available_n]
1474 mdot_F_hist = mdot_F_hist[:available_n]
1475 # Clamp non-finite mass-flow values; NaNs here would otherwise
1476 # propagate into impulse / capacity penalties and stall the search.
1477 mdot_O_hist = np.nan_to_num(
1478 mdot_O_hist,
1479 nan=0.0,
1480 posinf=0.0,
1481 neginf=0.0,
1482 )
1483 mdot_F_hist = np.nan_to_num(
1484 mdot_F_hist,
1485 nan=0.0,
1486 posinf=0.0,
1487 neginf=0.0,
1488 )
1489
1490 # Integrate mass flow rates to get total propellant consumed
1491 total_lox_mass = float(np.trapezoid(mdot_O_hist, time_hist)) # kg
1492 total_fuel_mass = float(np.trapezoid(mdot_F_hist, time_hist)) # kg
1493 total_propellant_mass = total_lox_mass + total_fuel_mass
1494
1495 # Calculate required impulse based on actual propellant consumption
1496 required_impulse = calculate_required_impulse_from_mass(
1497 target_apogee_m,
1498 rocket_dry_mass_kg,
1499 total_propellant_mass,
1500 target_burn_time,
1501 time_s=time_hist,
1502 mdot_kg_s=mdot_O_hist + mdot_F_hist,
1503 thrust_n=thrust_hist,
1504 vehicle=flight_vehicle,
1505 )
1506
1507 # Check 1: Total impulse must be >= required impulse
1508 total_impulse = float(np.trapezoid(thrust_hist, time_hist)) # N·s
1509 impulse_deficit = max(0, required_impulse - total_impulse)
1510 impulse_penalty = (impulse_deficit / max(required_impulse, 1e-9)) * 200.0 # Large penalty if insufficient
1511
1512 # Soft preference: keep the burn active closer to the target_burn_time.
1513 # We measure when 95% of the total impulse has been delivered; if that
1514 # happens too early relative to target_burn_time, we add a penalty.
1515 burn_time_penalty = 0.0
1516 if total_impulse > 0:
1517 # Cumulative impulse over time
1518 cumulative_impulse = cumulative_trapezoid(thrust_hist, time_hist, initial=0.0)
1519 impulse_95 = 0.95 * total_impulse
1520 idx_95 = np.searchsorted(cumulative_impulse, impulse_95, side="right")
1521 if idx_95 >= len(time_hist):
1522 idx_95 = len(time_hist) - 1
1523 t95 = float(time_hist[idx_95])
1524 # If 95% of impulse is achieved significantly before target_burn_time,
1525 # penalize the "slack" time to encourage more impulse later in the burn.
1526 burn_completion_slack = max(0.0, target_burn_time - t95)
1527 # Normalize by target_burn_time so penalty is dimensionless.
1528 burn_time_penalty = (burn_completion_slack / max(target_burn_time, 1e-9)) * 20.0
1529
1530 # Check 2: Propellant mass must not exceed tank capacity
1531 lox_capacity_exceeded = max(0, total_lox_mass - max_lox_tank_capacity_kg)
1532 fuel_capacity_exceeded = max(0, total_fuel_mass - max_fuel_tank_capacity_kg)
1533 capacity_penalty = 0.0
1534 if lox_capacity_exceeded > 0:
1535 capacity_penalty += (lox_capacity_exceeded / max(max_lox_tank_capacity_kg, 1e-9)) * 300.0
1536 if fuel_capacity_exceeded > 0:
1537 capacity_penalty += (fuel_capacity_exceeded / max(max_fuel_tank_capacity_kg, 1e-9)) * 300.0
1538
1539 # Check 3: Stability (must pass minimum threshold)
1540 stability_scores = results_layer2.get("stability_score", None)
1541 if stability_scores is not None:
1542 stability_scores = np.nan_to_num(
1543 np.atleast_1d(stability_scores),
1544 nan=0.0,
1545 posinf=0.0,
1546 neginf=0.0,
1547 )
1548 min_stability = float(np.min(stability_scores))
1549 else:
1550 chugging = results_layer2.get("chugging_stability_margin", np.array([1.0]))
1551 chugging = np.nan_to_num(
1552 np.atleast_1d(chugging),
1553 nan=0.0,
1554 posinf=0.0,
1555 neginf=0.0,
1556 )
1557 min_stability = max(0.0, min(1.0, (float(np.min(chugging)) - 0.3) * 1.5))
1558
1559 stability_penalty = 0.0
1560 if min_stability_margin is not None:
1561 # Check against minimum stability margin
1562 chugging_margins = results_layer2.get("chugging_stability_margin", np.array([1.0]))
1563 chugging_margins = np.nan_to_num(
1564 np.atleast_1d(chugging_margins),
1565 nan=0.0,
1566 posinf=0.0,
1567 neginf=0.0,
1568 )
1569 min_chugging = float(np.min(chugging_margins))
1570 if min_chugging < min_stability_margin:
1571 stability_penalty = (min_stability_margin - min_chugging) * 50.0
1572 else:
1573 # Default: penalty if stability score < 0.7
1574 stability_penalty = max(0, 0.7 - min_stability) * 10.0
1575
1576 # Check 4: O/F ratio (if specified)
1577 # Note: MR_hist was already extracted and validated above
1578 of_penalty = 0.0
1579 if optimal_of_ratio is not None:
1580 # Use only valid MR values for calculation
1581 valid_MR_values = MR_hist[finite_MR_mask]
1582 if len(valid_MR_values) > 0:
1583 # Calculate pointwise relative deviation
1584 # We want to check O/F along the curve, not just average
1585 deviations = np.abs(valid_MR_values - optimal_of_ratio) / max(optimal_of_ratio, 1e-9)
1586
1587 # 5% Deadband logic
1588 # Inside deadband (<= 0.05): small penalty
1589 # Outside deadband (> 0.05): steeper penalty
1590 deadband = 0.05
1591
1592 # Calculate per-point penalty
1593 # If dev <= deadband: penalty = dev * 1.0 (light guidance)
1594 # If dev > deadband: penalty = deadband * 1.0 + (dev - deadband) * 20.0 (strong enforcement)
1595 pointwise_penalties = np.where(
1596 deviations <= deadband,
1597 deviations * 1.0,
1598 deadband * 1.0 + (deviations - deadband) * 20.0
1599 )
1600
1601 # Compute mean and max penalties
1602 mean_penalty = np.mean(pointwise_penalties)
1603 max_penalty = np.max(pointwise_penalties)
1604
1605 # Weighted combination: 40% mean, 60% max
1606 # This balance ensures overall stability while penalizing spikes
1607 combined_penalty = 0.4 * mean_penalty + 0.6 * max_penalty
1608
1609 # Scale factor to match other objective terms magnitude
1610 of_penalty = combined_penalty * 50.0
1611 else:
1612 # No valid MR values - large penalty
1613 of_penalty = 100.0
1614
1615 # Check 5: Chamber Pressure Stability
1616 # We want to ensure Pc doesn't drift more than 25% from initial value
1617 pc_penalty = 0.0
1618 if "Pc" in results_layer2:
1619 Pc_hist = np.atleast_1d(results_layer2["Pc"])
1620 Pc_hist = Pc_hist[:available_n]
1621 # Filter for valid Pc values
1622 valid_Pc_mask = np.isfinite(Pc_hist) & (Pc_hist > 0)
1623 valid_Pc = Pc_hist[valid_Pc_mask]
1624
1625 if len(valid_Pc) > 0:
1626 # Use the first valid point as the initial/reference pressure
1627 # (Layer 1 fixes the initial state, so t=0 should be correct)
1628 Pc_initial = valid_Pc[0]
1629
1630 if Pc_initial > 0:
1631 # Calculate relative deviation
1632 deviations = np.abs(valid_Pc - Pc_initial) / Pc_initial
1633
1634 # Threshold is 25% (0.25)
1635 # We only penalize points that exceed this threshold
1636 excess_deviations = np.maximum(0.0, deviations - 0.25)
1637
1638 if np.any(excess_deviations > 0):
1639 # Strong penalty for violations
1640 # Scaling: 10% excess drift -> 0.1 * 1000 = 100 penalty points
1641 # This is significant but allows for smooth gradient
1642 pc_penalty = float(np.mean(excess_deviations)) * 1000.0
1643 else:
1644 # No valid Pc values - should be caught by thrust check, but just in case
1645 pc_penalty = 100.0
1646
1647 # Check 6: COPV Initial Pressure Optimization
1648 # Run COPV solver to determine minimum required initial pressure (P0_Pa)
1649 # Lower P0 is better (lighter tank, lower cost), so we add a penalty term
1650 copv_penalty = 0.0
1651 copv_results = None
1652 try:
1653 # Create DataFrame for COPV solver
1654 # Convert pressures from Pa to psi for COPV solver
1655 P_tank_O_psi = P_tank_O_array / 6894.76
1656 P_tank_F_psi = P_tank_F_array / 6894.76
1657
1658 df_copv = pd.DataFrame({
1659 "time": time_eval,
1660 "mdot_O (kg/s)": mdot_O_hist,
1661 "mdot_F (kg/s)": mdot_F_hist,
1662 "P_tank_O (psi)": P_tank_O_psi,
1663 "P_tank_F (psi)": P_tank_F_psi,
1664 })
1665
1666
1667 # Extract COPV volume from config
1668 # The COPV solver needs the volume in m³
1669 copv_volume_m3 = None
1670 if hasattr(config_layer2, "press_tank") and hasattr(config_layer2.press_tank, "free_volume_L"):
1671 # Convert from liters to m³
1672 copv_volume_m3 = float(config_layer2.press_tank.free_volume_L) / 1000.0
1673 elif hasattr(config_layer2, "press_tank") and hasattr(config_layer2.press_tank, "press_volume"):
1674 copv_volume_m3 = float(config_layer2.press_tank.press_volume)
1675 elif hasattr(config_layer2, "press_tank") and hasattr(config_layer2.press_tank, "volume_m3"):
1676 copv_volume_m3 = float(config_layer2.press_tank.volume_m3)
1677
1678 if copv_volume_m3 is None or copv_volume_m3 <= 0:
1679 # No valid COPV volume - skip COPV penalty
1680 copv_penalty = 0.0
1681 layer2_logger.warning(f" COPV volume not found in config, skipping COPV penalty")
1682 else:
1683 # Call COPV solver with explicit volume to find required P0
1684 copv_results = size_or_check_copv_for_polytropic_N2(
1685 df_copv,
1686 config_layer2,
1687 n=1.2, # polytropic exponent
1688 T0_K=300.0, # initial COPV temperature
1689 Tp_K=293.0, # default propellant gas temp
1690 use_real_gas=True, # use Z lookup table
1691 n2_Z_csv=str(N2_Z_LOOKUP_CSV),
1692 pressurant_R=296.8, # gas constant for N2
1693 branch_temperatures_K={
1694 "oxidizer": 250.0, # oxidizer gas temp
1695 "fuel": 293.0, # fuel gas temp
1696 },
1697 copv_volume_m3=copv_volume_m3,
1698 copv_P0_Pa=None, # Solve for P0
1699 )
1700
1701 # Extract initial pressure requirement
1702 P0_Pa = copv_results.get("P0_Pa", 30e6) # Default to 30 MPa if missing
1703
1704 # Penalty: normalize by reference pressure (30 MPa) and apply weight
1705 # This encourages lower COPV pressures while balancing other objectives
1706 reference_pressure = 30e6 # 30 MPa reference
1707 copv_penalty = (P0_Pa / reference_pressure) * 10.0
1708
1709 layer2_logger.info(f" COPV: P0={P0_Pa/1e6:.2f} MPa ({P0_Pa/6894.76:.0f} psi), penalty={copv_penalty:.2f}")
1710
1711 except Exception as e:
1712 # COPV solver failed - apply large penalty to discourage this solution
1713 copv_penalty = 1000.0
1714 layer2_logger.warning(f" COPV solver failed for eval #{eval_num}: {type(e).__name__}: {str(e)}")
1715 # Log full traceback for FileNotFoundError to see what file is missing
1716 if isinstance(e, FileNotFoundError):
1717 import traceback
1718 layer2_logger.warning(f" Full traceback:\n{traceback.format_exc()}")
1719 layer2_logger.warning(f" Applying large COPV penalty: {copv_penalty:.2f}")
1720
1721 # Check 7: Robust DDP Controller Simulation (Optional)
1722 controller_penalty = 0.0
1723 if use_controller_simulation and finite_thrust_ratio >= 0.5:
1724 # Add controller tracking penalty
1725 try:
1726 from engine.control.robust_ddp.controller import RobustDDPController
1727 from engine.control.robust_ddp.data_models import ControllerConfig, Measurement, NavState, Command, CommandType
1728 from engine.control.robust_ddp.dynamics import step, DynamicsParams
1729
1730 cfg = controller_config if controller_config else ControllerConfig()
1731 dyn_params = DynamicsParams.from_config(cfg)
1732 ctrl = RobustDDPController(cfg, optimized_config)
1733
1734 # Simulation settings
1735 sim_dt = cfg.dt
1736 num_steps = int(target_burn_time / sim_dt)
1737 if num_steps <= 0:
1738 num_steps = 1
1739
1740 # Initial state for simulation
1741 x_sim = np.array([
1742 30e6, # P_copv (Pa)
1743 24e6, # P_reg (Pa)
1744 initial_fuel_pressure_pa, # P_u_F (Pa)
1745 initial_lox_pressure_pa, # P_u_O (Pa)
1746 initial_fuel_pressure_pa * 0.95, # P_d_F (Pa)
1747 initial_lox_pressure_pa * 0.95, # P_d_O (Pa)
1748 0.01, # V_u_F (m^3)
1749 0.01, # V_u_O (m^3)
1750 ])
1751
1752 nav = NavState(h=0.0, vz=0.0, theta=0.0, mass_estimate=100.0)
1753 total_controller_cost = 0.0
1754
1755 import scipy.interpolate
1756 # Create interpolation functions for reference targets from layer2 candidate
1757 f_ref_interp = scipy.interpolate.interp1d(time_hist, thrust_hist, bounds_error=False, fill_value=(thrust_hist[0], thrust_hist[-1]))
1758 mr_ref_interp = scipy.interpolate.interp1d(time_hist, MR_hist, bounds_error=False, fill_value=(MR_hist[0], MR_hist[-1]))
1759
1760 max_obj_min = 0.0
1761
1762 for k_sim in range(num_steps):
1763 t_sim = k_sim * sim_dt
1764 # Current references
1765 curr_f_ref = float(f_ref_interp(t_sim))
1766 curr_mr_ref = float(mr_ref_interp(t_sim))
1767
1768 cmd = Command(
1769 command_type=CommandType.THRUST_DESIRED,
1770 thrust_desired=curr_f_ref,
1771 )
1772 # We could also pass mr_ref but the controller might use its fixed target inside command.
1773 # Assuming the controller uses cmd.thrust_desired
1774
1775 meas = Measurement(
1776 P_copv=x_sim[0],
1777 P_reg=x_sim[1],
1778 P_u_fuel=x_sim[2],
1779 P_u_ox=x_sim[3],
1780 P_d_fuel=x_sim[4],
1781 P_d_ox=x_sim[5]
1782 )
1783
1784 actuation_cmd, diagnostics = ctrl.step(meas, nav, cmd)
1785
1786 # Extract the DDP objective cost or tracking error
1787 # Diagnostics returns 'cost' or 'objective' if implemented
1788 step_cost = float(diagnostics.get("cost", 0.0))
1789 total_controller_cost += step_cost
1790
1791 # Track the "max of the min" (max cost step = worst tracking point)
1792 if step_cost > max_obj_min:
1793 max_obj_min = step_cost
1794
1795 # Step forward
1796 u_act = np.array([actuation_cmd.duty_F, actuation_cmd.duty_O])
1797
1798 # Simple mass flow estimate for dynamics
1799 try:
1800 eng_est = ctrl.engine_wrapper.estimate_from_pressures(x_sim[4], x_sim[5])
1801 sim_mdot_F = eng_est.mdot_F if np.isfinite(eng_est.mdot_F) else 0.0
1802 sim_mdot_O = eng_est.mdot_O if np.isfinite(eng_est.mdot_O) else 0.0
1803 except Exception:
1804 sim_mdot_F = 0.0
1805 sim_mdot_O = 0.0
1806
1807 x_sim = step(x_sim, u_act, sim_dt, dyn_params, sim_mdot_F, sim_mdot_O)
1808
1809 # Add penalty proportional to the worst-case (max) step cost across the trajectory
1810 controller_penalty = max_obj_min * 10.0 + (total_controller_cost / max(num_steps, 1)) * 5.0
1811
1812 if np.isfinite(controller_penalty):
1813 layer2_logger.info(f" Controller penalty: {controller_penalty:.2f} (max step cost: {max_obj_min:.2f})")
1814 else:
1815 controller_penalty = 1000.0 # Safe clamp
1816
1817 except Exception as e:
1818 layer2_logger.warning(f" Controller simulation failed for eval #{eval_num}: {type(e).__name__}: {str(e)}")
1819 controller_penalty = 1000.0
1820
1821 # Objective: minimize penalties (no initial thrust penalty - it's fixed from Layer 1)
1822 components = [
1823 impulse_penalty,
1824 burn_time_penalty,
1825 capacity_penalty,
1826 stability_penalty,
1827 of_penalty,
1828 pc_penalty,
1829 copv_penalty,
1830 controller_penalty,
1831 ]
1832 if not all(np.isfinite(c) for c in components):
1833 # If any penalty component is non-finite, treat this evaluation
1834 # as invalid and return a large penalty. This prevents NaNs from
1835 # leaking back into the optimizer and corrupting x_layer2.
1836 layer2_logger.warning(
1837 f" Non-finite penalty components in eval #{eval_num}; "
1838 "returning large penalty objective."
1839 )
1840 for handler in layer2_logger.handlers:
1841 handler.flush()
1842 return finish_evaluation(1e6)
1843
1844 obj = sum(components)
1845
1846 eval_time = time.time() - eval_start_time
1847
1848 # Update plot with objective value if plots are being saved
1849 if save_evaluation_plots and phase == "local":
1850 # Re-save plot with objective value
1851 save_evaluation_plot(
1852 eval_num=eval_num,
1853 time_arr=time_eval,
1854 P_lox=P_tank_O_array,
1855 P_fuel=P_tank_F_array,
1856 objective=obj,
1857 n_seg_lox=N_SEGMENTS,
1858 n_seg_fuel=N_SEGMENTS,
1859 )
1860
1861 # Update best objective and corresponding X vector
1862 prev_best = layer2_state["best_obj"]
1863 # Note: finish_evaluation will update best_obj/best_x if strictly better
1864 # But we need to handle specific logging and resetting counters here first
1865
1866 if obj < layer2_state["best_obj"]:
1867 improvement = prev_best - obj
1868 # Reset counters if we made significant progress
1869 if improvement > 1e-5:
1870 layer2_state["no_improvement_count"] = 0
1871 layer2_state["small_change_count"] = 0 # Reset rate-of-change counter too
1872 layer2_logger.info(
1873 f" ✓ New best objective: {obj:.6f} "
1874 f"(penalties: impulse={impulse_penalty:.2f}, burn_time={burn_time_penalty:.2f}, "
1875 f"capacity={capacity_penalty:.2f}, stability={stability_penalty:.2f}, "
1876 f"O/F={of_penalty:.2f}, Pc_stab={pc_penalty:.2f}, COPV={copv_penalty:.2f}) "
1877 f"- Evaluation took {eval_time:.2f}s"
1878 )
1879
1880 # Store COPV results for the best solution
1881 if copv_results is not None:
1882 layer2_state["best_copv_results"] = copv_results
1883
1884 # Stream best pressure curves to UI if callback provided
1885 if pressure_curve_callback is not None:
1886 try:
1887 # Extract COPV data from current evaluation's copv_results
1888 copv_pressure_trace = None
1889 copv_time_trace = None
1890 if copv_results is not None:
1891 copv_pressure_trace = copv_results.get("PH_trace_Pa", None)
1892 copv_time_trace = copv_results.get("time_s", None)
1893 if copv_pressure_trace is not None:
1894 copv_pressure_trace = np.asarray(copv_pressure_trace).copy()
1895 if copv_time_trace is not None:
1896 copv_time_trace = np.asarray(copv_time_trace).copy()
1897
1898 pressure_curve_callback(
1899 time_eval.copy(),
1900 P_tank_O_array.copy(),
1901 P_tank_F_array.copy(),
1902 copv_pressure_trace,
1903 copv_time_trace,
1904 )
1905 except Exception:
1906 # Never let UI callback break the optimizer
1907 pass
1908
1909 else:
1910 layer2_logger.info(
1911 f" Objective: {obj:.6f} (best: {layer2_state['best_obj']:.6f}) - "
1912 f"Evaluation took {eval_time:.2f}s"
1913 )
1914
1915 # Flush log
1916 for handler in layer2_logger.handlers:
1917 handler.flush()
1918
1919 return finish_evaluation(obj)
1920 except Exception as e:
1921 eval_time = time.time() - eval_start_time
1922 error_msg = f"Exception in objective evaluation #{eval_num} (took {eval_time:.2f}s): {repr(e)}"
1923 layer2_logger.error(error_msg)
1924 import traceback
1925 layer2_logger.error(traceback.format_exc())
1926 for handler in layer2_logger.handlers:
1927 handler.flush()
1928 if log_status:
1929 log_status("Layer 2 Pressure Error", error_msg)
1930 return finish_evaluation(1e6)
1931
1932 # Wrapper for coarse global-search objective (DE) using fewer time points
1933 def layer2_objective_de(x_layer2: np.ndarray) -> float:
1934 return layer2_objective(
1935 x_layer2,
1936 time_eval_override=time_array_de,
1937 n_points=n_time_points_de,
1938 phase="DE",
1939 )
1940
1941 # Wrapper for full-resolution local-search objective
1942 def layer2_objective_local(x_layer2: np.ndarray) -> float:
1943 return layer2_objective(
1944 x_layer2,
1945 time_eval_override=None,
1946 n_points=None,
1947 phase="local",
1948 )
1949
1950 # Optimize
1951 success = False
1952 P_tank_O_optimized = None
1953 P_tank_F_optimized = None
1954 summary = {}
1955 n_segments_used = N_SEGMENTS # Fixed number of segments per tank
1956 lox_segments = None # Track segments for saving to config
1957 fuel_segments = None
1958
1959 if skip_de_optimization:
1960 layer2_logger.info("Feed pressure model: dome_regulated — using eq. (6.2), skipping segment DE")
1961 _, P_tank_O_optimized, P_tank_F_optimized = dome_regulated_tank_pair(
1962 initial_lox_pressure_pa, initial_fuel_pressure_pa, target_burn_time, n_time_points,
1963 )
1964 success = True
1965 if update_progress:
1966 update_progress(
1967 "Layer 2: Dome-regulated pressure curves",
1968 0.64,
1969 "Using regulated P_tank(t) profile (eq. 6.2)",
1970 )
1971 else:
1972 layer2_logger.info("Starting optimization...")
1973 layer2_logger.info(f"Using fixed {N_SEGMENTS} segments per tank for LOX and fuel")
1974 layer2_logger.info(f"Max local iterations: {layer2_state['max_iter']}")
1975 layer2_logger.info("")
1976
1977 try:
1978 # Global search with differential evolution (coarse time grid, n_time_points_de)
1979 # Increased parameters for better global search capability
1980 layer2_logger.info("Running Global Search (DE) with popsize=5, maxiter=10...")
1981
1982 # Callback for DE to detect convergence
1983 def de_callback(intermediate_result):
1984 """Callback for differential_evolution to detect and log convergence."""
1985 # Check if stop was requested
1986 if stop_event is not None and stop_event.is_set():
1987 layer2_state["converged"] = True
1988 layer2_state["stopped_by_user"] = True
1989 if not layer2_state.get("stop_logged", False):
1990 layer2_logger.info("⚠ Stop requested by user during DE optimization")
1991 layer2_state["stop_logged"] = True
1992 for handler in layer2_logger.handlers:
1993 handler.flush()
1994 return True
1995
1996 # Track improvement for early stopping
1997 current_obj = layer2_state.get("best_obj", float("inf"))
1998 prev_best = layer2_state.get("de_prev_best", float("inf"))
1999
2000 # Calculate improvement from previous generation
2001 improvement = prev_best - current_obj
2002 layer2_state["de_prev_best"] = current_obj
2003
2004 # Count no-improvement generations
2005 if improvement < 1.0: # Less than 1.0 improvement
2006 layer2_state["de_no_improve_count"] = layer2_state.get("de_no_improve_count", 0) + 1
2007 else:
2008 layer2_state["de_no_improve_count"] = 0
2009
2010 # Early stop if 5 consecutive generations with < 1.0 improvement
2011 if layer2_state.get("de_no_improve_count", 0) >= 5:
2012 if not layer2_state.get("de_convergence_logged", False):
2013 layer2_logger.info(
2014 f"✓ DE converged early: 5 generations with < 1.0 improvement. "
2015 f"Best objective: {current_obj:.6f}"
2016 )
2017 layer2_state["de_convergence_logged"] = True
2018 for handler in layer2_logger.handlers:
2019 handler.flush()
2020 return True # Stop DE
2021
2022 if layer2_state.get("converged", False):
2023 # Convergence detected in objective function - stop DE
2024 if not layer2_state.get("de_convergence_logged", False):
2025 layer2_logger.info(
2026 f"✓ DE stopping early: convergence detected (best_obj={layer2_state['best_obj']:.6f})."
2027 )
2028 layer2_state["de_convergence_logged"] = True
2029 for handler in layer2_logger.handlers:
2030 handler.flush()
2031 return True
2032 return False
2033
2034 de_result = differential_evolution(
2035 layer2_objective_de,
2036 bounds,
2037 maxiter=de_maxiter,
2038 popsize=de_popsize,
2039 polish=False,
2040 tol=0.01, # Population convergence tolerance
2041 atol=0.5, # Absolute tolerance for objective improvement (early stopping)
2042 callback=de_callback,
2043 )
2044 layer2_logger.info(
2045 "Global search (differential_evolution) finished with objective %.6f",
2046 de_result.fun,
2047 )
2048 if layer2_state.get("converged", False):
2049 layer2_logger.info(
2050 "✓ DE converged early, but proceeding to local optimization (L-BFGS-B) "
2051 "for fine-grid polish regardless."
2052 )
2053 for handler in layer2_logger.handlers:
2054 handler.flush()
2055
2056 # Re-scoring Top K candidates
2057 # 1. Collect all valid candidates found during DE
2058 candidates = layer2_state.get("de_candidates", [])
2059
2060 # 2. Add the final DE result if not already included
2061 if hasattr(de_result, "x"):
2062 candidates.append((de_result.fun, de_result.x))
2063
2064 # 3. Sort by objective (ascending)
2065 # Filter out duplicates (simple check based on obj)
2066 candidates.sort(key=lambda x: x[0])
2067
2068 # 4. Take top K unique-ish candidates
2069 top_k_count = 5
2070 top_candidates = []
2071 seen_objs = set()
2072 for obj, x in candidates:
2073 if len(top_candidates) >= top_k_count:
2074 break
2075 # Rounded obj for duplicate detection
2076 obj_key = round(obj, 6)
2077 if obj_key not in seen_objs and obj < 1e5:
2078 seen_objs.add(obj_key)
2079 top_candidates.append((obj, x))
2080
2081 if not top_candidates and layer2_state["best_x"] is not None:
2082 top_candidates.append((layer2_state["best_obj"], layer2_state["best_x"]))
2083
2084 # Reset convergence flag so that re-scoring evaluations are actually performed
2085 # (otherwise layer2_objective returns constant best_obj immediately)
2086 layer2_state["converged"] = False
2087
2088 layer2_logger.info(f"Re-scoring top {len(top_candidates)} candidates on fine grid...")
2089
2090 best_rescored_obj = float("inf")
2091 best_rescored_x = None
2092
2093 for i, (old_obj, cand_x) in enumerate(top_candidates):
2094 # Evaluate on fine grid (local)
2095 # This implicitly updates layer2_state["best_obj"] if it finds a new global best
2096 new_obj = layer2_objective_local(cand_x)
2097 layer2_logger.info(f" Candidate #{i+1}: DE_obj={old_obj:.6f} -> Fine_obj={new_obj:.6f}")
2098
2099 if new_obj < best_rescored_obj:
2100 best_rescored_obj = new_obj
2101 best_rescored_x = cand_x
2102
2103 if best_rescored_x is not None:
2104 local_start_x = best_rescored_x
2105 layer2_logger.info(f"Selected best re-scored candidate (obj={best_rescored_obj:.6f}) as starting point for local optimization")
2106 elif layer2_state["best_x"] is not None:
2107 local_start_x = layer2_state["best_x"]
2108 layer2_logger.info("Re-scoring failed to find valid candidates, using overall best x")
2109 else:
2110 local_start_x = de_result.x # Fallback
2111
2112 for handler in layer2_logger.handlers:
2113 handler.flush()
2114
2115 # Re-evaluate the chosen start point on the FINE grid to establish a valid baseline.
2116 # This prevents comparing "apples to oranges" (coarse DE vs fine local).
2117 layer2_logger.info("Re-evaluating best DE solution on fine grid to establish baseline...")
2118 baseline_obj = layer2_objective_local(local_start_x)
2119
2120 # Force reset internal state to this fine-grid baseline
2121 layer2_state["best_obj"] = baseline_obj
2122 layer2_state["best_x"] = np.array(local_start_x, copy=True)
2123 layer2_state["last_obj"] = baseline_obj
2124 layer2_state["prev_obj"] = None
2125 # Reset convergence tracking for local optimization phase
2126 # (Even if DE converged early, we still run local optimization for fine-grid polish)
2127 layer2_state["converged"] = False
2128 layer2_state["no_improvement_count"] = 0
2129 layer2_state["small_change_count"] = 0
2130 layer2_state["identical_obj_count"] = 0
2131 layer2_state["last_identical_obj"] = None
2132
2133 layer2_logger.info(f"Fine-grid baseline objective: {baseline_obj:.6f}")
2134 layer2_logger.info("Proceeding to local optimization (L-BFGS-B) for fine-grid polish...")
2135 for handler in layer2_logger.handlers:
2136 handler.flush()
2137
2138 # Local polish with L-BFGS-B (full time grid, n_time_points)
2139 # Use tighter tolerances to stop earlier when converged
2140 # Also reduce max iterations if DE already found a good solution
2141 effective_max_iter = max_iterations
2142 if baseline_obj < 1.0: # If we have a good solution (on the fine grid)
2143 effective_max_iter = min(max_iterations, 15) # Use fewer iterations
2144 layer2_logger.info(
2145 f"Baseline is good (obj={baseline_obj:.6f}), "
2146 f"limiting local search to {effective_max_iter} iterations"
2147 )
2148
2149 result_layer2 = scipy_minimize(
2150 layer2_objective_local,
2151 local_start_x, # Use the best X vector found during DE
2152 method="L-BFGS-B",
2153 bounds=bounds,
2154 options={
2155 "maxiter": effective_max_iter,
2156 "ftol": 1e-6, # Function tolerance - stop when function change is small
2157 "gtol": 1e-5, # Gradient tolerance - stop when gradient is small
2158 "maxfun": effective_max_iter * 3, # Limit function evaluations to prevent infinite loops
2159 },
2160 callback=layer2_callback,
2161 )
2162
2163 layer2_logger.info("")
2164 layer2_logger.info("Optimization completed")
2165 layer2_logger.info(f"Success: {result_layer2.success}")
2166 layer2_logger.info(f"Final objective value: {result_layer2.fun:.6f}")
2167 layer2_logger.info(f"Iterations: {result_layer2.nit if hasattr(result_layer2, 'nit') else 'N/A'}")
2168 layer2_logger.info(f"Function evaluations: {result_layer2.nfev if hasattr(result_layer2, 'nfev') else 'N/A'}")
2169 layer2_logger.info("")
2170
2171 if result_layer2.success or result_layer2.fun < 1e5:
2172 success = True
2173 layer2_logger.info("✓ Optimization converged successfully")
2174
2175 # Extract optimized segments
2176 layer2_logger.info(f"Optimized solution uses {N_SEGMENTS} LOX segments and {N_SEGMENTS} fuel segments")
2177
2178 # CRITICAL FIX: Use the best X vector found across all optimization phases
2179 # (DE + local), not just the local optimizer's final X vector.
2180 # The local optimizer may not improve upon its starting point, so
2181 # result_layer2.x could be worse than the best found during DE.
2182 best_x_overall = layer2_state['best_x'] if layer2_state['best_x'] is not None else result_layer2.x
2183 best_obj_overall = layer2_state['best_obj']
2184
2185 # Log which solution we're using for final results
2186 if layer2_state['best_x'] is not None and best_obj_overall < result_layer2.fun:
2187 layer2_logger.info(
2188 f"Using best solution found during optimization (obj={best_obj_overall:.6f}) "
2189 f"instead of local optimizer's final solution (obj={result_layer2.fun:.6f})"
2190 )
2191 else:
2192 layer2_logger.info(f"Using local optimizer's final solution (obj={result_layer2.fun:.6f})")
2193
2194 lox_segments, fuel_segments = decode_segments_from_x(
2195 best_x_overall, # Use best X found during entire optimization
2196 N_SEGMENTS,
2197 initial_lox_pressure_pa,
2198 initial_fuel_pressure_pa,
2199 min_lox_pressure_floor_pa,
2200 min_fuel_pressure_floor_pa,
2201 )
2202
2203 # Generate optimized pressure curves
2204 P_tank_O_optimized = generate_pressure_curve_from_segments(lox_segments, n_time_points)
2205 P_tank_F_optimized = generate_pressure_curve_from_segments(fuel_segments, n_time_points)
2206
2207 if update_progress:
2208 update_progress(
2209 "Layer 2: Pressure Curve Optimization",
2210 0.64,
2211 f"Optimized: {N_SEGMENTS} LOX segments, {N_SEGMENTS} fuel segments"
2212 )
2213 else:
2214 layer2_logger.warning("⚠ Optimization did not converge, using initial guess")
2215 if update_progress:
2216 update_progress(
2217 "Layer 2: Pressure Curve Optimization",
2218 0.64,
2219 f"⚠️ Optimization did not converge, using initial guess"
2220 )
2221 # Use initial guess with shared parameterization
2222 lox_segments, fuel_segments = decode_segments_from_x(
2223 x0,
2224 N_SEGMENTS,
2225 initial_lox_pressure_pa,
2226 initial_fuel_pressure_pa,
2227 min_lox_pressure_floor_pa,
2228 min_fuel_pressure_floor_pa,
2229 )
2230 P_tank_O_optimized = generate_pressure_curve_from_segments(lox_segments, n_time_points)
2231 P_tank_F_optimized = generate_pressure_curve_from_segments(fuel_segments, n_time_points)
2232
2233 except Exception as e:
2234 error_msg = f"Exception in optimization: {repr(e)}"
2235 layer2_logger.error(error_msg)
2236 import traceback
2237 layer2_logger.error(traceback.format_exc())
2238 if log_status:
2239 log_status("Layer 2 Pressure Error", error_msg)
2240 if update_progress:
2241 update_progress(
2242 "Layer 2: Pressure Curve Optimization",
2243 0.64,
2244 f"⚠️ Optimization failed: {e}, using initial guess"
2245 )
2246 # Fallback to simple linear pressure decay
2247 P_tank_O_optimized = np.linspace(initial_lox_pressure_pa, initial_lox_pressure_pa * 0.7, n_time_points)
2248 P_tank_F_optimized = np.linspace(initial_fuel_pressure_pa, initial_fuel_pressure_pa * 0.7, n_time_points)
2249 layer2_logger.warning("Using fallback linear pressure decay")
2250 # Create fallback segments for config
2251 lox_segments = [{
2252 "length_ratio": 1.0,
2253 "type": "linear",
2254 "start_pressure": initial_lox_pressure_pa,
2255 "end_pressure": initial_lox_pressure_pa * 0.7,
2256 "k": None,
2257 }]
2258 fuel_segments = [{
2259 "length_ratio": 1.0,
2260 "type": "linear",
2261 "start_pressure": initial_fuel_pressure_pa,
2262 "end_pressure": initial_fuel_pressure_pa * 0.7,
2263 "k": None,
2264 }]
2265
2266 # Build summary
2267 layer2_logger.info("")
2268 layer2_logger.info("Calculating final results...")
2269 results_final = None # Initialize to ensure it's in scope
2270 thrust_final = None # Initialize for thrust curve data
2271 # Initialize variables for summary (in case calculation fails)
2272 total_impulse_actual = 0.0
2273 initial_thrust_actual = 0.0
2274 total_lox_mass_final = 0.0
2275 total_fuel_mass_final = 0.0
2276 total_propellant_mass = 0.0
2277 required_impulse_final = 0.0
2278 if P_tank_O_optimized is not None and P_tank_F_optimized is not None:
2279 # Calculate final total impulse and propellant consumption for summary
2280 try:
2281 results_final = runner_layer2.evaluate_arrays_with_time(
2282 time_array,
2283 P_tank_O_optimized,
2284 P_tank_F_optimized,
2285 track_ablative_geometry=False,
2286 use_coupled_solver=False,
2287 )
2288 thrust_final = np.atleast_1d(results_final.get("F", [peak_thrust]))
2289 mdot_O_final = np.atleast_1d(results_final.get("mdot_O", np.zeros(n_time_points)))
2290 mdot_F_final = np.atleast_1d(results_final.get("mdot_F", np.zeros(n_time_points)))
2291
2292 total_impulse_actual = float(np.trapezoid(thrust_final, time_array))
2293 initial_thrust_actual = float(thrust_final[0])
2294
2295 # Calculate actual propellant mass consumed
2296 total_lox_mass_final = float(np.trapezoid(mdot_O_final, time_array))
2297 total_fuel_mass_final = float(np.trapezoid(mdot_F_final, time_array))
2298 total_propellant_mass = total_lox_mass_final + total_fuel_mass_final
2299
2300 # Calculate required impulse from actual propellant consumption
2301 required_impulse_final = calculate_required_impulse_from_mass(
2302 target_apogee_m,
2303 rocket_dry_mass_kg,
2304 total_propellant_mass,
2305 target_burn_time,
2306 time_s=time_array,
2307 mdot_kg_s=mdot_O_final + mdot_F_final,
2308 thrust_n=thrust_final,
2309 vehicle=flight_vehicle,
2310 )
2311
2312 # Log final results
2313 layer2_logger.info("")
2314 layer2_logger.info("="*70)
2315 layer2_logger.info("Final Results Summary")
2316 layer2_logger.info("="*70)
2317 layer2_logger.info(f"Initial thrust: {initial_thrust_actual:.1f} N (target: {peak_thrust:.1f} N)")
2318 layer2_logger.info(f"Total impulse: {total_impulse_actual/1000:.1f} kN·s")
2319 layer2_logger.info(f"Required impulse: {required_impulse_final/1000:.1f} kN·s")
2320 layer2_logger.info(f"Impulse ratio: {total_impulse_actual/max(required_impulse_final, 1e-9)*100:.1f}%")
2321 layer2_logger.info(f"LOX consumed: {total_lox_mass_final:.3f} kg ({total_lox_mass_final/max(max_lox_tank_capacity_kg, 1e-9)*100:.1f}% of capacity)")
2322 layer2_logger.info(f"Fuel consumed: {total_fuel_mass_final:.3f} kg ({total_fuel_mass_final/max(max_fuel_tank_capacity_kg, 1e-9)*100:.1f}% of capacity)")
2323 layer2_logger.info(f"Total propellant: {total_propellant_mass:.3f} kg")
2324 layer2_logger.info(f"LOX end pressure: {P_tank_O_optimized[-1]/6894.76:.1f} psi ({P_tank_O_optimized[-1]/1e6:.2f} MPa)")
2325 layer2_logger.info(f"Fuel end pressure: {P_tank_F_optimized[-1]/6894.76:.1f} psi ({P_tank_F_optimized[-1]/1e6:.2f} MPa)")
2326 layer2_logger.info("="*70)
2327 except Exception as e:
2328 layer2_logger.error(f"Error calculating final results: {repr(e)}")
2329 total_impulse_actual = 0.0
2330 initial_thrust_actual = 0.0
2331 total_lox_mass_final = 0.0
2332 total_fuel_mass_final = 0.0
2333 total_propellant_mass = 0.0
2334 required_impulse_final = 0.0
2335
2336 # Calculate average O/F ratio and min stability from final results
2337 avg_of_ratio = None
2338 min_stability_margin_val = None
2339 if results_final is not None: # Only calculate if results_final was successfully computed
2340 try:
2341 MR_hist = np.atleast_1d(results_final.get("MR", []))
2342 if len(MR_hist) > 0:
2343 valid_MR = MR_hist[np.isfinite(MR_hist)]
2344 if len(valid_MR) > 0:
2345 avg_of_ratio = float(np.mean(valid_MR))
2346
2347 # Extract chugging_stability_margin from results
2348 # evaluate_arrays_with_time now includes comprehensive stability analysis (all 3 types)
2349 chugging_margins = results_final.get("chugging_stability_margin", None)
2350 if chugging_margins is not None:
2351 chugging_margins = np.atleast_1d(chugging_margins)
2352 valid_margins = chugging_margins[np.isfinite(chugging_margins)]
2353 if len(valid_margins) > 0:
2354 min_stability_margin_val = float(np.min(valid_margins))
2355 layer2_logger.info(f"Extracted min stability margin (comprehensive): {min_stability_margin_val:.3f} from {len(valid_margins)} time points")
2356 layer2_logger.info(f" (Accounts for chugging, acoustic, and feed system stability)")
2357 else:
2358 layer2_logger.warning("Stability margins found but all values are invalid (NaN/Inf)")
2359 else:
2360 layer2_logger.warning("chugging_stability_margin not found in results - stability analysis may have failed")
2361 except Exception as e:
2362 layer2_logger.warning(f"Error extracting stability/O/F from final results: {repr(e)}")
2363 pass # Use None if calculation fails
2364
2365 # Calculate COPV requirements for final optimized pressure curves
2366 copv_P0_Pa = None
2367 copv_pressure_trace_Pa = None
2368 copv_time_s = None
2369 if results_final is not None and mdot_O_final is not None and mdot_F_final is not None:
2370 try:
2371 # Create DataFrame for COPV solver with final results
2372 P_tank_O_psi_final = P_tank_O_optimized / 6894.76
2373 P_tank_F_psi_final = P_tank_F_optimized / 6894.76
2374
2375 df_copv_final = pd.DataFrame({
2376 "time": time_array,
2377 "mdot_O (kg/s)": mdot_O_final,
2378 "mdot_F (kg/s)": mdot_F_final,
2379 "P_tank_O (psi)": P_tank_O_psi_final,
2380 "P_tank_F (psi)": P_tank_F_psi_final,
2381 })
2382
2383
2384 # Extract COPV volume from config
2385 copv_volume_m3_final = None
2386 if hasattr(config_layer2, "press_tank") and hasattr(config_layer2.press_tank, "free_volume_L"):
2387 # Convert from liters to m³
2388 copv_volume_m3_final = float(config_layer2.press_tank.free_volume_L) / 1000.0
2389 elif hasattr(config_layer2, "press_tank") and hasattr(config_layer2.press_tank, "press_volume"):
2390 copv_volume_m3_final = float(config_layer2.press_tank.press_volume)
2391 elif hasattr(config_layer2, "press_tank") and hasattr(config_layer2.press_tank, "volume_m3"):
2392 copv_volume_m3_final = float(config_layer2.press_tank.volume_m3)
2393
2394 if copv_volume_m3_final is None or copv_volume_m3_final <= 0:
2395 layer2_logger.warning("COPV volume not found in config, skipping final COPV calculation")
2396 copv_P0_Pa = None
2397 copv_pressure_trace_Pa = None
2398 copv_time_s = None
2399 else:
2400 # Call COPV solver to get final pressure requirements
2401 copv_results_final = size_or_check_copv_for_polytropic_N2(
2402 df_copv_final,
2403 config_layer2,
2404 n=1.2, # polytropic exponent
2405 T0_K=300.0, # initial COPV temperature
2406 Tp_K=293.0, # default propellant gas temp
2407 use_real_gas=True, # use Z lookup table
2408 n2_Z_csv=str(N2_Z_LOOKUP_CSV),
2409 pressurant_R=296.8, # gas constant for N2
2410 branch_temperatures_K={
2411 "oxidizer": 250.0, # oxidizer gas temp
2412 "fuel": 293.0, # fuel gas temp
2413 },
2414 copv_volume_m3=copv_volume_m3_final,
2415 copv_P0_Pa=None, # Solve for P0
2416 )
2417
2418 # Extract COPV results
2419 copv_P0_Pa = float(copv_results_final.get("P0_Pa", 0.0))
2420 copv_pressure_trace_Pa = copv_results_final.get("PH_trace_Pa", np.array([]))
2421 copv_time_s = copv_results_final.get("time_s", np.array([]))
2422
2423 # Log COPV results
2424 layer2_logger.info("")
2425 layer2_logger.info("COPV Pressurization Requirements:")
2426 layer2_logger.info(f" Initial pressure (P0): {copv_P0_Pa/1e6:.2f} MPa ({copv_P0_Pa/6894.76:.0f} psi)")
2427 if len(copv_pressure_trace_Pa) > 0:
2428 copv_final_pressure = copv_pressure_trace_Pa[-1]
2429 layer2_logger.info(f" Final pressure: {copv_final_pressure/1e6:.2f} MPa ({copv_final_pressure/6894.76:.0f} psi)")
2430 layer2_logger.info(f" Pressure drop: {(copv_P0_Pa - copv_final_pressure)/1e6:.2f} MPa")
2431
2432 except Exception as e:
2433 layer2_logger.warning(f"Error calculating COPV requirements for final results: {repr(e)}")
2434 copv_P0_Pa = None
2435 copv_pressure_trace_Pa = None
2436 copv_time_s = None
2437
2438 # Prepare thrust curve data for frontend (time series without ablation/oxidation)
2439 thrust_curve_time = None
2440 thrust_curve_values = None
2441 of_curve_values = None
2442 delta_p_inj_O_values = None
2443 delta_p_inj_F_values = None
2444 if results_final is not None and thrust_final is not None and len(thrust_final) > 0:
2445 # Use the time array and thrust data from final evaluation
2446 # Ensure arrays are converted to lists for JSON serialization
2447 thrust_curve_time = time_array[:len(thrust_final)].tolist() if hasattr(time_array, 'tolist') else list(time_array[:len(thrust_final)])
2448 thrust_curve_values = thrust_final.tolist() if hasattr(thrust_final, 'tolist') else list(thrust_final)
2449
2450 # Extract O/F ratio (MR - mixture ratio) data
2451 MR_final = np.atleast_1d(results_final.get("MR", np.zeros(len(thrust_final))))
2452 if len(MR_final) > 0:
2453 of_curve_values = MR_final.tolist() if hasattr(MR_final, 'tolist') else list(MR_final)
2454
2455 # Extract injector pressure drops from diagnostics
2456 diagnostics_list = results_final.get("diagnostics", [])
2457 layer2_logger.info(f"Extracting injector pressure drops: diagnostics_list length={len(diagnostics_list)}, thrust_final length={len(thrust_final)}")
2458 if len(diagnostics_list) > 0:
2459 # Debug: log first diagnostic structure
2460 if len(diagnostics_list) > 0 and isinstance(diagnostics_list[0], dict):
2461 first_diag = diagnostics_list[0]
2462 layer2_logger.info(f"First diagnostic keys: {list(first_diag.keys())[:10]}...") # Log first 10 keys
2463 if "injector_pressure" in first_diag:
2464 layer2_logger.info(f"injector_pressure keys: {list(first_diag['injector_pressure'].keys()) if isinstance(first_diag['injector_pressure'], dict) else 'not a dict'}")
2465 if "delta_p_injector_O" in first_diag:
2466 layer2_logger.info(f"Found delta_p_injector_O directly: {first_diag['delta_p_injector_O']}")
2467 delta_p_inj_O_list = []
2468 delta_p_inj_F_list = []
2469 # Convert from Pa to PSI (1 PSI = 6894.76 Pa)
2470 PSI_TO_PA = 6894.76
2471
2472 # Ensure we process the same number of points as thrust data
2473 n_points = len(thrust_final)
2474 for i in range(n_points):
2475 if i < len(diagnostics_list):
2476 diag = diagnostics_list[i]
2477 if isinstance(diag, dict):
2478 # Try nested injector_pressure structure first
2479 injector_pressure = diag.get("injector_pressure")
2480 if isinstance(injector_pressure, dict) and "delta_p_injector_O" in injector_pressure:
2481 delta_p_O = injector_pressure.get("delta_p_injector_O")
2482 delta_p_F = injector_pressure.get("delta_p_injector_F")
2483 else:
2484 # Fallback to direct access in diagnostics dict
2485 delta_p_O = diag.get("delta_p_injector_O")
2486 delta_p_F = diag.get("delta_p_injector_F")
2487
2488 # Convert from Pa to PSI
2489 if delta_p_O is not None and np.isfinite(delta_p_O):
2490 delta_p_inj_O_list.append(float(delta_p_O / PSI_TO_PA))
2491 else:
2492 delta_p_inj_O_list.append(0.0)
2493
2494 if delta_p_F is not None and np.isfinite(delta_p_F):
2495 delta_p_inj_F_list.append(float(delta_p_F / PSI_TO_PA))
2496 else:
2497 delta_p_inj_F_list.append(0.0)
2498 else:
2499 delta_p_inj_O_list.append(0.0)
2500 delta_p_inj_F_list.append(0.0)
2501 else:
2502 # Pad with zeros if diagnostics list is shorter
2503 delta_p_inj_O_list.append(0.0)
2504 delta_p_inj_F_list.append(0.0)
2505
2506 if len(delta_p_inj_O_list) > 0:
2507 # Check if we actually got non-zero values
2508 non_zero_O = sum(1 for v in delta_p_inj_O_list if abs(v) > 1e-6)
2509 non_zero_F = sum(1 for v in delta_p_inj_F_list if abs(v) > 1e-6)
2510 layer2_logger.info(f"Injector pressure drops extracted: {non_zero_O}/{len(delta_p_inj_O_list)} non-zero LOX values, {non_zero_F}/{len(delta_p_inj_F_list)} non-zero Fuel values")
2511
2512 if non_zero_O > 0 or non_zero_F > 0:
2513 delta_p_inj_O_values = delta_p_inj_O_list
2514 delta_p_inj_F_values = delta_p_inj_F_list
2515
2516 # Log sample values for debugging
2517 sample_idx = min(5, len(delta_p_inj_O_list) - 1)
2518 layer2_logger.info(f"Sample injector pressure drops: LOX={delta_p_inj_O_list[sample_idx]:.2f} psi, Fuel={delta_p_inj_F_list[sample_idx]:.2f} psi (at index {sample_idx})")
2519 else:
2520 layer2_logger.warning("All injector pressure drops are zero - diagnostics may not contain injector data")
2521 delta_p_inj_O_values = None
2522 delta_p_inj_F_values = None
2523 else:
2524 layer2_logger.warning("No diagnostics found in results_final - cannot extract injector pressure drops")
2525
2526 summary = {
2527 "lox_segments": n_segments_used,
2528 "fuel_segments": n_segments_used,
2529 "initial_lox_pressure_pa": initial_lox_pressure_pa,
2530 "initial_fuel_pressure_pa": initial_fuel_pressure_pa,
2531 "lox_start_pressure_pa": float(P_tank_O_optimized[0]),
2532 "lox_end_pressure_pa": float(P_tank_O_optimized[-1]),
2533 "fuel_start_pressure_pa": float(P_tank_F_optimized[0]),
2534 "fuel_end_pressure_pa": float(P_tank_F_optimized[-1]),
2535 "target_burn_time": target_burn_time,
2536 "n_time_points": n_time_points,
2537 "peak_thrust": peak_thrust,
2538 "initial_thrust_actual": initial_thrust_actual,
2539 # Frontend-compatible field names
2540 "total_impulse_Ns": total_impulse_actual, # Frontend expects this name
2541 "required_impulse_Ns": required_impulse_final, # Frontend expects this name
2542 "lox_mass_kg": total_lox_mass_final, # Frontend expects this name
2543 "fuel_mass_kg": total_fuel_mass_final, # Frontend expects this name
2544 "burn_time_s": target_burn_time, # Frontend expects this
2545 "avg_of_ratio": avg_of_ratio, # Frontend expects this
2546 "min_stability_margin": min_stability_margin_val, # Frontend expects this
2547 "is_success": success, # Frontend expects this
2548 # Thrust curve data for frontend plotting (time series without ablation/oxidation)
2549 "thrust_curve_time": thrust_curve_time,
2550 "thrust_curve_values": thrust_curve_values,
2551 # O/F ratio (mixture ratio) curve data for frontend plotting
2552 "of_curve_values": of_curve_values,
2553 # Injector pressure drops (LOX and Fuel) for frontend plotting
2554 "delta_p_inj_O_psi": delta_p_inj_O_values,
2555 "delta_p_inj_F_psi": delta_p_inj_F_values,
2556 # Keep original names for backward compatibility
2557 "total_lox_mass_kg": total_lox_mass_final,
2558 "total_fuel_mass_kg": total_fuel_mass_final,
2559 "total_propellant_mass_kg": total_propellant_mass,
2560 "max_lox_tank_capacity_kg": max_lox_tank_capacity_kg,
2561 "max_fuel_tank_capacity_kg": max_fuel_tank_capacity_kg,
2562 "lox_capacity_ratio": total_lox_mass_final / max(max_lox_tank_capacity_kg, 1e-9),
2563 "fuel_capacity_ratio": total_fuel_mass_final / max(max_fuel_tank_capacity_kg, 1e-9),
2564 "required_impulse": required_impulse_final,
2565 "required_impulse_model": flight_vehicle.get("model"),
2566 "total_impulse_actual": total_impulse_actual,
2567 "impulse_ratio": total_impulse_actual / max(required_impulse_final, 1e-9),
2568 # COPV pressurization data
2569 "copv_P0_Pa": float(copv_P0_Pa) if copv_P0_Pa is not None else None,
2570 "copv_pressure_trace_Pa": copv_pressure_trace_Pa.tolist() if copv_pressure_trace_Pa is not None and hasattr(copv_pressure_trace_Pa, 'tolist') else None,
2571 "copv_time_s": copv_time_s.tolist() if copv_time_s is not None and hasattr(copv_time_s, 'tolist') else None,
2572 }
2573
2574 layer2_logger.info("")
2575 layer2_logger.info(f"Layer 2 optimization complete. Log saved to: {log_file_path}")
2576
2577 # Save optimized pressure curve segments to config
2578 if lox_segments is not None and fuel_segments is not None:
2579 try:
2580 # Convert segment dicts to PressureSegmentConfig objects
2581 lox_segment_configs = [
2583 length_ratio=float(seg["length_ratio"]),
2584 type=seg["type"],
2585 start_pressure_pa=float(seg["start_pressure"]),
2586 end_pressure_pa=float(seg["end_pressure"]),
2587 k=float(seg.get("k")) if seg.get("k") is not None else None,
2588 )
2589 for seg in lox_segments
2590 ]
2591 fuel_segment_configs = [
2593 length_ratio=float(seg["length_ratio"]),
2594 type=seg["type"],
2595 start_pressure_pa=float(seg["start_pressure"]),
2596 end_pressure_pa=float(seg["end_pressure"]),
2597 k=float(seg.get("k")) if seg.get("k") is not None else None,
2598 )
2599 for seg in fuel_segments
2600 ]
2601
2602 # Create PressureCurvesConfig and assign to optimized_config
2603 pressure_curves_config = PressureCurvesConfig(
2604 n_points=n_time_points,
2605 target_burn_time_s=target_burn_time,
2606 initial_lox_pressure_pa=initial_lox_pressure_pa,
2607 initial_fuel_pressure_pa=initial_fuel_pressure_pa,
2608 lox_segments=lox_segment_configs,
2609 fuel_segments=fuel_segment_configs,
2610 )
2611
2612 # Update the config with pressure curves
2613 # Direct assignment works with Pydantic v2 models
2614 optimized_config.pressure_curves = pressure_curves_config
2615
2616 layer2_logger.info(f"Saved {len(lox_segment_configs)} LOX segments and {len(fuel_segment_configs)} fuel segments to config")
2617 except Exception as e:
2618 layer2_logger.warning(f"Failed to save pressure curves to config: {repr(e)}")
2619 # Don't fail the entire optimization if config saving fails
2620
2621 # Clean up handler to prevent file handle issues
2622 layer2_logger.handlers.clear()
2623
2624 return optimized_config, time_array, P_tank_O_optimized, P_tank_F_optimized, summary, success
2625
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
Dict[str, Any] layer2_flight_vehicle(PintleEngineConfig config)
Tuple[float, float, Dict[str, Any], bool] run_layer2a_minimum_pressures(PintleEngineConfig optimized_config, float initial_lox_pressure_pa, float initial_fuel_pressure_pa, float peak_thrust, float target_apogee_m, float rocket_dry_mass_kg, float max_lox_tank_capacity_kg, float max_fuel_tank_capacity_kg, float target_burn_time, int n_time_points=100, Optional[Callable] update_progress=None, Optional[Callable] log_status=None, float min_pressure_pa=1e6, Optional[float] optimal_of_ratio=None, Optional[float] min_stability_margin=None)
np.ndarray generate_pressure_curve_from_segments(List[Dict[str, Any]] segments, int n_points=200, *, bool allow_rise=False, bool exact_ends=False)
Tuple[PintleEngineConfig, np.ndarray, np.ndarray, np.ndarray, Dict[str, Any], bool] run_layer2_pressure(PintleEngineConfig optimized_config, float initial_lox_pressure_pa, float initial_fuel_pressure_pa, float peak_thrust, float target_apogee_m, float rocket_dry_mass_kg, float max_lox_tank_capacity_kg, float max_fuel_tank_capacity_kg, float target_burn_time, int n_time_points=200, Optional[Callable] update_progress=None, Optional[Callable] log_status=None, float min_pressure_pa=1e6, Optional[float] optimal_of_ratio=None, Optional[float] min_stability_margin=None, int max_iterations=20, Optional[int] max_evaluations=None, bool save_evaluation_plots=False, Optional[float] min_lox_pressure_floor_pa=None, Optional[float] min_fuel_pressure_floor_pa=None, float min_pressure_slope_psi_per_sec=-25.0, Optional[Callable[[int, float, float], None]] objective_callback=None, Optional[Callable[[np.ndarray, np.ndarray, np.ndarray, Optional[np.ndarray], Optional[np.ndarray]], None]] pressure_curve_callback=None, Optional[Any] stop_event=None, int de_maxiter=5, int de_popsize=2, int de_n_time_points=25, bool use_controller_simulation=False, Optional[Any] controller_config=None)
float calculate_required_impulse_from_mass(float target_apogee_m, float rocket_dry_mass_kg, float total_propellant_mass_kg, float target_burn_time_s, *, Optional[np.ndarray] time_s=None, Optional[np.ndarray] mdot_kg_s=None, Optional[np.ndarray] thrust_n=None, Optional[Dict[str, Any]] vehicle=None)
List[Dict[str, Any]] segments_from_optimizer_vars_pressure(np.ndarray x_segments, int n_segments, float initial_pressure_pa, float min_pressure_pa=1e6)
TOML_NODISCARD_CTOR time() noexcept=default