STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
analysis.py
Go to the documentation of this file.
1"""Run a prepared Layer X burn and reduce it to what a person reads.
2
3The trace from :func:`feedtwin.session.burn.run_burn` is absolute SI, sampled at
4every step. This module turns it into four things:
5
6* **series**: every step in display units (psia, kg/s, N), one column per
7 quantity, for the plots;
8* **summary**: the numbers a design review asks for (burn time, total impulse,
9 the pressure ladder at T-0 and at its lowest, injector stiffness at its
10 lowest, residuals);
11* **events**: when things happened (T-0 settle, ignition, lowest tank pressure,
12 depletion);
13* **provenance**: everything needed to say what this run was: the drawing's
14 hash, the config's hash, the setup, the plan, the engine calibration, and every
15 parameter the drawing did not state.
16
17Integrals use the same rule the burn's mass bookkeeping uses: each step's value
18times that step. The library's conservation test checks that rule against
19tank inventory.
20"""
21
22from __future__ import annotations
23
24import math
25import time
26from dataclasses import asdict
27from typing import Any, Callable, Dict, List, Optional
28
29from engine.layerx.prepare import PSI, Prepared
30
31G0 = 9.80665
32
33Progress = Callable[[str, float], None]
34
35
36def _psia(values: List[float]) -> List[float]:
37 return [v / PSI for v in values]
38
39
40def _clean(value: Any) -> Any:
41 """JSON-safe: NaN and inf become None, tuples become lists."""
42 if isinstance(value, float):
43 return value if math.isfinite(value) else None
44 if isinstance(value, dict):
45 return {str(k): _clean(v) for k, v in value.items()}
46 if isinstance(value, (list, tuple)):
47 return [_clean(v) for v in value]
48 return value
49
50
51class StandTripped(RuntimeError):
52 """The stand tripped before the burn recorded a single step -- in the settle to lockup, before
53 the lead-in -- so there is no burn to reduce. ``record`` is the trip as ``result.tripped``
54 carries it (DATA-CONTRACT 4, lib/feedtwin's ``trip_record``). A trip after the first recorded
55 step is not an exception: the burn stops there and the result says so (``result.tripped``)."""
56
57 def __init__(self, record: Dict[str, Any]) -> None:
58 self.record = dict(record)
59 what = self.record.get("message") or (f"{self.record.get('label') or self.record.get('vessel') or 'a vessel'} "
60 f"over its {self.record.get('mawp_psia')} psia trip")
61 super().__init__(f"the stand tripped in the settle to lockup, before T-0: {what}")
62
63
64def _setup_dict(setup: Any) -> Dict[str, Any]:
65 out = {}
66 for key, value in asdict(setup).items():
67 if isinstance(value, (int, float, str, bool)) or value is None:
68 out[key] = value
69 return out
70
71
72def _burn_once(prep: Prepared, schedule: Optional[tuple], say: Progress, cancelled: Callable[[], bool],
73 lo: float, hi: float, accel: Optional[Dict[str, Any]] = None, pass_no: int = 1,
74 inline: Optional[Callable[..., Any]] = None,
75 live: Optional[Callable[[float, float, float], None]] = None) -> tuple:
76 """One twin pass. ``schedule`` is ``(t, A_throat)`` from the previous replay, applied to the
77 chamber before every step; ``None`` is the as-built throat. ``accel`` is the flight's proper
78 acceleration on the burn's clock (``flight.fly``), applied to every liquid column once the
79 engine fires; ``None`` is the pad's one g throughout.
80
81 ``inline`` (AUDIT D5-C) builds a :class:`flight.InlineAscent`
82 from the trace and session at Fire; it is then advanced at every later sample on that
83 sample's thrust and propellant flow, and its specific force is the acceleration the next step
84 burns at, exactly as ``accel`` is applied (one step of lag). A held vehicle reads exactly one
85 g0, so the lead-in and a vehicle still on the pad are the pad's burn. The ascent is returned
86 as the trace's ``inline_ascent`` record (``None`` without it)."""
87 from dataclasses import replace
88
89 from feedtwin.session.burn import BurnTrace, burn, find_probes, open_session, prime_at_t0
90
91 import numpy as np
92
93 model = prep.assembler() if prep.assembler is not None else prep.model
94 chamber = model.chamber
95 a0 = float(prep.link.design.throat_area) if prep.link is not None else float(chamber.throat_area)
96 chamber.throat_area = a0
97 hookup = None
98 if prep.settings.dome_regulator:
99 # The rail named the regulator the dome dial sets (2026-10-07): the session's dome knob drives
100 # that one alone, as the feed twin's Hookup page would have it. Unnamed, the session drives
101 # what its own dome knob always drove, exactly as before.
102 from feedtwin.session.hookup import DOME, Hookup, Knob
103
104 hookup = Hookup(knobs=(Knob(id=DOME, label="Layer X dome",
105 regulators=(prep.settings.dome_regulator,),
106 psig=float(prep.setup.dome_psi)),))
107 session = open_session(model, prep.machine, setup=prep.setup, hookup=hookup)
108 # A transducer on a regulator's dome line reads the dome Layer X sets: the line is no plumbing,
109 # so the solve leaves it at atmosphere (LE4's DP-PT-R read 14.7 psia under a 550 psia dome).
110 domes = dome_line_instruments(model.diagram, model.built)
111 dome_readings: Dict[str, List[float]] = {node: [] for node in domes}
112 say("Settling to tank pressure", lo)
113 settled = prime_at_t0(session, prep.plan)
114 # The drawing's own transducers are read too, so a burn says what each DAQ channel should show.
115 # And the whole network, every step (DATA-CONTRACT 2): recording only -- it reads the sample a
116 # step already produced and changes no number (the LE4 golden burn is bit-identical with it).
117 from dataclasses import replace as _replace
118
119 probes = _replace(find_probes(session), network=True, instruments={
120 i.id: i.node for i in getattr(session.model.built, "instruments", ()) if i.node in session.model.built.network.nodes})
121 trace = BurnTrace(probes=probes, t0_settled=settled)
122 own = trace.recorder(session)
123 initial = {k: float(s.state.liquid_mass) for k, s in session.tanks.items()}
124 watched = [t for t in (prep.plan.tanks or tuple(session.tanks)) if t in session.tanks]
125 ascent: Dict[str, Any] = {"vehicle": None, "t": [], "accel_m_s2": []}
126
127 def record(clock: float, sample: Any, firing: bool) -> None:
128 own(clock, sample, firing)
129 for node, signal in domes.items():
130 dome_readings[node].append(float(sample.signals.get(signal, sample.pressures.get(node, math.nan))))
131 if schedule is not None and firing:
132 # The throat the next step burns through: the replay's eroded throat at the end of
133 # this one (one step of lag, ~0.08 % of area on this engine).
134 chamber.throat_area = float(np.interp(clock, schedule[0], schedule[1]))
135 if accel is not None and (firing or clock > -1e-6):
136 # Likewise the acceleration: the flight's at the end of this step, for the next. The
137 # last lead-in sample (clock 0) sets it for the first firing step, which otherwise
138 # burned at the pad's one g.
139 a = float(np.interp(clock, accel["t"], accel["accel_m_s2"]))
140 session.setup = replace(session.setup, body_acceleration=a)
141 elif inline is not None and (firing or clock > -1e-6):
142 # The same, from the 1-DOF ascent this pass is flying as it burns: placed on the pad at
143 # the last lead-in sample (Fire, clock 0) with the vehicle as Fire finds it.
144 if ascent["vehicle"] is None:
145 ascent["vehicle"] = inline(trace, initial, clock)
146 ch = trace.chamber
147 a = float(ascent["vehicle"].advance(clock, ch["thrust_N"][-1], ch["mdot_oxidiser"][-1] + ch["mdot_fuel"][-1]))
148 ascent["t"].append(float(clock))
149 ascent["accel_m_s2"].append(a)
150 session.setup = replace(session.setup, body_acceleration=a)
151 if live is not None and firing:
152 # What the burn is doing, as it does it: the run page draws it while the pass runs.
153 # It reads the step just recorded and changes nothing.
154 ch = trace.chamber
155 live(float(clock), float(ch["thrust_N"][-1]), float(ch["pressure_Pa"][-1]) / PSI)
156 if firing and watched:
157 used = max(1.0 - session.tanks[t].state.liquid_mass / max(initial[t], 1e-9) for t in watched)
158 say(f"Burning, pass {pass_no}" if pass_no > 1 else "Burning", lo + (hi - lo) * min(max(used, 0.0), 1.0))
159
160 try:
161 trace.end = burn(session, prep.plan, record, cancelled=cancelled)
162 finally:
163 chamber.throat_area = a0
164 trace.notes = list(dict.fromkeys(session.assumptions))
165 if trace.end.cancelled:
166 from engine.layerx.pool import Cancelled
167
168 raise Cancelled()
169 trip = getattr(trace.end, "tripped", None)
170 if trip is not None and not trace.t:
171 from feedtwin.session.burn import trip_record
172
173 raise StandTripped(trip_record(trace) or {})
174 if trip is not None and trace.t[-1] - float(trip.t) > 1e-12 and (len(trace.t) < 2 or float(trip.t) > trace.t[-2]):
175 # The tripping step's sample is the stand as it tripped: Session.step stops its inner
176 # live-step loop on the trip (core.py), so that frame is at the trip's instant, but burn()
177 # stamps it with the full step's clock (25 ms late at a 50 ms step and a 25 ms live step).
178 # Stamped at the trip, the last step's length -- and the impulse, burn time and every
179 # integral over it -- end where the stand stopped.
180 trace.t[-1] = float(trip.t)
181 trace.inline_ascent = ascent if inline is not None else None # type: ignore[attr-defined]
182 trace.dome_readings = dome_readings # type: ignore[attr-defined]
183 return trace, initial
184
185
186def dome_line_instruments(diagram: Any, built: Any) -> Dict[str, str]:
187 """The network node of each transducer on a dome line nothing on the drawing loads, and the
188 dome signal it reads (``"<regulator tag>.dome"``). Layer X burns the vehicle alone: the GSE's
189 loader is cut (engine.layerx.vehicle), the dome is the dial's, and the line from the dome port
190 to the cut disconnect is no plumbing, so the solve leaves it at atmosphere. Walked from the
191 ``dome`` handle through junctions and instruments, stopping at any valve, regulator, disconnect
192 or vessel, as feed-twin walks it for a stand built with its GSE ignored
193 (``Session._find_cut_dome_ports``). A regulator whose loader is drawn on the vehicle is left
194 alone: the session already reads its line."""
195 from feedtwin.pid.document import BOUNDARY_TYPES, INLINE_TYPES, SOURCE_TYPES
196 from feedtwin.pid.network import DOME_HANDLE
197
198 by_id = {n.id: n for n in diagram.nodes}
199 adjacent: Dict[str, set] = {}
200 for edge in diagram.edges:
201 adjacent.setdefault(edge.source, set()).add(edge.target)
202 adjacent.setdefault(edge.target, set()).add(edge.source)
203 loaded = {loader.signal for loader in built.dome_loaders.values()}
204 stop = INLINE_TYPES | SOURCE_TYPES | BOUNDARY_TYPES
205 out: Dict[str, str] = {}
206 for node in diagram.nodes:
207 if node.type != "PR" or node.options.get("domeLoaded") != "yes":
208 continue
209 signal = built.actuators.get(node.id, "")
210 if not signal or signal in loaded:
211 continue
212 frontier = [e.target if e.source == node.id else e.source for e in diagram.edges
213 if (e.source == node.id and e.source_handle == DOME_HANDLE)
214 or (e.target == node.id and e.target_handle == DOME_HANDLE)]
215 seen: set = set()
216 while frontier:
217 here = frontier.pop()
218 if here in seen or here not in by_id or here == node.id:
219 continue
220 seen.add(here)
221 if by_id[here].type not in stop:
222 frontier.extend(adjacent.get(here, ()))
223 for inst in built.instruments:
224 if inst.id in seen and inst.node in built.network.nodes:
225 out[inst.node] = signal
226 return out
227
228
230 prep: Prepared,
231 *,
232 progress: Optional[Progress] = None,
233 cancelled: Callable[[], bool] = lambda: False,
234 runner: Any = None,
235 replay: bool = True,
236 config: Any = None,
237 diagnostics: bool = False,
238 sidecars: Optional[Dict[str, Any]] = None,
239 live: Optional[Callable[[float, float, float], None]] = None,
240) -> Dict[str, Any]:
241 """Burn ``prep`` and return the reduced result.
242
243 With ``replay`` (and an EngineDesign engine), the burn is closed against EngineDesign's
244 time-varying solve with the chamber eroding (:mod:`engine.layerx.replay`): twin pass,
245 replay, twin pass again with the replay's throat history, until that history stops moving.
246 ``runner`` (EngineDesign's, through its own feed lines) is used for the Forward-mode
247 cross-check; without it that check is skipped.
248
249 With ``prep.settings.flight`` and the engine ``config``, the burn first settles on the pad (the
250 stand's answer), then flies: a 1-DOF ascent stepped inside each further pass, from the pad's
251 throat history until it stops moving, and RocketPy flies the settled burn once. The RocketPy
252 outer loop this replaced gave the same burn (LE4 He: impulse 0.006 %, apogee 0.01 m) in 5
253 passes and 247 s against 2 and 100 s. ``result["flight"]`` carries the flight and the
254 pad-against-flight comparison.
255
256 ``diagnostics`` (DATA-CONTRACT 3): after the burn has settled, every diagnostic that reads it
257 (:func:`diagnostics_of`) into ``result["diagnostics"]``, the soak-back after the final replay,
258 and the stable event keys. They read the burn and change nothing in it; each one that fails
259 is ``{available: false, error}`` and the burn stands. The router asks for them on every run a
260 person starts; the tools that burn many candidates (sweep, set point, optimiser, reconcile)
261 leave them off and grade their candidates themselves. ``result["limits"]`` (the graded list,
262 :func:`grade_limits`) is written either way.
263
264 ``live``, if given, is told each firing step of the first pass as it burns -- ``(t [s from
265 Fire], thrust [N], chamber pressure [psia])`` -- so a person watching sees the burn while the
266 passes run. It reads the step and changes nothing.
267
268 ``sidecars``, a dict, receives the final replay's axial heat-flux grids as ``["axial"]``
269 (DATA-CONTRACT 4): too large for the run record, so the caller stores it beside it.
270
271 Every pass records the whole feed network (``result["network"]``, DATA-CONTRACT 2): recording
272 only, no number moves. A vessel over the trip pressure its drawing declares ends the burn on
273 that step (lib/feedtwin ``burn``): the result carries ``tripped``, a ``fail`` event keyed
274 ``trip``, ``converged: False``, and totals that stop at the trip. That pass is replayed for its
275 delivered figures if it fired, and neither iterated nor flown. A trip in the settle, before
276 the burn recorded a step, raises :class:`StandTripped`.
277 """
278 from feedtwin import __version__ as feedtwin_version
279
280 from engine.layerx import flight as flt
281 from engine.layerx import replay as rpl
282 from engine.layerx.prepare import options
283
284 if not prep.ok:
285 raise ValueError("preflight has failing checks; fix them before running")
286 say = progress or (lambda stage, fraction: None)
287 started = time.perf_counter()
288 opts = options(prep.settings)
289 loop = replay and prep.link is not None and prep.link.sampler is not None
290 feedback = loop and prep.link.mode in ("card", "calibrated")
291 flying = bool(prep.settings.flight) and config is not None
292 # The ascent is stepped inside the burn (``_inline_factory``); RocketPy flies the settled burn once.
293 # Flown, the burn first settles on the pad -- the stand's answer, kept for the pad-against-flight
294 # comparison -- and then flies, starting from the pad's throat history.
295 inline = flying
296 on_pad = flying
297 passes: List[Dict[str, Any]] = []
298 schedule = None
299 accel: Optional[Dict[str, Any]] = None
300 flown: Optional[Dict[str, Any]] = None
301 ground: Optional[Dict[str, Any]] = None
302 rp: Dict[str, Any] = {"available": False}
303 axial: Optional[Dict[str, Any]] = None
304 ascent: Optional[Dict[str, Any]] = None
305 make_ascent = _inline_factory(prep, config) if inline else None
306 budget = (rpl.MAX_PASSES if feedback else 1) * (2 if flying else 1)
307 pad_passes = 0
308 span = 0.8 / budget
309 for n in range(1, budget + 1):
310 lo = 0.03 + (n - 1) * span
311 trace, initial = _burn_once(prep, schedule, say, cancelled, lo, lo + 0.8 * span, accel, pass_no=n,
312 inline=None if on_pad else make_ascent, live=live if n == 1 else None)
313 result = reduce_trace(prep, trace, initial)
314 ascent = getattr(trace, "inline_ascent", None)
315 # A vessel trip ends the burn on the step that found it (lib/feedtwin ``burn``): this pass is
316 # the answer, a failed one. It is replayed for its delivered figures when it fired at all, and
317 # neither iterated nor flown -- a later pass would trip again, and a flight of a burn cut
318 # short by a trip is not the vehicle's.
319 tripped = result.get("tripped") is not None
320 if not (loop or flying):
321 break
322 entry: Dict[str, Any] = {"pass": n, "throat_applied": schedule is not None, "accel_applied": accel is not None,
323 "burn_time_s": result["summary"]["burn_time_s"]}
324 if inline and not on_pad:
325 entry["accel_inline"] = True
326 if tripped:
327 entry["tripped"] = True
328 passes.append(entry)
329 new = None
330 throat_done = True
331 dv = None
332 if loop and tripped and not any(result["series"]["firing"]):
333 # Tripped in the lead-in: nothing fired, so there is no burn to erode.
334 rp = {"available": False, "error": "the stand tripped before Fire: no firing steps to replay"}
335 elif loop:
336 say(f"Nozzle erosion, pass {n}", lo + 0.85 * span)
337 kept: Dict[str, Any] = {}
338 rp = rpl.replay(prep, result["series"], sidecars=kept, chug_eroded_geometry=opts["chug_eroded"])
339 axial = kept.get("axial")
340 if not rp.get("available"):
341 # Said once, by name: otherwise a failed replay reads as an unsettled throat.
342 result["events"].append({"t": result["series"]["t"][-1], "kind": "warn", "label": "Erosion replay failed",
343 "detail": f"{rp.get('error') or 'unavailable'}; thrust and Pc are the as-built "
344 "throat's, not the eroding nozzle's."})
345 new = rpl.throat_schedule(rp)
346 change = rpl.schedule_change(schedule, new)
347 entry.update({"throat_growth": (float(new[1][-1] / new[1][0] - 1.0) if new is not None else None),
348 "schedule_change": change, "agreement": rpl.agreement(rp, result["series"])})
349 throat_done = not feedback or new is None or (schedule is not None and change < rpl.THROAT_TOLERANCE)
350 dv = _delivered(rp, result, prep.plan.dt)
351 if tripped:
352 break
353 if on_pad:
354 pad_passes += 1
355 if throat_done or pad_passes >= budget // 2:
356 # The pad's answer, settled: what the stand will see. The flight starts from its throat.
357 ground = _pass_figures(result, dv)
358 entry.update(ground)
359 on_pad = False
360 elif throat_done:
361 if flying:
362 entry.update(_pass_figures(result, dv))
363 break
364 if feedback and new is not None:
365 schedule = new
366
367 soak_s = None
368 if diagnostics and loop and rp.get("available"):
369 # Straight after the final replay: the soak continues the wall state its coupled solve
370 # ended with, and the runner keeps only its last one (replay.soak_back refuses another's).
371 say("Soak-back", 0.86)
372 t_soak = time.perf_counter()
373 rp["soak"] = rpl.soak_back(prep, rp)
374 soak_s = time.perf_counter() - t_soak
375 if inline and config is not None and not tripped:
376 # The burn flew its own ascent in every pass; RocketPy flies the settled burn once, for
377 # the apogee, the vehicle's stability and the rail exit (AUDIT D5-C).
378 say("Flight", 0.87)
379 dv_now = _delivered(rp, result, prep.plan.dt) if loop and rp.get("available") else None
380 flown = flt.fly(config, _flight_curve(result, dv_now), _loads(result), prep.ambient_pa,
381 pressurant_kg=_pressurant_kg(result), copv_volume_L=prep.derived.get("copv_volume_L"),
382 liftoff_mass_kg=prep.settings.liftoff_mass_kg,
383 ullage_gas_kg=_safe(_ullage_gas_kg, prep, result),
384 pressurant_gas=prep.derived.get("pressurant_gas"))
385 if not flown.get("ok"):
386 result["events"].append({"t": result["series"]["t"][-1], "kind": "warn", "label": "Flight failed",
387 "detail": str(flown.get("error") or "the flight simulation did not fly")})
388
389 say("Checking", 0.9)
390 if passes:
391 result["passes"] = passes
392 last = passes[-1]
393 # A replay that was asked for and failed leaves the as-built throat's burn: not settled.
394 replay_failed = loop and not rp.get("available")
395 throat_settled = (not feedback and not replay_failed) or (not replay_failed and (
396 (len(passes) >= 2 and last["schedule_change"] < rpl.THROAT_TOLERANCE)
397 or (len(passes) == 1 and last.get("throat_growth") is not None
398 and abs(last["throat_growth"]) < rpl.THROAT_TOLERANCE)))
399 # A flight that failed on the first try leaves the pad's burn, which is settled as such; one
400 # that failed later leaves a burn carrying the previous flight's acceleration, which is not.
401 # Inline, the ascent is solved inside every pass: the acceleration settles with the throat.
402 late_failure = bool(last.get("flight_error")) and bool(last.get("accel_applied"))
403 accel_settled = (not flying) or inline or (bool(last.get("flight_error")) and not late_failure) or \
404 (last.get("accel_change", math.inf) < flt.ACCEL_TOLERANCE)
405 result["converged"] = bool(throat_settled and accel_settled)
406 if not result["converged"] and not tripped:
407 what = []
408 if replay_failed:
409 what.append("the erosion replay failed, so thrust and Pc are the as-built throat's")
410 elif not throat_settled:
411 what.append(f"the throat history still moved {(last.get('schedule_change') or 0.0) * 100:.3f} %")
412 if late_failure:
413 what.append("the flight failed, so this burn carries the previous flight's acceleration")
414 elif not accel_settled:
415 what.append(f"the flight's acceleration still moved {last.get('accel_change', math.inf) * 100:.3f} %")
416 result["events"].append({"t": result["series"]["t"][-1], "kind": "warn",
417 "label": "Burn did not settle",
418 "detail": f"After {len(passes)} passes " + " and ".join(what) + "."})
419 if tripped:
420 # Stopped at a vessel trip (the ``trip`` event says where and why): a failed burn, whatever
421 # the passes did. Every tool that grades burns reads ``tripped`` as failing.
422 result["converged"] = False
423 if result["summary"].get("t0_settled") is False:
424 # The settle to lockup missed the plan's datum (a relief lifting on an over-fill, a dial the
425 # regulator cannot reach): the burn ran from another T-0, which the ``T-0 did not settle``
426 # event states. Not the burn that was asked for, so not a settled answer; it was "done".
427 result["converged"] = False
428 if loop:
429 result["replay"] = rp
430 result["delivered"] = _delivered(rp, result, prep.plan.dt)
431 if flying and tripped:
432 trip = result["tripped"]
433 result["flight"] = {"ok": False, "error": (
434 f"not flown: the burn stopped at a vessel trip ({trip.get('label') or trip.get('vessel')}) at "
435 f"t = {float(trip.get('t') or 0.0):.3f} s, so its thrust curve is not the vehicle's")}
436 elif flying:
437 result["flight"] = _flight_block(prep, flown, ground, passes[-1] if passes else None)
438 if inline and isinstance(result["flight"], dict):
439 result["flight"].update(_inline_block(ascent, flown, flt))
440 dv = result.get("delivered")
441 if dv is not None:
442 # The air the nozzle exhausts into, step by step: the site's on the pad; in flight, the air
443 # at the vehicle's altitude. The plume's expansion state is the exit pressure against it.
444 ambient = [prep.ambient_pa / PSI] * len(dv["t"])
445 trajectory = (flown or {}).get("trajectory") if flying and (result.get("flight") or {}).get("ok") else None
446 if trajectory and config is not None and dv["t"]:
447 import numpy as np
448
449 from engine.core.runner import compute_ambient_pressure_from_elevation
450
451 site = float(getattr(getattr(config, "environment", None), "elevation", 0.0) or 0.0)
452 heights = np.interp(dv["t"], trajectory["t"], trajectory["altitude_m"])
453 ambient = [compute_ambient_pressure_from_elevation(site + float(h)) / PSI for h in heights]
454 dv["ambient_psia"] = ambient
455 if config is not None:
456 # The engine's size and dry mass, for the .eng export (engine/layerx/eng.py): a run does not
457 # keep its config, and the design may have moved by the time the curve is exported.
458 from engine.layerx.eng import motor_header
459
460 try:
461 result["motor"] = motor_header(config)
462 except Exception as exc: # noqa: BLE001 - the export then asks for the dimensions
463 result["motor"] = {"error": f"{type(exc).__name__}: {exc}"}
464 # The drawing's feed against the design's, per side, and the K0 that makes them agree.
465 from engine.layerx.feedfit import design_update, fit_feed
466
467 try:
468 result["feed_fit"] = fit_feed(prep, result, config)
469 if result["feed_fit"].get("available") and result["feed_fit"].get("writable", True):
470 # What "write into the design" sends (PUT /api/config, merged); the UI adds the run id.
471 # None when a side's K0 is not a loss: then there is nothing to write (write_refused).
472 result["feed_fit"]["design_update"] = design_update(result["feed_fit"], run_id="")
473 except Exception as exc: # noqa: BLE001 - a fit that cannot run is reported, the burn stands
474 result["feed_fit"] = {"available": False, "error": f"{type(exc).__name__}: {exc}"}
475 if prep.link is not None and prep.link.sampler is not None:
476 say("Checking", 0.93)
477 result["engine_check"] = engine_check(prep, result)
478 if runner is not None:
479 say("Checking", 0.96)
480 result["cross_check"] = cross_check(prep, result, runner)
481 result["test_mode"] = opts["test_mode"]
482 burn_wall = time.perf_counter() - started - (soak_s or 0.0)
483 if diagnostics:
484 say("Diagnostics", 0.97)
485 t_diag = time.perf_counter()
486 result["diagnostics"], wall = diagnostics_of(prep, result, config, runner, opts)
487 if soak_s is not None:
488 wall["soak"] = soak_s
489 result["provenance"]["diagnostics_wall_s"] = {
490 "burn": burn_wall, "diagnostics": time.perf_counter() - t_diag + (soak_s or 0.0), "blocks": wall}
491 if axial is not None and sidecars is not None:
492 sidecars["axial"] = axial
493 # The grading, the event keys and the record read the finished result; none may cost the burn.
494 try:
495 result["limits"] = grade_limits(result, prep, config, opts)
496 except Exception as exc: # noqa: BLE001 - a run that cannot be graded is still a run
497 result["limits"] = _failed(exc)
498 try:
499 _key_events(result, prep, opts)
500 except Exception as exc: # noqa: BLE001
501 result["events"].append({"t": result["series"]["t"][-1], "kind": "warn", "key": "warn:events",
502 "label": "Event keys", "detail": f"{type(exc).__name__}: {exc}"})
503 try:
504 result["provenance"]["models"] = model_record(result, prep)
505 except Exception as exc: # noqa: BLE001
506 result["provenance"]["models"] = _failed(exc)
507 # The hand-off is the curve the flight flies, so the Flight tab flying it gets the apogee this
508 # run reports.
509 result["timeseries"] = _flight_curve(result, result.get("delivered"))
510 result["provenance"]["wall_s"] = time.perf_counter() - started
511 result["provenance"]["feedtwin_version"] = feedtwin_version
512 result["provenance"]["phase"] = 6 if prep.settings.flight else 3
513 say("Done", 1.0)
514 return _clean(result)
515
516
517# ---------------------------------------------------------------- the inline ascent (D5-C)
518
519
520def _trace_ullage_gas_kg(prep: Prepared, trace: Any) -> Optional[float]:
521 """The gas in both tanks' ullages at the trace's latest sample [kg]: as :func:`_ullage_gas_kg`
522 prices it (the drawing's tank volumes, the twin's fill, pressure and gas temperature,
523 CoolProp's density), read live from the trace instead of a finished result."""
524 import CoolProp.CoolProp as CP
525
526 gas = prep.derived.get("pressurant_gas")
527 volumes = prep.derived.get("tank_volumes_L") or {}
528 if not gas:
529 return None
530 total = 0.0
531 for role in ("oxidiser", "fuel"):
532 tank = prep.roles.get(role, "")
533 v_L = volumes.get(tank)
534 col = trace.tank.get(tank) or {}
535 if not v_L or not col.get("fill_fraction"):
536 return None
537 ullage = v_L * 1e-3 * (1.0 - float(col["fill_fraction"][-1]))
538 rho = CP.PropsSI("D", "P", float(col["pressure_Pa"][-1]), "T", float(col["ullage_temperature_K"][-1]), str(gas))
539 total += rho * max(ullage, 0.0)
540 return total
541
542
543def _inline_factory(prep: Prepared, config: Any) -> Callable[[Any, Dict[str, float], float], Any]:
544 """What :func:`_burn_once` calls at Fire to place the inline ascent's vehicle on the pad: the
545 vehicle the RocketPy flight would fly (``flight.liftoff_mass`` books it the same way), with
546 the loads, the bottle's gas and the ullage gas the trace holds at that instant. A weighed
547 liftoff mass (``settings.liftoff_mass_kg``) wins."""
548 from engine.layerx import flight as flt
549
550 def make(trace: Any, initial: Dict[str, float], clock: float) -> Any:
551 loads = {"oxidiser": float(initial[prep.roles["oxidiser"]]), "fuel": float(initial[prep.roles["fuel"]])}
552 bottle = trace.probes.bottles[0] if trace.probes.bottles else ""
553 mass = (trace.bottle.get(bottle) or {}).get("mass_kg") or []
554 booked = flt.liftoff_mass(config, loads, ambient_pa=prep.ambient_pa,
555 pressurant_kg=float(mass[-1]) if mass and mass[-1] else None,
556 copv_volume_L=prep.derived.get("copv_volume_L"),
557 ullage_gas_kg=_safe(_trace_ullage_gas_kg, prep, trace),
558 pressurant_gas=prep.derived.get("pressurant_gas"),
559 weighed_kg=prep.settings.liftoff_mass_kg)
560 vehicle = flt.InlineAscent.from_config(config, float(booked["value"]), prep.ambient_pa)
561 vehicle.booked = booked # type: ignore[attr-defined] - the run record's liftoff mass
562 return vehicle
563
564 return make
565
566
567def _inline_block(ascent: Optional[Dict[str, Any]], flown: Optional[Dict[str, Any]], flt: Any) -> Dict[str, Any]:
568 """``result.flight`` additions for the inline ascent: the acceleration the final
569 pass burned at, the vehicle it flew, and how far it is from RocketPy's on the same burn."""
570 out: Dict[str, Any] = {"coupling": "inline"}
571 if not ascent or ascent.get("vehicle") is None:
572 out["inline"] = {"available": False, "error": "the burn never reached Fire"}
573 return out
574 vehicle = ascent["vehicle"]
575 sched = {"t": list(ascent["t"]), "accel_m_s2": list(ascent["accel_m_s2"])}
576 booked = getattr(vehicle, "booked", {}) or {}
577 rocketpy = (flown or {}).get("schedule") if (flown or {}).get("ok") else None
578 out["inline"] = {
579 "available": True,
580 "t": sched["t"],
581 "accel_m_s2": sched["accel_m_s2"],
582 "accel_g": [a / G0 for a in sched["accel_m_s2"]],
583 "liftoff_mass_kg": booked.get("value"),
584 "liftoff_mass": booked,
585 "liftoff_time_s": vehicle.liftoff_time_s,
586 "end_altitude_m": vehicle.z,
587 "end_velocity_m_s": vehicle.v,
588 # The largest relative difference against RocketPy's specific force on the settled burn, over
589 # the firing samples: the inline model's own check (AUDIT D5-C; 0.46 % on LE4 He).
590 "vs_rocketpy": _schedule_gap(sched, rocketpy) if rocketpy else None,
591 "vs_rocketpy_basis": ("max |a_inline - a_RocketPy| / a_RocketPy over the firing samples, RocketPy's "
592 "interpolated onto the burn's; Fire (clock 0) and the last sample left out: at Fire "
593 "the inline vehicle is still held (1 g0, no thrust yet) where RocketPy reads the "
594 "first firing sample's thrust, and the last sample is the depletion instant"),
595 "model": {
596 "name": "inline_vertical_1dof",
597 "source": flt.INLINE_SOURCE,
598 "assumptions": [
599 "vertical flight from the pad: the rail's inclination, wind and attitude are ignored",
600 "standard gravity g0 throughout (Somigliana at the site is ~0.11 % less)",
601 "advanced inside every burn pass on that pass's thrust and flow; each step burns at the "
602 "previous sample's specific force (one step of lag, as the outer coupling applies it)",
603 "RocketPy flies the settled burn once afterwards for the apogee, stability and rail exit; it does "
604 "not feed back",
605 ],
606 "inputs": {
607 "liftoff_mass_kg": {"value": booked.get("value"), "unit": "kg",
608 "provenance": booked.get("source") or "flight.liftoff_mass"},
609 },
610 },
611 }
612 return out
613
614
615def _schedule_gap(inline: Dict[str, Any], rocketpy: Dict[str, Any]) -> Optional[float]:
616 """See ``vs_rocketpy_basis`` in :func:`_inline_block`."""
617 import numpy as np
618
619 t = np.asarray(inline["t"], float)
620 a = np.asarray(inline["accel_m_s2"], float)
621 ref = np.interp(t, np.asarray(rocketpy["t"], float), np.asarray(rocketpy["accel_m_s2"], float))
622 keep = (t > 1e-9) & (np.arange(len(t)) < len(t) - 1) & (np.abs(ref) > 0.0)
623 if not keep.any():
624 return None
625 return float(np.max(np.abs(a[keep] - ref[keep]) / np.abs(ref[keep])))
626
627
628# ---------------------------------------------------------------- diagnostics (DATA-CONTRACT 3)
629
630#: The diagnostics blocks, in the order they are computed (each may read the ones before it).
631DIAGNOSTIC_KEYS = ("stability", "hardware", "ladder", "regulator", "solenoids", "pressurant", "saturation",
632 "cavitation", "injector", "thrust_shape", "ledger", "start", "outflow", "shutdown",
633 "water_hammer", "vv")
634#: The keys ``engine.layerx.diag.ledger.build_feed_diagnostics`` produces together.
635FEED_KEYS = ("ladder", "regulator", "solenoids", "pressurant", "saturation", "cavitation", "injector",
636 "thrust_shape", "ledger")
637
638
639def _failed(exc: BaseException) -> Dict[str, Any]:
640 return {"available": False, "error": f"{type(exc).__name__}: {exc}"}
641
642
643def diagnostics_of(prep: Prepared, result: Dict[str, Any], config: Any, runner: Any,
644 opts: Optional[Dict[str, Any]] = None) -> tuple:
645 """Every DATA-CONTRACT 3 block for a settled burn: ``(diagnostics, wall_s per block)``.
646
647 Each block is its module's (``engine/layerx/diag``), called on the finished result; the
648 order lets a block read the ones before it (the shutdown reads the gas-ingestion onsets, the
649 water hammer the start's arrival). A block that raises is ``{available: false, error}``; none
650 can take the burn down. ``opts`` are :func:`engine.layerx.prepare.options` (the run's)."""
651 from engine.layerx.prepare import options
652
653 opts = opts or options(prep.settings)
654 out: Dict[str, Any] = {}
655 wall: Dict[str, float] = {}
656 link = prep.link
657 sampler = getattr(link, "sampler", None) if link is not None else None
658
659 def block(name: str, fn: Callable[[], Any]) -> Any:
660 t0 = time.perf_counter()
661 try:
662 value = fn()
663 except Exception as exc: # noqa: BLE001 - a diagnostic never takes the burn with it
664 value = _failed(exc)
665 wall[name] = time.perf_counter() - t0
666 return value
667
668 from engine.layerx.diag import hardware as hw
669 from engine.layerx.diag import ledger as fd
670 from engine.layerx.diag import outflow as of
671 from engine.layerx.diag import shutdown as sd
672 from engine.layerx.diag import stability as stab
673 from engine.layerx.diag import start as st
674 from engine.layerx.diag import vv
675 from engine.layerx.diag import waterhammer as wh
676
677 # The chug margin on the chosen feed basis; the other basis rides along as ``other_basis``.
678 out["stability"] = block("stability", lambda: stab.stability_block(
679 prep, result, config, basis=opts["chug_basis"], eroded=opts["chug_eroded"]))
680 out["hardware"] = block("hardware", lambda: hw.hardware_block(
681 prep, result.get("replay"), result.get("delivered"), result.get("series")))
682 feed = block("feed", lambda: fd.build_feed_diagnostics(result, prep, config, runner=runner, sampler=sampler))
683 for key in FEED_KEYS:
684 value = feed.get(key) if isinstance(feed, dict) and feed.get("available", True) is not False else None
685 out[key] = value if value is not None else (feed if isinstance(feed, dict) and feed.get("available") is False
686 else {"available": False, "error": f"{key}: not built"})
687 out["start"] = block("start", lambda: st.start_from_run(prep, result, config, st.StartSettings(
688 fuel_lead_s=opts["fuel_lead_s"], valve_travel_s=opts["valve_travel_s"])))
689 out["outflow"] = block("outflow", lambda: of.outflow_from_run(prep, result, of.OutflowSettings(
690 outlet_d_mm=tuple(opts["outlet_d_mm"]))))
691 # A burn a vessel trip stopped ended with both tanks wet: its tail-off is a cutoff at the trip, the
692 # mains taken as commanded shut there (the stand's own abort sequence is not modelled).
693 trip = result.get("tripped") if isinstance(result.get("tripped"), dict) else None
694 cut = float(trip["t"]) if trip and isinstance(trip.get("t"), (int, float)) and trip["t"] > 0 else None
695
696 def shutdown() -> Any:
697 # No tank ran dry, so the burn time is the trip's and not a dry-out: hidden from the block,
698 # which otherwise reads it as one (a gas-ingestion onset before the trip still counts).
699 view = result if cut is None else {
700 **result, "summary": {**(result.get("summary") or {}), "burn_time_s": None, "depletion_s": None}}
701 value = sd.shutdown_from_run(prep, view, config, sd.ShutdownSettings(cutoff_s=cut),
702 outflow=out["outflow"] if isinstance(out["outflow"], list) else None)
703 if cut is not None and isinstance(value, dict) and value.get("first_dry_basis") == "timed cutoff with both tanks wet":
704 value["first_dry_basis"] = (f"the vessel trip at t = {cut:.3f} s, taken as the mains commanded shut there "
705 "with both tanks wet (the stand's abort sequence is not modelled)")
706 return value
707
708 out["shutdown"] = block("shutdown", shutdown)
709 out["water_hammer"] = block("water_hammer", lambda: wh.waterhammer_from_run(
710 prep, result, config, start=out["start"] if isinstance(out["start"], dict) else None))
711 # Last: the balances read the whole result, the network record when there is one.
712 out["vv"] = block("vv", lambda: vv.check(result, prep))
713 return out, wall
714
715
716# ---------------------------------------------------------------- limits (DATA-CONTRACT 1)
717
718#: Limits a new diagnostic grades, held at amber at worst until the team has reviewed them: their
719#: ratings and thresholds are estimates (coordinator decision, 2026-10-03). ``key`` prefixes.
720REVIEW_PENDING = ("water_hammer", "saturation", "cavitation", "separation", "regulator_wide_open", "outflow",
721 "conservation")
722REVIEW_NOTE = ("Graded amber at worst until the team has reviewed this check: the rating or threshold it is "
723 "held against is an estimate (Layer X integration, 2026-10-03).")
724#: What the graded chug margin is on, and why: AUDIT D7, the user's decision.
725CHUG_DECISION = ("D7: the graded chug margin stays on today's basis (EngineDesign's feed_system) and today's "
726 "whole-burn minimum, start included, until the user decides; the drawing basis and the "
727 "settled minimum (from the first full-flow step) are shown alongside.")
728
729
730def _review_pending(key: str) -> bool:
731 return any(key == p or key.startswith(p + "_") for p in REVIEW_PENDING)
732
733
734def grade_limits(result: Dict[str, Any], prep: Any = None, config: Any = None,
735 opts: Optional[Dict[str, Any]] = None, *,
736 meop_psi: Optional[Dict[str, float]] = None) -> List[Dict[str, Any]]:
737 """``result["limits"]``: :func:`engine.layerx.diag.limits.grade` with the run's choices.
738
739 * **Chug** (D7). On the ``config`` basis (the default) the graded ``chug_margin`` is today's:
740 the delivered whole-burn minimum, start included, as before the diagnostics existed. The
741 stability block's settled minimum is added beside it as ``chug_margin_settled`` and the
742 other basis as ``chug_margin_other_basis``, both ``info``. On ``drawing`` the stability
743 block is graded as ``diag.limits`` grades it (the settled minimum on the drawing basis).
744 * **Review pending.** A limit from a new diagnostic (:data:`REVIEW_PENDING`) grades ``warn``
745 at worst; one that would have been ``bad`` says so (``capped_from``).
746 * The design's tank cap grades ``warn`` (D11), as ``diag.limits`` already grades it.
747 """
748 from engine.layerx.diag.limits import grade
749
750 if opts is None:
751 from engine.layerx.prepare import options
752
753 opts = options(prep.settings) if prep is not None else {"chug_basis": "config"}
754 diag = result.get("diagnostics") if isinstance(result.get("diagnostics"), dict) else None
755 stab = (diag or {}).get("stability")
756 on_drawing = opts.get("chug_basis") == "drawing"
757 if on_drawing or not isinstance(stab, dict) or stab.get("available") is False:
758 entries = grade(result, prep, config, meop_psi=meop_psi)
759 else:
760 # Today's chug grade: the result as it was before the stability block existed.
761 without = {**result, "diagnostics": {k: v for k, v in diag.items() if k != "stability"}}
762 entries = [e for e in grade(without, prep, config, meop_psi=meop_psi)]
763 alongside = [e for e in grade({**result, "diagnostics": {"stability": stab}}, prep, config, meop_psi=meop_psi)
764 if str(e.get("key", "")).startswith("chug_margin")]
765 at = next((i for i, e in enumerate(entries) if e.get("key") == "chug_margin"), len(entries) - 1)
766 extra = []
767 for e in alongside:
768 if e.get("key") == "chug_margin":
769 e = {**e, "key": "chug_margin_settled", "label": "Chug margin, settled (from full flow)",
770 "grade": "info", "decision": "D7",
771 "hint": (e.get("hint") or "") + " Reported, not graded: " + CHUG_DECISION}
772 elif e.get("key") == "chug_margin_start":
773 continue # the graded minimum above already includes the start window
774 else:
775 e = {**e, "decision": "D7"}
776 extra.append(e)
777 for e in entries:
778 if e.get("key") == "chug_margin":
779 e["decision"] = "D7"
780 e["hint"] = (e.get("hint") or "") + " " + CHUG_DECISION
781 entries[at + 1:at + 1] = extra
782 out = []
783 for e in entries:
784 e = dict(e)
785 if _review_pending(str(e.get("key", ""))):
786 e["review_pending"] = True
787 if e.get("grade") == "bad":
788 e["capped_from"] = "bad"
789 e["grade"] = "warn"
790 e["hint"] = (e.get("hint") or "") + " " + REVIEW_NOTE
791 out.append(e)
792 return out
793
794
795# ---------------------------------------------------------------- events with stable keys
796
797def _key_events(result: Dict[str, Any], prep: Prepared, opts: Dict[str, Any]) -> None:
798 """Give every event a stable ``key`` (DATA-CONTRACT 4) and add the ones the diagnostics and
799 the limits know: the fuel lead and ignition (start), the graded chug minimum, burnout and a
800 vessel trip (kind ``fail``; :func:`reduce_trace` writes it first). Warnings are ``warn:<n>``,
801 numbered in time order."""
802 events: List[Dict[str, Any]] = result.setdefault("events", [])
803 diag = result.get("diagnostics") or {}
804 have = {e.get("key") for e in events if e.get("key")}
805
806 def add(key: str, t: Optional[float], kind: str, label: str, detail: str) -> None:
807 if key in have or t is None or not math.isfinite(float(t)):
808 return
809 events.append({"t": float(t), "kind": kind, "label": label, "detail": detail, "key": key})
810 have.add(key)
811
812 start = diag.get("start") if isinstance(diag.get("start"), dict) else {}
813 if start.get("available", True) is not False:
814 lead = start.get("fuel_lead_s")
815 if lead:
816 add("fuel_lead", -float(lead), "fire", "Fuel lead", f"Fuel main commanded {float(lead) * 1e3:.0f} ms before "
817 "the LOX main (the start diagnostic's sequence; the burn opens both at Fire)")
818 ign = start.get("ignition_s")
819 if ign is not None:
820 add("ignition", ign, "fire", "Ignition",
821 f"Both manifolds primed (LOX {float(start.get('prime_ox_s') or 0) * 1e3:.1f} ms, fuel "
822 f"{float(start.get('prime_fuel_s') or 0) * 1e3:.1f} ms after Fire); start transient model")
823 chug = next((e for e in result.get("limits") or [] if e.get("key") == "chug_margin"), None)
824 if chug and chug.get("t_worst") is not None and chug.get("value") is not None:
825 add("min_chug", chug["t_worst"], "min", "Lowest chug margin",
826 f"{float(chug['value']):.3f} ({chug.get('basis') or 'graded basis'})")
827 s = result.get("summary") or {}
828 trip = result.get("tripped")
829 before_fire = isinstance(trip, dict) and isinstance(trip.get("t"), (int, float)) and trip["t"] < 0.0
830 if s.get("burn_time_s") is not None and not before_fire: # a trip in the lead-in: nothing burned out
831 side = {"oxidiser": "LOX", "fuel": "fuel"}.get(s.get("depleted_side") or "", "")
832 add("burnout", s["burn_time_s"], "end", "Burnout",
833 f"{side} tank dry" if side else
834 "Stopped by the vessel trip, with propellant in both tanks" if isinstance(trip, dict) else "End of the burn")
835 if isinstance(trip, dict) and trip.get("t") is not None:
836 add("trip", trip["t"], "fail", f"Vessel trip ({trip.get('label') or trip.get('vessel') or 'vessel'})",
837 str(trip.get("message") or f"{trip.get('p_psia')} psia against {trip.get('mawp_psia')} psia"))
838 events.sort(key=lambda e: e["t"])
839 n = 0
840 for e in events:
841 if not e.get("key"):
842 if e.get("kind") == "warn":
843 e["key"] = f"warn:{n}"
844 n += 1
845 else:
846 e["key"] = str(e.get("kind") or "event")
847
848
849# ---------------------------------------------------------------- the run record's models
850
851def model_record(result: Dict[str, Any], prep: Optional[Prepared] = None) -> List[Dict[str, Any]]:
852 """Every model block in the result, flattened for the run record (DATA-CONTRACT rules: each
853 block that rests on a model carries ``model: {name, source, assumptions, inputs}``):
854 ``[{block, name, source, assumptions, inputs}]``, so Record lists every assumption, default
855 and source in one place. Also the opt-in card chamber's (D4-B) when it was on."""
856 found: List[Dict[str, Any]] = []
857
858 def walk(node: Any, path: str) -> None:
859 if isinstance(node, dict):
860 m = node.get("model")
861 if isinstance(m, dict) and (m.get("name") or m.get("source")):
862 found.append({"block": path, "name": m.get("name"), "source": m.get("source"),
863 "assumptions": list(m.get("assumptions") or []), "inputs": m.get("inputs") or {}})
864 for k, v in node.items():
865 if k != "model" and isinstance(v, (dict, list)):
866 walk(v, f"{path}.{k}" if path else str(k))
867 elif isinstance(node, list):
868 for i, v in enumerate(node):
869 if isinstance(v, (dict, list)):
870 walk(v, f"{path}[{i}]")
871
872 # The replay's own soak model is the hardware block's soak, already walked there.
873 for key in ("diagnostics", "flight"):
874 if isinstance(result.get(key), (dict, list)):
875 walk(result[key], key)
876 chamber = getattr(getattr(prep, "link", None), "chamber", None) if prep is not None else None
877 if chamber is not None and hasattr(chamber, "model_block"):
878 try:
879 m = chamber.model_block()
880 found.append({"block": "engine_card.chamber", "name": m.get("name"), "source": m.get("source"),
881 "assumptions": list(m.get("assumptions") or []), "inputs": m.get("inputs") or {}})
882 except Exception: # noqa: BLE001 - the record lists what it can
883 pass
884 return found
885
886def _loads(result: Dict[str, Any]) -> Dict[str, float]:
887 s = result["summary"]
888 return {"oxidiser": float(s["ox"]["loaded_kg"]), "fuel": float(s["fuel"]["loaded_kg"])}
889
890
891def _delivered(rp: Dict[str, Any], result: Dict[str, Any], dt: float) -> Optional[Dict[str, Any]]:
892 """The replay's answers on the twin's steps (``replay.delivered``), plus its impulse to
893 depletion: the replay's impulse carried to depletion in the same proportion as the twin's,
894 so a comparison graded on the replay is graded on a smooth number too."""
895 from engine.layerx import replay as rpl
896
897 dv = rpl.delivered(rp, result["series"], dt)
898 if dv is None:
899 return None
900 s = result["summary"]
901 twin, to_dep = s.get("total_impulse_Ns"), s.get("impulse_to_depletion_Ns")
902 dv["summary"]["impulse_to_depletion_Ns"] = (dv["summary"]["total_impulse_Ns"] * to_dep / twin
903 if twin and to_dep else None)
904 return dv
905
906
907def _flight_curve(result: Dict[str, Any], dv: Optional[Dict[str, Any]]) -> Dict[str, Any]:
908 """The burn as the flight flies it and as Forward and the Flight tab receive it: the
909 Time-Series payload. It starts with a sample at Fire and ends on the last step, which the
910 burn already cut to land on depletion (BurnPlan.end_on_depletion), so its impulse moves
911 smoothly with the design and nothing past the burn's own end is flown."""
912 from engine.layerx import replay as rpl
913
914 return rpl.timeseries_payload({**result, "delivered": dv})
915
916
917def _safe(fn: Callable[..., Any], *args: Any) -> Any:
918 try:
919 return fn(*args)
920 except Exception: # noqa: BLE001 - an estimate that cannot be made falls back to the flight's own
921 return None
922
923
924def _ullage_gas_kg(prep: Prepared, result: Dict[str, Any]) -> Optional[float]:
925 """The gas already in both tanks' ullages at Fire [kg]: the drawing's tank volumes, the twin's
926 fill, pressure and gas temperature, CoolProp's density. It flies with the vehicle; the flight's
927 own figure comes from the config's (smaller) tanks."""
928 import CoolProp.CoolProp as CP
929
930 gas = prep.derived.get("pressurant_gas")
931 volumes = prep.derived.get("tank_volumes_L") or {}
932 series = result["series"]
933 fire = next((i for i, f in enumerate(series["firing"]) if f), None)
934 if not gas or fire is None:
935 return None
936 total = 0.0
937 for side, role in (("ox", "oxidiser"), ("fuel", "fuel")):
938 v_L = volumes.get(prep.roles.get(role, ""))
939 s = series[side]
940 if not v_L or not s.get("fill_fraction"):
941 return None
942 i = max(fire - 1, 0) # the last lead-in sample: the tank as Fire found it
943 ullage = v_L * 1e-3 * (1.0 - float(s["fill_fraction"][i]))
944 rho = CP.PropsSI("D", "P", float(s["tank_psia"][i]) * PSI, "T", float(s["ullage_K"][i]), str(gas))
945 total += rho * max(ullage, 0.0)
946 return total
947
948
949def _pressurant_kg(result: Dict[str, Any]) -> Optional[float]:
950 """The bottle's gas at the first firing step."""
951 series = result["series"]
952 mass = series.get("copv_mass_kg") or []
953 fire = next((i for i, f in enumerate(series["firing"]) if f), None)
954 return float(mass[fire]) if fire is not None and fire < len(mass) and mass[fire] else None
955
956
957def _pass_figures(result: Dict[str, Any], dv: Optional[Dict[str, Any]]) -> Dict[str, Any]:
958 """What a pass delivered, for the pad-against-flight comparison."""
959 s = result["summary"]
960 d = (dv or {}).get("summary") or {}
961 out = {
962 "total_impulse_Ns": d.get("total_impulse_Ns", s.get("total_impulse_Ns")),
963 "mean_thrust_N": d.get("mean_thrust_N", s.get("mean_thrust_N")),
964 "pc_mean_psia": d.get("pc_mean_psia", s.get("pc_mean_psia")),
965 "isp_mean_s": d.get("isp_mean_s", s.get("isp_mean_s")),
966 "burn_time_s": s.get("burn_time_s"),
967 "of_mean": s.get("of_mean"),
968 "ox_dp_injector_min_psi": None,
969 "fuel_dp_injector_min_psi": None,
970 }
971 series = result["series"]
972 fire = [i for i, f in enumerate(series["firing"]) if f]
973 settled = [i for i in fire if series["t"][i] >= series["t"][fire[0]] + 0.2] if fire else []
974 for side, key in (("ox", "ox_dp_injector_min_psi"), ("fuel", "fuel_dp_injector_min_psi")):
975 vals = [series[side]["dp_injector_psi"][i] for i in settled if series[side]["dp_injector_psi"][i] is not None]
976 out[key] = min(vals) if vals else None
977 inlet = [series[side]["manifold_psia"][i] for i in settled if series[side].get("manifold_psia")]
978 out[f"{side}_manifold_mean_psia"] = (sum(inlet) / len(inlet)) if inlet else None
979 return out
980
981
982def _flight_block(prep: Prepared, flown: Optional[Dict[str, Any]], ground: Optional[Dict[str, Any]],
983 last: Optional[Dict[str, Any]]) -> Dict[str, Any]:
984 if flown is None:
985 return {"ok": False, "error": "the burn was not flown"}
986 if not flown.get("ok"):
987 return flown
988 out = dict(flown)
989 final = {k: last.get(k) for k in (ground or {})} if last else None
990 out["pad"] = ground
991 out["in_flight"] = final
992 out["vehicle_lines"] = prep.vehicle_lines
993 return out
994
995
996def _impulse_to_depletion(trace: Any, fire_idx: List[int], steps: Any, ox_tank: str, fuel_tank: str,
997 ch: Dict[str, List[float]]) -> tuple:
998 """The impulse the load delivers if the burn ends exactly when the first tank empties [N·s],
999 and when that is [s from Fire]: ``(impulse, t)``, or ``(None, None)`` for a burn too short.
1000
1001 The burn stops on a step: the step in which a tank goes dry counts in full, so ``total_impulse_Ns``
1002 moves by up to a step's worth (~330 N·s at 6.7 kN and 50 ms) as a design change slides the
1003 depletion across a step boundary. Any comparison between designs reads that as signal. This
1004 does not. It is the burn's own impulse per kilogram up to its last full step, times
1005 everything it could burn: what it burned up to that step, plus what is left at the O/F it was
1006 running, until the first tank empties.
1007 """
1008 if len(fire_idx) < 3:
1009 return None, None
1010 body = fire_idx[:-1]
1011 k = body[-1]
1012 step = (lambda i: float(steps)) if isinstance(steps, (int, float)) else (lambda i: float(steps[i]))
1013 impulse = sum(ch["thrust_N"][i] * step(i) for i in body)
1014 burned = sum((ch["mdot_oxidiser"][i] + ch["mdot_fuel"][i]) * step(i) for i in body)
1015 mo, mf = ch["mdot_oxidiser"][k], ch["mdot_fuel"][k]
1016 if burned <= 0.0 or mo <= 0.0 or mf <= 0.0:
1017 return None, None
1018 of = mo / mf
1019 left_o = max(trace.tank[ox_tank]["liquid_mass_kg"][k], 0.0)
1020 left_f = max(trace.tank[fuel_tank]["liquid_mass_kg"][k], 0.0)
1021 rest = min(left_f, left_o / of) * (1.0 + of)
1022 return impulse / burned * (burned + rest), float(trace.t[k]) + rest / (mo + mf)
1023
1024
1025def _dump_coefficients(prep: Prepared) -> Dict[str, float]:
1026 """``K_exit / (2 rho A_exit^2)`` per side [Pa/(kg/s)^2]: the dump of the feed line's velocity
1027 head into the manifold, as EngineDesign prices it (its config density and exit bore). Zero
1028 for the twin's native engine, whose injector leg starts at the line exit with no manifold."""
1029 if prep.link is None or prep.link.mode == "native" or prep.link.sampler is None:
1030 return {}
1031 import math as _m
1032
1033 config = prep.link.sampler.config
1034 feeds = config.feed_system
1035 out: Dict[str, float] = {}
1036 for side, key in (("oxidiser", "oxidizer"), ("fuel", "fuel")):
1037 feed = feeds[key] if isinstance(feeds, dict) else getattr(feeds, key)
1038 fluid = config.fluids[key]
1039 rho = float(fluid.density if hasattr(fluid, "density") else fluid["density"])
1040 d_exit = getattr(feed, "d_exit", None)
1041 area = _m.pi * (float(d_exit) / 2.0) ** 2 if d_exit else float(feed.A_hydraulic)
1042 out[side] = float(getattr(feed, "K_exit", 0.0) or 0.0) / (2.0 * rho * area * area)
1043 return out
1044
1045
1046def _card_outside(prep: Prepared, series: Dict[str, Any], fire_idx: List[int]) -> Optional[int]:
1047 """Firing steps at which the burn asked the engine card about a point outside what it was
1048 fitted to: an injector (flow, inlet pressure) or a chamber (O/F, flow) beyond its sample hull.
1049 ``None`` when there is no card."""
1050 card = prep.link.card if prep.link is not None else None
1051 if card is None:
1052 return None
1053 out = 0
1054 for i in fire_idx:
1055 mo, mf = series["ox"]["mdot"][i], series["fuel"]["mdot"][i]
1056 p_o, p_f = series["ox"]["inlet_psia"][i] * PSI, series["fuel"]["inlet_psia"][i] * PSI
1057 if not (card.oxidiser.covers(mo, p_o) and card.fuel.covers(mf, p_f)
1058 and card.chamber.covers(mo / mf if mf > 0 else 0.0, mo + mf)):
1059 out += 1
1060 return out
1061
1062
1063#: Instrument types that read temperature; every other instrument reads pressure.
1064THERMAL_INSTRUMENTS = frozenset({"TC", "RTD"})
1065
1066
1067def _instrument_series(prep: Prepared, trace: Any) -> Dict[str, Dict[str, Any]]:
1068 out: Dict[str, Dict[str, Any]] = {}
1069 built = prep.model.built if prep.model is not None else None
1070 for inst in getattr(built, "instruments", ()) or ():
1071 thermal = str(inst.type).upper() in THERMAL_INSTRUMENTS
1072 dome = None if thermal else (getattr(trace, "dome_readings", None) or {}).get(inst.node)
1073 column = dome if dome and len(dome) == len(trace.t) else (trace.temperature if thermal else trace.pressure).get(inst.node)
1074 if not column:
1075 continue
1076 out[inst.id] = {"tag": inst.tag or inst.id, "type": inst.type, "unit": "K" if thermal else "psia",
1077 "values": list(column) if thermal else _psia(column)}
1078 return out
1079
1080
1081def reduce_trace(prep: Prepared, trace: Any, initial: Dict[str, float]) -> Dict[str, Any]:
1082 ox_tank, fuel_tank = prep.roles["oxidiser"], prep.roles["fuel"]
1083 probes = trace.probes
1084 # The clock sums its steps and carries their float noise (T-0 read -6.9e-17 s in the events and the
1085 # CSV): a sample within 1e-12 s of a whole nanosecond is that nanosecond. A step cut to land on
1086 # depletion or a trip is further off than that and keeps its own time.
1087 trace.t[:] = [r + 0.0 if abs(x - (r := round(x, 9))) < 1e-12 else x for x in trace.t] # + 0.0: never -0.0
1088 n = len(trace.t)
1089 firing = trace.firing
1090 dt = prep.plan.dt
1091 # Each step's own length: the plan's, except a last step cut to land on depletion
1092 # (BurnPlan.end_on_depletion).
1093 steps = [dt] + [trace.t[i] - trace.t[i - 1] for i in range(1, n)]
1094 ch = trace.chamber
1095 inlet = {side: trace.pressure[node] for side, node in probes.injector_inlet.items()}
1096
1097 dumps = _dump_coefficients(prep)
1098
1099 def side_series(side: str, tank: str, mdot_key: str) -> Dict[str, List[float]]:
1100 tank_col = trace.tank[tank]
1101 # The line exit is the twin's last node; the still manifold is downstream of the dump of
1102 # the line's velocity head into it (EngineDesign's Borda term, K_exit rho v^2/2). Injector
1103 # stiffness is manifold to chamber, as EngineDesign and the chug model define it.
1104 k = dumps.get(side, 0.0)
1105 dump = [k * ch[mdot_key][i] ** 2 if firing[i] else 0.0 for i in range(n)]
1106 manifold = [inlet[side][i] - dump[i] for i in range(n)]
1107 dp = [
1108 (manifold[i] - ch["pressure_Pa"][i]) if firing[i] else 0.0 for i in range(n)
1109 ]
1110 stiffness = [
1111 (dp[i] / ch["pressure_Pa"][i]) if firing[i] and ch["pressure_Pa"][i] > 0 else 0.0
1112 for i in range(n)
1113 ]
1114 return {
1115 "tank_psia": _psia(tank_col["pressure_Pa"]),
1116 "outlet_psia": _psia(tank_col["outlet_pressure_Pa"]),
1117 "inlet_psia": _psia(inlet[side]),
1118 "dump_psi": _psia(dump),
1119 "manifold_psia": _psia(manifold),
1120 "dp_injector_psi": _psia(dp),
1121 "stiffness": stiffness,
1122 "mdot": list(ch[mdot_key]),
1123 "liquid_kg": list(tank_col["liquid_mass_kg"]),
1124 "ullage_K": list(tank_col["ullage_temperature_K"]),
1125 "liquid_K": list(tank_col["liquid_temperature_K"]),
1126 "fill_fraction": list(tank_col["fill_fraction"]),
1127 }
1128
1129 bottle_id = probes.bottles[0] if probes.bottles else ""
1130 bottle = trace.bottle.get(bottle_id, {})
1131 series = {
1132 "t": list(trace.t),
1133 "dt": steps,
1134 "firing": list(firing),
1135 "converged": list(trace.converged),
1136 "copv_psia": _psia(bottle.get("pressure_Pa", [])),
1137 "copv_mass_kg": list(bottle.get("mass_kg", [])),
1138 "copv_wall_K": list(bottle.get("wall_temperature_K", [])),
1139 "regulators": {
1140 reg: {"label": probes.regulator_label.get(reg, reg), "outlet_psia": _psia(trace.pressure.get(node, []))}
1141 for reg, node in probes.regulator_outlet.items()
1142 if node in trace.pressure
1143 },
1144 # What each instrument on the drawing reads, in its own quantity: transducers in psia,
1145 # thermocouples and RTDs in K. Keyed by the drawing's id; ``tag`` is what the stand calls it.
1146 "instruments": _instrument_series(prep, trace),
1147 "ox": side_series("oxidiser", ox_tank, "mdot_oxidiser"),
1148 "fuel": side_series("fuel", fuel_tank, "mdot_fuel"),
1149 "chamber": {
1150 "pc_psia": _psia(ch["pressure_Pa"]),
1151 "mr": list(ch["mixture_ratio"]),
1152 "thrust_N": list(ch["thrust_N"]),
1153 "isp_s": list(ch["isp_s"]),
1154 "cstar": list(ch["cstar"]),
1155 "extrapolated": list(ch["extrapolated"]),
1156 },
1157 }
1158
1159 fire_idx = [i for i in range(n) if firing[i]]
1160 lead_idx = [i for i in range(n) if not firing[i]]
1161 t0 = lead_idx[-1] if lead_idx else 0
1162 settled_idx = [i for i in fire_idx if trace.t[i] >= 0.2] or fire_idx
1163 settled_set = set(settled_idx)
1164
1165 def pick(values: List[float], idx: List[int], fn: Callable[[List[float]], float]) -> Optional[float]:
1166 chosen = [values[i] for i in idx if math.isfinite(values[i])]
1167 return fn(chosen) if chosen else None
1168
1169 thrust = ch["thrust_N"]
1170 impulse = sum(thrust[i] * steps[i] for i in fire_idx)
1171 used_ox = initial[ox_tank] - trace.tank[ox_tank]["liquid_mass_kg"][-1]
1172 used_fuel = initial[fuel_tank] - trace.tank[fuel_tank]["liquid_mass_kg"][-1]
1173 used = used_ox + used_fuel
1174 end = trace.end
1175 burn_time = end.depleted_s if end and end.depleted_s is not None else (trace.t[fire_idx[-1]] if fire_idx else 0.0)
1176 ox_s, fuel_s = series["ox"], series["fuel"]
1177
1178 def tank_block(s: Dict[str, List[float]], tank: str) -> Dict[str, Any]:
1179 return {
1180 "t0_psia": s["tank_psia"][t0],
1181 "min_psia": pick(s["tank_psia"], fire_idx, min),
1182 "end_psia": s["tank_psia"][-1],
1183 "ignition_dip_psi": (s["tank_psia"][t0] - (pick(s["tank_psia"], [i for i in fire_idx if trace.t[i] <= 0.5], min) or s["tank_psia"][t0])),
1184 "inlet_mean_psia": pick(s["inlet_psia"], settled_idx, lambda v: sum(v) / len(v)),
1185 "dp_injector_min_psi": pick(s["dp_injector_psi"], settled_idx, min),
1186 "stiffness_min": pick(s["stiffness"], settled_idx, min),
1187 # The first 0.2 s, which stiffness_min leaves out: ignition, where chug starts if it does.
1188 "stiffness_min_ignition": pick(s["stiffness"], [i for i in fire_idx if i not in settled_set], min),
1189 # The highest the vessel sees, hold and burn: what its rating must clear.
1190 "peak_psia": max(s["tank_psia"]) if s["tank_psia"] else None,
1191 "stiffness_mean": pick(s["stiffness"], settled_idx, lambda v: sum(v) / len(v)),
1192 "loaded_kg": initial[tank],
1193 "residual_kg": trace.tank[tank]["liquid_mass_kg"][-1],
1194 }
1195
1196 pc = series["chamber"]["pc_psia"]
1197 mr = ch["mixture_ratio"]
1198 depleted = bool(trace.end and trace.end.depleted_s is not None)
1199 to_depletion, depletion_s = (_impulse_to_depletion(trace, fire_idx, steps, ox_tank, fuel_tank, ch)
1200 if depleted else (None, None))
1201 summary = {
1202 "burn_time_s": burn_time,
1203 "depleted_tank": end.tank if end else "",
1204 "depleted_side": ("oxidiser" if end and end.tank == ox_tank else "fuel" if end and end.tank == fuel_tank else ""),
1205 "total_impulse_Ns": impulse,
1206 "mean_thrust_N": impulse / burn_time if burn_time else None,
1207 "peak_thrust_N": pick(thrust, fire_idx, max),
1208 "min_thrust_N": pick(thrust, settled_idx, min),
1209 "thrust_t0_N": thrust[fire_idx[0]] if fire_idx else None,
1210 "pc_mean_psia": pick(pc, settled_idx, lambda v: sum(v) / len(v)),
1211 "pc_min_psia": pick(pc, settled_idx, min),
1212 "pc_max_psia": pick(pc, settled_idx, max),
1213 "of_mean": (used_ox / used_fuel) if used_fuel > 0 else None,
1214 "of_min": pick(mr, settled_idx, min),
1215 "of_max": pick(mr, settled_idx, max),
1216 "isp_mean_s": (impulse / (used * G0)) if used > 0 else None,
1217 "propellant_used_kg": used,
1218 "impulse_to_depletion_Ns": to_depletion,
1219 "depletion_s": depletion_s,
1220 "ox": tank_block(ox_s, ox_tank),
1221 "fuel": tank_block(fuel_s, fuel_tank),
1222 "copv_t0_psia": series["copv_psia"][t0] if series["copv_psia"] else None,
1223 "copv_end_psia": series["copv_psia"][-1] if series["copv_psia"] else None,
1224 "copv_used_kg": (series["copv_mass_kg"][t0] - series["copv_mass_kg"][-1]) if series["copv_mass_kg"] else None,
1225 "regulators": {
1226 reg: {"label": v["label"], "t0_psia": v["outlet_psia"][t0], "min_psia": pick(v["outlet_psia"], fire_idx, min)}
1227 for reg, v in series["regulators"].items()
1228 },
1229 "steps": end.steps if end else n,
1230 "failed_steps": end.failed_steps if end else sum(1 for c in trace.converged if not c),
1231 "extrapolated_steps": sum(1 for i in fire_idx if ch["extrapolated"][i] > 0.5),
1232 "engine_model": prep.link.mode if prep.link else "",
1233 "card_outside_steps": _card_outside(prep, series, fire_idx),
1234 "t0_settled": trace.t0_settled,
1235 "dt": dt,
1236 }
1237
1238 # ---- events ---------------------------------------------------------------
1239 tripped = _trip_of(trace)
1240 events: List[Dict[str, Any]] = []
1241 # Stamped where its numbers come from: the last lead-in sample, at Fire.
1242 events.append({"t": trace.t[t0], "kind": "t0", "key": "t0", "label": "T-0 state",
1243 "detail": (f"Tanks at lockup ({ox_s['tank_psia'][t0]:.1f} / {fuel_s['tank_psia'][t0]:.1f} psia), "
1244 f"bottle {summary['copv_t0_psia']:.0f} psia") if summary["copv_t0_psia"] else "Primed"})
1245 if not trace.t0_settled:
1246 # What it asked for against what it started from, so a burn whose T-0 a relief or a regulator
1247 # moved (a fill over the bottle's relief vented 3260 psi in the settle) cannot read as the burn asked.
1248 asked_lockup, asked_fill = (prep.derived or {}).get("target_lockup_psia"), (prep.derived or {}).get("copv_psia")
1249 started = (f" Asked for a {asked_lockup:.1f} psia lockup from a {asked_fill:.0f} psia bottle; T-0 is LOX "
1250 f"{ox_s['tank_psia'][t0]:.1f} / fuel {fuel_s['tank_psia'][t0]:.1f} psia"
1251 + (f", bottle {summary['copv_t0_psia']:.0f} psia." if summary["copv_t0_psia"] else ".")
1252 if isinstance(asked_lockup, (int, float)) and isinstance(asked_fill, (int, float)) else "")
1253 events.append({"t": trace.t[0] - dt, "kind": "warn", "label": "T-0 did not settle",
1254 "detail": "The tanks were still moving at T-0; the trace opens off its datum." + started})
1255 events.append({"t": 0.0, "kind": "fire", "key": "fire", "label": "Fire", "detail": "Mains commanded open"})
1256 for side, key, s in (("LOX", "ox", ox_s), ("Fuel", "fuel", fuel_s)):
1257 if fire_idx:
1258 i_min = min(fire_idx, key=lambda i: s["tank_psia"][i])
1259 events.append({"t": trace.t[i_min], "kind": "min", "key": f"min_tank_{key}", "label": f"{side} tank lowest",
1260 "detail": f"{s['tank_psia'][i_min]:.1f} psia"})
1261 if end and end.depleted_s is not None:
1262 side = "LOX" if end.tank == ox_tank else "Fuel"
1263 other = fuel_tank if end.tank == ox_tank else ox_tank
1264 events.append({"t": end.depleted_s, "kind": "end", "key": "dry_ox" if end.tank == ox_tank else "dry_fuel",
1265 "label": f"{side} tank dry",
1266 "detail": f"{trace.tank[other]['liquid_mass_kg'][-1]:.3f} kg of the other propellant left"})
1267 elif tripped is not None:
1268 # Not the horizon: the stand stopped here, and nothing after this instant was burned.
1269 events.append({"t": float(tripped["t"]), "kind": "fail", "key": "trip",
1270 "label": f"Vessel trip ({tripped.get('label') or tripped.get('vessel') or 'vessel'})",
1271 "detail": (f"{tripped.get('message') or ''} The burn stopped on this step "
1272 f"({tripped['p_psia']:.1f} psia against the {tripped['mawp_psia']:.1f} psia the "
1273 "stand trips at); its totals end here.").strip()})
1274 elif end:
1275 events.append({"t": trace.t[-1], "kind": "warn", "label": "Horizon reached",
1276 "detail": "No tank ran dry before the burn horizon."})
1277 if summary.get("card_outside_steps"):
1278 events.append({"t": trace.t[-1], "kind": "warn", "label": "Outside the engine card",
1279 "detail": f"{summary['card_outside_steps']} firing steps asked the engine card about a "
1280 "flow or pressure outside the EngineDesign solves it was fitted to."})
1281 if summary["failed_steps"]:
1282 events.append({"t": trace.t[-1], "kind": "warn", "label": "Unconverged steps",
1283 "detail": f"{summary['failed_steps']} of {summary['steps']} steps held their last good flows."})
1284 events.sort(key=lambda e: e["t"])
1285
1286 # ---- provenance -----------------------------------------------------------
1287 report = prep.model.report
1288 provenance = {
1289 "drawing": {"id": prep.drawing.id, "name": prep.drawing.name, "source": prep.drawing.source,
1290 "sha256": prep.drawing.sha256},
1291 "config_sha256": prep.config_sha256,
1292 "settings": asdict(prep.settings),
1293 "derived": prep.derived,
1294 "setup": _setup_dict(prep.setup),
1295 "plan": {k: v for k, v in asdict(prep.plan).items()},
1296 "calibration": prep.link.calibration if prep.link else None,
1297 "engine_reference": prep.link.reference if prep.link else None,
1298 "assembly": {
1299 "symbols": report.symbols, "lines": report.lines, "nodes": report.nodes,
1300 "branches": report.branches, "unstated": report.unchecked,
1301 "assumptions": [asdict(a) for a in report.assumptions],
1302 "warnings": list(report.warnings),
1303 },
1304 "notes": list(trace.notes),
1305 "probes": {
1306 "tank_ullage": dict(probes.tank_ullage), "injector_inlet": dict(probes.injector_inlet),
1307 "chamber": probes.chamber, "regulator_outlet": dict(probes.regulator_outlet),
1308 "bottles": list(probes.bottles),
1309 },
1310 "created": time.time(),
1311 "phase": 2,
1312 }
1313 out = {"series": series, "summary": summary, "events": events, "provenance": provenance,
1314 "checks": [asdict(c) for c in prep.checks]}
1315 # Every node and branch, every step (DATA-CONTRACT 2), when the burn recorded them.
1316 network = _network_of(trace)
1317 if network is not None:
1318 out["network"] = network
1319 if tripped is not None:
1320 out["tripped"] = tripped
1321 return out
1322
1323
1324def _network_of(trace: Any) -> Optional[Dict[str, Any]]:
1325 """``result["network"]`` (DATA-CONTRACT 2) from a trace recorded with ``Probes(network=True)``;
1326 ``None`` for a trace without one (a caller's own, or one recorded before the recorder)."""
1327 if getattr(trace, "network", None) is None:
1328 return None
1329 from feedtwin.session.burn import network_dict
1330
1331 return network_dict(trace)
1332
1333
1334def _trip_of(trace: Any) -> Optional[Dict[str, Any]]:
1335 """``result["tripped"]`` (DATA-CONTRACT 4): lib/feedtwin's ``trip_record`` of the vessel trip
1336 that ended the burn, ``None`` when nothing tripped."""
1337 if getattr(getattr(trace, "end", None), "tripped", None) is None:
1338 return None
1339 from feedtwin.session.burn import trip_record
1340
1341 return trip_record(trace)
1342
1343
1344def _line_exit_psia(prep: Prepared, side: str, mdot: float, p_tank: float, dp_feed: Optional[float]) -> float:
1345 """EngineDesign's pressure at the end of its feed line [psia]: the tank, less every feed loss
1346 except the exit dump into the manifold. Its ``P_injector`` is downstream of that dump (the
1347 still manifold), which is not where the twin's line ends, so comparing the two directly
1348 compares two different places."""
1349 if dp_feed is None:
1350 return float("nan")
1351 from engine.pipeline.feed_loss import delta_p_feed
1352
1353 config = prep.link.sampler.config if prep.link is not None and prep.link.sampler is not None else None
1354 if config is None:
1355 return float("nan")
1356 feeds = config.feed_system
1357 feed = feeds[side] if isinstance(feeds, dict) else getattr(feeds, side)
1358 fluid = config.fluids["oxidizer" if side == "oxidizer" else "fuel"]
1359 rho = float(fluid.density if hasattr(fluid, "density") else fluid["density"])
1360 dump = delta_p_feed(mdot, rho, feed, p_tank)
1361 return (p_tank - (float(dp_feed) - dump)) / PSI
1362
1363
1364def cross_check(prep: Prepared, result: Dict[str, Any], runner: Any) -> Dict[str, Any]:
1365 """Layer X against EngineDesign's own forward solve at the same tank pressures.
1366
1367 Taken at the first step after the ignition transient. The difference has
1368 two sources, and the table says which is which:
1369 * the feed lines: EngineDesign's ``feed_system`` (one K per side) against
1370 the drawing's line-by-line network, visible as the injector-inlet
1371 pressure;
1372 * the engine away from its calibration point (none at T-0 when calibrated).
1373 """
1374 series = result["series"]
1375 t = series["t"]
1376 idx = next((i for i, (tt, f) in enumerate(zip(t, series["firing"])) if f and tt >= 0.25), None)
1377 if idx is None:
1378 return {"available": False}
1379 p_o = series["ox"]["tank_psia"][idx] * PSI
1380 p_f = series["fuel"]["tank_psia"][idx] * PSI
1381 try:
1382 res = runner.evaluate(p_o, p_f, silent=True)
1383 except Exception as exc: # noqa: BLE001 - a failed check is reported, not raised
1384 return {"available": False, "error": f"{type(exc).__name__}: {exc}"}
1385 d = res.get("diagnostics") or {}
1386 ed = {
1387 "pc_psia": res["Pc"] / PSI, "mdot_O": res["mdot_O"], "mdot_F": res["mdot_F"], "mr": res["MR"],
1388 "thrust_N": res["F"], "isp_s": res["Isp"],
1389 "inlet_O_psia": _line_exit_psia(prep, "oxidizer", res["mdot_O"], p_o, d.get("delta_p_feed_O")),
1390 "inlet_F_psia": _line_exit_psia(prep, "fuel", res["mdot_F"], p_f, d.get("delta_p_feed_F")),
1391 }
1392 lx = {
1393 "pc_psia": series["chamber"]["pc_psia"][idx], "mdot_O": series["ox"]["mdot"][idx],
1394 "mdot_F": series["fuel"]["mdot"][idx], "mr": series["chamber"]["mr"][idx],
1395 "thrust_N": series["chamber"]["thrust_N"][idx], "isp_s": series["chamber"]["isp_s"][idx],
1396 "inlet_O_psia": series["ox"]["inlet_psia"][idx], "inlet_F_psia": series["fuel"]["inlet_psia"][idx],
1397 }
1398 rows = []
1399 labels = {
1400 "inlet_O_psia": ("LOX line exit", "psia"), "inlet_F_psia": ("Fuel line exit", "psia"),
1401 "pc_psia": ("Chamber pressure", "psia"), "mdot_O": ("LOX flow", "kg/s"), "mdot_F": ("Fuel flow", "kg/s"),
1402 "mr": ("O/F", ""), "thrust_N": ("Thrust", "N"), "isp_s": ("Isp", "s"),
1403 }
1404 for key, (label, unit) in labels.items():
1405 a, b = lx[key], ed[key]
1406 rows.append({"key": key, "label": label, "unit": unit, "layerx": a, "enginedesign": b,
1407 "rel": ((a - b) / b) if b and math.isfinite(b) else None})
1408 return {
1409 "available": True,
1410 "t": t[idx],
1411 "tank_psia_O": p_o / PSI,
1412 "tank_psia_F": p_f / PSI,
1413 "rows": rows,
1414 "basis": ("EngineDesign's forward solve at Layer X's tank pressures at this instant, through its "
1415 "own feed_system K; Layer X through the drawing's lines. Line exit is the pressure where "
1416 "the feed line meets the injector, before the dump into the manifold, in both columns. "
1417 "The engine itself is the same in both (see the engine check), so a difference here "
1418 "is the feed lines."),
1419 }
1420
1421
1422def engine_check(prep: Prepared, result: Dict[str, Any], points: int = 12) -> Dict[str, Any]:
1423 """The engine the twin burned, against EngineDesign at the same injector-inlet pressures.
1424
1425 At ``points`` instants across the firing window, EngineDesign is solved at the line exit
1426 (its feed losses zeroed, its exit dump kept) at exactly the inlet pressures the twin's
1427 network delivered. Same boundary, same pressures, so any difference is the engine model and
1428 nothing else: the card's tabulation error, the T-0 fit's drift away from T-0, or the native
1429 engine's whole gap. The feed system is the twin's in both columns, and this does not test it.
1430 """
1431 series = result["series"]
1432 t = series["t"]
1433 rp = result.get("replay") or {}
1434 if rp.get("available"):
1435 return _engine_check_from_replay(prep, series, rp)
1436 firing = [i for i, f in enumerate(series["firing"]) if f and t[i] >= 0.1]
1437 if not firing:
1438 return {"available": False}
1439 step = max(len(firing) // max(points - 1, 1), 1)
1440 picks = firing[::step]
1441 if picks[-1] != firing[-2 if len(firing) > 1 else -1]:
1442 picks.append(firing[-2] if len(firing) > 1 else firing[-1])
1443 sampler = prep.link.sampler
1444 rows = []
1445 worst: Dict[str, float] = {"pc": 0.0, "thrust": 0.0, "mdot_O": 0.0, "mdot_F": 0.0}
1446 for i in picks:
1447 p_O = series["ox"]["inlet_psia"][i] * PSI
1448 p_F = series["fuel"]["inlet_psia"][i] * PSI
1449 ed = sampler(p_O, p_F)
1450 if ed is None:
1451 rows.append({"t": t[i], "available": False})
1452 continue
1453 lx = {"pc": series["chamber"]["pc_psia"][i] * PSI, "thrust": series["chamber"]["thrust_N"][i],
1454 "mdot_O": series["ox"]["mdot"][i], "mdot_F": series["fuel"]["mdot"][i]}
1455 want = {"pc": ed["Pc"], "thrust": ed["F"], "mdot_O": ed["mdot_O"], "mdot_F": ed["mdot_F"]}
1456 rel = {k: (lx[k] / want[k] - 1.0) if want[k] else None for k in lx}
1457 for k, v in rel.items():
1458 if v is not None:
1459 worst[k] = max(worst[k], abs(v))
1460 rows.append({"t": t[i], "available": True, "inlet_O_psia": p_O / PSI, "inlet_F_psia": p_F / PSI,
1461 "layerx": {**lx, "pc": lx["pc"] / PSI},
1462 "enginedesign": {**want, "pc": want["pc"] / PSI}, "rel": rel})
1463 return {
1464 "available": True,
1465 "mode": prep.link.mode,
1466 "rows": rows,
1467 "worst": worst,
1468 "basis": ("EngineDesign at the line exit (feed losses zeroed, exit dump kept), solved at the "
1469 "injector-inlet pressures the twin's network delivered at each instant."),
1470 }
1471
1472
1473def _engine_check_from_replay(prep: Prepared, series: Dict[str, Any], rp: Dict[str, Any]) -> Dict[str, Any]:
1474 """As :func:`engine_check`, against EngineDesign's replay of this very burn: same instants,
1475 same line-exit pressures, the chamber eroded as far as EngineDesign says it had by then. With
1476 the replay's throat history applied to the twin, this is what the agreement loop closed."""
1477 rows = []
1478 worst: Dict[str, float] = {"pc": 0.0, "thrust": 0.0, "mdot_O": 0.0, "mdot_F": 0.0}
1479 for k, i in enumerate(rp["index"]):
1480 if series["t"][i] < 0.1:
1481 continue
1482 want = {"pc": rp["pc_psia"][k], "thrust": rp["thrust_N"][k], "mdot_O": rp["mdot_O"][k],
1483 "mdot_F": rp["mdot_F"][k]}
1484 if any(v is None for v in want.values()):
1485 rows.append({"t": series["t"][i], "available": False})
1486 continue
1487 lx = {"pc": series["chamber"]["pc_psia"][i], "thrust": series["chamber"]["thrust_N"][i],
1488 "mdot_O": series["ox"]["mdot"][i], "mdot_F": series["fuel"]["mdot"][i]}
1489 rel = {key: (lx[key] / want[key] - 1.0) if want[key] else None for key in lx}
1490 for key, v in rel.items():
1491 if v is not None:
1492 worst[key] = max(worst[key], abs(v))
1493 rows.append({"t": series["t"][i], "available": True, "inlet_O_psia": rp["inlet_O_psia"][k],
1494 "inlet_F_psia": rp["inlet_F_psia"][k], "layerx": lx, "enginedesign": want, "rel": rel})
1495 return {
1496 "available": True,
1497 "mode": prep.link.mode,
1498 "against": "replay",
1499 "rows": rows,
1500 "worst": worst,
1501 "basis": ("EngineDesign's coupled time-varying solve (ablative and graphite recession on) at the "
1502 "line-exit pressures the twin delivered, at each instant. Thrust on the Layer X side "
1503 "is the card's at its as-built nozzle; the delivered thrust is EngineDesign's."),
1504 }
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
None __init__(self, Dict[str, Any] record)
Definition analysis.py:57
Dict[str, Dict[str, Any]] _instrument_series(Prepared prep, Any trace)
Definition analysis.py:1067
Dict[str, Any] cross_check(Prepared prep, Dict[str, Any] result, Any runner)
Definition analysis.py:1364
Dict[str, Any] _setup_dict(Any setup)
Definition analysis.py:64
Dict[str, Any] engine_check(Prepared prep, Dict[str, Any] result, int points=12)
Definition analysis.py:1422
Dict[str, Any] run_prepared(Prepared prep, *, Optional[Progress] progress=None, Callable[[], bool] cancelled=lambda:False, Any runner=None, bool replay=True, Any config=None, bool diagnostics=False, Optional[Dict[str, Any]] sidecars=None, Optional[Callable[[float, float, float], None]] live=None)
Definition analysis.py:240
List[float] _psia(List[float] values)
Definition analysis.py:36
tuple _impulse_to_depletion(Any trace, List[int] fire_idx, Any steps, str ox_tank, str fuel_tank, Dict[str, List[float]] ch)
Definition analysis.py:997
Dict[str, Any] _failed(BaseException exc)
Definition analysis.py:639
Any _safe(Callable[..., Any] fn, *Any args)
Definition analysis.py:917
Optional[int] _card_outside(Prepared prep, Dict[str, Any] series, List[int] fire_idx)
Definition analysis.py:1046
Optional[float] _schedule_gap(Dict[str, Any] inline, Dict[str, Any] rocketpy)
Definition analysis.py:615
Dict[str, Any] _flight_curve(Dict[str, Any] result, Optional[Dict[str, Any]] dv)
Definition analysis.py:907
Optional[float] _trace_ullage_gas_kg(Prepared prep, Any trace)
Definition analysis.py:520
List[Dict[str, Any]] model_record(Dict[str, Any] result, Optional[Prepared] prep=None)
Definition analysis.py:851
Dict[str, Any] _engine_check_from_replay(Prepared prep, Dict[str, Any] series, Dict[str, Any] rp)
Definition analysis.py:1473
Dict[str, float] _dump_coefficients(Prepared prep)
Definition analysis.py:1025
None _key_events(Dict[str, Any] result, Prepared prep, Dict[str, Any] opts)
Definition analysis.py:797
tuple diagnostics_of(Prepared prep, Dict[str, Any] result, Any config, Any runner, Optional[Dict[str, Any]] opts=None)
Definition analysis.py:644
Optional[Dict[str, Any]] _network_of(Any trace)
Definition analysis.py:1324
Optional[float] _pressurant_kg(Dict[str, Any] result)
Definition analysis.py:949
Dict[str, float] _loads(Dict[str, Any] result)
Definition analysis.py:886
Callable[[Any, Dict[str, float], float], Any] _inline_factory(Prepared prep, Any config)
Definition analysis.py:543
Dict[str, Any] reduce_trace(Prepared prep, Any trace, Dict[str, float] initial)
Definition analysis.py:1081
Dict[str, Any] _pass_figures(Dict[str, Any] result, Optional[Dict[str, Any]] dv)
Definition analysis.py:957
Optional[float] _ullage_gas_kg(Prepared prep, Dict[str, Any] result)
Definition analysis.py:924
float _line_exit_psia(Prepared prep, str side, float mdot, float p_tank, Optional[float] dp_feed)
Definition analysis.py:1344
Dict[str, Any] _flight_block(Prepared prep, Optional[Dict[str, Any]] flown, Optional[Dict[str, Any]] ground, Optional[Dict[str, Any]] last)
Definition analysis.py:983
tuple _burn_once(Prepared prep, Optional[tuple] schedule, Progress say, Callable[[], bool] cancelled, float lo, float hi, Optional[Dict[str, Any]] accel=None, int pass_no=1, Optional[Callable[..., Any]] inline=None, Optional[Callable[[float, float, float], None]] live=None)
Definition analysis.py:75
Dict[str, str] dome_line_instruments(Any diagram, Any built)
Definition analysis.py:186
Any _clean(Any value)
Definition analysis.py:40
List[Dict[str, Any]] grade_limits(Dict[str, Any] result, Any prep=None, Any config=None, Optional[Dict[str, Any]] opts=None, *, Optional[Dict[str, float]] meop_psi=None)
Definition analysis.py:736
Optional[Dict[str, Any]] _delivered(Dict[str, Any] rp, Dict[str, Any] result, float dt)
Definition analysis.py:891
Optional[Dict[str, Any]] _trip_of(Any trace)
Definition analysis.py:1334
bool _review_pending(str key)
Definition analysis.py:730
Dict[str, Any] _inline_block(Optional[Dict[str, Any]] ascent, Optional[Dict[str, Any]] flown, Any flt)
Definition analysis.py:567
TOML_NODISCARD_CTOR time() noexcept=default
static void make(uint8_t *out, uint64_t ts, uint8_t type, uint16_t sensor, float ref)
static void record(void *ctx, uint8_t type, const void *data, uint8_t len)