STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
outflow.py
Go to the documentation of this file.
1"""Layer X outflow: when each tank outlet starts drawing pressurant (the surface dip), and what is
2left in the tank then.
3
4``outflow_from_run(prep, result, settings=OutflowSettings(), accel=None)`` -> ``diagnostics.outflow``
5(a list, one row per propellant tank; DATA-CONTRACT 3).
6
7The twin burns each tank to its dry mass (1 g, ``LayerXSettings.dry_kg``): its outlet never draws
8gas. A real outlet does, once the liquid over it is shallow enough that the flow converging on the
9outlet pulls the surface down into it.
10
11The model: Lubin & Springer (1967)
12----------------------------------
13B. T. Lubin and G. S. Springer, "The formation of a dip on the surface of a liquid draining from a
14tank", J. Fluid Mech. 29(2), 385-390, 1967. With no swirl, a dip forms over a bottom outlet, and
15gas is drawn into it, when the liquid height over the outlet falls to
16
17 h_c = 0.69 (Q^2 / g')^(1/5), g' = g (1 - rho_gas / rho_liquid)
18
19``Q`` the volumetric outflow and ``g`` the specific force along the tank axis. Independent of the
20outlet diameter and of the initial height. The constant and the form ``0.69 (Q^2/g)^(1/5)`` are as
21restated by M. Wollen and C. Ostoich, "Design of a low gravity vane liquid acquisition device for
22cryogenic liquids ...", TFAWS 2024, paper TFAWS24-CR-1 ("Classic analytical result by Lubin and
23Springer"; checked against the slides). The buoyancy factor ``1 - rho_gas/rho_liquid`` on ``g`` is
24this module's (the restatement has none; the paper's own text was not available to check): it is
25the reduced gravity of a gas-over-liquid surface, and on LE4 (helium at ~600 psia over LOX) it moves
26``h_c`` by under 0.2 %. The constant is also what the point-sink analysis behind it gives: with the
27surface over a hemispherical sink of velocity
28``Q/(2 pi z^2)``, Bernoulli gives ``h = z + Q^2/(8 pi^2 g' z^4)``, which has no solution for ``h``
29below its minimum ``h_c = 1.25 (Q^2/(2 pi^2 g'))^(1/5) = 0.688 (Q^2/g')^(1/5)``
30(``point_sink_constant``; tested). **The constant 0.574 quoted in docs/layer-x.md could not be
31verified against the paper and is not used**; it would put the dip 17 % lower.
32
33What the run has to supply, and does not have
34---------------------------------------------
35* **Outlet diameter**: the drawing's line leaving the tank (its bore is the outlet).
36 It does not enter ``h_c``; it sets the outlet Froude number and the check that ``h_c`` is large
37 against the outlet (the sink approximation).
38* **Tank diameter**: the drawing's (estimated, "not measured").
39* **Bottom head shape**: not drawn. ``flat`` (default) puts the most liquid under ``h_c`` and is the
40 upper bound on the residual; ``hemispherical`` the least; ``ellipsoidal_2to1`` between. The row
41 carries all three.
42* **Swirl** raises the onset height (a vortex instead of a dip). Not modelled.
43
44The residual at onset is an upper bound on the unusable propellant: past the onset the outlet draws
45gas with the liquid rather than stopping at once.
46"""
47
48from __future__ import annotations
49
50import math
51from dataclasses import dataclass
52from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
53
54import numpy as np
55
56from engine.layerx.diag.start import G0, PSI, SIDES, finite, inp, model_block, param_provenance, unavailable
57
58MODEL_NAME = "lubin_springer_dip"
59LS_CONSTANT = 0.69
60LS_SOURCE = ("Lubin & Springer, 'The formation of a dip on the surface of a liquid draining from a tank', "
61 "J. Fluid Mech. 29(2) 385-390 (1967): h_c = 0.69 (Q^2/g)^(1/5), no swirl, as restated by Wollen & "
62 "Ostoich (TFAWS 2024, TFAWS24-CR-1) and as the point-sink analysis gives it (0.688); g reduced by "
63 "the buoyancy factor (1 - rho_g/rho_l) here (not from the restatement; < 0.2 % on h_c for LE4)")
64HEADS = {"flat": 0.0, "ellipsoidal_2to1": 0.5, "hemispherical": 1.0}
65"""Bottom head depth over the tank radius."""
66
67
68# ------------------------------------------------------------------ the correlation and the tank
69
70
71def point_sink_constant() -> float:
72 """1.25 (2 pi^2)^(-1/5): the minimum of ``h(z) = z + Q^2/(8 pi^2 g z^4)`` over ``(Q^2/g)^(1/5)``."""
73 return 1.25 * (2.0 * math.pi ** 2) ** -0.2
74
75
76def critical_height(Q: float, accel: float, rho_liquid: float, rho_gas: float = 0.0,
77 constant: float = LS_CONSTANT) -> float:
78 """Lubin & Springer's dip height [m] for a volumetric outflow ``Q`` [m^3/s] at specific force
79 ``accel`` [m/s^2]."""
80 g_eff = accel * (1.0 - rho_gas / rho_liquid)
81 if Q <= 0.0 or g_eff <= 0.0:
82 return 0.0 if Q <= 0.0 else math.inf
83 return constant * (Q * Q / g_eff) ** 0.2
84
85
86def volume_below(h: float, radius: float, head_depth: float) -> float:
87 """Liquid volume [m^3] up to height ``h`` over the outlet in a vertical cylinder of ``radius``
88 with a semi-ellipsoidal bottom head of depth ``head_depth`` (0 = flat, radius = hemisphere).
89 In the head, the cross-section at height y is ``pi R^2 (2 b y - y^2)/b^2``."""
90 if h <= 0.0:
91 return 0.0
92 R, b = radius, head_depth
93 if b <= 0.0:
94 return math.pi * R * R * h
95 if h <= b:
96 return math.pi * R * R / (b * b) * (b * h * h - h ** 3 / 3.0)
97 return (2.0 / 3.0) * math.pi * R * R * b + math.pi * R * R * (h - b)
98
99
100def liquid_height(V: float, radius: float, head_depth: float) -> float:
101 """Inverse of :func:`volume_below` (bisection: monotone)."""
102 if V <= 0.0:
103 return 0.0
104 lo, hi = 0.0, max(head_depth, 0.0) + V / (math.pi * radius * radius) + 1e-9
105 while volume_below(hi, radius, head_depth) < V:
106 hi *= 2.0
107 for _ in range(200):
108 mid = 0.5 * (lo + hi)
109 if volume_below(mid, radius, head_depth) < V:
110 lo = mid
111 else:
112 hi = mid
113 if hi - lo < 1e-12:
114 break
115 return 0.5 * (lo + hi)
116
117
118def onset(t: Sequence[float], liquid_kg: Sequence[float], mdot: Sequence[float], accel: Sequence[float], *,
119 rho_liquid: Sequence[float], rho_gas: Sequence[float], radius: float, head_depth: float,
120 constant: float = LS_CONSTANT) -> Dict[str, Any]:
121 """First instant the liquid height falls to the dip height, interpolated between steps.
122 Inputs are per step over the firing steps (mdot > 0)."""
123 margin_prev = None
124 rows: List[Tuple[float, float, float, float]] = []
125 for k in range(len(t)):
126 rl = rho_liquid[k]
127 h = liquid_height(liquid_kg[k] / rl, radius, head_depth)
128 hc = critical_height(mdot[k] / rl, accel[k], rl, rho_gas[k], constant)
129 rows.append((t[k], h, hc, liquid_kg[k]))
130 margin = h - hc
131 if margin <= 0.0:
132 if margin_prev is None:
133 return {"t": t[k], "residual_kg": liquid_kg[k], "h_c": hc, "h": h, "index": k, "rows": rows}
134 f = margin_prev / (margin_prev - margin)
135 t_on = t[k - 1] + f * (t[k] - t[k - 1])
136 m_on = liquid_kg[k - 1] + f * (liquid_kg[k] - liquid_kg[k - 1])
137 hc_on = rows[k - 1][2] + f * (hc - rows[k - 1][2])
138 return {"t": t_on, "residual_kg": m_on, "h_c": hc_on, "h": hc_on, "index": k, "rows": rows}
139 margin_prev = margin
140 return {"t": None, "residual_kg": None, "h_c": rows[-1][2] if rows else None,
141 "h": rows[-1][1] if rows else None, "index": None, "rows": rows}
142
143
144# ------------------------------------------------------------------ from a run
145
146
147@dataclass(frozen=True)
149 outlet_d_mm: Tuple[Optional[float], Optional[float]] = (None, None)
150 """(LOX, fuel) outlet bore [mm]. None = the first drawn line's bore (assumed)."""
151 head: str = "flat"
152 """Bottom head shape for the reported residual: flat | ellipsoidal_2to1 | hemispherical."""
153 constant: float = LS_CONSTANT
154
155
156def _schedule_accel(result: Mapping[str, Any], accel: Optional[Mapping[str, Any]]) -> Tuple[Optional[Mapping[str, Any]], str]:
157 if accel is not None:
158 return accel, "given acceleration schedule"
159 flight = result.get("flight") or {}
160 sch = flight.get("schedule") if isinstance(flight, Mapping) else None
161 if isinstance(sch, Mapping) and sch.get("t") and sch.get("accel_m_s2"):
162 return sch, "the flight's acceleration (result.flight.schedule)"
163 return None, "standard gravity (on the pad)"
164
165
166def outflow_from_run(prep: Any, result: Mapping[str, Any], settings: OutflowSettings = OutflowSettings(),
167 accel: Optional[Mapping[str, Any]] = None) -> List[Dict[str, Any]]:
168 """``diagnostics.outflow`` rows. Never raises."""
169 rows: List[Dict[str, Any]] = []
170 for (side, key), d_mm in zip(SIDES, settings.outlet_d_mm):
171 try:
172 rows.append(_row(prep, result, side, key, d_mm, settings, accel))
173 except Exception as exc: # noqa: BLE001
174 rows.append({"tank": (getattr(prep, "roles", {}) or {}).get(side), "side": key,
175 **unavailable(f"outflow: {exc}")})
176 return rows
177
178
179def _row(prep: Any, result: Mapping[str, Any], side: str, key: str, d_mm: Optional[float],
180 settings: OutflowSettings, accel: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
181 from feedtwin.props.fluid import Fluid
182
183 from engine.layerx.diag.start import feed_path
184
185 if settings.head not in HEADS:
186 raise ValueError(f"unknown head shape {settings.head!r}; one of {', '.join(HEADS)}")
187 series = result["series"]
188 sub = series[key]
189 tank = prep.roles[side]
190 node = prep.model.diagram.node(tank)
191 dparam = node.params.get("diameter")
192 if dparam is None:
193 raise ValueError(f"{tank} has no drawn diameter")
194 D_tank = float(dparam.si)
195 R = D_tank / 2.0
196 # Outlet bore: the first drawn line out of the tank.
197 net = prep.model.built.network
198 path = feed_path(net, prep.inlet_nodes[side], tank)
199 first = net.branches[path[0]].component if path else None
200 bore_line = float((getattr(first, "p", {}) or {}).get("bore", 0.0) or 0.0) if first is not None else 0.0
201 if d_mm is None:
202 d_out = bore_line
203 d_prov = f"drawing: the bore of the line leaving the tank ({path[0] if path else '?'})"
204 else:
205 d_out = float(d_mm) * 1e-3
206 d_prov = "setting"
207 species = (prep.derived.get("species") or {}).get(side)
208 gas = prep.derived.get("pressurant_gas")
209 gas_prov = f"feedtwin Fluid('{gas}') at the tank pressure and ullage temperature"
210 if not gas:
211 gas = "nitrogen"
212 gas_prov = "assumed nitrogen: the run names no pressurant gas (it enters only the buoyancy factor)"
213 fl, fg = Fluid(species), Fluid(gas)
214 idx = [i for i, f in enumerate(series.get("firing") or []) if f]
215 if not idx:
216 raise ValueError("the burn never fired")
217 t = [float(series["t"][i]) for i in idx]
218 liq = [float(sub["liquid_kg"][i]) for i in idx]
219 md = [float(sub["mdot"][i]) for i in idx]
220 p = [float(sub["tank_psia"][i]) * PSI for i in idx]
221 TL = [float(sub["liquid_K"][i]) for i in idx]
222 TU = [float(sub["ullage_K"][i]) for i in idx]
223 rho_l = [fl.get("rho", p=pi, T=ti) for pi, ti in zip(p, TL)]
224 rho_g = [fg.get("rho", p=pi, T=ti) for pi, ti in zip(p, TU)]
225 sch, accel_basis = _schedule_accel(result, accel)
226 if sch is not None:
227 a = [float(np.interp(ti, sch["t"], sch["accel_m_s2"])) for ti in t]
228 else:
229 a = [G0] * len(t)
230 by_head: Dict[str, Dict[str, Any]] = {}
231 for name, frac in HEADS.items():
232 on = onset(t, liq, md, a, rho_liquid=rho_l, rho_gas=rho_g, radius=R, head_depth=frac * R,
233 constant=settings.constant)
234 by_head[name] = on
235 main = by_head[settings.head]
236 load = finite((result.get("summary") or {}).get(key, {}).get("loaded_kg")) or liq[0]
237 # The impulse the burn credits after the onset: flow past it rests on gas-free outflow.
238 deliv = result.get("delivered") or {}
239 at_risk = None
240 if main["t"] is not None and deliv.get("t") and deliv.get("thrust_N"):
241 dt_ = [float(x) for x in deliv["t"]]
242 F = [float(x) for x in deliv["thrust_N"]]
243 at_risk = 0.0
244 for k in range(1, len(dt_)):
245 lo, hi = max(dt_[k - 1], main["t"]), dt_[k]
246 if hi > lo:
247 f0 = float(np.interp(lo, dt_, F))
248 at_risk += 0.5 * (f0 + F[k]) * (hi - lo)
249 k_on = main["index"]
250 i_ref = k_on if k_on is not None else len(t) - 1
251 Q_ref = md[i_ref] / rho_l[i_ref]
252 v_out = Q_ref / (math.pi * d_out ** 2 / 4.0) if d_out > 0 else None
253 fr = v_out / math.sqrt(a[i_ref] * d_out) if (v_out and d_out > 0) else None
254 hc = main["h_c"]
255 warnings: List[str] = []
256 if hc is not None and d_out > 0 and hc < 2.0 * d_out:
257 warnings.append(f"h_c {1e3 * hc:.1f} mm is under two outlet diameters: the point-sink picture behind the "
258 "correlation is stretched")
259 inputs = {
260 "tank_diameter": inp(D_tank, "m", param_provenance(dparam, "flagged: estimated, not measured")),
261 "outlet_d": inp(d_out, "m", d_prov),
262 "head": inp(settings.head, "-", "assumed flat: the head shape is not drawn; flat puts the most liquid under "
263 "h_c (upper bound on the residual)"),
264 "constant": inp(settings.constant, "-", LS_SOURCE),
265 "rho_liquid": inp(rho_l[i_ref], "kg/m^3", f"feedtwin Fluid('{species}') at the tank state"),
266 "rho_gas": inp(rho_g[i_ref], "kg/m^3", gas_prov),
267 "accel": inp(a[i_ref], "m/s^2", accel_basis),
268 }
269 model = model_block(
270 MODEL_NAME, LS_SOURCE,
271 [
272 "no swirl: a dip, not a vortex (a vortex forms higher)",
273 "bottom-centre outlet; h measured from the outlet plane",
274 "the tank is a vertical cylinder of the drawn diameter with the stated bottom head",
275 "the residual at onset is an upper bound on what the outlet cannot deliver gas-free",
276 "the specific force is standard gravity on the pad, or the flight's acceleration when flown",
277 "g is reduced by the buoyancy factor (1 - rho_gas/rho_liquid): this module's, not the restatement's",
278 "0.574 (docs/layer-x.md) could not be verified and is not used",
279 ],
280 inputs)
281 return {
282 "tank": tank, "side": key, "available": True,
283 "ingestion_onset_s": main["t"],
284 "residual_kg": main["residual_kg"],
285 "residual_frac": (main["residual_kg"] / load) if (main["residual_kg"] is not None and load) else None,
286 "residual_band_kg": {name: on["residual_kg"] for name, on in by_head.items()},
287 "onset_band_s": {name: on["t"] for name, on in by_head.items()},
288 "h_c_mm": 1e3 * hc if hc is not None else None,
289 "level_end_mm": 1e3 * liquid_height(liq[-1] / rho_l[-1], R, HEADS[settings.head] * R),
290 "outlet_d_mm": 1e3 * d_out,
291 "outlet_froude": fr,
292 "impulse_at_risk_Ns": at_risk,
293 "head": settings.head,
294 "warnings": warnings,
295 "unmeasured": ["outlet bore", "tank bottom head shape", "tank diameter (estimated)", "swirl at the outlet",
296 "a weighed residual from a cold flow to depletion would replace all of these"],
297 "model": model,
298 }
299
#define max(a, b)
Definition Arduino.h:43
float liquid_height(float V, float radius, float head_depth)
Definition outflow.py:100
float critical_height(float Q, float accel, float rho_liquid, float rho_gas=0.0, float constant=LS_CONSTANT)
Definition outflow.py:77
List[Dict[str, Any]] outflow_from_run(Any prep, Mapping[str, Any] result, OutflowSettings settings=OutflowSettings(), Optional[Mapping[str, Any]] accel=None)
Definition outflow.py:167
Tuple[Optional[Mapping[str, Any]], str] _schedule_accel(Mapping[str, Any] result, Optional[Mapping[str, Any]] accel)
Definition outflow.py:156
Dict[str, Any] onset(Sequence[float] t, Sequence[float] liquid_kg, Sequence[float] mdot, Sequence[float] accel, *, Sequence[float] rho_liquid, Sequence[float] rho_gas, float radius, float head_depth, float constant=LS_CONSTANT)
Definition outflow.py:120
Dict[str, Any] _row(Any prep, Mapping[str, Any] result, str side, str key, Optional[float] d_mm, OutflowSettings settings, Optional[Mapping[str, Any]] accel)
Definition outflow.py:180
float volume_below(float h, float radius, float head_depth)
Definition outflow.py:86