STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
layer3_thermal_protection.py
Go to the documentation of this file.
1"""Layer 3: Thermal Protection Optimization (Final Sizing)
2
3This layer optimizes final ablative liner and graphite insert thicknesses to
4meet recession requirements with margin while minimizing mass.
5
6Once Layer 2 passes, this refines the thermal protection to right-size
7the thicknesses (20% margin over max recession).
8"""
9
10from __future__ import annotations
11
12from typing import Dict, Any, Optional, Tuple, Callable
13from pathlib import Path
14import numpy as np
15import copy
16import logging
17import time
18from datetime import datetime
19import itertools
20
21from engine.pipeline.config_schemas import PintleEngineConfig
22from engine.core.runner import PintleEngineRunner
23
24
26 optimized_config: PintleEngineConfig,
27 time_array: np.ndarray,
28 P_tank_O_array: np.ndarray,
29 P_tank_F_array: np.ndarray,
30 full_time_results: Dict[str, Any],
31 n_time_points: int,
32 update_progress: Callable,
33 log_status: Callable,
34 objective_callback: Optional[Callable[[int, float, float], None]] = None,
35 optimization_method: str = "gradient", # "gradient", "cma", or "de"
36) -> Tuple[PintleEngineConfig, Dict[str, Any], Dict[str, Any]]:
37 """
38 Run Layer 3: Thermal Protection Optimization.
39
40 Optimizes final thermal protection thicknesses to meet recession requirements
41 with margin while minimizing mass, while preserving the Layer 2 burn
42 quality (impulse, stability).
43
44 Args:
45 optimization_method: Optimization algorithm to use:
46 - "gradient": Fast gradient descent (recommended). Exploits monotonic
47 relationship between thickness and recession. ~5-10 evaluations.
48 - "cma": CMA-ES global optimizer. More thorough but slower. ~60-80 evals.
49 - "de": Differential Evolution fallback. Similar to CMA-ES.
50
51 Returns:
52 Tuple of (optimized_config, updated_time_results, thermal_results)
53 """
54 from scipy.optimize import minimize as scipy_minimize
55
56 # Try to import CMA-ES, fall back to DE if not available
57 try:
58 import cma
59 use_cma = True
60 except ImportError:
61 use_cma = False
62 from scipy.optimize import differential_evolution
63
64 # ------------------------------------------------------------------
65 # Set up Layer 3 logging (mirrors Layer 2 style)
66 # ------------------------------------------------------------------
67 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
68 # Ensure output/logs directory exists
69 output_logs_dir = Path(__file__).resolve().parents[3] / "output" / "logs"
70 output_logs_dir.mkdir(parents=True, exist_ok=True)
71 log_file_path = output_logs_dir / f"layer3_thermal_{timestamp}.log"
72
73 layer3_logger = logging.getLogger("layer3_thermal")
74 layer3_logger.setLevel(logging.INFO)
75 layer3_logger.handlers.clear()
76
77 file_handler = logging.FileHandler(log_file_path, mode="w", encoding="utf-8")
78 file_handler.setLevel(logging.INFO)
79 file_formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
80 file_handler.setFormatter(file_formatter)
81 layer3_logger.addHandler(file_handler)
82 layer3_logger.propagate = False
83
84 layer3_logger.info("=" * 70)
85 layer3_logger.info("Layer 3: Thermal Protection Optimization")
86 layer3_logger.info("=" * 70)
87 layer3_logger.info(f"Log file: {log_file_path}")
88 layer3_logger.info(f"Time points: {n_time_points}")
89
90 ablative_cfg = optimized_config.ablative_cooling if hasattr(optimized_config, "ablative_cooling") else None
91 graphite_cfg = optimized_config.graphite_insert if hasattr(optimized_config, "graphite_insert") else None
92
93 thermal_results = {
94 "max_recession_chamber": None,
95 "max_recession_throat": None,
96 "ablative_adequate": True,
97 "graphite_adequate": True,
98 "thermal_protection_valid": True,
99 "log_file": log_file_path,
100 }
101
102 layer3_bounds = []
103 layer3_x0 = []
104
105 if ablative_cfg and ablative_cfg.enabled:
106 # Generic ablative bounds: 3–20 mm. Layer 3 will determine the actual
107 # required thickness based solely on its own time-series recession
108 # results, independent of any Layer 1 / Layer 2 guesses.
109 bounds_abl = (0.003, 0.025)
110 layer3_bounds.append(bounds_abl)
111 # Start from the midpoint of the search interval (independent of prior layers).
112 ablative_x0 = 0.5 * (bounds_abl[0] + bounds_abl[1])
113 layer3_x0.append(ablative_x0)
114 layer3_logger.info(
115 "Ablative enabled: search bounds=[%.3f, %.3f] mm, x0=%.3f mm",
116 bounds_abl[0] * 1000.0,
117 bounds_abl[1] * 1000.0,
118 ablative_x0 * 1000.0,
119 )
120
121 if graphite_cfg and graphite_cfg.enabled:
122 # Generic graphite bounds: 3–25 mm.
123 bounds_gra = (0.003, 0.025)
124 layer3_bounds.append(bounds_gra)
125 graphite_x0 = 0.5 * (bounds_gra[0] + bounds_gra[1])
126 layer3_x0.append(graphite_x0)
127 layer3_logger.info(
128 "Graphite enabled: search bounds=[%.3f, %.3f] mm, x0=%.3f mm",
129 bounds_gra[0] * 1000.0,
130 bounds_gra[1] * 1000.0,
131 graphite_x0 * 1000.0,
132 )
133
134 # Preserve the original Layer 2 time-series as the baseline burn that all
135 # Layer 3 candidates must still satisfy.
136 updated_time_results = full_time_results
137
138 # ------------------------------------------------------------------
139 # Baseline metrics from the incoming time-series (Layer 2 candidate)
140 # ------------------------------------------------------------------
141 try:
142 baseline_thrust = np.atleast_1d(full_time_results.get("F", np.zeros_like(time_array)))
143 if baseline_thrust.size > n_time_points:
144 baseline_thrust = baseline_thrust[:n_time_points]
145 baseline_impulse = float(np.trapezoid(baseline_thrust, time_array[: baseline_thrust.size]))
146 except Exception:
147 baseline_impulse = 0.0
148
149 # Baseline stability (use chugging margin if available, else stability_score)
150 baseline_min_stability = None
151 try:
152 if "chugging_stability_margin" in full_time_results:
153 chug = np.nan_to_num(
154 np.atleast_1d(full_time_results["chugging_stability_margin"]),
155 nan=0.0,
156 posinf=0.0,
157 neginf=0.0,
158 )
159 baseline_min_stability = float(np.min(chug))
160 elif "stability_score" in full_time_results:
161 stab = np.nan_to_num(
162 np.atleast_1d(full_time_results["stability_score"]),
163 nan=0.0,
164 posinf=0.0,
165 neginf=0.0,
166 )
167 baseline_min_stability = float(np.min(stab))
168 except Exception:
169 baseline_min_stability = None
170
171 layer3_logger.info(
172 "Baseline metrics from Layer 2: impulse=%.3f kN·s, min_stability=%s",
173 baseline_impulse / 1000.0,
174 f"{baseline_min_stability:.3f}" if baseline_min_stability is not None else "N/A",
175 )
176
177 if len(layer3_x0) > 0:
178 layer3_x0 = np.array(layer3_x0, dtype=float)
179
180 # Track optimization evaluations for optional streaming to UI
181 layer3_state: Dict[str, Any] = {
182 "eval_index": 0,
183 "best_objective": float("inf"),
184 }
185
186 # Simple cache so we don't rerun the expensive time-varying solver for
187 # identical (quantized) thickness combinations. Because we quantize to
188 # 0.1 mm, many optimizer proposals map to the same physical design.
189 _objective_cache: Dict[Tuple[float, ...], float] = {}
190
191 def downsample_array(arr: np.ndarray, n_coarse: int) -> np.ndarray:
192 """Downsample array from fine grid to coarse grid using linear interpolation."""
193 n_fine = len(arr)
194 if n_coarse >= n_fine:
195 return arr.copy()
196 # Use evenly spaced indices for downsampling
197 indices = np.linspace(0, n_fine - 1, n_coarse)
198 return np.interp(indices, np.arange(n_fine), arr)
199
200 def layer3_objective(x_layer3: np.ndarray, n_eval_points: Optional[int] = None) -> float:
201 """Optimize thermal protection to minimize mass while meeting recession requirements."""
202 eval_start = time.time()
203 layer3_state["eval_index"] += 1
204 eval_idx = int(layer3_state["eval_index"])
205
206 try:
207 x_layer3 = np.asarray(x_layer3, dtype=float)
208 if not np.all(np.isfinite(x_layer3)):
209 layer3_logger.warning(
210 "Eval %d received non-finite thickness vector %s; returning large penalty.",
211 eval_idx,
212 repr(x_layer3),
213 )
214 return 1e6
215
216 # Create isolated config copy - use Pydantic's model_copy if available for better isolation
217 # Otherwise fall back to deepcopy
218 if hasattr(optimized_config, 'model_copy'):
219 config_layer3 = optimized_config.model_copy(deep=True)
220 else:
221 config_layer3 = copy.deepcopy(optimized_config)
222 # CRITICAL: Always enable turbulence coupling for consistent physics
223 if hasattr(config_layer3, 'combustion') and hasattr(config_layer3.combustion, 'efficiency'):
224 config_layer3.combustion.efficiency.use_turbulence_coupling = True
225 idx_param = 0
226 chosen_thicknesses: list[float] = []
227
228 # Quantize thicknesses to a practical resolution (0.05 mm) so the
229 # local optimizer does not waste iterations on micrometer‑scale
230 # tweaks that are meaningless at the model fidelity.
231 quantum_m = 0.00005 # 0.05 mm
232
233 if ablative_cfg and ablative_cfg.enabled:
234 t_abl = float(np.clip(x_layer3[idx_param], layer3_bounds[idx_param][0], layer3_bounds[idx_param][1]))
235 t_abl = float(np.round(t_abl / quantum_m) * quantum_m)
236 config_layer3.ablative_cooling.initial_thickness = t_abl
237 chosen_thicknesses.append(t_abl)
238 idx_param += 1
239
240 if graphite_cfg and graphite_cfg.enabled:
241 t_gra = float(
242 np.clip(x_layer3[idx_param], layer3_bounds[idx_param][0], layer3_bounds[idx_param][1])
243 )
244 t_gra = float(np.round(t_gra / quantum_m) * quantum_m)
245 config_layer3.graphite_insert.initial_thickness = t_gra
246 chosen_thicknesses.append(t_gra)
247
248 # Verify config was updated correctly before creating runner
249 if ablative_cfg and ablative_cfg.enabled:
250 if abs(config_layer3.ablative_cooling.initial_thickness - chosen_thicknesses[0]) > 1e-9:
251 layer3_logger.warning(
252 "Eval %03d: Config ablative thickness mismatch! Expected %.6f, got %.6f",
253 eval_idx,
254 chosen_thicknesses[0],
255 config_layer3.ablative_cooling.initial_thickness,
256 )
257 if graphite_cfg and graphite_cfg.enabled:
258 gra_idx = 0 if not ablative_cfg or not ablative_cfg.enabled else 1
259 if abs(config_layer3.graphite_insert.initial_thickness - chosen_thicknesses[gra_idx]) > 1e-9:
260 layer3_logger.warning(
261 "Eval %03d: Config graphite thickness mismatch! Expected %.6f, got %.6f",
262 eval_idx,
263 chosen_thicknesses[gra_idx],
264 config_layer3.graphite_insert.initial_thickness,
265 )
266
267 # Check cache before running the expensive time solver.
268 cache_key = (tuple(chosen_thicknesses), n_eval_points)
269 if cache_key in _objective_cache:
270 obj_cached = float(_objective_cache[cache_key])
271 eval_time = time.time() - eval_start
272 layer3_logger.info(
273 "Eval %03d (cached): thicknesses=%s mm, n_pts=%s, obj=%.3f, dt=%.2fs",
274 eval_idx,
275 [t * 1000.0 for t in chosen_thicknesses],
276 n_eval_points,
277 obj_cached,
278 eval_time,
279 )
280 return obj_cached
281
282 # CRITICAL: Create a fresh PintleEngineRunner with the updated config for each evaluation
283 # This ensures the runner's internal state (solver, geometry, etc.) reflects the new thicknesses
284 runner_layer3 = PintleEngineRunner(config_layer3)
285
286 # Verify runner is using the correct config thicknesses
287 if ablative_cfg and ablative_cfg.enabled:
288 runner_abl_thick = runner_layer3.config.ablative_cooling.initial_thickness
289 if abs(runner_abl_thick - chosen_thicknesses[0]) > 1e-9:
290 layer3_logger.error(
291 "Eval %03d: Runner config ablative thickness mismatch! Expected %.6f, got %.6f",
292 eval_idx,
293 chosen_thicknesses[0],
294 runner_abl_thick,
295 )
296 if graphite_cfg and graphite_cfg.enabled:
297 gra_idx = 0 if not ablative_cfg or not ablative_cfg.enabled else 1
298 runner_gra_thick = runner_layer3.config.graphite_insert.initial_thickness
299 if abs(runner_gra_thick - chosen_thicknesses[gra_idx]) > 1e-9:
300 layer3_logger.error(
301 "Eval %03d: Runner config graphite thickness mismatch! Expected %.6f, got %.6f",
302 eval_idx,
303 chosen_thicknesses[gra_idx],
304 runner_gra_thick,
305 )
306
307 # Downsample arrays if using coarse grid for faster evaluation
308 # Downsample arrays if using coarse grid for faster evaluation
309 if n_eval_points is not None and n_eval_points < n_time_points:
310 eval_time_array = downsample_array(time_array, n_eval_points)
311 eval_P_tank_O = downsample_array(P_tank_O_array, n_eval_points)
312 eval_P_tank_F = downsample_array(P_tank_F_array, n_eval_points)
313 else:
314 # Use full resolution arrays
315 eval_time_array = time_array
316 eval_P_tank_O = P_tank_O_array
317 eval_P_tank_F = P_tank_F_array
318
319 # CRITICAL: Use same solver settings as ui_app.py for consistency
320 # During optimization, we use fully-coupled solver to match final results
321 results_layer3 = runner_layer3.evaluate_arrays_with_time(
322 eval_time_array,
323 eval_P_tank_O,
324 eval_P_tank_F,
325 track_ablative_geometry=True, # Enable ablative geometry tracking
326 use_coupled_solver=True, # Use fully-coupled solver (matches ui_app.py and final analysis)
327 )
328
329 recession_chamber = float(np.max(np.atleast_1d(results_layer3.get("recession_chamber", [0.0]))))
330 recession_throat = float(np.max(np.atleast_1d(results_layer3.get("recession_throat", [0.0]))))
331
332 # ------------------------------------------------------------------
333 # Re-check key Layer 2 burn metrics for this candidate
334 # ------------------------------------------------------------------
335 # Total impulse (use eval_time_array for integration)
336 thrust_hist = np.atleast_1d(results_layer3.get("F", np.zeros_like(eval_time_array)))
337 n_eval = len(eval_time_array)
338 if thrust_hist.size > n_eval:
339 thrust_hist = thrust_hist[:n_eval]
340 thrust_hist = np.nan_to_num(
341 thrust_hist,
342 nan=0.0,
343 posinf=0.0,
344 neginf=0.0,
345 )
346 total_impulse = float(np.trapezoid(thrust_hist, eval_time_array[: thrust_hist.size]))
347
348
349 impulse_penalty = 0.0
350 if baseline_impulse > 0.0 and total_impulse < baseline_impulse:
351 # Penalize fractional loss of impulse relative to baseline.
352 # Increased scale from 1e3 to 1e4 to make impulse preservation competitive
353 # with recession penalties while still prioritizing safety.
354 frac_loss = (baseline_impulse - total_impulse) / baseline_impulse
355 impulse_penalty = max(0.0, frac_loss) * 1e4
356
357 # Stability penalty (discourage worse-than-baseline stability)
358 stability_penalty = 0.0
359 try:
360 if "chugging_stability_margin" in results_layer3:
361 chug = np.nan_to_num(
362 np.atleast_1d(results_layer3["chugging_stability_margin"]),
363 nan=0.0,
364 posinf=0.0,
365 neginf=0.0,
366 )
367 min_stability = float(np.min(chug))
368 elif "stability_score" in results_layer3:
369 stab = np.nan_to_num(
370 np.atleast_1d(results_layer3["stability_score"]),
371 nan=0.0,
372 posinf=0.0,
373 neginf=0.0,
374 )
375 min_stability = float(np.min(stab))
376 else:
377 min_stability = None
378
379 if baseline_min_stability is not None and min_stability is not None:
380 # Penalize any drop below the baseline minimum stability.
381 # Increased scale from 5e2 to 5e3 to make stability preservation competitive
382 # with recession penalties while still prioritizing safety.
383 if min_stability < baseline_min_stability:
384 stability_penalty = (baseline_min_stability - min_stability) * 5e3
385 except Exception:
386 # If stability cannot be evaluated, don't add a penalty but
387 # also don't crash the objective.
388 pass
389
390 # Check if recession exceeds a safe margin of the thickness.
391 # We require recession to stay below (thickness / margin_factor),
392 # e.g. with margin_factor=1.25 → max_recession <= 0.8 * thickness.
393 idx_param = 0
394 recession_penalty = 0.0
395 margin_factor = 1.25 # 25% thickness margin over max recession
396 # Reduced from 1e5 to 5e4 to balance with impulse/stability penalties.
397 # Recession safety is still prioritized but not completely dominant.
398 penalty_scale = 5e4 # Strongly discourage unsafe solutions
399
400 if ablative_cfg and ablative_cfg.enabled:
401 thickness = x_layer3[idx_param]
402 required_thickness = recession_chamber * margin_factor
403 if required_thickness > 0.0 and thickness < required_thickness:
404 deficit = required_thickness - thickness
405 frac = deficit / required_thickness
406 recession_penalty += penalty_scale * frac * frac
407 idx_param += 1
408
409 if graphite_cfg and graphite_cfg.enabled:
410 thickness = x_layer3[idx_param]
411 required_thickness = recession_throat * margin_factor
412 if required_thickness > 0.0 and thickness < required_thickness:
413 deficit = required_thickness - thickness
414 frac = deficit / required_thickness
415 recession_penalty += penalty_scale * frac * frac
416
417 # Objective: minimize total thickness (proxy for mass) while
418 # honouring the recession safety margin and preserving the
419 # Layer 2 burn quality (impulse, stability).
420 total_thickness = float(np.sum(x_layer3))
421 obj = (
422 total_thickness * 1000.0 # thickness in mm for scaling
423 + recession_penalty
424 + impulse_penalty
425 + stability_penalty
426 )
427
428 # Optional: stream objective history to external callback (e.g., UI plot)
429 if objective_callback is not None:
430 try:
431 best_obj = float(layer3_state["best_objective"])
432 if obj < best_obj:
433 best_obj = obj
434 layer3_state["best_objective"] = best_obj
435 objective_callback(eval_idx, float(obj), float(best_obj))
436 except Exception:
437 # Never let UI/consumer callback break the optimizer loop
438 pass
439
440 eval_time = time.time() - eval_start
441 # Store in cache and log full diagnostics.
442 _objective_cache[cache_key] = obj
443
444 layer3_logger.info(
445 "Eval %03d: thicknesses=%s mm, recession (chamber/throat)=%.3f/%.3f mm, "
446 "penalties: recession=%.3f, impulse=%.3f, stability=%.3f, "
447 "obj=%.3f, dt=%.2fs",
448 eval_idx,
449 [t * 1000.0 for t in chosen_thicknesses],
450 recession_chamber * 1000.0,
451 recession_throat * 1000.0,
452 recession_penalty,
453 impulse_penalty,
454 stability_penalty,
455 obj,
456 eval_time,
457 )
458
459 return obj
460 except Exception as exc:
461 eval_time = time.time() - eval_start
462 layer3_logger.error("Exception in eval %03d (%.2fs): %r", eval_idx, eval_time, exc)
463 import traceback
464
465 layer3_logger.error(traceback.format_exc())
466 return 1e6
467
468 # Optimize Layer 3 using the selected method
469 try:
470 if optimization_method == "gradient":
471 # ------------------------------------------------------------------
472 # Fast Gradient Descent / Binary Search (exploits monotonicity)
473 # ------------------------------------------------------------------
474 # Key insight: thickness and recession have a monotonic relationship.
475 # More thickness = material lasts longer = less recession ratio.
476 # So we can use a simple bisection/gradient approach to find the
477 # minimum thickness that satisfies the margin constraint.
478 # ------------------------------------------------------------------
479 layer3_logger.info(
480 "Starting Layer 3 optimization using GRADIENT DESCENT (fast mode) with %d dims...",
481 len(layer3_x0),
482 )
483 layer3_logger.info(
484 "This exploits the monotonic relationship: more thickness = less recession ratio."
485 )
486
487 margin_factor = 1.25 # 25% margin (thickness >= recession * 1.25)
488
489 def evaluate_thickness_feasibility(thicknesses: np.ndarray) -> Tuple[bool, Dict[str, float]]:
490 """
491 Evaluate if given thicknesses meet the margin requirement.
492 Returns (is_feasible, recession_data).
493 """
494 # Create config with these thicknesses
495 if hasattr(optimized_config, 'model_copy'):
496 config_test = optimized_config.model_copy(deep=True)
497 else:
498 config_test = copy.deepcopy(optimized_config)
499
500 if hasattr(config_test, 'combustion') and hasattr(config_test.combustion, 'efficiency'):
501 config_test.combustion.efficiency.use_turbulence_coupling = True
502
503 idx = 0
504 if ablative_cfg and ablative_cfg.enabled:
505 config_test.ablative_cooling.initial_thickness = float(thicknesses[idx])
506 idx += 1
507 if graphite_cfg and graphite_cfg.enabled:
508 config_test.graphite_insert.initial_thickness = float(thicknesses[idx])
509
510 # Run simulation
511 runner_test = PintleEngineRunner(config_test)
512 results_test = runner_test.evaluate_arrays_with_time(
513 time_array,
514 P_tank_O_array,
515 P_tank_F_array,
516 track_ablative_geometry=True,
517 use_coupled_solver=True,
518 )
519
520 recession_chamber = float(np.max(np.atleast_1d(results_test.get("recession_chamber", [0.0]))))
521 recession_throat = float(np.max(np.atleast_1d(results_test.get("recession_throat", [0.0]))))
522
523 # Check feasibility for each component
524 feasible = True
525 recession_data = {
526 "recession_chamber": recession_chamber,
527 "recession_throat": recession_throat,
528 }
529
530 idx = 0
531 if ablative_cfg and ablative_cfg.enabled:
532 required_abl = recession_chamber * margin_factor
533 recession_data["required_ablative"] = required_abl
534 if thicknesses[idx] < required_abl:
535 feasible = False
536 idx += 1
537
538 if graphite_cfg and graphite_cfg.enabled:
539 required_gra = recession_throat * margin_factor
540 recession_data["required_graphite"] = required_gra
541 if thicknesses[idx] < required_gra:
542 feasible = False
543
544 return feasible, recession_data
545
546 # Step 1: Evaluate at lower bound to get baseline recession
547 lower_bounds = np.array([b[0] for b in layer3_bounds])
548 upper_bounds = np.array([b[1] for b in layer3_bounds])
549
550 layer3_logger.info("Step 1: Evaluating recession at lower bounds...")
551 layer3_state["eval_index"] += 1
552 _, recession_at_min = evaluate_thickness_feasibility(lower_bounds)
553
554 layer3_logger.info(
555 "Recession at min thickness: chamber=%.3f mm, throat=%.3f mm",
556 recession_at_min["recession_chamber"] * 1000,
557 recession_at_min["recession_throat"] * 1000,
558 )
559
560 # Step 2: Calculate initial estimate based on recession at minimum
561 # Since recession is roughly independent of thickness (it depends on heat flux),
562 # we can estimate: required_thickness ≈ recession * margin_factor
563 initial_estimate = []
564 idx = 0
565 if ablative_cfg and ablative_cfg.enabled:
566 est_abl = recession_at_min["recession_chamber"] * margin_factor * 1.1 # 10% extra safety
567 est_abl = float(np.clip(est_abl, lower_bounds[idx], upper_bounds[idx]))
568 initial_estimate.append(est_abl)
569 idx += 1
570 if graphite_cfg and graphite_cfg.enabled:
571 est_gra = recession_at_min["recession_throat"] * margin_factor * 1.1
572 est_gra = float(np.clip(est_gra, lower_bounds[idx], upper_bounds[idx]))
573 initial_estimate.append(est_gra)
574
575 initial_estimate = np.array(initial_estimate)
576 layer3_logger.info(
577 "Initial thickness estimate: %s mm",
578 [t * 1000 for t in initial_estimate],
579 )
580
581 # Step 3: Binary search refinement for each dimension independently
582 # This works because thickness dimensions are largely independent
583 best_thicknesses = initial_estimate.copy()
584
585 for dim_idx in range(len(layer3_bounds)):
586 lo = lower_bounds[dim_idx]
587 hi = upper_bounds[dim_idx]
588
589 dim_name = "ablative" if dim_idx == 0 and ablative_cfg and ablative_cfg.enabled else "graphite"
590 layer3_logger.info("Binary search for %s thickness...", dim_name)
591
592 # Binary search to find minimum feasible thickness
593 for iteration in range(6): # ~6 iterations gives <1% precision
594 mid = (lo + hi) / 2
595 test_thicknesses = best_thicknesses.copy()
596 test_thicknesses[dim_idx] = mid
597
598 layer3_state["eval_index"] += 1
599 eval_idx = int(layer3_state["eval_index"])
600
601 feasible, recession_data = evaluate_thickness_feasibility(test_thicknesses)
602
603 # Calculate objective for callback
604 obj = float(np.sum(test_thicknesses)) * 1000 # mm
605 if not feasible:
606 obj += 1e4 # Penalty for infeasible
607
608 if objective_callback is not None:
609 best_obj = float(layer3_state["best_objective"])
610 if obj < best_obj:
611 best_obj = obj
612 layer3_state["best_objective"] = best_obj
613 objective_callback(eval_idx, obj, best_obj)
614
615 layer3_logger.info(
616 "Eval %03d: %s=%.3f mm, feasible=%s, recession=%.3f mm",
617 eval_idx,
618 dim_name,
619 mid * 1000,
620 feasible,
621 (recession_data.get("recession_chamber", 0) if dim_name == "ablative"
622 else recession_data.get("recession_throat", 0)) * 1000,
623 )
624
625 if feasible:
626 hi = mid # Can try thinner
627 best_thicknesses[dim_idx] = mid
628 else:
629 lo = mid # Need thicker
630
631 # Final value with small safety margin
632 best_thicknesses[dim_idx] = hi * 1.02 # 2% extra margin
633 best_thicknesses[dim_idx] = float(np.clip(
634 best_thicknesses[dim_idx],
635 lower_bounds[dim_idx],
636 upper_bounds[dim_idx]
637 ))
638
639 layer3_logger.info(
640 "Gradient descent finished: optimal thicknesses = %s mm",
641 [t * 1000 for t in best_thicknesses],
642 )
643
644 # Create result object
645 class GradientResult:
646 def __init__(self, x, fun):
647 self.x = x
648 self.fun = fun
649 self.success = True
650
651 final_obj = float(np.sum(best_thicknesses)) * 1000
652 result_layer3 = GradientResult(best_thicknesses, final_obj)
653
654 elif use_cma and optimization_method != "de":
655 # ------------------------------------------------------------------
656 # CMA-ES optimization (preferred: better exploration for small dimensions)
657 # ------------------------------------------------------------------
658 layer3_logger.info(
659 "Starting Layer 3 optimization using CMA-ES with %d dims...",
660 len(layer3_x0),
661 )
662
663 # Calculate initial step size: ~15% of the range for good exploration
664 span = np.array([hi - lo for lo, hi in layer3_bounds])
665 sigma0 = float(np.mean(span) * 0.15) # 15% of average range
666 if not np.isfinite(sigma0) or sigma0 <= 0:
667 sigma0 = 0.002 # Default: 2mm
668
669 # Population size: CMA-ES default is 4+floor(3*ln(n)), but for 1-2D we want more exploration
670 # Use popsize ~8-12 for better exploration in small dimensions
671 n_dims = len(layer3_x0)
672 cma_popsize = max(8, 4 + int(3 * np.log(n_dims)))
673
674 # Budget: ~60-80 evaluations (reasonable for 3-4s per eval)
675 # This gives CMA-ES ~8-10 generations to explore and converge
676 cma_budget = min(80, cma_popsize * 10)
677
678 # Per-dimension scaling (larger ranges get larger step sizes)
679 cma_stds = span / np.mean(span) if np.mean(span) > 0 else np.ones(n_dims)
680
681 # Prepare bounds for CMA
682 lower_bounds = np.array([b[0] for b in layer3_bounds])
683 upper_bounds = np.array([b[1] for b in layer3_bounds])
684
685 # CMA-ES options
686 opts = {
687 "bounds": [lower_bounds.tolist(), upper_bounds.tolist()],
688 "popsize": cma_popsize,
689 "maxiter": 1000, # High limit, we'll stop based on budget
690 "verb_disp": 0,
691 "verb_log": 0,
692 "tolx": 1e-6,
693 "tolfun": 1e-4,
694 "tolstagnation": 20,
695 "ftarget": -np.inf,
696 "seed": 42, # Deterministic
697 }
698 if n_dims > 1:
699 opts["CMA_stds"] = cma_stds.tolist()
700
701 # Initialize CMA-ES
702 x0_clamped = np.clip(layer3_x0, lower_bounds, upper_bounds)
703 es = cma.CMAEvolutionStrategy(x0_clamped.tolist(), sigma0, opts)
704
705 # CMA-ES uses coarse 25-point grid for fast global search
706 n_coarse_points = 25
707 layer3_logger.info(
708 "CMA-ES will use coarse %d-point grid (vs full %d-point grid) for ~8x speedup",
709 n_coarse_points,
710 n_time_points,
711 )
712
713 best_x_cma = x0_clamped.copy()
714 best_f_cma = float(layer3_objective(best_x_cma, n_eval_points=n_coarse_points))
715 evals_used = 1
716
717 # Run CMA-ES iterations
718 while not es.stop() and evals_used < cma_budget:
719 candidates = es.ask()
720 fitnesses = []
721
722 # Limit candidates if we're close to budget
723 remaining_budget = cma_budget - evals_used
724 if remaining_budget < len(candidates):
725 candidates = candidates[:remaining_budget]
726
727 for candidate in candidates:
728 x_candidate = np.asarray(candidate, dtype=float)
729 # Ensure within bounds (CMA handles this, but double-check)
730 x_candidate = np.clip(x_candidate, lower_bounds, upper_bounds)
731 obj_val = float(layer3_objective(x_candidate, n_eval_points=n_coarse_points))
732 fitnesses.append(obj_val)
733 evals_used += 1
734
735 # Track best
736 if obj_val < best_f_cma:
737 best_f_cma = obj_val
738 best_x_cma = x_candidate.copy()
739
740 # Tell CMA-ES about all evaluated candidates
741 es.tell(candidates, fitnesses)
742
743 # Log progress every few generations
744 if evals_used % (cma_popsize * 2) == 0:
745 layer3_logger.info(
746 "CMA-ES progress: evals=%d/%d, best_obj=%.6f, sigma=%.6f",
747 evals_used,
748 cma_budget,
749 best_f_cma,
750 es.sigma,
751 )
752
753 layer3_logger.info(
754 "CMA-ES finished: final_obj=%.6f, evals=%d, sigma=%.6f",
755 best_f_cma,
756 evals_used,
757 es.sigma,
758 )
759
760 # Final local polish with L-BFGS-B on full 200-point grid
761 layer3_logger.info(
762 "Starting final local polish with L-BFGS-B on full %d-point grid...",
763 n_time_points,
764 )
765
766 # Create wrapper that uses full resolution
767 def layer3_objective_full_res(x):
768 return layer3_objective(x, n_eval_points=None)
769
770 result_layer3 = scipy_minimize(
771 layer3_objective_full_res,
772 best_x_cma,
773 method="L-BFGS-B",
774 bounds=layer3_bounds,
775 options={
776 "maxiter": 10,
777 "maxfun": 15,
778 "ftol": 1e-4,
779 },
780 )
781
782 # Use the better of CMA-ES best or L-BFGS-B result
783 if result_layer3.success and np.isfinite(result_layer3.fun):
784 if result_layer3.fun < best_f_cma:
785 best_x_cma = np.asarray(result_layer3.x, dtype=float)
786 best_f_cma = float(result_layer3.fun)
787 layer3_logger.info(
788 "L-BFGS-B polish improved: final_obj=%.6f",
789 best_f_cma,
790 )
791 else:
792 layer3_logger.info(
793 "L-BFGS-B polish did not improve (CMA-ES best was better)"
794 )
795
796 # Create result object compatible with rest of code
797 class CMA_Result:
798 def __init__(self, x, fun):
799 self.x = x
800 self.fun = fun
801 self.success = True
802
803 result_layer3 = CMA_Result(best_x_cma, best_f_cma)
804
805 else:
806 # ------------------------------------------------------------------
807 # Fallback: DE + grid search + L-BFGS-B (original approach)
808 # Used when optimization_method="de" or CMA-ES is not available
809 # ------------------------------------------------------------------
810 layer3_logger.info(
811 "Using differential_evolution with %d dims (method=%s, cma_available=%s)...",
812 len(layer3_x0),
813 optimization_method,
814 use_cma,
815 )
816 de_result = differential_evolution(
817 layer3_objective,
818 layer3_bounds,
819 maxiter=3,
820 popsize=2,
821 polish=False,
822 tol=0.3,
823 )
824 layer3_logger.info(
825 "Global search finished: de_obj=%.6f, nfev=%s",
826 float(de_result.fun) if np.isfinite(de_result.fun) else float("nan"),
827 getattr(de_result, "nfev", "N/A"),
828 )
829
830 best_local_x = np.asarray(de_result.x, dtype=float)
831 best_local_obj = float(de_result.fun) if np.isfinite(de_result.fun) else float("inf")
832
833 # Coarse local sweep
834 coarse_step_m = 0.0002 # 0.2 mm
835 offsets = [-2, -1, 0, 1, 2]
836
837 for delta_indices in itertools.product(offsets, repeat=len(layer3_bounds)):
838 delta_vec = np.asarray(delta_indices, dtype=float) * coarse_step_m
839 candidate_x = best_local_x + delta_vec
840 for i, (lo, hi) in enumerate(layer3_bounds):
841 candidate_x[i] = float(np.clip(candidate_x[i], lo, hi))
842
843 obj_val = float(layer3_objective(candidate_x))
844 if obj_val < best_local_obj:
845 best_local_obj = obj_val
846 best_local_x = candidate_x
847
848 layer3_logger.info(
849 "Coarse local sweep finished: best_obj=%.6f at x=%s mm",
850 best_local_obj,
851 [float(v) * 1000.0 for v in np.atleast_1d(best_local_x)],
852 )
853
854 layer3_logger.info("Starting Layer 3 local optimization using L-BFGS-B...")
855 result_layer3 = scipy_minimize(
856 layer3_objective,
857 best_local_x,
858 method="L-BFGS-B",
859 bounds=layer3_bounds,
860 options={
861 "maxiter": 12,
862 "maxfun": 25,
863 "ftol": 1e-3,
864 },
865 )
866 layer3_logger.info(
867 "Local optimization finished: success=%s, final_obj=%.6f, nit=%s, nfev=%s",
868 result_layer3.success,
869 float(result_layer3.fun) if np.isfinite(result_layer3.fun) else float("nan"),
870 getattr(result_layer3, "nit", "N/A"),
871 getattr(result_layer3, "nfev", "N/A"),
872 )
873
874 # Update config with optimized thicknesses
875 idx_param = 0
876 if ablative_cfg and ablative_cfg.enabled:
877 optimized_config.ablative_cooling.initial_thickness = float(
878 np.clip(result_layer3.x[idx_param], layer3_bounds[idx_param][0], layer3_bounds[idx_param][1])
879 )
880 thermal_results["optimized_ablative_thickness"] = optimized_config.ablative_cooling.initial_thickness
881 # No wall write-back needed: Layer 1 DERIVES its OD -> inner-diameter
882 # wall from ablative_cooling.initial_thickness (+ the metal case field)
883 # via utils.total_wall_thickness_m, so the sized liner set above is
884 # picked up automatically on the next run. (The old write-back
885 # overwrote the combined metal+ablative allowance with the ablative
886 # alone, dropping the metal case from the geometry budget.)
887 update_progress(
888 "Layer 3: Burn Analysis Optimization",
889 0.70,
890 (
891 f"✓ Optimized ablative: "
892 f"{optimized_config.ablative_cooling.initial_thickness*1000:.2f}mm "
893 ),
894 )
895 layer3_logger.info(
896 "Optimized ablative thickness: %.3f mm",
897 optimized_config.ablative_cooling.initial_thickness * 1000.0,
898 )
899 idx_param += 1
900
901 if graphite_cfg and graphite_cfg.enabled:
902 optimized_config.graphite_insert.initial_thickness = float(
903 np.clip(result_layer3.x[idx_param], layer3_bounds[idx_param][0], layer3_bounds[idx_param][1])
904 )
905 thermal_results["optimized_graphite_thickness"] = optimized_config.graphite_insert.initial_thickness
906 update_progress(
907 "Layer 3: Burn Analysis Optimization",
908 0.72,
909 (
910 f"✓ Optimized graphite: "
911 f"{optimized_config.graphite_insert.initial_thickness*1000:.2f}mm "
912 ),
913 )
914 layer3_logger.info(
915 "Optimized graphite thickness: %.3f mm",
916 optimized_config.graphite_insert.initial_thickness * 1000.0,
917 )
918 except Exception as e:
919 layer3_logger.error("Layer 3 optimization failed: %r", e)
920 import traceback
921
922 layer3_logger.error(traceback.format_exc())
923 update_progress(
924 "Layer 3: Burn Analysis Optimization",
925 0.72,
926 f"⚠️ Layer 3 optimization failed: {e}, using current values",
927 )
928
929 # Re-run time series with optimized thermal protection to verify
930 update_progress("Layer 3: Burn Analysis", 0.74, "Re-running time series with optimized thermal protection...")
931 try:
932 # CRITICAL: Ensure ablative and graphite tracking are enabled in config for complete time series
933 if ablative_cfg and ablative_cfg.enabled:
934 optimized_config.ablative_cooling.track_geometry_evolution = True
935 if graphite_cfg and graphite_cfg.enabled:
936 # Ensure graphite insert is properly configured for tracking
937 if hasattr(optimized_config.graphite_insert, 'enabled'):
938 optimized_config.graphite_insert.enabled = True
939
940 # CRITICAL: Always enable turbulence coupling for consistent physics
941 if hasattr(optimized_config, 'combustion') and hasattr(optimized_config.combustion, 'efficiency'):
942 optimized_config.combustion.efficiency.use_turbulence_coupling = True
943
944 # CRITICAL: Create a fresh PintleEngineRunner with the optimized config
945 # This ensures the runner uses the final optimized thicknesses, not any cached values
946 optimized_runner_updated = PintleEngineRunner(optimized_config)
947
948 # Verify runner has correct optimized thicknesses
949 if ablative_cfg and ablative_cfg.enabled:
950 runner_abl = optimized_runner_updated.config.ablative_cooling.initial_thickness
951 config_abl = optimized_config.ablative_cooling.initial_thickness
952 if abs(runner_abl - config_abl) > 1e-9:
953 layer3_logger.error(
954 "Post-opt runner ablative thickness mismatch! Config: %.6f, Runner: %.6f",
955 config_abl,
956 runner_abl,
957 )
958 if graphite_cfg and graphite_cfg.enabled:
959 runner_gra = optimized_runner_updated.config.graphite_insert.initial_thickness
960 config_gra = optimized_config.graphite_insert.initial_thickness
961 if abs(runner_gra - config_gra) > 1e-9:
962 layer3_logger.error(
963 "Post-opt runner graphite thickness mismatch! Config: %.6f, Runner: %.6f",
964 config_gra,
965 runner_gra,
966 )
967
968 # CRITICAL: Re-run with all tracking features enabled for complete time series analysis
969 # This ensures we get recession rates, cumulative recession, L*, throat area evolution, etc.
970 full_time_results_updated = optimized_runner_updated.evaluate_arrays_with_time(
971 time_array,
972 P_tank_O_array,
973 P_tank_F_array,
974 track_ablative_geometry=True, # Enable ablative geometry tracking (L*, volume, recession)
975 use_coupled_solver=True, # Use fully-coupled solver for accurate results
976 )
977 updated_time_results = full_time_results_updated
978 thermal_results["max_recession_chamber"] = float(
979 np.max(np.atleast_1d(full_time_results_updated.get("recession_chamber", [0.0])))
980 )
981 thermal_results["max_recession_throat"] = float(
982 np.max(np.atleast_1d(full_time_results_updated.get("recession_throat", [0.0])))
983 )
984 layer3_logger.info(
985 "Post-optimization max recession (chamber / throat): "
986 "%.3f mm / %.3f mm",
987 thermal_results["max_recession_chamber"] * 1000.0,
988 thermal_results["max_recession_throat"] * 1000.0,
989 )
990
991 # Final safety check: mark thermal_protection_valid only if the
992 # optimized thicknesses satisfy the margin criteria against the
993 # post-optimization recession histories.
994 margin_factor = 1.25
995 ablative_ok = True
996 graphite_ok = True
997
998 if ablative_cfg and ablative_cfg.enabled:
999 t_abl = optimized_config.ablative_cooling.initial_thickness
1000 required_abl = thermal_results["max_recession_chamber"] * margin_factor
1001 ablative_ok = t_abl >= required_abl
1002
1003 if graphite_cfg and graphite_cfg.enabled:
1004 t_gra = optimized_config.graphite_insert.initial_thickness
1005 required_gra = thermal_results["max_recession_throat"] * margin_factor
1006 graphite_ok = t_gra >= required_gra
1007
1008 thermal_results["ablative_adequate"] = bool(ablative_ok)
1009 thermal_results["graphite_adequate"] = bool(graphite_ok)
1010 thermal_results["thermal_protection_valid"] = bool(ablative_ok and graphite_ok)
1011 except Exception as e:
1012 layer3_logger.error("Re-evaluation after optimization failed: %r", e)
1013 import traceback
1014
1015 layer3_logger.error(traceback.format_exc())
1016 update_progress(
1017 "Layer 3: Burn Analysis",
1018 0.74,
1019 f"⚠️ Re-evaluation failed: {e}, using original results",
1020 )
1021
1022 status_msg = (
1023 "Completed | Ablative {:.2f} mm, Graphite {:.2f} mm, "
1024 "Max recession chamber {:.2f} mm, throat {:.2f} mm "
1025 "(valid: {}) (see {} for detailed log)".format(
1026 (optimized_config.ablative_cooling.initial_thickness * 1000)
1027 if ablative_cfg and ablative_cfg.enabled
1028 else 0.0,
1029 (optimized_config.graphite_insert.initial_thickness * 1000)
1030 if graphite_cfg and graphite_cfg.enabled
1031 else 0.0,
1032 (thermal_results.get("max_recession_chamber") or 0.0) * 1000,
1033 (thermal_results.get("max_recession_throat") or 0.0) * 1000,
1034 thermal_results.get("thermal_protection_valid", False),
1035 log_file_path,
1036 )
1037 )
1038 log_status("Layer 3", status_msg)
1039
1040 # CRITICAL: Final verification - ensure optimized_config has the optimized thicknesses
1041 # This is the authoritative source that will be returned and saved to session state
1042 if ablative_cfg and ablative_cfg.enabled:
1043 if "optimized_ablative_thickness" in thermal_results:
1044 optimized_config.ablative_cooling.initial_thickness = thermal_results["optimized_ablative_thickness"]
1045 layer3_logger.info(
1046 "Final optimized ablative thickness in config: %.3f mm",
1047 optimized_config.ablative_cooling.initial_thickness * 1000.0,
1048 )
1049
1050 if graphite_cfg and graphite_cfg.enabled:
1051 if "optimized_graphite_thickness" in thermal_results:
1052 optimized_config.graphite_insert.initial_thickness = thermal_results["optimized_graphite_thickness"]
1053 layer3_logger.info(
1054 "Final optimized graphite thickness in config: %.3f mm",
1055 optimized_config.graphite_insert.initial_thickness * 1000.0,
1056 )
1057
1058 layer3_logger.info("Layer 3 optimization complete. Log saved to: %s", log_file_path)
1059 # Clean up handlers so repeated calls don't leak file descriptors
1060 layer3_logger.handlers.clear()
1061
1062 return optimized_config, updated_time_results, thermal_results
1063
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
static emmc_status_t format(emmc_log_t *log)
Definition emmc_log.c:324
Tuple[PintleEngineConfig, Dict[str, Any], Dict[str, Any]] run_layer3_thermal_protection(PintleEngineConfig optimized_config, np.ndarray time_array, np.ndarray P_tank_O_array, np.ndarray P_tank_F_array, Dict[str, Any] full_time_results, int n_time_points, Callable update_progress, Callable log_status, Optional[Callable[[int, float, float], None]] objective_callback=None, str optimization_method="gradient")
TOML_NODISCARD_CTOR time() noexcept=default