STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
pressurant.py
Go to the documentation of this file.
1"""The pressurant: what the bottle held, what the burn took, what it needed, and the
2Joule-Thomson temperature change across the regulator.
3
4``build_pressurant(result, prep=None, config=None, ...)`` -> ``diagnostics.pressurant``
5(DATA-CONTRACT 3).
6
7* **Mass** comes from the bottle's vessel state (``series.copv_mass_kg``, ``series.copv_psia``),
8 never from network nodes (AUDIT 5.5: vessel states and node values differ). ``loaded_kg`` is
9 the first recorded sample (the bottle at the start of the lead-in; Layer X assumes it full after
10 the tanks are pressed, AUDIT D13), ``residual_kg`` the sample at the last firing step,
11 ``used_kg`` their difference.
12* **Bottle gas temperature** is the vessel's own: CoolProp T at (rho = m / V, p) with V the
13 drawing's bottle volume. It is a state the twin integrates; this recovers it from the two it
14 records.
15* **Required mass to burnout** = used + what must stay in the bottle for the regulator still to
16 pass the burnout flow at the burnout outlet pressure. The floor at the regulator inlet is the
17 larger of (outlet + the drawing's ``min_inlet_differential``) and the inlet pressure at which the
18 wide-open seat's IEC 60534-2-1 capacity (``engine.layerx.diag.regulator.iec_gas_flow``) equals
19 the burnout flow. The bottle is taken on from its burnout state to that floor **adiabatically**:
20 for a rigid vessel with outflow only and no heat, the first law ``d(m u) = h dm`` with
21 ``v = V/m`` gives ``du = -p dv``, i.e. ``ds = 0`` for the gas left inside, so the bottle follows
22 its burnout isentrope ``T_b(p) = T(p, s(p_end, T_end))`` (CoolProp). The regulator inlet gas is
23 that bottle gas throttled isenthalpically through the bottle-to-regulator line (the twin's own
24 adiabatic-line walk), and the line's drop, recorded at burnout, is carried to the floor at the
25 same flow as a loss-coefficient drop ``K mdot^2 / (2 rho A^2)``: scaled by the inlet density at
26 burnout over that at the floor. The three are solved together (fixed point). ``unusable_kg`` is
27 CoolProp's density at the floor bottle state, times V; ``margin_kg`` = loaded - required =
28 residual - unusable. Holding the bottle at its burnout temperature and the line at its burnout
29 drop instead (``unusable_held_T_kg``) is the other bound: it is what a bottle wall that kept the
30 gas at its burnout temperature would give, and it understates the unusable mass (the LE4 He
31 bottle tracks its isentrope to ~2 % over the burn, ``bottle_T_end_isentropic_K``).
32* **Joule-Thomson** across the regulator is an isenthalpic throttle: the steady-flow energy
33 equation with no heat, no work and the kinetic-energy change neglected gives h_out = h_in, so
34 ``T_out = T(p_out, h(p_in, T_in))`` (CoolProp). Helium above its inversion temperature (~40 K)
35 warms on throttling; nitrogen at room temperature cools. ``jt_dT_K`` is computed on the
36 regulator's inlet node state and outlet node pressure; ``twin_dT_K`` is the twin's own walked
37 temperature change across the same branch, for comparison.
38"""
39
40from __future__ import annotations
41
42import math
43from typing import Any, Dict, Mapping, Optional
44
45import numpy as np
46
47from engine.layerx.diag.ladder import (
48 PSI, arr, firing_mask, inp, model_block, node_p, node_T, out, scalar, series_t, unavailable,
49)
51 DEFAULT_XT, _built, _provenance, coolprop_name, gas_state, iec_gas_flow, regulator_id,
52)
53
54COOLPROP_SOURCE = ("CoolProp (Bell, Wronski, Quoilin & Lemort, 'Pure and Pseudo-pure Fluid Thermophysical "
55 "Property Evaluation and the Open-Source Thermophysical Property Library CoolProp', "
56 "Ind. Eng. Chem. Res. 53(6), 2498-2508, 2014)")
57
58
59def _cp_version() -> str:
60 try:
61 import CoolProp
62
63 return str(CoolProp.__version__)
64 except Exception: # noqa: BLE001
65 return "?"
66
67
68def jt_temperature_change(fluid: str, p_in_pa: float, T_in_K: float, p_out_pa: float) -> float:
69 """Isenthalpic throttle from (p_in, T_in) to p_out: T_out - T_in [K] (CoolProp). NaN if out of
70 range."""
71 import CoolProp.CoolProp as CP
72
73 try:
74 name = coolprop_name(fluid)
75 h = CP.PropsSI("HMASS", "P", p_in_pa, "T", T_in_K, name)
76 return float(CP.PropsSI("T", "P", p_out_pa, "HMASS", h, name) - T_in_K)
77 except Exception: # noqa: BLE001
78 return math.nan
79
80
81def density(fluid: str, p_pa: float, T_K: float) -> float:
82 import CoolProp.CoolProp as CP
83
84 try:
85 return float(CP.PropsSI("D", "P", p_pa, "T", T_K, coolprop_name(fluid)))
86 except Exception: # noqa: BLE001
87 return math.nan
88
89
90def temperature_from_state(fluid: str, rho: float, p_pa: float) -> float:
91 import CoolProp.CoolProp as CP
92
93 try:
94 return float(CP.PropsSI("T", "D", rho, "P", p_pa, coolprop_name(fluid)))
95 except Exception: # noqa: BLE001
96 return math.nan
97
98
99def isentropic_temperature(fluid: str, p0_pa: float, T0_K: float, p_pa: float) -> float:
100 """T [K] at ``p`` on the isentrope through (p0, T0) (CoolProp); NaN out of range. The gas left
101 in a rigid vessel discharging with no heat follows it (first law: ``d(mu) = h dm`` -> ``ds = 0``)."""
102 import CoolProp.CoolProp as CP
103
104 try:
105 name = coolprop_name(fluid)
106 s0 = CP.PropsSI("SMASS", "P", p0_pa, "T", T0_K, name)
107 return float(CP.PropsSI("T", "P", p_pa, "SMASS", s0, name))
108 except Exception: # noqa: BLE001
109 return math.nan
110
111
112FLOOR_ITERS = 50
113FLOOR_TOL_PA = 1.0
114
115
116def bottle_floor(fluid: str, mdot: float, p_out_pa: float, cv: float, xT: float, min_differential_pa: float,
117 p_b_end_pa: float, T_b_end_K: float, p_in_end_pa: float, T_in_end_K: float,
118 line_drop_end_pa: float) -> Dict[str, float]:
119 """The bottle state at which the regulator, wide open, just passes ``mdot`` to ``p_out``.
120
121 Solves together (fixed point on the regulator inlet pressure):
122
123 * regulator inlet ``p_in`` = :func:`regulator_inlet_floor` at the inlet gas temperature ``T_in``;
124 * bottle ``p_b = p_in + dp_line``, ``dp_line = line_drop_end * rho_in_end / rho_in`` (a
125 loss-coefficient drop at the same mass flow scales as 1/rho);
126 * bottle ``T_b`` on the burnout isentrope (:func:`isentropic_temperature`);
127 * ``T_in = T(p_in, h(p_b, T_b))``: the bottle gas throttled through the adiabatic line.
128
129 Returns {p_b, T_b, p_in, T_in, line_drop, iterations, converged} in Pa and K.
130 """
131 import CoolProp.CoolProp as CP
132
133 name = coolprop_name(fluid)
134 rho_in_end = density(fluid, p_in_end_pa, T_in_end_K)
135 drop_end = max(line_drop_end_pa, 0.0)
136 T_in = T_in_end_K
137 p_in = math.nan
138 p_b = T_b = drop = math.nan
139 converged = False
140 it = 0
141 for it in range(1, FLOOR_ITERS + 1):
142 p_new = regulator_inlet_floor(fluid, mdot, p_out_pa, T_in, cv, xT, min_differential_pa)
143 if not math.isfinite(p_new):
144 break
145 rho_in = density(fluid, p_new, T_in)
146 drop = drop_end * rho_in_end / rho_in if (drop_end > 0 and rho_in > 0) else drop_end
147 p_b = p_new + drop
148 T_b = isentropic_temperature(fluid, p_b_end_pa, T_b_end_K, p_b)
149 try:
150 h_b = CP.PropsSI("HMASS", "P", p_b, "T", T_b, name)
151 T_in = float(CP.PropsSI("T", "P", p_new, "HMASS", h_b, name))
152 except Exception: # noqa: BLE001
153 break
154 done = math.isfinite(p_in) and abs(p_new - p_in) < FLOOR_TOL_PA
155 p_in = p_new
156 if done:
157 converged = True
158 break
159 return {"p_b": p_b, "T_b": T_b, "p_in": p_in, "T_in": T_in, "line_drop": drop,
160 "iterations": float(it), "converged": float(converged)}
161
162
163def regulator_inlet_floor(fluid: str, mdot: float, p_out_pa: float, T_in_K: float, cv: float,
164 xT: float = DEFAULT_XT, min_differential_pa: float = 0.0) -> float:
165 """Lowest regulator inlet pressure [Pa] at which the wide-open seat (IEC 60534-2-1) still
166 passes ``mdot`` to ``p_out``, and at least ``p_out + min_differential``."""
167 from scipy.optimize import brentq
168
169 base = p_out_pa + max(0.0, min_differential_pa)
170 if not (math.isfinite(mdot) and mdot > 0 and math.isfinite(cv) and cv > 0):
171 return base
172
173 def excess(p_in: float) -> float:
174 rho, gamma = gas_state(fluid, p_in, T_in_K)
175 return iec_gas_flow(p_in, p_out_pa, rho, gamma, cv, xT)["mdot"] - mdot
176
177 lo = p_out_pa * (1.0 + 1e-9)
178 if excess(lo) >= 0.0:
179 return base
180 hi = 2.0 * p_out_pa
181 while excess(hi) < 0.0:
182 hi *= 2.0
183 if hi > 1e10:
184 return math.nan
185 return max(base, float(brentq(excess, lo, hi, xtol=1.0, rtol=1e-12)))
186
187
188def _derived(prep: Any, result: Mapping[str, Any]) -> Mapping[str, Any]:
189 d = getattr(prep, "derived", None) if prep is not None else None
190 if d:
191 return d
192 return ((result.get("provenance") or {}).get("derived")) or {}
193
194
195def build_pressurant(result: Mapping[str, Any], prep: Any = None, config: Any = None, *,
196 gas: Optional[str] = None, volume_m3: Optional[float] = None,
197 regulator: Optional[Mapping[str, Any]] = None,
198 xT: Optional[float] = None) -> Dict[str, Any]:
199 """``diagnostics.pressurant``. Never raises.
200
201 ``gas`` and ``volume_m3`` default to the drawing's (``derived.pressurant_gas``,
202 ``derived.copv_volume_L``, from ``prep`` or the result's provenance). ``regulator`` is
203 ``diagnostics.regulator`` when already built (its Cv and x_T are reused). Needs
204 ``result["network"]`` only for the regulator's inlet state (JT, floor); without it, those
205 keys are null and the mass budget stands.
206 """
207 try:
208 series = result.get("series") or {}
209 t = series_t(result)
210 n = t.size
211 derived = _derived(prep, result)
212 fluid = gas or derived.get("pressurant_gas")
213 if not fluid:
214 return unavailable("the pressurant species is unknown")
215 vol = volume_m3 if volume_m3 is not None else (
216 float(derived["copv_volume_L"]) / 1e3 if derived.get("copv_volume_L") else None)
217 mass = arr(series.get("copv_mass_kg"), n)
218 p_b = arr(series.get("copv_psia"), n) * PSI
219 if not np.isfinite(mass).any():
220 return unavailable("series.copv_mass_kg is missing")
221 firing = firing_mask(result, n)
222 end = int(np.flatnonzero(firing)[-1]) if firing.any() else n - 1
223 start = 0
224 loaded = float(mass[start])
225 residual = float(mass[end])
226 used = loaded - residual
227
228 T_b = np.full(n, math.nan)
229 if vol:
230 for k in range(n):
231 if math.isfinite(mass[k] + p_b[k]) and mass[k] > 0:
232 T_b[k] = temperature_from_state(fluid, mass[k] / vol, p_b[k])
233
234 # The burn's own check on the adiabatic-bottle assumption the floor makes: the isentrope from
235 # the loaded state to the burnout pressure, against the twin's bottle temperature there.
236 T_end_isentropic = (isentropic_temperature(fluid, p_b[start], T_b[start], p_b[end])
237 if math.isfinite(T_b[start] + p_b[start] + p_b[end]) else math.nan)
238
239 net = result.get("network")
240 jt = np.full(n, math.nan)
241 twin_dT = np.full(n, math.nan)
242 floor = None
243 floor_reg_in = None
244 unusable = None
245 unusable_held = None
246 fl: Dict[str, float] = {}
247 reg_basis = "no network recorded: no regulator inlet state"
248 cv = x_t = None
249 line_drop = None
250 min_diff = 0.0
251 min_diff_prov = "assumed 0: no min_inlet_differential on the drawing"
252 if isinstance(net, Mapping) and net.get("branches"):
253 rid = (regulator or {}).get("id") or regulator_id(net)
254 if rid is not None:
255 br = net["branches"][rid]
256 p_in = node_p(net, br["from"], n) * PSI
257 p_out = node_p(net, br["to"], n) * PSI
258 T_in = node_T(net, br["from"], n)
259 T_out = node_T(net, br["to"], n)
260 for k in range(n):
261 if math.isfinite(p_in[k] + T_in[k] + p_out[k]):
262 jt[k] = jt_temperature_change(fluid, p_in[k], T_in[k], p_out[k])
263 twin_dT = T_out - T_in
264 reg_basis = f"regulator {rid}: inlet node {br['from']} state, outlet node {br['to']} pressure"
265 cv = (regulator or {}).get("cv") or br.get("cv")
266 x_t = xT if xT is not None else DEFAULT_XT
267 built = _built(prep)
268 if built is not None:
269 try:
270 comp = built.network.branches[rid].component
271 cv = cv or comp.p.get("Cv")
272 if "min_inlet_differential" in comp.p:
273 min_diff = float(comp.p["min_inlet_differential"])
274 min_diff_prov = _provenance(comp, "min_inlet_differential")
275 except Exception: # noqa: BLE001
276 pass
277 if regulator and regulator.get("model"):
278 x_t = xT if xT is not None else float(
279 ((regulator["model"].get("inputs") or {}).get("xT") or {}).get("value") or DEFAULT_XT)
280 mdot_end = float(arr(br.get("mdot"), n)[end])
281 # bottle node -> regulator inlet node, the line(s) between them, at burnout
282 path = (net.get("paths") or {}).get("ox") or (net.get("paths") or {}).get("fuel") or []
283 bottle_node = net["branches"][path[0]]["from"] if path else br["from"]
284 line_drop = float((node_p(net, bottle_node, n) * PSI - p_in)[end])
285 if cv and math.isfinite(T_in[end]) and math.isfinite(p_out[end]):
286 # the other bound: bottle held at its burnout temperature, line at its burnout drop
287 held_in = regulator_inlet_floor(fluid, mdot_end, p_out[end], T_in[end], float(cv), x_t, min_diff)
288 if vol and math.isfinite(T_b[end]) and math.isfinite(held_in):
289 unusable_held = density(fluid, held_in + max(line_drop, 0.0), T_b[end]) * vol
290 if math.isfinite(T_b[end] + p_b[end]):
291 fl = bottle_floor(fluid, mdot_end, p_out[end], float(cv), x_t, min_diff, p_b[end], T_b[end],
292 p_in[end], T_in[end], line_drop)
293 if math.isfinite(fl["p_b"] + fl["T_b"]):
294 floor_reg_in = fl["p_in"]
295 floor = fl["p_b"]
296 if vol:
297 unusable = density(fluid, floor, fl["T_b"]) * vol
298 required = used + unusable if unusable is not None else None
299 margin = loaded - required if required is not None else None
300 live = firing & np.isfinite(jt)
301 model = model_block(
302 "pressurant budget + isenthalpic regulator throttle",
303 COOLPROP_SOURCE + f", version {_cp_version()}; steady-flow energy equation (adiabatic, no work, "
304 "kinetic energy neglected: h_out = h_in); first law for a rigid vessel discharging with no heat "
305 "(d(mu) = h dm -> ds = 0 for the gas left inside); IEC 60534-2-1:2011 for the seat capacity floor",
306 [
307 "mass from the bottle's vessel state; loaded = first recorded sample (bottle assumed full "
308 "after the tanks were pressed, AUDIT D13), residual = last firing step",
309 "bottle gas temperature = CoolProp T(rho = m/V, p) of the vessel state",
310 "required = used + the mass left when the bottle reaches the floor, the bottle taken on from "
311 "its burnout state along its isentrope (no wall heat after burnout: the conservative bound; "
312 "unusable_held_T_kg holds the burnout temperature and line drop instead)",
313 "floor at the regulator inlet = max(regulator outlet at burnout + min_inlet_differential, inlet "
314 "at which the wide-open seat's IEC capacity equals the burnout flow), at the inlet temperature "
315 "of the bottle gas throttled isenthalpically through the bottle-to-regulator line",
316 "bottle-to-regulator line drop carried from burnout to the floor at the same flow as a "
317 "loss-coefficient drop (scales as 1/rho at the regulator inlet; friction factor held)",
318 "JT: regulator inlet node (p, T) to outlet node p at constant enthalpy; no heat exchange "
319 "in the regulator body",
320 ],
321 {
322 "gas": inp(fluid, "", "drawing (KBOTTLE fluid)"),
323 "bottle_volume": inp(vol * 1e3 if vol else None, "L", "drawing (KBOTTLE volume)"),
324 "regulator_Cv": inp(scalar(cv) if cv else None, "US gpm/psi^0.5", "drawing (regulator Cv)"),
325 "regulator_xT": inp(x_t, "-", "restated for this diagnostic" if xT is not None else
326 "assumed: unpublished; feedtwin Valve default (AUDIT 9.6 C3)"),
327 "min_inlet_differential": inp(min_diff / PSI, "psi", min_diff_prov),
328 },
329 )
330 return {
331 "species": str(fluid),
332 "t": out(t),
333 "loaded_kg": scalar(loaded),
334 "used_kg": scalar(used),
335 "residual_kg": scalar(residual),
336 "required_kg": scalar(required) if required is not None else None,
337 "margin_kg": scalar(margin) if margin is not None else None,
338 "unusable_kg": scalar(unusable) if unusable is not None else None,
339 "unusable_held_T_kg": scalar(unusable_held) if unusable_held is not None else None,
340 "floor_psia": scalar(floor / PSI) if floor is not None else None,
341 "floor_regulator_inlet_psia": scalar(floor_reg_in / PSI) if floor_reg_in is not None else None,
342 "floor_bottle_T_K": scalar(fl.get("T_b")) if fl else None,
343 "floor_regulator_inlet_T_K": scalar(fl.get("T_in")) if fl else None,
344 "floor_line_drop_psi": scalar(fl["line_drop"] / PSI) if fl and math.isfinite(fl["line_drop"]) else None,
345 "floor_converged": bool(fl.get("converged")) if fl else None,
346 "bottle_end_psia": scalar(p_b[end] / PSI),
347 "bottle_T_end_isentropic_K": scalar(T_end_isentropic),
348 "margin_psi": scalar((p_b[end] - floor) / PSI) if floor is not None else None,
349 "line_drop_end_psi": scalar(line_drop / PSI) if line_drop is not None else None,
350 "t_end": scalar(t[end]),
351 "bottle_T_K": out(T_b),
352 "jt_dT_K": out(jt),
353 "twin_dT_K": out(twin_dT),
354 "jt_dT_range_K": ([scalar(np.nanmin(jt[live])), scalar(np.nanmax(jt[live]))] if live.any() else None),
355 "jt_basis": reg_basis,
356 "model": model,
357 }
358 except Exception as exc: # noqa: BLE001
359 return unavailable(f"{type(exc).__name__}: {exc}")
#define max(a, b)
Definition Arduino.h:43
Dict[str, Any] build_pressurant(Mapping[str, Any] result, Any prep=None, Any config=None, *, Optional[str] gas=None, Optional[float] volume_m3=None, Optional[Mapping[str, Any]] regulator=None, Optional[float] xT=None)
float jt_temperature_change(str fluid, float p_in_pa, float T_in_K, float p_out_pa)
Definition pressurant.py:68
float temperature_from_state(str fluid, float rho, float p_pa)
Definition pressurant.py:90
float isentropic_temperature(str fluid, float p0_pa, float T0_K, float p_pa)
Definition pressurant.py:99
float regulator_inlet_floor(str fluid, float mdot, float p_out_pa, float T_in_K, float cv, float xT=DEFAULT_XT, float min_differential_pa=0.0)
float density(str fluid, float p_pa, float T_K)
Definition pressurant.py:81
Dict[str, float] bottle_floor(str fluid, float mdot, float p_out_pa, float cv, float xT, float min_differential_pa, float p_b_end_pa, float T_b_end_K, float p_in_end_pa, float T_in_end_K, float line_drop_end_pa)
Mapping[str, Any] _derived(Any prep, Mapping[str, Any] result)