STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
layer1_static_optimization.py
Go to the documentation of this file.
1"""Layer 1: Static Optimization
2
3This layer implements the main optimization loop that optimizes ONLY
4static (time‑independent) quantities:
5
6- Engine geometry (throat, L*, expansion ratio, pintle or impinging jet geometry)
7- Initial tank pressures for LOX and fuel (single value per tank)
8
9All **time‑varying** pressure behavior (segments/curves over the burn)
10is handled **exclusively** in Layer 2 (`layer2_pressure.py`). Layer 1
11must NOT create or manipulate pressure segments or time arrays.
12"""
13
14from __future__ import annotations
15
16from typing import Tuple, Callable, Dict, Any, Optional, List, Sequence
17import numpy as np
18import copy
19import logging
20import time
21import os
22from datetime import datetime
23from pathlib import Path
24from concurrent.futures import ProcessPoolExecutor
25
26from engine.pipeline.config_schemas import PintleEngineConfig, HybridOptimizerConfig
27from engine.core.runner import PintleEngineRunner
28from engine.core.discharge import discharge_cd_inf_ratio
30 IMPINGEMENT_LD_TARGET_DEFAULT,
31 EXIT_LAND_DEFAULT,
32 FREE_JET_MAX_D,
33 PLATE_THICKNESS_DEFAULT,
34 MANIFOLD_Q_FRAC,
35 channel_for_ring,
36 channel_lands,
37 channel_velocity_head as _channel_velocity_head,
38 igniter_keepouts,
39 impingement_ld_band,
40 passage_back_entry,
41 ring_face_reach,
42)
43
44#: Standoff band when a caller hands over no requirements at all.
45_LD_BAND_DEFAULT = impingement_ld_band(None)
47 effective_flow_areas_from_cd,
48 merge_effective_area_warnings,
49)
51 injector_dp_bands_from_requirements,
52 injector_dp_ratio_penalty_weighted,
53 injector_dp_ratios_from_eval_result,
54 injector_dp_ratio_within_gate,
55)
56
57from scipy.optimize import minimize, differential_evolution
58
59try:
60 import cma
61except ImportError: # pragma: no cover - optional dependency
62 cma = None
63
64
65from engine.optimizer.utils import (
66 extract_all_parameters,
67 impinging_chamber_inner_diameter_for_bounds,
68 impinging_d_jet_upper_bound_m,
69 impinging_n_elements_hi_int,
70 impinging_spacing_upper_bound_m,
71)
72
74 chamber_length_calc,
75 contraction_length_horizontal_calc,
76)
77
78
79#: How ``ChamberSolver.solve`` words a solve that ran but did not close: its own checks on
80#: the root it found. A validation replay that ends in one of these is an invalid design,
81#: not a crash (see ``_validation_evaluate_or_bundle`` in ``run_layer1_optimization``).
82_CHAMBER_SOLVER_REFUSALS = (
83 "Solution validation failed",
84 "Convergence validation failed",
85 "Chamber pressure solver failed",
86 "Solver returned non-finite pressure",
87)
88
89
90TOTAL_WALL_THICKNESS_M = 0.0254 # fallback only: 1.0 inch total (0.5 in/side). See _layer1_total_wall_thickness_m.
91
92
93def _layer1_total_wall_thickness_m(config: Any) -> float:
94 """Total chamber-wall thickness (both sides) for the OD -> gas-side conversion.
95
96 Thin alias for :func:`engine.optimizer.utils.total_wall_thickness_m`: the wall is
97 DERIVED as ``2 x (metal case + ablative liner when enabled)`` from the fields that
98 own those layers (``design_requirements.metal_wall_thickness_per_side_m`` and
99 ``ablative_cooling.initial_thickness``). Layer 3 re-sizing the liner therefore
100 updates the wall on the next run automatically — there is no lump wall field and
101 no write-back (the old one overwrote metal+ablative with ablative alone, so
102 rerun geometry grew past what physically fits the OD envelope).
103 """
104 from engine.optimizer.utils import total_wall_thickness_m
105 return total_wall_thickness_m(config)
106
107# Default for ``requirements["min_stability_margin"]`` when unset. MUST stay identical in
108# ``run_layer1_optimization`` and ``_compute_objective_value`` — parallel CMA ranks candidates via
109# workers using the latter; L-BFGS-B uses ``objective()`` on the parent process.
110_LAYER1_DEFAULT_MIN_STABILITY_MARGIN = 1.2
111
112# Fallback injector ΔP/Pc bands when ``constants_dict`` omits keys (must match
113# ``requirements.get("injector_dp_ratio_*")`` defaults in ``run_layer1_optimization``).
114_LAYER1_DEFAULT_DP_O_BAND = (0.20, 0.40)
115_LAYER1_DEFAULT_DP_F_BAND = (0.20, 0.40)
116
117# Fallback when ``layer1_exit_pressure_inside_quad_scale`` is absent from constants (matches
118# ``_requirement_float(..., "layer1_exit_pressure_inside_quad_scale", 0.35)``).
119_LAYER1_DEFAULT_EXIT_PRESSURE_INSIDE_QUAD = 0.35
120
121# Tiny stability/packing residuals below this are treated as feasible for primary objective blending.
122_LAYER1_DEFAULT_INFEASIBILITY_GATE_EPS = 0.002
123
124# Multiplicative deadband for log-space momentum pull toward R=1 (optimizer soft term only).
125# Zero = always-on (log R)^2 gradient toward R=1.
126_LAYER1_DEFAULT_MOMENTUM_LOG_DEADBAND_REL = 0.0
127
128_LAYER1_BASE_INFEAS = 1e6
129_LAYER1_W_INFEAS = 1e5
130
131# Cap on the per-candidate derived-DOF solve; it exits on tolerance well before this.
132_LAYER1_DERIVE_MAX_ITERS_DEFAULT = 8
133
134# Chamber-pressure target: sign-off band and the objective's interior aim band (80 % of it,
135# the same convention as the dP/Pc and momentum bands -- penalty and gate never share an edge).
136_LAYER1_PC_GATE_REL = 0.01
137_LAYER1_PC_AIM_REL = 0.8 * _LAYER1_PC_GATE_REL
138
139
141 """Drop-in minimal executor used when process pools are unavailable."""
142
143 def __init__(self):
145
146 def __enter__(self):
147 return self
148
149 def __exit__(self, exc_type, exc, tb):
150 return False
151
152 def map(self, fn, iterable, chunksize=None):
153 del chunksize # Serial executor ignores chunk hints.
154 return map(fn, iterable)
155
156
157def _throat_area_m2_from_config(config: PintleEngineConfig) -> Optional[float]:
158 """Prefer unified ``chamber_geometry``; many YAMLs leave legacy ``chamber`` null."""
159 cg = getattr(config, "chamber_geometry", None)
160 if cg is not None:
161 at = getattr(cg, "A_throat", None)
162 if at is not None and np.isfinite(float(at)) and float(at) > 0:
163 return float(at)
164 ch = getattr(config, "chamber", None)
165 if ch is not None:
166 at = getattr(ch, "A_throat", None)
167 if at is not None and np.isfinite(float(at)) and float(at) > 0:
168 return float(at)
169 return None
170
171
172def _requirement_float(requirements: Dict[str, Any], key: str, default: float) -> float:
173 """Parse float from Layer-1 ``requirements``; treat explicit ``None`` as *unset*."""
174 v = requirements.get(key)
175 return float(default if v is None else v)
176
177
178def _layer1_thrust_deadband(req: Any) -> float:
179 """Relative thrust deadband for the objective.
180
181 2% when the throat is searched (the historical value: thrust is a soft target CMA has to
182 negotiate). 0.1% when the throat is SOLVED from thrust -- the derivation lands every
183 candidate on target, so a wide deadband only lets edge-sitting winners through with a
184 2-3% miss priced at ~1-3 objective points (measured: 7341 N against 7200 with no thrust
185 penalty at all). ``layer1_thrust_deadband_rel`` overrides both.
186 """
187 get = getattr(req, "get", None)
188 if get is None:
189 return 0.02
190 v = get("layer1_thrust_deadband_rel")
191 if v is not None:
192 try:
193 return max(0.0, float(v))
194 except (TypeError, ValueError):
195 pass
196 derived = get("layer1_derive_throat_from_thrust")
197 derived = True if derived is None else bool(derived)
198 return 0.001 if derived else 0.02
199
200
201def _layer1_igniter_keepouts(config_obj: Any, requirements: Dict[str, Any]) -> Optional[Dict[str, Any]]:
202 """The declared igniter port's keep-outs (engine.core.injectors.layout), or None."""
203 ign = getattr(getattr(config_obj, "injector", None), "igniter", None)
204 if ign is None:
205 return None
206 return igniter_keepouts(
207 ign.model_dump() if hasattr(ign, "model_dump") else dict(ign),
208 plate_thickness=_requirement_float(requirements, "layer1_injector_plate_thickness_m", 0.0),
209 min_web=_requirement_float(requirements, "layer1_injector_min_web_m", 0.0))
210
211
212def _layer1_plate(config_obj: Any) -> Dict[str, Any]:
213 """The injector.plate block as a dict ({} when undeclared): how the plug is machined."""
214 pl = getattr(getattr(config_obj, "injector", None), "plate", None)
215 if pl is None:
216 return {}
217 return pl.model_dump() if hasattr(pl, "model_dump") else dict(pl)
218
219
220def _layer1_injector_plate_constants(config_obj: Any, requirements: Dict[str, Any]) -> Dict[str, Any]:
221 """The injector-plate entries of Layer 1's constants dict, from the config and requirements.
222
223 Nothing declared under ``injector.plate`` => flat face, plenum back: exactly the constraints
224 this optimizer had before. ``layer1_injector_plate_support`` reads ``injector.plate.support``
225 when the schema carries it; a rim-retained plug defaults to simply supported.
226 """
227 # Orifice land length (diameters) per side, which decides whether the back-face entry is
228 # the counterbore or the orifice itself. Same source the Cd model reads.
229 def _land_ld(side: str) -> float:
230 try:
231 v = getattr(config_obj.discharge[side], "orifice_l_over_d", None)
232 except (KeyError, TypeError, AttributeError):
233 v = None
234 return float(v) if v is not None and np.isfinite(float(v)) and float(v) > 0 else 4.0
235 ld_O = _land_ld("oxidizer")
236 try:
237 _fuel_ld = getattr(config_obj.discharge["fuel"], "orifice_l_over_d", None)
238 except (KeyError, TypeError, AttributeError):
239 _fuel_ld = None
240 # The layout falls back to the oxidiser's L/d, not 4.0; so does this.
241 ld_F = _land_ld("fuel") if _fuel_ld is not None else ld_O
242 plate = _layer1_plate(config_obj)
243 plate_obj = getattr(getattr(config_obj, "injector", None), "plate", None)
244 support = getattr(plate_obj, "support", None) or plate.get("support") or "simply_supported"
245 _closure_plate_stress_coeff(support) # an unknown value fails here, not per candidate
246 return {
247 "layer1_injector_plate_thickness_m": _requirement_float(
248 requirements, "layer1_injector_plate_thickness_m", 0.0),
249 "layer1_injector_land_ld_O": ld_O,
250 "layer1_injector_land_ld_F": ld_F,
251 "layer1_injector_face_contoured": plate.get("face") == "contoured",
252 "layer1_injector_back_channels": plate.get("back") == "channels",
253 "layer1_injector_exit_land": (float(plate["exit_land"]) if plate.get("exit_land") is not None
254 else EXIT_LAND_DEFAULT),
255 "layer1_injector_channel_width_m": float(plate.get("channel_width") or 0.0),
256 "layer1_injector_channel_floor": str(plate.get("channel_floor") or "flat"),
257 "layer1_injector_channel_inlets": int(plate.get("channel_inlets") or 1),
258 "layer1_injector_plate_support": str(support),
259 }
260
261
262def _layer1_centre_clear_m(config_obj: Any, requirements: Dict[str, Any]) -> float:
263 """Centre keep-out diameter: the explicit reservation if set, else the igniter's, else 0."""
264 cc = _requirement_float(requirements, "layer1_injector_center_clear_dia_m", 0.0)
265 if cc > 0.0:
266 return cc
267 ign = _layer1_igniter_keepouts(config_obj, requirements)
268 return float(ign["face_keepout_dia"]) if ign is not None else 0.0
269
270
271def _requirement_bool(requirements: Dict[str, Any], key: str, default: bool) -> bool:
272 """Parse a checkbox-style flag from Layer-1 ``requirements``.
273
274 Explicit ``None`` (or a missing key) means *unset* and takes ``default``.
275 Accepts the string forms the frontend/YAML can produce so a config saying
276 ``false`` is not read as truthy.
277 """
278 v = requirements.get(key)
279 if v is None:
280 return bool(default)
281 if isinstance(v, str):
282 s = v.strip().lower()
283 if s in ("false", "0", "no", "off", ""):
284 return False
285 if s in ("true", "1", "yes", "on"):
286 return True
287 return bool(default)
288 return bool(v)
289
290
291def _layer1_infeasibility_gate_eps(requirements: Dict[str, Any]) -> float:
292 return max(
293 0.0,
295 requirements,
296 "layer1_infeasibility_gate_eps",
297 _LAYER1_DEFAULT_INFEASIBILITY_GATE_EPS,
298 ),
299 )
300
301
302def _layer1_inf_residual(infeasibility_score: float, requirements: Dict[str, Any]) -> float:
303 gate_eps = _layer1_infeasibility_gate_eps(requirements)
304 return max(0.0, float(infeasibility_score) - gate_eps)
305
306
308 infeasibility_score: float,
309 requirements: Dict[str, Any],
310 *,
311 length_violation: bool = False,
312) -> bool:
313 if length_violation:
314 return False
315 return _layer1_inf_residual(infeasibility_score, requirements) <= 0.0
316
317
318def _layer1_inf_residual_from_objective(obj: float) -> float:
319 """Infer feasibility residual from scalar objective (parallel worker path)."""
320 if not np.isfinite(obj) or float(obj) < _LAYER1_BASE_INFEAS:
321 return 0.0
322 return max(0.0, (float(obj) - _LAYER1_BASE_INFEAS) / _LAYER1_W_INFEAS)
323
324
325def _layer1_feasible_scalar_objective(obj: float) -> bool:
326 """True when ``obj`` is on the feasible (weighted-sum) branch, not the 1e6 infeasibility floor."""
327 return bool(np.isfinite(obj) and float(obj) < _LAYER1_BASE_INFEAS)
328
329
330def _layer1_valley_escape_tier(evals: int, best_f: float, stagnation: int) -> int:
331 """Sigma-boost tier for a search stuck on the infeasible plateau (0 = none).
332
333 Only while no feasible design has been found. The tiers used to fire on absolute
334 objective magnitudes (best > 100/150/300), which a shaping floor that never reaches zero
335 -- the chamber-mass or performance term -- always exceeds, so every feasible run was
336 kicked 4-7 times mid-convergence and blank ones never. Once feasible, restarts and
337 refreshes do the exploring.
338 """
340 return 0
341 if evals > 5000 and stagnation > 1000:
342 return 3
343 if evals > 3000 and stagnation > 500:
344 return 2
345 if evals > 1500 and stagnation > 300:
346 return 1
347 return 0
348
349
350def _x_solved_or(res: Dict[str, Any], fallback: np.ndarray) -> np.ndarray:
351 """The candidate vector AS EVALUATED, falling back to the proposal.
352
353 Derived DOFs are solved inside the worker and their bounds are collapsed, so the vector
354 CMA proposed still carries the seed for them. Only ``x_solved`` describes the design that
355 was actually scored -- record anything else and the winner reported back is not the
356 winner that was measured.
357 """
358 xs = res.get("x_solved") if isinstance(res, dict) else None
359 if xs is None:
360 return np.asarray(fallback, dtype=float).copy()
361 arr = np.asarray(xs, dtype=float)
362 if arr.shape != np.asarray(fallback).shape or not np.all(np.isfinite(arr)):
363 return np.asarray(fallback, dtype=float).copy()
364 return arr
365
366
368 res: Dict[str, Any], state: Optional[Dict[str, Any]]
369) -> None:
370 """Keep a parent-side copy of the last successful worker ``evaluate()`` for validation replay."""
371 if state is None:
372 return
373 if not res.get("success") or not isinstance(res.get("full_results"), dict):
374 return
375 fr = res["full_results"]
376 try:
377 rf = float(fr.get("F", float("nan")))
378 except (TypeError, ValueError):
379 rf = float("nan")
380 if not np.isfinite(rf):
381 return
382 # ``res`` came off the pool by unpickling, so this process is already the only
383 # holder of ``fr``; the parent reads scalars off ``res`` afterwards and never mutates
384 # full_results. The one consumer of this bundle (see the validation replay) deep-copies
385 # on read, so a copy here was the second of three on the same payload.
386 state["last_good_eval_bundle"] = {
387 "results": fr,
388 "P_O_Pa": float(res.get("P_O_Pa", 0.0)),
389 "P_F_Pa": float(res.get("P_F_Pa", 0.0)),
390 "thrust_error": float(res.get("thrust_error", 1.0)),
391 "of_error": float(res.get("of_error", 1.0)),
392 }
393
394
395def _lox_ring_inboard(spacing_O_m: Any, spacing_F_m: Any, *, fallback: bool = True) -> bool:
396 """True when the LOX pitch circle is inside (or on) the fuel's: D_pitch = n s / pi with one
397 n for both rings, so the spacings order the rings. ``fallback`` when either is unusable."""
398 try:
399 sO, sF = float(spacing_O_m), float(spacing_F_m)
400 except (TypeError, ValueError):
401 return bool(fallback)
402 if not (np.isfinite(sO) and np.isfinite(sF) and sO > 0 and sF > 0):
403 return bool(fallback)
404 return bool(sO <= sF)
405
406
408 mdot_O: float, mdot_F: float,
409 rho_O: float, rho_F: float,
410 n_elements: float, d_jet_O_m: float, d_jet_F_m: float,
411 angle_O_deg: float, angle_F_deg: float,
412 lox_inboard: bool = True,
413 *,
414 spacing_O_m: float = float("nan"),
415 spacing_F_m: float = float("nan"),
416) -> float:
417 """Tilt of the doublet's resultant from the chamber axis [deg]; + = toward the WALL.
418
419 Which ring is inboard comes from the pitch circles when the spacings are given
420 (:func:`_lox_ring_inboard`, the rule :func:`_resultant_tilt_breakeven_deg` uses), and
421 only falls back to ``lox_inboard`` without them. It used to come from the
422 ``layer1_ring_order_fuel_outboard`` REQUEST, so a design whose LOX ring sat outboard had
423 its tilt reported with the wrong sign against a break-even computed the right way.
424
425 This is the quantity the "protect the ablative" rule was always meant to guard. A
426 doublet's spray fan follows the VECTOR SUM of its two streams. With LOX inboard, the LOX
427 jet travels radially outward to the collision and the fuel jet inward, so with outward
428 positive::
429
430 p = mdot * v (stream momentum; v = mdot / (rho * n * A_jet))
431 P_r = p_O*sin(th_O) - p_F*sin(th_F)
432 P_z = p_O*cos(th_O) + p_F*cos(th_F)
433 alpha = atan2(P_r, P_z)
434
435 The momentum-FLUX ratio ``R = sqrt(rho_O v_O^2 / (rho_F v_F^2))`` that Layer 1 used as
436 the proxy carries no areas and no angles, and::
437
438 p_O/p_F = R^2 * (A_O n_O)/(A_F n_F)
439
440 so R = 1 is NOT balance -- balance is p_O = p_F. Measured on this design: at R = 1.0002
441 the stream ratio is 1.805 and the fan tilts +16.0 deg OUTWARD, reaching the liner 99 mm
442 down a 190 mm chamber while the spray is still liquid (x* = 167 mm). Banning R > 1 was
443 therefore selecting the WORST case for the liner, and costing 8.2 s of ideal Isp by
444 forcing O/F to 2.19 (CEA peak is at 1.65).
445
446 Angles are the lever: sin(th_O)/sin(th_F) = p_F/p_O gives alpha = 0 exactly.
447 """
448 try:
449 mO, mF = float(mdot_O), float(mdot_F)
450 rO, rF = float(rho_O), float(rho_F)
451 n = max(1.0, float(n_elements))
452 dO, dF = float(d_jet_O_m), float(d_jet_F_m)
453 tO, tF = np.deg2rad(float(angle_O_deg)), np.deg2rad(float(angle_F_deg))
454 except (TypeError, ValueError):
455 return float("nan")
456 A_O = np.pi * (dO / 2.0) ** 2 * n
457 A_F = np.pi * (dF / 2.0) ** 2 * n
458 if not all(np.isfinite(v) and v > 0 for v in (mO, mF, rO, rF, A_O, A_F)):
459 return float("nan")
460 p_O = mO * (mO / (rO * A_O)) # mdot * v
461 p_F = mF * (mF / (rF * A_F))
462 if not (np.isfinite(p_O) and np.isfinite(p_F)):
463 return float("nan")
464 lox_inboard = _lox_ring_inboard(spacing_O_m, spacing_F_m, fallback=lox_inboard)
465 sgn = 1.0 if lox_inboard else -1.0 # flip the rings, flip which way "outward" is
466 P_r = sgn * (p_O * np.sin(tO) - p_F * np.sin(tF))
467 P_z = p_O * np.cos(tO) + p_F * np.cos(tF)
468 if not (np.isfinite(P_r) and np.isfinite(P_z)) or P_z <= 0.0:
469 return float("nan")
470 return float(np.degrees(np.arctan2(P_r, P_z)))
471
472
474 *, n_elements: float, spacing_O_m: float, spacing_F_m: float,
475 angle_O_deg: float, angle_F_deg: float,
476 D_chamber_inner_m: float, L_chamber_m: float,
477) -> float:
478 """Outward tilt at which the spray fan just reaches the liner at the throat plane [deg].
479
480 ``layer1_resultant_tilt_max_deg`` is a CONSTANT the operator sets, but what makes a given
481 tilt safe is geometry the optimizer is free to move underneath it::
482
483 r_imp = r_inner + L_imp * tan(theta_inner) where the fans actually start
484 reach = (r_wall - r_imp) / tan(alpha) how far they run before the liner
485 safe <=> reach > L_chamber
486
487 so the break-even angle is ``atan2(r_wall - r_imp, L_chamber)``. Sizing the allowance by
488 hand is a trap: an allowance justified at one impingement radius stops being justified
489 when the ring pair moves out. Measured while setting this design up -- 6.0 deg was chosen
490 against r_imp = 37 mm (break-even 9.6 deg) and the converged design landed at r_imp =
491 44.1 mm, where break-even is 7.21 deg. Still inside, but on 1.25x rather than the 1.6x
492 that was intended, and nothing would have said so.
493
494 Returns NaN when the geometry is degenerate. This is a REPORTING aid; it deliberately does
495 not move the gate, which stays exactly where the operator put it.
496 """
497 try:
498 n = float(n_elements)
499 dp_O = n * float(spacing_O_m) / np.pi
500 dp_F = n * float(spacing_F_m) / np.pi
501 tan_sum = float(np.tan(np.deg2rad(float(angle_O_deg)))
502 + np.tan(np.deg2rad(float(angle_F_deg))))
503 r_wall = 0.5 * float(D_chamber_inner_m)
504 L = float(L_chamber_m)
505 except (TypeError, ValueError):
506 return float("nan")
507 if not (np.isfinite(tan_sum) and tan_sum > 1e-9 and r_wall > 0 and np.isfinite(L) and L > 0):
508 return float("nan")
509 dr = 0.5 * abs(dp_O - dp_F)
510 L_imp = dr / tan_sum
511 inner_is_O = _lox_ring_inboard(spacing_O_m, spacing_F_m)
512 th_in = float(angle_O_deg) if inner_is_O else float(angle_F_deg)
513 r_imp = 0.5 * (dp_O if inner_is_O else dp_F) + L_imp * float(np.tan(np.deg2rad(th_in)))
514 if not np.isfinite(r_imp) or r_imp >= r_wall:
515 return 0.0
516 return float(np.degrees(np.arctan2(r_wall - r_imp, L)))
517
518
520 *, from_reach: bool, constant_deg: float, breakeven_deg: float, margin: float,
521) -> float:
522 """The outward tilt this design is allowed, in degrees.
523
524 Two modes, and the default is the old one exactly:
525
526 * ``from_reach = False`` -- the operator's constant. Previous behaviour.
527 * ``from_reach = True`` -- derived from the design's own geometry. ``breakeven_deg`` is the
528 angle at which the fan arrives at the liner exactly at the throat plane; dividing the
529 chamber length by ``margin`` and re-taking the arctangent gives the angle that arrives at
530 ``margin`` chamber lengths instead, which is the same thing as::
531
532 allowed = atan( tan(breakeven) / margin )
533
534 Why this exists: an outward fan is a hazard only if it REACHES the liner, and the angle at
535 which it does is set by ``r_wall - r_imp`` over the chamber length. ``r_imp`` is an output
536 the optimizer moves freely, so a constant allowance silently changes meaning between runs.
537 Measured: 6.0 deg was justified against r_imp = 37 mm (break-even 9.6) and the converged
538 design landed at r_imp = 44.1 mm, where break-even is 7.21 -- still safe, but on 1.25x
539 rather than the 1.6x that was intended, and nothing said so.
540
541 A non-finite or non-positive break-even falls back to the constant rather than to zero: a
542 degenerate geometry should not silently forbid every candidate.
543 """
544 if not from_reach:
545 return float(constant_deg)
546 be = float(breakeven_deg)
547 if not (np.isfinite(be) and be > 0.0):
548 return float(constant_deg)
549 m = float(margin) if (np.isfinite(margin) and margin > 0.0) else 1.5
550 return float(np.degrees(np.arctan(np.tan(np.deg2rad(be)) / m)))
551
552
554 tilt_deg: Any,
555 *,
556 scale_deg: float = 2.0,
557 outward_multiplier: float = 25.0,
558) -> float:
559 """Preference for a spray fan aimed straight down the chamber: ``(tilt/scale)^2``.
560
561 ``tilt_deg`` is the resultant tilt, positive OUTWARD (toward the liner). Inward lean costs the
562 plain quadratic, outward lean ``outward_multiplier`` times it. This is a preference priced in
563 the objective; the wall guard (:func:`_impinging_resultant_wall_violation`) stays the hard limit.
564 """
565 try:
566 a = float(tilt_deg)
567 except (TypeError, ValueError):
568 return 0.0
569 if not np.isfinite(a):
570 return 0.0
571 sc = float(scale_deg) if (np.isfinite(scale_deg) and scale_deg > 0) else 2.0
572 k = max(1.0, float(outward_multiplier)) if a > 0.0 else 1.0
573 return float(k * (a / sc) ** 2)
574
575
577 tilt_deg: Any,
578 *,
579 max_outward_deg: float = 0.0,
580 scale_deg: float = 2.0,
581) -> float:
582 """Squared normalized excess of the resultant tilt past ``max_outward_deg``.
583
584 A CONSTRAINT, not a preference: a fan aimed at the liner is a burn-through, so this goes
585 to the infeasibility score (same treatment the R proxy had) rather than being priced
586 against Isp. Tilting INWARD costs nothing here -- it is a mixing loss, handled by the
587 ordinary quality terms.
588 """
589 if tilt_deg is None:
590 return 0.0
591 try:
592 a = float(tilt_deg)
593 except (TypeError, ValueError):
594 return 0.0
595 if not np.isfinite(a):
596 return 0.0
597 excess = a - float(max_outward_deg)
598 if excess <= 0.0:
599 return 0.0
600 sc = float(scale_deg) if (np.isfinite(scale_deg) and scale_deg > 0) else 2.0
601 return float((excess / sc) ** 2)
602
603
605 R: Any,
606 *,
607 band_width: float = 0.025,
608 safe_side_is_below_one: bool = True,
609 scale: float = 0.005,
610) -> float:
611 """SUPERSEDED by _impinging_resultant_wall_violation; retained for tests/back-compat.
612
613 R is a momentum-FLUX ratio: it carries no areas and no angles, so R = 1 is not a balanced
614 doublet (p_O/p_F = R^2 * A_O/A_F). Banning R > 1 to protect the liner selected the most
615 outward-tilted design available and cost 8.2 s of ideal Isp. The live guard is the
616 resultant tilt.
617
618 Squared normalized excess of the momentum ratio past the WALL side of the band.
619
620 Zero when safe. This is a CONSTRAINT, not a preference, so it is added to the
621 infeasibility score rather than priced as a quality term. Pricing it instead -- a
622 999999x multiplier on a W_MOM of 1.2e5 -- put a quality term at 1e7..1e11 against a
623 BASE_INFEAS of 1e6, which inverted the lexicographic order (every wall-side design
624 scored worse than every genuinely infeasible one), flattened the landscape CMA adapts
625 to, and stopped the run dead in about a second.
626
627 Routed through infeasibility it keeps the intended meaning -- no feasible design ever
628 sits on the wall side -- while staying on the same numeric scale as every other hard
629 constraint, so the infeasibility gradient can still walk a candidate back into the band.
630 """
631 if R is None:
632 return 0.0
633 try:
634 r = float(R)
635 except (TypeError, ValueError):
636 return 0.0
637 if not np.isfinite(r) or r <= 0.0:
638 return 0.0
639 w = abs(float(band_width)) if np.isfinite(band_width) else 0.025
640 lo, hi = (1.0 - w, 1.0) if safe_side_is_below_one else (1.0, 1.0 + w)
641 excess = np.log(r / hi) if safe_side_is_below_one else np.log(lo / r)
642 if not np.isfinite(excess) or excess <= 0.0:
643 return 0.0
644 # Normalize before squaring. Raw log-excess is ~1e-5 for a 1% overshoot, which sits under
645 # the infeasibility gate epsilon (0.002) and would be waved through as "practically
646 # feasible" -- the failure this function exists to prevent. At 0.005 (0.5% in log R) a
647 # 0.1% overshoot already scores 0.04, comfortably past the gate.
648 s_norm = float(scale) if (np.isfinite(scale) and scale > 0) else 0.005
649 return float((float(excess) / s_norm) ** 2)
650
651
653 R: Any,
654 *,
655 wall_side_multiplier: float = 1.0,
656 low_side_multiplier: float = 1.0,
657 band_width: float = 0.05,
658 safe_side_is_below_one: bool = True,
659 scale: float = 0.10,
660 band_lo: Optional[float] = None,
661 band_hi: Optional[float] = None,
662) -> float:
663 """Mild symmetric cost on the momentum ratio: free in the band, quadratic outside.
664
665 ``band_lo``/``band_hi`` (the configured impinging_momentum_R_min/max, or their aim band)
666 set the free band when given; otherwise it is [1/(1+band_width), 1+band_width].
667
668 A doublet's resultant is the vector sum of its two streams. With LOX inboard its jet
669 travels radially outward to the collision and the fuel jet travels inward, so R > 1 means
670 the LOX side wins and the fan is thrown OUTWARD onto the ablative; R < 1 sends it into
671 the core, which only costs mixing::
672
673 [1 - band_width, 1] free -- slightly fuel-dominant, fan leans safely in
674 R < 1 - band_width discouraged -- 10x, a mixing loss and negotiable
675 R > 1 not priced here -- it is a hard constraint; see
676 _impinging_momentum_wall_violation
677
678 ``wall_side_multiplier`` is retained only so an existing config can still express "price
679 the wall side instead of banning it"; at its default the wall side returns 0 from this
680 function because the infeasibility path owns it.
681 """
682 if R is None:
683 return 0.0
684 try:
685 r = float(R)
686 except (TypeError, ValueError):
687 return 0.0
688 if not np.isfinite(r) or r <= 0.0:
689 return 0.0
690
691 # SYMMETRIC band about 1. The one-sided [1-w, 1] band existed because R was the liner
692 # guard and "below 1" was the safe side. It is not the guard any more -- the resultant
693 # tilt is -- and keeping the band one-sided taxed exactly the tilt-balanced designs,
694 # which sit at R ~ 1.010-1.013 (measured: 1286-2050 objective points for R being 1% high).
695 # ``safe_side_is_below_one`` still selects which side the multiplier applies to, for
696 # anyone who deliberately re-enables a directional preference.
697 # LOG-symmetric band: [1/(1+w), 1+w]. Deviations are measured in log space, so a linear
698 # band [1-w, 1+w] would price R and 1/R differently for purely arithmetic reasons
699 # (0.90 -> 0.292 but 1.111 -> 0.319 at w = 0.05).
700 w = abs(float(band_width)) if np.isfinite(band_width) else 0.025
701 hi = 1.0 + w
702 lo = 1.0 / hi
703 if (band_lo is not None and band_hi is not None and np.isfinite(band_lo)
704 and np.isfinite(band_hi) and 0.0 < float(band_lo) < float(band_hi)):
705 lo, hi = float(band_lo), float(band_hi)
706 if lo <= r <= hi:
707 return 0.0
708
709 on_wall_side = (r > hi) if safe_side_is_below_one else (r < lo)
710 mult = float(wall_side_multiplier if on_wall_side else low_side_multiplier)
711 if not (np.isfinite(mult) and mult > 0):
712 mult = 1.0
713 # Clamp: a config left over from when this term was the liner BAN can still carry
714 # 999999, which against W_MOM = 1.2e5 put a quality term at 1e7..1e11 versus a
715 # BASE_INFEAS of 1e6 and stopped the run dead. The liner guard is the resultant tilt now.
716 mult = min(mult, 1.0e3)
717
718 s_norm = float(scale) if (np.isfinite(scale) and scale > 0) else 0.10
719 dev = np.log(r / hi) if r > hi else np.log(lo / r)
720 return float(mult * (float(dev) / s_norm) ** 2)
721
722
724 R: Any,
725 *,
726 r_band_lo: Optional[float] = None,
727 r_band_hi: Optional[float] = None,
728) -> float:
729 """Penalty for momentum_ratio_R outside a preferred band.
730
731 If both ``r_band_lo`` and ``r_band_hi`` are finite and ``r_band_hi > r_band_lo``,
732 uses a **ratio-space** deadband: zero when ``r_band_lo <= R <= r_band_hi``,
733 else squared distance to the nearest band edge.
734
735 Otherwise (defaults / legacy): **log-space** deadband zero for R in [1/1.1, 1.1],
736 ``max(0, |log R| - log(1.1))**2`` outside.
737
738 Returns 0.0 if R is missing, non-finite, or <= 0.
739 """
740 if R is None:
741 return 0.0
742 try:
743 r = float(R)
744 except (TypeError, ValueError):
745 return 0.0
746 if not np.isfinite(r) or r <= 0.0:
747 return 0.0
748
749 if (
750 r_band_lo is not None
751 and r_band_hi is not None
752 and np.isfinite(r_band_lo)
753 and np.isfinite(r_band_hi)
754 and float(r_band_hi) > float(r_band_lo)
755 ):
756 lo, hi = float(r_band_lo), float(r_band_hi)
757 if lo <= r <= hi:
758 return 0.0
759 excess = float(lo - r) if r < lo else float(r - hi)
760 return float(excess * excess)
761
762 log_R = float(np.log(r))
763 tol = float(np.log(1.1))
764 excess = max(0.0, abs(log_R) - tol)
765 return float(excess * excess)
766
767
769 R: Any,
770 *,
771 rel_deadband: float = _LAYER1_DEFAULT_MOMENTUM_LOG_DEADBAND_REL,
772) -> float:
773 """Soft penalty pulling ``momentum_ratio_R`` toward 1.0 in log space.
774
775 Unlike the wide ratio-band hinge (zero throughout [R_min, R_max]), this stays active near R≈1
776 so CMA/L-BFGS can co-optimize momentum with thrust and O/F without lexicographic blocking.
777 Final validation still uses ``impinging_momentum_R_min/max``.
778 """
779 if R is None:
780 return 0.0
781 try:
782 r = float(R)
783 except (TypeError, ValueError):
784 return 0.0
785 if not np.isfinite(r) or r <= 0.0:
786 return 0.0
787 log_R = float(np.log(r))
788 db = max(0.0, float(rel_deadband))
789 if db <= 0.0:
790 return float(log_R * log_R)
791 # SOFT deadband (not a hard flat zero). A hard zero-penalty band around R=1 removes the
792 # sharp attractor that steers CMA-ES, so R wanders out of band and the parallel search
793 # collapses (L-BFGS survives it, CMA does not). Instead, inside the tolerance band we keep a
794 # *gentle* quadratic (IN_BAND_SCALE × the full curvature) so there is still a gradient toward
795 # R=1, and outside the band we apply the full quadratic — made continuous at the edge. Net
796 # effect: machining-tolerance deviations of R are cheap (won't be chased below the
797 # manufacturing floor), but R is still held firmly inside the band for the optimizer.
798 IN_BAND_SCALE = 0.1
799 log_tol = float(np.log(1.0 + db))
800 abs_log_R = abs(log_R)
801 if abs_log_R <= log_tol:
802 return float(IN_BAND_SCALE * abs_log_R * abs_log_R)
803 # Continuous with the in-band branch at abs_log_R == log_tol:
804 # in_band(log_tol) = IN_BAND_SCALE * log_tol**2
805 # out_band(log_tol) = log_tol**2 - (1 - IN_BAND_SCALE) * log_tol**2 = IN_BAND_SCALE * log_tol**2
806 return float(abs_log_R * abs_log_R - (1.0 - IN_BAND_SCALE) * log_tol * log_tol)
807
808
810 R: Any,
811 *,
812 r_band_lo: Optional[float] = None,
813 r_band_hi: Optional[float] = None,
814) -> float:
815 """Normalized squared violation outside a configured momentum band.
816
817 Returns 0.0 when no valid band is configured or R is missing/non-finite.
818 """
819 if (
820 r_band_lo is None
821 or r_band_hi is None
822 or not np.isfinite(float(r_band_lo))
823 or not np.isfinite(float(r_band_hi))
824 or float(r_band_hi) <= float(r_band_lo)
825 ):
826 return 0.0
827 try:
828 r = float(R)
829 except (TypeError, ValueError):
830 return 0.0
831 if not np.isfinite(r) or r <= 0.0:
832 return 0.0
833 lo = float(r_band_lo)
834 hi = float(r_band_hi)
835 if lo <= r <= hi:
836 return 0.0
837 excess = (lo - r) if r < lo else (r - hi)
838 band_span = max(hi - lo, 1e-9)
839 norm = excess / band_span
840 return float(norm * norm)
841
842
844 impingement_angle_deg: Any,
845 *,
846 angle_band_lo_deg: Optional[float] = None,
847 angle_band_hi_deg: Optional[float] = None,
848) -> float:
849 """Penalty for effective impingement angle outside a preferred degree band.
850
851 Returns 0.0 when bounds are unset/invalid, or when angle is missing/non-finite.
852 """
853 if impingement_angle_deg is None:
854 return 0.0
855 try:
856 phi = float(impingement_angle_deg)
857 except (TypeError, ValueError):
858 return 0.0
859 if not np.isfinite(phi):
860 return 0.0
861 if (
862 angle_band_lo_deg is None
863 or angle_band_hi_deg is None
864 or not np.isfinite(float(angle_band_lo_deg))
865 or not np.isfinite(float(angle_band_hi_deg))
866 or float(angle_band_hi_deg) <= float(angle_band_lo_deg)
867 ):
868 return 0.0
869 lo = float(angle_band_lo_deg)
870 hi = float(angle_band_hi_deg)
871 # Jet angles are snapped to whole degrees, so the INCLUDED angle (theta_O + theta_F) moves
872 # in 1 deg steps and can only straddle a band edge, never sit on it. Half a degree of
873 # rounding slack keeps a 91 deg sum against a 90 deg ceiling from costing a flat
874 # W_IMPINGING_ANGLE (=400) on every single run.
875 if (lo - 0.5) <= phi <= (hi + 0.5):
876 return 0.0
877 excess = float((lo - 0.5) - phi) if phi < lo else float(phi - (hi + 0.5))
878 # Normalise by band span, exactly like _impinging_momentum_asymmetric_squared above.
879 # This term used to return raw DEGREES squared while every other objective term is a
880 # squared RELATIVE error, so 1.5 deg over a ceiling scored 2.25 where a 5% O/F miss
881 # scores 0.0025 -- a ~900x unit mismatch. With W_IMPINGING_ANGLE=400 that made a
882 # *preference* band outrank the user's primary O/F target 9:1 (900 vs ~102), silently
883 # returning O/F ~1.75 against a 1.65 request. Normalised, a 10 deg excursion on a
884 # 35 deg band now costs about as much as a 1.8% O/F miss, which is a sane trade.
885 band_span = max(hi - lo, 1e-9)
886 norm = excess / band_span
887 return float(norm * norm)
888
889
891 smd_um: float,
892 *,
893 smd_ref_um: float = 99.0,
894 Lstar_ref_m: float = 1.10,
895 exponent: float = 2.0,
896 Lstar_min_m: float = 0.0,
897 Lstar_max_m: float = float("inf"),
898) -> float:
899 """L* the chamber needs in order to vaporise a spray of this fineness [m].
900
901 L* and SMD are not independent knobs -- L* exists to buy residence time, and how much
902 residence time you need is set by how long the droplets take to evaporate:
903
904 tau_res = L* * c* / (R * Tc) (chamber volume / volumetric gas flow)
905 tau_vap = D32^2 / k_evap (d^2-law)
906
907 Holding the required margin ``tau_res / tau_vap`` fixed and solving for L* gives
908
909 L*_required ∝ D32^2
910
911 so the target scales as the SQUARE of SMD. That exponent is the d^2-law, not a fit; only
912 the anchor point is empirical, and it is stated as a pair the user can move:
913 ``smd_ref_um`` -> ``Lstar_ref_m``.
914
915 Anchored at 99 um -> 1.10 m, the implied targets are::
916
917 50 um -> 0.28 m 70 um -> 0.55 m 99 um -> 1.10 m
918 120 um -> 1.62 m 150 um -> 2.53 m
919
920 Clamping to [``Lstar_min_m``, ``Lstar_max_m``] is meaningful information, not a fudge: a
921 target pinned at the ceiling means the spray is too coarse to vaporise inside the chamber
922 envelope the requirements allow, and the honest fix is a finer spray, not a longer engine.
923 """
924 try:
925 smd = float(smd_um)
926 except (TypeError, ValueError):
927 return float("nan")
928 if not (np.isfinite(smd) and smd > 0 and smd_ref_um > 0 and Lstar_ref_m > 0):
929 return float("nan")
930 target = float(Lstar_ref_m) * (smd / float(smd_ref_um)) ** float(exponent)
931 if not np.isfinite(target):
932 return float("nan")
933 return float(np.clip(target, Lstar_min_m, Lstar_max_m))
934
935
936def _layer1_lstar_band_term(Lstar_m: float, target_m: float, deadband_m: float) -> float:
937 """Two-sided cost on L* outside ``target +/- deadband/2``, saturating.
938
939 Replaces the one-sided "don't grow past target" shape now that the target is a PHYSICAL
940 quantity rather than a preference. Under-shooting is now just as wrong as over-shooting:
941 below the band the droplets do not finish evaporating (c* falls), above it you are
942 carrying chamber you do not need. Free anywhere inside the band -- a 0.1 m window is
943 finer than the correlation's own accuracy, so there is nothing to gain by resolving
944 inside it.
945
946 Still saturating (e²/(1+e²), bounded in [0,1)) so an unreachable target can never
947 outvote a hard gate -- the failure mode that made the original quadratic 91% of the
948 objective.
949 """
950 if not (np.isfinite(Lstar_m) and np.isfinite(target_m)) or target_m <= 0 or Lstar_m <= 0:
951 return 0.0
952 half = max(0.0, 0.5 * float(deadband_m))
953 excess = max(0.0, abs(float(Lstar_m) - float(target_m)) - half)
954 if excess <= 0.0:
955 return 0.0
956 e = excess / float(target_m)
957 e2 = e * e
958 return float(e2 / (1.0 + e2))
959
960
961def _req_lookup(requirements, key):
962 """Read one requirement from either a dict or the pydantic DesignRequirementsConfig.
963
964 Layer 1 hands this module a plain dict in the worker objective but the model object
965 when the value is pulled off config.design_requirements, so a bare .get() silently
966 returns the default for half the call sites.
967 """
968 if requirements is None:
969 return None
970 getter = getattr(requirements, "get", None)
971 if callable(getter):
972 try:
973 return getter(key)
974 except Exception:
975 pass
976 return getattr(requirements, key, None)
977
978
979DEFAULT_CONTRACTION_HALF_ANGLE_DEG = 45.0
980
981
982def _layer1_contraction_theta(requirements) -> float:
983 """Convergent half-angle [rad], from config.
984
985 Was hardcoded np.pi/4 in three places (here, the worker objective, and
986 chamber_geometry.theta_default). 45 deg is the LENGTH-optimal angle but not the
987 MASS-optimal one: a shallower cone is longer yet moves volume out of the
988 full-diameter barrel -- which carries the whole ablative + structural wall stack --
989 into the tapering cone shell. Measured on the 8 kN ethalox point, 30 deg is the
990 lighter chamber at equal L*. Leaving this pinned hid that trade entirely.
991 """
992 v = _req_lookup(requirements, "layer1_contraction_half_angle_deg")
993 if v is None:
994 v = DEFAULT_CONTRACTION_HALF_ANGLE_DEG
995 try:
996 deg = float(v)
997 except (TypeError, ValueError):
998 deg = DEFAULT_CONTRACTION_HALF_ANGLE_DEG
999 if not np.isfinite(deg):
1000 deg = DEFAULT_CONTRACTION_HALF_ANGLE_DEG
1001 return np.radians(float(np.clip(deg, 5.0, 85.0)))
1002
1003
1004def _layer1_geometry_infeasibility(requirements, *, L_cylindrical, D_chamber_inner,
1005 A_chamber, n_elements) -> float:
1006 """Hard geometric constraints, as INFEASIBILITY -- never as weighted penalties.
1007
1008 A weight on a shape preference only moves the fixed point of the scalarised sum; it
1009 cannot express "this design cannot be built". These two genuinely cannot:
1010
1011 * ``layer1_min_Lcyl_over_D`` -- the CONSTANT-AREA length is where impinging sprays
1012 actually mix. Only total L_chamber/D was gated, which lets the convergent cone
1013 masquerade as mixing length. Paired with spray.evaporation.x_star_limit, which
1014 spray.py already raises on.
1015 * ``layer1_max_element_pitch_m`` -- element spacing on the injector face. eta_mixing
1016 (Rupe) sees only momentum ratio, so nothing charged for spreading a fixed element
1017 count over a bigger face. Without this the optimiser buys chamber diameter free.
1018
1019 Both return 0.0 when unset, so behaviour is unchanged unless configured.
1020 """
1021 score = 0.0
1022 if requirements is None:
1023 return score
1024 min_ld = _req_lookup(requirements, "layer1_min_Lcyl_over_D")
1025 if min_ld is not None and D_chamber_inner > 0 and np.isfinite(L_cylindrical):
1026 try:
1027 lo = float(min_ld)
1028 except (TypeError, ValueError):
1029 lo = 0.0
1030 if lo > 0:
1031 # RELATIVE residual, matching the stability/geometry gates elsewhere in this
1032 # function. An absolute squared miss does not scale against
1033 # layer1_infeasibility_gate_eps (default 2e-3): an 8 % shortfall on a 0.55 floor
1034 # squares to 1.8e-3 and slips under the gate, which is exactly what let Layer 1
1035 # return L_cyl/D = 0.507 and call it feasible.
1036 ratio = float(L_cylindrical) / float(D_chamber_inner)
1037 score += max(0.0, (lo - ratio) / lo) ** 2
1038
1039 max_pitch = _req_lookup(requirements, "layer1_max_element_pitch_m")
1040 if max_pitch is not None and n_elements and A_chamber > 0:
1041 try:
1042 hi = float(max_pitch)
1043 n = int(n_elements)
1044 except (TypeError, ValueError):
1045 hi, n = 0.0, 0
1046 if hi > 0 and n > 0:
1047 pitch = float(np.sqrt(A_chamber / n))
1048 score += max(0.0, pitch / hi - 1.0) ** 2
1049 return score
1050
1051
1052def _layer1_lstar_term(Lstar_m: float, target_m: float) -> float:
1053 """One-sided SATURATING cost on L* above ``target_m``: e²/(1+e²), e=(L*-target)/target.
1054
1055 Bounded in [0, 1) on purpose. The first version of this was a bare quadratic, and it
1056 reproduced exactly the pathology it was written to avoid: against a target L* cannot
1057 physically reach (~1.24 m at this design point), ((L*-0.6)/0.6)² × 4000 came to 4899 of a
1058 5363 objective -- 91% -- so the optimizer sold the momentum-ratio gate (R fell to 0.90,
1059 validation FAILED) chasing an L* it was never going to reach. Same shape as the W_SMD=2000
1060 trap against an unreachable 50 µm target.
1061
1062 Saturating means the WORST an L* miss can ever cost is W_LSTAR, so it can express a
1063 preference without outvoting a hard gate no matter how the target is set.
1064
1065 A chamber DRY-MASS penalty does not do this job. Wall mass ~ pi*D*L*t but chamber volume
1066 ~ pi*D^2*L/4, so mass/volume ~ 4t/D -- a FATTER chamber is more mass-efficient per unit
1067 volume. Charging for mass therefore buys a short wide chamber at the SAME L*, which is
1068 what was measured (D 0.131 -> 0.144 m, L_eng 0.327 -> 0.290 m, L* unchanged at 1.4986).
1069
1070 L* is the variable that actually has no opposing force anywhere in the objective: more
1071 residence time is pure reward (vaporisation -> c* -> thrust). One-sided so that anything
1072 at or below target is free -- this expresses "don't grow the chamber past what you need",
1073 not "prefer a small chamber".
1074 """
1075 if not (np.isfinite(Lstar_m) and np.isfinite(target_m)) or target_m <= 0 or Lstar_m <= 0:
1076 return 0.0
1077 e = max(0.0, (float(Lstar_m) - float(target_m)) / float(target_m))
1078 e2 = e * e
1079 return float(e2 / (1.0 + e2))
1080
1081
1083 A_chamber_inner_m2: float,
1084 L_chamber_m: float,
1085 total_wall_thickness_m: float,
1086 wall_density_kg_m3: float,
1087 Pc_pa: float = 0.0,
1088 closure_yield_pa: float = 205e6, # 304 stainless, annealed, minimum yield (ASTM A240)
1089 plate_support: str = "clamped",
1090 r_plate_m: float = 0.0,
1091 poisson: float = 0.3, # steels, 0.27-0.30; 304 SS ~0.29
1092) -> float:
1093 """Cylindrical-shell dry-mass proxy for the chamber barrel [kg].
1094
1095 Why this exists: chamber volume carried no cost. More residence time only helps delivered
1096 c*, so with nothing opposing it L* either pinned to its maximum or, once the objective hit
1097 zero, wandered with the seed. This is the counterweight to the Isp term.
1098
1099 Deliberately a barrel-only shell, not a full stack mass: it is a monotone proxy whose whole
1100 job is to make L* trade against something. ``wall_density_kg_m3`` is an EFFECTIVE density for
1101 the liner stack (metal + ablative + graphite), configured rather than derived.
1102 """
1103 if not (np.isfinite(A_chamber_inner_m2) and np.isfinite(L_chamber_m)):
1104 return float("nan")
1105 if A_chamber_inner_m2 <= 0 or L_chamber_m <= 0:
1106 return float("nan")
1107 t_side = 0.5 * float(total_wall_thickness_m)
1108 if t_side <= 0:
1109 return float("nan")
1110 d_inner = float(np.sqrt(4.0 * float(A_chamber_inner_m2) / np.pi))
1111 d_mean = d_inner + t_side # mid-wall diameter of the shell
1112 m_barrel = float(np.pi * d_mean * float(L_chamber_m) * t_side * float(wall_density_kg_m3))
1113
1114 # Injector-face closure. A flat plate under Pc thickens as t ~ R*sqrt(k*Pc/sigma), so its
1115 # mass goes as R^3 -- the only term in this model that opposes chamber DIAMETER. Without it
1116 # the barrel proxy alone is minimised by a short FAT chamber (mass/volume ~ 4t/D), which is
1117 # the documented reason a mass penalty was abandoned here. With it the proxy is monotone in
1118 # both length and diameter and the L*/bore trade is priced. Zero when Pc is not supplied,
1119 # so existing callers are unchanged.
1120 m_closure = 0.0
1121 if np.isfinite(Pc_pa) and Pc_pa > 0 and closure_yield_pa > 0:
1122 # The plate spans the plug (sleeve bore) when that is known, else the gas bore.
1123 a = float(r_plate_m) if (np.isfinite(r_plate_m) and r_plate_m > 0) else 0.5 * d_inner
1124 k = _closure_plate_stress_coeff(plate_support, poisson)
1125 t_face = a * float(np.sqrt(k * float(Pc_pa) / float(closure_yield_pa)))
1126 m_closure = float(np.pi * a ** 2 * t_face * float(wall_density_kg_m3))
1127 return m_barrel + m_closure
1128
1129
1130#: Declared ``injector.plate.support`` values -> the edge condition they mean.
1131_PLATE_SUPPORTS = {"simply_supported": "simply_supported", "clamped": "clamped", "fixed": "clamped"}
1132
1133
1134def _closure_plate_stress_coeff(support: str, poisson: float = 0.3) -> float:
1135 """k in sigma_max = k p a^2 / t^2 for a solid circular plate under uniform pressure p.
1136
1137 Roark's Formulas for Stress and Strain (7th ed.), Table 11.2, uniform load over the plate
1138 (Timoshenko & Woinowsky-Krieger, Theory of Plates and Shells, 2nd ed., sections 15-16,
1139 give the same):
1140
1141 * case 10a, edge SIMPLY SUPPORTED: max stress at the centre, k = 3 (3 + nu) / 8
1142 (1.2375 at nu = 0.3);
1143 * case 10b, edge FIXED (clamped): max stress at the edge, k = 3 / 4.
1144
1145 A plug retained by a rim/shoulder and a snap ring rotates at its edge, so it is closer to
1146 simply supported than to clamped -- the clamped figure sizes it ~22 % thin.
1147 """
1148 kind = _PLATE_SUPPORTS.get(str(support).strip().lower())
1149 if kind is None:
1150 raise ValueError(f"plate support {support!r}: expected one of {sorted(_PLATE_SUPPORTS)}")
1151 if kind == "clamped":
1152 return 0.75
1153 nu = float(poisson)
1154 return 3.0 * (3.0 + nu) / 8.0
1155
1156
1158 chamber_mass_kg: float,
1159 mass_ref_kg: float,
1160) -> float:
1161 """Normalised, dimensionless mass cost: (m / m_ref)^2. 0 when mass is unavailable."""
1162 if not np.isfinite(chamber_mass_kg) or chamber_mass_kg <= 0:
1163 return 0.0
1164 ref = float(mass_ref_kg)
1165 if not np.isfinite(ref) or ref <= 0:
1166 return 0.0
1167 return float((float(chamber_mass_kg) / ref) ** 2)
1168
1169
1170# nozzle_solver.rao's bell_percent: the contour Layer 1 emits is an 80 % Rao bell.
1171_LAYER1_BELL_FRACTION = 0.8
1172_G0 = 9.80665
1173
1174
1176 max_od_m: float, wall_total_m: float, A_throat_lo_m2: float, dt_ratio_min: float,
1177) -> float:
1178 """Smallest chamber OD worth searching [m]: wall + (D/Dt)_min x Dt at the smallest throat.
1179
1180 Any bore below that sits under the chamber-shape band for every reachable throat.
1181 """
1182 dt_lo = float(np.sqrt(4.0 * max(float(A_throat_lo_m2), 0.0) / np.pi))
1183 return float(wall_total_m) + max(float(dt_ratio_min), 1.05) * dt_lo
1184
1185
1187 A_throat: float, Lstar: float, D_chamber_inner: float, theta: float,
1188) -> Dict[str, float]:
1189 """Axial lengths of the chamber Layer 1 builds [m].
1190
1191 One source for the objective, the config writer and validation, so all three score the
1192 chamber that is drawn. The cone runs to the tangency of the 1.5 R_t entrance arc,
1193 r_tan = R_t (1 + 1.5 (1 - cos theta)); the arc then covers 1.5 R_t sin(theta) to the
1194 throat plane (nozzle_solver.rao)::
1195
1196 length = L_cyl + L_cone (cg.length: mass proxy, L/D band)
1197 face_to_throat = length + 1.5 R_t sin(theta) (injector face to throat plane)
1198 """
1199 A_t = float(A_throat)
1200 V = float(Lstar) * A_t
1201 D_in = float(D_chamber_inner)
1202 A_c = np.pi * (D_in / 2.0) ** 2 if D_in > 0 else 0.0
1203 R_t = float(np.sqrt(max(0.0, A_t / np.pi)))
1204 cr = A_c / A_t if (A_t > 0 and A_c > 0) else 10.0
1205 r_tan = R_t * (1.0 + 1.5 * (1.0 - float(np.cos(theta))))
1206 L_cyl = chamber_length_calc(
1207 chamber_volume=V, area_throat=A_t, contraction_ratio=cr, theta=theta)
1208 L_con = contraction_length_horizontal_calc(
1209 area_chamber=A_c, entrance_arc_start_y=r_tan, theta=theta)
1210 L = L_cyl + L_con
1211 if L <= 0 or L_cyl <= 0 or not np.isfinite(L):
1212 L = V / A_c if A_c > 0 else 0.2
1213 L_cyl = max(L * 0.5, 0.05)
1214 L = max(float(L), 0.005)
1215 L_arc = 1.5 * R_t * float(np.sin(theta))
1216 return {
1217 "length": L,
1218 "length_cylindrical": float(L_cyl),
1219 "length_contraction": float(L_con),
1220 "length_entrance_arc": float(L_arc),
1221 "face_to_throat": float(L + L_arc),
1222 }
1223
1224
1225def _layer1_site_ambient_pa(config_obj: Any) -> float:
1226 """The site's ambient pressure [Pa]: the engine solver's own (runner._get_ambient_pressure).
1227
1228 ``compute_ambient_pressure_from_elevation`` (the 1976 standard atmosphere) at
1229 ``environment.elevation``; sea level without one. Layer 1 runs every evaluation at this
1230 pressure and targets it as the exit pressure. It used to compute its own isothermal
1231 exponential, which read 94 069.7 Pa at the LE4 site's 626.67 m while the solver -- forward
1232 mode, the Time-Series tab, Layer X -- evaluates the design at 94 020.4 Pa.
1233 """
1234 from engine.core.runner import compute_ambient_pressure_from_elevation
1235
1236 env = getattr(config_obj, "environment", None)
1237 elevation = getattr(env, "elevation", None) if env is not None else None
1238 if elevation is None or not np.isfinite(float(elevation)) or float(elevation) < 0:
1239 return 101325.0
1240 return float(compute_ambient_pressure_from_elevation(float(elevation)))
1241
1242
1243def _layer1_bell_length_m(A_throat: float, eps: float) -> float:
1244 """Rao 80 % bell length from the throat [m]: 0.8 (sqrt(eps) - 1) R_t / tan 15 deg.
1245
1246 Huzel & Huang ch. 4; the same L_noz nozzle_solver.rao draws.
1247 """
1248 R_t = float(np.sqrt(max(0.0, float(A_throat) / np.pi)))
1249 e = float(eps)
1250 if not (np.isfinite(e) and e > 1.0 and R_t > 0):
1251 return 0.0
1252 return float(_LAYER1_BELL_FRACTION * (np.sqrt(e) - 1.0) * R_t / np.tan(np.radians(15.0)))
1253
1254
1255def _layer1_cea_ok(cea_cache: Any) -> bool:
1256 try:
1257 from engine.pipeline.cea_cache import CEACache
1258 except Exception: # pragma: no cover
1259 return False
1260 return isinstance(cea_cache, CEACache)
1261
1262
1263def _layer1_ideal_isp_s(cea_cache: Any, MR: float, Pc_pa: float, Pa: float, eps: float) -> float:
1264 """Ideal ambient Isp from the CEA table [s]: c* (Cf_vac - eps Pa/Pc) / g0.
1265
1266 Shifting equilibrium, the same table the thrust model integrates. NaN when unavailable.
1267 """
1268 if not _layer1_cea_ok(cea_cache):
1269 return float("nan")
1270 try:
1271 MR, Pc_pa, Pa, eps = float(MR), float(Pc_pa), float(Pa), float(eps)
1272 if not (MR > 0 and Pc_pa > 0 and Pa >= 0 and eps > 1.0):
1273 return float("nan")
1274 out = cea_cache.eval(MR, Pc_pa, Pa, eps)
1275 cf = float(out["Cf_vac"]) - eps * Pa / Pc_pa
1276 isp = float(out["cstar_ideal"]) * cf / _G0
1277 except Exception:
1278 return float("nan")
1279 return isp if np.isfinite(isp) and isp > 0 else float("nan")
1280
1281
1282def _layer1_isp_merit_s(result: Any, constants: dict, cea_cache: Any = None) -> float:
1283 """Delivered Isp with the requirement-pinned operating point held at its requirement [s].
1284
1285 O/F, and Pc when a target is set, are specifications with their own deadbands; the figure
1286 of merit must not buy Isp by moving them. Ideal Isp is re-evaluated with O/F clamped to its
1287 deadband and Pc to its aim band, and the delivered efficiency is kept::
1288
1289 Isp_merit = Isp * Isp_ideal(Pc', O/F') / Isp_ideal(Pc, O/F)
1290
1291 Inside both bands this is the delivered Isp exactly. Without a CEA table it is the
1292 delivered Isp.
1293 """
1294 if not isinstance(result, dict):
1295 return float("nan")
1296 try:
1297 isp = float(result.get("Isp", np.nan))
1298 mr = float(result.get("MR", np.nan))
1299 pc = float(result.get("Pc", np.nan))
1300 except (TypeError, ValueError):
1301 return float("nan")
1302 if not (np.isfinite(isp) and isp > 0):
1303 return float("nan")
1304 of_t = float(constants.get("optimal_of", 0.0) or 0.0)
1305 tol = max(0.0, float(constants.get("layer1_of_deadband_rel", 0.0) or 0.0))
1306 mr_h = float(np.clip(mr, of_t * (1.0 - tol), of_t * (1.0 + tol))) if of_t > 0 else mr
1307 pc_t = constants.get("layer1_target_Pc_pa")
1308 pc_h = (float(np.clip(pc, (1.0 - _LAYER1_PC_AIM_REL) * float(pc_t),
1309 (1.0 + _LAYER1_PC_AIM_REL) * float(pc_t)))
1310 if pc_t and float(pc_t) > 0 else pc)
1311 if (mr_h == mr and pc_h == pc) or not (np.isfinite(mr) and np.isfinite(pc)):
1312 return isp
1313 try:
1314 eps = float(result.get("eps", np.nan))
1315 except (TypeError, ValueError):
1316 eps = float("nan")
1317 Pa = float(constants.get("P_ambient", 101325.0))
1318 num = _layer1_ideal_isp_s(cea_cache, mr_h, pc_h, Pa, eps)
1319 den = _layer1_ideal_isp_s(cea_cache, mr, pc, Pa, eps)
1320 if not (np.isfinite(num) and np.isfinite(den) and den > 0):
1321 return isp
1322 return float(isp * num / den)
1323
1324
1325def _layer1_isp_term(isp_merit_s: float, isp_ref_s: float) -> float:
1326 """Propellant this engine needs beyond an ideal one, as a fraction: Isp_ref/Isp - 1.
1327
1328 At fixed total impulse I = F t_b the propellant is I/(g0 Isp) (Sutton & Biblarz ch. 2), so
1329 this equals (m_prop - m_prop_ideal)/m_prop_ideal: linear in the propellant the delivered Isp
1330 costs, the same currency as the chamber-mass term. 0 when either Isp is unavailable.
1331 """
1332 try:
1333 a, r = float(isp_merit_s), float(isp_ref_s)
1334 except (TypeError, ValueError):
1335 return 0.0
1336 if not (np.isfinite(a) and np.isfinite(r) and a > 0 and r > 0):
1337 return 0.0
1338 return float(r / a - 1.0)
1339
1340
1342 cea_cache: Any, MR: float, Pc_pa: float, Pa: float, eps_lo: float, eps_hi: float,
1343) -> Tuple[float, float]:
1344 """(Isp_ref [s], eps_ref): ideal ambient Isp at the requirement point, expanded to Pe = Pa.
1345
1346 The normaliser of the performance term -- an ideal engine at the target O/F and reference
1347 Pc, with the expansion ratio where the table's own exit pressure is ambient (the maximum of
1348 Cf_vac - eps Pa/Pc), clamped to the searchable eps range.
1349 """
1350 if not _layer1_cea_ok(cea_cache) or not (eps_hi > eps_lo > 1.0):
1351 return float("nan"), float("nan")
1352 e = _layer1_eps_for_exit_pressure_cea(cea_cache, MR, Pc_pa, Pa, 0.5 * (eps_lo + eps_hi))
1353 e = float(np.clip(e if e is not None else eps_lo, eps_lo, eps_hi))
1354 isp = _layer1_ideal_isp_s(cea_cache, MR, Pc_pa, Pa, e)
1355 return (isp, e) if np.isfinite(isp) else (float("nan"), float("nan"))
1356
1357
1359 requirements: dict, cea_cache: Any, optimal_of: float, Pc_ref_pa: float, Pa: float,
1360 eps_range: Tuple[float, float], target_thrust: float, burn_time_s: float,
1361 W_MASS_config: float, mass_ref_kg: float,
1362) -> Dict[str, float]:
1363 """Weights of the figure of merit: {W_ISP, W_MASS, Isp_ref_s, eps_ref, m_prop_ref_kg}.
1364
1365 ``layer1_W_ISP`` defaults to 1000, so the isp penalty reads in tenths of a percent of an
1366 ideal engine's propellant. An unset ``layer1_W_MASS`` prices chamber mass at the same rate
1367 per kg as that propellant, (W_ISP / m_prop_ref) = 2 W_MASS / m_ref at the reference mass --
1368 the fixed-total-impulse exchange. No CEA table: both terms keep their configured values
1369 and the Isp term is off.
1370 """
1371 W_ISP = _requirement_float(requirements, "layer1_W_ISP", 1000.0)
1372 isp_ref, eps_ref = _layer1_isp_reference(
1373 cea_cache, optimal_of, Pc_ref_pa, Pa, float(eps_range[0]), float(eps_range[1]))
1374 m_ref = (float(target_thrust) * float(burn_time_s) / (_G0 * isp_ref)
1375 if (burn_time_s > 0 and np.isfinite(isp_ref)) else float("nan"))
1376 if not np.isfinite(isp_ref):
1377 W_ISP = 0.0
1378 W_MASS = float(W_MASS_config)
1379 if (requirements.get("layer1_W_MASS") is None and W_ISP > 0
1380 and np.isfinite(m_ref) and m_ref > 0 and mass_ref_kg > 0):
1381 W_MASS = W_ISP * float(mass_ref_kg) / (2.0 * m_ref)
1382 return {"W_ISP": float(W_ISP), "W_MASS": float(W_MASS), "Isp_ref_s": float(isp_ref),
1383 "eps_ref": float(eps_ref), "m_prop_ref_kg": float(m_ref)}
1384
1385
1387 cea_cache: Any, MR: float, Pc_pa: float, Pa: float, eps_guess: Optional[float] = None,
1388) -> Optional[float]:
1389 """Expansion ratio at which the CEA table's own exit pressure equals ``Pa``.
1390
1391 For an isentropic (shifting) expansion dCf_vac/deps = Pe/Pc, so the secant slope of the
1392 table's Cf_vac between eps nodes is Pe/Pc at the interval midpoint. Interpolating
1393 ln(Pe/Pc) in ln(eps) between midpoints and solving Pe = Pa inverts the table the thrust
1394 model integrates -- not an isentrope at the chamber gamma. None when the table cannot say
1395 (2-D table, or Pa outside its eps range).
1396 """
1397 if not _layer1_cea_ok(cea_cache) or not getattr(cea_cache, "use_3d", False):
1398 return None
1399 grid = getattr(cea_cache, "eps_grid", None)
1400 try:
1401 MR, Pc_pa, Pa = float(MR), float(Pc_pa), float(Pa)
1402 except (TypeError, ValueError):
1403 return None
1404 if grid is None or len(grid) < 3 or not (MR > 0 and Pc_pa > Pa > 0):
1405 return None
1406 grid = np.asarray(grid, dtype=float)
1407 target = np.log(Pa / Pc_pa)
1408 # Local window around the guess: six nodes are enough and keep the call cheap.
1409 k0 = int(np.searchsorted(grid, float(eps_guess))) if (
1410 eps_guess is not None and np.isfinite(eps_guess)) else len(grid) // 2
1411 lo_i, hi_i = max(0, k0 - 3), min(len(grid), k0 + 3)
1412 for _ in range(len(grid)):
1413 nodes = grid[lo_i:hi_i]
1414 try:
1415 cf = np.array([cea_cache.eval_cf_vac(MR, Pc_pa, e) for e in nodes], dtype=float)
1416 except Exception:
1417 return None
1418 slope = np.diff(cf) / np.diff(nodes) # Pe/Pc at the midpoints
1419 if not np.all(np.isfinite(slope)) or np.any(slope <= 0):
1420 return None
1421 mids = np.sqrt(nodes[:-1] * nodes[1:])
1422 y = np.log(slope) # decreasing in eps
1423 inside = y[0] >= target >= y[-1]
1424 # Past the outer midpoints but inside the table: extend the end segment to the node.
1425 edge = (target > y[0] and lo_i == 0) or (target < y[-1] and hi_i == len(grid))
1426 if inside or edge:
1427 j = int(np.searchsorted(-y, -target))
1428 j = min(max(j, 1), len(y) - 1)
1429 t = (target - y[j - 1]) / (y[j] - y[j - 1])
1430 e = float(np.exp(np.log(mids[j - 1]) + t * (np.log(mids[j]) - np.log(mids[j - 1]))))
1431 return e if grid[0] <= e <= grid[-1] else None
1432 if target > y[0]:
1433 lo_i = max(0, lo_i - 3)
1434 else:
1435 hi_i = min(len(grid), hi_i + 3)
1436 return None
1437
1438
1440 P_exit_actual: float,
1441 target_P_exit: float,
1442 *,
1443 deadband_rel: float = 0.05,
1444 inside_rel_quad_scale: float = 0.0,
1445) -> float:
1446 """Exit nozzle vs ambient-equivalent ``target``: asymmetric hinge beyond deadband, optional quad inside.
1447
1448 Outside ``deadband_rel`` uses the asymmetric hinge used elsewhere; inside the deadband optionally
1449 adds ``inside_rel_quad_scale * rel**2`` to gently center on target (preference, not a hard gate).
1450 """
1451 if target_P_exit <= 0.0 or not np.isfinite(P_exit_actual):
1452 return 0.0
1453 rel = (float(P_exit_actual) - float(target_P_exit)) / float(target_P_exit)
1454 if not np.isfinite(rel):
1455 return 0.0
1456 deadband_rel = float(max(deadband_rel, 1e-9))
1457 if rel < -deadband_rel:
1458 excess = rel + deadband_rel
1459 return float((5.0 * excess) ** 2)
1460 if rel > deadband_rel:
1461 excess = rel - deadband_rel
1462 return float((1.0 * excess) ** 2)
1463 return float(max(0.0, inside_rel_quad_scale)) * float(rel ** 2)
1464
1465
1467 final_performance: Dict[str, Any],
1468 *,
1469 target_thrust: float,
1470 optimal_of: float,
1471 target_P_exit: float,
1472) -> Dict[str, float]:
1473 """Dimensionless thrust / O-F / exit-pressure mismatches (not weighted optimizer penalties).
1474
1475 The Layer 1 scalar objective multiplies these kinds of errors by ``W_THRUST``, ``W_OF``, ``W_EXIT``
1476 (order 1e2–1e4); the weighted sum will never approach machine epsilon. Use these metrics (especially
1477 ``rms_primary``) to judge whether the **physics** matches are tight.
1478 """
1479 try:
1480 F = float(final_performance.get("F", float("nan")))
1481 except (TypeError, ValueError):
1482 F = float("nan")
1483 try:
1484 MR = float(final_performance.get("MR", float("nan")))
1485 except (TypeError, ValueError):
1486 MR = float("nan")
1487 try:
1488 p_ex = float(final_performance.get("P_exit", float("nan")))
1489 except (TypeError, ValueError):
1490 p_ex = float("nan")
1491 rel_thrust = (
1492 abs(F - float(target_thrust)) / float(target_thrust)
1493 if float(target_thrust) > 0 and np.isfinite(F)
1494 else float("nan")
1495 )
1496 rel_of = (
1497 abs(MR - float(optimal_of)) / float(optimal_of)
1498 if float(optimal_of) > 0 and np.isfinite(MR)
1499 else float("nan")
1500 )
1501 rel_p_exit = (
1502 abs(p_ex - float(target_P_exit)) / float(target_P_exit)
1503 if float(target_P_exit) > 0 and np.isfinite(p_ex)
1504 else float("nan")
1505 )
1506 parts = [x for x in (rel_thrust, rel_of, rel_p_exit) if np.isfinite(x)]
1507 rms_primary = float(np.sqrt(np.mean(np.square(parts)))) if parts else float("nan")
1508 return {
1509 "rel_thrust": float(rel_thrust),
1510 "rel_of": float(rel_of),
1511 "rel_P_exit": float(rel_p_exit),
1512 "rms_primary": float(rms_primary),
1513 }
1514
1515
1517 theta_o_deg: Any,
1518 theta_f_deg: Any,
1519 *,
1520 max_delta_deg: float,
1521) -> float:
1522 """Soft penalty when paired oxidizer/fuel jet inclinations diverge excessively.
1523
1524 ``max_delta_deg`` is an allowable |Δθ| band for nominally coherent doublet impingement; excess
1525 is normalized by ``max(max_delta_deg, 5 degrees)`` so the term is order one before weights.
1526 """
1527 if max_delta_deg <= 0.0 or not np.isfinite(max_delta_deg):
1528 return 0.0
1529 try:
1530 t_o = float(theta_o_deg)
1531 t_f = float(theta_f_deg)
1532 except (TypeError, ValueError):
1533 return 0.0
1534 if not np.isfinite(t_o) or not np.isfinite(t_f):
1535 return 0.0
1536 dd = abs(t_o - t_f)
1537 cap = float(max_delta_deg)
1538 if dd <= cap:
1539 return 0.0
1540 excess = dd - cap
1541 span = float(max(cap, 5.0))
1542 return float((excess / span) ** 2)
1543
1544
1545def _relative_hinge_band_squared(v: Any, lo: Optional[float], hi: Optional[float]) -> float:
1546 """Normalized hinge² outside [lo, hi]."""
1547 try:
1548 vv = float(v)
1549 except (TypeError, ValueError):
1550 return 0.0
1551 if not np.isfinite(vv):
1552 return 0.0
1553 if (
1554 lo is None
1555 or hi is None
1556 or not np.isfinite(float(lo))
1557 or not np.isfinite(float(hi))
1558 or float(hi) <= float(lo)
1559 ):
1560 return 0.0
1561 lo_f = float(lo)
1562 hi_f = float(hi)
1563 if lo_f <= vv <= hi_f:
1564 return 0.0
1565 if vv < lo_f:
1566 s = max(1e-12, abs(lo_f))
1567 return float(((lo_f - vv) / s) ** 2)
1568 s = max(1e-12, abs(hi_f))
1569 return float(((vv - hi_f) / s) ** 2)
1570
1571
1572def _layer1_discharge_cd_inf_ratio_from_config(config_obj: Any, injector_type: str) -> float:
1573 """Cd_inf,O / Cd_inf,F at seed jet/orifice diameters (≈1 for matched impinging holes)."""
1574 if injector_type != "impinging":
1575 return 1.0
1576 try:
1577 geom = config_obj.injector.geometry
1578 d_o = float(geom.oxidizer.d_jet)
1579 d_f = float(geom.fuel.d_jet)
1580 return discharge_cd_inf_ratio(
1581 d_o,
1582 d_f,
1583 config_obj.discharge["oxidizer"],
1584 config_obj.discharge["fuel"],
1585 )
1586 except (AttributeError, TypeError, ValueError, KeyError):
1587 return 1.0
1588
1589
1590def _expected_geom_ao_af_for_unit_momentum_ratio(optimal_of: float, rho_o: float, rho_f: float) -> float:
1591 """Geometric A_O/A_F consistent with MR ≈ optimal_of when R ≈ 1 (bulk u, fixed ρ)."""
1592 if optimal_of <= 0 or rho_f <= 0 or rho_o <= 0:
1593 return float("nan")
1594 return float(optimal_of) / float(np.sqrt(float(rho_o) / float(rho_f)))
1595
1596
1598 d_jet_O: float,
1599 d_jet_F: float,
1600 *,
1601 n_elements_O: int,
1602 n_elements_F: int,
1603 optimal_of: float,
1604 rho_o: float,
1605 rho_f: float,
1606 min_d: float = 1e-5,
1607 rel_tol: float = 0.02,
1608) -> Tuple[float, float]:
1609 """Rescale jet diameters so A_O/A_F matches MR at R≈1 while preserving A_O·A_F.
1610
1611 YAML / UI seed orifices are not O/F-tuned; Layer 1 applies this before x0 so CMA/L-BFGS
1612 still optimize ``d_jet_O`` and ``d_jet_F`` from a physics-aligned starting point.
1613 """
1614 if optimal_of <= 0 or d_jet_O <= 0 or d_jet_F <= 0:
1615 return float(d_jet_O), float(d_jet_F)
1616 n_o = max(1, int(n_elements_O))
1617 n_f = max(1, int(n_elements_F))
1618 exp_af = _expected_geom_ao_af_for_unit_momentum_ratio(optimal_of, rho_o, rho_f)
1619 if not (np.isfinite(exp_af) and exp_af > 0):
1620 return float(d_jet_O), float(d_jet_F)
1621 a_o = float(n_o * np.pi * (float(d_jet_O) / 2.0) ** 2)
1622 a_f = float(n_f * np.pi * (float(d_jet_F) / 2.0) ** 2)
1623 if a_f <= 0:
1624 return float(d_jet_O), float(d_jet_F)
1625 actual_af = a_o / a_f
1626 if actual_af <= 0:
1627 return float(d_jet_O), float(d_jet_F)
1628 ratio_scale = float(exp_af / actual_af)
1629 if abs(ratio_scale - 1.0) < float(rel_tol):
1630 return float(d_jet_O), float(d_jet_F)
1631 a_o_new = a_o * np.sqrt(ratio_scale)
1632 a_f_new = a_f / np.sqrt(ratio_scale)
1633 d_o_new = float(np.sqrt(4.0 * a_o_new / (n_o * np.pi)))
1634 d_f_new = float(np.sqrt(4.0 * a_f_new / (n_f * np.pi)))
1635 return max(min_d, d_o_new), max(min_d, d_f_new)
1636
1637
1638def _write_impinging_jet_diameters_to_config(config_obj: Any, d_jet_O: float, d_jet_F: float) -> None:
1639 try:
1640 geom = config_obj.injector.geometry
1641 geom.oxidizer.d_jet = float(d_jet_O)
1642 geom.fuel.d_jet = float(d_jet_F)
1643 except (AttributeError, TypeError, ValueError):
1644 pass
1645
1646
1648 config_obj: Any,
1649 *,
1650 d_jet_O: float,
1651 d_jet_F: float,
1652 n_doublets: int,
1653 optimal_of: float,
1654 bounds: Sequence[Tuple[float, float]],
1655 logger: logging.Logger,
1656 also_config: Optional[Any] = None,
1657) -> Tuple[float, float]:
1658 """Align impinging x0 jet diameters with ``optimal_of_ratio`` and clip to Layer-1 bounds."""
1659 try:
1660 rho_o = float(config_obj.fluids["oxidizer"].density)
1661 rho_f = float(config_obj.fluids["fuel"].density)
1662 except (TypeError, ValueError, KeyError, AttributeError):
1663 return float(d_jet_O), float(d_jet_F)
1664 d_o_old, d_f_old = float(d_jet_O), float(d_jet_F)
1666 d_o_old,
1667 d_f_old,
1668 n_elements_O=int(n_doublets),
1669 n_elements_F=int(n_doublets),
1670 optimal_of=float(optimal_of),
1671 rho_o=rho_o,
1672 rho_f=rho_f,
1673 )
1674 d_o_new = float(np.clip(d_o_new, bounds[5][0], bounds[5][1]))
1675 d_f_new = float(np.clip(d_f_new, bounds[8][0], bounds[8][1]))
1676 if abs(d_o_new - d_o_old) > 1e-9 or abs(d_f_new - d_f_old) > 1e-9:
1677 _write_impinging_jet_diameters_to_config(config_obj, d_o_new, d_f_new)
1678 if also_config is not None:
1679 _write_impinging_jet_diameters_to_config(also_config, d_o_new, d_f_new)
1680 exp_af = _expected_geom_ao_af_for_unit_momentum_ratio(optimal_of, rho_o, rho_f)
1681 logger.info(
1682 "Auto-scaled impinging seed jets for MR=%.2f (ρ_O/ρ_F from config): "
1683 "d_jet_O %.4f→%.4f mm, d_jet_F %.4f→%.4f mm (target A_O/A_F≈%.3f for R≈1; "
1684 "d_jet_O/F remain optimizer DOFs)",
1685 float(optimal_of),
1686 d_o_old * 1e3,
1687 d_o_new * 1e3,
1688 d_f_old * 1e3,
1689 d_f_new * 1e3,
1690 float(exp_af) if np.isfinite(exp_af) else float("nan"),
1691 )
1692 return d_o_new, d_f_new
1693
1694
1696 A_geom_o: float,
1697 A_geom_f: float,
1698 optimal_of: float,
1699 rho_o: float,
1700 rho_f: float,
1701) -> Tuple[float, float, float]:
1702 """Returns (relative_error_squared, A_O/A_F, expected_A_O/A_F for R≈1)."""
1703 exp_af = _expected_geom_ao_af_for_unit_momentum_ratio(optimal_of, rho_o, rho_f)
1704 if not (np.isfinite(exp_af) and exp_af > 0 and A_geom_f > 0):
1705 return 0.0, float("nan"), exp_af
1706 ao_af = float(A_geom_o) / float(A_geom_f)
1707 rel = (ao_af - exp_af) / exp_af
1708 return float(rel * rel), ao_af, exp_af
1709
1710
1711def _effective_smd_um(diagnostics: Any, mr_mass: Any = None) -> float:
1712 """Mass-flux-weighted effective spray SMD [um] from injector diagnostics.
1713
1714 The blend the SMD penalty has always used, hoisted out so the L*-target correlation can
1715 read the SAME number rather than recomputing it from a second correlation (which is what
1716 previously let the two disagree about how fine the spray was).
1717 """
1718 if not isinstance(diagnostics, dict):
1719 return float("nan")
1720
1721 def _f(key):
1722 try:
1723 return float(diagnostics.get(key, np.nan))
1724 except (TypeError, ValueError):
1725 return float("nan")
1726
1727 d32_o, d32_f = _f("D32_O"), _f("D32_F")
1728 mr = float(mr_mass) if (mr_mass is not None and np.isfinite(float(mr_mass))
1729 and float(mr_mass) > 0) else _f("MR")
1730 if not (np.isfinite(mr) and mr > 0):
1731 mr = 3.0
1732 if np.isfinite(d32_o) and d32_o > 0 and np.isfinite(d32_f) and d32_f > 0:
1733 d32 = mr / (1.0 + mr) * d32_o + 1.0 / (1.0 + mr) * d32_f
1734 elif np.isfinite(d32_o) and d32_o > 0:
1735 d32 = d32_o
1736 elif np.isfinite(d32_f) and d32_f > 0:
1737 d32 = d32_f
1738 else:
1739 return float("nan")
1740 val = float(d32 * 1e6)
1741 return val if np.isfinite(val) and val > 0 else float("nan")
1742
1743
1745 smd_um: float,
1746 constants_or_req: Any,
1747 getter,
1748 fallback_target_m: float,
1749) -> Tuple[float, float]:
1750 """(target_m, deadband_m) for the L* penalty.
1751
1752 When ``layer1_Lstar_from_smd`` is on (default) the target is the physical one from
1753 :func:`_layer1_lstar_target_from_smd`; a fixed ``layer1_Lstar_target_m`` is the opt-out.
1754 Falls back to the fixed target whenever SMD is unavailable, so a diagnostics gap cannot
1755 silently switch the penalty off.
1756 """
1757 # NOTE: model_dump() emits an explicit None for every unset Optional field, so a bare
1758 # ``.get(key, default)`` returns None rather than the default. Treat None as unset --
1759 # this is the same trap _requirement_float exists to avoid.
1760 def _num(key, default):
1761 v = getter(key)
1762 try:
1763 return float(default if v is None else v)
1764 except (TypeError, ValueError):
1765 return float(default)
1766
1767 def _flag(key, default):
1768 v = getter(key)
1769 return bool(default) if v is None else bool(v)
1770
1771 deadband = _num("layer1_Lstar_deadband_m", 0.1)
1772 if not _flag("layer1_Lstar_from_smd", True):
1773 return float(fallback_target_m), deadband
1774 if not (np.isfinite(smd_um) and smd_um > 0):
1775 return float(fallback_target_m), deadband
1777 smd_um,
1778 smd_ref_um=_num("layer1_Lstar_smd_ref_um", 99.0),
1779 Lstar_ref_m=_num("layer1_Lstar_ref_m", 1.10),
1780 exponent=_num("layer1_Lstar_smd_exponent", 2.0),
1781 Lstar_min_m=_num("min_Lstar", 0.0),
1782 Lstar_max_m=_num("max_Lstar", 1.0e9),
1783 )
1784 if not np.isfinite(tgt) or tgt <= 0:
1785 return float(fallback_target_m), deadband
1786 return float(tgt), deadband
1787
1788
1790 diagnostics: Dict[str, Any],
1791 *,
1792 target_smd_microns: float,
1793 smd_rel_tol: float = 0.20,
1794 mr_mass: Optional[float] = None,
1795) -> Tuple[float, float, float]:
1796 """Return (range_penalty, effective_smd_um, impingement_angle_deg).
1797
1798 Single source of truth: the effective spray SMD is the mass-flux-weighted blend of the
1799 injector-physics Sauter means ``D32_O`` / ``D32_F`` (metres) reported by the impinging solve.
1800 Those values come from the Ingebo correlation, which already embeds the aerodynamic Weber number
1801 (gas-shear breakup), the liquid Reynolds number (viscous resistance — important for cryogens),
1802 and the impingement angle (through the law-of-cosines collision velocity ``u_rel``). We do NOT
1803 recompute SMD from a separate parametric correlation here — that previously gave the optimizer a
1804 different answer than the reported physics.
1805
1806 Penalty is a **two-sided** deadband: zero inside ``[target·(1−tol), target·(1+tol)]`` and the
1807 squared, target-normalized distance to the nearest edge outside it (so both too-coarse and
1808 too-fine atomization are penalized — "SMD within a range").
1809 """
1810 def _f(key: str) -> float:
1811 try:
1812 return float(diagnostics.get(key, np.nan))
1813 except (TypeError, ValueError):
1814 return float("nan")
1815
1816 d32_o = _f("D32_O")
1817 d32_f = _f("D32_F")
1818 imp_deg = _f("impingement_angle_deg")
1819
1820 mr = float(mr_mass) if (mr_mass is not None and np.isfinite(float(mr_mass)) and float(mr_mass) > 0) else _f("MR")
1821 if not (np.isfinite(mr) and mr > 0):
1822 mr = 3.0
1823
1824 if np.isfinite(d32_o) and d32_o > 0 and np.isfinite(d32_f) and d32_f > 0:
1825 d32_eff_m = float(mr / (1.0 + mr) * d32_o + 1.0 / (1.0 + mr) * d32_f)
1826 elif np.isfinite(d32_o) and d32_o > 0:
1827 d32_eff_m = float(d32_o)
1828 elif np.isfinite(d32_f) and d32_f > 0:
1829 d32_eff_m = float(d32_f)
1830 else:
1831 return 0.0, float("nan"), imp_deg
1832
1833 d32_eff_um = float(d32_eff_m * 1e6)
1834 if not np.isfinite(d32_eff_um) or d32_eff_um <= 0.0 or target_smd_microns <= 0.0:
1835 return 0.0, d32_eff_um, imp_deg
1836
1837 tol = max(0.0, float(smd_rel_tol))
1838 lo = float(target_smd_microns * (1.0 - tol))
1839 hi = float(target_smd_microns * (1.0 + tol))
1840 scale = max(float(target_smd_microns), 1e-9)
1841 if d32_eff_um < lo:
1842 smd_term = float(((lo - d32_eff_um) / scale) ** 2)
1843 elif d32_eff_um > hi:
1844 smd_term = float(((d32_eff_um - hi) / scale) ** 2)
1845 else:
1846 smd_term = 0.0
1847 return smd_term, d32_eff_um, imp_deg
1848
1849
1851 P_O_tank_psi: float,
1852 P_F_tank_psi: float,
1853 scale_psi: float = 100.0,
1854 tol_psi: float = 0.0,
1855 inband_frac: float = 0.0,
1856) -> float:
1857 """Squared, scale-normalized penalty on unequal propellant tank pressures.
1858
1859 Equal tank pressures do NOT fall out of momentum matching: the two propellants have different
1860 densities, different feed-system losses, and (because the orifices are sized/angled differently)
1861 different injector ΔP. So driving the tanks toward a common pressure is its own objective, and the
1862 optimizer pays for it by reshaping orifice area / velocity / angle while still holding R≈1 and SMD.
1863 """
1864 # ``tol_psi`` is a DEADBAND: a delta the instrumentation cannot even resolve is not an
1865 # error and must cost nothing. Without it, matching the tanks to 1.93 psi -- comfortably
1866 # inside a 10 psi PT margin, i.e. a success -- still carried 11 objective points, which
1867 # reads as "did not converge" when in fact the requirement was met with margin.
1868 po = float(P_O_tank_psi)
1869 pf = float(P_F_tank_psi)
1870 if not (np.isfinite(po) and np.isfinite(pf)):
1871 return 0.0
1872 s = float(scale_psi) if (np.isfinite(scale_psi) and scale_psi > 0) else 100.0
1873 t = float(tol_psi) if (np.isfinite(tol_psi) and tol_psi > 0) else 0.0
1874 delta = abs(po - pf)
1875 excess = max(0.0, delta - t)
1876
1877 # A pure deadband is flat inside the band, so nothing distinguishes "1 psi apart"
1878 # from "9.9 psi apart" and the optimizer parks on the edge -- which is how a 10 psi
1879 # requirement lands at 10.1 psi. ``inband_frac`` adds a weak always-on pull toward
1880 # equality: strong enough to break the tie inside the band, far too small (5% by
1881 # default) to outrank the real constraint outside it.
1882 # Normalize the in-band part by the TOLERANCE, not by ``scale``, so it saturates at
1883 # ``inband_frac`` exactly at the band edge and cannot grow with the scale choice. With the
1884 # old ``/s`` form and scale=10, tol=10, W=300, a delta sitting at the edge of the band
1885 # cost 15 objective points and a physically-required 13 psi cost 42 -- a permanent tax
1886 # that no design could pay off, on top of a requirement that may not be reachable at all.
1887 # This is a tie-breaker to stop the optimizer parking on the band edge, nothing more.
1888 f = float(inband_frac) if (np.isfinite(inband_frac) and inband_frac > 0) else 0.0
1889 inband = f * (min(delta, t) / t) ** 2 if (f > 0.0 and t > 0.0) else 0.0
1890 return float((excess / s) ** 2 + inband)
1891
1892
1893def _face_halves(*, r_in: float, d_in: float, th_in: float, r_out: float, d_out: float,
1894 th_out: float, face_contoured: bool, exit_land: float) -> Tuple[float, float]:
1895 """How far the ring pair reaches in from the inner pitch circle and out from the outer one on
1896 the face datum: the elliptical exit traces on a flat face, the groove edges on a contoured one
1897 (engine.core.injectors.layout.ring_face_reach -- the drawing's own rule)."""
1898 reach_in, reach_out = ring_face_reach(
1899 contoured=bool(face_contoured), r_in=r_in, d_in=float(d_in), th_in=float(th_in),
1900 r_out=r_out, d_out=float(d_out), th_out=float(th_out), exit_land=float(exit_land))
1901 return r_in - reach_in, reach_out - r_out
1902
1903
1905 *,
1906 n_elements: float,
1907 spacing_O_m: float,
1908 spacing_F_m: float,
1909 d_jet_O_m: float,
1910 d_jet_F_m: float,
1911 D_chamber_inner_m: float,
1912 angle_O_deg: float = float("nan"),
1913 angle_F_deg: float = float("nan"),
1914 Ld_min: float = 0.0,
1915 Ld_max: float = 0.0,
1916 ring_order_fuel_outboard: bool = True,
1917 center_clear_dia_m: float = 0.0,
1918 min_web_m: float = 0.0,
1919 wall_clearance_m: float = 0.0,
1920 spray_radius_frac: float = 0.0,
1921 spray_radius_tol: float = 0.08,
1922 face_contoured: bool = False,
1923 exit_land: float = EXIT_LAND_DEFAULT,
1924) -> float:
1925 """Doublet ring geometry, computed straight from the DESIGN VARIABLES.
1926
1927 HOW AN UNLIKE DOUBLET ACTUALLY WORKS. The oxidizer and fuel orifices sit on two DIFFERENT
1928 pitch circles and are canted toward each other; the jets close the radial gap between the
1929 rings and collide partway between them:
1930
1931 radial offset dr = |D_pitch_O - D_pitch_F| / 2
1932 impingement dist L_imp = dr / (tan(theta_O) + tan(theta_F))
1933
1934 So the rings MUST be separated -- that separation is the whole mechanism. An earlier version
1935 of this function penalised |D_pitch_O - D_pitch_F| toward zero, i.e. drove both streams onto
1936 the SAME circle, which gives dr = 0 and L_imp = 0: the jets "impinge" at the face plate.
1937 That is a face-eroding non-injector, and it was wrong.
1938
1939 The real constraint is on the impingement DISTANCE, not on the ring separation itself.
1940 Published practice for unlike impinging elements puts L_imp at roughly 3-7 orifice diameters
1941 (5-7 as SP-8089 is usually quoted), with ~60 deg included angle. The band itself comes
1942 from ``engine.core.injectors.layout.impingement_ld_band``; the defaults here (0, 0) leave
1943 the standoff term off for a caller that passes no band. Too short and the jets collide on
1944 the face; too long and the free jets wander before they meet, so the pair stops mixing.
1945
1946 This exists because ``_impinging_geometry_fit_squared`` reads its inputs out of the physics
1947 ``diagnostics`` dict, and the numba accelerator -- which is what the Layer-1 inner loop runs
1948 on -- emits none of the keys it needs (``D_pitch_O``, ``element_gap_O``,
1949 ``vaporization_length_total``, ``L_imp``, ``s_pair``: zero occurrences in engine/accel/).
1950 They come back None, every lookup fails, and the whole W_IMP_GEOM constraint has been
1951 returning 0.0 for every candidate since the C->numba port. The A/B parity suite compares
1952 physics outputs (thrust, Pc, MR), not diagnostic keys, so it passed while a constraint was
1953 silently switched off.
1954
1955 Consequences seen live: a seed whose fuel ring was 119.4 mm across a 114.8 mm bore (orifices
1956 outside the chamber wall), and converged designs with the oxidizer and fuel rings 20-51 mm
1957 apart radially -- i.e. "doublets" whose two jets are on different circles and cannot meet.
1958
1959 Pitch circle, orifice pitch and overlap are pure geometry -- n, spacing and d_jet are design
1960 variables -- so deriving them here makes the constraint independent of which physics path ran.
1961 Terms (all relative, one-sided, zero when comfortable):
1962 * ring fit -- each D_pitch = n*spacing/pi must stay inside the bore
1963 * no overlap -- adjacent orifices must not collide (spacing >= d_jet + min web)
1964 * standoff -- L_imp/d_avg inside [Ld_min, Ld_max]
1965 * ring order -- fuel outboard, so wall spillover is fuel-rich
1966 * centre clear -- keep a circle free at the axis (igniter boss / centre port)
1967 * wall clearance -- keep a land between the outer ring and the bore
1968 * spray radius -- put the impingement circle where the propellant is wanted
1969
1970 WHERE THE RING PAIR SITS was an exactly flat direction until the last three terms existed.
1971 ``_layer1_derive_fuel_spacing`` solves s_F so the standoff hits its target, and it does that
1972 by fixing the GAP ``|s_F - s_O|``; dr is therefore independent of s_O, the pair slides
1973 radially at zero cost, and s_O fell onto its own lower bound (0.003 m). The result on
1974 ethalox_8kN_FINAL: D_pitch_O = 26.90 mm, all 28 doublets impinging on a 41.79 mm circle
1975 inside a 127.00 mm bore -- 10.8 % of the chamber area, narrower than the 49.57 mm throat,
1976 a 1.44 mm LOX web, and no room at the axis for the 3/8 NPT igniter.
1977
1978 WHERE THE PROPELLANT LANDS. Every doublet on one ring collides on the SAME circle,
1979 ``r_imp = r_inner + L_imp*tan(theta_inner)``, and nothing used to constrain it. Bounding the
1980 pair only from BELOW (the centre-clear term) is not enough -- the direction is still flat
1981 above that bound, and the search parks on it: measured r_imp/r_wall = 0.33 before any of
1982 these terms, then 0.44 once only the lower bound existed. The target is the EQUAL-AREA
1983 radius, ``r_wall/sqrt(2)``, which splits the chamber cross-section in half.
1984
1985 RADIAL EXTENT IS ELLIPTICAL. A hole of diameter d inclined ``theta`` from the chamber axis
1986 cuts the (axis-normal) face as an ellipse: minor axis d circumferentially, MAJOR axis
1987 ``d/cos(theta)`` radially. At theta = 69 deg that is 2.79x the drill diameter, so the
1988 centre- and wall-clearance terms use the ellipse while the web term -- a circumferential
1989 distance -- correctly uses d.
1990 """
1991 bore = float(D_chamber_inner_m)
1992 n = float(n_elements)
1993 if not (np.isfinite(bore) and bore > 0 and np.isfinite(n) and n > 0):
1994 return 0.0
1995 term = 0.0
1996 dp = {}
1997 for tag, sp, dj in (("O", spacing_O_m, d_jet_O_m), ("F", spacing_F_m, d_jet_F_m)):
1998 s = float(sp)
1999 if not (np.isfinite(s) and s > 0):
2000 continue
2001 d_pitch = n * s / float(np.pi)
2002 dp[tag] = d_pitch
2003 term += max(0.0, d_pitch / bore - 1.0) ** 2 # ring must fit the bore
2004 d = float(dj)
2005 if np.isfinite(d) and d > 0:
2006 # Circumferential land between adjacent holes of the same stream. spacing >= d_jet
2007 # alone permits a web of exactly zero; min_web_m is the drill/manifold floor.
2008 web_min = float(min_web_m) if np.isfinite(min_web_m) and min_web_m > 0 else 0.0
2009 term += max(0.0, (d + web_min - s) / bore) ** 2
2010 # Impingement distance from the RADIAL ring offset. Both rings inside the bore is handled
2011 # above; here we require the pair to actually meet at a sane standoff.
2012 if "O" in dp and "F" in dp:
2013 dr = 0.5 * abs(dp["O"] - dp["F"])
2014 # Ring ORDER: fuel outboard of LOX. Whatever spills past the impingement point on
2015 # the outer ring is what reaches the chamber wall, and a fuel-rich wall film is what
2016 # the ablative liner wants -- an oxidiser-rich one attacks it. The physics below uses
2017 # |dr| and so is indifferent to the order; this term is what makes the choice.
2018 if ring_order_fuel_outboard and dp["F"] < dp["O"]:
2019 term += ((dp["O"] - dp["F"]) / bore) ** 2
2020 tO, tF = float(angle_O_deg), float(angle_F_deg)
2021 # CENTRE CLEARANCE and WALL CLEARANCE, on the elliptical face trace (major = d/cos th).
2022 _half_major = {}
2023 for _tag, _d, _th in (("O", d_jet_O_m, tO), ("F", d_jet_F_m, tF)):
2024 _dv = float(_d)
2025 if not (np.isfinite(_dv) and _dv > 0):
2026 continue
2027 _c = float(np.cos(np.deg2rad(float(_th)))) if np.isfinite(_th) else 1.0
2028 _half_major[_tag] = 0.5 * _dv / max(0.10, abs(_c))
2029 if _half_major:
2030 _inner_tag = "O" if dp["O"] <= dp["F"] else "F"
2031 _outer_tag = "F" if _inner_tag == "O" else "O"
2032 if face_contoured and len(_half_major) == 2 and np.isfinite(tO) and np.isfinite(tF):
2033 # Contoured face: the ring pair ends at the groove edges, not the hole traces.
2034 _dd = {"O": float(d_jet_O_m), "F": float(d_jet_F_m)}
2035 _tt = {"O": tO, "F": tF}
2036 _half_major[_inner_tag], _half_major[_outer_tag] = _face_halves(
2037 r_in=0.5 * dp[_inner_tag], d_in=_dd[_inner_tag], th_in=_tt[_inner_tag],
2038 r_out=0.5 * dp[_outer_tag], d_out=_dd[_outer_tag], th_out=_tt[_outer_tag],
2039 face_contoured=True, exit_land=exit_land)
2040 _cc = float(center_clear_dia_m)
2041 if np.isfinite(_cc) and _cc > 0 and _inner_tag in _half_major:
2042 inner_edge = 0.5 * dp[_inner_tag] - _half_major[_inner_tag]
2043 term += max(0.0, (0.5 * _cc - inner_edge) / bore) ** 2
2044 _wc = float(wall_clearance_m)
2045 _wc = max(0.0, _wc) if np.isfinite(_wc) else 0.0
2046 if _outer_tag in _half_major:
2047 outer_edge = 0.5 * dp[_outer_tag] + _half_major[_outer_tag]
2048 term += max(0.0, (outer_edge + _wc - 0.5 * bore) / bore) ** 2
2049 d_avg = 0.5 * (float(d_jet_O_m) + float(d_jet_F_m))
2050 if np.isfinite(tO) and np.isfinite(tF) and np.isfinite(d_avg) and d_avg > 0:
2051 tan_sum = float(np.tan(np.deg2rad(tO)) + np.tan(np.deg2rad(tF)))
2052 if tan_sum > 1e-9:
2053 L_imp = dr / tan_sum
2054 # SPRAY PLACEMENT. Relative, banded, zero inside the band.
2055 _srf = float(spray_radius_frac)
2056 if np.isfinite(_srf) and _srf > 0.0:
2057 _th_in = tO if dp["O"] <= dp["F"] else tF
2058 _r_in = 0.5 * min(dp["O"], dp["F"])
2059 _r_imp = _r_in + L_imp * float(np.tan(np.deg2rad(_th_in)))
2060 _frac = _r_imp / (0.5 * bore)
2061 _tol = float(spray_radius_tol) if (
2062 np.isfinite(spray_radius_tol) and spray_radius_tol > 0) else 0.08
2063 _miss = abs(_frac - _srf) - _tol
2064 if _miss > 0.0:
2065 term += (_miss / _srf) ** 2
2066 Ld = L_imp / d_avg
2067 lo, hi = float(Ld_min), float(Ld_max)
2068 if lo > 0 and hi >= lo:
2069 # Relative, one-sided: anywhere inside [lo, hi] orifice diameters is free.
2070 if Ld < lo:
2071 term += ((lo - Ld) / lo) ** 2
2072 elif Ld > hi:
2073 term += ((Ld - hi) / hi) ** 2
2074 return float(term)
2075
2076
2078 diagnostics: Dict[str, Any],
2079 *,
2080 D_chamber_inner_m: float,
2081 L_chamber_m: float,
2082) -> float:
2083 """Squared penalty keeping the impinging element geometry physically self-consistent.
2084
2085 Two soft constraints, read from the physics ``diagnostics``:
2086
2087 * **Ring fit** — the hole pitch-circle ``D_pitch = n·spacing/π`` must stay inside the chamber bore.
2088 * **No overlap** — adjacent orifices must not collide (``element_gap = spacing − d_jet`` ≥ 0).
2089
2090 It no longer co-locates the rings (that drove the impingement standoff to zero) nor charges a
2091 vaporization-length budget (a second, cruder evaporation model double-counting eta_vap); see
2092 the comments below. :func:`_impinging_ring_geometry_squared` carries the ring pair.
2093
2094 All terms are relative (scale-free) and one-sided (only violations are penalized), so a comfortably
2095 sized element contributes exactly zero.
2096 """
2097 def _f(key: str) -> float:
2098 try:
2099 return float(diagnostics.get(key, np.nan))
2100 except (TypeError, ValueError):
2101 return float("nan")
2102
2103 term = 0.0
2104
2105 bore = float(D_chamber_inner_m)
2106 if np.isfinite(bore) and bore > 0:
2107 for k in ("D_pitch_O", "D_pitch_F"):
2108 d_pitch = _f(k)
2109 if np.isfinite(d_pitch) and d_pitch > 0:
2110 term += max(0.0, d_pitch / bore - 1.0) ** 2
2111
2112 # No ring CO-LOCATION term. One used to charge (|D_pitch_O - D_pitch_F| / bore)^2, which
2113 # drives the ring gap -- and with it the impingement standoff L_imp = dr / (tan th_O +
2114 # tan th_F) -- to zero: jets colliding on the face. _impinging_ring_geometry_squared owns
2115 # the ring pair (fit, web, standoff band, order, clearances) from the design variables.
2116
2117 for k in ("element_gap_O", "element_gap_F"):
2118 gap = _f(k)
2119 if np.isfinite(gap):
2120 d_ref = _f("D_pitch_O") if k.endswith("_O") else _f("D_pitch_F")
2121 ref = d_ref if (np.isfinite(d_ref) and d_ref > 0) else (bore if np.isfinite(bore) and bore > 0 else 1.0)
2122 if gap < 0.0:
2123 term += (gap / ref) ** 2
2124
2125 # No vaporization-length budget. It charged (L_imp + L_b + x*) / L_chamber past 1, with
2126 # x* = u_ax D32^2 / k_evap from spray.py's second evaporation model (1-atm boiling point
2127 # and latent heat, a C_evap fitted to a legacy K, constant drop speed, no heat-up) -- a
2128 # cruder model than the c* spray march, whose eta_vap already charges incomplete
2129 # vaporization through Isp. On the 6500N doublet it added ~142 points (x* 186 mm against
2130 # the march's fuel drop gone by ~110-130 mm): the same loss counted twice, once wrongly.
2131 # ``L_chamber_m`` is kept in the signature for callers; nothing here reads it now.
2132 return float(term)
2133
2134
2136 *, n_elements: float, spacing_O_m: float, spacing_F_m: float,
2137 d_jet_O_m: float, d_jet_F_m: float, angle_O_deg: float, angle_F_deg: float,
2138) -> float:
2139 """Longest free jet to the impingement point, in that stream's own orifice diameters.
2140
2141 SP-8089 criterion 3.1.1.1.4 measures the impingement distance ALONG each jet:
2142 L_k / d_k = L_imp,axial / (d_k cos theta_k). Layer 1's standoff target is axial over the
2143 mean diameter, so the same target means a longer free jet at steep angles and for the
2144 smaller hole. NaN when the geometry is degenerate.
2145 """
2146 try:
2147 n = float(n_elements)
2148 dr = 0.5 * abs(n * (float(spacing_F_m) - float(spacing_O_m)) / np.pi)
2149 tO, tF = np.deg2rad(float(angle_O_deg)), np.deg2rad(float(angle_F_deg))
2150 dO, dF = float(d_jet_O_m), float(d_jet_F_m)
2151 except (TypeError, ValueError):
2152 return float("nan")
2153 tan_sum = float(np.tan(tO) + np.tan(tF))
2154 if not (np.isfinite(dr) and tan_sum > 1e-9 and dO > 0 and dF > 0):
2155 return float("nan")
2156 L_ax = dr / tan_sum
2157 return float(max(L_ax / (dO * max(1e-6, np.cos(tO))), L_ax / (dF * max(1e-6, np.cos(tF)))))
2158
2159
2161 """Squared relative excess of the longest free jet over SP-8089's 7 d (0 inside)."""
2162 ld = _impinging_free_jet_ld(**kw)
2163 if not np.isfinite(ld):
2164 return 0.0
2165 return float(max(0.0, ld / FREE_JET_MAX_D - 1.0) ** 2)
2166
2167
2169 state: str, score: float, chug: float, acoustic: float, feed: float,
2170 stability_results: Any, requirements: dict, min_stability: float,
2171) -> Tuple[bool, List[str]]:
2172 """Final stability sign-off: (passed, reasons).
2173
2174 ``require_stable_state`` means 'stable', not 'stable or marginal'. Margins are checked at
2175 face value -- a hidden 5 % let a chug gate margin of 0.9975 (Nyquist gain margin 0.998, an
2176 unstable loop) pass -- and an unknown chug gain margin is a failure, not a pass.
2177 """
2178 handicap = float(requirements.get("stability_margin_handicap", 0.0) or 0.0)
2179 eff_score = float(requirements.get("min_stability_score", 0.75)) * max(0.0, 1.0 - handicap)
2180 eff_margin = float(min_stability) * max(0.0, 1.0 - handicap)
2181 parts: List[str] = []
2182 if bool(requirements.get("require_stable_state", True)):
2183 if state != "stable":
2184 parts.append(f"state == 'stable' (got '{state}')")
2185 elif state == "unstable":
2186 parts.append("state!='unstable'")
2187 chug_sr = (stability_results.get("chugging") or {}) if isinstance(stability_results, dict) else {}
2188 if "chug_gain_margin" in chug_sr:
2189 try:
2190 gm_known = bool(np.isfinite(float(chug_sr["chug_gain_margin"])))
2191 except (TypeError, ValueError):
2192 gm_known = False
2193 if not gm_known:
2194 parts.append("chug gain margin unknown")
2195 if score < eff_score:
2196 parts.append(f"score>={eff_score:.2f} (got {score:.2f})")
2197 for name, m in (("chugging", chug), ("acoustic", acoustic), ("feed", feed)):
2198 if not (m >= eff_margin):
2199 parts.append(f"{name}_margin>={eff_margin:.3f} (got {m:.4f})")
2200 return (not parts), parts
2201
2202
2203def _layer1_momentum_gate(R: float, R_min: Optional[float], R_max: Optional[float],
2204 slack: float = 1.0) -> Tuple[bool, Tuple[Optional[float], Optional[float]]]:
2205 """Momentum-ratio sign-off on the configured band (``slack`` > 1 widens it about 1)."""
2206 if R_min is None or R_max is None or not (np.isfinite(R) and R > 0):
2207 return True, (R_min, R_max)
2208 lo = max(0.0, 1.0 - (1.0 - float(R_min)) * float(slack))
2209 hi = 1.0 + (float(R_max) - 1.0) * float(slack)
2210 return bool(lo <= R <= hi), (lo, hi)
2211
2212
2214 config: Any, requirements: dict, constants: dict,
2215 performance: Optional[Dict[str, Any]] = None,
2216) -> Dict[str, Tuple[bool, str]]:
2217 """Every declared geometric limit, checked on the emitted design at face value.
2218
2219 {gate: (passed, reason)}. Only limits the configuration declares are checked (plus the
2220 SP-8089 free-jet length and the bore, which always apply to a doublet). ``performance``
2221 (the emitted design's solve: ``mdot_O/F`` and ``diagnostics.delta_p_injector_O/F``) adds
2222 the manifold velocity-head gate when the plate declares channels.
2223 """
2224 gates: Dict[str, Tuple[bool, str]] = {}
2225 # Plate entries the caller's constants lack come from the config (a hand-built constants
2226 # dict, or none); the run's own constants carry every one of them.
2227 constants = {**_layer1_injector_plate_constants(config, requirements), **(constants or {})}
2228 inj = getattr(config, "injector", None)
2229
2230 # Igniter thread engagement: the tapped port needs the thread's effective length L2
2231 # (ASME B1.20.1, engine.core.injectors.hardware_tables.NPT) of metal -- the hub at the port
2232 # when one is declared, else the plate. The plate thickness is a declared number, not a
2233 # design variable, so nothing in the search can move this: it is a sign-off gate only.
2234 ign = getattr(inj, "igniter", None)
2235 if ign is not None and getattr(ign, "thread", None):
2236 t_plate = float(constants.get("layer1_injector_plate_thickness_m", 0.0) or 0.0)
2237 if not (np.isfinite(t_plate) and t_plate > 0.0):
2238 t_plate = PLATE_THICKNESS_DEFAULT # the layout's own fallback
2239 ko = igniter_keepouts(
2240 ign.model_dump() if hasattr(ign, "model_dump") else dict(ign),
2241 plate_thickness=t_plate,
2242 min_web=float(constants.get("layer1_injector_min_web_m", 0.0) or 0.0))
2243 if ko is not None:
2244 at = "injector.igniter.hub_thickness" if ko["hub_thickness"] is not None else "the plate"
2245 gates["igniter_engagement"] = (
2246 ko["engaged_thickness"] >= ko["l2"],
2247 f"{ko['thread']} igniter needs {ko['l2'] * 1e3:.2f} mm of thread engagement (ASME "
2248 f"B1.20.1 L2); {at} gives {ko['engaged_thickness'] * 1e3:.2f} mm at the port")
2249
2250 cg = getattr(config, "chamber_geometry", None)
2251 if cg is None:
2252 return gates
2253 try:
2254 At = float(cg.A_throat or 0.0)
2255 D = float(cg.chamber_diameter or 0.0)
2256 Lstar = float(cg.Lstar or 0.0)
2257 eps = float(cg.expansion_ratio or 0.0)
2258 except (TypeError, ValueError):
2259 return gates
2260 if not (At > 0 and D > 0 and Lstar > 0):
2261 return gates
2262 A_c = np.pi * D * D / 4.0
2263 lens = _layer1_chamber_lengths(At, Lstar, D, _layer1_contraction_theta(constants))
2264 geom = getattr(inj, "geometry", None)
2265 n = 0
2266 if getattr(inj, "type", None) == "impinging" and geom is not None:
2267 n = int(getattr(geom.oxidizer, "n_elements", 0) or 0)
2268
2269 max_pitch = _req_lookup(requirements, "layer1_max_element_pitch_m")
2270 if max_pitch is not None and float(max_pitch) > 0 and n > 0:
2271 pitch = float(np.sqrt(A_c / n))
2272 gates["element_pitch"] = (
2273 pitch <= float(max_pitch),
2274 f"Element pitch {pitch * 1e3:.2f} mm > limit {float(max_pitch) * 1e3:.2f} mm "
2275 f"({n} elements in a {D * 1e3:.1f} mm bore)")
2276 min_ld = _req_lookup(requirements, "layer1_min_Lcyl_over_D")
2277 if min_ld is not None and float(min_ld) > 0:
2278 r = lens["length_cylindrical"] / D
2279 gates["lcyl_over_d"] = (r >= float(min_ld),
2280 f"L_cyl/D {r:.3f} < minimum {float(min_ld):.3f}")
2281 max_len = _req_lookup(requirements, "max_engine_length")
2282 if max_len is not None and float(max_len) > 0:
2283 L_eng = lens["face_to_throat"] + _layer1_bell_length_m(At, eps)
2284 gates["engine_length"] = (L_eng <= float(max_len),
2285 f"Engine length {L_eng * 1e3:.1f} mm (face to exit) > "
2286 f"max_engine_length {float(max_len) * 1e3:.1f} mm")
2287 if n > 0:
2288 g = dict(n_elements=n, spacing_O_m=float(geom.oxidizer.spacing),
2289 spacing_F_m=float(geom.fuel.spacing), d_jet_O_m=float(geom.oxidizer.d_jet),
2290 d_jet_F_m=float(geom.fuel.d_jet), angle_O_deg=float(geom.oxidizer.impingement_angle),
2291 angle_F_deg=float(geom.fuel.impingement_angle))
2292 fj = _impinging_free_jet_ld(**g)
2293 if np.isfinite(fj):
2294 gates["free_jet"] = (fj <= FREE_JET_MAX_D,
2295 f"Free jet {fj:.2f} d to impingement > SP-8089's {FREE_JET_MAX_D:g} d")
2296 c = constants
2298 **g, D_chamber_inner_m=D,
2299 center_clear_dia_m=float(c.get("layer1_injector_center_clear_dia_m", 0.0) or 0.0),
2300 min_web_m=float(c.get("layer1_injector_min_web_m", 0.0) or 0.0),
2301 wall_clearance_m=float(c.get("layer1_injector_wall_clearance_m", 0.0) or 0.0),
2302 spray_radius_frac=float(c.get("layer1_injector_spray_radius_frac", 0.0) or 0.0),
2303 spray_radius_tol=float(c.get("layer1_injector_spray_radius_tol", 0.08) or 0.08),
2304 min_face_incidence_deg=float(c.get("layer1_injector_min_face_incidence_deg", 0.0) or 0.0),
2305 face_contoured=bool(c.get("layer1_injector_face_contoured", False)),
2306 exit_land=float(c.get("layer1_injector_exit_land", EXIT_LAND_DEFAULT)))
2307 gates["injector_face"] = (face <= 0.0, f"Injector face limits violated (score {face:.3g})")
2309 **g, D_chamber_inner_m=D,
2310 plate_thickness_m=float(c.get("layer1_injector_plate_thickness_m", 0.0) or 0.0),
2311 counterbore_dia_m=float(c.get("layer1_injector_counterbore_dia_m", 0.0) or 0.0),
2312 land_ld_O=float(c.get("layer1_injector_land_ld_O", 4.0) or 4.0),
2313 land_ld_F=float(c.get("layer1_injector_land_ld_F", 4.0) or 4.0),
2314 min_back_web_m=float(c.get("layer1_injector_min_back_web_m", 0.0) or 0.0),
2315 back_keepout_dia_m=float(c.get("layer1_injector_back_keepout_dia_m", 0.0) or 0.0),
2316 back_keepout_wall_m=float(c.get("layer1_injector_back_keepout_wall_m", 0.0) or 0.0),
2317 face_contoured=bool(c.get("layer1_injector_face_contoured", False)),
2318 exit_land=float(c.get("layer1_injector_exit_land", EXIT_LAND_DEFAULT)),
2319 back_channels=bool(c.get("layer1_injector_back_channels", False)),
2320 passage_ld_O=float(c.get("layer1_injector_land_ld_O", 4.0) or 4.0),
2321 passage_ld_F=float(c.get("layer1_injector_land_ld_F", 4.0) or 4.0),
2322 channel_width_m=float(c.get("layer1_injector_channel_width_m", 0.0) or 0.0),
2323 channel_floor=str(c.get("layer1_injector_channel_floor", "flat")),
2324 r_plate_m=float(c.get("layer1_injector_r_plate_m", 0.0) or 0.0),
2325 r_port_m=float(c.get("layer1_injector_r_port_m", 0.0) or 0.0),
2326 liner_thickness_m=float(c.get("layer1_injector_liner_thickness_m", 0.0) or 0.0))
2327 gates["injector_back_face"] = (back <= 0.0,
2328 f"Injector back-face limits violated (score {back:.3g})")
2329 if bool(c.get("layer1_injector_back_channels", False)) and isinstance(performance, dict):
2330 _dg = performance.get("diagnostics") if isinstance(performance.get("diagnostics"), dict) else {}
2331 fl = getattr(config, "fluids", None) or {}
2332
2333 def _rho(key: str, side: str) -> Any:
2334 v = c.get(key)
2335 if v is None:
2336 try:
2337 v = fl[side].density
2338 except (KeyError, TypeError, AttributeError):
2339 v = None
2340 return v
2341 inlets = int(c.get("layer1_injector_channel_inlets", 1) or 1)
2343 **g, plate_thickness_m=float(c.get("layer1_injector_plate_thickness_m", 0.0) or 0.0),
2344 face_contoured=bool(c.get("layer1_injector_face_contoured", False)),
2345 exit_land=float(c.get("layer1_injector_exit_land", EXIT_LAND_DEFAULT)),
2346 passage_ld_O=float(c.get("layer1_injector_land_ld_O", 4.0) or 4.0),
2347 passage_ld_F=float(c.get("layer1_injector_land_ld_F", 4.0) or 4.0),
2348 channel_width_m=float(c.get("layer1_injector_channel_width_m", 0.0) or 0.0),
2349 channel_floor=str(c.get("layer1_injector_channel_floor", "flat")),
2350 channel_inlets=inlets,
2351 mdot_O=performance.get("mdot_O"), mdot_F=performance.get("mdot_F"),
2352 dp_O=_dg.get("delta_p_injector_O"), dp_F=_dg.get("delta_p_injector_F"),
2353 rho_O=_rho("rho_O", "oxidizer"), rho_F=_rho("rho_F", "fuel"))
2354 for k, tag in (("O", "LOX"), ("F", "fuel")):
2355 h = mq.get(k)
2356 if h is None:
2357 continue
2358 gates[f"manifold_q_{k}"] = (
2359 h["q_over_dp"] <= h["q_frac"],
2360 f"{tag} channel velocity head {100 * h['q_over_dp']:.0f} % of the injector "
2361 f"drop at {inlets} feed port{'s' if inlets > 1 else ''} > "
2362 f"{100 * MANIFOLD_Q_FRAC:.0f} % (channel {h['flow_area'] * 1e6:.1f} mm^2, "
2363 f"needs {h['area_needed'] * 1e6:.0f} mm^2, or more ports)")
2364 return gates
2365
2366
2368 *,
2369 n_elements: float, spacing_O_m: float, spacing_F_m: float,
2370 d_jet_O_m: float, d_jet_F_m: float, D_chamber_inner_m: float,
2371 angle_O_deg: float, angle_F_deg: float,
2372 center_clear_dia_m: float = 0.0, min_web_m: float = 0.0,
2373 wall_clearance_m: float = 0.0, spray_radius_frac: float = 0.0,
2374 spray_radius_tol: float = 0.08, min_face_incidence_deg: float = 0.0,
2375 face_contoured: bool = False, exit_land: float = EXIT_LAND_DEFAULT,
2376) -> float:
2377 """Face real-estate misses as a GRADED infeasibility, for the parallel worker path.
2378
2379 These limits need three properties at once and only this shape has all three:
2380
2381 * **Hard.** Routed into ``infeasibility_score``, so any violation ranks below every
2382 feasible design instead of being priced against Isp. As a plain objective term at
2383 W_IMP_GEOM = 1500 a 0.022 spray-radius miss cost 1.5 points against an objective of
2384 ~2690 and every seed simply bought it -- the same way a 2.7 %-over ring once bought
2385 orifices outside the bore for 1.1 points.
2386 * **Graded.** A binary ``+= 1.0`` was tried and is worse than useless: it puts the whole
2387 violating region on one flat 1e6 plateau with no gradient, and 2 of 3 seeds then never
2388 found the feasible set at all (converged O/F 2.09 and 25.6). Same failure the
2389 per-jet angle box produced once; see the note at the angle bounds.
2390 * **In both paths.** The serial loop's ``_impinging_hard_geometry_blocks_eval`` never ran
2391 in the parallel CMA workers, where essentially every candidate is scored.
2392
2393 All terms are relative, one-sided, and exactly zero for a comfortable design, so a config
2394 that declares none of these keys contributes nothing -- except the bore itself: an
2395 orifice whose elliptical trace crosses the chamber wall is drilled into the liner whether
2396 or not a wall land was declared, so that check runs at a clearance of 0 too. The soft
2397 term and the serial hard block apply the same rule.
2398 """
2399 bore = float(D_chamber_inner_m)
2400 n = float(n_elements)
2401 if not (np.isfinite(bore) and bore > 0 and np.isfinite(n) and n > 0):
2402 return 0.0
2403 try:
2404 dp_O = n * float(spacing_O_m) / np.pi
2405 dp_F = n * float(spacing_F_m) / np.pi
2406 except (TypeError, ValueError):
2407 return 0.0
2408 if not (np.isfinite(dp_O) and np.isfinite(dp_F) and dp_O > 0 and dp_F > 0):
2409 return 0.0
2410
2411 def _half_major(d: float, th: float) -> float:
2412 c = float(np.cos(np.deg2rad(float(th)))) if np.isfinite(th) else 1.0
2413 return 0.5 * float(d) / max(0.10, abs(c))
2414
2415 ox_inner = dp_O <= dp_F
2416 r_in = 0.5 * (dp_O if ox_inner else dp_F)
2417 r_out = 0.5 * (dp_F if ox_inner else dp_O)
2418 d_in, th_in = (d_jet_O_m, angle_O_deg) if ox_inner else (d_jet_F_m, angle_F_deg)
2419 d_out, th_out = (d_jet_F_m, angle_F_deg) if ox_inner else (d_jet_O_m, angle_O_deg)
2420 if face_contoured and np.isfinite(th_in) and np.isfinite(th_out):
2421 h_in, h_out = _face_halves(r_in=r_in, d_in=d_in, th_in=th_in, r_out=r_out, d_out=d_out,
2422 th_out=th_out, face_contoured=True, exit_land=exit_land)
2423 else:
2424 h_in, h_out = _half_major(d_in, th_in), _half_major(d_out, th_out)
2425
2426 term = 0.0
2427 cc = float(center_clear_dia_m)
2428 if np.isfinite(cc) and cc > 0.0:
2429 inner_edge = r_in - h_in
2430 term += max(0.0, (0.5 * cc - inner_edge) / bore) ** 2
2431 wc = float(wall_clearance_m)
2432 wc = max(0.0, wc) if np.isfinite(wc) else 0.0
2433 outer_edge = r_out + h_out
2434 term += max(0.0, (outer_edge + wc - 0.5 * bore) / bore) ** 2
2435 # FACE INCIDENCE (90 - theta against the declared floor). The jet-angle search box already
2436 # holds this for free variables; a frozen or seeded angle does not pass through that box.
2437 inc = float(min_face_incidence_deg)
2438 if np.isfinite(inc) and inc > 0.0:
2439 for _th in (angle_O_deg, angle_F_deg):
2440 if np.isfinite(_th):
2441 term += max(0.0, (inc - (90.0 - float(_th))) / 90.0) ** 2
2442 web = float(min_web_m)
2443 if np.isfinite(web) and web > 0.0:
2444 for _sp, _dj in ((spacing_O_m, d_jet_O_m), (spacing_F_m, d_jet_F_m)):
2445 if float(_sp) > 0:
2446 term += max(0.0, (float(_dj) + web - float(_sp)) / bore) ** 2
2447 srf = float(spray_radius_frac)
2448 if np.isfinite(srf) and srf > 0.0:
2449 tan_sum = float(np.tan(np.deg2rad(float(angle_O_deg)))
2450 + np.tan(np.deg2rad(float(angle_F_deg))))
2451 if np.isfinite(tan_sum) and tan_sum > 1e-9:
2452 L_imp = 0.5 * abs(dp_O - dp_F) / tan_sum
2453 r_imp = r_in + L_imp * float(np.tan(np.deg2rad(float(th_in))))
2454 frac = r_imp / (0.5 * bore)
2455 tol = float(spray_radius_tol) if (
2456 np.isfinite(spray_radius_tol) and spray_radius_tol > 0) else 0.08
2457 term += max(0.0, abs(frac - srf) - tol) ** 2
2458 return float(term)
2459
2460
2462 *,
2463 n_elements: float, spacing_O_m: float, spacing_F_m: float,
2464 d_jet_O_m: float, d_jet_F_m: float, angle_O_deg: float, angle_F_deg: float,
2465 plate_thickness_m: float, face_contoured: bool, exit_land: float,
2466 passage_ld_O: float, passage_ld_F: float, channel_width_m: float, channel_floor: str,
2467) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], bool]]:
2468 """(LOX channel, fuel channel, LOX inboard) from ``layout.channel_for_ring`` -- the drawing's
2469 own geometry -- or None when the ring pair is degenerate."""
2470 from engine.core.injectors.layout import exit_recess as _exit_recess
2471 try:
2472 n = int(round(float(n_elements))) if np.isfinite(n_elements) else 0
2473 except (TypeError, ValueError):
2474 return None
2475 if n < 1:
2476 return None
2477 r_O = n * float(spacing_O_m) / np.pi / 2.0
2478 r_F = n * float(spacing_F_m) / np.pi / 2.0
2479 ox_inner = r_O <= r_F
2480 thO, thF = float(angle_O_deg), float(angle_F_deg)
2481 if not (np.isfinite(thO) and np.isfinite(thF)):
2482 return None
2483 dO, dF = float(d_jet_O_m), float(d_jet_F_m)
2484 if face_contoured:
2485 d_in, th_in, d_out, th_out = (dO, thO, dF, thF) if ox_inner else (dF, thF, dO, thO)
2486 z_E = -_exit_recess(d_in=d_in, th_in=th_in, d_out=d_out, th_out=th_out,
2487 exit_land=float(exit_land))
2488 else:
2489 z_E = 0.0
2490 w = float(channel_width_m) if np.isfinite(channel_width_m) and channel_width_m > 0 else None
2491 t = float(plate_thickness_m)
2492 chO = channel_for_ring(r_exit=r_O, z_exit=z_E, d=dO, theta_deg=thO, is_inner=ox_inner,
2493 plate_thickness=t, passage_ld=float(passage_ld_O), width=w,
2494 floor=channel_floor, exit_land=float(exit_land))
2495 chF = channel_for_ring(r_exit=r_F, z_exit=z_E, d=dF, theta_deg=thF, is_inner=not ox_inner,
2496 plate_thickness=t, passage_ld=float(passage_ld_F), width=w,
2497 floor=channel_floor, exit_land=float(exit_land))
2498 return chO, chF, ox_inner
2499
2500
2502 *,
2503 n_elements: float, spacing_O_m: float, spacing_F_m: float,
2504 d_jet_O_m: float, d_jet_F_m: float, angle_O_deg: float, angle_F_deg: float,
2505 plate_thickness_m: float, face_contoured: bool, exit_land: float,
2506 passage_ld_O: float, passage_ld_F: float, channel_width_m: float, channel_floor: str,
2507 channel_inlets: int, mdot_O: Any, mdot_F: Any, dp_O: Any, dp_F: Any, rho_O: Any, rho_F: Any,
2508) -> Dict[str, Dict[str, float]]:
2509 """Channels back: each channel's velocity head at its feed port against the injector drop.
2510
2511 ``layout.channel_velocity_head`` on the channel ``layout.channel_for_ring`` draws, with
2512 ``channel_inlets`` ports each splitting two ways: q = rho/2 (mdot / (2 n rho A))^2. The limit
2513 is ``layout.MANIFOLD_Q_FRAC`` of the drop (Rohde, Richards & Metger, NASA TN D-5467: holes
2514 fed across a channel recover none of its velocity head). {side: velocity-head dict}; a side
2515 is missing when its channel pierces the back (no channel) or its flow is not known -- the
2516 same skips as ``layout._check_manifold``.
2517 """
2518 t = float(plate_thickness_m)
2519 if not (np.isfinite(t) and t > 0.0):
2520 t = PLATE_THICKNESS_DEFAULT # the layout's own fallback, so both agree
2522 n_elements=n_elements, spacing_O_m=spacing_O_m, spacing_F_m=spacing_F_m,
2523 d_jet_O_m=d_jet_O_m, d_jet_F_m=d_jet_F_m, angle_O_deg=angle_O_deg,
2524 angle_F_deg=angle_F_deg, plate_thickness_m=t, face_contoured=face_contoured,
2525 exit_land=exit_land, passage_ld_O=passage_ld_O, passage_ld_F=passage_ld_F,
2526 channel_width_m=channel_width_m, channel_floor=channel_floor)
2527 out: Dict[str, Dict[str, float]] = {}
2528 if pair is None:
2529 return out
2530 chans = {"O": pair[0], "F": pair[1]}
2531 flows = {"O": (mdot_O, dp_O, rho_O), "F": (mdot_F, dp_F, rho_F)}
2532 for k, ch in chans.items():
2533 if ch["pierces_back"]:
2534 continue
2535 try:
2536 m, dp, rho = (float(v) for v in flows[k])
2537 except (TypeError, ValueError):
2538 continue
2539 if not (np.isfinite(m) and np.isfinite(dp) and np.isfinite(rho) and m > 0 and dp > 0 and rho > 0):
2540 continue
2541 h = _channel_velocity_head(flow_area=float(ch["flow_area"]), mdot=m, rho=rho, dp=dp,
2542 inlets=int(channel_inlets or 1))
2543 if np.isfinite(h["q_over_dp"]):
2544 out[k] = dict(h, flow_area=float(ch["flow_area"]))
2545 return out
2546
2547
2548def _impinging_manifold_violation(q: Dict[str, Dict[str, float]]) -> float:
2549 """Graded infeasibility from :func:`_impinging_manifold_q`: the channel AREA shortfall,
2550 ``(A_needed / A - 1)^2`` summed over the sides, 0 at or under ``MANIFOLD_Q_FRAC``."""
2551 term = 0.0
2552 for h in q.values():
2553 A, A_need = float(h.get("flow_area", 0.0)), float(h.get("area_needed", float("nan")))
2554 if A > 0 and np.isfinite(A_need):
2555 term += max(0.0, A_need / A - 1.0) ** 2
2556 return float(term)
2557
2558
2560 *,
2561 n_elements: float, spacing_O_m: float, spacing_F_m: float,
2562 d_jet_O_m: float, d_jet_F_m: float, D_chamber_inner_m: float,
2563 angle_O_deg: float, angle_F_deg: float, plate_thickness_m: float,
2564 face_contoured: bool, exit_land: float, passage_ld_O: float, passage_ld_F: float,
2565 channel_width_m: float, channel_floor: str, r_plate_m: float, r_port_m: float,
2566 min_land_m: float = 0.0, back_keepout_dia_m: float = 0.0, back_keepout_wall_m: float = 0.0,
2567) -> float:
2568 """Channel-back misses as a graded infeasibility. Active whenever the back is channels.
2569
2570 The same geometry the drawing uses (``engine.core.injectors.layout.channel_for_ring``):
2571 each passage runs its L/d from the exit to its channel floor. Infeasible when a passage would
2572 run out through the back face before its length, when the two channels overlap (or leave less
2573 than ``min_land_m`` between them), when the inner channel reaches the centre port or the
2574 igniter's thick centre, or when the outer channel runs off the plug.
2575 """
2576 import math as _m
2577 bore = float(D_chamber_inner_m)
2578 if not (np.isfinite(bore) and bore > 0):
2579 return 0.0
2581 n_elements=n_elements, spacing_O_m=spacing_O_m, spacing_F_m=spacing_F_m,
2582 d_jet_O_m=d_jet_O_m, d_jet_F_m=d_jet_F_m, angle_O_deg=angle_O_deg,
2583 angle_F_deg=angle_F_deg, plate_thickness_m=plate_thickness_m,
2584 face_contoured=face_contoured, exit_land=exit_land, passage_ld_O=passage_ld_O,
2585 passage_ld_F=passage_ld_F, channel_width_m=channel_width_m, channel_floor=channel_floor)
2586 if pair is None:
2587 return 0.0
2588 chO, chF, ox_inner = pair
2589 term = 0.0
2590 for ch in (chO, chF):
2591 if ch["pierces_back"]:
2592 term += ((ch["length_wanted"] - ch["length"]) / bore) ** 2
2593 ch_in, ch_out = (chO, chF) if ox_inner else (chF, chO)
2594 r_plate = float(r_plate_m) if np.isfinite(r_plate_m) and r_plate_m > 0 else 0.5 * bore
2595 lands = channel_lands(ch_in=ch_in, ch_out=ch_out, r_centre_hole=float(r_port_m or 0.0),
2596 r_plate=r_plate)
2597 need = max(0.0, float(min_land_m) if _m.isfinite(min_land_m) else 0.0)
2598 term += max(0.0, (need - lands["between"]) / bore) ** 2
2599 term += max(0.0, -lands["inner"] / bore) ** 2
2600 term += max(0.0, -lands["outer"] / bore) ** 2
2601 keep = float(back_keepout_dia_m) if np.isfinite(back_keepout_dia_m) else 0.0
2602 if keep > 0.0:
2603 wall = float(back_keepout_wall_m) if np.isfinite(back_keepout_wall_m) else 0.0
2604 term += max(0.0, (0.5 * keep + wall - ch_in["r_lo"]) / bore) ** 2
2605 return float(term)
2606
2607
2609 *,
2610 n_elements: float, spacing_O_m: float, spacing_F_m: float,
2611 d_jet_O_m: float, d_jet_F_m: float, D_chamber_inner_m: float,
2612 angle_O_deg: float, angle_F_deg: float,
2613 plate_thickness_m: float = 0.0, counterbore_dia_m: float = 0.0,
2614 land_ld_O: float = 4.0, land_ld_F: float = 4.0,
2615 min_back_web_m: float = 0.0,
2616 back_keepout_dia_m: float = 0.0, back_keepout_wall_m: float = 0.0,
2617 face_contoured: bool = False, exit_land: float = EXIT_LAND_DEFAULT,
2618 back_channels: bool = False, passage_ld_O: float = 4.0, passage_ld_F: float = 4.0,
2619 channel_width_m: float = 0.0, channel_floor: str = "flat",
2620 r_plate_m: float = 0.0, r_port_m: float = 0.0, liner_thickness_m: float = 0.0,
2621) -> float:
2622 """Back-face (manifold side) misses as a graded infeasibility.
2623
2624 Always on, when a plate thickness is known: passages that overlap at the back face, cross the
2625 axis, or open into the centre port -- none of those can be built. The declared limits
2626 (``min_back_web_m``, the igniter's back keep-out) add to that.
2627
2628 Each passage walks ``t tan(theta)`` radially through the plate, the two rings walking
2629 APART, so a face that fits can leave entries that crowd each other (inner ring) or leave
2630 no room for the wall between the two manifolds. The geometry is
2631 ``engine.core.injectors.layout.passage_back_entry`` -- the same call the drawing makes.
2632 Relative to the bore and squared, like the face terms, so CMA has a gradient home.
2633 """
2634 t = float(plate_thickness_m)
2635 if back_channels and not (np.isfinite(t) and t > 0.0):
2636 t = PLATE_THICKNESS_DEFAULT # the layout's own fallback, so both agree
2637 web_req = float(min_back_web_m) if np.isfinite(min_back_web_m) else 0.0
2638 land_req = 0.0 # the LOX and fuel entries may not overlap; the seal land is the user's
2639 keep = float(back_keepout_dia_m) if np.isfinite(back_keepout_dia_m) else 0.0
2640 # The plug fills the sleeve bore: bore + liner, from THIS candidate's bore.
2641 liner = float(liner_thickness_m) if np.isfinite(liner_thickness_m) else 0.0
2642 if np.isfinite(D_chamber_inner_m) and D_chamber_inner_m > 0:
2643 r_plate_m = 0.5 * float(D_chamber_inner_m) + liner
2644 if back_channels and np.isfinite(t) and t > 0.0:
2646 n_elements=n_elements, spacing_O_m=spacing_O_m, spacing_F_m=spacing_F_m,
2647 d_jet_O_m=d_jet_O_m, d_jet_F_m=d_jet_F_m, D_chamber_inner_m=D_chamber_inner_m,
2648 angle_O_deg=angle_O_deg, angle_F_deg=angle_F_deg, plate_thickness_m=t,
2649 face_contoured=face_contoured, exit_land=exit_land,
2650 passage_ld_O=passage_ld_O, passage_ld_F=passage_ld_F,
2651 channel_width_m=channel_width_m, channel_floor=channel_floor,
2652 r_plate_m=r_plate_m, r_port_m=r_port_m, min_land_m=land_req,
2653 back_keepout_dia_m=keep, back_keepout_wall_m=back_keepout_wall_m)
2654 if not (np.isfinite(t) and t > 0.0):
2655 return 0.0
2656 bore = float(D_chamber_inner_m)
2657 n = int(round(float(n_elements))) if np.isfinite(n_elements) else 0
2658 thO, thF = float(angle_O_deg), float(angle_F_deg)
2659 if not (np.isfinite(bore) and bore > 0 and n >= 1 and np.isfinite(thO) and np.isfinite(thF)):
2660 return 0.0
2661 r_O = n * float(spacing_O_m) / np.pi / 2.0
2662 r_F = n * float(spacing_F_m) / np.pi / 2.0
2663 ox_inner = r_O <= r_F
2664 dE = 0.0
2665 if face_contoured:
2666 from engine.core.injectors.layout import exit_recess as _exit_recess
2667 d_in, th_in, d_out, th_out = ((float(d_jet_O_m), thO, float(d_jet_F_m), thF) if ox_inner
2668 else (float(d_jet_F_m), thF, float(d_jet_O_m), thO))
2669 dE = _exit_recess(d_in=d_in, th_in=th_in, d_out=d_out, th_out=th_out, exit_land=float(exit_land))
2670 cb = float(counterbore_dia_m) if np.isfinite(counterbore_dia_m) else 0.0
2671 eO = passage_back_entry(r_face=r_O, d=float(d_jet_O_m), theta_deg=thO,
2672 plate_thickness=t, counterbore_dia=cb, land_ld=float(land_ld_O),
2673 is_inner=ox_inner, n=n, exit_depth=dE)
2674 eF = passage_back_entry(r_face=r_F, d=float(d_jet_F_m), theta_deg=thF,
2675 plate_thickness=t, counterbore_dia=cb, land_ld=float(land_ld_F),
2676 is_inner=not ox_inner, n=n, exit_depth=dE)
2677 term = 0.0
2678 for e in (eO, eF): # never buildable: entries that overlap
2679 term += max(0.0, (max(web_req, 0.0) - e.web) / bore) ** 2
2680 inner, outer = (eO, eF) if ox_inner else (eF, eO)
2681 # ... or cross, or reach the axis / the centre port
2682 term += max(0.0, (max(land_req, 0.0) - (outer.inner_edge - inner.outer_edge)) / bore) ** 2
2683 r_port = float(r_port_m) if np.isfinite(r_port_m) else 0.0
2684 term += max(0.0, (r_port - inner.inner_edge) / bore) ** 2
2685 if keep > 0.0:
2686 # The igniter's hub / socket on the back: the inner ring's entries must clear it.
2687 wall = float(back_keepout_wall_m) if np.isfinite(back_keepout_wall_m) else 0.0
2688 term += max(0.0, (0.5 * keep + wall - inner.inner_edge) / bore) ** 2
2689 return float(term)
2690
2691
2693 *,
2694 d_jet_O: float,
2695 d_jet_F: float,
2696 sp_O: float,
2697 sp_F: float,
2698 D_chamber_inner: float,
2699 D_throat_check: float,
2700 A_chamber_check: float,
2701 A_throat_check: float,
2702 n_elements: float = float("nan"),
2703 angle_O_deg: float = float("nan"),
2704 angle_F_deg: float = float("nan"),
2705 center_clear_dia_m: float = 0.0,
2706 min_web_m: float = 0.0,
2707 wall_clearance_m: float = 0.0,
2708 min_face_incidence_deg: float = 0.0,
2709 spray_radius_frac: float = 0.0,
2710 spray_radius_tol: float = 0.08,
2711 plate_thickness_m: float = 0.0,
2712 counterbore_dia_m: float = 0.0,
2713 land_ld_O: float = 4.0,
2714 land_ld_F: float = 4.0,
2715 min_back_web_m: float = 0.0,
2716 back_keepout_dia_m: float = 0.0,
2717 back_keepout_wall_m: float = 0.0,
2718 face_contoured: bool = False,
2719 exit_land: float = EXIT_LAND_DEFAULT,
2720 back_channels: bool = False,
2721 passage_ld_O: float = 4.0,
2722 passage_ld_F: float = 4.0,
2723 channel_width_m: float = 0.0,
2724 channel_floor: str = "flat",
2725 r_plate_m: float = 0.0,
2726 r_port_m: float = 0.0,
2727 liner_thickness_m: float = 0.0,
2728) -> bool:
2729 """True when doublet layout is physically impossible — skip ``evaluate()``.
2730
2731 The face-real-estate checks (centre clearance, web, wall land) are HARD for the same
2732 reason ring fit is: as soft terms they get bought for a rounding error against an
2733 objective of ~2700. They stay inert unless the corresponding requirement is set, so a
2734 config that declares none of them behaves exactly as before.
2735 """
2736 # FACE INCIDENCE. theta is measured from the chamber AXIS, so the angle the drill makes
2737 # with the face PLANE is 90 - theta. A twist drill entering a flat below ~40 deg of
2738 # incidence walks off the spot; the fix is a spot-face milled normal to the hole axis, and
2739 # whether the shop will cut 2n of those is a decision, not something the optimizer may
2740 # assume. This is the per-jet CEILING on theta, complementary to the
2741 # layer1_impinging_jet_angle_min_deg floor.
2742 _inc = float(min_face_incidence_deg) if np.isfinite(min_face_incidence_deg) else 0.0
2743 if _inc > 0.0:
2744 for _th in (angle_O_deg, angle_F_deg):
2745 if np.isfinite(_th) and (90.0 - float(_th)) < _inc:
2746 return True
2747 _web = float(min_web_m) if np.isfinite(min_web_m) and min_web_m > 0 else 0.0
2748 if sp_O > 0.0 and d_jet_O + _web > sp_O:
2749 return True
2750 if sp_F > 0.0 and d_jet_F + _web > sp_F:
2751 return True
2752 # RING FIT. D_pitch = n*spacing/pi, and the hole has width, so the ring needs
2753 # D_pitch + d_jet <= D_chamber_inner or the orifices are drilled outside the chamber
2754 # wall. This was only a SOFT term in _impinging_ring_geometry_squared -- a fuel ring
2755 # 2.7% over the bore scored (0.0268)^2 * W_IMP_GEOM = 1.1 points against an objective
2756 # of ~2700, so the optimiser bought impossible geometry for nothing and every gate
2757 # still reported PASS. Measured live: 130.4 mm fuel pitch circle on a 127.0 mm bore.
2758 # (A flat-face rule: on a contoured face the ring pair ends at the groove edges, which the
2759 # clearance check below measures; D_pitch + d is not a face extent there.)
2760 if np.isfinite(n_elements) and n_elements >= 1.0 and D_chamber_inner > 0.0 and not face_contoured:
2761 for _sp, _dj in ((sp_O, d_jet_O), (sp_F, d_jet_F)):
2762 if _sp > 0.0:
2763 if (float(n_elements) * _sp / np.pi) + _dj > D_chamber_inner:
2764 return True
2765 # CENTRE / WALL clearance on the elliptical face trace (radial major axis = d/cos theta).
2766 if (np.isfinite(n_elements) and n_elements >= 1.0 and D_chamber_inner > 0.0
2767 and sp_O > 0.0 and sp_F > 0.0):
2768 _dp_O = float(n_elements) * sp_O / np.pi
2769 _dp_F = float(n_elements) * sp_F / np.pi
2770 def _half_major(d: float, th: float) -> float:
2771 c = float(np.cos(np.deg2rad(float(th)))) if np.isfinite(th) else 1.0
2772 return 0.5 * float(d) / max(0.10, abs(c))
2773 _in_dp, _in_d, _in_th = ((_dp_O, d_jet_O, angle_O_deg) if _dp_O <= _dp_F
2774 else (_dp_F, d_jet_F, angle_F_deg))
2775 _out_dp, _out_d, _out_th = ((_dp_F, d_jet_F, angle_F_deg) if _dp_O <= _dp_F
2776 else (_dp_O, d_jet_O, angle_O_deg))
2777 if face_contoured and np.isfinite(_in_th) and np.isfinite(_out_th):
2778 _h_in, _h_out = _face_halves(r_in=0.5 * _in_dp, d_in=_in_d, th_in=_in_th,
2779 r_out=0.5 * _out_dp, d_out=_out_d, th_out=_out_th,
2780 face_contoured=True, exit_land=exit_land)
2781 else:
2782 _h_in, _h_out = _half_major(_in_d, _in_th), _half_major(_out_d, _out_th)
2783 _cc = float(center_clear_dia_m)
2784 if np.isfinite(_cc) and _cc > 0.0:
2785 if 0.5 * _in_dp - _h_in < 0.5 * _cc:
2786 return True
2787 # Runs at a declared clearance of 0 too: a trace across the bore is never buildable.
2788 _wc = float(wall_clearance_m)
2789 _wc = max(0.0, _wc) if np.isfinite(_wc) else 0.0
2790 if 0.5 * _out_dp + _h_out + _wc > 0.5 * D_chamber_inner:
2791 return True
2792 # SPRAY PLACEMENT, hard for the same reason ring fit is. As a soft term it costs
2793 # (miss/target)^2 * W_IMP_GEOM, which on a 0.022 miss at W = 1500 is 1.5 points against an
2794 # objective of ~2690 -- so every seed bought a spray circle outside its own declared band
2795 # for nothing, exactly the way a 2.7 %-over ring once bought orifices outside the bore.
2796 if (np.isfinite(n_elements) and n_elements >= 1.0 and D_chamber_inner > 0.0
2797 and sp_O > 0.0 and sp_F > 0.0 and np.isfinite(spray_radius_frac)
2798 and spray_radius_frac > 0.0):
2799 _dpo = float(n_elements) * sp_O / np.pi
2800 _dpf = float(n_elements) * sp_F / np.pi
2801 _ts = float(np.tan(np.deg2rad(float(angle_O_deg)))
2802 + np.tan(np.deg2rad(float(angle_F_deg))))
2803 if np.isfinite(_ts) and _ts > 1e-9:
2804 _L = 0.5 * abs(_dpo - _dpf) / _ts
2805 _th_in = float(angle_O_deg) if _dpo <= _dpf else float(angle_F_deg)
2806 _r_imp = 0.5 * min(_dpo, _dpf) + _L * float(np.tan(np.deg2rad(_th_in)))
2807 _frac = _r_imp / (0.5 * D_chamber_inner)
2808 _tol = float(spray_radius_tol) if (
2809 np.isfinite(spray_radius_tol) and spray_radius_tol > 0) else 0.08
2810 if abs(_frac - float(spray_radius_frac)) > _tol:
2811 return True
2812 # BACK FACE. Same graded function the worker path scores, used here as a block.
2813 if np.isfinite(n_elements) and _impinging_back_face_terms(
2814 n_elements=n_elements, spacing_O_m=sp_O, spacing_F_m=sp_F,
2815 d_jet_O_m=d_jet_O, d_jet_F_m=d_jet_F, D_chamber_inner_m=D_chamber_inner,
2816 angle_O_deg=angle_O_deg, angle_F_deg=angle_F_deg,
2817 plate_thickness_m=plate_thickness_m, counterbore_dia_m=counterbore_dia_m,
2818 land_ld_O=land_ld_O, land_ld_F=land_ld_F,
2819 min_back_web_m=min_back_web_m,
2820 back_keepout_dia_m=back_keepout_dia_m,
2821 back_keepout_wall_m=back_keepout_wall_m,
2822 face_contoured=face_contoured, exit_land=exit_land, back_channels=back_channels,
2823 passage_ld_O=passage_ld_O, passage_ld_F=passage_ld_F,
2824 channel_width_m=channel_width_m, channel_floor=channel_floor,
2825 r_plate_m=r_plate_m, r_port_m=r_port_m, liner_thickness_m=liner_thickness_m) > 0.0:
2826 return True
2827 if D_throat_check > 0.0 and D_chamber_inner > 0.0:
2828 if D_throat_check > D_chamber_inner * 0.95:
2829 return True
2830 if A_chamber_check > 0.0 and A_throat_check > 0.0:
2831 if (A_throat_check * 1.1) / A_chamber_check > 1.0:
2832 return True
2833 return False
2834
2835
2837 *,
2838 d_jet_O: float,
2839 d_jet_F: float,
2840 sp_O: float,
2841 sp_F: float,
2842 n_el_O: int,
2843 n_el_F: int,
2844 D_chamber_inner: float,
2845) -> float:
2846 """Layout infeasibility for unlike doublets: overlap + pitch-circle vs bore.
2847
2848 Uses the same pitch definition as the impinging injector model (``n·spacing/π``),
2849 not the legacy pintle-era ``(n_O+n_F)·spacing²/face`` proxy.
2850 """
2851 score = 0.0
2852 score += (max(0.0, d_jet_O - sp_O) / (sp_O + 1e-9)) ** 2
2853 score += (max(0.0, d_jet_F - sp_F) / (sp_F + 1e-9)) ** 2
2854 if D_chamber_inner > 0.0:
2855 for n_el, sp in ((n_el_O, sp_O), (n_el_F, sp_F)):
2856 n_i = max(1, int(n_el))
2857 s = float(sp)
2858 if s > 0.0:
2859 d_pitch = float(n_i) * s / np.pi
2860 score += max(0.0, d_pitch / D_chamber_inner - 1.0) ** 2
2861 return float(score)
2862
2863
2865 *,
2866 A_lox_flow: float,
2867 A_fuel_flow: float,
2868 A_throat_check: float,
2869 flow_cap: float = 0.95,
2870) -> float:
2871 """Effective injector area vs throat — impinging uses Cd×A from diagnostics when available."""
2872 if A_throat_check <= 0.0:
2873 return 0.0
2874 score = 0.0
2875 if np.isfinite(A_lox_flow):
2876 score += max(0.0, A_lox_flow / A_throat_check - flow_cap) ** 2
2877 if np.isfinite(A_fuel_flow):
2878 score += max(0.0, A_fuel_flow / A_throat_check - flow_cap) ** 2
2879 return float(score)
2880
2881
2883 initial_performance: Dict[str, Any],
2884 eval_results: Dict[str, Any],
2885) -> None:
2886 """Copy diagnostics and injector_pressure from runner.evaluate for Layer 1 summaries.
2887
2888 Stored-validation path builds a partial ``initial_performance`` dict; without these,
2889 momentum_ratio_R / injector ΔP metrics do not propagate to ``final_performance``.
2890 """
2891 di = eval_results.get("diagnostics")
2892 if isinstance(di, dict):
2893 initial_performance["diagnostics"] = di
2894 ip = eval_results.get("injector_pressure")
2895 if isinstance(ip, dict):
2896 initial_performance["injector_pressure"] = ip
2897
2898
2900 final_performance: Dict[str, Any],
2901 *,
2902 thrust_deadband_rel: float = 0.02,
2903 target_thrust: float,
2904 optimal_of: float,
2905 target_P_exit: float,
2906 layer1_exit_pressure_inside_quad_scale: float = 0.0,
2907 layer1_w_dp: float,
2908 layer1_w_dp_o: float,
2909 layer1_w_dp_f: float,
2910 injector_dp_o_band: Tuple[float, float],
2911 injector_dp_f_band: Tuple[float, float],
2912 injector_dp_o_soft_floor: Optional[float] = None,
2913 layer1_w_dp_o_floor: float = 0.0,
2914 layer1_w_dp_center: float = 0.0,
2915 layer1_w_thrust: float = 1.0e4,
2916 layer1_w_of: float = 1.0e4,
2917 layer1_w_of_low_mr_scale: float = 1.0,
2918 layer1_w_of_high_mr_scale: float = 1.0,
2919) -> Dict[str, float]:
2920 """Recompute primary scalar penalty contributions (matches inner objective, excluding Cf/length/momentum)."""
2921 w_thrust = float(layer1_w_thrust)
2922 w_of = float(layer1_w_of)
2923 w_of_low = max(1.0, float(layer1_w_of_low_mr_scale))
2924 w_of_high = max(1.0, float(layer1_w_of_high_mr_scale))
2925 w_exit = 2.0e2
2926
2927 f_act = float(final_performance.get("F", np.nan))
2928 thrust_penalty_sq_term = 0.0
2929 if target_thrust > 0 and np.isfinite(f_act):
2930 rel_error = abs(f_act - target_thrust) / target_thrust
2931 deadband = float(thrust_deadband_rel)
2932 if rel_error > deadband:
2933 thrust_penalty_sq_term = float((rel_error - deadband) ** 2)
2934 thrust_contrib = w_thrust * thrust_penalty_sq_term
2935
2936 mr_act = float(final_performance.get("MR", np.nan))
2937 if optimal_of > 0 and np.isfinite(mr_act):
2938 of_error = abs(mr_act - optimal_of) / optimal_of
2939 else:
2940 of_error = 1.0
2941 of_sq = float(of_error) ** 2
2942 if optimal_of > 0 and np.isfinite(mr_act):
2943 if mr_act < optimal_of:
2944 of_sq *= w_of_low
2945 elif mr_act > optimal_of:
2946 of_sq *= w_of_high
2947 of_contrib = w_of * of_sq
2948
2949 p_exit_actual = float(final_performance.get("P_exit", np.nan))
2950 exit_pressure_sq_term = float(
2952 p_exit_actual,
2953 float(target_P_exit),
2954 deadband_rel=0.05,
2955 inside_rel_quad_scale=float(layer1_exit_pressure_inside_quad_scale),
2956 )
2957 )
2958 exit_contrib = w_exit * exit_pressure_sq_term
2959
2960 pc = final_performance.get("Pc")
2961 inj_contrib = 0.0
2962 if pc is not None and np.isfinite(float(pc)) and float(pc) > 0:
2963 ro, rf = injector_dp_ratios_from_eval_result(float(pc), final_performance)
2964 inj_contrib = float(
2965 injector_dp_ratio_penalty_weighted(
2966 ro,
2967 rf,
2968 layer1_w_dp,
2969 0.0,
2970 o_band=injector_dp_o_band,
2971 f_band=injector_dp_f_band,
2972 w_dp_o=layer1_w_dp_o,
2973 w_dp_f=layer1_w_dp_f,
2974 o_soft_floor=injector_dp_o_soft_floor,
2975 w_dp_o_floor=layer1_w_dp_o_floor,
2976 w_dp_center=layer1_w_dp_center,
2977 )
2978 )
2979
2980 return {
2981 "thrust_penalty_contribution": float(thrust_contrib),
2982 "of_penalty_contribution": float(of_contrib),
2983 "exit_pressure_penalty_contribution": float(exit_contrib),
2984 "injector_dp_penalty_contribution": float(inj_contrib),
2985 }
2986
2987
2989 config: PintleEngineConfig,
2990 *,
2991 A_throat: float,
2992 Lstar: float,
2993 expansion_ratio: float,
2994 D_chamber_outer: float,
2995 max_nozzle_exit: float,
2996 wall_thickness_m: float,
2997 requirements=None,
2998) -> float:
2999 """Update chamber / nozzle fields from the first four Layer-1 DOFs.
3000
3001 Returns:
3002 Final expansion ratio after optional exit clipping [–]
3003 """
3004 from engine.pipeline.config_schemas import ensure_chamber_geometry
3005
3006 V_chamber = Lstar * A_throat
3007 D_chamber_inner = D_chamber_outer - wall_thickness_m
3008 if D_chamber_inner <= 0:
3009 D_chamber_inner = max(D_chamber_outer * 0.3, 0.01)
3010 A_chamber = np.pi * (D_chamber_inner / 2) ** 2
3011 R_throat = np.sqrt(max(0, A_throat / np.pi))
3012
3013 if A_throat > 0 and A_chamber > 0:
3014 contraction_ratio = A_chamber / A_throat
3015 else:
3016 contraction_ratio = 10.0
3017 if requirements is None:
3018 requirements = getattr(config, "design_requirements", None)
3019 theta_contraction = _layer1_contraction_theta(requirements)
3020 # The cone runs to the 1.5 R_t entrance-arc tangency, not to R_t; see
3021 # _layer1_chamber_lengths, which the objective scores with too.
3022 _lens = _layer1_chamber_lengths(A_throat, Lstar, D_chamber_inner, theta_contraction)
3023 L_chamber = _lens["length"]
3024 L_cylindrical = _lens["length_cylindrical"]
3025 L_contraction = _lens["length_contraction"]
3026
3027 if config.chamber_geometry is None:
3028 cg = ensure_chamber_geometry(config)
3029 else:
3030 cg = config.chamber_geometry
3031
3032 A_exit = A_throat * expansion_ratio
3033 if A_exit < 0:
3034 A_exit = A_throat * 10.0
3035 D_exit = np.sqrt(max(0, 4 * A_exit / np.pi))
3036 exp_ratio_out = expansion_ratio
3037 if D_exit > max_nozzle_exit:
3038 D_exit = max_nozzle_exit
3039 A_exit = np.pi * (D_exit / 2) ** 2
3040 exp_ratio_out = A_exit / A_throat if A_throat > 0 else 10.0
3041
3042 cg.A_throat = A_throat
3043 cg.volume = V_chamber
3044 cg.Lstar = Lstar
3045 cg.length = L_chamber
3046 # Write the sub-lengths too. Only cg.length was written, so length_cylindrical and
3047 # length_contraction kept whatever the template file happened to carry -- on the shipped
3048 # ethalox config, 121.0 mm and 45.1 mm against a real 191.1 / 39.1. That is why the two
3049 # L/D code paths disagreed (L_cylindrical + L_contraction = 166.1 mm here vs cg.length =
3050 # 230.2 mm at the config-path gate), and why the contraction half-angle appeared to have
3051 # no effect: the numbers it changes were computed and then thrown away.
3052 cg.length_cylindrical = float(L_cylindrical)
3053 cg.length_contraction = float(L_contraction)
3054 if "length_face_to_throat" in getattr(type(cg), "model_fields", {}):
3055 cg.length_face_to_throat = float(_lens["face_to_throat"])
3056 cg.chamber_diameter = D_chamber_inner
3057 cg.A_exit = A_exit
3058 cg.exit_diameter = D_exit
3059 cg.expansion_ratio = exp_ratio_out
3060
3061 if config.chamber is not None:
3062 config.chamber.A_throat = A_throat
3063 config.chamber.volume = V_chamber
3064 config.chamber.Lstar = Lstar
3065 config.chamber.length = L_chamber
3066 setattr(config.chamber, 'chamber_inner_diameter', D_chamber_inner)
3067 if hasattr(config.chamber, 'contraction_ratio'):
3068 config.chamber.contraction_ratio = contraction_ratio
3069 if hasattr(config.chamber, 'A_chamber'):
3070 config.chamber.A_chamber = A_chamber
3071 if config.nozzle is not None:
3072 config.nozzle.A_throat = A_throat
3073 config.nozzle.A_exit = A_exit
3074 config.nozzle.expansion_ratio = exp_ratio_out
3075 if hasattr(config.nozzle, 'exit_diameter'):
3076 config.nozzle.exit_diameter = D_exit
3077
3078 if hasattr(config.combustion, 'cea'):
3079 config.combustion.cea.expansion_ratio = exp_ratio_out
3080
3081 return float(exp_ratio_out)
3082
3083
3084# ============================================================================
3085# Derived design variables (solved, not searched)
3086# ============================================================================
3087# Two of the Layer-1 DOFs are not free choices -- they are determined by the
3088# requirements the user already stated, so searching them wastes dimensionality
3089# and lets CMA-ES sit on a wrong value that a penalty term then has to fight:
3090#
3091# expansion_ratio -- fixed by "expand to ambient". Closed-form isentropic
3092# inversion of Pc/Pe; no iteration, exact.
3093# A_throat -- fixed by the thrust requirement. Not closed form (the
3094# chamber solve gives Pc*At = mdot*c*, so thrust is set by
3095# mdot, not directly by At), but F is smooth and monotonic
3096# in At -- bigger At lowers Pc, which raises injector dP,
3097# which raises mdot -- with dlnF/dlnAt ~ 0.3. A log-log
3098# secant lands it in ~3 evaluations.
3099#
3100# Both are opt-out (``layer1_derive_expansion_ratio`` /
3101# ``layer1_derive_throat_from_thrust``) because this is a design suite: a user
3102# sizing a fixed-throat hardware set, or deliberately running over/under
3103# expanded, must still be able to search them.
3104
3105_DERIVE_AT_SLOPE_DEFAULT = 0.35 # dlnF/dlnAt, measured on ethalox impinging
3106_DERIVE_AT_SLOPE_MIN = 0.12
3107_DERIVE_AT_SLOPE_MAX = 1.20
3108
3109
3110def _layer1_stamp_design_point(config, performance, logger=None) -> None:
3111 """Write the ACHIEVED operating point into ``chamber_geometry.design_*``.
3112
3113 ``design_MR`` / ``design_pressure`` / ``design_thrust`` are supposed to describe the design
3114 the config represents. Nothing ever wrote them: `config_schemas` builds them with
3115 ``getattr(chamber, 'design_MR', 2.55)``, so an optimised config carried whatever the template
3116 started with. Observed on a real emitted design -- MR 2.55 / 350 psi / 7000 N stamped on an
3117 engine actually solved at O/F ~1.68 / 420 psi / 7200 N.
3118
3119 That is not cosmetic: ``backend/routers/geometry.py`` reads ``design_MR`` and feeds it
3120 straight into ``solve_chamber_geometry_with_cea``, so the Chamber Geometry tab drew the
3121 contour at the stale mixture ratio -- and 2.55 sits OUTSIDE the shipped CEA cache range
3122 (``MR_range: [1.0, 2.5]``), i.e. extrapolating past the table edge.
3123
3124 Warns rather than raises when the achieved MR falls outside the cache range: the design is
3125 still real, but anything reading design_MR against that cache is extrapolating.
3126 """
3127 cg = getattr(config, "chamber_geometry", None)
3128 if cg is None or not isinstance(performance, dict):
3129 return
3130
3131 def _finite_pos(value):
3132 try:
3133 v = float(value)
3134 except (TypeError, ValueError):
3135 return None
3136 return v if (np.isfinite(v) and v > 0) else None
3137
3138 mr = _finite_pos(performance.get("MR"))
3139 pc = _finite_pos(performance.get("Pc"))
3140 thrust = _finite_pos(performance.get("F"))
3141
3142 if mr is not None:
3143 cg.design_MR = mr
3144 if pc is not None:
3145 cg.design_pressure = pc
3146 if thrust is not None:
3147 cg.design_thrust = thrust
3148
3149 # Keep the legacy mirror in step -- some readers still fall back to config.chamber.
3150 legacy = getattr(config, "chamber", None)
3151 if legacy is not None:
3152 if mr is not None and hasattr(legacy, "design_MR"):
3153 legacy.design_MR = mr
3154 if pc is not None and hasattr(legacy, "design_pressure"):
3155 legacy.design_pressure = pc
3156 if thrust is not None and hasattr(legacy, "design_thrust"):
3157 legacy.design_thrust = thrust
3158
3159 if mr is None or logger is None:
3160 return
3161 try:
3162 mr_range = config.combustion.cea.MR_range
3163 lo, hi = float(mr_range[0]), float(mr_range[1])
3164 except (AttributeError, TypeError, ValueError, IndexError):
3165 return
3166 if not (lo <= mr <= hi):
3167 logger.warning(
3168 "design_MR %.4f is outside the CEA cache MR_range [%.2f, %.2f]; anything reading "
3169 "it against that cache is extrapolating past the table edge.", mr, lo, hi
3170 )
3171
3172
3173
3174def _layer1_warn_stale_pressure_curves(config, logger=None, tol_psi: float = 1.0) -> None:
3175 """Flag Layer-2 pressure curves that no longer match the tank pressures Layer 1 just set.
3176
3177 The initial tank pressure exists twice, owned by different layers and never reconciled:
3178 Layer 1 writes ``lox_tank/fuel_tank.initial_pressure_psi``; Layer 2 writes
3179 ``pressure_curves.initial_lox/fuel_pressure_pa``. Re-running Layer 1 moves the tanks and
3180 silently leaves the curves describing the previous design -- observed 11.3 psi out on the
3181 LOX side and 24.9 psi on the fuel side of a real emitted config.
3182
3183 This matters beyond EngineDesign. Tank pressure is the UPSTREAM BOUNDARY CONDITION for the
3184 feed-system twin (docs/adr/0001), which EngineDesign's optimizer will import directly for
3185 Layer X. Two disagreeing values for one boundary condition is exactly the kind of thing that
3186 silently poisons a twin, so say so loudly rather than letting it cross the boundary.
3187
3188 Detection only -- which layer should win is a design call, not something to guess here.
3189 """
3190 if logger is None:
3191 return
3192 curves = getattr(config, "pressure_curves", None)
3193 if curves is None:
3194 return
3195 PSI = 6894.76
3196 for tank_attr, curve_attr, label in (
3197 ("lox_tank", "initial_lox_pressure_pa", "LOX"),
3198 ("fuel_tank", "initial_fuel_pressure_pa", "fuel"),
3199 ):
3200 tank = getattr(config, tank_attr, None)
3201 if tank is None:
3202 continue
3203 try:
3204 tank_psi = float(getattr(tank, "initial_pressure_psi", float("nan")))
3205 curve_psi = float(getattr(curves, curve_attr, float("nan"))) / PSI
3206 except (TypeError, ValueError):
3207 continue
3208 if not (np.isfinite(tank_psi) and np.isfinite(curve_psi)):
3209 continue
3210 if abs(tank_psi - curve_psi) > tol_psi:
3211 logger.warning(
3212 "%s tank pressure disagrees with the Layer-2 pressure curve: tank %.1f psi vs "
3213 "curve start %.1f psi (%.1f psi apart). The curves predate this Layer 1 run; "
3214 "re-run Layer 2 before trusting them or anything downstream of them.",
3215 label, tank_psi, curve_psi, abs(tank_psi - curve_psi),
3216 )
3217
3218
3219def _layer1_eps_for_exit_pressure(Pc_Pa: float, gamma: float, Pe_Pa: float):
3220 """Expansion ratio that puts the exit plane exactly at ``Pe_Pa``.
3221
3222 Standard isentropic area-ratio relation -- solve the exit Mach number from
3223 the pressure ratio, then evaluate A/A*. Returns ``None`` when the inputs
3224 cannot describe a supersonic nozzle.
3225 """
3226 try:
3227 Pc = float(Pc_Pa)
3228 Pe = float(Pe_Pa)
3229 g = float(gamma)
3230 except (TypeError, ValueError):
3231 return None
3232 if not (np.isfinite(Pc) and np.isfinite(Pe) and np.isfinite(g)):
3233 return None
3234 if Pc <= 0.0 or Pe <= 0.0 or Pc <= Pe or not (1.0 < g < 2.0):
3235 return None
3236
3237 M_sq = (2.0 / (g - 1.0)) * ((Pc / Pe) ** ((g - 1.0) / g) - 1.0)
3238 if not np.isfinite(M_sq) or M_sq <= 1.0:
3239 return None
3240 M = float(np.sqrt(M_sq))
3241 eps = (1.0 / M) * (
3242 (2.0 / (g + 1.0)) * (1.0 + 0.5 * (g - 1.0) * M_sq)
3243 ) ** ((g + 1.0) / (2.0 * (g - 1.0)))
3244 if not np.isfinite(eps) or eps <= 1.0:
3245 return None
3246 return float(eps)
3247
3248
3249def _layer1_matched_eps(result: Any, Pa: float, cea_cache: Any = None,
3250 eps_guess: Optional[float] = None) -> Optional[float]:
3251 """Expansion ratio that expands to ``Pa`` at this result's Pc and O/F.
3252
3253 The CEA table's own exit pressure (shifting equilibrium, what the thrust model
3254 integrates) when a 3-D table is at hand; the chamber-gamma isentrope otherwise, which
3255 over-expands (Pe/Pa 0.93 at the 6.5 kN point).
3256 """
3257 if not isinstance(result, dict):
3258 return None
3260 cea_cache, result.get("MR"), result.get("Pc"), Pa, eps_guess)
3261 if eps is not None:
3262 return eps
3263 return _layer1_eps_for_exit_pressure(result.get("Pc"), result.get("gamma"), Pa)
3264
3265
3266def _layer1_derive_matched_tank_pressure(x: np.ndarray, constants: dict) -> None:
3267 """Slave the fuel tank pressure to the LOX tank pressure when they must match.
3268
3269 "Match propellant tank pressures" is a statement that the two are ONE quantity, not two
3270 that ought to end up nearby. Searching them separately and then penalising the gap makes
3271 the optimizer pay, forever, for a degree of freedom it was told not to use: at
3272 W_TANK_EQUAL = 300 it simply accepted a 13 psi gap because closing it cost more Isp than
3273 the penalty; at 30000 it got to 10 psi and carried a large residual for the remainder.
3274 Collapsing them removes the gap by construction -- the penalty goes to zero, the residual
3275 stops reporting a violation that is not a design flaw, and CMA gets one dimension back.
3276
3277 Applies to both injector families (the pressure indices come from ``constants``).
3278 Mutates ``x`` in place so every downstream reader sees the same pair.
3279 """
3280 if not bool(constants.get("layer1_lock_tank_pressures", False)):
3281 return
3282 io_ = int(constants.get("idx_P_O", 8))
3283 if_ = int(constants.get("idx_P_F", 9))
3284 if not (0 <= io_ < len(x) and 0 <= if_ < len(x)):
3285 return
3286 try:
3287 p_o = float(x[io_])
3288 except (TypeError, ValueError):
3289 return
3290 if not (np.isfinite(p_o) and p_o > 0):
3291 return
3292 # The fuel tank may carry a different cap; clamp rather than emit an out-of-range value.
3293 lo = float(constants.get("lock_P_F_min", 0.0))
3294 hi = float(constants.get("lock_P_F_max", np.inf))
3295 x[if_] = float(np.clip(p_o, lo, hi)) if hi > lo else p_o
3296
3297
3298def _layer1_derive_fuel_spacing(x: np.ndarray, constants: dict) -> None:
3299 """Solve the fuel-ring hole pitch so the jets meet at exactly the requested standoff.
3300
3301 The user states ONE number -- how many jet diameters off the face the doublet should
3302 collide (``layer1_impingement_Ld_target``) -- and that closes the geometry algebraically.
3303 There is nothing to search and nothing to negotiate::
3304
3305 L_imp = dr / (tan(theta_O) + tan(theta_F)) want L_imp = k * d_avg
3306 dr = n * |s_F - s_O| / (2*pi) (dr = half the pitch-circle gap)
3307
3308 => |s_F - s_O| = 2*pi * k * d_avg * (tan(theta_O) + tan(theta_F)) / n
3309
3310 One equation, one unknown. ``s_O`` (the LOX / inner ring) stays the free variable because
3311 it is the packing-constrained one -- the inner ring has the least circumference and LOX
3312 carries the higher volumetric flow -- and ``s_F`` follows from it. That removes a whole
3313 CMA dimension AND makes L/d exact by construction instead of a penalty the search haggles
3314 with.
3315
3316 Mutates ``x[10]`` in place so every downstream reader sees the solved value.
3317
3318 If the solved fuel ring will not fit the bore the value clamps to its bound; L/d then
3319 misses and the ordinary ring-geometry penalty reports it, rather than the derivation
3320 quietly producing an unbuildable ring.
3321 """
3322 if not bool(constants.get("layer1_derive_impingement_spacing", True)):
3323 return
3324 if constants.get("injector_type") != "impinging" or len(x) < 11:
3325 return
3326 try:
3327 # Snap the doublet count here and write it back. The consumers disagree about how to
3328 # integerise it -- the worker path truncates (int(x[4])), apply_x_to_config rounds --
3329 # and the derivation divides by n, so a half-count of disagreement puts the solved
3330 # standoff off target. Rounding once, in place, makes every reader use the same n.
3331 n = float(max(1, int(round(float(x[4])))))
3332 x[4] = n
3333 d_avg = 0.5 * (float(x[5]) + float(x[8]))
3334 tan_sum = float(np.tan(np.deg2rad(float(x[6]))) + np.tan(np.deg2rad(float(x[9]))))
3335 s_O = float(x[7])
3336 except (TypeError, ValueError):
3337 return
3338 k = float(constants.get("layer1_impingement_Ld_target", IMPINGEMENT_LD_TARGET_DEFAULT))
3339 if not (np.isfinite(d_avg) and d_avg > 0 and np.isfinite(tan_sum) and tan_sum > 1e-9
3340 and np.isfinite(s_O) and s_O > 0 and k > 0):
3341 return
3342
3343 delta_s = 2.0 * np.pi * k * d_avg * tan_sum / n
3344 if not np.isfinite(delta_s) or delta_s <= 0:
3345 return
3346 # Fuel ring outboard => larger pitch circle => larger hole pitch for the same count.
3347 outboard = bool(constants.get("layer1_ring_order_fuel_outboard", True))
3348 s_F = s_O + delta_s if outboard else max(1e-6, s_O - delta_s)
3349
3350 lo = float(constants.get("derive_spacing_F_min", 0.0))
3351 hi = float(constants.get("derive_spacing_F_max", np.inf))
3352 if hi > lo:
3353 s_F = float(np.clip(s_F, lo, hi))
3354 x[10] = float(s_F)
3355
3356
3358 x_best: np.ndarray,
3359 config_base: PintleEngineConfig,
3360 constants: dict,
3361 logger=None,
3362 diag: Optional[dict] = None,
3363) -> Optional[Tuple[PintleEngineConfig, np.ndarray, dict]]:
3364 """Rebuild the emitted design from the winning vector through the WORKER pipeline.
3365
3366 Finalisation used to stitch the design together from several sources -- geometry from
3367 ``best_config`` / ``last_eval_config``, tank pressures from whichever of
3368 ``best_config_x`` / ``last_eval_config_x`` / ``best_x`` was populated. Those need not
3369 describe the same candidate, and with the tank lock on they did not: the throat was
3370 solved for one pair of pressures and shipped with another, so the reported engine
3371 overshot thrust by 4-6% and, re-evaluated as written, did not solve at all
3372 ("Supply < Demand at all Pc").
3373
3374 This does exactly what ``_eval_candidate`` does, once, on the winner: apply x (which
3375 locks the tanks and solves the fuel ring pitch), evaluate, solve the throat and expansion
3376 ratio against THOSE pressures to convergence, and hand back a config whose every field
3377 came from one vector. Returns None if the winner cannot be evaluated, in which case the
3378 caller falls back to the old snapshot path.
3379 """
3380 try:
3381 x = np.asarray(x_best, dtype=float).copy()
3382 config = copy.deepcopy(config_base)
3383 _apply_x_to_worker_config_inplace(x, config, constants)
3384 io_ = int(constants.get("idx_P_O", 8))
3385 if_ = int(constants.get("idx_P_F", 9))
3386 P_O_pa = float(x[io_]) * 6894.757293168
3387 P_F_pa = float(x[if_]) * 6894.757293168
3388 # Evaluate in the thermal state the WORKERS scored and the EMITTED config carries --
3389 # the user's own flags, untouched. Solving thermal-OFF and shipping thermal-ON is not
3390 # a subtlety: the liner is 0.5 in per side, so disabling it grows the bore by a full
3391 # inch (OD-1.5in vs OD-0.5in). A throat solved for one chamber cannot bracket a Pc in
3392 # the other, and the emitted design then failed to evaluate at all ("Supply < Demand
3393 # at all Pc") on a run that reported F = 7200 N.
3394 _runner_cfg = copy.deepcopy(config)
3395 # A FRESH runner on every evaluation. The solve mutates the config in place; a runner
3396 # built once could carry anything derived at construction, in which case the solve
3397 # "converges" against a stale engine and validation -- which always builds fresh --
3398 # sees a different one. On a knife-edge design (thrust barely reachable under the
3399 # momentum ban) that was enough to flip the same seed between exact and a 3-14% tank
3400 # boost from run to run. Finalisation runs once, so the rebuild cost is irrelevant.
3401 _pamb = float(constants.get("P_ambient", 101325.0))
3402
3403 def _ev():
3404 try:
3405 return PintleEngineRunner(copy.deepcopy(_runner_cfg)).evaluate(
3406 float(x[io_]) * 6894.757293168, float(x[if_]) * 6894.757293168,
3407 P_ambient=_pamb, silent=True)
3408 except Exception:
3409 return None
3410
3411 class _CfgProxy: # what _layer1_solve_derived_geometry writes geometry onto
3412 config = _runner_cfg
3413 runner = _CfgProxy()
3414
3415 if diag is not None:
3416 diag["x_in_tanks"] = [float(x[io_]), float(x[if_])]; diag["x_in_At_mm2"] = float(x[0]) * 1e6
3417 first = None
3418 _at_hi = float(constants.get("derive_At_max", np.inf))
3419 _prev = 1.0
3420 for _grow in (1.0, 1.3, 1.7, 2.2):
3421 if _grow != 1.0:
3422 # Same recovery the worker uses: "Supply < Demand" means the throat is too
3423 # small to bracket a Pc for this injector -- grow it and let the solve refine.
3424 x[0] = float(min(x[0] * (_grow / _prev), _at_hi))
3426 _runner_cfg, A_throat=float(x[0]), Lstar=float(x[1]), expansion_ratio=float(x[2]),
3427 D_chamber_outer=_quantize_chamber_od_m(
3428 float(x[3]), constants.get("layer1_chamber_od_increment_in", 0.0),
3429 wall_m=float(constants.get("TOTAL_WALL_THICKNESS_M", TOTAL_WALL_THICKNESS_M)),
3430 snap_target=constants.get("layer1_chamber_od_snap_target", "outer")),
3431 max_nozzle_exit=float(constants.get("max_nozzle_exit", 1.0)),
3432 wall_thickness_m=float(constants.get("TOTAL_WALL_THICKNESS_M", TOTAL_WALL_THICKNESS_M)))
3433 _prev = _grow
3434 first = _ev()
3435 if isinstance(first, dict):
3436 if _grow != 1.0 and diag is not None: diag["first_eval_grew_At"] = _grow
3437 break
3438 if not bool(constants.get("layer1_derive_throat_from_thrust", True)):
3439 break
3440 if not isinstance(first, dict):
3441 if diag is not None: diag["fail"] = "first_eval"
3442 if logger is not None:
3443 logger.warning("Final rebuild: first evaluate() of the winner failed at tanks %.1f/%.1f psi",
3444 float(x[io_]), float(x[if_]))
3445 return None
3446 if diag is not None:
3447 diag["first_F"] = float(first.get("F", float("nan"))); diag["first_Pc_psi"] = float(first.get("Pc", 0)) / 6894.757293168
3448 if logger is not None:
3449 logger.info("Final rebuild: winner evaluates F=%.1f Pc=%.1f before solve",
3450 float(first.get("F", float("nan"))), float(first.get("Pc", float("nan"))) / 6894.757293168)
3451 c = dict(constants)
3452 c["layer1_derive_max_iters"] = max(14, int(c.get("layer1_derive_max_iters", 2)))
3453 c["layer1_derive_thrust_tol_rel"] = 1.0e-4
3454 # Solve on the runner's config (which evaluate() reads), then mirror onto ``config``.
3455 x_first = x.copy()
3456 try:
3457 from engine.pipeline.cea_cache import CEACache
3458 _rb_cea = CEACache(_runner_cfg.combustion.cea)
3459 except Exception:
3460 _rb_cea = None
3461 result = _layer1_solve_derived_geometry(x, runner.config, c, _ev, first, cea_cache=_rb_cea)
3462 if not isinstance(result, dict):
3463 return None
3464 # eps/thrust POLISH. The solve loop updates eps and A_throat from the SAME result and
3465 # then evaluates, so eps is always exact for the PREVIOUS Pc -- it ends one step
3466 # behind (measured: eps 5.5558 shipped where 5.5848 was exact at the final Pc, so the
3467 # exit plane sat at 13.76 psi instead of 13.64). Crucially Pc does NOT depend on eps,
3468 # so the system is triangular: set eps exactly from the current Pc, re-evaluate to get
3469 # the true thrust, then take one secant step on A_throat -- and repeat. Cheap here
3470 # because finalisation runs once; the per-candidate solve keeps its 2-iteration form.
3471 _Ft_pol = float(constants.get("target_thrust", 0.0) or 0.0)
3472 _tol_pol = 2.0e-4
3473
3474 def _write_geom():
3475 """Push x's geometry onto the config the evaluator reads."""
3477 _runner_cfg, A_throat=float(x[0]), Lstar=float(x[1]), expansion_ratio=float(x[2]),
3478 D_chamber_outer=_quantize_chamber_od_m(
3479 float(x[3]), constants.get("layer1_chamber_od_increment_in", 0.0),
3480 wall_m=float(constants.get("TOTAL_WALL_THICKNESS_M", TOTAL_WALL_THICKNESS_M)),
3481 snap_target=constants.get("layer1_chamber_od_snap_target", "outer")),
3482 max_nozzle_exit=float(constants.get("max_nozzle_exit", 1.0)),
3483 wall_thickness_m=float(constants.get("TOTAL_WALL_THICKNESS_M", TOTAL_WALL_THICKNESS_M)))
3484
3485 # Everything the polish may touch, so a failed step can be undone COMPLETELY -- x and
3486 # the config together. Restoring x alone left the config on the geometry that had just
3487 # failed, and the consistency check below then rejected the whole rebuild
3488 # ("Supply < Demand"), throwing away a polish that had reached 7215.4 N.
3489 _pol_x0 = x.copy()
3490 _pol_result0 = result
3491 for _ in range(6):
3492 _eps_now = _layer1_matched_eps(
3493 result, float(c.get("P_ambient", 101325.0)), _rb_cea, float(x[2]))
3494 _moved = False
3495 if _eps_now is not None:
3496 _eps_now = float(np.clip(_eps_now, float(c.get("derive_eps_min", 1.5)),
3497 float(c.get("derive_eps_max", 100.0))))
3498 if abs(_eps_now - float(x[2])) > 1e-6 * max(1.0, abs(float(x[2]))):
3499 x[2] = _eps_now; _moved = True
3500 if _moved:
3501 _eps_prev = float(_pol_x0[2]) if False else None
3502 _write_geom()
3503 _r2 = _ev()
3504 if isinstance(_r2, dict):
3505 result = _r2
3506 else:
3507 x[:] = _pol_x0; result = _pol_result0; _write_geom()
3508 break
3509 _Fn = float(result.get("F", float("nan")))
3510 if not (_Ft_pol > 0 and np.isfinite(_Fn) and _Fn > 0):
3511 break
3512 if abs(_Fn / _Ft_pol - 1.0) <= _tol_pol and not _moved:
3513 break
3514 _at_new = float(np.clip(
3515 float(x[0]) * (_Ft_pol / _Fn) ** (1.0 / _DERIVE_AT_SLOPE_DEFAULT),
3516 float(c.get("derive_At_min", 1e-6)), float(c.get("derive_At_max", 1.0))))
3517 if abs(_at_new - float(x[0])) <= 1e-6 * float(x[0]):
3518 break
3519 _x_prev = x.copy(); _res_prev = result
3520 x[0] = _at_new
3521 _write_geom()
3522 _r3 = _ev()
3523 if isinstance(_r3, dict):
3524 result = _r3
3525 _pol_x0 = x.copy(); _pol_result0 = result # last known-good polished state
3526 else:
3527 x[:] = _x_prev; result = _res_prev; _write_geom()
3528 break
3529 if diag is not None:
3530 diag["polish_eps"] = float(x[2]); diag["polish_F"] = float(result.get("F", float("nan")))
3531 # Never ship a thrust WORSE than the pool's winner we started from. The solve is a
3532 # local secant on a possibly knife-edge design; when it misbehaves (measured: +38%
3533 # runaway, +2% ratchet) the honest output is the winner itself.
3534 _Ft = float(constants.get("target_thrust", 0.0) or 0.0)
3535 if _Ft > 0:
3536 _e_first = abs(float(first.get("F", np.inf)) - _Ft)
3537 _e_solved = abs(float(result.get("F", np.inf)) - _Ft)
3538 if not (_e_solved <= _e_first + 1e-9):
3539 x[:] = x_first
3541 _runner_cfg, A_throat=float(x[0]), Lstar=float(x[1]), expansion_ratio=float(x[2]),
3542 D_chamber_outer=_quantize_chamber_od_m(
3543 float(x[3]), constants.get("layer1_chamber_od_increment_in", 0.0),
3544 wall_m=float(constants.get("TOTAL_WALL_THICKNESS_M", TOTAL_WALL_THICKNESS_M)),
3545 snap_target=constants.get("layer1_chamber_od_snap_target", "outer")),
3546 max_nozzle_exit=float(constants.get("max_nozzle_exit", 1.0)),
3547 wall_thickness_m=float(constants.get("TOTAL_WALL_THICKNESS_M", TOTAL_WALL_THICKNESS_M)))
3548 result = first
3549 if diag is not None: diag["reverted_to_first"] = True
3550 if logger is not None:
3551 logger.warning("Final rebuild: solve made thrust worse (%.1f -> %.1f N vs target %.0f); "
3552 "keeping the pool winner", float(first.get("F")), _e_solved + _Ft, _Ft)
3553 # Return the config the solve actually converged on. Re-deriving ``config`` from x
3554 # did not reproduce it (measured: solve F=7225, returned config "Supply < Demand").
3555 # No thermal-flag surgery: the solve ran in the user's own state, so the returned
3556 # config is already in it.
3557 config = copy.deepcopy(_runner_cfg)
3558 if diag is not None:
3559 diag["x_solved"] = [float(v) for v in x]
3560 # Independent check: the returned config, exactly as it will be handed out.
3561 _chk = copy.deepcopy(config)
3562 try:
3563 _fresh = PintleEngineRunner(_chk).evaluate(
3564 float(x[io_]) * 6894.757293168, float(x[if_]) * 6894.757293168,
3565 P_ambient=_pamb, silent=True)
3566 _dF = abs(float(_fresh["F"]) - float(result["F"])) / max(1.0, float(result["F"]))
3567 if diag is not None:
3568 if result.get("_derive_tank_fallback") is not None:
3569 diag["tank_fallback"] = result.get("_derive_tank_fallback")
3570 diag["iters"] = result.get("_derive_iters")
3571 diag["restored_after_failed_step"] = bool(result.get("_derive_restored_after_failed_step", False))
3572 diag.update({"solved_F": float(result["F"]), "fresh_F": float(_fresh["F"]),
3573 "solved_Pc_psi": float(result["Pc"]) / 6894.757293168,
3574 "solved_At_mm2": float(x[0]) * 1e6, "solved_tanks": [float(x[io_]), float(x[if_])],
3575 "dF_rel": float(_dF)})
3576 if logger is not None:
3577 logger.info("Final rebuild: solved F=%.1f vs fresh-runner F=%.1f (%.3f%%)",
3578 float(result["F"]), float(_fresh["F"]), 100 * _dF)
3579 if _dF > 1e-3 and logger is not None:
3580 logger.warning("Final rebuild: solved/fresh thrust disagree by %.2f%% -- returned "
3581 "config is not what the solve saw", 100 * _dF)
3582 except Exception as _e:
3583 if diag is not None: diag["fail"] = "fresh_eval: " + str(_e)[:80]
3584 if logger is not None:
3585 logger.warning("Final rebuild: returned config FAILS a fresh evaluate: %s", str(_e)[:90])
3586 return None
3587 if logger is not None:
3588 logger.info(
3589 "Final design rebuilt from the winning vector: F=%.1f N, Pc=%.1f psi, "
3590 "A_throat=%.1f mm2, tanks %.1f/%.1f psi",
3591 float(result.get("F", float("nan"))),
3592 float(result.get("Pc", float("nan"))) / 6894.757293168,
3593 float(x[0]) * 1e6, float(x[io_]), float(x[if_]),
3594 )
3595 return config, x, result
3596 except Exception as exc:
3597 if logger is not None:
3598 logger.warning("Final rebuild from winning vector failed: %s", exc)
3599 return None
3600
3601
3603 config: PintleEngineConfig,
3604 P_O_pa: float,
3605 P_F_pa: float,
3606 constants: dict,
3607 logger=None,
3608) -> None:
3609 """Re-solve the derived DOFs against the tank pressures the final config actually carries.
3610
3611 Finalisation assembles the emitted design from more than one source: the geometry comes
3612 from ``best_config``/``last_eval_config`` while the tank pressures are taken from a
3613 separate ``x`` (see the fallback chain below it). Those need not come from the same
3614 candidate, and when they do not, the throat is solved for one pair of tank pressures and
3615 then shipped with another -- the reported engine overshot thrust by 4-6% and, in the worst
3616 case, did not solve at all ("Supply < Demand at all Pc") when re-evaluated as written.
3617
3618 Solving once more here closes the loop: whatever pressures ended up on the config, the
3619 throat and expansion ratio are the ones that hit the target for THOSE pressures.
3620 """
3621 if not (bool(constants.get("layer1_derive_throat_from_thrust", True))
3622 or bool(constants.get("layer1_derive_expansion_ratio", True))):
3623 return
3624 try:
3625 from engine.pipeline.config_schemas import ensure_chamber_geometry
3626 cg = ensure_chamber_geometry(config)
3627 At = float(cg.A_throat or 0.0)
3628 Lstar = float(cg.Lstar or 0.0)
3629 eps = float(cg.expansion_ratio or 0.0)
3630 wall = float(constants.get("TOTAL_WALL_THICKNESS_M", TOTAL_WALL_THICKNESS_M))
3631 D_outer = float(cg.chamber_diameter or 0.0) + wall
3632 if not (At > 0 and Lstar > 0 and eps > 0 and D_outer > 0):
3633 return
3634 runner = PintleEngineRunner(copy.deepcopy(config))
3635
3636 def _ev():
3637 try:
3638 return runner.evaluate(P_O_pa, P_F_pa,
3639 P_ambient=float(constants.get("P_ambient", 101325.0)),
3640 silent=True)
3641 except Exception:
3642 return None
3643
3644 first = _ev()
3645 if not isinstance(first, dict):
3646 return
3647 x = np.array([At, Lstar, eps, D_outer], dtype=float)
3648 c = dict(constants)
3649 # Converge properly here: this runs once, not per candidate.
3650 c["layer1_derive_max_iters"] = max(6, int(c.get("layer1_derive_max_iters", 2)))
3651 c["layer1_derive_thrust_tol_rel"] = 1.0e-4
3652 _layer1_solve_derived_geometry(x, runner.config, c, _ev, first,
3653 cea_cache=getattr(runner, "cea_cache", None))
3654 if abs(float(x[0]) - At) <= 1e-12 and abs(float(x[2]) - eps) <= 1e-12:
3655 return
3657 config,
3658 A_throat=float(x[0]),
3659 Lstar=float(x[1]),
3660 expansion_ratio=float(x[2]),
3661 D_chamber_outer=_quantize_chamber_od_m(
3662 float(x[3]),
3663 constants.get("layer1_chamber_od_increment_in", 0.0),
3664 wall_m=wall,
3665 snap_target=constants.get("layer1_chamber_od_snap_target", "outer"),
3666 ),
3667 max_nozzle_exit=float(constants.get("max_nozzle_exit", 1.0)),
3668 wall_thickness_m=wall,
3669 )
3670 if logger is not None:
3671 logger.info(
3672 "Final design re-solved against its own tank pressures: "
3673 "A_throat %.1f -> %.1f mm2, eps %.3f -> %.3f",
3674 At * 1e6, float(x[0]) * 1e6, eps, float(x[2]),
3675 )
3676 except Exception as exc: # never let finalisation die here
3677 if logger is not None:
3678 logger.warning("Final derived-geometry re-solve skipped: %s", exc)
3679
3680
3682 x: np.ndarray,
3683 config: PintleEngineConfig,
3684 constants: dict,
3685 evaluate: Callable[[], Optional[dict]],
3686 result: Optional[dict],
3687 cea_cache: Any = None,
3688) -> Optional[dict]:
3689 """Replace searched ``A_throat`` / ``expansion_ratio`` with solved values.
3690
3691 Mutates ``x[0]`` / ``x[2]`` and ``config`` in place so that the objective
3692 terms, the cached design and the finalised config all see the same solved
3693 geometry that was actually evaluated. Returns the evaluation result for the
3694 solved geometry (or the input ``result`` when nothing was derived).
3695
3696 Falling back is always safe: if the requirement is unreachable inside the
3697 configured bounds the value clamps to the bound and the ordinary thrust /
3698 exit-pressure penalty carries the remaining residual, which the
3699 infeasibility gradient can still descend.
3700 """
3701 derive_eps = bool(constants.get("layer1_derive_expansion_ratio", True))
3702 derive_at = bool(constants.get("layer1_derive_throat_from_thrust", True))
3703 # Fuel jet diameter from the O/F target. Only meaningful when the tanks are locked: then
3704 # dP_O ~= dP_F and O/F = (Cd_O*A_O*sqrt(rho_O)) / (Cd_F*A_F*sqrt(rho_F)) is a pure area
3705 # ratio, so mdot_F ~ d_F^2 at fixed dP and one secant step in d_F lands MR on target.
3706 # Unlocked, differential tank pressure also sets O/F and the derivation would fight the
3707 # optimizer for it.
3708 derive_df = (bool(constants.get("layer1_derive_fuel_jet_from_of", False))
3709 and constants.get("injector_type") == "impinging" and len(x) > 8)
3710 # Locked tank pressure from the injector dP/Pc target: P_tank = Pc*(1+r) + dP_feed.
3711 # With the tanks one variable, this closes the last pressure DOF and makes the dP/Pc
3712 # band exact by construction -- which is what lets O/F reach its target: at the correct
3713 # area ratio the throat grows, Pc falls, and a tank pressure left near the cap pushes
3714 # dP/Pc to ~0.47 against a 0.30 ceiling (measured; obj 4.3e6). Tanks that follow Pc down
3715 # remove that wall.
3716 derive_pt = (bool(constants.get("layer1_derive_tank_from_dp_ratio", False))
3717 and bool(constants.get("layer1_lock_tank_pressures", False)))
3718 if not (derive_eps or derive_at or derive_df or derive_pt):
3719 return result
3720 if not isinstance(result, dict):
3721 return result
3722
3723 Pa = float(constants.get("P_ambient", 0.0) or 0.0)
3724 F_target = float(constants.get("target_thrust", 0.0) or 0.0)
3725 eps_lo = float(constants.get("derive_eps_min", 1.5))
3726 eps_hi = float(constants.get("derive_eps_max", 100.0))
3727 at_lo = float(constants.get("derive_At_min", 1e-6))
3728 at_hi = float(constants.get("derive_At_max", 1.0))
3729 max_iters = int(constants.get("layer1_derive_max_iters", 4))
3730 thrust_tol = float(constants.get("layer1_derive_thrust_tol_rel", 2.0e-4))
3731
3732 if derive_at and not (F_target > 0.0 and at_hi > at_lo):
3733 derive_at = False
3734 MR_target = float(constants.get("optimal_of", 0.0) or 0.0)
3735 df_lo = float(constants.get("derive_dF_min", 1e-5))
3736 df_hi = float(constants.get("derive_dF_max", 1.0))
3737 of_tol = float(constants.get("layer1_derive_of_tol_rel", 1.0e-3))
3738 if derive_df and not (MR_target > 0.0 and df_hi > df_lo):
3739 derive_df = False
3740 io_ = int(constants.get("idx_P_O", 8)); if_ = int(constants.get("idx_P_F", 9))
3741 pt_lo = float(constants.get("lock_P_T_min", 0.0)); pt_hi = float(constants.get("lock_P_T_max", np.inf))
3742 r_dp = float(constants.get("layer1_dp_ratio_target", 0.25))
3743 if derive_pt and not (0 <= io_ < len(x) and 0 <= if_ < len(x) and pt_hi > pt_lo and r_dp > 0):
3744 derive_pt = False
3745 if derive_eps and not (Pa > 0.0 and eps_hi > eps_lo):
3746 derive_eps = False
3747 if not (derive_eps or derive_at or derive_df or derive_pt):
3748 return result
3749
3750 slope = _DERIVE_AT_SLOPE_DEFAULT
3751 prev_at = None
3752 prev_F = None
3753 max_nozzle_exit = float(constants.get("max_nozzle_exit", 1.0))
3754 wall = float(constants.get("TOTAL_WALL_THICKNESS_M", TOTAL_WALL_THICKNESS_M))
3755 od_inc = constants.get("layer1_chamber_od_increment_in", 0.0)
3756 od_snap_target = constants.get("layer1_chamber_od_snap_target", "outer")
3757
3758 _iters_used = 0
3759 for _ in range(max(1, max_iters)):
3760 _iters_used += 1
3761 changed = False
3762 # Snapshot the DOFs this step may move. If the re-evaluation below fails, the loop
3763 # keeps the previous good ``result`` -- so x and the config must be put back to the
3764 # state that result describes. Without this, a failed last step left x/config on
3765 # the geometry that had just FAILED while returning a result from the one before:
3766 # the finalised design then would not evaluate ("Supply < Demand", validation boosted
3767 # tanks 3-14%), and in the worker path an unbuildable vector could be recorded as the
3768 # winner (measured: rebuild fresh-eval failed on 3/3 seeds, residual -0.0023 kg/s).
3769 _snap_x = x.copy()
3770
3771 if derive_eps:
3772 eps_new = _layer1_matched_eps(result, Pa, cea_cache, float(x[2]))
3773 if eps_new is not None:
3774 eps_new = float(np.clip(eps_new, eps_lo, eps_hi))
3775 if abs(eps_new - float(x[2])) > 1e-4 * max(1.0, abs(float(x[2]))):
3776 x[2] = eps_new
3777 changed = True
3778
3779 if derive_at:
3780 F_now = result.get("F")
3781 At_now = float(x[0])
3782 try:
3783 F_now = float(F_now)
3784 except (TypeError, ValueError):
3785 F_now = float("nan")
3786 if np.isfinite(F_now) and F_now > 0.0 and At_now > 0.0:
3787 # Secant on the local log-log slope once we have two points.
3788 if prev_at is not None and prev_F is not None and prev_F > 0.0:
3789 d_ln_at = np.log(At_now) - np.log(prev_at)
3790 d_ln_F = np.log(F_now) - np.log(prev_F)
3791 if abs(d_ln_at) > 1e-9:
3792 s = d_ln_F / d_ln_at
3793 if np.isfinite(s):
3794 # Positive-only, deliberately. A sign-preserving clamp trusted a
3795 # negative slope measured AT the supply boundary for too long and
3796 # ran thrust away to +38% (7218 -> 9954 N) on 3/8 finalisations.
3797 # Off-boundary, dlnF/dlnAt is +0.3; the guard in the rebuild
3798 # handles the boundary case by refusing to make thrust worse.
3799 slope = float(
3800 np.clip(s, _DERIVE_AT_SLOPE_MIN, _DERIVE_AT_SLOPE_MAX)
3801 )
3802 rel_err = F_target / F_now
3803 if abs(rel_err - 1.0) > thrust_tol:
3804 prev_at, prev_F = At_now, F_now
3805 At_new = float(np.clip(At_now * rel_err ** (1.0 / slope), at_lo, at_hi))
3806 if abs(At_new - At_now) > 1e-4 * At_now:
3807 x[0] = At_new
3808 changed = True
3809
3810 if derive_df:
3811 MR_now = result.get("MR")
3812 try:
3813 MR_now = float(MR_now)
3814 except (TypeError, ValueError):
3815 MR_now = float("nan")
3816 d_F = float(x[8])
3817 if np.isfinite(MR_now) and MR_now > 0.0 and d_F > 0.0:
3818 if abs(MR_now / MR_target - 1.0) > of_tol:
3819 # MR = mdot_O/mdot_F and mdot_F ~ d_F^2 => d_F_new = d_F*sqrt(MR_now/MR_target)
3820 d_F_new = float(np.clip(d_F * np.sqrt(MR_now / MR_target), df_lo, df_hi))
3821 if abs(d_F_new - d_F) > 1e-6 * d_F:
3822 x[8] = d_F_new
3823 changed = True
3824
3825 if derive_pt:
3826 Pc_now = result.get("Pc")
3827 ip = result.get("injector_pressure") if isinstance(result.get("injector_pressure"), dict) else {}
3828 try:
3829 Pc_now = float(Pc_now)
3830 dpf_O = float(ip.get("delta_p_feed_O", 0.0) or 0.0)
3831 dpf_F = float(ip.get("delta_p_feed_F", 0.0) or 0.0)
3832 except (TypeError, ValueError):
3833 Pc_now = float("nan")
3834 if np.isfinite(Pc_now) and Pc_now > 0.0:
3835 # One tank feeds both sides; use the larger feed loss so the weaker side still
3836 # meets the target (the other lands slightly above it, inside the band).
3837 P_needed_psi = (Pc_now * (1.0 + r_dp) + max(dpf_O, dpf_F)) / 6894.757293168
3838 P_needed_psi = float(np.clip(P_needed_psi, pt_lo, pt_hi))
3839 P_cur = float(x[io_])
3840 if abs(P_needed_psi - P_cur) > 1e-3 * max(1.0, P_cur):
3841 # Relaxed: Pc responds strongly to P_tank at fixed throat, so a full step
3842 # oscillates against the throat solve.
3843 P_new = P_cur + 0.6 * (P_needed_psi - P_cur)
3844 x[io_] = P_new
3845 x[if_] = P_new
3846 changed = True
3847
3848 if not changed:
3849 break
3850
3851 # The fuel jet lives on the injector, not the chamber: write it (and the ring pitch
3852 # that depends on it) back onto the config before re-evaluating.
3853 if derive_df and hasattr(config, "injector") and hasattr(config.injector, "geometry"):
3854 _layer1_derive_fuel_spacing(x, constants)
3855 config.injector.geometry.fuel.d_jet = float(x[8])
3856 config.injector.geometry.fuel.spacing = float(x[10])
3857
3859 config,
3860 A_throat=float(x[0]),
3861 Lstar=float(x[1]),
3862 expansion_ratio=float(x[2]),
3863 D_chamber_outer=_quantize_chamber_od_m(
3864 float(x[3]), od_inc, wall_m=wall, snap_target=od_snap_target),
3865 max_nozzle_exit=max_nozzle_exit,
3866 wall_thickness_m=wall,
3867 )
3868 if derive_pt:
3869 if getattr(config, "lox_tank", None) is not None:
3870 config.lox_tank.initial_pressure_psi = float(x[io_])
3871 if getattr(config, "fuel_tank", None) is not None:
3872 config.fuel_tank.initial_pressure_psi = float(x[if_])
3873 next_result = evaluate()
3874 # A failed step off a feasibility edge is not the end: halve the move toward the
3875 # previous good state and retry -- the chamber solve fails to bracket by a hair on
3876 # knife-edge winners, and a full secant step overshoots that hair.
3877 _halvings = 0
3878 while not isinstance(next_result, dict) and _halvings < 4:
3879 _halvings += 1
3880 x[:] = _snap_x + 0.5 * (x - _snap_x)
3882 config, A_throat=float(x[0]), Lstar=float(x[1]), expansion_ratio=float(x[2]),
3883 D_chamber_outer=_quantize_chamber_od_m(
3884 float(x[3]), od_inc, wall_m=wall, snap_target=od_snap_target),
3885 max_nozzle_exit=max_nozzle_exit, wall_thickness_m=wall)
3886 if derive_df and hasattr(config, "injector") and hasattr(config.injector, "geometry"):
3887 config.injector.geometry.fuel.d_jet = float(x[8]); config.injector.geometry.fuel.spacing = float(x[10])
3888 if derive_pt:
3889 if getattr(config, "lox_tank", None) is not None: config.lox_tank.initial_pressure_psi = float(x[io_])
3890 if getattr(config, "fuel_tank", None) is not None: config.fuel_tank.initial_pressure_psi = float(x[if_])
3891 next_result = evaluate()
3892 if not isinstance(next_result, dict):
3893 # Still failing after halving -- keep the last good result AND put x and the
3894 # config back to the state that result describes.
3895 x[:] = _snap_x
3897 config,
3898 A_throat=float(x[0]),
3899 Lstar=float(x[1]),
3900 expansion_ratio=float(x[2]),
3901 D_chamber_outer=_quantize_chamber_od_m(
3902 float(x[3]), od_inc, wall_m=wall, snap_target=od_snap_target),
3903 max_nozzle_exit=max_nozzle_exit,
3904 wall_thickness_m=wall,
3905 )
3906 if derive_df and hasattr(config, "injector") and hasattr(config.injector, "geometry"):
3907 config.injector.geometry.fuel.d_jet = float(x[8])
3908 config.injector.geometry.fuel.spacing = float(x[10])
3909 if derive_pt:
3910 if getattr(config, "lox_tank", None) is not None:
3911 config.lox_tank.initial_pressure_psi = float(x[io_])
3912 if getattr(config, "fuel_tank", None) is not None:
3913 config.fuel_tank.initial_pressure_psi = float(x[if_])
3914 try:
3915 result["_derive_restored_after_failed_step"] = True
3916 except Exception:
3917 pass
3918 break
3919 result = next_result
3920
3921 try:
3922 result["_derive_iters"] = _iters_used
3923 except Exception:
3924 pass
3925 return result
3926
3927
3928# ============================================================================
3929# Worker Process Globals (for parallel CMA-ES evaluation)
3930# ============================================================================
3931# These are set by _init_worker() in each worker process and reused across evaluations
3932_worker_runner = None
3933_worker_base_config = None
3934_worker_bounds = None
3935_worker_requirements = None
3936_worker_constants = None
3937_worker_debug_strict = False
3938
3939
3940
3942 bounds: list,
3943 max_chamber_od: float,
3944 max_nozzle_exit: float,
3945 injector_type: str = "pintle",
3946 chamber_od_increment_in: float = 0.0,
3947 chamber_od_snap_target: str = "outer",
3948 derive_constants: Optional[dict] = None,
3949 derive_spacing_F_range: Tuple[float, float] = (0.0, float("inf")),
3950) -> Callable:
3951 """Create the apply_x_to_config function with dependencies.
3952
3953 Returns a function that converts optimizer variables to engine config.
3954 """
3955 derive_constants = dict(derive_constants or {})
3956
3957 def _pf_clip_range(default_bound):
3958 """Clip range for the fuel tank pressure.
3959
3960 Locked to the LOX tank means its own bound is collapsed to a seed point; clipping
3961 there would throw the locked value away, so use the range it was locked within.
3962 """
3963 if derive_constants.get("layer1_lock_tank_pressures"):
3964 return (float(derive_constants.get("lock_P_F_min", default_bound[0])),
3965 float(derive_constants.get("lock_P_F_max", default_bound[1])))
3966 return (float(default_bound[0]), float(default_bound[1]))
3967
3968 def apply_x_to_config(
3969 x: np.ndarray,
3970 base_config: PintleEngineConfig,
3971 ) -> Tuple[PintleEngineConfig, float, float]:
3972 """Apply optimization variables to config."""
3973 config = copy.deepcopy(base_config)
3974 inj_type = injector_type
3975 if inj_type not in ("pintle", "impinging"):
3976 inj_type = getattr(getattr(base_config, "injector", None), "type", "pintle")
3977
3978 x = np.asarray(x, dtype=float).copy()
3979 _layer1_derive_matched_tank_pressure(x, derive_constants)
3980 A_throat = float(np.clip(x[0], bounds[0][0], bounds[0][1]))
3981 Lstar = float(np.clip(x[1], bounds[1][0], bounds[1][1]))
3982 expansion_ratio = float(np.clip(x[2], bounds[2][0], bounds[2][1]))
3983 D_chamber_outer = float(np.clip(x[3], bounds[3][0], bounds[3][1]))
3984 D_chamber_outer = _quantize_chamber_od_m(
3985 D_chamber_outer, chamber_od_increment_in,
3986 wall_m=_layer1_total_wall_thickness_m(base_config),
3987 snap_target=chamber_od_snap_target,
3988 )
3989
3991 config,
3992 A_throat=A_throat,
3993 Lstar=Lstar,
3994 expansion_ratio=expansion_ratio,
3995 D_chamber_outer=D_chamber_outer,
3996 max_nozzle_exit=max_nozzle_exit,
3997 wall_thickness_m=_layer1_total_wall_thickness_m(base_config),
3998 )
3999
4000 if inj_type == "impinging":
4001 n_doublets = int(round(np.clip(x[4], bounds[4][0], bounds[4][1])))
4002 n_el_O = n_doublets
4003 d_jet_O = float(np.clip(x[5], bounds[5][0], bounds[5][1]))
4004 ang_O = float(np.clip(x[6], bounds[6][0], bounds[6][1]))
4005 sp_O = float(np.clip(x[7], bounds[7][0], bounds[7][1]))
4006 n_el_F = n_doublets
4007 d_jet_F = float(np.clip(x[8], bounds[8][0], bounds[8][1]))
4008 ang_F = float(np.clip(x[9], bounds[9][0], bounds[9][1]))
4009 # Fuel ring pitch is SOLVED from the standoff requirement, not searched. Clip to
4010 # its own search range, never to bounds[10] -- that is collapsed to a point when
4011 # the derivation is on, and clipping there would throw the solved value away.
4012 _x_local = np.array(x, dtype=float)
4013 _layer1_derive_fuel_spacing(_x_local, derive_constants)
4014 _spF_lo, _spF_hi = derive_spacing_F_range
4015 sp_F = float(np.clip(_x_local[10], _spF_lo, _spF_hi))
4016 P_O_start_psi = float(np.clip(x[11], bounds[11][0], bounds[11][1]))
4017 P_F_start_psi = float(np.clip(x[12], *_pf_clip_range(bounds[12])))
4018 if hasattr(config.injector, "geometry"):
4019 config.injector.geometry.oxidizer.n_elements = max(1, n_el_O)
4020 config.injector.geometry.oxidizer.d_jet = d_jet_O
4021 config.injector.geometry.oxidizer.impingement_angle = ang_O
4022 config.injector.geometry.oxidizer.spacing = sp_O
4023 config.injector.geometry.fuel.n_elements = max(1, n_el_F)
4024 config.injector.geometry.fuel.d_jet = d_jet_F
4025 config.injector.geometry.fuel.impingement_angle = ang_F
4026 config.injector.geometry.fuel.spacing = sp_F
4027 # Keep tank stagnation pressures on ``config`` in sync with ``x`` (runner.evaluate uses x-derived Pa).
4028 if getattr(config, "lox_tank", None) is not None:
4029 config.lox_tank.initial_pressure_psi = float(P_O_start_psi)
4030 if getattr(config, "fuel_tank", None) is not None:
4031 config.fuel_tank.initial_pressure_psi = float(P_F_start_psi)
4032 return config, P_O_start_psi, P_F_start_psi
4033
4034 d_pintle_tip = float(np.clip(x[4], bounds[4][0], bounds[4][1]))
4035 h_gap = float(np.clip(x[5], bounds[5][0], bounds[5][1]))
4036 n_orifices = int(round(np.clip(x[6], bounds[6][0], bounds[6][1])))
4037 d_orifice = float(np.clip(x[7], bounds[7][0], bounds[7][1]))
4038 P_O_start_psi = float(np.clip(x[8], bounds[8][0], bounds[8][1]))
4039 P_F_start_psi = float(np.clip(x[9], *_pf_clip_range(bounds[9])))
4040
4041 if hasattr(config.injector, 'geometry'):
4042 if hasattr(config.injector.geometry, 'fuel'):
4043 config.injector.geometry.fuel.d_pintle_tip = d_pintle_tip
4044 config.injector.geometry.fuel.h_gap = h_gap
4045 config.injector.geometry.fuel.d_reservoir_inner = d_pintle_tip + 2 * h_gap
4046 config.injector.geometry.fuel.d_hydraulic = 2 * h_gap
4047 config.injector.geometry.fuel.A_entry = np.pi * (d_pintle_tip / 2) ** 2
4048 if hasattr(config.injector.geometry, 'lox'):
4049 config.injector.geometry.lox.n_orifices = n_orifices
4050 config.injector.geometry.lox.d_orifice = d_orifice
4051 config.injector.geometry.lox.theta_orifice = 90.0
4052 config.injector.geometry.lox.d_hydraulic = d_orifice
4053 config.injector.geometry.lox.A_entry = np.pi * (d_orifice / 2) ** 2
4054
4055 if getattr(config, "lox_tank", None) is not None:
4056 config.lox_tank.initial_pressure_psi = float(P_O_start_psi)
4057 if getattr(config, "fuel_tank", None) is not None:
4058 config.fuel_tank.initial_pressure_psi = float(P_F_start_psi)
4059
4060 return config, P_O_start_psi, P_F_start_psi
4061
4062 return apply_x_to_config
4063
4064
4065
4066
4067
4068# ============================================================================
4069# Helper Functions for Parallel CMA-ES Evaluation
4070# ============================================================================
4071
4072def _config_to_dict(config: PintleEngineConfig) -> dict:
4073 """Convert config to lightweight dict for pickling.
4074
4075 Uses pydantic's dict() method if available, otherwise falls back to __dict__.
4076 """
4077 return config.model_dump() if hasattr(config, 'model_dump') else config.__dict__
4078
4079
4080def _dict_to_config(config_dict: dict) -> PintleEngineConfig:
4081 """Reconstruct config from dict."""
4082 return PintleEngineConfig(**config_dict)
4083
4084
4085
4087 D_outer_m: float,
4088 increment_in: float,
4089 wall_m: float = 0.0,
4090 snap_target: str = "outer",
4091) -> float:
4092 """Snap a chamber diameter to a stock increment; returns the OUTER diameter [m].
4093
4094 Ablative sleeve, chamber tube and case are bought in fixed sizes -- typically half-inch
4095 steps -- so a continuous optimum like 4.2 in is not purchasable: you buy 4.0 or 4.5 and
4096 the design you build is not the design that was optimised. Snapping inside the evaluation
4097 means every candidate the optimizer scores is one you can actually order, so the returned
4098 optimum is already manufacturable rather than something to round afterwards (rounding
4099 afterwards silently moves contraction ratio, L*, and wall thickness off the optimum).
4100
4101 ``snap_target`` picks WHICH diameter lands on the increment, because only one of them
4102 can: bore = OD - wall, and the wall is rarely a whole increment (a 1.13 in wall turns a
4103 6.00 in OD into a 4.87 in bore). ``"outer"`` snaps the tube you buy; ``"bore"`` snaps the
4104 gas-side diameter, for when the liner ID is what comes in fixed sizes. Either way the
4105 OUTER diameter is returned, since that is what the geometry writer takes.
4106
4107 increment_in <= 0 disables snapping and returns the value unchanged.
4108 """
4109 if increment_in is None or not np.isfinite(increment_in) or increment_in <= 0:
4110 return float(D_outer_m)
4111 if not np.isfinite(D_outer_m) or D_outer_m <= 0:
4112 return float(D_outer_m)
4113 step_m = float(increment_in) * 0.0254
4114
4115 if str(snap_target).strip().lower() == "bore":
4116 w = float(wall_m) if wall_m is not None and np.isfinite(wall_m) else 0.0
4117 bore = float(D_outer_m) - w
4118 if bore <= 0:
4119 return float(D_outer_m)
4120 n = max(1, round(bore / step_m))
4121 return float(n * step_m + w)
4122
4123 n = round(float(D_outer_m) / step_m)
4124 if n < 1:
4125 n = 1
4126 return float(n * step_m)
4127
4128
4129def _l1_shrink_derived_stds(cma_stds: np.ndarray, derived: Dict[int, float]) -> list:
4130 """CMA_stds with derived dimensions damped to effectively zero.
4131
4132 Derived DOFs keep their FULL bounds -- collapsing them to a point made every one of the
4133 ~50 ``np.clip(x, bounds...)`` sites in this module silently revert the solved value to
4134 the seed. Full bounds make each clip a no-op; this keeps CMA from wasting variance on a
4135 coordinate whose value is overwritten at evaluation anyway.
4136 """
4137 out = list(np.asarray(cma_stds, dtype=float).tolist())
4138 for k in (derived or {}):
4139 k = int(k)
4140 if 0 <= k < len(out):
4141 out[k] = max(1e-12, float(out[k]) * 1e-6)
4142 return out
4143
4144
4146 n_dims: int,
4147 integer_dims: Optional[Sequence[int]],
4148 od_index: int = -1,
4149 od_step_m: float = 0.0,
4150 bounds: Optional[Sequence[Sequence[float]]] = None,
4151) -> Tuple[list, list]:
4152 """CMA options that make discrete DOFs behave like discrete DOFs.
4153
4154 Snapping a variable at dispatch (``_snap_integer_dims``) guarantees the design that
4155 gets EVALUATED is manufacturable, but it leaves CMA-ES searching a continuous
4156 coordinate over a staircase objective: many distinct proposals collapse onto the same
4157 evaluated value, so most of the variation CMA generates in that coordinate returns a
4158 bit-identical objective and teaches its covariance adaptation nothing. Worse, as sigma
4159 decays during convergence it can fall below half a cell, at which point essentially
4160 every proposal rounds to the same value and the coordinate freezes on whatever cell it
4161 happened to occupy -- measured on a real run: 1116 distinct proposals for the chamber
4162 diameter produced 8 distinct evaluated sizes, and four seeds of one problem landed on
4163 5.5/6.5/5.5/6.5 in while every other output matched to 4 significant figures.
4164
4165 Two fixes, both from ``cma`` itself:
4166
4167 * ``integer_variables`` -- integer centering plus a minimum std, for coordinates whose
4168 granularity really is 1 (element counts, whole-degree jet angles).
4169 * ``minstd`` -- a hard floor on the per-coordinate std, in the coordinate's OWN units.
4170 This is how the chamber diameter is handled: its granularity is one stock increment
4171 in metres, not 1, so it must NOT be declared an integer variable (that would centre
4172 it on integer *metres*) -- it just needs a std floor of a fraction of one increment.
4173
4174 Returns:
4175 ``(integer_variables, minstd)`` ready to drop into the CMA options dict.
4176 """
4177 def _span(i: int) -> float:
4178 """Width of coordinate i's search domain, or inf when bounds are unknown."""
4179 if bounds is None or i >= len(bounds):
4180 return float("inf")
4181 try:
4182 return float(bounds[i][1]) - float(bounds[i][0])
4183 except (TypeError, ValueError, IndexError):
4184 return float("inf")
4185
4186 def _fits(i: int, m: float) -> bool:
4187 """A std floor only makes sense if the domain is wide enough to hold it.
4188
4189 A PINNED coordinate -- user-frozen via frozen_parameters, or one of the derived DOFs
4190 this module collapses itself -- has a domain of ~2e-12. Handing cma a minstd of 0.2
4191 (or 0.00254 m for the chamber OD) against that makes it inflate the sampling std by
4192 ~1e9 to satisfy the floor, which blows up the covariance matrix on a coordinate that
4193 cannot move anyway: 'sigma0 x stds is larger than the bounded domain size in
4194 variable 3', and the run collapses. Nothing is lost by skipping them -- a pinned
4195 coordinate has no cells to straddle.
4196 """
4197 return _span(i) > 2.0 * m
4198
4199 idx = [
4200 int(i) for i in (integer_dims or [])
4201 if 0 <= int(i) < n_dims and _fits(int(i), 0.2)
4202 ]
4203 minstd = [0.0] * n_dims
4204 # 0.2 is cma's own scale for integer handling: small enough not to inflate the search,
4205 # large enough that proposals still straddle a cell boundary.
4206 for i in idx:
4207 minstd[i] = 0.2
4208 if od_step_m > 0.0 and 0 <= od_index < n_dims:
4209 _m = 0.2 * float(od_step_m)
4210 if _fits(od_index, _m):
4211 minstd[od_index] = _m
4212 return idx, minstd
4213
4214
4215def _snap_integer_dims(x: np.ndarray, integer_indices: list) -> np.ndarray:
4216 """Snap integer dimensions to nearest integer.
4217
4218 Ensures cache consistency and physical validity for discrete variables.
4219
4220 Args:
4221 x: Candidate vector
4222 integer_indices: List of indices to snap (e.g., [6] for n_orifices)
4223
4224 Returns:
4225 Snapped vector with integer dimensions rounded
4226 """
4227 x_snapped = x.copy()
4228 for idx in integer_indices:
4229 x_snapped[idx] = round(x_snapped[idx])
4230 return x_snapped
4231
4232
4233def _get_num_workers(config_obj) -> int:
4234 """Get number of workers from config or default to cpu_count - 1.
4235
4236 ``ED_L1_WORKERS`` overrides both (``1`` runs every candidate in-process, which is what a
4237 debugger or an infeasibility trace needs)."""
4238 env = os.environ.get("ED_L1_WORKERS")
4239 if env:
4240 try:
4241 return max(1, int(env))
4242 except ValueError:
4243 pass
4244 if hasattr(config_obj, 'optimizer') and hasattr(config_obj.optimizer, 'num_workers'):
4245 num_workers = config_obj.optimizer.num_workers
4246 else:
4247 # Default to cpu_count - 1 (leave one core free)
4248 num_workers = max(1, os.cpu_count() - 1)
4249
4250 return num_workers
4251
4252
4253def _init_worker(config_dict: dict, bounds_array: np.ndarray, requirements_dict: dict,
4254 constants_dict: dict, debug_strict: bool = False):
4255 """Initialize worker process with lightweight data.
4256
4257 Builds PintleEngineRunner ONCE per worker for major speedup.
4258 Runner is reused by mutating its config in-place (runner.evaluate is stateless).
4259
4260 Args:
4261 config_dict: Serialized config (primitives only)
4262 bounds_array: Bounds array [n_dims x 2]
4263 requirements_dict: Requirements dict
4264 constants_dict: Constants needed for geometry calculations
4265 debug_strict: If True, re-raise exceptions; if False, return penalties
4266 """
4267 global _worker_runner, _worker_base_config, _worker_bounds, _worker_requirements, _worker_constants, _worker_debug_strict
4268
4269 # Warm the JIT in THIS process. @njit(cache=True) persists compiled code to
4270 # disk, but the cache is not shared memory -- every worker still loads and
4271 # deserializes it on first call. Un-warmed, that cost lands inside the first
4272 # CMA generation and skews it. (The C path never needed this: one .so was
4273 # mmapped into every child for free.)
4274 try:
4275 from engine import accel as _accel
4276 if _accel.enabled():
4277 _accel.warmup()
4278 except Exception:
4279 pass # a warmup failure must never stop a worker from starting
4280
4281 # Reconstruct config from dict
4282 _worker_base_config = _dict_to_config(config_dict)
4283
4284 # Build runner ONCE per worker (major speedup)
4285 # Note: PintleEngineRunner.evaluate() is stateless between calls
4286 _worker_runner = PintleEngineRunner(_worker_base_config)
4287
4288 # Store bounds and requirements
4289 _worker_bounds = np.array(bounds_array, dtype=np.float64)
4290 _worker_requirements = requirements_dict
4291 _worker_constants = constants_dict
4292 _worker_debug_strict = debug_strict
4293
4294
4295def _apply_x_to_worker_config_inplace(x: np.ndarray, config: PintleEngineConfig, constants: dict):
4296 """Apply x to config IN-PLACE (mutates config, no copy).
4297
4298 Assumes x is already bounded and snapped (CMA-ES + parent handle this).
4299 """
4300 inj_type = constants.get("injector_type", "pintle")
4302
4303 A_throat = float(x[0])
4304 Lstar = float(x[1])
4305 expansion_ratio = float(x[2])
4306 max_nozzle_exit = constants.get('max_nozzle_exit', 1.0)
4307 wall = float(constants.get('TOTAL_WALL_THICKNESS_M', TOTAL_WALL_THICKNESS_M))
4308 D_chamber_outer = _quantize_chamber_od_m(
4309 float(x[3]), constants.get("layer1_chamber_od_increment_in", 0.0),
4310 wall_m=wall,
4311 snap_target=constants.get("layer1_chamber_od_snap_target", "outer"),
4312 )
4313
4315 config,
4316 A_throat=A_throat,
4317 Lstar=Lstar,
4318 expansion_ratio=expansion_ratio,
4319 D_chamber_outer=D_chamber_outer,
4320 max_nozzle_exit=max_nozzle_exit,
4321 wall_thickness_m=wall,
4322 )
4323
4325 if inj_type == "impinging":
4326 # Solve the fuel ring pitch from the impingement-standoff requirement BEFORE anything
4327 # reads it, so config, objective and reported design all see the same solved value.
4328 _layer1_derive_fuel_spacing(x, constants)
4329 n_doublets = int(x[4])
4330 n_el_O = n_doublets
4331 d_jet_O = float(x[5])
4332 ang_O = float(x[6])
4333 sp_O = float(x[7])
4334 n_el_F = n_doublets
4335 d_jet_F = float(x[8])
4336 ang_F = float(x[9])
4337 sp_F = float(x[10])
4338 if hasattr(config, 'injector') and hasattr(config.injector, 'geometry'):
4339 config.injector.geometry.oxidizer.n_elements = max(1, n_el_O)
4340 config.injector.geometry.oxidizer.d_jet = d_jet_O
4341 config.injector.geometry.oxidizer.impingement_angle = ang_O
4342 config.injector.geometry.oxidizer.spacing = sp_O
4343 config.injector.geometry.fuel.n_elements = max(1, n_el_F)
4344 config.injector.geometry.fuel.d_jet = d_jet_F
4345 config.injector.geometry.fuel.impingement_angle = ang_F
4346 config.injector.geometry.fuel.spacing = sp_F
4347 _io = int(constants.get("idx_P_O", 11))
4348 _if = int(constants.get("idx_P_F", 12))
4349 if len(x) > _if:
4350 _po = float(x[_io])
4351 _pf = float(x[_if])
4352 if getattr(config, "lox_tank", None) is not None:
4353 config.lox_tank.initial_pressure_psi = _po
4354 if getattr(config, "fuel_tank", None) is not None:
4355 config.fuel_tank.initial_pressure_psi = _pf
4356 return
4357
4358 d_pintle_tip = float(x[4])
4359 h_gap = float(x[5])
4360 n_orifices = int(x[6])
4361 d_orifice = float(x[7])
4362
4363 if hasattr(config, 'injector') and getattr(config.injector, 'type', '') == "pintle":
4364 if hasattr(config.injector.geometry, 'fuel'):
4365 config.injector.geometry.fuel.d_pintle_tip = d_pintle_tip
4366 config.injector.geometry.fuel.h_gap = h_gap
4367 config.injector.geometry.fuel.d_reservoir_inner = d_pintle_tip + 2 * h_gap
4368 config.injector.geometry.fuel.d_hydraulic = 2 * h_gap
4369 config.injector.geometry.fuel.A_entry = np.pi * (d_pintle_tip / 2) ** 2
4370 if hasattr(config.injector.geometry, 'lox'):
4371 config.injector.geometry.lox.n_orifices = n_orifices
4372 config.injector.geometry.lox.d_orifice = d_orifice
4373 config.injector.geometry.lox.theta_orifice = 90.0
4374 config.injector.geometry.lox.d_hydraulic = d_orifice
4375 config.injector.geometry.lox.A_entry = np.pi * (d_orifice / 2) ** 2
4376
4377 _io = int(constants.get("idx_P_O", 8))
4378 _if = int(constants.get("idx_P_F", 9))
4379 if len(x) > _if:
4380 _po = float(x[_io])
4381 _pf = float(x[_if])
4382 if getattr(config, "lox_tank", None) is not None:
4383 config.lox_tank.initial_pressure_psi = _po
4384 if getattr(config, "fuel_tank", None) is not None:
4385 config.fuel_tank.initial_pressure_psi = _pf
4386
4387
4388# Infeasibility trace: set ED_L1_TRACE_INFEAS=1 (with ED_L1_WORKERS=1 so candidates run in-process)
4389# and each objective evaluation appends {checkpoint: running infeasibility score} here -- the only
4390# way to see WHICH gate keeps a run infeasible when every candidate returns the 1e6 floor.
4391_INFEAS_TRACE: List[Dict[str, float]] = []
4392_INFEAS_TRACE_ON = bool(os.environ.get("ED_L1_TRACE_INFEAS"))
4393
4394
4395def _infeas_trace(entry: Optional[Dict[str, float]], label: str, value: float) -> None:
4396 if entry is not None:
4397 entry[label] = float(value)
4398
4399
4401 result: Any,
4402 x: np.ndarray,
4403 requirements: dict,
4404 constants: dict,
4405 cea_cache: Any = None,
4406 *,
4407 eval_error: Optional[str] = None,
4408 return_terms: bool = False,
4409) -> Any:
4410 """The Layer-1 objective; with ``return_terms`` every weighted term and the values behind it.
4411
4412 The ONE scoring function. The worker pool (``_eval_candidate``) and the inline
4413 ``objective()`` closure both call it on the SOLVED vector, so CMA, the block stage,
4414 L-BFGS-B and the reported breakdown rank the same surface. It used to exist twice and the
4415 two drifted by ~300 points on the same design (chamber length, ΔP centre term, L* keys).
4416
4417 Structure: infeasibility (BASE_INFEAS + W_INFEAS x residual) dominates; below it the
4418 requirement terms (thrust, Pc, O/F with deadbands), the preference bands, and the
4419 figure of merit -- delivered Isp (``isp_penalty``) against chamber mass
4420 (``chamber_mass_penalty``).
4421
4422 Returns the scalar, or with ``return_terms`` a dict: ``objective``, ``terms`` (weighted;
4423 sums to ``objective`` on a feasible design) and the context values.
4424 """
4425 inj_type = constants.get("injector_type", "pintle")
4426 x = np.asarray(x, dtype=float)
4428 idx_P_O = int(constants.get("idx_P_O", 8))
4429 idx_P_F = int(constants.get("idx_P_F", 9))
4430
4431 target_thrust = float(constants.get("target_thrust", 1000))
4432 optimal_of = float(constants.get("optimal_of", 2.5))
4433 target_P_exit = float(constants.get("P_ambient", 101325.0))
4434 max_lox_P_psi = float(constants.get("max_lox_P_psi", 500))
4435 max_fuel_P_psi = float(constants.get("max_fuel_P_psi", 500))
4436 TOTAL_WALL_THICKNESS_M = float(constants.get("TOTAL_WALL_THICKNESS_M", 0.0254))
4437 max_nozzle_exit = float(constants.get("max_nozzle_exit", 1.0))
4438 min_stability = float(requirements.get("min_stability_margin", _LAYER1_DEFAULT_MIN_STABILITY_MARGIN))
4439
4440 A_throat = float(x[0])
4441 Lstar = float(x[1])
4442 expansion_ratio = float(x[2])
4443 D_chamber_outer = _quantize_chamber_od_m(
4444 float(x[3]), constants.get("layer1_chamber_od_increment_in", 0.0),
4445 wall_m=TOTAL_WALL_THICKNESS_M,
4446 snap_target=constants.get("layer1_chamber_od_snap_target", "outer"),
4447 )
4448
4449 d_pintle_tip = 0.0
4450 n_el_O = 0
4451 d_jet_O = d_jet_F = ang_O = ang_F = sp_O = sp_F = float("nan")
4452 if inj_type == "impinging":
4453 # [4]=n_doublets, [5:8]=LOX jet, [8:11]=fuel jet, [11:13]=tank pressures.
4454 _layer1_derive_fuel_spacing(x, constants)
4455 n_el_O = int(x[4])
4456 d_jet_O, ang_O, sp_O = float(x[5]), float(x[6]), float(x[7])
4457 d_jet_F, ang_F, sp_F = float(x[8]), float(x[9]), float(x[10])
4458 A_lox_injector = float(n_el_O * np.pi * (d_jet_O / 2.0) ** 2)
4459 A_fuel_injector = float(n_el_O * np.pi * (d_jet_F / 2.0) ** 2)
4460 else:
4461 d_pintle_tip = float(x[4])
4462 h_gap = float(x[5])
4463 n_orifices = int(x[6])
4464 d_orifice = float(x[7])
4465 A_lox_injector = float(n_orifices * np.pi * (d_orifice / 2.0) ** 2)
4466 R_inner = float(d_pintle_tip / 2.0)
4467 A_fuel_injector = float(np.pi * ((R_inner + h_gap) ** 2 - R_inner ** 2))
4468
4469 D_chamber_inner = D_chamber_outer - TOTAL_WALL_THICKNESS_M
4470 if D_chamber_inner <= 0:
4471 D_chamber_inner = max(D_chamber_outer * 0.3, 0.01)
4472 A_chamber_check = np.pi * (D_chamber_inner / 2.0) ** 2
4473 A_throat_check = A_throat
4474
4475 A_exit = A_throat * expansion_ratio
4476 if A_exit < 0:
4477 A_exit = A_throat * 10.0
4478 D_exit_check = np.sqrt(max(0.0, 4.0 * A_exit / np.pi))
4479 if D_exit_check > max_nozzle_exit:
4480 D_exit_check = max_nozzle_exit
4481 D_throat_check = np.sqrt(4.0 * A_throat_check / np.pi) if A_throat_check > 0 else 0.0
4482
4483 out: Dict[str, Any] = {"eval_success": False, "terms": {}}
4484
4485 # Flow-capacity ratios use A_eff = Cd x A_geom when diagnostics supply Cd (post-evaluate)
4486 A_lox_flow, A_fuel_flow = A_lox_injector, A_fuel_injector
4487 if inj_type in ("impinging", "pintle") and isinstance(result, dict):
4488 A_lox_flow, A_fuel_flow, eff_w = effective_flow_areas_from_cd(
4489 result.get("diagnostics"), A_lox_injector, A_fuel_injector)
4490 if eff_w and isinstance(result.get("diagnostics"), dict):
4491 merge_effective_area_warnings(result["diagnostics"], eff_w)
4492 lox_ratio = A_lox_flow / A_throat_check if A_throat_check > 0 else np.nan
4493 fuel_ratio = A_fuel_flow / A_throat_check if A_throat_check > 0 else np.nan
4494
4495 P_O_psi = float(x[idx_P_O])
4496 P_F_psi = float(x[idx_P_F])
4497 P_O_ratio = P_O_psi / max_lox_P_psi if max_lox_P_psi > 0 else 0.0
4498 P_F_ratio = P_F_psi / max_fuel_P_psi if max_fuel_P_psi > 0 else 0.0
4499
4500 infeasibility_score = 0.0
4501 geom_hard = 0.0 # face / back-face limits: not forgiven by the gate
4502 _tr = {} if _INFEAS_TRACE_ON else None
4503 if _tr is not None:
4504 _INFEAS_TRACE.append(_tr)
4505
4506 if A_chamber_check > 0 and A_throat_check > 0:
4507 cr_check = A_chamber_check / A_throat_check
4508 infeasibility_score += max(0.0, (A_throat_check * 1.1) / A_chamber_check - 1.0) ** 2
4509 if cr_check < 1.5:
4510 infeasibility_score += (1.5 - cr_check) ** 2
4511 elif cr_check > 15.0:
4512 infeasibility_score += (cr_check - 15.0) ** 2
4513 if inj_type == "pintle" and D_chamber_inner > 0:
4514 infeasibility_score += max(0.0, (d_pintle_tip * 1.1) / D_chamber_inner - 1.0) ** 2
4515 if D_throat_check > 0 and D_chamber_inner > 0:
4516 infeasibility_score += max(0.0, D_throat_check - D_chamber_inner * 0.95) ** 2 * 10.0
4517 if D_exit_check > 0 and D_throat_check > 0:
4518 infeasibility_score += max(0.0, D_throat_check / D_exit_check - 1.0) ** 2
4519
4520 if inj_type == "pintle" and A_throat_check > 0:
4521 infeasibility_score += max(0.0, lox_ratio - 1.0) ** 2
4522 infeasibility_score += max(0.0, fuel_ratio - 1.0) ** 2
4523 if A_fuel_flow > 0:
4524 area_ratio = A_lox_flow / A_fuel_flow
4525 Cd_ratio = float(constants.get("discharge_cd_inf_ratio", 1.0))
4526 rho_ratio = np.sqrt(
4527 max(float(constants.get("rho_oxidizer", 1140.0)), 1e-9)
4528 / max(float(constants.get("rho_fuel", 780.0)), 1e-9))
4529 area_ratio_factor = Cd_ratio * rho_ratio * np.sqrt(1.2)
4530 required = optimal_of / area_ratio_factor if area_ratio_factor > 0 else np.inf
4531 if required > 0 and np.isfinite(required):
4532 infeasibility_score += max(0.0, abs(area_ratio - required) / required - 0.5) ** 2
4533 elif inj_type == "impinging" and A_throat_check > 0:
4534 # Face and back-face real estate: hard limits, scored on every path.
4536 n_elements=n_el_O, spacing_O_m=sp_O, spacing_F_m=sp_F,
4537 d_jet_O_m=d_jet_O, d_jet_F_m=d_jet_F, D_chamber_inner_m=D_chamber_inner,
4538 angle_O_deg=ang_O, angle_F_deg=ang_F,
4539 center_clear_dia_m=float(constants.get("layer1_injector_center_clear_dia_m", 0.0) or 0.0),
4540 min_web_m=float(constants.get("layer1_injector_min_web_m", 0.0) or 0.0),
4541 wall_clearance_m=float(constants.get("layer1_injector_wall_clearance_m", 0.0) or 0.0),
4542 spray_radius_frac=float(constants.get("layer1_injector_spray_radius_frac", 0.0) or 0.0),
4543 spray_radius_tol=float(constants.get("layer1_injector_spray_radius_tol", 0.08) or 0.08),
4544 min_face_incidence_deg=float(
4545 constants.get("layer1_injector_min_face_incidence_deg", 0.0) or 0.0),
4546 face_contoured=bool(constants.get("layer1_injector_face_contoured", False)),
4547 exit_land=float(constants.get("layer1_injector_exit_land", EXIT_LAND_DEFAULT)),
4548 )
4549 geom_hard += _impinging_back_face_terms(
4550 n_elements=n_el_O, spacing_O_m=sp_O, spacing_F_m=sp_F,
4551 d_jet_O_m=d_jet_O, d_jet_F_m=d_jet_F, D_chamber_inner_m=D_chamber_inner,
4552 angle_O_deg=ang_O, angle_F_deg=ang_F,
4553 plate_thickness_m=float(constants.get("layer1_injector_plate_thickness_m", 0.0) or 0.0),
4554 counterbore_dia_m=float(constants.get("layer1_injector_counterbore_dia_m", 0.0) or 0.0),
4555 land_ld_O=float(constants.get("layer1_injector_land_ld_O", 4.0) or 4.0),
4556 land_ld_F=float(constants.get("layer1_injector_land_ld_F", 4.0) or 4.0),
4557 min_back_web_m=float(constants.get("layer1_injector_min_back_web_m", 0.0) or 0.0),
4558 back_keepout_dia_m=float(constants.get("layer1_injector_back_keepout_dia_m", 0.0) or 0.0),
4559 back_keepout_wall_m=float(constants.get("layer1_injector_back_keepout_wall_m", 0.0) or 0.0),
4560 face_contoured=bool(constants.get("layer1_injector_face_contoured", False)),
4561 exit_land=float(constants.get("layer1_injector_exit_land", EXIT_LAND_DEFAULT)),
4562 back_channels=bool(constants.get("layer1_injector_back_channels", False)),
4563 # One L/d per hole: the Cd's L/d is also the passage to the channel floor.
4564 passage_ld_O=float(constants.get("layer1_injector_land_ld_O", 4.0) or 4.0),
4565 passage_ld_F=float(constants.get("layer1_injector_land_ld_F", 4.0) or 4.0),
4566 channel_width_m=float(constants.get("layer1_injector_channel_width_m", 0.0) or 0.0),
4567 channel_floor=str(constants.get("layer1_injector_channel_floor", "flat")),
4568 r_plate_m=float(constants.get("layer1_injector_r_plate_m", 0.0) or 0.0),
4569 r_port_m=float(constants.get("layer1_injector_r_port_m", 0.0) or 0.0),
4570 liner_thickness_m=float(constants.get("layer1_injector_liner_thickness_m", 0.0) or 0.0),
4571 )
4572 # SP-8089: impingement no further than 7 d along either jet.
4573 geom_hard += _impinging_free_jet_violation(
4574 n_elements=n_el_O, spacing_O_m=sp_O, spacing_F_m=sp_F,
4575 d_jet_O_m=d_jet_O, d_jet_F_m=d_jet_F, angle_O_deg=ang_O, angle_F_deg=ang_F)
4576 # Channels back: the channel is the manifold, and a feed port's velocity head past
4577 # MANIFOLD_Q_FRAC of the drop starves the holes beside it. Active only when the plate
4578 # declares channels and the solve produced flows; the orifice size, angle and drop are
4579 # all this search's to move, so it is a hard limit, not a report.
4580 if bool(constants.get("layer1_injector_back_channels", False)) and isinstance(result, dict):
4581 _dg = result.get("diagnostics") if isinstance(result.get("diagnostics"), dict) else {}
4583 n_elements=n_el_O, spacing_O_m=sp_O, spacing_F_m=sp_F,
4584 d_jet_O_m=d_jet_O, d_jet_F_m=d_jet_F, angle_O_deg=ang_O, angle_F_deg=ang_F,
4585 plate_thickness_m=float(constants.get("layer1_injector_plate_thickness_m", 0.0) or 0.0),
4586 face_contoured=bool(constants.get("layer1_injector_face_contoured", False)),
4587 exit_land=float(constants.get("layer1_injector_exit_land", EXIT_LAND_DEFAULT)),
4588 passage_ld_O=float(constants.get("layer1_injector_land_ld_O", 4.0) or 4.0),
4589 passage_ld_F=float(constants.get("layer1_injector_land_ld_F", 4.0) or 4.0),
4590 channel_width_m=float(constants.get("layer1_injector_channel_width_m", 0.0) or 0.0),
4591 channel_floor=str(constants.get("layer1_injector_channel_floor", "flat")),
4592 channel_inlets=int(constants.get("layer1_injector_channel_inlets", 1) or 1),
4593 mdot_O=result.get("mdot_O"), mdot_F=result.get("mdot_F"),
4594 dp_O=_dg.get("delta_p_injector_O"), dp_F=_dg.get("delta_p_injector_F"),
4595 rho_O=constants.get("rho_O"), rho_F=constants.get("rho_F"))
4596 geom_hard += _impinging_manifold_violation(_mq)
4597 _infeas_trace(_tr, "manifold_q", geom_hard)
4598 infeasibility_score += _impinging_infeasibility_layout_terms(
4599 d_jet_O=d_jet_O, d_jet_F=d_jet_F, sp_O=sp_O, sp_F=sp_F,
4600 n_el_O=n_el_O, n_el_F=n_el_O, D_chamber_inner=D_chamber_inner)
4601 infeasibility_score += _impinging_infeasibility_flow_capacity_terms(
4602 A_lox_flow=A_lox_flow, A_fuel_flow=A_fuel_flow, A_throat_check=A_throat_check)
4603
4604 _infeas_trace(_tr, "geometry", infeasibility_score)
4605
4606 # --- Evaluation ---
4607 eval_success = bool(result.get("success", False)) if isinstance(result, dict) else False
4608 if isinstance(result, dict) and not eval_success:
4609 eval_success = bool(np.isfinite(float(result.get("F", np.nan)))
4610 and np.isfinite(float(result.get("Pc", np.nan))))
4611 out.update({
4612 "lox_ratio": lox_ratio, "fuel_ratio": fuel_ratio,
4613 "D_chamber_inner": D_chamber_inner, "D_throat": D_throat_check,
4614 })
4615
4616 if not eval_success:
4617 # Directional guidance only: which way out of the unevaluable region.
4618 infeasibility_score += 1.0
4619 err = (eval_error or "").lower()
4620 if ("supply > demand" in err) or ("invalid bracket" in err) or ("no solution" in err):
4621 if np.isfinite(lox_ratio):
4622 infeasibility_score += max(0.0, lox_ratio - 0.90) ** 2
4623 if np.isfinite(fuel_ratio):
4624 infeasibility_score += max(0.0, fuel_ratio - 0.90) ** 2
4625 else:
4626 infeasibility_score += max(0.0, 0.90 - P_O_ratio) ** 2 + max(0.0, 0.90 - P_F_ratio) ** 2
4627 if np.isfinite(geom_hard) and geom_hard > 0.0:
4628 infeasibility_score += float(geom_hard)
4629 out.update({
4630 "objective": float(_LAYER1_BASE_INFEAS + _LAYER1_W_INFEAS * float(infeasibility_score)),
4631 "infeasibility_score": float(infeasibility_score),
4632 "inf_residual": float(infeasibility_score),
4633 "length_violation": False,
4634 "thrust_error": 1.0, "of_error": 1.0,
4635 "stability_state": "unstable", "stability_score": 0.0,
4636 "chugging_margin": 0.0, "acoustic_margin": 0.0, "feed_margin": 0.0,
4637 })
4638 return out if return_terms else float(out["objective"])
4639
4640 F_actual = float(result.get("F", np.nan))
4641 MR_actual = float(result.get("MR", np.nan))
4642 Pc_actual = float(result.get("Pc", np.nan))
4643 Isp_actual = float(result.get("Isp", np.nan))
4644 Cf_actual = float(result.get("Cf_actual", result.get("Cf", np.nan)))
4645 P_exit_actual = float(result.get("P_exit", np.nan))
4646 stability = result.get("stability_results", {}) or {}
4647 diagnostics = result.get("diagnostics") if isinstance(result.get("diagnostics"), dict) else {}
4648
4649 thrust_error = (abs(F_actual - target_thrust) / target_thrust
4650 if (target_thrust > 0 and np.isfinite(F_actual)) else 1.0)
4651 # O/F deadband: hitting the target to within tolerance IS success and costs zero.
4652 _of_tol = float(constants.get("layer1_of_deadband_rel", 0.0) or 0.0)
4653 of_error_raw = (abs(MR_actual - optimal_of) / optimal_of
4654 if (optimal_of > 0 and np.isfinite(MR_actual)) else 1.0)
4655 of_error = max(0.0, of_error_raw - _of_tol)
4656
4657 thrust_penalty_sq_term = 0.0
4658 if target_thrust > 0 and np.isfinite(F_actual):
4659 excess = thrust_error - _layer1_thrust_deadband(requirements)
4660 if excess > 0:
4661 thrust_penalty_sq_term = excess ** 2
4662 else:
4663 thrust_penalty_sq_term = 1.0
4664
4665 # Chamber-pressure target: exact L1 outside the interior aim band (80 % of the +/-1 %
4666 # sign-off band, so the search never parks on the gate's edge). The L1 shape keeps a
4667 # constant pull; inside, the quadratic terms get the gradient.
4668 pc_penalty_term = 0.0
4669 pc_target_pa = constants.get("layer1_target_Pc_pa")
4670 if pc_target_pa and float(pc_target_pa) > 0:
4671 if np.isfinite(Pc_actual):
4672 pc_penalty_term = max(0.0, abs(Pc_actual - float(pc_target_pa)) / float(pc_target_pa)
4673 - _LAYER1_PC_AIM_REL)
4674 else:
4675 pc_penalty_term = 1.0
4676
4677 _ins_q_raw = constants.get("layer1_exit_pressure_inside_quad_scale")
4678 _ins_q = (float(_ins_q_raw) if _ins_q_raw is not None and np.isfinite(float(_ins_q_raw))
4679 else _LAYER1_DEFAULT_EXIT_PRESSURE_INSIDE_QUAD)
4680 exit_pressure_sq_term = float(_layer1_exit_pressure_sq_term(
4681 P_exit_actual, target_P_exit,
4682 deadband_rel=float(constants.get("layer1_exit_pressure_deadband_rel", 0.05)),
4683 inside_rel_quad_scale=_ins_q))
4684
4685 # Stability. require_stable_state means 'stable', not 'stable or marginal'.
4686 stability_state = stability.get("stability_state", "unstable")
4687 stability_score = float(stability.get("stability_score", 0.0))
4688 chugging_margin = max(0.0, float(stability.get("chugging", {}).get("stability_margin", 0.0)))
4689 acoustic_margin = max(0.0, float(stability.get("acoustic", {}).get("stability_margin", 0.0)))
4690 feed_margin = max(0.0, float(stability.get("feed_system", {}).get("stability_margin", 0.0)))
4691 handicap = float(requirements.get("stability_margin_handicap", 0.0))
4692 effective_min_score = float(requirements.get("min_stability_score", 0.75)) * max(0.0, 1.0 - handicap)
4693 effective_margin = float(min_stability) * max(0.0, 1.0 - handicap)
4694 require_stable_state = bool(requirements.get("require_stable_state", True))
4695 state_ok = (stability_state == "stable") if require_stable_state else (stability_state != "unstable")
4696 if not state_ok:
4697 infeasibility_score += 1.0
4698 if effective_min_score > 0:
4699 infeasibility_score += max(0.0, (effective_min_score - stability_score) / effective_min_score) ** 2
4700 if effective_margin > 0:
4701 for _m in (chugging_margin, acoustic_margin, feed_margin):
4702 infeasibility_score += max(0.0, (effective_margin - _m) / effective_margin) ** 2
4703 _infeas_trace(_tr, "stability", infeasibility_score)
4704
4705 Cf_lo, Cf_hi = 1.3, 1.8
4706 _cf = float(Cf_actual) if np.isfinite(Cf_actual) else 0.0
4707 cf_hinge = (max(0.0, Cf_lo - _cf) ** 2 + max(0.0, _cf - Cf_hi) ** 2) / (Cf_hi - Cf_lo) ** 2
4708
4709 # Chamber lengths: the SAME helper _layer1_apply_chamber_geometry_to_config uses.
4710 theta_contraction = _layer1_contraction_theta(constants)
4711 _lens = _layer1_chamber_lengths(A_throat, Lstar, D_chamber_inner, theta_contraction)
4712 L_chamber_curr = _lens["length"]
4713 L_cylindrical = _lens["length_cylindrical"]
4714 eps_eff = (np.pi * D_exit_check ** 2 / 4.0) / A_throat if A_throat > 0 else expansion_ratio
4715 # Face to exit plane: face-to-throat plus the 80 % Rao bell the contour draws.
4716 L_engine_curr = _lens["face_to_throat"] + _layer1_bell_length_m(A_throat, eps_eff)
4717 max_engine_length = float(requirements.get("max_engine_length", 0.50) or 0.50)
4718 length_term = 0.0
4719 length_violation = False
4720 if np.isfinite(L_engine_curr) and max_engine_length > 0:
4721 if L_engine_curr > max_engine_length:
4722 length_violation = True
4723 length_term = ((L_engine_curr - max_engine_length) / max_engine_length) ** 2
4724 else:
4725 length_term = max(0.0, (L_engine_curr - max_engine_length * 0.9)
4726 / (max_engine_length * 0.1)) ** 2
4727
4728 smd_eff_um = _effective_smd_um(diagnostics, MR_actual if np.isfinite(MR_actual) else None)
4729
4730 # L* band around a target (SMD-derived by default). Every key from the requirements, with
4731 # the resolved L* search bounds from constants -- the worker used to read the keys from a
4732 # curated constants dict that lacked them and scored a different target.
4733 W_LSTAR = float(constants.get("layer1_W_LSTAR", 0.0) or 0.0)
4734 lstar_term = 0.0
4735 lstar_target_curr = float("nan")
4736 if W_LSTAR > 0.0:
4737 def _lstar_get(k):
4738 if k in ("min_Lstar", "max_Lstar") and constants.get(k) is not None:
4739 return constants.get(k)
4740 return requirements.get(k)
4741 lstar_target_curr, _lstar_db = _layer1_resolve_lstar_target(
4742 smd_eff_um, requirements, _lstar_get,
4743 float(constants.get("layer1_Lstar_target_m", 1.0) or 1.0))
4744 lstar_term = _layer1_lstar_band_term(Lstar, lstar_target_curr, _lstar_db)
4745
4746 # Figure of merit: delivered Isp, as the propellant it costs for the required impulse,
4747 # against the chamber it takes (both priced per kg; see run_layer1_optimization).
4748 W_ISP = float(constants.get("layer1_W_ISP", 0.0) or 0.0)
4749 isp_merit = _layer1_isp_merit_s(result, constants, cea_cache)
4750 isp_ref = float(constants.get("layer1_Isp_ref_s", np.nan) or np.nan)
4751 isp_term = _layer1_isp_term(isp_merit, isp_ref) if W_ISP > 0.0 else 0.0
4752
4753 W_MASS = float(constants.get("layer1_W_MASS", 0.0) or 0.0)
4754 chamber_mass_kg = _layer1_chamber_mass_kg(
4755 A_chamber_check, L_chamber_curr, TOTAL_WALL_THICKNESS_M,
4756 float(constants.get("layer1_chamber_wall_density_kg_m3", 2000.0)),
4757 Pc_pa=float(Pc_actual) if np.isfinite(Pc_actual) else 0.0,
4758 plate_support=str(constants.get("layer1_injector_plate_support", "clamped")),
4759 # The plug fills the sleeve bore: this candidate's bore plus the liner.
4760 r_plate_m=(0.5 * float(D_chamber_inner)
4761 + float(constants.get("layer1_injector_liner_thickness_m", 0.0) or 0.0)
4762 if constants.get("layer1_injector_plate_support") is not None else 0.0))
4763 mass_term = (_layer1_chamber_mass_term(
4764 chamber_mass_kg, float(constants.get("layer1_chamber_mass_ref_kg", 5.0)))
4765 if W_MASS > 0.0 else 0.0)
4766
4767 # Hard geometric constraints (element pitch, L_cyl/D) -> infeasibility.
4768 infeasibility_score += _layer1_geometry_infeasibility(
4769 constants, L_cylindrical=L_cylindrical, D_chamber_inner=D_chamber_inner,
4770 A_chamber=A_chamber_check, n_elements=n_el_O if inj_type == "impinging" else 0)
4771
4772 W_THRUST = float(constants.get("layer1_W_THRUST", 1e4))
4773 W_PC = float(constants.get("layer1_W_PC", 0.0))
4774 W_OF = float(constants.get("layer1_W_OF", 1e4))
4775 w_of_low_mr = max(1.0, float(constants.get("layer1_W_OF_low_MR_scale", 1.0)))
4776 w_of_high_mr = max(1.0, float(constants.get("layer1_W_OF_high_MR_scale", 1.0)))
4777 W_CF = 1e2
4778 W_EXIT = float(constants.get("layer1_W_EXIT", 2.0e2))
4779 W_LEN = 1e4
4780 W_MOM = float(constants.get("W_MOM", 75.0))
4781 W_DP = float(constants.get("W_DP", 160.0))
4782 W_SMD = float(constants.get("W_SMD", 0.0))
4783 W_ANGLE = float(constants.get("W_IMPINGING_ANGLE", 0.0))
4784 W_JET_ASYM = float(constants.get("W_IMPINGING_JET_ASYM", 0.0))
4785 _jet_asym_mx = constants.get("layer1_impinging_jet_angle_max_asym_deg")
4786 jet_asym_max = float(_jet_asym_mx) if _jet_asym_mx is not None else 28.0
4787 W_CHAMBER_SHAPE = float(constants.get("W_CHAMBER_SHAPE", 2500.0))
4788 W_TANK_EQUAL = float(constants.get("W_TANK_EQUAL", 0.0))
4789 W_IMP_GEOM = float(constants.get("W_IMP_GEOM", 0.0))
4790 W_geom_ao_af = float(constants.get("W_geom_ao_af_momentum", 0.0))
4791
4792 momentum_term = angle_term = jet_asym_term = tilt_term = 0.0
4793 W_TILT = float(constants.get("layer1_W_TILT", 0.0) or 0.0)
4794 R_val = float("nan")
4795 tilt_deg = float("nan")
4796 tilt_breakeven = float("nan")
4797 imp_angle_deg = float("nan")
4798 if inj_type == "impinging":
4799 try:
4800 imp_angle_deg = float(diagnostics.get("impingement_angle_deg", np.nan))
4801 except (TypeError, ValueError):
4802 imp_angle_deg = float("nan")
4803 tilt_breakeven = _resultant_tilt_breakeven_deg(
4804 n_elements=n_el_O, spacing_O_m=sp_O, spacing_F_m=sp_F,
4805 angle_O_deg=ang_O, angle_F_deg=ang_F,
4806 D_chamber_inner_m=D_chamber_inner, L_chamber_m=L_chamber_curr)
4807 _Rv = diagnostics.get("momentum_ratio_R")
4808 if _Rv is not None and np.isfinite(float(_Rv)) and float(_Rv) > 0:
4809 R_val = float(_Rv)
4810 # Wall guard: the RESULTANT TILT, a constraint. R = 1 is not balance.
4812 result.get("mdot_O"), result.get("mdot_F"),
4813 constants.get("rho_O", 1141.0), constants.get("rho_F", 789.0),
4814 n_el_O, d_jet_O, d_jet_F, ang_O, ang_F,
4815 lox_inboard=bool(constants.get("layer1_ring_order_fuel_outboard", True)),
4816 spacing_O_m=sp_O, spacing_F_m=sp_F)
4817 infeasibility_score += _impinging_resultant_wall_violation(
4818 tilt_deg,
4819 max_outward_deg=_resolve_tilt_allowance_deg(
4820 from_reach=bool(constants.get("layer1_resultant_tilt_from_reach", False)),
4821 constant_deg=float(constants.get("layer1_resultant_tilt_max_deg", 0.0)),
4822 breakeven_deg=tilt_breakeven,
4823 margin=float(constants.get("layer1_resultant_tilt_reach_margin", 1.5))),
4824 scale_deg=float(constants.get("layer1_resultant_tilt_scale_deg", 2.0)))
4825 _infeas_trace(_tr, "wall_tilt", infeasibility_score)
4826 _infeas_trace(_tr, "tilt_deg", tilt_deg)
4827 if W_TILT > 0.0:
4829 tilt_deg,
4830 scale_deg=float(constants.get("layer1_resultant_tilt_scale_deg", 2.0)),
4831 outward_multiplier=float(
4832 constants.get("layer1_resultant_tilt_outward_multiplier", 25.0) or 25.0))
4833 # The configured R band (its interior aim band, see run_layer1_optimization).
4834 _mlo = constants.get("impinging_momentum_R_min")
4835 _mhi = constants.get("impinging_momentum_R_max")
4837 R_val,
4838 wall_side_multiplier=float(constants.get("layer1_momentum_wall_side_multiplier", 1.0)),
4839 low_side_multiplier=float(constants.get("layer1_momentum_low_side_multiplier", 10.0)),
4840 band_width=float(constants.get("layer1_momentum_band_width", 0.05)),
4841 safe_side_is_below_one=bool(constants.get("layer1_ring_order_fuel_outboard", True)),
4842 scale=float(constants.get("layer1_momentum_scale", 0.10)),
4843 band_lo=float(_mlo) if _mlo is not None else None,
4844 band_hi=float(_mhi) if _mhi is not None else None,
4845 )
4846 if W_ANGLE > 0.0:
4847 _alo = constants.get("layer1_impinging_angle_deg_min")
4848 _ahi = constants.get("layer1_impinging_angle_deg_max")
4849 angle_term = _impinging_angle_hinge_squared(
4850 imp_angle_deg,
4851 angle_band_lo_deg=float(_alo) if _alo is not None else None,
4852 angle_band_hi_deg=float(_ahi) if _ahi is not None else None)
4853 if W_JET_ASYM > 0.0:
4855 ang_O, ang_F, max_delta_deg=jet_asym_max)
4856
4857 chamber_shape_term = 0.0
4858 dt_ratio = D_chamber_inner / D_throat_check if (D_chamber_inner > 0 and D_throat_check > 0) else float("nan")
4859 ld_ratio = L_chamber_curr / D_chamber_inner if (D_chamber_inner > 0 and L_chamber_curr > 0) else float("nan")
4860 if np.isfinite(dt_ratio):
4861 chamber_shape_term += _relative_hinge_band_squared(
4862 dt_ratio, float(constants.get("layer1_chamber_dt_ratio_min", 2.2)),
4863 float(constants.get("layer1_chamber_dt_ratio_max", 3.2)))
4864 if np.isfinite(ld_ratio):
4865 chamber_shape_term += _relative_hinge_band_squared(
4866 ld_ratio, float(constants.get("layer1_chamber_ld_ratio_min", 1.0)),
4867 float(constants.get("layer1_chamber_ld_ratio_max", 3.2)))
4868
4869 geom_ao_af_term = 0.0
4870 geom_ao_af_ratio = expected_ao_af = float("nan")
4871 if inj_type == "impinging" and W_geom_ao_af > 0.0 and A_fuel_injector > 0:
4872 geom_ao_af_term, geom_ao_af_ratio, expected_ao_af = _geom_ao_af_momentum_hint_squared(
4873 float(A_lox_injector), float(A_fuel_injector), float(optimal_of),
4874 float(constants.get("rho_oxidizer", 1140.0)), float(constants.get("rho_fuel", 422.6)))
4875 # A guidance aid: taper it as O/F approaches target.
4876 _of_tol_for_geom = max(0.05, float(constants.get("layer1_of_validation_tol", 0.15)))
4877 geom_ao_af_scale = min(1.0, float(of_error / _of_tol_for_geom) ** 2)
4878 geom_ao_af_weighted = W_geom_ao_af * geom_ao_af_term * geom_ao_af_scale
4879
4880 injector_dp_weighted = 0.0
4881 ratio_o = ratio_f = None
4882 if np.isfinite(Pc_actual) and Pc_actual > 0:
4883 ratio_o, ratio_f = injector_dp_ratios_from_eval_result(Pc_actual, result)
4884 injector_dp_weighted = injector_dp_ratio_penalty_weighted(
4885 ratio_o, ratio_f, W_DP, float(constants.get("W_DP_HIGH", 480.0)),
4886 o_band=(float(constants.get("injector_dp_ratio_O_min", _LAYER1_DEFAULT_DP_O_BAND[0])),
4887 float(constants.get("injector_dp_ratio_O_max", _LAYER1_DEFAULT_DP_O_BAND[1]))),
4888 f_band=(float(constants.get("injector_dp_ratio_F_min", _LAYER1_DEFAULT_DP_F_BAND[0])),
4889 float(constants.get("injector_dp_ratio_F_max", _LAYER1_DEFAULT_DP_F_BAND[1]))),
4890 w_dp_o=float(constants.get("W_DP_O", W_DP)),
4891 w_dp_f=float(constants.get("W_DP_F", W_DP)),
4892 o_soft_floor=(float(constants["injector_dp_ratio_O_soft_floor"])
4893 if constants.get("injector_dp_ratio_O_soft_floor") is not None else None),
4894 w_dp_o_floor=float(constants.get("W_DP_O_FLOOR", 0.0) or 0.0),
4895 w_dp_center=float(constants.get("W_DP_CENTER", 500.0)),
4896 )
4897
4898 smd_term = 0.0
4899 if inj_type == "impinging" and W_SMD > 0.0:
4900 smd_term, _, _ = _impinging_smd_penalty_with_angle(
4901 diagnostics,
4902 target_smd_microns=float(constants.get("target_smd_microns", 50.0)),
4903 smd_rel_tol=float(constants.get("layer1_smd_rel_tol", 0.20)),
4904 mr_mass=float(MR_actual) if np.isfinite(MR_actual) and MR_actual > 0 else None)
4905
4906 tank_equal_term = 0.0
4907 if W_TANK_EQUAL > 0.0:
4908 tank_equal_term = _tank_pressure_equal_squared(
4909 P_O_psi, P_F_psi, scale_psi=float(constants.get("layer1_tank_equal_scale_psi", 100.0)),
4910 tol_psi=float(constants.get("layer1_tank_equal_tol_psi", 0.0) or 0.0),
4911 inband_frac=float(constants.get("layer1_tank_equal_inband_frac", 0.0) or 0.0))
4912
4913 geom_fit_term = 0.0
4914 if inj_type == "impinging" and W_IMP_GEOM > 0.0:
4915 geom_fit_term = _impinging_geometry_fit_squared(
4916 diagnostics, D_chamber_inner_m=D_chamber_inner, L_chamber_m=L_chamber_curr)
4917 # Ring geometry from the design variables (the accelerator emits none of the keys
4918 # the diagnostics fit reads).
4919 if constants.get("layer1_enforce_ring_geometry", True):
4920 geom_fit_term += _impinging_ring_geometry_squared(
4921 n_elements=n_el_O, spacing_O_m=sp_O, spacing_F_m=sp_F,
4922 d_jet_O_m=d_jet_O, d_jet_F_m=d_jet_F, D_chamber_inner_m=D_chamber_inner,
4923 angle_O_deg=ang_O, angle_F_deg=ang_F,
4924 Ld_min=float(constants.get("layer1_impingement_Ld_min", _LD_BAND_DEFAULT.lo)),
4925 Ld_max=float(constants.get("layer1_impingement_Ld_max", _LD_BAND_DEFAULT.hi)),
4926 ring_order_fuel_outboard=bool(constants.get("layer1_ring_order_fuel_outboard", True)),
4927 center_clear_dia_m=float(constants.get("layer1_injector_center_clear_dia_m", 0.0) or 0.0),
4928 min_web_m=float(constants.get("layer1_injector_min_web_m", 0.0) or 0.0),
4929 wall_clearance_m=float(constants.get("layer1_injector_wall_clearance_m", 0.0) or 0.0),
4930 spray_radius_frac=float(constants.get("layer1_injector_spray_radius_frac", 0.0) or 0.0),
4931 spray_radius_tol=float(constants.get("layer1_injector_spray_radius_tol", 0.08) or 0.08),
4932 face_contoured=bool(constants.get("layer1_injector_face_contoured", False)),
4933 exit_land=float(constants.get("layer1_injector_exit_land", EXIT_LAND_DEFAULT)),
4934 )
4935
4936 if not np.isfinite(infeasibility_score) or infeasibility_score < 0:
4937 infeasibility_score = 1.0
4938 inf_residual = _layer1_inf_residual(infeasibility_score, requirements)
4939 # Face and back-face geometry are hard limits, not numerical residuals: they bypass the gate.
4940 if np.isfinite(geom_hard) and geom_hard > 0.0:
4941 inf_residual = float(inf_residual) + float(geom_hard)
4942
4943 of_sq = float(of_error) ** 2
4944 if np.isfinite(MR_actual) and optimal_of > 0:
4945 if MR_actual < optimal_of:
4946 of_sq *= w_of_low_mr
4947 elif MR_actual > optimal_of:
4948 of_sq *= w_of_high_mr
4949
4950 terms = {
4951 "thrust_penalty": float(W_THRUST * thrust_penalty_sq_term),
4952 "pc_target_penalty": float(W_PC * pc_penalty_term),
4953 "of_penalty": float(W_OF * of_sq),
4954 "cf_penalty": float(W_CF * cf_hinge),
4955 "exit_pressure_penalty": float(W_EXIT * exit_pressure_sq_term),
4956 "injector_dp_penalty": float(injector_dp_weighted),
4957 "length_penalty": float(W_LEN * length_term),
4958 "chamber_shape_penalty": float(W_CHAMBER_SHAPE * chamber_shape_term),
4959 "lstar_penalty": float(W_LSTAR * lstar_term),
4960 "isp_penalty": float(W_ISP * isp_term),
4961 "chamber_mass_penalty": float(W_MASS * mass_term),
4962 "momentum_balance_penalty": float(W_MOM * momentum_term),
4963 "resultant_tilt_penalty": float(W_TILT * tilt_term),
4964 "impingement_angle_penalty": float(W_ANGLE * angle_term),
4965 "jet_angle_asymmetry_penalty": float(W_JET_ASYM * jet_asym_term),
4966 "smd_penalty": float(W_SMD * smd_term),
4967 "tank_pressure_equal_penalty": float(W_TANK_EQUAL * tank_equal_term),
4968 "impinging_geometry_fit_penalty": float(W_IMP_GEOM * geom_fit_term),
4969 "geom_ao_af_momentum_penalty": float(geom_ao_af_weighted),
4970 }
4971 obj_quality = float(sum(terms.values()))
4972
4973 # Infeasibility carries a gradient, not a plateau: the quality terms ride on top so an
4974 # infeasible start can still descend toward thrust/O-F while it crosses the boundary.
4975 if length_violation:
4976 obj = _LAYER1_BASE_INFEAS + _LAYER1_W_INFEAS * length_term + obj_quality
4977 elif inf_residual > 0.0:
4978 obj = _LAYER1_BASE_INFEAS + _LAYER1_W_INFEAS * float(inf_residual) + obj_quality
4979 else:
4980 obj = obj_quality
4981 if not np.isfinite(obj):
4982 obj = _LAYER1_BASE_INFEAS
4983
4984 t_b = float(constants.get("target_burn_time", 0.0) or 0.0)
4985 out.update({
4986 "objective": float(obj),
4987 "objective_quality": obj_quality,
4988 "terms": terms,
4989 "eval_success": True,
4990 "infeasibility_score": float(infeasibility_score),
4991 "inf_residual": float(inf_residual),
4992 "length_violation": bool(length_violation),
4993 "F": F_actual, "MR": MR_actual, "Pc": Pc_actual, "Isp": Isp_actual,
4994 "Cf": Cf_actual, "P_exit": P_exit_actual,
4995 "thrust_error": float(thrust_error), "of_error": float(of_error),
4996 "thrust_penalty_sq": float(thrust_penalty_sq_term),
4997 "cf_hinge": float(cf_hinge),
4998 "stability_results": stability, "stability_state": stability_state,
4999 "stability_score": stability_score, "state_ok": bool(state_ok),
5000 "chugging_margin": chugging_margin, "acoustic_margin": acoustic_margin,
5001 "feed_margin": feed_margin, "effective_min_score": effective_min_score,
5002 "effective_margin": effective_margin,
5003 "injector_dp_ratio_O": ratio_o, "injector_dp_ratio_F": ratio_f,
5004 "momentum_ratio_R": R_val, "resultant_tilt_deg": tilt_deg,
5005 "resultant_tilt_breakeven_deg": tilt_breakeven,
5006 "effective_smd_microns": smd_eff_um, "impingement_angle_deg": imp_angle_deg,
5007 "chamber_D_over_Dt": dt_ratio, "chamber_L_over_D": ld_ratio,
5008 "L_chamber": L_chamber_curr, "L_cylindrical": L_cylindrical,
5009 "L_face_to_throat": _lens["face_to_throat"], "L_engine": float(L_engine_curr),
5010 "Lstar_m": Lstar, "Lstar_target_m": lstar_target_curr,
5011 "chamber_mass_kg": float(chamber_mass_kg),
5012 "Isp_merit_s": isp_merit, "Isp_ref_s": isp_ref,
5013 "propellant_mass_kg": (float(target_thrust * t_b / (_G0 * isp_merit))
5014 if (t_b > 0 and np.isfinite(isp_merit) and isp_merit > 0) else float("nan")),
5015 "geom_ao_af_momentum_scale": float(geom_ao_af_scale),
5016 "geom_ao_af": geom_ao_af_ratio, "expected_ao_af_for_R1": expected_ao_af,
5017 })
5018 return out if return_terms else float(obj)
5019
5020
5022 result: Any, x: np.ndarray, requirements: dict, constants: dict, *,
5023 cea_cache: Any = None, eval_error: Optional[str] = None,
5024) -> Dict[str, Any]:
5025 """:func:`_compute_objective_value` with its terms and context."""
5026 return _compute_objective_value(result, x, requirements, constants, cea_cache,
5027 eval_error=eval_error, return_terms=True)
5028
5029
5031 """Whether the Layer-1 inner loop uses the single-call native ed_evaluate.
5032
5033 On whenever the accelerator is enabled (the default). Set
5034 ED_LAYER1_NATIVE_EVAL=0 to force the full Python path for the inner loop
5035 (debugging / A-B parity) without disabling the accelerator elsewhere.
5036 """
5037 import os
5038 if os.environ.get("ED_LAYER1_NATIVE_EVAL", "1") != "1":
5039 return False
5040 from engine import accel
5041 return accel.enabled()
5042
5043
5045 """Pure evaluation function for parallel execution.
5046
5047 Reuses worker's PintleEngineRunner by mutating its config in-place.
5048 Assumes x_raw is already snapped (integers) and within bounds (CMA-ES handles this).
5049 Returns lightweight scalar diagnostics only.
5050
5051 Args:
5052 x_raw: Candidate vector (already snapped and bounded)
5053
5054 Returns:
5055 dict with 'value', 'success', and optional diagnostics
5056 """
5057 try:
5058 # x_raw is already snapped and bounded - just convert to array
5059 x = np.asarray(x_raw, dtype=np.float64)
5060
5061 # Apply x to runner's config IN-PLACE (no clipping, CMA-ES handles bounds)
5062 _apply_x_to_worker_config_inplace(x, _worker_runner.config, _worker_constants)
5063
5064 # Extract pressures from x (indices depend on injector type)
5065 io = int(_worker_constants.get("idx_P_O", 8))
5066 iof = int(_worker_constants.get("idx_P_F", 9))
5067 P_O_psi = float(x[io])
5068 P_F_psi = float(x[iof])
5069 P_O_Pa = P_O_psi * 6894.76
5070 P_F_Pa = P_F_psi * 6894.76
5071
5072 # Evaluate using worker's runner (reused across calls).
5073 # Inner-loop fast path: one accelerated evaluate (chamber + nozzle + thrust)
5074 # + accelerated stability, ~25x faster per candidate. Falls back to the full
5075 # Python path for unsupported configs (e.g. pintle) or a non-converged
5076 # accelerated solve. The winning design is always re-evaluated at full
5077 # Python fidelity at finalization.
5078 #
5079 # NOTE: both paths compute the SAME delivered thrust
5080 # (F = zeta_n*Cf_vac*Pc*At - Pa*Ae). The old "frozen vs shifting nozzle"
5081 # distinction is gone -- the shifting-equilibrium nozzle was retired
5082 # 2026-06-28 (reaction_chemistry.py) in favour of CEA's Cf_vac, which is
5083 # itself a shifting-equilibrium coefficient baked into the cache.
5084 def _evaluate_once():
5085 _res = None
5086 # Re-read pressures from x each time: the derived-DOF solve may move them.
5087 P_O_Pa = float(x[io]) * 6894.76
5088 P_F_Pa = float(x[iof]) * 6894.76
5090 # NOTE: `from engine import accel` + attribute access is load-bearing.
5091 # tests/test_accel_is_actually_used.py patches accel.evaluate to prove
5092 # this seam is still reached; a `from engine.accel import evaluate`
5093 # import would bind at import time and silently defeat that guard.
5094 from engine import accel as _accel
5095 _res = _accel.evaluate(
5096 _worker_runner.config, _worker_runner.cea_cache,
5097 P_O_Pa, P_F_Pa, _worker_constants['P_ambient'])
5098 if _res is None:
5099 _res = _worker_runner.evaluate(
5100 P_O_Pa, P_F_Pa,
5101 P_ambient=_worker_constants['P_ambient'],
5102 debug=False,
5103 silent=True
5104 )
5105 return _res
5106
5107 result = None
5108 _first_err = None
5109 _prev_grow = 1.0
5110 for _grow in (1.0, 1.3, 1.7, 2.2):
5111 if _grow != 1.0:
5112 # A "Supply < Demand at all Pc" first evaluation is not a bad candidate, it is
5113 # a throat too small to bracket a Pc for this injector -- exactly what the
5114 # derived-throat solve would fix, except that solve never runs without a first
5115 # result. Grow the throat and try again before declaring the candidate dead;
5116 # otherwise the whole region around the correct O/F is unreachable (measured:
5117 # every candidate at the right area ratio died here and CMA parked at MR 2.2).
5118 _at_hi = float(_worker_constants.get("derive_At_max", np.inf))
5119 x[0] = float(min(x[0] * (_grow / _prev_grow), _at_hi))
5120 _apply_x_to_worker_config_inplace(x, _worker_runner.config, _worker_constants)
5121 _prev_grow = _grow
5122 try:
5123 result = _evaluate_once()
5124 except ValueError as _ve:
5125 if "Supply < Demand" in str(_ve) or "Insufficient mass flow" in str(_ve):
5126 _first_err = _ve
5127 result = None
5128 else:
5129 raise
5130 if result is not None:
5131 break
5132 if not bool(_worker_constants.get("layer1_derive_throat_from_thrust", True)):
5133 break
5134 if result is None and _first_err is not None:
5135 raise _first_err
5136
5137 # Solve the derived DOFs (expansion ratio, throat area) and re-evaluate.
5138 # Mutates x in place so _compute_objective_value and the cached design
5139 # below both see the geometry that was actually evaluated.
5140 _cea = getattr(_worker_runner, "cea_cache", None)
5142 x, _worker_runner.config, _worker_constants, _evaluate_once, result,
5143 cea_cache=_cea,
5144 )
5145
5146 _terms = _layer1_objective_terms(
5147 result, x, _worker_requirements, _worker_constants, cea_cache=_cea)
5148 obj_value = float(_terms["objective"])
5149 _f = float(result.get("F", 0))
5150 _mr = float(result.get("MR", 0))
5151 thr_e = float(_terms.get("thrust_error", 1.0))
5152 of_e = float(_terms.get("of_error", 1.0))
5153 _mom_term = float(_terms["terms"].get("momentum_balance_penalty", 0.0))
5154 return {
5155 'value': float(obj_value),
5156 'success': True,
5157 'F': _f,
5158 'MR': _mr,
5159 'Pc': float(result.get('Pc', 0)),
5160 # No copy: ``result`` is a fresh dict (runner.evaluate and accel.evaluate
5161 # both build one per call and retain no reference to it), nothing here mutates
5162 # it, and returning it hands it straight to the pool -- which pickles it, and
5163 # pickling already yields an object the parent owns outright. The deepcopy was
5164 # a third copy of data that crosses a process boundary anyway, and it cost
5165 # 0.12 ms against a 1.17 ms evaluate(), i.e. ~10% of every worker candidate.
5166 'full_results': result,
5167 # x after the derived DOFs were solved. Differs from the candidate
5168 # CMA-ES proposed whenever A_throat / expansion_ratio are derived;
5169 # the parent must report *this* vector, not the proposal.
5170 'x_solved': x.tolist(),
5171 'P_O_Pa': float(P_O_Pa),
5172 'P_F_Pa': float(P_F_Pa),
5173 'thrust_error': float(thr_e),
5174 'of_error': float(of_e),
5175 'momentum_term': float(_mom_term),
5176 'inf_residual': float(_layer1_inf_residual_from_objective(float(obj_value))),
5177 }
5178 except Exception as e:
5179 if _worker_debug_strict:
5180 # Strict mode: re-raise to crash fast (for debugging)
5181 raise
5182 else:
5183 # Robust mode: return structured penalty
5184 # Classify error to give more useful feedback to optimizer/logger
5185 err_type = type(e).__name__
5186 err_msg = str(e).lower()
5187
5188 # A bug in the evaluation code (NameError, AttributeError, TypeError, ...) is
5189 # NOT a bad candidate -- but this handler used to bury it as one, so every
5190 # candidate "failed" and the run merely looked like it converged badly. These
5191 # can never be a property of the design vector, so re-raise them and let the
5192 # run die loudly instead of silently scoring garbage.
5193 if isinstance(e, (NameError, AttributeError, ImportError, SyntaxError)):
5194 raise
5195
5196 # Base penalty for failed evaluation (much lower than 1e10 to avoid destroying covariance)
5197 # Feasible solutions are ~2e4 - 5e4
5198 penalty_base = 1.0e7
5199
5200 # Categorize failure
5201 if "geometry" in err_msg or "bound" in err_msg:
5202 # Geometry/Bound violation (Critical but maybe structural)
5203 penalty_val = penalty_base * 1.0
5204 fail_reason = "GeometryViolation"
5205 elif "negative" in err_msg or "pressure" in err_msg or "choked" in err_msg:
5206 # Physics failure (Negative area, pressure, unchoked, etc.)
5207 penalty_val = penalty_base * 0.5 # Slightly "better" failure than total nonsense
5208 fail_reason = "PhysicsViolation"
5209 elif "solver" in err_msg or "convergence" in err_msg:
5210 # Numerical solver failure
5211 penalty_val = penalty_base * 0.2 # Least bad failure, might just be stiff region
5212 fail_reason = "SolverConvergence"
5213 else:
5214 # Unknown/Generic
5215 penalty_val = penalty_base * 0.8
5216 fail_reason = f"Error_{err_type}"
5217
5218 return {
5219 'value': float(penalty_val),
5220 'success': False,
5221 'error_type': fail_reason,
5222 'error_msg': str(e)[:200],
5223 }
5224
5225
5227 x0: np.ndarray,
5228 *,
5229 lower_bounds: np.ndarray,
5230 upper_bounds: np.ndarray,
5231 span: np.ndarray,
5232 integer_dims: List[int],
5233 rng: np.random.Generator,
5234 executor: Any,
5235 n_trials: int,
5236 sigma_frac: float,
5237 eval_cache: Dict[Any, Any],
5238 make_cache_key_fn: Callable[[np.ndarray], Any],
5239 opt_state: Dict[str, Any],
5240 logger: logging.Logger,
5241) -> Tuple[np.ndarray, float]:
5242 """Local cloud around ``x0`` using the **same** ``_eval_candidate`` path as CMA-ES.
5243
5244 Picks the clipped/snapped vector with lowest scalar objective. This only replaces the
5245 CMA initial mean when ``n_trials > 0``; it does not alter ``_compute_objective_value``,
5246 validation gates, or final ``evaluate()`` physics.
5247 """
5248 x0_snap = _snap_integer_dims(
5249 np.clip(np.asarray(x0, dtype=float), lower_bounds, upper_bounds),
5250 integer_dims,
5251 )
5252 if n_trials <= 0:
5253 return x0_snap.copy(), float("inf")
5254
5255 n_t = int(max(1, n_trials))
5256 candidates: List[np.ndarray] = [x0_snap.copy()]
5257 sig = float(max(sigma_frac, 0.0))
5258 scale = np.asarray(span, dtype=float)
5259 # Multi-ring perturbations (tighter + moderate + wider) to escape ``BASE_INFEAS`` plateau.
5260 fracs = (
5261 float(max(0.008, 0.45 * sig)),
5262 float(sig),
5263 float(min(0.22, max(sig * 1.65, sig + 0.02))),
5264 )
5265 for k in range(n_t - 1):
5266 f_k = fracs[k % len(fracs)]
5267 noise = rng.standard_normal(len(x0_snap)) * f_k * scale
5268 xq = np.clip(x0_snap + noise, lower_bounds, upper_bounds)
5269 candidates.append(_snap_integer_dims(xq, integer_dims))
5270
5271 nw = int(getattr(executor, "_max_workers", 1) or 1)
5272 chunksize = max(1, len(candidates) // (nw * 4))
5273 results = list(executor.map(_eval_candidate, candidates, chunksize=chunksize))
5274
5275 best_f = float("inf")
5276 best_x = x0_snap.copy()
5277 for cand, res in zip(candidates, results):
5278 obj_val = float(res["value"])
5279 cache_key = make_cache_key_fn(cand)
5280 eval_cache[cache_key] = {
5281 "value": obj_val,
5282 "success": bool(res.get("success", False)),
5283 }
5284 opt_state["function_evaluations"] = int(opt_state.get("function_evaluations", 0)) + 1
5286 if not res.get("success", True):
5287 reason = res.get("error_type", "Unknown")
5288 opt_state["num_failures"] = opt_state.get("num_failures", 0) + 1
5289 if "fail_counts" not in opt_state:
5290 opt_state["fail_counts"] = {}
5291 opt_state["fail_counts"][reason] = opt_state["fail_counts"].get(reason, 0) + 1
5292 if obj_val < best_f:
5293 best_f = obj_val
5294 best_x = cand.copy()
5295
5296 logger.info(
5297 "Layer 1 CMA warm-start: %d candidates (sigma_frac=%.4g × span), best objective=%.6g",
5298 len(candidates),
5299 sigma_frac,
5300 best_f,
5301 )
5302 return best_x, best_f
5303
5304
5306 opt_state: Dict[str, Any],
5307 x_vec: np.ndarray,
5308 obj_val: float,
5309 *,
5310 parent_objective: Optional[Callable[[np.ndarray], float]] = None,
5311) -> None:
5312 """Keep ``best_objective`` / ``best_x`` aligned with parallel ``_eval_candidate`` results.
5313
5314 When ``parent_objective`` is supplied, re-run the full parent ``objective()`` once on a
5315 new global best so ``iteration_history``, ``best_config``, and penalty breakdown fields
5316 stay populated (parallel workers only return scalars).
5317 """
5318 if not np.isfinite(obj_val):
5319 return
5320 cur = float(opt_state.get("best_objective", float("inf")))
5321 if obj_val < cur and _layer1_feasible_scalar_objective(obj_val):
5322 opt_state["best_objective"] = float(obj_val)
5323 opt_state["best_x"] = np.asarray(x_vec, dtype=float).copy()
5324 opt_state["last_best_eval"] = int(opt_state.get("function_evaluations", 0))
5325 if parent_objective is not None:
5326 try:
5327 parent_objective(np.asarray(x_vec, dtype=float))
5328 except Exception:
5329 pass
5330
5331
5333 objective_callback: Optional[Callable[[int, float, float], None]],
5334 opt_state: Dict[str, Any],
5335 iteration: int,
5336 current_obj: float,
5337 best_obj: float,
5338) -> None:
5339 """SSE / UI objective curve (parallel CMA does not call ``objective()``).
5340
5341 ``iteration`` arrives from two different counters -- the parent candidate index in
5342 ``_eval_candidate`` and ``function_evaluations`` in the parallel CMA generation loop --
5343 and both are reset between phases. Plotted raw, the x-axis folded back on itself
5344 (observed: 1, 17, 65, 113, 161, 209, then 2, 3, 4 ...), which made the curve unreadable.
5345 Clamp to a non-decreasing sequence here, at the single point every emission passes
5346 through, rather than trying to keep every call site's counter in sync.
5347 """
5348 if objective_callback is None:
5349 return
5350 x = int(iteration)
5351 x_max = int(opt_state.get("_plot_x_max", 0))
5352 if x <= x_max:
5353 x = x_max + 1
5354 opt_state["_plot_x_max"] = x
5355 try:
5356 objective_callback(x, float(current_obj), float(best_obj))
5357 except Exception:
5358 pass
5359
5360
5361def _layer1_infeasibility_reason(runner, x, requirements: dict, constants: dict) -> Optional[str]:
5362 """One sentence on WHICH hard constraint held the best candidate out of the feasible set.
5363
5364 A run whose every candidate sat on the 1e6 infeasibility floor used to end with
5365 "objective inf" and "Validation failed", which told the user nothing. Re-evaluate the best
5366 design in-process with the infeasibility trace on and name the dominant gate.
5367 """
5368 global _INFEAS_TRACE_ON
5369 try:
5370 idx_P_O = 11 if constants.get("injector_type") == "impinging" else 8
5371 P_O = float(x[idx_P_O]) * 6894.76
5372 P_F = float(x[idx_P_O + 1]) * 6894.76
5373 result = runner.evaluate(P_O, P_F, silent=True)
5374 except Exception as e:
5375 return f"No feasible design: the best candidate could not even be evaluated ({type(e).__name__}: {str(e)[:120]})."
5376 prev, n0 = _INFEAS_TRACE_ON, len(_INFEAS_TRACE)
5377 _INFEAS_TRACE_ON = True
5378 try:
5379 _compute_objective_value(result, np.asarray(x, dtype=float), requirements, constants)
5380 except Exception as e:
5381 return f"No feasible design: objective re-evaluation failed ({type(e).__name__})."
5382 finally:
5383 _INFEAS_TRACE_ON = prev
5384 if len(_INFEAS_TRACE) <= n0:
5385 return None
5386 tr = _INFEAS_TRACE.pop()
5387 geom = float(tr.get("geometry", 0.0))
5388 stab = float(tr.get("stability", geom)) - geom
5389 wall = float(tr.get("wall_tilt", tr.get("stability", geom))) - float(tr.get("stability", geom))
5390 parts = []
5391 if wall > 0:
5392 tilt = tr.get("tilt_deg", float("nan"))
5393 lim = float(constants.get("layer1_resultant_tilt_max_deg", 0.0))
5394 parts.append((wall, f"the spray resultant tilts {tilt:+.1f} deg outward toward the wall (limit {lim:g} deg) -- "
5395 "lower the oxidizer jet angle, raise the fuel jet angle, or widen the angle bands"))
5396 if stab > 0:
5397 parts.append((stab, "a stability gate (minimum score / margins) is not met -- lower min_stability_score or stiffen the injector"))
5398 if geom > 0:
5399 parts.append((geom, "injector packing, flow capacity, or chamber proportions violate a hard limit -- widen the chamber OD or the jet bounds"))
5400 if not parts:
5401 # Re-evaluated in isolation the best design passes every gate: the search is stuck ON a
5402 # constraint boundary and CMA's samples keep landing a hair outside it. For a doublet that
5403 # is almost always the spray-resultant tilt limit, which defaults to exactly 0 deg outward.
5404 lim = float(constants.get("layer1_resultant_tilt_max_deg", 0.0))
5405 if constants.get("injector_type") == "impinging":
5406 return ("No candidate cleared every hard constraint, yet the best design re-evaluates as feasible "
5407 f"on its own: the search is pinned on the spray-resultant tilt limit ({lim:g} deg outward). "
5408 "Re-run, or allow a degree of outward tilt (layer1_resultant_tilt_max_deg) if the liner can take it.")
5409 return ("No candidate cleared every hard constraint, yet the best design re-evaluates as feasible on its "
5410 "own: the search is pinned on a constraint boundary. Re-run, or relax the tightest gate slightly.")
5411 parts.sort(key=lambda t: -t[0])
5412 return "No candidate cleared every hard constraint. On the best one, " + parts[0][1] + "."
5413
5414
5416 objective: Callable[[np.ndarray], float],
5417 bounds: list,
5418 x0: np.ndarray,
5419 max_evals: int = 150,
5420 random_seed: int = 42,
5421) -> np.ndarray:
5422 """
5423 Lightweight global search for Layer 1 using random sampling + short DE.
5424
5425 This is intended to very quickly improve the starting point before the
5426 main local optimizer (L-BFGS-B) runs in the orchestrator.
5427
5428 - Keeps evaluation budget small (max_evals) to avoid long runtimes.
5429 - Always respects the provided bounds.
5430 - Falls back gracefully if scipy's differential_evolution is unavailable.
5431 """
5432 try:
5433 from scipy.optimize import differential_evolution
5434 except Exception:
5435 differential_evolution = None
5436
5437 if max_evals <= 0 or objective is None:
5438 return x0
5439
5440 rng = np.random.default_rng(random_seed)
5441
5442 bounds_arr = np.asarray(bounds, dtype=float)
5443 lower = bounds_arr[:, 0]
5444 upper = bounds_arr[:, 1]
5445
5446 # Ensure starting point is within bounds
5447 best_x = np.clip(np.asarray(x0, dtype=float), lower, upper)
5448 try:
5449 best_f = float(objective(best_x))
5450 except Exception:
5451 # If evaluation fails, just return the original guess
5452 return x0
5453
5454 evals_used = 1
5455 dim = best_x.size
5456
5457 # ------------------------------------------------------------------
5458 # Phase 1: Random sampling within bounds (very small number of points)
5459 # ------------------------------------------------------------------
5460 n_random = max(5, min(20, max_evals // 3))
5461 for _ in range(n_random):
5462 if evals_used >= max_evals:
5463 break
5464 candidate = lower + rng.random(dim) * (upper - lower)
5465 try:
5466 f_val = float(objective(candidate))
5467 except Exception:
5468 evals_used += 1
5469 continue
5470 evals_used += 1
5471 if np.isfinite(f_val) and f_val < best_f:
5472 best_f = f_val
5473 best_x = candidate
5474
5475 # ------------------------------------------------------------------
5476 # Phase 2: Very short Differential Evolution (if available)
5477 # ------------------------------------------------------------------
5478 if differential_evolution is not None and evals_used < max_evals:
5479 # Rough heuristic to keep DE cheap; cap iterations and population.
5480 # For typical Layer 1 dimensionality (~20 vars) this keeps runtime modest.
5481 remaining_evals = max_evals - evals_used
5482 popsize = 8
5483 # Each DE iter uses approximately popsize * dim evaluations
5484 approx_evals_per_iter = max(1, popsize * dim)
5485 maxiter = max(1, min(5, remaining_evals // approx_evals_per_iter))
5486
5487 if maxiter > 0:
5488 # Wrap objective to track best solution without exceeding budget
5489 def wrapped_obj(v: np.ndarray) -> float:
5490 nonlocal best_x, best_f, evals_used
5491 if evals_used >= max_evals:
5492 # Return current best to encourage convergence without new work
5493 return best_f
5494 try:
5495 f_val_inner = float(objective(v))
5496 except Exception:
5497 evals_used += 1
5498 return 1e7
5499 evals_used += 1
5500 if np.isfinite(f_val_inner) and f_val_inner < best_f:
5501 best_f = f_val_inner
5502 best_x = np.asarray(v, dtype=float)
5503 return f_val_inner
5504
5505 try:
5506 _ = differential_evolution(
5507 wrapped_obj,
5508 bounds=bounds,
5509 maxiter=maxiter,
5510 popsize=popsize,
5511 tol=0.01,
5512 polish=False,
5513 updating="deferred",
5514 mutation=(0.5, 1.0),
5515 recombination=0.7,
5516 seed=random_seed,
5517 )
5518 except Exception:
5519 # If DE fails for any reason, just keep the best point found so far.
5520 pass
5521
5522 return best_x
5523
5524
5525def _layer1_check_of_target_in_cea_range(config_obj: Any, optimal_of: Any) -> None:
5526 """Refuse a target O/F the propellant's CEA table cannot evaluate.
5527
5528 A propellant switch keeps the previous design target, so an ethalox target of 1.4 left
5529 behind on a methalox config used to run a full optimization against a table that stops at
5530 2.4 -- the mixture ratio pinned at the table edge and the run returned a huge objective with
5531 nothing to say why. Fail before any work is done, with the fix in the message.
5532 """
5533 try:
5534 mr_range = config_obj.combustion.cea.MR_range
5535 lo, hi = float(mr_range[0]), float(mr_range[1])
5536 of = float(optimal_of)
5537 except (AttributeError, TypeError, ValueError, IndexError):
5538 return
5539 if not (np.isfinite(of) and lo <= of <= hi):
5540 preset = getattr(config_obj, "propellant_preset", None) or "this propellant"
5541 raise ValueError(
5542 f"Design target O/F {of:.2f} is outside the CEA table for {preset} "
5543 f"(MR_range [{lo:.2f}, {hi:.2f}]). Set optimal_of_ratio inside that range in Design "
5544 f"Requirements -- switching propellant keeps the previous target."
5545 )
5546
5547
5549 config_obj: Any, optimal_of: Any, cea_cache: Any = None, Pc_pa: float = 3.0e6,
5550 Pa: float = 101325.0,
5551) -> Tuple[float, Optional[str]]:
5552 """The O/F Layer 1 designs to, and a warning when it is not the one requested.
5553
5554 A target outside the propellant's CEA table (a blank template keeps the previous
5555 propellant's O/F) cannot be evaluated. Rather than abort, design to the O/F of peak ideal
5556 ambient Isp at ``Pc_pa`` inside the table -- the conventional starting point (Sutton &
5557 Biblarz ch. 5) -- or, without a table, the nearest table edge, and say so.
5558 """
5559 try:
5560 lo, hi = (float(v) for v in config_obj.combustion.cea.MR_range)
5561 except (AttributeError, TypeError, ValueError):
5562 return float(optimal_of), None
5563 try:
5564 of = float(optimal_of)
5565 except (TypeError, ValueError):
5566 of = float("nan")
5567 if np.isfinite(of) and lo <= of <= hi:
5568 return of, None
5569 preset = getattr(config_obj, "propellant_preset", None) or "this propellant"
5570 grid = np.linspace(lo, hi, 121)
5571 isp = np.array([_layer1_ideal_isp_s(
5572 cea_cache, m, Pc_pa, Pa,
5573 _layer1_eps_for_exit_pressure_cea(cea_cache, m, Pc_pa, Pa) or getattr(cea_cache, "eps_min", 4.0))
5574 for m in grid]) if _layer1_cea_ok(cea_cache) else np.full(grid.shape, np.nan)
5575 if np.any(np.isfinite(isp)):
5576 new = float(grid[int(np.nanargmax(isp))])
5577 why = f"the peak ideal Isp O/F at {Pc_pa / 6894.757:.0f} psia"
5578 else:
5579 new = float(np.clip(of if np.isfinite(of) else 0.5 * (lo + hi), lo, hi))
5580 why = "the nearest table edge"
5581 msg = (f"Design target O/F {of:.2f} is outside the CEA table for {preset} "
5582 f"(MR_range [{lo:.2f}, {hi:.2f}]); designing to O/F {new:.3f}, {why}. Set "
5583 f"optimal_of_ratio in Design Requirements -- switching propellant keeps the previous target.")
5584 return new, msg
5585
5586
5588 config_obj: PintleEngineConfig,
5589 runner: PintleEngineRunner,
5590 requirements: Dict[str, Any],
5591 target_burn_time: float,
5592 tolerances: Dict[str, float],
5593 pressure_config: Dict[str, Any],
5594 update_progress: Optional[Callable[[str, float, str], None]] = None,
5595 log_status: Optional[Callable[[str, str], None]] = None,
5596 objective_callback: Optional[Callable[[int, float, float], None]] = None,
5597 stop_event: Optional[Any] = None, # threading.Event for stop signal
5598 layer1_max_iterations: Optional[int] = None,
5599 layer1_cma_restarts: Optional[int] = None,
5600 layer1_smoke: bool = False,
5601) -> Tuple[PintleEngineConfig, Dict[str, Any]]:
5602 """
5603 Run complete Layer 1 optimization: geometry + initial tank pressures.
5604
5605 This function contains ALL Layer 1 optimization logic:
5606 - Setup (bounds, initial guess)
5607 - Objective function definition
5608 - Optimization loop (CMA-ES with random restarts + L-BFGS-B)
5609 - Validation
5610 - Results packaging
5611
5612 Note:
5613 Default ``max_iterations`` is 150 for robust convergence (override via ``layer1_max_iterations``
5614 for smoke tests). Design vector is **10** DOFs for pintle and **13** for impinging injectors
5615 (paired unlike doublets: shared ``n_doublets`` for LOX/fuel counts, independent jets per side).
5616 Random CMA restarts default to 3 (override via ``layer1_cma_restarts``).
5617 Set ``layer1_smoke=True`` to force pure CMA-ES (ignore ``hybrid_cma_blocks``) and,
5618 unless ``layer1_cma_restarts`` is set explicitly, use a single CMA restart for short runs.
5619
5620 Args:
5621 config_obj: Base engine configuration
5622 runner: Engine runner (for validation)
5623 requirements: Design requirements dict
5624 target_burn_time: Target burn time [s]
5625 tolerances: Tolerance dict (thrust, apogee)
5626 pressure_config: Pressure configuration dict
5627 update_progress: Optional progress callback (stage, progress, message)
5628 log_status: Optional status logging callback (stage, message)
5629 objective_callback: Optional callback for objective history (iteration, objective, best_objective)
5630 layer1_smoke: When True, force pure CMA-ES and (if ``layer1_cma_restarts`` is unset) a single restart.
5631
5632 Returns:
5633 optimized_config: Optimized engine configuration
5634 results: Results dict with performance, validation, history, etc.
5635 """
5636
5637 # Set up Layer 1 logging
5638 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
5639 # Ensure output/logs directory exists
5640 output_logs_dir = Path(__file__).resolve().parents[3] / "output" / "logs"
5641 output_logs_dir.mkdir(parents=True, exist_ok=True)
5642 # PID too: two runs started in the same second (a sweep) wrote one interleaved file.
5643 log_file_path = output_logs_dir / f"layer1_static_{timestamp}_{os.getpid()}.log"
5644
5645 # Create logger for Layer 1
5646 layer1_logger = logging.getLogger('layer1_static')
5647 layer1_logger.setLevel(logging.INFO)
5648
5649 # Remove existing handlers to avoid duplicates
5650 layer1_logger.handlers.clear()
5651
5652 # File handler
5653 file_handler = logging.FileHandler(log_file_path, mode='w', encoding='utf-8')
5654 file_handler.setLevel(logging.INFO)
5655 file_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
5656 file_handler.setFormatter(file_formatter)
5657 layer1_logger.addHandler(file_handler)
5658
5659 # Prevent propagation to root logger
5660 layer1_logger.propagate = False
5661
5662 layer1_logger.info("="*70)
5663 layer1_logger.info("Layer 1: Static Optimization")
5664 layer1_logger.info("="*70)
5665 layer1_logger.info(f"Log file: {log_file_path}")
5666
5667 # Default callbacks
5668 if update_progress is None:
5669 def update_progress(stage: str, progress: float, message: str):
5670 pass
5671 if log_status is None:
5672 def log_status(stage: str, message: str):
5673 pass
5674
5675 # Helper to check stop event
5676 class OptimizationStopped(Exception):
5677 """Exception raised when optimization is stopped by user."""
5678 pass
5679
5680 def check_stop():
5681 """Check if optimization should stop and raise exception if so."""
5682 if stop_event is not None and stop_event.is_set():
5683 raise OptimizationStopped("Optimization stopped by user")
5684
5685 # Extract requirements
5686 target_thrust = requirements.get("target_thrust", 7000.0)
5687 optimal_of = requirements.get("optimal_of_ratio", 2.3)
5688 layer1_warnings: List[str] = []
5689 min_stability = float(requirements.get("min_stability_margin", _LAYER1_DEFAULT_MIN_STABILITY_MARGIN))
5690
5691 def _resolve_Lstar_bounds_from_req_and_config() -> Tuple[float, float]:
5692 """Layer‑1 DOF ``x[1]`` is L* [m]. Prefer ``requirements``; fill missing keys from ``config_obj.design_requirements``."""
5693 mn_raw = requirements.get("min_Lstar", None)
5694 mx_raw = requirements.get("max_Lstar", None)
5695 try:
5696 dr = getattr(config_obj, "design_requirements", None)
5697 if mn_raw is None and dr is not None:
5698 mn_raw = getattr(dr, "min_Lstar", None)
5699 if mx_raw is None and dr is not None:
5700 mx_raw = getattr(dr, "max_Lstar", None)
5701 except (TypeError, ValueError, AttributeError):
5702 pass
5703 try:
5704 mn = float(mn_raw) if mn_raw is not None else 0.95
5705 except (TypeError, ValueError):
5706 mn = 0.95
5707 try:
5708 mx = float(mx_raw) if mx_raw is not None else 1.27
5709 except (TypeError, ValueError):
5710 mx = 1.27
5711 if not np.isfinite(mn) or mn <= 0:
5712 mn = 0.95
5713 if not np.isfinite(mx) or mx <= 0:
5714 mx = 1.27
5715 if mx <= mn:
5716 mx = mn + 1e-6
5717 return mn, mx
5718
5719 min_Lstar, max_Lstar = _resolve_Lstar_bounds_from_req_and_config()
5720 layer1_logger.info(
5721 "Layer 1 optimizing L* [m] within [%.5f, %.5f] (x[1]); freeze ``design_requirements.frozen_parameters.Lstar_mm`` for a single target.",
5722 min_Lstar,
5723 max_Lstar,
5724 )
5725
5726 max_chamber_od = requirements.get("max_chamber_outer_diameter", 0.15)
5727 # Total OD->inner-diameter wall, DERIVED per side as metal case + ablative liner
5728 # (see utils.total_wall_thickness_m). Layer 3 re-sizes the liner field itself,
5729 # so reruns pick up the sized stack automatically.
5730 wall_total_m = _layer1_total_wall_thickness_m(config_obj)
5731 layer1_logger.info(
5732 "Layer 1 wall (OD - inner): %.2f mm total (%.2f mm/side = metal case + ablative liner).",
5733 wall_total_m * 1e3, wall_total_m * 0.5e3,
5734 )
5735 max_nozzle_exit = requirements.get("max_nozzle_exit_diameter", 0.101)
5736 thrust_tol = tolerances.get("thrust", 0.10)
5737
5738 # Target exit pressure = the site's ambient, the one every evaluation below is run at
5739 # (P_ambient). The solver's own (_layer1_site_ambient_pa), so Layer 1 designs at the pressure
5740 # forward mode, the Time-Series tab and Layer X evaluate the design at.
5741 target_P_exit = _layer1_site_ambient_pa(config_obj)
5742 layer1_logger.info(f"Site ambient (target exit pressure): {target_P_exit:.1f} Pa ({target_P_exit/101325.0:.3f} atm)")
5743
5744 # An O/F the propellant's table cannot evaluate (a blank template keeps the previous
5745 # propellant's) is replaced, with a warning, rather than aborting the run.
5746 _caps = [v for v in (requirements.get("max_lox_tank_pressure_psi"),
5747 requirements.get("max_fuel_tank_pressure_psi")) if v]
5748 _of_pc_pa = (float(requirements.get("target_chamber_pressure_psi") or 0.0)
5749 or (min(float(v) for v in _caps) / 1.3 if _caps else 435.0)) * 6894.757
5750 _of_cea = getattr(runner, "cea_cache", None)
5751 optimal_of, _of_msg = _layer1_resolve_of_target(
5752 config_obj, optimal_of, _of_cea if _layer1_cea_ok(_of_cea) else None, _of_pc_pa,
5753 float(target_P_exit))
5754 if _of_msg:
5755 requirements = dict(requirements)
5756 requirements["optimal_of_ratio"] = optimal_of
5757 layer1_warnings.append(_of_msg)
5758 layer1_logger.warning(_of_msg)
5759 log_status("warning", _of_msg)
5760
5761 layer1_logger.info(f"Target thrust: {target_thrust:.1f} N")
5762 layer1_logger.info(f"Target O/F ratio: {optimal_of:.2f}")
5763 layer1_logger.info(f"Min stability margin: {min_stability:.2f}")
5764 layer1_logger.info(f"Target burn time: {target_burn_time:.2f} s")
5765
5766 # Default max_iterations for robust convergence (10-DOF pintle / 13-DOF impinging geometry + pressures)
5767 # With popsize ~48: many evaluations per CMA iteration unless clamped for smoke tests.
5768 # Split across num_restarts as generations/restart; sized so each restart keeps ~50 generations
5769 # while allowing enough restarts for genuine multi-start global exploration (see restart loop).
5770 # CMA generations. The hybrid path spends max_iterations x popsize evaluations, so at
5771 # popsize=48 this sets the total budget: 625 x 48 = 30000 (was 200 -> 9600, ~10 s, which
5772 # was not enough to reliably escape a bad basin -- observed 1 run in 3 landing somewhere
5773 # useless). Everything downstream is derived by division from this (per-restart generations,
5774 # hybrid cycle budgets, block budgets), so raising it scales every stage together.
5775 max_iterations = 1042
5776 if layer1_max_iterations is not None:
5777 max_iterations = max(1, int(layer1_max_iterations))
5778
5779 layer1_logger.info(f"Max iterations: {max_iterations}")
5780 layer1_logger.info("")
5781
5782 # Get max pressures (stagnation search must never exceed these [psi]).
5783 max_lox_P_psi = float(pressure_config.get("max_lox_pressure_psi", 700))
5784 max_fuel_P_psi = float(pressure_config.get("max_fuel_pressure_psi", 850))
5785 # Defensive: if anything passed a looser pressure_config than ``config_obj.design_requirements``,
5786 # use the stricter of the two (common when requirements are edited in the UI but a stale field lingers).
5787 try:
5788 _dr_cap = getattr(config_obj, "design_requirements", None)
5789 if _dr_cap is not None:
5790 _lox_cap = getattr(_dr_cap, "max_lox_tank_pressure_psi", None)
5791 if _lox_cap is not None and float(_lox_cap) > 0.0:
5792 max_lox_P_psi = min(max_lox_P_psi, float(_lox_cap))
5793 _f_cap = getattr(_dr_cap, "max_fuel_tank_pressure_psi", None)
5794 if _f_cap is not None and float(_f_cap) > 0.0:
5795 max_fuel_P_psi = min(max_fuel_P_psi, float(_f_cap))
5796 except (TypeError, ValueError, AttributeError):
5797 pass
5798 layer1_logger.info(
5799 "Layer 1 tank stagnation caps (psi): max LOX=%.1f, max fuel=%.1f",
5800 max_lox_P_psi,
5801 max_fuel_P_psi,
5802 )
5803 psi_to_Pa = 6894.76
5804
5805 # Objective tolerance for early stopping (weighted scalar; see ``primary_relative_residual`` for physics scale)
5806 obj_tolerance = 2.0 # For good solution: obj ≈ 1.25, so 2.0 is reasonable
5807 layer1_lbfgs_gtol = float(max(1e-15, _requirement_float(requirements, "layer1_lbfgs_gtol", 1e-9)))
5808 _lbfgs_2 = requirements.get("layer1_lbfgs_second_pass")
5809 layer1_lbfgs_second_pass = True if _lbfgs_2 is None else bool(_lbfgs_2)
5810
5811 update_progress("Layer 1: Setup", 0.01, "Initializing Layer 1 optimization...")
5812
5813 # Prepare base config
5814 config_base = copy.deepcopy(config_obj)
5815 if hasattr(config_base, 'injector') and config_base.injector.type == "pintle":
5816 if hasattr(config_base.injector.geometry, 'lox'):
5817 config_base.injector.geometry.lox.theta_orifice = 90.0
5818
5819 l1_injector_type = getattr(getattr(config_base, "injector", None), "type", "pintle")
5820 if l1_injector_type not in ("pintle", "impinging"):
5821 raise ValueError(
5822 "Layer 1 static optimization supports injector types 'pintle' and 'impinging' only; "
5823 f"got {l1_injector_type!r}."
5824 )
5825 idx_P_O = 11 if l1_injector_type == "impinging" else 8
5826 idx_P_F = 12 if l1_injector_type == "impinging" else 9
5827 layer1_integer_dims = [4] if l1_injector_type == "impinging" else [6]
5828
5829 # Whole-degree impingement angles (doublets only).
5830 #
5831 # A drill jig is indexed in whole degrees -- nobody sets up a 43.7 deg bore at
5832 # collegiate scale -- so a fractional angle is an optimum you cannot build, and
5833 # rounding it afterwards moves impingement distance, momentum ratio and SMD off
5834 # the point that was actually scored. Snapping inside the evaluation means every
5835 # candidate ranked is one the shop can cut. Indices 6 / 9 are impingement_angle_O
5836 # and impingement_angle_F; the pintle vector has no angle DOF (index 6 there is
5837 # n_orifices, which is already integral), hence the injector-type guard.
5838 if l1_injector_type == "impinging" and _requirement_bool(
5839 requirements, "layer1_integer_jet_angles", True
5840 ):
5841 layer1_integer_dims = layer1_integer_dims + [6, 9]
5842 layer1_logger.info(
5843 "Layer 1: impingement angles snapped to whole degrees (machining limit); "
5844 "set layer1_integer_jet_angles=false to search fractional angles."
5845 )
5846
5847 # Enable turbulence coupling
5848 if hasattr(config_base, 'combustion') and hasattr(config_base.combustion, 'efficiency'):
5849 config_base.combustion.efficiency.use_turbulence_coupling = True
5850
5851 # Calculate initial A_throat guess. If the user set a chamber-pressure target, SIZE THE
5852 # SEED THROAT FOR IT — F = Cf·Pc·At, so a throat seeded for the old hardcoded 580 psi is
5853 # ~35-65% undersized when the target is 300-450 psi, and CMA starts in the wrong basin
5854 # (low thrust at pinned Pc) it then has to climb out of.
5855 _pc_tgt_seed = requirements.get("target_chamber_pressure_psi")
5856 if _pc_tgt_seed is not None and float(_pc_tgt_seed) > 0:
5857 Pc_est_psi = float(_pc_tgt_seed)
5858 else:
5859 Pc_est_psi = 580.0
5860 Pc_est = Pc_est_psi * psi_to_Pa
5861 Cf_est = 1.5
5862 A_throat_init = target_thrust / (Cf_est * Pc_est) if Pc_est > 0 else 0.001
5863 A_throat_init = max(float(A_throat_init), 5e-5)
5864
5865 # Calculate bounds ensuring injector area < throat area (pintle) or manufacturable jets (impinging)
5866 min_A_throat_safe = 5e-5
5867
5868 # Stagnation-pressure search box: fractions of respective tank caps (independent LOX vs fuel).
5869 # Equal optimizer values (e.g. 720/720 psi) can occur when caps and fractions match and the objective is symmetric —
5870 # there is **no** equality constraint forcing P_O == P_F.
5871 # Search box for tank stagnation pressure, as a fraction of the user's cap.
5872 #
5873 # This used to be [0.65, 0.85], which made the top 15% of the user's own pressure cap
5874 # UNREACHABLE: a 600 psi cap could only ever be searched over 390-510 psi, so a design that
5875 # needed 600 psi to make thrust simply could not be found and the run looked "non-convergent".
5876 # The cap is already the physical limit -- the search must be allowed to reach it. The lower
5877 # edge stays well below 1 so low-Pc designs remain reachable.
5878 #
5879 # ``or`` would map an explicit 0.0 to the default (the layer1_W_PC falsy-value bug class),
5880 # so these test for None instead.
5881 _fmin = requirements.get("layer1_stagnation_pressure_frac_min")
5882 _fmax = requirements.get("layer1_stagnation_pressure_frac_max")
5883 min_P_ratio = float(_fmin) if _fmin is not None else 0.35
5884 max_P_ratio = float(_fmax) if _fmax is not None else 1.00
5885 if max_P_ratio <= min_P_ratio:
5886 max_P_ratio = min_P_ratio + 1e-3
5887 exp_ratio_lo = _requirement_float(requirements, "layer1_expansion_ratio_min", 4.0)
5888 exp_ratio_hi = _requirement_float(requirements, "layer1_expansion_ratio_max", 12.0)
5889 if exp_ratio_hi <= exp_ratio_lo:
5890 exp_ratio_hi = exp_ratio_lo + 1e-3
5891
5892 # Never search outside the CEA table's own expansion-ratio grid. The 3D cache is built over
5893 # a fixed eps_range; asking it for eps outside that range interpolates off the end of the
5894 # data and returns a confident, wrong Cf. Observed live: a config allowing eps >= 3.0 against
5895 # a cache built for [4, 15] converged at eps = 3.83, i.e. silently extrapolated. The config
5896 # may narrow the window; it may not widen it past what the table actually covers.
5897 try:
5898 _cache_eps = getattr(getattr(config_obj.combustion, "cea", None), "eps_range", None)
5899 if _cache_eps and len(_cache_eps) == 2:
5900 _ce_lo, _ce_hi = float(_cache_eps[0]), float(_cache_eps[1])
5901 _clamped_lo, _clamped_hi = max(exp_ratio_lo, _ce_lo), min(exp_ratio_hi, _ce_hi)
5902 if (_clamped_lo, _clamped_hi) != (exp_ratio_lo, exp_ratio_hi):
5903 layer1_logger.warning(
5904 "Expansion-ratio search [%g, %g] exceeds the CEA cache grid [%g, %g]; "
5905 "clamping to [%g, %g] to avoid extrapolating the table.",
5906 exp_ratio_lo, exp_ratio_hi, _ce_lo, _ce_hi, _clamped_lo, _clamped_hi,
5907 )
5908 exp_ratio_lo, exp_ratio_hi = _clamped_lo, _clamped_hi
5909 if exp_ratio_hi <= exp_ratio_lo:
5910 exp_ratio_hi = exp_ratio_lo + 1e-3
5911 except (AttributeError, TypeError, ValueError):
5912 pass
5913
5914 # Throat floor from target thrust and available pressure envelope.
5915 # Prevents optimizer spending budget in clearly unreachable tiny-throat basins.
5916 _cf_hi_for_floor = _requirement_float(requirements, "layer1_cf_upper_bound_for_throat_floor", 1.8)
5917 _pc_floor_factor = _requirement_float(requirements, "layer1_pc_fraction_for_throat_floor", 0.75)
5918 _pc_cap_psi = min(max_lox_P_psi * max_P_ratio, max_fuel_P_psi * max_P_ratio)
5919 _pc_cap_pa = _pc_cap_psi * psi_to_Pa * max(0.1, _pc_floor_factor)
5920 if target_thrust > 0 and _pc_cap_pa > 0 and _cf_hi_for_floor > 0:
5921 _a_req = target_thrust / (_cf_hi_for_floor * _pc_cap_pa)
5922 if np.isfinite(_a_req) and _a_req > 0:
5923 # Keep some slack below the rough feasibility line.
5924 min_A_throat_safe = max(min_A_throat_safe, 0.7 * float(_a_req))
5925 layer1_logger.info(
5926 "A_throat lower bound raised by thrust-feasibility floor: %.2f mm^2 "
5927 "(target %.0f N, Pc_cap≈%.1f psi, Cf_hi=%.2f)",
5928 min_A_throat_safe * 1e6,
5929 target_thrust,
5930 _pc_cap_psi,
5931 _cf_hi_for_floor,
5932 )
5933 # OD floor from the smallest reachable throat, not from the envelope cap: below
5934 # wall + (D/Dt)_min * Dt_min every reachable throat violates the chamber-shape band.
5935 # 0.5 x cap put a 73 mm bore floor under a 1 kN engine that wants ~40 mm (DEF-09).
5936 min_outer_diameter = _layer1_chamber_od_floor_m(
5937 max_chamber_od, wall_total_m, min_A_throat_safe,
5938 _requirement_float(requirements, "layer1_chamber_dt_ratio_min", 2.2))
5939 if not (min_outer_diameter < max_chamber_od):
5940 layer1_logger.warning(
5941 "Chamber OD cap %.1f mm cannot hold the %.1f mm wall plus a bore at the D/Dt floor "
5942 "for the smallest reachable throat; the chamber-shape term will report it.",
5943 max_chamber_od * 1e3, wall_total_m * 1e3)
5944 min_outer_diameter = max_chamber_od * 0.5
5945 # Throat ceiling from thrust at the lowest Cf / stagnation fraction the search allows
5946 # (was a flat 4000 mm^2, which binds above ~15 kN).
5947 A_throat_hi = max(4.0e-3, float(target_thrust) / (
5948 1.3 * max(min_P_ratio, 0.05) * min(max_lox_P_psi, max_fuel_P_psi) * psi_to_Pa))
5949
5950 if l1_injector_type == "impinging":
5951 D_inner_bounds = impinging_chamber_inner_diameter_for_bounds(
5952 config_base,
5953 max_chamber_outer_diameter_m=max_chamber_od,
5954 wall_thickness_m=wall_total_m,
5955 )
5956 n_hi_int = impinging_n_elements_hi_int(D_inner_bounds)
5957 _nd_cap = requirements.get("layer1_impinging_n_doublets_max")
5958 if _nd_cap is not None:
5959 try:
5960 _c = int(_nd_cap)
5961 if _c >= 5:
5962 n_hi_int = min(n_hi_int, _c)
5963 except (TypeError, ValueError):
5964 pass
5965 # Upper continuous edge so clip→round cannot exceed n_hi_int (avoid legacy +3.99 overrun).
5966 n_hi_upper = float(n_hi_int) + 0.499
5967 d_jet_hi = impinging_d_jet_upper_bound_m(D_inner_bounds)
5968 spacing_hi = impinging_spacing_upper_bound_m(D_inner_bounds)
5969
5970 # Impingement-angle SEARCH BOUNDS derived from the user's requirement instead of the old
5971 # hardcoded (15, 85), which made layer1_impinging_angle_deg_min/max unable to affect the
5972 # search at all -- they fed only a soft penalty (W_IMPINGING_ANGLE ~400, nothing against
5973 # W_MOM), so asking for a 55 deg floor still returned 42/48 deg for 0.04 objective points.
5974 #
5975 # CAREFUL -- two different quantities share the word "angle":
5976 # * each design variable is ONE JET's inclination from the chamber axis, and
5977 # * layer1_impinging_angle_deg_min/max bounds the INCLUDED angle, the sum of the two.
5978 # Applying the included-angle band per-jet is wrong: both jets went to the 55 deg floor,
5979 # making an included 110 deg, far outside the [55, 90] the user actually asked for, for a
5980 # 160000-point penalty. So map the band to per-jet limits, widened by the permitted
5981 # asymmetry so an asymmetric pair inside the included band is still reachable.
5982 _ang_lo_req = requirements.get("layer1_impinging_angle_deg_min")
5983 _ang_hi_req = requirements.get("layer1_impinging_angle_deg_max")
5984 _incl_lo = float(_ang_lo_req) if _ang_lo_req is not None else 30.0
5985 _incl_hi = float(_ang_hi_req) if _ang_hi_req is not None else 170.0
5986 _asym = _requirement_float(requirements, "layer1_impinging_jet_angle_max_asym_deg", 28.0)
5987 imp_angle_lo_bound = float(np.clip(0.5 * (_incl_lo - _asym), 5.0, 89.0))
5988 imp_angle_hi_bound = float(np.clip(0.5 * (_incl_hi + _asym), 6.0, 89.0))
5989 # Never let the DERIVED per-jet box come out tighter than the historical [15, 85]. The
5990 # included-angle band is a preference expressed through W_IMPINGING_ANGLE; shrinking the
5991 # hard search box to match it cut the reachable region enough that a random start often
5992 # never found a feasible point at all, and CMA then died on the flat 1e6 infeasible
5993 # plateau after ~1000 of 50016 evaluations. Measured: 2 of 4 random seeds failed.
5994 imp_angle_lo_bound = min(imp_angle_lo_bound, 15.0)
5995 imp_angle_hi_bound = max(imp_angle_hi_bound, 85.0)
5996 # MANUFACTURING FLOOR on a single jet's inclination. The widening above can drop the
5997 # per-jet lower bound to 10 deg or less, and the optimiser will use it: the 8 kN
5998 # ethalox point came back with theta_O = 13 deg. A hole that shallow is not drillable
5999 # on a flat face without a spot-face or a jig -- the drill walks on entry -- and the
6000 # element gets very long for the standoff it buys. This is a HARD bound, not a
6001 # preference, because no weight in the objective knows about drilling.
6002 _jet_lo = requirements.get("layer1_impinging_jet_angle_min_deg")
6003 if _jet_lo is not None:
6004 try:
6005 _jet_lo = float(_jet_lo)
6006 if np.isfinite(_jet_lo) and 0.0 < _jet_lo < imp_angle_hi_bound:
6007 imp_angle_lo_bound = max(imp_angle_lo_bound, _jet_lo)
6008 except (TypeError, ValueError):
6009 pass
6010 # MACHINING CEILING on a single jet's inclination, the mirror of the floor above.
6011 # theta is measured from the chamber AXIS, so the drill meets the face PLANE at
6012 # (90 - theta). Requiring at least `min_face_incidence` degrees of incidence is exactly
6013 # theta <= 90 - min_face_incidence. This has to be applied AFTER the max(..., 85.0)
6014 # widening or that line hands the box straight back. Measured why it matters: the
6015 # first design to clear every gate put the fuel jet at 69 deg -- 21 deg of incidence,
6016 # and 35.4 mm of passage through a 12.7 mm plate.
6017 _inc_req = requirements.get("layer1_injector_min_face_incidence_deg")
6018 _pl = _layer1_plate(config_obj)
6019 if _inc_req is not None and _pl.get("face") == "contoured" and _pl.get("back") == "channels":
6020 # Every exit leaves its flank square on a contoured face, and with channels the
6021 # passage length is set by the hole's L/d rather than t / cos(theta): the ceiling
6022 # has nothing left to protect. (With a plenum back it still bounds the passage.)
6023 layer1_logger.info(
6024 "Contoured injector face: layer1_injector_min_face_incidence_deg (%s) not applied",
6025 _inc_req)
6026 _inc_req = None
6027 if _inc_req is not None:
6028 try:
6029 _inc_req = float(_inc_req)
6030 if np.isfinite(_inc_req) and _inc_req > 0.0:
6031 _th_ceiling = 90.0 - _inc_req
6032 if _th_ceiling <= imp_angle_lo_bound:
6033 raise ValueError(
6034 f"layer1_injector_min_face_incidence_deg = {_inc_req:g} caps the jet "
6035 f"angle at {_th_ceiling:g} deg, at or below the "
6036 f"layer1_impinging_jet_angle_min_deg floor of {imp_angle_lo_bound:g}. "
6037 f"No jet angle satisfies both."
6038 )
6039 imp_angle_hi_bound = min(imp_angle_hi_bound, _th_ceiling)
6040 layer1_logger.info(
6041 "Face-incidence floor %g deg -> jet angle capped at %.1f deg "
6042 "(drill meets the face at >= %g deg)",
6043 _inc_req, imp_angle_hi_bound, _inc_req)
6044 except (TypeError, ValueError) as _e:
6045 if isinstance(_e, ValueError) and "min_face_incidence" in str(_e):
6046 raise
6047 if imp_angle_hi_bound <= imp_angle_lo_bound:
6048 imp_angle_hi_bound = min(89.0, imp_angle_lo_bound + 1.0)
6049 layer1_logger.info(
6050 "Impingement-angle bounds: included [%g, %g] deg (asym <= %g) -> per-jet [%.1f, %.1f]",
6051 _incl_lo, _incl_hi, _asym, imp_angle_lo_bound, imp_angle_hi_bound,
6052 )
6053 # HOLE-PITCH FLOOR FROM THE RESERVED CENTRE CIRCLE. D_pitch = n*spacing/pi, so a ring
6054 # that must clear a centre boss of diameter D_c needs spacing >= pi*D_c/n. Without this
6055 # the search box still reaches the old 3 mm floor, the hard block rejects every such
6056 # candidate, and CMA burns its budget on a region it can never use. Take n at its CAP:
6057 # more elements need LESS pitch for the same circle, so n_hi_int gives the loosest floor
6058 # that is still valid for every reachable n.
6059 _spacing_lo = 0.003
6060 _cc_req = _layer1_centre_clear_m(config_obj, requirements)
6061 if np.isfinite(_cc_req) and _cc_req > 0.0 and n_hi_int >= 1:
6062 _spacing_lo = max(_spacing_lo, float(np.pi) * float(_cc_req) / float(n_hi_int))
6063 if _spacing_lo >= spacing_hi:
6064 raise ValueError(
6065 f"layer1_injector_center_clear_dia_m = {_cc_req*1000:.1f} mm needs a hole "
6066 f"pitch of at least {_spacing_lo*1000:.2f} mm at n = {n_hi_int}, but the "
6067 f"pitch ceiling for a {D_inner_bounds*1000:.1f} mm bore is "
6068 f"{spacing_hi*1000:.2f} mm. Reduce the reserved centre circle, or raise "
6069 f"layer1_impinging_n_doublets_max."
6070 )
6071 layer1_logger.info(
6072 "Centre clearance %.1f mm reserved -> hole-pitch floor %.2f mm (was 3.00) at n=%d",
6073 _cc_req * 1000.0, _spacing_lo * 1000.0, n_hi_int,
6074 )
6075 layer1_logger.info(
6076 f"Impinging injector bounds from chamber bore D_inner≈{D_inner_bounds*1000:.2f} mm: "
6077 f"n_doublets ≤ {n_hi_int} (hard int cap via search box), "
6078 f"d_jet ≤ {d_jet_hi*1000:.2f} mm, spacing ≤ {spacing_hi*1000:.2f} mm"
6079 )
6080 bounds = [
6081 (min_A_throat_safe, A_throat_hi),
6082 (min_Lstar, max_Lstar),
6083 (exp_ratio_lo, exp_ratio_hi),
6084 (min_outer_diameter, max_chamber_od),
6085 (5.0, n_hi_upper),
6086 (0.0005, d_jet_hi),
6087 (imp_angle_lo_bound, imp_angle_hi_bound),
6088 (_spacing_lo, spacing_hi),
6089 (0.0005, d_jet_hi),
6090 (imp_angle_lo_bound, imp_angle_hi_bound),
6091 (_spacing_lo, spacing_hi),
6092 (max_lox_P_psi * min_P_ratio, max_lox_P_psi * max_P_ratio),
6093 (max_fuel_P_psi * min_P_ratio, max_fuel_P_psi * max_P_ratio),
6094 ]
6095 else:
6096 max_n_orifices = 14
6097 max_d_orifice = 0.003
6098 max_LOX_area = max_n_orifices * np.pi * (max_d_orifice / 2) ** 2
6099 max_d_pintle = 0.040
6100 max_h_gap = 0.0015
6101 R_inner_max = max_d_pintle / 2
6102 R_outer_max = R_inner_max + max_h_gap
6103 max_fuel_area = np.pi * (R_outer_max ** 2 - R_inner_max ** 2)
6104 max_injector_area = max(max_LOX_area, max_fuel_area)
6105
6106 bounds = [
6107 (min_A_throat_safe, A_throat_hi),
6108 (min_Lstar, max_Lstar),
6109 (exp_ratio_lo, exp_ratio_hi),
6110 (min_outer_diameter, max_chamber_od),
6111 (0.006, 0.040),
6112 (0.0003, 0.0015),
6113 (14, 14.1),
6114 (0.001, 0.004),
6115 (max_lox_P_psi * min_P_ratio, max_lox_P_psi * max_P_ratio),
6116 (max_fuel_P_psi * min_P_ratio, max_fuel_P_psi * max_P_ratio),
6117 ]
6118
6119 # Optional explicit stagnation-pressure ranges [psi] for x[idx_P_O], x[idx_P_F]
6120 # (overrides default 65–85% of max_lox_pressure_psi / max_fuel_pressure_psi bands).
6121 _p_o_min = requirements.get("layer1_P_O_start_psi_min")
6122 _p_o_max = requirements.get("layer1_P_O_start_psi_max")
6123 if _p_o_min is not None and _p_o_max is not None:
6124 lo_p, hi_p = float(_p_o_min), min(float(_p_o_max), max_lox_P_psi)
6125 lo_p = min(lo_p, hi_p)
6126 if hi_p > lo_p:
6127 bounds[idx_P_O] = (lo_p, hi_p)
6128 _p_f_min = requirements.get("layer1_P_F_start_psi_min")
6129 _p_f_max = requirements.get("layer1_P_F_start_psi_max")
6130 if _p_f_min is not None and _p_f_max is not None:
6131 lo_pf, hi_pf = float(_p_f_min), min(float(_p_f_max), max_fuel_P_psi)
6132 lo_pf = min(lo_pf, hi_pf)
6133 if hi_pf > lo_pf:
6134 bounds[idx_P_F] = (lo_pf, hi_pf)
6135
6136 # Hard ceiling: optimized stagnation cannot exceed allowable tank pressures [psi].
6137 _o_lo, _o_hi = bounds[idx_P_O]
6138 bounds[idx_P_O] = (float(min(_o_lo, max_lox_P_psi)), float(min(_o_hi, max_lox_P_psi)))
6139 if bounds[idx_P_O][0] > bounds[idx_P_O][1]:
6140 bounds[idx_P_O] = (bounds[idx_P_O][1], bounds[idx_P_O][0])
6141 _f_lo, _f_hi = bounds[idx_P_F]
6142 bounds[idx_P_F] = (float(min(_f_lo, max_fuel_P_psi)), float(min(_f_hi, max_fuel_P_psi)))
6143 if bounds[idx_P_F][0] > bounds[idx_P_F][1]:
6144 bounds[idx_P_F] = (bounds[idx_P_F][1], bounds[idx_P_F][0])
6145
6146 # Calculate initial guess
6147 # Check if we can use the input config as the starting point (x0)
6148 # This key feature allows the user to providing a "good guess" to speed up optimization
6149 has_valid_start_geom = False
6150
6151 # Extract candidate values from config
6152 try:
6153 # Helper to safely get float > 0
6154 def _get_val(obj, attr, default=None):
6155 val = getattr(obj, attr, default)
6156 return float(val) if val is not None and val > 0 else None
6157
6158 # Chamber/Nozzle Geometry
6159 # Try chamber_geometry first, then legacy sections
6160 cg_in = getattr(config_obj, 'chamber_geometry', None)
6161 c_in = getattr(config_obj, 'chamber', None)
6162 n_in = getattr(config_obj, 'nozzle', None)
6163
6164 A_throat_in = _get_val(cg_in, 'A_throat') or _get_val(c_in, 'A_throat') or _get_val(n_in, 'A_throat')
6165 Lstar_in = _get_val(cg_in, 'Lstar') or _get_val(c_in, 'Lstar')
6166 eps_in = _get_val(cg_in, 'expansion_ratio') or _get_val(n_in, 'expansion_ratio')
6167 # Inner diameter -> Outer diameter
6168 D_inner_in = _get_val(cg_in, 'chamber_diameter') or _get_val(c_in, 'chamber_inner_diameter')
6169
6170 # Injector Geometry
6171 inj_in = getattr(config_obj, 'injector', None)
6172 inj_geom = getattr(inj_in, 'geometry', None)
6173 is_pintle = (getattr(inj_in, 'type', '') == 'pintle')
6174 is_impinging = (getattr(inj_in, 'type', '') == 'impinging')
6175
6176 d_pintle_in = h_gap_in = n_orifices_in = d_orifice_in = None
6177 n_O_in = d_jet_O_in = ang_O_in = sp_O_in = None
6178 n_F_in = d_jet_F_in = ang_F_in = sp_F_in = None
6179
6180 if is_pintle and inj_geom:
6181 fuel_in = getattr(inj_geom, 'fuel', None)
6182 lox_in = getattr(inj_geom, 'lox', None)
6183
6184 d_pintle_in = _get_val(fuel_in, 'd_pintle_tip')
6185 h_gap_in = _get_val(fuel_in, 'h_gap')
6186 n_orifices_in = _get_val(lox_in, 'n_orifices')
6187 d_orifice_in = _get_val(lox_in, 'd_orifice')
6188 elif is_impinging and inj_geom:
6189 ox_in = getattr(inj_geom, 'oxidizer', None)
6190 fu_in = getattr(inj_geom, 'fuel', None)
6191 if ox_in and fu_in:
6192 n_O_in = getattr(ox_in, 'n_elements', None)
6193 n_F_in = getattr(fu_in, 'n_elements', None)
6194 n_doublets_in = None
6195 if n_O_in is not None and float(n_O_in) > 0:
6196 no_i = int(n_O_in)
6197 if n_F_in is not None and float(n_F_in) > 0:
6198 nf_i = int(n_F_in)
6199 if nf_i != no_i:
6200 layer1_logger.warning(
6201 "Impinging YAML n_elements_O (%s) != n_elements_F (%s); paired doublets "
6202 "use one n_doublets — Layer-1 x0 uses min(O,F)=%s.",
6203 no_i,
6204 nf_i,
6205 min(no_i, nf_i),
6206 )
6207 n_doublets_in = min(no_i, nf_i)
6208 else:
6209 n_doublets_in = no_i
6210 elif n_F_in is not None and float(n_F_in) > 0:
6211 n_doublets_in = int(n_F_in)
6212 d_jet_O_in = _get_val(ox_in, 'd_jet')
6213 ang_O_in = _get_val(ox_in, 'impingement_angle')
6214 sp_O_in = _get_val(ox_in, 'spacing')
6215 d_jet_F_in = _get_val(fu_in, 'd_jet')
6216 ang_F_in = _get_val(fu_in, 'impingement_angle')
6217 sp_F_in = _get_val(fu_in, 'spacing')
6218
6219 pintle_complete = (
6220 is_pintle
6221 and all(
6222 v is not None
6223 for v in [
6224 A_throat_in,
6225 Lstar_in,
6226 eps_in,
6227 D_inner_in,
6228 d_pintle_in,
6229 h_gap_in,
6230 n_orifices_in,
6231 d_orifice_in,
6232 ]
6233 )
6234 )
6235 imp_complete = (
6236 is_impinging
6237 and all(
6238 v is not None
6239 for v in [
6240 A_throat_in,
6241 Lstar_in,
6242 eps_in,
6243 D_inner_in,
6244 n_doublets_in,
6245 d_jet_O_in,
6246 ang_O_in,
6247 sp_O_in,
6248 d_jet_F_in,
6249 ang_F_in,
6250 sp_F_in,
6251 ]
6252 )
6253 )
6254
6255 if pintle_complete:
6256 A_throat_init = A_throat_in
6257 Lstar_init = Lstar_in
6258 eps_init = eps_in
6259 outer_diameter_init = D_inner_in + wall_total_m
6260
6261 default_d_pintle = d_pintle_in
6262 default_h_gap = h_gap_in
6263 default_n_orifices = int(n_orifices_in)
6264 default_d_orifice = d_orifice_in
6265
6266 has_valid_start_geom = True
6267 layer1_logger.info("Using values from input configuration as initial guess (x0).")
6268
6269 elif imp_complete:
6270 A_throat_init = A_throat_in
6271 Lstar_init = Lstar_in
6272 eps_init = eps_in
6273 outer_diameter_init = D_inner_in + wall_total_m
6274
6275 default_n_doublets = int(n_doublets_in)
6276 default_d_jet_O = float(d_jet_O_in)
6277 default_d_jet_F = float(d_jet_F_in)
6278 default_ang_O = float(ang_O_in)
6279 default_ang_F = float(ang_F_in)
6280 default_sp_O = float(sp_O_in)
6281 default_sp_F = float(sp_F_in)
6282
6283 has_valid_start_geom = True
6284 layer1_logger.info("Using impinging injector geometry from input configuration as initial guess (x0).")
6285
6286 else:
6287 layer1_logger.info("Input configuration incomplete/invalid for x0. Using heuristic defaults.")
6288
6289 except Exception as e:
6290 layer1_logger.warning(f"Failed to extract x0 from config: {e}. Using defaults.")
6291 has_valid_start_geom = False
6292
6293 if not has_valid_start_geom:
6294 if l1_injector_type == "impinging":
6295 default_n_doublets = 12
6296 default_d_jet_O = 0.002
6297 default_d_jet_F = 0.002
6298 default_ang_O = 45.0
6299 default_ang_F = 45.0
6300 default_sp_O = 0.012
6301 default_sp_F = 0.012
6302 A_lox_est = default_n_doublets * np.pi * (default_d_jet_O / 2.0) ** 2
6303 A_fuel_est = default_n_doublets * np.pi * (default_d_jet_F / 2.0) ** 2
6304 max_injector_area_est = max(A_lox_est, A_fuel_est)
6305 A_throat_min_safe = max_injector_area_est * 1.15
6306 A_throat_init = max(A_throat_init, A_throat_min_safe)
6307 Lstar_init = (min_Lstar + max_Lstar) / 2
6308 eps_init = 8.0
6309 outer_diameter_init = np.clip(max_chamber_od * 0.55, min_outer_diameter, max_chamber_od)
6310 else:
6311 default_n_orifices = 14
6312 default_d_orifice = 0.002
6313 default_d_pintle = 0.016
6314 default_h_gap = 0.0006
6315
6316 A_lox_est = default_n_orifices * np.pi * (default_d_orifice / 2) ** 2
6317 R_inner_est = default_d_pintle / 2
6318 R_outer_est = R_inner_est + default_h_gap
6319 A_fuel_est = np.pi * (R_outer_est ** 2 - R_inner_est ** 2)
6320 max_injector_area_est = max(A_lox_est, A_fuel_est)
6321
6322 A_throat_min_safe = max_injector_area_est * 1.1
6323 A_throat_init = max(A_throat_init, A_throat_min_safe)
6324
6325 Lstar_init = (min_Lstar + max_Lstar) / 2
6326 eps_init = 8.0
6327 outer_diameter_init = np.clip(max_chamber_od * 0.55, min_outer_diameter, max_chamber_od)
6328
6329 # Clean up variables for array creation
6330 A_throat_init = float(np.clip(A_throat_init, bounds[0][0], bounds[0][1]))
6331
6332 P_O_start_init = max_lox_P_psi * 0.80
6333 P_F_start_init = max_fuel_P_psi * 0.80
6334 P_O_start_init = float(np.clip(P_O_start_init, bounds[idx_P_O][0], bounds[idx_P_O][1]))
6335 P_F_start_init = float(np.clip(P_F_start_init, bounds[idx_P_F][0], bounds[idx_P_F][1]))
6336
6337 if l1_injector_type == "impinging":
6338 default_d_jet_O, default_d_jet_F = _layer1_rescale_impinging_seed_jets_for_mr(
6339 config_obj,
6340 d_jet_O=default_d_jet_O,
6341 d_jet_F=default_d_jet_F,
6342 n_doublets=int(default_n_doublets),
6343 optimal_of=float(optimal_of),
6344 bounds=bounds,
6345 logger=layer1_logger,
6346 also_config=config_base,
6347 )
6348
6349 if l1_injector_type == "impinging":
6350 x0 = np.array([
6351 A_throat_init,
6352 Lstar_init if 'Lstar_init' in locals() else (min_Lstar + max_Lstar) / 2,
6353 eps_init if 'eps_init' in locals() else 8.0,
6354 outer_diameter_init,
6355 float(default_n_doublets),
6356 default_d_jet_O,
6357 default_ang_O,
6358 default_sp_O,
6359 default_d_jet_F,
6360 default_ang_F,
6361 default_sp_F,
6362 P_O_start_init,
6363 P_F_start_init,
6364 ])
6365 else:
6366 x0 = np.array([
6367 A_throat_init,
6368 Lstar_init if 'Lstar_init' in locals() else (min_Lstar + max_Lstar) / 2,
6369 eps_init if 'eps_init' in locals() else 8.0,
6370 outer_diameter_init,
6371 default_d_pintle,
6372 default_h_gap,
6373 default_n_orifices,
6374 default_d_orifice,
6375 P_O_start_init,
6376 P_F_start_init,
6377 ])
6378
6379 # =========================================================================
6380 # Handle Frozen Parameters from Design Requirements
6381 # =========================================================================
6382 # If user has specified frozen_parameters, pin those values by:
6383 # 1. Setting bounds to [value, value] (prevents CMA-ES from exploring)
6384 # 2. Setting x0 to the frozen value
6385 # Frozen parameters use user-friendly units and are converted to SI here.
6386 #
6387 # ``requirements`` often comes from ``design_requirements.model_dump()`` via the API. If the UI
6388 # saved a document without nested ``frozen_parameters`` (or with an empty dict), we must not
6389 # drop pins that still exist on ``config_base.design_requirements`` from the loaded YAML.
6390 def _frozen_param_nonempty(fp: Any) -> bool:
6391 return isinstance(fp, dict) and any(v is not None for v in fp.values())
6392
6393 _fp_req = requirements.get("frozen_parameters")
6394 _fp_cfg: Dict[str, Any] = {}
6395 dr0 = getattr(config_base, "design_requirements", None)
6396 if dr0 is not None and dr0.frozen_parameters is not None:
6397 _fp_cfg = dr0.frozen_parameters.model_dump(exclude_none=True)
6398 if _fp_cfg:
6399 if not _frozen_param_nonempty(_fp_req):
6400 requirements = dict(requirements)
6401 requirements["frozen_parameters"] = dict(_fp_cfg)
6402 elif isinstance(_fp_req, dict):
6403 merged_fp = dict(_fp_cfg)
6404 for _k, _v in _fp_req.items():
6405 if _v is not None:
6406 merged_fp[_k] = _v
6407 requirements = dict(requirements)
6408 requirements["frozen_parameters"] = merged_fp
6409
6410 frozen = requirements.get("frozen_parameters", {}) or {}
6411 frozen_param_names = []
6412
6413 # Map frozen parameters to their x-vector indices and unit conversions
6414 # Index | Parameter name | Frozen key | Conversion
6415 # ------+-------------------------+--------------------+------------
6416 # 0 | A_throat [m²] | A_throat_mm2 | mm² -> m² (*1e-6)
6417 # 1 | Lstar [m] | Lstar_mm | mm -> m (*1e-3)
6418 # 2 | expansion_ratio [-] | expansion_ratio | none
6419 # 3 | D_chamber_outer [m] | D_chamber_outer_mm | mm -> m (*1e-3)
6420 #
6421 # Pintle continues: 4=d_pintle_tip, 5=h_gap, 6=n_orifices, 7=d_orifice, 8-9 pressures.
6422 #
6423 # Impinging (paired unlike doublets — see frozen_mapping below): 4=n_doublets (int),
6424 # 5–7 LOX jet, 8–10 fuel jet, 11–12 pressures.
6425 #
6426 # 4 | d_pintle_tip [m] | d_pintle_tip_mm | mm -> m (*1e-3) # pintle only
6427 # 5 | h_gap [m] | h_gap_mm | mm -> m (*1e-3)
6428 # 6 | n_orifices [-] | n_orifices | none (int)
6429 # 7 | d_orifice [m] | d_orifice_mm | mm -> m (*1e-3)
6430 # 8 | P_O_start [psi] | P_O_start_psi | none
6431 # 9 | P_F_start [psi] | P_F_start_psi | none
6432
6433 if l1_injector_type == "impinging":
6434 frozen_mapping = [
6435 ("A_throat_mm2", 1e-6),
6436 ("Lstar_mm", 1e-3),
6437 ("expansion_ratio", 1.0),
6438 ("D_chamber_outer_mm", 1e-3),
6439 ("n_doublets", 1.0),
6440 ("d_jet_O_mm", 1e-3),
6441 ("impingement_angle_O_deg", 1.0),
6442 ("spacing_O_mm", 1e-3),
6443 ("d_jet_F_mm", 1e-3),
6444 ("impingement_angle_F_deg", 1.0),
6445 ("spacing_F_mm", 1e-3),
6446 ("P_O_start_psi", 1.0),
6447 ("P_F_start_psi", 1.0),
6448 ]
6449 else:
6450 frozen_mapping = [
6451 ("A_throat_mm2", 1e-6),
6452 ("Lstar_mm", 1e-3),
6453 ("expansion_ratio", 1.0),
6454 ("D_chamber_outer_mm", 1e-3),
6455 ("d_pintle_tip_mm", 1e-3),
6456 ("h_gap_mm", 1e-3),
6457 ("n_orifices", 1.0),
6458 ("d_orifice_mm", 1e-3),
6459 ("P_O_start_psi", 1.0),
6460 ("P_F_start_psi", 1.0),
6461 ]
6462
6463 int_keys = {"n_orifices", "n_doublets"}
6464
6465 for idx, (key, conversion) in enumerate(frozen_mapping):
6466 frozen_val = frozen.get(key)
6467 if frozen_val is not None:
6468 val_si = frozen_val * conversion
6469 if key in int_keys:
6470 val_si = int(round(frozen_val))
6471 eps = 1e-12 if key not in int_keys else 0.01
6472 bounds[idx] = (val_si - eps, val_si + eps)
6473 x0[idx] = val_si
6474 frozen_param_names.append(f"{key}={frozen_val}")
6475
6476 if frozen_param_names:
6477 layer1_logger.info(f"Frozen parameters: {', '.join(frozen_param_names)}")
6478 layer1_logger.info("These values will be fixed during optimization.")
6479
6480 # Optional exploration band for throat area [mm²] → bounds index 0 [m²].
6481 # Applied after frozen pinning so unfrozen A_throat can still be narrowed.
6482 _atm_lo = requirements.get("layer1_A_throat_mm2_min")
6483 _atm_hi = requirements.get("layer1_A_throat_mm2_max")
6484 if _atm_lo is not None and _atm_hi is not None:
6485 lo_m2 = float(_atm_lo) * 1e-6
6486 hi_m2 = float(_atm_hi) * 1e-6
6487 if hi_m2 <= lo_m2:
6488 raise ValueError(
6489 "layer1_A_throat_mm2_max must exceed layer1_A_throat_mm2_min "
6490 f"(got min={_atm_lo}, max={_atm_hi})."
6491 )
6492 b0_lo = max(float(bounds[0][0]), lo_m2)
6493 b0_hi = min(float(bounds[0][1]), hi_m2)
6494 if b0_lo >= b0_hi:
6495 raise ValueError(
6496 "layer1 A_throat band does not intersect default bounds "
6497 f"(band mm² [{_atm_lo}, {_atm_hi}], intersect [{b0_lo}, {b0_hi}] m²)."
6498 )
6499 bounds[0] = (b0_lo, b0_hi)
6500 layer1_logger.info(
6501 "Layer 1 A_throat interval from requirements "
6502 "layer1_A_throat_mm2_min/max: [%.6f, %.6f] mm²",
6503 bounds[0][0] * 1e6,
6504 bounds[0][1] * 1e6,
6505 )
6506 # =========================================================================
6507
6508 # When a chamber-pressure target is set, ANCHOR the tank-pressure search window to the
6509 # target (the tank pressure needed to PRODUCE that Pc) rather than to the tank-pressure
6510 # cap. The default window is [0.65,0.85]*cap, which fixes the achievable Pc near the cap
6511 # and leaves a low Pc target sitting at the very edge — so the target penalty fights a
6512 # search space that doesn't contain it. Tank ≈ Pc + injector ΔP (~20-45% of Pc) + feed,
6513 # i.e. tank ≈ Pc*[1.12, 1.75]. Also re-seed x0 to the window centre (the clip below keeps
6514 # it in range) so the search STARTS near the target Pc.
6515 _pc_tgt_psi = requirements.get("target_chamber_pressure_psi")
6516 if _pc_tgt_psi is not None and float(_pc_tgt_psi) > 0:
6517 _pc_t = float(_pc_tgt_psi)
6518 for _pidx, _cap in ((idx_P_O, max_lox_P_psi), (idx_P_F, max_fuel_P_psi)):
6519 _lo = max(_pc_t * 1.12, 50.0)
6520 _hi = min(_pc_t * 1.75, float(_cap))
6521 if _hi <= _lo:
6522 # Target needs more tank pressure than the cap allows → best effort near the cap.
6523 _hi = float(_cap)
6524 _lo = max(0.5 * _hi, min(_pc_t, _hi - 1.0))
6525 bounds[_pidx] = (_lo, _hi)
6526 x0[_pidx] = 0.5 * (_lo + _hi)
6527 layer1_logger.info(
6528 "Layer 1 tank-pressure window re-anchored to Pc target %.0f psi: tanks ∈ [%.0f, %.0f] psi "
6529 "(was cap-relative). This is what lets Pc actually reach the target.",
6530 _pc_t, bounds[idx_P_O][0], bounds[idx_P_O][1],
6531 )
6532
6533 # ------------------------------------------------------------------
6534 # Derived DOFs: solve instead of search
6535 # ------------------------------------------------------------------
6536 # ``expansion_ratio`` is fixed by "expand to ambient" and ``A_throat`` by the
6537 # thrust requirement, so neither is a free design choice -- searching them
6538 # burns two CMA-ES dimensions and lets the optimizer park on a wrong value
6539 # that a penalty term then has to claw back. Capture their *search* ranges
6540 # (the solver still clamps to them, so an unreachable requirement degrades to
6541 # a bound + the ordinary penalty rather than a runaway), then collapse the
6542 # CMA bounds so no population variance is spent on them.
6543 #
6544 # Both are opt-out: a user sizing to fixed throat hardware, or deliberately
6545 # running over/under expanded, needs to search them.
6546 derive_expansion_ratio = _requirement_bool(
6547 requirements, "layer1_derive_expansion_ratio", True)
6548 derive_throat_from_thrust = _requirement_bool(
6549 requirements, "layer1_derive_throat_from_thrust", True)
6550 # Solved to tolerance, not for a fixed count: the loop exits once thrust is within
6551 # layer1_derive_thrust_tol_rel and eps has stopped moving. Two fixed iterations left a
6552 # 0.4-0.9 % thrust miss that depended on where CMA proposed the throat, and the search
6553 # traded that miss against the chamber-mass term (L1-04). The cap only bounds a
6554 # non-converging secant.
6555 derive_max_iters = max(1, int(_requirement_float(
6556 requirements, "layer1_derive_max_iters", float(_LAYER1_DERIVE_MAX_ITERS_DEFAULT))))
6557 derive_thrust_tol = float(_requirement_float(
6558 requirements, "layer1_derive_thrust_tol_rel", 1.0e-3))
6559
6560 if derive_throat_from_thrust and not (target_thrust > 0):
6561 layer1_logger.warning(
6562 "Layer 1: A_throat derivation requested but no positive thrust target; "
6563 "searching A_throat instead."
6564 )
6565 derive_throat_from_thrust = False
6566
6567 derive_At_range = (float(bounds[0][0]), float(bounds[0][1]))
6568 derive_eps_range = (float(bounds[2][0]), float(bounds[2][1]))
6569 derive_dF_range = ((float(bounds[8][0]), float(bounds[8][1])) if len(bounds) > 8 else (1e-5, 1.0))
6570 _tanks_locked_flag = _requirement_bool(requirements, "layer1_lock_tank_pressures",
6571 _requirement_float(requirements, "W_TANK_EQUAL", 0.0) > 0.0)
6572 # OFF by default. It does what it says -- tanks track Pc so dP/Pc holds exactly -- but the
6573 # dP/Pc ceiling was not what blocked O/F (the momentum wall-ban is; see
6574 # _impinging_momentum_wall_violation), and un-anchoring the tank pressure let it slide
6575 # to 400-470 psi: Pc 298-364, Isp 217-224, a worse engine, plus a rebuild that no longer
6576 # converged in its iteration budget (validation had to boost tanks 6%). Opt-in.
6577 derive_tank_from_dp = _tanks_locked_flag and _requirement_bool(
6578 requirements, "layer1_derive_tank_from_dp_ratio", False)
6579 _dp_lo = _requirement_float(requirements, "injector_dp_ratio_F_min", 0.2)
6580 _dp_hi = _requirement_float(requirements, "injector_dp_ratio_F_max", 0.3)
6581 dp_ratio_target = _requirement_float(requirements, "layer1_dp_ratio_target", 0.5 * (_dp_lo + _dp_hi))
6582 lock_P_T_range = (float(bounds[idx_P_O][0]), float(bounds[idx_P_O][1]))
6583 # OFF by default. The one-shot secant d_F*sqrt(MR/MR_target) assumes dP is fixed, but
6584 # moving d_F moves total mdot, hence Pc, hence dP -- the step overshoots, rails d_F at a
6585 # bound, starves the injector, and every candidate fails (measured: tanks stuck at the
6586 # 480/480 seed on all seeds, MR 3.72, thrust -15 N). Needs a damped, multi-step solve
6587 # coupled with the throat before it can default on. Opt-in until then.
6588 derive_fuel_jet_from_of = (
6589 l1_injector_type == "impinging"
6590 and _requirement_bool(requirements, "layer1_derive_fuel_jet_from_of", False)
6591 )
6592
6593 # Capture the fuel-pitch SEARCH range before the pin collapses bounds[10] to a point --
6594 # clamping the solved value to the pinned bound would discard the derivation entirely.
6595 derive_spacing_F_range = (
6596 (float(bounds[10][0]), float(bounds[10][1])) if len(bounds) > 10 else (0.0, float("inf"))
6597 )
6598 lock_P_F_range = (float(bounds[idx_P_F][0]), float(bounds[idx_P_F][1]))
6599
6600 # Derived DOFs are removed from the SEARCH, but their bounds stay at full range.
6601 #
6602 # Collapsing the bounds to a point was the wrong mechanism: this module clips x to bounds
6603 # in ~50 places (objective entry, apply_x_to_config, per-variable reads, finalisation
6604 # reporting), and every one of them silently reverted the solved value back to the seed.
6605 # Chasing them individually is unwinnable. With full bounds every clip is a no-op, the
6606 # solved value survives end to end, and cma's ``fixed_variables`` keeps CMA from
6607 # spending any search effort on the dimension.
6608 _pinned_msgs = []
6609 _l1_fixed_vars: Dict[int, float] = {}
6610 if _requirement_bool(requirements, "layer1_lock_tank_pressures",
6611 _requirement_float(requirements, "W_TANK_EQUAL", 0.0) > 0.0):
6612 _pf_seed = float(np.clip(x0[idx_P_O], *lock_P_F_range))
6613 x0[idx_P_F] = _pf_seed
6614 _l1_fixed_vars[idx_P_F] = _pf_seed
6615 _pinned_msgs.append("fuel tank pressure (locked to the LOX tank)")
6616 if derive_throat_from_thrust:
6617 _at_seed = float(np.clip(x0[0], *derive_At_range))
6618 x0[0] = _at_seed
6619 _l1_fixed_vars[0] = _at_seed
6620 _pinned_msgs.append(
6621 "A_throat (solved from thrust target %.0f N, search range %.1f-%.1f mm²)"
6622 % (target_thrust, derive_At_range[0] * 1e6, derive_At_range[1] * 1e6)
6623 )
6624 if derive_expansion_ratio:
6625 _eps_seed = float(np.clip(x0[2], *derive_eps_range))
6626 x0[2] = _eps_seed
6627 _l1_fixed_vars[2] = _eps_seed
6628 _pinned_msgs.append(
6629 "expansion_ratio (solved for Pe = %.2f psi, clamp %.2f-%.2f)"
6630 % (target_P_exit / 6894.76, derive_eps_range[0], derive_eps_range[1])
6631 )
6632 if (l1_injector_type == "impinging" and len(bounds) > 10
6633 and _requirement_bool(requirements, "layer1_derive_impingement_spacing", True)):
6634 _spf_seed = float(np.clip(x0[10], *derive_spacing_F_range))
6635 x0[10] = _spf_seed
6636 _l1_fixed_vars[10] = _spf_seed
6637 if derive_fuel_jet_from_of and len(bounds) > 8:
6638 _l1_fixed_vars[8] = float(np.clip(x0[8], *derive_dF_range))
6639 _pinned_msgs.append("fuel jet diameter (solved from the O/F target)")
6640 if derive_tank_from_dp:
6641 _l1_fixed_vars[idx_P_O] = float(np.clip(x0[idx_P_O], *lock_P_T_range))
6642 _pinned_msgs.append("tank pressure (solved from dP/Pc = %.2f)" % dp_ratio_target)
6643 _pinned_msgs.append(
6644 "fuel ring pitch (solved so the jets meet at %.2f jet diameters)"
6645 % _requirement_float(requirements, "layer1_impingement_Ld_target", IMPINGEMENT_LD_TARGET_DEFAULT)
6646 )
6647 if _pinned_msgs:
6648 layer1_logger.info(
6649 "Layer 1 dimensionality: %d of %d DOFs are solved, not searched -- %s",
6650 len(_pinned_msgs), len(bounds), "; ".join(_pinned_msgs),
6651 )
6652
6653 _od_increment_in = _requirement_float(
6654 requirements, "layer1_chamber_od_increment_in", 0.0)
6655 _od_snap_target = str(
6656 requirements.get("layer1_chamber_od_snap_target") or "outer").strip().lower()
6657 if _od_snap_target not in ("outer", "bore"):
6658 layer1_logger.warning(
6659 "Layer 1: unknown layer1_chamber_od_snap_target=%r; using 'outer'.",
6660 _od_snap_target)
6661 _od_snap_target = "outer"
6662 # Shared by the worker pool (via constants_dict) and the inline objective, so
6663 # both paths solve the derived DOFs identically.
6664 _derive_constants = {
6665 "layer1_derive_expansion_ratio": derive_expansion_ratio,
6666 "layer1_derive_throat_from_thrust": derive_throat_from_thrust,
6667 "layer1_derive_max_iters": derive_max_iters,
6668 "layer1_derive_thrust_tol_rel": derive_thrust_tol,
6669 "derive_eps_min": derive_eps_range[0],
6670 "derive_eps_max": derive_eps_range[1],
6671 "derive_At_min": derive_At_range[0],
6672 "derive_At_max": derive_At_range[1],
6673 "derive_dF_min": derive_dF_range[0],
6674 "derive_dF_max": derive_dF_range[1],
6675 "layer1_derive_fuel_jet_from_of": derive_fuel_jet_from_of,
6676 "layer1_derive_tank_from_dp_ratio": derive_tank_from_dp,
6677 "layer1_dp_ratio_target": dp_ratio_target,
6678 "lock_P_T_min": lock_P_T_range[0],
6679 "lock_P_T_max": lock_P_T_range[1],
6680 "optimal_of": optimal_of,
6681 "P_ambient": target_P_exit,
6682 "target_thrust": target_thrust,
6683 "max_nozzle_exit": max_nozzle_exit,
6684 "TOTAL_WALL_THICKNESS_M": wall_total_m,
6685 "layer1_chamber_od_increment_in": _od_increment_in,
6686 "layer1_chamber_od_snap_target": _od_snap_target,
6687 "injector_type": l1_injector_type,
6688 "idx_P_O": idx_P_O,
6689 "idx_P_F": idx_P_F,
6690 "layer1_lock_tank_pressures": _requirement_bool(
6691 requirements, "layer1_lock_tank_pressures",
6692 _requirement_float(requirements, "W_TANK_EQUAL", 0.0) > 0.0),
6693 "lock_P_F_min": lock_P_F_range[0],
6694 "lock_P_F_max": lock_P_F_range[1],
6695 "layer1_derive_impingement_spacing": _requirement_bool(
6696 requirements, "layer1_derive_impingement_spacing", True),
6697 "layer1_impingement_Ld_target": _requirement_float(
6698 requirements, "layer1_impingement_Ld_target", IMPINGEMENT_LD_TARGET_DEFAULT),
6699 "layer1_ring_order_fuel_outboard": _requirement_bool(
6700 requirements, "layer1_ring_order_fuel_outboard", True),
6701 "derive_spacing_F_min": derive_spacing_F_range[0],
6702 "derive_spacing_F_max": derive_spacing_F_range[1],
6703 }
6704
6705 # Clip to bounds
6706 for i, (lo, hi) in enumerate(bounds):
6707 x0[i] = np.clip(x0[i], lo, hi)
6708
6709 update_progress("Layer 1: Setup", 0.05, "Creating apply_x_to_config function...")
6710 apply_x_to_config = create_layer1_apply_x_to_config(
6711 bounds, max_chamber_od, max_nozzle_exit, l1_injector_type,
6712 chamber_od_increment_in=_od_increment_in,
6713 chamber_od_snap_target=_od_snap_target,
6714 derive_constants=_derive_constants,
6715 derive_spacing_F_range=derive_spacing_F_range,
6716 )
6717
6718 # Precompute bounds arrays for clipping, caching, and CMA-ES scaling
6719 lower_bounds = np.array([b[0] for b in bounds], dtype=float)
6720 upper_bounds = np.array([b[1] for b in bounds], dtype=float)
6721 # Clipping bounds for the OBJECTIVE, which must not throw away a solved DOF.
6722 #
6723 # The derived dims sit on collapsed bounds so CMA cannot spend variance on them. But the
6724 # objective clips its incoming vector to those same bounds, which snapped A_throat back
6725 # to the seed on every call -- so the design that got REPORTED was never the design that
6726 # was scored (measured: solved across 1190-4000 mm2, reported 1548 mm2 every run,
6727 # unmoved by tank cap, contraction ratio, or thrust). Clip those dims to the range they
6728 # were actually solved within instead.
6729 lower_bounds_eff = lower_bounds.copy()
6730 upper_bounds_eff = upper_bounds.copy()
6731 for _di, _rng in (
6732 (0, derive_At_range if derive_throat_from_thrust else None),
6733 (2, derive_eps_range if derive_expansion_ratio else None),
6734 (10, derive_spacing_F_range
6735 if (l1_injector_type == "impinging" and len(bounds) > 10
6736 and _requirement_bool(requirements, "layer1_derive_impingement_spacing", True))
6737 else None),
6738 (idx_P_F, lock_P_F_range
6739 if _requirement_bool(requirements, "layer1_lock_tank_pressures",
6740 _requirement_float(requirements, "W_TANK_EQUAL", 0.0) > 0.0)
6741 else None),
6742 ):
6743 if _rng is not None and _di < len(lower_bounds_eff):
6744 lower_bounds_eff[_di] = float(_rng[0])
6745 upper_bounds_eff[_di] = float(_rng[1])
6746 span = np.maximum(upper_bounds - lower_bounds, 1e-9)
6747 # Default to a fresh random seed each run so reruns explore the design space
6748 # differently. Pin layer1_random_seed to an int only when reproducibility is wanted.
6749 _rs = requirements.get("layer1_random_seed")
6750 layer1_seed_base = int(_rs) if _rs is not None else int(np.random.SeedSequence().entropy % (2**31))
6751 rng = np.random.default_rng(layer1_seed_base)
6752 layer1_logger.info(
6753 "Layer 1 RNG/CMA reproducibility: layer1_random_seed base=%s "
6754 "(CMA seed per restart = base + restart_idx × 1_000_003)",
6755 layer1_seed_base,
6756 )
6757
6758 # Get report_every_n from requirements (default: 1 for real-time)
6759 # None-safe: the key is now DECLARED in DesignRequirementsConfig, so it arrives
6760 # present-with-value-None rather than absent, and .get(k, default) no longer fires.
6761 _ren = requirements.get("report_every_n")
6762 report_every_n = int(_ren) if _ren is not None else 1
6763 report_every_n = max(1, report_every_n) # At least every iteration
6764
6765 # Initialize optimization state
6766 opt_state = {
6767 "iteration": 0,
6768 "function_evaluations": 0,
6769 "best_objective": float('inf'),
6770 "best_x": None,
6771 "best_config": None,
6772 "best_config_x": None,
6773 "best_lox_end_ratio": None,
6774 "best_fuel_end_ratio": None,
6775 "best_pressures": None,
6776 "last_eval_config": None,
6777 "last_eval_config_x": None,
6778 "best_validation_tank_pa": None,
6779 "last_eval_validation_tank_pa": None,
6780 "last_good_eval_bundle": None,
6781 "best_results_for_validation": None,
6782 "converged": False,
6783 "objective_satisfied": False,
6784 "satisfied_obj": float('inf'),
6785 "satisfied_eval_count": 0,
6786 "consecutive_failures": 0,
6787 "last_valid_obj": float('inf'),
6788 "history": [],
6789 "objective_buffer": [], # Buffer for batch reporting
6790 "stop_optimization": False,
6791 "force_maxfun_1": False,
6792 "last_best_eval": 0,
6793 "valley_escape_tier": 0, # 0=normal, 1=mild, 2=medium, 3=full
6794 "cooldown_until": 0,
6795 }
6796
6797 layer1_W_MOM = _requirement_float(requirements, "W_MOM", 75.0)
6798 layer1_momentum_log_deadband_rel = _requirement_float(
6799 requirements,
6800 "layer1_momentum_log_deadband_rel",
6801 _LAYER1_DEFAULT_MOMENTUM_LOG_DEADBAND_REL,
6802 )
6803 _irm_lo = requirements.get("impinging_momentum_R_min")
6804 _irm_hi = requirements.get("impinging_momentum_R_max")
6805 layer1_impinging_R_mom_lo = float(_irm_lo) if _irm_lo is not None else None
6806 layer1_impinging_R_mom_hi = float(_irm_hi) if _irm_hi is not None else None
6807 # AIM band for the optimizer (penalty + interior objective gates): the middle 80% of the
6808 # validation band. A zero-inside-deadband penalty lets CMA converge EPSILON OUTSIDE the
6809 # band edge (gradient there is ~0, e.g. R=1.051 costs 0.03), which the hard validation
6810 # gate then rejects by 0.001 — a fully converged run stamped INVALID. The penalty and the
6811 # gate must never share an edge: the optimizer aims at the interior; final validation
6812 # keeps the exact configured band and passes with real margin.
6813 layer1_R_mom_aim_lo = layer1_R_mom_aim_hi = None
6814 if layer1_impinging_R_mom_lo is not None and layer1_impinging_R_mom_hi is not None:
6815 _r_half = 0.5 * (layer1_impinging_R_mom_hi - layer1_impinging_R_mom_lo)
6816 _r_mid = 0.5 * (layer1_impinging_R_mom_hi + layer1_impinging_R_mom_lo)
6817 layer1_R_mom_aim_lo = _r_mid - 0.8 * _r_half
6818 layer1_R_mom_aim_hi = _r_mid + 0.8 * _r_half
6819 layer1_W_chamber_shape = _requirement_float(requirements, "W_CHAMBER_SHAPE", 2500.0)
6820 layer1_chamber_dt_ratio_min = _requirement_float(requirements, "layer1_chamber_dt_ratio_min", 2.2)
6821 layer1_chamber_dt_ratio_max = _requirement_float(requirements, "layer1_chamber_dt_ratio_max", 3.2)
6822 layer1_chamber_ld_ratio_min = _requirement_float(requirements, "layer1_chamber_ld_ratio_min", 1.0)
6823 layer1_chamber_ld_ratio_max = _requirement_float(requirements, "layer1_chamber_ld_ratio_max", 3.2)
6824 _iang_lo = requirements.get("layer1_impinging_angle_deg_min")
6825 _iang_hi = requirements.get("layer1_impinging_angle_deg_max")
6826 layer1_impinging_angle_deg_lo = float(_iang_lo) if _iang_lo is not None else None
6827 layer1_impinging_angle_deg_hi = float(_iang_hi) if _iang_hi is not None else None
6828 layer1_W_impinging_angle = _requirement_float(requirements, "W_IMPINGING_ANGLE", 0.0)
6829 layer1_W_impinging_jet_asym = _requirement_float(requirements, "W_IMPINGING_JET_ASYM", 160.0)
6830 layer1_impinging_jet_max_asym_deg = _requirement_float(
6831 requirements, "layer1_impinging_jet_angle_max_asym_deg", 28.0
6832 )
6833 layer1_exit_pressure_inside_quad = _requirement_float(
6834 requirements, "layer1_exit_pressure_inside_quad_scale", 0.35
6835 )
6836 layer1_W_EXIT = _requirement_float(requirements, "layer1_W_EXIT", 2.0e2)
6837 # Exit-pressure deadband. Was hardcoded at 0.05, i.e. ANY P_exit within +/-5% of ambient
6838 # cost nothing at all -- for a 13.64 psi target that is 12.96-14.33 psi free, which is why
6839 # runs kept landing at 14.6 and looking "converged". Config-driven so it can be tightened
6840 # to the precision actually wanted.
6841 layer1_exit_pressure_deadband_rel = _requirement_float(
6842 requirements, "layer1_exit_pressure_deadband_rel", 0.05
6843 )
6844 # Chamber dry-mass cost. Unset, it is priced against the performance term below.
6845 layer1_W_MASS = _requirement_float(requirements, "layer1_W_MASS", 0.0)
6846 layer1_W_LSTAR = _requirement_float(requirements, "layer1_W_LSTAR", 0.0)
6847 layer1_Lstar_target_m = _requirement_float(requirements, "layer1_Lstar_target_m", 1.0)
6848 layer1_chamber_wall_density = _requirement_float(
6849 requirements, "layer1_chamber_wall_density_kg_m3", 2000.0
6850 )
6851 layer1_chamber_mass_ref_kg = _requirement_float(
6852 requirements, "layer1_chamber_mass_ref_kg", 5.0
6853 )
6854 layer1_W_DP = _requirement_float(requirements, "W_DP", 160.0)
6855 # Pull toward the centre of the ΔP/Pc band. The atomization (SMD) benefit of higher ΔP is a
6856 # strong competing force that pins ΔP/Pc to the 0.40 edge, so this needs a real counterweight
6857 # (~50) to settle near ~0.33; it is still small vs the band hinge / thrust so it never overrides
6858 # feasibility. Lower it toward 0 to allow the edge, raise it to pull harder toward 0.30. Tune via
6859 # design_requirements.W_DP_CENTER.
6860 layer1_W_DP_CENTER = _requirement_float(requirements, "W_DP_CENTER", 500.0)
6861 layer1_W_DP_O = (
6862 float(requirements["W_DP_O"])
6863 if requirements.get("W_DP_O") is not None
6864 else layer1_W_DP
6865 )
6866 layer1_W_DP_F = (
6867 float(requirements["W_DP_F"])
6868 if requirements.get("W_DP_F") is not None
6869 else layer1_W_DP
6870 )
6871 layer1_W_DP_HIGH = _requirement_float(requirements, "W_DP_HIGH", 480.0)
6872 layer1_dp_o_band, layer1_dp_f_band = injector_dp_bands_from_requirements(
6873 requirements,
6874 default_o=_LAYER1_DEFAULT_DP_O_BAND,
6875 default_f=_LAYER1_DEFAULT_DP_F_BAND,
6876 )
6877
6878 # Interior AIM bands for the ΔP/Pc PENALTY (workers + parent objective + diagnostics):
6879 # middle 80% of the validation band, same rationale as the momentum-ratio aim band —
6880 # a zero-inside-deadband penalty parks candidates ON the band edge (seen live:
6881 # ΔP_F/Pc = 0.400 vs gate [0.200, 0.400] → converged run stamped INVALID). Final
6882 # validation keeps the exact configured bands and now passes with real margin.
6883 def _interior_band(band, frac: float = 0.8):
6884 lo, hi = float(band[0]), float(band[1])
6885 half = 0.5 * (hi - lo)
6886 mid = 0.5 * (hi + lo)
6887 return (mid - frac * half, mid + frac * half)
6888
6889 layer1_dp_o_aim = _interior_band(layer1_dp_o_band)
6890 layer1_dp_f_aim = _interior_band(layer1_dp_f_band)
6891
6892 # Figure of merit: delivered Isp against chamber mass, both priced per kg. At fixed total
6893 # impulse I = F t_b the propellant is I/(g0 Isp); the isp term is the propellant beyond an
6894 # ideal engine's, as a fraction of it (W_ISP per unit fraction, so W_ISP/m_prop_ref per kg).
6895 # Unset, the chamber-mass weight takes the same price per kg at its reference mass.
6896 _l1_cea_cache = getattr(runner, "cea_cache", None)
6897 if not _layer1_cea_ok(_l1_cea_cache):
6898 _l1_cea_cache = None
6899 _pc_ref_psi = (float(_pc_tgt_seed) if (_pc_tgt_seed is not None and float(_pc_tgt_seed) > 0)
6900 else min(max_lox_P_psi, max_fuel_P_psi) * max_P_ratio
6901 / (1.0 + 0.25 * (sum(layer1_dp_o_band) + sum(layer1_dp_f_band))))
6902 _t_b = float(target_burn_time or 0.0)
6904 requirements, _l1_cea_cache, float(optimal_of), _pc_ref_psi * psi_to_Pa,
6905 float(target_P_exit), (float(exp_ratio_lo), float(exp_ratio_hi)),
6906 float(target_thrust), _t_b, layer1_W_MASS, layer1_chamber_mass_ref_kg)
6907 layer1_W_ISP, layer1_W_MASS = _mw["W_ISP"], _mw["W_MASS"]
6908 layer1_Isp_ref_s, _eps_ref, layer1_m_prop_ref_kg = _mw["Isp_ref_s"], _mw["eps_ref"], _mw["m_prop_ref_kg"]
6909 if not np.isfinite(layer1_Isp_ref_s) and _requirement_float(requirements, "layer1_W_ISP", 1.0) > 0:
6910 layer1_logger.warning("Layer 1: no CEA table for the Isp reference; performance term off.")
6911 layer1_logger.info(
6912 "Layer 1 figure of merit: W_ISP=%g on Isp_ref %.2f s (ideal, O/F %.3f, Pc %.0f psi, eps %.3f); "
6913 "m_prop_ref %.3f kg for %.0f N x %.2f s; W_MASS=%g (%s)",
6914 layer1_W_ISP, layer1_Isp_ref_s, float(optimal_of), _pc_ref_psi, _eps_ref,
6915 layer1_m_prop_ref_kg, float(target_thrust), _t_b, layer1_W_MASS,
6916 "config" if requirements.get("layer1_W_MASS") is not None else "priced like propellant")
6917 _lio_sf_raw = requirements.get("injector_dp_ratio_O_soft_floor")
6918 layer1_dp_o_soft_floor: Optional[float] = (
6919 float(_lio_sf_raw)
6920 if _lio_sf_raw is not None and np.isfinite(float(_lio_sf_raw))
6921 else None
6922 )
6923 layer1_W_DP_O_FLOOR = _requirement_float(requirements, "W_DP_O_FLOOR", 0.0)
6924 layer1_W_SMD = _requirement_float(requirements, "W_SMD", 0.0)
6925 layer1_target_smd_microns = _requirement_float(requirements, "target_smd_microns", 50.0)
6926 layer1_smd_rel_tol = _requirement_float(requirements, "layer1_smd_rel_tol", 0.20)
6927 layer1_W_TANK_EQUAL = _requirement_float(requirements, "W_TANK_EQUAL", 0.0)
6928 layer1_tank_equal_scale_psi = _requirement_float(requirements, "layer1_tank_equal_scale_psi", 100.0)
6929 layer1_tank_equal_tol_psi = _requirement_float(requirements, "layer1_tank_equal_tol_psi", 0.0)
6930 layer1_of_deadband_rel = _requirement_float(requirements, "layer1_of_deadband_rel", 0.0)
6931 # Default ON. This was made opt-in while the flat 1e6 infeasibility plateau meant a hard
6932 # geometry constraint could stall the search before it ever found a feasible point; the
6933 # infeasibility GRADIENT removed that failure mode (6/6 seeds converge with it enabled),
6934 # so leaving it off just silences a weight the user set and lets impossible rings through.
6935 layer1_enforce_ring_geometry = _requirement_bool(
6936 requirements, "layer1_enforce_ring_geometry", True)
6937 # Injector face real estate. Default 0.0 => inert, identical to the previous behaviour.
6938 layer1_injector_center_clear_dia_m = _requirement_float(
6939 requirements, "layer1_injector_center_clear_dia_m", 0.0)
6940 layer1_injector_min_web_m = _requirement_float(
6941 requirements, "layer1_injector_min_web_m", 0.0)
6942 layer1_injector_wall_clearance_m = _requirement_float(
6943 requirements, "layer1_injector_wall_clearance_m", 0.0)
6944 layer1_injector_spray_radius_frac = _requirement_float(
6945 requirements, "layer1_injector_spray_radius_frac", 0.0)
6946 layer1_injector_spray_radius_tol = _requirement_float(
6947 requirements, "layer1_injector_spray_radius_tol", 0.08)
6948 layer1_injector_min_face_incidence_deg = _requirement_float(
6949 requirements, "layer1_injector_min_face_incidence_deg", 0.0)
6950 layer1_injector_plate_thickness_m = _requirement_float(
6951 requirements, "layer1_injector_plate_thickness_m", 0.0)
6952 layer1_injector_counterbore_dia_m = _requirement_float(
6953 requirements, "layer1_injector_counterbore_dia_m", 0.0)
6954 layer1_injector_min_back_web_m = _requirement_float(
6955 requirements, "layer1_injector_min_back_web_m", 0.0)
6956 # Orifice L/d per side and how the plug is machined (injector.plate): one derivation,
6957 # shared with the sign-off gates (_layer1_injector_plate_constants).
6958 _pc = _layer1_injector_plate_constants(config_obj, requirements)
6959 layer1_injector_land_ld_O = _pc["layer1_injector_land_ld_O"]
6960 layer1_injector_land_ld_F = _pc["layer1_injector_land_ld_F"]
6961 layer1_injector_face_contoured = _pc["layer1_injector_face_contoured"]
6962 layer1_injector_back_channels = _pc["layer1_injector_back_channels"]
6963 layer1_injector_exit_land = _pc["layer1_injector_exit_land"]
6964 layer1_injector_channel_width_m = _pc["layer1_injector_channel_width_m"]
6965 layer1_injector_channel_floor = _pc["layer1_injector_channel_floor"]
6966 layer1_injector_channel_inlets = _pc["layer1_injector_channel_inlets"]
6967 layer1_injector_plate_support = _pc["layer1_injector_plate_support"]
6968 if layer1_injector_face_contoured and layer1_injector_back_channels:
6969 # Square exits AND passage length set by the channel: nothing left for the ceiling.
6970 layer1_injector_min_face_incidence_deg = 0.0
6971 from engine.core.injectors.layout import assembly_envelope as _assembly_envelope
6972 _env = _assembly_envelope(config_obj)
6973 layer1_injector_r_plate_m = float(_env["r_sleeve_id"]) # fallback; per candidate below
6974 layer1_injector_liner_thickness_m = float(_env["liner_thickness"])
6975 # A declared igniter port sets the centre keep-out from its thread when no explicit
6976 # reservation is given, and its hub / socket becomes a back-face keep-out. Nothing
6977 # declared => nothing changes.
6978 layer1_injector_center_clear_dia_m = _layer1_centre_clear_m(config_obj, requirements)
6979 _ign = _layer1_igniter_keepouts(config_obj, requirements)
6980 layer1_injector_back_keepout_dia_m = float(_ign["back_keepout_dia"]) if _ign else 0.0
6981 layer1_injector_back_keepout_wall_m = float(_ign["face_wall"]) if _ign else 0.0
6982 layer1_injector_r_port_m = 0.5 * float(_ign["thread_od"]) if _ign else 0.0
6983 layer1_resultant_tilt_from_reach = _requirement_bool(
6984 requirements, "layer1_resultant_tilt_from_reach", False)
6985 layer1_resultant_tilt_reach_margin = _requirement_float(
6986 requirements, "layer1_resultant_tilt_reach_margin", 1.5)
6987 layer1_chamber_od_increment_in = _requirement_float(
6988 requirements, "layer1_chamber_od_increment_in", 0.0
6989 )
6990 layer1_chamber_od_snap_target = _od_snap_target
6991 layer1_tank_equal_inband_frac = _requirement_float(
6992 requirements, "layer1_tank_equal_inband_frac", 0.0)
6993 # "Match propellant tank pressures" says the two are ONE quantity. Follow the checkbox
6994 # (which sets W_TANK_EQUAL) by default, but keep it separately overridable so a user can
6995 # want them near-equal without welding them together.
6996 layer1_lock_tank_pressures = _requirement_bool(
6997 requirements, "layer1_lock_tank_pressures", layer1_W_TANK_EQUAL > 0.0)
6998 layer1_ring_order_fuel_outboard = _requirement_bool(
6999 requirements, "layer1_ring_order_fuel_outboard", True)
7000 # R's ONLY job was to guard the liner, and it did it badly (R = 1 is not balance --
7001 # p_O/p_F = R^2*A_O/A_F). The resultant tilt below now does that job properly, so R keeps
7002 # no directional preference: a multiplier of 1 makes it symmetric about 1, a mild mixing
7003 # preference and nothing more. Left at 10 it charged 430-1630 objective points against
7004 # precisely the tilt-balanced designs, because balancing the fan puts R a few tenths of a
7005 # percent above 1.
7006 layer1_momentum_wall_side_multiplier = _requirement_float(
7007 requirements, "layer1_momentum_wall_side_multiplier", 1.0)
7008 layer1_resultant_tilt_max_deg = _requirement_float(
7009 requirements, "layer1_resultant_tilt_max_deg", 0.0)
7010 layer1_resultant_tilt_scale_deg = _requirement_float(
7011 requirements, "layer1_resultant_tilt_scale_deg", 2.0)
7012 layer1_W_TILT = _requirement_float(requirements, "layer1_W_TILT", 0.0)
7013 layer1_resultant_tilt_outward_multiplier = _requirement_float(
7014 requirements, "layer1_resultant_tilt_outward_multiplier", 25.0)
7015 layer1_resultant_tilt_gate_tol_deg = _requirement_float(
7016 requirements, "layer1_resultant_tilt_gate_tol_deg", 1.0)
7017 layer1_momentum_low_side_multiplier = _requirement_float(
7018 requirements, "layer1_momentum_low_side_multiplier", 10.0)
7019 layer1_momentum_band_width = _requirement_float(
7020 requirements, "layer1_momentum_band_width", 0.05)
7021 layer1_momentum_scale = _requirement_float(requirements, "layer1_momentum_scale", 0.10)
7022 # The gate is the configured band; >1 widens it on purpose, never by default.
7023 layer1_momentum_gate_safe_slack = max(1.0, _requirement_float(
7024 requirements, "layer1_momentum_gate_safe_slack", 1.0))
7025 # The user states ONE number -- how many jet diameters off the face the jets should meet --
7026 # and the free window is that target +/- tol. Explicit Ld_min/Ld_max still win, so an
7027 # existing config that set the band keeps its behaviour.
7028 layer1_derive_impingement_spacing = _requirement_bool(
7029 requirements, "layer1_derive_impingement_spacing", True)
7030 # With the spacing DERIVED, L/d lands on the target exactly, so the band is the target --
7031 # zero width. It is not a preference to negotiate; the only way to miss it is the fuel
7032 # ring clamping at the bore, and that is exactly what should be reported. The tolerance
7033 # remains for anyone who turns the derivation off and wants a search window instead.
7034 # One resolver (engine.core.injectors.layout) so the drawing and audit agree with this.
7035 _ld_target, layer1_impingement_Ld_min, layer1_impingement_Ld_max = impingement_ld_band(
7036 requirements)
7037 layer1_W_IMP_GEOM = _requirement_float(requirements, "W_IMP_GEOM", 0.0)
7038 layer1_W_geom_ao_af = float(requirements.get("W_geom_ao_af_momentum", 0.0))
7039 # With the tanks locked, dP_O ~= dP_F, so R ~= Cd_O/Cd_F ~= 1 by construction and the
7040 # injector AREA ratio is the only lever left for O/F. This hint pushes that same area
7041 # ratio toward "R=1 at the target MR", which presumes the two dPs can differ -- a target
7042 # that no longer exists -- and its scale GROWS with O/F error, so the worse O/F gets the
7043 # harder it pulls the wrong way (measured: O/F 30% off, of=5500-6400 of a 6300-7700
7044 # objective, geom_ao_af=360-420 riding alongside it). Off when locked.
7045 _tanks_locked_for_geom = _requirement_bool(
7046 requirements, "layer1_lock_tank_pressures",
7047 _requirement_float(requirements, "W_TANK_EQUAL", 0.0) > 0.0)
7048 layer1_W_THRUST_obj = _requirement_float(requirements, "layer1_W_THRUST", 1e4)
7049 layer1_W_OF_obj = _requirement_float(requirements, "layer1_W_OF", 1e4)
7050 _w_ol = requirements.get("layer1_W_OF_low_MR_scale")
7051 layer1_W_OF_low_MR_scale = max(1.0, float(_w_ol)) if _w_ol is not None else 1.0
7052 _w_oh = requirements.get("layer1_W_OF_high_MR_scale")
7053 layer1_W_OF_high_MR_scale = max(1.0, float(_w_oh)) if _w_oh is not None else 1.0
7054 # Impinging methane runs can stall at high O/F while still satisfying momentum/ΔP if
7055 # AO/AF geometry guidance is disabled. Apply a conservative adaptive fallback when unset.
7056 if l1_injector_type == "impinging" and layer1_W_geom_ao_af <= 0.0:
7057 layer1_W_geom_ao_af = 0.0 if _tanks_locked_for_geom else max(400.0, 0.05 * layer1_W_OF_obj)
7058 if l1_injector_type == "impinging" and _tanks_locked_for_geom and layer1_W_geom_ao_af > 0.0:
7059 # Explicit value in the config, but the tanks are locked: dP_O ~= dP_F makes R ~= 1
7060 # structural and this hint redundant with the O/F term (same target area ratio), while
7061 # its error-scaled weight adds positive feedback to any O/F miss. Off, and say so.
7062 layer1_logger.info(
7063 "Layer 1: W_geom_ao_af_momentum=%g ignored -- tank pressures are locked, so the "
7064 "momentum ratio is fixed by construction and O/F alone owns the injector area ratio.",
7065 layer1_W_geom_ao_af)
7066 layer1_W_geom_ao_af = 0.0
7067 layer1_of_validation_tol = _requirement_float(requirements, "layer1_of_validation_tol", 0.15)
7068 # Final validation uses ``layer1_thrust_validation_rel_tol`` when set; errors_acceptable must match.
7069 _thr_gate_yaml = requirements.get("layer1_thrust_validation_rel_tol")
7070 layer1_gate_thrust_tol = (
7071 float(_thr_gate_yaml)
7072 if _thr_gate_yaml is not None and np.isfinite(float(_thr_gate_yaml)) and float(_thr_gate_yaml) > 0
7073 else float(thrust_tol)
7074 )
7075 layer1_logger.info(
7076 "Injector ΔP_inj/Pc preferred bands (soft hinge): "
7077 f"oxidizer [{layer1_dp_o_band[0]:.3f}, {layer1_dp_o_band[1]:.3f}], "
7078 f"fuel [{layer1_dp_f_band[0]:.3f}, {layer1_dp_f_band[1]:.3f}]"
7079 )
7080 layer1_logger.info(
7081 "Injector ΔP weights: W_DP=%g (fallback); W_DP_O=%g; W_DP_F=%g",
7082 layer1_W_DP,
7083 layer1_W_DP_O,
7084 layer1_W_DP_F,
7085 )
7086 if layer1_dp_o_soft_floor is not None and layer1_dp_o_soft_floor > 0 and layer1_W_DP_O_FLOOR > 0:
7087 layer1_logger.info(
7088 "LOX dP_inj/Pc soft floor: penalize ratios below %.4f using W_DP_O_FLOOR=%.4g*(floor-r_O)^2",
7089 layer1_dp_o_soft_floor,
7090 layer1_W_DP_O_FLOOR,
7091 )
7092 if layer1_W_geom_ao_af > 0.0:
7093 layer1_logger.info(
7094 f"Geometry AO/AF momentum hint weight W_geom_ao_af_momentum={layer1_W_geom_ao_af:g} "
7095 f"(targets A_O/A_F ≈ MR/√(ρ_O/ρ_F) for R≈1)"
7096 )
7097 if layer1_impinging_R_mom_lo is not None and layer1_impinging_R_mom_hi is not None:
7098 layer1_logger.info(
7099 "Impinging momentum_ratio_R: validation band "
7100 f"[{layer1_impinging_R_mom_lo:g}, {layer1_impinging_R_mom_hi:g}], optimizer AIM band "
7101 f"[{layer1_R_mom_aim_lo:g}, {layer1_R_mom_aim_hi:g}] "
7102 f"(penalty pushes inside the aim band so validation passes with margin)"
7103 )
7104 if layer1_W_impinging_angle > 0.0 and layer1_impinging_angle_deg_lo is not None and layer1_impinging_angle_deg_hi is not None:
7105 layer1_logger.info(
7106 "Impinging effective-angle preferred band (deg): "
7107 f"[{layer1_impinging_angle_deg_lo:g}, {layer1_impinging_angle_deg_hi:g}] "
7108 f"with W_IMPINGING_ANGLE={layer1_W_impinging_angle:g}"
7109 )
7110 if l1_injector_type == "impinging" and layer1_W_impinging_jet_asym > 0.0:
7111 layer1_logger.info(
7112 "Impinging paired jet |Δθ_O/F| soft cap: max Δ=%.2f° with W_IMPINGING_JET_ASYM=%g",
7113 float(layer1_impinging_jet_max_asym_deg),
7114 float(layer1_W_impinging_jet_asym),
7115 )
7116 layer1_logger.info(
7117 "Ambient-matched exit pressure: inside-deadband quadratic scale "
7118 "layer1_exit_pressure_inside_quad_scale=%g (W_EXIT×scale×rel²; 0=flat inside band)",
7119 float(layer1_exit_pressure_inside_quad),
7120 )
7121 layer1_logger.info(
7122 "Layer 1 primary weights: layer1_W_THRUST=%g layer1_W_OF=%g low_MR_scale=%g high_MR_scale=%g; "
7123 "of_validation_tol=%.4f",
7124 layer1_W_THRUST_obj,
7125 layer1_W_OF_obj,
7126 layer1_W_OF_low_MR_scale,
7127 layer1_W_OF_high_MR_scale,
7128 layer1_of_validation_tol,
7129 )
7130
7131 log_flags = {
7132 "marginal_candidate_logged": False,
7133 }
7134
7135 update_progress("Layer 1: Objective", 0.10, "Defining objective function...")
7136
7137 # ------------------------------------------------------------------
7138 # Objective acceleration: cache expensive evaluate() calls
7139 # ------------------------------------------------------------------
7140 # Many optimizers will revisit the same point (especially with discrete
7141 # variables like n_orifices). Caching is a large speed win because
7142 # `PintleEngineRunner.evaluate()` is the expensive part.
7143 #
7144 # Cache key strategy:
7145 # - clip to bounds
7146 # - quantize continuous dims to a fraction of their span
7147 # - keep discrete dims exact (n_orifices)
7148 # Finer granularity (1e-5 instead of 1e-4) to preserve gradient information for L-BFGS-B
7149 _ocr = requirements.get("objective_cache_rel") # None-safe, see report_every_n
7150 cache_rel = float(_ocr) if _ocr is not None else 1e-5 # 0.001% of span per bin
7151 cache_rel = float(np.clip(cache_rel, 1e-6, 1e-3))
7152 cache_steps = np.maximum(span * cache_rel, 1e-12)
7153 eval_cache: Dict[Tuple[int, ...], Dict[str, Any]] = {}
7154
7155 def _make_eval_cache_key(x_raw: np.ndarray) -> Tuple[int, ...]:
7156 """Quantize x to a stable hashable key for caching evaluate() results."""
7157 x_arr = np.clip(np.asarray(x_raw, dtype=float), lower_bounds, upper_bounds)
7158 x_arr = x_arr.copy()
7159 for di in layer1_integer_dims:
7160 if 0 <= di < len(x_arr):
7161 x_arr[di] = int(round(x_arr[di]))
7162 key_parts = []
7163 for i, v in enumerate(x_arr):
7164 if i in layer1_integer_dims:
7165 key_parts.append(int(v))
7166 continue
7167 step = float(cache_steps[i])
7168 # Quantize within bounds so different absolute magnitudes hash consistently
7169 key_parts.append(int(round((float(v) - float(lower_bounds[i])) / step)))
7170 return tuple(key_parts)
7171
7172 def _check_valley_escape_tier() -> int:
7174 opt_state["function_evaluations"], opt_state["best_objective"],
7175 opt_state["function_evaluations"] - opt_state["last_best_eval"])
7176
7177 # Define objective function
7178 def objective(x: np.ndarray) -> float:
7179 """Layer 1 objective function: optimize geometry + initial pressures.
7180
7181 Staged/lexicographic structure (approximate but smooth):
7182 1) Feasibility (hard constraints + stability gates)
7183 2) Thrust closeness
7184 3) O/F closeness
7185 4) Regularization (Cf band, chamber length, exit pressure)
7186
7187 Note: we still return a single scalar for SciPy/CMA-ES, but the scaling
7188 preserves the priority ordering and reduces "weight fights".
7189 """
7190
7191 # Initialize opt_state keys
7192 for key in ["consecutive_failures", "last_valid_obj", "iteration", "function_evaluations", "best_objective", "best_x"]:
7193 if key not in opt_state:
7194 opt_state[key] = 0 if key in ["iteration", "function_evaluations", "consecutive_failures"] else (float('inf') if key in ["last_valid_obj", "best_objective"] else None)
7195
7196 opt_state["iteration"] += 1
7197 iteration = opt_state["iteration"]
7198 opt_state["function_evaluations"] += 1
7199
7200 # Progress update. ``iteration`` counts candidate evaluations, so it must be
7201 # measured against the evaluation budget (max_iterations x popsize x restarts),
7202 # not against ``max_iterations``, which caps CMA *generations*.
7203 # Set once popsize/restarts are known; before that (x0 validation, warm start)
7204 # there is no budget to measure against, so report the raw count rather than a
7205 # fraction of the wrong denominator.
7206 eval_budget = int(opt_state.get("eval_budget", 0))
7207 if eval_budget > 0:
7208 progress = 0.10 + 0.40 * min(iteration / eval_budget, 1.0)
7209 eval_str = f"{iteration}/{eval_budget}"
7210 else:
7211 progress = 0.10
7212 eval_str = f"{iteration}"
7213 if iteration <= 3 or iteration % 25 == 0:
7214 try:
7215 _bo = float(opt_state["best_objective"])
7216 except (TypeError, ValueError):
7217 _bo = float("inf")
7218 _lv_raw = opt_state.get("last_valid_obj", float("inf"))
7219 try:
7220 _lv = float(_lv_raw) if _lv_raw is not None else float("inf")
7221 except (TypeError, ValueError):
7222 _lv = float("inf")
7223 best_obj_str = f"{_bo:.3e}" if np.isfinite(_bo) else "inf"
7224 curr_obj_str = f"{_lv:.3e}" if np.isfinite(_lv) else "inf"
7225 update_progress("Layer 1: Optimization", progress, f"Eval {eval_str} | Curr: {curr_obj_str} | Best: {best_obj_str}")
7226 layer1_logger.info(f"[{int(progress*100)}%] Evaluation {eval_str} - "
7227 f"Objective: {curr_obj_str} (Best: {best_obj_str})")
7228 for handler in layer1_logger.handlers:
7229 handler.flush()
7230
7231 # Always work with a clipped/consistent candidate vector (helps caching and stability)
7232 x_clipped = np.clip(np.asarray(x, dtype=float), lower_bounds_eff, upper_bounds_eff)
7233 # Apply the SAME derivations the workers apply, to the vector this objective will
7234 # score, cache, store as last_eval_config and report in the history. Without this the
7235 # reported design is not the design that was evaluated: the fuel tank came back at
7236 # its pinned seed (535.2) while the physics had run with it locked to the LOX tank,
7237 # and the fuel ring pitch had the same problem.
7238 _layer1_derive_matched_tank_pressure(x_clipped, _derive_constants)
7239 _layer1_derive_fuel_spacing(x_clipped, _derive_constants)
7240 # Discrete variable
7241 x_clipped = x_clipped.copy()
7242 for di in layer1_integer_dims:
7243 if 0 <= di < len(x_clipped):
7244 x_clipped[di] = int(round(x_clipped[di]))
7245
7246 # Convert x to config
7247 config, _, _ = apply_x_to_config(x_clipped, config_base)
7248
7249 # Feasibility pre-checks (cheap): injector sizing, geometry, and O/F area sanity
7250 from engine.pipeline.config_schemas import ensure_chamber_geometry
7251 cg = ensure_chamber_geometry(config)
7252 A_throat_check = float(cg.A_throat or 0.0)
7253 D_chamber_inner = float(cg.chamber_diameter or 0.0)
7254 A_chamber_check = np.pi * (D_chamber_inner / 2) ** 2
7255
7256 geom = getattr(getattr(config, "injector", None), "geometry", None)
7257 has_pintle = bool(getattr(getattr(config, "injector", None), "type", None) == "pintle" and geom is not None)
7258 has_impinging = bool(getattr(getattr(config, "injector", None), "type", None) == "impinging" and geom is not None)
7259
7260 A_lox_injector = np.nan
7261 A_fuel_injector = np.nan
7262 lox_ratio = np.nan
7263 fuel_ratio = np.nan
7264 area_ratio_error = np.nan
7265
7266 infeasibility_score = 0.0
7267
7268 # --- Geometric Validation Rules ---
7269
7270 # 1. Throat area must be less than chamber area (with some margin)
7271 # Typically contraction ratio (A_chamber / A_throat) is between 2 and 10
7272 if A_chamber_check > 0 and A_throat_check > 0:
7273 contraction_ratio_check = A_chamber_check / A_throat_check
7274 # Constraint: A_throat < A_chamber (contraction_ratio > 1.0)
7275 infeasibility_score += max(0.0, (A_throat_check * 1.1) / A_chamber_check - 1.0) ** 2
7276
7277 # Preferred range for contraction ratio: [1.5, 15.0]
7278 if contraction_ratio_check < 1.5:
7279 infeasibility_score += (1.5 - contraction_ratio_check) ** 2
7280 elif contraction_ratio_check > 15.0:
7281 infeasibility_score += (contraction_ratio_check - 15.0) ** 2
7282
7283 # 2. Pintle diameter must be less than chamber diameter
7284 if has_pintle and D_chamber_inner > 0:
7285 d_pintle_tip_check = float(geom.fuel.d_pintle_tip)
7286 # Constraint: d_pintle_tip < D_chamber_inner (with 10% margin)
7287 infeasibility_score += max(0.0, (d_pintle_tip_check * 1.1) / D_chamber_inner - 1.0) ** 2
7288
7289 # 2a. Explicit Throat Diameter vs Chamber Diameter check
7290 # D_throat < D_chamber check
7291 D_throat_check = np.sqrt(4.0 * A_throat_check / np.pi) if A_throat_check > 0 else 0.0
7292 if D_throat_check > 0 and D_chamber_inner > 0:
7293 # Penalize if throat is larger than 95% of chamber
7294 # This reinforces the contraction ratio check with an explicit diameter-based gradient
7295 infeasibility_score += max(0.0, D_throat_check - D_chamber_inner * 0.95) ** 2 * 10.0
7296
7297 # 3. Nozzle exit diameter vs throat diameter
7298 D_exit_check = float(cg.exit_diameter or 0.0)
7299 R_throat_check = np.sqrt(max(0, A_throat_check / np.pi))
7300 D_throat_check = 2 * R_throat_check
7301 if D_exit_check > 0 and D_throat_check > 0:
7302 # Expansion ratio should be >= 1.0 (already handled by bounds, but for safety)
7303 infeasibility_score += max(0.0, D_throat_check / D_exit_check - 1.0) ** 2
7304
7305 # --- Injector and O/F Validation ---
7306
7307 if has_pintle and A_throat_check > 0:
7308 A_lox_injector = float(geom.lox.n_orifices * np.pi * (geom.lox.d_orifice / 2) ** 2)
7309 R_inner = float(geom.fuel.d_pintle_tip / 2)
7310 R_outer = float(R_inner + geom.fuel.h_gap)
7311 A_fuel_injector = float(np.pi * (R_outer ** 2 - R_inner ** 2))
7312 # Flow-capacity vs throat and O/F area sanity use Cd-weighted A_eff (post-evaluate below)
7313
7314 if has_impinging and A_throat_check > 0:
7315 oxg = geom.oxidizer
7316 fug = geom.fuel
7317 A_lox_injector = float(oxg.n_elements * np.pi * (oxg.d_jet / 2.0) ** 2)
7318 A_fuel_injector = float(fug.n_elements * np.pi * (fug.d_jet / 2.0) ** 2)
7319 sp_O = float(oxg.spacing)
7320 sp_F = float(fug.spacing)
7321 djo = float(oxg.d_jet)
7322 djf = float(fug.d_jet)
7323 infeasibility_score += _impinging_infeasibility_layout_terms(
7324 d_jet_O=djo,
7325 d_jet_F=djf,
7326 sp_O=sp_O,
7327 sp_F=sp_F,
7328 n_el_O=int(oxg.n_elements),
7329 n_el_F=int(fug.n_elements),
7330 D_chamber_inner=D_chamber_inner,
7331 )
7332
7333 # Tank pressures (dimensionless ratios are used for penalties and caching guidance)
7334 P_O_psi = float(np.clip(x_clipped[idx_P_O], bounds[idx_P_O][0], bounds[idx_P_O][1]))
7335 P_F_psi = float(np.clip(x_clipped[idx_P_F], bounds[idx_P_F][0], bounds[idx_P_F][1]))
7336 P_O_test = P_O_psi * psi_to_Pa
7337 P_F_test = P_F_psi * psi_to_Pa
7338 P_O_ratio = P_O_psi / max_lox_P_psi if max_lox_P_psi > 0 else 0.0
7339 P_F_ratio = P_F_psi / max_fuel_P_psi if max_fuel_P_psi > 0 else 0.0
7340
7341 skip_physics_eval = False
7342 if has_impinging and A_throat_check > 0 and geom is not None:
7343 oxg = geom.oxidizer
7344 fug = geom.fuel
7345 skip_physics_eval = _impinging_hard_geometry_blocks_eval(
7346 d_jet_O=float(oxg.d_jet),
7347 d_jet_F=float(fug.d_jet),
7348 sp_O=float(oxg.spacing),
7349 sp_F=float(fug.spacing),
7350 D_chamber_inner=D_chamber_inner,
7351 D_throat_check=D_throat_check,
7352 A_chamber_check=A_chamber_check,
7353 A_throat_check=A_throat_check,
7354 n_elements=float(oxg.n_elements),
7355 angle_O_deg=float(oxg.impingement_angle),
7356 angle_F_deg=float(fug.impingement_angle),
7357 center_clear_dia_m=layer1_injector_center_clear_dia_m,
7358 min_web_m=layer1_injector_min_web_m,
7359 wall_clearance_m=layer1_injector_wall_clearance_m,
7360 min_face_incidence_deg=layer1_injector_min_face_incidence_deg,
7361 spray_radius_frac=layer1_injector_spray_radius_frac,
7362 spray_radius_tol=layer1_injector_spray_radius_tol,
7363 plate_thickness_m=layer1_injector_plate_thickness_m,
7364 counterbore_dia_m=layer1_injector_counterbore_dia_m,
7365 land_ld_O=layer1_injector_land_ld_O,
7366 land_ld_F=layer1_injector_land_ld_F,
7367 min_back_web_m=layer1_injector_min_back_web_m,
7368 back_keepout_dia_m=layer1_injector_back_keepout_dia_m,
7369 back_keepout_wall_m=layer1_injector_back_keepout_wall_m,
7370 face_contoured=layer1_injector_face_contoured,
7371 exit_land=layer1_injector_exit_land,
7372 back_channels=layer1_injector_back_channels,
7373 passage_ld_O=layer1_injector_land_ld_O,
7374 passage_ld_F=layer1_injector_land_ld_F,
7375 channel_width_m=layer1_injector_channel_width_m,
7376 channel_floor=layer1_injector_channel_floor,
7377 r_plate_m=layer1_injector_r_plate_m,
7378 r_port_m=layer1_injector_r_port_m,
7379 liner_thickness_m=layer1_injector_liner_thickness_m,
7380 )
7381 elif infeasibility_score > 0.0:
7382 skip_physics_eval = True
7383
7384 eval_success = False
7385 final_results: Dict[str, Any] = {}
7386 final_pressures = (P_O_test, P_F_test)
7387 eval_error_str: Optional[str] = None
7388
7389 # During L-BFGS-B refinement, ``objective()`` is called sequentially and needs
7390 # exact ``evaluate()`` values for finite-diff gradients — cache quantization can
7391 # flatten curvature and inflate residuals. Parallel CMA paths use workers, not this
7392 # cache toggle.
7393 cache_enabled = not bool(opt_state.get("layer1_lbfgs_phase", False))
7394 cache_key = _make_eval_cache_key(x_clipped) if cache_enabled else None
7395 if not skip_physics_eval:
7396 cached = eval_cache.get(cache_key) if cache_enabled and cache_key is not None else None
7397 use_cached = False
7398 if cached is not None:
7399 # Handle both dict format (full cache from objective) and float/dict format (from parallel eval)
7400 if isinstance(cached, dict):
7401 # Check if it's the full format with "results" key
7402 if "results" in cached:
7403 eval_success = bool(cached.get("success", False))
7404 final_results = copy.deepcopy(cached.get("results", {})) if eval_success else {}
7405 eval_error_str = cached.get("error", None)
7406 use_cached = True
7407 # Replay the solved DOFs onto x/config. The cache key is
7408 # the *proposed* vector, so without this a hit would hand
7409 # back results for the solved geometry while ``config``
7410 # still described the unsolved one.
7411 _xs = cached.get("x_solved")
7412 if _xs is not None and len(_xs) == len(x_clipped):
7413 x_clipped[:] = np.asarray(_xs, dtype=float)
7415 config,
7416 A_throat=float(x_clipped[0]),
7417 Lstar=float(x_clipped[1]),
7418 expansion_ratio=float(x_clipped[2]),
7419 D_chamber_outer=_quantize_chamber_od_m(
7420 float(x_clipped[3]), _od_increment_in,
7421 wall_m=wall_total_m, snap_target=_od_snap_target),
7422 max_nozzle_exit=max_nozzle_exit,
7423 wall_thickness_m=wall_total_m,
7424 )
7425 else:
7426 # Partial format from parallel eval (only has 'value', 'success')
7427 # Can't use this - we need the full results dict, so treat as cache miss
7428 use_cached = False
7429 else:
7430 # Old format: direct float value - can't use, need full results
7431 use_cached = False
7432
7433 if not use_cached:
7434 # Thermal protection stays as the config declares it, so this path scores the
7435 # same bore as the worker pool. It used to be forced off "for speed", which made
7436 # the inline objective and the pool disagree by the liner thickness.
7437 config_runner = copy.deepcopy(config)
7438 if os.environ.get("ED_L1_INLINE_THERMAL_OFF", "0") == "1":
7439 if hasattr(config_runner, "ablative_cooling") and config_runner.ablative_cooling:
7440 config_runner.ablative_cooling.enabled = False
7441 if hasattr(config_runner, "graphite_insert") and config_runner.graphite_insert:
7442 config_runner.graphite_insert.enabled = False
7443
7444 test_runner = PintleEngineRunner(config_runner)
7445
7446 _last_err = [None]
7447
7448 def _evaluate_once_slow():
7449 # The worker pool's evaluation path (accelerator first, Python when it
7450 # declines), so the inline objective scores exactly what the pool scores.
7451 try:
7453 from engine import accel as _accel
7454 _r = _accel.evaluate(test_runner.config, test_runner.cea_cache,
7455 P_O_test, P_F_test, target_P_exit)
7456 if _r is not None:
7457 return _r
7458 return test_runner.evaluate(
7459 P_O_test, P_F_test, P_ambient=target_P_exit, silent=True)
7460 except Exception as _e:
7461 _last_err[0] = str(_e)
7462 return None
7463
7464 # Same first-evaluation recovery as the worker: "Supply < Demand" is a throat
7465 # too small to bracket a Pc, which the derived-throat solve fixes once it has
7466 # a result -- grow it and retry before calling the candidate dead.
7467 _first = None
7468 _prev_grow = 1.0
7469 for _grow in (1.0, 1.3, 1.7, 2.2):
7470 if _grow != 1.0:
7471 x_clipped[0] = float(min(x_clipped[0] * (_grow / _prev_grow),
7472 derive_At_range[1]))
7474 config_runner, A_throat=float(x_clipped[0]),
7475 Lstar=float(x_clipped[1]), expansion_ratio=float(x_clipped[2]),
7476 D_chamber_outer=_quantize_chamber_od_m(
7477 float(x_clipped[3]), _od_increment_in,
7478 wall_m=wall_total_m, snap_target=_od_snap_target),
7479 max_nozzle_exit=max_nozzle_exit, wall_thickness_m=wall_total_m)
7480 _prev_grow = _grow
7481 _first = _evaluate_once_slow()
7482 _err = (_last_err[0] or "")
7483 if (_first is not None or not derive_throat_from_thrust
7484 or not ("Supply < Demand" in _err or "Insufficient mass flow" in _err)):
7485 break
7486 if _first is None:
7487 eval_success = False
7488 eval_error_str = _last_err[0] or "evaluate() raised"
7489 final_results = {}
7490 else:
7491 # Same derived-DOF solve as the worker path.
7493 x_clipped, config_runner, _derive_constants,
7494 _evaluate_once_slow, _first,
7495 cea_cache=getattr(test_runner, "cea_cache", None),
7496 )
7497 final_results = _solved if isinstance(_solved, dict) else _first
7498 eval_success = True
7499 # Mirror the solved geometry onto ``config`` -- it is what
7500 # gets stored as ``last_eval_config`` and finalised.
7502 config,
7503 A_throat=float(x_clipped[0]),
7504 Lstar=float(x_clipped[1]),
7505 expansion_ratio=float(x_clipped[2]),
7506 D_chamber_outer=_quantize_chamber_od_m(
7507 float(x_clipped[3]), _od_increment_in,
7508 wall_m=wall_total_m, snap_target=_od_snap_target),
7509 max_nozzle_exit=max_nozzle_exit,
7510 wall_thickness_m=wall_total_m,
7511 )
7512
7513 if cache_enabled and cache_key is not None:
7514 eval_cache[cache_key] = {
7515 "success": bool(eval_success),
7516 "results": copy.deepcopy(final_results) if eval_success else {},
7517 "error": eval_error_str,
7518 "x_solved": x_clipped.tolist(),
7519 }
7520
7521 # Score with THE objective -- the one the worker pool uses -- on the solved vector.
7522 if eval_success and isinstance(final_results, dict):
7523 final_results["eps"] = float(final_results.get("eps", x_clipped[2]))
7525 final_results if eval_success else None, x_clipped, requirements, constants_dict,
7526 cea_cache=_l1_cea_cache, eval_error=eval_error_str)
7527 obj = float(_T["objective"])
7528 _tw = _T["terms"]
7529 infeasibility_score = float(_T.get("infeasibility_score", 1.0))
7530 inf_residual = float(_T.get("inf_residual", infeasibility_score))
7531 length_violation = bool(_T.get("length_violation", False))
7532 raw_feasible_obj = obj if (eval_success and inf_residual <= 0.0 and not length_violation) else float("nan")
7533
7534 F_actual = float(_T.get("F", np.nan))
7535 Isp_actual = float(_T.get("Isp", np.nan))
7536 MR_actual = float(_T.get("MR", np.nan))
7537 Pc_actual = float(_T.get("Pc", np.nan))
7538 Cf_actual = float(_T.get("Cf", np.nan))
7539 thrust_error = float(_T.get("thrust_error", 1.0))
7540 of_error = float(_T.get("of_error", 1.0))
7541 stability = _T.get("stability_results", {}) or {}
7542 stability_state = _T.get("stability_state", "unstable")
7543 stability_score = float(_T.get("stability_score", 0.0))
7544 chugging_margin = float(_T.get("chugging_margin", 0.0))
7545 acoustic_margin = float(_T.get("acoustic_margin", 0.0))
7546 feed_margin = float(_T.get("feed_margin", 0.0))
7547 state_ok = bool(_T.get("state_ok", False))
7548 effective_min_score = float(_T.get("effective_min_score", 0.0))
7549 effective_margin = float(_T.get("effective_margin", 0.0))
7550 ratio_o_obj = _T.get("injector_dp_ratio_O")
7551 ratio_f_obj = _T.get("injector_dp_ratio_F")
7552 L_chamber_curr = float(_T.get("L_chamber", np.nan))
7553 smd_eff_um = float(_T.get("effective_smd_microns", np.nan))
7554 imp_angle_deg = float(_T.get("impingement_angle_deg", np.nan))
7555 cf_hinge = float(_T.get("cf_hinge", 0.0))
7556 W_CF = 1e2
7557
7558 if eval_success and isinstance(final_results, dict) and np.isfinite(F_actual):
7559 opt_state["last_good_eval_bundle"] = {
7560 "results": copy.deepcopy(final_results),
7561 "P_O_Pa": float(P_O_test),
7562 "P_F_Pa": float(P_F_test),
7563 "thrust_error": float(thrust_error),
7564 "of_error": float(of_error),
7565 }
7566
7567 # "Acceptable" uses the final validation gates (configured bands, not aim bands).
7568 thrust_tol_validation = float(layer1_gate_thrust_tol)
7569 of_tol_validation = float(layer1_of_validation_tol)
7570 dp_gate_obj = True
7571 _ok_o_dp = injector_dp_ratio_within_gate(ratio_o_obj, layer1_dp_o_band[0], layer1_dp_o_band[1])
7572 if _ok_o_dp is not None:
7573 dp_gate_obj &= bool(_ok_o_dp)
7574 _ok_f_dp = injector_dp_ratio_within_gate(ratio_f_obj, layer1_dp_f_band[0], layer1_dp_f_band[1])
7575 if _ok_f_dp is not None:
7576 dp_gate_obj &= bool(_ok_f_dp)
7577 momentum_gate_obj = True
7578 _R_m_gate = float(_T.get("momentum_ratio_R", np.nan))
7579 if (has_impinging and np.isfinite(_R_m_gate)
7580 and layer1_impinging_R_mom_lo is not None and layer1_impinging_R_mom_hi is not None):
7581 momentum_gate_obj = (
7582 float(layer1_impinging_R_mom_lo) <= _R_m_gate <= float(layer1_impinging_R_mom_hi))
7583 errors_acceptable = (
7584 eval_success
7585 and _layer1_feasible_for_primary_objective(infeasibility_score, requirements)
7586 and (thrust_error <= thrust_tol_validation + 1e-12)
7587 and (of_error <= of_tol_validation + 1e-12)
7588 and (stability_score >= effective_min_score * 0.8)
7589 and dp_gate_obj
7590 and momentum_gate_obj
7591 )
7592 if errors_acceptable:
7593 if final_pressures is not None:
7594 opt_state["best_pressures"] = final_pressures
7595 opt_state["best_results_for_validation"] = {
7596 "F": F_actual, "MR": MR_actual,
7597 "thrust_error": thrust_error, "of_error": of_error,
7598 "stability_score": stability_score, "stability_state": stability_state,
7599 "chugging_margin": chugging_margin, "acoustic_margin": acoustic_margin,
7600 "feed_margin": feed_margin, "stability_results": stability,
7601 }
7602 if not opt_state.get('acceptable_found_logged', False):
7603 log_status("Layer 1", f"✓ Acceptable solution found! Obj={obj:.6e}, Thrust err: {thrust_error*100:.2f}%, O/F err: {of_error*100:.2f}% (continuing optimization...)")
7604 opt_state['acceptable_found_logged'] = True
7605 if np.isfinite(obj) and obj <= obj_tolerance:
7606 opt_state["objective_satisfied"] = True
7607 if obj < float(opt_state.get("satisfied_obj", float("inf"))):
7608 opt_state["satisfied_obj"] = float(obj)
7609 opt_state["satisfied_eval_count"] = int(opt_state.get("satisfied_eval_count", 0)) + 1
7610
7611 # A failed evaluation already scores >= BASE_INFEAS; never hand back anything better.
7612 if eval_success and np.isfinite(obj):
7613 opt_state['consecutive_failures'] = 0
7614 opt_state['last_valid_obj'] = obj
7615 else:
7616 opt_state['consecutive_failures'] += 1
7617
7618 # Record history with all parameterization variables
7619 def _finite_or_none(v: Any) -> Optional[float]:
7620 try:
7621 vv = float(v)
7622 except Exception:
7623 return None
7624 return vv if np.isfinite(vv) else None
7625
7626 # Derived DOFs are clipped to their *search* range, not to the collapsed
7627 # CMA bound -- the latter is a single point at the seed and would throw
7628 # away the value the solver just found.
7629 _at_clip = derive_At_range if derive_throat_from_thrust else bounds[0]
7630 _eps_clip = derive_eps_range if derive_expansion_ratio else bounds[2]
7631 A_throat_curr = float(np.clip(x_clipped[0], _at_clip[0], _at_clip[1]))
7632 Lstar_curr = float(np.clip(x_clipped[1], bounds[1][0], bounds[1][1]))
7633 expansion_ratio_curr = float(np.clip(x_clipped[2], _eps_clip[0], _eps_clip[1]))
7634 # Report the diameter that was actually EVALUATED, i.e. after stock snapping.
7635 # Reporting raw x[3] here meant a run snapped to a 6.000 in tube was displayed
7636 # as 5.963 in -- the number the user reads back was never the number built.
7637 D_outer_curr = _quantize_chamber_od_m(
7638 float(np.clip(x_clipped[3], bounds[3][0], bounds[3][1])),
7639 _od_increment_in, wall_m=wall_total_m, snap_target=_od_snap_target,
7640 )
7641 if l1_injector_type == "impinging":
7642 n_doublets_curr = int(round(np.clip(x_clipped[4], bounds[4][0], bounds[4][1])))
7643 n_elements_O_curr = n_doublets_curr
7644 d_jet_O_curr = float(np.clip(x_clipped[5], bounds[5][0], bounds[5][1]))
7645 imp_ang_O_curr = float(np.clip(x_clipped[6], bounds[6][0], bounds[6][1]))
7646 spacing_O_curr = float(np.clip(x_clipped[7], bounds[7][0], bounds[7][1]))
7647 n_elements_F_curr = n_doublets_curr
7648 d_jet_F_curr = float(np.clip(x_clipped[8], bounds[8][0], bounds[8][1]))
7649 imp_ang_F_curr = float(np.clip(x_clipped[9], bounds[9][0], bounds[9][1]))
7650 spacing_F_curr = float(np.clip(x_clipped[10], bounds[10][0], bounds[10][1]))
7651 d_pintle_tip_curr = float("nan")
7652 h_gap_curr = float("nan")
7653 n_orifices_curr = -1
7654 d_orifice_curr = float("nan")
7655 else:
7656 d_pintle_tip_curr = float(np.clip(x_clipped[4], bounds[4][0], bounds[4][1]))
7657 h_gap_curr = float(np.clip(x_clipped[5], bounds[5][0], bounds[5][1]))
7658 n_orifices_curr = int(round(np.clip(x_clipped[6], bounds[6][0], bounds[6][1])))
7659 d_orifice_curr = float(np.clip(x_clipped[7], bounds[7][0], bounds[7][1]))
7660 n_elements_O_curr = -1
7661 n_elements_F_curr = -1
7662 d_jet_O_curr = float("nan")
7663 d_jet_F_curr = float("nan")
7664 imp_ang_O_curr = float("nan")
7665 imp_ang_F_curr = float("nan")
7666 spacing_O_curr = float("nan")
7667 spacing_F_curr = float("nan")
7668 P_O_start_psi_hist = float(np.clip(x_clipped[idx_P_O], bounds[idx_P_O][0], bounds[idx_P_O][1]))
7669 P_F_start_psi_hist = float(np.clip(x_clipped[idx_P_F], bounds[idx_P_F][0], bounds[idx_P_F][1]))
7670
7671 D_inner_curr = D_outer_curr - wall_total_m
7672 if D_inner_curr <= 0:
7673 D_inner_curr = max(D_outer_curr * 0.3, 0.01)
7674
7675 lox_start_ratio_hist = P_O_start_psi_hist / max_lox_P_psi if max_lox_P_psi > 0 else 0.7
7676 fuel_start_ratio_hist = P_F_start_psi_hist / max_fuel_P_psi if max_fuel_P_psi > 0 else 0.7
7677 combined_stability_margin = min(chugging_margin, acoustic_margin, feed_margin) if eval_success else 0.0
7678 L_chamber_hist = L_chamber_curr if (np.isfinite(L_chamber_curr)) else None
7679 _R_hist = float("nan")
7680 if has_impinging and eval_success:
7681 _Rv_hist = final_results.get("diagnostics", {}).get("momentum_ratio_R")
7682 if _Rv_hist is not None:
7683 try:
7684 _R_hist = float(_Rv_hist)
7685 except (TypeError, ValueError):
7686 _R_hist = float("nan")
7687 _lstar_lo_hist = float(requirements.get("min_Lstar", 0.76))
7688 _lstar_hi_hist = float(requirements.get("max_Lstar", 1.5))
7689 _exp_lo_hist = float(exp_ratio_lo)
7690 _exp_hi_hist = float(exp_ratio_hi)
7691
7692 _candidate_new_best = (
7693 eval_success
7695 and float(obj) < float(opt_state.get("best_objective", float("inf")))
7696 )
7697 _skip_history = (
7698 bool(opt_state.get("layer1_lbfgs_phase"))
7699 and not _candidate_new_best
7700 and (
7701 not eval_success
7703 or float(inf_residual) > 0.0
7704 )
7705 )
7706 if not _skip_history:
7707 opt_state["history"].append({
7708 "iteration": iteration,
7709 "x": x_clipped.copy(),
7710 # Parameterization variables (geometries and pressures)
7711 "A_throat": A_throat_curr,
7712 "Lstar": Lstar_curr,
7713 "expansion_ratio": expansion_ratio_curr,
7714 "D_chamber_outer": D_outer_curr,
7715 "D_chamber_inner": D_inner_curr,
7716 "L_chamber": L_chamber_hist,
7717 "d_pintle_tip": d_pintle_tip_curr,
7718 "h_gap": h_gap_curr,
7719 "n_orifices": n_orifices_curr,
7720 "d_orifice": d_orifice_curr,
7721 "n_elements_O": n_elements_O_curr,
7722 "n_elements_F": n_elements_F_curr,
7723 "n_doublets": float(n_elements_O_curr) if l1_injector_type == "impinging" else float("nan"),
7724 "d_jet_O": d_jet_O_curr,
7725 "impingement_angle_O": imp_ang_O_curr,
7726 "spacing_O": spacing_O_curr,
7727 "d_jet_F": d_jet_F_curr,
7728 "impingement_angle_F": imp_ang_F_curr,
7729 "spacing_F": spacing_F_curr,
7730 "effective_smd_microns": _finite_or_none(smd_eff_um),
7731 "impingement_angle_deg_effective": _finite_or_none(imp_angle_deg),
7732 "P_O_start_psi": P_O_start_psi_hist,
7733 "P_F_start_psi": P_F_start_psi_hist,
7734 # Performance metrics
7735 "thrust": _finite_or_none(F_actual),
7736 "thrust_error": thrust_error,
7737 "of_error": of_error,
7738 "Isp": _finite_or_none(Isp_actual),
7739 "MR": _finite_or_none(MR_actual),
7740 "Pc": _finite_or_none(Pc_actual),
7741 "Cf": _finite_or_none(Cf_actual),
7742 "Cf_error": float(np.sqrt(cf_hinge)) if np.isfinite(cf_hinge) else 1.0,
7743 "Cf_penalty": float(_tw.get("cf_penalty", 0.0)),
7744 # Stability metrics
7745 "stability_margin": combined_stability_margin,
7746 "stability_state": stability_state,
7747 "stability_score": stability_score,
7748 "chugging_margin": chugging_margin,
7749 "acoustic_margin": acoustic_margin,
7750 "feed_margin": feed_margin,
7751 # Pressure ratios
7752 "lox_end_ratio": lox_start_ratio_hist,
7753 "fuel_end_ratio": fuel_start_ratio_hist,
7754 "lox_start_ratio": lox_start_ratio_hist,
7755 "fuel_start_ratio": fuel_start_ratio_hist,
7756 # Feasibility diagnostics
7757 "infeasibility_score": float(infeasibility_score),
7758 "eval_success": bool(eval_success),
7759 "eval_error": eval_error_str,
7760 # Constraint metrics (for UI per-term residual plots)
7761 "injector_dp_ratio_O": _finite_or_none(ratio_o_obj),
7762 "injector_dp_ratio_F": _finite_or_none(ratio_f_obj),
7763 "momentum_ratio_R": _finite_or_none(_R_hist),
7764 "raw_feasible_objective": _finite_or_none(raw_feasible_obj),
7765 "penalty_thrust": float(_tw.get("thrust_penalty", 0.0)),
7766 "penalty_pc_target": float(_tw.get("pc_target_penalty", 0.0)),
7767 "penalty_of": float(_tw.get("of_penalty", 0.0)),
7768 "penalty_cf": float(_tw.get("cf_penalty", 0.0)),
7769 "penalty_exit": float(_tw.get("exit_pressure_penalty", 0.0)),
7770 "penalty_injector_dp": float(_tw.get("injector_dp_penalty", 0.0)),
7771 "penalty_length": float(_tw.get("length_penalty", 0.0)),
7772 "penalty_chamber_shape": float(_tw.get("chamber_shape_penalty", 0.0)),
7773 "penalty_chamber_mass": float(_tw.get("chamber_mass_penalty", 0.0)),
7774 "penalty_isp": float(_tw.get("isp_penalty", 0.0)),
7775 "Isp_merit": _finite_or_none(_T.get("Isp_merit_s")),
7776 "penalty_lstar": float(_tw.get("lstar_penalty", 0.0)),
7777 "chamber_mass_kg": float(_T.get("chamber_mass_kg", float("nan"))),
7778 "penalty_momentum": float(_tw.get("momentum_balance_penalty", 0.0)),
7779 "penalty_impingement_angle": float(_tw.get("impingement_angle_penalty", 0.0)),
7780 "penalty_jet_asymmetry": float(_tw.get("jet_angle_asymmetry_penalty", 0.0)),
7781 "penalty_smd": float(_tw.get("smd_penalty", 0.0)),
7782 "penalty_tank_equal": float(_tw.get("tank_pressure_equal_penalty", 0.0)),
7783 "penalty_geom_fit": float(_tw.get("impinging_geometry_fit_penalty", 0.0)),
7784 "penalty_geom_ao_af": float(_tw.get("geom_ao_af_momentum_penalty", 0.0)),
7785 "band_violation_Lstar_sq": float(
7786 _relative_hinge_band_squared(Lstar_curr, _lstar_lo_hist, _lstar_hi_hist)
7787 ) if np.isfinite(Lstar_curr) else None,
7788 "band_violation_expansion_sq": float(
7789 _relative_hinge_band_squared(expansion_ratio_curr, _exp_lo_hist, _exp_hi_hist)
7790 ),
7791 "band_violation_dp_O_sq": float(
7793 ratio_o_obj,
7794 r_band_lo=float(layer1_dp_o_aim[0]),
7795 r_band_hi=float(layer1_dp_o_aim[1]),
7796 )
7797 ) if ratio_o_obj is not None and np.isfinite(float(ratio_o_obj)) else None,
7798 "band_violation_dp_F_sq": float(
7800 ratio_f_obj,
7801 r_band_lo=float(layer1_dp_f_aim[0]),
7802 r_band_hi=float(layer1_dp_f_aim[1]),
7803 )
7804 ) if ratio_f_obj is not None and np.isfinite(float(ratio_f_obj)) else None,
7805 "band_violation_momentum_R_sq": float(
7807 _R_hist,
7808 r_band_lo=layer1_R_mom_aim_lo,
7809 r_band_hi=layer1_R_mom_aim_hi,
7810 )
7811 ) if (
7812 has_impinging
7813 and np.isfinite(_R_hist)
7814 and layer1_impinging_R_mom_lo is not None
7815 and layer1_impinging_R_mom_hi is not None
7816 ) else None,
7817 "band_violation_angle_sq": float(
7819 imp_angle_deg,
7820 angle_band_lo_deg=layer1_impinging_angle_deg_lo,
7821 angle_band_hi_deg=layer1_impinging_angle_deg_hi,
7822 )
7823 ) if layer1_W_impinging_angle > 0.0 else None,
7824 # Objective (weighted penalty sum fed to CMA/L-BFGS)
7825 "objective": obj,
7826 })
7827
7828 # Track best (feasible weighted-sum only — never promote obj≈1e6 infeasibility floor)
7829 is_new_best = (
7830 eval_success
7832 and float(obj) < float(opt_state.get("best_objective", float("inf")))
7833 )
7834 if is_new_best:
7835 opt_state["best_objective"] = obj
7836 opt_state["best_x"] = x_clipped.copy()
7837 opt_state["last_best_eval"] = opt_state["function_evaluations"]
7838 if eval_success:
7839 opt_state["last_eval_config"] = copy.deepcopy(config)
7840 opt_state["last_eval_config_x"] = x_clipped.copy()
7841 opt_state["last_eval_validation_tank_pa"] = (float(P_O_test), float(P_F_test))
7842
7843 # Store objective component breakdown for diagnostics.
7844 #
7845 # Every weighted term in the objective MUST appear here -- this is what the
7846 # UI plots as "where the residual is coming from", and a term that is in the
7847 # objective but not in this dict shows up as an unexplained constant offset.
7848 # ``penalty_sum`` and ``penalty_unaccounted`` make that failure visible: on a
7849 # feasible design the sum should reproduce ``objective`` to rounding.
7850 # (L* and chamber-mass were missing here, which hid a 3000 x L*-term
7851 # contribution -- ~95% of the reported objective -- from the plots.)
7852 _breakdown_terms = dict(_tw)
7853 _penalty_sum = float(sum(_breakdown_terms.values()))
7854 opt_state["best_objective_breakdown"] = {
7855 "objective": float(obj),
7856 **_breakdown_terms,
7857 "penalty_sum": _penalty_sum,
7858 # Non-zero here means a weighted term exists in the objective that is
7859 # not reported above; the plots would be attributing it to nothing.
7860 "penalty_unaccounted": float(obj) - _penalty_sum,
7861 # Context values (not penalties -- excluded from the sum)
7862 "injector_dp_ratio_O": ratio_o_obj,
7863 "injector_dp_ratio_F": ratio_f_obj,
7864 "chamber_D_over_Dt": _finite_or_none(_T.get("chamber_D_over_Dt")),
7865 "chamber_L_over_D": _finite_or_none(_T.get("chamber_L_over_D")),
7866 "Lstar_m": _finite_or_none(Lstar_curr),
7867 "Lstar_target_m": _finite_or_none(_T.get("Lstar_target_m")),
7868 "chamber_mass_kg": _finite_or_none(_T.get("chamber_mass_kg")),
7869 "Isp_s": _finite_or_none(Isp_actual),
7870 "Isp_merit_s": _finite_or_none(_T.get("Isp_merit_s")),
7871 "Isp_ref_s": _finite_or_none(_T.get("Isp_ref_s")),
7872 "propellant_mass_kg": _finite_or_none(_T.get("propellant_mass_kg")),
7873 "L_face_to_throat_m": _finite_or_none(_T.get("L_face_to_throat")),
7874 "L_engine_m": _finite_or_none(_T.get("L_engine")),
7875 "momentum_ratio_R": _finite_or_none(_R_hist),
7876 "resultant_tilt_deg": _finite_or_none(_T.get("resultant_tilt_deg")),
7877 "resultant_tilt_breakeven_deg": _finite_or_none(_T.get("resultant_tilt_breakeven_deg")),
7878 "effective_smd_microns": _finite_or_none(smd_eff_um),
7879 "impingement_angle_deg": _finite_or_none(imp_angle_deg),
7880 "geom_ao_af_momentum_scale": _finite_or_none(_T.get("geom_ao_af_momentum_scale")),
7881 "geom_ao_af": _finite_or_none(_T.get("geom_ao_af")),
7882 "expected_ao_af_for_R1": _finite_or_none(_T.get("expected_ao_af_for_R1")),
7883 "infeasibility_penalty": float(obj - _penalty_sum) if (inf_residual > 0.0 or length_violation or not eval_success) else 0.0,
7884 "length_violation": bool(length_violation),
7885 "is_infeasible": not _layer1_feasible_for_primary_objective(
7886 infeasibility_score, requirements, length_violation=length_violation
7887 ),
7888 }
7889
7890 # If we were in a valley escape mode, exit it
7891 if opt_state.get("valley_escape_tier", 0) > 0:
7892 layer1_logger.info(f" *** Improvement found: Exiting Valley Escape Tier {opt_state['valley_escape_tier']} ***")
7893 opt_state["valley_escape_tier"] = 0
7894 # Only store a "best config" if we actually evaluated successfully and are feasible.
7895 if eval_success and _layer1_feasible_for_primary_objective(infeasibility_score, requirements):
7896 opt_state["best_config"] = copy.deepcopy(config)
7897 opt_state["best_config_x"] = x_clipped.copy()
7898 opt_state["best_validation_tank_pa"] = (float(P_O_test), float(P_F_test))
7899 opt_state["best_lox_end_ratio"] = lox_start_ratio_hist
7900 opt_state["best_fuel_end_ratio"] = fuel_start_ratio_hist
7901 if (
7902 eval_success
7903 and _layer1_feasible_for_primary_objective(infeasibility_score, requirements)
7904 and not length_violation
7905 and final_pressures is not None
7906 ):
7907 opt_state["best_pressures"] = final_pressures
7908 opt_state["best_results_for_validation"] = {
7909 "F": F_actual,
7910 "MR": MR_actual,
7911 "thrust_error": thrust_error,
7912 "of_error": of_error,
7913 "stability_score": stability_score,
7914 "stability_state": stability_state,
7915 "chugging_margin": chugging_margin,
7916 "acoustic_margin": acoustic_margin,
7917 "feed_margin": feed_margin,
7918 "stability_results": stability,
7919 }
7920 layer1_logger.info(
7921 f" ✓ New best objective: {obj:.6f} "
7922 f"(thrust_err: {thrust_error*100:.2f}%, O/F_err: {of_error*100:.2f}%, "
7923 f"Cf: {Cf_actual:.3f}, stability: {stability_state}, score: {stability_score:.3f})"
7924 )
7925 for handler in layer1_logger.handlers:
7926 handler.flush()
7927
7928 # Buffer objective data every iteration (for batch reporting)
7929 opt_state["objective_buffer"].append({
7930 "iteration": int(iteration),
7931 "objective": float(obj),
7932 "best_objective": float(opt_state.get("best_objective", obj)),
7933 })
7934
7935 # Stream objective history to external callback (e.g., UI plot) if provided
7936 # Only call callback every report_every_n iterations (batch reporting)
7937 should_report = (iteration % report_every_n == 0) or opt_state.get('objective_satisfied', False)
7938 if objective_callback is not None and should_report:
7939 try:
7940 # Send all buffered entries (batch reporting)
7941 for buffered_entry in opt_state["objective_buffer"]:
7943 objective_callback,
7944 opt_state,
7945 buffered_entry["iteration"],
7946 buffered_entry["objective"],
7947 buffered_entry["best_objective"],
7948 )
7949 # Clear buffer after reporting
7950 opt_state["objective_buffer"] = []
7951 except Exception:
7952 # Never let UI/consumer callback break the optimizer loop
7953 pass
7954
7955 # Convergence check
7956 stability_acceptable = (
7957 state_ok and
7958 (stability_score >= effective_min_score * 0.6) and
7959 (chugging_margin >= effective_margin * 0.5) and
7960 (acoustic_margin >= effective_margin * 0.5) and
7961 (feed_margin >= effective_margin * 0.5)
7962 )
7963
7964 convergence_thrust_tol = thrust_tol * 2.0
7965 convergence_of_tol = 0.30
7966 # `best_objective` is updated above when `is_new_best` is True, so comparing
7967 # against it here would always be False for a new best. Re-use the flag.
7968 converged_now = (
7969 thrust_error < convergence_thrust_tol
7970 and of_error < convergence_of_tol
7971 and stability_acceptable
7972 and np.isfinite(float(opt_state.get("best_objective", float("inf"))))
7973 and float(opt_state.get("best_objective", float("inf"))) <= obj_tolerance
7974 )
7975 # Sticky convergence: once reached with a qualified best solution, keep it true.
7976 opt_state["converged"] = bool(opt_state.get("converged", False) or converged_now)
7977
7978 return obj
7979
7980 # Run optimization
7981 layer1_logger.info("")
7982 layer1_logger.info("Starting optimization...")
7983 layer1_logger.info("")
7984 opt_state["iteration"] = 0
7985 opt_state["function_evaluations"] = 0
7986
7987 class _ResultWrapper:
7988 def __init__(self, x, fun, success=True):
7989 self.x = np.asarray(x, dtype=float)
7990 self.fun = float(fun)
7991 self.success = success
7992
7993 result = None
7994 x0_refined = x0
7995 # lower_bounds/upper_bounds/span are computed above (used for caching and solvers)
7996
7997 # Check CMA-ES availability (required)
7998 if cma is None:
7999 raise ImportError(
8000 "CMA-ES (cma package) is required for Layer 1 optimization but not installed.\n"
8001 "Install with: pip install cma"
8002 )
8003
8004 # Run CMA-ES global optimization with random restarts
8005 layer1_logger.info("Using CMA-ES with random restart strategy for robust optimization.")
8006 update_progress("Layer 1: CMA-ES", 0.45, "Running CMA-ES global solver...")
8007
8008 # IMPROVED: Initial step size fraction (reduced to 0.15 for better convergence with wide bounds)
8009 target_fraction_of_range = 0.15 # 15% of range per sigma for reliable convergence
8010
8011 # Calculate base sigma0 from 25th percentile span (less sensitive to large diameter bounds)
8012 # Using percentile instead of median prevents large bound changes from dominating step size
8013 sigma0 = float(np.percentile(span, 25) * target_fraction_of_range)
8014 if not np.isfinite(sigma0) or sigma0 <= 0:
8015 sigma0 = 0.05
8016
8017 # Set CMA_stds proportional to each variable's range
8018 # This ensures variables with larger ranges (like expansion_ratio) get larger step sizes
8019 cma_stds = np.ones_like(span)
8020 for i in range(len(span)):
8021 if span[i] > 0:
8022 # Desired step size for this dimension: fraction of its range
8023 desired_step = span[i] * target_fraction_of_range
8024 # CMA_stds[i] = desired_step / sigma0
8025 # This makes step size in dimension i proportional to its range
8026 cma_stds[i] = max(0.1, desired_step / sigma0) if sigma0 > 0 else 1.0
8027
8028 # Discrete integer dimensions (n_orifices or impinging element counts)
8029 target_step_n = 1.0
8030 if sigma0 > 0:
8031 for idx_disc in layer1_integer_dims:
8032 if 0 <= idx_disc < len(cma_stds):
8033 cma_stds[idx_disc] = max(cma_stds[idx_disc], target_step_n / sigma0)
8034
8035 # IMPROVED: Larger population for better exploration (32-80 instead of 16-48)
8036 # Standardized population for better diversity (increased to 48)
8037 popsize = 48
8038 layer1_logger.info(f"Population size: {popsize}")
8039
8040 # 4 restarts (with the alternating strategy: restarts 0,1,3 global-explore, 2 refines) gives
8041 # three genuine global searches from diverse starts — more chances to escape a bad basin than
8042 # the old 3 (which were 1 global + 2 local refinements). iter_budget keeps ~50 gens/restart.
8043 # Restarts are DERIVED so that generations-per-restart stays fixed as the total budget grows.
8044 # iter_budget = max_iterations // num_restarts, so hardcoding num_restarts=4 meant a bigger
8045 # budget bought LONGER restarts (200->625 gens would have made each restart 156 generations).
8046 # That is the wrong way to spend it: a long restart just sits in one basin burning evaluations
8047 # on a valley it already found. More restarts from diverse starts is what actually escapes.
8048 # Scaling this way, 625 generations at 50 per restart gives 12 restarts instead of 4.
8049 _gens_per_restart = max(5, int(_requirement_float(
8050 requirements, "layer1_generations_per_restart", 50.0)))
8051 if layer1_cma_restarts is not None:
8052 num_restarts = max(1, int(layer1_cma_restarts))
8053 else:
8054 num_restarts = max(1, int(round(max_iterations / float(_gens_per_restart))))
8055 if layer1_smoke and layer1_cma_restarts is None:
8056 num_restarts = 1
8057 layer1_logger.info(
8058 "Restart plan: %d generations total / %d per restart = %d restarts (%d evals at popsize %d)",
8059 max_iterations, _gens_per_restart, num_restarts, max_iterations * popsize, popsize,
8060 )
8061 layer1_logger.info(f"Using {num_restarts} CMA restart(s)")
8062
8063 # Legacy CMA uses this for ``maxiter`` per restart (hybrid path sets its own budget).
8064 total_eval_budget = max_iterations
8065
8066 # Evaluation budget for progress reporting only -- never used to bound the search.
8067 # CMA runs ``max_iterations`` generations of ``popsize`` per restart; the hybrid
8068 # branch replaces this with its own cap once it computes one.
8069 opt_state["eval_budget"] = max(1, int(max_iterations) * int(popsize) * int(num_restarts))
8070
8071 best_x_global = x0_refined
8072 best_f_global = float('inf')
8073
8074
8075 # ============================================================================
8076 # Setup for Parallel Evaluation (used by both hybrid and default CMA-ES)
8077 # ============================================================================
8078 num_workers = _get_num_workers(config_obj)
8079 debug_strict = getattr(config_obj.optimizer, 'debug_strict', False) if hasattr(config_obj, 'optimizer') else False
8080
8081 layer1_logger.info(f"Using {num_workers} worker processes for parallel evaluation")
8082
8083 # Prepare worker init args
8084 # Workers must evaluate the SAME engine the inline objective, the validation replay and
8085 # the final rebuild evaluate -- thermal protection off (the ablative liner is sized by
8086 # Layer 3; Layer 1 scores the bare chamber). They did not: only the worker path left it on,
8087 # so a design feasible only with the liner in place could win the pool and then be
8088 # unbuildable everywhere downstream (finalisation: "first evaluate() of the winner
8089 # failed"; validation: 3-14% tank boost), and which seeds hit this depended on pool
8090 # ordering -- the run-to-run nondeterminism.
8091 _worker_cfg_src = copy.deepcopy(config_base)
8092 # Thermal protection stays ON in the pool by DEFAULT. Disabling ablative_cooling also drops
8093 # the liner from total_wall_thickness_m -- at 0.5 in per side that is a full inch of bore --
8094 # so scoring with it off sizes a chamber the real engine does not have. Set
8095 # ED_L1_WORKER_THERMAL_OFF=1 to restore the old faster/no-liner behaviour for an A/B.
8096 if os.environ.get("ED_L1_WORKER_THERMAL_OFF", "0") == "1":
8097 if getattr(_worker_cfg_src, "ablative_cooling", None):
8098 _worker_cfg_src.ablative_cooling.enabled = False
8099 if getattr(_worker_cfg_src, "graphite_insert", None):
8100 _worker_cfg_src.graphite_insert.enabled = False
8101 config_dict = _config_to_dict(_worker_cfg_src)
8102 bounds_array = np.column_stack([lower_bounds, upper_bounds])
8103 constants_dict = {
8104 # Geometry DOFs and hard geometric constraints. constants_dict is a CURATED dict --
8105 # a key absent here simply never reaches the worker objective, which silently fell
8106 # back to theta = 45 deg while _layer1_apply_chamber_geometry_to_config (reading the
8107 # config directly) used the configured angle. The objective was then scoring a
8108 # different chamber than the one being built.
8109 'layer1_contraction_half_angle_deg': requirements.get('layer1_contraction_half_angle_deg'),
8110 'layer1_min_Lcyl_over_D': requirements.get('layer1_min_Lcyl_over_D'),
8111 'layer1_max_element_pitch_m': requirements.get('layer1_max_element_pitch_m'),
8112 'target_thrust': target_thrust,
8113 'optimal_of': optimal_of,
8114 'P_ambient': target_P_exit, # Ambient pressure (atmospheric)
8115 'max_lox_P_psi': max_lox_P_psi,
8116 'max_fuel_P_psi': max_fuel_P_psi,
8117 'TOTAL_WALL_THICKNESS_M': wall_total_m,
8118 'max_nozzle_exit': max_nozzle_exit,
8119 'max_chamber_od': max_chamber_od,
8120 # Derived-DOF solve (see _layer1_solve_derived_geometry)
8121 'layer1_derive_expansion_ratio': derive_expansion_ratio,
8122 'layer1_derive_throat_from_thrust': derive_throat_from_thrust,
8123 'layer1_derive_max_iters': derive_max_iters,
8124 'layer1_derive_thrust_tol_rel': derive_thrust_tol,
8125 # NOTE: the *search* ranges captured before the CMA bounds were pinned --
8126 # using lower_bounds/upper_bounds here would clamp every solve back onto
8127 # the seed value and silently defeat the derivation.
8128 'derive_eps_min': derive_eps_range[0],
8129 'derive_eps_max': derive_eps_range[1],
8130 'derive_At_min': derive_At_range[0],
8131 'derive_At_max': derive_At_range[1],
8132 'derive_dF_min': derive_dF_range[0],
8133 'derive_dF_max': derive_dF_range[1],
8134 'layer1_derive_fuel_jet_from_of': derive_fuel_jet_from_of,
8135 'layer1_derive_tank_from_dp_ratio': derive_tank_from_dp,
8136 'layer1_dp_ratio_target': dp_ratio_target,
8137 'lock_P_T_min': lock_P_T_range[0],
8138 'lock_P_T_max': lock_P_T_range[1],
8139 'injector_type': l1_injector_type,
8140 'idx_P_O': idx_P_O,
8141 'idx_P_F': idx_P_F,
8142 'W_MOM': layer1_W_MOM,
8143 'W_DP': layer1_W_DP,
8144 'W_DP_CENTER': layer1_W_DP_CENTER,
8145 'W_DP_O': layer1_W_DP_O,
8146 'W_DP_F': layer1_W_DP_F,
8147 'W_DP_HIGH': layer1_W_DP_HIGH,
8148 # Workers' ΔP/Pc PENALTY bands = interior AIM bands (validation uses the raw
8149 # configured bands) — see the aim-band comment where these are derived.
8150 'injector_dp_ratio_O_min': layer1_dp_o_aim[0],
8151 'injector_dp_ratio_O_max': layer1_dp_o_aim[1],
8152 'injector_dp_ratio_F_min': layer1_dp_f_aim[0],
8153 'injector_dp_ratio_F_max': layer1_dp_f_aim[1],
8154 'W_geom_ao_af_momentum': layer1_W_geom_ao_af,
8155 'rho_oxidizer': float(config_base.fluids["oxidizer"].density),
8156 'rho_fuel': float(config_base.fluids["fuel"].density),
8157 'discharge_cd_inf_ratio': _layer1_discharge_cd_inf_ratio_from_config(config_base, l1_injector_type),
8158 # Workers' hinge PENALTY band = the interior AIM band (final validation uses the raw
8159 # configured band). Sharing an edge lets CMA park epsilon outside and fail validation.
8160 'impinging_momentum_R_min': layer1_R_mom_aim_lo,
8161 'impinging_momentum_R_max': layer1_R_mom_aim_hi,
8162 'layer1_momentum_log_deadband_rel': layer1_momentum_log_deadband_rel,
8163 'W_IMPINGING_ANGLE': layer1_W_impinging_angle,
8164 'W_IMPINGING_JET_ASYM': layer1_W_impinging_jet_asym,
8165 'layer1_impinging_jet_angle_max_asym_deg': layer1_impinging_jet_max_asym_deg,
8166 'layer1_exit_pressure_inside_quad_scale': layer1_exit_pressure_inside_quad,
8167 'layer1_impinging_angle_deg_min': layer1_impinging_angle_deg_lo,
8168 'layer1_impinging_angle_deg_max': layer1_impinging_angle_deg_hi,
8169 'W_CHAMBER_SHAPE': layer1_W_chamber_shape,
8170 'layer1_chamber_dt_ratio_min': layer1_chamber_dt_ratio_min,
8171 'layer1_chamber_dt_ratio_max': layer1_chamber_dt_ratio_max,
8172 'layer1_chamber_ld_ratio_min': layer1_chamber_ld_ratio_min,
8173 'layer1_chamber_ld_ratio_max': layer1_chamber_ld_ratio_max,
8174 'injector_dp_ratio_O_soft_floor': layer1_dp_o_soft_floor,
8175 'W_DP_O_FLOOR': layer1_W_DP_O_FLOOR,
8176 'W_SMD': layer1_W_SMD,
8177 'target_smd_microns': layer1_target_smd_microns,
8178 'layer1_smd_rel_tol': layer1_smd_rel_tol,
8179 'W_TANK_EQUAL': layer1_W_TANK_EQUAL,
8180 'layer1_tank_equal_scale_psi': layer1_tank_equal_scale_psi,
8181 'layer1_tank_equal_tol_psi': layer1_tank_equal_tol_psi,
8182 'layer1_of_deadband_rel': layer1_of_deadband_rel,
8183 'layer1_chamber_od_increment_in': layer1_chamber_od_increment_in,
8184 'layer1_chamber_od_snap_target': layer1_chamber_od_snap_target,
8185 'layer1_tank_equal_inband_frac': layer1_tank_equal_inband_frac,
8186 'layer1_lock_tank_pressures': layer1_lock_tank_pressures,
8187 'lock_P_F_min': lock_P_F_range[0],
8188 'lock_P_F_max': lock_P_F_range[1],
8189 'layer1_enforce_ring_geometry': layer1_enforce_ring_geometry,
8190 'layer1_injector_center_clear_dia_m': layer1_injector_center_clear_dia_m,
8191 'layer1_injector_min_web_m': layer1_injector_min_web_m,
8192 'layer1_injector_wall_clearance_m': layer1_injector_wall_clearance_m,
8193 'layer1_injector_spray_radius_frac': layer1_injector_spray_radius_frac,
8194 'layer1_injector_spray_radius_tol': layer1_injector_spray_radius_tol,
8195 'layer1_injector_min_face_incidence_deg': layer1_injector_min_face_incidence_deg,
8196 'layer1_injector_plate_thickness_m': layer1_injector_plate_thickness_m,
8197 'layer1_injector_counterbore_dia_m': layer1_injector_counterbore_dia_m,
8198 'layer1_injector_min_back_web_m': layer1_injector_min_back_web_m,
8199 'layer1_injector_land_ld_O': layer1_injector_land_ld_O,
8200 'layer1_injector_land_ld_F': layer1_injector_land_ld_F,
8201 'layer1_injector_back_keepout_dia_m': layer1_injector_back_keepout_dia_m,
8202 'layer1_injector_back_keepout_wall_m': layer1_injector_back_keepout_wall_m,
8203 'layer1_injector_face_contoured': layer1_injector_face_contoured,
8204 'layer1_injector_back_channels': layer1_injector_back_channels,
8205 'layer1_injector_exit_land': layer1_injector_exit_land,
8206 'layer1_injector_channel_width_m': layer1_injector_channel_width_m,
8207 'layer1_injector_channel_floor': layer1_injector_channel_floor,
8208 'layer1_injector_channel_inlets': layer1_injector_channel_inlets,
8209 'layer1_injector_plate_support': layer1_injector_plate_support,
8210 'layer1_injector_r_plate_m': layer1_injector_r_plate_m,
8211 'layer1_injector_r_port_m': layer1_injector_r_port_m,
8212 'layer1_injector_liner_thickness_m': layer1_injector_liner_thickness_m,
8213 'layer1_resultant_tilt_from_reach': layer1_resultant_tilt_from_reach,
8214 'layer1_resultant_tilt_reach_margin': layer1_resultant_tilt_reach_margin,
8215 'layer1_ring_order_fuel_outboard': layer1_ring_order_fuel_outboard,
8216 'layer1_momentum_wall_side_multiplier': layer1_momentum_wall_side_multiplier,
8217 'layer1_resultant_tilt_max_deg': layer1_resultant_tilt_max_deg,
8218 'layer1_resultant_tilt_scale_deg': layer1_resultant_tilt_scale_deg,
8219 'layer1_W_TILT': layer1_W_TILT,
8220 'layer1_resultant_tilt_outward_multiplier': layer1_resultant_tilt_outward_multiplier,
8221 'rho_O': float(config_base.fluids["oxidizer"].density),
8222 'rho_F': float(config_base.fluids["fuel"].density),
8223 'layer1_momentum_low_side_multiplier': layer1_momentum_low_side_multiplier,
8224 'layer1_momentum_band_width': layer1_momentum_band_width,
8225 'layer1_momentum_scale': layer1_momentum_scale,
8226 'layer1_derive_impingement_spacing': layer1_derive_impingement_spacing,
8227 'layer1_impingement_Ld_target': _ld_target,
8228 'derive_spacing_F_min': derive_spacing_F_range[0],
8229 'derive_spacing_F_max': derive_spacing_F_range[1],
8230 'layer1_impingement_Ld_min': layer1_impingement_Ld_min,
8231 'layer1_impingement_Ld_max': layer1_impingement_Ld_max,
8232 'W_IMP_GEOM': layer1_W_IMP_GEOM,
8233 'layer1_W_EXIT': layer1_W_EXIT,
8234 'layer1_exit_pressure_deadband_rel': layer1_exit_pressure_deadband_rel,
8235 'layer1_W_MASS': layer1_W_MASS,
8236 'layer1_W_ISP': layer1_W_ISP,
8237 'layer1_Isp_ref_s': layer1_Isp_ref_s,
8238 'target_burn_time': float(target_burn_time or 0.0),
8239 'min_Lstar': min_Lstar,
8240 'max_Lstar': max_Lstar,
8241 'layer1_W_LSTAR': layer1_W_LSTAR,
8242 'layer1_Lstar_target_m': layer1_Lstar_target_m,
8243 'layer1_chamber_wall_density_kg_m3': layer1_chamber_wall_density,
8244 'layer1_chamber_mass_ref_kg': layer1_chamber_mass_ref_kg,
8245 'layer1_W_THRUST': layer1_W_THRUST_obj,
8246 'layer1_W_OF': layer1_W_OF_obj,
8247 'layer1_W_OF_low_MR_scale': layer1_W_OF_low_MR_scale,
8248 'layer1_W_OF_high_MR_scale': layer1_W_OF_high_MR_scale,
8249 'layer1_of_validation_tol': layer1_of_validation_tol,
8250 }
8251
8252 # Optional chamber-pressure target (design_requirements.target_chamber_pressure_psi).
8253 # Pc is emergent in a pressure-fed system, so this enters as a soft objective target,
8254 # NOT a frozen x-pin: the optimizer moves tank pressure to land Pc here while the throat
8255 # absorbs the thrust target. Only wired in (nonzero weight) when the user set a target.
8256 _pc_target_psi = requirements.get("target_chamber_pressure_psi")
8257 if _pc_target_psi is not None and float(_pc_target_psi) > 0:
8258 constants_dict['layer1_target_Pc_pa'] = float(_pc_target_psi) * 6894.757293168
8259 # Matches W_THRUST magnitude. The L1 (linear) shape is what pins Pc exactly: near a
8260 # solution the squared thrust/OF terms have ~0 gradient, so the L1 Pc pull is
8261 # essentially uncontested and drives Pc onto target without a huge (ill-conditioning) weight.
8262 # None => default 1e4; an explicit 0.0 stays 0.0 (penalty off, seeding
8263 # effects kept). MUST mirror _W_PC_obj in the parent objective above.
8264 constants_dict['layer1_W_PC'] = _requirement_float(requirements, "layer1_W_PC", 1.0e4)
8265 layer1_logger.info(
8266 "Layer 1 chamber-pressure target: %.0f psi (%.3f MPa), W_PC=%g (exact L1). Throat sizes "
8267 "for thrust; tank pressure drives Pc to target.",
8268 float(_pc_target_psi), constants_dict['layer1_target_Pc_pa'] / 1e6,
8269 constants_dict['layer1_W_PC'],
8270 )
8271
8272 integer_dims = list(layer1_integer_dims)
8273 # Chamber OD granularity: one stock increment, in metres. Zero when snapping is off,
8274 # in which case the coordinate really is continuous and gets no std floor.
8275 _l1_od_step_m = float(_od_increment_in) * 0.0254 if _od_increment_in > 0 else 0.0
8276 _l1_int_vars, _l1_minstd = _layer1_cma_discrete_options(
8277 len(bounds), integer_dims, od_index=3, od_step_m=_l1_od_step_m, bounds=bounds)
8278 if _l1_int_vars or _l1_od_step_m > 0:
8279 layer1_logger.info(
8280 "Layer 1 discrete DOFs handed to CMA: integer_variables=%s%s",
8281 _l1_int_vars,
8282 (" + chamber-OD std floor %.4f m (%.2f in step)"
8283 % (_l1_minstd[3], _od_increment_in)) if _l1_od_step_m > 0 else "",
8284 )
8285
8286 # DISPATCH: Check optimizer mode (smoke runs force pure CMA so max_iterations stays meaningful)
8287 optimizer_mode = "cma" # default
8288 if hasattr(config_obj, "optimizer") and config_obj.optimizer:
8289 optimizer_mode = config_obj.optimizer.mode
8290 effective_mode = optimizer_mode
8291 if layer1_smoke:
8292 effective_mode = "cma"
8293 layer1_logger.info(
8294 "Layer 1 smoke: forcing pure CMA-ES (config optimizer.mode=%r ignored for hybrid).",
8295 optimizer_mode,
8296 )
8297
8298 # The accelerator (engine/accel) is the Layer-1 inner-loop fast path: one
8299 # evaluate() per candidate. Warm the JIT once here in the parent — before the
8300 # worker pool is created — so workers deserialize ready-compiled kernels rather
8301 # than each compiling inside the first CMA generation.
8302 #
8303 # Unlike the old C prewarm, this is NOT free per worker: a .so is mmapped into
8304 # every child at no cost, whereas each worker still loads the cached njit
8305 # artifacts on first call. _init_worker warms them again for that reason.
8306 try:
8307 from engine import accel as _accel
8308 if _accel.enabled():
8309 _accel.warmup()
8310 except Exception as e: # never let a warmup hiccup block optimization
8311 layer1_logger.warning(
8312 "Accelerator warmup failed (%s); Layer-1 falls back to Python evaluation.", e)
8313
8314 # Create evaluator executor for candidate scoring.
8315 # On some macOS/sandboxed environments, ProcessPool creation can fail with
8316 # PermissionError/OSError (e.g., semaphore sysconf restrictions). Fall back
8317 # to serial evaluation in-process so optimization can still run.
8318 executor_context = None
8319 if num_workers > 1:
8320 try:
8321 executor_context = ProcessPoolExecutor(
8322 max_workers=num_workers,
8323 initializer=_init_worker,
8324 initargs=(config_dict, bounds_array, requirements, constants_dict, debug_strict),
8325 )
8326 except (PermissionError, OSError) as e:
8327 layer1_logger.warning(
8328 "ProcessPool unavailable (%s). Falling back to serial Layer-1 evaluation.",
8329 e,
8330 )
8331 num_workers = 1
8332
8333 if executor_context is None:
8334 _init_worker(config_dict, bounds_array, requirements, constants_dict, debug_strict)
8335 executor_context = _LocalSerialExecutor()
8336
8337 with executor_context as executor:
8338
8339 # Optional: Warm-up call per worker (first call can trigger lazy imports)
8340 if num_workers > 1:
8341 dummy_candidate = _snap_integer_dims(x0_refined.copy(), integer_dims)
8342 list(executor.map(_eval_candidate, [dummy_candidate] * num_workers))
8343
8344 # ------------------------------------------------------------------
8345 # CMA warm-start (hybrid + legacy): same ``_eval_candidate`` / objective as CMA
8346 # workers. Improves ``x0``; syncs ``opt_state`` + SSE plot (parallel CMA never
8347 # calls ``objective()``, so the UI curve used to miss this entirely).
8348 # ------------------------------------------------------------------
8349 best_x_global = np.asarray(x0_refined, dtype=float).copy()
8350 best_f_global = float("inf")
8351 layer1_warmstart_applied = False
8352 _ws_n_raw = requirements.get("layer1_cma_warmstart_trials")
8353 _ws_n = 16 if _ws_n_raw is None else int(_ws_n_raw)
8354 if layer1_smoke:
8355 _ws_n = min(_ws_n, 4)
8356 _ws_sig = _requirement_float(requirements, "layer1_cma_warmstart_sigma_frac", 0.04)
8357 if _ws_n > 0:
8358 x0_refined, _ws_best_f = _layer1_cma_warmstart_candidates(
8359 x0_refined,
8360 lower_bounds=lower_bounds,
8361 upper_bounds=upper_bounds,
8362 span=span,
8363 integer_dims=integer_dims,
8364 rng=rng,
8365 executor=executor,
8366 n_trials=_ws_n,
8367 sigma_frac=_ws_sig,
8368 eval_cache=eval_cache,
8369 make_cache_key_fn=_make_eval_cache_key,
8370 opt_state=opt_state,
8371 logger=layer1_logger,
8372 )
8373 layer1_warmstart_applied = True
8374 best_x_global = np.asarray(x0_refined, dtype=float).copy()
8375 if np.isfinite(_ws_best_f):
8376 best_f_global = float(_ws_best_f)
8378 opt_state, best_x_global, float(_ws_best_f), parent_objective=objective
8379 )
8381 objective_callback,
8382 opt_state,
8383 int(opt_state.get("function_evaluations", 0)),
8384 float(_ws_best_f),
8385 float(opt_state.get("best_objective", _ws_best_f)),
8386 )
8387
8388 # Element count n, jet diameters, hole pitches (the fuel pitch is derived).
8389 _l1_iso_area = (4, (5, 8), (7, 10)) if l1_injector_type == "impinging" else None
8390 if effective_mode == "hybrid_cma_blocks" and hasattr(config_obj.optimizer, "hybrid"):
8391 layer1_logger.info("Using Hybrid CMA + Block Re-optimization mode.")
8392 hybrid_config = config_obj.optimizer.hybrid
8393
8394 # Hybrid evaluation budget = max_iterations * popsize (population-sized generations).
8395 # NOTE: a previous revision made this just `max_iterations`, which silently cut the
8396 # search budget by ~popsize (48x) and caused the optimizer to terminate far too early at
8397 # a much worse objective ("nothing converging"). The * popsize form is what gives the
8398 # optimizer enough evaluations to actually converge; the run still stops early via the
8399 # CMA stagnation/convergence criteria long before the ceiling, so this is a budget CAP,
8400 # not a fixed run length. (The default max_iterations is sized so this is fast under the
8401 # native kernel — see where max_iterations is set.)
8402 total_budget_evals = max(int(popsize), int(max_iterations) * int(popsize))
8403 opt_state["eval_budget"] = int(total_budget_evals)
8404 layer1_logger.info(
8405 "Hybrid evaluation budget: %s (max(%s, max_iterations=%s x popsize=%s))",
8406 total_budget_evals,
8407 popsize,
8408 max_iterations,
8409 popsize,
8410 )
8411
8412 # Run Multi-Track Hybrid?
8413 num_tracks = hybrid_config.num_tracks
8414
8415 best_x_global = x0_refined
8416 best_f_global = float('inf')
8417
8418 if num_tracks > 1:
8419 layer1_logger.info(f"Running {num_tracks} independent hybrid tracks...")
8420 # Track initialization: Top-N restarts?
8421 # User feedback says: "track i starts from best of restart i"
8422 # So we first need to run N restarts of global exploration to seed tracks?
8423 # OR we simply start N tracks from random perturbations of x0.
8424 # "Multi-track: Initialize tracks from Top-N best results of Stage A restarts."
8425 # My run_hybrid_optimization includes Stage A internally.
8426 # So "Multi-track" essentially means distinct runs of run_hybrid_optimization?
8427 # OR running Stage A first, then branching?
8428 # Let's treat "Multi-track" as running the WHOLE hybrid process N times with different seeds.
8429
8430 for track_i in range(num_tracks):
8431 check_stop() # Check if optimization should stop
8432 layer1_logger.info(f"--- Track {track_i+1}/{num_tracks} ---")
8433
8434 # Perturb start point for diversity if track > 0
8435 if track_i > 0:
8436 x0_track = x0_refined + rng.standard_normal(len(x0_refined)) * (0.1 * span)
8437 x0_track = np.clip(x0_track, lower_bounds, upper_bounds)
8438 else:
8439 x0_track = x0_refined
8440
8441 t_x, t_f, t_ev = run_hybrid_optimization(
8442 objective,
8443 list(zip(lower_bounds, upper_bounds)),
8444 x0_track,
8445 hybrid_config,
8446 total_budget=total_budget_evals // num_tracks,
8447 logger=layer1_logger,
8448 log_status_fn=log_status,
8449 update_progress_fn=update_progress,
8450 valley_escape_tracker=opt_state,
8451 # Parallel evaluation parameters
8452 executor=executor,
8453 integer_dims=integer_dims,
8454 od_index=3,
8455 od_step_m=_l1_od_step_m,
8456 fixed_variables=_l1_fixed_vars,
8457 eval_cache=eval_cache,
8458 make_cache_key_fn=_make_eval_cache_key,
8459 stop_event=stop_event,
8460 seed=int((layer1_seed_base + track_i * 7919) % (2 ** 31)),
8461 popsize=popsize,
8462 iso_area=_l1_iso_area,
8463 )
8464
8465 if t_f < best_f_global:
8466 best_f_global = t_f
8467 best_x_global = t_x
8468 layer1_logger.info(f"Track {track_i+1} found new global best: {best_f_global:.5f}")
8469
8470 else:
8471 # Single track
8472 best_x_global, best_f_global, evs = run_hybrid_optimization(
8473 objective,
8474 list(zip(lower_bounds, upper_bounds)),
8475 x0_refined,
8476 hybrid_config,
8477 total_budget=total_budget_evals,
8478 logger=layer1_logger,
8479 log_status_fn=log_status,
8480 update_progress_fn=update_progress,
8481 valley_escape_tracker=opt_state,
8482 # Parallel evaluation parameters
8483 executor=executor,
8484 integer_dims=integer_dims,
8485 od_index=3,
8486 od_step_m=_l1_od_step_m,
8487 fixed_variables=_l1_fixed_vars,
8488 eval_cache=eval_cache,
8489 make_cache_key_fn=_make_eval_cache_key,
8490 stop_event=stop_event,
8491 seed=int(layer1_seed_base),
8492 popsize=popsize,
8493 iso_area=_l1_iso_area,
8494 )
8495
8496 else:
8497 # Default/Fallback: Legacy CMA-ES Logic with Parallel Evaluation
8498 best_x_global = np.asarray(x0_refined, dtype=float).copy()
8499 if not layer1_warmstart_applied:
8500 best_f_global = float("inf")
8501
8502 for restart_idx in range(num_restarts):
8503 restart_name = f"Restart {restart_idx + 1}/{num_restarts}" if num_restarts > 1 else "Main search"
8504
8505 # Multi-start strategy. CRITICAL: odd restarts must RE-EXPLORE globally from a
8506 # FRESH RANDOM point — not refine the incumbent — otherwise a bad first basin traps
8507 # every restart at the same spot ("gives up, goes flat"). Even restarts (>0) polish
8508 # the best basin found so far. So we alternate genuine global exploration with
8509 # exploitation instead of doing three local refinements of one basin.
8510 if restart_idx == 0:
8511 current_sigma_fraction = target_fraction_of_range # 25% (global, from warm start)
8512 x_start = x0_refined
8513 elif restart_idx % 2 == 1:
8514 # GLOBAL re-exploration: start from the incumbent best kicked by a WIDE
8515 # random perturbation (30% of each dim's span), with global sigma. A pure
8516 # uniform-random 13-D start is almost surely infeasible (observed: whole
8517 # restarts flatlining at the 1e6 infeasibility plateau) — anchoring at a
8518 # feasible incumbent keeps the restart searching real designs while the
8519 # big kick + 25% sigma still escapes the incumbent's basin.
8520 current_sigma_fraction = target_fraction_of_range # 25% (global)
8521 x_start = best_x_global + rng.standard_normal(len(best_x_global)) * (0.3 * span)
8522 layer1_logger.info(" (global re-exploration: wide kick off incumbent best)")
8523 else:
8524 # Exploit: refine around the best basin found so far.
8525 current_sigma_fraction = 0.15 # 15% (medium, local)
8526 perturbation = rng.standard_normal(len(best_x_global)) * (sigma0 * 0.3)
8527 x_start = best_x_global + perturbation
8528
8529 x_start = np.clip(x_start, lower_bounds, upper_bounds)
8530
8531 current_sigma0 = float(np.percentile(span, 25) * current_sigma_fraction)
8532 if not np.isfinite(current_sigma0) or current_sigma0 <= 0:
8533 current_sigma0 = 0.05
8534 # First restart only: tighter initial cloud around warm-started ``x0`` so generation-1
8535 # samples do not all land in ``BASE_INFEAS`` (~1e6) when global sigma is huge.
8536 if restart_idx == 0 and layer1_warmstart_applied:
8537 _s0_scale = _requirement_float(requirements, "layer1_cma_restart0_sigma_scale", 0.48)
8538 current_sigma0 *= float(np.clip(_s0_scale, 0.12, 1.0))
8539
8540 current_cma_stds = np.ones_like(span)
8541 for i in range(len(span)):
8542 if span[i] > 0:
8543 desired_step = span[i] * current_sigma_fraction
8544 current_cma_stds[i] = max(0.1, desired_step / current_sigma0) if current_sigma0 > 0 else 1.0
8545
8546 if current_sigma0 > 0:
8547 for idx_disc in layer1_integer_dims:
8548 if 0 <= idx_disc < len(current_cma_stds):
8549 current_cma_stds[idx_disc] = max(
8550 current_cma_stds[idx_disc], 1.0 / current_sigma0
8551 )
8552
8553 layer1_logger.info(f"")
8554 layer1_logger.info(f"Starting {restart_name} (sigma: {current_sigma_fraction*100:.0f}% of range)...")
8555
8556 # Floor at 1: layer1_max_iterations < num_restarts floors this to 0,
8557 # which hands CMA maxiter=0 and then divides by it at the progress
8558 # update below (ZeroDivisionError on any run with max_iterations < 4).
8559 iter_budget = max(1, total_eval_budget // num_restarts)
8560
8561 # Without an explicit ``seed``, cma uses non-deterministic defaults → different optima each run.
8562 _cma_restart_seed = layer1_seed_base + int(restart_idx) * 1_000_003
8563 cma_options = {
8564 "bounds": [lower_bounds.tolist(), upper_bounds.tolist()],
8565 "popsize": popsize,
8566 "maxiter": iter_budget,
8567 "verb_disp": 0,
8568 "verb_log": 0,
8569 "CMA_stds": _l1_shrink_derived_stds(current_cma_stds, _l1_fixed_vars),
8570 "integer_variables": _l1_int_vars,
8571 "minstd": _l1_minstd,
8572 "tolx": 1e-8,
8573 "tolfun": 1e-9,
8574 "tolstagnation": 50,
8575 "ftarget": -np.inf,
8576 "seed": _cma_restart_seed,
8577 }
8578
8579 try:
8580 es = cma.CMAEvolutionStrategy(x_start.tolist(), current_sigma0, cma_options)
8581 while not es.stop():
8582 # Check if optimization should stop
8583 check_stop()
8584
8585 # Check for Valley Escape boost
8586 target_tier = _check_valley_escape_tier()
8587 if target_tier > opt_state["valley_escape_tier"] and opt_state["function_evaluations"] > opt_state["cooldown_until"]:
8588 # Tier definitions
8589 tier_names = ["None", "Mild", "Medium", "Full"]
8590 boost_factors = [1.0, 1.5, 2.0, 3.0]
8591 current_factor = boost_factors[opt_state["valley_escape_tier"]]
8592 target_factor = boost_factors[target_tier]
8593 relative_boost = target_factor / current_factor
8594
8595 old_sigma = es.sigma
8596 es.sigma *= relative_boost
8597
8598 # Clamp sigma to 30% of 25th percentile span to prevent sampling nonsense
8599 max_sigma = 0.3 * float(np.percentile(span, 25))
8600 if es.sigma > max_sigma:
8601 es.sigma = max_sigma
8602
8603 # Update state
8604 opt_state["valley_escape_tier"] = target_tier
8605 opt_state["cooldown_until"] = opt_state["function_evaluations"] + 1000
8606
8607 layer1_logger.info("!" * 20)
8608 layer1_logger.info(f"VALLEY ESCAPE TRIGGERED (Tier: {tier_names[target_tier]})")
8609 layer1_logger.info(f"Evals: {opt_state['function_evaluations']}, Best: {opt_state['best_objective']:.2f}, Stagnation: {opt_state['function_evaluations'] - opt_state['last_best_eval']}")
8610 layer1_logger.info(f"Sigma: {old_sigma:.6f} -> {es.sigma:.6f} (Boost: {relative_boost:.2f}x)")
8611 layer1_logger.info("!" * 20)
8612 for handler in layer1_logger.handlers:
8613 handler.flush()
8614
8615
8616 # CMA-ES samples within bounds (no manual clipping needed)
8617 candidates = es.ask()
8618
8619 # Snap integer dimensions for consistency
8620 # Convert to numpy arrays (pickle-safe in WSL)
8621 candidates_snapped = [
8622 _snap_integer_dims(np.asarray(c, dtype=np.float64), integer_dims)
8623 for c in candidates
8624 ]
8625
8626 # Parent-side caching: check cache before submitting work
8627 # Cache keys use SNAPPED values for consistency
8628 cache_keys = [_make_eval_cache_key(c) for c in candidates_snapped]
8629 uncached_indices = []
8630 uncached_candidates = []
8631 values = [None] * len(candidates)
8632 gen_meta: List[Optional[Dict[str, Any]]] = [None] * len(candidates)
8633
8634 for i, key in enumerate(cache_keys):
8635 if key in eval_cache:
8636 cached_val = eval_cache[key]
8637 # Handle dict format from parallel eval (has 'value' key)
8638 # Note: dict format from objective() has 'results' but not 'value', so we can't use it here
8639 if isinstance(cached_val, dict) and 'value' in cached_val:
8640 values[i] = float(cached_val['value'])
8641 if cached_val.get("worker_payload"):
8642 gen_meta[i] = dict(cached_val["worker_payload"])
8643 elif isinstance(cached_val, (int, float)):
8644 # Old format: direct float value (backward compatibility)
8645 values[i] = float(cached_val)
8646 else:
8647 # Full format from objective() or unknown format - can't extract value, treat as uncached
8648 uncached_indices.append(i)
8649 uncached_candidates.append(candidates_snapped[i])
8650 else:
8651 uncached_indices.append(i)
8652 uncached_candidates.append(candidates_snapped[i])
8653
8654 # Parallel evaluation of uncached candidates
8655 if uncached_candidates:
8656 chunksize = max(1, len(uncached_candidates) // (num_workers * 4))
8657 results = list(executor.map(_eval_candidate, uncached_candidates, chunksize=chunksize))
8658
8659 # Merge results back and update parent state
8660 for idx, res in zip(uncached_indices, results):
8661 obj_val = res['value']
8662 values[idx] = obj_val
8663 gen_meta[idx] = res
8664
8665 # Cache result (using snapped key) - store in dict format for compatibility
8666 eval_cache[cache_keys[idx]] = {
8667 'value': obj_val,
8668 'success': res.get('success', True),
8669 'worker_payload': {
8670 'value': obj_val,
8671 'success': res.get('success', True),
8672 'thrust_error': res.get('thrust_error'),
8673 'of_error': res.get('of_error'),
8674 'momentum_term': res.get('momentum_term'),
8675 'inf_residual': res.get('inf_residual'),
8676 'F': res.get('F'),
8677 'MR': res.get('MR'),
8678 'full_results': res.get('full_results'),
8679 },
8680 }
8681
8682 # Parent owns: tracking, logging, history
8683 # Count ALL evaluations (success + failure) for accurate metrics
8684 opt_state['function_evaluations'] += 1
8686
8687 if not res['success']:
8688 # Track failures
8689 reason = res.get('error_type', 'Unknown')
8690 opt_state['num_failures'] = opt_state.get('num_failures', 0) + 1
8691
8692 # Track failure reasons
8693 if 'fail_counts' not in opt_state:
8694 opt_state['fail_counts'] = {}
8695 opt_state['fail_counts'][reason] = opt_state['fail_counts'].get(reason, 0) + 1
8696
8697 # Periodic failure logging (every 100 evals)
8698 if opt_state['function_evaluations'] % 100 == 0:
8699 # Sort failures by count descending
8700 sorted_fails = sorted(opt_state['fail_counts'].items(), key=lambda item: item[1], reverse=True)
8701 top_fails = sorted_fails[:3]
8702 fail_msg = ", ".join([f"{k}: {v}" for k, v in top_fails])
8703 layer1_logger.warning(f"Recent Failures (Top 3): {fail_msg}")
8704
8705 layer1_logger.warning(f"Eval failed: {reason}")
8706
8707 # Tell CMA-ES using ORIGINAL candidates (not snapped)
8708 # This is correct because CMA-ES needs to update its distribution
8709 es.tell(candidates, values)
8710
8711 _fv = [
8712 float(v) if v is not None and np.isfinite(float(v)) else float("inf")
8713 for v in values
8714 ]
8715 if _fv and min(_fv) < float("inf"):
8716 _gen_best = float(min(_fv))
8717 _gi = int(np.argmin(np.asarray(_fv, dtype=float)))
8719 opt_state,
8720 candidates_snapped[_gi],
8721 _gen_best,
8722 parent_objective=objective,
8723 )
8725 objective_callback,
8726 opt_state,
8727 int(opt_state.get("function_evaluations", 0)),
8728 _gen_best,
8729 float(opt_state.get("best_objective", _gen_best)),
8730 )
8731
8732 iter_idx = max(1, es.countiter)
8733 overall_progress = restart_idx / num_restarts
8734 restart_progress = iter_idx / iter_budget / num_restarts
8735 progress = 0.10 + 0.35 * (overall_progress + restart_progress)
8736 update_progress("Layer 1: CMA-ES", progress,
8737 f"{restart_name} - iteration {iter_idx}/{iter_budget}")
8738
8739 cma_result = es.result
8740 restart_best_x = np.asarray(cma_result.xbest, dtype=float)
8741 restart_best_f = float(cma_result.fbest)
8742
8743 layer1_logger.info(f"{restart_name} finished. Final obj: {restart_best_f:.6f}")
8744
8745 if restart_best_f < best_f_global:
8746 best_f_global = restart_best_f
8747 best_x_global = restart_best_x
8748 layer1_logger.info(f" ✓ New global best: {best_f_global:.6f}")
8749
8750 if best_f_global < obj_tolerance:
8751 layer1_logger.info(f"Found excellent solution, skipping restarts")
8752 break
8753
8754 for handler in layer1_logger.handlers:
8755 handler.flush()
8756
8757 except Exception as e:
8758 layer1_logger.error(f"{restart_name} failed: {e}")
8759 if restart_idx == 0:
8760 raise
8761 continue
8762
8763
8764 # Use best result across all restarts
8765 x0_refined = best_x_global
8766 best_fun = best_f_global
8767 layer1_logger.info(f"")
8768 layer1_logger.info(f"CMA-ES complete. Global best objective: {best_fun:.6f}")
8769 layer1_logger.info(f"Total function evaluations: {opt_state['function_evaluations']}")
8770 log_status("Layer 1", f"CMA-ES complete: {num_restarts} restart(s), obj={best_fun:.3f}, refining with L-BFGS-B...")
8771 for handler in layer1_logger.handlers:
8772 handler.flush()
8773
8774 # Reset function evaluation counter for L-BFGS-B refinement
8775 opt_state["function_evaluations"] = 0
8776
8777
8778 # L-BFGS-B refinement runs after CMA-ES
8779 # Always run full local optimization regardless of current objective value
8780 # ``minimize(..., ftol=...)`` for L-BFGS-B is **not** a tolerance on Layer 1's
8781 # composite objective magnitude. SciPy divides by ``mach_eps`` internally to form
8782 # Fortran ``factr`` (see ``scipy.optimize._lbfgsb_py``). Passing ``obj_tolerance*0.1``
8783 # (~0.2) made ``factr`` ~ 1e15 → effectively immediate "convergence" with ~1e-4 residuals.
8784 maxfun_capped = min(max_iterations * 5, 2500)
8785
8786 opt_state["layer1_lbfgs_phase"] = True
8787 update_progress("Layer 1: Local Refinement", 0.47, f"Refining with L-BFGS-B (max {maxfun_capped} func evals)...")
8788 layer1_logger.info("Phase 2: Local refinement (L-BFGS-B)...")
8789
8790 try:
8791 lbfgs_result = minimize(
8792 objective,
8793 x0_refined,
8794 method='L-BFGS-B',
8795 bounds=bounds,
8796 options={
8797 'maxiter': max_iterations,
8798 'maxfun': maxfun_capped,
8799 # Omitted ftol uses SciPy's default (~``mach_eps`` × 1e7 / ``factr`` workflow).
8800 'gtol': layer1_lbfgs_gtol,
8801 'maxls': 50,
8802 'disp': False,
8803 }
8804 )
8805 if layer1_lbfgs_second_pass and getattr(lbfgs_result, "success", True):
8806 _gt2 = float(max(1e-15, min(1e-12, layer1_lbfgs_gtol * 0.05)))
8807 try:
8808 lbfgs_result2 = minimize(
8809 objective,
8810 np.asarray(lbfgs_result.x, dtype=float),
8811 method="L-BFGS-B",
8812 bounds=bounds,
8813 options={
8814 "maxiter": max_iterations,
8815 "maxfun": min(800, maxfun_capped),
8816 "gtol": _gt2,
8817 "maxls": 60,
8818 "disp": False,
8819 },
8820 )
8821 if (
8822 np.isfinite(getattr(lbfgs_result2, "fun", float("nan")))
8823 and _layer1_feasible_scalar_objective(float(lbfgs_result2.fun))
8824 and (
8825 not _layer1_feasible_scalar_objective(float(lbfgs_result.fun))
8826 or float(lbfgs_result2.fun) <= float(lbfgs_result.fun)
8827 )
8828 ):
8829 lbfgs_result = lbfgs_result2
8830 layer1_logger.info(
8831 "L-BFGS-B second pass improved feasible objective to %.6g (gtol=%.1e)",
8832 float(lbfgs_result.fun),
8833 _gt2,
8834 )
8835 elif np.isfinite(getattr(lbfgs_result2, "fun", float("nan"))):
8836 layer1_logger.info(
8837 "L-BFGS-B second pass rejected: fun=%.6g (infeasible or worse than %.6g)",
8838 float(lbfgs_result2.fun),
8839 float(lbfgs_result.fun),
8840 )
8841 except Exception as _e2:
8842 layer1_logger.debug("L-BFGS-B second pass skipped: %s", _e2)
8843 layer1_logger.info("")
8844 layer1_logger.info("Optimization completed")
8845 layer1_logger.info(f"Success: {lbfgs_result.success}")
8846 _report_fun = float(getattr(lbfgs_result, "fun", float("nan")))
8847 if not _layer1_feasible_scalar_objective(_report_fun):
8848 _report_fun = float(opt_state.get("best_objective", _report_fun))
8849 layer1_logger.info(
8850 "L-BFGS-B terminal iterate was infeasible (obj≈1e6); reporting incumbent best=%.6g",
8851 _report_fun,
8852 )
8853 layer1_logger.info(f"Final objective value: {_report_fun:.6f}")
8854 layer1_logger.info(f"Iterations: {lbfgs_result.nit if hasattr(lbfgs_result, 'nit') else 'N/A'}")
8855 layer1_logger.info(f"Function evaluations: {lbfgs_result.nfev if hasattr(lbfgs_result, 'nfev') else 'N/A'}")
8856 layer1_logger.info("")
8857 result = lbfgs_result
8858 except Exception as e:
8859 layer1_logger.error(f"L-BFGS-B error: {e}")
8860 log_status("Layer 1 Warning", f"L-BFGS-B error: {e}, using best result found")
8861 # Use CMA-ES result or best found during optimization
8862 if 'best_fun' in locals():
8863 # CMA-ES succeeded but L-BFGS-B failed
8864 result = _ResultWrapper(x0_refined, best_fun)
8865 else:
8866 # Use best from optimization state
8867 best_x = opt_state.get('best_x', x0)
8868 best_obj = opt_state.get('best_objective', float('inf'))
8869 result = _ResultWrapper(best_x, best_obj)
8870 finally:
8871 opt_state.pop("layer1_lbfgs_phase", None)
8872
8873 # Never report a final iterate worse than the globally-best evaluated candidate.
8874 # CMA-ES tracks the incumbent best (opt_state["best_x"] / best_f_global), but the
8875 # subsequent L-BFGS-B refinement computes finite-difference gradients on an objective
8876 # that is integer-snapped (n_doublets). Those gradients are unreliable, so L-BFGS-B can wander to a terminal iterate that is
8877 # dramatically worse than the incumbent (observed: 289 -> ~1.1e6, i.e. back to
8878 # infeasible). ``result.x`` is that terminal iterate, and when no feasible
8879 # ``best_config`` snapshot was captured (e.g. the best point was found in a parallel
8880 # worker eval, which only syncs best_x/best_objective) the finalizer below would report
8881 # the wandered iterate. Reconcile ``result`` to the incumbent best so the optimizer
8882 # always returns the best design it actually found.
8883 _incumbent_obj = float(opt_state.get("best_objective", float("inf")))
8884 _incumbent_x = opt_state.get("best_x")
8885 if (
8886 _incumbent_x is not None
8887 and np.isfinite(_incumbent_obj)
8888 and _incumbent_obj < float(getattr(result, "fun", float("inf")))
8889 ):
8890 layer1_logger.info(
8891 "Reconciling final result to incumbent best: result.fun=%.6g -> best_objective=%.6g "
8892 "(L-BFGS-B terminal iterate was worse than the tracked global best)",
8893 float(getattr(result, "fun", float("inf"))),
8894 _incumbent_obj,
8895 )
8896 result = _ResultWrapper(np.asarray(_incumbent_x, dtype=float).copy(), _incumbent_obj)
8897
8898 # Parallel CMA/hybrid workers only sync scalar objectives. Re-evaluate the incumbent on
8899 # the parent process so best_config, validation payloads, and iteration_history carry
8900 # the full per-term penalty breakdown used by the UI convergence plots.
8901 _final_inc_x = opt_state.get("best_x")
8902 if _final_inc_x is not None:
8903 try:
8904 layer1_logger.info(
8905 "Final incumbent parent re-eval (obj=%.6g) for validation snapshot and history",
8906 float(opt_state.get("best_objective", float("nan"))),
8907 )
8908 objective(np.asarray(_final_inc_x, dtype=float))
8909 except Exception as _final_exc:
8910 layer1_logger.warning("Final incumbent parent re-eval failed: %s", _final_exc)
8911
8912 validation_tank_pa: Optional[Tuple[float, float]] = None
8913 _rebuilt = None
8914 # The pool's winner is the reconciled incumbent in ``result.x``; ``opt_state["best_x"]``
8915 # is only written when the INLINE objective finds a new best, which in hybrid mode may
8916 # never happen. Keying the rebuild off best_x alone made it skip silently and fall back
8917 # to a stale snapshot (tanks 477 psi where the winner had 555; validation had to boost
8918 # 9-14% to make that snapshot solve).
8919 _rb_x = getattr(result, "x", None)
8920 _rb_src = "result.x"
8921 if _rb_x is None or len(_rb_x) != len(bounds) or not np.all(np.isfinite(np.asarray(_rb_x, dtype=float))):
8922 _rb_x = opt_state.get("best_x"); _rb_src = "opt_state.best_x"
8923 opt_state["final_rebuild_diag"] = {"fired": False, "src": _rb_src}
8924 if _rb_x is not None and len(_rb_x) == len(bounds):
8925 layer1_logger.info("Final rebuild: from %s (len %d)", _rb_src, len(_rb_x))
8927 np.asarray(_rb_x, dtype=float), config_base, constants_dict, logger=layer1_logger,
8928 diag=opt_state["final_rebuild_diag"])
8929 opt_state["final_rebuild_diag"]["fired"] = True
8930 opt_state["final_rebuild_diag"]["returned"] = _rebuilt is not None
8931 if _rebuilt is None:
8932 layer1_logger.warning("Final rebuild returned None -- falling back to snapshot path")
8933 else:
8934 layer1_logger.warning(
8935 "Final rebuild SKIPPED: no usable vector (result.x=%s, best_x=%s, expected len %d)",
8936 None if getattr(result, "x", None) is None else len(result.x),
8937 None if opt_state.get("best_x") is None else len(opt_state["best_x"]), len(bounds))
8938 if _rebuilt is not None:
8939 optimized_config, _rb_x_solved, _rb_result = _rebuilt
8940 # The breakdown describes the EMITTED design (full-fidelity result of the rebuild),
8941 # not whichever in-loop candidate last set a new inline best.
8942 try:
8944 _rb_result, np.asarray(_rb_x_solved, dtype=float).copy(), requirements,
8945 constants_dict, cea_cache=_l1_cea_cache)
8946 _bd = {"objective": float(_rbT["objective"]), **_rbT["terms"]}
8947 _bd["penalty_sum"] = float(sum(_rbT["terms"].values()))
8948 _bd["penalty_unaccounted"] = _bd["objective"] - _bd["penalty_sum"]
8949 for _k in ("injector_dp_ratio_O", "injector_dp_ratio_F", "chamber_D_over_Dt",
8950 "chamber_L_over_D", "Lstar_m", "Lstar_target_m", "chamber_mass_kg",
8951 "Isp", "Isp_merit_s", "Isp_ref_s", "propellant_mass_kg",
8952 "L_face_to_throat", "L_engine", "momentum_ratio_R", "resultant_tilt_deg",
8953 "resultant_tilt_breakeven_deg", "effective_smd_microns",
8954 "impingement_angle_deg", "infeasibility_score", "length_violation"):
8955 _bd[_k] = _rbT.get(_k)
8956 _bd["source"] = "emitted design (final rebuild)"
8957 opt_state["best_objective_breakdown"] = _bd
8958 except Exception as _bd_exc:
8959 layer1_logger.warning("Breakdown of the emitted design failed: %s", _bd_exc)
8960 # Everything downstream that reads a vector for this design must see the solved one.
8961 opt_state["best_config"] = copy.deepcopy(optimized_config)
8962 opt_state["best_config_x"] = np.asarray(_rb_x_solved, dtype=float).copy()
8963 opt_state["last_eval_config"] = copy.deepcopy(optimized_config)
8964 opt_state["last_eval_config_x"] = np.asarray(_rb_x_solved, dtype=float).copy()
8965 final_lox_end_ratio = opt_state.get("best_lox_end_ratio", 0.7)
8966 final_fuel_end_ratio = opt_state.get("best_fuel_end_ratio", 0.7)
8967 validation_tank_pa = (
8968 float(_rb_x_solved[idx_P_O]) * 6894.757293168,
8969 float(_rb_x_solved[idx_P_F]) * 6894.757293168,
8970 )
8971 elif opt_state["best_config"] is not None:
8972 optimized_config = copy.deepcopy(opt_state["best_config"])
8973 final_lox_end_ratio = opt_state.get("best_lox_end_ratio", 0.7)
8974 final_fuel_end_ratio = opt_state.get("best_fuel_end_ratio", 0.7)
8975 validation_tank_pa = opt_state.get("best_validation_tank_pa")
8976 elif opt_state.get("last_eval_config") is not None:
8977 optimized_config = copy.deepcopy(opt_state["last_eval_config"])
8978 final_lox_end_ratio = opt_state.get("best_lox_end_ratio", 0.7)
8979 final_fuel_end_ratio = opt_state.get("best_fuel_end_ratio", 0.7)
8980 validation_tank_pa = opt_state.get("last_eval_validation_tank_pa")
8981 else:
8982 _rx = getattr(result, "x", None)
8983 _rx = np.asarray(_rx if _rx is not None else x0, dtype=float).copy()
8984 optimized_config, P_O_final_psi, P_F_final_psi = apply_x_to_config(_rx, config_base)
8985 final_lox_end_ratio = P_O_final_psi / max_lox_P_psi if max_lox_P_psi > 0 else 0.7
8986 final_fuel_end_ratio = P_F_final_psi / max_fuel_P_psi if max_fuel_P_psi > 0 else 0.7
8987
8988 # Ensure tank configs exist
8989 if optimized_config.lox_tank is None:
8990 from engine.pipeline.config_schemas import LOXTankConfig
8991 optimized_config.lox_tank = LOXTankConfig(lox_h=0.5, lox_radius=0.1, ox_tank_pos=1.0)
8992 if optimized_config.fuel_tank is None:
8993 from engine.pipeline.config_schemas import FuelTankConfig
8994 optimized_config.fuel_tank = FuelTankConfig(rp1_h=0.5, rp1_radius=0.1, fuel_tank_pos=0.5)
8995
8996 # Extract optimized pressures
8997 best_x = opt_state.get("best_x", result.x if hasattr(result, 'x') else x0)
8998 # Pressures must match the geometry snapshot in ``best_config`` when present (``best_x`` alone can
8999 # refer to an infeasible iterate while ``best_config`` still holds the last feasible design).
9000 x_for_tank_psi = opt_state.get("best_config_x")
9001 if x_for_tank_psi is None or len(x_for_tank_psi) <= idx_P_F:
9002 x_for_tank_psi = opt_state.get("last_eval_config_x")
9003 if x_for_tank_psi is None or len(x_for_tank_psi) <= idx_P_F:
9004 x_for_tank_psi = best_x
9005 if x_for_tank_psi is not None and len(x_for_tank_psi) > idx_P_F:
9006 P_O_start_optimized_psi = float(
9007 np.clip(x_for_tank_psi[idx_P_O], bounds[idx_P_O][0], bounds[idx_P_O][1])
9008 )
9009 # Clip to the SEARCH range, not to bounds[idx_P_F]: when the tanks are locked that
9010 # bound is collapsed to the seed, and clipping there overwrote the locked value with
9011 # it -- the reported fuel tank read 535.2 psi on every run while the physics had used
9012 # it equal to the LOX tank.
9013 P_F_start_optimized_psi = float(
9014 np.clip(x_for_tank_psi[idx_P_F], lower_bounds_eff[idx_P_F], upper_bounds_eff[idx_P_F])
9015 )
9016 optimized_config.lox_tank.initial_pressure_psi = P_O_start_optimized_psi
9017 optimized_config.fuel_tank.initial_pressure_psi = P_F_start_optimized_psi
9018 # The geometry above and these pressures can come from different candidates; re-solve
9019 # the derived DOFs so the emitted design is self-consistent. Skipped when the rebuild
9020 # already produced the design from ONE vector -- a second solve on top of a converged
9021 # one only perturbs A_throat (measured as a 0.1-0.5% thrust drift).
9022 if _rebuilt is None:
9024 optimized_config,
9025 P_O_start_optimized_psi * 6894.757293168,
9026 P_F_start_optimized_psi * 6894.757293168,
9027 constants_dict,
9028 logger=layer1_logger,
9029 )
9030 else:
9031 optimized_config.lox_tank.initial_pressure_psi = max_lox_P_psi * 0.8
9032 optimized_config.fuel_tank.initial_pressure_psi = max_fuel_P_psi * 0.8
9033
9034 # Ensure orifice angle is 90°
9035 if hasattr(optimized_config, 'injector') and optimized_config.injector.type == "pintle":
9036 if hasattr(optimized_config.injector.geometry, 'lox'):
9037 optimized_config.injector.geometry.lox.theta_orifice = 90.0
9038
9039 iteration_history = opt_state["history"]
9040
9041 def _validation_evaluate_boost(
9042 runner: PintleEngineRunner,
9043 po: float,
9044 pf: float,
9045 ) -> Tuple[Dict[str, Any], float]:
9046 """Evaluate the emitted design at its OWN tank pressures.
9047
9048 If it does not close there, find -- for the report only, never the verdict -- the
9049 smallest tank boost inside the tank limits at which it would. Any scale other than 1.0
9050 makes the design invalid (see validation_replay_ok below).
9051 """
9052 po0, pf0 = float(po), float(pf)
9053 try:
9054 return runner.evaluate(po0, pf0, P_ambient=target_P_exit, silent=True), 1.0
9055 except ValueError as exc:
9056 if "Supply < Demand" not in str(exc) and "Insufficient mass flow" not in str(exc):
9057 raise
9058 first_exc = exc
9059 _cap = min(max_lox_P_psi * psi_to_Pa / max(po0, 1.0),
9060 max_fuel_P_psi * psi_to_Pa / max(pf0, 1.0))
9061 for scale in (1.03, 1.06, 1.09, 1.14, 1.20, 1.28, 1.40, 1.55, 1.72):
9062 if scale > _cap:
9063 break
9064 try:
9065 return runner.evaluate(po0 * scale, pf0 * scale, P_ambient=target_P_exit,
9066 silent=True), scale
9067 except ValueError as exc:
9068 if "Supply < Demand" not in str(exc) and "Insufficient mass flow" not in str(exc):
9069 raise
9070 raise first_exc
9071
9072 def _validation_evaluate_or_bundle(
9073 runner: PintleEngineRunner,
9074 po: float,
9075 pf: float,
9076 ) -> Tuple[Dict[str, Any], float]:
9077 """Replay validation evaluate; fall back to the last optimizer ``evaluate`` payload if the
9078 solver diverges -- for the report only; the design is then invalid.
9079
9080 The chamber solver says it did not close in two ways: a ``ValueError`` from the root
9081 find, and a ``RuntimeError`` from its own checks on what it found ("Solution
9082 validation failed: Isp is non-positive"). Both mean the emitted design does not
9083 close at its tank pressures; only the first used to be caught, so a design whose
9084 replay landed on a non-physical root crashed the optimizer instead of being reported
9085 invalid -- on CI's runners, where the smoke search's last candidate is one of them.
9086 """
9087 try:
9088 return _validation_evaluate_boost(runner, po, pf)
9089 except (ValueError, RuntimeError) as exc:
9090 if isinstance(exc, RuntimeError) and not str(exc).startswith(_CHAMBER_SOLVER_REFUSALS):
9091 raise
9092 b = opt_state.get("last_good_eval_bundle")
9093 if not isinstance(b, dict) or not isinstance(b.get("results"), dict):
9094 raise
9095 layer1_logger.warning(
9096 "Layer 1 validation replay failed (%s); reporting the last in-loop evaluate "
9097 "payload, and the design is INVALID.", exc
9098 )
9099 perf = copy.deepcopy(b["results"])
9100 perf["layer1_validation_used_last_good_bundle"] = True
9101 perf["layer1_validation_replay_error"] = str(exc)[:200]
9102 return perf, 1.0
9103
9104 # Validation
9105 update_progress("Layer 1: Validation", 0.52, "Validating optimized configuration...")
9106
9107 best_x = opt_state.get("best_x", result.x if hasattr(result, 'x') else x0)
9108 validation_pressure_scale = 1.0
9109
9110 # Prefer exact LOX/fuel tank pressures (Pa) used in the iterate that produced this geometry snapshot;
9111 # avoids psi↔Pa rounding drift vs ``initial_pressure_psi`` clipping.
9112 if validation_tank_pa is not None and len(validation_tank_pa) == 2:
9113 P_O_initial = float(validation_tank_pa[0])
9114 P_F_initial = float(validation_tank_pa[1])
9115 else:
9116 try:
9117 P_O_initial = float(optimized_config.lox_tank.initial_pressure_psi) * psi_to_Pa
9118 P_F_initial = float(optimized_config.fuel_tank.initial_pressure_psi) * psi_to_Pa
9119 except Exception:
9120 if best_x is not None and len(best_x) > idx_P_O:
9121 P_O_initial = float(np.clip(best_x[idx_P_O], bounds[idx_P_O][0], bounds[idx_P_O][1])) * psi_to_Pa
9122 else:
9123 P_O_initial = max_lox_P_psi * psi_to_Pa * 0.95
9124 if best_x is not None and len(best_x) > idx_P_F:
9125 P_F_initial = float(np.clip(best_x[idx_P_F], bounds[idx_P_F][0], bounds[idx_P_F][1])) * psi_to_Pa
9126 else:
9127 P_F_initial = max_fuel_P_psi * psi_to_Pa * 0.95
9128
9129 # Validate the engine AS DESIGNED. Disabling the liner here changed the wall by 0.5 in
9130 # per side, i.e. a full inch of bore, so the replay scored a different chamber than the
9131 # one being shipped: the rebuild solved F = 7208.8 N and validation reported 7240.4 N for
9132 # the same design. Reported numbers must describe the config the user receives.
9133 optimized_config_runner = copy.deepcopy(optimized_config)
9134 if os.environ.get("ED_L1_VALIDATE_THERMAL_OFF", "0") == "1": # A/B hook only
9135 if getattr(optimized_config_runner, "ablative_cooling", None):
9136 optimized_config_runner.ablative_cooling.enabled = False
9137 if getattr(optimized_config_runner, "graphite_insert", None):
9138 optimized_config_runner.graphite_insert.enabled = False
9139
9140 optimized_runner = PintleEngineRunner(optimized_config_runner)
9141
9142 # When nothing was feasible, say which gate held the best candidate out (see the helper).
9143 infeasible_reason = None
9144 try:
9145 if best_x is not None and not _layer1_feasible_scalar_objective(float(opt_state.get("best_objective", float("inf")))):
9146 infeasible_reason = _layer1_infeasibility_reason(optimized_runner, best_x, requirements, constants_dict)
9147 if infeasible_reason and log_status:
9148 log_status("warning", infeasible_reason)
9149 except Exception:
9150 infeasible_reason = None
9151
9152 # Use stored validation results if available
9153 if "best_results_for_validation" in opt_state and opt_state["best_results_for_validation"] is not None:
9154 stored_results = opt_state["best_results_for_validation"]
9155 # No placeholder Isp/Pc (this used to seed 250 s / 2 MPa): they come from the replay
9156 # below or not at all.
9157 initial_performance = {
9158 "F": stored_results["F"],
9159 "MR": stored_results["MR"],
9160 "stability_results": stored_results.get("stability_results", {}),
9161 }
9162 initial_thrust_error = stored_results["thrust_error"]
9163 initial_MR_error = stored_results["of_error"]
9164 stored_stability_score = stored_results.get("stability_score", None)
9165 stored_stability_state = stored_results.get("stability_state", None)
9166 log_status("Layer 1 Validation", f"Using stored validation results: Thrust err {initial_thrust_error*100:.2f}%, O/F err {initial_MR_error*100:.2f}%")
9167
9168 # For stored results, we need to re-evaluate to get P_exit and Cf
9169 # (stored_results only contains F, MR, errors, and stability)
9170 try:
9171 eval_results, validation_pressure_scale = _validation_evaluate_or_bundle(
9172 optimized_runner, P_O_initial, P_F_initial
9173 )
9174 # Copy P_exit and Cf from evaluation if available
9175 if "P_exit" in eval_results:
9176 initial_performance["P_exit"] = eval_results["P_exit"]
9177 if "Cf" in eval_results or "Cf_actual" in eval_results:
9178 initial_performance["Cf"] = eval_results.get("Cf_actual", eval_results.get("Cf"))
9179 initial_performance["Cf_actual"] = initial_performance["Cf"]
9180 # Also copy other useful metrics that might be missing
9181 if "Isp" in eval_results:
9182 initial_performance["Isp"] = eval_results["Isp"]
9183 if "Pc" in eval_results:
9184 initial_performance["Pc"] = eval_results["Pc"]
9185 # Copy mass flow rates and efficiency metrics
9186 if "mdot_total" in eval_results:
9187 initial_performance["mdot_total"] = eval_results["mdot_total"]
9188 if "mdot_O" in eval_results:
9189 initial_performance["mdot_O"] = eval_results["mdot_O"]
9190 if "mdot_F" in eval_results:
9191 initial_performance["mdot_F"] = eval_results["mdot_F"]
9192 if "eta_cstar" in eval_results:
9193 initial_performance["eta_cstar"] = eval_results["eta_cstar"]
9194 if "cstar_actual" in eval_results:
9195 initial_performance["cstar_actual"] = eval_results["cstar_actual"]
9196 if "cstar_ideal" in eval_results:
9197 initial_performance["cstar_ideal"] = eval_results["cstar_ideal"]
9198 # Replay thrust/MR/stability must match diagnostics used for ΔP and momentum gates.
9199 if "F" in eval_results:
9200 initial_performance["F"] = eval_results["F"]
9201 if "MR" in eval_results:
9202 initial_performance["MR"] = eval_results["MR"]
9203 if eval_results.get("stability_results"):
9204 initial_performance["stability_results"] = eval_results["stability_results"]
9205 # Copy chamber_intrinsics (contains is_choked, etc.)
9206 if "chamber_intrinsics" in eval_results and eval_results["chamber_intrinsics"]:
9207 initial_performance["chamber_intrinsics"] = eval_results["chamber_intrinsics"]
9208 _merge_runner_eval_into_performance(initial_performance, eval_results)
9209 for _vk in ("layer1_validation_used_last_good_bundle", "layer1_validation_replay_error"):
9210 if _vk in eval_results:
9211 initial_performance[_vk] = eval_results[_vk]
9212 except Exception as _replay_exc:
9213 initial_performance["layer1_validation_replay_error"] = str(_replay_exc)[:200]
9214 # If re-evaluation fails, calculate Cf from available data
9215 F_val = initial_performance.get("F", 0)
9216 Pc_val = initial_performance.get("Pc", 0)
9217 A_throat_val = _throat_area_m2_from_config(optimized_config)
9218 if A_throat_val and A_throat_val > 0 and Pc_val > 0:
9219 Cf_calculated = F_val / (Pc_val * A_throat_val)
9220 initial_performance["Cf_actual"] = Cf_calculated
9221 initial_performance["Cf"] = Cf_calculated
9222 else:
9223 initial_performance, validation_pressure_scale = _validation_evaluate_or_bundle(
9224 optimized_runner, P_O_initial, P_F_initial
9225 )
9226 initial_thrust_error = (
9227 abs(initial_performance.get("F", 0) - target_thrust) / target_thrust if target_thrust > 0 else 1.0
9228 )
9229 initial_MR_error = (
9230 abs(initial_performance.get("MR", 0) - optimal_of) / optimal_of if optimal_of > 0 else 1.0
9231 )
9232
9233 # Ensure Cf is included (calculate if not provided by runner)
9234 if "Cf" not in initial_performance and "Cf_actual" not in initial_performance:
9235 F_val = initial_performance.get("F", 0)
9236 Pc_val = initial_performance.get("Pc", 0)
9237 A_throat_val = _throat_area_m2_from_config(optimized_config)
9238 if A_throat_val and A_throat_val > 0 and Pc_val > 0:
9239 Cf_calculated = F_val / (Pc_val * A_throat_val)
9240 initial_performance["Cf_actual"] = Cf_calculated
9241 initial_performance["Cf"] = Cf_calculated
9242
9243 if validation_pressure_scale is not None and np.isfinite(float(validation_pressure_scale)):
9244 sc = float(validation_pressure_scale)
9245 initial_performance["layer1_validation_tank_pressure_scale"] = sc
9246 # Nominal stagnation pressures from the optimizer iterate (``P_O_initial`` / ``P_F_initial`` in Pa).
9247 _po_nom_psi = min(float(P_O_initial) / float(psi_to_Pa), max_lox_P_psi)
9248 _pf_nom_psi = min(float(P_F_initial) / float(psi_to_Pa), max_fuel_P_psi)
9249 # Saved YAML + "Optimized Tank Pressures" must stay at **nominal** caps: replay may multiply tanks
9250 # by ``sc>1`` only to get a convergent ``evaluate()`` after marginal supply starvation (common when
9251 # spray / η models tighten injector-side closure). Persisting ``*_nom*sc`` made UI/YAML look like
9252 # the design exceeded the Layer‑1 stagnation band (~65–85% of tank max).
9253 optimized_config.lox_tank.initial_pressure_psi = _po_nom_psi
9254 optimized_config.fuel_tank.initial_pressure_psi = _pf_nom_psi
9255 if abs(sc - 1.0) > 1e-6:
9256 initial_performance["layer1_validation_replay_boosted_tanks"] = True
9257 initial_performance["P_O_start_psi_validation_replay"] = float(_po_nom_psi * sc)
9258 initial_performance["P_F_start_psi_validation_replay"] = float(_pf_nom_psi * sc)
9259
9260 # Impinging: expose jet momentum-balance ratio and hinge penalty at top level for UI / summaries
9261 if getattr(getattr(optimized_config, "injector", None), "type", None) == "impinging":
9262 _diag_vals = initial_performance.get("diagnostics")
9263 initial_performance["momentum_balance_penalty"] = 0.0
9264 if isinstance(_diag_vals, dict):
9265 _mr_promote = _diag_vals.get("momentum_ratio_R")
9266 if _mr_promote is not None and np.isfinite(_mr_promote) and float(_mr_promote) > 0:
9267 initial_performance["momentum_ratio_R"] = float(_mr_promote)
9268 initial_performance["momentum_balance_penalty"] = float(
9269 layer1_W_MOM
9271 _mr_promote,
9272 r_band_lo=layer1_R_mom_aim_lo,
9273 r_band_hi=layer1_R_mom_aim_hi,
9274 )
9275 )
9276 # Audit fields for UI (same definitions as impinging.flows momentum_ratio_R)
9277 for _rk, _cast in (
9278 ("v_O_bulk", float),
9279 ("v_F_bulk", float),
9280 ("A_jet_O", float),
9281 ("A_jet_F", float),
9282 ("rho_O_momentum", float),
9283 ("rho_F_momentum", float),
9284 ("d_jet_O", float),
9285 ("d_jet_F", float),
9286 ):
9287 _rv = _diag_vals.get(_rk)
9288 if _rv is not None:
9289 try:
9290 _rf = _cast(_rv)
9291 if np.isfinite(_rf):
9292 initial_performance[_rk] = _rf
9293 except (TypeError, ValueError):
9294 pass
9295 for _ik in ("momentum_ratio_n_elements_O", "momentum_ratio_n_elements_F"):
9296 _iv = _diag_vals.get(_ik)
9297 if _iv is not None:
9298 try:
9299 initial_performance[_ik] = int(_iv)
9300 except (TypeError, ValueError):
9301 pass
9302 try:
9303 ig = optimized_config.injector.geometry
9304 rho_o_p = float(optimized_config.fluids["oxidizer"].density)
9305 rho_f_p = float(optimized_config.fluids["fuel"].density)
9306 Aog_p = float(ig.oxidizer.n_elements * np.pi * (ig.oxidizer.d_jet / 2.0) ** 2)
9307 Afg_p = float(ig.fuel.n_elements * np.pi * (ig.fuel.d_jet / 2.0) ** 2)
9308 if Afg_p > 0:
9309 _sq_g, ao_r, exp_r = _geom_ao_af_momentum_hint_squared(
9310 Aog_p, Afg_p, optimal_of, rho_o_p, rho_f_p
9311 )
9312 initial_performance["geom_ao_af"] = float(ao_r)
9313 initial_performance["expected_ao_af_for_R1"] = float(exp_r)
9314 if np.isfinite(exp_r) and float(exp_r) != 0.0:
9315 initial_performance["geom_ao_af_rel_error"] = float((float(ao_r) - float(exp_r)) / float(exp_r))
9316 initial_performance["geom_ao_af_momentum_hint_sq"] = float(_sq_g)
9317 except Exception:
9318 pass
9319
9320 # Injector ΔP/Pc ratios and weighted penalty (all injector types when Pc and diagnostics allow)
9321 initial_performance["injector_dp_out_of_range"] = False
9322 _pc_prom = initial_performance.get("Pc")
9323 if _pc_prom is not None and np.isfinite(float(_pc_prom)) and float(_pc_prom) > 0:
9324 _ro_p, _rf_p = injector_dp_ratios_from_eval_result(float(_pc_prom), initial_performance)
9325 if _ro_p is not None:
9326 initial_performance["injector_dp_ratio_O"] = float(_ro_p)
9327 if _rf_p is not None:
9328 initial_performance["injector_dp_ratio_F"] = float(_rf_p)
9329 initial_performance["injector_dp_penalty"] = float(
9330 injector_dp_ratio_penalty_weighted(
9331 _ro_p,
9332 _rf_p,
9333 layer1_W_DP,
9334 layer1_W_DP_HIGH,
9335 o_band=layer1_dp_o_aim,
9336 f_band=layer1_dp_f_aim,
9337 w_dp_o=layer1_W_DP_O,
9338 w_dp_f=layer1_W_DP_F,
9339 o_soft_floor=layer1_dp_o_soft_floor,
9340 w_dp_o_floor=layer1_W_DP_O_FLOOR,
9341 )
9342 )
9343 lo_o, hi_o = layer1_dp_o_band
9344 lo_f, hi_f = layer1_dp_f_band
9345 _oor = False
9346 if injector_dp_ratio_within_gate(_ro_p, float(lo_o), float(hi_o)) is False:
9347 _oor = True
9348 if injector_dp_ratio_within_gate(_rf_p, float(lo_f), float(hi_f)) is False:
9349 _oor = True
9350 initial_performance["injector_dp_out_of_range"] = bool(_oor)
9351
9352 # Thrust/MR gate fractions must match final replay payload (ΔP/R gates already use same evaluate).
9353 _Fv_gate = initial_performance.get("F")
9354 _MRv_gate = initial_performance.get("MR")
9355 if target_thrust > 0 and _Fv_gate is not None and np.isfinite(float(_Fv_gate)):
9356 initial_thrust_error = abs(float(_Fv_gate) - float(target_thrust)) / float(target_thrust)
9357 else:
9358 initial_thrust_error = 1.0
9359 if optimal_of > 0 and _MRv_gate is not None and np.isfinite(float(_MRv_gate)):
9360 initial_MR_error = abs(float(_MRv_gate) - float(optimal_of)) / float(optimal_of)
9361 else:
9362 initial_MR_error = 1.0
9363
9364 # Update chamber_geometry.Cf with the calculated thrust coefficient
9365 Cf_final = initial_performance.get("Cf_actual", initial_performance.get("Cf"))
9366 if Cf_final is not None and np.isfinite(Cf_final):
9367 if optimized_config.chamber_geometry is not None:
9368 optimized_config.chamber_geometry.Cf = float(Cf_final)
9369
9370 # Check stability (replay evaluate provides nested stability_results; snapshot uses flat margins).
9371 sr_live = initial_performance.get("stability_results")
9372 snap = opt_state.get("best_results_for_validation") or {}
9373 if isinstance(sr_live, dict) and sr_live:
9374 stability_results = sr_live
9375 stability_state = stability_results.get("stability_state", "unstable")
9376 stability_score = float(stability_results.get("stability_score", 0.0))
9377 chugging_margin = float(stability_results.get("chugging", {}).get("stability_margin", 0))
9378 acoustic_margin = float(stability_results.get("acoustic", {}).get("stability_margin", 0))
9379 feed_margin = float(stability_results.get("feed_system", {}).get("stability_margin", 0))
9380 elif snap:
9381 stability_results = snap.get("stability_results", {})
9382 stability_state = snap.get("stability_state", "unstable")
9383 stability_score = float(snap.get("stability_score", 0.0))
9384 chugging_margin = float(snap.get("chugging_margin", 0))
9385 acoustic_margin = float(snap.get("acoustic_margin", 0))
9386 feed_margin = float(snap.get("feed_margin", 0))
9387 else:
9388 stability_results = {}
9389 stability_state = "unstable"
9390 stability_score = 0.0
9391 chugging_margin = acoustic_margin = feed_margin = 0.0
9392 initial_stability = min(chugging_margin, acoustic_margin, feed_margin)
9393
9394 # Validation checks
9395 stability_check_passed, stability_parts = _layer1_stability_gate(
9396 stability_state, stability_score, chugging_margin, acoustic_margin, feed_margin,
9397 stability_results, requirements, min_stability)
9398 require_stable_state = requirements.get("require_stable_state", True)
9399
9400 _thr_v = requirements.get("layer1_thrust_validation_rel_tol")
9401 thrust_tol_valid = float(_thr_v) if _thr_v is not None else float(thrust_tol)
9402 _of_v = requirements.get("layer1_of_validation_tol")
9403 of_tol_valid = float(_of_v if _of_v is not None else 0.15)
9404 if not np.isfinite(of_tol_valid) or of_tol_valid <= 0.0:
9405 of_tol_valid = 0.15
9406 _gate_tol_eps = 1e-12
9407 thrust_check_passed = initial_thrust_error <= thrust_tol_valid + _gate_tol_eps
9408 of_check_passed = initial_MR_error <= of_tol_valid + _gate_tol_eps
9409
9410 # Geometry validation (consistency check for final result)
9411 A_throat_final = float(getattr(optimized_config.chamber_geometry, 'A_throat', 0.0))
9412 D_chamber_final = float(getattr(optimized_config.chamber_geometry, 'chamber_diameter', 0.0))
9413 A_chamber_final = np.pi * (D_chamber_final / 2) ** 2
9414
9415 geometry_check_passed = True
9416 geometry_failure_reasons = []
9417
9418 if A_chamber_final > 0 and A_throat_final > 0:
9419 contraction_ratio_final = A_chamber_final / A_throat_final
9420 if contraction_ratio_final < 1.1:
9421 geometry_check_passed = False
9422 geometry_failure_reasons.append(f"Throat area too large for chamber (contraction ratio {contraction_ratio_final:.2f} < 1.1)")
9423
9424 if (
9425 getattr(optimized_config.injector, "type", None) == "pintle"
9426 and hasattr(optimized_config.injector, 'geometry')
9427 and hasattr(optimized_config.injector.geometry, 'fuel')
9428 ):
9429 d_pintle_final = float(optimized_config.injector.geometry.fuel.d_pintle_tip)
9430 if d_pintle_final >= D_chamber_final:
9431 geometry_check_passed = False
9432 geometry_failure_reasons.append(f"Pintle diameter {d_pintle_final*1000:.1f}mm >= chamber diameter {D_chamber_final*1000:.1f}mm")
9433
9434 if (
9435 getattr(optimized_config.injector, "type", None) == "impinging"
9436 and hasattr(optimized_config.injector, "geometry")
9437 ):
9438 ig = optimized_config.injector.geometry
9439 d_max = max(float(ig.oxidizer.d_jet), float(ig.fuel.d_jet))
9440 if D_chamber_final > 0 and d_max * 1.15 >= D_chamber_final:
9441 geometry_check_passed = False
9442 geometry_failure_reasons.append(
9443 f"Impinging jet diameter (max {d_max*1000:.2f} mm) too large vs chamber inner Ø {D_chamber_final*1000:.1f} mm"
9444 )
9445
9446 # Chamber length constraint (use unified chamber_geometry when legacy chamber is null).
9447 from engine.pipeline.config_schemas import ensure_chamber_geometry as _ensure_cg_len
9448
9449 _cg_len = _ensure_cg_len(optimized_config)
9450 _len_raw = getattr(_cg_len, "length", None)
9451 L_chamber_final = (
9452 float(_len_raw)
9453 if (_len_raw is not None and np.isfinite(float(_len_raw)))
9454 else np.nan
9455 )
9456 _mcl = requirements.get("max_chamber_length_m") # None-safe, see report_every_n
9457 max_chamber_length = float(_mcl) if _mcl is not None else 0.50
9458
9459 if np.isfinite(L_chamber_final) and max_chamber_length > 0:
9460 if L_chamber_final > max_chamber_length:
9461 geometry_check_passed = False
9462 geometry_failure_reasons.append(f"Chamber length {L_chamber_final*1000:.1f}mm > max allowed {max_chamber_length*1000:.1f}mm")
9463
9464 # Injector ΔP ratio gate (optional; enforced when Pc and ratios are available).
9465 def _as_finite_float_or_nan(v: Any) -> float:
9466 try:
9467 f = float(v)
9468 except (TypeError, ValueError):
9469 return float("nan")
9470 return f if np.isfinite(f) else float("nan")
9471
9472 ratio_o_val = ratio_f_val = float("nan")
9473 dp_gate_passed = True
9474 pc_val = float(initial_performance.get("Pc", np.nan))
9475 if np.isfinite(pc_val) and pc_val > 0:
9476 _ro, _rf = injector_dp_ratios_from_eval_result(pc_val, initial_performance)
9477 ratio_o_val = _as_finite_float_or_nan(_ro)
9478 ratio_f_val = _as_finite_float_or_nan(_rf)
9479 _chk_o_final = injector_dp_ratio_within_gate(
9480 ratio_o_val, layer1_dp_o_band[0], layer1_dp_o_band[1]
9481 )
9482 if _chk_o_final is not None:
9483 dp_gate_passed &= bool(_chk_o_final)
9484 _chk_f_final = injector_dp_ratio_within_gate(
9485 ratio_f_val, layer1_dp_f_band[0], layer1_dp_f_band[1]
9486 )
9487 if _chk_f_final is not None:
9488 dp_gate_passed &= bool(_chk_f_final)
9489
9490 # Impinging momentum-ratio gate (optional).
9491 momentum_gate_passed = True
9492 momentum_gate_band = (layer1_impinging_R_mom_lo, layer1_impinging_R_mom_hi)
9493 momentum_r_val = float(initial_performance.get("momentum_ratio_R", np.nan))
9494 if (
9495 getattr(optimized_config.injector, "type", None) == "impinging"
9496 and layer1_impinging_R_mom_lo is not None
9497 and layer1_impinging_R_mom_hi is not None
9498 and np.isfinite(momentum_r_val)
9499 and momentum_r_val > 0
9500 ):
9501 # SYMMETRIC, and no longer the liner check. R is a momentum-FLUX ratio; the liner
9502 # gate is the resultant tilt below. The asymmetric widening here encoded the old
9503 # "below 1 is safe" rule, and with the tilt guard doing the real work the balanced
9504 # designs sit at R ~ 1.05 -- passing every other check and failing this one, which
9505 # marked a good engine invalid (measured: R = 1.0508 against a 1.05 ceiling,
9506 # momentum_gate_passed False, pressure_candidate_valid False).
9507 momentum_gate_passed, momentum_gate_band = _layer1_momentum_gate(
9508 momentum_r_val, layer1_impinging_R_mom_lo, layer1_impinging_R_mom_hi,
9509 layer1_momentum_gate_safe_slack)
9510
9511 # The real ablative gate: where the spray fan actually points.
9512 resultant_tilt_gate_passed = True
9513 _tilt_final = float("nan")
9514 if getattr(optimized_config.injector, "type", None) == "impinging":
9515 _gf = getattr(optimized_config.injector, "geometry", None)
9516 _tilt_final = _impinging_resultant_tilt_deg(
9517 initial_performance.get("mdot_O"), initial_performance.get("mdot_F"),
9518 float(optimized_config.fluids["oxidizer"].density),
9519 float(optimized_config.fluids["fuel"].density),
9520 getattr(_gf.oxidizer, "n_elements", float("nan")) if _gf else float("nan"),
9521 getattr(_gf.oxidizer, "d_jet", float("nan")) if _gf else float("nan"),
9522 getattr(_gf.fuel, "d_jet", float("nan")) if _gf else float("nan"),
9523 getattr(_gf.oxidizer, "impingement_angle", float("nan")) if _gf else float("nan"),
9524 getattr(_gf.fuel, "impingement_angle", float("nan")) if _gf else float("nan"),
9525 lox_inboard=layer1_ring_order_fuel_outboard,
9526 spacing_O_m=getattr(_gf.oxidizer, "spacing", float("nan")) if _gf else float("nan"),
9527 spacing_F_m=getattr(_gf.fuel, "spacing", float("nan")) if _gf else float("nan"),
9528 )
9529 initial_performance["resultant_tilt_deg"] = (
9530 float(_tilt_final) if np.isfinite(_tilt_final) else None)
9531 # The tilt ALLOWANCE is a constant the operator sets; what makes it safe is geometry
9532 # the optimizer moves underneath it. Report the break-even angle for the design that
9533 # actually came out, so an allowance that its own impingement radius no longer
9534 # justifies is visible instead of silent. See _resultant_tilt_breakeven_deg.
9535 _cg_f = getattr(optimized_config, "chamber_geometry", None)
9536 _L_cham_f = float("nan")
9537 if _cg_f is not None:
9538 _lc = getattr(_cg_f, "length_cylindrical", None)
9539 _ln = getattr(_cg_f, "length_contraction", None)
9540 if _lc is not None and _ln is not None:
9541 _L_cham_f = float(_lc) + float(_ln)
9542 elif getattr(_cg_f, "length", None) is not None:
9543 _L_cham_f = float(_cg_f.length)
9545 n_elements=getattr(_gf.oxidizer, "n_elements", float("nan")) if _gf else float("nan"),
9546 spacing_O_m=getattr(_gf.oxidizer, "spacing", float("nan")) if _gf else float("nan"),
9547 spacing_F_m=getattr(_gf.fuel, "spacing", float("nan")) if _gf else float("nan"),
9548 angle_O_deg=getattr(_gf.oxidizer, "impingement_angle", float("nan")) if _gf else float("nan"),
9549 angle_F_deg=getattr(_gf.fuel, "impingement_angle", float("nan")) if _gf else float("nan"),
9550 D_chamber_inner_m=float(getattr(_cg_f, "chamber_diameter", float("nan")))
9551 if _cg_f is not None else float("nan"),
9552 L_chamber_m=_L_cham_f,
9553 )
9554 initial_performance["resultant_tilt_breakeven_deg"] = (
9555 float(_be_final) if np.isfinite(_be_final) else None)
9556 _tilt_allow_final = _resolve_tilt_allowance_deg(
9557 from_reach=layer1_resultant_tilt_from_reach,
9558 constant_deg=layer1_resultant_tilt_max_deg,
9559 breakeven_deg=_be_final,
9560 margin=layer1_resultant_tilt_reach_margin,
9561 )
9562 initial_performance["resultant_tilt_allowed_deg"] = float(_tilt_allow_final)
9563 if np.isfinite(_be_final) and float(_tilt_allow_final) > _be_final:
9564 layer1_logger.warning(
9565 "The permitted outward tilt, %.2f deg, exceeds the break-even tilt for this "
9566 "design's own geometry (%.2f deg): a fan at the allowed limit would reach the "
9567 "liner %.0f mm from the face, inside a %.0f mm chamber. Lower the allowance, or "
9568 "move the element ring inboard.",
9569 float(_tilt_allow_final), _be_final,
9570 1000.0 * _L_cham_f * float(np.tan(np.deg2rad(_be_final)))
9571 / max(1e-9, float(np.tan(np.deg2rad(float(_tilt_allow_final))))),
9572 1000.0 * _L_cham_f,
9573 )
9574 if np.isfinite(_tilt_final):
9575 resultant_tilt_gate_passed = bool(
9576 _tilt_final <= float(_tilt_allow_final)
9577 + float(layer1_resultant_tilt_gate_tol_deg))
9578 initial_performance["resultant_tilt_gate_passed"] = bool(resultant_tilt_gate_passed)
9579
9580 # Every declared geometric limit, re-checked on the emitted design at face value. In the
9581 # search these share one sum-of-squares tolerance (gate_eps), which lets a single 4.5 %
9582 # violation through; sign-off does not.
9583 limit_gates = _layer1_declared_limit_gates(optimized_config, requirements, constants_dict,
9584 performance=initial_performance)
9585 for _gname, (_gok, _gmsg) in limit_gates.items():
9586 initial_performance[f"{_gname}_gate_passed"] = bool(_gok)
9587 if not _gok:
9588 geometry_check_passed = False
9589 geometry_failure_reasons.append(_gmsg)
9590
9591 # The design must close at its OWN tank pressures, evaluated as emitted. A replay that only
9592 # solves with the tanks boosted, or that fell back to an earlier in-loop evaluation, does
9593 # not describe this design.
9594 _sc_v = float(initial_performance.get("layer1_validation_tank_pressure_scale", 1.0) or 1.0)
9595 validation_replay_ok = True
9596 if abs(_sc_v - 1.0) > 1e-9:
9597 validation_replay_ok = False
9598 failure_reasons_replay = (
9599 f"Design does not close at its own tank pressures (needs x{_sc_v:.2f}: "
9600 f"{float(P_O_initial) / psi_to_Pa * _sc_v:.0f}/{float(P_F_initial) / psi_to_Pa * _sc_v:.0f} psi)")
9601 elif initial_performance.get("layer1_validation_used_last_good_bundle"):
9602 validation_replay_ok = False
9603 failure_reasons_replay = (
9604 "Validation replay failed ("
9605 + str(initial_performance.get("layer1_validation_replay_error", "evaluate() raised"))
9606 + "); the numbers shown are an earlier in-loop evaluation")
9607 elif initial_performance.get("layer1_validation_replay_error"):
9608 validation_replay_ok = False
9609 failure_reasons_replay = ("Validation replay failed: "
9610 + str(initial_performance["layer1_validation_replay_error"]))
9611 else:
9612 failure_reasons_replay = ""
9613 initial_performance["validation_replay_ok"] = bool(validation_replay_ok)
9614
9615 # A chamber-pressure target is a requirement: the emitted design must meet it.
9616 pc_gate_passed = True
9617 pc_gate_reason = ""
9618 _pc_t_v = requirements.get("target_chamber_pressure_psi")
9619 _pc_v = float(initial_performance.get("Pc", np.nan))
9620 if _pc_t_v is not None and float(_pc_t_v) > 0:
9621 _pc_miss = abs(_pc_v / (float(_pc_t_v) * 6894.757293168) - 1.0) if np.isfinite(_pc_v) else np.inf
9622 pc_gate_passed = bool(_pc_miss <= _LAYER1_PC_GATE_REL)
9623 if not pc_gate_passed:
9624 pc_gate_reason = (
9625 f"Chamber pressure {_pc_v / 6894.757293168:.1f} psia is {100 * _pc_miss:.2f} % off "
9626 f"the {float(_pc_t_v):.0f} psia target (limit {100 * _LAYER1_PC_GATE_REL:.0f} %)")
9627 initial_performance["pc_gate_passed"] = bool(pc_gate_passed)
9628
9629 pressure_candidate_valid = (
9630 thrust_check_passed
9631 and pc_gate_passed
9632 and of_check_passed
9633 and resultant_tilt_gate_passed
9634 and stability_check_passed
9635 and geometry_check_passed
9636 and dp_gate_passed
9637 and momentum_gate_passed
9638 and validation_replay_ok
9639 )
9640 # Surface truth to API/UI: mid-optimization ``converged`` can latch True from soft thresholds;
9641 # overrides only when final hard gates settle.
9642 opt_state["converged"] = bool(pressure_candidate_valid)
9643
9644 # Build failure reasons
9645 failure_reasons = []
9646 if not validation_replay_ok:
9647 failure_reasons.append(failure_reasons_replay)
9648 if not pc_gate_passed:
9649 failure_reasons.append(pc_gate_reason)
9650 if not thrust_check_passed:
9651 failure_reasons.append(f"Thrust error {initial_thrust_error*100:.1f}% > {thrust_tol_valid*100:.1f}% limit")
9652 if not of_check_passed:
9653 failure_reasons.append(f"O/F error {initial_MR_error*100:.1f}% > {of_tol_valid*100:.1f}% limit")
9654 if not geometry_check_passed:
9655 failure_reasons.extend(geometry_failure_reasons)
9656 if not stability_check_passed:
9657 failure_reasons.append(f"Stability failed: {'; '.join(stability_parts)}")
9658 if not dp_gate_passed:
9659 dp_parts = []
9660 if np.isfinite(ratio_o_val):
9661 dp_parts.append(
9662 f"ΔP_O/Pc in [{layer1_dp_o_band[0]:.3f},{layer1_dp_o_band[1]:.3f}] (got {ratio_o_val:.3f})"
9663 )
9664 if np.isfinite(ratio_f_val):
9665 dp_parts.append(
9666 f"ΔP_F/Pc in [{layer1_dp_f_band[0]:.3f},{layer1_dp_f_band[1]:.3f}] (got {ratio_f_val:.3f})"
9667 )
9668 failure_reasons.append("Injector ΔP ratio failed: " + "; ".join(dp_parts))
9669 if not momentum_gate_passed:
9670 failure_reasons.append(
9671 f"Momentum ratio R not in [{float(momentum_gate_band[0]):.3f},"
9672 f"{float(momentum_gate_band[1]):.3f}] (got {momentum_r_val:.3f})"
9673 )
9674
9675 if not pressure_candidate_valid and not failure_reasons:
9676 failure_reasons.append("Validation failed: no requirements met")
9677
9678 # Log validation
9679 if pressure_candidate_valid:
9680 update_progress("Layer 1: Validation", 0.53, f"✓ VALID - Thrust err: {initial_thrust_error*100:.1f}%, O/F err: {initial_MR_error*100:.1f}%, Stability: {stability_state}")
9681 log_status("Layer 1", f"VALID | Thrust err {initial_thrust_error*100:.1f}%, O/F err {initial_MR_error*100:.1f}%, Stability {stability_state}")
9682 layer1_logger.info("✓ Validation: VALID")
9683 else:
9684 update_progress("Layer 1: Validation", 0.53, f"✗ INVALID - {'; '.join(failure_reasons)}")
9685 log_status("Layer 1", f"INVALID | Reasons: {', '.join(failure_reasons)}")
9686 layer1_logger.warning(f"✗ Validation: INVALID - {'; '.join(failure_reasons)}")
9687
9688 # Build final performance dict
9689 final_performance = initial_performance.copy()
9690 final_performance["pressure_candidate_valid"] = pressure_candidate_valid
9691 final_performance["initial_thrust_error"] = initial_thrust_error
9692 final_performance["initial_MR_error"] = initial_MR_error
9693 final_performance["initial_stability"] = initial_stability
9694 final_performance["initial_stability_state"] = stability_state
9695 final_performance["initial_stability_score"] = stability_score
9696 final_performance["thrust_check_passed"] = thrust_check_passed
9697 final_performance["of_check_passed"] = of_check_passed
9698 final_performance["stability_check_passed"] = stability_check_passed
9699 final_performance["geometry_check_passed"] = geometry_check_passed
9700 final_performance["dp_gate_passed"] = dp_gate_passed
9701 final_performance["momentum_gate_passed"] = momentum_gate_passed
9702 final_performance["injector_dp_ratio_O"] = ratio_o_val if np.isfinite(ratio_o_val) else None
9703 final_performance["injector_dp_ratio_F"] = ratio_f_val if np.isfinite(ratio_f_val) else None
9704 final_performance["layer1_Lstar_search_min_m"] = float(min_Lstar)
9705 final_performance["layer1_Lstar_search_max_m"] = float(max_Lstar)
9706 final_performance["layer1_W_geom_ao_af_momentum_effective"] = float(layer1_W_geom_ao_af)
9707 try:
9708 if isinstance(requirements, dict):
9709 req_used = {
9710 str(k): requirements.get(k) for k in sorted(requirements.keys(), key=str)
9711 }
9712 req_used["W_geom_ao_af_momentum"] = float(layer1_W_geom_ao_af)
9713 final_performance["layer1_requirements_used"] = req_used
9714 else:
9715 final_performance["layer1_requirements_used"] = {}
9716 except Exception:
9717 final_performance["layer1_requirements_used"] = {}
9718 try:
9719 from engine.pipeline.config_schemas import ensure_chamber_geometry as _eg_lstar_diag
9720 _cgd = _eg_lstar_diag(optimized_config)
9721 _atd = float(_cgd.A_throat) if _cgd.A_throat else float("nan")
9722 _vold = float(_cgd.volume) if _cgd.volume is not None else float("nan")
9723 _lscd = float(_cgd.Lstar) if _cgd.Lstar is not None else float("nan")
9724 _dind = float(_cgd.chamber_diameter) if _cgd.chamber_diameter else float("nan")
9725 _acd = float(np.pi * (_dind / 2.0) ** 2) if np.isfinite(_dind) and _dind > 0 else float("nan")
9726 final_performance["layer1_geometry_Lstar_config_m"] = _lscd if np.isfinite(_lscd) else None
9727 final_performance["layer1_geometry_volume_over_At_m"] = (
9728 float(_vold / _atd) if np.isfinite(_vold) and np.isfinite(_atd) and _atd > 0 else None
9729 )
9730 final_performance["layer1_geometry_Ac_over_At"] = (
9731 float(_acd / _atd) if np.isfinite(_acd) and np.isfinite(_atd) and _atd > 0 else None
9732 )
9733 if np.isfinite(_vold) and np.isfinite(_atd) and _atd > 0 and np.isfinite(_lscd):
9734 final_performance["layer1_geometry_V_equals_Lstar_At"] = bool(
9735 np.isclose(_vold, _lscd * _atd, rtol=1e-4, atol=1e-8)
9736 )
9737 except Exception:
9738 pass
9739 if getattr(optimized_config.injector, "type", None) == "impinging":
9740 _diag_fp = final_performance.get("diagnostics", {}) if isinstance(final_performance, dict) else {}
9741 _diag_map = _diag_fp if isinstance(_diag_fp, dict) else {}
9742 try:
9743 _mr_fp_raw = float(final_performance.get("MR", np.nan))
9744 except (TypeError, ValueError):
9745 _mr_fp_raw = float("nan")
9746 _mr_fp = _mr_fp_raw if np.isfinite(_mr_fp_raw) and _mr_fp_raw > 0 else None
9747 _smd_term_fp, _smd_eff_um_fp, _imp_deg_fp = _impinging_smd_penalty_with_angle(
9748 _diag_map,
9749 target_smd_microns=layer1_target_smd_microns,
9750 smd_rel_tol=layer1_smd_rel_tol,
9751 mr_mass=_mr_fp,
9752 )
9753 # When SMD objective is disabled, report physical diagnostic SMD directly.
9754 if layer1_W_SMD <= 0.0:
9755 _do = float("nan")
9756 _df = float("nan")
9757 _v_o = _diag_map.get("D32_O")
9758 if _v_o is not None:
9759 try:
9760 _fv_o = float(_v_o)
9761 except (TypeError, ValueError):
9762 _fv_o = float("nan")
9763 if np.isfinite(_fv_o) and _fv_o > 0:
9764 _do = _fv_o
9765 _v_f = _diag_map.get("D32_F")
9766 if _v_f is not None:
9767 try:
9768 _fv_f = float(_v_f)
9769 except (TypeError, ValueError):
9770 _fv_f = float("nan")
9771 if np.isfinite(_fv_f) and _fv_f > 0:
9772 _df = _fv_f
9773 if np.isfinite(_do) and _do > 0 and np.isfinite(_df) and _df > 0:
9774 _mr_b = _mr_fp if _mr_fp is not None else 3.0
9775 _w_o = float(_mr_b / (1.0 + _mr_b))
9776 _w_f = float(1.0 / (1.0 + _mr_b))
9777 _smd_eff_um_fp = float((_w_o * _do + _w_f * _df) * 1e6)
9778 elif np.isfinite(_do) and _do > 0:
9779 _smd_eff_um_fp = float(_do * 1e6)
9780 elif np.isfinite(_df) and _df > 0:
9781 _smd_eff_um_fp = float(_df * 1e6)
9782 final_performance["effective_smd_microns"] = _smd_eff_um_fp if np.isfinite(_smd_eff_um_fp) else None
9783 final_performance["impingement_angle_deg_effective"] = _imp_deg_fp if np.isfinite(_imp_deg_fp) else None
9784 final_performance["smd_penalty"] = float(layer1_W_SMD * _smd_term_fp)
9785 for _gk in ("L_imp", "D_pitch_O", "D_pitch_F", "element_gap_O", "element_gap_F",
9786 "vaporization_length_total"):
9787 _gv = _diag_map.get(_gk)
9788 try:
9789 _gvf = float(_gv) if _gv is not None else float("nan")
9790 except (TypeError, ValueError):
9791 _gvf = float("nan")
9792 final_performance[_gk] = _gvf if np.isfinite(_gvf) else None
9793 final_performance["failure_reasons"] = failure_reasons
9794 final_performance["layer1_warnings"] = list(layer1_warnings)
9795 # Add individual stability margins at root level for easy access
9796 final_performance["chugging_margin"] = chugging_margin
9797 final_performance["acoustic_margin"] = acoustic_margin
9798 final_performance["feed_margin"] = feed_margin
9799
9800 # Tank pressures consistent with validated runner config (avoid ``best_x`` / snapshot mismatch).
9801 try:
9802 P_O_start_optimized_psi = float(optimized_config.lox_tank.initial_pressure_psi)
9803 P_F_start_optimized_psi = float(optimized_config.fuel_tank.initial_pressure_psi)
9804 final_performance["P_O_start_psi"] = P_O_start_optimized_psi
9805 final_performance["P_F_start_psi"] = P_F_start_optimized_psi
9806 final_performance["P_O_start_ratio"] = P_O_start_optimized_psi / max_lox_P_psi if max_lox_P_psi > 0 else 0.0
9807 final_performance["P_F_start_ratio"] = P_F_start_optimized_psi / max_fuel_P_psi if max_fuel_P_psi > 0 else 0.0
9808 except Exception:
9809 if best_x is not None and len(best_x) > idx_P_F:
9810 P_O_start_optimized_psi = float(np.clip(best_x[idx_P_O], bounds[idx_P_O][0], bounds[idx_P_O][1]))
9811 P_F_start_optimized_psi = float(np.clip(best_x[idx_P_F], bounds[idx_P_F][0], bounds[idx_P_F][1]))
9812 final_performance["P_O_start_psi"] = P_O_start_optimized_psi
9813 final_performance["P_F_start_psi"] = P_F_start_optimized_psi
9814 final_performance["P_O_start_ratio"] = P_O_start_optimized_psi / max_lox_P_psi if max_lox_P_psi > 0 else 0.0
9815 final_performance["P_F_start_ratio"] = P_F_start_optimized_psi / max_fuel_P_psi if max_fuel_P_psi > 0 else 0.0
9816 else:
9817 final_performance["P_O_start_psi"] = max_lox_P_psi * 0.8
9818 final_performance["P_F_start_psi"] = max_fuel_P_psi * 0.8
9819 final_performance["P_O_start_ratio"] = 0.8
9820 final_performance["P_F_start_ratio"] = 0.8
9821
9822 # Add chamber_intrinsics from initial_performance (contains is_choked, etc.)
9823 if "chamber_intrinsics" in initial_performance:
9824 final_performance["chamber_intrinsics"] = initial_performance["chamber_intrinsics"]
9825
9826 # Calculate and add effective_injector_area_ratio
9827 # This is the ratio of (effective injector area) / (throat area)
9828 # Effective injector area = max(A_lox_injector, A_fuel_injector) with Cd weighting
9829 try:
9830 from engine.pipeline.config_schemas import ensure_chamber_geometry
9831 cg = ensure_chamber_geometry(optimized_config)
9832 A_throat_final = float(cg.A_throat) if cg.A_throat and cg.A_throat > 0 else None
9833
9834 inj = getattr(optimized_config, 'injector', None)
9835 inj_geom = getattr(inj, 'geometry', None) if inj else None
9836 is_pintle = getattr(inj, 'type', None) == 'pintle' if inj else False
9837 is_impinging = getattr(inj, 'type', None) == 'impinging' if inj else False
9838
9839 if is_impinging and inj_geom and A_throat_final:
9840 ox = getattr(inj_geom, 'oxidizer', None)
9841 fu = getattr(inj_geom, 'fuel', None)
9842 if ox and fu:
9843 n_o = float(getattr(ox, 'n_elements', 0))
9844 d_o = float(getattr(ox, 'd_jet', 0))
9845 n_f = float(getattr(fu, 'n_elements', 0))
9846 d_f = float(getattr(fu, 'd_jet', 0))
9847 A_lox_g = n_o * np.pi * (d_o / 2.0) ** 2 if d_o > 0 else 0.0
9848 A_fuel_g = n_f * np.pi * (d_f / 2.0) ** 2 if d_f > 0 else 0.0
9849 diag_ev = initial_performance.get("diagnostics") or {}
9850 cd_o = diag_ev.get("Cd_O")
9851 cd_f = diag_ev.get("Cd_F")
9852 if (
9853 cd_o is not None and np.isfinite(cd_o) and float(cd_o) > 0
9854 and cd_f is not None and np.isfinite(cd_f) and float(cd_f) > 0
9855 ):
9856 A_eff = float(cd_o) * A_lox_g + float(cd_f) * A_fuel_g
9857 else:
9858 try:
9859 from engine.core.discharge import cd_inf_from_orifice_diameter
9860
9861 oxg = getattr(inj_geom, "oxidizer", None)
9862 fug = getattr(inj_geom, "fuel", None)
9863 d_o = float(getattr(oxg, "d_jet", 0.002) or 0.002)
9864 d_f = float(getattr(fug, "d_jet", 0.002) or 0.002)
9865 Cd_lox = cd_inf_from_orifice_diameter(
9866 d_o, optimized_config.discharge["oxidizer"]
9867 )
9868 Cd_fu = cd_inf_from_orifice_diameter(
9869 d_f, optimized_config.discharge["fuel"]
9870 )
9871 except Exception:
9872 Cd_lox, Cd_fu = 0.6, 0.6
9873 A_eff = Cd_lox * A_lox_g + Cd_fu * A_fuel_g
9874 final_performance["effective_injector_area_ratio"] = float(A_eff / A_throat_final)
9875
9876 if is_pintle and inj_geom and A_throat_final:
9877 # LOX injector area (discrete orifices)
9878 lox_geom = getattr(inj_geom, 'lox', None)
9879 fuel_geom = getattr(inj_geom, 'fuel', None)
9880
9881 if lox_geom and fuel_geom:
9882 n_orifices = float(getattr(lox_geom, 'n_orifices', 0))
9883 d_orifice = float(getattr(lox_geom, 'd_orifice', 0))
9884 d_pintle_tip = float(getattr(fuel_geom, 'd_pintle_tip', 0))
9885 h_gap = float(getattr(fuel_geom, 'h_gap', 0))
9886 Cd_lox = float(getattr(lox_geom, 'Cd', 0.65))
9887 Cd_fuel = float(getattr(fuel_geom, 'Cd', 0.4))
9888
9889 A_lox_injector = n_orifices * np.pi * (d_orifice / 2) ** 2 if d_orifice > 0 else 0.0
9890 R_inner = d_pintle_tip / 2
9891 R_outer = R_inner + h_gap
9892 A_fuel_injector = np.pi * (R_outer ** 2 - R_inner ** 2) if h_gap > 0 else 0.0
9893
9894 # Effective areas (Cd-weighted geometric areas)
9895 A_lox_effective = Cd_lox * A_lox_injector
9896 A_fuel_effective = Cd_fuel * A_fuel_injector
9897
9898 # Total effective injector area (sum of both propellants)
9899 A_injector_total_effective = A_lox_effective + A_fuel_effective
9900
9901 # Ratio of total effective injector area to throat area
9902 effective_injector_area_ratio = A_injector_total_effective / A_throat_final
9903 final_performance["effective_injector_area_ratio"] = float(effective_injector_area_ratio)
9904 except Exception as e:
9905 layer1_logger.debug(f"Could not calculate effective_injector_area_ratio: {e}")
9906
9908 final_performance,
9909 target_thrust=target_thrust,
9910 optimal_of=optimal_of,
9911 target_P_exit=target_P_exit,
9912 )
9913 # Build results dict
9914 results = {
9915 # Top-level injector_type tag so the UI dispatches result rendering to the right param set
9916 # (INJECTOR_PARITY_PLAN W4). optimized_parameters already carries type-appropriate keys.
9917 "injector_type": getattr(getattr(optimized_config, "injector", None), "type", None),
9918 "performance": final_performance,
9919 "iteration_history": iteration_history,
9920 "convergence_info": {
9921 "converged": opt_state["converged"],
9922 "iterations": len(iteration_history),
9923 "final_change": opt_state["best_objective"],
9924 "best_objective": float(opt_state.get("best_objective", float("inf"))),
9925 "best_objective_breakdown": (
9926 dict(opt_state.get("best_objective_breakdown", {}))
9927 if isinstance(opt_state.get("best_objective_breakdown", {}), dict)
9928 else {}
9929 ),
9930 "primary_relative_residual": _prim_rel,
9931 "infeasible_reason": infeasible_reason,
9932 },
9933 "exit_pressure_targeting": {
9934 "target_P_exit": target_P_exit, # Atmospheric pressure from environment config (GPS/GFS-derived)
9935 },
9936 "optimized_pressure_curves": {
9937 "lox_end_ratio": final_lox_end_ratio,
9938 "fuel_end_ratio": final_fuel_end_ratio,
9939 "lox_start_psi": final_performance["P_O_start_psi"],
9940 "fuel_start_psi": final_performance["P_F_start_psi"],
9941 },
9942 "layer_status": {
9943 "layer_1_pressure_candidate": pressure_candidate_valid,
9944 },
9945 "optimized_parameters": extract_all_parameters(optimized_config),
9946 "final_rebuild_diag": opt_state.get("final_rebuild_diag"),
9947 }
9948
9949 # Final summary logging
9950 layer1_logger.info("")
9951 layer1_logger.info("="*70)
9952 layer1_logger.info("Final Results Summary")
9953 layer1_logger.info("="*70)
9954 if "best_results_for_validation" in opt_state and opt_state["best_results_for_validation"] is not None:
9955 stored = opt_state["best_results_for_validation"]
9956 layer1_logger.info(f"Thrust: {stored.get('F', 0):.1f} N (target: {target_thrust:.1f} N)")
9957 layer1_logger.info(f"Thrust error: {stored.get('thrust_error', 0)*100:.2f}%")
9958 layer1_logger.info(f"O/F ratio: {stored.get('MR', 0):.3f} (target: {optimal_of:.3f})")
9959 layer1_logger.info(f"O/F error: {stored.get('of_error', 0)*100:.2f}%")
9960 layer1_logger.info(f"Stability state: {stored.get('stability_state', 'unknown')}")
9961 layer1_logger.info(f"Stability score: {stored.get('stability_score', 0):.3f}")
9962 layer1_logger.info(f"Chugging margin: {stored.get('chugging_margin', 0):.3f}")
9963 layer1_logger.info(f"Acoustic margin: {stored.get('acoustic_margin', 0):.3f}")
9964 layer1_logger.info(f"Feed margin: {stored.get('feed_margin', 0):.3f}")
9965 if final_performance.get("P_O_start_psi") is not None:
9966 layer1_logger.info(f"LOX initial pressure: {final_performance['P_O_start_psi']:.1f} psi")
9967 if final_performance.get("P_F_start_psi") is not None:
9968 layer1_logger.info(f"Fuel initial pressure: {final_performance['P_F_start_psi']:.1f} psi")
9969 _idr_o = final_performance.get("injector_dp_ratio_O")
9970 _idr_f = final_performance.get("injector_dp_ratio_F")
9971 _idp = final_performance.get("injector_dp_penalty")
9972 if _idr_o is not None and np.isfinite(_idr_o):
9973 layer1_logger.info(f"Injector ΔP/Pc (LOX): {_idr_o:.4f}")
9974 if _idr_f is not None and np.isfinite(_idr_f):
9975 layer1_logger.info(f"Injector ΔP/Pc (fuel): {_idr_f:.4f}")
9976 if _idp is not None and np.isfinite(_idp):
9977 layer1_logger.info(f"Injector ΔP ratio penalty (weighted): {float(_idp):.6g}")
9978 if (
9979 getattr(getattr(optimized_config, "injector", None), "type", None) == "impinging"
9980 and final_performance.get("momentum_ratio_R") is not None
9981 ):
9982 layer1_logger.info(
9983 f"Impinging jet momentum ratio R: {final_performance['momentum_ratio_R']:.3f} "
9984 f"(sqrt momentum-flux ratio; target ~1.0)"
9985 )
9986 mbp = final_performance.get("momentum_balance_penalty")
9987 if mbp is not None and np.isfinite(mbp):
9988 layer1_logger.info(
9989 f"Impinging momentum-balance penalty (W_MOM × hinge): {float(mbp):.6g}"
9990 )
9991 layer1_logger.info(f"Validation: {'VALID' if pressure_candidate_valid else 'INVALID'}")
9992
9994 final_performance,
9995 thrust_deadband_rel=_layer1_thrust_deadband(requirements),
9996 target_thrust=target_thrust,
9997 optimal_of=optimal_of,
9998 target_P_exit=target_P_exit,
9999 layer1_exit_pressure_inside_quad_scale=layer1_exit_pressure_inside_quad,
10000 layer1_w_dp=layer1_W_DP,
10001 layer1_w_dp_o=layer1_W_DP_O,
10002 layer1_w_dp_f=layer1_W_DP_F,
10003 injector_dp_o_band=layer1_dp_o_band,
10004 injector_dp_f_band=layer1_dp_f_band,
10005 injector_dp_o_soft_floor=layer1_dp_o_soft_floor,
10006 layer1_w_dp_o_floor=layer1_W_DP_O_FLOOR,
10007 layer1_w_dp_center=layer1_W_DP_CENTER,
10008 layer1_w_thrust=layer1_W_THRUST_obj,
10009 layer1_w_of=layer1_W_OF_obj,
10010 layer1_w_of_low_mr_scale=layer1_W_OF_low_MR_scale,
10011 layer1_w_of_high_mr_scale=layer1_W_OF_high_MR_scale,
10012 )
10013 layer1_logger.info("")
10014 layer1_logger.info(
10015 "Final evaluate — primary objective penalty contributions "
10016 "(W_THRUST/W_OF/W_EXIT × terms + injector_dp weighted; same structure as scalar objective)"
10017 )
10018 layer1_logger.info(
10019 f" • Thrust penalty contribution: {_primary_terms['thrust_penalty_contribution']:.6f}"
10020 )
10021 layer1_logger.info(
10022 f" • O/F penalty contribution: {_primary_terms['of_penalty_contribution']:.6f}"
10023 )
10024 layer1_logger.info(
10025 f" • Exit pressure penalty contribution: {_primary_terms['exit_pressure_penalty_contribution']:.6f}"
10026 )
10027 layer1_logger.info(
10028 f" • Injector ΔP penalty contribution: {_primary_terms['injector_dp_penalty_contribution']:.6f}"
10029 )
10030 layer1_logger.info(
10031 " • Primary relative |ΔF|/F, |ΔMR|/MR, |ΔP_exit|/P_tgt: "
10032 f"{_prim_rel['rel_thrust']:.4g}, {_prim_rel['rel_of']:.4g}, {_prim_rel['rel_P_exit']:.4g} "
10033 f"(RMS={_prim_rel['rms_primary']:.4g}, dimensionless; not the weighted scalar)"
10034 )
10035 if final_performance.get("injector_dp_out_of_range"):
10036 layer1_logger.warning(
10037 " injector_dp_out_of_range: True (one or both ΔP_inj/Pc streams exceed 0.50)"
10038 )
10039
10040 # Log objective function breakdown if objective > 1e0 (indicates non-ideal convergence)
10041 best_obj_value = opt_state.get("best_objective", float('inf'))
10042 if best_obj_value > 1.0:
10043 layer1_logger.info("")
10044 layer1_logger.info("-"*70)
10045 layer1_logger.info(f"⚠ Objective Function Breakdown (objective = {best_obj_value:.6f} > 1.0)")
10046 layer1_logger.info("-"*70)
10047 breakdown = opt_state.get("best_objective_breakdown", {})
10048 if breakdown:
10049 if breakdown.get("is_infeasible", False):
10050 layer1_logger.info("Solution is INFEASIBLE:")
10051 if breakdown.get("length_violation", False):
10052 layer1_logger.info(f" • Chamber length violation penalty: {breakdown.get('infeasibility_penalty', 0):.6f}")
10053 else:
10054 layer1_logger.info(f" • Infeasibility penalty (constraints violated): {breakdown.get('infeasibility_penalty', 0):.6f}")
10055 else:
10056 layer1_logger.info("Objective component contributions:")
10057 components = [
10058 ("Thrust penalty", breakdown.get("thrust_penalty", 0)),
10059 ("O/F ratio penalty", breakdown.get("of_penalty", 0)),
10060 ("Cf band penalty", breakdown.get("cf_penalty", 0)),
10061 ("Exit pressure penalty", breakdown.get("exit_pressure_penalty", 0)),
10062 ("Injector ΔP penalty", breakdown.get("injector_dp_penalty", 0)),
10063 ("Chamber length penalty", breakdown.get("length_penalty", 0)),
10064 ("Chamber shape penalty", breakdown.get("chamber_shape_penalty", 0)),
10065 ("Momentum balance penalty", breakdown.get("momentum_balance_penalty", 0)),
10066 ("Impingement angle penalty", breakdown.get("impingement_angle_penalty", 0)),
10067 ("Jet asymmetry penalty", breakdown.get("jet_angle_asymmetry_penalty", 0)),
10068 ("SMD penalty", breakdown.get("smd_penalty", 0)),
10069 ("Geometry fit penalty", breakdown.get("impinging_geometry_fit_penalty", 0)),
10070 ("AO/AF momentum hint", breakdown.get("geom_ao_af_momentum_penalty", 0)),
10071 ("Tank equal penalty", breakdown.get("tank_pressure_equal_penalty", 0)),
10072 ]
10073 # Sort by value descending
10074 components.sort(key=lambda x: x[1], reverse=True)
10075 total = sum(c[1] for c in components)
10076 for name, value in components:
10077 if value > 0 or total > 0:
10078 pct = (value / total * 100) if total > 0 else 0.0
10079 layer1_logger.info(f" • {name}: {value:.6f} ({pct:.1f}%)")
10080 layer1_logger.info(f" Total: {total:.6f}")
10081 else:
10082 layer1_logger.info(" (component breakdown not available)")
10083 layer1_logger.info("-"*70)
10084
10085 layer1_logger.info("="*70)
10086 layer1_logger.info("")
10087 layer1_logger.info(f"Layer 1 optimization complete. Log saved to: {log_file_path}")
10088
10089 # Clean up handler to prevent file handle issues
10090 layer1_logger.handlers.clear()
10091
10092 update_progress("Layer 1: Complete", 1.0, "Layer 1 optimization complete!")
10093
10094 # Stamp the ACHIEVED operating point onto the config we are about to hand back, so
10095 # chamber_geometry.design_* describes this engine rather than whatever template it came from.
10096 _layer1_stamp_design_point(optimized_config, final_performance, layer1_logger)
10097 _layer1_warn_stale_pressure_curves(optimized_config, layer1_logger)
10098
10099 return optimized_config, results
10100
10101
10103 """Manages a pool of top-K elite solutions."""
10104 def __init__(self, k: int = 50):
10105 self.k = k
10106 self.points = [] # List of tuples (f_val, x_array)
10107
10108 def add(self, x: np.ndarray, f: float):
10109 """Add a candidate to the pool if it's good enough."""
10110 # Check for duplicates (optional, simple distance check or exact match)
10111 # For simplicity in this iteration, just naive add & sort
10112 self.points.append((f, np.asarray(x, dtype=float).copy()))
10113 # Sort by f (ascending, minimization)
10114 self.points.sort(key=lambda item: item[0])
10115 # Keep top k
10116 if len(self.points) > self.k:
10117 self.points = self.points[:self.k]
10118
10119 def get_elites(self) -> Tuple[np.ndarray, np.ndarray]:
10120 """Return (X, F) arrays of current elites."""
10121 if not self.points:
10122 return np.array([]), np.array([])
10123 f_vals = np.array([p[0] for p in self.points])
10124 x_vals = np.array([p[1] for p in self.points])
10125 return x_vals, f_vals
10126
10127 def get_best(self) -> Tuple[np.ndarray, float]:
10128 """Return best solution found so far."""
10129 if not self.points:
10130 return None, float('inf')
10131 return self.points[0][1], self.points[0][0]
10132
10133
10135 objective_fn: Callable[[np.ndarray], float],
10136 x0: np.ndarray,
10137 sigma0: float,
10138 bounds: list,
10139 budget: int,
10140 popsize: int,
10141 cma_stds: np.ndarray = None,
10142 seed: int = None,
10143 elite_pool: Optional[ElitePool] = None,
10144 # Optional logic for penalty/validity
10145 true_objective_fn: Optional[Callable[[np.ndarray], float]] = None,
10146 valley_escape_tracker: Optional[Dict[str, Any]] = None,
10147 logger = None,
10148 # Parallel evaluation support
10149 executor: Optional[ProcessPoolExecutor] = None,
10150 integer_dims: Optional[list] = None,
10151 od_index: int = -1,
10152 od_step_m: float = 0.0,
10153 fixed_variables: Optional[Dict[int, float]] = None,
10154 eval_cache: Optional[dict] = None,
10155 make_cache_key_fn: Optional[Callable[[np.ndarray], Tuple[int, ...]]] = None,
10156 stop_event: Optional[Any] = None, # threading.Event for stop signal
10157 embed_base: Optional[np.ndarray] = None,
10158 embed_idx: Optional[List[int]] = None,
10159 full_objective_fn: Optional[Callable[[np.ndarray], float]] = None,
10160) -> Tuple[np.ndarray, float, int]:
10161 """
10162 Core re-usable CMA-ES wrapper with optional parallel evaluation.
10163
10164 ``embed_base``/``embed_idx``: the search is over a sub-vector (a block); each candidate is
10165 written into a copy of ``embed_base`` at ``embed_idx`` and the FULL vector is scored by the
10166 worker pool, so a block ranks on the same objective as Stage A. ``full_objective_fn`` is
10167 the full-vector parent objective used for best-tracking sync.
10168
10169 Args:
10170 objective_fn: The function CMA-ES optimizes (could include penalties).
10171 x0: Initial mean.
10172 sigma0: Initial step size.
10173 bounds: List of (min, max) for EACH dimension.
10174 budget: Max function evaluations.
10175 popsize: Population size.
10176 cma_stds: Coordinate-wise standard deviations (optional scaling).
10177 seed: Random seed.
10178 elite_pool: Optional pool to capture all candidates evaluated.
10179 true_objective_fn: If provided, used to evaluate candidates for ElitePool
10180 and best-so-far tracking (ignoring the penalized objective).
10181 If None, objective_fn is used.
10182 executor: Optional ProcessPoolExecutor for parallel evaluation.
10183 integer_dims: List of integer dimension indices for snapping.
10184 eval_cache: Optional evaluation cache dict.
10185
10186 Returns:
10187 (best_x, best_f, evals_used)
10188 Note: best_f returned is the one derived from true_objective_fn if present,
10189 otherwise objective_fn.
10190 """
10191 if cma is None:
10192 raise ImportError("CMA-ES required")
10193
10194 # Prepare bounds for CMA
10195 lower_bounds = np.array([b[0] for b in bounds])
10196 upper_bounds = np.array([b[1] for b in bounds])
10197
10198 # Options
10199 opts = {
10200 "bounds": [lower_bounds.tolist(), upper_bounds.tolist()],
10201 "popsize": popsize,
10202 "maxiter": budget, # Rough upper bound, we track evals manually better
10203 "verb_disp": 0,
10204 "verb_log": 0,
10205 "tolx": 1e-8,
10206 "tolfun": 1e-9,
10207 "tolstagnation": 50,
10208 "ftarget": -np.inf,
10209 "seed": seed,
10210 }
10211 if cma_stds is not None:
10212 opts["CMA_stds"] = cma_stds.tolist()
10213 # Discrete DOFs: integer centering + a std floor so a coordinate cannot collapse
10214 # inside one cell and freeze. See _layer1_cma_discrete_options.
10215 _int_vars, _minstd = _layer1_cma_discrete_options(
10216 len(x0), integer_dims, od_index=od_index, od_step_m=od_step_m, bounds=bounds)
10217 if _int_vars:
10218 opts["integer_variables"] = _int_vars
10219 if any(v > 0.0 for v in _minstd):
10220 opts["minstd"] = _minstd
10221 # Derived dims: keep FULL bounds (so no clip anywhere can revert the solved value) and
10222 # simply shrink CMA's step so it spends no meaningful search effort on them. cma's own
10223 # ``fixed_variables`` would be the natural tool, but it re-indexes the search space and
10224 # then collides with ``integer_variables``, whose indices are in the full space.
10225 if fixed_variables and cma_stds is not None and len(x0) == len(bounds):
10226 _st = list(opts.get("CMA_stds") or cma_stds.tolist())
10227 for _k in fixed_variables:
10228 _k = int(_k)
10229 if 0 <= _k < len(_st):
10230 _st[_k] = max(1e-12, float(_st[_k]) * 1e-6)
10231 opts["CMA_stds"] = _st
10232
10233 # Initialize
10234 # Ensure x0 is within bounds
10235 x0_clamped = np.clip(x0, lower_bounds, upper_bounds)
10236
10237 evals = 0
10238 best_x = x0_clamped.copy()
10239
10240 # Initial eval (through the pool when embedded, so every value compared below is scored
10241 # the same way)
10242 if executor is not None and embed_base is not None and embed_idx is not None:
10243 _full0 = np.asarray(embed_base, dtype=np.float64).copy()
10244 _full0[list(embed_idx)] = _snap_integer_dims(best_x.astype(np.float64), integer_dims or [])
10245 best_f = float(list(executor.map(_eval_candidate, [_full0]))[0].get("value", float("inf")))
10246 elif true_objective_fn:
10247 best_f = true_objective_fn(best_x)
10248 else:
10249 best_f = objective_fn(best_x)
10250
10251 # CMA object
10252 # CMA expects list for x0
10253 es = cma.CMAEvolutionStrategy(x0_clamped.tolist(), sigma0, opts)
10254
10255 stop_loop = False
10256
10257 while not es.stop() and not stop_loop:
10258 # Check if optimization should stop
10259 if stop_event is not None and stop_event.is_set():
10260 raise RuntimeError("Optimization stopped by user")
10261
10262 if evals >= budget:
10263 break
10264
10265 # Check for Valley Escape boost if tracker provided
10266 if valley_escape_tracker is not None:
10267 evals_now = valley_escape_tracker.get("function_evaluations", 0)
10268 best_f_tracker = valley_escape_tracker.get("best_objective", float('inf'))
10269 last_best = valley_escape_tracker.get("last_best_eval", 0)
10270 stagnation = evals_now - last_best
10271 current_tier = valley_escape_tracker.get("valley_escape_tier", 0)
10272 cooldown = valley_escape_tracker.get("cooldown_until", 0)
10273
10274 target_tier = _layer1_valley_escape_tier(evals_now, best_f_tracker, stagnation)
10275
10276 if target_tier > current_tier and evals_now > cooldown:
10277 # Apply boost
10278 boost_factors = [1.0, 2.0, 4.0, 8.0]
10279 relative_boost = boost_factors[target_tier] / boost_factors[current_tier]
10280
10281 old_sigma = es.sigma
10282 es.sigma *= relative_boost
10283
10284 # Clamp sigma to 50% of the entire span (much looser)
10285 # We want to allow large jumps if needed to escape deep local minima
10286 span_vals = upper_bounds - lower_bounds
10287 max_sigma = 0.5 * float(np.max(span_vals))
10288 if es.sigma > max_sigma:
10289 es.sigma = max_sigma
10290
10291 # Update tracker
10292 valley_escape_tracker["valley_escape_tier"] = target_tier
10293 valley_escape_tracker["cooldown_until"] = evals_now + 1000
10294
10295 if logger:
10296 tier_names = ["None", "Mild", "Medium", "Full"]
10297 logger.info("!" * 20)
10298 logger.info(f"VALLEY ESCAPE TRIGGERED in run_cma_core (Tier: {tier_names[target_tier]})")
10299 logger.info(f"Evals: {evals_now}, Best: {best_f_tracker:.2f}, Stagnation: {stagnation}")
10300 logger.info(f"Sigma: {old_sigma:.6f} -> {es.sigma:.6f} (Boost: {relative_boost:.2f}x)")
10301 logger.info("!" * 20)
10302
10303 candidates = es.ask()
10304
10305 # PARALLEL EVALUATION PATH
10306 if executor is not None and integer_dims is not None:
10307 # Snap integer dimensions for consistency
10308 candidates_snapped = [
10309 _snap_integer_dims(np.asarray(c, dtype=np.float64), integer_dims)
10310 for c in candidates
10311 ]
10312 if embed_base is not None and embed_idx is not None:
10313 _sub = candidates_snapped
10314 candidates_snapped = []
10315 for c in _sub:
10316 _full = np.asarray(embed_base, dtype=np.float64).copy()
10317 _full[list(embed_idx)] = c
10318 candidates_snapped.append(_full)
10319
10320 def _out(v):
10321 # What CMA and the caller see: the block sub-vector when embedded.
10322 v = np.asarray(v, dtype=float)
10323 return v[list(embed_idx)].copy() if (embed_base is not None and embed_idx is not None) else v
10324
10325 # Parent-side caching if cache provided
10326 if eval_cache is not None and make_cache_key_fn is not None:
10327 cache_keys = [make_cache_key_fn(c) for c in candidates_snapped]
10328 uncached_indices = []
10329 uncached_candidates = []
10330 fitness_values = [None] * len(candidates)
10331
10332 for i, key in enumerate(cache_keys):
10333 if key in eval_cache:
10334 cached_val = eval_cache[key]
10335 # Handle dict format from parallel eval (has 'value' key)
10336 # Note: dict format from objective() has 'results' but not 'value', so we can't use it here
10337 if isinstance(cached_val, dict) and 'value' in cached_val:
10338 fitness_values[i] = float(cached_val['value'])
10339 elif isinstance(cached_val, (int, float)):
10340 # Old format: direct float value (backward compatibility)
10341 fitness_values[i] = float(cached_val)
10342 else:
10343 # Full format from objective() or unknown format - can't extract value, treat as uncached
10344 uncached_indices.append(i)
10345 uncached_candidates.append(candidates_snapped[i])
10346 else:
10347 uncached_indices.append(i)
10348 uncached_candidates.append(candidates_snapped[i])
10349
10350 # Parallel evaluation of uncached candidates
10351 if uncached_candidates:
10352 num_workers = executor._max_workers
10353 chunksize = max(1, len(uncached_candidates) // (num_workers * 4))
10354 results = list(executor.map(_eval_candidate, uncached_candidates, chunksize=chunksize))
10355
10356 # Merge results back
10357 for idx, res in zip(uncached_indices, results):
10358 obj_val = res['value']
10359 fitness_values[idx] = obj_val
10360 # Store in dict format for compatibility with objective() cache format
10361 eval_cache[cache_keys[idx]] = {
10362 'value': obj_val,
10363 'success': res.get('success', True),
10364 }
10365 evals += 1
10366 _store_last_good_eval_bundle_from_worker_res(res, valley_escape_tracker)
10367
10368 # Update best and elite pool using true objective
10369 if res['success']:
10370 # For hybrid mode, we use the same objective for both
10371 # (true_objective_fn is typically None in hybrid mode)
10372 if obj_val < best_f:
10373 best_f = obj_val
10374 # The SOLVED vector, not the proposal. The derived DOFs
10375 # (A_throat, expansion ratio, fuel ring pitch) sit on
10376 # collapsed bounds, so what CMA proposed for them is the
10377 # seed -- the values that were actually SCORED come back in
10378 # x_solved. Recording the proposal meant the reported design
10379 # carried the seed throat: measured, A_throat was handed in
10380 # at 1568 mm2 every time, solved across 1190-4000 mm2, and
10381 # the final design still reported ~1556 mm2.
10382 best_x = _out(_x_solved_or(res, candidates_snapped[idx]))
10383
10384 if elite_pool:
10385 elite_pool.add(candidates_snapped[idx], obj_val)
10386
10387 if evals >= budget:
10388 stop_loop = True
10389
10390 # Count cached evals
10391 for i, val in enumerate(fitness_values):
10392 if val is not None and i not in uncached_indices:
10393 evals += 1 # Count cache hits as evals for budget tracking
10394 else:
10395 # No caching - evaluate all in parallel
10396 num_workers = executor._max_workers
10397 chunksize = max(1, len(candidates_snapped) // (num_workers * 4))
10398 results = list(executor.map(_eval_candidate, candidates_snapped, chunksize=chunksize))
10399
10400 fitness_values = []
10401 for i, res in enumerate(results):
10402 obj_val = res['value']
10403 fitness_values.append(obj_val)
10404 evals += 1
10405 _store_last_good_eval_bundle_from_worker_res(res, valley_escape_tracker)
10406
10407 if res['success']:
10408 if obj_val < best_f:
10409 best_f = obj_val
10410 best_x = _out(_x_solved_or(res, candidates_snapped[i]))
10411
10412 if elite_pool:
10413 elite_pool.add(candidates_snapped[i], obj_val)
10414
10415 if evals >= budget:
10416 stop_loop = True
10417
10418 # Parent-side sync: parallel workers only return scalars; record rich history on new global best.
10419 if valley_escape_tracker is not None and fitness_values:
10420 _fv = [
10421 float(v) if v is not None and np.isfinite(float(v)) else float("inf")
10422 for v in fitness_values
10423 ]
10424 if _fv and min(_fv) < float("inf"):
10425 _gen_best = float(min(_fv))
10426 _gi = int(np.argmin(np.asarray(_fv, dtype=float)))
10428 valley_escape_tracker,
10429 candidates_snapped[_gi],
10430 _gen_best,
10431 parent_objective=(full_objective_fn if full_objective_fn is not None
10432 else objective_fn),
10433 )
10434 else:
10435 # SEQUENTIAL EVALUATION PATH (original code)
10436 fitness_values = []
10437
10438 for cand in candidates:
10439 # Clip candidate for evaluation
10440 cand_arr = np.clip(np.asarray(cand, dtype=float), lower_bounds, upper_bounds)
10441
10442 # 1. Penalized objective (for CMA ranking)
10443 val_penalized = objective_fn(cand_arr)
10444 fitness_values.append(val_penalized)
10445 evals += 1
10446
10447 # 2. True objective (for elites / best tracking)
10448 if true_objective_fn:
10449 val_true = true_objective_fn(cand_arr)
10450 else:
10451 val_true = val_penalized
10452
10453 # Update local best using TRUE value
10454 if val_true < best_f:
10455 best_f = val_true
10456 best_x = cand_arr.copy()
10457
10458 # Add to elite pool
10459 if elite_pool:
10460 elite_pool.add(cand_arr, val_true)
10461
10462 if evals >= budget:
10463 stop_loop = True
10464
10465 es.tell(candidates, fitness_values)
10466
10467 return best_x, best_f, evals
10468
10469
10470
10472 x: np.ndarray,
10473 f: float,
10474 bounds: list,
10475 executor: Any,
10476 integer_dims: list,
10477 iso_area: Tuple[int, Tuple[int, ...], Tuple[int, ...]],
10478 logger=None,
10479 eval_cache: Optional[dict] = None,
10480 make_cache_key_fn: Optional[Callable[[np.ndarray], Any]] = None,
10481) -> Tuple[np.ndarray, float, int]:
10482 """Exhaustive search over the element count along the iso-area path.
10483
10484 ``iso_area = (i_n, (i_d, ...), (i_s, ...))``: moving the count n -> n' at fixed jet
10485 diameters changes the injector flow area by n'/n, so every neighbouring count is a
10486 different injector (dP, O/F, Pc all move) and CMA, stepping one integer at a time, rarely
10487 crosses between them -- four seeds ended at n = 5, 5, 7 and 13 with objectives 20 % apart.
10488 Scaling d by sqrt(n/n') and the hole pitch by n/n' keeps the flow areas and both pitch
10489 circles, so each count is scored as the same injector re-divided. Every reachable count
10490 is evaluated through the pool (one parallel batch per round); the best improvement is
10491 taken and the scan repeats from it.
10492 """
10493 i_n, i_d, i_s = iso_area
10494 lo_n = int(np.ceil(float(bounds[i_n][0]) - 1e-9))
10495 hi_n = int(np.floor(float(bounds[i_n][1]) + 1e-9))
10496 if executor is None or hi_n <= lo_n:
10497 return x, f, 0
10498 best_x = np.asarray(x, dtype=float).copy()
10499 best_f = float(f)
10500 evals = 0
10501 for _ in range(4):
10502 n0 = int(round(best_x[i_n]))
10503 cands = []
10504 for n in range(lo_n, hi_n + 1):
10505 if n == n0:
10506 continue
10507 c = best_x.copy()
10508 c[i_n] = float(n)
10509 for i in i_d:
10510 c[i] = c[i] * np.sqrt(n0 / n)
10511 for i in i_s:
10512 c[i] = c[i] * n0 / n
10513 if all(bounds[i][0] <= c[i] <= bounds[i][1] for i in (*i_d, *i_s)):
10514 cands.append(_snap_integer_dims(c, integer_dims))
10515 if not cands:
10516 break
10517 results = list(executor.map(_eval_candidate, cands))
10518 evals += len(results)
10519 vals = [float(r["value"]) if r.get("success") else float("inf") for r in results]
10520 k = int(np.argmin(vals))
10521 if eval_cache is not None and make_cache_key_fn is not None:
10522 for c, r in zip(cands, results):
10523 eval_cache[make_cache_key_fn(c)] = {"value": r["value"], "success": r.get("success", True)}
10524 if not (vals[k] < best_f):
10525 break
10526 if logger:
10527 logger.info("Element-count scan: n %d -> %d (iso-area), f %.5f -> %.5f",
10528 n0, int(round(cands[k][i_n])), best_f, vals[k])
10529 best_f = vals[k]
10530 best_x = _x_solved_or(results[k], cands[k])
10531 return best_x, best_f, evals
10532
10533
10535 elite_pool: ElitePool,
10536 num_blocks: int,
10537 dim: int,
10538 method: str = "random",
10539 overlap_fraction: float = 0.0,
10540 rng: np.random.Generator = None,
10541) -> list[list[int]]:
10542 """
10543 Compute variable blocks for re-optimization.
10544
10545 Methods:
10546 - "random": Randomly partition variables.
10547 - "corr_greedy": Use correlation matrix of standardized elites.
10548 Distance = 1 - abs(Correlation).
10549 """
10550 if rng is None:
10551 rng = np.random.default_rng()
10552
10553 all_indices = np.arange(dim)
10554
10555 if method == "corr_greedy" and len(elite_pool.points) > dim + 2:
10556 # 1. Get elites X (size K x dim)
10557 X_raw, _ = elite_pool.get_elites()
10558
10559 # 2. Standardize X (zero mean, unit variance) to avoid scale bias
10560 # Handle zero variance dimensions safely
10561 stds = np.std(X_raw, axis=0)
10562 valid_dims = stds > 1e-12
10563 X_std = np.zeros_like(X_raw)
10564 X_std[:, valid_dims] = (X_raw[:, valid_dims] - np.mean(X_raw[:, valid_dims], axis=0)) / stds[valid_dims]
10565
10566 # 3. Compute correlation matrix (not covariance!)
10567 # Shape (dim, dim)
10568 if len(X_std) > 1:
10569 corr_mat = np.corrcoef(X_std, rowvar=False)
10570 # Handle NaNs if they appear (e.g. constant columns)
10571 corr_mat = np.nan_to_num(corr_mat, nan=0.0)
10572 else:
10573 # Fallback if not enough points
10574 corr_mat = np.eye(dim)
10575
10576 # 4. Computed distance metric: d_ij = 1 - abs(corr_ij)
10577 dist_mat = 1.0 - np.abs(corr_mat)
10578
10579 # 5. Greedy clustering
10580 # Start with unassigned, pick one, add nearest neighbors until block filled
10581 unassigned = set(all_indices)
10582 blocks = []
10583 target_block_size = int(np.ceil(dim / num_blocks))
10584
10585 while unassigned:
10586 if len(blocks) == num_blocks - 1:
10587 # Last block takes all remaining
10588 blocks.append(list(unassigned))
10589 break
10590
10591 # Pick seed (random or first available)
10592 seed = list(unassigned)[0]
10593 current_block = [seed]
10594 unassigned.remove(seed)
10595
10596 # Add N nearest neighbors
10597 while len(current_block) < target_block_size and unassigned:
10598 # Find nearest to *any* in current block? Or centroid?
10599 # Simple: nearest to seed or mean distance to block
10600 # Let's do min-average-distance to current block
10601 candidates = list(unassigned)
10602
10603 # Compute avg dist to current block for each candidate
10604 # dist_mat[c, current_block]
10605 avg_dists = [np.mean(dist_mat[c, current_block]) for c in candidates]
10606 best_cand_idx = np.argmin(avg_dists)
10607 best_cand = candidates[best_cand_idx]
10608
10609 current_block.append(best_cand)
10610 unassigned.remove(best_cand)
10611
10612 blocks.append(current_block)
10613
10614 # If we have overlap
10615 if overlap_fraction > 0:
10616 # Overlap implementation:
10617 # Start each block with 'overlap' amount of indices from the previous block
10618 # This logic modifies the 'blocks' list we just created.
10619 # However simple partition-based generation above doesn't support overlap naturally.
10620 # Let's refine: The greedy approach above created partition.
10621 # To add overlap: Append N items from block[i-1] to block[i]
10622 # EXCEPT for first block.
10623
10624 # Note: This increases total size, so "target_block_size" in previous step was for partition.
10625 overlap_count = int(np.ceil(target_block_size * overlap_fraction))
10626 if overlap_count > 0:
10627 for i in range(1, len(blocks)):
10628 # Get overlap candidates from previous block (random or specific?)
10629 # Random is safest to avoid bias
10630 prev_block = blocks[i-1]
10631 if len(prev_block) > 0:
10632 overlap_indices = rng.choice(prev_block, size=min(len(prev_block), overlap_count), replace=False)
10633 # Add to current
10634 blocks[i].extend(overlap_indices)
10635 # Uniquify just in case
10636 blocks[i] = list(set(blocks[i]))
10637
10638 return blocks
10639 else:
10640 # Fallback to random
10641 perm = rng.permutation(dim)
10642 # Split into ~equal chunks
10643 blocks = [list(a) for a in np.array_split(perm, num_blocks)]
10644 return blocks
10645
10646
10648 objective: Callable[[np.ndarray], float],
10649 bounds: list,
10650 x0: np.ndarray,
10651 hybrid_config: HybridOptimizerConfig,
10652 total_budget: int = 3000,
10653 logger = None,
10654 log_status_fn = None,
10655 update_progress_fn = None,
10656 valley_escape_tracker: Optional[Dict[str, Any]] = None,
10657 # Parallel evaluation support
10658 executor: Optional[ProcessPoolExecutor] = None,
10659 integer_dims: Optional[list] = None,
10660 od_index: int = -1,
10661 od_step_m: float = 0.0,
10662 fixed_variables: Optional[Dict[int, float]] = None,
10663 eval_cache: Optional[dict] = None,
10664 make_cache_key_fn: Optional[Callable[[np.ndarray], Tuple[int, ...]]] = None,
10665 stop_event: Optional[Any] = None, # threading.Event for stop signal
10666 seed: Optional[int] = None,
10667 popsize: int = 16,
10668 iso_area: Optional[Tuple[int, Tuple[int, ...], Tuple[int, ...]]] = None,
10669) -> Tuple[np.ndarray, float, int]:
10670 """
10671 Run Hybrid CMA-ES + Block Re-optimization.
10672
10673 ``iso_area`` (impinging): after Stage A and before every refresh, every element count is
10674 scored along the iso-area path (see _layer1_iso_area_count_scan) and the best is taken.
10675
10676 ``seed`` makes the whole search a deterministic function of its inputs. It was not:
10677 this function built ``np.random.default_rng()`` with no seed and called ``run_cma_core``
10678 without ``seed=`` in Stage A, in every block and in every refresh, so CMA seeded itself
10679 from the clock. ``layer1_random_seed`` reached the warm start and nothing after it --
10680 measured, three runs of one config at seed 37 gave three different injectors (included
10681 angle 89 / 87 / 83 deg). Every ``run_cma_core`` call below now takes a seed derived from
10682 this one, distinct per stage so no two stages replay the same sample stream.
10683
10684 ``popsize`` is the Stage A / refresh population. It was hardcoded to 16 while the caller
10685 computed and logged 48; the block stage keeps its own smaller population.
10686
10687 Logic:
10688 1. Stage A: Global Exploration (Standard CMA-ES)
10689 2. Loop Cycles:
10690 a. Stage B: Build Blocks (Random or Correlation-based)
10691 b. Stage C: Block Re-optimization (Soft freezing)
10692 3. Stage D: Global Refresh (Periodic)
10693
10694 Args:
10695 objective: Main objective function (returns float).
10696 bounds: List of (min, max).
10697 x0: Initial guess.
10698 hybrid_config: Configuration.
10699 total_budget: Max evals.
10700
10701 Returns:
10702 (best_x, best_f, total_evals)
10703 """
10704 evals_used = 0
10705 dim = len(x0)
10706
10707 # Setup constraints/bounds arrays
10708 lower_bounds = np.array([b[0] for b in bounds])
10709 upper_bounds = np.array([b[1] for b in bounds])
10710 span = upper_bounds - lower_bounds
10711
10712 # 1. Initialize Elite Pool
10713 elite_pool = ElitePool(k=hybrid_config.elite_k)
10714
10715 # One generator for everything this function samples (Stage A kick, block partition),
10716 # and one derived CMA seed per stage. ``None`` keeps the old fresh-entropy behaviour.
10717 rng = np.random.default_rng(seed)
10718
10719 def _sub_seed(k: int) -> Optional[int]:
10720 return None if seed is None else int((int(seed) + k * 1_000_003) % (2 ** 31))
10721
10722 # 2. Budget allocation
10723 # Reserve slice for Stage A
10724 # The rest is split among cycles
10725 stage_a_budget = int(total_budget * (1.0 - hybrid_config.per_block_budget_fraction))
10726 block_budget_total = total_budget - stage_a_budget
10727
10728 if hybrid_config.cycles > 0 and hybrid_config.num_blocks > 0:
10729 per_cycle_budget = block_budget_total // hybrid_config.cycles
10730 if hybrid_config.refresh_every_pass:
10731 # Subtract refresh budget from per-cycle
10732 refresh_cost = int(total_budget * hybrid_config.refresh_budget_fraction)
10733 per_cycle_budget = max(0, per_cycle_budget - refresh_cost)
10734 else:
10735 per_cycle_budget = 0
10736
10737 # --- STAGE A: Global Exploration (CMA-ES) ---
10738 if update_progress_fn: update_progress_fn("Hybrid: Stage A", 0.1, "Global Exploration")
10739 if logger: logger.info(f"Stage A: Global Search (Budget: {stage_a_budget})")
10740
10741 # Setup CMA stds
10742 sigma0 = 0.25 * float(np.percentile(span, 25)) # Heuristic 25% of 25th percentile span
10743 cma_stds = span / (4.0 * sigma0) # Normalize so sigma*std roughly covers range
10744
10745 # We use a randomized restart wrapper for Stage A to ensure good coverage
10746 # But for simplicity we call run_cma_core once with restart logic INTERNAL if we wanted,
10747 # but run_cma_core is single run.
10748 # Let's do 2 quick restarts in Stage A budget if budget allows
10749
10750 best_x_global = x0.copy()
10751 valid_f = objective(best_x_global)
10752 elite_pool.add(best_x_global, valid_f)
10753 best_f_global = valid_f
10754
10755 # Split Stage A budget into 2 restarts
10756 budget_a1 = int(stage_a_budget * 0.6)
10757 budget_a2 = stage_a_budget - budget_a1
10758
10759 # Run 1
10760 x_res, f_res, evs = run_cma_core(
10761 objective, x0, sigma0, bounds, budget_a1,
10762 popsize=popsize, cma_stds=cma_stds, elite_pool=elite_pool, seed=_sub_seed(1),
10763 valley_escape_tracker=valley_escape_tracker, logger=logger,
10764 # Parallel evaluation
10765 executor=executor, integer_dims=integer_dims, eval_cache=eval_cache,
10766 od_index=od_index, od_step_m=od_step_m, fixed_variables=fixed_variables,
10767 make_cache_key_fn=make_cache_key_fn,
10768 stop_event=stop_event,
10769 )
10770 evals_used += evs
10771 if f_res < best_f_global:
10772 best_f_global = f_res
10773 best_x_global = x_res
10774
10775 # Run 2: GLOBAL re-exploration, not a second polish of run 1.
10776 #
10777 # This used to restart from the incumbent with a 1 % kick and half the step size -- a
10778 # local refine -- and nothing downstream (blocks, refreshes) ever leaves the incumbent's
10779 # neighbourhood either. So after run 1 stagnated (typically ~5k of a 25k Stage A budget)
10780 # the entire remaining budget polished one basin, and the objective's flat directions
10781 # (injector angle, O/F inside its band) were settled by whichever basin run 1 happened
10782 # to stop in. The legacy CMA path's odd restarts kick 30 % of each dimension's span off
10783 # the incumbent at full sigma -- anchored so the start is not almost-surely infeasible,
10784 # wide enough to leave the basin -- and that is what run 2 does now.
10785 x0_2 = np.clip(best_x_global + rng.standard_normal(dim) * (0.30 * span),
10786 lower_bounds, upper_bounds)
10787 if logger:
10788 logger.info("Stage A run 2: global re-exploration, 30 %% span kick off f=%.5f", best_f_global)
10789
10790 if budget_a2 > 100:
10791 x_res, f_res, evs = run_cma_core(
10792 objective, x0_2, sigma0, bounds, budget_a2,
10793 popsize=popsize, cma_stds=cma_stds, elite_pool=elite_pool, seed=_sub_seed(2),
10794 valley_escape_tracker=valley_escape_tracker, logger=logger,
10795 # Parallel evaluation
10796 executor=executor, integer_dims=integer_dims, eval_cache=eval_cache,
10797 od_index=od_index, od_step_m=od_step_m, fixed_variables=fixed_variables,
10798 make_cache_key_fn=make_cache_key_fn,
10799 stop_event=stop_event,
10800 )
10801 evals_used += evs
10802 if f_res < best_f_global:
10803 best_f_global = f_res
10804 best_x_global = x_res
10805
10806 if logger: logger.info(f"Stage A Complete. Best f: {best_f_global:.5f}")
10807
10808 def _count_scan():
10809 nonlocal best_x_global, best_f_global, evals_used
10810 if iso_area is None or executor is None:
10811 return
10812 _x, _f, _ev = _layer1_iso_area_count_scan(
10813 best_x_global, best_f_global, bounds, executor, integer_dims or [], iso_area,
10814 logger=logger, eval_cache=eval_cache, make_cache_key_fn=make_cache_key_fn)
10815 evals_used += _ev
10816 if _f < best_f_global:
10817 best_x_global, best_f_global = np.asarray(_x, dtype=float), float(_f)
10818
10819 _count_scan()
10820
10821 # --- CYCLES ---
10822 current_x = best_x_global.copy()
10823
10824 for cycle_idx in range(hybrid_config.cycles):
10825 # Check if optimization should stop
10826 if stop_event is not None and stop_event.is_set():
10827 raise RuntimeError("Optimization stopped by user")
10828
10829 if update_progress_fn:
10830 update_progress_fn(f"Hybrid: Cycle {cycle_idx+1}", 0.3 + 0.6*(cycle_idx/max(1,hybrid_config.cycles)), f"Block Optimization ({hybrid_config.block_method})")
10831
10832 cycle_budget = per_cycle_budget
10833
10834 # --- STAGE B: Build Blocks ---
10836 elite_pool, hybrid_config.num_blocks, dim,
10837 method=hybrid_config.block_method,
10838 overlap_fraction=hybrid_config.overlap_fraction,
10839 rng=rng
10840 )
10841
10842 # Derived DOFs are solved inside the objective, never searched: a block that proposes
10843 # them freely feeds the solve a random start (L1-07). Drop them, and fold any block
10844 # left with one coordinate into its neighbour (cma does not run a 1-D problem).
10845 blocks = [[int(i) for i in b if not (fixed_variables and int(i) in fixed_variables)]
10846 for b in blocks]
10847 blocks = [b for b in blocks if b]
10848 _merged: List[List[int]] = []
10849 for b in blocks:
10850 if _merged and len(b) < 2:
10851 _merged[-1].extend(b)
10852 else:
10853 _merged.append(list(b))
10854 if len(_merged) > 1 and len(_merged[0]) < 2:
10855 _merged[1].extend(_merged.pop(0))
10856 blocks = _merged
10857
10858 if logger: logger.info(f"Cycle {cycle_idx+1}: Created {len(blocks)} blocks using {hybrid_config.block_method} (Overlap: {hybrid_config.overlap_fraction})")
10859
10860 # Calculate penalty weight
10861 # λ_eff = λ_schedule * f_scale / ||x||^2
10862 # Determine f_scale
10863 _, f_vals_elite = elite_pool.get_elites()
10864 if len(f_vals_elite) > 0:
10865 f_scale = np.median(np.abs(f_vals_elite))
10866 else:
10867 f_scale = max(1.0, abs(best_f_global))
10868
10869 if not hybrid_config.lambda_normalize:
10870 f_scale = 1.0 # Use raw user lambda
10871
10872 base_lambda = hybrid_config.lambda0 * (hybrid_config.lambda_mult ** cycle_idx)
10873 base_lambda = min(base_lambda, hybrid_config.lambda_max)
10874
10875 # --- STAGE C: Block Re-optimization ---
10876 if len(blocks) > 0:
10877 budget_per_block = cycle_budget // len(blocks)
10878 else:
10879 budget_per_block = 0
10880
10881 for b_i, block_indices in enumerate(blocks):
10882 if budget_per_block < 20: continue # Skip if too small
10883 # Derived DOFs are solved inside the objective, never searched: a block that
10884 # proposes them freely feeds the solve a random start (L1-07).
10885 if len(block_indices) < 2:
10886 continue # cma does not run a 1-D problem
10887
10888 non_block_indices = [i for i in range(dim) if i not in block_indices]
10889
10890 # Constants for this block run
10891 x_fixed_vals = current_x[non_block_indices]
10892
10893 # Define block bounds
10894 block_lower = lower_bounds[block_indices]
10895 block_upper = upper_bounds[block_indices]
10896 block_bounds = list(zip(block_lower, block_upper))
10897
10898 # Initial mean for this block
10899 z0 = current_x[block_indices]
10900
10901 # Objective wrapper
10902 def block_obj_fn(z):
10903 # Stitch
10904 full_x = current_x.copy() # Start with current baseline
10905 full_x[block_indices] = z # Overwrite block
10906 # Clip full x to be safe
10907 full_x = np.clip(full_x, lower_bounds, upper_bounds)
10908 return objective(full_x)
10909
10910 # Same per-coordinate scaling as Stage A. One isotropic sigma over coordinates
10911 # whose ranges differ by 1e5 (m^2 next to psi) sampled the small ones uniformly
10912 # across their bounds and moved the large ones by a fraction of a unit.
10913 z_stds = np.asarray(cma_stds, dtype=float)[block_indices]
10914 z_sigma = sigma0
10915 # Apply additional boost to block sigma if valley escape is active
10916 if valley_escape_tracker is not None:
10917 tier = valley_escape_tracker.get("valley_escape_tier", 0)
10918 if tier > 0:
10919 # Block reopt: smaller boost (1.2 - 1.8x)
10920 block_boost = 1.0 + (tier * 0.2) # Tier 1: 1.2, Tier 2: 1.4, Tier 3: 1.6
10921 z_sigma *= block_boost
10922
10923 # A block optimizes a SUBSET of coordinates, so global discrete indices do
10924 # not address the sub-vector -- they have to be remapped through
10925 # ``block_indices``. Passing the global list would mark the wrong coordinates
10926 # as integers; passing nothing (what this did before) let a block search
10927 # element counts and jet angles as continuous values.
10928 _blk = list(block_indices)
10929 _blk_int_dims = [j for j, g in enumerate(_blk) if g in set(integer_dims or [])]
10930 _blk_od_index = _blk.index(od_index) if od_index in _blk else -1
10931 # Scored by the worker pool on the stitched full vector -- the same objective
10932 # and evaluation path as Stage A and the refreshes, so the comparisons below
10933 # never mix two scorings.
10934 z_best, z_f, z_evals = run_cma_core(
10935 block_obj_fn, z0, z_sigma, block_bounds, budget_per_block,
10936 popsize=max(8, 4 + int(3 * np.log(len(z0)+1))), # Smaller pop for blocks
10937 cma_stds=z_stds,
10938 seed=_sub_seed(100 + 10 * cycle_idx + b_i),
10939 elite_pool=None,
10940 true_objective_fn=block_obj_fn,
10941 valley_escape_tracker=valley_escape_tracker, logger=logger,
10942 executor=executor,
10943 integer_dims=_blk_int_dims,
10944 od_index=_blk_od_index, od_step_m=od_step_m,
10945 eval_cache=eval_cache if executor is not None else None,
10946 make_cache_key_fn=make_cache_key_fn,
10947 stop_event=stop_event,
10948 embed_base=current_x.copy() if executor is not None else None,
10949 embed_idx=list(block_indices) if executor is not None else None,
10950 full_objective_fn=objective,
10951 )
10952
10953 evals_used += z_evals
10954
10955 # Update current_x if improved (current_x scored the same way z_f was)
10956 if executor is not None:
10957 _cur = _snap_integer_dims(np.asarray(current_x, dtype=np.float64), integer_dims or [])
10958 _cur_res = list(executor.map(_eval_candidate, [_cur]))[0]
10959 f_current = float(_cur_res.get("value", float("inf")))
10960 else:
10961 f_current = objective(current_x)
10962 if z_f < f_current:
10963 current_x[block_indices] = z_best
10964 if z_f < best_f_global:
10965 best_f_global = z_f
10966 best_x_global = current_x.copy()
10967 if logger: logger.info(f" Block {b_i} improved global best -> {best_f_global:.5f}")
10968
10969 # Also add the new best to elite pool
10970 elite_pool.add(current_x, z_f)
10971
10972 _count_scan()
10973 current_x = best_x_global.copy()
10974
10975 # --- STAGE D: Global Refresh ---
10976 if hybrid_config.refresh_every_pass:
10977 ref_budget = int(total_budget * hybrid_config.refresh_budget_fraction)
10978 if ref_budget > 50:
10979 if logger: logger.info(f"Cycle {cycle_idx+1}: Global Refresh (Budget {ref_budget})")
10980
10981 # Start around current best
10982 x_ref = best_x_global.copy()
10983
10984 # Reduced sigma
10985 # User req 4: "sigma = refresh_sigma_scale * sigma_initial"
10986 sigma_ref = sigma0 * hybrid_config.refresh_sigma_scale
10987
10988 # Apply additional boost to refresh sigma if valley escape is active
10989 if valley_escape_tracker is not None:
10990 tier = valley_escape_tracker.get("valley_escape_tier", 0)
10991 if tier > 0:
10992 # Global refresh: larger boost (1.5 - 2.5x)
10993 refresh_boost = 1.0 + (tier * 0.5) # Tier 1: 1.5, Tier 2: 2.0, Tier 3: 2.5
10994 sigma_ref *= refresh_boost
10995
10996 # Full-dimension restart -- same space as ``bounds``, so it takes the
10997 # global discrete-DOF handling. It previously passed neither, which let a
10998 # refresh walk element counts and jet angles off their integer grid.
10999 x_ref_res, f_ref_res, evs_ref = run_cma_core(
11000 objective, x_ref, sigma_ref, bounds, ref_budget,
11001 popsize=popsize, cma_stds=cma_stds, elite_pool=elite_pool,
11002 seed=_sub_seed(1000 + cycle_idx),
11003 valley_escape_tracker=valley_escape_tracker, logger=logger,
11004 executor=executor, integer_dims=integer_dims,
11005 od_index=od_index, od_step_m=od_step_m, fixed_variables=fixed_variables,
11006 eval_cache=eval_cache,
11007 make_cache_key_fn=make_cache_key_fn,
11008 stop_event=stop_event,
11009 )
11010 evals_used += evs_ref
11011
11012 if f_ref_res < best_f_global:
11013 best_f_global = f_ref_res
11014 best_x_global = x_ref_res
11015 current_x = x_ref_res # Update incumbent for next cycle
11016 if logger: logger.info(f" Refresh improved global best -> {best_f_global:.5f}")
11017
11018 _count_scan()
11019 return best_x_global, best_f_global, evals_used
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
Dict[str, Dict[str, float]] _impinging_manifold_q(*, float n_elements, float spacing_O_m, float spacing_F_m, float d_jet_O_m, float d_jet_F_m, float angle_O_deg, float angle_F_deg, float plate_thickness_m, bool face_contoured, float exit_land, float passage_ld_O, float passage_ld_F, float channel_width_m, str channel_floor, int channel_inlets, Any mdot_O, Any mdot_F, Any dp_O, Any dp_F, Any rho_O, Any rho_F)
float _impinging_free_jet_ld(*, float n_elements, float spacing_O_m, float spacing_F_m, float d_jet_O_m, float d_jet_F_m, float angle_O_deg, float angle_F_deg)
float _impinging_momentum_hinge_squared(Any R, *, Optional[float] r_band_lo=None, Optional[float] r_band_hi=None)
Optional[Tuple[PintleEngineConfig, np.ndarray, dict]] _layer1_rebuild_final_config(np.ndarray x_best, PintleEngineConfig config_base, dict constants, logger=None, Optional[dict] diag=None)
Optional[float] _layer1_matched_eps(Any result, float Pa, Any cea_cache=None, Optional[float] eps_guess=None)
float _layer1_lstar_target_from_smd(float smd_um, *, float smd_ref_um=99.0, float Lstar_ref_m=1.10, float exponent=2.0, float Lstar_min_m=0.0, float Lstar_max_m=float("inf"))
float _impinging_momentum_band_violation_squared(Any R, *, Optional[float] r_band_lo=None, Optional[float] r_band_hi=None)
float _expected_geom_ao_af_for_unit_momentum_ratio(float optimal_of, float rho_o, float rho_f)
float _impinging_geometry_fit_squared(Dict[str, Any] diagnostics, *, float D_chamber_inner_m, float L_chamber_m)
Tuple[bool, Tuple[Optional[float], Optional[float]]] _layer1_momentum_gate(float R, Optional[float] R_min, Optional[float] R_max, float slack=1.0)
Tuple[float, float] _rescale_impinging_jet_diameters_for_mr_target(float d_jet_O, float d_jet_F, *, int n_elements_O, int n_elements_F, float optimal_of, float rho_o, float rho_f, float min_d=1e-5, float rel_tol=0.02)
float _layer1_discharge_cd_inf_ratio_from_config(Any config_obj, str injector_type)
Tuple[np.ndarray, float, int] run_cma_core(Callable[[np.ndarray], float] objective_fn, np.ndarray x0, float sigma0, list bounds, int budget, int popsize, np.ndarray cma_stds=None, int seed=None, Optional[ElitePool] elite_pool=None, Optional[Callable[[np.ndarray], float]] true_objective_fn=None, Optional[Dict[str, Any]] valley_escape_tracker=None, logger=None, Optional[ProcessPoolExecutor] executor=None, Optional[list] integer_dims=None, int od_index=-1, float od_step_m=0.0, Optional[Dict[int, float]] fixed_variables=None, Optional[dict] eval_cache=None, Optional[Callable[[np.ndarray], Tuple[int,...]]] make_cache_key_fn=None, Optional[Any] stop_event=None, Optional[np.ndarray] embed_base=None, Optional[List[int]] embed_idx=None, Optional[Callable[[np.ndarray], float]] full_objective_fn=None)
float _impinging_momentum_log_target_squared(Any R, *, float rel_deadband=_LAYER1_DEFAULT_MOMENTUM_LOG_DEADBAND_REL)
float _impinging_face_infeasibility_terms(*, float n_elements, float spacing_O_m, float spacing_F_m, float d_jet_O_m, float d_jet_F_m, float D_chamber_inner_m, float angle_O_deg, float angle_F_deg, float center_clear_dia_m=0.0, float min_web_m=0.0, float wall_clearance_m=0.0, float spray_radius_frac=0.0, float spray_radius_tol=0.08, float min_face_incidence_deg=0.0, bool face_contoured=False, float exit_land=EXIT_LAND_DEFAULT)
float _requirement_float(Dict[str, Any] requirements, str key, float default)
None _layer1_stamp_design_point(config, performance, logger=None)
None _write_impinging_jet_diameters_to_config(Any config_obj, float d_jet_O, float d_jet_F)
None _layer1_check_of_target_in_cea_range(Any config_obj, Any optimal_of)
Dict[str, Any] _layer1_objective_terms(Any result, np.ndarray x, dict requirements, dict constants, *, Any cea_cache=None, Optional[str] eval_error=None)
Optional[float] _throat_area_m2_from_config(PintleEngineConfig config)
bool _impinging_hard_geometry_blocks_eval(*, float d_jet_O, float d_jet_F, float sp_O, float sp_F, float D_chamber_inner, float D_throat_check, float A_chamber_check, float A_throat_check, float n_elements=float("nan"), float angle_O_deg=float("nan"), float angle_F_deg=float("nan"), float center_clear_dia_m=0.0, float min_web_m=0.0, float wall_clearance_m=0.0, float min_face_incidence_deg=0.0, float spray_radius_frac=0.0, float spray_radius_tol=0.08, float plate_thickness_m=0.0, float counterbore_dia_m=0.0, float land_ld_O=4.0, float land_ld_F=4.0, float min_back_web_m=0.0, float back_keepout_dia_m=0.0, float back_keepout_wall_m=0.0, bool face_contoured=False, float exit_land=EXIT_LAND_DEFAULT, bool back_channels=False, float passage_ld_O=4.0, float passage_ld_F=4.0, float channel_width_m=0.0, str channel_floor="flat", float r_plate_m=0.0, float r_port_m=0.0, float liner_thickness_m=0.0)
float _layer1_chamber_mass_kg(float A_chamber_inner_m2, float L_chamber_m, float total_wall_thickness_m, float wall_density_kg_m3, float Pc_pa=0.0, float closure_yield_pa=205e6, str plate_support="clamped", float r_plate_m=0.0, float poisson=0.3)
None _infeas_trace(Optional[Dict[str, float]] entry, str label, float value)
Tuple[np.ndarray, float, int] run_hybrid_optimization(Callable[[np.ndarray], float] objective, list bounds, np.ndarray x0, HybridOptimizerConfig hybrid_config, int total_budget=3000, logger=None, log_status_fn=None, update_progress_fn=None, Optional[Dict[str, Any]] valley_escape_tracker=None, Optional[ProcessPoolExecutor] executor=None, Optional[list] integer_dims=None, int od_index=-1, float od_step_m=0.0, Optional[Dict[int, float]] fixed_variables=None, Optional[dict] eval_cache=None, Optional[Callable[[np.ndarray], Tuple[int,...]]] make_cache_key_fn=None, Optional[Any] stop_event=None, Optional[int] seed=None, int popsize=16, Optional[Tuple[int, Tuple[int,...], Tuple[int,...]]] iso_area=None)
Optional[Tuple[Dict[str, Any], Dict[str, Any], bool]] _impinging_channel_pair(*, float n_elements, float spacing_O_m, float spacing_F_m, float d_jet_O_m, float d_jet_F_m, float angle_O_deg, float angle_F_deg, float plate_thickness_m, bool face_contoured, float exit_land, float passage_ld_O, float passage_ld_F, float channel_width_m, str channel_floor)
np.ndarray run_layer1_global_search(Callable[[np.ndarray], float] objective, list bounds, np.ndarray x0, int max_evals=150, int random_seed=42)
Tuple[bool, List[str]] _layer1_stability_gate(str state, float score, float chug, float acoustic, float feed, Any stability_results, dict requirements, float min_stability)
float _layer1_centre_clear_m(Any config_obj, Dict[str, Any] requirements)
bool _requirement_bool(Dict[str, Any] requirements, str key, bool default)
Dict[str, Any] _layer1_injector_plate_constants(Any config_obj, Dict[str, Any] requirements)
Optional[Dict[str, Any]] _layer1_igniter_keepouts(Any config_obj, Dict[str, Any] requirements)
Tuple[np.ndarray, float] _layer1_cma_warmstart_candidates(np.ndarray x0, *, np.ndarray lower_bounds, np.ndarray upper_bounds, np.ndarray span, List[int] integer_dims, np.random.Generator rng, Any executor, int n_trials, float sigma_frac, Dict[Any, Any] eval_cache, Callable[[np.ndarray], Any] make_cache_key_fn, Dict[str, Any] opt_state, logging.Logger logger)
float _impinging_back_face_terms(*, float n_elements, float spacing_O_m, float spacing_F_m, float d_jet_O_m, float d_jet_F_m, float D_chamber_inner_m, float angle_O_deg, float angle_F_deg, float plate_thickness_m=0.0, float counterbore_dia_m=0.0, float land_ld_O=4.0, float land_ld_F=4.0, float min_back_web_m=0.0, float back_keepout_dia_m=0.0, float back_keepout_wall_m=0.0, bool face_contoured=False, float exit_land=EXIT_LAND_DEFAULT, bool back_channels=False, float passage_ld_O=4.0, float passage_ld_F=4.0, float channel_width_m=0.0, str channel_floor="flat", float r_plate_m=0.0, float r_port_m=0.0, float liner_thickness_m=0.0)
None _store_last_good_eval_bundle_from_worker_res(Dict[str, Any] res, Optional[Dict[str, Any]] state)
float _impinging_ring_geometry_squared(*, float n_elements, float spacing_O_m, float spacing_F_m, float d_jet_O_m, float d_jet_F_m, float D_chamber_inner_m, float angle_O_deg=float("nan"), float angle_F_deg=float("nan"), float Ld_min=0.0, float Ld_max=0.0, bool ring_order_fuel_outboard=True, float center_clear_dia_m=0.0, float min_web_m=0.0, float wall_clearance_m=0.0, float spray_radius_frac=0.0, float spray_radius_tol=0.08, bool face_contoured=False, float exit_land=EXIT_LAND_DEFAULT)
Optional[dict] _layer1_solve_derived_geometry(np.ndarray x, PintleEngineConfig config, dict constants, Callable[[], Optional[dict]] evaluate, Optional[dict] result, Any cea_cache=None)
float _impinging_channel_terms(*, float n_elements, float spacing_O_m, float spacing_F_m, float d_jet_O_m, float d_jet_F_m, float D_chamber_inner_m, float angle_O_deg, float angle_F_deg, float plate_thickness_m, bool face_contoured, float exit_land, float passage_ld_O, float passage_ld_F, float channel_width_m, str channel_floor, float r_plate_m, float r_port_m, float min_land_m=0.0, float back_keepout_dia_m=0.0, float back_keepout_wall_m=0.0)
float _impinging_angle_hinge_squared(Any impingement_angle_deg, *, Optional[float] angle_band_lo_deg=None, Optional[float] angle_band_hi_deg=None)
Dict[str, Tuple[bool, str]] _layer1_declared_limit_gates(Any config, dict requirements, dict constants, Optional[Dict[str, Any]] performance=None)
None _layer1_emit_objective_plot_point(Optional[Callable[[int, float, float], None]] objective_callback, Dict[str, Any] opt_state, int iteration, float current_obj, float best_obj)
float _impinging_jet_pair_asymmetry_squared(Any theta_o_deg, Any theta_f_deg, *, float max_delta_deg)
int _layer1_valley_escape_tier(int evals, float best_f, int stagnation)
None _layer1_finalize_derived_geometry(PintleEngineConfig config, float P_O_pa, float P_F_pa, dict constants, logger=None)
float _layer1_chamber_mass_term(float chamber_mass_kg, float mass_ref_kg)
float _effective_smd_um(Any diagnostics, Any mr_mass=None)
list[list[int]] compute_blocks_from_elites(ElitePool elite_pool, int num_blocks, int dim, str method="random", float overlap_fraction=0.0, np.random.Generator rng=None)
Any _compute_objective_value(Any result, np.ndarray x, dict requirements, dict constants, Any cea_cache=None, *, Optional[str] eval_error=None, bool return_terms=False)
Tuple[PintleEngineConfig, Dict[str, Any]] run_layer1_optimization(PintleEngineConfig config_obj, PintleEngineRunner runner, Dict[str, Any] requirements, float target_burn_time, Dict[str, float] tolerances, Dict[str, Any] pressure_config, Optional[Callable[[str, float, str], None]] update_progress=None, Optional[Callable[[str, str], None]] log_status=None, Optional[Callable[[int, float, float], None]] objective_callback=None, Optional[Any] stop_event=None, Optional[int] layer1_max_iterations=None, Optional[int] layer1_cma_restarts=None, bool layer1_smoke=False)
float _impinging_resultant_wall_violation(Any tilt_deg, *, float max_outward_deg=0.0, float scale_deg=2.0)
Tuple[float, Optional[str]] _layer1_resolve_of_target(Any config_obj, Any optimal_of, Any cea_cache=None, float Pc_pa=3.0e6, float Pa=101325.0)
Tuple[float, float] _layer1_rescale_impinging_seed_jets_for_mr(Any config_obj, *, float d_jet_O, float d_jet_F, int n_doublets, float optimal_of, Sequence[Tuple[float, float]] bounds, logging.Logger logger, Optional[Any] also_config=None)
float _impinging_infeasibility_flow_capacity_terms(*, float A_lox_flow, float A_fuel_flow, float A_throat_check, float flow_cap=0.95)
float _layer1_isp_merit_s(Any result, dict constants, Any cea_cache=None)
list _l1_shrink_derived_stds(np.ndarray cma_stds, Dict[int, float] derived)
None _merge_runner_eval_into_performance(Dict[str, Any] initial_performance, Dict[str, Any] eval_results)
np.ndarray _x_solved_or(Dict[str, Any] res, np.ndarray fallback)
Optional[float] _layer1_eps_for_exit_pressure_cea(Any cea_cache, float MR, float Pc_pa, float Pa, Optional[float] eps_guess=None)
None _layer1_warn_stale_pressure_curves(config, logger=None, float tol_psi=1.0)
_init_worker(dict config_dict, np.ndarray bounds_array, dict requirements_dict, dict constants_dict, bool debug_strict=False)
Dict[str, float] _layer1_final_primary_objective_terms(Dict[str, Any] final_performance, *, float thrust_deadband_rel=0.02, float target_thrust, float optimal_of, float target_P_exit, float layer1_exit_pressure_inside_quad_scale=0.0, float layer1_w_dp, float layer1_w_dp_o, float layer1_w_dp_f, Tuple[float, float] injector_dp_o_band, Tuple[float, float] injector_dp_f_band, Optional[float] injector_dp_o_soft_floor=None, float layer1_w_dp_o_floor=0.0, float layer1_w_dp_center=0.0, float layer1_w_thrust=1.0e4, float layer1_w_of=1.0e4, float layer1_w_of_low_mr_scale=1.0, float layer1_w_of_high_mr_scale=1.0)
float _layer1_exit_pressure_sq_term(float P_exit_actual, float target_P_exit, *, float deadband_rel=0.05, float inside_rel_quad_scale=0.0)
float _layer1_lstar_band_term(float Lstar_m, float target_m, float deadband_m)
Tuple[float, float] _face_halves(*, float r_in, float d_in, float th_in, float r_out, float d_out, float th_out, bool face_contoured, float exit_land)
float _layer1_infeasibility_gate_eps(Dict[str, Any] requirements)
float _layer1_chamber_od_floor_m(float max_od_m, float wall_total_m, float A_throat_lo_m2, float dt_ratio_min)
None _layer1_sync_best_from_worker_eval(Dict[str, Any] opt_state, np.ndarray x_vec, float obj_val, *, Optional[Callable[[np.ndarray], float]] parent_objective=None)
float _quantize_chamber_od_m(float D_outer_m, float increment_in, float wall_m=0.0, str snap_target="outer")
float _layer1_apply_chamber_geometry_to_config(PintleEngineConfig config, *, float A_throat, float Lstar, float expansion_ratio, float D_chamber_outer, float max_nozzle_exit, float wall_thickness_m, requirements=None)
Dict[str, float] _layer1_chamber_lengths(float A_throat, float Lstar, float D_chamber_inner, float theta)
float _impinging_momentum_wall_violation(Any R, *, float band_width=0.025, bool safe_side_is_below_one=True, float scale=0.005)
float _tank_pressure_equal_squared(float P_O_tank_psi, float P_F_tank_psi, float scale_psi=100.0, float tol_psi=0.0, float inband_frac=0.0)
float _relative_hinge_band_squared(Any v, Optional[float] lo, Optional[float] hi)
np.ndarray _snap_integer_dims(np.ndarray x, list integer_indices)
Optional[str] _layer1_infeasibility_reason(runner, x, dict requirements, dict constants)
Tuple[float, float, float] _geom_ao_af_momentum_hint_squared(float A_geom_o, float A_geom_f, float optimal_of, float rho_o, float rho_f)
float _impinging_manifold_violation(Dict[str, Dict[str, float]] q)
float _layer1_isp_term(float isp_merit_s, float isp_ref_s)
Tuple[float, float] _layer1_resolve_lstar_target(float smd_um, Any constants_or_req, getter, float fallback_target_m)
float _resolve_tilt_allowance_deg(*, bool from_reach, float constant_deg, float breakeven_deg, float margin)
bool _lox_ring_inboard(Any spacing_O_m, Any spacing_F_m, *, bool fallback=True)
float _layer1_inf_residual(float infeasibility_score, Dict[str, Any] requirements)
float _impinging_resultant_tilt_deg(float mdot_O, float mdot_F, float rho_O, float rho_F, float n_elements, float d_jet_O_m, float d_jet_F_m, float angle_O_deg, float angle_F_deg, bool lox_inboard=True, *, float spacing_O_m=float("nan"), float spacing_F_m=float("nan"))
Tuple[list, list] _layer1_cma_discrete_options(int n_dims, Optional[Sequence[int]] integer_dims, int od_index=-1, float od_step_m=0.0, Optional[Sequence[Sequence[float]]] bounds=None)
float _resultant_tilt_breakeven_deg(*, float n_elements, float spacing_O_m, float spacing_F_m, float angle_O_deg, float angle_F_deg, float D_chamber_inner_m, float L_chamber_m)
_apply_x_to_worker_config_inplace(np.ndarray x, PintleEngineConfig config, dict constants)
Dict[str, float] _layer1_merit_weights(dict requirements, Any cea_cache, float optimal_of, float Pc_ref_pa, float Pa, Tuple[float, float] eps_range, float target_thrust, float burn_time_s, float W_MASS_config, float mass_ref_kg)
float _impinging_resultant_tilt_preference(Any tilt_deg, *, float scale_deg=2.0, float outward_multiplier=25.0)
Dict[str, float] _layer1_primary_relative_metrics(Dict[str, Any] final_performance, *, float target_thrust, float optimal_of, float target_P_exit)
float _closure_plate_stress_coeff(str support, float poisson=0.3)
float _layer1_geometry_infeasibility(requirements, *, L_cylindrical, D_chamber_inner, A_chamber, n_elements)
bool _layer1_feasible_for_primary_objective(float infeasibility_score, Dict[str, Any] requirements, *, bool length_violation=False)
_layer1_eps_for_exit_pressure(float Pc_Pa, float gamma, float Pe_Pa)
float _layer1_ideal_isp_s(Any cea_cache, float MR, float Pc_pa, float Pa, float eps)
float _impinging_momentum_asymmetric_squared(Any R, *, float wall_side_multiplier=1.0, float low_side_multiplier=1.0, float band_width=0.05, bool safe_side_is_below_one=True, float scale=0.10, Optional[float] band_lo=None, Optional[float] band_hi=None)
Callable create_layer1_apply_x_to_config(list bounds, float max_chamber_od, float max_nozzle_exit, str injector_type="pintle", float chamber_od_increment_in=0.0, str chamber_od_snap_target="outer", Optional[dict] derive_constants=None, Tuple[float, float] derive_spacing_F_range=(0.0, float("inf")))
Tuple[np.ndarray, float, int] _layer1_iso_area_count_scan(np.ndarray x, float f, list bounds, Any executor, list integer_dims, Tuple[int, Tuple[int,...], Tuple[int,...]] iso_area, logger=None, Optional[dict] eval_cache=None, Optional[Callable[[np.ndarray], Any]] make_cache_key_fn=None)
Tuple[float, float] _layer1_isp_reference(Any cea_cache, float MR, float Pc_pa, float Pa, float eps_lo, float eps_hi)
Tuple[float, float, float] _impinging_smd_penalty_with_angle(Dict[str, Any] diagnostics, *, float target_smd_microns, float smd_rel_tol=0.20, Optional[float] mr_mass=None)
float _impinging_infeasibility_layout_terms(*, float d_jet_O, float d_jet_F, float sp_O, float sp_F, int n_el_O, int n_el_F, float D_chamber_inner)
None _layer1_derive_matched_tank_pressure(np.ndarray x, dict constants)