STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
feasibility_impinging_lox_ch4_sweep.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Feasibility sweep: impinging LOX/CH4 geometry vs ΔP/Pc band (no physics changes).
3
4Loads a YAML config (default: configs/impinging_lox_ch4_8000N.yaml) with frozen
5chamber/throat/nozzle, sweeps injector counts/orifice diameters (optional tank
6pressures), runs runner.evaluate, and ranks cases vs design_requirements thrust/O/F
7plus momentum R window and ΔP_inj/Pc band.
8
9Before ``evaluate``, unrealistic impinging combinations are skipped (precheck tags
10in ``Row.err``): area vs throat, face packing density, spacing / ring count / n·d_jet,
11and coarse mdot-vs-throat proxies from tank PSI.
12
13Usage (from repo root, with venv and PYTHONPATH=.):
14 PYTHONPATH=. python scripts/feasibility_impinging_lox_ch4_sweep.py --config configs/impinging_lox_ch4_8000N.yaml
15 PYTHONPATH=. python scripts/feasibility_impinging_lox_ch4_sweep.py --quick
16
17Related: ``scripts/pareto_throat_dp_pressures.py`` sweeps throat area × tank-cap scenarios
18and solves tank pressures for thrust/MR targets (geometry fixed from YAML).
19"""
20
21from __future__ import annotations
22
23# Mission window for momentum ratio R (impinging diagnostics)
24R_BAND_LO = 0.909
25R_BAND_HI = 1.1
26# Preferred injector ΔP/Pc per stream
27DP_BAND_LO = 0.20
28DP_BAND_HI = 0.35
29# "Near" thrust / O/F for counting full mission hits (relative)
30MISSION_F_REL = 0.05
31MISSION_MR_REL = 0.05
32
33import argparse
34import copy
35import sys
36from collections import Counter
37from dataclasses import dataclass
38from pathlib import Path
39from typing import Any, Dict, List, Optional, Sequence, Tuple
40
41import numpy as np
42
43# Repo root = parent of scripts/
44ROOT = Path(__file__).resolve().parents[1]
45if str(ROOT) not in sys.path:
46 sys.path.insert(0, str(ROOT))
47
48from engine.core.runner import PintleEngineRunner
49from engine.core.injectors.flow_capacity import effective_flow_areas_from_cd
50from engine.optimizer.injector_dp_penalty import injector_dp_ratios_from_eval_result
51from engine.optimizer.layers.layer1_static_optimization import TOTAL_WALL_THICKNESS_M
52from engine.optimizer.utils import (
53 impinging_chamber_inner_diameter_for_bounds,
54 impinging_d_jet_upper_bound_m,
55 impinging_n_elements_hi_int,
56 impinging_spacing_upper_bound_m,
57)
58from engine.pipeline.config_schemas import ensure_chamber_geometry
59from engine.pipeline.io import load_config
60
61PSI_TO_PA = 6894.76
62
63
65 n_min: int,
66 n_max: int,
67 *,
68 quick: bool,
69 bins: Optional[int] = None,
70 quick_clip_max: Optional[int] = None,
71) -> List[int]:
72 """Coarse n_grid: small engines keep step-5 sweep; large n_max uses ~8-bin linspace."""
73 if n_max <= n_min:
74 return [n_min]
75 hi = min(n_max, quick_clip_max) if (quick and quick_clip_max is not None) else n_max
76 if quick:
77 nb = bins or 4
78 return sorted({int(round(v)) for v in np.linspace(n_min, hi, num=nb)})
79 if n_max <= 40:
80 return list(range(n_min, n_max + 1, 5))
81 nb = bins or 8
82 return sorted({int(round(v)) for v in np.linspace(n_min, n_max, num=nb)})
83
84
85def _geom_A(n: int, d_jet: float) -> float:
86 return float(n) * np.pi * (d_jet / 2.0) ** 2
87
88
90 cfg,
91 n_O: int,
92 d_O: float,
93 n_F: int,
94 d_F: float,
95 *,
96 P_O_psi: float,
97 P_F_psi: float,
98) -> Optional[str]:
99 """Return skip tag if geometry is unrealistic before runner.evaluate(); else None.
100
101 Mirrors Layer-1 soft limits (area vs throat, packing, d_jet vs spacing) plus coarse
102 Bernoulli mdot proxies using tank PSI and a guessed Pc so obviously oversized A_eff
103 does not burn closure iterations.
104 """
105 try:
106 cg = ensure_chamber_geometry(cfg)
107 At = float(cg.A_throat or 0.0)
108 D_inner = float(cg.chamber_diameter or 0.0)
109 except Exception:
110 return None
111 if At <= 0 or not np.isfinite(At) or D_inner <= 0 or not np.isfinite(D_inner):
112 return None
113
114 A_go = _geom_A(n_O, d_O)
115 A_gf = _geom_A(n_F, d_F)
116
117 # --- 1) Geometric injector area vs throat (per-stream and combined) ---
118 # Layer-1 penalizes effective-area ratios ~0.95 vs throat; use geometric cap pre-eval.
119 if max(A_go, A_gf) / At > 0.92:
120 return "precheck:max_geometric_area_vs_throat"
121 if (A_go + A_gf) / At > 1.35:
122 return "precheck:sum_geometric_area_vs_throat"
123
124 # Cd-weighted capacity (discharge Cd_inf as upper-ish effective discharge)
125 try:
126 cd_o_cfg = float(getattr(cfg.discharge["oxidizer"], "Cd_inf", 0.4) or 0.4)
127 cd_f_cfg = float(getattr(cfg.discharge["fuel"], "Cd_inf", 0.3) or 0.3)
128 except Exception:
129 cd_o_cfg, cd_f_cfg = 0.4, 0.3
130 a_eff_o = max(cd_o_cfg, 0.08) * A_go
131 a_eff_f = max(cd_f_cfg, 0.08) * A_gf
132 if max(a_eff_o, a_eff_f) / At > 0.96:
133 return "precheck:max_Aeff_vs_throat"
134 if (a_eff_o + a_eff_f) / At > 1.15:
135 return "precheck:sum_Aeff_vs_throat"
136
137 # --- 2) Face packing (same form as Layer-1 infeasibility) ---
138 g = cfg.injector.geometry
139 sp_o = float(getattr(g.oxidizer, "spacing", 0.012) or 0.012)
140 sp_f = float(getattr(g.fuel, "spacing", 0.012) or 0.012)
141 face_a = np.pi * (D_inner / 2.0) ** 2
142 pack = float(n_O + n_F) * max(sp_o, sp_f) ** 2
143 if face_a > 0 and pack / face_a > 0.68:
144 return "precheck:packing_density"
145
146 # --- 3) Circumference / spacing vs d_jet ---
147 circ = np.pi * D_inner
148 if d_O >= sp_o * 0.998 or d_F >= sp_f * 0.998:
149 return "precheck:d_jet_ge_spacing"
150 if 2.0 * d_O > sp_o or 2.0 * d_F > sp_f:
151 return "precheck:layer1_spacing_rule_2p0dj"
152 # Jets cannot occupy more than ~full ring per stream (side-by-side diameter sum)
153 if n_O > 0 and n_O * d_O > 1.08 * circ:
154 return "precheck:n_O_d_O_vs_circ"
155 if n_F > 0 and n_F * d_F > 1.08 * circ:
156 return "precheck:n_F_d_F_vs_circ"
157 k_ring = 1.65
158 n_cap_o = max(int(circ / max(k_ring * d_O, 1e-9)), 1)
159 n_cap_f = max(int(circ / max(k_ring * d_F, 1e-9)), 1)
160 if n_O > n_cap_o + 2 or n_F > n_cap_f + 2:
161 return "precheck:ring_element_count"
162
163 # --- 4) Coarse mdot vs choked throat capacity (order-of-magnitude) ---
164 try:
165 rho_o = float(cfg.fluids["oxidizer"].density)
166 rho_f = float(cfg.fluids["fuel"].density)
167 except Exception:
168 rho_o, rho_f = 1140.0, 422.6
169 P_O = float(P_O_psi) * PSI_TO_PA
170 P_F = float(P_F_psi) * PSI_TO_PA
171 # Conservative chamber estimate: sub-fraction of weaker feed branch
172 Pc_est = min(P_O, P_F) * 0.38
173 Pc_est = max(Pc_est, 8.0e4)
174 cstar_typ = 1580.0 # LOX/CH4-class c* [m/s] for mdot_cap order-of-magnitude only
175 mdot_cap = max(Pc_est * At / cstar_typ, 1e-9)
176 dP_o = max(P_O - Pc_est, 0.07 * P_O, 4.0e4)
177 dP_f = max(P_F - Pc_est, 0.07 * P_F, 4.0e4)
178 md_o = a_eff_o * np.sqrt(max(2.0 * rho_o * dP_o, 0.0))
179 md_f = a_eff_f * np.sqrt(max(2.0 * rho_f * dP_f, 0.0))
180 if md_o > 2.3 * mdot_cap or md_f > 2.3 * mdot_cap:
181 return "precheck:mdot_proxy_stream_vs_throat"
182 if md_o + md_f > 2.85 * mdot_cap:
183 return "precheck:mdot_proxy_combined_vs_throat"
184
185 return None
186
187
189 n_O: int,
190 d_O: float,
191 n_F: int,
192 d_F: float,
193 P_O_psi: float,
194 P_F_psi: float,
195 err: str,
196) -> Row:
197 ag_o = _geom_A(n_O, d_O)
198 ag_f = _geom_A(n_F, d_F)
199 return Row(
200 n_O,
201 d_O,
202 n_F,
203 d_F,
204 P_O_psi,
205 P_F_psi,
206 False,
207 err,
208 np.nan,
209 np.nan,
210 np.nan,
211 np.nan,
212 np.nan,
213 np.nan,
214 np.nan,
215 np.nan,
216 np.nan,
217 np.nan,
218 ag_o,
219 ag_f,
220 )
221
222
223@dataclass
224class Row:
225 n_O: int
226 d_O: float
227 n_F: int
228 d_F: float
229 P_O_psi: float
230 P_F_psi: float
231 ok: bool
232 err: str
233 F: float
234 MR: float
235 Pc: float
236 r_O: float
237 r_F: float
238 R: float
239 Cd_O: float
240 Cd_F: float
241 A_eff_O: float
242 A_eff_F: float
243 A_geom_O: float
244 A_geom_F: float
245
246
247def _apply_impinging_geometry(cfg, n_O: int, d_O: float, n_F: int, d_F: float) -> None:
248 g = cfg.injector.geometry
249 g.oxidizer.n_elements = max(1, int(n_O))
250 g.oxidizer.d_jet = float(d_O)
251 g.fuel.n_elements = max(1, int(n_F))
252 g.fuel.d_jet = float(d_F)
253
254
256 base_cfg,
257 n_O: int,
258 d_O: float,
259 n_F: int,
260 d_F: float,
261 P_O_psi: float,
262 P_F_psi: float,
263) -> Row:
265 base_cfg,
266 n_O,
267 d_O,
268 n_F,
269 d_F,
270 P_O_psi=P_O_psi,
271 P_F_psi=P_F_psi,
272 )
273 if reason is not None:
274 return _failed_row(n_O, d_O, n_F, d_F, P_O_psi, P_F_psi, reason)
275
276 A_geom_O = _geom_A(n_O, d_O)
277 A_geom_F = _geom_A(n_F, d_F)
278 cfg = copy.deepcopy(base_cfg)
279 try:
280 _apply_impinging_geometry(cfg, n_O, d_O, n_F, d_F)
281 runner = PintleEngineRunner(cfg)
282 res = runner.evaluate(P_O_psi * PSI_TO_PA, P_F_psi * PSI_TO_PA, silent=True)
283 except Exception as e:
284 return _failed_row(
285 n_O,
286 d_O,
287 n_F,
288 d_F,
289 P_O_psi,
290 P_F_psi,
291 type(e).__name__ + ": " + str(e)[:120],
292 )
293
294 pc = float(res.get("Pc") or np.nan)
295 diag = res.get("diagnostics") if isinstance(res.get("diagnostics"), dict) else {}
296 ro, rf = injector_dp_ratios_from_eval_result(pc, res)
297 if ro is None:
298 ro = np.nan
299 if rf is None:
300 rf = np.nan
301 R = diag.get("momentum_ratio_R")
302 if R is None:
303 R = np.nan
304 cd_o = diag.get("Cd_O")
305 cd_f = diag.get("Cd_F")
306 if cd_o is None:
307 cd_o = np.nan
308 if cd_f is None:
309 cd_f = np.nan
310
311 ae_o, ae_f, _ = effective_flow_areas_from_cd(diag, A_geom_O, A_geom_F)
312
313 return Row(
314 n_O,
315 d_O,
316 n_F,
317 d_F,
318 P_O_psi,
319 P_F_psi,
320 True,
321 "",
322 float(res.get("F", np.nan)),
323 float(res.get("MR", np.nan)),
324 pc,
325 float(ro),
326 float(rf),
327 float(R),
328 float(cd_o),
329 float(cd_f),
330 float(ae_o),
331 float(ae_f),
332 A_geom_O,
333 A_geom_F,
334 )
335
336
338 r_o: float,
339 r_f: float,
340 lo: float = DP_BAND_LO,
341 hi: float = DP_BAND_HI,
342) -> bool:
343 return (
344 np.isfinite(r_o)
345 and np.isfinite(r_f)
346 and lo <= r_o <= hi
347 and lo <= r_f <= hi
348 )
349
350
351def _in_r_band(r: float, lo: float = R_BAND_LO, hi: float = R_BAND_HI) -> bool:
352 return np.isfinite(r) and lo <= r <= hi
353
354
356 row: Row,
357 F_t: float,
358 MR_t: float,
359 *,
360 f_rel: float = MISSION_F_REL,
361 mr_rel: float = MISSION_MR_REL,
362) -> bool:
363 """F/MR near targets, R in band, both ΔP/Pc streams in band."""
364 if not row.ok or not np.isfinite(row.F) or not np.isfinite(row.MR):
365 return False
366 if abs(row.F - F_t) > f_rel * F_t:
367 return False
368 if abs(row.MR - MR_t) > mr_rel * MR_t:
369 return False
370 if not _in_r_band(row.R):
371 return False
372 if not _in_dp_band(row.r_O, row.r_F):
373 return False
374 return True
375
376
378 row: Row,
379 F_t: float,
380 MR_t: float,
381 w_dp: float = 5.0,
382 *,
383 r_lo: float = R_BAND_LO,
384 r_hi: float = R_BAND_HI,
385 dp_lo: float = DP_BAND_LO,
386 dp_hi: float = DP_BAND_HI,
387) -> float:
388 """Lower is better (composite deviation from targets)."""
389 if not row.ok or not np.isfinite(row.F):
390 return 1e30
391 e_f = (row.F - F_t) ** 2 / max(F_t**2, 1.0)
392 e_mr = (row.MR - MR_t) ** 2 / max(MR_t**2, 1e-9)
393
394 def r_pen(r: float) -> float:
395 if not np.isfinite(r):
396 return 1.0
397 if r < r_lo:
398 return (r_lo - r) ** 2
399 if r > r_hi:
400 return (r - r_hi) ** 2
401 return 0.0
402
403 e_r = r_pen(row.R)
404
405 def dp_pen(r: float) -> float:
406 if not np.isfinite(r):
407 return 1.0
408 if r < dp_lo:
409 return (dp_lo - r) ** 2
410 if r > dp_hi:
411 return (r - dp_hi) ** 2
412 return 0.0
413
414 e_dp = dp_pen(row.r_O) + dp_pen(row.r_F)
415 return float(e_f + e_mr + 4.0 * e_r + w_dp * e_dp)
416
417
419 rows: List[Row],
420 base,
421 n_O_list: Sequence[int],
422 n_F_list: Sequence[int],
423 d_O_list: Sequence[float],
424 d_F_list: Sequence[float],
425 tank_grid: Sequence[Tuple[float, float]],
426) -> int:
427 """Append evaluations; returns total count for this grid."""
428 total = (
429 len(n_O_list)
430 * len(n_F_list)
431 * len(d_O_list)
432 * len(d_F_list)
433 * len(tank_grid)
434 )
435 done = 0
436 for po, pf in tank_grid:
437 for n_O in n_O_list:
438 for n_F in n_F_list:
439 for d_O in d_O_list:
440 for d_F in d_F_list:
441 rows.append(_one_eval(base, n_O, d_O, n_F, d_F, po, pf))
442 done += 1
443 if done % 500 == 0:
444 print(f" progress {done}/{total} …", flush=True)
445 return total
446
447
449 name: str,
450 *,
451 n_min: int,
452 n_max: int,
453 quick: bool,
454 P_O_yaml: float,
455 P_F_yaml: float,
456 max_lox_psi: float,
457 max_fuel_psi: float,
458 P_O_lo_l1: float,
459 P_O_hi_l1: float,
460 P_F_lo_l1: float,
461 P_F_hi_l1: float,
462 d_jet_upper_m: float = 0.004,
463) -> Tuple[List[int], List[int], List[float], List[float], List[Tuple[float, float]], str]:
464 """Return (n_O, n_F, d_O, d_F, tanks, description). n_max/d_jet_hi scale with chamber bore."""
465 # Default tank corners (Layer-1 style + YAML)
466 tanks_l1 = [
467 (P_O_yaml, P_F_yaml),
468 (P_O_lo_l1, P_F_lo_l1),
469 (P_O_hi_l1, P_F_hi_l1),
470 (P_O_lo_l1, P_F_hi_l1),
471 (P_O_hi_l1, P_F_lo_l1),
472 ((P_O_lo_l1 + P_O_hi_l1) / 2, (P_F_lo_l1 + P_F_hi_l1) / 2),
473 ]
474
475 if name == "widen_ox":
476 if quick:
477 n_O = [6, 18, 30, 42]
478 n_F = list(range(n_min, min(n_max + 1, 28), 6))
479 d_O = [0.0015, 0.0025, 0.0035, 0.0045, 0.0049]
480 d_F = [0.0012, 0.0020, 0.0028, 0.0034]
481 else:
482 n_O = [6, 10, 14, 18, 22, 26, 30, 34, 38, 42]
483 n_F = list(range(n_min, n_max + 1, 4))
484 d_O = [0.0010, 0.0018, 0.0026, 0.0034, 0.0040, 0.0046, 0.0049]
485 d_F = np.linspace(0.0009, 0.0036, num=6, dtype=float).tolist()
486 desc = (
487 "widen_ox: extend oxidizer side — n_O up to 42, d_jet_O up to 4.9 mm; "
488 "fuel n_F/d_F same class as Layer-1 (cap n_F by n_max). Tanks: Layer-1 corners + YAML."
489 )
490 return n_O, n_F, d_O, d_F, tanks_l1, desc
491
492 if name == "tighten_fuel":
493 n_F_hi = 16
494 if quick:
495 n_O = [6, 14, 22, 30, 34]
496 n_F = [6, 10, 14, n_F_hi]
497 d_O = [0.0012, 0.0022, 0.0032]
498 d_F = [0.0009, 0.0013, 0.0017, 0.0021, 0.0025]
499 else:
500 n_O = list(range(n_min, n_max + 1, 4))
501 n_F = [6, 8, 10, 12, 14, n_F_hi]
502 d_O = np.linspace(0.001, 0.0036, num=6, dtype=float).tolist()
503 d_F = [0.00085, 0.00115, 0.00145, 0.00175, 0.00205, 0.00235]
504 desc = (
505 "tighten_fuel: cap fuel injectors — n_F ≤ 16, d_jet_F ≤ ~2.35 mm (coarse grid); "
506 "oxidizer side unchanged span vs Layer-1. Tanks: Layer-1 corners + YAML."
507 )
508 return n_O, n_F, d_O, d_F, tanks_l1, desc
509
510 if name == "expand_tanks":
511 n_sym = _symmetric_n_values(n_min, n_max, quick=quick)
512 d_hi_grid = float(min(max(float(d_jet_upper_m), 0.0034), 0.0075))
513 if quick:
514 d_sym = [0.0014, 0.0022, min(0.0030, d_hi_grid)]
515 d_sym = sorted({float(round(d, 6)) for d in d_sym if d <= d_hi_grid + 1e-9})
516 if not d_sym:
517 d_sym = [0.001]
518 else:
519 d_sym = np.linspace(0.001, d_hi_grid, num=5, dtype=float).tolist()
520 # Higher LOX (up to ~98% catalog max), lower/higher fuel across wider range
521 tank_grid = [
522 (P_O_yaml, P_F_yaml),
523 (max_lox_psi * 0.92, max_fuel_psi * 0.48),
524 (max_lox_psi * 0.97, max_fuel_psi * 0.45),
525 (max_lox_psi * 0.88, max_fuel_psi * 0.90),
526 (max_lox_psi * 0.72, max_fuel_psi * 0.55),
527 (max_lox_psi * 0.82, max_fuel_psi * 0.40),
528 (max_lox_psi * 0.65, max_fuel_psi * 0.68),
529 (max_lox_psi * 0.55, max_fuel_psi * 0.78),
530 ]
531 desc = (
532 "expand_tanks: symmetric injector grid; tank grid extends LOX toward ~0.97·P_max_lox "
533 "and fuel toward ~0.40–0.90·P_max_fuel (outside strict Layer-1 0.65–0.85 band)."
534 )
535 return n_sym, n_sym, d_sym, d_sym, tank_grid, desc
536
537 if name == "asymmetric":
538 if quick:
539 n_O = [12, 22, 34, 42]
540 n_F = [6, 10, 14]
541 d_O = [0.0024, 0.0034, 0.0044]
542 d_F = [0.0010, 0.0016, 0.0022]
543 else:
544 n_O = [10, 18, 26, 34, 42]
545 n_F = [6, 8, 10, 12, 14, 16]
546 d_O = [0.0018, 0.0026, 0.0034, 0.0042, 0.0049]
547 d_F = [0.00095, 0.00135, 0.00175, 0.00215, 0.00255]
548 tank_grid = [
549 (P_O_yaml, P_F_yaml),
550 (max_lox_psi * 0.95, max_fuel_psi * 0.55),
551 (max_lox_psi * 0.92, max_fuel_psi * 0.48),
552 (max_lox_psi * 0.88, max_fuel_psi * 0.62),
553 (max_lox_psi * 0.78, max_fuel_psi * 0.42),
554 (P_O_hi_l1, max_fuel_psi * 0.50),
555 ]
556 desc = (
557 "asymmetric: large oxidizer / small fuel grid (explicit O–F imbalance) + tank pairs "
558 "favoring high LOX / moderate–low fuel feed pressure."
559 )
560 return n_O, n_F, d_O, d_F, tank_grid, desc
561
562 raise ValueError(f"unknown scenario {name!r}")
563
564
565def _stream_dp_ok(r: float) -> bool:
566 return np.isfinite(r) and DP_BAND_LO <= r <= DP_BAND_HI
567
568
569def _r_distance_to_band(r: float) -> float:
570 """0 if R in [R_BAND_LO, R_BAND_HI], else squared distance to nearest edge."""
571 if not np.isfinite(r):
572 return 1e6
573 if r < R_BAND_LO:
574 return float((R_BAND_LO - r) ** 2)
575 if r > R_BAND_HI:
576 return float((r - R_BAND_HI) ** 2)
577 return 0.0
578
579
581 ok_rows: List[Row],
582 band_rows: List[Row],
583 F_target: float,
584 MR_target: float,
585 *,
586 max_show: int = 8,
587) -> None:
588 """Pairwise-best rows and marginal constraint failure stats (successful evals only)."""
589 if not ok_rows:
590 return
591
592 print()
593 print("=== Extended feasibility analysis (pairwise bests + constraint margins) ===")
594
595 # --- Pairwise bests (ignore unstated constraints) ---
596 def fmt_geo(r: Row) -> str:
597 return (
598 f"nO={r.n_O} dO={r.d_O*1000:.2f}mm nF={r.n_F} dF={r.d_F*1000:.2f}mm "
599 f"tanks ({r.P_O_psi:.0f},{r.P_F_psi:.0f}) psi"
600 )
601
602 tf_mr_score = lambda r: (
603 ((r.F - F_target) / max(MISSION_F_REL * F_target, 1e-9)) ** 2
604 + ((r.MR - MR_target) / max(MISSION_MR_REL * MR_target, 1e-9)) ** 2
605 if np.isfinite(r.F) and np.isfinite(r.MR)
606 else np.inf
607 )
608 tf_mr_best = sorted(ok_rows, key=tf_mr_score)[:max_show]
609 print()
610 print(f"--- Best: thrust + O/F only (min Σ normalized² vs ±{MISSION_F_REL:.0%} gates) — top {max_show} ---")
611 for i, r in enumerate(tf_mr_best):
612 print(
613 f"{i+1:3d} F={r.F:.1f} MR={r.MR:.3f} R={r.R:.3f} "
614 f"rO={r.r_O:.3f} rF={r.r_F:.3f} {fmt_geo(r)}"
615 )
616
617 if band_rows:
618 tb = sorted(
619 band_rows,
620 key=lambda r: abs(r.F - F_target) / max(F_target, 1e-9),
621 )[:max_show]
622 print()
623 print(f"--- Best: thrust + ΔP/Pc band (both streams in band) — top {max_show} ---")
624 for i, r in enumerate(tb):
625 print(
626 f"{i+1:3d} |F−tgt|/tgt={(abs(r.F-F_target)/F_target)*100:.2f}% "
627 f"F={r.F:.1f} MR={r.MR:.3f} R={r.R:.3f} rO={r.r_O:.3f} rF={r.r_F:.3f} {fmt_geo(r)}"
628 )
629
630 ob = sorted(
631 band_rows,
632 key=lambda r: abs(r.MR - MR_target) / max(MR_target, 1e-9),
633 )[:max_show]
634 print()
635 print(f"--- Best: O/F + ΔP/Pc band — top {max_show} ---")
636 for i, r in enumerate(ob):
637 print(
638 f"{i+1:3d} |MR−tgt|/tgt={(abs(r.MR-MR_target)/MR_target)*100:.2f}% "
639 f"F={r.F:.1f} MR={r.MR:.3f} R={r.R:.3f} rO={r.r_O:.3f} rF={r.r_F:.3f} {fmt_geo(r)}"
640 )
641
642 rb = sorted(band_rows, key=lambda r: _r_distance_to_band(r.R))[:max_show]
643 print()
644 print(f"--- Best: momentum R + ΔP/Pc band (min distance of R to [{R_BAND_LO},{R_BAND_HI}]) — top {max_show} ---")
645 for i, r in enumerate(rb):
646 print(
647 f"{i+1:3d} R_dist={_r_distance_to_band(r.R):.5f} R={r.R:.3f} "
648 f"F={r.F:.1f} MR={r.MR:.3f} rO={r.r_O:.3f} rF={r.r_F:.3f} {fmt_geo(r)}"
649 )
650 else:
651 print()
652 print("--- Pairwise ΔP-band sections skipped (no ΔP/Pc-in-band rows) ---")
653
654 # --- Marginal constraint violations (counts among successful evaluations) ---
655 n = len(ok_rows)
656 c_thrust = c_mr = c_r = 0
657 c_ro_hi = c_ro_lo = c_rf_hi = c_rf_lo = 0
658 c_dp_pair = 0
659 for r in ok_rows:
660 if not (np.isfinite(r.F) and abs(r.F - F_target) <= MISSION_F_REL * F_target):
661 c_thrust += 1
662 if not (np.isfinite(r.MR) and abs(r.MR - MR_target) <= MISSION_MR_REL * MR_target):
663 c_mr += 1
664 if not _in_r_band(r.R):
665 c_r += 1
666 if np.isfinite(r.r_O):
667 if r.r_O > DP_BAND_HI:
668 c_ro_hi += 1
669 elif r.r_O < DP_BAND_LO:
670 c_ro_lo += 1
671 if np.isfinite(r.r_F):
672 if r.r_F > DP_BAND_HI:
673 c_rf_hi += 1
674 elif r.r_F < DP_BAND_LO:
675 c_rf_lo += 1
676 if _stream_dp_ok(r.r_O) and _stream_dp_ok(r.r_F):
677 c_dp_pair += 1
678
679 def pct(k: int) -> float:
680 return 100.0 * float(k) / float(max(n, 1))
681
682 print()
683 print(
684 f"--- Constraint margins among {n} successful evaluations "
685 f"(each line is fraction failing that condition; rows can fail multiple) ---"
686 )
687 print(f" Thrust outside ±{MISSION_F_REL:.0%} of {F_target:.0f} N: {c_thrust:6d} ({pct(c_thrust):5.1f}%)")
688 print(f" O/F outside ±{MISSION_MR_REL:.0%} of {MR_target:.3f}: {c_mr:6d} ({pct(c_mr):5.1f}%)")
689 print(f" R outside [{R_BAND_LO},{R_BAND_HI}]: {c_r:6d} ({pct(c_r):5.1f}%)")
690 print(f" Oxidizer ΔP/Pc > {DP_BAND_HI:.2f}: {c_ro_hi:6d} ({pct(c_ro_hi):5.1f}%)")
691 print(f" Oxidizer ΔP/Pc < {DP_BAND_LO:.2f}: {c_ro_lo:6d} ({pct(c_ro_lo):5.1f}%)")
692 print(f" Fuel ΔP/Pc > {DP_BAND_HI:.2f}: {c_rf_hi:6d} ({pct(c_rf_hi):5.1f}%)")
693 print(f" Fuel ΔP/Pc < {DP_BAND_LO:.2f}: {c_rf_lo:6d} ({pct(c_rf_lo):5.1f}%)")
694 print(
695 f" BOTH ΔP/Pc streams in [{DP_BAND_LO:.2f},{DP_BAND_HI:.2f}]: "
696 f"{c_dp_pair:6d} ({pct(c_dp_pair):5.1f}%)"
697 )
698
699
701 rows: List[Row],
702 F_target: float,
703 MR_target: float,
704 max_rows: int,
705 req: Dict[str, Any],
706 n_max: int,
707 P_O_lo: float,
708 P_O_hi: float,
709 P_F_lo: float,
710 P_F_hi: float,
711 scenario_footer: str = "",
712) -> None:
713 ok_rows = [r for r in rows if r.ok]
714 pre_rows = [r for r in rows if r.err.startswith("precheck:")]
715 eval_exc_rows = [r for r in rows if (not r.ok) and (not r.err.startswith("precheck:"))]
716 band_rows = [r for r in ok_rows if _in_dp_band(r.r_O, r.r_F)]
717 mission_rows = [r for r in ok_rows if _mission_all_met(r, F_target, MR_target)]
718
719 print(f"Total grid points: {len(rows)}")
720 print(f"Precheck skips (no evaluate): {len(pre_rows)}")
721 if pre_rows:
722 ctr = Counter(r.err for r in pre_rows)
723 for tag, k in ctr.most_common(12):
724 print(f" {tag}: {k}")
725 print(f"Evaluate attempts: {len(rows) - len(pre_rows)}")
726 print(f"Successful evaluates: {len(ok_rows)}")
727 print(f"Evaluate failures (exceptions): {len(eval_exc_rows)}")
728 print(f"Both ΔP/Pc in [{DP_BAND_LO:.2f}, {DP_BAND_HI:.2f}]: {len(band_rows)}")
729 print(f"Full mission criteria met (F, MR, R band, ΔP band): {len(mission_rows)}")
730 print()
731
732 if mission_rows:
733 print(f"--- Cases meeting ALL mission criteria — up to {min(20, len(mission_rows))} ---")
734 for i, r in enumerate(mission_rows[:20]):
735 print(
736 f"{i+1:3d} F={r.F:.1f} MR={r.MR:.3f} R={r.R:.3f} "
737 f"rO={r.r_O:.3f} rF={r.r_F:.3f} "
738 f"nO={r.n_O} dO={r.d_O*1000:.2f}mm nF={r.n_F} dF={r.d_F*1000:.2f}mm "
739 f"tanks ({r.P_O_psi:.0f},{r.P_F_psi:.0f}) psi"
740 )
741 print()
742
743 ok_sorted = sorted(ok_rows, key=lambda r: _distance_score(r, F_target, MR_target))
744 print(f"--- Closest cases (composite score, lower=better) — top {max_rows} ---")
745 for i, r in enumerate(ok_sorted[:max_rows]):
746 sc = _distance_score(r, F_target, MR_target)
747 print(
748 f"{i+1:3d} score={sc:.4g} F={r.F:.1f} MR={r.MR:.3f} R={r.R:.3f} "
749 f"rO={r.r_O:.3f} rF={r.r_F:.3f} nO={r.n_O} dO={r.d_O*1000:.2f}mm "
750 f"nF={r.n_F} dF={r.d_F*1000:.2f}mm tanks psi ({r.P_O_psi:.0f},{r.P_F_psi:.0f})"
751 )
752
753 if band_rows:
754 bs = sorted(band_rows, key=lambda r: _distance_score(r, F_target, MR_target, w_dp=0.0))
755 print()
756 print(f"--- Best among ΔP/Pc-in-band cases (no ΔP term in score) — top {min(15, max_rows)} ---")
757 for i, r in enumerate(bs[: min(15, max_rows)]):
758 sc = _distance_score(r, F_target, MR_target, w_dp=0.0)
759 print(
760 f"{i+1:3d} score={sc:.4g} F={r.F:.1f} MR={r.MR:.3f} R={r.R:.3f} "
761 f"rO={r.r_O:.3f} rF={r.r_F:.3f} nO={r.n_O} dO={r.d_O*1000:.2f}mm "
762 f"nF={r.n_F} dF={r.d_F*1000:.2f}mm tanks ({r.P_O_psi:.0f},{r.P_F_psi:.0f}) psi"
763 )
764 else:
765 print()
766 print(
767 f"No successful evaluation had BOTH streams with "
768 f"{DP_BAND_LO:.2f} <= ΔP/Pc <= {DP_BAND_HI:.2f}."
769 )
770
771 partial = [
772 r
773 for r in ok_rows
774 if _in_dp_band(r.r_O, r.r_F)
775 and _in_r_band(r.R)
776 and abs(r.F - F_target) <= MISSION_F_REL * F_target * 2
777 ]
778 partial.sort(key=lambda r: abs(r.MR - MR_target))
779 if partial:
780 print()
781 print(
782 "--- Among F±10%, ΔP band, R band: smallest |MR−MR_target| (diagnostic) — top 12 ---"
783 )
784 for i, r in enumerate(partial[:12]):
785 print(
786 f"{i+1:3d} |MR−tgt|={abs(r.MR-MR_target):.3f} F={r.F:.0f} MR={r.MR:.3f} R={r.R:.3f} "
787 f"rO={r.r_O:.3f} rF={r.r_F:.3f} nO={r.n_O} dO={r.d_O*1000:.2f} "
788 f"nF={r.n_F} dF={r.d_F*1000:.2f} ({r.P_O_psi:.0f},{r.P_F_psi:.0f}) psi"
789 )
790
792 ok_rows,
793 band_rows,
794 F_target,
795 MR_target,
796 max_show=min(10, max_rows),
797 )
798
799 print()
800 if scenario_footer:
801 print(scenario_footer)
802 print()
803 throat_mm2 = req.get("frozen_parameters", {}).get("A_throat_mm2")
804 if throat_mm2:
805 print(
806 f"Frozen throat: A_throat = {throat_mm2:.2f} mm² — choked throat sets mdot(Pc)."
807 )
808
809
810def main(argv: Optional[Sequence[str]] = None) -> None:
811 parser = argparse.ArgumentParser(description=__doc__)
812 parser.add_argument(
813 "--quick",
814 action="store_true",
815 help="Smaller grid (~few hundred evals)",
816 )
817 parser.add_argument(
818 "--max-rows",
819 type=int,
820 default=50,
821 help="How many top rows to print per category",
822 )
823 parser.add_argument(
824 "--tank-sweep",
825 action="store_true",
826 help="After main grid (YAML tanks), add Layer-1 tank-box corners on a sparse geometry grid (~hundreds of evals).",
827 )
828 parser.add_argument(
829 "--config",
830 type=str,
831 default="configs/impinging_lox_ch4_8000N.yaml",
832 help="Path to engine YAML (relative to repo root or absolute).",
833 )
834 parser.add_argument(
835 "--scenario",
836 type=str,
837 choices=("baseline", "widen_ox", "tighten_fuel", "expand_tanks", "asymmetric"),
838 default="baseline",
839 help=(
840 "baseline: symmetric Layer-1-style grid (+ optional --tank-sweep). "
841 "Other scenarios relax/adjust injector O/F bounds or tank PSI grid only "
842 "(physics unchanged)."
843 ),
844 )
845 args = parser.parse_args(argv)
846
847 cfg_path = Path(args.config)
848 if not cfg_path.is_absolute():
849 cfg_path = ROOT / cfg_path
850 base = load_config(cfg_path)
851 req = base.design_requirements.model_dump()
852 F_target = float(req.get("target_thrust", 4000.0))
853 MR_target = float(req.get("optimal_of_ratio", 3.2))
854 max_chamber_od = float(req.get("max_chamber_outer_diameter", 0.15))
855 max_lox_psi = float(req.get("max_lox_tank_pressure_psi") or 700.0)
856 max_fuel_psi = float(req.get("max_fuel_tank_pressure_psi") or 850.0)
857
858 D_inner_fb = impinging_chamber_inner_diameter_for_bounds(
859 base,
860 max_chamber_outer_diameter_m=max_chamber_od,
861 wall_thickness_m=TOTAL_WALL_THICKNESS_M,
862 )
863 n_hi_int = impinging_n_elements_hi_int(D_inner_fb)
864 d_jet_hi = impinging_d_jet_upper_bound_m(D_inner_fb)
865 spacing_hi = impinging_spacing_upper_bound_m(D_inner_fb)
866 n_min, n_max = 6, int(n_hi_int)
867
868 # YAML baseline tanks [psi]
869 P_O_yaml = float(base.lox_tank.initial_pressure_psi)
870 P_F_yaml = float(base.fuel_tank.initial_pressure_psi)
871
872 # Layer-1 pressure bands (same as optimization)
873 min_P_ratio, max_P_ratio = 0.65, 0.85
874 P_O_lo = max_lox_psi * min_P_ratio
875 P_O_hi = max_lox_psi * max_P_ratio
876 P_F_lo = max_fuel_psi * min_P_ratio
877 P_F_hi = max_fuel_psi * max_P_ratio
878
879 print("=== Impinging LOX/CH4 feasibility sweep ===")
880 print(f"Config: {cfg_path}")
881 print(f"Scenario: {args.scenario}")
882 print(f"Frozen chamber/throat/nozzle: from YAML (A_throat frozen_parameters / chamber_geometry).")
883 print(
884 f"Targets: F={F_target:.0f} N, MR={MR_target:.3f}, "
885 f"R in [{R_BAND_LO:.3f},{R_BAND_HI:.3f}], "
886 f"ΔP_inj/Pc per stream in [{DP_BAND_LO:.2f}, {DP_BAND_HI:.2f}]"
887 )
888 print(
889 f"Full mission hit (count): |F−F_target|/F_target ≤ {MISSION_F_REL:.0%}, "
890 f"|MR−MR_target|/MR_target ≤ {MISSION_MR_REL:.0%}, plus R band and ΔP band."
891 )
892 print(
893 f"Injector face inner Ø (bounds/packing ref): {D_inner_fb*1000:.2f} mm "
894 f"({D_inner_fb / 0.0254:.3f} in bore)"
895 )
896 print(
897 f"Scaled injector bounds (Layer-1 class): n_elements ≤ {n_hi_int}, "
898 f"d_jet ≤ {d_jet_hi*1000:.2f} mm, spacing ≤ {spacing_hi*1000:.2f} mm "
899 f"(packing uses face_a = π·D_inner²/4 from runner geometry)."
900 )
901 print(f"Layer-1 reference n_hi≈{float(n_hi_int):.1f} (oxidizer scenarios may exceed n_hi).")
902 print(f"Tank bands (Layer-1 ref): LOX [{P_O_lo:.1f}, {P_O_hi:.1f}] psi, fuel [{P_F_lo:.1f}, {P_F_hi:.1f}] psi")
903 print(f"YAML tanks: LOX={P_O_yaml:.1f} psi, fuel={P_F_yaml:.1f} psi")
904 print()
905
906 rows: List[Row] = []
907
908 if args.scenario == "baseline":
909 if args.quick:
910 n_list = _symmetric_n_values(n_min, n_max, quick=True, quick_clip_max=40)
911 d_list = sorted(
912 {round(x, 6) for x in [0.0010, 0.0015, 0.0020, 0.0025, min(0.0030, d_jet_hi)]}
913 )
914 tank_grid = [(P_O_yaml, P_F_yaml)]
915 else:
916 n_list = _symmetric_n_values(n_min, n_max, quick=False)
917 d_hi_use = float(min(max(d_jet_hi, 0.0036), 0.0075))
918 d_list = np.linspace(0.0008, d_hi_use, num=6, dtype=float).tolist()
919 tank_grid = [(P_O_yaml, P_F_yaml)]
920 total_main = len(n_list) ** 2 * len(d_list) ** 2 * len(tank_grid)
921 print(f"Main grid evaluations (symmetric): {total_main}")
922 run_grid_independent(rows, base, n_list, n_list, d_list, d_list, tank_grid)
923
924 if args.tank_sweep:
925 tank_corners = [
926 (P_O_yaml, P_F_yaml),
927 (P_O_lo, P_F_lo),
928 (P_O_hi, P_F_hi),
929 (P_O_lo, P_F_hi),
930 (P_O_hi, P_F_lo),
931 ((P_O_lo + P_O_hi) / 2, (P_F_lo + P_F_hi) / 2),
932 ]
933 n_sparse = _symmetric_n_values(n_min, n_max, quick=False, bins=6)
934 d_hi_sparse = float(min(max(d_jet_hi, 0.0034), 0.0075))
935 d_sparse = np.linspace(0.001, d_hi_sparse, num=4, dtype=float).tolist()
936 extra = len(n_sparse) ** 2 * len(d_sparse) ** 2 * len(tank_corners)
937 print(f"Tank-sweep add-on evaluations: {extra}")
938 run_grid_independent(rows, base, n_sparse, n_sparse, d_sparse, d_sparse, tank_corners)
939 scenario_footer = (
940 "baseline: symmetric Layer-1-class injector bounds; optional --tank-sweep adds Layer-1 tank corners."
941 )
942 else:
943 n_O, n_F, d_O, d_F, tank_grid, scen_desc = _scenario_spec(
944 args.scenario,
945 n_min=n_min,
946 n_max=n_max,
947 quick=args.quick,
948 P_O_yaml=P_O_yaml,
949 P_F_yaml=P_F_yaml,
950 max_lox_psi=max_lox_psi,
951 max_fuel_psi=max_fuel_psi,
952 P_O_lo_l1=P_O_lo,
953 P_O_hi_l1=P_O_hi,
954 P_F_lo_l1=P_F_lo,
955 P_F_hi_l1=P_F_hi,
956 d_jet_upper_m=d_jet_hi,
957 )
958 print(scen_desc)
959 total_main = (
960 len(n_O) * len(n_F) * len(d_O) * len(d_F) * len(tank_grid)
961 )
962 print(f"Scenario grid evaluations: {total_main}")
963 print(f" n_O ∈ {min(n_O)}…{max(n_O)} ({len(n_O)} pts), n_F ∈ {min(n_F)}…{max(n_F)} ({len(n_F)} pts)")
964 print(
965 f" d_O ∈ [{min(d_O)*1000:.2f},{max(d_O)*1000:.2f}] mm ({len(d_O)} pts), "
966 f"d_F ∈ [{min(d_F)*1000:.2f},{max(d_F)*1000:.2f}] mm ({len(d_F)} pts)"
967 )
968 print(f" tank combos: {len(tank_grid)}")
969 print()
970 run_grid_independent(rows, base, n_O, n_F, d_O, d_F, tank_grid)
971 scenario_footer = scen_desc
972
974 rows,
975 F_target,
976 MR_target,
977 args.max_rows,
978 req,
979 n_max,
980 P_O_lo,
981 P_O_hi,
982 P_F_lo,
983 P_F_hi,
984 scenario_footer=scenario_footer,
985 )
986
987
988if __name__ == "__main__":
989 main()
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
None _print_extended_feasibility_analysis(List[Row] ok_rows, List[Row] band_rows, float F_target, float MR_target, *, int max_show=8)
float _distance_score(Row row, float F_t, float MR_t, float w_dp=5.0, *, float r_lo=R_BAND_LO, float r_hi=R_BAND_HI, float dp_lo=DP_BAND_LO, float dp_hi=DP_BAND_HI)
Tuple[List[int], List[int], List[float], List[float], List[Tuple[float, float]], str] _scenario_spec(str name, *, int n_min, int n_max, bool quick, float P_O_yaml, float P_F_yaml, float max_lox_psi, float max_fuel_psi, float P_O_lo_l1, float P_O_hi_l1, float P_F_lo_l1, float P_F_hi_l1, float d_jet_upper_m=0.004)
None _print_report(List[Row] rows, float F_target, float MR_target, int max_rows, Dict[str, Any] req, int n_max, float P_O_lo, float P_O_hi, float P_F_lo, float P_F_hi, str scenario_footer="")
Row _one_eval(base_cfg, int n_O, float d_O, int n_F, float d_F, float P_O_psi, float P_F_psi)
Row _failed_row(int n_O, float d_O, int n_F, float d_F, float P_O_psi, float P_F_psi, str err)
List[int] _symmetric_n_values(int n_min, int n_max, *, bool quick, Optional[int] bins=None, Optional[int] quick_clip_max=None)
Optional[str] _precheck_impinging_skip_reason(cfg, int n_O, float d_O, int n_F, float d_F, *, float P_O_psi, float P_F_psi)
int run_grid_independent(List[Row] rows, base, Sequence[int] n_O_list, Sequence[int] n_F_list, Sequence[float] d_O_list, Sequence[float] d_F_list, Sequence[Tuple[float, float]] tank_grid)
bool _in_dp_band(float r_o, float r_f, float lo=DP_BAND_LO, float hi=DP_BAND_HI)
None _apply_impinging_geometry(cfg, int n_O, float d_O, int n_F, float d_F)
bool _mission_all_met(Row row, float F_t, float MR_t, *, float f_rel=MISSION_F_REL, float mr_rel=MISSION_MR_REL)
bool _in_r_band(float r, float lo=R_BAND_LO, float hi=R_BAND_HI)
int main()