STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
burn.py
Go to the documentation of this file.
1"""A burn, from T-0 to a dry tank, as one call.
2
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.
8
9What this is not
10----------------
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`.
16
17Numerics
18--------
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.
24"""
25
26from __future__ import annotations
27
28import time
29from dataclasses import dataclass, field, replace
30from typing import Any, Callable, Mapping
31
32from feedtwin.session.core import PAD_HOLD_S, Sample, Session, Setup
33from feedtwin.session.gauge import ATMOSPHERE, PSI, from_psig, psia, psig, say
34from feedtwin.session.model import Model
35from feedtwin.session.network_trace import NetworkTrace
36from feedtwin.session.hookup import Hookup
37from feedtwin.session.hookup import binding as hookup_binding
38from feedtwin.session.statemachine import StateMachine
39
40#: Called after every step of a burn with ``(clock, sample, firing)``. The
41#: session itself is live at that moment, so a recorder may read it directly.
42Recorder = Callable[[float, Sample, bool], None]
43
44
45def burn_setup(**changes: Any) -> Setup:
46 """The study's numerics, with ``changes`` on top.
47
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
51 they meant.
52 """
53 base: dict[str, Any] = {
54 "stratification": False,
55 "boiling_onset_K": 0.0,
56 "chilldown_nucleate": 0.0,
57 # On in the library and the cockpit since 2026-10-03; off here so the
58 # benchmark (docs/PHYSICS-BENCHMARK.md 2.x) is the scheme it was stated
59 # at. A study case asks for line walls itself.
60 "line_walls": False,
61 # The ullage against its dry wall only (2026-10-06): on in the library
62 # and the cockpit, off here for the same reason as line walls.
63 "ullage_wall_by_level": False,
64 # The regulator seat as a gas sees it (2026-10-08): the same again.
65 "regulator_compressible_seat": False,
66 # A burn reads the trace past depletion; the cockpit's automatic Vent
67 # at burnout would open the vents on it.
68 "auto_vent": False,
69 "max_iterations": 120,
70 "tick_budget": 1.0e9,
71 }
72 base.update(changes)
73 return Setup(**base)
74
75
76#: The key :func:`open_session` marks a model's ``meta`` with once a session has run it.
77OPENED = "session_opened"
78
79
81 model: Model,
82 machine: StateMachine,
83 *,
84 setup: Setup | None = None,
85 hookup: Hookup | None = None,
86) -> Session:
87 """A session on ``model``, its valves bound to ``machine`` by label.
88
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).
96 """
97 if model.meta.get(OPENED):
98 raise ValueError(
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"
101 )
102 if isinstance(model.meta, dict):
103 model.meta[OPENED] = True
104 # The cockpit's own binding (names, the plumbing, the hookup's pins): one
105 # rule for both, so a stand binds the same wherever it is run.
106 return Session(
107 model,
108 machine,
109 hookup_binding(model, machine, hookup),
110 setup=setup,
111 hookup=hookup,
112 )
113
114
115@dataclass(frozen=True, slots=True)
117 """Where T-0 is and how to get past it.
118
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.
122 """
123
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"
143
144 settle: bool = True
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
156
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."""
160 dt: float = 0.05
161 horizon_s: float = 14.0
162 dry_kg: float = 0.06
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."""
174
175
176def press_valves(session: Session) -> list[str]:
177 """Drawing ids of the valves that press the propellant tanks.
178
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.
184 """
185 return [
186 symbol
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()
191 ]
192
193
194def prime_at_t0(session: Session, plan: BurnPlan) -> bool:
195 """Put the session at T-0: loaded, at lockup, bottle charged, mains shut.
196
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.
200 """
201 if plan.bottle_litres is not None:
202 for bottle in session.bottles.values():
203 bottle.volume.volume = plan.bottle_litres / 1e3
204
205 def prime(tank_psi: float) -> None:
206 session.prime(
207 fill_fraction=plan.fill_fraction,
208 tank_psi=tank_psi,
209 copv_psi=plan.copv_psi,
210 state=plan.ready_state,
211 hold_s=plan.hold_s,
212 loads=plan.loads,
213 )
214
215 # Charge the bottle first: the regulator's lockup reads it. Then put the
216 # tanks at that lockup, unless the plan names one. A caller that already
217 # solved its dome for ``tank_psi`` (Layer X) lands within a hair of it and
218 # keeps its own number.
219 prime(plan.tank_psi)
220 tank_psi = plan.tank_psi
221 if plan.lockup_psi is None:
222 lockups = [
223 lockup
224 for tank_id in session.vehicle_tanks
225 if (lockup := regulator_lockup(session, tank_id, place=True)) is not None
226 ]
227 if lockups:
228 primed, note = reachable_lockup(session, min(lockups))
229 if note:
230 session.assumptions.append(note)
231 if abs(psig(primed) - plan.tank_psi) > 0.05:
232 tank_psi = psig(primed)
233 prime(tank_psi)
234 if not plan.settle:
235 return True
236
237 lockup = tank_psi if plan.lockup_psi is None else plan.lockup_psi
238 for symbol in press_valves(session):
239 session.set_valve(symbol, True)
240 target = from_psig(lockup)
241 steady = 0
242 elapsed = 0.0
243 while elapsed < plan.settle_max_s:
244 session.step(plan.settle_dt)
245 elapsed += plan.settle_dt
246 if session.tripped:
247 # A vessel over its rating during the settle: the stand has
248 # stopped, and every further step would return the same frame.
249 session.release()
250 session.assumptions.append(
251 f"T-0 not reached: the stand tripped during the settle. "
252 f"{session.tripped}"
253 )
254 return False
255 within = all(
256 abs(sim.pressure - target) < plan.settle_band_psi * PSI
257 for sim in (session.tanks[t] for t in session.vehicle_tanks)
258 )
259 steady = steady + 1 if within else 0
260 if steady >= plan.settle_steps and elapsed >= plan.settle_min_s:
261 break
262 session.release()
263 if steady < plan.settle_steps:
264 worst = max(
265 abs(psig(session.tanks[t].pressure) - lockup) for t in session.vehicle_tanks
266 )
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."
270 )
271 return False
272 return True
273
274
276 session: Session,
277 tank_id: str,
278 inlet: float | None = None,
279 *,
280 loaded_dome: bool = False,
281 place: bool = False,
282) -> float | None:
283 """Where the regulator feeding ``tank_id`` locks up, right now [Pa abs].
284
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`).
290
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.
295
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.
302 """
303 from feedtwin.comps.regulator import Regulator
304
305 net = session.model.built.network
306 sim = session.tanks[tank_id]
307 # Undirected: a drawing's edge direction is how it was drawn, not which
308 # way gas flows. The walk stops at boundaries -- other vessels, the
309 # chamber, this tank's own liquid outlet -- so it searches the press side.
310 boundaries = set(net.fixed_nodes) | {sim.outlet_node}
311 regulator = None
312 seen = {sim.ullage_node}
313 frontier = [sim.ullage_node]
314 while frontier and regulator is None:
315 nxt: list[str] = []
316 for node in frontier:
317 for branch in net.branches.values():
318 if node not in (branch.upstream, branch.downstream):
319 continue
320 other = (
321 branch.upstream if branch.downstream == node else branch.downstream
322 )
323 if other in seen:
324 continue
325 if isinstance(branch.component, Regulator):
326 regulator = branch
327 break
328 seen.add(other)
329 if other not in boundaries:
330 nxt.append(other)
331 if regulator is not None:
332 break
333 frontier = nxt
334 if regulator is None:
335 return None
336 # The vehicle's bottle: a cart's K-bottle bank sits at 6,000 psi behind a
337 # shut valve, and the supply effect reads the bottle that feeds the
338 # regulator, not the fullest one on the stand.
339 ground = session.ground
340 bottles = [b.pressure for k, b in session.bottles.items() if k not in ground]
341 if not bottles:
342 return None
343 component = regulator.component
344 assert isinstance(component, Regulator)
345 supply = max(bottles) if inlet is None else inlet
346 signals = (
347 session.signals() if place else session.peek_signals(loaded_dome=loaded_dome)
348 )
349 flow = net.conditions(regulator.upstream, supply, signals)
350 return float(component.lockup_pressure(flow))
351
352
353def reachable_lockup(session: Session, lockup: float) -> tuple[float, str]:
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).
356
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).
364 """
365 ground = session.ground
366 bottles = [b for k, b in session.bottles.items() if k not in ground]
367 note = ""
368 if bottles:
369 bottle = max(bottles, key=lambda b: b.pressure)
370 if lockup > bottle.pressure:
371 note = (
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."
375 )
376 lockup = bottle.pressure
377 if lockup < ATMOSPHERE:
378 note = (
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."
382 )
383 lockup = ATMOSPHERE
384 return lockup, note
385
386
387@dataclass(frozen=True, slots=True)
388class T0:
389 """Where :func:`jump_to_t0` left the stand."""
390
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."""
394 tank_psi: float
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."""
402
403
405 session: Session,
406 *,
407 copv_psi: float,
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,
413) -> T0:
414 """The cockpit's shortcut past the pad: loaded, charged, pressed, in Ready.
415
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.
423
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.
431
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.
436 """
437 notes: list[str] = []
438 if loads is None:
439 loads = session.fire_loads()
440 loads = dict(loads)
441 for tank_id, kg in list(loads.items()):
442 sim = session.tanks[tank_id]
443 full = (
444 sim.tank.geometry.total_volume
445 * fill_fraction
446 * sim.tank.liquid_density(sim.state)
447 )
448 if kg > full:
449 loads[tank_id] = full
450 notes.append(
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."
454 )
455 if loads:
456 notes.append(
457 "Loaded for a fire: "
458 + ", ".join(
459 f"{session.tanks[k].label} {v:.3f} kg" for k, v in sorted(loads.items())
460 )
461 + "."
462 )
463 ready = ready_state if ready_state in session.machine.states else session.state
464 # Charge the bottle first: the supply-pressure effect reads it.
465 session.prime(
466 fill_fraction=fill_fraction,
467 tank_psi=fallback_psi,
468 copv_psi=copv_psi,
469 state=ready,
470 hold_s=hold_s,
471 loads=loads,
472 )
473 lockups = {
474 tank_id: lockup
475 for tank_id in session.vehicle_tanks
476 if (lockup := regulator_lockup(session, tank_id, place=True)) is not None
477 }
478 tank_psi = fallback_psi
479 if lockups:
480 primed, note = reachable_lockup(session, min(lockups.values()))
481 tank_psi = psig(primed)
482 if max(lockups.values()) - min(lockups.values()) > PSI:
483 # Where the tanks went, not the lowest lockup: under a bottle below
484 # it the next note says they were primed at the bottle, and this one
485 # said the lockup.
486 notes.append(
487 f"The regulators lock up at {psia(min(lockups.values())):,.1f}-"
488 f"{say(max(lockups.values()), 1)}; every tank was primed at "
489 f"{say(primed, 1)}."
490 )
491 if note:
492 notes.append(note)
493 session.prime(
494 fill_fraction=fill_fraction,
495 tank_psi=tank_psi,
496 copv_psi=copv_psi,
497 state=ready,
498 hold_s=hold_s,
499 loads=loads,
500 )
501 else:
502 notes.append(
503 f"No regulator feeds the tanks; primed at "
504 f"{say(from_psig(fallback_psi), 1)}."
505 )
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.")
509 return T0(
510 lockup_psi={k: round(psig(v), 1) for k, v in lockups.items()},
511 tank_psi=round(tank_psi, 1),
512 notes=notes,
513 loads=loads,
514 )
515
516
517@dataclass(frozen=True, slots=True)
519 """A vessel trip that ended a burn, on the burn's clock."""
520
521 vessel: str
522 """Drawing id of the vessel that went over its rating."""
523 label: str
524 kind: str
525 """``tank`` or ``bottle``."""
526 t: float
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."""
532 pressure: float
533 """The vessel's pressure then, absolute [Pa]."""
534 limit: float
535 """What it trips at, absolute [Pa] (:attr:`feedtwin.session.core.Trip.limit`)."""
536 message: str
537
538
539@dataclass(frozen=True, slots=True)
541 """How a burn stopped."""
542
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)."""
546 tank: str
547 """Which tank ran dry, empty if none did."""
548 cancelled: bool
549 steps: int
550 failed_steps: int
551 wall_s: float
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."""
555
556
557def burn(
558 session: Session,
559 plan: BurnPlan,
560 record: Recorder,
561 *,
562 cancelled: Callable[[], bool] = lambda: False,
563) -> BurnEnd:
564 """Lead-in, then Fire until a tank runs dry, a vessel trips, or the horizon
565 passes.
566
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).
573 """
574 started = time.perf_counter()
575 # The vehicle's tanks: a cart's transfer tank running low is not a burn
576 # ending.
577 watched = [
578 session.tanks[tank_id]
579 for tank_id in (plan.tanks or session.vehicle_tanks)
580 if tank_id in session.tanks
581 ]
582 steps = 0
583 failed = 0
584 origin = session.t # the session clock at the start of the lead-in
585
586 def tripped(fire_t: float | None) -> BurnTrip | None:
587 trip = session.trip
588 if trip is None:
589 return None
590 if fire_t is None:
591 t = trip.t - origin - plan.lead_in_s
592 else:
593 t = trip.t - fire_t
594 return BurnTrip(
595 vessel=trip.vessel,
596 label=trip.label,
597 kind=trip.kind,
598 t=t,
599 pressure=trip.pressure,
600 limit=trip.limit,
601 message=session.tripped or "",
602 )
603
604 def ended(trip: BurnTrip | None, stopped: bool = False) -> BurnEnd:
605 if trip is not None and steps > 0:
606 # Said in the trace's notes too (run_burn copies the assumptions),
607 # so a reader of the notes alone does not take it for a full burn.
608 session.assumptions.append(
609 f"Burn stopped at t = {trip.t:+.3f} s, the step a vessel tripped: "
610 f"{trip.message}"
611 )
612 return BurnEnd(
613 depleted_s=None,
614 tank="",
615 cancelled=stopped,
616 steps=steps,
617 failed_steps=failed,
618 wall_s=time.perf_counter() - started,
619 tripped=trip,
620 )
621
622 if session.tripped:
623 # Tripped before the lead-in (the settle): there is no burn to run.
624 return ended(tripped(None))
625
626 clock = -plan.lead_in_s
627 while clock < -1e-9:
628 sample = session.step(plan.dt)
629 clock += plan.dt
630 steps += 1
631 failed += 0 if sample.converged else 1
632 record(clock, sample, False)
633 if session.tripped:
634 return ended(tripped(None))
635
636 session.state = plan.fire_state
637 session._state_since = session.t
638 fire_t = session.t
639 clock = 0.0
640 depleted: float | None = None
641 dry = ""
642 stopped = False
643 last: dict[str, float] = {}
644 while clock <= plan.horizon_s:
645 if cancelled():
646 stopped = True
647 break
648 step = plan.dt
649 final = ""
650 if plan.end_on_depletion and last:
651 # Each tank's drain rate over the last step, held for this one: the
652 # step that would carry a tank past dry_kg is cut to land on it.
653 for sim in watched:
654 rate = (last[sim.id] - sim.state.liquid_mass) / plan.dt
655 if rate > 0.0:
656 reach = (sim.state.liquid_mass - plan.dry_kg) / rate
657 if reach < step:
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)
661 clock += step
662 steps += 1
663 failed += 0 if sample.converged else 1
664 record(clock, sample, True)
665 if session.tripped:
666 return ended(tripped(fire_t))
667 if step == plan.dt:
668 last = before
669 empty = [sim for sim in watched if sim.state.liquid_mass < plan.dry_kg]
670 if empty or final:
671 depleted = clock if plan.end_on_depletion else round(clock, 2)
672 dry = empty[0].id if empty else final
673 break
674
675 return BurnEnd(
676 depleted_s=depleted,
677 tank=dry,
678 cancelled=stopped,
679 steps=steps,
680 failed_steps=failed,
681 wall_s=time.perf_counter() - started,
682 )
683
684
685# --------------------------------------------------------------------- a trace
686
687
688@dataclass(frozen=True, slots=True)
689class Probes:
690 """The places on a feed system a burn is read at, found from the drawing.
691
692 Every id is a network node except where noted. A probe the drawing does
693 not have is simply absent.
694 """
695
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."""
705 chamber: str = ""
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)."""
718
719
720def find_probes(session: Session) -> Probes:
721 """Read the probe points off a built session."""
722 built = session.model.built
723 net = built.network
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":
728 continue
729 branches = [b for b in built.branches_of.get(node.id, ()) if b in net.branches]
730 if branches:
731 regulator_outlet[node.id] = net.branches[branches[-1]].downstream
732 regulator_label[node.id] = node.label or node.id
733 fluids = {
734 node.id: str(node.fluid or "")
735 for node in session.model.diagram.nodes
736 if node.id in session.tanks
737 }
738 # The engine's side ports are its injector *branches*; the injector inlet
739 # is the node each one draws from -- the face the feed lines deliver to.
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:
747 inlets[side] = port
748 return Probes(
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()},
751 tank_fluid=fluids,
752 regulator_outlet=regulator_outlet,
753 regulator_label=regulator_label,
754 injector_inlet=inlets,
755 chamber=ports.get("chamber", ""),
756 bottles=tuple(session.bottles),
757 )
758
759
760#: Tank quantities recorded per step, beyond pressure.
761#:
762#: ``surface_temperature_K`` is the liquid surface the ullage sees -- the
763#: stratified layer when ``Setup.stratification`` is on, the bulk liquid when it
764#: is off (then equal to ``liquid_temperature_K``). It is what sets the vapour
765#: pressure, so it is the temperature a saturation margin at the tank is taken
766#: against (EngineDesign/docs/layerx/AUDIT.md 5.4, 9.5 section 4).
767TANK_FIELDS = (
768 "liquid_mass_kg",
769 "ullage_temperature_K",
770 "liquid_temperature_K",
771 "fill_fraction",
772 "wall_temperature_K",
773 "surface_temperature_K",
774)
775
776
777@dataclass
779 """A burn, sampled at every step. Absolute SI throughout.
780
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.
783 """
784
785 probes: Probes
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."""
807
808 def nodes(self) -> list[str]:
809 p = self.probes
810 found = [
811 *p.tank_ullage.values(),
812 *p.tank_outlet.values(),
813 *p.regulator_outlet.values(),
814 *p.injector_inlet.values(),
815 *p.instruments.values(),
816 ]
817 if p.chamber:
818 found.append(p.chamber)
819 return list(dict.fromkeys(n for n in found if n))
820
821 def recorder(self, session: Session) -> Recorder:
822 """A :data:`Recorder` that appends every step of ``session`` here."""
823 nodes = self.nodes()
824 for node in nodes:
825 self.pressure.setdefault(node, [])
826 self.temperature.setdefault(node, [])
827 for tank_id in session.tanks:
828 columns: dict[str, list[float]] = {
829 "pressure_Pa": [],
830 "outlet_pressure_Pa": [],
831 }
832 columns.update({k: [] for k in TANK_FIELDS})
833 self.tank.setdefault(tank_id, columns)
834 for bottle_id in session.bottles:
835 self.bottle.setdefault(
836 bottle_id, {"pressure_Pa": [], "mass_kg": [], "wall_temperature_K": []}
837 )
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
841 for key in (
842 "pressure_Pa",
843 "mdot_oxidiser",
844 "mdot_fuel",
845 "mixture_ratio",
846 "thrust_N",
847 "isp_s",
848 "cstar",
849 "extrapolated",
850 ):
851 self.chamber.setdefault(key, [])
852
853 def record(clock: float, sample: Sample, firing: bool) -> None:
854 self.t.append(clock)
855 self.firing.append(firing)
856 self.converged.append(bool(sample.converged))
857 for node in nodes:
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)
875 )
876 result = sample.chamber if firing else None
877 ch = self.chamber
878 if result is None:
879 for column_values in ch.values():
880 column_values.append(0.0)
881 return
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)
890
891 return record
892
893
895 session: Session,
896 plan: BurnPlan,
897 *,
898 cancelled: Callable[[], bool] = lambda: False,
899 also: Recorder | None = None,
900 network: bool = False,
901) -> BurnTrace:
902 """Prime, settle, burn, and return the whole trace.
903
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.
907 """
908 settled = prime_at_t0(session, plan)
909 probes = find_probes(session)
910 if network:
911 probes = replace(probes, network=True)
912 trace = BurnTrace(probes=probes, t0_settled=settled)
913 own = trace.recorder(session)
914
915 def record(clock: float, sample: Sample, firing: bool) -> None:
916 own(clock, sample, firing)
917 if also is not None:
918 also(clock, sample, firing)
919
920 trace.end = burn(session, plan, record, cancelled=cancelled)
921 trace.notes = list(dict.fromkeys(session.assumptions))
922 return trace
923
924
925def network_dict(trace: BurnTrace) -> dict[str, Any]:
926 """The trace's network as the Layer X result's ``network`` block
927 (EngineDesign/docs/layerx/DATA-CONTRACT.md section 2).
928
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.
934 """
935 if trace.network is None:
936 return {
937 "available": False,
938 "error": "the burn was recorded without Probes(network=True)",
939 }
940 return trace.network.record(trace.t)
941
942
943#: The name the wave-1 Layer X diagnostics were written against; the same function.
944network_record = network_dict
945
946
947def trip_record(trace: BurnTrace) -> dict[str, Any] | None:
948 """The Layer X result's ``tripped`` block, or ``None`` when nothing tripped.
949
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.
953 """
954 trip = trace.end.tripped if trace.end is not None else None
955 if trip is None:
956 return None
957 return {
958 "vessel": trip.vessel,
959 "label": trip.label,
960 "kind": trip.kind,
961 "t": trip.t,
962 "p_psia": trip.pressure / PSI,
963 "mawp_psia": trip.limit / PSI,
964 "message": trip.message,
965 }
966
967
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)
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
list[str] nodes(self)
Definition burn.py:808
Recorder recorder(self, Session session)
Definition burn.py:821
BurnPlan plan_with(BurnPlan plan, **Any changes)
Definition burn.py:968
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)
Definition burn.py:413
list[str] press_valves(Session session)
Definition burn.py:176
BurnTrace run_burn(Session session, BurnPlan plan, *, Callable[[], bool] cancelled=lambda:False, Recorder|None also=None, bool network=False)
Definition burn.py:901
dict[str, Any] network_dict(BurnTrace trace)
Definition burn.py:925
Setup burn_setup(**Any changes)
Definition burn.py:45
tuple[float, str] reachable_lockup(Session session, float lockup)
Definition burn.py:353
float|None regulator_lockup(Session session, str tank_id, float|None inlet=None, *, bool loaded_dome=False, bool place=False)
Definition burn.py:282
Session open_session(Model model, StateMachine machine, *, Setup|None setup=None, Hookup|None hookup=None)
Definition burn.py:86
dict[str, Any]|None trip_record(BurnTrace trace)
Definition burn.py:947
Probes find_probes(Session session)
Definition burn.py:720
bool prime_at_t0(Session session, BurnPlan plan)
Definition burn.py:194
static void record(void *ctx, uint8_t type, const void *data, uint8_t len)