STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
replay.py
Go to the documentation of this file.
1"""Phase 3: EngineDesign replays the twin's burn, with the chamber eroding as it goes.
2
3The engine card (phase 2) is EngineDesign's injector and chamber at the geometry they were
4built with. A real burn does not keep that geometry. The graphite throat and the ablative liner
5recede, and on the 6.8 kN engine EngineDesign's coupled solver puts the throat ~5-6 % larger in
6area by burnout. At the same line pressures that is ~3 % more flow and ~2.5 % less chamber
7pressure late in the burn: the card alone cannot see it, and the feed system feels it, because
8more flow drains the tanks faster and droops the regulator harder.
9
10So the burn is closed in two passes that are iterated to agreement:
11
12pass 1, the twin
13 The feed system burns against the card. The card's chamber divides by the throat area,
14 so the throat history from the previous replay is applied step by step (the first pass
15 uses the as-built throat).
16pass 2, EngineDesign
17 The coupled time-varying solver (``TimeVaryingCoupledSolver``) runs at the line-exit
18 pressures the twin delivered, with ablative and graphite recession integrated through
19 the burn. The config is the line-exit copy (``engine/layerx/card.py``), so its "tank"
20 pressures are exactly the twin's injector-face pressures.
21
22Iterate until the throat history stops moving, which is usually two passes. The feed-system
23answers (tank pressures, depletion time) are the twin's last pass. The engine answers (chamber
24pressure, flow, thrust, Isp, recession) are EngineDesign's replay of that pass. Where the two
25overlap, flow at the same inlet pressures, the disagreement is reported as the agreement, so
26the hand-off between them is checked rather than assumed.
27"""
28
29from __future__ import annotations
30
31import math
32from typing import Any, Dict, List, Optional, Sequence
33
34import numpy as np
35
36from engine.layerx.prepare import PSI, Prepared
37
38#: Replay points across the firing window. Thrust and recession are smooth after the first
39#: tenth of a second, so this is resolution to spare. Each point costs ~0.1 s.
40REPLAY_POINTS = 28
41#: The agreement loop stops when the applied throat history moved less than this (relative).
42THROAT_TOLERANCE = 2.0e-4
43#: Passes allowed before the loop reports that it did not settle.
44MAX_PASSES = 4
45
46
47def replay_indices(firing: Sequence[bool], n: int = REPLAY_POINTS) -> List[int]:
48 """Indices of firing steps to replay: evenly spread, first and last always included."""
49 fire = [i for i, f in enumerate(firing) if f]
50 if len(fire) <= n:
51 return fire
52 picks = [fire[round(k * (len(fire) - 1) / (n - 1))] for k in range(n)]
53 return sorted(dict.fromkeys(picks))
54
55
56def erodes(config: Any) -> bool:
57 """True when the engine has a wall that recedes: a graphite throat insert or an ablative liner.
58 Such an engine is replayed on EngineDesign's coupled time-varying solver, and only on it."""
59 gr = getattr(config, "graphite_insert", None)
60 abl = getattr(config, "ablative_cooling", None)
61 return bool((gr is not None and getattr(gr, "enabled", False)) or (abl is not None and getattr(abl, "enabled", False)))
62
63
64def _number(v: Any, scale: float = 1.0) -> Optional[float]:
65 try:
66 f = float(v)
67 except (TypeError, ValueError):
68 return None
69 return f * scale if math.isfinite(f) else None
70
71
72def _dig(d: Any, path: str) -> Any:
73 for part in path.split("."):
74 if not isinstance(d, dict):
75 return None
76 d = d.get(part)
77 return d
78
79
80#: Per-step scalars the chamber solve computes and the replay used to drop (audit 5.2), as
81#: ``key -> (dotted path in the step's diagnostics, scale)``. Missing on an injector type that does
82#: not compute one: the column is then all None.
83DIAGNOSTIC_COLUMNS = {
84 "momentum_ratio": ("momentum_ratio_R", 1.0),
85 "rupe_M": ("rupe_M", 1.0),
86 "rupe_Em": ("cstar_efficiency.rupe_Em", 1.0),
87 "eta_vap": ("cstar_efficiency.eta_vaporization", 1.0),
88 "eta_mix": ("cstar_efficiency.eta_mixing", 1.0),
89 "eta_heat_loss": ("cstar_efficiency.eta_heat_loss", 1.0),
90 "kappa": ("stagnation_loss_kappa", 1.0),
91 "Cd_O": ("Cd_O", 1.0),
92 "Cd_F": ("Cd_F", 1.0),
93 "Cd_eff_O": ("Cd_eff_manifold_O", 1.0),
94 "Cd_eff_F": ("Cd_eff_manifold_F", 1.0),
95 "smd_O_um": ("D32_O", 1e6),
96 "smd_F_um": ("D32_F", 1e6),
97 "v_O_m_s": ("u_O", 1.0),
98 "v_F_m_s": ("u_F", 1.0),
99 "dp_injector_O_psi": ("delta_p_injector_O", 1.0 / PSI),
100 "dp_injector_F_psi": ("delta_p_injector_F", 1.0 / PSI),
101}
102
103#: Per-step arrays of the coupled solver's results the replay used to drop: ``key -> (results key,
104#: scale)``. Wall temperatures on LE4 are adiabatic-backed (no case declared): see
105#: ``engine/layerx/diag/hardware.py`` for how they are labelled.
106RESULT_COLUMNS = {
107 "M_exit": ("M_exit", 1.0),
108 "D_throat_mm": ("D_throat", 1e3),
109 "D_chamber_mm": ("D_chamber", 1e3),
110 "V_chamber_m3": ("V_chamber", 1.0),
111 "A_exit_m2": ("A_exit", 1.0),
112 "contraction_ratio": ("contraction_ratio", 1.0),
113 "cstar_ideal": ("cstar_ideal", 1.0),
114 "chug_frequency_hz": ("chugging_frequency", 1.0),
115 "T_graphite_back_K": ("T_graphite_back", 1.0),
116 "T_bondline_K": ("T_bondline", 1.0),
117 "T_bondline_peak_K": ("T_bondline_peak", 1.0),
118 "char_depth_chamber_mm": ("char_depth_chamber", 1e3),
119 "recession_liner_peak_mm": ("recession_liner_peak", 1e3),
120 "x_liner_peak_mm": ("x_liner_peak", 1e3),
121 "q_conv_throat_MW_m2": ("q_conv_throat", 1e-6),
122 "q_rad_throat_MW_m2": ("q_rad_throat", 1e-6),
123 "q_chem_throat_MW_m2": ("q_chem_throat", 1e-6),
124 "q_conv_chamber_MW_m2": ("q_conv_chamber", 1e-6),
125 "q_rad_chamber_MW_m2": ("q_rad_chamber", 1e-6),
126 "graphite_recession_rate_mm_s": ("graphite_recession_rate", 1e3),
127 "liner_recession_rate_mm_s": ("ablative_recession_rate", 1e3),
128}
129
130#: Per-station fields kept from the coupled solver's ``stations`` read-out: ``key -> (field, scale)``.
131STATION_FIELDS = {
132 "recession_mm": ("receded", 1e3),
133 "remaining_mm": ("remaining", 1e3),
134 "T_surface_K": ("T_surface", 1.0),
135 "T_back_K": ("T_back", 1.0),
136 "T_interface_K": ("T_interface", 1.0),
137 "q_net_MW_m2": ("q_net", 1e-6),
138 "q_conv_MW_m2": ("q_conv", 1e-6),
139 "q_rad_MW_m2": ("q_rad", 1e-6),
140 "q_chem_MW_m2": ("q_chem", 1e-6),
141}
142
143
144def _stations(steps: Sequence[Optional[Dict[str, Dict[str, Any]]]]) -> Dict[str, Dict[str, Any]]:
145 """The solver's per-step station read-outs as one record per station, columns over the replay."""
146 names: List[str] = []
147 for s in steps:
148 for name in (s or {}):
149 if name not in names:
150 names.append(name)
151 out: Dict[str, Dict[str, Any]] = {}
152 for name in names:
153 first = next((s[name] for s in steps if s and name in s), {})
154 rec: Dict[str, Any] = {"x_mm": _number(first.get("x"), 1e3), "kind": first.get("kind")}
155 for key, (field, scale) in STATION_FIELDS.items():
156 rec[key] = [_number(((s or {}).get(name) or {}).get(field), scale) for s in steps]
157 out[name] = rec
158 return out
159
160
161def _axial(t: Sequence[float], stations: Dict[str, Dict[str, Any]], diags: Sequence[Any]) -> Dict[str, Any]:
162 """The axial profiles over the burn, for the ``axial`` sidecar (DATA-CONTRACT §4).
163
164 Two bases, kept apart because they are not the same physics:
165
166 * the top-level arrays are the transient wall stations (x along the wall, one column per
167 station, one row per replay point): the gas-side load at each station's own surface
168 temperature, that temperature, and the recession. These are what the erosion integrates.
169 * ``profile`` is the chamber solve's whole-contour Bartz + radiation flux (278 points face to
170 exit) at the quasi-steady liner surface temperature (``ablative_cooling.liner_response``,
171 ``with_profile``): a display of where the gas loads the wall, evaluated at one wall
172 temperature everywhere, not the transient wall. Each step's profile is drawn on its own
173 redrawn contour; it is interpolated here onto the first step's x so the rows line up.
174 """
175 order = sorted(stations, key=lambda n: (stations[n]["x_mm"] if stations[n]["x_mm"] is not None else 0.0))
176 rows = range(len(t))
177
178 def grid(key: str) -> List[List[Optional[float]]]:
179 return [[stations[n][key][k] for n in order] for k in rows]
180
181 out: Dict[str, Any] = {
182 "x_mm": [stations[n]["x_mm"] for n in order],
183 "station": order,
184 "kind": [stations[n]["kind"] for n in order],
185 "t": [float(v) for v in t],
186 "layout": "rows are t, columns are x_mm",
187 "q_MW_m2": grid("q_net_MW_m2"),
188 "T_wall_K": grid("T_surface_K"),
189 "recession_mm": grid("recession_mm"),
190 "T_back_K": grid("T_back_K"),
191 "basis": ("EngineDesign TimeVaryingCoupledSolver wall stations: 1-D transient conduction per "
192 "station under Bartz convection, Leckner H2O/CO2 radiation and (graphite) carbon "
193 "oxidation; q is the net gas-side load at the station's own surface temperature, "
194 "frozen over the next replay interval"),
195 }
196 prof = [(_dig(d, "cooling.ablative") or {}) for d in diags]
197 if prof and all(p.get("segment_x") for p in prof):
198 x0 = np.asarray(prof[0]["segment_x"], float)
199 q = [np.interp(x0, np.asarray(p["segment_x"], float), np.asarray(p["segment_q_net"], float)) for p in prof]
200 out["profile"] = {
201 "x_mm": [round(float(v) * 1e3, 4) for v in x0],
202 "r_mm": [round(float(v) * 1e3, 4) for v in prof[0]["segment_r"]],
203 "q_MW_m2": [[round(float(v) * 1e-6, 5) for v in row] for row in q],
204 "wall_K": [_number(p.get("profile_wall_temperature")) for p in prof],
205 "basis": ("chamber solve, whole contour, quasi-steady liner surface at its ablation "
206 "temperature (blown convection + radiation, net); not the transient wall"),
207 }
208 return out
209
210
211def replay(prep: Prepared, series: Dict[str, Any], n: int = REPLAY_POINTS, *,
212 sidecars: Optional[Dict[str, Any]] = None, soak: bool = False,
213 chug_eroded_geometry: bool = False) -> Dict[str, Any]:
214 """EngineDesign's coupled time-varying solve at the twin's line-exit pressures.
215
216 An engine with a graphite insert or an ablative liner (:func:`erodes`) is replayed on the
217 coupled solver only (``track_ablative_geometry=True, strict_erosion=True``). The replay used to
218 pass ``None``, which tracked geometry only when the liner was enabled *and* tracked: a
219 graphite-only engine, or one with ``track_geometry_evolution: false``, replayed through the
220 legacy constant-geometry loop with zero erosion and no warning (audit 5.3). If the coupled
221 solve fails now, the replay is unavailable and says why.
222
223 ``sidecars``, when a dict, receives ``sidecars["axial"]`` (:func:`_axial`), too large for the
224 run record. ``soak`` adds the soak-back after this burn (:func:`soak_back`) as ``["soak"]``;
225 pass it on the final replay of a run only (or call :func:`soak_back` after it).
226 ``chug_eroded_geometry`` hands the chug analysis each step's eroded geometry (default off,
227 the previous behaviour; D7-D).
228 """
229 if prep.link is None or prep.link.sampler is None:
230 return {"available": False, "error": "no EngineDesign link"}
231 idx = replay_indices(series["firing"], n)
232 if len(idx) < 2:
233 return {"available": False, "error": "too few firing steps to replay"}
234 t = np.array([series["t"][i] for i in idx])
235 p_o = np.array([series["ox"]["inlet_psia"][i] for i in idx]) * PSI
236 p_f = np.array([series["fuel"]["inlet_psia"][i] for i in idx]) * PSI
237 runner = prep.link.sampler.runner
238 eroding = erodes(runner.config)
239 # The walls start at Fire, not at the first firing sample: that sample closes the first step,
240 # and starting there put the whole recession history a step late (throat growth 3.92 against
241 # 4.05 % on the 6.8 kN burn). Fire is solved at the first step's pressures and dropped after.
242 t_fire = float(t[0]) - float(prep.plan.dt)
243 lead = t_fire < float(t[0]) - 1e-9
244 try:
245 out = runner.evaluate_arrays_with_time(
246 np.concatenate([[t_fire], t]) if lead else t,
247 np.concatenate([[p_o[0]], p_o]) if lead else p_o,
248 np.concatenate([[p_f[0]], p_f]) if lead else p_f,
249 track_ablative_geometry=eroding, use_coupled_solver=True,
250 P_ambient=prep.ambient_pa, strict_erosion=eroding,
251 chug_eroded_geometry=chug_eroded_geometry,
252 )
253 except Exception as exc: # noqa: BLE001 - a failed replay is reported, the burn still stands
254 return {"available": False, "error": f"{type(exc).__name__}: {exc}"}
255 if eroding and "stations" not in out:
256 # Belt and braces: whatever path answered, it was not the coupled solver.
257 return {"available": False, "error": "the erosion replay did not run EngineDesign's coupled "
258 "time-varying solver; recession would read as zero"}
259 if lead:
260 out = {k: (v[1:] if isinstance(v, (list, np.ndarray)) and len(v) == len(t) + 1 else v) for k, v in out.items()}
261
262 def col(key: str, scale: float = 1.0) -> Optional[List[float]]:
263 if key not in out:
264 return None
265 return [float(v) * scale if v is not None and np.isfinite(v) else None for v in np.asarray(out[key], float)]
266
267 at = np.asarray(out.get("A_throat", np.full(len(t), np.nan)), float)
268 a0 = float(prep.link.design.throat_area)
269 data = {
270 "available": True,
271 "t": [float(v) for v in t],
272 "index": idx,
273 "inlet_O_psia": [float(v) / PSI for v in p_o],
274 "inlet_F_psia": [float(v) / PSI for v in p_f],
275 "pc_psia": col("Pc", 1.0 / PSI),
276 "thrust_N": col("F"),
277 "mdot_O": col("mdot_O"),
278 "mdot_F": col("mdot_F"),
279 "mr": col("MR"),
280 "isp_s": col("Isp"),
281 "cstar": col("cstar_actual"),
282 "eta_cstar": col("eta_cstar"),
283 "gamma": col("gamma_chamber"),
284 "A_throat_m2": [float(v) for v in at],
285 "throat_area_ratio": [float(v) / a0 if np.isfinite(v) and a0 > 0 else None for v in at],
286 "recession_throat_mm": col("recession_throat", 1e3),
287 "recession_chamber_mm": col("recession_chamber", 1e3),
288 "eps": col("eps"),
289 # The nozzle's state, for the plume: exit pressure and temperature, the exit gamma, and
290 # the chamber's gas temperature.
291 "p_exit_psia": col("P_exit", 1.0 / PSI),
292 "t_exit_K": col("T_exit"),
293 "gamma_exit": col("gamma_exit"),
294 "tc_K": col("Tc"),
295 "Lstar_m": col("Lstar"),
296 "heat_flux_throat_MW_m2": col("heat_flux_throat", 1e-6),
297 "heat_flux_chamber_MW_m2": col("heat_flux_chamber", 1e-6),
298 "T_graphite_surface_K": col("T_graphite_surface"),
299 "T_liner_surface_K": col("T_liner_surface"),
300 "char_depth_peak_mm": col("char_depth_peak", 1e3),
301 "chug_margin": col("chugging_stability_margin"),
302 "throat_ablation": bool(getattr(prep.link.sampler.config.graphite_insert, "enabled", False)),
303 "liner_ablation": bool(getattr(prep.link.sampler.config.ablative_cooling, "enabled", False)),
304 }
305 # What the replay computes at every point and used to drop (audit 5.2). Old keys above are
306 # unchanged; everything below is new and optional to a reader.
307 for key, (src, scale) in RESULT_COLUMNS.items():
308 data[key] = col(src, scale)
309 diags = list(out.get("diagnostics") or [])
310 if len(diags) != len(t):
311 diags = [None] * len(t)
312 for key, (path, scale) in DIAGNOSTIC_COLUMNS.items():
313 data[key] = [_number(_dig(d, path), scale) for d in diags]
314 steps = list(out.get("stations") or [])
315 data["stations"] = _stations(steps) if len(steps) == len(t) else {}
316 data["wall_layers"] = out.get("wall_layers") or {}
317 data["ambient_pa"] = float(prep.ambient_pa)
318 data["nozzle_efficiency"] = _number(getattr(runner.config.chamber_geometry, "nozzle_efficiency", None))
319 data["coupled_solver"] = "stations" in out
320 data["chug_eroded_geometry"] = bool(chug_eroded_geometry)
321 if sidecars is not None:
322 sidecars["axial"] = _axial(data["t"], data["stations"], diags)
323 if soak:
324 data["soak"] = soak_back(prep, data)
325 return data
326
327
328#: The soak-back window, in multiples of the slowest layer's conduction time L^2/alpha.
329SOAK_FACTOR = 3.0
330
331
332def soak_back(prep: Prepared, rp: Dict[str, Any], factor: float = SOAK_FACTOR) -> Dict[str, Any]:
333 """The soak-back after the burn ``rp`` replayed (DATA-CONTRACT diagnostics.hardware.soak).
334
335 Uses the wall state the coupled solver ended that replay with (the runner keeps its last
336 solver), continued with the hot face adiabatic for ``factor`` x the slowest layer's L^2/alpha
337 (:meth:`TimeVaryingCoupledSolver.soak_back_history`). Call it after the final replay of a run:
338 a later replay replaces the wall state, and that is refused rather than reported.
339
340 The back face is adiabatic in the wall model. With no case declared (LE4:
341 ``stainless_steel_case: null``) the insert's back face is therefore an *adiabatic upper bound*:
342 nothing behind it takes heat (audit D17). With a case declared it is the case's outer face,
343 still adiabatic to the room."""
344 if not rp.get("available"):
345 return {"available": False, "error": "no replay"}
346 runner = getattr(getattr(prep.link, "sampler", None), "runner", None) if prep.link is not None else None
347 solver = getattr(runner, "last_time_varying_solver", None)
348 if solver is None or not getattr(solver, "state_history", None):
349 return {"available": False, "error": "the replay did not run the coupled solver; no wall state to soak"}
350 # The runner keeps only its last solve: a later replay (another pass, another run on a shared
351 # runner) replaces it. Its end time and end throat must both be this replay's.
352 last = solver.state_history[-1]
353 a_end = rp.get("A_throat_m2") or [None]
354 if (abs(float(last.time) - float(rp["t"][-1])) > 1e-9 or a_end[-1] is None
355 or not math.isclose(float(last.A_throat), float(a_end[-1]), rel_tol=1e-12)):
356 return {"available": False, "error": "the runner's last coupled solve is not this replay's"}
357 try:
358 res = solver.soak_back_history(factor=factor)
359 except Exception as exc: # noqa: BLE001 - reported, the burn stands
360 return {"available": False, "error": f"{type(exc).__name__}: {exc}"}
361 case = getattr(runner.config, "stainless_steel_case", None)
362 declared = bool(case is not None and getattr(case, "enabled", False))
363 basis = "case outer face, adiabatic (upper bound)" if declared else "adiabatic upper bound"
364 rows = []
365 for name, s in res["stations"].items():
366 rows.append({"station": name, "kind": s["kind"], "x_mm": s["x"] * 1e3,
367 "T_back_start_K": s["T_back_start"], "T_back_peak_K": s["T_back_peak"],
368 "t_back_peak_s": s["t_back_peak_s"], "t_back_95_s": s["t_back_95_s"],
369 "T_interface_peak_K": s["T_interface_peak"], "t_interface_peak_s": s["t_interface_peak_s"],
370 "back_basis": basis})
371 if not rows:
372 return {"available": False, "error": "the engine has no wall stations"}
373 top = max(rows, key=lambda r: r["T_back_peak_K"])
374 bond = max(rows, key=lambda r: r["T_interface_peak_K"])
375 size = res["sizing"]
376 # With both faces adiabatic the outer face has no peak: it climbs monotonically to the stored
377 # heat's equilibrium, and "within 0.05 K of it" lands hours out on a phenolic backing (LE4:
378 # +27,072 s of a 45,103 s window). Said so, with the time to 95 % of the rise as its clock;
379 # the bond line behind the insert, which does peak (seconds after shutdown), is the soak-back
380 # that loads the hardware.
381 asymptotic = top["t_back_peak_s"] > 0.5 * res["duration_s"]
382 return {
383 "available": True,
384 "peak_K": top["T_back_peak_K"],
385 "t_peak_s": top["t_back_peak_s"],
386 "t_95_s": top["t_back_95_s"],
387 "station": top["station"],
388 "asymptotic": asymptotic,
389 "interface_peak_K": bond["T_interface_peak_K"],
390 "t_interface_peak_s": bond["t_interface_peak_s"],
391 "interface_station": bond["station"],
392 "duration_s": res["duration_s"],
393 "clock": "seconds after the last replay point (burnout)",
394 "back_basis": basis,
395 "stations": rows,
396 "model": {
397 "name": "soak-back: 1-D transient conduction, hot face adiabatic after shutdown",
398 "source": ("EngineDesign WallModel (engine/pipeline/thermal/wall_conduction.py), backward Euler "
399 "on the replay's final wall state; window sized from the slowest mode of an "
400 "insulated slab, exp(-pi^2 alpha t/L^2) (Carslaw & Jaeger 1959, ch. III)"),
401 "assumptions": [
402 "hot face adiabatic after shutdown: no convection to the cooling gas, no radiation out "
403 "of the nozzle; an upper bound on the heat that reaches the back",
404 "back face adiabatic: " + ("the declared case's outer face does not lose heat to the room"
405 if declared else "no case or backing is declared, so nothing "
406 "behind the liner or insert takes heat (D17)"),
407 "no contact resistance between layers; each station 1-D through the wall, no axial "
408 "conduction between stations",
409 f"window {factor:g} x the slowest layer's L^2/alpha ({size['tau_s']:.4g} s, "
410 f"{size['station']} {size['layer']})",
411 "a peak's time is the first time within 0.05 K of it: a plateau is dated when it is reached; "
412 "the outer face, adiabatic on both sides, only approaches its equilibrium, so its clock is the "
413 "time to 95 % of the rise (asymptotic: true)",
414 "properties as built (config); graphite cp(T) by its named model, every other property constant",
415 ],
416 "inputs": {
417 "duration": {"value": res["duration_s"], "unit": "s",
418 "provenance": f"derived: {factor:g} x max L^2/alpha over the wall layers"},
419 "slowest_L2_over_alpha": {"value": size["tau_s"], "unit": "s",
420 "provenance": "derived from the wall layers' thickness, k, rho, cp (config)"},
421 "case_declared": {"value": declared, "unit": "",
422 "provenance": "config stainless_steel_case"},
423 },
424 },
425 }
426
427
428def throat_schedule(rp: Dict[str, Any]) -> Optional[tuple]:
429 """``(t, A_throat)`` from a replay, for the next twin pass; ``None`` without one."""
430 if not rp.get("available"):
431 return None
432 t = np.asarray(rp["t"], float)
433 a = np.asarray(rp["A_throat_m2"], float)
434 ok = np.isfinite(a) & np.isfinite(t)
435 if ok.sum() < 2:
436 return None
437 if not ok.all():
438 # A step the solver could not close leaves a gap, not a reason to drop the whole history.
439 a = np.interp(t, t[ok], a[ok])
440 return t, a
441
442
443def schedule_change(old: Optional[tuple], new: Optional[tuple]) -> float:
444 """Largest relative change of the throat history between two passes."""
445 if new is None:
446 # No history this pass: nothing to compare is "settled" only if there was none before.
447 return 0.0 if old is None else math.inf
448 t, a = new
449 if old is None:
450 return float(np.max(np.abs(a / a[0] - 1.0))) if len(a) else 0.0
451 return float(np.max(np.abs(np.interp(t, old[0], old[1]) / a - 1.0)))
452
453
454def agreement(rp: Dict[str, Any], series: Dict[str, Any]) -> Dict[str, Any]:
455 """The twin's flows and chamber pressure against EngineDesign's replay at the same instants
456 and inlet pressures: what the hand-off between the two passes leaves open."""
457 if not rp.get("available"):
458 return {"available": False}
459 worst: Dict[str, float] = {}
460 for key, twin, ed in (("mdot_O", series["ox"]["mdot"], rp["mdot_O"]),
461 ("mdot_F", series["fuel"]["mdot"], rp["mdot_F"]),
462 ("pc", series["chamber"]["pc_psia"], rp["pc_psia"])):
463 vals = [abs(twin[i] / e - 1.0) for i, e in zip(rp["index"], ed or []) if e]
464 worst[key] = max(vals) if vals else float("nan")
465 return {"available": True, "worst": worst}
466
467
468def _interp(t: Sequence[float], xs: Sequence[float], ys: Sequence[Optional[float]]) -> List[float]:
469 pts = [(x, y) for x, y in zip(xs, ys) if y is not None and math.isfinite(y)]
470 if not pts:
471 return [float("nan")] * len(t)
472 xa, ya = zip(*pts)
473 return [float(v) for v in np.interp(t, xa, ya)]
474
475
476def delivered(rp: Dict[str, Any], series: Dict[str, Any], dt: float) -> Optional[Dict[str, Any]]:
477 """The engine's answers over the burn, from the replay, on the twin's firing steps.
478
479 Thrust, chamber pressure, flow and Isp are EngineDesign's (interpolated between replay
480 points; they are smooth). Burn time and the propellant used are the twin's, whose last pass
481 already ran with the eroding throat.
482 """
483 if not rp.get("available"):
484 return None
485 fire = [i for i, f in enumerate(series["firing"]) if f]
486 t = [series["t"][i] for i in fire]
487 out = {"t": t}
488 for key in ("thrust_N", "pc_psia", "mdot_O", "mdot_F", "isp_s", "mr", "throat_area_ratio",
489 "recession_throat_mm", "cstar", "gamma", "eps", "p_exit_psia", "t_exit_K", "gamma_exit", "tc_K",
490 "chug_margin"):
491 out[key] = _interp(t, rp["t"], rp.get(key) or [])
492 # The first firing step is the ignition transient, inside the first replay point; the
493 # interpolant holds its value there, which is the twin's own handling of that step too.
494 # Each firing step's own length (the last may be cut to land on depletion).
495 lengths = [float(series["dt"][i]) for i in fire] if series.get("dt") else [dt] * len(fire)
496 impulse = sum(f * h for f, h in zip(out["thrust_N"], lengths) if math.isfinite(f))
497 used = sum((mo + mf) * h for mo, mf, h in zip(out["mdot_O"], out["mdot_F"], lengths) if math.isfinite(mo + mf))
498 burn = t[-1] if t else 0.0
499 settled = [k for k, tt in enumerate(t) if tt >= 0.2] or list(range(len(t)))
500 pick = lambda key, fn: fn([out[key][k] for k in settled if math.isfinite(out[key][k])]) # noqa: E731
501 out["summary"] = {
502 "total_impulse_Ns": impulse,
503 "mean_thrust_N": impulse / burn if burn else None,
504 "peak_thrust_N": max(out["thrust_N"]) if out["thrust_N"] else None,
505 "min_thrust_N": pick("thrust_N", min),
506 "pc_mean_psia": pick("pc_psia", lambda v: sum(v) / len(v)),
507 "pc_min_psia": pick("pc_psia", min),
508 "pc_max_psia": pick("pc_psia", max),
509 "isp_mean_s": impulse / (used * 9.80665) if used > 0 else None,
510 "propellant_burned_kg": used,
511 "throat_area_growth": (out["throat_area_ratio"][-1] - 1.0) if out["throat_area_ratio"] else None,
512 "throat_recession_mm": out["recession_throat_mm"][-1] if out["recession_throat_mm"] else None,
513 # EngineDesign's chug gain margin (worst over the mixing-lag band), lowest over the whole
514 # burn, ignition included: below 1 the feed-coupled loop is predicted unstable.
515 "chug_margin_min": None,
516 "chug_margin_min_t": None,
517 # Its feed is EngineDesign's own feed_system, not the drawing's lines: the run grades its chug
518 # on the drawing (diagnostics.stability, the limits' ``chug_margin``). On LE4 (3) the two read
519 # 1.21 and 1.004; anything quoting this figure says which it is.
520 "chug_margin_basis": "config",
521 }
522 gm = [(v, tt) for v, tt in zip(out.get("chug_margin") or [], t) if math.isfinite(v)]
523 if gm:
524 low = min(gm)
525 out["summary"]["chug_margin_min"], out["summary"]["chug_margin_min_t"] = low[0], low[1]
526 return out
527
528
529def timeseries_payload(result: Dict[str, Any]) -> Dict[str, Any]:
530 """The burn in the shape the Time-Series tab stores (``api/client.ts TimeSeriesData``), so
531 Forward mode's burn view and the Flight tab read a Layer X run exactly as they read a
532 Time-Series run. Engine quantities from the replay when there is one; feed-system
533 quantities from the twin. Time starts at the Fire command, with a sample there."""
534 series = result["series"]
535 fire = [i for i, f in enumerate(series["firing"]) if f]
536 dv = result.get("delivered")
537
538 def engine(key_rp: str, key_twin: List[float], scale: float = 1.0) -> List[float]:
539 if dv is not None and dv.get(key_rp) is not None:
540 return [float(v) * scale for v in dv[key_rp]]
541 return [float(key_twin[i]) * scale for i in fire]
542
543 thrust_kN = engine("thrust_N", series["chamber"]["thrust_N"], 1e-3)
544 mo = engine("mdot_O", series["ox"]["mdot"])
545 mf = engine("mdot_F", series["fuel"]["mdot"])
546 data = {
547 "time": [float(series["t"][i]) for i in fire],
548 "P_tank_O_psi": [float(series["ox"]["tank_psia"][i]) for i in fire],
549 "P_tank_F_psi": [float(series["fuel"]["tank_psia"][i]) for i in fire],
550 "Pc_psi": engine("pc_psia", series["chamber"]["pc_psia"]),
551 "thrust_kN": thrust_kN,
552 "Isp_s": engine("isp_s", series["chamber"]["isp_s"]),
553 "MR": engine("mr", series["chamber"]["mr"]),
554 "mdot_O_kg_s": mo,
555 "mdot_F_kg_s": mf,
556 "mdot_total_kg_s": [a + b for a, b in zip(mo, mf)],
557 "cstar_actual_m_s": engine("cstar", series["chamber"]["cstar"]),
558 "gamma": engine("gamma", [float("nan")] * len(series["t"])),
559 "lox_mass_remaining_kg": [float(series["ox"]["liquid_kg"][i]) for i in fire],
560 "fuel_mass_remaining_kg": [float(series["fuel"]["liquid_kg"][i]) for i in fire],
561 "delta_P_injector_O_psi": [float(series["ox"]["dp_injector_psi"][i]) for i in fire],
562 "delta_P_injector_F_psi": [float(series["fuel"]["dp_injector_psi"][i]) for i in fire],
563 "copv_pressure_psi": [float(series["copv_psia"][i]) for i in fire] if series["copv_psia"] else None,
564 }
565 if dv is not None:
566 data["recession_cumulative_throat_um"] = [float(v) * 1e3 for v in dv["recession_throat_mm"]]
567 s = result["summary"]
568 # Each sample closes its step: the first is stamped one step after Fire, and every reader of
569 # this shape (backend/routers/flight.py, the Flight tab, flight.fly) puts t = 0 at the first
570 # sample. Without a sample at Fire the first step's thrust was dropped: 340 N·s and ~70 m of
571 # apogee on the 6.8 kN burn. The step holds its values across itself, so the Fire sample
572 # carries the first step's, with the tanks still at their load.
573 dt = float(s.get("dt") or 0.0)
574 if data["time"] and dt > 0.0 and data["time"][0] - dt > -1e-9:
575 loaded = {"lox_mass_remaining_kg": s["ox"]["loaded_kg"], "fuel_mass_remaining_kg": s["fuel"]["loaded_kg"]}
576 for key, values in data.items():
577 if not values:
578 continue
579 if key == "time":
580 values.insert(0, round(values[0] - dt, 9))
581 else:
582 values.insert(0, float(loaded[key]) if key in loaded else values[0])
583 d = (dv or {}).get("summary") or {}
584 summary = {
585 "avg_thrust_kN": (d.get("mean_thrust_N") or s.get("mean_thrust_N") or 0.0) * 1e-3,
586 "peak_thrust_kN": (d.get("peak_thrust_N") or s.get("peak_thrust_N") or 0.0) * 1e-3,
587 "min_thrust_kN": (d.get("min_thrust_N") or s.get("min_thrust_N") or 0.0) * 1e-3,
588 "avg_Pc_psi": d.get("pc_mean_psia") or s.get("pc_mean_psia"),
589 "peak_Pc_psi": d.get("pc_max_psia") or s.get("pc_max_psia"),
590 "avg_Isp_s": d.get("isp_mean_s") or s.get("isp_mean_s"),
591 "total_impulse_kNs": (d.get("total_impulse_Ns") or s.get("total_impulse_Ns") or 0.0) * 1e-3,
592 "total_propellant_kg": s.get("propellant_used_kg"),
593 "burn_time_s": s.get("burn_time_s"),
594 "lox_propellant_kg": s["ox"]["loaded_kg"] - s["ox"]["residual_kg"],
595 "fuel_propellant_kg": s["fuel"]["loaded_kg"] - s["fuel"]["residual_kg"],
596 }
597 return {"data": data, "summary": summary, "source": "layerx"}
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
Dict[str, Dict[str, Any]] _stations(Sequence[Optional[Dict[str, Dict[str, Any]]]] steps)
Definition replay.py:144
Dict[str, Any] _axial(Sequence[float] t, Dict[str, Dict[str, Any]] stations, Sequence[Any] diags)
Definition replay.py:161
Optional[Dict[str, Any]] delivered(Dict[str, Any] rp, Dict[str, Any] series, float dt)
Definition replay.py:476
Dict[str, Any] soak_back(Prepared prep, Dict[str, Any] rp, float factor=SOAK_FACTOR)
Definition replay.py:332
Optional[float] _number(Any v, float scale=1.0)
Definition replay.py:64
Dict[str, Any] timeseries_payload(Dict[str, Any] result)
Definition replay.py:529
List[int] replay_indices(Sequence[bool] firing, int n=REPLAY_POINTS)
Definition replay.py:47
Optional[tuple] throat_schedule(Dict[str, Any] rp)
Definition replay.py:428
Dict[str, Any] agreement(Dict[str, Any] rp, Dict[str, Any] series)
Definition replay.py:454
Any _dig(Any d, str path)
Definition replay.py:72
float schedule_change(Optional[tuple] old, Optional[tuple] new)
Definition replay.py:443
bool erodes(Any config)
Definition replay.py:56
List[float] _interp(Sequence[float] t, Sequence[float] xs, Sequence[Optional[float]] ys)
Definition replay.py:468