STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
combustion_physics.py
Go to the documentation of this file.
1"""c* efficiency physics: vaporization and mixing.
2
3 eta_c* = eta_vap * eta_mix * eta_HL
4
5eta_HL, the heat lost to the wall upstream of the throat, is applied in combustion_eff.eta_cstar.
6
7- eta_vap: c* retained by incomplete vaporization, F c*(O/F_vap)/c*(O/F), F the mass fraction
8 vaporized by the throat from a one-dimensional spray march started where the doublet's sheet
9 breaks into drops, in the manner of Priem & Heidmann (NASA TR R-67, 1960).
10- eta_mix: the stream-tube c* integral (Pieper, Dean & Valentine, JSR 4(6), 1967) of an O/F
11 distribution that reproduces Rupe's mixing factor E_m (JPL TR 32-1546 eq. 1), E_m from Rupe's
12 correlation against M = rho_O v_O^2 d_O / (rho_F v_F^2 d_F) (TR 32-1546 Fig. 1), plus any
13 element-to-element striation the injector reports. c*(O/F) is CEA.
14- Chemical kinetics carry no c* loss. At ~3200 K and ~3 MPa the LOX/ethanol products relax in about
15 a microsecond (CO + OH, H + OH + M) against a millisecond stay time. Kinetic losses belong to the
16 nozzle, where CEA's frozen and shifting Cf bracket them (JANNAF, CPIA 246).
17"""
18
19from __future__ import annotations
20
21import json
22import math
23import os
24from typing import Any, Dict, List, Optional, Tuple
25
26import numpy as np
27
28from engine.pipeline.config_schemas import CombustionEfficiencyConfig
29from engine.pipeline.physics_constants import PRANDTL_DEFAULT, R_UNIVERSAL_KMOL
30
31P_BOIL_REF = 101325.0 # normal boiling point pressure [Pa]
32WATSON_EXPONENT = 0.38 # Watson (1943); Poling, Prausnitz & O'Connell eq. 7-12.1
33
34# Resolution of the spray march -- numerics, not physics. Size classes are Gauss-Legendre points in
35# the cumulative volume fraction V (y = -ln(1 - V) = (D/X)^q); steps are Heun (second order). 100
36# steps and 16 classes agree with 3200 and 200 to 5e-5 in eta_vap (6500 N design, L* 0.5-2 m,
37# D32 x1-x3).
38_MARCH_STEPS = 100
39_GL_X, _GL_W = np.polynomial.legendre.leggauss(16)
40_RR_NODES = -np.log1p(-0.5 * (_GL_X + 1.0))
41_RR_WEIGHTS = 0.5 * _GL_W
42# dx/v guard for a drop stalled in still gas at the face: it then evaporates within that step.
43_V_FLOOR = 1.0e-3 # [m/s]
44
45try: # the march is ~75 % of a chamber evaluation in NumPy; the same loop compiled is ~50x cheaper
46 from numba import njit as _njit
47except ImportError: # pragma: no cover
48 _njit = None
49#: False forces the NumPy march (the reference the compiled one is tested against).
50_USE_COMPILED_MARCH = _njit is not None
51
52
53def _march_core_py(D2_0, w_cls, rho_l, mu, rho_f, k_f, lnB, t_h, inv_FB, cd_blow, U_c, rho_c, pr3,
54 cp_g, dx, n_steps, u_drop0, F0, v_floor, rr_w, starts, nq):
55 """The Heun march of ``spray_vaporization_march`` as scalar loops, for compilation.
56
57 Same formulas and step order as the NumPy loop there; returns (fraction vaporized per stream,
58 x at 95 % per stream (NaN if not reached), F at the end). ``inv_FB`` divides the convective
59 part of Nu (Abramzon-Sirignano) and ``cd_blow`` multiplies the drag of an evaporating drop
60 (Yuen-Chen); both are 1 with the blowing model off.
61 """
62 n = D2_0.size
63 ns = starts.size
64 D2 = D2_0.copy()
65 v = np.full(n, u_drop0)
66 t = np.zeros(n)
67 K1 = np.empty(n)
68 it1 = np.empty(n)
69 dt1 = np.empty(n)
70 D2p = np.empty(n)
71 vp = np.empty(n)
72 rem = np.empty(n)
73 F = F0
74 prev = np.zeros(ns)
75 x95 = np.full(ns, np.nan)
76 for i in range(n_steps):
77 Ug1 = U_c * F
78 for j in range(n):
79 if D2[j] == 0.0:
80 # Gone: D^2 stays 0 and nothing reads this class's velocity or clock again, so
81 # its rates are not computed (the sums below see exactly what they saw before).
82 D2p[j] = 0.0
83 continue
84 D = math.sqrt(D2[j])
85 dU = abs(Ug1 - v[j])
86 Re = rho_c * dU * D / mu[j]
87 K1[j] = 4.0 * (2.0 + 0.6 * math.sqrt(rho_f[j] * dU * D / mu[j]) * pr3 * inv_FB[j]) * k_f[j] * lnB[j] / (rho_l[j] * cp_g)
88 phi = 1.0 + Re ** (2.0 / 3.0) / 6.0 if Re <= 1000.0 else 0.424 * Re / 24.0
89 if t[j] > t_h[j]:
90 phi *= cd_blow[j]
91 it1[j] = 18.0 * mu[j] * phi / (rho_l[j] * D2[j])
92 dt1[j] = dx / max(v[j], v_floor)
93 t_ev = min(max(t[j] + dt1[j] - t_h[j], 0.0), dt1[j])
94 D2p[j] = max(D2[j] - K1[j] * t_ev, 0.0) if t_ev > 0.0 else D2[j]
95 vp[j] = Ug1 + (v[j] - Ug1) * math.exp(-dt1[j] * it1[j]) if D2[j] > 0.0 else Ug1
96 Fp = 0.0
97 for j in range(n):
98 Fp += w_cls[j] * (1.0 - (D2p[j] / D2_0[j]) ** 1.5)
99 Ug2 = U_c * (F0 + Fp)
100 Ug = 0.5 * (Ug1 + Ug2)
101 for j in range(n):
102 if D2[j] == 0.0:
103 continue
104 D = math.sqrt(D2p[j])
105 dU = abs(Ug2 - vp[j])
106 Re = rho_c * dU * D / mu[j]
107 K2 = 4.0 * (2.0 + 0.6 * math.sqrt(rho_f[j] * dU * D / mu[j]) * pr3 * inv_FB[j]) * k_f[j] * lnB[j] / (rho_l[j] * cp_g)
108 phi = 1.0 + Re ** (2.0 / 3.0) / 6.0 if Re <= 1000.0 else 0.424 * Re / 24.0
109 if t[j] + dt1[j] > t_h[j]:
110 phi *= cd_blow[j]
111 it2 = 18.0 * mu[j] * phi / (rho_l[j] * D2p[j])
112 dt2 = dx / max(vp[j], v_floor)
113 K = 0.5 * (K1[j] + K2)
114 itau = 0.5 * (it1[j] + it2)
115 dt = 0.5 * (dt1[j] + dt2)
116 t_ev = min(max(t[j] + dt - t_h[j], 0.0), dt)
117 D2n = max(D2[j] - K * t_ev, 0.0) if t_ev > 0.0 else D2[j]
118 vn = Ug + (v[j] - Ug) * math.exp(-dt * itau) if D2[j] > 0.0 else Ug
119 t[j] = t[j] + dt
120 D2[j] = D2n
121 v[j] = vn
122 acc = 0.0
123 for j in range(n):
124 rem[j] = (D2[j] / D2_0[j]) ** 1.5
125 acc += w_cls[j] * (1.0 - rem[j])
126 F = F0 + acc
127 x_i = (i + 1) * dx
128 for s in range(ns):
129 f = 0.0
130 for k in range(nq):
131 f += rr_w[k] * rem[starts[s] + k]
132 f = 1.0 - f
133 if math.isnan(x95[s]) and f >= 0.95:
134 p = prev[s]
135 x95[s] = x_i - dx * (f - 0.95) / (f - p) if f > p else x_i
136 prev[s] = f
137 return prev, x95, F
138
139
140_march_core = _njit(cache=True, error_model="numpy")(_march_core_py) if _njit is not None else None
141
142
143def _record(assumptions: List[Dict[str, Any]], name: str, value: Any, unit: str, reason: str) -> Any:
144 """Note an assumed input in the result and in the process-wide assumptions registry."""
145 from engine.pipeline.assumptions import assume
146 assumptions.append({"name": name, "value": value, "unit": unit, "reason": reason})
147 return assume(name, value, unit=unit, reason=reason)
148
149
151 Pc: float,
152 Tc: float,
153 R: float,
154 Ac: float,
155 At: float,
156 Lstar: float,
157 m_dot_total: float,
158) -> Dict[str, float]:
159 """Chamber gas state and stay time.
160
161 tau_res is the stay time t_s = V_c rho / mdot = L* rho_c / (mdot/A_t) (Sutton & Biblarz ch. 8),
162 the transit time of the gas at the chamber exit velocity.
163 """
164 for name, v in (("R", R), ("Tc", Tc), ("Ac", Ac), ("At", At), ("Lstar", Lstar),
165 ("m_dot_total", m_dot_total), ("Pc", Pc)):
166 if not (np.isfinite(v) and v > 0):
167 raise ValueError(f"Invalid {name}={v}. Must be positive and finite.")
168 rho_ch = Pc / (R * Tc)
169 U_bulk = m_dot_total / (rho_ch * Ac)
170 G_throat = m_dot_total / At
171 return {
172 "rho_ch": float(rho_ch),
173 "U_bulk": float(U_bulk),
174 "G_throat": float(G_throat),
175 "tau_res": float(Lstar * rho_ch / G_throat),
176 }
177
178
180 P: float,
181 T_boil: float,
182 L_boil: float,
183 molecular_weight: float,
184 T_crit: Optional[float] = None,
185) -> Tuple[float, float]:
186 """Surface temperature [K] and latent heat [J/kg] of a drop evaporating at pressure P.
187
188 T_sat from Clausius-Clapeyron through the normal boiling point,
189 1/T_sat = 1/T_boil - R_u/(M L_boil) ln(P/101325),
190 and h_fg(T_sat) from Watson, h_fg = L_boil ((T_c - T_sat)/(T_c - T_boil))^0.38.
191 At 2.977 MPa this gives ethanol 472.1 K / 503 kJ/kg and oxygen 143.6 K / 109 kJ/kg; CoolProp
192 gives 473.1 / 506 and 141.5 / 120. At or above the critical pressure the surface reaches T_c
193 and h_fg = 0. Without T_c the normal-boiling-point latent heat is kept, which overstates h_fg.
194 """
195 for name, v in (("P", P), ("T_boil", T_boil), ("L_boil", L_boil),
196 ("molecular_weight", molecular_weight)):
197 if not (np.isfinite(v) and v > 0):
198 raise ValueError(f"saturation_state: invalid {name}={v}")
199 inv_T = 1.0 / T_boil - (R_UNIVERSAL_KMOL / (molecular_weight * L_boil)) * math.log(P / P_BOIL_REF)
200 if T_crit is not None and np.isfinite(T_crit) and T_crit > 0:
201 if T_crit <= T_boil:
202 raise ValueError(f"saturation_state: T_crit={T_crit} K must exceed T_boil={T_boil} K")
203 if inv_T <= 1.0 / T_crit:
204 return float(T_crit), 0.0
205 T_sat = 1.0 / inv_T
206 return float(T_sat), float(L_boil * ((T_crit - T_sat) / (T_crit - T_boil)) ** WATSON_EXPONENT)
207 if inv_T <= 0.0:
208 raise ValueError(f"saturation_state: P={P:.3e} Pa is beyond Clausius-Clapeyron for this "
209 f"fluid and no critical temperature was given")
210 return float(1.0 / inv_T), float(L_boil)
211
212
213def _gas_viscosity(T: float, M_gas: float) -> float:
214 from engine.pipeline.thermal.regen_cooling import calculate_gas_viscosity_huzel
215 return float(calculate_gas_viscosity_huzel(T, M_gas))
216
217
218# --------------------------------------------------------------------------- c*(O/F) off the table
219# The stream-tube mixing integral and the vaporized-gas O/F need c* well outside a design's CEA
220# cache (LOX/ethanol caches span O/F 1.0-2.5; a Rupe E_m of 0.8 puts ~40 % of a Gaussian's mass
221# outside that). This is a second CEA table, c* only, over O/F 0.1-20 on the aux-table Pc grid,
222# built once per propellant pair from rocketcea (same CEA_Obj(oxName, fuelName) cards as the
223# cache) and committed beside it.
224CSTAR_WIDE_SCHEMA_VERSION = 1
225_CSTAR_WIDE_MR = np.geomspace(0.1, 20.0, 161)
226_CSTAR_WIDE_PC = np.geomspace(1.0e5, 1.2e7, 16) # Pa (the aux-table grid)
227_PSI_PA = 6894.757293168361
228_FT_M = 0.3048
229_CSTAR_WIDE_MEMO: Dict[Tuple[str, str], Optional["CstarWideTable"]] = {}
230
231
233 """c* [m/s] on (O/F, Pc) from CEA, interpolated linearly in (ln O/F, ln Pc)."""
234
235 def __init__(self, ox_name: str, fuel_name: str, MR: np.ndarray, Pc: np.ndarray,
236 cstar: np.ndarray, path: Optional[str] = None):
237 self.ox_name, self.fuel_name, self.path = ox_name, fuel_name, path
238 self.MR, self.Pc, self.cstar = np.asarray(MR, float), np.asarray(Pc, float), np.asarray(cstar, float)
239 self._lnMR, self._lnPc = np.log(self.MR), np.log(self.Pc)
240 self.MR_min, self.MR_max = float(self.MR[0]), float(self.MR[-1])
241
242 @staticmethod
243 def default_path(ox_name: str, fuel_name: str) -> str:
244 root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
245 return os.path.join(root, "output", "cache", f"cstar_wide_{ox_name}_{fuel_name}.npz")
246
247 @classmethod
248 def load(cls, ox_name: str, fuel_name: str, path: Optional[str] = None) -> Optional["CstarWideTable"]:
249 path = path or cls.default_path(ox_name, fuel_name)
250 if not os.path.exists(path):
251 return None
252 with np.load(path, allow_pickle=False) as d:
253 try:
254 meta = json.loads(str(d["meta"]))
255 except Exception:
256 return None
257 if (meta.get("schema") != CSTAR_WIDE_SCHEMA_VERSION or meta.get("ox_name") != ox_name
258 or meta.get("fuel_name") != fuel_name):
259 return None
260 return cls(ox_name, fuel_name, d["MR"], d["Pc"], d["cstar"], path)
261
262 @classmethod
263 def build(cls, ox_name: str, fuel_name: str, path: Optional[str] = None) -> "CstarWideTable":
264 from rocketcea.cea_obj import CEA_Obj
265 C = CEA_Obj(oxName=ox_name, fuelName=fuel_name)
266 tab = np.full((_CSTAR_WIDE_MR.size, _CSTAR_WIDE_PC.size), np.nan)
267 for i, mr in enumerate(_CSTAR_WIDE_MR):
268 for j, pc in enumerate(_CSTAR_WIDE_PC):
269 try:
270 v = float(C.get_Cstar(float(pc) / _PSI_PA, float(mr))) * _FT_M
271 except Exception:
272 v = float("nan")
273 tab[i, j] = v if (np.isfinite(v) and v > 0) else np.nan
274 if np.isnan(tab).any():
275 raise RuntimeError(f"CEA returned no c* at {int(np.isnan(tab).sum())} points for "
276 f"{ox_name}/{fuel_name}; the wide c* table would have holes")
277 path = path or cls.default_path(ox_name, fuel_name)
278 meta = {"schema": CSTAR_WIDE_SCHEMA_VERSION, "ox_name": ox_name, "fuel_name": fuel_name,
279 "source": "rocketcea CEA_Obj.get_Cstar (equilibrium chamber)"}
280 try:
281 os.makedirs(os.path.dirname(path), exist_ok=True)
282 np.savez(path, MR=_CSTAR_WIDE_MR, Pc=_CSTAR_WIDE_PC, cstar=tab, meta=json.dumps(meta))
283 except OSError:
284 pass
285 return cls(ox_name, fuel_name, _CSTAR_WIDE_MR, _CSTAR_WIDE_PC, tab, path)
286
287 def row(self, Pc: float) -> np.ndarray:
288 """c*(O/F grid) at Pc, linear in ln Pc (clamped to the grid: c* moves ~1 % per doubling)."""
289 x = float(np.clip(math.log(Pc), self._lnPc[0], self._lnPc[-1]))
290 j = int(np.clip(np.searchsorted(self._lnPc, x) - 1, 0, self._lnPc.size - 2))
291 f = (x - self._lnPc[j]) / (self._lnPc[j + 1] - self._lnPc[j])
292 return (1.0 - f) * self.cstar[:, j] + f * self.cstar[:, j + 1]
293
294
295def get_cstar_wide_table(ox_name: str, fuel_name: str) -> Optional[CstarWideTable]:
296 """The committed wide c* table for a propellant pair; built from rocketcea when missing and CEA
297 builds are allowed (cea_cache._cea_build_allowed). None when neither is possible."""
298 key = (str(ox_name), str(fuel_name))
299 if key in _CSTAR_WIDE_MEMO:
300 return _CSTAR_WIDE_MEMO[key]
301 tab = CstarWideTable.load(*key)
302 if tab is None:
303 from engine.pipeline.cea_cache import _cea_build_allowed
304 if _cea_build_allowed():
305 try:
306 tab = CstarWideTable.build(*key)
307 except Exception:
308 tab = None
309 _CSTAR_WIDE_MEMO[key] = tab
310 return tab
311
312
314 """c*(O/F) at one chamber pressure, for ratios of c* between stream tubes.
315
316 One function serves numerator and denominator, so a perfectly mixed spray gives exactly 1.
317 Source, in order: the wide CEA table (O/F 0.1-20); else the design's CEA cache inside its O/F
318 range. Beyond either range the tube is the edge mixture diluted with unburned propellant:
319 with cp and molecular weight held, T0 and so c*^2 scale with the limiting propellant's
320 fraction, c* = c*_edge sqrt(r/r_edge) (fuel side) or sqrt((1 - r)/(1 - r_edge)) (oxidizer
321 side), r = O/(O + F). Against rocketcea for LOX/ethanol at 374 psia from the 1.0/2.5 edges this
322 is +8 % at O/F 0.7, -4 % at 0.3, -3 % at 3.0, -14 % at 6. Every use is recorded
323 (``outside``) so the caller can say how much mass rode on it.
324 """
325
326 def __init__(self, Pc: float, wide: Optional[CstarWideTable] = None, cea_cache: Any = None):
327 if wide is None and cea_cache is None:
328 raise ValueError("c*(O/F) needs the wide CEA c* table or the design's CEA cache")
329 self.Pc = float(Pc)
330 if wide is not None:
331 self.source = f"CEA c* table O/F {wide.MR_min:g}-{wide.MR_max:g} ({os.path.basename(wide.path or '')})"
332 self._lnMR = wide._lnMR
333 self._row = wide.row(self.Pc)
334 self.MR_lo, self.MR_hi = wide.MR_min, wide.MR_max
335 self._cache = None
336 else:
337 self.MR_lo, self.MR_hi = float(cea_cache.MR_min), float(cea_cache.MR_max)
338 self.source = f"design CEA cache O/F {self.MR_lo:g}-{self.MR_hi:g}"
339 self._cache = cea_cache
340 self.r_lo = self.MR_lo / (1.0 + self.MR_lo)
341 self.r_hi = self.MR_hi / (1.0 + self.MR_hi)
342 self._c_lo = float(self._in_range(np.array([self.MR_lo]))[0])
343 self._c_hi = float(self._in_range(np.array([self.MR_hi]))[0])
344
345 def _in_range(self, mr: np.ndarray) -> np.ndarray:
346 if self._cache is None:
347 return np.interp(np.log(mr), self._lnMR, self._row)
348 return np.array([float(self._cache.eval(float(m), self.Pc)["cstar_ideal"]) for m in mr])
349
350 def of_fraction(self, r: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
351 """c* at oxidizer mass fractions r = O/(O+F) in [0, 1]; returns (c*, outside-range mask)."""
352 r = np.clip(np.asarray(r, dtype=float), 0.0, 1.0)
353 out = np.empty_like(r)
354 lo, hi = r < self.r_lo, r > self.r_hi
355 mid = ~(lo | hi)
356 if mid.any():
357 rm = r[mid]
358 out[mid] = self._in_range(np.clip(rm / (1.0 - rm), self.MR_lo, self.MR_hi))
359 out[lo] = self._c_lo * np.sqrt(r[lo] / self.r_lo)
360 out[hi] = self._c_hi * np.sqrt((1.0 - r[hi]) / (1.0 - self.r_hi))
361 return out, (lo | hi)
362
363 def __call__(self, MR: float) -> float:
364 MR = float(MR)
365 r = 1.0 if not np.isfinite(MR) else MR / (1.0 + MR)
366 return float(self.of_fraction(np.array([r]))[0][0])
367
368
369# --------------------------------------------------------------------------- liquid properties
370#: Config fluid names -> CoolProp names. A name not here is tried as given.
371_COOLPROP_ALIASES = {"LOX": "Oxygen", "O2": "Oxygen", "GOX": "Oxygen", "LCH4": "Methane",
372 "CH4": "Methane", "LNG": "Methane", "LH2": "Hydrogen", "H2": "Hydrogen",
373 "N2O": "NitrousOxide", "H2O2": None, "RP-1": None, "RP1": None,
374 "Kerosene": None, "Jet-A": None}
375_AS_MEMO: Dict[str, Any] = {}
376_HEATUP_GL_X, _HEATUP_GL_W = np.polynomial.legendre.leggauss(24)
377
378
379def _coolprop_state(name: Optional[str]):
380 if not name:
381 return None
382 cp_name = _COOLPROP_ALIASES.get(str(name), str(name))
383 if cp_name is None:
384 return None
385 if cp_name in _AS_MEMO:
386 return _AS_MEMO[cp_name]
387 try:
388 import CoolProp.CoolProp as CP
389 st = (CP, CP.AbstractState("HEOS", cp_name))
390 except Exception:
391 st = None
392 _AS_MEMO[cp_name] = st
393 return st
394
395
396def liquid_heatup_properties(name: Optional[str], P: float, T0: float, T_s: float,
397 Tc: float) -> Optional[Dict[str, float]]:
398 """Heat-up integral I = int_{T0}^{Ts} cp_l(T, P) dT / (T_c - T) and the liquid density at the
399 mean drop temperature (T0 + Ts)/2, from CoolProp for the named fluid. None when CoolProp does
400 not know the fluid.
401
402 The drop's surface temperature Ts comes from Clausius-Clapeyron; when it lies above CoolProp's
403 own T_sat(P) the liquid is integrated to T_sat - 0.05 K and the last sliver is carried at the
404 cp there (reported as ``T_liquid_limit``). Above the critical pressure there is no saturation
405 and the liquid is integrated to Ts.
406 """
407 st = _coolprop_state(name)
408 if st is None or not (T_s > T0):
409 return None
410 CP, AS = st
411 try:
412 try:
413 AS.update(CP.PQ_INPUTS, float(P), 0.0)
414 T_lim = float(AS.T()) - 0.05
415 except Exception:
416 T_lim = float("inf") # supercritical: single phase to Ts
417 T_hi = min(float(T_s), T_lim)
418 if not T_hi > T0:
419 return None
420 Tn = 0.5 * (T_hi - T0) * (_HEATUP_GL_X + 1.0) + T0
421 cp = np.empty_like(Tn)
422 for k, T in enumerate(Tn):
423 AS.update(CP.PT_INPUTS, float(P), float(T))
424 cp[k] = AS.cpmass()
425 I = 0.5 * (T_hi - T0) * float(np.sum(_HEATUP_GL_W * cp / (Tc - Tn)))
426 if T_s > T_hi:
427 AS.update(CP.PT_INPUTS, float(P), T_hi)
428 I += float(AS.cpmass()) * math.log((Tc - T_hi) / (Tc - T_s))
429 T_mean = 0.5 * (T0 + min(float(T_s), T_hi))
430 AS.update(CP.PT_INPUTS, float(P), T_mean)
431 rho = float(AS.rhomass())
432 cp_mean = I / math.log((Tc - T0) / (Tc - T_s))
433 except Exception:
434 return None
435 if not all(np.isfinite(v) and v > 0 for v in (I, rho, cp_mean)):
436 return None
437 return {"heatup_integral": float(I), "rho_l": rho, "T_mean": float(T_mean),
438 "cp_l_effective": float(cp_mean), "T_liquid_limit": float(T_hi)}
439
440
441#: Droplet blowing (Stefan-flow) models for the march. Named so a run report says which law set
442#: the vaporization rate. The Spalding transfer numbers here are B_T = 16-80 (LOX and ethanol in a
443#: ~3200 K gas), far past the B < 1-ish range where uncorrected Ranz-Marshall convection and
444#: solid-sphere drag hold.
445BLOWING_MODELS = ("abramzon_sirignano", "none")
446
447
448def abramzon_sirignano_F(B: float) -> float:
449 """Film-thickening factor F(B) = (1 + B)^0.7 ln(1 + B) / B (Abramzon & Sirignano, Int. J. Heat
450 Mass Transfer 32(9), 1989, eq. 19). Nu* = 2 + (Nu0 - 2)/F(B_T): the Stefan flow thickens the
451 film and cuts the convective part of the heat transfer. F(0) = 1."""
452 if not (np.isfinite(B) and B >= 0.0):
453 raise ValueError(f"abramzon_sirignano_F: B={B} must be finite and >= 0")
454 if B < 1e-8:
455 return 1.0
456 return float((1.0 + B) ** 0.7 * math.log1p(B) / B)
457
458
459def yuen_chen_drag_factor(B: float) -> float:
460 """C_D of an evaporating drop over the standard curve at film properties, (1 + B)^-0.2
461 (Yuen & Chen, Combust. Sci. Technol. 14, 1976, eq. 5; Faeth, PECS 9, 1983)."""
462 if not (np.isfinite(B) and B >= 0.0):
463 raise ValueError(f"yuen_chen_drag_factor: B={B} must be finite and >= 0")
464 return float((1.0 + B) ** -0.2)
465
466
468 streams: List[Dict[str, float]],
469 *,
470 Pc: float,
471 Tc: float,
472 gamma: float,
473 R: float,
474 m_dot_total: float,
475 Ac: float,
476 L_chamber: float,
477 u_drop0: float,
478 rr_q: float,
479 blowing: str = "abramzon_sirignano",
480 profile_points: int = 0,
481) -> Dict[str, Any]:
482 """Fraction of each stream vaporized along the chamber (Priem & Heidmann, NASA TR R-67).
483
484 ``profile_points`` > 0 also records each stream's fraction vaporized at about that many
485 stations (``out["profile"][name] = [[x, f], ...]``, x from where the drops form). It runs the
486 Python march, which the compiled one matches (tests/test_compiled_hot_loops.py); 0, the
487 default, is the solver's path and records nothing.
488
489 Each stream is a Rosin-Rammler spray (volume basis, X = D32 Gamma(1 - 1/q)) resolved into
490 size classes. Drops start at ``u_drop0`` where they form (the caller places that plane, and
491 ``L_chamber`` is the distance from it to the throat). The gas is the burned vaporized
492 propellant: U_g(x) = U_c F(x), F the fraction vaporized, U_c = mdot/(rho_c A_c). Per class:
493
494 - heat-up then evaporation, in series (Law, Prog. Energy Combust. Sci. 8, 1982), lumped, at
495 the initial slip: t_heat = rho_l D0^2 / (6 Nu0 k_f) * I, I = int_{T0}^{Ts} cp_l dT/(T_c - T)
496 (``heatup_integral`` when the caller has the liquid's cp(T); else cp_l ln((T_c - T0)/(T_c - Ts)));
497 - d^2-law, dD^2/dt = -K, K = 4 Nu k_f ln(1 + B)/(rho_l c_pg), B = c_pg (T_c - T_s)/h_fg
498 (Spalding; Turns ch. 3 and 10), Nu = 2 + 0.6 Re_f^0.5 Pr^(1/3) (Ranz-Marshall);
499 - drag, dv/dt = (U_g - v)/tau_p, tau_p = rho_l D^2/(18 mu_f phi), phi = 1 + Re^(2/3)/6 below
500 Re = 1000 and 0.424 Re/24 above (Putnam), Re on free-stream density (Yuen & Chen 1976).
501
502 ``blowing`` = "abramzon_sirignano" corrects both for the Stefan flow of an evaporating drop:
503 Nu* = 2 + (Nu0 - 2)/F(B) (Abramzon & Sirignano 1989) and C_D (1 + B)^-0.2 (Yuen & Chen 1976),
504 applied while the drop evaporates (not during heat-up). "none" is the uncorrected laws.
505
506 Film properties at T_f = T_s + (T_c - T_s)/3 (1/3 rule): mu_f from Huzel & Huang,
507 k_f = mu_f c_pg/Pr, rho_f = Pc/(R T_f), c_pg = gamma R/(gamma - 1).
508
509 ``streams``: dicts with name, mass_fraction, D32, rho_l, cp_l, T0, T_s, h_fg (and optionally
510 heatup_integral); a stream with ``instant`` set is vaporized at the face.
511 """
512 if blowing not in BLOWING_MODELS:
513 raise ValueError(f"blowing model {blowing!r} is not one of {BLOWING_MODELS}")
514 if not (rr_q > 1.0 and np.isfinite(rr_q)):
515 raise ValueError(f"Rosin-Rammler spread q={rr_q} must exceed 1")
516 for name, v in (("Pc", Pc), ("Tc", Tc), ("R", R), ("m_dot_total", m_dot_total), ("Ac", Ac),
517 ("L_chamber", L_chamber), ("u_drop0", u_drop0)):
518 if not (np.isfinite(v) and v > 0):
519 raise ValueError(f"spray_vaporization_march: invalid {name}={v}")
520 if not (np.isfinite(gamma) and gamma > 1.0):
521 raise ValueError(f"spray_vaporization_march: invalid gamma={gamma}")
522
523 cp_g = gamma * R / (gamma - 1.0)
524 rho_c = Pc / (R * Tc)
525 U_c = m_dot_total / (rho_c * Ac)
526 M_gas = R_UNIVERSAL_KMOL / R
527 Pr = PRANDTL_DEFAULT
528 pr3 = Pr ** (1.0 / 3.0)
529 x_scale = math.gamma(1.0 - 1.0 / rr_q)
530 y_pow = _RR_NODES ** (1.0 / rr_q)
531 nq = _RR_NODES.size
532
533 F0 = 0.0
534 keys = ("D0", "w", "rho_l", "mu", "rho_f", "k", "lnB", "t_h", "inv_FB", "cd_blow")
535 cols: Dict[str, List[np.ndarray]] = {k: [] for k in keys}
536 marched: List[Tuple[str, float, slice]] = []
537 blow_out: Dict[str, Dict[str, float]] = {}
538 for s in streams:
539 w = float(s["mass_fraction"])
540 if s.get("instant"):
541 F0 += w
542 continue
543 T_s, h_fg, rho_l = float(s["T_s"]), float(s["h_fg"]), float(s["rho_l"])
544 T_f = T_s + (Tc - T_s) / 3.0
545 mu_f = _gas_viscosity(T_f, M_gas)
546 k_f = mu_f * cp_g / Pr
547 rho_f = Pc / (R * T_f)
548 D0 = float(s["D32"]) * x_scale * y_pow
549 Nu0 = 2.0 + 0.6 * np.sqrt(rho_f * u_drop0 * D0 / mu_f) * pr3
550 I_heat = s.get("heatup_integral")
551 if I_heat is None:
552 # A drop injected at or above its surface temperature needs no heat-up.
553 I_heat = float(s["cp_l"]) * max(math.log((Tc - float(s["T0"])) / (Tc - T_s)), 0.0)
554 t_h = rho_l * D0 ** 2 / (6.0 * Nu0 * k_f) * max(float(I_heat), 0.0)
555 B = cp_g * (Tc - T_s) / h_fg if h_fg > 0 else math.inf
556 lnB = math.log1p(B)
557 inv_FB, cd_blow = 1.0, 1.0
558 if blowing == "abramzon_sirignano" and np.isfinite(B):
559 inv_FB = 1.0 / abramzon_sirignano_F(B)
560 cd_blow = yuen_chen_drag_factor(B)
561 blow_out[str(s["name"])] = {"B_T": float(B), "F_B": 1.0 / inv_FB, "drag_factor": cd_blow}
562 i0 = sum(a.size for a in cols["D0"])
563 marched.append((str(s["name"]), w, slice(i0, i0 + nq)))
564 for key, val in (("D0", D0), ("w", w * _RR_WEIGHTS), ("rho_l", rho_l), ("mu", mu_f),
565 ("rho_f", rho_f), ("k", k_f), ("lnB", lnB), ("t_h", t_h),
566 ("inv_FB", inv_FB), ("cd_blow", cd_blow)):
567 cols[key].append(np.broadcast_to(np.asarray(val, dtype=float), (nq,)).copy())
568
569 out: Dict[str, Any] = {"U_c": float(U_c), "L_chamber": float(L_chamber),
570 "u_drop0": float(u_drop0), "frac_vaporized": {}, "x_vap95": {},
571 "blowing_model": blowing, "blowing": blow_out}
572 for s in streams:
573 if s.get("instant"):
574 out["frac_vaporized"][str(s["name"])] = 1.0
575 out["x_vap95"][str(s["name"])] = 0.0
576 if not marched:
577 out["F_throat"] = float(F0)
578 return out
579
580 D0, w_cls, rho_l, mu, rho_f, k_f, lnB, t_h, inv_FB, cd_blow = (np.concatenate(cols[k]) for k in keys)
581 D2_0 = D0 ** 2
582 dx = L_chamber / _MARCH_STEPS
583
584 def _rates(D2: np.ndarray, v: np.ndarray, F: float, t_drag: np.ndarray):
585 U_g = U_c * F
586 D = np.sqrt(D2)
587 dU = np.abs(U_g - v)
588 Re_d = rho_c * dU * D / mu
589 K = 4.0 * (2.0 + 0.6 * np.sqrt(rho_f * dU * D / mu) * pr3 * inv_FB) * k_f * lnB / (rho_l * cp_g)
590 phi = np.where(Re_d <= 1000.0, 1.0 + Re_d ** (2.0 / 3.0) / 6.0, 0.424 * Re_d / 24.0)
591 phi = np.where(t_drag > t_h, phi * cd_blow, phi)
592 inv_tau = 18.0 * mu * phi / (rho_l * D2)
593 return U_g, K, inv_tau, dx / np.maximum(v, _V_FLOOR)
594
595 def _advance(D2, v, t, U_g, K, inv_tau, dt):
596 t_ev = np.clip(t + dt - t_h, 0.0, dt)
597 D2n = np.where(t_ev > 0.0, np.maximum(D2 - K * t_ev, 0.0), D2)
598 vn = np.where(D2 > 0.0, U_g + (v - U_g) * np.exp(-dt * inv_tau), U_g)
599 return D2n, vn, t + dt
600
601 def _vaporized(D2: np.ndarray) -> Tuple[np.ndarray, float]:
602 rem = (D2 / D2_0) ** 1.5
603 return rem, F0 + float(np.sum(w_cls * (1.0 - rem)))
604
605 if _USE_COMPILED_MARCH and not profile_points:
606 starts = np.array([sl.start for _, _, sl in marched], dtype=np.int64)
607 fv, xv, F = _march_core(D2_0, w_cls, rho_l, mu, rho_f, k_f, lnB, t_h, inv_FB, cd_blow,
608 float(U_c), float(rho_c), float(pr3), float(cp_g), float(dx),
609 int(_MARCH_STEPS), float(u_drop0), float(F0), float(_V_FLOOR),
610 _RR_WEIGHTS, starts, int(nq))
611 for (name, _, _), f, x in zip(marched, fv, xv):
612 out["frac_vaporized"][name] = float(f)
613 out["x_vap95"][name] = None if math.isnan(x) else float(x)
614 out["F_throat"] = float(F)
615 return out
616
617 D2 = D2_0.copy()
618 v = np.full(D0.size, float(u_drop0))
619 t = np.zeros(D0.size)
620 F = F0
621 prev = {name: 0.0 for name, _, _ in marched}
622 x95 = {name: math.nan for name, _, _ in marched}
623 every = max(1, _MARCH_STEPS // int(profile_points)) if profile_points else 0
624 prof: Dict[str, List[List[float]]] = {name: [[0.0, 0.0]] for name, _, _ in marched}
625 with np.errstate(divide="ignore", invalid="ignore", over="ignore"):
626 for i in range(_MARCH_STEPS):
627 # Heun: rates at both ends of the step, averaged (trapezoid in x).
628 r1 = _rates(D2, v, F, t)
629 D2p, vp, _ = _advance(D2, v, t, *r1)
630 r2 = _rates(D2p, vp, _vaporized(D2p)[1], t + r1[3])
631 D2, v, t = _advance(D2, v, t, *(0.5 * (a + b) for a, b in zip(r1, r2)))
632 rem, F = _vaporized(D2)
633 x_i = (i + 1) * dx
634 for name, w, sl in marched:
635 f = 1.0 - float(np.sum(_RR_WEIGHTS * rem[sl]))
636 if math.isnan(x95[name]) and f >= 0.95:
637 p = prev[name]
638 x95[name] = x_i - dx * (f - 0.95) / (f - p) if f > p else x_i
639 prev[name] = f
640 if every and ((i + 1) % every == 0 or i + 1 == _MARCH_STEPS):
641 prof[name].append([float(x_i), float(f)])
642 for name, w, sl in marched:
643 out["frac_vaporized"][name] = float(prev[name])
644 out["x_vap95"][name] = None if math.isnan(x95[name]) else float(x95[name])
645 out["F_throat"] = float(F)
646 if every:
647 out["profile"] = prof
648 return out
649
650
652 *,
653 Pc: float,
654 Tc: float,
655 gamma: float,
656 R: float,
657 MR: float,
658 m_dot_total: float,
659 Ac: float,
660 At: float,
661 Lstar: float,
662 spray_diagnostics: Dict[str, Any],
663 fuel_props: Optional[Dict[str, Any]],
664 ox_props: Optional[Dict[str, Any]],
665 u_fuel: float,
666 u_lox: float,
667 rr_q: float,
668 assumptions: List[Dict[str, Any]],
669 blowing: str = "abramzon_sirignano",
670 cstar_fn: Optional[CstarOfMR] = None,
671) -> Tuple[float, Dict[str, Any]]:
672 """c* retained by incomplete vaporization (Priem & Heidmann, NASA TR R-67).
673
674 Unvaporized liquid crosses the throat carrying mass and no energy, so
675 c*_eff = Pc At/mdot = (mdot_gas/mdot) c*(O/F_gas):
676
677 eta_vap = F c*(O/F_vap)/c*(O/F), F = fraction vaporized, O/F_vap = O/F f_O/f_F,
678
679 f_O, f_F each stream's fraction vaporized. The c* ratio has either sign: it is above 1 when
680 the vaporized gas lies nearer the c* peak than the injected O/F, and below 1 past it (the
681 6.5 kN ethalox design sits at the LOX/ethanol c* peak, O/F ~1.53 at 374 psia, and its
682 fuel-limited gas at 1.62 loses 0.1 %). Without ``cstar_fn`` it is taken as 1 and recorded.
683
684 Drops form where the doublet's sheet breaks up, x0 = L_imp + L_sheet_breakup from the face
685 (injector diagnostics), and are marched from there over L - x0; L = L* A_t/A_c is the
686 volume-equivalent cylinder. Each stream evaporates at its own saturation state at Pc with its
687 own D32. Liquid heat-up integrates CoolProp's cp(T) at Pc for the named fluid, with the liquid
688 density at the mean drop temperature; a fluid CoolProp does not know uses the configured cp
689 and density, recorded.
690 """
691 if not (np.isfinite(MR) and MR > 0):
692 raise ValueError(f"Invalid MR={MR}")
693 w_O = MR / (1.0 + MR)
694 w_F = 1.0 / (1.0 + MR)
695 diag = spray_diagnostics or {}
696
697 def _d32(key: str) -> Optional[float]:
698 try:
699 v = float(diag.get(key))
700 except (TypeError, ValueError):
701 return None
702 return v if (np.isfinite(v) and v > 0) else None
703
704 def _instant(tag: str, w: float, why: str) -> Dict[str, Any]:
705 label = "fuel" if tag == "F" else "oxidizer"
706 _record(assumptions, f"combustion.vaporization.{label}", "vaporized at the face", "", why)
707 return {"name": tag, "mass_fraction": w, "instant": True}
708
709 def _stream(tag: str, w: float, props: Optional[Dict[str, Any]]) -> Dict[str, Any]:
710 label = "fuel" if tag == "F" else "oxidizer"
711 D32 = _d32(f"D32_{tag}")
712 if D32 is None:
713 return _instant(tag, w, "no D32 from the injector")
714 if props is None:
715 return _instant(tag, w, f"fluids.{label} properties not passed to the c* model")
716 p = dict(props)
717 p["density"] = p.get("density") or diag.get(f"rho_{tag}_momentum")
718 p["latent_heat"] = p.get("L_vap") or p.get("latent_heat")
719 missing = [k for k in ("density", "boiling_point", "latent_heat", "molecular_weight") if not p.get(k)]
720 if missing:
721 return _instant(tag, w, f"fluids.{label} {', '.join(missing)} not passed to the c* model")
722 if p.get("temperature") is None:
723 p["temperature"] = _record(assumptions, f"combustion.vaporization.{label}.temperature", 293.0,
724 "K", f"fluids.{label}.temperature not passed to the c* model")
725 T_crit = p.get("critical_temperature") or None
726 if T_crit is None:
727 _record(assumptions, f"combustion.vaporization.{label}.critical_temperature", None, "K",
728 "no critical temperature: latent heat kept at its normal-boiling-point value")
729 T_s, h_fg = saturation_state(Pc, float(p["boiling_point"]), float(p["latent_heat"]),
730 float(p["molecular_weight"]), T_crit)
731 T0 = float(p["temperature"])
732 st = {"name": tag, "mass_fraction": w, "D32": D32, "T0": T0, "T_s": T_s, "h_fg": h_fg}
733 liq = liquid_heatup_properties(p.get("name"), Pc, T0, T_s, Tc) if T_s > T0 else None
734 if liq is not None:
735 st.update(rho_l=liq["rho_l"], cp_l=liq["cp_l_effective"], heatup_integral=liq["heatup_integral"],
736 liquid_source=f"CoolProp {p.get('name')}: cp(T) at Pc, rho at {liq['T_mean']:.1f} K",
737 T_liquid_limit=liq["T_liquid_limit"])
738 else:
739 if p.get("specific_heat") is None:
740 p["specific_heat"] = _record(assumptions, f"combustion.vaporization.{label}.specific_heat",
741 2000.0, "J/(kg K)",
742 f"fluids.{label}.specific_heat not passed to the c* model")
743 if T_s > T0:
744 _record(assumptions, f"combustion.vaporization.{label}.liquid_properties",
745 {"cp_l": float(p["specific_heat"]), "rho_l": float(p["density"])}, "SI",
746 (f"CoolProp has no fluid {p.get('name')!r}" if p.get("name") else
747 f"fluids.{label}.name not passed") + ": heat-up at constant cp_l and the "
748 "configured density")
749 st.update(rho_l=float(p["density"]), cp_l=float(p["specific_heat"]),
750 liquid_source="configured cp_l and density (constant)")
751 return st
752
753 if fuel_props is None:
754 raise ValueError("fuel_props is required for the vaporization model.")
755 if _d32("D32_O") is None and _d32("D32_F") is None:
756 raise ValueError(f"spray_diagnostics must carry D32_O or D32_F; got keys {list(diag.keys())}")
757 streams = [_stream("O", w_O, ox_props), _stream("F", w_F, fuel_props)]
758
759 # Drops leave the impingement with the pair's axial momentum per unit mass,
760 # u_ax = (mdot_O u_O cos th_O + mdot_F u_F cos th_F) / mdot (spray.spray_axial_velocity).
761 u_ax = diag.get("u_axial_spray")
762 try:
763 u_ax = float(u_ax)
764 except (TypeError, ValueError):
765 u_ax = float("nan")
766 if not (np.isfinite(u_ax) and u_ax > 0):
767 u_mass = w_O * u_lox + w_F * u_fuel
768 included = diag.get("impingement_angle_deg")
769 if included is not None and np.isfinite(float(included)):
770 u_ax = u_mass * math.cos(math.radians(0.5 * float(included)))
771 _record(assumptions, "combustion.vaporization.spray_axial_velocity", u_ax, "m/s",
772 "u_axial_spray not in the injector diagnostics: both jets taken at half the "
773 "included impingement angle")
774 else:
775 u_ax = u_mass
776 _record(assumptions, "combustion.vaporization.spray_axial_velocity", u_ax, "m/s",
777 "no impingement geometry: drops leave the face axially with the streams' "
778 "mass-averaged injection speed")
779
780 # Where drops exist: the jets meet at L_imp and the sheet breaks up L_sheet_breakup further on.
781 def _len(key: str) -> Optional[float]:
782 try:
783 v = float(diag.get(key))
784 except (TypeError, ValueError):
785 return None
786 return v if (np.isfinite(v) and v >= 0) else None
787
788 L_imp, L_b = _len("L_imp"), _len("L_sheet_breakup")
789 if L_imp is None and L_b is None:
790 x0 = 0.0
791 _record(assumptions, "combustion.vaporization.drop_formation_x", 0.0, "m",
792 "no L_imp / L_sheet_breakup from the injector: drops taken to form at the face")
793 else:
794 x0 = (L_imp or 0.0) + (L_b or 0.0)
795
796 L_ch = Lstar * At / Ac
797 L_march = L_ch - x0
798 marched_any = any(not s.get("instant") for s in streams)
799 march_kw = dict(Pc=Pc, Tc=Tc, gamma=gamma, R=R, m_dot_total=m_dot_total, Ac=Ac,
800 L_chamber=max(L_march, 1e-9), u_drop0=u_ax, rr_q=rr_q, blowing=blowing)
801 if L_march > 0.0 or not marched_any:
802 march = spray_vaporization_march(streams, **march_kw)
803 else:
804 # The sheet reaches the throat before it breaks up: no drop evaporates in the chamber.
805 march = {"F_throat": float(sum(s["mass_fraction"] for s in streams if s.get("instant"))),
806 "frac_vaporized": {s["name"]: (1.0 if s.get("instant") else 0.0) for s in streams},
807 "x_vap95": {s["name"]: (0.0 if s.get("instant") else None) for s in streams},
808 "blowing_model": blowing, "blowing": {}}
809 F = float(march["F_throat"])
810 fO = float(march["frac_vaporized"].get("O", 1.0))
811 fF = float(march["frac_vaporized"].get("F", 1.0))
812 if cstar_fn is not None:
813 MR_vap = MR * fO / fF if fF > 0.0 else math.inf
814 cstar_ratio = cstar_fn(MR_vap) / cstar_fn(MR)
815 else:
816 MR_vap = MR * fO / fF if fF > 0.0 else math.inf
817 cstar_ratio = 1.0
818 _record(assumptions, "combustion.vaporization.cstar_of_vaporized_OF", 1.0, "",
819 "no c*(O/F) source passed: the vaporized gas's c* taken at the injected O/F")
820 eta_vap = F * cstar_ratio
821 diag_out = {
822 "fraction_vaporized": F,
823 "frac_vaporized_O": march["frac_vaporized"].get("O"),
824 "frac_vaporized_F": march["frac_vaporized"].get("F"),
825 "MR_vaporized": float(MR_vap),
826 "cstar_vaporized_ratio": float(cstar_ratio),
827 "x_vap95_O": march["x_vap95"].get("O"),
828 "x_vap95_F": march["x_vap95"].get("F"),
829 "L_chamber_equiv": float(L_ch),
830 "x_drop_formation": float(x0),
831 "L_march": float(max(L_march, 0.0)),
832 "u_drop0": float(u_ax),
833 "rr_q": float(rr_q),
834 "blowing_model": blowing,
835 # What it takes to run this march again with a profile (vaporization_profile): the
836 # stability report draws the same drops that set eta_vap, not a model of its own.
837 "march_replay": {"streams": [{k: v for k, v in st.items() if isinstance(v, (int, float, str, bool))}
838 for st in streams],
839 "kw": {k: (float(v) if not isinstance(v, str) else v) for k, v in march_kw.items()},
840 "x0": float(x0), "L_chamber": float(L_ch)},
841 }
842 for s in streams:
843 if not s.get("instant"):
844 n = s["name"]
845 diag_out[f"T_surface_{n}"] = s["T_s"]
846 diag_out[f"h_fg_{n}"] = s["h_fg"]
847 diag_out[f"rho_l_{n}"] = s["rho_l"]
848 diag_out[f"cp_l_{n}"] = s["cp_l"]
849 diag_out[f"liquid_props_{n}"] = s["liquid_source"]
850 b = march.get("blowing", {}).get(n)
851 if b:
852 diag_out[f"B_T_{n}"] = b["B_T"]
853 return eta_vap, diag_out
854
855
857 Tc: float,
858 Pc: float,
859 tau_res: float,
860 SMD: float,
861 rho_l: float,
862 cp_l: float,
863 L_eff: float,
864 T_inj: float,
865 cp_g: float,
866 rho_g: float,
867 mu_g: float,
868 U_slip: float,
869 D_m: Optional[float] = None,
870 Pr: float = 0.8,
871 fuel_props: Optional[dict] = None,
872 debug: bool = False,
873) -> Tuple[float, Dict[str, float]]:
874 """Single-drop gasification time scale. Used only by reaction_chemistry's progress diagnostics;
875 c* comes from ``spray_vaporization_march``.
876
877 eta = 1 - exp(-tau_res/tau_vap), tau_vap = tau_heat + tau_gasify: heat-up and gasification are
878 successive stages of one drop's life, so their times add (Law 1982; Lefebvre, Atomization and
879 Sprays ch. 8). They were combined as parallel rates, which made tau_vap shorter than either.
880 """
882 D_M_REF, D_M_T_REF, D_M_P_REF, U_SLIP_CAP, D_MIN_GASIFICATION
883 )
884 if Tc <= 0 or Pc <= 0 or tau_res <= 0:
885 raise ValueError(f"Invalid inputs: Tc={Tc}, Pc={Pc}, tau_res={tau_res}")
886 if SMD <= 0:
887 raise ValueError(f"Invalid SMD: {SMD}")
888
889 D = max(SMD, D_MIN_GASIFICATION)
890 D_sq = D ** 2
891 T_star_fuel_cap_K = fuel_props.get("T_star_fuel_cap_K", 1000.0) if fuel_props else 1000.0
892 dT_safe = max(200.0, 0.10 * Tc)
893 T_star = float(np.clip(min(T_star_fuel_cap_K, Tc - dT_safe), T_inj + 50.0, Tc - dT_safe))
894 k_g = mu_g * cp_g / Pr
895 if D_m is None:
896 D_m = D_M_REF * (Tc / D_M_T_REF) ** 1.75 * (D_M_P_REF / max(Pc, 1e3))
897 U_slip_capped = max(min(abs(U_slip), U_SLIP_CAP), 0.1)
898 Re = rho_g * U_slip_capped * D / max(mu_g, 1e-10)
899 Sc = mu_g / (rho_g * max(D_m, 1e-12))
900 Nu = 2.0 + 0.6 * np.sqrt(max(Re, 0.0)) * (Pr ** (1.0 / 3.0))
901 Sh = 2.0 + 0.6 * np.sqrt(max(Re, 0.0)) * (Sc ** (1.0 / 3.0))
902 dT_initial, dT_final = Tc - T_inj, Tc - T_star
903 if dT_final <= 0 or dT_initial <= dT_final:
904 tau_heat = 0.0
905 else:
906 tau_heat = (rho_l * cp_l * D_sq) / (6.0 * Nu * k_g) * np.log(dT_initial / dT_final)
907 energy_available = cp_g * (Tc - T_star)
908 Phi = float(np.clip(energy_available / max(energy_available + L_eff, 1e-6), 1e-6, 1.0))
909 denominator = 6.0 * rho_g * D_m * Sh * Phi
910 tau_gasify = (rho_l * D_sq) / denominator if denominator > 0 else np.inf
911 tau_vap = tau_heat + tau_gasify
912 eta_vap = 1.0 - np.exp(-tau_res / tau_vap) if (np.isfinite(tau_vap) and tau_vap > 0) else 1.0
913 diagnostics = {
914 "tau_heat": float(tau_heat),
915 "tau_gasify": float(tau_gasify),
916 "tau_vap": float(tau_vap),
917 "T_star": float(T_star),
918 "Nu": float(Nu),
919 "Sh": float(Sh),
920 "Phi": float(Phi),
921 "k_g": float(k_g),
922 "D_m": float(D_m),
923 "U_slip_capped": float(U_slip_capped),
924 "Re": float(Re),
925 "Sc": float(Sc),
926 "D": float(D),
927 }
928 return float(eta_vap), diagnostics
929
930
932 rho_O: float, v_O: float, d_O: float,
933 rho_F: float, v_F: float, d_F: float,
934) -> float:
935 """Elverum & Morey mixing parameter M = rho_O v_O^2 d_O / (rho_F v_F^2 d_F).
936
937 JPL Memo 30-5 (1959) eq. 1, SP-8089 eq. 1: mixture-ratio uniformity of an unlike element is
938 best at M = M_opt, 1.0 for a 1-on-1 doublet (SP-8089 Table IV). No angle term.
939 """
940 num = rho_O * v_O * v_O * d_O
941 den = rho_F * v_F * v_F * d_F
942 if not (np.isfinite(num) and np.isfinite(den) and num > 0 and den > 0):
943 raise ValueError(f"Rupe M needs positive rho, v, d on both streams; got {num} / {den}")
944 return float(num / den)
945
946
947def rupe_M_from_diagnostics(diag: Optional[Dict[str, Any]]) -> Optional[float]:
948 """Rupe M from injector diagnostics: ``rupe_M`` if published, else from the jet state
949 (rho_*_momentum, v_*_bulk, d_jet_*), else from momentum_ratio_R and the jet diameters
950 (M = R^2 d_O/d_F). None when the injector is not an impinging doublet."""
951 if not diag:
952 return None
953
954 def _f(key: str) -> float:
955 try:
956 return float(diag.get(key))
957 except (TypeError, ValueError):
958 return float("nan")
959
960 M = _f("rupe_M")
961 if np.isfinite(M) and M > 0:
962 return M
963 rO, vO, dO = _f("rho_O_momentum"), _f("v_O_bulk"), _f("d_jet_O")
964 rF, vF, dF = _f("rho_F_momentum"), _f("v_F_bulk"), _f("d_jet_F")
965 if all(np.isfinite(x) and x > 0 for x in (rO, vO, dO, rF, vF, dF)):
966 return rupe_mixing_parameter(rO, vO, dO, rF, vF, dF)
967 Rm = _f("momentum_ratio_R")
968 if all(np.isfinite(x) and x > 0 for x in (Rm, dO, dF)):
969 return float(Rm * Rm * dO / dF)
970 return None
971
972
974 theta_O_deg: Optional[float],
975 theta_F_deg: Optional[float],
976 d_O: Optional[float] = None,
977 d_F: Optional[float] = None,
978) -> float:
979 """Momentum ratio R = sqrt(rho_O u_O^2 / rho_F u_F^2) at which a doublet's resultant is axial.
980
981 The transverse momenta cancel when mdot_O u_O sin(theta_O) = mdot_F u_F sin(theta_F), i.e.
982 rho_O u_O^2 A_O sin(theta_O) = rho_F u_F^2 A_F sin(theta_F), so
983 R_tilt = (d_F/d_O) sqrt(sin(theta_F)/sin(theta_O)).
984 This sets the spray direction, not the mixing optimum (that is Rupe's M = 1); the c* model does
985 not use it. Without diameters the orifices are taken equal. 1.0 without angles.
986 """
987 if theta_O_deg is None or theta_F_deg is None:
988 return 1.0
989 sO = np.sin(np.deg2rad(float(theta_O_deg)))
990 sF = np.sin(np.deg2rad(float(theta_F_deg)))
991 if not (np.isfinite(sO) and np.isfinite(sF)) or sO <= 0.0 or sF <= 0.0:
992 return 1.0
993 area = 1.0
994 if d_O is not None and d_F is not None and d_O > 0 and d_F > 0:
995 area = float(d_F) / float(d_O)
996 return float(area * np.sqrt(sF / sO))
997
998
999# --------------------------------------------------------------------------- mixing
1000#: Rupe's correlation of E_m against N_R = 1/(1 + phi), phi = rho_F V_F^2 D_F/(rho_O V_O^2 D_O)
1001#: (= 1/M), for free circular jets of unlike doublets: Hoehn, Rupe & Sotter, JPL TR 32-1546 (1972)
1002#: eq. 2 and Fig. 1 (reproduced from Rupe's 1956 data, orifice area ratios 0.26-1.0). The mean
1003#: line, digitized from Fig. 1 at 13 points over N_R 0.26-0.78 and fitted with
1004#: E_m = E_max (1 - a (N_R - N_opt)^2), gives E_max 77.2, N_opt 0.525, a = 6.07, within 2.4 points
1005#: of the digitized line (the data scatter +-5 points about it). The curvature a is used here
1006#: with the optimum N_opt = M_opt/(1 + M_opt) of rupe_M_opt; E_max is rupe_Em_opt.
1007RUPE_FIG1_N_RANGE = (0.26, 0.78)
1008# The normal is resolved by Gauss-Legendre on each half, [0, 6 sigma] mirrored: E_m is a mean
1009# absolute deviation, and |z| has a kink at the centre that Gauss-Hermite does not integrate (24
1010# Hermite nodes put the MAD 1.7 % high, which moved eta_mix by 2.6e-3 against a direct CEA quad).
1011_HALF_X, _HALF_W = np.polynomial.legendre.leggauss(32)
1012_HALF_X = 3.0 * (_HALF_X + 1.0)
1013_HALF_W = 3.0 * _HALF_W * np.exp(-0.5 * _HALF_X ** 2)
1014_GAUSS_X = np.concatenate([-_HALF_X[::-1], _HALF_X])
1015_GAUSS_W = np.concatenate([_HALF_W[::-1], _HALF_W])
1016_GAUSS_W = _GAUSS_W / _GAUSS_W.sum()
1017#: Symmetric unit-MAD distributions of r - R: sum w |z| = 1 (the Rupe E_m of r = R + MAD z is
1018#: 1 - MAD/(2 R (1 - R)) exactly, for both shapes).
1019_UNIT_MAD = {
1020 "gaussian": (_GAUSS_X / float(np.sum(_GAUSS_W * np.abs(_GAUSS_X))), _GAUSS_W),
1021 "two_tube": (np.array([-1.0, 1.0]), np.array([0.5, 0.5])),
1022}
1023MIXING_DISTRIBUTIONS = tuple(_UNIT_MAD)
1024
1025
1026def rupe_Em_at_M(M: float, Em_opt: float, M_opt: float = 1.0, curvature: float = 6.07) -> float:
1027 """Rupe mixing factor E_m of an unlike doublet at mixing parameter M = rho_O v_O^2 d_O /
1028 (rho_F v_F^2 d_F), from Rupe's correlation (JPL TR 32-1546 Fig. 1, see RUPE_FIG1_N_RANGE):
1029
1030 N_R = M/(1 + M), E_m = Em_opt (1 - curvature (N_R - N_opt)^2), floored at 0.
1031 """
1032 for name, v in (("M", M), ("M_opt", M_opt)):
1033 if not (np.isfinite(v) and v > 0):
1034 raise ValueError(f"rupe_Em_at_M: {name}={v} must be positive and finite")
1035 if not (0.0 < Em_opt <= 1.0):
1036 raise ValueError(f"rupe_Em_at_M: Em_opt={Em_opt} must lie in (0, 1]")
1037 if not (np.isfinite(curvature) and curvature >= 0):
1038 raise ValueError(f"rupe_Em_at_M: curvature={curvature} must be finite and >= 0")
1039 N = M / (1.0 + M)
1040 N0 = M_opt / (1.0 + M_opt)
1041 return float(max(Em_opt * (1.0 - curvature * (N - N0) ** 2), 0.0))
1042
1043
1044def rupe_Em_of_distribution(r: np.ndarray, w: np.ndarray, R: float) -> float:
1045 """Rupe's mixing factor (JPL TR 32-1546 eq. 1, as a fraction) of stream tubes with oxidizer
1046 mass fractions r and mass weights w about the bulk R:
1047 E_m = 1 - sum_{r<R} w (R - r)/R - sum_{r>R} w (r - R)/(1 - R)."""
1048 r, w = np.asarray(r, float), np.asarray(w, float) / float(np.sum(w))
1049 lo = r < R
1050 return float(1.0 - np.sum(w[lo] * (R - r[lo])) / R - np.sum(w[~lo] * (r[~lo] - R)) / (1.0 - R))
1051
1052
1054 MR: float,
1055 cstar_fn: CstarOfMR,
1056 elements: List[Tuple[float, float, float]],
1057 distribution: str = "gaussian",
1058) -> Dict[str, Any]:
1059 """c* mixing efficiency by stream tubes (Pieper, Dean & Valentine, JSR 4(6), 1967; Dickerson
1060 et al., AFRPL-TR-68-147): each tube burns to equilibrium at its own O/F and none mixes with
1061 another before the throat, so
1062
1063 eta_mix = sum_i w_i c*(O/F_i) / c*(O/F_bulk).
1064
1065 ``elements`` = [(mass weight, element O/F, element E_m)]. Within an element the oxidizer mass
1066 fraction r = O/(O+F) is spread about the element's own r_j with the mean absolute deviation
1067 that reproduces Rupe's E_m, MAD = 2 (1 - E_m) r_j (1 - r_j), in the named shape:
1068 "gaussian" (normal in r, 64 tubes to 6 sigma) or "two_tube" (equal-mass tubes at r_j +- MAD).
1069 Element-to-element striation enters through the r_j; the element centres are shifted together
1070 so their mass-weighted mean is the bulk (the injector's element split and MR come from
1071 different solves; the shift is reported). The integral stays unmixed to the throat, which
1072 over-states the loss by whatever gas-phase mixing does downstream of the spray.
1073 """
1074 if distribution not in _UNIT_MAD:
1075 raise ValueError(f"mixing distribution {distribution!r} is not one of {MIXING_DISTRIBUTIONS}")
1076 if not (np.isfinite(MR) and MR > 0):
1077 raise ValueError(f"stream_tube_mixing_efficiency: MR={MR}")
1078 R = MR / (1.0 + MR)
1079 W = np.array([e[0] for e in elements], float)
1080 if not (W.size and np.all(W >= 0) and W.sum() > 0):
1081 raise ValueError("stream_tube_mixing_efficiency: element weights must be >= 0 with a positive sum")
1082 W = W / W.sum()
1083 rj = np.array([e[1] / (1.0 + e[1]) for e in elements], float)
1084 Em = np.array([e[2] for e in elements], float)
1085 shift = R - float(np.sum(W * rj))
1086 rj = rj + shift
1087 z, wz = _UNIT_MAD[distribution]
1088 mad = 2.0 * (1.0 - Em) * rj * (1.0 - rj)
1089 r = (rj[:, None] + mad[:, None] * z[None, :]).ravel()
1090 w = (W[:, None] * wz[None, :]).ravel()
1091 clipped = (r < 0.0) | (r > 1.0)
1092 r = np.clip(r, 0.0, 1.0)
1093 cs, outside = cstar_fn.of_fraction(r)
1094 c_bulk = cstar_fn(MR)
1095 eta = float(np.sum(w * cs) / c_bulk)
1096 return {
1097 "eta_mix": eta,
1098 "distribution": distribution,
1099 "Em_total": rupe_Em_of_distribution(r, w, R),
1100 "element_centre_shift": float(shift),
1101 "mass_outside_cstar_table": float(np.sum(w[outside])),
1102 "mass_clipped_to_pure_propellant": float(np.sum(w[clipped])),
1103 "OF_range_99pct": _mass_quantiles_OF(r, w, (0.005, 0.995)),
1104 }
1105
1106
1107def _mass_quantiles_OF(r: np.ndarray, w: np.ndarray, qs: Tuple[float, float]) -> Tuple[float, float]:
1108 """O/F at the given cumulative mass fractions of the stream tubes."""
1109 o = np.argsort(r)
1110 c = np.cumsum(w[o]) / float(np.sum(w))
1111 out = []
1112 for q in qs:
1113 rq = float(r[o][min(int(np.searchsorted(c, q)), r.size - 1)])
1114 out.append(rq / (1.0 - rq) if rq < 1.0 else math.inf)
1115 return tuple(out)
1116
1117
1118def _element_split(diag: Dict[str, Any], MR: float) -> Optional[List[Tuple[float, float]]]:
1119 """Per-element (mass flow, O/F) from the injector's manifold model, or None.
1120
1121 ``element_mixture_ratios``: O/F per element. ``element_mass_flows``: per element either the
1122 total mdot, or (mdot_O, mdot_F) pairs (which then also give the O/F).
1123 """
1124 mrs = diag.get("element_mixture_ratios")
1125 flows = diag.get("element_mass_flows")
1126 if mrs is None and flows is None:
1127 return None
1128 fl = np.asarray(flows, float) if flows is not None else None
1129 if fl is not None and fl.ndim == 2 and fl.shape[1] == 2:
1130 tot = fl.sum(axis=1)
1131 mr = fl[:, 0] / fl[:, 1]
1132 else:
1133 mr = np.asarray(mrs, float).ravel()
1134 tot = np.ones_like(mr) if fl is None else fl.ravel()
1135 if tot.size != mr.size:
1136 raise ValueError(f"element_mass_flows ({tot.size}) and element_mixture_ratios ({mr.size}) differ in length")
1137 if not (np.all(np.isfinite(mr)) and np.all(mr > 0) and np.all(np.isfinite(tot)) and np.all(tot >= 0)):
1138 raise ValueError("element_mixture_ratios / element_mass_flows must be finite and positive")
1139 return list(zip(tot.tolist(), mr.tolist()))
1140
1141
1142def _cstar_source(Pc: float, cea_cache: Any, assumptions: List[Dict[str, Any]]) -> Optional[CstarOfMR]:
1143 if cea_cache is None:
1144 return None
1145 cfg = getattr(cea_cache, "config", None)
1146 wide = None
1147 if cfg is not None and getattr(cfg, "ox_name", None) and getattr(cfg, "fuel_name", None):
1148 wide = get_cstar_wide_table(cfg.ox_name, cfg.fuel_name)
1149 if wide is None:
1150 _record(assumptions, "combustion.cstar_wide_table", None, "",
1151 "no wide CEA c* table for this propellant pair (none committed, rocketcea "
1152 "unavailable): c* beyond the design cache's O/F range by the dilution law")
1153 return CstarOfMR(Pc, wide=wide, cea_cache=cea_cache)
1154
1155
1157 Lstar: float,
1158 Pc: float,
1159 Tc: float,
1160 cstar_ideal: float,
1161 gamma: float,
1162 R: float,
1163 MR: float,
1164 config: CombustionEfficiencyConfig,
1165 Ac: float,
1166 At: float,
1167 m_dot_total: float,
1168 u_fuel: Optional[float] = None,
1169 u_lox: Optional[float] = None,
1170 spray_diagnostics: Optional[Dict] = None,
1171 fuel_props: Optional[Dict] = None,
1172 ox_props: Optional[Dict] = None,
1173 debug: bool = False,
1174 cea_cache: Any = None,
1175 cstar_fn: Optional[CstarOfMR] = None,
1176) -> Dict[str, Any]:
1177 """eta_vap * eta_mix. Heat loss is applied by combustion_eff.eta_cstar.
1178
1179 eta_mix is the stream-tube c* integral (``stream_tube_mixing_efficiency``) of an O/F
1180 distribution built from Rupe's mixing factor E_m: E_m at the element's M from
1181 ``rupe_Em_at_M`` (impinging doublets) or ``rupe_Em_opt`` itself (other elements, recorded),
1182 and element-to-element striation from the injector's ``element_mixture_ratios`` /
1183 ``element_mass_flows`` when it publishes them (uniform elements otherwise). c*(O/F) comes from
1184 ``cstar_fn``, else the wide CEA c* table / ``cea_cache`` (``CstarOfMR``).
1185
1186 Returns eta_total, eta_vaporization, eta_mixing, the inputs that set them and the assumptions
1187 made for missing inputs.
1188 """
1189 if u_fuel is None or u_lox is None:
1190 raise ValueError("u_fuel and u_lox (injection velocities, diagnostics 'u_F'/'u_O') are required.")
1191 assumptions: List[Dict[str, Any]] = []
1192 state = compute_combustion_state(Pc, Tc, R, Ac, At, Lstar, m_dot_total)
1193 if cstar_fn is None:
1194 cstar_fn = _cstar_source(Pc, cea_cache, assumptions)
1195 if cstar_fn is None:
1196 raise ValueError("The mixing and vaporization c* ratios need c*(O/F): pass cea_cache (the "
1197 "design's CEACache) or cstar_fn.")
1198 blowing = str(getattr(config, "droplet_blowing_model", "abramzon_sirignano"))
1199
1200 vap_diag: Dict[str, Any] = {}
1201 if config.model == "constant":
1202 eta_vap = 1.0 - config.C
1203 elif config.model == "linear":
1204 eta_vap = float(np.clip(1.0 - config.C * (1.0 - Lstar / 1.0), 0.0, 1.0))
1205 else:
1206 if spray_diagnostics is None:
1207 raise ValueError("spray_diagnostics (D32_O/D32_F, u_O/u_F) are required for the vaporization model.")
1208 eta_vap, vap_diag = calculate_vaporization_efficiency(
1209 Pc=Pc, Tc=Tc, gamma=gamma, R=R, MR=MR, m_dot_total=m_dot_total, Ac=Ac, At=At,
1210 Lstar=Lstar, spray_diagnostics=spray_diagnostics, fuel_props=fuel_props,
1211 ox_props=ox_props, u_fuel=float(u_fuel), u_lox=float(u_lox),
1212 rr_q=float(config.spray_size_spread_q), assumptions=assumptions,
1213 blowing=blowing, cstar_fn=cstar_fn,
1214 )
1215
1216 # --- mixing: Rupe E_m -> O/F distribution -> stream-tube c* integral
1217 Em_opt = float(config.rupe_Em_opt)
1218 M_opt = float(config.rupe_M_opt)
1219 curv = float(config.rupe_Em_curvature)
1220 dist = str(config.mixing_distribution)
1221 diag = spray_diagnostics or {}
1222 rupe_M = rupe_M_from_diagnostics(spray_diagnostics)
1223 if rupe_M is not None:
1224 Em = rupe_Em_at_M(rupe_M, Em_opt, M_opt, curv)
1225 N_R = rupe_M / (1.0 + rupe_M)
1226 mixing_basis = "Rupe E_m at the element's M (JPL TR 32-1546 Fig. 1)"
1227 if not (RUPE_FIG1_N_RANGE[0] <= N_R <= RUPE_FIG1_N_RANGE[1]):
1228 _record(assumptions, "combustion.mixing.rupe_N_R_extrapolated", float(N_R), "",
1229 f"N_R = M/(1+M) outside Rupe's data {RUPE_FIG1_N_RANGE}: E_m extrapolated")
1230 else:
1231 Em = Em_opt
1232 mixing_basis = "rupe_Em_opt (no Rupe M: not an impinging doublet)"
1233 _record(assumptions, "combustion.mixing.Em_non_impinging", Em_opt, "",
1234 "no mixing correlation for this element type: E_m taken as rupe_Em_opt")
1235
1236 split = _element_split(diag, MR)
1237 if split is None:
1238 elements = [(1.0, float(MR), Em)]
1239 striation = "uniform elements (no element_mixture_ratios from the injector)"
1240 else:
1241 # An element's M moves with its own split: same orifices, v ~ mdot, so M_j = M (MR_j/MR)^2.
1242 elements = []
1243 for w_j, mr_j in split:
1244 Em_j = Em if rupe_M is None else rupe_Em_at_M(rupe_M * (mr_j / MR) ** 2, Em_opt, M_opt, curv)
1245 elements.append((w_j, mr_j, Em_j))
1246 striation = f"{len(split)} elements from the injector's manifold model"
1247 mix = stream_tube_mixing_efficiency(MR, cstar_fn, elements, dist)
1248 alt_name = "two_tube" if dist == "gaussian" else "gaussian"
1249 mix_alt = stream_tube_mixing_efficiency(MR, cstar_fn, elements, alt_name)
1250 if mix["mass_outside_cstar_table"] > 0.0:
1251 _record(assumptions, "combustion.mixing.cstar_outside_table", mix["mass_outside_cstar_table"],
1252 "mass fraction", f"stream tubes beyond {cstar_fn.source}: c* by the dilution law")
1253 if mix["mass_clipped_to_pure_propellant"] > 1e-3:
1254 _record(assumptions, "combustion.mixing.clipped_to_pure_propellant",
1255 mix["mass_clipped_to_pure_propellant"], "mass fraction",
1256 f"the {dist} O/F spread reaches past pure propellant; those tubes are taken as pure")
1257 if split is not None and abs(mix["element_centre_shift"]) > 1e-3:
1258 _record(assumptions, "combustion.mixing.element_centre_shift", mix["element_centre_shift"], "",
1259 "the injector's element O/F split does not average to the chamber O/F; element "
1260 "oxidizer fractions shifted together to the bulk")
1261 eta_mixing = float(mix["eta_mix"])
1262
1263 eta_total = eta_vap * eta_mixing
1264 return {
1265 "eta_total": float(eta_total),
1266 "eta_vaporization": float(eta_vap),
1267 "eta_mixing": eta_mixing,
1268 "rupe_M": float(rupe_M) if rupe_M is not None else None,
1269 "rupe_M_opt": M_opt,
1270 "rupe_Em_opt": Em_opt,
1271 "rupe_Em": float(Em),
1272 "rupe_Em_total": float(mix["Em_total"]),
1273 "mixing_distribution": dist,
1274 f"eta_mixing_{alt_name}": float(mix_alt["eta_mix"]),
1275 "mixing_OF_range_99pct": mix["OF_range_99pct"],
1276 "mixing_mass_outside_cstar_table": mix["mass_outside_cstar_table"],
1277 "mixing_striation": striation,
1278 "mixing_basis": mixing_basis,
1279 "cstar_source": cstar_fn.source,
1280 "vaporization_model": str(config.model),
1281 "tau_res": state["tau_res"],
1282 **vap_diag,
1283 "assumptions": assumptions,
1284 }
1285
1286
1287def vaporization_profile(ce: Dict[str, Any], points: int = 40) -> Optional[Dict[str, Any]]:
1288 """The droplet march that set eta_vap, run again with its profile, in chamber coordinates
1289 (x from the injector face). ``ce`` is the c* model's diagnostics
1290 (``diagnostics["cstar_efficiency"]``). Per stream: the fraction vaporized along the chamber,
1291 where it reaches 95 % (None if it does not), and the fraction at the chamber end. None when
1292 the solve carried no march (the sheet reached the throat, or an older result)."""
1293 rp = (ce or {}).get("march_replay")
1294 if not rp:
1295 return None
1296 x0, L = float(rp["x0"]), float(rp["L_chamber"])
1297 m = spray_vaporization_march(rp["streams"], **rp["kw"], profile_points=points)
1298 out: Dict[str, Any] = {"L_chamber": L, "x_drop_formation": x0, "streams": {}}
1299 for st in rp["streams"]:
1300 n = str(st["name"])
1301 if st.get("instant"):
1302 out["streams"][n] = {"instant": True, "x95": 0.0, "frac_end": 1.0, "profile": [[0.0, 1.0], [L, 1.0]]}
1303 continue
1304 x95 = m["x_vap95"].get(n)
1305 out["streams"][n] = {
1306 "instant": False,
1307 "x95": None if x95 is None else x0 + float(x95),
1308 "frac_end": float(m["frac_vaporized"][n]),
1309 "profile": [[0.0, 0.0]] + [[x0 + x, f] for x, f in m.get("profile", {}).get(n, [])],
1310 }
1311 return out
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
Tuple[np.ndarray, np.ndarray] of_fraction(self, np.ndarray r)
__init__(self, float Pc, Optional[CstarWideTable] wide=None, Any cea_cache=None)
Optional["CstarWideTable"] load(cls, str ox_name, str fuel_name, Optional[str] path=None)
"CstarWideTable" build(cls, str ox_name, str fuel_name, Optional[str] path=None)
__init__(self, str ox_name, str fuel_name, np.ndarray MR, np.ndarray Pc, np.ndarray cstar, Optional[str] path=None)
Dict[str, Any] stream_tube_mixing_efficiency(float MR, CstarOfMR cstar_fn, List[Tuple[float, float, float]] elements, str distribution="gaussian")
float rupe_mixing_parameter(float rho_O, float v_O, float d_O, float rho_F, float v_F, float d_F)
float rupe_Em_of_distribution(np.ndarray r, np.ndarray w, float R)
Dict[str, Any] spray_vaporization_march(List[Dict[str, float]] streams, *, float Pc, float Tc, float gamma, float R, float m_dot_total, float Ac, float L_chamber, float u_drop0, float rr_q, str blowing="abramzon_sirignano", int profile_points=0)
Tuple[float, Dict[str, float]] calculate_gasification_efficiency(float Tc, float Pc, float tau_res, float SMD, float rho_l, float cp_l, float L_eff, float T_inj, float cp_g, float rho_g, float mu_g, float U_slip, Optional[float] D_m=None, float Pr=0.8, Optional[dict] fuel_props=None, bool debug=False)
Any _record(List[Dict[str, Any]] assumptions, str name, Any value, str unit, str reason)
Optional[Dict[str, Any]] vaporization_profile(Dict[str, Any] ce, int points=40)
_march_core_py(D2_0, w_cls, rho_l, mu, rho_f, k_f, lnB, t_h, inv_FB, cd_blow, U_c, rho_c, pr3, cp_g, dx, n_steps, u_drop0, F0, v_floor, rr_w, starts, nq)
Optional[CstarWideTable] get_cstar_wide_table(str ox_name, str fuel_name)
Tuple[float, Dict[str, Any]] calculate_vaporization_efficiency(*, float Pc, float Tc, float gamma, float R, float MR, float m_dot_total, float Ac, float At, float Lstar, Dict[str, Any] spray_diagnostics, Optional[Dict[str, Any]] fuel_props, Optional[Dict[str, Any]] ox_props, float u_fuel, float u_lox, float rr_q, List[Dict[str, Any]] assumptions, str blowing="abramzon_sirignano", Optional[CstarOfMR] cstar_fn=None)
Optional[float] rupe_M_from_diagnostics(Optional[Dict[str, Any]] diag)
float rupe_Em_at_M(float M, float Em_opt, float M_opt=1.0, float curvature=6.07)
float rupe_R_opt_from_angles(Optional[float] theta_O_deg, Optional[float] theta_F_deg, Optional[float] d_O=None, Optional[float] d_F=None)
Dict[str, float] compute_combustion_state(float Pc, float Tc, float R, float Ac, float At, float Lstar, float m_dot_total)
Optional[List[Tuple[float, float]]] _element_split(Dict[str, Any] diag, float MR)
Tuple[float, float] saturation_state(float P, float T_boil, float L_boil, float molecular_weight, Optional[float] T_crit=None)
Optional[CstarOfMR] _cstar_source(float Pc, Any cea_cache, List[Dict[str, Any]] assumptions)
float _gas_viscosity(float T, float M_gas)
Dict[str, Any] calculate_combustion_efficiency_advanced(float Lstar, float Pc, float Tc, float cstar_ideal, float gamma, float R, float MR, CombustionEfficiencyConfig config, float Ac, float At, float m_dot_total, Optional[float] u_fuel=None, Optional[float] u_lox=None, Optional[Dict] spray_diagnostics=None, Optional[Dict] fuel_props=None, Optional[Dict] ox_props=None, bool debug=False, Any cea_cache=None, Optional[CstarOfMR] cstar_fn=None)
Tuple[float, float] _mass_quantiles_OF(np.ndarray r, np.ndarray w, Tuple[float, float] qs)
Optional[Dict[str, float]] liquid_heatup_properties(Optional[str] name, float P, float T0, float T_s, float Tc)