45from typing
import Any, Callable, Dict, List, Mapping, Optional, Sequence
50 PSI, SIDES, feed_line, finite, inp, linear_characteristic, manifold_volumes, model_block, unavailable,
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)
107 hist_t: List[float] = []
108 hist_m: List[float] = []
111 up = math.inf
if s.wet
else s.rho * s.V_up
116 if t > t_stop + horizon_s:
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
122 want = s.mdot0 * s.closing_fraction(x) * dt
123 through_seat =
min(want, up)
127 from_dn =
min(want - through_seat, dn)
129 step = through_seat + from_dn
132 step =
min(s.mdot0 * dt, dn)
140 if (shut
or up <= 0.0)
and dn <= 0.0:
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}
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
155 mode, alone_side =
"LOX-rich",
"ox"
157 mode, alone_side =
"fuel-rich",
"fuel"
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)
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"]
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,
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."""
204 except Exception
as exc:
205 return unavailable(f
"shutdown: {exc}")
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]
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 []:
223 onset = finite(row.get(
"ingestion_onset_s"))
224 if k
and onset
is not None:
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)
233 t0 = float(settings.cutoff_s)
234 basis =
"timed cutoff with both tanks wet"
236 raise ValueError(
"no depletion time on this run")
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:
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
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
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)
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:
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")
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),
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.",
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",
317 note =
_note(cls[
"mode"], cls)
320 "first_dry":
None if cutoff
else first_dry,
321 "first_dry_s":
None if cutoff
else t_dry,
322 "first_dry_basis": basis,
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,
331 "other_kg_at_first_dry": resid,
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"],
Dict[str, Any] _shutdown_from_run(Any prep, Mapping[str, Any] result, Any config, ShutdownSettings settings, Optional[Sequence[Mapping[str, Any]]] outflow)
Dict[str, Any] tail_off(TailSide a, TailSide b, *, float t0, float t_cmd, float dt=1.0e-5, float horizon_s=2.0)
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)