STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
saturation.py
Go to the documentation of this file.
1"""Saturation margin at every liquid node, and cavitation at the injector orifices.
2
3``build_saturation(result, prep=None, config=None, ...)`` -> ``diagnostics.saturation``;
4``build_cavitation(result, prep=None, config, ...)`` -> ``diagnostics.cavitation``
5(DATA-CONTRACT 3).
6
7**Saturation.** At each liquid node of the recorded network, per twin step,
8``margin = p_node - P_sat(T_node)`` with ``P_sat`` CoolProp's (oxygen on the LOX side, ethanol on the
9fuel side; the node's own fluid where the prepared model knows it). Node pressures are total
10pressures in the twin's lumped-K convention (AUDIT 9.6 B4), so where the drawing gives the bores
11of the branches at a node the static margin is reported too: the total less the largest dynamic
12head ``mdot^2 / (2 rho A^2)`` of the flowing branches at that node, at the saturated-liquid
13density (the twin's own liquid basis). The minimum and its time are taken over the firing steps:
14before Fire the lines downstream of the shut mains are held at 101.325 kPa by the twin (they would
15be gas-filled on the stand; AUDIT 9.5 4), which is not a margin anyone has. ``ENG.chamber`` is not a
16liquid node and is never included.
17
18**Cavitation.** Nurick (1976), "Orifice Cavitation and Its Effect on Spray Mixing", J. Fluids
19Eng. 98(4): once the static pressure at the vena contracta reaches the vapour pressure, a sharp
20orifice's flow follows ``Cd = Cc sqrt(K)`` with ``K = (p_up - p_v) / (p_up - p_c)``, so it cavitates
21below ``K_crit = (Cd / Cc)^2``; ``Cc = (1/Cc0^2 - 11.4 r/d)^-1/2``. This module does not restate
22that: it calls EngineDesign's own implementation, ``engine.core.discharge.cavitation_margin`` (the
23same ``Cc0`` and r/d law the Forward report uses), per step, with ``p_up`` the manifold pressure
24(``series.*.manifold_psia``), ``p_c`` the chamber's, ``p_v`` CoolProp's at the line-exit liquid
25temperature, and ``Cd`` the orifices' effective Cd that step,
26``mdot / (n A_jet sqrt(2 rho dp_inj))`` at the config density (the card's injector basis).
27``K_incipient`` in the contract is that ``K_crit``: below it the vena contracta is at vapour
28pressure in Nurick's model. Bubbles can appear somewhat above it; no allowance is added.
29
30**Hydraulic flip.** A flip needs a cavitating orifice (K below K_crit) whose separated region
31reaches the exit before the flow reattaches, which a long bore prevents. The L/d below which that
32happens is taken as an explicit input, ``flip_l_over_d_max``, default 5.0 *assumed* (the repo's
33injector plan, ``docs/injector_face_and_manifold_plan.md``: "L/d >= 5 preferred for a sharp
34entry"); the value Nurick reports was not verified for this module and is not cited. ``flip_risk``
35is true when an orifice cavitates on any firing step and its L/d is at or under that threshold.
36"""
37
38from __future__ import annotations
39
40import math
41from typing import Any, Dict, List, Mapping, Optional
42
43import numpy as np
44
45from engine.layerx.diag.ladder import (
46 PSI, SIDE_CONFIG, SIDE_ROLE, SIDES, arr, firing_mask, inp, model_block, node_T, out, scalar, series_t,
47 unavailable,
48)
49from engine.layerx.diag.regulator import _built, coolprop_name
50
51NURICK_SOURCE = ("Nurick, W. H. (1976), 'Orifice Cavitation and Its Effect on Spray Mixing', J. Fluids Eng. "
52 "98(4), 681-687: Cd = Cc sqrt(K), K = (p_up - p_v)/(p_up - p_c), cavitating below "
53 "K_crit = (Cd/Cc)^2; Cc = (1/Cc0^2 - 11.4 r/d)^-1/2 -- as implemented in "
54 "engine.core.discharge.cavitation_margin")
55
56DEFAULT_FLAG_MARGIN_PSI = 25.0
57"""Saturation margin under which a liquid node is flagged [psi]. Assumed: about one LOX-line
58dynamic head at full flow (25.0 psi, AUDIT 9.6 B4), room for the transients the twin does not
59resolve (valve opening, line acoustics); no source."""
60
61DEAD_LEG_KG_S = 1.0e-3
62"""Peak flow through a liquid node over the firing steps under which it is a dead leg and is not
63graded [kg/s]. 1 g/s is 0.05 % of LE4's ~1.8 kg/s LOX feed: a node carrying less is not on the feed.
64The twin's dead legs carry exactly zero (a shut valve, a capped port); the margin is a judgement."""
65
66DEFAULT_CAVITATION_MARGIN = 0.2
67"""K / K_crit under 1 + this is flagged as near cavitation. Assumed: K_crit is a model value
68(Nurick's onset for the vena contracta at vapour pressure) and incipient cavitation appears above
69it; no source."""
70
71DEFAULT_FLIP_LD_MAX = 5.0
72"""L/d at or under which a cavitating sharp-entry orifice is counted at flip risk. Assumed from the
73repo's injector plan ('L/d >= 5 preferred for a sharp entry'); set it from Nurick (1976) or a cold
74flow."""
75
76
77def saturation_pressure(fluid: str, T_K: float) -> float:
78 """P_sat(T) [Pa] by CoolProp; NaN above the critical point or out of range."""
79 import CoolProp.CoolProp as CP
80
81 try:
82 return float(CP.PropsSI("P", "T", T_K, "Q", 0, coolprop_name(fluid)))
83 except Exception: # noqa: BLE001
84 return math.nan
85
86
87def saturated_liquid_density(fluid: str, T_K: float) -> float:
88 import CoolProp.CoolProp as CP
89
90 try:
91 return float(CP.PropsSI("D", "T", T_K, "Q", 0, coolprop_name(fluid)))
92 except Exception: # noqa: BLE001
93 return math.nan
94
95
96def _derived(prep: Any, result: Mapping[str, Any]) -> Mapping[str, Any]:
97 d = getattr(prep, "derived", None) if prep is not None else None
98 return d or ((result.get("provenance") or {}).get("derived")) or {}
99
100
101def side_fluid(prep: Any, result: Mapping[str, Any], side: Optional[str]) -> Optional[str]:
102 """The propellant on a network side (``ox``/``fuel``), from the drawing's roles."""
103 if side not in SIDE_ROLE:
104 return None
105 return (_derived(prep, result).get("species") or {}).get(SIDE_ROLE[side])
106
107
108def node_fluid(prep: Any, result: Mapping[str, Any], nid: str, side: Optional[str]) -> Optional[str]:
109 built = _built(prep)
110 if built is not None:
111 try:
112 return str(built.network.nodes[nid].fluid)
113 except Exception: # noqa: BLE001
114 pass
115 return side_fluid(prep, result, side)
116
117
118def _branch_bore(prep: Any, bid: str) -> Optional[float]:
119 built = _built(prep)
120 if built is None:
121 return None
122 try:
123 b = built.network.branches[bid].component.p.get("bore")
124 return float(b) if b else None
125 except Exception: # noqa: BLE001
126 return None
127
128
129def _peak_flow(net: Mapping[str, Any], nid: str, n: int, firing: np.ndarray) -> float:
130 """The largest |mass flow| [kg/s] through any branch at node ``nid`` over the firing steps."""
131 peak = 0.0
132 for br in (net.get("branches") or {}).values():
133 if nid not in (br.get("from"), br.get("to")):
134 continue
135 m = np.abs(arr(br.get("mdot"), n))[firing]
136 m = m[np.isfinite(m)]
137 if m.size:
138 peak = max(peak, float(m.max()))
139 return peak
140
141
142def build_saturation(result: Mapping[str, Any], prep: Any = None, config: Any = None, *,
143 flag_margin_psi: float = DEFAULT_FLAG_MARGIN_PSI,
144 dead_leg_kg_s: float = DEAD_LEG_KG_S) -> Dict[str, Any]:
145 """``diagnostics.saturation``. Never raises."""
146 try:
147 net = result.get("network")
148 if not isinstance(net, Mapping) or not net.get("nodes"):
149 return unavailable("no feed network was recorded for this run (result.network)")
150 t = series_t(result)
151 n = t.size
152 series = result.get("series") or {}
153 firing = firing_mask(result, n)
154 rows: List[Dict[str, Any]] = []
155 skipped: List[str] = []
156 for nid, nd in net["nodes"].items():
157 if nd.get("phase") != "liquid" or nd.get("kind") == "chamber":
158 continue
159 if firing.any() and _peak_flow(net, nid, n, firing) < dead_leg_kg_s:
160 # A dead leg (a capped disconnect, a dump valve's outlet, a stub behind a shut valve): no
161 # propellant moves through it while firing, so nothing there can cavitate or flash into
162 # the feed. The twin holds an isolated one at 101.325 kPa, where LOX at 90.19 K sits on its
163 # own saturation line: it "boiled" by construction on every LOX burn (OF-QD-B and
164 # Ox-Dump-R outlets on LE4 (3), -0.003 psi, amber).
165 skipped.append(f"{nd.get('label') or nid} (no flow through it while firing: a dead leg)")
166 continue
167 side = nd.get("side")
168 fluid = node_fluid(prep, result, nid, side)
169 if not fluid:
170 skipped.append(f"{nid} (fluid unknown)")
171 continue
172 p = arr(nd.get("p_psia"), n)
173 T = node_T(net, nid, n)
174 T_src = "node (twin's enthalpy walk)"
175 if not np.isfinite(T).any() and side in SIDES:
176 T = arr((series.get(side) or {}).get("liquid_K"), n)
177 T_src = f"series.{side}.liquid_K (tank bulk liquid)"
178 psat = np.array([saturation_pressure(fluid, x) if math.isfinite(x) else math.nan for x in T]) / PSI
179 margin = p - psat
180 # Static: subtract the largest velocity head of the flowing branches at this node.
181 q = np.zeros(n)
182 have_bore = False
183 for bid, br in net["branches"].items():
184 if nid not in (br.get("from"), br.get("to")) or br.get("kind") == "injector":
185 # the engine leg's "bore" is the orifices' total area: their velocity head belongs
186 # to the cavitation block, not to the line-exit node's static pressure
187 continue
188 bore = _branch_bore(prep, bid)
189 if not bore:
190 continue
191 have_bore = True
192 mdot = np.abs(np.nan_to_num(arr(br.get("mdot"), n)))
193 rho = np.array([saturated_liquid_density(fluid, x) if math.isfinite(x) else math.nan for x in T])
194 area = math.pi * bore * bore / 4.0
195 with np.errstate(divide="ignore", invalid="ignore"):
196 qb = np.where(rho > 0, mdot * mdot / (2.0 * rho * area * area), 0.0) / PSI
197 q = np.maximum(q, np.nan_to_num(qb))
198 static = margin - q if have_bore else np.full(n, math.nan)
199 live = firing & np.isfinite(margin)
200 k_min = int(np.flatnonzero(live)[np.nanargmin(margin[live])]) if live.any() else None
201 live_s = firing & np.isfinite(static)
202 ks_min = int(np.flatnonzero(live_s)[np.nanargmin(static[live_s])]) if live_s.any() else None
203 worst = static[ks_min] if ks_min is not None else (margin[k_min] if k_min is not None else math.nan)
204 rows.append({
205 "id": nid, "label": nd.get("label") or nid, "side": side, "fluid": fluid,
206 "T_source": T_src,
207 "margin_psi": out(margin), "margin_static_psi": out(static),
208 "min_psi": scalar(margin[k_min]) if k_min is not None else None,
209 "t_min": scalar(t[k_min]) if k_min is not None else None,
210 "min_static_psi": scalar(static[ks_min]) if ks_min is not None else None,
211 "t_min_static": scalar(t[ks_min]) if ks_min is not None else None,
212 "flagged": bool(math.isfinite(worst) and worst < flag_margin_psi),
213 })
214 if not rows:
215 return unavailable("no liquid node with a known fluid in result.network")
216 worst_row = min((r for r in rows if r["min_psi"] is not None), default=None,
217 key=lambda r: r["min_static_psi"] if r["min_static_psi"] is not None else r["min_psi"])
218 return {
219 "nodes": rows,
220 "worst": ({"id": worst_row["id"], "min_psi": worst_row["min_psi"],
221 "min_static_psi": worst_row["min_static_psi"],
222 "t_min": (worst_row["t_min_static"] if worst_row["t_min_static"] is not None
223 else worst_row["t_min"])} if worst_row else None),
224 "flag_margin_psi": flag_margin_psi,
225 "skipped": skipped,
226 "model": model_block(
227 "saturation margin p - P_sat(T) at the liquid nodes",
228 "CoolProp saturation curves (oxygen, ethanol); the twin's node pressures and walked temperatures",
229 [
230 "node pressures are total (lumped K); the static margin subtracts the largest velocity head "
231 "mdot^2/(2 rho A^2) of the node's flowing branches, at the saturated-liquid density",
232 "min and t_min over the firing steps; before Fire the lines past the shut mains are held at "
233 "101.325 kPa by the twin (gas-filled on the stand), so those steps are not graded",
234 "no local accelerations below the line bore (valve throats, orifice vena contracta) -- the "
235 "orifices are graded by the cavitation block instead",
236 "pure anhydrous ethanol (no water fraction, AUDIT D15)",
237 "a liquid node no branch carries flow through while firing (a dead leg: a capped disconnect, a "
238 "shut dump valve's outlet) is not graded; it is listed under skipped",
239 ],
240 {"flag_margin_psi": inp(flag_margin_psi, "psi",
241 "assumed: ~one LOX-line dynamic head (AUDIT 9.6 B4); no source"),
242 "dead_leg_kg_s": inp(dead_leg_kg_s, "kg/s",
243 "assumed: 0.05 % of LE4's LOX feed; the twin's dead legs carry exactly zero")},
244 ),
245 }
246 except Exception as exc: # noqa: BLE001
247 return unavailable(f"{type(exc).__name__}: {exc}")
248
249
250# ---- the orifices -------------------------------------------------------------------------------
251
252def orifice_geometry(config: Any, side: str) -> Dict[str, Any]:
253 """The config's orifices on one network side: n, d_jet, area (all holes), angle, spacing, L/d,
254 r/d, config density."""
255 key = SIDE_CONFIG[side]
256 g = getattr(config.injector.geometry, key)
257 n_el = int(getattr(g, "n_elements"))
258 d = float(getattr(g, "d_jet"))
259 dc = (config.discharge or {}).get(key) if isinstance(config.discharge, Mapping) else None
260 from engine.core.discharge import inlet_radius_ratio_of
261
262 return {
263 "n": n_el, "d_jet": d, "area": n_el * math.pi * d * d / 4.0,
264 "angle_deg": float(getattr(g, "impingement_angle", math.nan)),
265 "spacing": float(getattr(g, "spacing", math.nan) or math.nan),
266 "l_over_d": float(getattr(dc, "orifice_l_over_d", math.nan) or math.nan) if dc is not None else math.nan,
267 "r_over_d": float(inlet_radius_ratio_of(dc)) if dc is not None else 0.0,
268 "inlet": getattr(dc, "inlet_geometry", None) if dc is not None else None,
269 "rho": float(config.fluids[key].density),
270 }
271
272
273def cd_effective(mdot: np.ndarray, dp_psi: np.ndarray, area: float, rho: float) -> np.ndarray:
274 """mdot / (A sqrt(2 rho dp)): the orifices' effective Cd on the twin's flow and orifice drop."""
275 dp = dp_psi * PSI
276 with np.errstate(divide="ignore", invalid="ignore"):
277 return np.where((dp > 0) & (mdot > 0), mdot / (area * np.sqrt(2.0 * rho * dp)), np.nan)
278
279
280def build_cavitation(result: Mapping[str, Any], prep: Any = None, config: Any = None, *,
281 flip_l_over_d_max: float = DEFAULT_FLIP_LD_MAX,
282 near_margin: float = DEFAULT_CAVITATION_MARGIN) -> Dict[str, Any]:
283 """``diagnostics.cavitation``. Never raises. Needs ``config`` (the orifices) and the series;
284 the network only for the line-exit liquid temperature (else ``series.*.liquid_K``)."""
285 try:
286 if config is None:
287 return unavailable("the engine config is needed for the orifices")
288 from engine.core.discharge import cavitation_margin
289
290 series = result.get("series") or {}
291 t = series_t(result)
292 n = t.size
293 firing = firing_mask(result, n)
294 pc = arr((series.get("chamber") or {}).get("pc_psia"), n)
295 net = result.get("network") if isinstance(result.get("network"), Mapping) else None
296 inlets = (_derived(prep, result).get("inlet_nodes") or {})
297 block: Dict[str, Any] = {"t": out(t)}
298 inputs: Dict[str, Dict[str, Any]] = {}
299 for side in SIDES:
300 s = series.get(side) or {}
301 if s.get("manifold_psia") is None:
302 continue
303 fluid = side_fluid(prep, result, side) or ("oxygen" if side == "ox" else "ethanol")
304 geo = orifice_geometry(config, side)
305 p_up = arr(s.get("manifold_psia"), n)
306 mdot = arr(s.get("mdot"), n)
307 dpi = arr(s.get("dp_injector_psi"), n)
308 node = inlets.get(SIDE_ROLE[side])
309 T = node_T(net, node, n) if net is not None and node and node in net["nodes"] else np.full(n, math.nan)
310 T_src = f"line-exit node {node}" if np.isfinite(T).any() else f"series.{side}.liquid_K"
311 if not np.isfinite(T).any():
312 T = arr(s.get("liquid_K"), n)
313 pv = np.array([saturation_pressure(fluid, x) if math.isfinite(x) else math.nan for x in T])
314 cd = cd_effective(mdot, dpi, geo["area"], geo["rho"])
315 K = np.full(n, math.nan)
316 Kc = np.full(n, math.nan)
317 Cc = math.nan
318 for k in range(n):
319 if not (firing[k] and math.isfinite(cd[k]) and math.isfinite(pv[k])):
320 continue
321 m = cavitation_margin(P_in=p_up[k] * PSI, Pc=pc[k] * PSI, Pv=pv[k], Cd=cd[k],
322 r_over_d=geo["r_over_d"])
323 K[k], Kc[k], Cc = m["K"], m["K_crit"], m["Cc"]
324 with np.errstate(divide="ignore", invalid="ignore"):
325 ratio = K / Kc
326 live = np.isfinite(ratio)
327 k_min = int(np.flatnonzero(live)[np.nanargmin(ratio[live])]) if live.any() else None
328 cavitates = bool(live.any() and np.nanmin(ratio[live]) < 1.0)
329 ld = geo["l_over_d"]
330 flip = bool(cavitates and math.isfinite(ld) and ld <= flip_l_over_d_max)
331 block[side] = {
332 "K": out(K), "K_crit": out(Kc), "margin": out(ratio), "Cd_eff": out(cd),
333 "p_v_psia": out(pv / PSI),
334 "K_incipient": scalar(Kc[k_min]) if k_min is not None else None,
335 "Cc": scalar(Cc),
336 "L_over_d": scalar(ld), "r_over_d": scalar(geo["r_over_d"]), "inlet": geo["inlet"],
337 "flip_risk": flip, "cavitates": cavitates,
338 "near": bool(live.any() and np.nanmin(ratio[live]) < 1.0 + near_margin),
339 "min_K": scalar(K[k_min]) if k_min is not None else None,
340 "min_margin": scalar(ratio[k_min]) if k_min is not None else None,
341 "t_min": scalar(t[k_min]) if k_min is not None else None,
342 "T_source": T_src,
343 }
344 key = SIDE_CONFIG[side]
345 inputs[f"{side}_L_over_d"] = inp(ld, "-", f"config discharge.{key}.orifice_l_over_d "
346 f"({getattr((config.discharge or {}).get(key), 'l_over_d_source', '?')})")
347 inputs[f"{side}_r_over_d"] = inp(geo["r_over_d"], "-", f"config discharge.{key}.inlet_geometry "
348 f"'{geo['inlet']}' (engine.core.discharge.INLET_GEOMETRY_RD)")
349 inputs[f"{side}_density"] = inp(geo["rho"], "kg/m^3", f"config fluids.{key}.density (card basis)")
350 inputs[f"{side}_area"] = inp(geo["area"] * 1e6, "mm^2", f"config injector.geometry.{key} "
351 f"({geo['n']} x d_jet {geo['d_jet'] * 1e3:.4f} mm)")
352 if not any(s in block for s in SIDES):
353 return unavailable("series carries no manifold pressure")
354 inputs["flip_l_over_d_max"] = inp(flip_l_over_d_max, "-",
355 "assumed: repo injector plan 'L/d >= 5 preferred for a sharp entry' "
356 "(docs/injector_face_and_manifold_plan.md); not taken from Nurick 1976")
357 inputs["near_margin"] = inp(near_margin, "-", "assumed: no source")
358 from engine.core.discharge import NURICK_CC0
359
360 inputs["Cc0"] = inp(NURICK_CC0, "-", "engine.core.discharge.NURICK_CC0 (Nurick 1976 sharp-edge contraction)")
361 block["model"] = model_block(
362 "orifice cavitation number vs Nurick's critical K, and hydraulic-flip risk",
363 NURICK_SOURCE,
364 [
365 "p_up = manifold pressure (line exit less the Borda dump), p_c = chamber, per twin step",
366 "p_v = CoolProp saturation pressure at the line-exit liquid temperature",
367 "Cd = the orifices' effective Cd on the twin's flow and orifice drop at the config density",
368 "K_incipient = K_crit = (Cd/Cc)^2: the vena contracta at vapour pressure; no allowance for "
369 "bubbles appearing above it",
370 "flip risk = cavitating on some firing step and L/d <= flip_l_over_d_max",
371 "start transient not modelled (both mains open together; no priming), so early steps carry "
372 "full-flow pressures",
373 ],
374 inputs,
375 )
376 return block
377 except Exception as exc: # noqa: BLE001
378 return unavailable(f"{type(exc).__name__}: {exc}")
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
float saturated_liquid_density(str fluid, float T_K)
Definition saturation.py:87
Dict[str, Any] orifice_geometry(Any config, str side)
Dict[str, Any] build_cavitation(Mapping[str, Any] result, Any prep=None, Any config=None, *, float flip_l_over_d_max=DEFAULT_FLIP_LD_MAX, float near_margin=DEFAULT_CAVITATION_MARGIN)
Optional[str] node_fluid(Any prep, Mapping[str, Any] result, str nid, Optional[str] side)
Optional[float] _branch_bore(Any prep, str bid)
Mapping[str, Any] _derived(Any prep, Mapping[str, Any] result)
Definition saturation.py:96
float saturation_pressure(str fluid, float T_K)
Definition saturation.py:77
np.ndarray cd_effective(np.ndarray mdot, np.ndarray dp_psi, float area, float rho)
Dict[str, Any] build_saturation(Mapping[str, Any] result, Any prep=None, Any config=None, *, float flag_margin_psi=DEFAULT_FLAG_MARGIN_PSI, float dead_leg_kg_s=DEAD_LEG_KG_S)
float _peak_flow(Mapping[str, Any] net, str nid, int n, np.ndarray firing)
Optional[str] side_fluid(Any prep, Mapping[str, Any] result, Optional[str] side)