601 optimized_config: PintleEngineConfig,
602 initial_lox_pressure_pa: float,
603 initial_fuel_pressure_pa: float,
605 target_apogee_m: float,
606 rocket_dry_mass_kg: float,
607 max_lox_tank_capacity_kg: float,
608 max_fuel_tank_capacity_kg: float,
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,
614 optimal_of_ratio: Optional[float] =
None,
615 min_stability_margin: Optional[float] =
None,
616 max_iterations: int = 20,
617 max_evaluations: Optional[int] =
None,
618 save_evaluation_plots: bool =
False,
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,
622 objective_callback: Optional[Callable[[int, float, float],
None]] =
None,
623 pressure_curve_callback: Optional[Callable[[np.ndarray, np.ndarray, np.ndarray, Optional[np.ndarray], Optional[np.ndarray]],
None]] =
None,
624 stop_event: Optional[Any] =
None,
627 de_n_time_points: int = 25,
628 use_controller_simulation: bool =
False,
629 controller_config: Optional[Any] =
None,
630) -> Tuple[PintleEngineConfig, np.ndarray, np.ndarray, np.ndarray, Dict[str, Any], bool]:
632 Run Layer 2: Pressure Curve Optimization.
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)
641 Tuple of (optimized_config, time_array, P_tank_O_array, P_tank_F_array, summary, success)
643 from scipy.optimize
import minimize
as scipy_minimize, differential_evolution
644 from scipy.integrate
import cumulative_trapezoid
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)
654 timestamp = datetime.now().strftime(
"%Y%m%d_%H%M%S")
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"
661 layer2_logger = logging.getLogger(
'layer2_pressure')
662 layer2_logger.setLevel(logging.INFO)
665 layer2_logger.handlers.clear()
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)
675 layer2_logger.propagate =
False
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(
"")
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}")
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)
709 def save_evaluation_plot(
711 time_arr: np.ndarray,
714 objective: Optional[float] =
None,
715 n_seg_lox: Optional[int] =
None,
716 n_seg_fuel: Optional[int] =
None,
718 """Save a plot of the pressure curves for this evaluation (overwrites previous plot)."""
719 if evaluation_plot_file
is None:
722 fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8))
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)
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]')
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}"
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')
752 ax2.grid(
True, alpha=0.3)
758 plt.savefig(evaluation_plot_file, dpi=100, bbox_inches=
'tight')
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
774 res_init = runner_layer2.evaluate(
775 initial_lox_pressure_pa,
776 initial_fuel_pressure_pa,
779 Pc_initial_ref = res_init.get(
"Pc", 0.0)
784 safe_pressure_floor = 0.75 * Pc_initial_ref
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)
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")
797 skip_de_optimization = get_feed_pressure_model(optimized_config) ==
"dome_regulated"
814 def build_x0_compact() -> np.ndarray:
816 initial_ratio = initial_lox_pressure_pa /
max(initial_fuel_pressure_pa, 1e-9)
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))
823 fuel_ratio_factor = 1.0
825 x.extend([length_ratio, lox_end_ratio, lox_k, fuel_ratio_factor, fuel_k])
826 return np.array(x, dtype=float)
828 x0 = build_x0_compact()
832 bounds: list[tuple[float, float]] = []
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)
842 min_lox_ratio_bound =
min(min_lox_ratio_bound, 0.99)
844 for _
in range(N_SEGMENTS):
845 bounds.append((0.01, 1.0))
846 bounds.append((min_lox_ratio_bound, 1.0))
847 bounds.append((0.1, 2.0))
848 bounds.append((0.75, 1.25))
849 bounds.append((0.1, 2.0))
854 "max_iter": max_iterations,
855 "best_obj": float(
"inf"),
862 "no_improvement_count": 0,
863 "small_change_count": 0,
864 "identical_obj_count": 0,
865 "last_identical_obj":
None,
869 def layer2_callback(xk):
872 phase = layer2_state.get(
"phase",
"local")
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:
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"]
891 if layer2_state[
"last_obj"]
is not None and layer2_state[
"prev_obj"]
is not None:
893 current_obj = layer2_state[
"last_obj"]
894 prev_obj = layer2_state[
"prev_obj"]
897 if abs(current_obj - prev_obj) < 1e-10:
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
904 layer2_state[
"identical_obj_count"] = 1
905 layer2_state[
"last_identical_obj"] = current_obj
907 layer2_state[
"identical_obj_count"] = 1
908 layer2_state[
"last_identical_obj"] = current_obj
911 if layer2_state[
"identical_obj_count"] >= 5:
912 layer2_state[
"converged"] =
True
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."
920 layer2_state[
"identical_obj_count"] = 0
921 layer2_state[
"last_identical_obj"] =
None
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
929 if relative_change < 1e-3
or abs_change < 1e-4:
930 layer2_state[
"small_change_count"] += 1
932 layer2_state[
"small_change_count"] = 0
936 if phase ==
"DE" and layer2_state[
"small_change_count"] >= 3
and not layer2_state[
"converged"]:
937 layer2_state[
"converged"] =
True
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}%)"
946 if layer2_state[
"last_obj"]
is not None and not layer2_state[
"converged"]:
948 if layer2_state[
"best_obj"] < 2.0:
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:
953 layer2_state[
"no_improvement_count"] += 1
955 layer2_state[
"no_improvement_count"] = 0
958 if layer2_state[
"no_improvement_count"] >= 3
and layer2_state[
"best_obj"] < 2.0:
959 layer2_state[
"converged"] =
True
961 f
"✓ Early convergence detected: objective {layer2_state['best_obj']:.6f} "
962 f
"has not improved significantly in {layer2_state['no_improvement_count']} iterations"
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})")
971 layer2_logger.info(f
"[{progress_pct}%] Iteration {layer2_state['iter']}/{layer2_state['max_iter']} "
972 f
"({elapsed:.1f}s elapsed)")
975 for handler
in layer2_logger.handlers:
980 progress = 0.60 + 0.04 * frac
982 "Layer 2: Pressure Curve Optimization",
984 f
"Layer 2 optimization {layer2_state['iter']}/{layer2_state['max_iter']} ({progress_pct}%)",
987 def decode_segments_from_x(
988 x_layer2: np.ndarray,
990 initial_lox_p: float,
991 initial_fuel_p: float,
994 ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
996 Decode the compact optimization vector into LOX and fuel segment lists.
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.
1004 segments_lox: List[Dict[str, Any]] = []
1005 segments_fuel: List[Dict[str, Any]] = []
1008 max_segments = len(x_layer2) // VARS_PER_SEGMENT
1009 n_segments =
min(n_segments, max_segments)
1014 raw_lengths: List[float] = []
1015 for i
in range(n_segments):
1016 base = i * VARS_PER_SEGMENT
1017 if base >= len(x_layer2):
1019 lr_raw = float(x_layer2[base])
1020 if not np.isfinite(lr_raw):
1025 lr = float(np.clip(lr_raw, 0.01, 1.0))
1026 raw_lengths.append(lr)
1028 total_lr = float(sum(raw_lengths))
if raw_lengths
else 1.0
1030 if not np.isfinite(total_lr)
or total_lr <= 0:
1032 length_ratios = [lr / total_lr
for lr
in raw_lengths]
if raw_lengths
else [1.0]
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)
1039 for i
in range(n_segments):
1040 base = i * VARS_PER_SEGMENT
1041 if base + 4 >= len(x_layer2):
1044 length_ratio = length_ratios[i]
if i < len(length_ratios)
else 1.0 / n_segments
1047 segment_time_duration = length_ratio * target_burn_time
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)
1056 min_pressure_drop_pa = abs(min_pressure_slope_psi_per_sec) * 6894.76 * segment_time_duration
1059 max_allowed_end_pressure_lox = prev_lox_end - min_pressure_drop_pa
1062 max_allowed_end_ratio_lox = max_allowed_end_pressure_lox /
max(initial_lox_p, 1e-9)
1066 lox_end_ratio =
min(lox_end_ratio_raw, start_ratio_lox * 0.99, max_allowed_end_ratio_lox)
1068 lox_end_p = initial_lox_p * lox_end_ratio
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))
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)
1079 max_allowed_end_pressure_fuel = prev_fuel_end - min_pressure_drop_pa
1081 fuel_end_p =
min(fuel_end_p, max_allowed_end_pressure_fuel)
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))
1089 "length_ratio": length_ratio,
1091 "start_pressure": prev_lox_end,
1092 "end_pressure": lox_end_p,
1096 "length_ratio": length_ratio,
1098 "start_pressure": prev_fuel_end,
1099 "end_pressure": fuel_end_p,
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
1107 return segments_lox, segments_fuel
1109 def layer2_objective(
1111 time_eval_override: Optional[np.ndarray] =
None,
1112 n_points: Optional[int] =
None,
1113 phase: str =
"local",
1115 """Optimize pressure curves after initial point. Initial pressures are fixed from Layer 1.
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").
1127 layer2_state[
"phase"] = phase
1130 if stop_event
is not None and stop_event.is_set():
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:
1140 return float(best_obj)
if np.isfinite(best_obj)
else 1e6
1143 layer2_state[
"eval_count"] += 1
1144 eval_num = layer2_state[
"eval_count"]
1146 def finish_evaluation(final_obj: float) -> float:
1147 """Helper to finalize evaluation: update state, call callbacks, and return value."""
1149 prev_obj = layer2_state.get(
"prev_obj")
1150 last_obj = layer2_state.get(
"last_obj")
1155 if final_obj < layer2_state[
"best_obj"]:
1156 improvement = layer2_state[
"best_obj"] - final_obj
1157 layer2_state[
"best_obj"] = final_obj
1160 if np.all(np.isfinite(np.asarray(x_layer2, dtype=float))):
1161 layer2_state[
"best_x"] = np.array(x_layer2, copy=
True)
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
1171 layer2_state[
"prev_obj"] = last_obj
1172 layer2_state[
"last_obj"] = final_obj
1176 if not layer2_state.get(
"converged",
False)
and last_obj
is not None:
1177 current_obj = final_obj
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
1185 layer2_state[
"identical_obj_count"] = 1
1186 layer2_state[
"last_identical_obj"] = current_obj
1188 layer2_state[
"identical_obj_count"] = 1
1189 layer2_state[
"last_identical_obj"] = current_obj
1192 if layer2_state[
"identical_obj_count"] >= 5:
1193 layer2_state[
"converged"] =
True
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."
1199 for handler
in layer2_logger.handlers:
1203 layer2_state[
"identical_obj_count"] = 0
1204 layer2_state[
"last_identical_obj"] =
None
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
1212 if relative_change < 1e-3
or abs_change < 1e-4:
1213 layer2_state[
"small_change_count"] += 1
1215 layer2_state[
"small_change_count"] = 0
1218 if layer2_state[
"small_change_count"] >= 3:
1219 layer2_state[
"converged"] =
True
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}%)"
1226 for handler
in layer2_logger.handlers:
1233 if phase ==
"DE" and layer2_state[
"best_obj"] < 2.0:
1235 if improvement
is None or improvement < 1e-5:
1236 layer2_state[
"no_improvement_count"] += 1
1238 layer2_state[
"no_improvement_count"] = 0
1241 if layer2_state[
"no_improvement_count"] >= 3:
1242 layer2_state[
"converged"] =
True
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"
1247 for handler
in layer2_logger.handlers:
1251 if layer2_state.get(
"converged",
False):
1257 if phase ==
"DE" and np.isfinite(final_obj)
and final_obj < 1e5:
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)))
1263 if objective_callback
is not None:
1266 int(layer2_state[
"eval_count"]),
1268 float(layer2_state[
"best_obj"]),
1274 return float(final_obj)
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."
1286 for handler
in layer2_logger.handlers:
1288 return finish_evaluation(1e6)
1293 if layer2_state.get(
"converged",
False):
1294 best_obj = layer2_state.get(
"best_obj", 1e6)
1295 if np.isfinite(best_obj):
1299 return finish_evaluation(float(best_obj))
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()
1307 time_eval = time_eval_override.copy()
1312 f
" → Evaluation #{eval_num} [{phase}]: {N_SEGMENTS} shared segments (LOX/Fuel, {n_points} pts)"
1314 for handler
in layer2_logger.handlers:
1318 lox_segments, fuel_segments = decode_segments_from_x(
1321 initial_lox_pressure_pa,
1322 initial_fuel_pressure_pa,
1323 local_min_lox_floor,
1324 local_min_fuel_floor,
1332 P_tank_O_array[0] = initial_lox_pressure_pa
1333 P_tank_F_array[0] = initial_fuel_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
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))
1354 hard_ratio_penalty = 5e4 + max_excess * 1e4
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"
1359 for handler
in layer2_logger.handlers:
1361 return finish_evaluation(hard_ratio_penalty)
1364 if save_evaluation_plots:
1365 save_evaluation_plot(
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,
1375 layer2_logger.info(f
" Running time series solver ({n_points} points)...")
1376 for handler
in layer2_logger.handlers:
1380 results_layer2 = runner_layer2.evaluate_arrays_with_time(
1384 track_ablative_geometry=
False,
1385 use_coupled_solver=
False,
1388 layer2_logger.info(f
" Time series solver completed in {ts_time:.2f}s")
1389 for handler
in layer2_logger.handlers:
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)
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:
1399 return finish_evaluation(1e6)
1401 thrust_hist = thrust_hist[:available_n]
1402 time_hist = time_eval[:available_n]
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
1410 if finite_thrust_ratio < 0.5:
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."
1417 for handler
in layer2_logger.handlers:
1419 return finish_evaluation(1e6)
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."
1428 for handler
in layer2_logger.handlers:
1430 return finish_evaluation(1e6)
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."
1437 for handler
in layer2_logger.handlers:
1439 return finish_evaluation(1e6)
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)
1445 finite_MR_count = np.sum(finite_MR_mask)
1447 if finite_MR_count < available_n * 0.5:
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."
1454 for handler
in layer2_logger.handlers:
1456 return finish_evaluation(1e6)
1460 thrust_hist = np.nan_to_num(
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]
1477 mdot_O_hist = np.nan_to_num(
1483 mdot_F_hist = np.nan_to_num(
1491 total_lox_mass = float(np.trapezoid(mdot_O_hist, time_hist))
1492 total_fuel_mass = float(np.trapezoid(mdot_F_hist, time_hist))
1493 total_propellant_mass = total_lox_mass + total_fuel_mass
1499 total_propellant_mass,
1502 mdot_kg_s=mdot_O_hist + mdot_F_hist,
1503 thrust_n=thrust_hist,
1504 vehicle=flight_vehicle,
1508 total_impulse = float(np.trapezoid(thrust_hist, time_hist))
1509 impulse_deficit =
max(0, required_impulse - total_impulse)
1510 impulse_penalty = (impulse_deficit /
max(required_impulse, 1e-9)) * 200.0
1515 burn_time_penalty = 0.0
1516 if total_impulse > 0:
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])
1526 burn_completion_slack =
max(0.0, target_burn_time - t95)
1528 burn_time_penalty = (burn_completion_slack /
max(target_burn_time, 1e-9)) * 20.0
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
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),
1548 min_stability = float(np.min(stability_scores))
1550 chugging = results_layer2.get(
"chugging_stability_margin", np.array([1.0]))
1551 chugging = np.nan_to_num(
1552 np.atleast_1d(chugging),
1557 min_stability =
max(0.0,
min(1.0, (float(np.min(chugging)) - 0.3) * 1.5))
1559 stability_penalty = 0.0
1560 if min_stability_margin
is not None:
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),
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
1574 stability_penalty =
max(0, 0.7 - min_stability) * 10.0
1579 if optimal_of_ratio
is not None:
1581 valid_MR_values = MR_hist[finite_MR_mask]
1582 if len(valid_MR_values) > 0:
1585 deviations = np.abs(valid_MR_values - optimal_of_ratio) /
max(optimal_of_ratio, 1e-9)
1595 pointwise_penalties = np.where(
1596 deviations <= deadband,
1598 deadband * 1.0 + (deviations - deadband) * 20.0
1602 mean_penalty = np.mean(pointwise_penalties)
1603 max_penalty = np.max(pointwise_penalties)
1607 combined_penalty = 0.4 * mean_penalty + 0.6 * max_penalty
1610 of_penalty = combined_penalty * 50.0
1618 if "Pc" in results_layer2:
1619 Pc_hist = np.atleast_1d(results_layer2[
"Pc"])
1620 Pc_hist = Pc_hist[:available_n]
1622 valid_Pc_mask = np.isfinite(Pc_hist) & (Pc_hist > 0)
1623 valid_Pc = Pc_hist[valid_Pc_mask]
1625 if len(valid_Pc) > 0:
1628 Pc_initial = valid_Pc[0]
1632 deviations = np.abs(valid_Pc - Pc_initial) / Pc_initial
1636 excess_deviations = np.maximum(0.0, deviations - 0.25)
1638 if np.any(excess_deviations > 0):
1642 pc_penalty = float(np.mean(excess_deviations)) * 1000.0
1655 P_tank_O_psi = P_tank_O_array / 6894.76
1656 P_tank_F_psi = P_tank_F_array / 6894.76
1658 df_copv = pd.DataFrame({
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,
1669 copv_volume_m3 =
None
1670 if hasattr(config_layer2,
"press_tank")
and hasattr(config_layer2.press_tank,
"free_volume_L"):
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)
1678 if copv_volume_m3
is None or copv_volume_m3 <= 0:
1681 layer2_logger.warning(f
" COPV volume not found in config, skipping COPV penalty")
1684 copv_results = size_or_check_copv_for_polytropic_N2(
1691 n2_Z_csv=str(N2_Z_LOOKUP_CSV),
1693 branch_temperatures_K={
1697 copv_volume_m3=copv_volume_m3,
1702 P0_Pa = copv_results.get(
"P0_Pa", 30e6)
1706 reference_pressure = 30e6
1707 copv_penalty = (P0_Pa / reference_pressure) * 10.0
1709 layer2_logger.info(f
" COPV: P0={P0_Pa/1e6:.2f} MPa ({P0_Pa/6894.76:.0f} psi), penalty={copv_penalty:.2f}")
1711 except Exception
as e:
1713 copv_penalty = 1000.0
1714 layer2_logger.warning(f
" COPV solver failed for eval #{eval_num}: {type(e).__name__}: {str(e)}")
1716 if isinstance(e, FileNotFoundError):
1718 layer2_logger.warning(f
" Full traceback:\n{traceback.format_exc()}")
1719 layer2_logger.warning(f
" Applying large COPV penalty: {copv_penalty:.2f}")
1722 controller_penalty = 0.0
1723 if use_controller_simulation
and finite_thrust_ratio >= 0.5:
1730 cfg = controller_config
if controller_config
else ControllerConfig()
1731 dyn_params = DynamicsParams.from_config(cfg)
1732 ctrl = RobustDDPController(cfg, optimized_config)
1736 num_steps = int(target_burn_time / sim_dt)
1744 initial_fuel_pressure_pa,
1745 initial_lox_pressure_pa,
1746 initial_fuel_pressure_pa * 0.95,
1747 initial_lox_pressure_pa * 0.95,
1752 nav = NavState(h=0.0, vz=0.0, theta=0.0, mass_estimate=100.0)
1753 total_controller_cost = 0.0
1755 import scipy.interpolate
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]))
1762 for k_sim
in range(num_steps):
1763 t_sim = k_sim * sim_dt
1765 curr_f_ref = float(f_ref_interp(t_sim))
1766 curr_mr_ref = float(mr_ref_interp(t_sim))
1769 command_type=CommandType.THRUST_DESIRED,
1770 thrust_desired=curr_f_ref,
1784 actuation_cmd, diagnostics = ctrl.step(meas, nav, cmd)
1788 step_cost = float(diagnostics.get(
"cost", 0.0))
1789 total_controller_cost += step_cost
1792 if step_cost > max_obj_min:
1793 max_obj_min = step_cost
1796 u_act = np.array([actuation_cmd.duty_F, actuation_cmd.duty_O])
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
1807 x_sim = step(x_sim, u_act, sim_dt, dyn_params, sim_mdot_F, sim_mdot_O)
1810 controller_penalty = max_obj_min * 10.0 + (total_controller_cost /
max(num_steps, 1)) * 5.0
1812 if np.isfinite(controller_penalty):
1813 layer2_logger.info(f
" Controller penalty: {controller_penalty:.2f} (max step cost: {max_obj_min:.2f})")
1815 controller_penalty = 1000.0
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
1832 if not all(np.isfinite(c)
for c
in components):
1836 layer2_logger.warning(
1837 f
" Non-finite penalty components in eval #{eval_num}; "
1838 "returning large penalty objective."
1840 for handler
in layer2_logger.handlers:
1842 return finish_evaluation(1e6)
1844 obj = sum(components)
1846 eval_time =
time.time() - eval_start_time
1849 if save_evaluation_plots
and phase ==
"local":
1851 save_evaluation_plot(
1854 P_lox=P_tank_O_array,
1855 P_fuel=P_tank_F_array,
1857 n_seg_lox=N_SEGMENTS,
1858 n_seg_fuel=N_SEGMENTS,
1862 prev_best = layer2_state[
"best_obj"]
1866 if obj < layer2_state[
"best_obj"]:
1867 improvement = prev_best - obj
1869 if improvement > 1e-5:
1870 layer2_state[
"no_improvement_count"] = 0
1871 layer2_state[
"small_change_count"] = 0
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"
1881 if copv_results
is not None:
1882 layer2_state[
"best_copv_results"] = copv_results
1885 if pressure_curve_callback
is not None:
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()
1898 pressure_curve_callback(
1900 P_tank_O_array.copy(),
1901 P_tank_F_array.copy(),
1902 copv_pressure_trace,
1911 f
" Objective: {obj:.6f} (best: {layer2_state['best_obj']:.6f}) - "
1912 f
"Evaluation took {eval_time:.2f}s"
1916 for handler
in layer2_logger.handlers:
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)
1925 layer2_logger.error(traceback.format_exc())
1926 for handler
in layer2_logger.handlers:
1929 log_status(
"Layer 2 Pressure Error", error_msg)
1930 return finish_evaluation(1e6)
1933 def layer2_objective_de(x_layer2: np.ndarray) -> float:
1934 return layer2_objective(
1936 time_eval_override=time_array_de,
1937 n_points=n_time_points_de,
1942 def layer2_objective_local(x_layer2: np.ndarray) -> float:
1943 return layer2_objective(
1945 time_eval_override=
None,
1952 P_tank_O_optimized =
None
1953 P_tank_F_optimized =
None
1955 n_segments_used = N_SEGMENTS
1957 fuel_segments =
None
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,
1967 "Layer 2: Dome-regulated pressure curves",
1969 "Using regulated P_tank(t) profile (eq. 6.2)",
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(
"")
1980 layer2_logger.info(
"Running Global Search (DE) with popsize=5, maxiter=10...")
1983 def de_callback(intermediate_result):
1984 """Callback for differential_evolution to detect and log convergence."""
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:
1997 current_obj = layer2_state.get(
"best_obj", float(
"inf"))
1998 prev_best = layer2_state.get(
"de_prev_best", float(
"inf"))
2001 improvement = prev_best - current_obj
2002 layer2_state[
"de_prev_best"] = current_obj
2005 if improvement < 1.0:
2006 layer2_state[
"de_no_improve_count"] = layer2_state.get(
"de_no_improve_count", 0) + 1
2008 layer2_state[
"de_no_improve_count"] = 0
2011 if layer2_state.get(
"de_no_improve_count", 0) >= 5:
2012 if not layer2_state.get(
"de_convergence_logged",
False):
2014 f
"✓ DE converged early: 5 generations with < 1.0 improvement. "
2015 f
"Best objective: {current_obj:.6f}"
2017 layer2_state[
"de_convergence_logged"] =
True
2018 for handler
in layer2_logger.handlers:
2022 if layer2_state.get(
"converged",
False):
2024 if not layer2_state.get(
"de_convergence_logged",
False):
2026 f
"✓ DE stopping early: convergence detected (best_obj={layer2_state['best_obj']:.6f})."
2028 layer2_state[
"de_convergence_logged"] =
True
2029 for handler
in layer2_logger.handlers:
2034 de_result = differential_evolution(
2035 layer2_objective_de,
2042 callback=de_callback,
2045 "Global search (differential_evolution) finished with objective %.6f",
2048 if layer2_state.get(
"converged",
False):
2050 "✓ DE converged early, but proceeding to local optimization (L-BFGS-B) "
2051 "for fine-grid polish regardless."
2053 for handler
in layer2_logger.handlers:
2058 candidates = layer2_state.get(
"de_candidates", [])
2061 if hasattr(de_result,
"x"):
2062 candidates.append((de_result.fun, de_result.x))
2066 candidates.sort(key=
lambda x: x[0])
2072 for obj, x
in candidates:
2073 if len(top_candidates) >= top_k_count:
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))
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"]))
2086 layer2_state[
"converged"] =
False
2088 layer2_logger.info(f
"Re-scoring top {len(top_candidates)} candidates on fine grid...")
2090 best_rescored_obj = float(
"inf")
2091 best_rescored_x =
None
2093 for i, (old_obj, cand_x)
in enumerate(top_candidates):
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}")
2099 if new_obj < best_rescored_obj:
2100 best_rescored_obj = new_obj
2101 best_rescored_x = cand_x
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")
2110 local_start_x = de_result.x
2112 for handler
in layer2_logger.handlers:
2117 layer2_logger.info(
"Re-evaluating best DE solution on fine grid to establish baseline...")
2118 baseline_obj = layer2_objective_local(local_start_x)
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
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
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:
2141 effective_max_iter = max_iterations
2142 if baseline_obj < 1.0:
2143 effective_max_iter =
min(max_iterations, 15)
2145 f
"Baseline is good (obj={baseline_obj:.6f}), "
2146 f
"limiting local search to {effective_max_iter} iterations"
2149 result_layer2 = scipy_minimize(
2150 layer2_objective_local,
2155 "maxiter": effective_max_iter,
2158 "maxfun": effective_max_iter * 3,
2160 callback=layer2_callback,
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(
"")
2171 if result_layer2.success
or result_layer2.fun < 1e5:
2173 layer2_logger.info(
"✓ Optimization converged successfully")
2176 layer2_logger.info(f
"Optimized solution uses {N_SEGMENTS} LOX segments and {N_SEGMENTS} fuel segments")
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']
2186 if layer2_state[
'best_x']
is not None and best_obj_overall < result_layer2.fun:
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})"
2192 layer2_logger.info(f
"Using local optimizer's final solution (obj={result_layer2.fun:.6f})")
2194 lox_segments, fuel_segments = decode_segments_from_x(
2197 initial_lox_pressure_pa,
2198 initial_fuel_pressure_pa,
2199 min_lox_pressure_floor_pa,
2200 min_fuel_pressure_floor_pa,
2209 "Layer 2: Pressure Curve Optimization",
2211 f
"Optimized: {N_SEGMENTS} LOX segments, {N_SEGMENTS} fuel segments"
2214 layer2_logger.warning(
"⚠ Optimization did not converge, using initial guess")
2217 "Layer 2: Pressure Curve Optimization",
2219 f
"⚠️ Optimization did not converge, using initial guess"
2222 lox_segments, fuel_segments = decode_segments_from_x(
2225 initial_lox_pressure_pa,
2226 initial_fuel_pressure_pa,
2227 min_lox_pressure_floor_pa,
2228 min_fuel_pressure_floor_pa,
2233 except Exception
as e:
2234 error_msg = f
"Exception in optimization: {repr(e)}"
2235 layer2_logger.error(error_msg)
2237 layer2_logger.error(traceback.format_exc())
2239 log_status(
"Layer 2 Pressure Error", error_msg)
2242 "Layer 2: Pressure Curve Optimization",
2244 f
"⚠️ Optimization failed: {e}, using initial guess"
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")
2252 "length_ratio": 1.0,
2254 "start_pressure": initial_lox_pressure_pa,
2255 "end_pressure": initial_lox_pressure_pa * 0.7,
2259 "length_ratio": 1.0,
2261 "start_pressure": initial_fuel_pressure_pa,
2262 "end_pressure": initial_fuel_pressure_pa * 0.7,
2267 layer2_logger.info(
"")
2268 layer2_logger.info(
"Calculating final results...")
2269 results_final =
None
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:
2281 results_final = runner_layer2.evaluate_arrays_with_time(
2285 track_ablative_geometry=
False,
2286 use_coupled_solver=
False,
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)))
2292 total_impulse_actual = float(np.trapezoid(thrust_final, time_array))
2293 initial_thrust_actual = float(thrust_final[0])
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
2304 total_propellant_mass,
2307 mdot_kg_s=mdot_O_final + mdot_F_final,
2308 thrust_n=thrust_final,
2309 vehicle=flight_vehicle,
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
2338 min_stability_margin_val =
None
2339 if results_final
is not None:
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))
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)")
2358 layer2_logger.warning(
"Stability margins found but all values are invalid (NaN/Inf)")
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)}")
2367 copv_pressure_trace_Pa =
None
2369 if results_final
is not None and mdot_O_final
is not None and mdot_F_final
is not None:
2372 P_tank_O_psi_final = P_tank_O_optimized / 6894.76
2373 P_tank_F_psi_final = P_tank_F_optimized / 6894.76
2375 df_copv_final = pd.DataFrame({
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,
2385 copv_volume_m3_final =
None
2386 if hasattr(config_layer2,
"press_tank")
and hasattr(config_layer2.press_tank,
"free_volume_L"):
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)
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")
2397 copv_pressure_trace_Pa =
None
2401 copv_results_final = size_or_check_copv_for_polytropic_N2(
2408 n2_Z_csv=str(N2_Z_LOOKUP_CSV),
2410 branch_temperatures_K={
2414 copv_volume_m3=copv_volume_m3_final,
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([]))
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")
2432 except Exception
as e:
2433 layer2_logger.warning(f
"Error calculating COPV requirements for final results: {repr(e)}")
2435 copv_pressure_trace_Pa =
None
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:
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)
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)
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:
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]}...")
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 = []
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):
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")
2485 delta_p_O = diag.get(
"delta_p_injector_O")
2486 delta_p_F = diag.get(
"delta_p_injector_F")
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))
2492 delta_p_inj_O_list.append(0.0)
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))
2497 delta_p_inj_F_list.append(0.0)
2499 delta_p_inj_O_list.append(0.0)
2500 delta_p_inj_F_list.append(0.0)
2503 delta_p_inj_O_list.append(0.0)
2504 delta_p_inj_F_list.append(0.0)
2506 if len(delta_p_inj_O_list) > 0:
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")
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
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})")
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
2524 layer2_logger.warning(
"No diagnostics found in results_final - cannot extract injector pressure drops")
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,
2540 "total_impulse_Ns": total_impulse_actual,
2541 "required_impulse_Ns": required_impulse_final,
2542 "lox_mass_kg": total_lox_mass_final,
2543 "fuel_mass_kg": total_fuel_mass_final,
2544 "burn_time_s": target_burn_time,
2545 "avg_of_ratio": avg_of_ratio,
2546 "min_stability_margin": min_stability_margin_val,
2547 "is_success": success,
2549 "thrust_curve_time": thrust_curve_time,
2550 "thrust_curve_values": thrust_curve_values,
2552 "of_curve_values": of_curve_values,
2554 "delta_p_inj_O_psi": delta_p_inj_O_values,
2555 "delta_p_inj_F_psi": delta_p_inj_F_values,
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),
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,
2574 layer2_logger.info(
"")
2575 layer2_logger.info(f
"Layer 2 optimization complete. Log saved to: {log_file_path}")
2578 if lox_segments
is not None and fuel_segments
is not None:
2581 lox_segment_configs = [
2583 length_ratio=float(seg[
"length_ratio"]),
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,
2589 for seg
in lox_segments
2591 fuel_segment_configs = [
2593 length_ratio=float(seg[
"length_ratio"]),
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,
2599 for seg
in fuel_segments
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,
2614 optimized_config.pressure_curves = pressure_curves_config
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)}")
2622 layer2_logger.handlers.clear()
2624 return optimized_config, time_array, P_tank_O_optimized, P_tank_F_optimized, summary, success