STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
shutdown.py
Go to the documentation of this file.
1"""Layer X shutdown: which tank runs dry first, and what the tail-off feeds the chamber.
2
3``shutdown_from_run(prep, result, config=None, settings=ShutdownSettings(), outflow=None)``
4-> ``diagnostics.shutdown`` (DATA-CONTRACT 3). ``tail_off(a, b, ...)`` is the model on plain inputs.
5
6The main burn ends on depletion (``end_on_depletion``): the first tank reaches the dry mass and
7the twin stops. It has no tail. This module takes that instant (or, when ``diag.outflow`` says
8the outlet starts drawing gas earlier, that onset; or a timed cutoff) and follows each side's
9**liquid** into the chamber from there, so the run says whether the engine shuts down
10oxidiser-rich or fuel-rich, and how much of one propellant arrives with none of the other.
11
12The model (a bounding timeline, every rule stated in the block)
13---------------------------------------------------------------
14* **The dry side.** Its tank is empty, but the line from the tank outlet to the orifices is full
15 of liquid. The pressurant pushes that column through at the pre-dry flow (the ullage is still
16 at tank pressure; gas behind a liquid slug). Its liquid ends when the line's contents
17 ``rho (V_up + V_dn)`` have gone, or, once the main valve is shut, when the liquid that was
18 downstream of the seat has gone.
19* **The wet side** keeps flowing at its pre-dry rate until its main valve shuts.
20* **The valves** are commanded shut ``delay_s`` after the first dry-out (default 0: commanded at
21 it; the stand's actual cutoff logic is not on the drawing) and close over the drawing's travel
22 time along the valve's own characteristic. Through the closing valve the flow is quasi-steady
23 at the pre-shutdown driving pressure: ``m = m0 sqrt(R0 / (R_rest + R_valve(1)/f(x)^2))``,
24 ``R0 = R_rest + R_valve(1)``, with ``R_rest`` the line and injector losses at the pre-shutdown
25 point (the line's own time constant, 1-7 ms, is short against 50 ms of travel; AUDIT 9.5 §1).
26* **After the seat shuts** the liquid downstream of it (tube, manifold, orifice passages) drains
27 into the chamber. Its **mass** is exact; its **rate** is taken as the pre-shutdown flow, the
28 fastest it can go, so the duration is a lower bound (the real drain decays with the chamber
29 pressure, and LOX at ambient pressure flashes).
30* **Mode**: the side whose liquid ends last is the one entering the chamber alone at the end:
31 **LOX-rich** if LOX, **fuel-rich** if fuel. ``tail_mr_max`` is the O/F of everything that enters
32 from the first dry-out to the end of flow (cumulative; once one side has stopped the
33 instantaneous O/F is unbounded, so the cumulative figure and ``alone_kg``/``alone_s`` carry it).
34
35A LOX-rich tail feeds oxygen with no fuel into a chamber whose walls are at their hottest: the
36copper injector face and the graphite throat insert are both attacked by oxidiser-rich gas (the
37replay's own graphite model counts O2 and O among the oxidisers, AUDIT 5.1). That is a graded note,
38not a computed recession; no face thermal model exists (AUDIT 7 item 8).
39"""
40
41from __future__ import annotations
42
43import math
44from dataclasses import dataclass, field
45from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence
46
47import numpy as np
48
49from engine.layerx.diag.start import (
50 PSI, SIDES, feed_line, finite, inp, linear_characteristic, manifold_volumes, model_block, unavailable,
51)
52
53MODEL_NAME = "shutdown_tail_timeline"
54
55
56@dataclass(frozen=True)
58 key: str
59 """``ox`` or ``fuel``."""
60 rho: float
61 mdot0: float
62 """Flow just before the first dry-out (or the cutoff) [kg/s]."""
63 V_up: float
64 """Liquid volume tank outlet -> main valve seat [m^3]."""
65 V_dn: float
66 """Liquid volume seat -> orifices: tube, manifold, passages [m^3]."""
67 travel_s: float
68 R_valve_open: float = 0.0
69 R_rest: float = 0.0
70 wet: bool = True
71 """The tank still holds liquid (False: this is the side that ran dry)."""
72 cv_fraction: Callable[[float], float] = linear_characteristic
73 inputs: Dict[str, Dict[str, Any]] = field(default_factory=dict)
74
75 def closing_fraction(self, x: float) -> float:
76 """Flow through the valve at travel ``x`` over the full-open flow (quasi-steady)."""
77 if x >= 1.0:
78 return 1.0
79 f = self.cv_fraction(max(x, 0.0))
80 if f <= 0.0:
81 return 0.0
82 r0 = self.R_rest + self.R_valve_open
83 if r0 <= 0.0:
84 return 1.0
85 return math.sqrt(r0 / (self.R_rest + self.R_valve_open / (f * f)))
86
87
88@dataclass(frozen=True)
90 delay_s: float = 0.0
91 """First dry-out (or cutoff) to the main valves' shut command [s]. 0 = commanded at it."""
92 cutoff_s: Optional[float] = None
93 """A timed cutoff [s, Fire = 0] before depletion: both tanks wet. None = burn to depletion."""
94 tie_frac: float = 0.01
95 """The other tank's residual under this fraction of its load reads as a tie."""
96 dt: float = 1.0e-5
97
98
99def tail_off(a: TailSide, b: TailSide, *, t0: float, t_cmd: float, dt: float = 1.0e-5,
100 horizon_s: float = 2.0) -> Dict[str, Any]:
101 """Each side's liquid flow into the chamber from ``t0`` (the first dry-out) with the valves
102 commanded shut at ``t_cmd``. Returns per side the flow history, the mass delivered after
103 ``t0`` and when its liquid ended."""
104 out: Dict[str, Any] = {"t0": t0, "t_cmd": t_cmd, "sides": {}}
105 t_stop = t_cmd + max(a.travel_s, b.travel_s)
106 for s in (a, b):
107 hist_t: List[float] = []
108 hist_m: List[float] = []
109 delivered = 0.0
110 t_end = None
111 up = math.inf if s.wet else s.rho * s.V_up # liquid upstream of the seat [kg]
112 dn = s.rho * s.V_dn # liquid downstream of the seat [kg]
113 n = 0
114 while True:
115 t = t0 + n * dt
116 if t > t_stop + horizon_s:
117 break
118 tm = t + 0.5 * dt # the step [t, t + dt) at its midpoint
119 x = 1.0 if tm <= t_cmd else (0.0 if s.travel_s <= 0.0 else 1.0 - (tm - t_cmd) / s.travel_s)
120 shut = tm > t_cmd and x <= 0.0
121 if not shut:
122 want = s.mdot0 * s.closing_fraction(x) * dt
123 through_seat = min(want, up) # liquid crossing the seat
124 up -= through_seat
125 # Liquid in = liquid out while the column is whole; once the gas front has crossed
126 # the seat the pressurant pushes the downstream column out at the same rate.
127 from_dn = min(want - through_seat, dn)
128 dn -= from_dn
129 step = through_seat + from_dn
130 else:
131 # Seat shut: the liquid downstream of it drains, at most at the pre-shutdown rate.
132 step = min(s.mdot0 * dt, dn)
133 dn -= step
134 m = step / dt
135 hist_t.append(t)
136 hist_m.append(m)
137 delivered += step
138 if m > 0.0:
139 t_end = t + dt
140 if (shut or up <= 0.0) and dn <= 0.0:
141 break
142 n += 1
143 out["sides"][s.key] = {"t": hist_t, "mdot": hist_m, "delivered_kg": delivered,
144 "t_end": t_end if t_end is not None else t0}
145 return out
146
147
148def classify(tail: Mapping[str, Any], *, end_tol_s: float = 1.0e-3) -> Dict[str, Any]:
149 """Mode, the propellant alone at the end, LOX alone, and the cumulative tail O/F."""
150 ox, fu = tail["sides"]["ox"], tail["sides"]["fuel"]
151 t_ox, t_fu = ox["t_end"], fu["t_end"]
152 if abs(t_ox - t_fu) <= end_tol_s:
153 mode, alone_side = "simultaneous", None
154 elif t_ox > t_fu:
155 mode, alone_side = "LOX-rich", "ox"
156 else:
157 mode, alone_side = "fuel-rich", "fuel"
158
159 def mass_after(side: Mapping[str, Any], t_from: float) -> float:
160 dt = (side["t"][1] - side["t"][0]) if len(side["t"]) > 1 else 0.0
161 return sum(m * dt for t, m in zip(side["t"], side["mdot"]) if t >= t_from - 1e-12)
162
163 alone_kg, alone_s = 0.0, 0.0
164 if alone_side == "ox":
165 alone_kg, alone_s = mass_after(ox, t_fu), t_ox - t_fu
166 elif alone_side == "fuel":
167 alone_kg, alone_s = mass_after(fu, t_ox), t_fu - t_ox
168 m_ox, m_fu = ox["delivered_kg"], fu["delivered_kg"]
169 return {
170 "mode": mode, "alone_side": alone_side, "alone_kg": alone_kg, "alone_s": alone_s,
171 "lox_alone_kg": alone_kg if alone_side == "ox" else 0.0,
172 "lox_alone_s": alone_s if alone_side == "ox" else 0.0,
173 "tail_ox_kg": m_ox, "tail_fuel_kg": m_fu,
174 "tail_mr_max": (m_ox / m_fu) if m_fu > 0 else None,
175 "end_ox_s": t_ox, "end_fuel_s": t_fu,
176 }
177
178
179def _note(mode: str, cls: Mapping[str, Any]) -> Dict[str, str]:
180 if mode == "LOX-rich":
181 return {"grade": "warn", "text": (
182 f"LOX-rich tail-off: {1e3 * cls['alone_kg']:.0f} g of LOX enters the hot chamber with no fuel over at "
183 f"least {1e3 * cls['alone_s']:.0f} ms. Oxidiser-rich gas attacks the copper injector face and oxidises "
184 "the graphite throat insert; a fuel-rich shutdown (fuel valve closing last, or less fuel-side volume "
185 "downstream of the LOX seat) avoids it.")}
186 if mode == "fuel-rich":
187 return {"grade": "info", "text": (
188 f"Fuel-rich tail-off: fuel flows alone for at least {1e3 * cls['alone_s']:.0f} ms "
189 f"({1e3 * cls['alone_kg']:.0f} g) after the LOX stops; the face and throat see fuel-rich gas.")}
190 return {"grade": "info", "text": "Both propellants stop within 1 ms of each other."}
191
192
193def _side_key(side: Optional[str]) -> Optional[str]:
194 return {"oxidiser": "ox", "oxidizer": "ox", "ox": "ox", "fuel": "fuel"}.get(str(side)) if side else None
195
196
197def shutdown_from_run(prep: Any, result: Mapping[str, Any], config: Any = None,
198 settings: ShutdownSettings = ShutdownSettings(),
199 outflow: Optional[Sequence[Mapping[str, Any]]] = None) -> Dict[str, Any]:
200 """``diagnostics.shutdown`` for a finished run. ``outflow`` is ``diagnostics.outflow`` when it
201 was computed: an earlier gas-ingestion onset is then the first dry-out. Never raises."""
202 try:
203 return _shutdown_from_run(prep, result, config, settings, outflow)
204 except Exception as exc: # noqa: BLE001
205 return unavailable(f"shutdown: {exc}")
206
207
208def _shutdown_from_run(prep: Any, result: Mapping[str, Any], config: Any, settings: ShutdownSettings,
209 outflow: Optional[Sequence[Mapping[str, Any]]]) -> Dict[str, Any]:
210 series = result["series"]
211 summary = result.get("summary") or {}
212 t = [float(x) for x in series["t"]]
213 firing = [bool(f) for f in series.get("firing") or []]
214 fire_idx = [i for i, f in enumerate(firing) if f]
215 if not fire_idx:
216 raise ValueError("the burn never fired")
217 first_dry = _side_key(summary.get("depleted_side"))
218 t_dry = finite(summary.get("depletion_s")) or finite(summary.get("burn_time_s"))
219 basis = "the twin's depletion (dry mass reached)"
220 ingest: Dict[str, float] = {}
221 for row in outflow or []:
222 k = _side_key(row.get("side"))
223 onset = finite(row.get("ingestion_onset_s"))
224 if k and onset is not None:
225 ingest[k] = onset
226 if ingest:
227 k_first = min(ingest, key=lambda k: ingest[k])
228 if t_dry is None or ingest[k_first] < t_dry:
229 first_dry, t_dry = k_first, ingest[k_first]
230 basis = "gas-ingestion onset at the tank outlet (diag.outflow, Lubin & Springer)"
231 cutoff = settings.cutoff_s is not None and (t_dry is None or settings.cutoff_s < t_dry)
232 if cutoff:
233 t0 = float(settings.cutoff_s) # type: ignore[arg-type]
234 basis = "timed cutoff with both tanks wet"
235 elif t_dry is None:
236 raise ValueError("no depletion time on this run")
237 else:
238 t0 = t_dry
239 # The step at or before t0 for the pre-shutdown flows.
240 i0 = max([i for i in fire_idx if t[i] <= t0 + 1e-9] or [fire_idx[0]])
241 if config is None and prep.link is not None and getattr(prep.link, "sampler", None) is not None:
242 config = prep.link.sampler.config
243 man = manifold_volumes(config) if config is not None else None
244 inputs: Dict[str, Dict[str, Any]] = {}
245 sides: Dict[str, TailSide] = {}
246 warnings: List[str] = []
247 pc = float(series["chamber"]["pc_psia"][i0]) * PSI
248 for side, key in SIDES:
249 sub = series[key]
250 m0 = float(sub["mdot"][i0])
251 p_tank = float(sub["tank_psia"][i0]) * PSI
252 T = float(sub["liquid_K"][i0])
253 feed = feed_line(prep, side, mdot_ref=m0, p_ref=p_tank, T_ref=T)
254 if feed.valve is None:
255 raise ValueError(f"{side}: no main valve with a travel time on the drawn path")
256 warnings.extend(feed.warnings)
257 p_in = float(sub["inlet_psia"][i0]) * PSI
258 phi = m0 / math.sqrt(max(p_in - pc, 1.0))
259 v_man = man[key]["V_m3"] if man else 0.0
260 if man is None:
261 warnings.append("no engine config: manifold volume taken as 0")
262 V_up = feed.sum("up", "volume")
263 V_dn = feed.sum("dn", "volume") + v_man
264 wet = cutoff or key != first_dry
265 sides[key] = TailSide(
266 key=key, rho=feed.rho, mdot0=m0, V_up=V_up, V_dn=V_dn, travel_s=float(feed.valve.travel_s or 0.0),
267 R_valve_open=feed.valve.R, R_rest=feed.sum("up", "R") + feed.sum("dn", "R") + 1.0 / phi ** 2,
268 wet=wet, cv_fraction=feed.valve.cv_fraction or linear_characteristic)
269 inputs[f"{key}.mdot0"] = inp(m0, "kg/s", f"main burn series at t = {t[i0]:.3f} s (last step before the first dry-out)")
270 inputs[f"{key}.V_up"] = inp(V_up * 1e3, "L", "drawn tube volume, tank outlet to the main valve")
271 inputs[f"{key}.V_dn"] = inp(V_dn * 1e3, "L", "drawn tube volume after the main valve + injector ring and passages "
272 "(config layout); valve body not drawn")
273 inputs[f"{key}.rho"] = inp(feed.rho, "kg/m^3", f"feedtwin Fluid('{feed.fluid}') at the pre-shutdown tank state")
274 inputs[f"{key}.travel_s"] = inp(sides[key].travel_s, "s", "drawing main-valve travel_time")
275 for e in (feed.valve,):
276 inputs.update(e.inputs)
277 t_cmd = t0 + settings.delay_s
278 tail = tail_off(sides["ox"], sides["fuel"], t0=t0, t_cmd=t_cmd, dt=settings.dt)
279 cls = classify(tail)
280 # A tie: the other tank holds a small fraction of its load when the first runs dry.
281 tie = False
282 resid: Optional[float] = None
283 if not cutoff and first_dry:
284 other = "fuel" if first_dry == "ox" else "ox"
285 load = finite((summary.get(other) or {}).get("loaded_kg"))
286 liq = [float(x) for x in series[other]["liquid_kg"]]
287 resid = float(np.interp(t0, t, liq))
288 if load and resid < settings.tie_frac * load:
289 tie = True
290 warnings.append(f"near tie: the {other} tank holds {resid:.3f} kg ({100 * resid / load:.2f} % of its load) "
291 "when the first runs dry; a small O/F or head change flips the order")
292 inputs.update({
293 "delay_s": inp(settings.delay_s, "s", "assumed 0: valves commanded at the first dry-out; the stand's cutoff "
294 "detection is not on the drawing. Unmeasured"),
295 "cutoff_s": inp(settings.cutoff_s, "s", "setting: a timed cutoff before depletion (None = burn to depletion)"),
296 "t0": inp(t0, "s", basis),
297 })
298 model = model_block(
299 MODEL_NAME,
300 "Bounding tail-off timeline: the dry side's line contents pushed through by the pressurant, the wet side "
301 "at its pre-dry flow, quasi-steady flow through the closing main valves (drawing travel and Cv "
302 "characteristic), then the liquid downstream of each seat drained at the pre-shutdown rate.",
303 [
304 "the first dry-out is the twin's depletion, or the earlier gas-ingestion onset from diag.outflow",
305 "the dry side's line contents are pushed through at the pre-dry flow (ullage still at tank pressure)",
306 "the wet side keeps its pre-dry flow although the chamber pressure falls once the other side stops "
307 "(its injector drop rises, up to sqrt((p_tank - p_a)/(p_tank - Pc)) in flow), so alone_kg is a lower bound",
308 "valves commanded shut delay_s after the first dry-out (default 0) and closing along their drawn travel",
309 "flow through a closing valve is quasi-steady at the pre-shutdown driving pressure",
310 "the liquid downstream of a shut seat drains at the pre-shutdown rate: its mass is exact, its duration a "
311 "lower bound (the real drain decays with Pc; LOX at ambient flashes)",
312 "the side whose liquid ends last enters the chamber alone; tail_mr_max is the cumulative O/F after the "
313 "first dry-out (the instantaneous O/F is unbounded once one side stops)",
314 "the attack on the copper face and graphite throat is qualitative: no face thermal or oxidation model",
315 ],
316 inputs)
317 note = _note(cls["mode"], cls)
318 return {
319 "available": True,
320 "first_dry": None if cutoff else first_dry,
321 "first_dry_s": None if cutoff else t_dry,
322 "first_dry_basis": basis,
323 "mode": cls["mode"],
324 "tail_mr_max": cls["tail_mr_max"],
325 "alone_side": cls["alone_side"], "alone_kg": cls["alone_kg"], "alone_s": cls["alone_s"],
326 "lox_alone_kg": cls["lox_alone_kg"], "lox_alone_s": cls["lox_alone_s"],
327 "tail_ox_kg": cls["tail_ox_kg"], "tail_fuel_kg": cls["tail_fuel_kg"],
328 "end_ox_s": cls["end_ox_s"], "end_fuel_s": cls["end_fuel_s"],
329 "valve_command_s": t_cmd,
330 "tie": tie,
331 "other_kg_at_first_dry": resid,
332 "note": note,
333 "warnings": warnings,
334 "unmeasured": ["delay_s (cutoff detection / sequence)",
335 f"main valve travel ({max(sides['ox'].travel_s, sides['fuel'].travel_s):g} s, no test cited)",
336 "valve body volume downstream of the seat", "injector feed-port and back-passage volume"],
337 "model": model,
338 }
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
float closing_fraction(self, float x)
Definition shutdown.py:75
Dict[str, Any] _shutdown_from_run(Any prep, Mapping[str, Any] result, Any config, ShutdownSettings settings, Optional[Sequence[Mapping[str, Any]]] outflow)
Definition shutdown.py:209
Dict[str, str] _note(str mode, Mapping[str, Any] cls)
Definition shutdown.py:179
Dict[str, Any] tail_off(TailSide a, TailSide b, *, float t0, float t_cmd, float dt=1.0e-5, float horizon_s=2.0)
Definition shutdown.py:100
Optional[str] _side_key(Optional[str] side)
Definition shutdown.py:193
Dict[str, Any] shutdown_from_run(Any prep, Mapping[str, Any] result, Any config=None, ShutdownSettings settings=ShutdownSettings(), Optional[Sequence[Mapping[str, Any]]] outflow=None)
Definition shutdown.py:199
Dict[str, Any] classify(Mapping[str, Any] tail, *, float end_tol_s=1.0e-3)
Definition shutdown.py:148