STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
pareto_throat_dp_pressures.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Pareto-style trade study: throat area × tank-cap philosophy → ΔP/Pc (pressures solved).
3
4Workflow (automates the recommendation: fix feed/tank envelope, sweep throat, *then*
5interpret geometry):
6
71. Load YAML (default ``configs/impinging_lox_ch4_8000N.yaml``); **injector geometry** from
8 config (unchanged unless you edit YAML first).
92. For each **tank scenario** ``lox_cap:fuel_cap`` [psi], build Layer-1-style **pressure bounds**
10 ``[lo_frac, hi_frac]`` of each cap (defaults 0.62–0.92).
113. **1-D sweep** of ``A_throat`` [mm²] from ``center × (1 ± span)``.
124. For each grid point, **multi-start L-BFGS-B** on ``(P_O, P_F)`` [psi] minimizes
13 ``(F/F_target−1)² + (MR/MR_target−1)²``. Optional **differential_evolution** polish if residual
14 is still large (``--de-fallback``).
155. Writes ``output/pareto_throat_dp_pressures.csv`` and prints rows + a small **non-dominated**
16 set on ``(mission_err, ΔP_F/Pc)`` among near-target points.
17
18**Note:** Only ``chamber_geometry.A_throat`` is updated per row; chamber volume / L* are not
19re-solved here—use for **trends** and pressure–throat coupling, not final hardware sign-off.
20Exit area is scaled so ``A_exit / A_throat`` stays equal to YAML ``expansion_ratio``.
21
22The pressure objective matches **F** and **MR** only; **momentum ratio R** is diagnostic.
23Tune jets using ``feasibility_impinging_lox_ch4_sweep.py`` (or Layer‑1) after picking a throat
24and tank-cap envelope from this script.
25
26Examples::
27
28 python scripts/pareto_throat_dp_pressures.py --quick
29 python scripts/pareto_throat_dp_pressures.py --tank-scenarios 700:700,700:600,700:500 --n-throat 11
30"""
31
32from __future__ import annotations
33
34import argparse
35import copy
36import csv
37import math
38import sys
39from pathlib import Path
40from typing import Any, Dict, List, Optional, Sequence, Tuple
41
42import numpy as np
43
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 # noqa: E402
49from engine.optimizer.injector_dp_penalty import injector_dp_ratios_from_eval_result # noqa: E402
50from engine.pipeline.io import load_config # noqa: E402
51
52PSI_TO_PA = 6894.76
53
54
55def _parse_tank_scenarios(s: str) -> List[Tuple[float, float]]:
56 out: List[Tuple[float, float]] = []
57 for part in s.split(","):
58 part = part.strip()
59 if not part:
60 continue
61 if ":" not in part:
62 raise ValueError(f"Bad tank scenario {part!r}; use lox_cap:fuel_cap (e.g. 700:600)")
63 a, b = part.split(":", 1)
64 out.append((float(a.strip()), float(b.strip())))
65 if not out:
66 raise ValueError("No tank scenarios parsed")
67 return out
68
69
70def _throat_center_mm2(cfg: Any) -> float:
71 req = cfg.design_requirements.model_dump()
72 fp = req.get("frozen_parameters") or {}
73 if isinstance(fp, dict) and fp.get("A_throat_mm2") is not None:
74 return float(fp["A_throat_mm2"])
75 if cfg.chamber_geometry is not None and cfg.chamber_geometry.A_throat is not None:
76 return float(cfg.chamber_geometry.A_throat) * 1.0e6
77 raise ValueError("Cannot infer throat center: no frozen A_throat_mm2 or chamber_geometry.A_throat")
78
79
80def _apply_throat_mm2(cfg: Any, mm2: float) -> None:
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)
87 Ae = eps * At
88 cg.A_throat = At
89 cg.A_exit = Ae
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:
93 nozzle.A_throat = At
94 nozzle.A_exit = Ae
95 nozzle.expansion_ratio = eps
96 ed = getattr(nozzle, "exit_diameter", None)
97 if ed is not None:
98 nozzle.exit_diameter = float(cg.exit_diameter)
99
100
102 runner: PintleEngineRunner,
103 P_O_psi: float,
104 P_F_psi: float,
105 F_t: float,
106 MR_t: float,
107) -> Dict[str, Any]:
108 try:
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")
124 return {
125 "ok": True,
126 "err": "",
127 "F": F,
128 "MR": MR,
129 "Pc": pc,
130 "r_O": rfo,
131 "r_F": rff,
132 "R": R_f,
133 "m_err": m_err,
134 }
135
136
138 runner: PintleEngineRunner,
139 F_t: float,
140 MR_t: float,
141 b_o: Tuple[float, float],
142 b_f: Tuple[float, float],
143 *,
144 de_fallback: bool,
145 de_maxiter: int,
146 de_pop: int,
147 maxfun_local: int,
148 quick_local: bool,
149) -> Tuple[Optional[np.ndarray], float, int]:
150 from scipy.optimize import differential_evolution, minimize
151
152 lo_o, hi_o = b_o
153 lo_f, hi_f = b_f
154 bounds = [(lo_o, hi_o), (lo_f, hi_f)]
155
156 nfev = 0
157
158 def objective(xy: np.ndarray) -> float:
159 nonlocal nfev
160 nfev += 1
161 row = _row_eval(runner, float(xy[0]), float(xy[1]), F_t, MR_t)
162 if not row["ok"]:
163 return 1e12
164 F, MR = row["F"], row["MR"]
165 return float((F / F_t - 1.0) ** 2 + (MR / MR_t - 1.0) ** 2)
166
167 starts = []
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)
170 for r_o in ro_grid:
171 for r_f in rf_grid:
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])
174
175 best_x: Optional[np.ndarray] = None
176 best_fun = 1e12
177 fallback_x: Optional[np.ndarray] = None
178 fallback_fun = 1e12
179 for x0 in starts:
180 opt = minimize(
181 objective,
182 np.asarray(x0, dtype=float),
183 method="L-BFGS-B",
184 bounds=bounds,
185 options={"maxfun": maxfun_local, "ftol": 1e-12},
186 )
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)):
190 fallback_fun = fv
191 fallback_x = xf
192 if opt.success and fv < best_fun:
193 best_fun = fv
194 best_x = xf
195 elif fv < best_fun:
196 best_fun = fv
197 best_x = xf
198
199 if best_x is None and fallback_x is not None:
200 best_x = fallback_x
201 best_fun = fallback_fun
202
203 # Coarse grid if optimizers never produced a finite iterate (stiff/noisy objective).
204 if best_x is None or not np.all(np.isfinite(best_x)):
205 gx: Optional[np.ndarray] = None
206 gf = 1e12
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))
210 if v < gf:
211 gf = float(v)
212 gx = np.asarray([Po, Pf], dtype=float)
213 if gx is not None and gf < 1e11:
214 best_x = gx
215 best_fun = gf
216
217 if de_fallback and best_fun > 2.5e-3: # ~5% combined scale trigger
218 de = differential_evolution(
219 objective,
220 bounds,
221 maxiter=int(de_maxiter),
222 popsize=int(de_pop),
223 polish=True,
224 seed=42,
225 atol=1e-5,
226 tol=1e-4,
227 )
228 if de.fun < best_fun:
229 best_fun = float(de.fun)
230 best_x = np.asarray(de.x, dtype=float)
231
232 return best_x, best_fun, nfev
233
234
236 rows: Sequence[Dict[str, Any]],
237 *,
238 m_err_cap: float,
239) -> List[Dict[str, Any]]:
240 """Minimize both m_err and ΔP_F/Pc; keep non-dominated among m_err <= cap."""
241
242 def _rf(row: Dict[str, Any]) -> float:
243 v = row.get("dP_F_over_Pc", "")
244 if v == "" or v is None:
245 return 1e9
246 x = float(v)
247 return x if math.isfinite(x) else 1e9
248
249 pts = [r for r in rows if r.get("ok") and float(r.get("m_err", 1e9)) <= m_err_cap]
250 if not pts:
251 return []
252 nd: List[Dict[str, Any]] = []
253 for i, p in enumerate(pts):
254 mi = float(p["m_err"])
255 ri = _rf(p)
256 dominated = False
257 for j, q in enumerate(pts):
258 if i == j:
259 continue
260 mj = float(q["m_err"])
261 rj = _rf(q)
262 if (mj <= mi and rj <= ri) and (mj < mi or rj < ri):
263 dominated = True
264 break
265 if not dominated:
266 nd.append(p)
267 nd.sort(key=lambda r: (float(r["m_err"]), _rf(r)))
268 return nd
269
270
271def main() -> None:
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)
276 ap.add_argument(
277 "--tank-scenarios",
278 type=str,
279 default="700:700,700:600,700:500",
280 help="Comma-separated lox_cap:fuel_cap [psi] design maxima for pressure bounds",
281 )
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)
291 ap.add_argument(
292 "--output",
293 type=str,
294 default=str(ROOT / "output/pareto_throat_dp_pressures.csv"),
295 )
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()
298
299 cfg_path = Path(args.config)
300 if not cfg_path.is_absolute():
301 cfg_path = ROOT / cfg_path
302
303 scenarios = _parse_tank_scenarios(args.tank_scenarios)
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
306
307 base = load_config(str(cfg_path))
308 center_mm2 = _throat_center_mm2(base)
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)
312
313 if base.chamber_geometry is not None:
314 base.chamber_geometry.design_MR = float(args.target_MR)
315
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)
320
321 out_path = Path(args.output)
322 out_path.parent.mkdir(parents=True, exist_ok=True)
323
324 all_rows: List[Dict[str, Any]] = []
325
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}")
332 print()
333
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}] ---")
338
339 for at_mm2 in throat_grid:
340 cfg = copy.deepcopy(base)
341 _apply_throat_mm2(cfg, float(at_mm2))
342 runner = PintleEngineRunner(cfg)
343
344 x_best, sq_err, _nfev = _solve_pressures(
345 runner,
346 F_t,
347 MR_t,
348 b_o,
349 b_f,
350 de_fallback=de_fb,
351 de_maxiter=args.de_maxiter,
352 de_pop=args.de_pop,
353 maxfun_local=60 if args.quick else int(args.maxfun_local),
354 quick_local=bool(args.quick),
355 )
356
357 if x_best is None:
358 all_rows.append(
359 {
360 "lox_cap": lox_cap,
361 "fuel_cap": fuel_cap,
362 "A_throat_mm2": float(at_mm2),
363 "P_O_psi": "",
364 "P_F_psi": "",
365 "sq_err_FM": "",
366 "F_N": "",
367 "MR": "",
368 "Pc_MPa": "",
369 "dP_O_over_Pc": "",
370 "dP_F_over_Pc": "",
371 "R": "",
372 "m_err": "",
373 "ok": False,
374 "notes": "no_solver_result",
375 }
376 )
377 print(f" At={at_mm2:.1f} mm² FAIL (no solver result)")
378 continue
379
380 rowd = _row_eval(runner, float(x_best[0]), float(x_best[1]), F_t, MR_t)
381 ok = rowd["ok"]
382 note = ""
383 if ok and rowd["r_F"] > 1.5:
384 note = "high_dP_F"
385 if ok and (rowd["r_O"] < 0.05 or rowd["r_F"] < 0.05):
386 note = (note + "; " if note else "") + "tiny_deltaP_stream"
387
388 rec = {
389 "lox_cap": lox_cap,
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 "",
402 "ok": ok,
403 "notes": note or rowd.get("err", ""),
404 }
405 all_rows.append(rec)
406
407 if ok:
408 print(
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}"
412 )
413 else:
414 print(f" At={at_mm2:.1f} mm² eval_fail: {rowd.get('err','')}")
415 print()
416
417 fieldnames = [
418 "lox_cap",
419 "fuel_cap",
420 "A_throat_mm2",
421 "P_O_psi",
422 "P_F_psi",
423 "sq_err_FM",
424 "F_N",
425 "MR",
426 "Pc_MPa",
427 "dP_O_over_Pc",
428 "dP_F_over_Pc",
429 "R",
430 "m_err",
431 "ok",
432 "notes",
433 ]
434 with open(out_path, "w", newline="", encoding="utf-8") as f:
435 w = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
436 w.writeheader()
437 for r in all_rows:
438 w.writerow(r)
439
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)))
442 if not nd:
443 print("(none — relax --m-err-cap or improve pressure bounds / physics envelope)")
444 else:
445 for r in nd[:20]:
446 print(
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"
450 )
451
452 print(f"\nCSV: {out_path}")
453
454
455if __name__ == "__main__":
456 main()
#define max(a, b)
Definition Arduino.h:43
static emmc_status_t format(emmc_log_t *log)
Definition emmc_log.c:324
List[Tuple[float, float]] _parse_tank_scenarios(str s)
Dict[str, Any] _row_eval(PintleEngineRunner runner, float P_O_psi, float P_F_psi, float F_t, float MR_t)
None _apply_throat_mm2(Any cfg, float mm2)
List[Dict[str, Any]] _pareto_nd(Sequence[Dict[str, Any]] rows, *, float m_err_cap)
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)