1"""Saturation margin at every liquid node, and cavitation at the injector orifices.
3``build_saturation(result, prep=None, config=None, ...)`` -> ``diagnostics.saturation``;
4``build_cavitation(result, prep=None, config, ...)`` -> ``diagnostics.cavitation``
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.
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.
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.
38from __future__
import annotations
41from typing
import Any, Dict, List, Mapping, Optional
46 PSI, SIDE_CONFIG, SIDE_ROLE, SIDES, arr, firing_mask, inp, model_block, node_T, out, scalar, series_t,
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")
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."""
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."""
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
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
78 """P_sat(T) [Pa] by CoolProp; NaN above the critical point or out of range."""
79 import CoolProp.CoolProp
as CP
82 return float(CP.PropsSI(
"P",
"T", T_K,
"Q", 0, coolprop_name(fluid)))
88 import CoolProp.CoolProp
as CP
91 return float(CP.PropsSI(
"D",
"T", T_K,
"Q", 0, coolprop_name(fluid)))
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 {}
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:
105 return (
_derived(prep, result).get(
"species")
or {}).get(SIDE_ROLE[side])
108def node_fluid(prep: Any, result: Mapping[str, Any], nid: str, side: Optional[str]) -> Optional[str]:
110 if built
is not None:
112 return str(built.network.nodes[nid].fluid)
123 b = built.network.branches[bid].component.p.get(
"bore")
124 return float(b)
if b
else None
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."""
132 for br
in (net.get(
"branches")
or {}).values():
133 if nid
not in (br.get(
"from"), br.get(
"to")):
135 m = np.abs(arr(br.get(
"mdot"), n))[firing]
136 m = m[np.isfinite(m)]
138 peak =
max(peak, float(m.max()))
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."""
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)")
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":
159 if firing.any()
and _peak_flow(net, nid, n, firing) < dead_leg_kg_s:
165 skipped.append(f
"{nd.get('label') or nid} (no flow through it while firing: a dead leg)")
167 side = nd.get(
"side")
170 skipped.append(f
"{nid} (fluid unknown)")
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
183 for bid, br
in net[
"branches"].items():
184 if nid
not in (br.get(
"from"), br.get(
"to"))
or br.get(
"kind") ==
"injector":
192 mdot = np.abs(np.nan_to_num(arr(br.get(
"mdot"), n)))
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)
205 "id": nid,
"label": nd.get(
"label")
or nid,
"side": side,
"fluid": fluid,
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),
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"])
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,
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",
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",
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")},
246 except Exception
as exc:
247 return unavailable(f
"{type(exc).__name__}: {exc}")
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
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),
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."""
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)
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``)."""
287 return unavailable(
"the engine config is needed for the orifices")
290 series = result.get(
"series")
or {}
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]] = {}
300 s = series.get(side)
or {}
301 if s.get(
"manifold_psia")
is None:
303 fluid =
side_fluid(prep, result, side)
or (
"oxygen" if side ==
"ox" else "ethanol")
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])
315 K = np.full(n, math.nan)
316 Kc = np.full(n, math.nan)
319 if not (firing[k]
and math.isfinite(cd[k])
and math.isfinite(pv[k])):
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"):
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)
330 flip = bool(cavitates
and math.isfinite(ld)
and ld <= flip_l_over_d_max)
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,
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,
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")
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",
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",
377 except Exception
as exc:
378 return unavailable(f
"{type(exc).__name__}: {exc}")
float saturated_liquid_density(str fluid, float T_K)
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)
float saturation_pressure(str fluid, float T_K)
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)