32from typing
import Any, Dict, List, Optional, Sequence
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] = []
148 for name
in (s
or {}):
149 if name
not in names:
151 out: Dict[str, Dict[str, Any]] = {}
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]
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).
164 Two bases, kept apart because they are not the same physics:
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.
175 order = sorted(stations, key=
lambda n: (stations[n][
"x_mm"]
if stations[n][
"x_mm"]
is not None else 0.0))
178 def grid(key: str) -> List[List[Optional[float]]]:
179 return [[stations[n][key][k]
for n
in order]
for k
in rows]
181 out: Dict[str, Any] = {
182 "x_mm": [stations[n][
"x_mm"]
for n
in 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"),
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]
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"),
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.
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.
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).
229 if prep.link
is None or prep.link.sampler
is None:
230 return {
"available":
False,
"error":
"no EngineDesign link"}
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)
242 t_fire = float(t[0]) - float(prep.plan.dt)
243 lead = t_fire < float(t[0]) - 1e-9
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,
253 except Exception
as exc:
254 return {
"available":
False,
"error": f
"{type(exc).__name__}: {exc}"}
255 if eroding
and "stations" not in out:
257 return {
"available":
False,
"error":
"the erosion replay did not run EngineDesign's coupled "
258 "time-varying solver; recession would read as zero"}
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()}
262 def col(key: str, scale: float = 1.0) -> Optional[List[float]]:
265 return [float(v) * scale
if v
is not None and np.isfinite(v)
else None for v
in np.asarray(out[key], float)]
267 at = np.asarray(out.get(
"A_throat", np.full(len(t), np.nan)), float)
268 a0 = float(prep.link.design.throat_area)
271 "t": [float(v)
for v
in t],
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"),
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),
291 "p_exit_psia": col(
"P_exit", 1.0 / PSI),
292 "t_exit_K": col(
"T_exit"),
293 "gamma_exit": col(
"gamma_exit"),
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)),
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)
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).
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.
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"}
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"}
358 res = solver.soak_back_history(factor=factor)
359 except Exception
as exc:
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"
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})
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"])
381 asymptotic = top[
"t_back_peak_s"] > 0.5 * res[
"duration_s"]
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)",
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)"),
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",
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"},
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}
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.
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.
483 if not rp.get(
"available"):
485 fire = [i
for i, f
in enumerate(series[
"firing"])
if f]
486 t = [series[
"t"][i]
for i
in fire]
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",
491 out[key] =
_interp(t, rp[
"t"], rp.get(key)
or [])
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])])
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,
515 "chug_margin_min":
None,
516 "chug_margin_min_t":
None,
520 "chug_margin_basis":
"config",
522 gm = [(v, tt)
for v, tt
in zip(out.get(
"chug_margin")
or [], t)
if math.isfinite(v)]
525 out[
"summary"][
"chug_margin_min"], out[
"summary"][
"chug_margin_min_t"] = low[0], low[1]
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")
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]
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"])
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"]),
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,
566 data[
"recession_cumulative_throat_um"] = [float(v) * 1e3
for v
in dv[
"recession_throat_mm"]]
567 s = result[
"summary"]
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():
580 values.insert(0, round(values[0] - dt, 9))
582 values.insert(0, float(loaded[key])
if key
in loaded
else values[0])
583 d = (dv
or {}).get(
"summary")
or {}
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"],
597 return {
"data": data,
"summary": summary,
"source":
"layerx"}