1"""Run a prepared Layer X burn and reduce it to what a person reads.
3The trace from :func:`feedtwin.session.burn.run_burn` is absolute SI, sampled at
4every step. This module turns it into four things:
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
11* **events**: when things happened (T-0 settle, ignition, lowest tank pressure,
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.
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
22from __future__
import annotations
26from dataclasses
import asdict
27from typing
import Any, Callable, Dict, List, Optional
33Progress = Callable[[str, float],
None]
36def _psia(values: List[float]) -> List[float]:
37 return [v / PSI
for v
in values]
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]
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``)."""
57 def __init__(self, record: Dict[str, Any]) ->
None:
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}")
66 for key, value
in asdict(setup).items():
67 if isinstance(value, (int, float, str, bool))
or value
is None:
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.
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
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
98 if prep.settings.dome_regulator:
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)
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)
117 from dataclasses
import replace
as _replace
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": []}
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:
134 chamber.throat_area = float(np.interp(clock, schedule[0], schedule[1]))
135 if accel
is not None and (firing
or clock > -1e-6):
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):
144 if ascent[
"vehicle"]
is None:
145 ascent[
"vehicle"] = inline(trace, initial, clock)
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:
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))
161 trace.end = burn(session, prep.plan, record, cancelled=cancelled)
163 chamber.throat_area = a0
164 trace.notes = list(dict.fromkeys(session.assumptions))
165 if trace.end.cancelled:
169 trip = getattr(trace.end,
"tripped",
None)
170 if trip
is not None and not trace.t:
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]):
180 trace.t[-1] = float(trip.t)
181 trace.inline_ascent = ascent
if inline
is not None else None
182 trace.dome_readings = dome_readings
183 return trace, initial
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."""
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":
209 signal = built.actuators.get(node.id,
"")
210 if not signal
or signal
in loaded:
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)]
217 here = frontier.pop()
218 if here
in seen
or here
not in by_id
or here == node.id:
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
232 progress: Optional[Progress] =
None,
233 cancelled: Callable[[], bool] =
lambda:
False,
237 diagnostics: bool =
False,
238 sidecars: Optional[Dict[str, Any]] =
None,
239 live: Optional[Callable[[float, float, float],
None]] =
None,
241 """Burn ``prep`` and return the reduced result.
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.
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.
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.
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.
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.
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`.
278 from feedtwin
import __version__
as feedtwin_version
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
297 passes: List[Dict[str, Any]] = []
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
306 budget = (rpl.MAX_PASSES
if feedback
else 1) * (2
if flying
else 1)
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)
314 ascent = getattr(trace,
"inline_ascent",
None)
319 tripped = result.get(
"tripped")
is not None
320 if not (loop
or flying):
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
327 entry[
"tripped"] =
True
332 if loop
and tripped
and not any(result[
"series"][
"firing"]):
334 rp = {
"available":
False,
"error":
"the stand tripped before Fire: no firing steps to replay"}
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"):
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)
355 if throat_done
or pad_passes >= budget // 2:
364 if feedback
and new
is not None:
368 if diagnostics
and loop
and rp.get(
"available"):
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:
379 dv_now =
_delivered(rp, result, prep.plan.dt)
if loop
and rp.get(
"available")
else None
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")})
391 result[
"passes"] = passes
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)))
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:
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} %")
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) +
"."})
422 result[
"converged"] =
False
423 if result[
"summary"].get(
"t0_settled")
is False:
427 result[
"converged"] =
False
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")}
437 result[
"flight"] =
_flight_block(prep, flown, ground, passes[-1]
if passes
else None)
438 if inline
and isinstance(result[
"flight"], dict):
440 dv = result.get(
"delivered")
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"]:
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:
461 result[
"motor"] = motor_header(config)
462 except Exception
as exc:
463 result[
"motor"] = {
"error": f
"{type(exc).__name__}: {exc}"}
468 result[
"feed_fit"] = fit_feed(prep, result, config)
469 if result[
"feed_fit"].get(
"available")
and result[
"feed_fit"].get(
"writable",
True):
472 result[
"feed_fit"][
"design_update"] = design_update(result[
"feed_fit"], run_id=
"")
473 except Exception
as exc:
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)
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)
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
495 result[
"limits"] =
grade_limits(result, prep, config, opts)
496 except Exception
as exc:
497 result[
"limits"] =
_failed(exc)
500 except Exception
as exc:
501 result[
"events"].append({
"t": result[
"series"][
"t"][-1],
"kind":
"warn",
"key":
"warn:events",
502 "label":
"Event keys",
"detail": f
"{type(exc).__name__}: {exc}"})
504 result[
"provenance"][
"models"] =
model_record(result, prep)
505 except Exception
as exc:
506 result[
"provenance"][
"models"] =
_failed(exc)
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
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
526 gas = prep.derived.get(
"pressurant_gas")
527 volumes = prep.derived.get(
"tank_volumes_L")
or {}
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"):
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)
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."""
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
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"}
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
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,
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"),
596 "name":
"inline_vertical_1dof",
597 "source": flt.INLINE_SOURCE,
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 "
607 "liftoff_mass_kg": {
"value": booked.get(
"value"),
"unit":
"kg",
608 "provenance": booked.get(
"source")
or "flight.liftoff_mass"},
615def _schedule_gap(inline: Dict[str, Any], rocketpy: Dict[str, Any]) -> Optional[float]:
616 """See ``vs_rocketpy_basis`` in :func:`_inline_block`."""
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)
625 return float(np.max(np.abs(a[keep] - ref[keep]) / np.abs(ref[keep])))
631DIAGNOSTIC_KEYS = (
"stability",
"hardware",
"ladder",
"regulator",
"solenoids",
"pressurant",
"saturation",
632 "cavitation",
"injector",
"thrust_shape",
"ledger",
"start",
"outflow",
"shutdown",
633 "water_hammer",
"vv")
635FEED_KEYS = (
"ladder",
"regulator",
"solenoids",
"pressurant",
"saturation",
"cavitation",
"injector",
636 "thrust_shape",
"ledger")
639def _failed(exc: BaseException) -> Dict[str, Any]:
640 return {
"available":
False,
"error": f
"{type(exc).__name__}: {exc}"}
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)``.
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)."""
653 opts = opts
or options(prep.settings)
654 out: Dict[str, Any] = {}
655 wall: Dict[str, float] = {}
657 sampler = getattr(link,
"sampler",
None)
if link
is not None else None
659 def block(name: str, fn: Callable[[], Any]) -> Any:
660 t0 = time.perf_counter()
663 except Exception
as exc:
665 wall[name] = time.perf_counter() - t0
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"]))))
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
696 def shutdown() -> Any:
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)")
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))
712 out[
"vv"] = block(
"vv",
lambda: vv.check(result, prep))
720REVIEW_PENDING = (
"water_hammer",
"saturation",
"cavitation",
"separation",
"regulator_wide_open",
"outflow",
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).")
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.")
731 return any(key == p
or key.startswith(p +
"_")
for p
in REVIEW_PENDING)
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.
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.
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)
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)
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":
775 e = {**e,
"decision":
"D7"}
778 if e.get(
"key") ==
"chug_margin":
780 e[
"hint"] = (e.get(
"hint")
or "") +
" " + CHUG_DECISION
781 entries[at + 1:at + 1] = extra
786 e[
"review_pending"] =
True
787 if e.get(
"grade") ==
"bad":
788 e[
"capped_from"] =
"bad"
790 e[
"hint"] = (e.get(
"hint")
or "") +
" " + REVIEW_NOTE
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")}
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)):
809 events.append({
"t": float(t),
"kind": kind,
"label": label,
"detail": detail,
"key": key})
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")
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")
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:
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"])
842 if e.get(
"kind") ==
"warn":
843 e[
"key"] = f
"warn:{n}"
846 e[
"key"] = str(e.get(
"kind")
or "event")
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]] = []
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}]")
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"):
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 {}})
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"])}
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."""
897 dv = rpl.delivered(rp, result[
"series"], dt)
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)
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."""
914 return rpl.timeseries_payload({**result,
"delivered": dv})
917def _safe(fn: Callable[..., Any], *args: Any) -> Any:
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
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:
937 for side, role
in ((
"ox",
"oxidiser"), (
"fuel",
"fuel")):
938 v_L = volumes.get(prep.roles.get(role,
""))
940 if not v_L
or not s.get(
"fill_fraction"):
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)
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
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 {}
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,
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
982def _flight_block(prep: Prepared, flown: Optional[Dict[str, Any]], ground: Optional[Dict[str, Any]],
983 last: Optional[Dict[str, Any]]) -> Dict[str, Any]:
985 return {
"ok":
False,
"error":
"the burn was not flown"}
986 if not flown.get(
"ok"):
989 final = {k: last.get(k)
for k
in (ground
or {})}
if last
else None
991 out[
"in_flight"] = final
992 out[
"vehicle_lines"] = prep.vehicle_lines
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.
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.
1008 if len(fire_idx) < 3:
1010 body = fire_idx[:-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:
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)
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:
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)
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
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)):
1064THERMAL_INSTRUMENTS = frozenset({
"TC",
"RTD"})
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)
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)}
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
1087 trace.t[:] = [r + 0.0
if abs(x - (r := round(x, 9))) < 1e-12
else x
for x
in trace.t]
1089 firing = trace.firing
1093 steps = [dt] + [trace.t[i] - trace.t[i - 1]
for i
in range(1, n)]
1095 inlet = {side: trace.pressure[node]
for side, node
in probes.injector_inlet.items()}
1099 def side_series(side: str, tank: str, mdot_key: str) -> Dict[str, List[float]]:
1100 tank_col = trace.tank[tank]
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)]
1108 (manifold[i] - ch[
"pressure_Pa"][i])
if firing[i]
else 0.0
for i
in range(n)
1111 (dp[i] / ch[
"pressure_Pa"][i])
if firing[i]
and ch[
"pressure_Pa"][i] > 0
else 0.0
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"]),
1129 bottle_id = probes.bottles[0]
if probes.bottles
else ""
1130 bottle = trace.bottle.get(bottle_id, {})
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", [])),
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
1147 "ox": side_series(
"oxidiser", ox_tank,
"mdot_oxidiser"),
1148 "fuel": side_series(
"fuel", fuel_tank,
"mdot_fuel"),
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"]),
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)
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
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
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"]
1178 def tank_block(s: Dict[str, List[float]], tank: str) -> Dict[str, Any]:
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),
1188 "stiffness_min_ignition": pick(s[
"stiffness"], [i
for i
in fire_idx
if i
not in settled_set], min),
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],
1196 pc = series[
"chamber"][
"pc_psia"]
1197 mr = ch[
"mixture_ratio"]
1198 depleted = bool(trace.end
and trace.end.depleted_s
is not None)
1200 if depleted
else (
None,
None))
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,
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()
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,
1240 events: List[Dict[str, Any]] = []
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:
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)):
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:
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()})
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"])
1287 report = prep.model.report
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,
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,
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),
1304 "notes": list(trace.notes),
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),
1313 out = {
"series": series,
"summary": summary,
"events": events,
"provenance": provenance,
1314 "checks": [asdict(c)
for c
in prep.checks]}
1317 if network
is not None:
1318 out[
"network"] = network
1319 if tripped
is not None:
1320 out[
"tripped"] = tripped
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:
1331 return network_dict(trace)
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:
1341 return trip_record(trace)
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."""
1353 config = prep.link.sampler.config
if prep.link
is not None and prep.link.sampler
is not None else None
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
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.
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
1372 * the engine away from its calibration point (none at T-0 when calibrated).
1374 series = result[
"series"]
1376 idx = next((i
for i, (tt, f)
in enumerate(zip(t, series[
"firing"]))
if f
and tt >= 0.25),
None)
1378 return {
"available":
False}
1379 p_o = series[
"ox"][
"tank_psia"][idx] * PSI
1380 p_f = series[
"fuel"][
"tank_psia"][idx] * PSI
1382 res = runner.evaluate(p_o, p_f, silent=
True)
1383 except Exception
as exc:
1384 return {
"available":
False,
"error": f
"{type(exc).__name__}: {exc}"}
1385 d = res.get(
"diagnostics")
or {}
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")),
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],
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"),
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})
1411 "tank_psia_O": p_o / PSI,
1412 "tank_psia_F": p_f / PSI,
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."),
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.
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.
1431 series = result[
"series"]
1433 rp = result.get(
"replay")
or {}
1434 if rp.get(
"available"):
1436 firing = [i
for i, f
in enumerate(series[
"firing"])
if f
and t[i] >= 0.1]
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
1445 worst: Dict[str, float] = {
"pc": 0.0,
"thrust": 0.0,
"mdot_O": 0.0,
"mdot_F": 0.0}
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)
1451 rows.append({
"t": t[i],
"available":
False})
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():
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})
1465 "mode": prep.link.mode,
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."),
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."""
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:
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})
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():
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})
1497 "mode": prep.link.mode,
1498 "against":
"replay",
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."),
None __init__(self, Dict[str, Any] record)
Dict[str, Dict[str, Any]] _instrument_series(Prepared prep, Any trace)
Dict[str, Any] cross_check(Prepared prep, Dict[str, Any] result, Any runner)
Dict[str, Any] _setup_dict(Any setup)
Dict[str, Any] engine_check(Prepared prep, Dict[str, Any] result, int points=12)
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)
List[float] _psia(List[float] values)
tuple _impulse_to_depletion(Any trace, List[int] fire_idx, Any steps, str ox_tank, str fuel_tank, Dict[str, List[float]] ch)
Dict[str, Any] _failed(BaseException exc)
Any _safe(Callable[..., Any] fn, *Any args)
Optional[int] _card_outside(Prepared prep, Dict[str, Any] series, List[int] fire_idx)
Optional[float] _schedule_gap(Dict[str, Any] inline, Dict[str, Any] rocketpy)
Dict[str, Any] _flight_curve(Dict[str, Any] result, Optional[Dict[str, Any]] dv)
Optional[float] _trace_ullage_gas_kg(Prepared prep, Any trace)
List[Dict[str, Any]] model_record(Dict[str, Any] result, Optional[Prepared] prep=None)
Dict[str, Any] _engine_check_from_replay(Prepared prep, Dict[str, Any] series, Dict[str, Any] rp)
Dict[str, float] _dump_coefficients(Prepared prep)
None _key_events(Dict[str, Any] result, Prepared prep, Dict[str, Any] opts)
tuple diagnostics_of(Prepared prep, Dict[str, Any] result, Any config, Any runner, Optional[Dict[str, Any]] opts=None)
Optional[Dict[str, Any]] _network_of(Any trace)
Optional[float] _pressurant_kg(Dict[str, Any] result)
Dict[str, float] _loads(Dict[str, Any] result)
Callable[[Any, Dict[str, float], float], Any] _inline_factory(Prepared prep, Any config)
Dict[str, Any] reduce_trace(Prepared prep, Any trace, Dict[str, float] initial)
Dict[str, Any] _pass_figures(Dict[str, Any] result, Optional[Dict[str, Any]] dv)
Optional[float] _ullage_gas_kg(Prepared prep, Dict[str, Any] result)
float _line_exit_psia(Prepared prep, str side, float mdot, float p_tank, Optional[float] dp_feed)
Dict[str, Any] _flight_block(Prepared prep, Optional[Dict[str, Any]] flown, Optional[Dict[str, Any]] ground, Optional[Dict[str, Any]] last)
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)
Dict[str, str] dome_line_instruments(Any diagram, Any built)
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)
Optional[Dict[str, Any]] _delivered(Dict[str, Any] rp, Dict[str, Any] result, float dt)
Optional[Dict[str, Any]] _trip_of(Any trace)
bool _review_pending(str key)
Dict[str, Any] _inline_block(Optional[Dict[str, Any]] ascent, Optional[Dict[str, Any]] flown, Any flt)
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)