128 cfg = load_config(str(cfg_template))
130 p_to = float(P_tank_O_psi) * PSI_TO_PA
131 p_tf = float(P_tank_F_psi) * PSI_TO_PA
133 row: Dict[str, Any] = {
134 "P_tank_O_psi": float(P_tank_O_psi),
135 "P_tank_F_psi": float(P_tank_F_psi),
136 "source_iter": int(h.get(
"iteration", -2)),
143 res = runner.evaluate(p_to, p_tf, silent=
True)
144 except Exception
as e:
145 row[
"reject_reason"] = f
"eval_exception:{e}"
148 pc_pa = float(res.get(
"Pc", float(
"nan")))
149 if not np.isfinite(pc_pa)
or pc_pa <= 0:
150 row[
"reject_reason"] =
"invalid_pc"
155 d_inj_O =
_inj_diag(res,
"delta_p_injector_O")
156 d_inj_F =
_inj_diag(res,
"delta_p_injector_F")
157 d_fd_O =
_inj_diag(res,
"delta_p_feed_O")
158 d_fd_F =
_inj_diag(res,
"delta_p_feed_F")
160 if None in (P_inj_O, P_inj_F, d_inj_O, d_inj_F, d_fd_O, d_fd_F):
161 row[
"reject_reason"] =
"missing_pressure_diagnostics"
165 d_feed_geom_O = p_to - P_inj_O
166 d_feed_geom_F = p_tf - P_inj_F
167 if d_fd_O < -1e-3
or d_fd_F < -1e-3:
168 rej.append(
"diag_negative_delta_p_feed")
169 if d_feed_geom_O < -1e-3:
170 rej.append(
"negative_feed_allocation_O(P_inj>P_tank)")
171 if d_feed_geom_F < -1e-3:
172 rej.append(
"negative_feed_allocation_F(P_inj>P_tank)")
174 dp_tot_O = p_to - pc_pa
175 dp_tot_F = p_tf - pc_pa
176 f_feed_O = (d_fd_O / dp_tot_O)
if dp_tot_O > 1000.0
else float(
"nan")
177 f_feed_F = (d_fd_F / dp_tot_F)
if dp_tot_F > 1000.0
else float(
"nan")
179 budget_res_O = dp_tot_O - d_fd_O - d_inj_O
180 budget_res_F = dp_tot_F - d_fd_F - d_inj_F
182 ratio_o = (d_inj_O / pc_pa)
if pc_pa > 0
else float(
"nan")
183 ratio_f = (d_inj_F / pc_pa)
if pc_pa > 0
else float(
"nan")
184 ro, rf = injector_dp_ratios_from_eval_result(pc_pa, res)
186 diag = res.get(
"diagnostics")
if isinstance(res.get(
"diagnostics"), dict)
else {}
187 R = diag.get(
"momentum_ratio_R")
188 Rv = float(R)
if R
is not None and np.isfinite(R)
else float(
"nan")
189 Fo = float(res.get(
"F", float(
"nan")))
190 MRo = float(res.get(
"MR", float(
"nan")))
192 ig = cfg.injector.geometry
194 f
"LOX n={ig.oxidizer.n_elements} d={ig.oxidizer.d_jet*1000:.4f}mm "
195 f
"sp={ig.oxidizer.spacing*1000:.4f}mm θ={ig.oxidizer.impingement_angle:.2f}° | "
196 f
"F n={ig.fuel.n_elements} d={ig.fuel.d_jet*1000:.4f}mm "
197 f
"sp={ig.fuel.spacing*1000:.4f}mm θ={ig.fuel.impingement_angle:.2f}°"
200 tgt_ok_o = dp_inj_pc_lo <= ratio_o <= dp_inj_pc_hi
if np.isfinite(ratio_o)
else False
201 tgt_ok_f = dp_inj_pc_lo <= ratio_f <= dp_inj_pc_hi
if np.isfinite(ratio_f)
else False
202 mr_ok = abs(MRo / MR_TARGET - 1.0) <= 0.06
if np.isfinite(MRo)
else False
203 r_ok = abs(Rv - 1.0) <= 0.10
if np.isfinite(Rv)
else False
210 "Pc_psi": pc_pa * PA_TO_PSI,
212 "P_inj_O_psi": P_inj_O * PA_TO_PSI,
213 "P_inj_F_psi": P_inj_F * PA_TO_PSI,
214 "delta_p_inj_O_psi": d_inj_O * PA_TO_PSI,
215 "delta_p_inj_F_psi": d_inj_F * PA_TO_PSI,
216 "delta_p_feed_O_psi": d_fd_O * PA_TO_PSI,
217 "delta_p_feed_F_psi": d_fd_F * PA_TO_PSI,
218 "delta_P_tank_minus_Pc_O_psi": dp_tot_O * PA_TO_PSI,
219 "delta_P_tank_minus_Pc_F_psi": dp_tot_F * PA_TO_PSI,
220 "dP_inj_over_Pc_O": ratio_o,
221 "dP_inj_over_Pc_F": ratio_f,
222 "dP_inj_over_Pc_solver_O": float(ro)
if ro
is not None and np.isfinite(ro)
else None,
223 "dP_inj_over_Pc_solver_F": float(rf)
if rf
is not None and np.isfinite(rf)
else None,
224 "feed_loss_fraction_O": f_feed_O,
225 "feed_loss_fraction_F": f_feed_F,
226 "budget_residual_O_Pa": budget_res_O,
227 "budget_residual_F_Pa": budget_res_F,
228 "A_throat_mm2": float(h[
"A_throat"]) * 1e6,
229 "n_O": int(h[
"n_elements_O"]),
230 "n_F": int(h[
"n_elements_F"]),
231 "d_jet_O_mm": float(h[
"d_jet_O"]) * 1000,
232 "d_jet_F_mm": float(h[
"d_jet_F"]) * 1000,
233 "spacing_O_mm": float(h[
"spacing_O"]) * 1000,
234 "spacing_F_mm": float(h[
"spacing_F"]) * 1000,
235 "theta_O_deg": float(h[
"impingement_angle_O"]),
236 "theta_F_deg": float(h[
"impingement_angle_F"]),
237 "goal_dp_band_O": tgt_ok_o,
238 "goal_dp_band_F": tgt_ok_f,
239 "goal_mr_3p5_tol6pct": mr_ok,
240 "goal_R_unity_tol10pct": r_ok,
245 row[
"accepted"] = len(rej) == 0
246 row[
"reject_reason"] =
";".join(rej)
if rej
else "ok"
248 mr_e = abs(MRo / MR_TARGET - 1.0)
if np.isfinite(MRo)
else 1e9
249 r_e = abs(Rv - 1.0)
if np.isfinite(Rv)
else 1e9
251 max(0.0, dp_inj_pc_lo - ratio_o, ratio_o - dp_inj_pc_hi) ** 2
252 +
max(0.0, dp_inj_pc_lo - ratio_f, ratio_f - dp_inj_pc_hi) ** 2
253 if (np.isfinite(ratio_o)
and np.isfinite(ratio_f))
256 thrust_e = abs(Fo / F_TARGET - 1.0)
if np.isfinite(Fo)
else 1e9
257 row[
"_sort_key"] = (thrust_e, mr_e, r_e, f_err)
263 ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
264 ap.add_argument(
"--config", type=str, default=str(ROOT /
"configs/impinging_lox_ch4_8000N.yaml"))
269 help=
"Fixed LOX tank stagnation pressure [psi] (not optimized)",
275 help=
"Fixed fuel tank stagnation pressure [psi] (not optimized)",
277 ap.add_argument(
"--throat-min-mm2", type=float, default=2520.0)
278 ap.add_argument(
"--throat-max-mm2", type=float, default=2600.0)
279 ap.add_argument(
"--layer1-max-iterations", type=int, default=320)
280 ap.add_argument(
"--layer1-cma-restarts", type=int, default=2)
281 ap.add_argument(
"--dp-inj-over-pc-min", type=float, default=0.15, help=
"Reporting / goal hinge lower")
282 ap.add_argument(
"--dp-inj-over-pc-max", type=float, default=0.40, help=
"Reporting / goal hinge upper (both streams)")
283 ap.add_argument(
"--history-pick", type=int, default=72)
285 "--max-pressure-cap-margin-psi",
288 help=
"pressure_config caps = tank + margin (must exceed stagnation pressures for Layer‑1 ratio checks)",
290 ap.add_argument(
"--skip-layer1", action=
"store_true", help=
"Only evaluate baseline YAML+jets at frozen tanks")
291 ap.add_argument(
"--output-csv", type=str, default=str(ROOT /
"output/study_fixed_tanks_feed_allocation.csv"))
292 args = ap.parse_args()
294 cfg_path = Path(args.config)
295 if not cfg_path.is_absolute():
296 cfg_path = ROOT / cfg_path
298 dp_lo = float(args.dp_inj_over_pc_min)
299 dp_hi = float(args.dp_inj_over_pc_max)
300 po = float(args.p_tank_o_psi)
301 pf = float(args.p_tank_f_psi)
302 max_lox_cap = po + float(args.max_pressure_cap_margin_psi)
303 max_fuel_cap = pf + float(args.max_pressure_cap_margin_psi)
305 cfg = load_config(str(cfg_path))
306 mid_mm2 = 0.5 * (float(args.throat_min_mm2) + float(args.throat_max_mm2))
308 if cfg.chamber_geometry
is not None:
309 cfg.chamber_geometry.design_MR = float(cfg.design_requirements.optimal_of_ratio)
311 ig0 = cfg.injector.geometry
312 ig0.oxidizer.n_elements = 68
313 ig0.oxidizer.d_jet = 1.424e-3
314 ig0.oxidizer.spacing = 5.901e-3
315 ig0.oxidizer.impingement_angle = 63.15
316 ig0.fuel.n_elements = 32
317 ig0.fuel.d_jet = 1.392e-3
318 ig0.fuel.spacing = 4.118e-3
319 ig0.fuel.impingement_angle = 75.05
321 req = cfg.design_requirements.model_dump()
323 fp.pop(
"A_throat_mm2",
None)
324 fp.pop(
"P_O_start_psi",
None)
325 fp.pop(
"P_F_start_psi",
None)
326 fp[
"P_O_start_psi"] = po
327 fp[
"P_F_start_psi"] = pf
328 if cfg.chamber_geometry
is not None:
329 fp[
"expansion_ratio"] = float(cfg.chamber_geometry.expansion_ratio)
330 fp[
"Lstar_mm"] = float(cfg.chamber_geometry.Lstar) * 1000.0
331 req[
"frozen_parameters"] = fp
333 req[
"layer1_A_throat_mm2_min"] = float(args.throat_min_mm2)
334 req[
"layer1_A_throat_mm2_max"] = float(args.throat_max_mm2)
335 req[
"max_lox_tank_pressure_psi"] =
max(float(req.get(
"max_lox_tank_pressure_psi", 0.0)), max_lox_cap)
336 req[
"max_fuel_tank_pressure_psi"] =
max(float(req.get(
"max_fuel_tank_pressure_psi", 0.0)), max_fuel_cap)
338 req[
"injector_dp_ratio_O_min"] = dp_lo
339 req[
"injector_dp_ratio_O_max"] = dp_hi
340 req[
"injector_dp_ratio_F_min"] = dp_lo
341 req[
"injector_dp_ratio_F_max"] = dp_hi
343 req[
"impinging_momentum_R_min"] = 0.93
344 req[
"impinging_momentum_R_max"] = 1.07
346 req[
"W_MOM"] = 1600.0
347 req[
"W_geom_ao_af_momentum"] = 6500.0
349 req[
"W_DP_O"] = 900.0
350 req[
"W_DP_F"] = 900.0
352 req[
"target_thrust"] = F_TARGET
353 req[
"optimal_of_ratio"] = MR_TARGET
355 req[
"require_stable_state"] =
False
356 req[
"min_stability_score"] =
min(float(req.get(
"min_stability_score", 0.75)), 0.58)
357 req[
"min_stability_margin"] =
min(float(req.get(
"min_stability_margin", 1.2)), 1.05)
358 req[
"layer1_infeasibility_gate_eps"] = 2.0e-3
361 "mode":
"optimizer_controlled",
362 "max_lox_pressure_psi": float(req[
"max_lox_tank_pressure_psi"]),
363 "max_fuel_pressure_psi": float(req[
"max_fuel_tank_pressure_psi"]),
366 print(
"Fixed stagnation tanks + feed ΔP allocation study")
367 print(f
" P_tank_O = {po:.3f} psi P_tank_F = {pf:.3f} psi (frozen; not optimized)")
368 print(f
" Throat [{args.throat_min_mm2:.0f}, {args.throat_max_mm2:.0f}] mm²")
369 print(f
" Goal hinges: ΔP_inj/Pc ∈ [{dp_lo:.2f}, {dp_hi:.2f}] (both streams) | MR≈{MR_TARGET} | R≈1")
371 print(
"Model: P_inj = P_tank − ΔP_feed ; ΔP_inj = max(0, P_inj − Pc) ⇒ P_tank − Pc = ΔP_feed + ΔP_inj")
373 hist: List[Dict[str, Any]] = []
375 if not args.skip_layer1:
376 print(
"\nRunning Layer‑1 (geometry + throat only)...")
377 _opt_cfg, results = run_layer1_optimization(
381 target_burn_time=float(req.get(
"target_burn_time", 6.0)),
382 tolerances={
"thrust": 0.10,
"apogee": 0.15},
383 pressure_config=pcfg,
385 layer1_max_iterations=int(args.layer1_max_iterations),
386 layer1_cma_restarts=int(args.layer1_cma_restarts),
388 hist = list(results.get(
"iteration_history")
or [])
389 oc = _opt_cfg.chamber_geometry
390 og = _opt_cfg.injector.geometry
391 assert oc
is not None and og
is not None
394 "A_throat": float(oc.A_throat),
395 "n_elements_O": int(og.oxidizer.n_elements),
396 "d_jet_O": float(og.oxidizer.d_jet),
397 "impingement_angle_O": float(og.oxidizer.impingement_angle),
398 "spacing_O": float(og.oxidizer.spacing),
399 "n_elements_F": int(og.fuel.n_elements),
400 "d_jet_F": float(og.fuel.d_jet),
401 "impingement_angle_F": float(og.fuel.impingement_angle),
402 "spacing_F": float(og.fuel.spacing),
404 "eval_success":
True,
405 "objective": float(results.get(
"convergence_info", {}).get(
"final_change", float(
"nan"))),
406 "thrust": (results.get(
"performance")
or {}).get(
"F"),
410 cg = cfg.chamber_geometry
411 ig = cfg.injector.geometry
412 assert cg
is not None
415 "A_throat": float(cg.A_throat),
416 "n_elements_O": int(ig.oxidizer.n_elements),
417 "d_jet_O": float(ig.oxidizer.d_jet),
418 "impingement_angle_O": float(ig.oxidizer.impingement_angle),
419 "spacing_O": float(ig.oxidizer.spacing),
420 "n_elements_F": int(ig.fuel.n_elements),
421 "d_jet_F": float(ig.fuel.d_jet),
422 "impingement_angle_F": float(ig.fuel.impingement_angle),
423 "spacing_F": float(ig.fuel.spacing),
425 "eval_success":
True,
427 "objective": float(
"nan"),
431 sorted_hist = sorted(hist, key=
lambda z: float(z.get(
"objective", 1e30)))
432 picked: List[Dict[str, Any]] = []
434 for h
in sorted_hist:
435 if not h.get(
"eval_success"):
437 if h.get(
"thrust")
is not None:
438 thrust_v = float(h[
"thrust"])
439 if not np.isfinite(thrust_v):
446 if len(picked) >= int(args.history_pick):
449 rows: List[Dict[str, Any]] = []
459 r[
"_hist_order"] = len(rows)
462 accepted = [r
for r
in rows
if r[
"accepted"]]
463 print(
"\n=== Summary ===")
464 print(f
" Candidates replayed: {len(rows)} | Accepted (non‑negative feed): {len(accepted)}")
467 [r
for r
in accepted
if isinstance(r.get(
"_sort_key"), tuple)],
468 key=
lambda z: z[
"_sort_key"],
472 print(
"\nBest accepted candidate (combined distance to thrust→MR→R→ΔP band):")
474 f
" F={b['F_N']:.2f} N MR={b['MR']:.4f} R={b['R']:.4f} Pc={b['Pc_psi']:.2f} psi\n"
475 f
" ΔP_inj/Pc: O={b['dP_inj_over_Pc_O']:.4f} F={b['dP_inj_over_Pc_F']:.4f}\n"
476 f
" f_feed (share of P_tank−Pc): O={b['feed_loss_fraction_O']:.4f} F={b['feed_loss_fraction_F']:.4f}\n"
480 print(
"\nNo accepted candidates — inspect reject_reason column in CSV.")
482 out_path = Path(args.output_csv)
483 out_path.parent.mkdir(parents=
True, exist_ok=
True)
499 "delta_p_feed_O_psi",
500 "delta_p_feed_F_psi",
503 "feed_loss_fraction_O",
504 "feed_loss_fraction_F",
505 "budget_residual_O_Pa",
506 "budget_residual_F_Pa",
509 "goal_mr_3p5_tol6pct",
510 "goal_R_unity_tol10pct",
524 for k
in rows[0].keys()
525 if not str(k).startswith(
"_")
and k
not in preferred
527 fieldnames = [k
for k
in preferred
if k
in rows[0]] + extras
528 with open(out_path,
"w", newline=
"", encoding=
"utf-8")
as f:
529 w = csv.DictWriter(f, fieldnames=fieldnames, extrasaction=
"ignore")
532 w.writerow({k: r.get(k)
for k
in fieldnames})
533 print(f
"\nWrote {out_path}")