1"""A burn, from T-0 to a dry tank, as one call.
3The feed-twin COPV study was the first thing to fly a whole burn headless: build
4a session, put it at T-0 directly, settle it to regulator lockup with the mains
5shut, record a lead-in, command Fire and step until a tank runs dry. That
6sequence lives here now, unchanged, so the study and EngineDesign's Layer X
7burn the same way -- and a number either of them quotes means the same thing.
11It is not a rehearsal of the pad. :func:`prime_at_t0` sets the T-0 state
12directly rather than flying fills and presses, because pressing the tanks draws
13on the bottle a burn is usually asked about: an undersized COPV would then fail
14for two reasons at once and the trace could not say which. See
15:meth:`~feedtwin.session.core.Session.prime`.
19:func:`burn_setup` is the study's setting: no latency budget, a generous Newton
20allowance, and the cockpit's newer thermal closures (stratified surface layer,
21boiling onset, nucleate regime) off, because the expectations in
22``docs/PHYSICS-BENCHMARK.md`` 2.x were set without them. Turn them on
23deliberately, with a fresh baseline.
26from __future__
import annotations
29from dataclasses
import dataclass, field, replace
30from typing
import Any, Callable, Mapping
42Recorder = Callable[[float, Sample, bool],
None]
46 """The study's numerics, with ``changes`` on top.
48 A study wants the opposite of a cockpit: never fold a tick to save wall
49 clock, and give Newton room. The thermal closures the cockpit grew after the
50 benchmark expectations were set stay off, so those numbers keep meaning what
53 base: dict[str, Any] = {
54 "stratification":
False,
55 "boiling_onset_K": 0.0,
56 "chilldown_nucleate": 0.0,
63 "ullage_wall_by_level":
False,
65 "regulator_compressible_seat":
False,
69 "max_iterations": 120,
77OPENED =
"session_opened"
82 machine: StateMachine,
84 setup: Setup |
None =
None,
85 hookup: Hookup |
None =
None,
87 """A session on ``model``, its valves bound to ``machine`` by label.
89 A model is consumed by the session that runs it: the session writes node
90 pressures, the pinned chamber node and propagated temperatures into the
91 model's network as it steps. A second session on the same model starts from
92 the first one's end state, and on an engine-coupled stand that is a chamber
93 closure that fails from the first fire step (41 of 51 steps held, measured).
94 So this refuses a model it has already opened. Assemble a fresh one per run
95 (:func:`~feedtwin.session.model.assemble_model` is cheap).
97 if model.meta.get(OPENED):
99 "this model has already been run by a session, which leaves its "
100 "network at that run's end state; assemble a fresh model per session"
102 if isinstance(model.meta, dict):
103 model.meta[OPENED] =
True
109 hookup_binding(model, machine, hookup),
115@dataclass(frozen=True, slots=True)
117 """Where T-0 is and how to get past it.
119 Pressures are **gauge**, as a dial or a PT reads them -- see
120 :mod:`feedtwin.session.gauge`, and :func:`~feedtwin.session.gauge.psig_from_psia`
121 for a caller that knows the absolute pressure it wants.
124 tank_psi: float = 550.0
125 """Tank pressure at prime [psig] when no regulator feeds the tanks. Where one
126 does, the tanks are primed and settled at the lockup it gives off the
127 charged bottle (:func:`regulator_lockup`): dome + bias - S x bottle, which
128 a fixed number here would miss by the supply effect -- no higher than the
129 bottle and no lower than atmosphere (:func:`reachable_lockup`)."""
130 copv_psi: float = 4500.0
131 """Bottle at T-0 [psig]."""
132 fill_fraction: float = 0.95
133 """Liquid volume over tank volume at T-0, for every tank not in ``loads``."""
134 loads: Mapping[str, float] |
None =
None
135 """Propellant mass at T-0 [kg] per tank id, instead of ``fill_fraction``.
136 A vehicle whose load is fixed (by mass, by rule) says so here."""
137 bottle_litres: float |
None =
None
138 """Override every bottle's volume [L]; ``None`` keeps the drawing's."""
139 hold_s: float = PAD_HOLD_S
140 """How long the tanks have been loaded at T-0 [s]."""
141 ready_state: str =
"Ready"
142 fire_state: str =
"Fire"
145 """Settle to regulator lockup with the mains shut before the lead-in."""
146 lockup_psi: float |
None =
None
147 """What the settle waits for [psig]; ``None`` means ``tank_psi``."""
148 settle_band_psi: float = 4.0
149 """Every tank within this of lockup for ``settle_steps`` consecutive steps.
150 Wider than the regulator's own 0.5 % dead band on purpose: a collapsing
151 ullage cycles inside that band and a tighter criterion never fires."""
152 settle_steps: int = 10
153 settle_min_s: float = 2.0
154 settle_max_s: float = 10.0
155 settle_dt: float = 0.05
157 lead_in_s: float = 0.5
158 """Mains-shut hold recorded before ignition: the datum the ignition step is
159 measured from. Without it a trace opens mid-step and the drop is invisible."""
161 horizon_s: float = 14.0
163 """Below this a tank is dry and the burn is over [kg]."""
164 tanks: tuple[str, ...] |
None =
None
165 """Tank ids whose depletion ends the burn; ``None`` means every tank."""
166 end_on_depletion: bool =
False
167 """Shorten the last step so the burn ends when the first tank reaches
168 ``dry_kg``, rather than on whichever step carries it past. Off, the burn
169 ends on a step: the end of burn, and every quantity read there (total
170 impulse, the bottle at burnout, the residual), moves by up to a step's
171 worth as a design change slides the depletion across a step boundary
172 (~330 N.s and ~34 psi of bottle on a 6.8 kN engine at 50 ms). A caller
173 comparing designs against each other wants it on."""
177 """Drawing ids of the valves that press the propellant tanks.
179 The table's press actuators, minus the fill and GSE lines that share the
180 word. In ``Ready`` the table shuts them, so a tank whose ullage is
181 collapsing drifts below lockup while it waits -- physical, and what an
182 operator tops up before going to Ready. A burn wants the tank *at* lockup at
183 T-0, so the settle holds these open and releases them after.
187 for actuator, symbol
in session.binding.to_symbol.items()
188 if "press" in actuator.lower()
189 and "fill" not in actuator.lower()
190 and "gse" not in actuator.lower()
195 """Put the session at T-0: loaded, at lockup, bottle charged, mains shut.
197 Returns whether the settle converged. When it did not, the session's
198 assumptions say by how much, because a trace that opens off its datum reads
199 as a violent drop-and-recovery at ignition.
201 if plan.bottle_litres
is not None:
202 for bottle
in session.bottles.values():
203 bottle.volume.volume = plan.bottle_litres / 1e3
205 def prime(tank_psi: float) ->
None:
207 fill_fraction=plan.fill_fraction,
209 copv_psi=plan.copv_psi,
210 state=plan.ready_state,
220 tank_psi = plan.tank_psi
221 if plan.lockup_psi
is None:
224 for tank_id
in session.vehicle_tanks
230 session.assumptions.append(note)
231 if abs(psig(primed) - plan.tank_psi) > 0.05:
232 tank_psi = psig(primed)
237 lockup = tank_psi
if plan.lockup_psi
is None else plan.lockup_psi
239 session.set_valve(symbol,
True)
240 target = from_psig(lockup)
243 while elapsed < plan.settle_max_s:
244 session.step(plan.settle_dt)
245 elapsed += plan.settle_dt
250 session.assumptions.append(
251 f
"T-0 not reached: the stand tripped during the settle. "
256 abs(sim.pressure - target) < plan.settle_band_psi * PSI
257 for sim
in (session.tanks[t]
for t
in session.vehicle_tanks)
259 steady = steady + 1
if within
else 0
260 if steady >= plan.settle_steps
and elapsed >= plan.settle_min_s:
263 if steady < plan.settle_steps:
265 abs(psig(session.tanks[t].pressure) - lockup)
for t
in session.vehicle_tanks
267 session.assumptions.append(
268 f
"T-0 not settled: after {plan.settle_max_s:.0f} s a tank is "
269 f
"{worst:.1f} psi from lockup; the burn starts off its datum."
278 inlet: float |
None =
None,
280 loaded_dome: bool =
False,
283 """Where the regulator feeding ``tank_id`` locks up, right now [Pa abs].
285 ``inlet`` [Pa abs]: where it would lock up with the bottle at this
286 pressure instead -- a charged COPV, or an empty one, to show the range a
287 tank sees as the bottle blows down. Default: the bottle as it is.
288 ``loaded_dome``: with the dome as its knob sets it, though the dome line
289 is shut (:meth:`Session.peek_signals`).
291 Moves nothing, unless ``place``: T-0 has always evaluated it through the
292 step's :meth:`Session.signals`, which also put each valve where the state
293 commands it and took the dome as it stands -- what a stand put at T-0
294 starts from. Its two callers there keep that, so burns are unchanged.
296 Walks upstream from the tank's ullage to the first regulator in the
297 network (dome loaders are lifted out of it, so this is the unit that
298 presses the tank), then evaluates its own law at zero flow: the session's
299 knob and dome signals, and the bottle behind it at its present pressure,
300 which is what the supply-pressure effect reads. ``None`` when no regulator
301 feeds the tank -- a stand pressed straight off a bottle has no lockup.
305 net = session.model.built.network
306 sim = session.tanks[tank_id]
310 boundaries = set(net.fixed_nodes) | {sim.outlet_node}
312 seen = {sim.ullage_node}
313 frontier = [sim.ullage_node]
314 while frontier
and regulator
is None:
316 for node
in frontier:
317 for branch
in net.branches.values():
318 if node
not in (branch.upstream, branch.downstream):
321 branch.upstream
if branch.downstream == node
else branch.downstream
325 if isinstance(branch.component, Regulator):
329 if other
not in boundaries:
331 if regulator
is not None:
334 if regulator
is None:
339 ground = session.ground
340 bottles = [b.pressure
for k, b
in session.bottles.items()
if k
not in ground]
343 component = regulator.component
344 assert isinstance(component, Regulator)
345 supply =
max(bottles)
if inlet
is None else inlet
347 session.signals()
if place
else session.peek_signals(loaded_dome=loaded_dome)
349 flow = net.conditions(regulator.upstream, supply, signals)
350 return float(component.lockup_pressure(flow))
354 """Where tanks pressed toward ``lockup`` [Pa abs] off the vehicle's bottle
355 land, and a note when that is not ``lockup`` (empty when it is).
357 :func:`regulator_lockup` is the regulator's law at zero flow, dome + bias
358 - S x inlet, and nothing in that law stops at the inlet. A regulator
359 delivers no more than its inlet: with the bottle under the lockup it stands
360 wide open and the tanks end at the bottle (T-0 primed LE4 (3) at 579 psig
361 off a 400 psig COPV). Nor does it pull a vacuum: a lockup under atmosphere
362 -- a dome turned to zero against the supply effect -- is a shut regulator,
363 and the tanks stay at atmosphere (it primed them at 9.6 psia).
365 ground = session.ground
366 bottles = [b
for k, b
in session.bottles.items()
if k
not in ground]
369 bottle =
max(bottles, key=
lambda b: b.pressure)
370 if lockup > bottle.pressure:
372 f
"{bottle.label} at {say(bottle.pressure, 1)} is under the "
373 f
"{say(lockup, 1)} lockup: the regulator stands wide open, and "
374 "the tanks were primed at the bottle."
376 lockup = bottle.pressure
377 if lockup < ATMOSPHERE:
379 f
"The regulator locks up at {say(lockup, 1)}, under atmosphere: it is "
380 f
"shut at these knobs, and the tanks were primed at "
381 f
"{say(ATMOSPHERE, 1)}. Turn the dome up to press them."
387@dataclass(frozen=True, slots=True)
389 """Where :func:`jump_to_t0` left the stand."""
391 lockup_psi: dict[str, float]
392 """Each tank's regulator lockup [psig] at the knobs as set; a tank with no
393 regulator upstream is absent."""
395 """What the tanks were primed at [psig]."""
396 notes: list[str] = field(default_factory=list)
397 loads: dict[str, float] = field(default_factory=dict)
398 """The propellant each tank was loaded with [kg], by tank id: the fire
399 load where the engine states one. Empty when nothing named a load and the
400 tanks went to the fill fraction. A burn plan that primes again must carry
401 these, or it reloads to the fill fraction."""
408 fill_fraction: float,
409 hold_s: float = PAD_HOLD_S,
410 ready_state: str =
"Ready",
411 fallback_psi: float = 550.0,
412 loads: Mapping[str, float] |
None =
None,
414 """The cockpit's shortcut past the pad: loaded, charged, pressed, in Ready.
416 The initial condition the study and Layer X burn from
417 (:meth:`Session.prime`: tanks loaded to ``fill_fraction``, a LOX wall
418 chilled by ``hold_s`` on the pad, the bottle at ``copv_psi``), with the
419 tanks at the lockup *this stand's regulators* give at the knobs as they
420 are set (:func:`regulator_lockup`), not at a number chosen here. Ready
421 shuts the press valves, as the table has it; what the tanks then do while
422 the stand waits for Fire is the stand's own physics.
424 Nothing about the stand changes: the knobs, the settings and the drawing
425 stay as they are. The pad itself (fills, chilldown, presses) is skipped,
426 which is the point; fly it from Idle for the transients it carries.
427 Tanks fed by regulators that lock up at different pressures are primed at
428 the lowest of them and the note says so. Never above the bottle nor below
429 atmosphere (:func:`reachable_lockup`): a bottle under the lockup leaves
430 the regulator wide open, and a lockup under the air is a shut regulator.
432 The tanks hold what a fire is loaded with: ``loads`` [kg] per tank, or the
433 engine's fire load (:meth:`Session.fire_loads`) -- never a fraction of the
434 tank drawn, which on LE4 (6) is a third more LOX than the vehicle carries.
435 ``fill_fraction`` is for a tank neither names.
437 notes: list[str] = []
439 loads = session.fire_loads()
441 for tank_id, kg
in list(loads.items()):
442 sim = session.tanks[tank_id]
444 sim.tank.geometry.total_volume
446 * sim.tank.liquid_density(sim.state)
449 loads[tank_id] = full
451 f
"{sim.label}: the {kg:.3f} kg fire load does not fit its "
452 f
"{sim.tank.geometry.total_volume * 1e3:.2f} L; loaded to "
453 f
"{fill_fraction:.0%}, {full:.3f} kg."
457 "Loaded for a fire: "
459 f
"{session.tanks[k].label} {v:.3f} kg" for k, v
in sorted(loads.items())
463 ready = ready_state
if ready_state
in session.machine.states
else session.state
466 fill_fraction=fill_fraction,
467 tank_psi=fallback_psi,
475 for tank_id
in session.vehicle_tanks
478 tank_psi = fallback_psi
481 tank_psi = psig(primed)
482 if max(lockups.values()) -
min(lockups.values()) > PSI:
487 f
"The regulators lock up at {psia(min(lockups.values())):,.1f}-"
488 f
"{say(max(lockups.values()), 1)}; every tank was primed at "
494 fill_fraction=fill_fraction,
503 f
"No regulator feeds the tanks; primed at "
504 f
"{say(from_psig(fallback_psi), 1)}."
506 for tank_id
in session.vehicle_tanks:
507 if lockups
and tank_id
not in lockups:
508 notes.append(f
"{session.tanks[tank_id].label}: no regulator upstream.")
510 lockup_psi={k: round(psig(v), 1)
for k, v
in lockups.items()},
511 tank_psi=round(tank_psi, 1),
517@dataclass(frozen=True, slots=True)
519 """A vessel trip that ended a burn, on the burn's clock."""
522 """Drawing id of the vessel that went over its rating."""
525 """``tank`` or ``bottle``."""
527 """Seconds from the Fire command at which the step that found it ended:
528 negative in the lead-in, and exactly ``-lead_in_s`` when it tripped during
529 the settle, before the lead-in ran (the stand's clock stops at a trip, so
530 how long before is not kept; :attr:`message` and the trace's notes say it
531 was the settle). The trace's last sample is that step."""
533 """The vessel's pressure then, absolute [Pa]."""
535 """What it trips at, absolute [Pa] (:attr:`feedtwin.session.core.Trip.limit`)."""
539@dataclass(frozen=True, slots=True)
541 """How a burn stopped."""
543 depleted_s: float |
None
544 """Seconds after Fire that a tank ran dry, rounded to 0.01 s; ``None`` if
545 none did before the horizon (or the burn was cancelled)."""
547 """Which tank ran dry, empty if none did."""
552 tripped: BurnTrip |
None =
None
553 """Set when a vessel went over its rating: the burn stopped on that step
554 rather than integrating the stand's frozen frame to the horizon."""
562 cancelled: Callable[[], bool] =
lambda:
False,
564 """Lead-in, then Fire until a tank runs dry, a vessel trips, or the horizon
567 The clock reads seconds from the Fire command: negative through the
568 lead-in, zero at ignition. ``record`` sees every step, the one that trips
569 included; nothing after it. A tripped session returns its frozen frame on
570 every later step (:meth:`Session.step`), so carrying on to the horizon would
571 integrate one instant's thrust for the rest of the burn -- 99,478 N.s
572 instead of ~24,000 on the LE4 audit's restated fuel tank (AUDIT.md 5.2).
574 started = time.perf_counter()
578 session.tanks[tank_id]
579 for tank_id
in (plan.tanks
or session.vehicle_tanks)
580 if tank_id
in session.tanks
586 def tripped(fire_t: float |
None) -> BurnTrip |
None:
591 t = trip.t - origin - plan.lead_in_s
599 pressure=trip.pressure,
601 message=session.tripped
or "",
604 def ended(trip: BurnTrip |
None, stopped: bool =
False) -> BurnEnd:
605 if trip
is not None and steps > 0:
608 session.assumptions.append(
609 f
"Burn stopped at t = {trip.t:+.3f} s, the step a vessel tripped: "
618 wall_s=time.perf_counter() - started,
624 return ended(tripped(
None))
626 clock = -plan.lead_in_s
628 sample = session.step(plan.dt)
631 failed += 0
if sample.converged
else 1
632 record(clock, sample,
False)
634 return ended(tripped(
None))
636 session.state = plan.fire_state
637 session._state_since = session.t
640 depleted: float |
None =
None
643 last: dict[str, float] = {}
644 while clock <= plan.horizon_s:
650 if plan.end_on_depletion
and last:
654 rate = (last[sim.id] - sim.state.liquid_mass) / plan.dt
656 reach = (sim.state.liquid_mass - plan.dry_kg) / rate
658 step, final =
max(reach, 1e-4), sim.id
659 before = {sim.id: sim.state.liquid_mass
for sim
in watched}
660 sample = session.step(step)
663 failed += 0
if sample.converged
else 1
664 record(clock, sample,
True)
666 return ended(tripped(fire_t))
669 empty = [sim
for sim
in watched
if sim.state.liquid_mass < plan.dry_kg]
671 depleted = clock
if plan.end_on_depletion
else round(clock, 2)
672 dry = empty[0].id
if empty
else final
681 wall_s=time.perf_counter() - started,
688@dataclass(frozen=True, slots=True)
690 """The places on a feed system a burn is read at, found from the drawing.
692 Every id is a network node except where noted. A probe the drawing does
693 not have is simply absent.
696 tank_ullage: Mapping[str, str] = field(default_factory=dict)
697 tank_outlet: Mapping[str, str] = field(default_factory=dict)
698 tank_fluid: Mapping[str, str] = field(default_factory=dict)
699 """Tank id to the species it holds."""
700 regulator_outlet: Mapping[str, str] = field(default_factory=dict)
701 """Regulator symbol id to the node its last branch delivers into."""
702 regulator_label: Mapping[str, str] = field(default_factory=dict)
703 injector_inlet: Mapping[str, str] = field(default_factory=dict)
704 """``oxidiser``/``fuel`` to the injector-face node, when an engine is on."""
706 bottles: tuple[str, ...] = ()
707 """Bottle ids (also their network nodes, via :class:`BottleSim`)."""
708 instruments: Mapping[str, str] = field(default_factory=dict)
709 """Instrument id to the network node it reads. Opt-in: :func:`find_probes`
710 leaves it empty, so a burn records what it always did; a caller that wants
711 the drawing's own transducers read (to compare with the DAQ) adds them."""
712 network: bool =
False
713 """Record the whole network every step -- every node's pressure and
714 temperature, every branch's flow, drop and opening, and the bottle-to-chamber
715 path per side -- into :attr:`BurnTrace.network`. Opt-in and recording only:
716 off, the trace is exactly what it always was; on, no physics changes. See
717 :mod:`feedtwin.session.network_trace` (node values, not vessel states)."""
721 """Read the probe points off a built session."""
722 built = session.model.built
724 regulator_outlet: dict[str, str] = {}
725 regulator_label: dict[str, str] = {}
726 for node
in session.model.diagram.nodes:
727 if node.type !=
"PR":
729 branches = [b
for b
in built.branches_of.get(node.id, ())
if b
in net.branches]
731 regulator_outlet[node.id] = net.branches[branches[-1]].downstream
732 regulator_label[node.id] = node.label
or node.id
734 node.id: str(node.fluid
or "")
735 for node
in session.model.diagram.nodes
736 if node.id
in session.tanks
740 ports = built.engine_ports
741 inlets: dict[str, str] = {}
742 for side
in (
"oxidiser",
"fuel"):
743 port = ports.get(side,
"")
744 if port
in net.branches:
745 inlets[side] = net.branches[port].upstream
746 elif port
in net.nodes:
749 tank_ullage={k: s.ullage_node
for k, s
in session.tanks.items()},
750 tank_outlet={k: s.outlet_node
for k, s
in session.tanks.items()},
752 regulator_outlet=regulator_outlet,
753 regulator_label=regulator_label,
754 injector_inlet=inlets,
755 chamber=ports.get(
"chamber",
""),
756 bottles=tuple(session.bottles),
769 "ullage_temperature_K",
770 "liquid_temperature_K",
772 "wall_temperature_K",
773 "surface_temperature_K",
779 """A burn, sampled at every step. Absolute SI throughout.
781 Columns rather than rows, because every consumer plots columns. ``t`` is
782 seconds from the Fire command; ``firing`` is false through the lead-in.
786 t: list[float] = field(default_factory=list)
787 firing: list[bool] = field(default_factory=list)
788 converged: list[bool] = field(default_factory=list)
789 pressure: dict[str, list[float]] = field(default_factory=dict)
790 """Node id to absolute pressure [Pa], for every probe node."""
791 temperature: dict[str, list[float]] = field(default_factory=dict)
792 """Node id to temperature [K], for every probe node the solve reports."""
793 tank: dict[str, dict[str, list[float]]] = field(default_factory=dict)
794 """Tank id to ``pressure_Pa``, ``outlet_pressure_Pa`` and :data:`TANK_FIELDS`."""
795 bottle: dict[str, dict[str, list[float]]] = field(default_factory=dict)
796 """Bottle id to ``pressure_Pa``, ``mass_kg``, ``wall_temperature_K``."""
797 chamber: dict[str, list[float]] = field(default_factory=dict)
798 """``pressure_Pa``, ``mdot_oxidiser``, ``mdot_fuel``, ``mixture_ratio``,
799 ``thrust_N``, ``isp_s``, ``cstar``, ``extrapolated`` (1.0 when the CEA
800 point was clamped). Zeros while not firing."""
801 end: BurnEnd |
None =
None
802 t0_settled: bool =
True
803 notes: list[str] = field(default_factory=list)
804 network: NetworkTrace |
None =
None
805 """The whole network per step, when :attr:`Probes.network` asked for it;
806 ``None`` otherwise. :func:`network_dict` turns it into the result block."""
811 *p.tank_ullage.values(),
812 *p.tank_outlet.values(),
813 *p.regulator_outlet.values(),
814 *p.injector_inlet.values(),
815 *p.instruments.values(),
818 found.append(p.chamber)
819 return list(dict.fromkeys(n
for n
in found
if n))
822 """A :data:`Recorder` that appends every step of ``session`` here."""
827 for tank_id
in session.tanks:
828 columns: dict[str, list[float]] = {
830 "outlet_pressure_Pa": [],
832 columns.update({k: []
for k
in TANK_FIELDS})
833 self.
tank.setdefault(tank_id, columns)
834 for bottle_id
in session.bottles:
836 bottle_id, {
"pressure_Pa": [],
"mass_kg": [],
"wall_temperature_K": []}
838 if self.probes.network
and self.
network is None:
839 self.
network = NetworkTrace.of(session)
840 network = self.
network if self.probes.network
else None
851 self.
chamber.setdefault(key, [])
853 def record(clock: float, sample: Sample, firing: bool) ->
None:
855 self.
firing.append(firing)
856 self.
converged.append(bool(sample.converged))
858 self.
pressure[node].append(float(sample.pressures.get(node, 0.0)))
859 self.
temperature[node].append(float(sample.temperatures.get(node, 0.0)))
860 if network
is not None:
861 network.append(sample)
862 for tank_id, sim
in session.tanks.items():
863 column = self.
tank[tank_id]
864 readout = sim.readouts()
865 column[
"pressure_Pa"].append(sim.pressure)
866 column[
"outlet_pressure_Pa"].append(sim.outlet_pressure)
867 for key
in TANK_FIELDS:
868 column[key].append(float(readout[key]))
869 for bottle_id, bottle
in session.bottles.items():
870 column = self.
bottle[bottle_id]
871 column[
"pressure_Pa"].append(bottle.pressure)
872 column[
"mass_kg"].append(float(bottle.state.mass))
873 column[
"wall_temperature_K"].append(
874 float(bottle.state.wall_temperature)
876 result = sample.chamber
if firing
else None
879 for column_values
in ch.values():
880 column_values.append(0.0)
882 ch[
"pressure_Pa"].append(float(result.pressure))
883 ch[
"mdot_oxidiser"].append(float(result.mdot_oxidiser))
884 ch[
"mdot_fuel"].append(float(result.mdot_fuel))
885 ch[
"mixture_ratio"].append(float(result.mixture_ratio))
886 ch[
"thrust_N"].append(float(result.thrust))
887 ch[
"isp_s"].append(float(result.specific_impulse))
888 ch[
"cstar"].append(float(result.combustion.cstar))
889 ch[
"extrapolated"].append(1.0
if result.combustion.extrapolated
else 0.0)
898 cancelled: Callable[[], bool] =
lambda:
False,
899 also: Recorder |
None =
None,
900 network: bool =
False,
902 """Prime, settle, burn, and return the whole trace.
904 ``also`` is called alongside the trace's own recorder -- for progress, or a
905 caller that wants its own columns. ``network`` records the whole network
906 as well (:attr:`Probes.network`); off, the trace is what it always was.
911 probes = replace(probes, network=
True)
912 trace =
BurnTrace(probes=probes, t0_settled=settled)
913 own = trace.recorder(session)
915 def record(clock: float, sample: Sample, firing: bool) ->
None:
916 own(clock, sample, firing)
918 also(clock, sample, firing)
920 trace.end = burn(session, plan, record, cancelled=cancelled)
921 trace.notes = list(dict.fromkeys(session.assumptions))
926 """The trace's network as the Layer X result's ``network`` block
927 (EngineDesign/docs/layerx/DATA-CONTRACT.md section 2).
929 Pressures psia (absolute), drops psi, temperatures K, flows kg/s, on the
930 trace's own clock. Node values are the step's last network solve -- a vessel
931 node is the boundary that solve used, not the post-step vessel state (the
932 block's ``basis`` says so). A burn recorded without :attr:`Probes.network`
933 gives ``{"available": False, "error": ...}`` rather than raising.
935 if trace.network
is None:
938 "error":
"the burn was recorded without Probes(network=True)",
940 return trace.network.record(trace.t)
944network_record = network_dict
948 """The Layer X result's ``tripped`` block, or ``None`` when nothing tripped.
950 ``{vessel, label, kind, t, p_psia, mawp_psia, message}``: absolute psia, on
951 the trace's clock. ``mawp_psia`` is the pressure the stand trips at, which is
952 the drawing's MAWP or its burst pressure over the safety factor.
954 trip = trace.end.tripped
if trace.end
is not None else None
958 "vessel": trip.vessel,
962 "p_psia": trip.pressure / PSI,
963 "mawp_psia": trip.limit / PSI,
964 "message": trip.message,
968def plan_with(plan: BurnPlan, **changes: Any) -> BurnPlan:
969 """``plan`` with ``changes``. A frozen dataclass rebuilt with ``replace``,
970 so a field added later is carried rather than dropped."""
971 return replace(plan, **changes)
Recorder recorder(self, Session session)
BurnPlan plan_with(BurnPlan plan, **Any changes)
T0 jump_to_t0(Session session, *, float copv_psi, float fill_fraction, float hold_s=PAD_HOLD_S, str ready_state="Ready", float fallback_psi=550.0, Mapping[str, float]|None loads=None)
list[str] press_valves(Session session)
BurnTrace run_burn(Session session, BurnPlan plan, *, Callable[[], bool] cancelled=lambda:False, Recorder|None also=None, bool network=False)
dict[str, Any] network_dict(BurnTrace trace)
Setup burn_setup(**Any changes)
tuple[float, str] reachable_lockup(Session session, float lockup)
float|None regulator_lockup(Session session, str tank_id, float|None inlet=None, *, bool loaded_dome=False, bool place=False)
Session open_session(Model model, StateMachine machine, *, Setup|None setup=None, Hookup|None hookup=None)
dict[str, Any]|None trip_record(BurnTrace trace)
Probes find_probes(Session session)
bool prime_at_t0(Session session, BurnPlan plan)
static void record(void *ctx, uint8_t type, const void *data, uint8_t len)