1"""c* efficiency physics: vaporization and mixing.
3 eta_c* = eta_vap * eta_mix * eta_HL
5eta_HL, the heat lost to the wall upstream of the throat, is applied in combustion_eff.eta_cstar.
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).
19from __future__
import annotations
24from typing
import Any, Dict, List, Optional, Tuple
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
46 from numba
import njit
as _njit
50_USE_COMPILED_MARCH = _njit
is not None
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.
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.
65 v = np.full(n, u_drop0)
75 x95 = np.full(ns, np.nan)
76 for i
in range(n_steps):
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
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
98 Fp += w_cls[j] * (1.0 - (D2p[j] / D2_0[j]) ** 1.5)
100 Ug = 0.5 * (Ug1 + Ug2)
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]:
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
124 rem[j] = (D2[j] / D2_0[j]) ** 1.5
125 acc += w_cls[j] * (1.0 - rem[j])
131 f += rr_w[k] * rem[starts[s] + k]
133 if math.isnan(x95[s])
and f >= 0.95:
135 x95[s] = x_i - dx * (f - 0.95) / (f - p)
if f > p
else x_i
140_march_core =
_njit(cache=
True, error_model=
"numpy")(_march_core_py)
if _njit
is not None else None
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."""
146 assumptions.append({
"name": name,
"value": value,
"unit": unit,
"reason": reason})
147 return assume(name, value, unit=unit, reason=reason)
158) -> Dict[str, float]:
159 """Chamber gas state and stay time.
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.
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
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),
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.
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.
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:
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
206 return float(T_sat), float(L_boil * ((T_crit - T_sat) / (T_crit - T_boil)) ** WATSON_EXPONENT)
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)
215 return float(calculate_gas_viscosity_huzel(T, M_gas))
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)
227_PSI_PA = 6894.757293168361
229_CSTAR_WIDE_MEMO: Dict[Tuple[str, str], Optional[
"CstarWideTable"]] = {}
233 """c* [m/s] on (O/F, Pc) from CEA, interpolated linearly in (ln O/F, ln Pc)."""
235 def __init__(self, ox_name: str, fuel_name: str, MR: np.ndarray, Pc: np.ndarray,
236 cstar: np.ndarray, path: Optional[str] =
None):
238 self.
MR, self.
Pc, self.
cstar = np.asarray(MR, float), np.asarray(Pc, float), np.asarray(cstar, float)
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")
248 def load(cls, ox_name: str, fuel_name: str, path: Optional[str] =
None) -> Optional[
"CstarWideTable"]:
250 if not os.path.exists(path):
252 with np.load(path, allow_pickle=
False)
as d:
254 meta = json.loads(str(d[
"meta"]))
257 if (meta.get(
"schema") != CSTAR_WIDE_SCHEMA_VERSION
or meta.get(
"ox_name") != ox_name
258 or meta.get(
"fuel_name") != fuel_name):
260 return cls(ox_name, fuel_name, d[
"MR"], d[
"Pc"], d[
"cstar"], path)
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):
270 v = float(C.get_Cstar(float(pc) / _PSI_PA, float(mr))) * _FT_M
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")
278 meta = {
"schema": CSTAR_WIDE_SCHEMA_VERSION,
"ox_name": ox_name,
"fuel_name": fuel_name,
279 "source":
"rocketcea CEA_Obj.get_Cstar (equilibrium chamber)"}
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))
285 return cls(ox_name, fuel_name, _CSTAR_WIDE_MR, _CSTAR_WIDE_PC, tab, path)
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))
292 return (1.0 - f) * self.
cstar[:, j] + f * self.
cstar[:, j + 1]
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)
304 if _cea_build_allowed():
306 tab = CstarWideTable.build(*key)
309 _CSTAR_WIDE_MEMO[key] = tab
314 """c*(O/F) at one chamber pressure, for ratios of c* between stream tubes.
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.
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")
331 self.
source = f
"CEA c* table O/F {wide.MR_min:g}-{wide.MR_max:g} ({os.path.basename(wide.path or '')})"
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}"
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])
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
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)
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])
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)
382 cp_name = _COOLPROP_ALIASES.get(str(name), str(name))
385 if cp_name
in _AS_MEMO:
386 return _AS_MEMO[cp_name]
388 import CoolProp.CoolProp
as CP
389 st = (CP, CP.AbstractState(
"HEOS", cp_name))
392 _AS_MEMO[cp_name] = st
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
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.
408 if st
is None or not (T_s > T0):
413 AS.update(CP.PQ_INPUTS, float(P), 0.0)
414 T_lim = float(AS.T()) - 0.05
417 T_hi =
min(float(T_s), T_lim)
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))
425 I = 0.5 * (T_hi - T0) * float(np.sum(_HEATUP_GL_W * cp / (Tc - Tn)))
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))
435 if not all(np.isfinite(v)
and v > 0
for v
in (I, rho, cp_mean)):
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)}
445BLOWING_MODELS = (
"abramzon_sirignano",
"none")
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")
456 return float((1.0 + B) ** 0.7 * math.log1p(B) / B)
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)
468 streams: List[Dict[str, float]],
479 blowing: str =
"abramzon_sirignano",
480 profile_points: int = 0,
482 """Fraction of each stream vaporized along the chamber (Priem & Heidmann, NASA TR R-67).
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.
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:
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).
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.
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).
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.
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}")
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
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)
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]] = {}
539 w = float(s[
"mass_fraction"])
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
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")
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
557 inv_FB, cd_blow = 1.0, 1.0
558 if blowing ==
"abramzon_sirignano" and np.isfinite(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())
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}
574 out[
"frac_vaporized"][str(s[
"name"])] = 1.0
575 out[
"x_vap95"][str(s[
"name"])] = 0.0
577 out[
"F_throat"] = float(F0)
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)
582 dx = L_chamber / _MARCH_STEPS
584 def _rates(D2: np.ndarray, v: np.ndarray, F: float, t_drag: np.ndarray):
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)
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
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)))
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)
618 v = np.full(D0.size, float(u_drop0))
619 t = np.zeros(D0.size)
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):
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)
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:
638 x95[name] = x_i - dx * (f - 0.95) / (f - p)
if f > p
else x_i
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)
647 out[
"profile"] = prof
662 spray_diagnostics: Dict[str, Any],
663 fuel_props: Optional[Dict[str, Any]],
664 ox_props: Optional[Dict[str, Any]],
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).
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):
677 eta_vap = F c*(O/F_vap)/c*(O/F), F = fraction vaporized, O/F_vap = O/F f_O/f_F,
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.
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.
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 {}
697 def _d32(key: str) -> Optional[float]:
699 v = float(diag.get(key))
700 except (TypeError, ValueError):
702 return v
if (np.isfinite(v)
and v > 0)
else None
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}
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}")
713 return _instant(tag, w,
"no D32 from the injector")
715 return _instant(tag, w, f
"fluids.{label} properties not passed to the c* model")
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)]
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
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}
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"])
739 if p.get(
"specific_heat")
is None:
740 p[
"specific_heat"] =
_record(assumptions, f
"combustion.vaporization.{label}.specific_heat",
742 f
"fluids.{label}.specific_heat not passed to the c* model")
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)")
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)]
761 u_ax = diag.get(
"u_axial_spray")
764 except (TypeError, ValueError):
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")
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")
781 def _len(key: str) -> Optional[float]:
783 v = float(diag.get(key))
784 except (TypeError, ValueError):
786 return v
if (np.isfinite(v)
and v >= 0)
else None
788 L_imp, L_b = _len(
"L_imp"), _len(
"L_sheet_breakup")
789 if L_imp
is None and L_b
is None:
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")
794 x0 = (L_imp
or 0.0) + (L_b
or 0.0)
796 L_ch = Lstar * At / Ac
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:
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)
816 MR_vap = MR * fO / fF
if fF > 0.0
else math.inf
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
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),
834 "blowing_model": blowing,
837 "march_replay": {
"streams": [{k: v
for k, v
in st.items()
if isinstance(v, (int, float, str, bool))}
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)},
843 if not s.get(
"instant"):
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)
852 diag_out[f
"B_T_{n}"] = b[
"B_T"]
853 return eta_vap, diag_out
869 D_m: Optional[float] =
None,
871 fuel_props: Optional[dict] =
None,
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``.
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.
882 D_M_REF, D_M_T_REF, D_M_P_REF, U_SLIP_CAP, D_MIN_GASIFICATION
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}")
887 raise ValueError(f
"Invalid SMD: {SMD}")
889 D =
max(SMD, D_MIN_GASIFICATION)
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
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:
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
914 "tau_heat": float(tau_heat),
915 "tau_gasify": float(tau_gasify),
916 "tau_vap": float(tau_vap),
917 "T_star": float(T_star),
923 "U_slip_capped": float(U_slip_capped),
928 return float(eta_vap), diagnostics
932 rho_O: float, v_O: float, d_O: float,
933 rho_F: float, v_F: float, d_F: float,
935 """Elverum & Morey mixing parameter M = rho_O v_O^2 d_O / (rho_F v_F^2 d_F).
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.
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)
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."""
954 def _f(key: str) -> float:
956 return float(diag.get(key))
957 except (TypeError, ValueError):
961 if np.isfinite(M)
and M > 0:
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)):
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)
974 theta_O_deg: Optional[float],
975 theta_F_deg: Optional[float],
976 d_O: Optional[float] =
None,
977 d_F: Optional[float] =
None,
979 """Momentum ratio R = sqrt(rho_O u_O^2 / rho_F u_F^2) at which a doublet's resultant is axial.
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.
987 if theta_O_deg
is None or theta_F_deg
is None:
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:
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))
1007RUPE_FIG1_N_RANGE = (0.26, 0.78)
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()
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])),
1023MIXING_DISTRIBUTIONS = tuple(_UNIT_MAD)
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):
1030 N_R = M/(1 + M), E_m = Em_opt (1 - curvature (N_R - N_opt)^2), floored at 0.
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")
1040 N0 = M_opt / (1.0 + M_opt)
1041 return float(
max(Em_opt * (1.0 - curvature * (N - N0) ** 2), 0.0))
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))
1050 return float(1.0 - np.sum(w[lo] * (R - r[lo])) / R - np.sum(w[~lo] * (r[~lo] - R)) / (1.0 - R))
1055 cstar_fn: CstarOfMR,
1056 elements: List[Tuple[float, float, float]],
1057 distribution: str =
"gaussian",
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
1063 eta_mix = sum_i w_i c*(O/F_i) / c*(O/F_bulk).
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.
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}")
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")
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))
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)
1098 "distribution": distribution,
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])),
1108 """O/F at the given cumulative mass fractions of the stream tubes."""
1110 c = np.cumsum(w[o]) / float(np.sum(w))
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)
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.
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).
1124 mrs = diag.get(
"element_mixture_ratios")
1125 flows = diag.get(
"element_mass_flows")
1126 if mrs
is None and flows
is 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]
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()))
1142def _cstar_source(Pc: float, cea_cache: Any, assumptions: List[Dict[str, Any]]) -> Optional[CstarOfMR]:
1143 if cea_cache
is None:
1145 cfg = getattr(cea_cache,
"config",
None)
1147 if cfg
is not None and getattr(cfg,
"ox_name",
None)
and getattr(cfg,
"fuel_name",
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)
1164 config: CombustionEfficiencyConfig,
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,
1177 """eta_vap * eta_mix. Heat loss is applied by combustion_eff.eta_cstar.
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``).
1186 Returns eta_total, eta_vaporization, eta_mixing, the inputs that set them and the assumptions
1187 made for missing inputs.
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]] = []
1193 if cstar_fn
is None:
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"))
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))
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.")
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,
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 {}
1223 if rupe_M
is not None:
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")
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")
1238 elements = [(1.0, float(MR), Em)]
1239 striation =
"uniform elements (no element_mixture_ratios from the injector)"
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"
1248 alt_name =
"two_tube" if dist ==
"gaussian" else "gaussian"
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"])
1263 eta_total = eta_vap * eta_mixing
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"],
1283 "assumptions": assumptions,
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")
1296 x0, L = float(rp[
"x0"]), float(rp[
"L_chamber"])
1298 out: Dict[str, Any] = {
"L_chamber": L,
"x_drop_formation": x0,
"streams": {}}
1299 for st
in rp[
"streams"]:
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]]}
1304 x95 = m[
"x_vap95"].get(n)
1305 out[
"streams"][n] = {
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, [])],
Tuple[np.ndarray, np.ndarray] of_fraction(self, np.ndarray r)
np.ndarray _in_range(self, np.ndarray mr)
__init__(self, float Pc, Optional[CstarWideTable] wide=None, Any cea_cache=None)
float __call__(self, float MR)
Optional["CstarWideTable"] load(cls, str ox_name, str fuel_name, Optional[str] path=None)
str default_path(str ox_name, str fuel_name)
"CstarWideTable" build(cls, str ox_name, str fuel_name, Optional[str] path=None)
np.ndarray row(self, float Pc)
__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)
float yuen_chen_drag_factor(float B)
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)
_coolprop_state(Optional[str] name)
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)
float abramzon_sirignano_F(float B)
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)