40from typing
import Any, Dict, List, Optional, Sequence, Tuple
81 """Set throat area and rescale exit area so ``A_exit/A_throat == expansion_ratio`` (runner consistency check)."""
82 if cfg.chamber_geometry
is None:
83 raise ValueError(
"config.chamber_geometry is required")
84 cg = cfg.chamber_geometry
85 At = float(mm2) * 1.0e-6
86 eps = float(cg.expansion_ratio)
90 cg.exit_diameter = 2.0 * math.sqrt(
max(Ae, 1e-30) / math.pi)
91 nozzle = getattr(cfg,
"nozzle",
None)
92 if nozzle
is not None:
95 nozzle.expansion_ratio = eps
96 ed = getattr(nozzle,
"exit_diameter",
None)
98 nozzle.exit_diameter = float(cg.exit_diameter)
102 runner: PintleEngineRunner,
109 res = runner.evaluate(float(P_O_psi) * PSI_TO_PA, float(P_F_psi) * PSI_TO_PA, silent=
True)
110 except Exception
as e:
111 return {
"ok":
False,
"err": f
"{type(e).__name__}: {e}"}
112 F = float(res.get(
"F", float(
"nan")))
113 MR = float(res.get(
"MR", float(
"nan")))
114 pc = float(res.get(
"Pc", float(
"nan")))
115 if not math.isfinite(F)
or not math.isfinite(MR)
or not math.isfinite(pc)
or pc <= 0:
116 return {
"ok":
False,
"err":
"nonfinite F/MR/Pc"}
117 ro, rf = injector_dp_ratios_from_eval_result(pc, res)
118 diag = res.get(
"diagnostics")
if isinstance(res.get(
"diagnostics"), dict)
else {}
119 R = diag.get(
"momentum_ratio_R")
120 R_f = float(R)
if R
is not None and np.isfinite(R)
else float(
"nan")
121 m_err = math.hypot(F / F_t - 1.0, MR / MR_t - 1.0)
122 rfo = float(ro)
if ro
is not None and np.isfinite(ro)
else float(
"nan")
123 rff = float(rf)
if rf
is not None and np.isfinite(rf)
else float(
"nan")
138 runner: PintleEngineRunner,
141 b_o: Tuple[float, float],
142 b_f: Tuple[float, float],
149) -> Tuple[Optional[np.ndarray], float, int]:
150 from scipy.optimize
import differential_evolution, minimize
154 bounds = [(lo_o, hi_o), (lo_f, hi_f)]
158 def objective(xy: np.ndarray) -> float:
161 row =
_row_eval(runner, float(xy[0]), float(xy[1]), F_t, MR_t)
164 F, MR = row[
"F"], row[
"MR"]
165 return float((F / F_t - 1.0) ** 2 + (MR / MR_t - 1.0) ** 2)
168 ro_grid = (0.65, 0.85)
if quick_local
else (0.65, 0.75, 0.85)
169 rf_grid = (0.65, 0.85)
if quick_local
else (0.65, 0.75, 0.85)
172 starts.append([lo_o + r_o * (hi_o - lo_o), lo_f + r_f * (hi_f - lo_f)])
173 starts.append([(lo_o + hi_o) * 0.5, (lo_f + hi_f) * 0.5])
175 best_x: Optional[np.ndarray] =
None
177 fallback_x: Optional[np.ndarray] =
None
182 np.asarray(x0, dtype=float),
185 options={
"maxfun": maxfun_local,
"ftol": 1e-12},
187 xf = np.asarray(opt.x, dtype=float)
188 fv = float(opt.fun)
if np.isfinite(opt.fun)
else 1e12
189 if fv < fallback_fun
and np.all(np.isfinite(xf)):
192 if opt.success
and fv < best_fun:
199 if best_x
is None and fallback_x
is not None:
201 best_fun = fallback_fun
204 if best_x
is None or not np.all(np.isfinite(best_x)):
205 gx: Optional[np.ndarray] =
None
207 for Po
in np.linspace(lo_o, hi_o, num=11):
208 for Pf
in np.linspace(lo_f, hi_f, num=11):
209 v = objective(np.asarray([Po, Pf], dtype=float))
212 gx = np.asarray([Po, Pf], dtype=float)
213 if gx
is not None and gf < 1e11:
217 if de_fallback
and best_fun > 2.5e-3:
218 de = differential_evolution(
221 maxiter=int(de_maxiter),
228 if de.fun < best_fun:
229 best_fun = float(de.fun)
230 best_x = np.asarray(de.x, dtype=float)
232 return best_x, best_fun, nfev
236 rows: Sequence[Dict[str, Any]],
239) -> List[Dict[str, Any]]:
240 """Minimize both m_err and ΔP_F/Pc; keep non-dominated among m_err <= cap."""
242 def _rf(row: Dict[str, Any]) -> float:
243 v = row.get(
"dP_F_over_Pc",
"")
244 if v ==
"" or v
is None:
247 return x
if math.isfinite(x)
else 1e9
249 pts = [r
for r
in rows
if r.get(
"ok")
and float(r.get(
"m_err", 1e9)) <= m_err_cap]
252 nd: List[Dict[str, Any]] = []
253 for i, p
in enumerate(pts):
254 mi = float(p[
"m_err"])
257 for j, q
in enumerate(pts):
260 mj = float(q[
"m_err"])
262 if (mj <= mi
and rj <= ri)
and (mj < mi
or rj < ri):
267 nd.sort(key=
lambda r: (float(r[
"m_err"]), _rf(r)))
272 ap = argparse.ArgumentParser(description=__doc__)
273 ap.add_argument(
"--config", type=str, default=str(ROOT /
"configs/impinging_lox_ch4_8000N.yaml"))
274 ap.add_argument(
"--target-F", type=float, default=8000.0)
275 ap.add_argument(
"--target-MR", type=float, default=3.5)
279 default=
"700:700,700:600,700:500",
280 help=
"Comma-separated lox_cap:fuel_cap [psi] design maxima for pressure bounds",
282 ap.add_argument(
"--p-lo-frac", type=float, default=0.62, help=
"Lower fraction of cap for P bounds")
283 ap.add_argument(
"--p-hi-frac", type=float, default=0.92, help=
"Upper fraction of cap for P bounds")
284 ap.add_argument(
"--throat-span", type=float, default=0.125, help=
"Fractional ± span around center A_throat")
285 ap.add_argument(
"--n-throat", type=int, default=9, help=
"Number of throat samples (linspace)")
286 ap.add_argument(
"--quick", action=
"store_true", help=
"Fewer throat points (5) and no DE fallback")
287 ap.add_argument(
"--de-fallback", action=
"store_true", help=
"Run differential_evolution if L-BFGS-B residual is high")
288 ap.add_argument(
"--de-maxiter", type=int, default=18)
289 ap.add_argument(
"--de-pop", type=int, default=8)
290 ap.add_argument(
"--maxfun-local", type=int, default=100)
294 default=str(ROOT /
"output/pareto_throat_dp_pressures.csv"),
296 ap.add_argument(
"--m-err-cap", type=float, default=0.035, help=
"Mission err cap for Pareto slice (L2 on F/MR frac)")
297 args = ap.parse_args()
299 cfg_path = Path(args.config)
300 if not cfg_path.is_absolute():
301 cfg_path = ROOT / cfg_path
304 n_throat = 5
if args.quick
else max(3, int(args.n_throat))
305 de_fb = bool(args.de_fallback)
and not args.quick
307 base = load_config(str(cfg_path))
309 lo_mm2 = center_mm2 * (1.0 - float(args.throat_span))
310 hi_mm2 = center_mm2 * (1.0 + float(args.throat_span))
311 throat_grid = np.linspace(lo_mm2, hi_mm2, num=n_throat, dtype=float)
313 if base.chamber_geometry
is not None:
314 base.chamber_geometry.design_MR = float(args.target_MR)
316 F_t = float(args.target_F)
317 MR_t = float(args.target_MR)
318 lo_f = float(args.p_lo_frac)
319 hi_f = float(args.p_hi_frac)
321 out_path = Path(args.output)
322 out_path.parent.mkdir(parents=
True, exist_ok=
True)
324 all_rows: List[Dict[str, Any]] = []
326 print(
"=== Pareto throat × tank-cap study (pressures solved for F/MR) ===")
327 print(f
"Config: {cfg_path}")
328 print(f
"Target: F={F_t:.0f} N, MR={MR_t:.3f}")
329 print(f
"A_throat center={center_mm2:.2f} mm², sweep [{lo_mm2:.2f}, {hi_mm2:.2f}] mm², n={n_throat}")
330 print(f
"Tank scenarios [psi] (max): {scenarios}")
331 print(f
"Pressure search: [{lo_f:.2f},{hi_f:.2f}] × cap per stream; DE fallback={de_fb}")
334 for lox_cap, fuel_cap
in scenarios:
335 b_o = (lox_cap * lo_f, lox_cap * hi_f)
336 b_f = (fuel_cap * lo_f, fuel_cap * hi_f)
337 print(f
"--- Scenario LOX_cap={lox_cap:.0f} FUEL_cap={fuel_cap:.0f} psi; bounds O [{b_o[0]:.1f},{b_o[1]:.1f}], F [{b_f[0]:.1f},{b_f[1]:.1f}] ---")
339 for at_mm2
in throat_grid:
340 cfg = copy.deepcopy(base)
351 de_maxiter=args.de_maxiter,
353 maxfun_local=60
if args.quick
else int(args.maxfun_local),
354 quick_local=bool(args.quick),
361 "fuel_cap": fuel_cap,
362 "A_throat_mm2": float(at_mm2),
374 "notes":
"no_solver_result",
377 print(f
" At={at_mm2:.1f} mm² FAIL (no solver result)")
380 rowd =
_row_eval(runner, float(x_best[0]), float(x_best[1]), F_t, MR_t)
383 if ok
and rowd[
"r_F"] > 1.5:
385 if ok
and (rowd[
"r_O"] < 0.05
or rowd[
"r_F"] < 0.05):
386 note = (note +
"; " if note
else "") +
"tiny_deltaP_stream"
390 "fuel_cap": fuel_cap,
391 "A_throat_mm2": float(at_mm2),
392 "P_O_psi": float(x_best[0])
if ok
else "",
393 "P_F_psi": float(x_best[1])
if ok
else "",
394 "sq_err_FM": float(sq_err),
395 "F_N": rowd.get(
"F",
"")
if ok
else "",
396 "MR": rowd.get(
"MR",
"")
if ok
else "",
397 "Pc_MPa": rowd.get(
"Pc",
"") / 1e6
if ok
else "",
398 "dP_O_over_Pc": rowd.get(
"r_O",
"")
if ok
else "",
399 "dP_F_over_Pc": rowd.get(
"r_F",
"")
if ok
else "",
400 "R": rowd.get(
"R",
"")
if ok
else "",
401 "m_err": rowd.get(
"m_err",
"")
if ok
else "",
403 "notes": note
or rowd.get(
"err",
""),
409 f
" At={at_mm2:.1f} mm² P=({x_best[0]:.1f},{x_best[1]:.1f}) psi "
410 f
"F={rowd['F']:.0f} MR={rowd['MR']:.3f} m_err={rowd['m_err']:.4f} "
411 f
"dP_O={rowd['r_O']:.3f} dP_F={rowd['r_F']:.3f} R={rowd['R']:.3f} sq={sq_err:.2e}"
414 print(f
" At={at_mm2:.1f} mm² eval_fail: {rowd.get('err','')}")
434 with open(out_path,
"w", newline=
"", encoding=
"utf-8")
as f:
435 w = csv.DictWriter(f, fieldnames=fieldnames, extrasaction=
"ignore")
440 nd =
_pareto_nd([r
for r
in all_rows
if r.get(
"ok")], m_err_cap=float(args.m_err_cap))
441 print(
"=== Non-dominated (m_err, ΔP_F/Pc), m_err ≤ {:.3f} ===".
format(float(args.m_err_cap)))
443 print(
"(none — relax --m-err-cap or improve pressure bounds / physics envelope)")
447 f
" caps({r['lox_cap']:.0f},{r['fuel_cap']:.0f}) At={r['A_throat_mm2']:.1f} mm² "
448 f
"m_err={float(r['m_err']):.4f} dP_F={float(r['dP_F_over_Pc']):.3f} "
449 f
"P=({float(r['P_O_psi']):.1f},{float(r['P_F_psi']):.1f}) psi"
452 print(f
"\nCSV: {out_path}")
Tuple[Optional[np.ndarray], float, int] _solve_pressures(PintleEngineRunner runner, float F_t, float MR_t, Tuple[float, float] b_o, Tuple[float, float] b_f, *, bool de_fallback, int de_maxiter, int de_pop, int maxfun_local, bool quick_local)