1"""Conversion time lags for the chug loop — a registry of NAMED models.
3The chug characteristic equation (``chug.py``) needs one number per propellant stream: the lag
4between an injection-rate perturbation and the heat release it produces. That number is where the
5propellant and the injector enter the stability problem, so it is the one place that must not
8Two models, both selectable through ``StabilityConfig.time_lag_model``:
11 The historical STAR model: ``tau = D32**2 / K_v`` with the Godsave/Spalding evaporation
12 constant. This is the **quiescent** droplet lifetime — no convection, no atomization, no
13 mixing — and it is reproduced here bit-for-bit so old results stay reproducible.
16 The double-time-lag decomposition of Leonardi, Nasuti, Di Matteo & Steelant, *"A methodology
17 to study the possible occurrence of chugging in liquid rocket engines during transient
18 start-up"*, Acta Astronautica 139 (2017) 344-356 [hereafter **L17**]:
20 tau_tot = tau_atom + tau_vap + tau_mix (L17 eq. 5)
22 with a convection-corrected vaporization lag (eq. 8-9) and an atomization lag (eq. 6-7). The
23 decisive structural point is **phase**: L17 §3.2 gives the gaseous propellant *only* the mixing
24 lag, because a gas neither atomizes nor vaporizes. STAR's previous code ran the d^2-law on both
25 streams unconditionally, which silently invents a droplet lifetime for a gas.
27What is deliberately NOT implemented
28------------------------------------
29L17 eq. 10 (the initial droplet diameter ``D0``) is a correlation "specifically developed for
30coaxial injectors and liquid oxygen". EngineDesign already solves a Sauter mean diameter with the
31injector-appropriate model — Ingebo for impinging, Lefebvre for coaxial, the pintle sheet model for
32pintle — so ``D0`` is taken from that solve. Adopting eq. 10 would hardcode *one* injector and *one*
33oxidizer into a multi-injector, multi-propellant tool, which is precisely the coupling this module
34exists to remove. (It is also not dimensionally closed as printed: the exponents 2.25 and -2.65 do
35not cancel, so its units depend on ref. [24] and cannot be reconstructed from L17 alone.)
37The convection correction (L17 eq. 8) is OFF by default — and why
38-----------------------------------------------------------------
39L17 eq. 8 divides the quiescent droplet lifetime by ``1 + 1.5*alpha``, ``alpha = 1 - 3e-3*pc[bar]``.
40Applying it makes the model *worse* against the paper's own experiment, so it ships behind a named
41switch set to ``"none"``. The evidence (``scripts/chug_timelag_benchmark.py``, benchmarks B and E,
42on the GH2/LOX rig of L17 ref. [25]):
44 * Eq. 9's evaporation constant in the QUIESCENT form, ``tau_vap = D0**2 / k``, gives 4.98 ms at
45 L17's own reference point (D0 = 83 um, MR 5, 44.8 bar, 2038 K) against the experiment-derived
46 4.4 ms — **+13 %**. Apply eq. 8 and it becomes 2.17 ms, **-51 %**.
47 * End-to-end through ``chug.py``, quiescent eq. 9 predicts 53 Hz and a stability boundary at
48 Delta_p_ox/pc = 0.30 against a measured 66 Hz and 0.35. With eq. 8 it is 104 Hz and 0.20.
49 * Eq. 8 also disagrees with Ranz-Marshall in *trend*, not just magnitude: it depends on chamber
50 pressure alone — no slip velocity, no drop size — and it FALLS as Pc rises, while the physical
53The coherent reading is that eq. 9's ``k`` was already calibrated against real chamber data (L17
54routes its 4.4 ms through Priem-Heidmann's L50/v_inj), so it *contains* the convective enhancement
55and eq. 8 double-counts it. That also reconciles L17's two otherwise-inconsistent statements about
56the same working point. Both corrections remain available through ``CONVECTION_MODELS`` so the run
57report can say which one produced the answer.
60from __future__
import annotations
62from dataclasses
import dataclass, field
63from typing
import Callable, Dict, Optional, Tuple
74 "vaporization_lag_leonardi",
75 "evaporation_constant_leonardi",
76 "convection_correction_leonardi",
77 "ranz_marshall_correction",
79 "DEFAULT_CONVECTION_MODEL",
83 "MASS_HALF_LIFE_FRACTION",
92MASS_HALF_LIFE_FRACTION: float = float(1.0 - 0.5 ** (2.0 / 3.0))
99@dataclass(frozen=True)
101 """One propellant stream at the injector face. All SI.
103 ``phase`` is ``"liquid"`` or ``"gas"`` **at injection conditions** and decides which lags apply
104 at all — it is not a cosmetic label. ``D0`` is the initial drop size from the injector's own
105 spray model (SMD), ``d_orifice`` is L17's liquid post inner diameter ``D_l``, and ``u_inj`` is
106 its injection velocity ``u_l``.
110 rho_l: float = float(
"nan")
111 mu_l: float = float(
"nan")
112 sigma_l: float = float(
"nan")
113 T_boil: float = float(
"nan")
114 T_crit: float = float(
"nan")
115 h_fg: float = float(
"nan")
116 D0: float = float(
"nan")
117 u_inj: float = float(
"nan")
118 d_orifice: float = float(
"nan")
122 return str(self.
phase).lower().startswith(
"g")
125@dataclass(frozen=True)
127 """Chamber gas state the lags are evaluated against. All SI except where noted."""
138 return float(self.Pc / 1.0e5)
141@dataclass(frozen=True)
143 """Per-stream lag decomposition. ``tau_total`` is what the chug loop consumes as ``tau_conv``."""
152 K_v: float = float(
"nan")
153 notes: Tuple[str, ...] = field(default_factory=tuple)
161 "K_v": float(self.
K_v),
"notes": list(self.
notes),
170 """Atomization lag [s]. **L17 eq. 6-7** (after Boronine, Vollmer & Frey, ref. [23]):
172 tau_atom = 6e-4 * (rho_g/rho_l)^-0.32 * We_g^0.03 * Re_l^0.55 * D_l/u_l
173 Re_l = u_l D_l rho_l / mu_l
174 We_g = 2 rho_g (u_g - u_l)^2 D_l / sigma_l
176 Returns 0.0 for a gas stream (nothing to atomize) and NaN when an input is missing, so a caller
177 that cannot supply injector geometry gets a recorded fallback rather than a fabricated lag.
181 D_l, u_l = float(st.d_orifice), float(st.u_inj)
182 if not (np.isfinite(D_l)
and D_l > 0
and np.isfinite(u_l)
and u_l > 0):
184 if not (np.isfinite(st.rho_l)
and st.rho_l > 0
and np.isfinite(ch.rho_g)
and ch.rho_g > 0):
186 if not (np.isfinite(st.mu_l)
and st.mu_l > 0
and np.isfinite(st.sigma_l)
and st.sigma_l > 0):
188 Re_l = u_l * D_l * st.rho_l / st.mu_l
189 We_g = 2.0 * ch.rho_g * (float(ch.u_g) - u_l) ** 2 * D_l / st.sigma_l
192 we_term = float(We_g ** 0.03)
if We_g > 0
else 1.0
193 return float(6.0e-4 * (ch.rho_g / st.rho_l) ** (-0.32) * we_term * Re_l ** 0.55 * (D_l / u_l))
197 """L17 eq. 9 evaporation constant ``k`` [m^2/s]:
199 k = 1e-6 * [ 1.01/(1 + MR) + 1.16e-3 (T_inf - T_cr)^0.93 ]^0.86
201 ``T_inf`` is the combustion-gas temperature [K] and ``T_cr`` the LIQUID critical temperature
202 [K] — a propellant property, which is why it is a per-stream input here and not a constant.
204 if not (np.isfinite(MR)
and MR > -1.0):
206 if not (np.isfinite(T_inf)
and np.isfinite(T_crit)):
208 dT = float(T_inf) - float(T_crit)
213 inner = 1.01 / (1.0 + float(MR)) + 1.16e-3 * dT ** 0.93
216 return float(1.0e-6 * inner ** 0.86)
220 """L17 eq. 8 convective speed-up factor ``1 + 1.5*alpha`` with ``alpha = 1 - 3e-3*pc[bar]``.
222 ``tau_vap = tau_vap(Re=0) / (1 + 1.5*alpha)`` — a droplet in a crossflow lives shorter than a
223 quiescent one. Note the factor FALLS with chamber pressure (alpha -> 0 near 333 bar), i.e. the
224 correlation says the convective enhancement washes out at high Pc. Floored at 1.0 so the
225 correction can never *lengthen* the lag past the quiescent value.
227 if not np.isfinite(Pc_bar):
229 alpha = 1.0 - 3.0e-3 * float(Pc_bar)
230 return float(
max(1.0, 1.0 + 1.5 * alpha))
234 """Textbook convective correction ``Nu/2 = 1 + 0.3 Re_d^0.5 Pr^(1/3)`` (Ranz-Marshall).
236 Not used by any model — it is the independent yardstick the benchmark scores L17 eq. 8 against,
237 so a disagreement shows up as a number instead of an assumption. ``Re_d`` is the droplet
238 Reynolds number based on the slip velocity and the drop diameter.
240 if not (np.isfinite(Re_d)
and Re_d >= 0
and np.isfinite(Pr)
and Pr > 0):
242 return float(1.0 + 0.3 * np.sqrt(Re_d) * Pr ** (1.0 / 3.0))
245def _slip_reynolds(st: StreamThermo, ch: ChamberThermo, mu_g: float = 7.0e-5) -> float:
246 """Droplet Reynolds number on the gas-droplet slip velocity. Used only by ``ranz_marshall``."""
247 u_l = float(st.u_inj)
if np.isfinite(st.u_inj)
else 0.0
248 slip = abs(float(ch.u_g) - u_l)
249 if not (np.isfinite(slip)
and slip > 0
and np.isfinite(st.D0)
and st.D0 > 0):
251 return float(ch.rho_g * slip * st.D0 / mu_g)
256CONVECTION_MODELS: Dict[str, Callable[[StreamThermo, ChamberThermo], float]] = {
257 "none":
lambda st, ch: 1.0,
264DEFAULT_CONVECTION_MODEL =
"none"
268 *, convection: str = DEFAULT_CONVECTION_MODEL) -> float:
269 """Vaporization lag [s] from **L17 eq. 9**'s evaporation constant: ``tau = D0**2 / k / factor``.
271 ``convection`` names an entry of ``CONVECTION_MODELS``; the default ``"none"`` leaves the
272 quiescent form, which is the variant that matches the experiment (module docstring). 0.0 for a
278 if not (np.isfinite(D0)
and D0 > 0):
281 if not (np.isfinite(k)
and k > 0):
283 fn = CONVECTION_MODELS.get(str(convection))
286 f
"unknown convection model {convection!r}; known: {sorted(CONVECTION_MODELS)}"
289 if not (np.isfinite(corr)
and corr > 0):
293 return float((D0 * D0 / k) / corr)
297 """Quiescent d^2-law lag [s] and its evaporation constant K_v — STAR's historical model.
299 Exactly ``core.lags_from_smd(..., chi=1.0)``: same call, same arguments, same order, so the
300 ``d2_law`` branch is bit-for-bit what the code did before this module existed.
303 return 0.0, float(
"nan")
304 tau_vap, _, K_v = core.lags_from_smd(
305 st.D0, k_g=ch.k_g, rho_l=st.rho_l, cp_g=ch.cp_g, T_inf=ch.Tc,
306 T_boil=st.T_boil, h_fg=st.h_fg, chi=1.0,
308 return float(tau_vap), float(K_v)
316 """Mixing lag [s] shared by all streams, as a fraction of the rate-limiting vaporization lag.
318 **Why a fraction and not L17 fig. 1.** The paper reads tau_mix off Szuch's empirical curve of
319 mixing time versus L50 (the length to vaporize 50% of the liquid, NASA TN-D-7026). That curve is
320 a figure, not a table, and is not reproduced here; digitizing it by eye would be inventing a
321 correlation. What L17 *does* state numerically is its own calibration point — tau_vap = 4.4 ms
322 and tau_mix = 2.2 ms for the validation engine (L17 §3.1), i.e. a ratio of 0.5 — and L50 is
323 itself proportional to tau_vap at fixed droplet speed (``MASS_HALF_LIFE_FRACTION``), so a
324 constant of proportionality is the faithful reduction of the curve to one number.
326 The default 0.5 therefore carries the paper's provenance, and the caller records it through the
327 assumptions registry. Swap in a digitized curve by replacing this function, not by tuning 0.5.
329 if not (np.isfinite(tau_vap_liquid)
and tau_vap_liquid >= 0):
331 if not (np.isfinite(mix_fraction)
and mix_fraction >= 0):
333 return float(mix_fraction * tau_vap_liquid)
340_ModelResult = Tuple[float, float, float, float, Tuple[str, ...]]
343def _model_d2_law(st: StreamThermo, ch: ChamberThermo, convection: str) -> _ModelResult:
344 """-> (tau_atom, tau_vap, K_v, tau_mix_basis, notes). tau_mix is applied by ``compute_lags``.
346 ``convection`` is accepted and ignored: the d^2-law is the historical model and is reproduced
347 unchanged, corrections included (there were none).
350 notes: Tuple[str, ...] = ()
352 notes = (
"gas at injection: no atomization or vaporization lag",)
353 return 0.0, tau_vap, K_v, tau_vap, notes
356def _model_leonardi(st: StreamThermo, ch: ChamberThermo, convection: str) -> _ModelResult:
363 notes_l.append(
"gas at injection: no atomization or vaporization lag (L17 §3.2)")
364 return 0.0, 0.0, float(
"nan"), 0.0, tuple(notes_l)
366 if not np.isfinite(tau_atom):
367 notes_l.append(
"atomization lag unavailable (injector jet diameter/velocity missing)")
370 if not np.isfinite(tau_vap):
372 "L17 eq. 9 unavailable (needs the liquid critical temperature and Tc > T_crit); "
373 "fell back to the d^2-law for this stream"
376 elif convection !=
"none":
377 notes_l.append(f
"convective speed-up applied: {convection}")
378 return tau_atom, tau_vap, K_v, tau_vap, tuple(notes_l)
382TIME_LAG_MODELS: Dict[str, Callable[[StreamThermo, ChamberThermo, str], _ModelResult]] = {
383 "d2_law": _model_d2_law,
384 "leonardi_dtl": _model_leonardi,
389 streams: Dict[str, StreamThermo],
392 model: str =
"d2_law",
393 mix_fraction: float = 0.0,
394 convection: str = DEFAULT_CONVECTION_MODEL,
395 on_fallback: Optional[Callable[[str, float, str, str], float]] =
None,
396) -> Dict[str, LagBreakdown]:
397 """Lag breakdown for every stream under one named model.
399 ``mix_fraction`` scales the shared mixing lag off the SLOWEST liquid stream's vaporization lag
400 (the rate-limiting one — L17 assigns the same tau_mix to both propellants). Pass 0.0 to disable
401 it, which is what ``d2_law`` does by default so it reproduces the historical numbers exactly.
403 ``on_fallback(name, value, unit, reason) -> value`` is the assumptions hook; the integration
404 layer passes ``assumptions.assume`` so nothing is substituted silently. Defaults to identity for
405 pure-numeric use (tests, the benchmark script).
407 fb = on_fallback
if on_fallback
is not None else (
lambda name, value, unit, reason: value)
408 fn = TIME_LAG_MODELS.get(str(model))
411 f
"unknown time_lag_model {model!r}; known models: {sorted(TIME_LAG_MODELS)}"
414 if str(convection)
not in CONVECTION_MODELS:
416 f
"unknown convection model {convection!r}; known: {sorted(CONVECTION_MODELS)}"
418 raw = {key: fn(st, ch, str(convection))
for key, st
in streams.items()}
422 r[3]
for key, r
in raw.items()
423 if not streams[key].is_gas
and np.isfinite(r[3])
and r[3] > 0
425 if mix_fraction > 0.0
and liquid_taus:
430 out: Dict[str, LagBreakdown] = {}
431 for key, st
in streams.items():
432 tau_atom, tau_vap, K_v, _, notes = raw[key]
433 ta = tau_atom
if np.isfinite(tau_atom)
else fb(
434 f
"stability.tau_atom_{key}", 0.0,
"s",
435 f
"{model}: atomization lag for the {key} stream needs the injector jet diameter and "
436 f
"injection velocity; neither reached the stability layer",
439 if not np.isfinite(tv):
441 f
"stability.tau_vap_{key}", 2.0e-3,
"s",
442 f
"{model}: vaporization lag non-finite for {st.name or key} "
443 f
"(check T_boil < Tc, h_fg > 0, and a positive SMD)",
445 tm = tau_mix
if np.isfinite(tau_mix)
else 0.0
447 stream=key, model=str(model), phase=str(st.phase), convection=str(convection),
448 tau_atom=float(ta), tau_vap=float(tv), tau_mix=float(tm),
449 tau_total=float(ta + tv + tm), K_v=float(K_v), notes=notes,
Dict[str, object] as_dict(self)
float _slip_reynolds(StreamThermo st, ChamberThermo ch, float mu_g=7.0e-5)
float convection_correction_leonardi(float Pc_bar)
Tuple[float, float] vaporization_lag_d2(StreamThermo st, ChamberThermo ch)
_ModelResult _model_d2_law(StreamThermo st, ChamberThermo ch, str convection)
float vaporization_lag_leonardi(StreamThermo st, ChamberThermo ch, *, str convection=DEFAULT_CONVECTION_MODEL)
float ranz_marshall_correction(float Re_d, float Pr=0.7)
float evaporation_constant_leonardi(float MR, float T_inf, float T_crit)
float resolve_mixing_lag(float tau_vap_liquid, float mix_fraction)
Dict[str, LagBreakdown] compute_lags(Dict[str, StreamThermo] streams, ChamberThermo ch, *, str model="d2_law", float mix_fraction=0.0, str convection=DEFAULT_CONVECTION_MODEL, Optional[Callable[[str, float, str, str], float]] on_fallback=None)
float atomization_lag(StreamThermo st, ChamberThermo ch)
_ModelResult _model_leonardi(StreamThermo st, ChamberThermo ch, str convection)