1"""Spray and mixing of the unlike-doublet injector, as one report: what each jet does from its
2orifice to where it is burnt, each number beside the band it is judged against and where that
3band comes from, and how the answer moves across the inputs the literature does not pin down.
5Presentation of the solve (``PintleEngineRunner.evaluate``) and the plate layout
6(``layout_from_config``): the numbers are the solver's own, read back, except the few checks the
7solver does not make (jet intact length, spray lean, the spray's reach against the chamber), which
8are closed forms cited where they are made. Nothing here feeds back into the solve.
10Rows: ``{"label", "value", "unit", "band", "status", "source", "note"}``; status is ``ok``,
11``warn``, ``bad`` or ``info`` (a number with no band to hold it to).
13from __future__
import annotations
17from typing
import Any, Dict, List, Mapping, Optional
31RUPE_M_BAND = (0.8, 1.25)
34def _row(label: str, value: Any, unit: str =
"", band: str =
"", status: str =
"info",
35 source: str =
"", note: str =
"") -> Dict[str, Any]:
36 return {
"label": label,
"value": value,
"unit": unit,
"band": band,
"status": status,
37 "source": source,
"note": note}
40def _f(v: Any, default: float = float(
"nan")) -> float:
43 except (TypeError, ValueError):
47def jet_rows(cfg: Any, result: Mapping[str, Any], lay: Optional[Mapping[str, Any]]) -> List[Dict[str, Any]]:
48 """Each jet from its orifice to the impingement point."""
49 d = result.get(
"diagnostics")
or {}
50 geo, fl = cfg.injector.geometry, cfg.fluids
51 rho_g =
_f(d.get(
"rho_gas_breakup"))
52 rows: List[Dict[str, Any]] = []
53 for k, side, name
in ((
"O",
"oxidizer",
"LOX"), (
"F",
"fuel",
"fuel")):
54 st, f = getattr(geo, side), fl[side]
56 v =
_f(d.get(f
"v_{k}_bulk"))
57 rho, mu, sig = float(f.density), float(f.viscosity), float(f.surface_tension)
58 Re = rho * v * dj / mu
59 We_l = rho * v * v * dj / sig
60 rows.append(
_row(f
"{name} jet velocity", v,
"m/s",
"10–60",
"ok" if 10 <= v <= 60
else "warn",
61 "bulk mdot / (rho A)", f
"Re {Re:.3g}, liquid We {We_l:.3g}"))
63 free =
_f(lay[
"face"][f
"free_jet_{k}"])
65 rows.append(
_row(f
"{name} free jet to impingement", ld,
"d", f
"≤ {FREE_JET_MAX_D:g}",
66 "ok" if ld <= FREE_JET_MAX_D
else "warn",
67 "NASA SP-8089 3.1.1.1.4 (along the jet)", f
"{free * 1000:.2f} mm"))
69 core = CORE_C_LOW * dj * math.sqrt(rho / rho_g)
70 rows.append(
_row(f
"{name} intact core (low bound)", core / dj,
"d",
71 f
"> free jet {ld:.1f} d",
"ok" if core > free
else "bad",
72 "Chehroudi et al. SAE 850126: L = C d √(ρl/ρg), C 7–16",
73 f
"at ρg {rho_g:.2f} kg/m³: the jets meet before they come apart"
74 if core > free
else "the jet atomizes before it meets its pair"))
76 cd_eff = d.get(f
"Cd_eff_manifold_{k}")
78 p = (lay
or {}).get(
"passages", {}).get(k, {})
79 rows.append(
_row(f
"{name} orifice Cd",
_f(cd),
"",
"",
"info",
80 (p.get(
"cd")
or {}).get(
"model",
"discharge model"),
81 f
"L/d {_f(p.get('plate_l_over_d', p.get('land_ld'))):.2f}"
82 + (f
"; {_f(cd_eff):.4f} through the manifold" if cd_eff
is not None else "")))
86def impingement_rows(cfg: Any, result: Mapping[str, Any], lay: Optional[Mapping[str, Any]]) -> List[Dict[str, Any]]:
87 d = result.get(
"diagnostics")
or {}
88 geo = cfg.injector.geometry
89 thO, thF = float(geo.oxidizer.impingement_angle), float(geo.fuel.impingement_angle)
90 rows = [
_row(
"included angle", thO + thF,
"°",
"60–100",
"ok" if 60 <= thO + thF <= 100
else "warn",
91 "Sutton 8.3; Huzel & Huang 4.2 practice")]
92 M =
_f(d.get(
"rupe_M"))
94 rows.append(
_row(
"Rupe M", M,
"", f
"{lo:g}–{hi:g}",
"ok" if lo <= M <= hi
else "warn",
95 "ρO vO² dO / (ρF vF² dF); Rupe 1953, Elverum & Morey 1959",
96 "best mixing near 1"))
97 rows.append(
_row(
"momentum ratio R",
_f(d.get(
"momentum_ratio_R")),
"",
"",
"info",
98 "ṁO vO / ṁF vF per element"))
100 vO, vF =
_f(d.get(
"v_O_bulk")),
_f(d.get(
"v_F_bulk"))
101 pO, pF =
_f(result.get(
"mdot_O")) * vO,
_f(result.get(
"mdot_F")) * vF
102 ox_inner = bool(lay[
"face"][
"ox_is_inner"])
if lay
is not None else True
103 s = 1.0
if ox_inner
else -1.0
104 rad = s * (pO * math.sin(math.radians(thO)) - pF * math.sin(math.radians(thF)))
105 ax = pO * math.cos(math.radians(thO)) + pF * math.cos(math.radians(thF))
106 lean = math.degrees(math.atan2(rad, ax))
107 rows.append(
_row(
"spray lean", lean,
"° (+ outward)",
"≈ 0, inward allowed",
108 "ok" if lean <= 0.25
else "warn",
"Sutton eq. 8-7 (momentum resultant)",
109 "toward the wall" if lean > 0
else "toward the axis"))
112 rb = float(lay[
"envelope"][
"r_bore"])
113 req = cfg.design_requirements.model_dump()
if hasattr(cfg,
"design_requirements")
else {}
114 frac, tol = req.get(
"layer1_injector_spray_radius_frac"), req.get(
"layer1_injector_spray_radius_tol")
117 ok = abs(v - frac) <= (tol
or 0.08)
118 rows.append(
_row(
"impingement ring / bore radius", v,
"", f
"{frac:g} ± {tol or 0.08:g}",
119 "ok" if ok
else "warn",
"layer1_injector_spray_radius_frac (0.707 = equal area)",
120 f
"⌀{2 * f['r_imp'] * 1000:.1f} mm, {f['z_imp'] * 1000:.2f} mm off the face"))
122 rows.append(
_row(
"impingement ring / bore radius", v,
"",
"",
"info",
"",
123 f
"⌀{2 * f['r_imp'] * 1000:.1f} mm"))
128 d = result.get(
"diagnostics")
or {}
130 basis = (f
"NACA TN 4222 (Ingebo 1958), carried to the propellants by {smd.smd_property_transfer}"
131 if smd.smd_property_scaling
else "NACA TN 4222 (Ingebo 1958), heptane in air")
133 for k, name
in ((
"O",
"LOX"), (
"F",
"fuel")):
134 rows.append(
_row(f
"{name} D32",
_f(d.get(f
"D32_{k}")) * 1e6,
"µm",
"",
"info", basis,
135 f
"× {smd.smd_scale:g} (smd_scale)" if smd.smd_scale != 1.0
else "uncalibrated: measure it"))
136 rows.append(
_row(f
"{name} gas Weber",
_f(d.get(f
"We_{k}")),
"",
"",
"info",
137 "ρg Vrel² d / σ at the breakup gas density"))
138 rows.append(
_row(
"sheet breakup length",
_f(d.get(
"L_sheet_breakup")) * 1000,
"mm",
"",
"info",
139 "h ~ d²/(4 L_imp), t_b ~ (h/u) √(ρl/ρg)"))
144 d = result.get(
"diagnostics")
or {}
145 ce = d.get(
"cstar_efficiency")
or {}
146 L =
_f(ce.get(
"L_chamber_equiv"))
147 x0 =
_f(ce.get(
"x_drop_formation"), 0.0)
149 for k, name
in ((
"O",
"LOX"), (
"F",
"fuel")):
151 x95 =
_f(ce.get(f
"x_vap95_{k}")) + x0
152 fr =
_f(ce.get(f
"frac_vaporized_{k}"))
153 rows.append(
_row(f
"{name} 95 % vaporized, from the face", x95 * 1000,
"mm", f
"< chamber {L * 1000:.0f} mm",
154 "ok" if x95 < L
else "bad",
"droplet march (blowing: "
155 f
"{ce.get('blowing_model', '?')}; liquid: {ce.get(f'liquid_props_{k}', '?')})",
156 f
"{fr * 100:.1f} % vaporized at the throat; {(L - x95) / L * 100:+.0f} % margin"))
157 rows.append(
_row(
"vaporized at the throat",
_f(ce.get(
"fraction_vaporized")) * 100,
"%",
"",
"info",
158 "the droplet march", f
"O/F of the vapour {_f(ce.get('MR_vaporized')):.3f}"))
160 xs =
_f(d.get(
"x_star"))
161 lim =
_f(getattr(getattr(cfg.spray,
"evaporation",
None),
"x_star_limit",
None))
162 rows.append(
_row(
"x* (d²-law, Vrel τ_evap)", xs * 1000,
"mm",
163 f
"config limit {lim * 1000:.0f} mm" if lim == lim
else "",
"info",
164 "spray.evaporation.x_star_limit",
165 "a fixed number, not this chamber: the march above is what η_vap uses"))
169def mixing_rows(cfg: Any, result: Mapping[str, Any]) -> List[Dict[str, Any]]:
170 d = result.get(
"diagnostics")
or {}
171 ce = d.get(
"cstar_efficiency")
or {}
172 eff = cfg.combustion.efficiency
174 _row(
"Rupe E_m",
_f(ce.get(
"rupe_Em")),
"", f
"optimum {eff.rupe_Em_opt:g}",
"info",
175 ce.get(
"mixing_basis",
"Rupe, JPL TR 32-1546"),
"cold-flow mixture-ratio uniformity"),
176 _row(
"η mixing",
_f(ce.get(
"eta_mixing")),
"",
"",
"info",
"stream-tube c* integral over the E_m spread",
177 f
"{ce.get('mixing_distribution', '')}; two-tube shape {_f(ce.get('eta_mixing_two_tube')):.4f}"),
178 _row(
"η vaporization",
_f(ce.get(
"eta_vaporization")),
"",
"",
"info",
"droplet march"),
179 _row(
"η heat loss",
_f(ce.get(
"eta_heat_loss")),
"",
"",
"info",
"wall heat to the liner"),
180 _row(
"η c*",
_f(result.get(
"eta_cstar", d.get(
"eta_cstar"))),
"",
"",
"info",
181 "η_vap × η_mix × η_HL"),
183 lo, hi = d.get(
"element_mixture_ratio_min"), d.get(
"element_mixture_ratio_max")
185 rows.append(
_row(
"element O/F spread", (hi - lo) / result[
"MR"] * 100,
"%",
"",
"info",
186 "ring-manifold solve", f
"{lo:.4f}–{hi:.4f}"))
194 (
"SMD transfer: Dombrowski & Johns", (
"spray",
"smd",
"smd_property_transfer"),
"dombrowski_johns",
195 "the three published transfers of TN 4222 to the propellants"),
196 (
"SMD transfer: none (heptane in air)", (
"spray",
"smd",
"smd_property_transfer"),
"none",
197 "the three published transfers of TN 4222 to the propellants"),
198 (
"Rupe E_m opt 0.70", (
"combustion",
"efficiency",
"rupe_Em_opt"), 0.70,
199 "short or cavitating orifices (Nurick & McHale, JPL TR 32-1546)"),
200 (
"Rupe E_m opt 0.85", (
"combustion",
"efficiency",
"rupe_Em_opt"), 0.85,
201 "top of the circular 1-on-1 doublet band (JPL TR 32-1546 p. 1)"),
205def _solve(cfg: Any) -> Mapping[str, Any]:
207 return PintleEngineRunner(copy.deepcopy(cfg)).evaluate(
208 cfg.lox_tank.initial_pressure_psi * PSI, cfg.fuel_tank.initial_pressure_psi * PSI, silent=
True)
211def _summary(r: Mapping[str, Any]) -> Dict[str, float]:
212 ce = (r.get(
"diagnostics")
or {}).get(
"cstar_efficiency")
or {}
213 return {
"F":
_f(r.get(
"F")),
"Isp":
_f(r.get(
"Isp")),
"Pc_psia":
_f(r.get(
"Pc")) / PSI,
214 "OF":
_f(r.get(
"MR")),
"eta_cstar":
_f(r.get(
"eta_cstar")),
215 "eta_vap":
_f(ce.get(
"eta_vaporization")),
"eta_mix":
_f(ce.get(
"eta_mixing")),
216 "vap_F":
_f(ce.get(
"frac_vaporized_F")),
"vap_O":
_f(ce.get(
"frac_vaporized_O"))}
219def sensitivity(cfg: Any, base: Mapping[str, Any]) -> List[Dict[str, Any]]:
220 """Each open input moved to the end of its published range, the design re-solved AS WRITTEN
221 (same tanks, same holes). The value each case actually ran with is read back from the
222 solved config, so a case the model ignored cannot pass for one it honoured."""
223 out = [{
"case":
"as configured",
"why":
"", **
_summary(base),
"applied":
True}]
224 for name, path, value, why
in SENSITIVITY_CASES:
225 c = copy.deepcopy(cfg)
228 obj = getattr(obj, p)
229 setattr(obj, path[-1], value)
233 except Exception
as e:
234 out.append({
"case": name,
"why": why,
"error": str(e),
"applied":
True})
236 out.append({
"case": name,
"why": why, **
_summary(r),
"applied": getattr(got, path[-1]) == value})
241 with_sensitivity: bool =
True) -> Dict[str, Any]:
242 """The report for ``cfg`` at its own tank pressures (solved here unless ``result`` is given)."""
244 r = result
if result
is not None else _solve(cfg)
246 lay = layout_from_config(cfg, drawings=
False, flows=flows_from_result(r, cfg))
250 "design": {
"F":
_f(r.get(
"F")),
"Pc_psia":
_f(r.get(
"Pc")) / PSI,
"OF":
_f(r.get(
"MR")),
251 "Isp":
_f(r.get(
"Isp")),
"P_tank_O_psi": cfg.lox_tank.initial_pressure_psi,
252 "P_tank_F_psi": cfg.fuel_tank.initial_pressure_psi},
254 {
"title":
"Jets",
"rows":
jet_rows(cfg, r, lay)},
258 {
"title":
"Mixing and c*",
"rows":
mixing_rows(cfg, r)},
260 "sensitivity":
sensitivity(cfg, r)
if with_sensitivity
else None,
List[Dict[str, Any]] mixing_rows(Any cfg, Mapping[str, Any] result)
Dict[str, Any] spray_mixing_report(Any cfg, Optional[Mapping[str, Any]] result=None, *, bool with_sensitivity=True)
Mapping[str, Any] _solve(Any cfg)
List[Dict[str, Any]] vaporization_rows(Any cfg, Mapping[str, Any] result)
List[Dict[str, Any]] sensitivity(Any cfg, Mapping[str, Any] base)
Dict[str, float] _summary(Mapping[str, Any] r)
List[Dict[str, Any]] jet_rows(Any cfg, Mapping[str, Any] result, Optional[Mapping[str, Any]] lay)
Dict[str, Any] _row(str label, Any value, str unit="", str band="", str status="info", str source="", str note="")
List[Dict[str, Any]] impingement_rows(Any cfg, Mapping[str, Any] result, Optional[Mapping[str, Any]] lay)
float _f(Any v, float default=float("nan"))
List[Dict[str, Any]] atomization_rows(Any cfg, Mapping[str, Any] result)