STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
spray_report.py
Go to the documentation of this file.
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.
4
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.
9
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).
12"""
13from __future__ import annotations
14
15import copy
16import math
17from typing import Any, Dict, List, Mapping, Optional
18
19PSI = 6894.757
20
21#: Intact liquid core of a jet in the atomization regime, L = C d sqrt(rho_l / rho_g), C = 7-16
22#: (Chehroudi, Chen, Bracco & Onuma, SAE 850126, 1985). The low end is the conservative one here:
23#: the question is only whether the jets reach each other before they come apart.
24CORE_C_LOW = 7.0
25
26#: SP-8089 criterion 3.1.1.1.4 (p. 80): impingement distance along the jet no greater than 5-7 d.
27FREE_JET_MAX_D = 7.0
28
29#: Rupe's best mixing at M = rho_O v_O^2 d_O / (rho_F v_F^2 d_F) near 1 (Rupe 1953; Elverum &
30#: Morey, JPL Memo 30-5, 1959); the band the hand-check holds it to.
31RUPE_M_BAND = (0.8, 1.25)
32
33
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}
38
39
40def _f(v: Any, default: float = float("nan")) -> float:
41 try:
42 return float(v)
43 except (TypeError, ValueError):
44 return default
45
46
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]
55 dj = float(st.d_jet)
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}"))
62 if lay is not None:
63 free = _f(lay["face"][f"free_jet_{k}"])
64 ld = free / dj
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"))
68 if rho_g > 0:
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"))
75 cd = d.get(f"Cd_{k}")
76 cd_eff = d.get(f"Cd_eff_manifold_{k}")
77 if cd is not None:
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 "")))
83 return rows
84
85
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"))
93 lo, hi = RUPE_M_BAND
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"))
99 # Resultant lean of the fan: the two jets' momenta added. The inner ring aims outward.
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"))
110 if lay is not None:
111 f = lay["face"]
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")
115 v = f["r_imp"] / rb
116 if frac:
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"))
121 else:
122 rows.append(_row("impingement ring / bore radius", v, "", "", "info", "",
123 f"⌀{2 * f['r_imp'] * 1000:.1f} mm"))
124 return rows
125
126
127def atomization_rows(cfg: Any, result: Mapping[str, Any]) -> List[Dict[str, Any]]:
128 d = result.get("diagnostics") or {}
129 smd = cfg.spray.smd
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")
132 rows = []
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)"))
140 return rows
141
142
143def vaporization_rows(cfg: Any, result: Mapping[str, Any]) -> List[Dict[str, Any]]:
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)
148 rows = []
149 for k, name in (("O", "LOX"), ("F", "fuel")):
150 # The march measures from where the drops form; this is from the face.
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}"))
159 # The older d²-law estimate and the fixed limit the solver still flags it against.
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"))
166 return rows
167
168
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
173 rows = [
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"),
182 ]
183 lo, hi = d.get("element_mixture_ratio_min"), d.get("element_mixture_ratio_max")
184 if lo is not None:
185 rows.append(_row("element O/F spread", (hi - lo) / result["MR"] * 100, "%", "", "info",
186 "ring-manifold solve", f"{lo:.4f}–{hi:.4f}"))
187 return rows
188
189
190# ---- what the answer rests on -------------------------------------------------------------
191
192#: The inputs the literature leaves open, and the range it gives for each.
193SENSITIVITY_CASES = (
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)"),
202)
203
204
205def _solve(cfg: Any) -> Mapping[str, Any]:
206 from engine.core.runner import PintleEngineRunner
207 return PintleEngineRunner(copy.deepcopy(cfg)).evaluate(
208 cfg.lox_tank.initial_pressure_psi * PSI, cfg.fuel_tank.initial_pressure_psi * PSI, silent=True)
209
210
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"))}
217
218
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)
226 obj = c
227 for p in path[:-1]:
228 obj = getattr(obj, p)
229 setattr(obj, path[-1], value)
230 got = obj
231 try:
232 r = _solve(c)
233 except Exception as e: # a case that does not solve is a finding, not a crash
234 out.append({"case": name, "why": why, "error": str(e), "applied": True})
235 continue
236 out.append({"case": name, "why": why, **_summary(r), "applied": getattr(got, path[-1]) == value})
237 return out
238
239
240def spray_mixing_report(cfg: Any, result: Optional[Mapping[str, Any]] = None, *,
241 with_sensitivity: bool = True) -> Dict[str, Any]:
242 """The report for ``cfg`` at its own tank pressures (solved here unless ``result`` is given)."""
243 from engine.core.injectors.layout import flows_from_result, layout_from_config
244 r = result if result is not None else _solve(cfg)
245 try:
246 lay = layout_from_config(cfg, drawings=False, flows=flows_from_result(r, cfg))
247 except Exception:
248 lay = None
249 rep = {
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},
253 "sections": [
254 {"title": "Jets", "rows": jet_rows(cfg, r, lay)},
255 {"title": "Impingement", "rows": impingement_rows(cfg, r, lay)},
256 {"title": "Atomization", "rows": atomization_rows(cfg, r)},
257 {"title": "Vaporization", "rows": vaporization_rows(cfg, r)},
258 {"title": "Mixing and c*", "rows": mixing_rows(cfg, r)},
259 ],
260 "sensitivity": sensitivity(cfg, r) if with_sensitivity else None,
261 }
262 return rep
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)