STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
timelag.py
Go to the documentation of this file.
1"""Conversion time lags for the chug loop — a registry of NAMED models.
2
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
6hardcode either.
7
8Two models, both selectable through ``StabilityConfig.time_lag_model``:
9
10``d2_law``
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.
14
15``leonardi_dtl``
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**]:
19
20 tau_tot = tau_atom + tau_vap + tau_mix (L17 eq. 5)
21
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.
26
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.)
36
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]):
43
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
51 correction does not.
52
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.
58"""
59
60from __future__ import annotations
61
62from dataclasses import dataclass, field
63from typing import Callable, Dict, Optional, Tuple
64
65import numpy as np
66
67from engine.pipeline.stability import core
68
69__all__ = [
70 "StreamThermo",
71 "ChamberThermo",
72 "LagBreakdown",
73 "atomization_lag",
74 "vaporization_lag_leonardi",
75 "evaporation_constant_leonardi",
76 "convection_correction_leonardi",
77 "ranz_marshall_correction",
78 "CONVECTION_MODELS",
79 "DEFAULT_CONVECTION_MODEL",
80 "TIME_LAG_MODELS",
81 "compute_lags",
82 "resolve_mixing_lag",
83 "MASS_HALF_LIFE_FRACTION",
84]
85
86
87#: Fraction of the d^2-law droplet lifetime at which HALF the droplet MASS has vaporized.
88#: Closed form, not a fit: under d^2 = d0^2 (1 - t/tau_vap) the remaining mass fraction is
89#: (1 - t/tau_vap)^{3/2}, so m/m0 = 1/2 at t/tau_vap = 1 - (1/2)^{2/3} = 0.3700.
90#: This is what turns a vaporization lifetime into L17's L50 (the length to vaporize 50% of the
91#: liquid), which is the abscissa of the Szuch mixing-lag curve (L17 fig. 1).
92MASS_HALF_LIFE_FRACTION: float = float(1.0 - 0.5 ** (2.0 / 3.0))
93
94
95# ---------------------------------------------------------------------------
96# Parameter containers
97# ---------------------------------------------------------------------------
98
99@dataclass(frozen=True)
101 """One propellant stream at the injector face. All SI.
102
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``.
107 """
108 name: str
109 phase: str
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")
119
120 @property
121 def is_gas(self) -> bool:
122 return str(self.phase).lower().startswith("g")
123
124
125@dataclass(frozen=True)
127 """Chamber gas state the lags are evaluated against. All SI except where noted."""
128 Pc: float
129 Tc: float
130 MR: float
131 rho_g: float
132 u_g: float
133 k_g: float
134 cp_g: float
135
136 @property
137 def Pc_bar(self) -> float:
138 return float(self.Pc / 1.0e5)
139
140
141@dataclass(frozen=True)
143 """Per-stream lag decomposition. ``tau_total`` is what the chug loop consumes as ``tau_conv``."""
144 stream: str
145 model: str
146 phase: str
147 convection: str
148 tau_atom: float
149 tau_vap: float
150 tau_mix: float
151 tau_total: float
152 K_v: float = float("nan")
153 notes: Tuple[str, ...] = field(default_factory=tuple)
154
155 def as_dict(self) -> Dict[str, object]:
156 return {
157 "stream": self.stream, "model": self.model, "phase": self.phase,
158 "convection": self.convection,
159 "tau_atom_s": float(self.tau_atom), "tau_vap_s": float(self.tau_vap),
160 "tau_mix_s": float(self.tau_mix), "tau_total_s": float(self.tau_total),
161 "K_v": float(self.K_v), "notes": list(self.notes),
162 }
163
164
165# ---------------------------------------------------------------------------
166# L17 correlations
167# ---------------------------------------------------------------------------
168
169def atomization_lag(st: StreamThermo, ch: ChamberThermo) -> float:
170 """Atomization lag [s]. **L17 eq. 6-7** (after Boronine, Vollmer & Frey, ref. [23]):
171
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
175
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.
178 """
179 if st.is_gas:
180 return 0.0
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):
183 return float("nan")
184 if not (np.isfinite(st.rho_l) and st.rho_l > 0 and np.isfinite(ch.rho_g) and ch.rho_g > 0):
185 return float("nan")
186 if not (np.isfinite(st.mu_l) and st.mu_l > 0 and np.isfinite(st.sigma_l) and st.sigma_l > 0):
187 return float("nan")
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
190 # We_g -> 0 when the gas and the jet move together; We^0.03 is a very weak power, but 0**0.03
191 # is 0 and would zero the lag outright, so the degenerate case falls back to the We-free form.
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))
194
195
196def evaporation_constant_leonardi(MR: float, T_inf: float, T_crit: float) -> float:
197 """L17 eq. 9 evaporation constant ``k`` [m^2/s]:
198
199 k = 1e-6 * [ 1.01/(1 + MR) + 1.16e-3 (T_inf - T_cr)^0.93 ]^0.86
200
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.
203 """
204 if not (np.isfinite(MR) and MR > -1.0):
205 return float("nan")
206 if not (np.isfinite(T_inf) and np.isfinite(T_crit)):
207 return float("nan")
208 dT = float(T_inf) - float(T_crit)
209 if dT <= 0.0:
210 # Chamber colder than the liquid's critical point: the correlation's (T_inf - T_cr)^0.93
211 # is undefined. Report NaN rather than clipping — a caller must record the substitution.
212 return float("nan")
213 inner = 1.01 / (1.0 + float(MR)) + 1.16e-3 * dT ** 0.93
214 if inner <= 0.0:
215 return float("nan")
216 return float(1.0e-6 * inner ** 0.86)
217
218
219def convection_correction_leonardi(Pc_bar: float) -> float:
220 """L17 eq. 8 convective speed-up factor ``1 + 1.5*alpha`` with ``alpha = 1 - 3e-3*pc[bar]``.
221
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.
226 """
227 if not np.isfinite(Pc_bar):
228 return float("nan")
229 alpha = 1.0 - 3.0e-3 * float(Pc_bar)
230 return float(max(1.0, 1.0 + 1.5 * alpha))
231
232
233def ranz_marshall_correction(Re_d: float, Pr: float = 0.7) -> float:
234 """Textbook convective correction ``Nu/2 = 1 + 0.3 Re_d^0.5 Pr^(1/3)`` (Ranz-Marshall).
235
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.
239 """
240 if not (np.isfinite(Re_d) and Re_d >= 0 and np.isfinite(Pr) and Pr > 0):
241 return float("nan")
242 return float(1.0 + 0.3 * np.sqrt(Re_d) * Pr ** (1.0 / 3.0))
243
244
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):
250 return float("nan")
251 return float(ch.rho_g * slip * st.D0 / mu_g)
252
253
254#: Named convective speed-up factors for the droplet lifetime: ``tau = tau_quiescent / factor``.
255#: ``"none"`` is the default — see the module docstring for the benchmark that decided it.
256CONVECTION_MODELS: Dict[str, Callable[[StreamThermo, ChamberThermo], float]] = {
257 "none": lambda st, ch: 1.0,
258 "leonardi_eq8": lambda st, ch: convection_correction_leonardi(ch.Pc_bar),
259 "ranz_marshall": lambda st, ch: ranz_marshall_correction(_slip_reynolds(st, ch)),
260}
261
262#: Ships off. L17 eq. 9's evaporation constant already carries the convective enhancement
263#: (benchmark B: quiescent +13 % vs the experiment-derived anchor, eq. 8-corrected -51 %).
264DEFAULT_CONVECTION_MODEL = "none"
265
266
267def vaporization_lag_leonardi(st: StreamThermo, ch: ChamberThermo,
268 *, convection: str = DEFAULT_CONVECTION_MODEL) -> float:
269 """Vaporization lag [s] from **L17 eq. 9**'s evaporation constant: ``tau = D0**2 / k / factor``.
270
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
273 gas stream.
274 """
275 if st.is_gas:
276 return 0.0
277 D0 = float(st.D0)
278 if not (np.isfinite(D0) and D0 > 0):
279 return float("nan")
280 k = evaporation_constant_leonardi(ch.MR, ch.Tc, st.T_crit)
281 if not (np.isfinite(k) and k > 0):
282 return float("nan")
283 fn = CONVECTION_MODELS.get(str(convection))
284 if fn is None:
285 raise ValueError(
286 f"unknown convection model {convection!r}; known: {sorted(CONVECTION_MODELS)}"
287 )
288 corr = fn(st, ch)
289 if not (np.isfinite(corr) and corr > 0):
290 # A correction that cannot be evaluated must not silently become 1.0 — that would be a
291 # different model wearing this one's name. Report NaN and let the caller record it.
292 return float("nan")
293 return float((D0 * D0 / k) / corr)
294
295
296def vaporization_lag_d2(st: StreamThermo, ch: ChamberThermo) -> Tuple[float, float]:
297 """Quiescent d^2-law lag [s] and its evaporation constant K_v — STAR's historical model.
298
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.
301 """
302 if st.is_gas:
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,
307 )
308 return float(tau_vap), float(K_v)
309
310
311# ---------------------------------------------------------------------------
312# Mixing lag (shared by every stream — it is a chamber property, not a stream property)
313# ---------------------------------------------------------------------------
314
315def resolve_mixing_lag(tau_vap_liquid: float, mix_fraction: float) -> float:
316 """Mixing lag [s] shared by all streams, as a fraction of the rate-limiting vaporization lag.
317
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.
325
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.
328 """
329 if not (np.isfinite(tau_vap_liquid) and tau_vap_liquid >= 0):
330 return float("nan")
331 if not (np.isfinite(mix_fraction) and mix_fraction >= 0):
332 return float("nan")
333 return float(mix_fraction * tau_vap_liquid)
334
335
336# ---------------------------------------------------------------------------
337# Model registry
338# ---------------------------------------------------------------------------
339
340_ModelResult = Tuple[float, float, float, float, Tuple[str, ...]]
341
342
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``.
345
346 ``convection`` is accepted and ignored: the d^2-law is the historical model and is reproduced
347 unchanged, corrections included (there were none).
348 """
349 tau_vap, K_v = vaporization_lag_d2(st, ch)
350 notes: Tuple[str, ...] = ()
351 if st.is_gas:
352 notes = ("gas at injection: no atomization or vaporization lag",)
353 return 0.0, tau_vap, K_v, tau_vap, notes
354
355
356def _model_leonardi(st: StreamThermo, ch: ChamberThermo, convection: str) -> _ModelResult:
357 notes_l = []
358 if st.is_gas:
359 # Belt and braces: both primitives below already return 0.0 for a gas, so deleting this
360 # early return changes no number today (a mutation test confirms it). It stays because it
361 # is where L17 §3.2's rule is legible -- but do not remove it on the grounds that it is
362 # redundant without re-checking that BOTH primitives still guard the gas case themselves.
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)
365 tau_atom = atomization_lag(st, ch)
366 if not np.isfinite(tau_atom):
367 notes_l.append("atomization lag unavailable (injector jet diameter/velocity missing)")
368 tau_vap = vaporization_lag_leonardi(st, ch, convection=convection)
369 K_v = float("nan")
370 if not np.isfinite(tau_vap):
371 notes_l.append(
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"
374 )
375 tau_vap, K_v = vaporization_lag_d2(st, ch)
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)
379
380
381#: name -> callable. Keep the keys stable: they are written into config files and report payloads.
382TIME_LAG_MODELS: Dict[str, Callable[[StreamThermo, ChamberThermo, str], _ModelResult]] = {
383 "d2_law": _model_d2_law,
384 "leonardi_dtl": _model_leonardi,
385}
386
387
389 streams: Dict[str, StreamThermo],
390 ch: ChamberThermo,
391 *,
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.
398
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.
402
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).
406 """
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))
409 if fn is None:
410 raise ValueError(
411 f"unknown time_lag_model {model!r}; known models: {sorted(TIME_LAG_MODELS)}"
412 )
413
414 if str(convection) not in CONVECTION_MODELS:
415 raise ValueError(
416 f"unknown convection model {convection!r}; known: {sorted(CONVECTION_MODELS)}"
417 )
418 raw = {key: fn(st, ch, str(convection)) for key, st in streams.items()}
419
420 # Mixing lag: one number for the whole chamber, scaled off the slowest LIQUID stream.
421 liquid_taus = [
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
424 ]
425 if mix_fraction > 0.0 and liquid_taus:
426 tau_mix = resolve_mixing_lag(max(liquid_taus), mix_fraction)
427 else:
428 tau_mix = 0.0
429
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",
437 )
438 tv = tau_vap
439 if not np.isfinite(tv):
440 tv = fb(
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)",
444 )
445 tm = tau_mix if np.isfinite(tau_mix) else 0.0
446 out[key] = LagBreakdown(
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,
450 )
451 return out
#define max(a, b)
Definition Arduino.h:43
float _slip_reynolds(StreamThermo st, ChamberThermo ch, float mu_g=7.0e-5)
Definition timelag.py:245
float convection_correction_leonardi(float Pc_bar)
Definition timelag.py:219
Tuple[float, float] vaporization_lag_d2(StreamThermo st, ChamberThermo ch)
Definition timelag.py:296
_ModelResult _model_d2_law(StreamThermo st, ChamberThermo ch, str convection)
Definition timelag.py:343
float vaporization_lag_leonardi(StreamThermo st, ChamberThermo ch, *, str convection=DEFAULT_CONVECTION_MODEL)
Definition timelag.py:268
float ranz_marshall_correction(float Re_d, float Pr=0.7)
Definition timelag.py:233
float evaporation_constant_leonardi(float MR, float T_inf, float T_crit)
Definition timelag.py:196
float resolve_mixing_lag(float tau_vap_liquid, float mix_fraction)
Definition timelag.py:315
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)
Definition timelag.py:396
float atomization_lag(StreamThermo st, ChamberThermo ch)
Definition timelag.py:169
_ModelResult _model_leonardi(StreamThermo st, ChamberThermo ch, str convection)
Definition timelag.py:356