STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
core.py
Go to the documentation of this file.
1"""A stand that exists in time.
2
3Everything before this was a calculator with a state label on it: each request
4re-solved from scratch with the tanks *pinned* at the regulator's setpoint. So
5the tanks were always pressurised, "Fuel Press" changed nothing you could see,
6and transducers read pressures that had no business existing. Nothing
7accumulated, so nothing behaved.
8
9A session fixes that by holding the thing a stand actually has -- **inventory**.
10Each tank is a real vessel with a gas mass, a gas energy, a liquid mass and a
11wall temperature, and those integrate forward. That single change is what makes
12the sequence mean something:
13
14* A tank starts **empty and at atmosphere**. Its transducer reads 14.7 psi,
15 because that is what a vented tank reads.
16* ``Ox Fill`` puts propellant in it. The level rises; the ullage shrinks.
17* ``Ox Press`` opens the path from the regulator, gas flows into the ullage, and
18 the pressure *climbs over several seconds* the way a real tank does -- fast at
19 first into a small ullage, slower as it fills.
20* ``Ox Vent`` opens it to atmosphere and it blows down.
21* ``Fire`` opens the mains, propellant leaves, and the ullage cools and droops
22 because the gas behind it is doing expansion work.
23
24None of that is scripted. It falls out of integrating the vessels against a
25network solved at each instant.
26
27How a tick works
28----------------
29The system is a semi-explicit index-1 DAE: the vessels are differential, the
30network is algebraic. Rather than hand the whole thing to a stiff integrator --
31which is what Phase 07 did, and what would not converge on an imported
32network -- this marches it at a fixed small step, which is what a flight
33simulator does and is stable for the same reason:
34
351. Write each vessel's current pressure into its network boundary node.
362. Solve the network for flows. Pure algebra at frozen vessel state.
373. Hand those flows back to the vessels as inflow and outflow.
384. Advance the vessels by ``dt``.
39
40A tick that fails to converge does not abort the session -- it holds the last
41good flows and keeps going, because a stand does not stop existing because one
42solve was hard, and the alternative is a simulator that dies mid-sequence.
43"""
44
45from __future__ import annotations
46
47import copy
48import math
49import time
50import uuid
51from collections import deque
52from dataclasses import dataclass, field, replace
53from typing import Any, Callable, Collection, Deque, Mapping
54
56 DEFAULT_FRICTION_METHOD,
57 darcy_friction_factor,
58 reynolds,
59)
60from feedtwin.comps.iec_gas import XT_TYPICAL
61from feedtwin.comps.regulator import (
62 SEAT_XT_SIGNAL,
63 Regulator,
64)
65from feedtwin.engine.balance import MixtureBalance
66from feedtwin.engine.chamber import MIN_CHAMBER_FLOW as ENGINE_MIN_CHAMBER_FLOW
67from feedtwin.engine.chamber import ChamberResult
68from feedtwin.engine.component import fold_injectors
69from feedtwin.comps.wall import (
70 STAINLESS_DENSITY,
71 LineWall,
72 fitting_metal,
73 wall_bore,
74 wall_runs,
75)
76from feedtwin.props import Fluid, PropertyError
77from fluids.fittings import Cv_to_K
78from feedtwin.vessels.convection import GasFilm, still_gas_conductance
79from feedtwin.solve.network import Network
80from feedtwin.solve.steady import SteadyResult, solve_steady
81from feedtwin.vessels.geometry import CylindricalTank, cylindrical_from_volume
82from feedtwin.vessels.collapse import ConductionCollapse, NoCollapse
83from feedtwin.vessels.vapour import NoVapour, SaturatedVapour, latent_heat
84from feedtwin.vessels.tank import Tank, TankRates, TankState
85from feedtwin.vessels.volume import GRAVITY, GasVolume, VesselState
86
87from feedtwin.pid.document import BOUNDARY_TYPES, INLINE_TYPES, SOURCE_TYPES, PidNode
88from feedtwin.pid.network import DOME_HANDLE, DomeLine, DomeLoader, propellant_side
89from feedtwin.session.gauge import ATMOSPHERE, PSI, from_psig, psig, say
90from feedtwin.session.diagnostics import SolverRecord, boundary_nodes, crossing
91from feedtwin.session.hookup import CHARGE, DOME, Hookup
92from feedtwin.session.model import AssemblyError, Model
93from feedtwin.session.statemachine import Binding, StateMachine, _words, fills
94from feedtwin.session.watch import Watcher
95
96#: Standard atmosphere [Pa]. What a vented vessel sits at, and the zero of every
97#: gauge on the stand -- see `feedtwin.session.gauge.psig`.
98AMBIENT = ATMOSPHERE
99
100#: Largest step a single tick may take [s]. A browser tab that was in the
101#: background for a minute must not be allowed to integrate a minute of stand
102#: in one Euler step; it catches up over several ticks or it does not catch up
103#: at all, and either is better than a step that goes unstable.
104MAX_STEP = 0.25
105
106#: Largest fractional pressure change a vessel may make while the network flows
107#: are held fixed.
108#:
109#: Sub-stepping the vessel alone is not enough. The flows come from one network
110#: solve at the state the tick started in, so a tank filling fast integrates a
111#: *stale* inflow: it sails past the pressure at which the regulator would have
112#: closed and overshoots by a hundred psi before the next solve notices. Above
113#: this the tick re-solves partway through, which is the only thing that stops
114#: it -- the vessel cannot know the flow should have stopped.
115MAX_COUPLED_CHANGE = 0.04
116
117#: Liquid below which a tank is called dry [kg]. A tank does not deliver a
118#: smooth trickle down to the last gram -- the outlet unports and the engine
119#: gets gas -- so a small floor is closer to the truth than zero as well as
120#: being better conditioned.
121#: Sweeps of the enthalpy walk in `Session._propagate_temperatures`. The longest
122#: path from a bottle to the injector face on a stand is under a dozen branches;
123#: bounding it stops a recirculating drawing spinning.
124_TEMPERATURE_SWEEPS = 12
125#: Flow below which a branch carries no temperature information [kg/s].
126_TEMPERATURE_MIN_FLOW = 1.0e-6
127#: Temperature change worth another sweep [K].
128_TEMPERATURE_TOL = 0.05
129#: How far above its dew point a gas node is held when the enthalpy walk lands
130#: it inside the two-phase dome [K] (`Session._propagate_temperatures`). The
131#: lines carry one phase; exactly on the saturation line a (p, T) state is
132#: ambiguous and the property layer refuses it. Half a kelvin is ~6 kPa of
133#: nitrogen's saturation curve near an atmosphere: room for the solve to move
134#: the node's pressure without crossing the line.
135_DEW_MARGIN_K = 0.5
136
137ROOM = 293.15
138"""Room temperature [K]. Where a wall starts when its line has no upstream node
139to take a temperature from -- which on a drawn stand is nothing."""
140
141#: Propellant per side that counts as "lit" [kg/s]; the chamber's own rule.
142MIN_CHAMBER_FLOW = ENGINE_MIN_CHAMBER_FLOW
143
144#: Seconds of Fire with no thrust before the console calls it a cold flow
145#: [s]. A display delay, not physics: the valves are still travelling for the
146#: first tenths of a real start, and "Not lit" flashed on every ignition.
147COLD_FLOW_S = 0.5
148
149DRY_MASS = 1.0e-3
150
151
152#: Cap on those re-solves. One step is allowed to cost this many network solves
153#: and no more; past it the step is simply taken, and the next one corrects.
154#:
155#: Raised from 12 once the coupling was sized from the ullage time constant
156#: (see COUPLING_SAFETY): a helium ullage of half a litre behind a regulator has
157#: a time constant of two or three milliseconds, and a 20 ms step needs far more
158#: than twelve solves to resolve it. Nothing else bounds a slow tick any more
159#: (TICK_BUDGET is effectively off): a stand that cannot keep up runs in slow
160#: motion.
161MAX_COUPLING_STEPS = 400
162
163#: Fraction of the regulator-ullage time constant one coupling step may span.
164#:
165#: The regulator and the ullage it feeds are an RC pair: the ullage stores gas
166#: (capacitance ``C = V rho / p``, kg/Pa) and the regulator meters it in
167#: (resistance ``R = droop / rated_flow``, Pa per kg/s). Their product is a
168#: time constant, and an explicit scheme that steps further than that in one
169#: go does what explicit schemes always do past their stability limit: it
170#: overshoots, the next solve overcorrects, and the trace fills with tick-rate
171#: noise that looks like physics and is not. The lighter the gas, the smaller
172#: ``C`` and the shorter the constant -- which is why helium, at the same dt,
173#: was forty times noisier than nitrogen, and why the noise vanished as dt
174#: fell. Sizing the step from the constant, rather than from how much the
175#: vessels happened to move last tick, is the actual stability condition.
176#:
177#: **One tau, measured rather than assumed.** This was 0.25, on the reasoning
178#: that a quarter of a time constant must be safe. It is -- and so is a whole
179#: one. Sweeping it over a two-second burn against a tau/4 reference moves the
180#: answer by less than the run-to-run noise until well past 1.0, and this step
181#: is what a study spends all of its wall clock on:
182#:
183#: ========== ========== ========== ============= =============
184#: safety GN2 speed GN2 rough helium speed helium rough
185#: ========== ========== ========== ============= =============
186#: 0.25 (was) 1.0x 0.53 psi 1.0x 0.54 psi
187#: 1.00 (now) 3.2x 0.64 psi 3.4x 0.56 psi
188#: 2.00 5.2x 1.21 psi 5.9x 0.55 psi
189#: 4.00 7.3x 4.01 psi 10.5x 1.70 psi
190#: 8.00 12.7x 11.98 psi -- --
191#: ========== ========== ========== ============= =============
192#:
193#: "rough" is the worst tick-to-tick jump in tank pressure, which is what this
194#: instability actually looks like on a plot. It is flat to 2.0 and broken by
195#: 4.0, so one tau keeps a factor of four in hand -- and the RC estimate is
196#: itself conservative, because it ignores line resistance, which only
197#: lengthens tau. Raise it further only with a fresh sweep in hand.
198COUPLING_SAFETY = 1.0
199
200#: How long a primed stand has been sitting loaded before T-0 [s].
201#:
202#: Five minutes: long enough that the interface's ``1/sqrt(t)`` collapse flux
203#: has fallen to a few percent of its first-second value and the wetted wall
204#: has chilled to the liquid, short enough to be a pad hold rather than a
205#: soak. A repressurisation just before ignition would reset the interface
206#: clock to seconds and is the pessimistic case for pressurant consumption;
207#: `Session.prime` takes ``hold_s`` so a study can ask for it.
208PAD_HOLD_S = 300.0
209
210#: Fill fraction below which a tank is worth mentioning.
211LOW_TANK = 0.10
212
213#: Fraction of its fill target below which a bottle is worth mentioning.
214LOW_BOTTLE = 0.25
215
216#: Propellant leaving a vehicle tank with the engine cold faster than this
217#: [kg/s] gets a note naming where it goes: five grams a second, well over
218#: anything a closed stand weeps and well under a dump.
219LEAK_NOTE_KG_S = 0.005
220
221#: Chamber closure tolerance [Pa] and iteration cap. The chamber node is a
222#: boundary whose value depends on the flows it receives; each cockpit step
223#: finds the pressure at which the network's delivery and the chamber's
224#: c*-limited throughput agree, by regula falsi on a bracket that always
225#: exists (ambient below, the feed pressure above). A relaxed step per tick
226#: -- what this used to be -- is a fixed-point iteration on a map whose
227#: slope is -p_c / (2 dp_injector): stable for a stiff injector, and for a
228#: soft one (the 7200 N doublet at 40 psi of injector drop) it diverged
229#: into a flip-flop between 718 psia and nothing, frame by frame.
230CHAMBER_TOL = 0.5 * PSI
231CHAMBER_ITERATIONS = 12
232
233#: Width of the band over which a tank stops accepting gas, as a fraction of
234#: the pressure supplying it [-].
235#:
236#: Matches the regulator's own `is_choked` band, deliberately: the two are
237#: deciding the same question from opposite ends, and a vessel that closes at a
238#: point while the component closes over a band will chatter between them.
239SUPPLY_BAND = 0.005
240
241#: Shut-to-open time for an actuator with none declared [s].
242#:
243#: Only reached when a drawing leaves `travel_time` off a valve. Fast for a
244#: solenoid and slow for a big ball valve, so it is worth declaring; what it
245#: must not be is zero, which is what this code did before and which makes the
246#: ignition dip a property of the time step.
247DEFAULT_TRAVEL = 0.05
248
249#: Wall-clock budget for one tick [s].
250#:
251#: It was 0.15 s: past that the tick folded the coupling steps it had no
252#: time for into one and let the next tick correct, so the panel never hung.
253#: It also meant the cockpit's answer depended on what else the machine was
254#: doing, and that the console and the Study tab -- the same physics, the
255#: same code -- gave different traces. The console now runs the study's
256#: numerics and never folds; when it cannot keep up with the wall clock it
257#: runs in slow motion and the top bar says so. Effectively unbounded.
258TICK_BUDGET = 1.0e9
259
260#: The cockpit's outer integration step [s] -- the study's ``dt``. A panel
261#: tick of up to 0.25 s is integrated as a run of these, so the vessel
262#: integration, the temperature walk and the chamber closure happen on the
263#: same grid whichever tab is asking.
264LIVE_STEP = 0.02
265
266#: An implicit coupling step was built and measured here, and removed. The
267#: scheme -- move the vessels, re-solve the network where they landed, redo the
268#: move on the average of the flows at both ends, iterate -- is trapezoidal, and
269#: trapezoidal *is* A-stable. Picard iteration on it is not: the fixed point
270#: only contracts while the step stays near the time constant, so past 4 tau it
271#: diverged exactly like the explicit scheme, at twice the cost per step. On
272#: GN2 it matched explicit at 3.0x against 2.8x; on helium it was worse, 2.4x
273#: against 3.1x. Real implicit stability needs a Newton solve over the vessel
274#: states and the network together, not a fixed point over them in turn -- and
275#: that is a coupled Jacobian of perhaps fifty unknowns, which is the one place
276#: in this model where the CFD toolbox would genuinely apply. See
277#: docs/solver-notes.md.
278#:
279#: What does work is the linear half of that: each ullage enters the solve as
280#: a storage node, its pressure an unknown tied to the gas it receives by the
281#: vessel's own response over the step (``Session._ullage_storage``,
282#: ``solve_steady(storage=...)``). Backward Euler on the press path, inside the
283#: one Newton solve, at the cost of three trial vessel steps per tank.
284#:
285#: Vessel sub-steps inside one coupling step. The vessels are stiffer than the
286#: network is expensive, so they take several short steps per solve.
287SUBSTEPS = 4
288
289#: How close to its chilled temperature a tank's wall must be for a load to
290#: stop chilling it and start collecting [K].
291CHILLED_BAND = 0.5
292
293#: Temperature of the gas a GSE fill delivers [K]: a bank at ambient.
294FILL_SUPPLY_T = 293.15
295#: The state table's actuators for the GSE side of a bottle whose fill is not
296#: drawn: its charge and its dump, the Diablo table's rows read as the DAQ reads
297#: them. The one place the session names actuators itself, because that side
298#: has no valve on the drawing for the hookup to bind. A table without them
299#: cannot charge or dump such a bottle, and the notes say so (`Session._notes`).
300GSE_CHARGE = "GSE High Press Control"
301GSE_DUMP = "GSE High Press Vent"
302#: Symbols the session integrates as gas bottles (:meth:`Session._build_vessels`).
303BOTTLE_TYPES = frozenset({"KBOTTLE", "DEWAR"})
304
305
306def _ground(model: Model) -> frozenset[str]:
307 """Drawing ids off the vehicle (:attr:`Session.ground`)."""
308 vehicle = model.built.vehicle
309 if vehicle is None:
310 return frozenset()
311 return frozenset(n.id for n in model.diagram.nodes if n.id not in vehicle)
312
313
314def _bottle_ids(model: Model) -> list[str]:
315 """The drawing's bottles, in the order the session builds them."""
316 net = model.built.network
317 return [
318 n.id
319 for n in model.diagram.nodes
320 if n.type in BOTTLE_TYPES and n.id in net.nodes
321 ]
322
323
324def drawn_fills(model: Model) -> tuple[dict[str, str], dict[str, str]]:
325 """The vessels the drawing itself fills, each with what fills it:
326 ``(tanks, bottles)``, vessel id -> supply id.
327
328 Tanks a drawn dewar reaches, and bottles another drawn bottle or dewar
329 reaches, through the drawing's lines and valves (open or shut -- a valve
330 is how the fill is commanded, not whether it exists) without passing
331 through another vessel -- from another page of the drawing, which is
332 where pid-designer puts the cart. Two flight bottles manifolded together
333 on the vehicle page are not one filling the other. A cart's own tank and
334 bottles are supplies and never listed. The session's built-in fill steps
335 aside for every one (:attr:`Session._drawn_fill`)."""
336 built = model.built
337 net = built.network
338 types = {n.id: n.type for n in model.diagram.nodes}
339 pages = {n.id: n.page or "Main" for n in model.diagram.nodes}
340 # A vessel joined to its manifold by an unsized line shares a node with
341 # the junction: the vessel is what that place is. (Keyed last-wins, a
342 # cart's K-bottle read as its junction and was never found.)
343 place: dict[str, str] = {}
344 for sid, kind in types.items():
345 where = built.node_of.get(sid, sid)
346 if where not in place or kind in {"TANK", "KBOTTLE", "DEWAR"}:
347 place[where] = sid
348 neighbours: dict[str, set[str]] = {}
349 for branch in net.branches.values():
350 neighbours.setdefault(branch.upstream, set()).add(branch.downstream)
351 neighbours.setdefault(branch.downstream, set()).add(branch.upstream)
352
353 def reaches(starts: set[str], kinds: set[str], own: str) -> str:
354 seen, frontier = set(starts), list(starts)
355 while frontier:
356 here = frontier.pop()
357 for there in neighbours.get(here, ()):
358 if there in seen:
359 continue
360 seen.add(there)
361 symbol = place.get(there, "")
362 if (
363 symbol
364 and symbol != own
365 and types.get(symbol) in kinds
366 and pages.get(symbol) != pages.get(own)
367 ):
368 return symbol
369 if net.nodes[there].pressure is not None:
370 continue # another vessel, a vent, the chamber: stop
371 frontier.append(there)
372 return ""
373
374 tanks: dict[str, str] = {}
375 fed_by = {
376 vehicle_tank: supply_tank
377 for supply_tank, fed in built.supplies.items()
378 for vehicle_tank in fed
379 }
380 for tank_id, ports in built.tanks.items():
381 if tank_id in built.supplies:
382 continue # the cart's own tank: pre-loaded, never loaded here
383 supply = fed_by.get(tank_id) or reaches(
384 {ports.ullage, ports.outlet}, {"DEWAR"}, tank_id
385 )
386 if supply:
387 tanks[tank_id] = supply
388 bottles: dict[str, str] = {}
389 ground = _ground(model)
390 for bottle_id in _bottle_ids(model):
391 if bottle_id in ground:
392 continue # a cart bottle is the supply, delivered full
393 start = built.node_of.get(bottle_id, bottle_id)
394 supply = reaches({start}, {"KBOTTLE", "DEWAR"}, bottle_id)
395 if supply:
396 bottles[bottle_id] = supply
397 return tanks, bottles
398
399
400def _pressed_supplies(model: Model) -> dict[str, list[str]]:
401 """The cart's transfer tanks a table row presses
402 (:meth:`Session._supply_press`), labels by side (``lox``/``fuel``): a
403 supply tank with a drawn pressure. Not a dewar: its own circuit holds it,
404 whatever the table says."""
405 built = model.built
406 by_id = {n.id: n for n in model.diagram.nodes}
407 out: dict[str, list[str]] = {}
408 for tank_id in built.supplies:
409 node, ports = by_id.get(tank_id), built.tanks.get(tank_id)
410 if node is None or ports is None:
411 continue # never built as a vessel, so never pressed
412 if node.params.get("pressure") is None or node.drawn_as == "DEWAR":
413 continue
414 species = Fluid(built.network.nodes[ports.outlet].fluid).name
415 out.setdefault(propellant_side(species), []).append(node.label or tank_id)
416 return out
417
418
420 machine: StateMachine, model: Model, drawn: Collection[str] | None = None
421) -> dict[str, str]:
422 """The table's rows the session reads by name and acts on itself with no
423 valve wired to them, with what each does: the built-in COPV charge and
424 dump, and the cart's press on a transfer tank
425 (:meth:`Session._supply_press`). Such a row is not "wired to nothing" --
426 renaming or dropping it stops what it does, and an editor must say so.
427
428 Only where the session would act on ``model``: the charge and dump while
429 a vehicle bottle's fill is not drawn (a cart drawn charging it charges it
430 through its own valves, and an unwired GSE High Press Control then
431 charges nothing), the press while a transfer tank of the row's side has a
432 drawn pressure. Rocket only, give it the cut model: the cart's fill went
433 with the cart. ``drawn``: the vessels the drawing fills, when a session
434 has found them already (:attr:`Session.builtin`); else found here."""
435 if drawn is None:
436 tanks, bottles = drawn_fills(model)
437 drawn = {*tanks, *bottles}
438 ground = _ground(model)
439 labels = {n.id: n.label or n.id for n in model.diagram.nodes}
440 charged = [
441 labels.get(b, b)
442 for b in _bottle_ids(model)
443 if b not in drawn and b not in ground
444 ]
445 pressed = _pressed_supplies(model)
446 out: dict[str, str] = {}
447 for row in machine.actuators:
448 words = _words(row)
449 sides = sorted(words & set(pressed))
450 if row == GSE_CHARGE and charged:
451 out[row] = (
452 f"Charges {', '.join(charged)} by the twin's built-in fill: "
453 "nothing on the stand fills it."
454 )
455 elif row == GSE_DUMP and charged:
456 out[row] = (
457 f"Dumps {', '.join(charged)} by the twin's built-in vent: "
458 "nothing on the stand fills it."
459 )
460 elif {"fill", "press"} <= words and sides:
461 supplies = [label for side in sides for label in pressed[side]]
462 out[row] = (
463 f"Presses {', '.join(supplies)} to its drawn pressure by the "
464 "cart's built-in press while no valve is wired to this row."
465 )
466 return out
467
468
469#: The room [K], for the heat that leaks through a tank skin.
470AMBIENT_T = 293.15
471#: Fiberglass batt, for a drawing that gives a thickness and no conductivity.
472FIBERGLASS_K = 0.04
473#: A liquid colder than this is a cryogen for the purposes of the notes.
474CRYOGENIC_K = 150.0
475#: Relative step for the press-path slope in ``Session._press_path_timescale``.
476#: Coarser than the solver's 1e-6: this sizes a step count, not a Newton step.
477PRESS_PATH_FD = 1.0e-3
478#: Probe for an ullage's storage slope in ``Session._ullage_storage``: the
479#: share of its gas a trial step adds or takes. A finite difference, not a
480#: physical number: small enough to stay linear, big enough to read through
481#: the property layer's rounding.
482STORAGE_PROBE = 1.0e-3
483#: Share of the supply band a tick's drain must cover before the press-path
484#: constant is applied (``Session._press_path_timescale``). A tenth: ~0.3 psi
485#: a tick at a 550 psig lockup, against 2-6 psi on a burning LE4 helium tank.
486PRESS_PATH_DRAIN_FRACTION = 0.1
487#: Fixed-point passes on the fill line's friction factor, which depends on the
488#: flow it sets. Clamond moves by under 0.1 % after the third.
489FILL_LINE_ITERATIONS = 4
490#: Wall roughness of the dewar's fill line [m]: drawn tube, the same number the
491#: drawing reader falls back to for a pipe (``pid.network.FALLBACKS``).
492FILL_LINE_ROUGHNESS = 1.5e-6
493#: A wetted wall this far above its liquid is still chilling down, and will
494#: boil the liquid hard if the tank is shut. Said in the notes.
495WARM_WALL_K = 30.0
496#: A bottle this much warmer than its own wall is still settling from its
497#: fill, and its pressure will follow the gas down. Said in the notes.
498HOT_BOTTLE_K = 15.0
499#: Most of an ullage's mass one coupling step may move. Coarser than the 4 %
500#: pressure-change rule because the vessel clips the lockup crossing exactly
501#: (CHARGE_GAMMA below); this only sets how finely a fast press is resolved.
502#: A wide-open regulator into a 3 g ullage still asks for ~300 steps a tick.
503MAX_MASS_STEP = 0.10
504#: Pressure rise per unit of mass charged into an ullage runs up to gamma
505#: times the isothermal figure (an adiabatic charge lands at gamma T_in).
506#: Helium's 1.67 bounds nitrogen's 1.40, so the clip errs toward refusing a
507#: little that the next step then accepts, never toward overshooting.
508CHARGE_GAMMA = 1.67
509#: A tank is "being pressed" -- its charge jet stirring the ullage -- while it
510#: sits more than this fraction of the supply pressure below the supply.
511#: Inside the band it is at lockup, and what arrives is slosh, not a jet.
512STIR_BAND = 0.02
513
514#: Largest fraction of a vessel's gas mass one integration step may move. Above
515#: this an explicit step overshoots -- a vent is the case that bites, because it
516#: can empty an ullage in well under a second.
517MAX_MASS_FRACTION = 0.02
518
519#: Largest fraction of a vessel's gas *temperature* one step may change.
520#:
521#: Bounding mass alone is not enough. Gas leaving a tank carries its enthalpy,
522#: which exceeds its internal energy by the flow work, so a vent removes energy
523#: faster than mass and an ullage stepped for mass alone lands far colder than
524#: it should. Temperature rather than energy because internal energy has an
525#: arbitrary reference -- nitrogen's crosses zero in the range a blowdown visits,
526#: and a fractional bound on a quantity that passes through zero means nothing.
527MAX_TEMPERATURE_FRACTION = 0.03
528
529#: How many times a sub-step may halve itself when the state it lands on is one
530#: the equation of state will not price. Belt to the sub-stepping's braces: the
531#: bound above is an estimate made before the step, and a vent onto a small
532#: ullage is violent enough to beat an estimate.
533STEP_RETRIES = 6
534
535#: What is left of a tank's liquid, as a fraction of what a vessel step began
536#: with, below which it is round-off and the tank is empty. Not physics: the
537#: step a tank empties on lands at ``held - leaving * span``, which is zero
538#: only to the last bit, and which side of zero it falls on depends on the
539#: machine. A residue of 2e-19 kg was then a liquid whose temperature the heat
540#: balance integrated as q / (m cp): -58,000 K, no density at that, every
541#: halving refused, and the ullage rebuilt at atmosphere -- 336 g of nitrogen
542#: gone from a dry fuel tank, on some CI runners and not others.
543LIQUID_ROUNDOFF = 1.0e-12
544
545#: Cap on the adaptive sub-steps, so one violent instant cannot stall the tick.
546MAX_VESSEL_STEPS = 200
547
548#: Newton budget for one live solve. The study's figure; it was 30 on the
549#: cockpit, which held the last good flows through the regulator's crossover
550#: rather than resolving it. Same physics, same numbers, both tabs.
551LIVE_ITERATIONS = 120
552
553#: Convergence tolerance for a live tick. Looser than a reported steady solve
554#: because this drives a display reading three significant figures at 5 Hz, and
555#: the extra digit costs iterations that show up as stutter.
556LIVE_TOL = 1.0e-4
557
558#: History kept for the plots [samples]. At 10 Hz this is about seven minutes,
559#: which covers a fill and a press and a burn.
560HISTORY = 4000
561
562#: A tank is called full here: a 5 % ullage, which is how the stand is loaded
563#: (6.5 kg of ethanol plus 5 %, operator). Leaving real ullage is the point: a
564#: tank filled to the brim has nowhere to put pressurant and would spike to the
565#: relief on the first press. It was 0.85, a guess.
566FULL_FRACTION = 0.95
567
568
569@dataclass
570class Setup:
571 """What the operator can dial before and during a run.
572
573 Fill rates are times-to-full rather than mass flows because that is what
574 somebody setting up a rehearsal actually knows -- "the ox fill takes about
575 two minutes" -- and because the supply doing the filling is a road tanker or
576 a GSE cart that is deliberately not on the drawing yet. Everything else here
577 is solved.
578 """
579
580 dome_psi: float = 500.0
581 """Dome control regulator setting [psig] -- what the dial reads. Gauge on
582 the wire and in saved setups; a person sets and reads it in psia, 514.7
583 here (ADR 0007). The 1092-50 delivers dome + its 50 psi bias less its
584 supply effect, S x inlet gauge, so lockup is *not* dome + bias: on LE4 (6)
585 off a full 4,514.7 psia bottle, 514.7 psia here locks the tanks up at
586 498.4 psia (docs/PHYSICS-BENCHMARK.md 4.11)."""
587
588 copv_target_psi: float = 4500.0
589 """Bottle fill target [psig], as its gauge would read it: what GN2 High
590 Press fills the bottle to. Shown as psia, 4,514.7 here (ADR 0007)."""
591
592 supply_press_s: float = 3.0
593 """Seconds the cart's press line takes to bring a ground supply tank (a fuel
594 transfer tank) to its drawn pressure, when that line is not on the drawing
595 and its actuator ("Fuel Fill Press") is open. **Assumed**: a first-order
596 rate standing in for a regulator and a line nobody has drawn. Draw the
597 press line and this is not used."""
598 copv_fill_s: float = 9.7
599 """Seconds to take the bottle from empty to target while the fill valve is
600 open. Fitted 2026-10-05 to the 12 Sep pulse fill (DAQ run
601 daq_20260912_204917: six High Press CTRL pulses, 233 to 3,515 psig): with
602 the stand's own valve commands, 9.7 s puts the twin's bottle within 183 psi
603 RMS of the DAQ over the fill. The operator's 25 s was 1,489 psi RMS -- it
604 reached 1,430 psig where the stand reached 3,515. The cart is not on the
605 drawing, so this is a rate rather than a solved flow, and it only holds with
606 the GSE bank well above the bottle: the rate is set by the bank side, not
607 the solenoid. How hot the bottle ends up is ``fill_stirring``'s business,
608 not this number's."""
609
610 fuel_fill_s: float = 15.0
611 """Seconds to take the fuel tank from empty to :data:`FULL_FRACTION`:
612 about what pouring 6.5 kg of ethanol through the fill port takes on the
613 stand (operator). Nothing to chill, so nothing slows it."""
614
615 load_chill_s: float = 30.0
616 """Seconds a cryogen load spends chilling a room-temperature tank before
617 any liquid stays in it.
618
619 LOX poured into a warm tank does not collect: it meets the wall, flashes,
620 and leaves through the vent until the metal is down at saturation. On the
621 stand that takes about ten minutes of pouring (operator); here it is
622 compressed so a load can be watched, and the wall temperature on the tank
623 card is what falls meanwhile. The flashed oxygen leaves with the vent
624 rather than pressing the ullage -- at a twentieth of the real time it would
625 be twenty times the real vent flow. Zero: the liquid collects from the
626 first second and the wall chills as the load goes, which is the model
627 before this existed.
628
629 Only read with :attr:`dewar_psi` at zero. A dewar load chills in real
630 time, at the rate its own flow takes heat out of the wall."""
631
632 tank_fill_s: float = 120.0
633 """Seconds to take a cryogen tank from empty to :data:`FULL_FRACTION`.
634
635 Two minutes rather than the thirty seconds it was: a LOX load has to chill
636 the tank wall as it goes, and the wall gives that heat up by boiling the
637 liquid it meets, at a rate the vent has to carry. Loaded in thirty
638 seconds the wall is still 240 K when the vent shuts and the tank runs
639 away; loaded over minutes -- which is what a dewar transfer takes -- the
640 frost has formed by the time it is full. Set it to what the load takes.
641
642 Only read with :attr:`dewar_psi` at zero; with a dewar the load takes as
643 long as its line delivers."""
644
645 dewar_psi: float = 100.0
646 """LOX dewar pressure [psig] -- what pushes a cryogen load into its tank.
647 Shown as psia, 114.7 here (ADR 0007).
648
649 The stand fills from a ~100 psig dewar (operator, 2026-10-05). The dewar
650 and its fill line are GSE and not on the drawing yet, so they are this
651 knob and the three below. With it set, a load is a flow: the dewar
652 pressure less the tank's, through the fill line. While the tank wall is
653 warm, everything that arrives boils on it and the vapour goes into the
654 ullage, so the tank climbs until the vent carries what the dewar sends --
655 the rise the stand shows during a chilldown, larger the faster it pours.
656 Once the wall is at saturation the liquid collects. The dewar's liquid is
657 taken at the tank's own liquid temperature, and the dewar's height above
658 the tank is not counted.
659
660 Zero: the fixed-rate load of :attr:`tank_fill_s` and
661 :attr:`load_chill_s`, which is the model before this existed."""
662
663 dewar_line_bore_mm: float = 7.75
664 """Bore of the dewar's fill line [mm]: 3/8 in tube, 0.035 in wall
665 (operator: 3/8 in lines for now). Friction by Clamond over
666 :attr:`dewar_line_length_m`, plus the exit into the tank."""
667
668 dewar_line_length_m: float = 3.0
669 """Length of the dewar's fill line [m]. Estimated: a hose from a dewar
670 beside the stand to the tank's fill disconnect. Measure it."""
671
672 dewar_fill_cv: float = 0.019
673 """Flow coefficient of everything on the fill line that is not tube [Cv]:
674 the dewar's liquid valve, the cart's LOX Fill valve, the disconnect. In
675 practice, how far the dewar valve is open.
676
677 Calibrated, not known, and the calibration depends on the drawing's vent.
678 The stand tops out near 30 psig while a LOX tank chills, in about ten
679 minutes (operator). On the LE4-like test stand (the tank vented through
680 its top disconnect and the cart's Cv 0.5 valve,
681 tests/test_dewar_load.py) this value reads ~30 psig at 30 s and chills in
682 ~4.8 min; 0.013 read ~21 there once the vent stopped taking back air the
683 pressurant floor re-created (2026-10-06). LE4 (6) as drawn vents through a
684 different path: there this peaks at 57 psig and chills in 5.3 min, 0.013
685 at 38 psig and 6.9 min (2026-10-08). The team set it here (2026-10-08:
686 nobody waits out a chilldown in the cockpit -- :meth:`Session.
687 skip_chilldown`). A clean 3/8 in line from 100 psig pours ~0.7 kg/s; every
688 gram of it boils on a warm wall, and the tank rides up to the dewar's own
689 pressure."""
690
691 gse_vent_cv: float = 0.5
692 """Flow coefficient of the cart's vent valve [Cv] (operator: ~0.5).
693
694 A tank with no vent valve drawn vents through a disconnect on its top and
695 a valve on the cart (see ``feedtwin.pid.network._gse_vents``); where the
696 drawing gives that disconnect no Cv or Cd, this is its size. It was the
697 fallback valve's Cv 4, which no one chose."""
698
699 fill_stirring: float = 20.0
700 """Multiplier on a vessel's gas-to-wall conductance while gas is being
701 charged into it -- the bottle during its GSE fill, a tank ullage during a
702 press.
703
704 The still-gas conductance is the vessel's ``wall_conductance``; a charge
705 jet stirs the vessel and forced convection off it runs several times
706 natural, which is what keeps a real 25 s fill from landing at the
707 adiabatic-charge temperature. Twenty is an estimate with the right order
708 of magnitude -- a 25 s GN2 High Press then ends with the gas at 336 K
709 over a 316 K wall and sags 3% in the next minute, against 380 K and 12%
710 for a still vessel -- to be calibrated against the bottle RTD after a
711 real fill; 1 is a still vessel and the full adiabatic-charge heating."""
712
713 bottle_delivered: bool = False
714 """The pressurant bottle arrives full and cold, at the pressure and
715 temperature the drawing states for it, the way a supplier's cylinder does.
716
717 Off (the default), it starts empty and ``GN2 High Press`` fills it from
718 GSE over ``copv_fill_s`` -- which is what the Diablo table's ``GSE High
719 Press Control`` actuator is for. On, the bottle is cold, so the tanks it
720 presses sag only by their own charge heating (~30 psi), which is what a
721 stand whose bottle was filled hours earlier shows."""
722
723 ullage_collapse: bool = True
724 """Model heat leaving the ullage into the propellant surface.
725
726 Real, and it is why a tank droops on a long hold: warm pressurant meets cold
727 liquid and gives its heat up across the interface. Off, the ullage exchanges
728 heat only with the vessel wall.
729
730 Toggleable because it is a *separate* question from how much gas a bottle
731 can deliver, and mixing the two makes a pressurant study impossible to read
732 -- collapse and an under-sized COPV both show up as a tank that will not
733 hold pressure."""
734
735 ullage_vapour: bool = True
736 """Propellant vapour in the ullage -- boil-off and condensation.
737
738 On by default on the cockpit since 2026-09-11. It was off, on the grounds
739 that for a storable at room temperature it is negligible (ethanol at
740 293 K is 5.8 kPa of vapour against a 38 bar ullage) and that it is the
741 more fragile model. Both still true; but a LOX tank without it cannot
742 boil, so with the vent shut it sat at whatever it was pressed to
743 forever, and an operator who knows a loaded LOX tank climbs read that as
744 the twin being broken. With this on and ``ambient_leak`` giving the wall
745 somewhere to get heat from, it climbs. The study sets its own.
746 See :mod:`feedtwin.vessels.vapour`."""
747
748 ambient_leak: float = 8.0
749 """Air film on the outside of the tanks [W/(m^2.K)]; zero is a tank with
750 no outside. Eight is still air on a cold surface. What the *liquid* sees
751 is this in series with whatever insulation the drawing declares on the
752 tank (``insulation_thickness``, ``insulation_conductivity``): an inch of
753 fiberglass takes a bare tank's ~450 W down to ~75 W, half a gram a second
754 of boil-off, a psi every few seconds with the vent shut. The LOX tank on
755 the stand wears an inch of fiberglass (operator). An estimate either way:
756 the RTD on the tank and the vent's hiss say what it really is."""
757
758 line_walls: bool = True
759 """Heat a line's own metal gives the gas flowing through it.
760
761 **On by default** since 2026-10-03 (the team: "fitting heat should be on";
762 the feed twin is the source of truth, so the cockpit and EngineDesign's
763 Layer X read it from here). The Study keeps it off unless asked
764 (:func:`~feedtwin.session.burn.burn_setup`). It is the thermal option with
765 the largest known bias behind it. Every component is otherwise adiabatic, and
766 for a liquid leg over a five-second burn that is fine; on the pressurant path
767 it is not. Nitrogen leaves the regulator at 251 K into metal sitting at
768 293 K, and the drawn press path carries about 420 g of tube and fittings --
769 worth **6.1 K at ignition and 4.4 K averaged over the burn, 14% of the
770 30.3 K Joule-Thomson drop.** A metre of line with ten fittings is 37%.
771
772 Needs `wall_thickness` and/or `fitting_mass` on the lines to have any effect;
773 a drawing that states neither has no metal to give and this changes nothing.
774 See :mod:`feedtwin.comps.wall`, including what it deliberately leaves out."""
775
776 chilldown: float = 100.0
777 """Liquid-to-wall conductance [W/(m^2.K)]. Zero disables chilldown.
778
779 The wall term the tank already had is ullage-to-wall; this is the wetted
780 face. It is what cools a tank during a load, and with `ullage_vapour` on it
781 is what boils the propellant while doing so. A number rather than a flag
782 because the honest value depends on insulation and fill, and nobody should
783 be able to turn on "chilldown" without saying how hard.
784
785 Order of magnitude for a bare stainless tank in film boiling: 50-200. An
786 insulated one is far lower. Zero is the tank this model assumed before
787 2026-09-11, adiabatic below the surface; it is what the Study and Layer X
788 pass, not the default.
789
790 100 W/(m^2.K) by default (the cockpit's, and this library's) since
791 2026-09-11 (it was 0): the
792 ambient leak arrives at the wetted wall and this is the only way it
793 reaches the liquid. Boiling on a wall a few kelvin above saturation runs
794 at hundreds to thousands; a hundred passes a 400 W leak with 10 K of wall
795 superheat. The study sets its own."""
796
797 tick_budget: float = TICK_BUDGET
798 """Wall-clock seconds one tick may spend before folding the rest of its
799 coupling steps into one. Effectively off everywhere since the console took
800 the Study's numerics (docs/PHYSICS-BENCHMARK.md 3.10): a folded step is
801 exactly the under-resolved step the coupling count was chosen to avoid, and
802 a console that folds integrates a different scheme from the one benchmarked.
803 Lower it and the fold in :meth:`Session._integrate` comes back."""
804 max_iterations: int = LIVE_ITERATIONS
805 """Newton iterations the network solve may take per coupling step.
806
807 The Study's 120 on the console too, since it held the last good flows
808 through a regulator's crossover at 30. A solve that has not closed in this
809 many iterations holds the last converged flows and pressures for the step
810 (nothing moves right after the circuit changed), which the frame reports as
811 not converged. A helium burn, whose regulator branch is nearly flat and whose
812 Newton steps are correspondingly long, went from 39 failed ticks in 140 to 4
813 by raising this from 30 to 60.
814 """
815
816 # ---- the constants that used to be module-level, now dialled from the
817 # Configuration tab (feed-twin backend/tunables.py explains each). These are
818 # the cockpit's values, which Layer X and the Study tab share. The benchmark
819 # study is *not* run at them: `feedtwin.session.burn.burn_setup` turns
820 # stratification, boiling onset, nucleate boiling, line walls,
821 # ullage-wall-by-level, the compressible regulator seat and the automatic
822 # vent off, because the expectations in docs/PHYSICS-BENCHMARK.md 2.x were
823 # stated before those existed.
824 wall_boiling: bool = True
825 """Boil at a superheated wetted wall (needs ``ullage_vapour``)."""
826 chilldown_nucleate: float = 3000.0
827 """Wall-to-liquid conductance in nucleate boiling [W/(m^2.K)], once the
828 wall superheat is under ``leidenfrost_K``. Zero: film value throughout."""
829 leidenfrost_K: float = 40.0
830 """Wall superheat above saturation at which a vapour film insulates the
831 wall (film boiling) rather than the liquid wetting it [K]."""
832 boiling_onset_K: float = 2.0
833 """Wall superheat needed before the wetted wall boils rather than warming
834 the liquid [K]; nucleate-boiling incipience for a cryogen on metal."""
835 stratification: bool = True
836 """Track a surface layer apart from the bulk liquid. Off: well mixed."""
837 surface_layer_m: float = 0.01
838 """Thickness of the stratified surface layer [m]."""
839 surface_mixing: float = 5.0
840 """Layer-to-bulk conductance per unit interface area [W/(m^2.K)]."""
841 fill_supply_T: float = FILL_SUPPLY_T
842 ambient_T: float = AMBIENT_T
843 full_fraction: float = FULL_FRACTION
844 charge_gamma: float = CHARGE_GAMMA
845 supply_band: float = SUPPLY_BAND
846 stir_band: float = STIR_BAND
847 valve_travel_s: float = DEFAULT_TRAVEL
848 auto_vent: bool = True
849 """Go to Vent on its own when a tank runs dry during Fire."""
850 low_tank: float = LOW_TANK
851 warm_wall_K: float = WARM_WALL_K
852 hot_bottle_K: float = HOT_BOTTLE_K
853 tank_wall_kg_per_L: float = 8.0 / 17.5
854 tank_wall_capacity: float = 900.0
855 tank_wall_hA: float = 12.0
856 wall_hA_from_gas: bool = True
857 """Estimate a vessel's gas-to-wall conductance from its gas and its size
858 (Churchill–Chu natural convection, ``feedtwin.vessels.convection``) when
859 the drawing leaves it blank, instead of the per-litre default above. A
860 value on the drawing always wins."""
861 ullage_wall_by_level: bool = True
862 """A tank's ullage exchanges heat with the dry wall only -- the wall above
863 the liquid -- so its wall conductance is scaled by that wall's share of the
864 tank. Off: the whole tank's conductance at every fill (the scheme the
865 benchmark Study was stated at; ``burn_setup`` pins it off). On a 95 % full
866 LOX tank the difference is ~20x, and a freshly pressed ullage fell from 548
867 to 260 psig in six seconds of Ready instead of the ~30 s the stand shows."""
868 wall_hA_dT: float = 10.0
869 """Gas-to-wall temperature difference the still-gas film is evaluated at
870 [K]. Natural convection stiffens roughly as dT^(1/4); ten kelvin is a
871 blowdown in progress."""
872 burst_safety_factor: float = 2.0
873 """A vessel trips the stand at ``burst_pressure / this``. Two is the
874 factor the team designs to; a drawing that still carries an MAWP trips
875 at that instead."""
876 bottle_wall_kg_per_L: float = 60.0 / 44.0
877 bottle_wall_hA: float = 20.0
878 bottle_volume_L: float = 4.6871
879 """Water volume of a pressurant bottle the drawing gives none [L]: the
880 stand's COPV. A 45 scf SCBA cylinder holds 52.8 mol of free air (70 degF,
881 14.696 psia); at 4,500 psia air's Z is 1.1145, so the cylinder is 4.64 L, plus
882 3 in^3 of fittings (operator). It was a 44 L K-bottle, which blew LE4's
883 bottle down 250 psi in a burn its real one loses thousands in. A value on
884 the drawing always wins."""
885 live_step: float = LIVE_STEP
886 max_coupled_change: float = MAX_COUPLED_CHANGE
887 coupling_safety: float = COUPLING_SAFETY
888 max_mass_step: float = MAX_MASS_STEP
889 body_acceleration: float = GRAVITY
890 """Proper acceleration along the vehicle's long axis [m/s^2]: what an
891 accelerometer on it reads. Every liquid column -- tank heads and line
892 elevation -- is ``rho * this * dz``. Standard gravity on a stand or a pad
893 (the default, so nothing changes unless it is set); thrust less drag over
894 mass in flight: 8.6 g at liftoff and 9.8 g at burnout on LE4 flown on
895 the helium drawing (Layer X, 2026-10-03), where each metre of vertical
896 fuel line carries ~11 psi of head instead of ~1."""
897 chamber_tolerance_psi: float = (
898 0.5 # CHAMBER_TOL, in psi: 0.5 * PSI is the same float
899 )
900 """How closely the chamber closure solves ``g(p) = p`` each coupling step
901 [psi]. Half a psi is 0.13 % of a 380 psia chamber: invisible on a panel,
902 and the same order as an engine card's whole error, so a caller quoting
903 thrust to a tenth of a percent (EngineDesign's Layer X) sets it tighter."""
904 network_tolerance: float = LIVE_TOL
905 """Scaled residual the network solve must reach each coupling step.
906
907 Each mass balance is judged against the largest node demand (at least
908 1 g/s) and each branch against the highest boundary pressure -- the
909 bottle -- so 1e-4 on a full COPV is ~3 kPa on every branch, and an
910 injector drop comes out a few tenths of a percent off its own relation. A
911 caller quoting the engine finer than that sets this lower."""
912 regulator_compressible_seat: bool = True
913 """The regulator's wide-open seat seen as a gas sees it: IEC 60534-2-1's
914 expansion factor ``Y = 1 - x / (3 F_gamma xT)`` and its choke at
915 ``x >= F_gamma xT`` (:mod:`feedtwin.comps.iec_gas`), in place of the
916 incompressible ``K rho v^2 / 2`` at the inlet density, which overstates a
917 GN2 regulator's capacity near burnout by 25 % (0.398 against 0.319 kg/s on
918 the COPV study's GN2 drawing; EngineDesign/docs/layerx/AUDIT.md 5.3, 9.6 C3)
919 and by up to 1.5x choked. Only a wide-open regulator is affected: while it
920 regulates, its outlet is the droop law and the seat never binds.
921
922 **On by default** since 2026-10-08 (the team), in the cockpit, Layer X and
923 the Study tab. Measured then: the LE4 (6) helium burn and a press from
924 50 psig to lockup bit-identical; the COPV study's GN2 drawing burned off a
925 1,200 psig bottle, -0.04 % thrust. :func:`~feedtwin.session.burn.burn_setup`
926 pins it off, so the benchmark Study is the incompressible law its
927 expectations were stated at. Off is that law, bit for bit."""
928 regulator_xT: float = XT_TYPICAL
929 """The seat's pressure-differential ratio factor at choked flow, for a
930 regulator whose drawing gives none (a drawn ``xT`` wins). 0.70: IEC 60534's
931 typical value, **assumed** -- nobody has measured the 1092's. Read only with
932 :attr:`regulator_compressible_seat` on."""
933 ullage_wall_T0_K: Mapping[str, float] = field(default_factory=dict)
934 """Upper (ullage) wall temperature at T-0, per tank id [K].
935
936 :meth:`Session.prime` builds a loaded tank's wetted wall at its liquid and
937 its upper wall at the pressurant's temperature, 293.15 K. That is right
938 for a tank pressed from warm, wrong for a LOX tank whose ullage wall has sat
939 above the liquid for a hold: at 150 K it costs ~225 psi of bottle on the LE4
940 helium burn and sags the tank 578 -> 565 psia in the lead-in
941 (EngineDesign/docs/layerx/AUDIT.md D1, 9.5 section 2). A tank named here
942 starts with its upper wall at that temperature and its gas where it was
943 (freshly pressed, warm); the settle and the burn then exchange heat between
944 them. Empty -- the default -- is the previous behaviour exactly. Measured by
945 an RTD on the tank's upper shell, where there is one; otherwise sweep it."""
946 cryogen_ullage_wall_T0_K: float = 0.0
947 """The same, for every tank holding a cryogen (liquid below 150 K) that
948 :attr:`ullage_wall_T0_K` does not name [K]. Zero -- the default -- leaves
949 the upper wall at the pressurant's temperature, exactly as before. A dial
950 for the cockpit's Configuration tab, where a per-tank map has no row."""
951 ground_rests: bool = True
952 """The ground support is integrated only while it is doing something. A
953 cart vessel with nothing flowing in or out of it, and no built-in press
954 acting on it, holds where it is: its wall, vapour and ambient leak stand
955 still until a valve puts it to work. And while the engine burns, the
956 ground with no open path to the vehicle leaves the solve as well -- its
957 lines carry nothing and read what they last read -- so the burn is
958 solved on the vehicle alone. Nothing it holds can reach the vehicle until
959 a valve joins them, so the burn is a vehicle-only drawing's, number for
960 number; on LE4 (6) the cart is two-thirds of the network and was most of
961 a Fire tick. **Simplification** (the team, 2026-10-07: the cart's
962 physics is not what the twin is for). Only a drawing with its ground
963 support drawn has anything to rest. Off integrates every cart vessel
964 every step, as before."""
965 ignore_gse: bool = False
966 """Simulate the rocket alone and fill it the simple way. On: everything
967 off the vehicle (:func:`feedtwin.pid.roles.vehicle_only`) is cut from the
968 drawing before it is built -- the cart's tanks, bottles, dewar,
969 regulators and valves are not simulated at all, and each vehicle
970 disconnect is a capped half. What a one-page drawing of the rocket gets
971 then stands in for the cart: GN2 High Press charges the bottle to
972 :attr:`copv_target_psi` over :attr:`copv_fill_s`, Fuel Fill pours over
973 :attr:`fuel_fill_s`, Ox Fill loads from a dewar at :attr:`dewar_psi`,
974 and the dome is :attr:`dome_psi`, set on the dome-loaded regulator
975 itself. **Simplification** (the operator, 2026-10-08: the twin misreads
976 the complicated GSE, and "the rest is only handled on the rocket, which
977 is what we need"). Read when the model is assembled
978 (``assemble_model(vehicle_only=...)``), so changing it opens a new
979 stand; a session reports the value its model was built with. Off -- the
980 default -- simulates the drawn GSE, exactly as before."""
981 pressurant: str = ""
982 """The gas in the bottles and press lines: ``""`` the drawing's,
983 ``"helium"`` or ``"nitrogen"`` the drawing's other gas swapped for it at
984 293 K wherever it is drawn (``assemble_model(fluid_swap=...)``, the swap
985 the Study tab's pressurant cases make). For comparing a helium hot-fire
986 press against GN2 on one drawing (the team, 2026-10-02: helium for hot
987 fire, nitrogen for water flows). Read when the model is assembled, so
988 changing it opens a new stand. Empty -- the default -- is the drawing's
989 gas, exactly as before."""
990
991
993 label: str, species: str, temperature: float, exc: Exception
994) -> str:
995 """Why a tank could not start, in the drawing's terms."""
996 try:
997 critical = Fluid(species).critical_temperature
998 except Exception: # noqa: BLE001 - the message must not fail too
999 critical = float("nan")
1000 if critical == critical and temperature > critical:
1001 return (
1002 f"{label} holds {species} at {temperature:.0f} K, above its critical "
1003 f"temperature ({critical:.0f} K), so it cannot be a liquid and a TANK "
1004 "has nothing to start from. If it is the pressurant (a COPV or a "
1005 "bottle), draw it as a Pressurant bottle (KBOTTLE); if it is a "
1006 "propellant, give it the liquid's temperature."
1007 )
1008 return (
1009 f"{label} could not start as a {species} tank at {temperature:.0f} K "
1010 f"({exc}). Check its fluid and temperature; a vessel of gas is a "
1011 "Pressurant bottle (KBOTTLE), not a TANK."
1012 )
1013
1014
1015def _bottle_geometry(volume: float) -> CylindricalTank:
1016 """A cylinder at L/D = 4 holding this volume: the shape of a gas bottle,
1017 which the drawing does not give and the film only needs roughly."""
1018 diameter = (max(volume, 1e-9) / math.pi) ** (1.0 / 3.0)
1019 return cylindrical_from_volume(volume, diameter)
1020
1021
1022def _tank_geometry(volume: float, diameter: float) -> CylindricalTank:
1023 """A barrel of the right volume at the drawing's diameter, 2:1 heads.
1024
1025 Delegates to the library so the drawing reader's static head and this
1026 vessel agree on where the liquid surface is.
1027 """
1028 return cylindrical_from_volume(volume, diameter)
1029
1030
1031#: Vessel-wall defaults, stated per litre and scaled by area, for a drawing
1032#: that declares none. Each is `estimated` and is reported as an assumption
1033#: whenever it is used; a drawing that knows better declares `wall_mass`,
1034#: `wall_capacity` and `wall_conductance` on the symbol and these never fire.
1035#:
1036#: These used to be three bare numbers -- 8 / 900 / 12 for a tank and
1037#: 30 / 500 / 20 for a bottle -- applied to every vessel whatever its size, so a
1038#: 4.7 L cylinder was given a 44 L K-bottle's wall. Mass now scales with
1039#: volume; conductance ``hA`` scales with surface area, hence as V^(2/3).
1040TANK_WALL = {
1041 "kg_per_litre": 8.0 / 17.5,
1042 "capacity": 900.0,
1043 "hA_ref": (12.0, 17.5),
1044 "basis": "aluminium tank, 8 kg at 17.5 L; hA 12 W/K at 17.5 L scaled as V^(2/3)",
1045}
1046BOTTLE_WALL = {
1047 "kg_per_litre": 60.0 / 44.0,
1048 "capacity": 500.0,
1049 "hA_ref": (20.0, 4.687),
1050 "basis": "steel K-bottle, ~60 kg at 44 L water volume; hA 20 W/K at 4.687 L "
1051 "scaled as V^(2/3). A composite cylinder is far lighter -- declare it",
1052}
1053
1054
1055def _skin_conductance(node: object, air_film: float, assumptions: list[str]) -> float:
1056 """Overall air-to-wall conductance per unit skin [W/(m^2.K)].
1057
1058 The air film in series with the insulation the drawing declares:
1059 ``U = 1 / (1/h_air + t/k)``. A tank that declares no insulation is bare,
1060 and says so in the assumptions when it is a cryogen tank, because the
1061 difference is a factor of six in boil-off.
1062 """
1063 params = getattr(node, "params", {}) or {}
1064 label = getattr(node, "label", "") or getattr(node, "id", "vessel")
1065 if air_film <= 0.0:
1066 return 0.0
1067 thickness = params.get("insulation_thickness")
1068 conductivity = params.get("insulation_conductivity")
1069 t = float(thickness.si) if thickness is not None else 0.0
1070 k = float(conductivity.si) if conductivity is not None else 0.0
1071 if t <= 0.0:
1072 return air_film
1073 if k <= 0.0:
1074 k = FIBERGLASS_K
1075 assumptions.append(
1076 f"{label} insulation: {t * 1e3:.0f} mm on the drawing, conductivity not "
1077 f"specified; {k} W/(m·K) assumed (fiberglass)."
1078 )
1079 return 1.0 / (1.0 / air_film + t / k)
1080
1081
1082def _trip_limit(node: object, safety_factor: float, assumptions: list[str]) -> float:
1083 """Absolute pressure a vessel trips the stand at, or 0 for no limit.
1084
1085 The drawing says what the vessel will take as a burst pressure -- what a
1086 team that built it knows -- and the stand stops at that over a stated
1087 factor of safety. An older drawing carrying an MAWP trips at the MAWP.
1088
1089 Both are read as the drawing reads them (feedtwin.model.pressure): bare
1090 "psi" is gauge, so ``.si`` is already absolute. The safety factor divides
1091 what the wall carries -- the pressure across it, gauge -- not the absolute.
1092 """
1093 params = getattr(node, "params", {}) or {}
1094 label = getattr(node, "label", "") or getattr(node, "id", "vessel")
1095 burst = params.get("burst_pressure")
1096 if burst is not None and burst.si > ATMOSPHERE:
1097 sf = max(float(safety_factor), 1.0)
1098 across = (float(burst.si) - ATMOSPHERE) / sf
1099 assumptions.append(
1100 f"{label} trip limit: burst pressure / {sf:g} across the wall "
1101 f"({say(across + ATMOSPHERE, 1)})."
1102 )
1103 return across + ATMOSPHERE
1104 rated_at = params.get("MAWP")
1105 return float(rated_at.si) if rated_at is not None else 0.0
1106
1107
1109 node: object,
1110 litres: float,
1111 defaults: dict[str, Any],
1112 assumptions: list[str],
1113 estimate: Callable[[], GasFilm] | None = None,
1114) -> tuple[float, float, float]:
1115 """``(wall_mass, wall_capacity, wall_conductance)`` for one vessel.
1116
1117 From the drawing where it says; from the scaled defaults where it does
1118 not, with a note. Reading the three separately, so a drawing that weighed
1119 the vessel but has no idea of its film coefficient still gets credit for
1120 the number it knows.
1121 """
1122 params = getattr(node, "params", {}) or {}
1123 label = getattr(node, "label", "") or getattr(node, "id", "vessel")
1124 missing: list[str] = []
1125
1126 def read(name: str, fallback: float) -> float:
1127 param = params.get(name)
1128 if param is not None and param.si > 0.0:
1129 return float(param.si)
1130 missing.append(name)
1131 return fallback
1132
1133 ha_ref, litres_ref = defaults["hA_ref"]
1134 mass = read("wall_mass", defaults["kg_per_litre"] * litres * 1e3)
1135 capacity = read("wall_capacity", defaults["capacity"])
1136 conductance = read(
1137 "wall_conductance",
1138 ha_ref * (max(litres * 1e3, 1e-6) / litres_ref) ** (2.0 / 3.0),
1139 )
1140 # The film from the gas, when the drawing said nothing and the setup
1141 # asks. Replaces the per-litre default, not a declared value.
1142 if estimate is not None and "wall_conductance" in missing:
1143 try:
1144 film = estimate()
1145 except Exception as exc: # property failure: keep the default, say so
1146 assumptions.append(
1147 f"{label} wall film: not estimable ({exc}); per-litre default "
1148 f"{conductance:.1f} W/K used."
1149 )
1150 else:
1151 conductance = film.hA
1152 assumptions.append(
1153 f"{label} wall film: hA {film.hA:.1f} W/K, natural convection in "
1154 f"the ullage (Churchill–Chu; h {film.h:.1f} W/(m²·K), "
1155 f"{film.area:.2f} m², ΔT {film.delta_T:g} K, "
1156 f"Ra {film.grashof * film.prandtl:.2e})."
1157 )
1158 if missing:
1159 assumptions.append(
1160 f"{label} wall: {mass:.2f} kg, {capacity:.0f} J/(kg·K), "
1161 f"{conductance:.1f} W/K, estimated ({defaults['basis']}). Not on the "
1162 f"drawing: {', '.join(missing)}."
1163 )
1164 return mass, capacity, conductance
1165
1166
1167@dataclass
1169 """One propellant tank, integrating."""
1170
1171 id: str
1172 label: str
1173 tank: Tank
1174 state: TankState
1175 ullage_node: str
1176 outlet_node: str
1177 #: Set while a Fill state is selected for this tank.
1178 filling: bool = False
1179 empty: bool = False
1180 fill_seconds: float = 30.0
1181 #: Seconds a load takes to chill this tank's wall from room temperature
1182 #: before liquid collects; zero for none (see :attr:`Setup.load_chill_s`).
1183 chill_seconds: float = 0.0
1184 #: The pour is still chilling the wall: nothing is collecting yet.
1185 chilling: bool = False
1186 #: Propellant the chill has flashed off and vented [kg].
1187 chill_boiled: float = 0.0
1188 #: Dewar pressure behind a cryogen load [Pa absolute]; zero loads at the
1189 #: fixed rate of ``fill_seconds`` instead (see :attr:`Setup.dewar_psi`).
1190 dewar_pressure: float = 0.0
1191 #: The dewar's fill line: bore [m], length [m], and the Cv of what on it
1192 #: is not tube. See :attr:`Setup.dewar_fill_cv`.
1193 fill_line_bore: float = 7.75e-3
1194 fill_line_length: float = 3.0
1195 fill_cv: float = 0.019
1196 #: What the dewar delivered over the last step [kg/s].
1197 fill_flow: float = 0.0
1198 #: The drawing symbol this was built from, for knobs re-read live.
1199 node: object = None
1200 #: Maximum allowable working pressure [Pa], 0 if the drawing gives none.
1201 mawp: float = 0.0
1202 #: Dials the session copies in each tick (see feed-twin backend/tunables.py).
1203 full_fraction: float = FULL_FRACTION
1204 #: What a load stops at [kg]: the engine's fire load for this tank
1205 #: (:meth:`Session.fire_loads`), 0 for "fill to :attr:`full_fraction`".
1206 load_kg: float = 0.0
1207 charge_gamma: float = CHARGE_GAMMA
1208 supply_band: float = SUPPLY_BAND
1209 stir_band: float = STIR_BAND
1210 gravity: float = GRAVITY
1211 """What the liquid column feels [m/s^2]; the session copies
1212 :attr:`Setup.body_acceleration` in each tick."""
1213 #: Ledger for the solver tab (feedtwin.session.diagnostics), cumulative:
1214 #: mass the built-in load put in [kg]; mass and ullage energy the
1215 #: integrator's own guards changed beyond what the rates said -- floors,
1216 #: clamps, a step that could not be reached -- [kg] and [J]. Read, never
1217 #: acted on.
1218 added_kg: float = 0.0
1219 fixed_kg: float = 0.0
1220 fixed_J: float = 0.0
1221 #: No open gas path from the ullage to the air (:meth:`Session._sealed`):
1222 #: the floor in :meth:`_settled` then never adds gas. Set each step.
1223 sealed: bool = False
1224 #: Gas the floor in :meth:`_settled` put in above what the ullage held at
1225 #: the start of a step, cumulative [kg], outside vent inflow
1226 #: (:attr:`vent_drawn_kg`): air from nowhere if the tank is sealed.
1227 floored_kg: float = 0.0
1228 #: Air an open vent let back in, cumulative [kg]: the physical floor's
1229 #: additions out of a built-in chill (:meth:`_atmosphere_floor`), booked
1230 #: as mass that crossed the boundary (in :attr:`added_kg`), not a clamp.
1231 vent_drawn_kg: float = 0.0
1232 #: What the last :meth:`_settled` drew in, (kg, J), for :meth:`_ledger`.
1233 _drawn_piece: tuple[float, float] = (0.0, 0.0)
1234 #: Liquid the network delivered to the outlet that the tank refused --
1235 #: from no tank feeding it -- cumulative [kg] (`Session._move_vessels`).
1236 refused_kg: float = 0.0
1237
1238 @property
1239 def pressure(self) -> float:
1240 return float(self.tank.pressure(self.state))
1241
1242 @property
1243 def outlet_pressure(self) -> float:
1244 return float(self.tank.outlet_pressure(self.state, self.gravity))
1245
1246 def take_back(self, mass: float) -> None:
1247 """Return pressurant this ullage sent out that no vessel took [kg].
1248
1249 It left booked at the ullage's enthalpy, so it comes back at it: the
1250 exact reverse of the debit in :meth:`advance`.
1251 """
1252 if mass <= 0.0:
1253 return
1254 ullage = self.state.ullage
1255 self.state = replace(
1256 self.state,
1257 ullage=replace(
1258 ullage,
1259 mass=ullage.mass + mass,
1260 energy=ullage.energy + mass * self.tank.gas_enthalpy(self.state),
1261 ),
1262 )
1263
1264 def readouts(self) -> dict[str, float]:
1265 return {
1266 "pressure_psi": psig(self.pressure),
1267 "ullage_temperature_K": self.tank.gas_temperature(self.state),
1268 "liquid_mass_kg": self.state.liquid_mass,
1269 "liquid_temperature_K": self.state.liquid_temperature,
1270 "fill_fraction": self.tank.fill_fraction(self.state),
1271 "level_m": self.tank.level(self.state),
1272 # The metal under the liquid. A LOX tank whose wall is still warm
1273 # boils hard the moment its vent shuts; the panel and the pad
1274 # guide need to see that before the operator does.
1275 "wall_temperature_K": (
1276 self.state.wetted_wall_temperature
1277 if self.state.wetted_wall_temperature is not None
1278 else self.state.ullage.wall_temperature
1279 ),
1280 "volume_L": self.tank.geometry.total_volume * 1e3,
1281 "chilling": 1.0 if self.chilling else 0.0,
1282 "fill_flow_g_s": self.fill_flow * 1e3,
1283 # The surface the ullage sees, when it is tracked apart from the
1284 # bulk; equal to the liquid temperature when it is not.
1285 "surface_temperature_K": (
1286 self.state.surface_temperature
1287 if self.state.surface_temperature is not None
1288 else self.state.liquid_temperature
1289 ),
1290 }
1291
1292 def _chill(self, dt: float) -> bool:
1293 """Spend this step's pour on the wall, if the wall still wants it.
1294
1295 Only for a cryogen load into a tank that holds no liquid yet: what is
1296 poured flashes on the metal and goes out of the vent, so nothing
1297 collects until the wall is down at saturation for the tank's pressure
1298 (plus the boiling onset, where a wall stops boiling what touches it).
1299 The wall falls at the rate that takes it from the room to the liquid
1300 in ``chill_seconds``; the heat it gives up is the propellant flashed,
1301 ``Q / h_fg``, booked in :attr:`chill_boiled`.
1302
1303 Returns whether the step was spent chilling.
1304 """
1305 state = self.state
1306 if (
1307 self.chill_seconds <= 0.0
1308 or state.liquid_mass > 1e-3
1309 or state.liquid_temperature >= CRYOGENIC_K
1310 ):
1311 return False
1312 target = self._chill_target()
1313 walls = [state.ullage.wall_temperature]
1314 if state.wetted_wall_temperature is not None:
1315 walls.append(state.wetted_wall_temperature)
1316 # Half a kelvin of slack: at the target the ullage gas still hands the
1317 # wall a trace of heat each step, and a test against the bare target
1318 # called that "still chilling" forever and never let the load collect.
1319 if max(walls) <= target + CHILLED_BAND:
1320 return False
1321 rate = max(AMBIENT_T - state.liquid_temperature, 1.0) / self.chill_seconds
1322 cooled = [max(w - rate * dt, min(w, target)) for w in walls]
1323 capacity = self.tank.wall_mass * self.tank.wall_capacity
1324 heat = capacity * sum(w - c for w, c in zip(walls, cooled)) / len(walls)
1325 try:
1326 h_fg = latent_heat(self.tank.liquid, state.liquid_temperature)
1327 except (ValueError, PropertyError):
1328 h_fg = 0.0
1329 if h_fg > 0.0:
1330 self.chill_boiled += heat / h_fg
1331 self.state = replace(
1332 state,
1333 ullage=replace(state.ullage, wall_temperature=cooled[0]),
1334 wetted_wall_temperature=(
1335 cooled[1] if state.wetted_wall_temperature is not None else None
1336 ),
1337 )
1338 return True
1339
1340 def _chill_target(self, pressure: float | None = None) -> float:
1341 """Where a load's chilldown takes the wall [K]: saturation at the tank's
1342 pressure (or ``pressure``), plus the boiling onset, where a wall stops
1343 boiling what touches it."""
1344 try:
1345 target = self.tank.liquid.get(
1346 "T", p=self.pressure if pressure is None else pressure, q=0.0
1347 )
1348 except (ValueError, PropertyError):
1349 target = self.state.liquid_temperature
1350 return float(target) + max(self.tank.boiling_onset, 0.0)
1351
1352 def skip_chill(self) -> bool:
1353 """Put the wall where a chilldown leaves it, now.
1354
1355 Where it leaves it once the boil-off has vented: the chill target at
1356 atmosphere (or at the tank's pressure, if lower). Not at the tank's
1357 present pressure -- a chilling tank sits above atmosphere on its own
1358 boil-off, that pressure falls as soon as the boiling stops, and a wall
1359 left at its saturation is above the new one a moment later and chilling
1360 again. What the metal gave up is the propellant the chill flashes off
1361 and vents, booked in :attr:`chill_boiled`; a load then collects from its
1362 next step. Returns whether there was anything to chill: not for a liquid
1363 that is no cryogen, nor a wall already there.
1364 """
1365 state = self.state
1366 if state.liquid_temperature >= CRYOGENIC_K:
1367 return False
1368 target = self._chill_target(min(self.pressure, AMBIENT))
1369 walls = [state.ullage.wall_temperature]
1370 if state.wetted_wall_temperature is not None:
1371 walls.append(state.wetted_wall_temperature)
1372 if max(walls) <= target + CHILLED_BAND:
1373 return False
1374 cooled = [min(w, target) for w in walls]
1375 capacity = self.tank.wall_mass * self.tank.wall_capacity
1376 heat = capacity * sum(w - c for w, c in zip(walls, cooled)) / len(walls)
1377 try:
1378 h_fg = latent_heat(self.tank.liquid, state.liquid_temperature)
1379 except (ValueError, PropertyError):
1380 h_fg = 0.0
1381 if h_fg > 0.0:
1382 self.chill_boiled += heat / h_fg
1383 self.state = replace(
1384 state,
1385 ullage=self._chilled_ullage(state, cooled[0]),
1386 wetted_wall_temperature=(
1387 cooled[1] if state.wetted_wall_temperature is not None else None
1388 ),
1389 )
1390 self.chilling = False
1391 return True
1392
1393 def _chilled_ullage(self, state: TankState, wall: float) -> VesselState:
1394 """The ullage a chill leaves: at the chilled wall's temperature, at the
1395 pressure it holds now.
1396
1397 A chill floods the ullage with its own cold boil-off; skipping one
1398 cooled the metal and left the gas warm, which then chilled on the wall
1399 after the skip and pulled the tank under the air -- in the old floor,
1400 39.6 g of nitrogen in one step (2026-10-12). The gas the colder ullage
1401 holds is the boil-off a chill would have left there, from the load,
1402 and is booked as the load's (:attr:`added_kg`).
1403 """
1404 held = state.ullage
1405 try:
1406 volume = max(self.tank.ullage_volume(state), 1e-9)
1407 partial = self.tank.pressure(state) - self.tank.vapour_pressure(state)
1408 if partial <= 0.0 or wall <= 0.0:
1409 raise ValueError("no pressurant pressure to hold")
1410 density = float(self.tank.gas.get("rho", p=partial, T=wall))
1411 energy = float(self.tank.gas.get("u", p=partial, T=wall))
1412 except (ValueError, PropertyError):
1413 return replace(held, wall_temperature=wall)
1414 mass = density * volume
1415 self.added_kg += mass - held.mass
1416 return VesselState(mass=mass, energy=energy * mass, wall_temperature=wall)
1417
1418 @property
1419 def load_target_kg(self) -> float:
1420 """What a load fills this tank to [kg] (:meth:`_wanted`), for the pad
1421 guide: a fire load of 6.75 kg is 73 % of LE4's LOX tank, and a guide
1422 that wanted 90 % said the load had slipped after every T-0."""
1423 return self._wanted()
1424
1425 def _wanted(self) -> float:
1426 """Liquid mass a load stops at [kg]: the fire load when there is one,
1427 never more than the full fraction of the tank."""
1428 capacity = self.tank.geometry.total_volume * self.full_fraction
1429 full = capacity * self.tank.liquid_density(self.state)
1430 return min(self.load_kg, full) if self.load_kg > 0.0 else full
1431
1432 def _collect(self, added: float) -> None:
1433 """Put ``added`` kg of the load into the liquid."""
1434 # The rising liquid does p dV work on the ullage it displaces: the term
1435 # `Tank.rates` books as `expansion_power` for a draining tank, with the
1436 # sign turned. Without it the vent took the displaced gas at its
1437 # enthalpy and what stayed lost p v per kilogram vented -- a fuel
1438 # ullage poured onto at atmosphere cooled 293 -> 285 K, where by hand
1439 # an adiabatic displacement at constant pressure leaves it as it was.
1440 work = 0.0
1441 if added > 0.0:
1442 try:
1443 rho_l = self.tank.liquid_density(self.state)
1444 if rho_l > 0.0:
1445 work = self.tank.pressure(self.state) * added / rho_l
1446 except (ValueError, PropertyError):
1447 work = 0.0
1448 ullage = self.state.ullage
1449 # `replace`, not a fresh TankState: rebuilding field by field silently
1450 # drops anything added to the dataclass later, which is exactly how
1451 # `vapour_mass` came to reset to zero every step.
1452 self.state = replace(
1453 self.state,
1454 ullage=replace(ullage, energy=ullage.energy + work),
1455 liquid_mass=self.state.liquid_mass + added,
1456 # A load stirs the liquid; the layer forms once it is still.
1457 surface_temperature=(
1458 None
1459 if self.state.surface_temperature is None
1460 else self.state.liquid_temperature
1461 ),
1462 )
1463
1464 def fill_line_flow(self, drop: float) -> float:
1465 """What the dewar's fill line passes at a pressure drop [kg/s].
1466
1467 Liquid through the line: ``drop = (f L / D + K_valves + 1) rho v^2 / 2``,
1468 the 1 being the exit into the tank, ``K_valves`` from the Cv of what on
1469 the line is not tube (``fluids.fittings.Cv_to_K``), and ``f`` Clamond at
1470 the Reynolds number the flow itself sets -- iterated, since the flow
1471 sets it. Nothing flows back into the dewar.
1472 """
1473 bore = self.fill_line_bore
1474 if drop <= 0.0 or bore <= 0.0:
1475 return 0.0
1476 state = self.state
1477 rho = self.tank.liquid_density(state)
1478 try:
1479 mu = self.tank.liquid.get("mu", T=state.liquid_temperature, q=0.0)
1480 except (ValueError, PropertyError):
1481 mu = 0.0
1482 area = math.pi * bore * bore / 4.0
1483 fixed = 1.0 + (Cv_to_K(self.fill_cv, bore) if self.fill_cv > 0.0 else 0.0)
1484 friction = 0.0
1485 flow = 0.0
1486 for _ in range(FILL_LINE_ITERATIONS):
1487 flow = area * math.sqrt(
1488 2.0 * rho * drop / (fixed + friction * self.fill_line_length / bore)
1489 )
1490 Re = reynolds(flow, bore, rho, mu)
1491 friction = darcy_friction_factor(
1492 Re, FILL_LINE_ROUGHNESS / bore, DEFAULT_FRICTION_METHOD
1493 )
1494 return flow
1495
1496 def _dewar_load(self, dt: float) -> bool:
1497 """One step of a cryogen load pushed in by the dewar.
1498
1499 What arrives is :meth:`fill_line_flow` at the dewar's pressure less the
1500 tank's. Into a tank holding no liquid yet, with the wall above
1501 saturation for the tank's pressure (plus the boiling onset, as in
1502 :meth:`_chill`), it boils on the wall: the wall gives up ``h_fg`` for
1503 every gram, and the vapour goes into the **ullage**, where only the
1504 vent can take it out. That is the climb the stand shows while a tank
1505 chills, and why it is larger the faster the dewar pours. What arrives
1506 once the wall is cold -- or what the wall had no heat left to boil --
1507 collects, up to the full fraction.
1508
1509 Returns whether the step was spent chilling.
1510 """
1511 state = self.state
1512 room = self._wanted() - state.liquid_mass
1513 if room <= 0.0 or dt <= 0.0:
1514 return False
1515 arriving = self.fill_line_flow(self.dewar_pressure - self.pressure) * dt
1516 boiled = 0.0
1517 if state.liquid_mass <= 1e-3:
1518 try:
1519 target = self.tank.liquid.get("T", p=self.pressure, q=0.0)
1520 h_fg = latent_heat(self.tank.liquid, state.liquid_temperature)
1521 except (ValueError, PropertyError):
1522 target, h_fg = state.liquid_temperature, 0.0
1523 target += max(self.tank.boiling_onset, 0.0)
1524 walls = [state.ullage.wall_temperature]
1525 if state.wetted_wall_temperature is not None:
1526 walls.append(state.wetted_wall_temperature)
1527 capacity = self.tank.wall_mass * self.tank.wall_capacity
1528 if max(walls) > target + CHILLED_BAND and h_fg > 0.0 and capacity > 0.0:
1529 # Each wall gives up the same heat per kelvin, so each falls by
1530 # the same amount -- the one that boils what arrived -- unless
1531 # it reaches the target first.
1532 fall = arriving * h_fg / capacity
1533 if all(w - fall >= target for w in walls):
1534 # All of it boils. Said exactly rather than as heat / h_fg,
1535 # whose round-off is a femtogram of "liquid" left on a warm
1536 # wall -- which heats past the critical point in one step.
1537 cooled = [w - fall for w in walls]
1538 boiled = arriving
1539 else:
1540 cooled = [max(w - fall, min(w, target)) for w in walls]
1541 heat = capacity * sum(w - c for w, c in zip(walls, cooled))
1542 boiled = min(heat / len(walls) / h_fg, arriving)
1543 self.chill_boiled += boiled
1544 self.state = replace(
1545 state,
1546 ullage=replace(state.ullage, wall_temperature=cooled[0]),
1547 wetted_wall_temperature=(
1548 cooled[1] if state.wetted_wall_temperature is not None else None
1549 ),
1550 vapour_mass=state.vapour_mass + boiled,
1551 )
1552 collected = min(arriving - boiled, room)
1553 if collected > 0.0:
1554 self._collect(collected)
1555 self.fill_flow = (boiled + max(collected, 0.0)) / dt
1556 return boiled > 0.0
1557
1559 self,
1560 dt: float,
1561 *,
1562 mdot_liquid_out: float,
1563 mdot_gas_in: float,
1564 mdot_gas_out: float,
1565 enthalpy_gas_in: float,
1566 supply_pressure: float = 0.0,
1567 stirring: float = 1.0,
1568 vent_fraction: float = 1.0,
1569 mdot_liquid_in: float = 0.0,
1570 liquid_in_temperature: float | None = None,
1571 ) -> float:
1572 """One vessel step, with the flows the network just produced.
1573
1574 ``mdot_liquid_in`` is propellant the network delivers to this tank's
1575 outlet from another tank -- a cart's transfer tank loading it -- arriving
1576 at ``liquid_in_temperature`` and mixed into the liquid. It squeezes the
1577 ullage as it comes in, which ``Tank.rates`` prices as the p dV work the
1578 drain does in reverse.
1579
1580 Returns the gas inflow it **refused** [kg/s]. The caller owes that back
1581 to whatever was debited for it: the network solved a flow leaving the
1582 bottle, and if the tank does not take it, it did not happen.
1583
1584 In and out are separate, not netted. They are simultaneous during a
1585 fill -- pressurant on one port, vent on another -- and they carry
1586 different energy: gas arriving brings the supply's enthalpy, gas leaving
1587 takes the ullage's own. Netting them would price the whole exchange at
1588 the supply's enthalpy and make a venting tank warm up.
1589 """
1590 self.fill_flow = 0.0
1591 if (
1592 self.filling
1593 and self.dewar_pressure > 0.0
1594 and self.state.liquid_temperature < CRYOGENIC_K
1595 ):
1596 self.chilling = self._dewar_load(dt)
1597 else:
1598 self.chilling = self.filling and self._chill(dt)
1599 if self.filling and not self.chilling:
1600 wanted = self._wanted()
1601 if self.state.liquid_mass < wanted:
1602 span = max(self.fill_seconds, 1e-3)
1603 added = min(wanted * dt / span, wanted - self.state.liquid_mass)
1604 self._collect(added)
1605 self.fill_flow = added / dt if dt > 0.0 else 0.0
1606 self.added_kg += self.fill_flow * dt
1607
1608 # Gas cannot flow into a vessel that has reached the pressure feeding
1609 # it. The network solve says how much is flowing *at the pressure the
1610 # tick started from*, so a tank filling fast sails past that point
1611 # between solves and keeps taking gas it could not have taken -- which
1612 # is why a 500 psi regulator used to leave a tank at 550 once it had
1613 # cooled back down. Stopping the inflow at the crossover is exact, and
1614 # it is the vessel's to enforce because only the vessel knows where its
1615 # pressure got to mid-step.
1616 refused = 0.0
1617 if supply_pressure > 0.0 and mdot_gas_in > 0.0:
1618 # Only once the tank is *past* its supply by a clear margin. The
1619 # reference here is the node next door, which in a converged solve
1620 # sits above the tank by exactly the line loss between them -- a
1621 # psi or two on a helium press line. Closing the moment the tank
1622 # touches it therefore closes on every sub-step that puts any gas
1623 # in, and the relay that produces -- fill, cross, refuse, drain
1624 # back, fill -- sits at exactly half duty, so the tank receives half
1625 # of every flow the network solved. A clean 50.0% loss that reads
1626 # as physics because it is perfectly steady. The margin makes the
1627 # clamp what it was meant to be: a backstop for the tick lag, not a
1628 # second regulator arguing with the first.
1629 # gas goes in, the tank crosses the supply, the next sub-step
1630 # refuses, the liquid leaving drops it back under, and the step
1631 # after that accepts again. That limit cycle sits at exactly half
1632 # duty, so the tank receives half of every flow the network solved
1633 # for it -- a clean 50.0% loss that looks like physics because it is
1634 # perfectly steady. The band is the same half percent the regulator
1635 # uses in `is_choked`, and for the same reason.
1636 band = self.supply_band * supply_pressure
1637 over = self.pressure - (supply_pressure + band)
1638 if over > 0.0:
1639 refused = mdot_gas_in
1640 mdot_gas_in = 0.0
1641 else:
1642 # Below the supply, but perhaps not by a whole step's worth.
1643 # The inflow is applied for `dt` at the rate the network
1644 # solved with the tank where it *was*; on a 0.43 L fuel
1645 # ullage holding twenty grams, one step near lockup put the
1646 # tank 35 psi past the regulator, which then shut and left it
1647 # there until the gas cooled. Clip the step to the mass that
1648 # lands the tank on the supply -- linearised, with the charge
1649 # heating's gamma so a hot arrival does not carry it over --
1650 # and refuse the rest back to the bottle it came from.
1651 held = self.state.ullage.mass + self.state.vapour_mass
1652 room = (
1653 (supply_pressure + band - self.pressure)
1654 / max(self.pressure, 1.0)
1655 / self.charge_gamma
1656 * held
1657 )
1658 allowed = room / dt if dt > 0.0 else mdot_gas_in
1659 if mdot_gas_in > allowed > 0.0:
1660 refused = mdot_gas_in - allowed
1661 mdot_gas_in = allowed
1662
1663 # The charge jet stirs the ullage only while there is a charge: gas
1664 # arriving with the tank well below its supply. At lockup two tanks
1665 # on one manifold trade a few grams a second back and forth, and
1666 # calling every one of those arrivals a jet ran the wall exchange at
1667 # twenty times natural on a 293 K wall -- the ullages warmed, the
1668 # tanks ratcheted 16 psi above lockup in ten seconds, and the study
1669 # could no longer settle at T-0.
1670 pressing = (
1671 mdot_gas_in > 0.0
1672 and supply_pressure > 0.0
1673 and supply_pressure - self.pressure > self.stir_band * supply_pressure
1674 )
1675 stirring = stirring if pressing else 1.0
1676
1677 # A tank cannot deliver more liquid than it holds. Without this the
1678 # solver happily draws propellant out of an empty vessel and the mains
1679 # keep flowing after the tank is dry. The session cuts its coupling
1680 # step at the instant a tank runs out (`Session._dry_cut`), so the
1681 # network never asks for more than is there; this is the backstop, and
1682 # what it refuses is booked, because the network has already sent it
1683 # downstream. Zeroing the outflow below a gram -- what this did --
1684 # refused the rest of the step without a word: 17-25 g of propellant
1685 # the engine burned and no vessel gave.
1686 self.empty = self.state.liquid_mass <= DRY_MASS
1687 mdot_liquid_in = max(mdot_liquid_in, 0.0)
1688 holds = max(self.state.liquid_mass, 0.0) / dt if dt > 0.0 else 0.0
1689 holds += mdot_liquid_in
1690 if mdot_liquid_out > holds:
1691 self.fixed_kg += (mdot_liquid_out - holds) * dt
1692 mdot_liquid_out = holds
1693 if mdot_liquid_in > 0.0 and dt > 0.0:
1694 if liquid_in_temperature is not None:
1695 arriving = mdot_liquid_in * dt
1696 held_liquid = max(self.state.liquid_mass, 0.0)
1697 mixed = (
1698 held_liquid * self.state.liquid_temperature
1699 + arriving * liquid_in_temperature
1700 ) / (held_liquid + arriving)
1701 self.state = replace(self.state, liquid_temperature=mixed)
1702 # Net through the outlet: negative is a tank being loaded.
1703 mdot_liquid_out -= mdot_liquid_in
1704
1705 # What leaves through a vent is the ullage as it is: pressurant and
1706 # propellant vapour in proportion. The network solved one gas flow out
1707 # of the node; the split is the vessel's to make, because only the
1708 # vessel knows what it holds. Debiting the pressurant alone -- what
1709 # this did -- left every gram a LOX tank boiled sitting in the ullage,
1710 # and a tank venting 400 g/s through a wide-open 3/8 in vent climbed
1711 # to 500 psig during its own fill.
1712 # ...and only the part that actually reaches a vent. Two tanks at
1713 # lockup trade a few grams a second through the press manifold; the
1714 # network does not know species, so vapour that left with that slosh
1715 # came back as pressurant and the pair ratcheted upward. What goes to
1716 # the manifold and back is booked as pressurant both ways; what goes
1717 # out a vent takes its vapour share with it (`Session._vent_fraction`).
1718 held = self.state.ullage.mass + self.state.vapour_mass
1719 vapour_share = self.state.vapour_mass / held if held > 0.0 else 0.0
1720 vented = max(mdot_gas_out, 0.0) * min(max(vent_fraction, 0.0), 1.0)
1721 vapour_out = vented * vapour_share
1722 pressurant_out = mdot_gas_out - vapour_out
1723 # The pressurant does not go below its floor (`_settled`): it carries
1724 # the ullage's heat capacity. Once it is there, what the vent takes is
1725 # vapour -- a LOX tank chilling under its own boil-off vents oxygen,
1726 # not the air it started with. Split by mass share regardless, the vent
1727 # took air the floor then put back: ~3 g/s and 800 W through a whole
1728 # chilldown, booked as a guard. Only the vented part shifts; gas traded
1729 # with the press manifold stays pressurant (see above).
1730 if vented > vapour_out and dt > 0.0 and self.state.vapour_mass > 0.0:
1731 spare = max(
1732 self.state.ullage.mass - self._pressurant_floor(), 0.0
1733 ) / dt + max(mdot_gas_in, 0.0)
1734 over = min(pressurant_out, vented - vapour_out) - spare
1735 if over > 0.0:
1736 shift = min(over, self.state.vapour_mass / dt - vapour_out)
1737 if shift > 0.0:
1738 vapour_out += shift
1739 pressurant_out -= shift
1740 # One call, with the energy the two streams actually carry folded into
1741 # an effective inlet enthalpy -- the rates() signature takes a single
1742 # gas stream, and this keeps the physics right without forking it.
1743 # The vapour carries no energy term (see Tank.rates), so only the
1744 # pressurant's share leaves with the ullage's enthalpy.
1745 net_gas = mdot_gas_in - pressurant_out
1746 if abs(net_gas) > 1e-12:
1747 leaving = pressurant_out * self.tank.gas_enthalpy(self.state)
1748 enthalpy = (mdot_gas_in * enthalpy_gas_in - leaving) / net_gas
1749 else:
1750 enthalpy = enthalpy_gas_in
1751 # Sub-step on the ullage's own time constant rather than the caller's
1752 # clock. A 6 mm vent empties a 17 litre ullage in a fraction of a
1753 # second, so a step sized for the *display* rate marches straight past
1754 # atmosphere into a numerical vacuum -- at which point the property
1755 # layer is asked to price nitrogen at 0.09 kg/m^3 and refuses, which is
1756 # correct and useless. Bounding the fractional mass change per step
1757 # costs a handful of extra evaluations only while something is actually
1758 # moving fast.
1759 steps = 1
1760 if held > 0.0 and (abs(net_gas) > 0.0 or vapour_out > 0.0):
1761 by_mass = (abs(net_gas) + vapour_out) * dt / held / MAX_MASS_FRACTION
1762 # dT ~ (dU/dt) / (m . cv), estimated from the enthalpy crossing the
1763 # boundary. Only a scale -- the retry below is what guarantees it.
1764 gas_T = self.tank.gas_temperature(self.state)
1765 cv = max(self.tank.gas.get("cv", p=AMBIENT, T=gas_T), 1.0)
1766 d_temperature = abs(net_gas * enthalpy) * dt / (self.state.ullage.mass * cv)
1767 by_temperature = d_temperature / (gas_T * MAX_TEMPERATURE_FRACTION)
1768 steps = min(int(max(by_mass, by_temperature)) + 1, MAX_VESSEL_STEPS)
1769 inner = dt / steps
1770
1771 for _ in range(steps):
1772 self._one_step(
1773 inner, mdot_liquid_out, net_gas, enthalpy, stirring, vapour_out
1774 )
1775 return refused
1776
1778 self,
1779 dt: float,
1780 mdot_liquid_out: float,
1781 net_gas: float,
1782 enthalpy: float,
1783 stirring: float = 1.0,
1784 vapour_out: float = 0.0,
1785 ) -> None:
1786 """One vessel step that lands somewhere the gas can actually be.
1787
1788 Halves itself and retries when it does not. An explicit step onto a
1789 small ullage during a vent can overshoot into a state the equation of
1790 state refuses -- correctly, since it is a solid -- and the alternative
1791 to retrying is the run dying at the moment it is meant to demonstrate.
1792
1793 Halving shortens the piece, never the step: once a piece lands, the
1794 next one starts from it, until all of ``dt`` is integrated. Returning
1795 on the first piece that landed -- what this did -- dropped the rest of
1796 the step's liquid outflow, gas exchange and heat every time a vent
1797 forced a retry. The pieces are counted in whole ``dt / 2**halvings``
1798 so they sum to ``dt`` exactly; with no retry it is one piece of ``dt``,
1799 as before.
1800 """
1801 halvings = 0
1802 done = 0 # pieces of dt / 2**halvings already integrated
1803 while done < 1 << halvings:
1804 remaining = dt / (1 << halvings)
1805 rates = self.tank.rates(
1806 self.state,
1807 mdot_liquid_out=mdot_liquid_out,
1808 mdot_gas_in=net_gas,
1809 enthalpy_gas_in=enthalpy,
1810 # The press jet stirs the ullage; a venting or quiet tank is
1811 # back to still-gas exchange.
1812 stirring=stirring if net_gas > 0.0 else 1.0,
1813 mdot_vapour_out=vapour_out,
1814 )
1815 stepped = self.tank.step(self.state, rates, remaining)
1816 liquid = stepped.liquid_mass
1817 if liquid <= LIQUID_ROUNDOFF * self.state.liquid_mass:
1818 # Emptied on this piece: what is left is round-off, not
1819 # propellant (LIQUID_ROUNDOFF). The ledger books the residue.
1820 liquid = 0.0
1821 candidate = replace(
1822 stepped,
1823 ullage=self._settled(stepped.ullage),
1824 liquid_mass=liquid,
1825 )
1826 try:
1827 self.tank.pressure(candidate)
1828 except Exception: # noqa: BLE001 - any refusal means "too far"
1829 if halvings == STEP_RETRIES:
1830 break
1831 halvings += 1
1832 done *= 2
1833 continue
1834 self._ledger(rates, remaining, candidate, drawn=self._drawn_piece)
1835 self.state = candidate
1836 done += 1
1837 else:
1838 return
1839 # Still unreachable after halving: the ullage has run out of gas to
1840 # give. Settle it at atmosphere against the wall, which is where a
1841 # fully vented tank ends up anyway.
1842 volume = max(self.tank.ullage_volume(self.state), 1e-9)
1843 wall = self.state.ullage.wall_temperature
1844 mass = self.tank.gas.get("rho", p=AMBIENT, T=wall) * volume
1845 start = self.state
1846 left = dt - done * dt / (1 << halvings)
1847 # `replace`, not a fresh TankState: rebuilding field by field drops
1848 # anything added to the dataclass later -- it is how vapour_mass once
1849 # reset every step, and it would have dropped the wetted wall here.
1850 self.state = replace(
1851 self.state,
1852 ullage=VesselState(
1853 mass=mass,
1854 energy=self.tank.gas.get("u", p=AMBIENT, T=wall) * mass,
1855 wall_temperature=wall,
1856 ),
1857 # The ullage was rebuilt from atmosphere, so any vapour that was in
1858 # it has gone out of the vent with the rest.
1859 vapour_mass=0.0,
1860 )
1861 self._ledger(rates, left, self.state, start)
1862
1864 self,
1865 rates: TankRates,
1866 dt: float,
1867 landed: TankState,
1868 start: TankState | None = None,
1869 drawn: tuple[float, float] = (0.0, 0.0),
1870 ) -> None:
1871 """Book what the guards changed: where a piece of step ``landed``
1872 against where the rates over its ``dt`` said it would. Air an open vent
1873 let in (``drawn``, kg and J, from :meth:`_settled`) crossed the
1874 boundary: it is booked as in, not as a clamp."""
1875 was = start or self.state
1876
1877 def total(state: TankState) -> float:
1878 return state.liquid_mass + state.ullage.mass + state.vapour_mass
1879
1880 promised = (rates.ullage.mass + rates.liquid_mass + rates.vapour_mass) * dt
1881 self.fixed_kg += total(landed) - total(was) - promised - drawn[0]
1882 self.fixed_J += (
1883 landed.ullage.energy
1884 - was.ullage.energy
1885 - rates.ullage.energy * dt
1886 - drawn[1]
1887 )
1888 self.added_kg += drawn[0]
1889 self.vent_drawn_kg += drawn[0]
1890
1891 def _pressurant_floor(self, wall_temperature: float | None = None) -> float:
1892 """The least pressurant the ullage holds [kg]: atmosphere's worth at the
1893 wall, in the ullage's present volume (see :meth:`_settled`)."""
1894 volume = max(self.tank.ullage_volume(self.state), 1e-9)
1895 wall = (
1896 self.state.ullage.wall_temperature
1897 if wall_temperature is None
1898 else wall_temperature
1899 )
1900 return float(self.tank.gas.get("rho", p=AMBIENT, T=wall)) * volume
1901
1902 def _specific_energy_at(self, mass: float, temperature: float) -> float:
1903 """The pressurant's specific internal energy [J/kg] as ``mass`` of it
1904 fills the ullage at ``temperature``: at its own partial density, which
1905 is what :meth:`_gas_temperature_of` reads it back at.
1906
1907 Priced at a whole atmosphere instead, a floored ullage sharing the tank
1908 with propellant vapour came back colder than it was restated at --
1909 nitrogen near its dew line is far from ideal, ~1 K at 85 K between one
1910 atmosphere and half of one -- and each floored step ratcheted it down:
1911 a vented LOX load took its ullage to nitrogen's 77 K boiling point over
1912 90 K liquid and a 92 K wall.
1913 """
1914 volume = max(self.tank.ullage_volume(self.state), 1e-9)
1915 if mass > 0.0:
1916 try:
1917 return float(self.tank.gas.get("u", rho=mass / volume, T=temperature))
1918 except (ValueError, PropertyError):
1919 pass
1920 return float(self.tank.gas.get("u", p=AMBIENT, T=temperature))
1921
1922 def _gas_temperature_of(self, ullage: VesselState) -> float:
1923 """The pressurant's own temperature in ``ullage`` [K]; the wall's when
1924 it cannot be priced."""
1925 volume = max(self.tank.ullage_volume(self.state), 1e-9)
1926 if ullage.mass > 0.0:
1927 try:
1928 temperature = float(
1929 self.tank.gas.get(
1930 "T", rho=ullage.mass / volume, u=ullage.energy / ullage.mass
1931 )
1932 )
1933 if temperature > 0.0:
1934 return temperature
1935 except (ValueError, PropertyError):
1936 pass
1937 return ullage.wall_temperature
1938
1939 def _atmosphere_floor(self, ullage: VesselState | None = None) -> float:
1940 """The pressurant that brings the tank back to atmosphere [kg]: its
1941 partial pressure the air's less what the propellant vapour already
1942 holds (Dalton, as :meth:`Tank.pressure`), at the gas's own temperature.
1943
1944 What "a vent cannot pull a tank below the air outside it" means. The
1945 old floor (:meth:`_pressurant_floor`) asked for a whole atmosphere of
1946 pressurant at the *wall's* temperature, which only fits a tank whose
1947 ullage is the wall's own boil-off -- a built-in chill, where it stands
1948 in for that boil-off (see :meth:`_settled`).
1949 """
1950 held = ullage if ullage is not None else self.state.ullage
1951 volume = max(self.tank.ullage_volume(self.state), 1e-9)
1952 try:
1953 vapour = self.tank.vapour_pressure(self.state)
1954 except (ValueError, PropertyError):
1955 vapour = 0.0
1956 partial = AMBIENT - vapour
1957 if partial <= 0.0:
1958 return 0.0
1959 temperature = self._gas_temperature_of(held)
1960 return float(self.tank.gas.get("rho", p=partial, T=temperature)) * volume
1961
1962 def _settled(self, ullage: VesselState) -> VesselState:
1963 """Keep the ullage inside the states the gas can actually occupy.
1964
1965 Two floors, and both are physics rather than numerics.
1966
1967 A vent cannot pull a tank below the air outside it, so the **mass**
1968 cannot fall below what atmosphere would hold in that volume. Only with a
1969 vent: a :attr:`sealed` tank has nothing to let air in, and goes below
1970 atmosphere as its liquid drains.
1971
1972 The floor is on the pressurant alone because the pressurant carries the
1973 ullage's heat capacity: the propellant vapour has no energy term (see
1974 ``Tank.rates``), so an ullage vented down to vapour alone would have no
1975 thermal mass to integrate. What keeps the floor from *creating* gas is
1976 the vent's split (:meth:`advance`): once the pressurant is at its floor
1977 the vent takes vapour, which is what a tank chilling down under its own
1978 boil-off is venting.
1979
1980 Only the mass, deliberately. Chilling is bounded by sizing the step
1981 against the energy leaving (see MAX_ENERGY_FRACTION) rather than by
1982 clamping the temperature afterwards: a clamp applied every sub-step is
1983 a ratchet that puts energy *in*, and a venting tank then climbs in
1984 pressure instead of falling.
1985 """
1986 self._drawn_piece = (0.0, 0.0)
1987 restate_at = ullage.wall_temperature
1988 floor = self._pressurant_floor(ullage.wall_temperature)
1989 drawn_through_vent = not self.sealed and not self.chilling
1990 if drawn_through_vent:
1991 # Out of a built-in chill the floor keeps what it always kept -- the
1992 # vent does not take the pressurant below its old floor, capped at
1993 # what the ullage holds (the pressurant carries the ullage's heat
1994 # capacity; the vent takes vapour first) -- but it *grows* the
1995 # ullage only below the air, and only to what puts the tank back
1996 # at it (:meth:`_atmosphere_floor`): the pressurant's share of an
1997 # atmosphere beside the vapour, at the gas's own temperature. It
1998 # used to grow to a whole atmosphere of pressurant at the wall's
1999 # temperature the moment a venting LOX tank dipped 0.02 psi under
2000 # the air during a fill -- 57 g where the ullage held 17.6, the
2001 # 7.9 g of oxygen vapour holding a third of the pressure ignored,
2002 # 39.6 g of nitrogen from nowhere in one step (Ethalox Stand,
2003 # 2026-10-12). Below the air, an open vent lets in what puts the
2004 # tank back at it: a hundredth of a gram there.
2005 # With no vapour the pressurant is the whole ullage, and what the
2006 # vent cannot take it below is atmosphere at its own temperature.
2007 physical = self._atmosphere_floor(ullage)
2008 if self.state.vapour_mass <= 0.0:
2009 floor = physical
2010 floor = min(floor, self.state.ullage.mass)
2011 restate_at = self._gas_temperature_of(ullage)
2012 try:
2013 below = self.tank.pressure(self.state) < AMBIENT
2014 except (ValueError, PropertyError):
2015 below = False
2016 if below:
2017 floor = max(floor, physical)
2018 elif self.sealed:
2019 # No open path to the air: the floor stands in for gas the vent
2020 # lets back in, and there is no vent. As the liquid drains or the
2021 # gas cools, a sealed tank goes below atmosphere -- and holds the
2022 # liquid there by that vacuum. Grown with the ullage, the floor
2023 # refilled a sealed fuel tank with air as it drained, and the tank
2024 # emptied at 129 g/s through a drain driven by its 0.45 psi of
2025 # head, booking the air as clamp energy.
2026 floor = min(floor, self.state.ullage.mass)
2027 elif self.state.vapour_mass > 0.0:
2028 try:
2029 above = self.tank.pressure(self.state) > AMBIENT
2030 except (ValueError, PropertyError):
2031 above = False
2032 if above:
2033 # Vapour holds the tank above atmosphere: the floor only keeps
2034 # the pressurant from leaving (the vent takes vapour instead),
2035 # never grows it. Grown with a cooling wall -- an atmosphere of
2036 # air is heavier at 150 K than at 290 K -- it still added
2037 # ~0.03 g/s of air to a tank at 20 psig of its own boil-off.
2038 floor = min(floor, self.state.ullage.mass)
2039 mass = max(ullage.mass, floor)
2040 if mass == ullage.mass:
2041 return ullage
2042 # Air from nowhere is what lands above what the ullage held before the
2043 # step; holding it there against a vent is the floor's own job, and
2044 # air an open vent let in is inflow (:attr:`vent_drawn_kg`), not this.
2045 if not drawn_through_vent:
2046 self.floored_kg += max(mass - max(ullage.mass, self.state.ullage.mass), 0.0)
2047 # Mass was floored, so the energy that went with it has to be restated
2048 # or the specific energy jumps. In a built-in chill, at the wall: the
2049 # ullage the floor stands in for is the wall's boil-off. Otherwise at
2050 # the gas's own temperature, where the floor was priced.
2051 specific = self._specific_energy_at(mass, restate_at)
2052 if drawn_through_vent:
2053 grown = mass - ullage.mass
2054 self._drawn_piece = (grown, grown * float(specific))
2055 return VesselState(
2056 mass=mass,
2057 energy=specific * mass,
2058 wall_temperature=ullage.wall_temperature,
2059 )
2060
2061
2062@dataclass
2064 """A pressurant bottle: filled from GSE, then blowing down.
2065
2066 Starts **empty**, like everything else on the stand. A COPV that is already
2067 at 4500 psi when the app opens is the same lie as a tank that is already
2068 full: it skips the step where somebody has to decide to do it.
2069 """
2070
2071 id: str
2072 label: str
2073 volume: GasVolume
2074 state: VesselState
2075 node: str
2076 filling: bool = False
2077 venting: bool = False
2078 target: float = from_psig(4500.0)
2079 fill_seconds: float = 9.7
2080 #: Temperature of the cart's gas [K]; the session copies it in each tick.
2081 fill_supply_T: float = FILL_SUPPLY_T
2082 charged: bool = False
2083 """Whether this bottle has ever been filled. A note saying a bottle is
2084 "down to" fifteen psi is wrong before anybody has put gas in it -- it is
2085 not down to anything, it is where it started."""
2086 peak: float = 0.0
2087 """The highest pressure it has held since it was last charged or primed
2088 [Pa abs]: what a "down to" note is down from. A bottle climbing through its
2089 charge is not down to anything."""
2090 #: Ledger for the solver tab, cumulative [kg]: what the built-in charge
2091 #: put in, what the built-in dump took out beyond the network's draw, and
2092 #: what the floor at atmosphere added back.
2093 added_kg: float = 0.0
2094 dumped_kg: float = 0.0
2095 fixed_kg: float = 0.0
2096 #: Maximum allowable working pressure [Pa], 0 if the drawing gives none.
2097 mawp: float = 0.0
2098
2099 @property
2100 def pressure(self) -> float:
2101 return float(self.volume.pressure(self.state))
2102
2103 @property
2104 def fraction(self) -> float:
2105 """How full, against the target it is being filled to."""
2106 return min(max(self.pressure / self.target, 0.0), 1.0) if self.target else 0.0
2107
2109 self,
2110 dt: float,
2111 *,
2112 mdot_out: float,
2113 stirring: float = 1.0,
2114 mdot_in: float = 0.0,
2115 enthalpy_in: float = 0.0,
2116 ) -> None:
2117 """``mdot_in`` is gas the drawing itself delivers (a cart drawn on the
2118 GSE page, charging through its own valves); zero for a bottle whose
2119 GSE is not drawn, which is charged by ``filling`` instead."""
2120 if self.pressure > 2.0 * AMBIENT:
2121 self.charged = True
2122 # The most it has held since the charge it is on: a charge starts what
2123 # a later "down to" is down from. Kept from a charge before a dump, a
2124 # recharge paused part way read "down to 781 psia, 19% of the 4,018
2125 # psia it was filled to" of a bottle that had only gained since.
2126 self.peak = self.pressure if self.filling else max(self.peak, self.pressure)
2127 # The GSE cart doing the filling is not on the drawing yet, so the fill
2128 # is commanded by the state at a rate the operator sets rather than
2129 # solved. Everything after the bottle -- the regulator, the tanks, the
2130 # engine -- is solved.
2131 if self.filling and self.pressure < self.target:
2132 full = self.volume.initial_state(
2133 pressure=self.target, temperature=self.state.wall_temperature
2134 )
2135 span = max(self.fill_seconds, 1e-3)
2136 added = min(full.mass * dt / span, full.mass - self.state.mass)
2137 if added > 0.0:
2138 self.added_kg += added
2139 # Gas arrives from a bank at ambient temperature and at least
2140 # the target pressure: its enthalpy is the bank's, whatever
2141 # the bottle's wall has warmed to. The bottle still heats --
2142 # an adiabatic charge from empty lands near gamma times the
2143 # supply temperature -- but it no longer feeds on its own
2144 # warmth through the supply term.
2145 enthalpy = self.volume.fluid.get(
2146 "h", p=self.target, T=self.fill_supply_T
2147 )
2148 # And what the network draws while it charges leaves in the
2149 # same step: dm/dt = charge - draw. Returning with the charge
2150 # alone let a press valve opened during GN2 High Press fill a
2151 # tank from gas the bottle never lost (290 g on LE4 (3)).
2152 rates = self.volume.rates(
2153 self.state,
2154 mdot_in=added / dt,
2155 enthalpy_in=enthalpy,
2156 mdot_out=max(mdot_out, 0.0),
2157 stirring=stirring,
2158 )
2159 self.state = self.volume.step(self.state, rates, dt)
2160 return
2161
2162 drawn = max(mdot_out, 0.0)
2163 # The built-in dump empties the bottle to the air and no further: a
2164 # bottle at atmosphere has nothing to push out of an open valve, and
2165 # the last step takes only what stands above it. Taken at a ninth of
2166 # the gas a second whatever the bottle held, it carried a dumped COPV
2167 # under the air every step for the floor to put back.
2168 if self.venting:
2169 excess = self.state.mass - self._air(self.state).mass
2170 if excess > 0.0 and dt > 0.0:
2171 dump = min(self.state.mass / max(self.fill_seconds, 1e-3), excess / dt)
2172 mdot_out = max(mdot_out, dump)
2173
2174 if self.state.mass <= 1e-6 and mdot_in <= 0.0:
2175 return
2176 self.dumped_kg += (max(mdot_out, 0.0) - drawn) * dt
2177 if mdot_in > 0.0:
2178 rates = self.volume.rates(
2179 self.state,
2180 mdot_out=max(mdot_out, 0.0),
2181 mdot_in=mdot_in,
2182 enthalpy_in=enthalpy_in,
2183 stirring=stirring,
2184 )
2185 else:
2186 rates = self.volume.rates(self.state, mdot_out=max(mdot_out, 0.0))
2187 stepped = self.volume.step(self.state, rates, dt)
2188 floored = self._at_atmosphere(stepped)
2189 self.fixed_kg += floored.mass - stepped.mass
2190 self.state = floored
2191
2192 def _air(self, state: VesselState) -> VesselState:
2193 """The bottle holding an atmosphere at the gas's own temperature (the
2194 wall's, for a state with no gas to read one from)."""
2195 try:
2196 gas_T = (
2197 self.volume.temperature(state)
2198 if state.mass > 1e-9
2199 else state.wall_temperature
2200 )
2201 except (ValueError, PropertyError):
2202 gas_T = state.wall_temperature
2203 return self.volume.initial_state(pressure=AMBIENT, temperature=gas_T)
2204
2205 def _at_atmosphere(self, stepped: VesselState) -> VesselState:
2206 """``stepped``, or an atmosphere at the gas's own temperature if it went
2207 below one: a bottle open to the air does not go under it.
2208
2209 The energy is restated with the mass, as :meth:`TankSim._settled`
2210 does. Floored at the wall's density with the energy left as it was,
2211 every floored step put gas back with none of its heat, the gas cooled,
2212 and a COPV dumped at an abort read 14.4 psia.
2213 """
2214 air = self._air(stepped)
2215 if stepped.mass >= air.mass:
2216 return stepped
2217 return VesselState(
2218 mass=air.mass,
2219 energy=air.energy,
2220 wall_temperature=stepped.wall_temperature,
2221 )
2222
2223
2224@dataclass(frozen=True, slots=True)
2225class Trip:
2226 """A vessel over the pressure its drawing rates it for: why the stand stopped.
2227
2228 :attr:`Session.tripped` says it in words for the panel; this says it in
2229 numbers for a caller that has to act on it -- a burn that must stop at the
2230 trip rather than integrate the frozen frame to its horizon.
2231 """
2232
2233 vessel: str
2234 """Drawing id of the vessel."""
2235 label: str
2236 kind: str
2237 """``tank`` or ``bottle``."""
2238 t: float
2239 """Session clock at the step that found it [s]."""
2240 pressure: float
2241 """Vessel pressure then, absolute [Pa]."""
2242 limit: float
2243 """The absolute pressure it trips at [Pa]: the drawing's MAWP, or its burst
2244 pressure over :attr:`Setup.burst_safety_factor`, plus the gauge zero."""
2245
2246
2247@dataclass
2249 """One instant of the session, as the app draws it."""
2250
2251 t: float
2252 state: str
2253 pressures: Mapping[str, float]
2254 temperatures: Mapping[str, float]
2255 """Node temperature [K] at this instant.
2256
2257 Recorded per sample rather than read off the live network when a plot asks,
2258 because a history has to carry what the stand *was*, not what it is now --
2259 the same reason pressures are here."""
2260 flows: Mapping[str, float]
2261 signals: Mapping[str, float]
2262 converged: bool
2263 tanks: Mapping[str, dict[str, float]]
2264 chamber: ChamberResult | None = None
2265 balance: MixtureBalance | None = None
2266 notes: tuple[str, ...] = ()
2267
2268
2270 """A stand, live. Built once, ticked forever.
2271
2272 The state machine commands valves, the valves decide what is connected, the
2273 network decides the flows and the vessels decide what that does to their
2274 contents. Nothing here scripts a pressure curve; every number on screen is
2275 the consequence of the previous tick.
2276 """
2277
2279 self,
2280 model: Model,
2281 machine: StateMachine,
2282 binding: Binding,
2283 *,
2284 state: str = "Idle",
2285 setup: Setup | None = None,
2286 hookup: Hookup | None = None,
2287 ) -> None:
2288 self.id = uuid.uuid4().hex[:12]
2289 self.model = model
2290 self.machine = machine
2291 self.binding = binding
2292 #: Which knob sets which regulator (feedtwin.session.hookup). None: the
2293 #: one dome knob drives what it always drove, exactly as before.
2294 self.hookup = hookup
2295 #: Each knob's setting [psig], by knob id; the ``dome`` knob is
2296 #: ``setup.dome_psi`` and is not kept here.
2297 self.knobs: dict[str, float] = (
2298 {k.id: k.psig for k in hookup.knobs} if hookup is not None else {}
2299 )
2300 self.state = state if state in machine.states else machine.states[0]
2301 #: Built on the vehicle alone, the GSE cut away (Setup.ignore_gse,
2302 #: assemble_model(vehicle_only=...)). The setup says what the stand is.
2303 self.gse_ignored = bool(model.meta.get("vehicle_only"))
2304 self.setup = setup or Setup()
2305 if self.setup.ignore_gse != self.gse_ignored:
2306 self.setup = replace(self.setup, ignore_gse=self.gse_ignored)
2307 self.forced: dict[str, float] = {}
2308 #: Where each actuator actually is, as opposed to where it is told to be.
2309 self._positions: dict[str, float] = {}
2310 #: The pressure shut in each gated dome (DomeLine), by the loaded
2311 #: regulator's id [Pa abs]; atmosphere until something loads it.
2312 self._dome_held: dict[str, float] = {}
2313 #: Network node -> pressure for the dome lines, written over the solve's
2314 #: own (it never sees a dome line: loading gas, not feed).
2315 self._dome_readings: dict[str, float] = {}
2316 #: Prime asks for the dome as loaded on the pad, not as it was.
2317 self._dome_primed = False
2318 self._travel: dict[str, float] = {}
2319 #: The frame on display: the last one integrated, and the one a tripped
2320 #: stand holds.
2321 self._shown: Sample | None = None
2322 #: Why the stand stopped, if it has: a vessel over its MAWP. Cleared
2323 #: only by opening a new stand -- there is no un-bursting a tank.
2324 self.tripped: str | None = None
2325 #: The same trip in numbers; set with :attr:`tripped`, never cleared.
2326 self.trip: Trip | None = None
2327 #: Set when Fire ended by a tank running dry (see _burnout_check).
2328 self.burnout: str | None = None
2329 #: Stand time of the last step the engine made thrust; None: never.
2330 #: Fire with nothing lit is a cold flow, not a burn (_burnout_check,
2331 #: _notes).
2332 self._lit_at: float | None = None
2333 #: Stand time the current state was entered at (command_state).
2334 self._state_since = 0.0
2335 self.t = 0.0
2336 self.wall = time.monotonic()
2337 self.history: Deque[Sample] = deque(maxlen=HISTORY)
2338 #: The solver tab: one record per tick (feedtwin.session.diagnostics).
2339 self.solver_log: Deque[SolverRecord] = deque(maxlen=HISTORY)
2340 #: Laws the stand cannot break, checked each tick (session.watch).
2342 self._tick: dict[str, float] = {}
2343 self._boundary: frozenset[str] | None = None
2344 self._crossed_in = 0.0
2345 self._crossed_out = 0.0
2346 self._inventory0: float | None = None
2347 self._ledger0 = (0.0, 0.0, 0.0)
2348 self._guard_J0 = 0.0
2349 self.assumptions: list[str] = []
2350 self._last_flows: dict[str, float] = {}
2351 #: Node pressures of the last converged solve: what a failed one holds
2352 #: (:meth:`_held`).
2353 self._last_pressures: dict[str, float] = {}
2354 # Warm start. Consecutive ticks are 50 ms apart and the network barely
2355 # moves between them, so starting each solve from the previous answer is
2356 # both faster and far more robust: cold-starting a network whose bottle
2357 # is at 4500 psi and whose tanks are at 14.7 sends Newton into a basin
2358 # where a node comes back at minus two thousand bar.
2359 self._guess: dict[str, float] = {}
2360 #: Fractional vessel-pressure change over the last tick, and the dt it
2361 #: took. Together they set how finely the next tick has to interleave
2362 #: solving and integrating -- see MAX_COUPLED_CHANGE.
2363 self._last_change = 0.0
2364 self._last_dt = 1.0
2365 #: Which branches were shut last solve. When this changes the network is
2366 #: a different circuit, and a warm start taken from the old one is not a
2367 #: starting point -- it is a guess about a system that no longer exists.
2368 self._last_isolated: frozenset[str] = frozenset()
2369 #: The ground support resting this step while the engine burns
2370 #: (Setup.ground_rests, _rest_ground):
2371 #: its branches, out of the solve, and its vessels' ids, not integrated.
2372 self._resting: tuple[frozenset[str], frozenset[str]] = (
2373 frozenset(),
2374 frozenset(),
2375 )
2376 self._fire_loads: dict[str, float] | None = None
2377 self._resting_memo: tuple[
2378 frozenset[str] | None, tuple[frozenset[str], frozenset[str]]
2379 ] = (None, self._resting)
2380 #: Each ullage's stiffest reachable regulator slope, per shut set and
2381 #: boundaries (_regulator_slopes).
2382 self._regulator_memo: tuple[object, dict[str, float]] = (None, {})
2383 self._chamber_guess = AMBIENT
2384 self._last_chamber: ChamberResult | None = None
2385 # A cold chamber is open to atmosphere through its own nozzle, so pin
2386 # the boundary there before the first solve rather than leaving the
2387 # drawing's design chamber pressure sitting on it.
2388 #
2389 # The ENGINE symbol's `pressure` is the boundary to use when there is no
2390 # engine *model* on the end -- the honest "nobody has said what is on the
2391 # end of the pipe" case. With a model attached, `_chamber()` overwrites
2392 # it on the first tick anyway; leaving it until then meant the frame the
2393 # UI paints on open showed the whole downstream leg at design chamber
2394 # pressure on a stand holding nothing.
2395 #: The chamber boundary node, and what the drawing says it runs at.
2396 #:
2397 #: Found from the drawing rather than from `engine_ports`, which is only
2398 #: populated once an engine *model* is attached -- and the cold-stand
2399 #: problem is worse without one, because then nothing ever overwrites the
2400 #: drawing's design pressure.
2401 # Built before the chamber, because a wall takes its starting
2402 # temperature from the node its line hangs off and those are seeded from
2403 # the drawing at build time.
2404 self._build_line_walls()
2406 self._design_chamber = AMBIENT
2407 net_nodes = self.model.built.network.nodes
2408 # With an engine attached the chamber is a node the engine built
2409 # (`ENG.chamber`); without one it is the ENGINE symbol's own node. The
2410 # drawing symbol's id is *not* a network node in the first case, which is
2411 # why this asks `engine_ports` first rather than only walking the drawing.
2412 candidates = [self.model.built.engine_ports.get("chamber", "")]
2413 candidates += [
2414 self.model.built.node_of.get(symbol.id, symbol.id)
2415 for symbol in self.model.diagram.nodes
2416 if symbol.type in ("ENGINE", "INJECTOR")
2417 ]
2418 for anchor in candidates:
2419 if anchor and anchor in net_nodes:
2420 self._chamber_node = anchor
2421 self._design_chamber = net_nodes[anchor].pressure or AMBIENT
2422 net_nodes[anchor].pressure = AMBIENT
2423 break
2424 # Pressure held in a section that is shut off at both ends. The solver
2425 # reports such a node as its live neighbour's pressure and says so via
2426 # indeterminate_dead_ends -- correct as an upper bound, wrong as a
2427 # reading. A transducer on a trapped line reads what was trapped in it,
2428 # which on a stand that has not been pressurised yet is atmosphere.
2429 self._trapped: dict[str, float] = {}
2430
2431 self.tanks: dict[str, TankSim] = {}
2432 self.bottles: dict[str, BottleSim] = {}
2433 self._build_vessels()
2435 # The fill valves whose free end is that hose: out of the solve. The
2436 # stub is pinned at the tank's own pressure so nothing moves through it,
2437 # but a line with height still trickled liquid from it into the outlet
2438 # -- ~0.7 g/s on the Ethalox Stand's LOX fill, a second copy of the
2439 # commanded load that the tank refused, read as mass unaccounted for
2440 # (2026-10-12). The commanded rate is the fill; the hose passes nothing.
2441 net = self.model.built.network
2442 self._fill_hoses: frozenset[str] = frozenset(
2443 branch_id
2444 for branch_id, branch in net.branches.items()
2445 if branch.upstream in self._fill_stubs
2446 or branch.downstream in self._fill_stubs
2447 )
2448 #: Vessels whose fill the drawing itself carries: a GSE page with the
2449 #: cart's bottle or dewar plumbed to them. Their built-in fill (the
2450 #: tanker load, the cart's COPV charge) steps aside and the network
2451 #: fills them, or they would be filled twice. Empty on a drawing with
2452 #: no GSE drawn, and then nothing changes.
2453 self._drawn_fill: frozenset[str] = self._find_drawn_fills()
2454 #: Network nodes on the dome line of each dome-loaded regulator whose
2455 #: loader went with the GSE, by regulator id: they read the dome. Empty
2456 #: unless the GSE is ignored.
2457 self._dome_ports: dict[str, frozenset[str]] = (
2458 self._find_cut_dome_ports() if self.gse_ignored else {}
2459 )
2460 #: The vehicle's half of each such dome line, where valves are drawn on
2461 #: it: the cart's dome supply stays plugged in at the capped disconnect
2462 #: (the knob), and the rocket's valves gate it as a drawn line's do.
2463 self._cut_dome_lines: dict[str, DomeLine] = (
2464 self._find_cut_dome_lines() if self.gse_ignored else {}
2465 )
2466 if self.gse_ignored:
2467 raw_cut = model.meta.get("ground_cut")
2468 cut = [str(c) for c in raw_cut] if isinstance(raw_cut, list) else []
2469 self.assumptions.append(
2470 "The drawn GSE is ignored (Configuration: Ignore the drawn GSE): "
2471 + (
2472 f"{', '.join(cut)} and the rest of the cart are not simulated"
2473 if cut
2474 else "this drawing has no GSE off the vehicle to cut"
2475 )
2476 + ". The bottle charge, the loads and the dome are the GSE "
2477 "Controls settings, as on a drawing of the rocket alone."
2478 )
2479 # Vehicle tanks a cart tank loads through the drawing: the only tanks
2480 # that take liquid in at their outlet.
2481 # Both ends of a transfer line: liquid that sloshes back into the cart's
2482 # tank is propellant too, and dropping it lost mass every step.
2483 self._liquid_fed: frozenset[str] = frozenset(
2484 t for fed in self.model.built.supplies.values() for t in fed
2485 ) | frozenset(self.model.built.supplies)
2486 # The valve the crew shuts when a drawn load is in: the first one on the
2487 # transfer line from the cart's tank. Shut once, when the tank reaches
2488 # its load, as the built-in load stops at its full fraction.
2489 # ...and the branches of that line, which stay open to a dry tank:
2490 # it is filled through them (`_dry_branches`).
2491 self._fill_lines: dict[str, frozenset[str]] = {}
2492 self._fill_stops: dict[str, str] = self._find_fill_stops()
2493 self._fill_stopped: set[str] = set()
2494 self._sealed_cache: dict[frozenset[str], frozenset[str]] = {}
2495 #: What the crew last did with each transfer valve (open or shut), so a
2496 #: hand on the P&ID between changes is left alone.
2497 self._fill_crew: dict[str, bool] = {}
2498 # Shut from the start, not from the first tick: a valve that slews shut
2499 # over its travel lets a primed flight tank drain back into the cart.
2500 for valve in self._fill_stops.values():
2501 self._fill_crew[valve] = False
2502 self.forced[valve] = 0.0
2503 self._positions[valve] = 0.0
2504 #: Pressure relief valves (comps.relief) and where each one is: open or
2505 #: shut (its hysteresis), and its lift as the signal the solve reads.
2506 #: Empty on a drawing with no relief, and then nothing below runs.
2507 self._reliefs: list[tuple[str, Any]] = [
2508 (branch_id, branch)
2509 for branch_id, branch in self.model.built.network.branches.items()
2510 if getattr(branch.component, "type", "") == "relief_valve"
2511 ]
2512 self._relief_open: dict[str, bool] = {}
2513 self._relief_lift: dict[str, float] = {}
2514
2515 # ------------------------------------------------------------ building
2516
2517 @property
2518 def ground(self) -> frozenset[str]:
2519 """Drawing ids of the ground support: everything off the vehicle
2520 (:func:`feedtwin.pid.roles.vehicle_ids`). Empty for a drawing that is
2521 one piece."""
2522 return _ground(self.model)
2523
2524 def fire_loads(self) -> dict[str, float]:
2525 """Propellant a fire is loaded with, per vehicle tank [kg].
2526
2527 The engine's ``fire_load`` (its config's ``lox_tank.mass`` and
2528 ``fuel_tank.mass``) on the tank of that side. What the vehicle carries
2529 is fixed by the competition, not by the size of the tank drawn, so the
2530 pad loads this and T-0 starts with it. Empty without an engine that
2531 states one: then loads fill the tank to its full fraction.
2532 """
2533 if self._fire_loads is None:
2534 load = getattr(self.model.engine, "fire_load", None) or {}
2535 self._fire_loads = {
2536 tank_id: float(load[side])
2537 for tank_id in self.vehicle_tanks
2538 if (side := propellant_side(self.tanks[tank_id].tank.liquid.name))
2539 in load
2540 }
2541 return self._fire_loads
2542
2543 def short_loads(self) -> list[str]:
2544 """Each vehicle tank too small for the engine's fire load, said.
2545
2546 A load stops at the tank's full fraction (``TankSim._wanted``), so a
2547 drawn tank that cannot hold its fire load is loaded short, silently,
2548 and every burn on the stand is that much shorter than the engine was
2549 designed for. Empty when every tank holds its load.
2550 """
2551 out: list[str] = []
2552 for tank_id, kg in self.fire_loads().items():
2553 sim = self.tanks[tank_id]
2554 fraction = self.setup.full_fraction
2555 held = (
2556 sim.tank.geometry.total_volume
2557 * fraction
2558 * sim.tank.liquid_density(sim.state)
2559 )
2560 if held < kg - 0.005:
2561 out.append(
2562 f"{sim.label} holds {held:.2f} kg at its {fraction:.0%} fill, "
2563 f"under the engine's {kg:.2f} kg fire load: a fire is loaded "
2564 f"with {held:.2f} kg. A bigger tank on the drawing, or a "
2565 "higher full fraction (Configuration), loads the rest."
2566 )
2567 return out
2568
2569 @property
2570 def vehicle_tanks(self) -> tuple[str, ...]:
2571 """The tanks the engine burns from: every tank, less the ground's."""
2572 ground = self.ground
2573 return tuple(t for t in self.tanks if t not in ground)
2574
2575 def _find_drawn_fills(self) -> frozenset[str]:
2576 """The vessels the drawing fills (:func:`drawn_fills`), each said: its
2577 built-in fill steps aside."""
2578 labels = {n.id: n.label or n.id for n in self.model.diagram.nodes}
2579 tanks, bottles = drawn_fills(self.model)
2580 for tank_id, supply in tanks.items():
2581 self.assumptions.append(
2582 f"{labels.get(tank_id, tank_id)} is loaded through the drawing "
2583 f"(from {labels.get(supply, supply)}): the built-in tanker load is off."
2584 )
2585 for bottle_id, supply in bottles.items():
2586 self.assumptions.append(
2587 f"{labels.get(bottle_id, bottle_id)} is charged through the drawing "
2588 f"(from {labels.get(supply, supply)}): the cart's built-in charge is off."
2589 )
2590 return frozenset({*tanks, *bottles})
2591
2592 @property
2593 def builtin(self) -> dict[str, str]:
2594 """The table's rows this stand acts on by name with no valve wired
2595 (:func:`builtin_rows`), on the fills it found."""
2596 return builtin_rows(self.machine, self.model, self._drawn_fill)
2597
2598 def _cut_dome_regulators(self) -> list[PidNode]:
2599 """The dome-loaded regulators whose loader is not on the drawing: it
2600 was on the cart and went with it (:attr:`Setup.ignore_gse`)."""
2601 built = self.model.built
2602 loaded = {loader.signal for loader in built.dome_loaders.values()}
2603 return [
2604 node
2605 for node in self.model.diagram.nodes
2606 if node.type == "PR" and node.options.get("domeLoaded") == "yes"
2607 # A loader still drawn sets it, or it was never built.
2608 and built.actuators.get(node.id, "") not in loaded | {""}
2609 ]
2610
2611 def _find_cut_dome_ports(self) -> dict[str, frozenset[str]]:
2612 """The network nodes a dome-loaded regulator's dome line reaches, for
2613 each one with no loader left on the drawing: its loader was on the cart
2614 and went with it (:attr:`Setup.ignore_gse`). The line is no plumbing,
2615 so the solve leaves it at atmosphere; its transducer reads the dome the
2616 knob sets. Walked from the ``dome`` handle through junctions and
2617 instruments, stopping at any valve, regulator, disconnect or vessel."""
2618 built = self.model.built
2619 diagram = self.model.diagram
2620 by_id = {n.id: n for n in diagram.nodes}
2621 adjacent: dict[str, set[str]] = {}
2622 for edge in diagram.edges:
2623 adjacent.setdefault(edge.source, set()).add(edge.target)
2624 adjacent.setdefault(edge.target, set()).add(edge.source)
2625 stop = INLINE_TYPES | SOURCE_TYPES | BOUNDARY_TYPES
2626 out: dict[str, frozenset[str]] = {}
2627 for node in self._cut_dome_regulators():
2628 frontier = [
2629 e.target if e.source == node.id else e.source
2630 for e in diagram.edges
2631 if (e.source == node.id and e.source_handle == DOME_HANDLE)
2632 or (e.target == node.id and e.target_handle == DOME_HANDLE)
2633 ]
2634 seen: set[str] = set()
2635 while frontier:
2636 here = frontier.pop()
2637 if here in seen or here not in by_id or here == node.id:
2638 continue
2639 seen.add(here)
2640 if by_id[here].type not in stop:
2641 frontier.extend(adjacent.get(here, ()))
2642 places = {
2643 built.node_of[sid]
2644 for sid in seen
2645 if by_id[sid].type not in stop and sid in built.node_of
2646 } | {i.node for i in built.instruments if i.id in seen}
2647 places &= set(built.network.nodes)
2648 if places:
2649 out[node.id] = frozenset(places)
2650 return out
2651
2652 def _find_cut_dome_lines(self) -> dict[str, DomeLine]:
2653 """Each cut dome line (:meth:`_find_cut_dome_ports`) with a valve drawn
2654 on the vehicle's half of it, as a :class:`DomeLine` whose loader is the
2655 capped disconnect the cart's dome supply came in through.
2656
2657 The dome line is loading gas off the cart, and like the cart's vents
2658 it stays plugged into the rocket while the stand is on it: rocket only,
2659 the dome comes in at the knob through that disconnect, and the
2660 rocket's own dome valve decides whether it reaches the regulator.
2661 :meth:`_gated_dome` walks it as it walks a drawn line, so a valve no
2662 state-table row drives passes and the dome follows the knob exactly as
2663 before; one a row drives loads the dome only in the states that open
2664 it, and holds what was shut in otherwise. Walked from the ``dome``
2665 handle through junctions, valves and instruments to one disconnect;
2666 a line that reaches none, or more than one, is left as it was."""
2667 built = self.model.built
2668 diagram = self.model.diagram
2669 by_id = {n.id: n for n in diagram.nodes}
2670 adjacent: dict[str, set[str]] = {}
2671 for edge in diagram.edges:
2672 adjacent.setdefault(edge.source, set()).add(edge.target)
2673 adjacent.setdefault(edge.target, set()).add(edge.source)
2674 barrier = INLINE_TYPES - {"MAN", "ROT", "SOL", "MOV"} | SOURCE_TYPES
2675 barrier |= BOUNDARY_TYPES
2676 out: dict[str, DomeLine] = {}
2677 for regulator in (node.id for node in self._cut_dome_regulators()):
2678 ports = frozenset(
2679 e.target if e.source == regulator else e.source
2680 for e in diagram.edges
2681 if (e.source == regulator and e.source_handle == DOME_HANDLE)
2682 or (e.target == regulator and e.target_handle == DOME_HANDLE)
2683 )
2684 seen: set[str] = set()
2685 couplings: set[str] = set()
2686 frontier = list(ports)
2687 while frontier:
2688 here = frontier.pop()
2689 if here in seen or here not in by_id or here == regulator:
2690 continue
2691 seen.add(here)
2692 if by_id[here].type == "QD":
2693 couplings.add(here)
2694 continue
2695 if by_id[here].type not in barrier:
2696 frontier.extend(adjacent.get(here, ()))
2697 valves = frozenset(
2698 sid for sid in seen if sid in built.actuators and sid not in couplings
2699 )
2700 if len(couplings) != 1 or not valves:
2701 continue
2702 lines_on = {
2703 sid: sum(1 for e in diagram.edges if sid in (e.source, e.target))
2704 for sid in valves
2705 }
2706 out[regulator] = DomeLine(
2707 loaded=regulator,
2708 loader=next(iter(couplings)),
2709 adjacent={
2710 sid: frozenset(o for o in adjacent.get(sid, ()) if o in seen)
2711 for sid in seen
2712 },
2713 ports=ports,
2714 valves=valves,
2715 vents=frozenset(v for v in valves if lines_on[v] == 1),
2716 )
2717 return out
2718
2719 def _build_vessels(self) -> None:
2720 """Turn the drawing's tanks and bottles into integrable vessels.
2721
2722 Tanks start **empty and at atmosphere** whatever the drawing's pressure
2723 field says. That field is a design pressure, not a starting condition,
2724 and starting a simulator at the answer is the thing that made the last
2725 one useless.
2726 """
2727 by_id = {n.id: n for n in self.model.diagram.nodes}
2728 net = self.model.built.network
2729
2730 for drawing_id, ports in self.model.built.tanks.items():
2731 node = by_id.get(drawing_id)
2732 if node is None:
2733 continue
2734 volume = node.params.get("volume")
2735 diameter = node.params.get("diameter")
2736 litres = volume.si if volume is not None else 0.0175
2737 bore = diameter.si if diameter is not None else 0.1524
2738 if diameter is None:
2739 self.assumptions.append(
2740 f"{node.label} diameter: {bore * 1e3:.0f} mm assumed (not on the "
2741 "drawing); sets the liquid column height and outlet head."
2742 )
2743
2744 species = net.nodes[ports.outlet].fluid
2745 temperature = net.nodes[ports.outlet].temperature
2746 # The ullage node carries the pressurant, so the drawing already
2747 # says what is pushing on this propellant. Hard-coding nitrogen here
2748 # made a helium stand quietly a nitrogen one.
2749 pressurant = Fluid(net.nodes[ports.ullage].fluid)
2750 geometry = _tank_geometry(litres, bore)
2751 wall_mass, wall_capacity, wall_conductance = _vessel_wall(
2752 node,
2753 litres,
2754 self._tank_wall_defaults(),
2755 self.assumptions,
2756 estimate=self._film_estimator(pressurant, node, geometry),
2757 )
2758 tank = Tank(
2759 Fluid(species),
2760 pressurant,
2761 _tank_geometry(litres, bore),
2762 collapse=None if self.setup.ullage_collapse else NoCollapse(),
2763 vapour=SaturatedVapour() if self.setup.ullage_vapour else NoVapour(),
2764 wall_mass=wall_mass,
2765 wall_capacity=wall_capacity,
2766 wall_conductance=wall_conductance,
2767 wetted_conductance=self.setup.chilldown,
2768 nucleate_conductance=self.setup.chilldown_nucleate,
2769 leidenfrost_superheat=self.setup.leidenfrost_K,
2770 boiling_onset=self.setup.boiling_onset_K,
2771 surface_layer=(
2772 self.setup.surface_layer_m if self.setup.stratification else 0.0
2773 ),
2774 surface_mixing=self.setup.surface_mixing,
2775 wall_by_level=self.setup.ullage_wall_by_level,
2776 # A dewar is vacuum-jacketed: its inner vessel takes no skin
2777 # leak worth the name (and the drawing gives it no insulation
2778 # to read one from).
2779 ambient_conductance=(
2780 0.0
2781 if getattr(node, "drawn_as", "") == "DEWAR"
2782 else _skin_conductance(
2783 node, self.setup.ambient_leak, self.assumptions
2784 )
2785 ),
2786 ambient_temperature=self.setup.ambient_T,
2787 # Boiling at a superheated wall is what makes a shut LOX tank
2788 # climb; it needs the vapour somewhere to go.
2789 wall_boiling=self.setup.ullage_vapour and self.setup.wall_boiling,
2790 )
2791 limit = _trip_limit(node, self.setup.burst_safety_factor, self.assumptions)
2792 label = node.label or drawing_id
2793 try:
2794 start = tank.initial_state(
2795 pressure=AMBIENT,
2796 liquid_mass=0.0,
2797 liquid_temperature=temperature,
2798 gas_temperature=293.15,
2799 )
2800 except ValueError as exc:
2801 # A propellant tank starts from its saturated liquid, which does
2802 # not exist above the critical point. A COPV drawn with the tank
2803 # symbol lands here -- nitrogen at 293 K -- and CoolProp's "rhoV
2804 # is invalid" reached the API as a 500 naming nothing.
2805 raise AssemblyError(
2806 "tank", _not_a_liquid_tank(label, species, temperature, exc)
2807 ) from exc
2808 self.tanks[drawing_id] = TankSim(
2809 id=drawing_id,
2810 label=label,
2811 tank=tank,
2812 node=node,
2813 # Gauge, like every number an operator reads: 1000 psi on the
2814 # drawing is what the tank's own gauge would show.
2815 mawp=limit,
2816 state=start,
2817 ullage_node=ports.ullage,
2818 outlet_node=ports.outlet,
2819 full_fraction=self.setup.full_fraction,
2820 )
2821 if drawing_id in self.ground:
2822 # A cart tank arrives filled -- a fuel transfer tank is loaded
2823 # in the shop and pressed on the pad -- and it is where the
2824 # vehicle's load comes from, not another load.
2825 sim = self.tanks[drawing_id]
2826 loaded = sim._wanted()
2827 # A dewar sits at its own pressure, built by its own circuit,
2828 # with its liquid at saturation under it; a transfer tank is
2829 # filled in the shop and pressed on the pad.
2830 dewar = getattr(node, "drawn_as", "") == "DEWAR"
2831 drawn = node.params.get("pressure")
2832 held = drawn.si if (dewar and drawn is not None) else AMBIENT
2833 sim.state = tank.initial_state(
2834 pressure=held,
2835 liquid_mass=loaded,
2836 liquid_temperature=temperature,
2837 # The ullage is the model's pressurant, not the dewar's own
2838 # vapour; started cold it sits on nitrogen's saturation
2839 # line, where the property layer cannot price it.
2840 gas_temperature=293.15,
2841 contact_time=PAD_HOLD_S if dewar else 0.0,
2842 split_wall=dewar,
2843 )
2844 if dewar:
2845 # Its inner vessel has held its liquid for days: all of
2846 # the metal is at the liquid's temperature. Started warm,
2847 # the wall boiled the LOX and the dewar climbed 650 psi in
2848 # a minute.
2849 sim.state = replace(
2850 sim.state,
2851 ullage=replace(sim.state.ullage, wall_temperature=temperature),
2852 )
2853 self.assumptions.append(
2854 f"{label} is ground support: it starts loaded, "
2855 f"{loaded:.2f} kg ({self.setup.full_fraction:.0%} of its "
2856 f"{litres * 1e3:.1f} L, Setup), at "
2857 + (f"its drawn {say(held, 1)}." if dewar else "atmosphere.")
2858 )
2859
2860 for node in self.model.diagram.nodes:
2861 if node.type not in BOTTLE_TYPES or node.id not in net.nodes:
2862 continue
2863 volume = node.params.get("volume")
2864 pressure = node.params.get("pressure")
2865 temp_param = node.params.get("temperature")
2866 litres = (
2867 volume.si if volume is not None else self.setup.bottle_volume_L * 1e-3
2868 )
2869 if volume is None:
2870 self.assumptions.append(
2871 f"{node.label} volume: {litres * 1e3:.2f} L assumed -- the "
2872 "stand's 45 scf SCBA COPV (not on the drawing); sets the "
2873 "pressure droop."
2874 )
2875 wall_mass, wall_capacity, wall_conductance = _vessel_wall(
2876 node,
2877 litres,
2878 self._bottle_wall_defaults(),
2879 self.assumptions,
2880 estimate=self._film_estimator(
2881 Fluid(net.nodes[node.id].fluid), node, _bottle_geometry(litres)
2882 ),
2883 )
2884 gas = GasVolume(
2885 Fluid(net.nodes[node.id].fluid),
2886 litres,
2887 wall_mass=wall_mass,
2888 wall_capacity=wall_capacity,
2889 wall_conductance=wall_conductance,
2890 )
2891 # The drawing's pressure is what a full bottle reads. A delivered
2892 # bottle starts there, at the temperature the drawing gives it;
2893 # one charged on the pad starts empty, at atmosphere, and the
2894 # drawing's pressure seeds the fill target instead.
2895 rated = pressure.si if pressure else 4500.0 * PSI
2896 ambient_T = temp_param.si if temp_param is not None else 293.15
2897 # A cart's K-bottle is delivered full; only the vehicle's own
2898 # bottle is charged on the pad (`Setup.bottle_delivered`).
2899 # (A dewar holds liquid, which a gas volume cannot start with; it
2900 # keeps the old start until it is modelled as the liquid supply it
2901 # is.)
2902 delivered = bool(self.setup.bottle_delivered) or (
2903 node.id in self.ground and node.type == "KBOTTLE"
2904 )
2905 self.bottles[node.id] = BottleSim(
2906 id=node.id,
2907 label=node.label or node.id,
2908 volume=gas,
2909 state=gas.initial_state(
2910 pressure=rated if delivered else AMBIENT,
2911 temperature=ambient_T,
2912 ),
2913 node=node.id,
2914 # The fill target is the panel's; with none set, the drawing's
2915 # own rated pressure. (`from_psig(0)` is an atmosphere, never
2916 # falsy, so `or rated` here never fired.)
2917 target=(
2918 from_psig(self.setup.copv_target_psi)
2919 if self.setup.copv_target_psi > 0.0
2920 else rated
2921 ),
2922 fill_seconds=self.setup.copv_fill_s,
2923 charged=delivered,
2924 mawp=_trip_limit(
2925 node, self.setup.burst_safety_factor, self.assumptions
2926 ),
2927 )
2928
2929 # ------------------------------------------------------------- commands
2930
2932 self,
2933 *,
2934 fill_fraction: float = 0.95,
2935 tank_psi: float = 500.0,
2936 copv_psi: float = 0.0,
2937 state: str = "Ready",
2938 hold_s: float = PAD_HOLD_S,
2939 loads: Mapping[str, float] | None = None,
2940 ) -> None:
2941 """Put the stand where it is at T-0, without rehearsing the pad.
2942
2943 ``hold_s`` is how long the tanks have been loaded [s]. It sets two
2944 things the thermal models are exquisitely sensitive to and that
2945 ``initial_state`` cannot know: the interface contact time, whose
2946 collapse flux goes as ``1/sqrt(t)`` and is fifty kilowatts at a
2947 millisecond; and the wall temperature, which on a cryogenic tank has
2948 chilled to its contents by the time anybody fires. Built as if freshly
2949 filled -- wall at room temperature, interface a moment old -- a LOX
2950 tank with collapse and chilldown enabled collapsed from 550 to 411 psig
2951 during the study's own settle and read as a violent drop and recovery
2952 at ignition. That was the initial condition, not the burn.
2953
2954 Loading and pressing through the sequence is the honest way to get here
2955 and is what the console does. It is the wrong way to *study* a burn,
2956 because pressing the tanks draws on the same bottle the study is about:
2957 an under-sized COPV then fails for two reasons at once and the trace
2958 cannot tell you which.
2959
2960 This sets the initial condition directly -- tanks loaded to
2961 ``fill_fraction`` and sitting at ``tank_psi``, bottle at ``copv_psi`` --
2962 so the burn starts from a stated, reproducible state and the only thing
2963 being asked is whether the bottle can hold the tanks up.
2964
2965 ``loads`` gives the propellant mass [kg] for the tanks it names, in
2966 place of ``fill_fraction`` -- for a vehicle whose load is fixed by mass
2967 (a competition rule, a measured fill) rather than by a level. A load
2968 that would not fit the tank is refused, not clipped.
2969 """
2970 loads = dict(loads or {})
2971 unknown = sorted(set(loads) - set(self.tanks))
2972 if unknown:
2973 raise ValueError(
2974 f"loads names {', '.join(unknown)}, which this drawing has no "
2975 f"tank for (tanks: {', '.join(sorted(self.tanks)) or 'none'})"
2976 )
2977 ground = self.ground
2978 for tank_id, sim in self.tanks.items():
2979 if tank_id in ground and tank_id not in loads:
2980 continue # the cart's tank: T-0 is the vehicle's, not its
2981 capacity = sim.tank.geometry.total_volume * fill_fraction
2982 rho = sim.tank.liquid.get("rho", T=sim.state.liquid_temperature, q=0.0)
2983 if tank_id in loads:
2984 volume = loads[tank_id] / rho
2985 if not 0.0 < volume < sim.tank.geometry.total_volume:
2986 raise ValueError(
2987 f"{tank_id}: {loads[tank_id]:.3f} kg is "
2988 f"{volume * 1e3:.2f} L of liquid in a "
2989 f"{sim.tank.geometry.total_volume * 1e3:.2f} L tank"
2990 )
2991 capacity = volume
2992 # A tank that has been loaded for `hold_s`: the wall under the
2993 # liquid has chilled to it, the wall above the ullage has not, and
2994 # the interface has been there the whole time. One lumped wall
2995 # cannot say that -- at liquid temperature it condenses the
2996 # pressurant on the ullage face, at ambient it boils the liquid --
2997 # so the primed tank carries two.
2998 sim.state = sim.tank.initial_state(
2999 pressure=from_psig(tank_psi),
3000 liquid_mass=capacity * rho,
3001 liquid_temperature=sim.state.liquid_temperature,
3002 gas_temperature=293.15,
3003 contact_time=max(hold_s, 0.0),
3004 split_wall=True,
3005 )
3006 upper = self._ullage_wall_T0(tank_id, sim)
3007 if upper is not None:
3008 # Only the metal: the gas is as pressed, and the two exchange
3009 # heat from the first step (Setup.ullage_wall_T0_K).
3010 sim.state = replace(
3011 sim.state,
3012 ullage=replace(sim.state.ullage, wall_temperature=upper),
3013 )
3014 note = (
3015 f"{sim.label} upper wall at T-0: {upper:.1f} K (Setup), "
3016 "not the pressurant's 293.15 K."
3017 )
3018 if note not in self.assumptions:
3019 self.assumptions.append(note)
3020 sim.empty = False
3021 for bottle in self.bottles.values():
3022 if bottle.id in ground:
3023 continue # a cart bottle keeps what it holds
3024 if copv_psi > 0.0:
3025 bottle.state = bottle.volume.initial_state(
3026 pressure=from_psig(copv_psi), temperature=293.15
3027 )
3028 # Set, not reached: what a "down to" note is down from starts
3029 # here. Kept from an earlier charge, a bottle primed under it
3030 # read "down to 415 psia, 10% of the 4,015 psia it was filled
3031 # to" of gas nobody had taken out of it.
3032 bottle.peak = bottle.pressure
3033 bottle.charged = True
3034 if state in self.machine.states and state != self.state:
3035 self.state = state
3036 self._state_since = self.t
3037 # T-0 has the dome loaded on the pad, whatever the line's valves are
3038 # doing when the session is put there.
3039 self._dome_primed = True
3040 self._guess = {}
3041 self._last_flows = {}
3042 self._last_pressures = {}
3043 self._last_isolated = frozenset()
3044 # The inventory was set, not reached: the mass balance starts here.
3045 self.reset_balance()
3046
3047 def _ullage_wall_T0(self, tank_id: str, sim: TankSim) -> float | None:
3048 """The upper-wall T-0 temperature the setup asks of this tank [K], or
3049 None for the previous behaviour (the pressurant's temperature)."""
3050 named = self.setup.ullage_wall_T0_K.get(tank_id)
3051 if named is not None and named > 0.0:
3052 return float(named)
3053 cryogen = self.setup.cryogen_ullage_wall_T0_K
3054 if cryogen > 0.0 and sim.state.liquid_temperature < CRYOGENIC_K:
3055 return float(cryogen)
3056 return None
3057
3058 def _loader_supply(self, loader: object) -> float:
3059 """Pressure feeding a dome control regulator [Pa].
3060
3061 The bottle it hangs off, read from state rather than from the last
3062 solve: the dome has to be known *before* the network is solved, and a
3063 stale node pressure from the previous tick would put the dome one step
3064 behind the supply it is supposed to track.
3065 """
3066 ground = self.ground
3067 for bottle in sorted(self.bottles.values(), key=lambda b: b.id in ground):
3068 if bottle.charged:
3069 return float(bottle.pressure)
3070 return 0.0
3071
3073 self, gas: Fluid, node: object, geometry: CylindricalTank
3074 ) -> Callable[[], GasFilm] | None:
3075 """A thunk for the still-gas film of one vessel, or None when the
3076 setup says to keep the per-litre default."""
3077 if not self.setup.wall_hA_from_gas:
3078 return None
3079 params = getattr(node, "params", {}) or {}
3080 nominal = params.get("pressure")
3081 pressure = float(nominal.si) if nominal is not None else 500.0 * PSI
3082 d_t = self.setup.wall_hA_dT
3083
3084 def estimate() -> GasFilm:
3085 return still_gas_conductance(
3086 gas,
3087 pressure,
3088 self.setup.ambient_T,
3089 geometry.height,
3090 geometry.wetted_area(geometry.height),
3091 d_t,
3092 )
3093
3094 return estimate
3095
3096 def _tank_wall_defaults(self) -> dict[str, Any]:
3097 return {
3098 "kg_per_litre": self.setup.tank_wall_kg_per_L,
3099 "capacity": self.setup.tank_wall_capacity,
3100 "hA_ref": (self.setup.tank_wall_hA, 17.5),
3101 "basis": TANK_WALL["basis"],
3102 }
3103
3104 def _bottle_wall_defaults(self) -> dict[str, Any]:
3105 return {
3106 "kg_per_litre": self.setup.bottle_wall_kg_per_L,
3107 "capacity": BOTTLE_WALL["capacity"],
3108 "hA_ref": (self.setup.bottle_wall_hA, 4.687),
3109 "basis": BOTTLE_WALL["basis"],
3110 }
3111
3112 def apply_thermal(self) -> None:
3113 """Push the thermal knobs in ``setup`` onto vessels already built.
3114
3115 The tanks were constructed with whatever the setup said at the time;
3116 the console changes it while the stand runs, and a knob that only
3117 took effect on the next session was a knob that did not work.
3118 """
3119 for sim in self.tanks.values():
3120 tank = sim.tank
3121 tank.collapse = (
3122 ConductionCollapse() if self.setup.ullage_collapse else NoCollapse()
3123 )
3124 tank.vapour = SaturatedVapour() if self.setup.ullage_vapour else NoVapour()
3125 tank.wetted_conductance = float(self.setup.chilldown)
3126 tank.nucleate_conductance = float(self.setup.chilldown_nucleate)
3127 tank.leidenfrost_superheat = float(self.setup.leidenfrost_K)
3128 tank.boiling_onset = float(self.setup.boiling_onset_K)
3129 tank.surface_mixing = float(self.setup.surface_mixing)
3130 tank.wall_by_level = bool(self.setup.ullage_wall_by_level)
3131 tank.surface_layer = (
3132 float(self.setup.surface_layer_m) if self.setup.stratification else 0.0
3133 )
3134 # A layer switched on mid-run starts at the bulk; switched off, the
3135 # state drops it and the liquid is well mixed from here.
3136 if tank.surface_layer > 0.0 and sim.state.surface_temperature is None:
3137 sim.state = replace(
3138 sim.state, surface_temperature=sim.state.liquid_temperature
3139 )
3140 elif (
3141 tank.surface_layer <= 0.0 and sim.state.surface_temperature is not None
3142 ):
3143 sim.state = replace(sim.state, surface_temperature=None)
3144 tank.ambient_conductance = _skin_conductance(
3145 sim.node, float(self.setup.ambient_leak), []
3146 )
3147 tank.ambient_temperature = float(self.setup.ambient_T)
3148 tank.wall_boiling = bool(
3149 self.setup.ullage_vapour and self.setup.wall_boiling
3150 )
3151
3152 def command_state(self, state: str) -> None:
3153 if state not in self.machine.states:
3154 raise ValueError(f"no state {state!r}")
3155 if not self.machine.can_go(self.state, state):
3156 raise PermissionError(
3157 f"{self.state} cannot go to {state}. From here: "
3158 f"{', '.join(self.machine.targets(self.state))}"
3159 )
3160 self.state = state
3161 self._state_since = self.t
3162 # A transition writes every actuator the table knows, the way the
3163 # DAQ does, so a valve taken by hand goes back to the table's command
3164 # here. Holds used to outlive the state forever: the table opened Fuel
3165 # Main and LOX Press in *Idle* (see NEEDS-REPAIR.md; the machine now
3166 # holds Idle shut), an operator shut them by hand because that was
3167 # plainly wrong for a cold stand, and from then on Ox Press pressed
3168 # nothing and Fire opened no main -- with no sign of why beyond a
3169 # small HELD badge. Valves the table never commands keep whatever
3170 # the hand set.
3171 for symbol in self.binding.to_symbol.values():
3172 self.forced.pop(symbol, None)
3173
3174 @property
3175 def operator_held(self) -> list[str]:
3176 """Valves a person has taken from the state table, by drawing id.
3177
3178 :attr:`forced` holds those, and also the twin's own crew on a drawn
3179 load -- the transfer valve it shuts at the start and opens for the
3180 load. Shown as the operator's "1 held" on a stand nobody had touched,
3181 that read as a bug (the operator, 2026-10-09). A crew valve a person
3182 has since turned the other way is the person's again.
3183 """
3184 crew = {
3185 valve
3186 for valve, opened in self._fill_crew.items()
3187 if self.forced.get(valve) == (1.0 if opened else 0.0)
3188 }
3189 return sorted(v for v in self.forced if v not in crew)
3190
3191 def set_valve(self, drawing_id: str, is_open: bool) -> None:
3192 self.forced[drawing_id] = 1.0 if is_open else 0.0
3193
3194 def release(self, drawing_id: str = "") -> None:
3195 """Hand a valve back to the state table; with no id, every valve a
3196 person took (:attr:`operator_held`) -- not the crew's on a drawn load,
3197 which would open the transfer line out of turn."""
3198 if drawing_id:
3199 self.forced.pop(drawing_id, None)
3200 else:
3201 for valve in self.operator_held:
3202 self.forced.pop(valve, None)
3203
3204 def skip_chilldown(self, tank_id: str = "") -> list[str]:
3205 """Chill a cryogen tank's wall now rather than wait for the load to.
3206
3207 A load spends minutes chilling the metal before anything collects --
3208 ~5 on LE4 at the calibrated dewar valve, ~10 on the stand -- and nobody
3209 rehearsing waits that out (the team, 2026-10-08). Each vehicle tank, or
3210 the one named, gets :meth:`TankSim.skip_chill`; the cart's own vessels
3211 are supplies and are left alone. The assumptions say so, so a burn's
3212 record shows its pad was cut short. Returns the labels of the tanks it
3213 chilled: none when every wall is already cold.
3214 """
3215 if tank_id and tank_id not in self.tanks:
3216 raise KeyError(f"no tank {tank_id!r}")
3217 ground = self.ground
3218 chilled: list[str] = []
3219 for sim in self.tanks.values():
3220 if (tank_id and sim.id != tank_id) or sim.id in ground:
3221 continue
3222 flashed = sim.chill_boiled
3223 if not sim.skip_chill():
3224 continue
3225 chilled.append(sim.label)
3226 self.assumptions.append(
3227 f"Chilldown skipped on {sim.label}: wall put at "
3228 f"{sim.state.ullage.wall_temperature:.0f} K, where the load's chill "
3229 f"takes it, without the {(sim.chill_boiled - flashed) * 1e3:.0f} g "
3230 "of propellant that chill flashes off."
3231 )
3232 return chilled
3233
3234 # ---------------------------------------------------------------- signals
3235
3236 def signals(self, dt: float = 0.0) -> dict[str, float]:
3237 """What the components read this tick: the state, then hand overrides.
3238
3239 Valve positions *slew* toward their commanded value rather than
3240 snapping to it. A main valve that goes shut-to-open in zero time is a
3241 step input into an ullage of half a litre, and the tank pressure dip it
3242 produces is a property of the step, not of the stand: the drop comes
3243 out several times deeper than the pad shows, and it arrives before the
3244 chamber has lit, so the injector sees full tank pressure against
3245 ambient and pulls flow no real start ever pulls. Each valve carries a
3246 `travel_time`; this is the one place it can be honoured, because the
3247 state machine deals in commanded positions and knows nothing of time.
3248 """
3249 built = self.model.built
3250 out: dict[str, float] = {}
3251 commanded = self.binding.positions_for(self.machine, self.state)
3252 lines = {line.loader: line for line in built.dome_lines.values()}
3253 self._dome_readings = {}
3254
3255 # The dome, through the regulator that actually loads it. `dome_psi` is
3256 # the knob on the *control* regulator, not the dome pressure itself:
3257 # that reg hangs off the same bottle as the one it controls, so its
3258 # outlet -- and with it the dome, and with it every tank on the stand --
3259 # rides up as the bottle blows down. Writing the knob straight onto the
3260 # dome pins it flat and silently switches the supply-pressure effect off
3261 # for the whole stand, which is the wrong sign to guess at and the one
3262 # people do guess at.
3263 if self.hookup is not None:
3264 # Each knob sets its own regulators: a loader through its own
3265 # outlet, as below; anything else through its dome signal, which a
3266 # plain regulator reads as its setpoint.
3267 labels = {n.id: n.label or n.id for n in self.model.diagram.nodes}
3268 for knob in self.hookup.knobs:
3269 psig_set = (
3270 self.setup.dome_psi
3271 if knob.id == DOME
3272 else (
3273 self.setup.copv_target_psi
3274 if knob.id == CHARGE
3275 else self.knobs.get(knob.id, knob.psig)
3276 )
3277 )
3278 for regulator in knob.regulators:
3279 loader = built.dome_loaders.get(regulator)
3280 if loader is not None:
3281 out[loader.signal] = self._gated_dome(
3282 lines.get(regulator),
3283 self._through_loader(loader, psig_set),
3284 commanded,
3285 )
3286 elif regulator in labels:
3287 out[f"{labels[regulator]}.dome"] = from_psig(psig_set)
3288 else:
3289 for loader in built.dome_loaders.values():
3290 out[loader.signal] = self._gated_dome(
3291 lines.get(loader.id),
3292 self._through_loader(loader, self.setup.dome_psi),
3293 commanded,
3294 )
3295 if not built.dome_loaders:
3296 dome_signal = next(
3297 (s for s in built.actuators.values() if s.endswith(".dome")), ""
3298 )
3299 if dome_signal:
3300 out[dome_signal] = from_psig(self.setup.dome_psi)
3301
3302 # A dome line whose loader went with the GSE: gated by the rocket's own
3303 # valves on it where it has any, then read as the dome it holds.
3304 for regulator, line in self._cut_dome_lines.items():
3305 signal = built.actuators[regulator]
3306 if signal in out:
3307 out[signal] = self._gated_dome(line, out[signal], commanded)
3308 for regulator, places in self._dome_ports.items():
3309 dome = out.get(built.actuators[regulator])
3310 if dome is not None:
3311 self._dome_readings.update(dict.fromkeys(places, dome))
3312
3313 self._dome_primed = False
3314 for drawing_id, signal in built.actuators.items():
3315 if signal.endswith(".dome"):
3316 continue
3317 # Uncommanded, a valve rests where the drawing puts it: an actuated
3318 # valve at its unpowered position, a hand valve where the build
3319 # read it (`BuiltNetwork.rest`). Shut when the drawing says nothing.
3320 target = (
3321 self.forced[drawing_id]
3322 if drawing_id in self.forced
3323 else commanded.get(drawing_id, built.rest.get(drawing_id, 0.0))
3324 )
3325 out[signal] = self._slew(drawing_id, target, dt)
3326 return out
3327
3328 def peek_signals(self, *, loaded_dome: bool = False) -> dict[str, float]:
3329 """What the components would read now, moving nothing.
3330
3331 :meth:`signals` is the step's: it slews each valve toward its command
3332 (a zero ``dt`` snaps it there), spends the T-0 dome prime, and records
3333 what each dome line holds. A readout must do none of that -- the
3334 lockup under the dome knob, asked for on every console tick, snapped
3335 every valve to its command mid-travel, so the console's mains opened
3336 in one tick whatever their travel time.
3337
3338 ``loaded_dome``: read each dome line as open to its loader, the dome
3339 the knob sets -- not what a shut line holds (atmosphere, in Idle).
3340 """
3341 positions = dict(self._positions)
3342 held = dict(self._dome_held)
3343 readings = dict(self._dome_readings)
3344 primed = self._dome_primed
3345 self._dome_primed = primed or loaded_dome
3346 try:
3347 return self.signals()
3348 finally:
3349 self._positions.clear()
3350 self._positions.update(positions)
3351 self._dome_held.clear()
3352 self._dome_held.update(held)
3353 self._dome_readings = readings
3354 self._dome_primed = primed
3355
3357 self, line: DomeLine | None, live: float, commanded: Mapping[str, float]
3358 ) -> float:
3359 """The dome a gated dome line gives: its loader's outlet while the line
3360 is open between them, atmosphere once an open vent reaches the port
3361 side, and otherwise what was shut in [Pa abs].
3362
3363 Walked from the dome port each tick, through what is open. A valve no
3364 actuator drives passes (nothing on the stand shuts it); a vent valve
3365 no actuator drives, and a hand valve nobody has opened, rest shut.
3366 """
3367 if line is None:
3368 return live
3369 built = self.model.built
3370
3371 def is_open(valve: str) -> bool:
3372 if valve in self.forced:
3373 return self.forced[valve] >= 0.5
3374 if valve in commanded:
3375 return commanded[valve] >= 0.5
3376 if valve in built.hand_valves or valve in line.vents:
3377 return built.rest.get(valve, 0.0) >= 0.5
3378 return True
3379
3380 def region(starts: frozenset[str] | set[str]) -> set[str]:
3381 reached: set[str] = set()
3382 frontier = list(starts)
3383 while frontier:
3384 here = frontier.pop()
3385 if here in reached:
3386 continue
3387 reached.add(here)
3388 if here in line.valves and not is_open(here):
3389 continue # shut: its body is reached, nothing past it
3390 frontier.extend(line.adjacent.get(here, ()))
3391 return reached
3392
3393 port_side = region(line.ports)
3394 if line.loader in port_side or self._dome_primed:
3395 held = live
3396 elif any(v in port_side and is_open(v) for v in line.vents):
3397 held = AMBIENT
3398 else:
3399 held = self._dome_held.get(line.loaded, AMBIENT)
3400 self._dome_held[line.loaded] = held
3401 # What the line's transducers read: the dome on its side, the loader's
3402 # outlet on the loader's side of a shut valve.
3403 loader_side = region({line.loader}) - port_side
3404 for symbols, value in ((port_side, held), (loader_side, live)):
3405 for symbol in symbols:
3406 for node in (
3407 built.node_of.get(symbol),
3408 f"{symbol}.in",
3409 f"{symbol}.out",
3410 ):
3411 if node and node in built.network.nodes:
3412 self._dome_readings[node] = value
3413 for instrument in built.instruments:
3414 for symbols, value in ((port_side, held), (loader_side, live)):
3415 if any(
3416 built.node_of.get(sym) == instrument.node
3417 or instrument.node in (f"{sym}.in", f"{sym}.out")
3418 for sym in symbols
3419 ):
3420 self._dome_readings[instrument.node] = value
3421 return held
3422
3423 def _through_loader(self, loader: DomeLoader, psig_set: float) -> float:
3424 """The dome a control regulator set to ``psig_set`` gives, from its
3425 supply now [Pa]."""
3426 built = self.model.built
3427 supply = self._loader_supply(loader)
3428 if supply <= 0.0 or not isinstance(loader.component, Regulator):
3429 return from_psig(psig_set)
3430 conditions = built.network.conditions(
3431 loader.supply_node,
3432 supply,
3433 {f"{loader.component.id}.dome": from_psig(psig_set)},
3434 )
3435 return float(loader.component.outlet_setpoint(0.0, conditions))
3436
3437 def _slew(self, drawing_id: str, target: float, dt: float) -> float:
3438 """Move one actuator toward ``target`` at its own travel rate."""
3439 current = self._positions.get(drawing_id, target)
3440 if dt <= 0.0:
3441 self._positions[drawing_id] = target
3442 return target
3443 travel = self._travel_time(drawing_id)
3444 step = 1.0 if travel <= 0.0 else dt / travel
3445 if abs(target - current) <= step:
3446 current = target
3447 else:
3448 current += step if target > current else -step
3449 self._positions[drawing_id] = current
3450 return current
3451
3452 def _travel_time(self, drawing_id: str) -> float:
3453 """Shut-to-open time for one actuator [s], off the drawing."""
3454 cached = self._travel.get(drawing_id)
3455 if cached is not None:
3456 return cached
3457 seconds = self.setup.valve_travel_s
3458 for branch_id in self.model.built.branches_of.get(drawing_id, ()):
3459 branch = self.model.built.network.branches.get(branch_id)
3460 component = getattr(branch, "component", None)
3461 if component is not None:
3462 found = component.p.get("travel_time", 0.0)
3463 if found > 0.0:
3464 seconds = float(found)
3465 break
3466 self._travel[drawing_id] = seconds
3467 return seconds
3468
3469 # ------------------------------------------------------------------ tick
3470
3471 def _through_outlets(self) -> frozenset[str]:
3472 """Dry tanks whose outlet other lines pass through, by tank id.
3473
3474 An unsized tee on a tank's outlet line is joined into the outlet node
3475 (an unsized line is a direct connection), so on LE4 (6) the cart's
3476 transfer tank outlet *is* the tee between the fill line and the dump.
3477 Cutting every branch on a dry tank's outlet (:meth:`_dry_branches`) cut
3478 the fill line from the dump with it: Eth-Tank sat at 45 psig with the
3479 dump open and nothing solved. A dry tank's outlet with two or more lines
3480 on it -- and not being loaded through them -- is a junction instead: no
3481 pressure of its own (:meth:`_apply_vessel_pressures`), nothing taken
3482 from or given to the empty tank, flow passing between the lines.
3483 """
3484 net = self.model.built.network
3485 out: set[str] = set()
3486 for sim in self.tanks.values():
3487 if sim.state.liquid_mass > DRY_MASS or self._loading(sim):
3488 continue
3489 lines = sum(
3490 1
3491 for branch in net.branches.values()
3492 if sim.outlet_node in (branch.upstream, branch.downstream)
3493 and sim.ullage_node not in (branch.upstream, branch.downstream)
3494 )
3495 if lines >= 2:
3496 out.add(sim.id)
3497 return frozenset(out)
3498
3499 def _dry_branches(self) -> frozenset[str]:
3500 """Feed branches leaving a tank with nothing left in it."""
3501 net = self.model.built.network
3502 out: set[str] = set()
3503 through = self._through_outlets()
3504 for sim in self.tanks.values():
3505 if sim.state.liquid_mass > DRY_MASS or sim.id in through:
3506 continue
3507 filling = self._fill_lines.get(sim.id, frozenset())
3508 # The line it is loaded through stays open to a dry tank in its fill
3509 # state, and only then. Always open, an open branch flows both
3510 # ways: with a dump open on the far side (LE4's FD-ROT-G, which
3511 # nothing commands and rests open) the dry tank "drained" through
3512 # it at ~0.5 kg/s for as long as the stand ran, the vessel's floor
3513 # re-making every gram -- 20 kg of propellant from nothing in 40 s.
3514 # Not on the last solve's pressures: the two sides of a dry tank's
3515 # idle line sit within a hair of each other, the line flipped every
3516 # step, and a circuit that changes every step drops its flows every
3517 # step (a topped LOX tank's vent with them). Nor on the transfer
3518 # valve held open by hand, which is that drain again. A dry tank is
3519 # loaded through the drawing in its fill state.
3520 loading = self._loading(sim)
3521 for branch_id, branch in net.branches.items():
3522 if sim.outlet_node not in (branch.upstream, branch.downstream):
3523 continue
3524 if branch_id in filling and loading:
3525 continue
3526 out.add(branch_id)
3527 return frozenset(out)
3528
3529 def _firing(self) -> bool:
3530 """Propellant reached the engine on the last solve."""
3531 ports = self.model.built.engine_ports
3532 return any(
3533 self._last_flows.get(ports.get(side, ""), 0.0) > 0.0
3534 for side in ("oxidiser", "fuel")
3535 )
3536
3537 def _rest_ground(self, signals: Mapping[str, float]) -> None:
3538 """Decide which ground support rests this step (``self._resting``).
3539
3540 Nothing rests here unless the engine is burning, the drawing has
3541 ground support, and :attr:`Setup.ground_rests` is on. (A cart vessel
3542 with nothing flowing rests at any time: :meth:`_move_vessels`.)
3543 """
3544 nothing: tuple[frozenset[str], frozenset[str]] = (frozenset(), frozenset())
3545 if not self.setup.ground_rests or not self.ground or not self._firing():
3546 self._resting = nothing
3547 return
3548 net = self.model.built.network
3549 shut = frozenset(net.isolated(signals)) | self._dry_branches()
3550 key, value = self._resting_memo
3551 if key != shut:
3552 value = self._unreachable_ground(shut)
3553 self._resting_memo = (shut, value)
3554 self._resting = value
3555
3557 self, shut: frozenset[str]
3558 ) -> tuple[frozenset[str], frozenset[str]]:
3559 """Ground branches and vessels with no open path to the vehicle.
3560
3561 Walked from the vehicle's vessels and the engine across every branch
3562 not ``shut``. A tank is one place, so arriving at either of its nodes
3563 reaches both; any other fixed node -- atmosphere -- ends the walk, or
3564 every vent would join the cart to the vehicle through the sky.
3565 """
3566 net = self.model.built.network
3567 ground = self.ground
3568 sibling: dict[str, str] = {}
3569 for sim in self.tanks.values():
3570 sibling[sim.ullage_node] = sim.outlet_node
3571 sibling[sim.outlet_node] = sim.ullage_node
3572 vessels = set(sibling) | {b.node for b in self.bottles.values()}
3573 seeds = {
3574 node
3575 for sim in self.tanks.values()
3576 if sim.id not in ground
3577 for node in (sim.ullage_node, sim.outlet_node)
3578 }
3579 seeds |= {b.node for b in self.bottles.values() if b.id not in ground}
3580 chamber = self.model.built.engine_ports.get("chamber")
3581 if chamber:
3582 seeds.add(chamber)
3583 adjacent: dict[str, list[str]] = {}
3584 for branch_id, branch in net.branches.items():
3585 if branch_id in shut:
3586 continue
3587 adjacent.setdefault(branch.upstream, []).append(branch.downstream)
3588 adjacent.setdefault(branch.downstream, []).append(branch.upstream)
3589 reached = set(seeds)
3590 stack = list(seeds)
3591 while stack:
3592 here = stack.pop()
3593 if here in sibling and sibling[here] not in reached:
3594 reached.add(sibling[here])
3595 stack.append(sibling[here])
3596 for there in adjacent.get(here, ()):
3597 if there in reached:
3598 continue
3599 if net.nodes[there].is_fixed and there not in vessels:
3600 continue # atmosphere: a sink, not a path
3601 reached.add(there)
3602 stack.append(there)
3603 branches = frozenset(
3604 branch_id
3605 for branch_id, branch in net.branches.items()
3606 if branch_id not in shut
3607 and branch.upstream not in reached
3608 and branch.downstream not in reached
3609 )
3610 resting = {
3611 sim.id
3612 for sim in self.tanks.values()
3613 if sim.id in ground and sim.ullage_node not in reached
3614 }
3615 resting |= {
3616 b.id
3617 for b in self.bottles.values()
3618 if b.id in ground and b.node not in reached
3619 }
3620 return branches, frozenset(resting)
3621
3623 self,
3624 signals: Mapping[str, float] | None = None,
3625 dt: float = 0.0,
3626 press: bool = True,
3627 ) -> float:
3628 """Shortest ullage RC time constant on the stand [s], or 0.
3629
3630 ``C = V rho / p`` is the isothermal gas capacitance of an ullage -- how
3631 much mass it takes to raise its pressure by a pascal. Two resistances
3632 are paired with it, and the shorter product is what the coupling step
3633 has to resolve.
3634
3635 The regulator's slope, ``flow_droop / rated_flow``: how many pascals
3636 its outlet gives up per kg/s drawn. That is the regulator-ullage loop.
3637
3638 And the ullage's own press path (:meth:`_press_path_timescale`): what
3639 the flow into the tank does when the *tank* moves, with the outlet node
3640 next door held. Two tanks on one press manifold trade gas through
3641 nothing but their solenoids, and on helium that is a few milliseconds
3642 where the regulator's is tens. Stepped past it, each solve sent gas the
3643 wrong way through a solenoid for a whole coupling step: the tank fell
3644 15-30 psi in a quarter of a hundredth of a second, the other dumped
3645 into it, and every LE4 helium burn plotted a 0.5-0.8 s sawtooth that
3646 cost 170 N of mean thrust (2026-10-05).
3647 """
3648 resting_branches, resting = self._resting
3649 net = self.model.built.network
3650 shut = (
3651 frozenset(net.isolated(signals)) | self._dry_branches() | resting_branches
3652 )
3653 slopes = self._regulator_slopes(shut)
3654 tau = float("inf")
3655 for sim in self.tanks.values():
3656 if sim.id in resting:
3657 continue
3658 p = sim.pressure
3659 if p <= 0.0:
3660 continue
3661 volume = sim.tank.ullage_volume(sim.state)
3662 if volume <= 0.0:
3663 continue
3664 capacitance = sim.state.ullage.mass / p # V rho / p, with V rho = m
3665 resistance = slopes.get(sim.ullage_node, 0.0)
3666 if resistance > 0.0:
3667 tau = min(tau, capacitance * resistance)
3668 if press:
3669 tau = min(
3670 tau, self._press_path_timescale(sim, capacitance, signals, dt)
3671 )
3672 return tau if tau < float("inf") else 0.0
3673
3674 def _regulator_slopes(self, shut: frozenset[str]) -> dict[str, float]:
3675 """Each ullage's stiffest regulator slope, ``flow_droop / rated_flow``
3676 [Pa/(kg/s)], over the regulators it is joined to.
3677
3678 Walked from the ullage across every branch not ``shut``, never on
3679 through another boundary (a vessel, the sky). A regulator the tank
3680 has no open path to is no loop with it: every regulator on the stand
3681 used to be paired with every ullage, so a LOX tank topped in Ox Fill,
3682 its press valve shut, was stepped at the cart's fuel regulator's time
3683 constant -- three solves every 20 ms for a loop that did not exist.
3684 Remembered per shut set and boundaries, as ``Network.dead_ends`` is.
3685 """
3686 net = self.model.built.network
3687 fixed = frozenset(n for n, node in net.nodes.items() if node.is_fixed)
3688 key = (shut, fixed)
3689 memo_key, memo = self._regulator_memo
3690 if memo_key == key:
3691 return memo
3692 adjacent: dict[str, list[tuple[str, str]]] = {}
3693 for branch_id, branch in net.branches.items():
3694 if branch_id in shut:
3695 continue
3696 adjacent.setdefault(branch.upstream, []).append(
3697 (branch_id, branch.downstream)
3698 )
3699 adjacent.setdefault(branch.downstream, []).append(
3700 (branch_id, branch.upstream)
3701 )
3702 slope_of: dict[str, float] = {}
3703 for branch_id, branch in net.branches.items():
3704 comp = getattr(branch, "component", None)
3705 if comp is None or "Regulator" not in type(comp).__name__:
3706 continue
3707 droop = comp.p.get("flow_droop", 0.0)
3708 rated = comp.p.get("rated_flow", 0.0)
3709 if droop > 0.0 and rated > 0.0:
3710 slope_of[branch_id] = droop / rated
3711 out: dict[str, float] = {}
3712 for sim in self.tanks.values():
3713 start = sim.ullage_node
3714 reached = {start}
3715 stack = [start]
3716 found: list[float] = []
3717 while stack:
3718 here = stack.pop()
3719 for branch_id, there in adjacent.get(here, ()):
3720 if branch_id in slope_of:
3721 found.append(slope_of[branch_id])
3722 if there in reached:
3723 continue
3724 reached.add(there)
3725 if there not in fixed:
3726 stack.append(there)
3727 if found:
3728 out[start] = min(found)
3729 self._regulator_memo = (key, out)
3730 return out
3731
3733 self,
3734 sim: TankSim,
3735 capacitance: float,
3736 signals: Mapping[str, float] | None,
3737 dt: float = 0.0,
3738 ) -> float:
3739 """``C / G`` for one ullage while its tank is delivering liquid [s].
3740
3741 ``G`` is the ullage's admittance: the sum over the gas branches on it of
3742 ``1 / (d dp / d mdot)``, each slope taken at the flow the last solve put
3743 through it -- or at the flow the ullage needs to replace the liquid
3744 leaving it, if that is more. Counting only the branch on the tank and
3745 not what lies beyond it overstates ``G``, so it errs toward more steps.
3746
3747 Only while liquid is leaving. A tank at rest has no drain to outrun its
3748 press path, and at zero flow a quadratic loss has zero slope: the
3749 constant goes to zero and would spend the step limit on a pad hold that
3750 a few grams of slosh cannot disturb.
3751
3752 And only while the tank rides its supply -- within ``supply_band`` of
3753 the node feeding it, the band in which :meth:`TankSim.advance` clamps
3754 the inflow. That is where a step's drain carries the tank across its
3755 supply and the next solve sends its gas back out. A tank sitting
3756 clearly under its supply cannot be carried across, and gets nothing
3757 from the finer steps: the shipped GN2 stand burns 6-8 psi under its
3758 manifold and traced identically either way, at twice the cost.
3759
3760 And only while the drain is enough to matter: over the tick, more
3761 than :data:`PRESS_PATH_DRAIN_FRACTION` of that band. Below it the
3762 drain cannot carry the tank across its supply, and what is left is a
3763 nearly empty tank at an abort, with a trickle out and a trickle in --
3764 whose zero-flow slope asked for 0.3 ms steps and stalled the panel.
3765
3766 Infinity when there is nothing to measure.
3767 """
3768 net = self.model.built.network
3769 _, liquid_out = self._outlet_split(sim, self._last_flows)
3770 volume = sim.tank.ullage_volume(sim.state)
3771 if liquid_out <= 0.0 or volume <= 0.0:
3772 return float("inf")
3773 rho_liquid = max(sim.tank.liquid_density(sim.state), 1.0)
3774 # Isothermal: the ullage pressure falls as p Q / V while it is unfed.
3775 drained = sim.pressure * liquid_out / rho_liquid / volume * dt
3776 # The press side only: gas branches whose far end is a free node of
3777 # the network -- a press line, a manifold. A vent ends at atmosphere,
3778 # a fixed pressure; counted, a venting tank read as riding its supply
3779 # and its vent valve's slope at drain flow asked an Engine Abort for
3780 # 0.3 ms steps. Nor a branch that can carry nothing -- shut, or a stub
3781 # ending at a capped port: priced at the drain flow, the wide fitting
3782 # under LE4 (6)'s fuel-tank top QD asked the ignition step for 0.07 ms
3783 # steps, three hundred solves on the first tick of Fire.
3784 shut = frozenset(net.isolated(signals)) | self._dry_branches()
3785 dead = shut | {d.branch for d in net.dead_ends(exclude=shut)}
3786 paths = []
3787 for branch_id, branch in net.branches.items():
3788 if sim.ullage_node not in (branch.upstream, branch.downstream):
3789 continue
3790 if branch_id in dead:
3791 continue
3792 if sim.outlet_node in (branch.upstream, branch.downstream):
3793 continue # the liquid column, not a gas path
3794 far = (
3795 branch.upstream
3796 if branch.downstream == sim.ullage_node
3797 else branch.downstream
3798 )
3799 if net.nodes[far].pressure is None:
3800 paths.append((branch_id, branch))
3801 supply = max(
3802 (
3803 self._guess.get(
3804 (
3805 branch.upstream
3806 if branch.downstream == sim.ullage_node
3807 else branch.downstream
3808 ),
3809 0.0,
3810 )
3811 for _, branch in paths
3812 ),
3813 default=0.0,
3814 )
3815 band = self.setup.supply_band * supply
3816 if supply <= 0.0 or supply - sim.pressure > band:
3817 return float("inf")
3818 # Nor a "supply" at atmosphere: a manifold vented through another tank
3819 # is a vent one node further on, and nothing there can carry the tank
3820 # across a regulated supply. A fuel tank draining by gravity at an
3821 # Engine Abort with the LOX tank empty (its vent open on the manifold)
3822 # read 0.27 ms and ran the panel 40x slower than real time.
3823 if supply - AMBIENT <= band:
3824 return float("inf")
3825 if drained < PRESS_PATH_DRAIN_FRACTION * band:
3826 return float("inf")
3827 rho_gas = sim.state.ullage.mass / volume
3828 needed = rho_gas * liquid_out / rho_liquid
3829 admittance = 0.0
3830 for branch_id, branch in paths:
3831 upstream = self._guess.get(branch.upstream)
3832 if upstream is None or upstream <= 0.0:
3833 continue
3834 flow = max(abs(self._last_flows.get(branch_id, 0.0)), needed)
3835 if flow <= 0.0:
3836 continue
3837 step = flow * PRESS_PATH_FD
3838 try:
3839 conditions = net.conditions(
3840 branch.upstream, upstream, dict(signals or {})
3841 )
3842 slope = (
3843 branch.component.total_dp(flow + step, conditions)
3844 - branch.component.total_dp(flow - step, conditions)
3845 ) / (2.0 * step)
3846 except (
3847 Exception
3848 ): # noqa: BLE001 - a branch that cannot price it says nothing
3849 continue
3850 if slope > 0.0:
3851 admittance += 1.0 / slope
3852 return capacitance / admittance if admittance > 0.0 else float("inf")
3853
3854 def _vessel_pressures(self) -> dict[str, float]:
3855 out = {sim.id: sim.pressure for sim in self.tanks.values()}
3856 out.update({b.id: b.pressure for b in self.bottles.values()})
3857 return out
3858
3860 self, dt: float, isolated: frozenset[str]
3861 ) -> dict[str, tuple[float, float]]:
3862 """Each ullage's closure over a ``dt`` coupling step, for the solve.
3863
3864 ``{ullage node: (C / dt, reference)}`` (``solve_steady(storage=...)``):
3865 where the vessel lands with no gas exchanged -- liquid still leaving at
3866 the last solve's rate, the ullage collapsing, a load still pouring --
3867 and how many kg/s over the step it takes to move it a pascal from
3868 there. Both read off the vessel itself, by trial steps on a copy, so
3869 whatever it models (charge heating, collapse, vapour) is in the slope
3870 the network sees.
3871
3872 Without this the solve holds each tank at its start-of-step pressure,
3873 an explicit coupling of a tiny capacitance behind a huge conductance.
3874 LE4's 0.41 L ullages behind Cv 4 press solenoids flip-flopped through
3875 their shared manifold every coupling step: one tank 5 psi high dumping
3876 40 g/s, the other taking 100 g/s and refusing nearly all of it at its
3877 supply clip, the regulator drooping at a phantom 60 g/s and both tanks
3878 parked 25 psi under lockup for as long as the valves stayed open.
3879
3880 The slope taken is the stiffer of gas in (at the arriving enthalpy)
3881 and gas out: over-stating the vessel's stiffness only damps the
3882 closure, under-stating it hands back some of the explicit coupling.
3883 A tank cut off by shut valves gets none and stays a fixed boundary.
3884
3885 Except an ullage draining to the sky (:meth:`_venting`), which is
3886 closed on its gas-out slope alone. There the flow is one way and
3887 steady, so the slope is not a damping: the node lands at
3888 ``reference - q dt / slope`` while the vessel lands where its own
3889 slope puts it, and the stiffer one opens a gap between them that
3890 grows with the step. Over boiling LOX the two differ eightfold --
3891 warm gas arriving compresses the ullage, gas leaving is replaced by
3892 flash boil-off -- and a topped tank on its vent sat 4 psi above the
3893 node its vent flowed from, at 7.3 psig where finer coupling found
3894 3.4. A vent ends at a fixed pressure, so there is no second vessel
3895 across it to flip-flop with.
3896 """
3897 out: dict[str, tuple[float, float]] = {}
3898 if dt <= 0.0:
3899 return out
3900 net = self.model.built.network
3901 # A stub carries nothing either, and the solve drops a store whose
3902 # every branch is one (``_stores``), so its trial steps would be
3903 # thrown away: on a stand at rest, most of them.
3904 cut = set(isolated) | {d.branch for d in net.dead_ends(exclude=isolated)}
3905 for sim in self.tanks.values():
3906 node = sim.ullage_node
3907 touching = [
3908 (branch_id, sim.outlet_node in (branch.upstream, branch.downstream))
3909 for branch_id, branch in net.branches.items()
3910 if node in (branch.upstream, branch.downstream)
3911 ]
3912 live = any(
3913 branch_id not in isolated for branch_id, inner in touching if not inner
3914 ) and any(branch_id not in cut for branch_id, _ in touching)
3915 held = sim.state.ullage.mass + sim.state.vapour_mass
3916 if not live and held > 0.0:
3917 self._close_outlet(sim, dt, cut, out)
3918 if not live or held <= 0.0:
3919 continue
3920 liquid_in, liquid_out = self._outlet_split(sim, self._last_flows)
3921 if sim.id not in self._liquid_fed:
3922 liquid_in = 0.0
3923 enthalpy = self._pressurant_enthalpy(sim)
3924
3925 def land(gas_in: float = 0.0, gas_out: float = 0.0) -> float:
3926 trial = copy.copy(sim) # state is frozen; the copy owns its own
3927 trial.advance(
3928 dt,
3929 mdot_liquid_in=liquid_in,
3930 mdot_liquid_out=liquid_out,
3931 mdot_gas_in=gas_in,
3932 mdot_gas_out=gas_out,
3933 enthalpy_gas_in=enthalpy,
3934 )
3935 return trial.pressure
3936
3937 probe = STORAGE_PROBE * held / dt
3938 try:
3939 reference = land()
3940 if self._venting(node, self._last_flows):
3941 slope = reference - land(gas_out=probe)
3942 else:
3943 slope = max(
3944 land(gas_in=probe) - reference,
3945 reference - land(gas_out=probe),
3946 )
3947 except Exception: # noqa: BLE001 - a vessel that cannot price it
3948 continue # stays a fixed boundary, as it always was
3949 if slope > 0.0 and reference > 0.0:
3950 out[node] = (probe / slope, reference)
3951 return out
3952
3954 self,
3955 sim: TankSim,
3956 dt: float,
3957 cut: set[str],
3958 out: dict[str, tuple[float, float]],
3959 ) -> None:
3960 """Close a shut-in tank's outlet over the step, as its ullage is closed.
3961
3962 With the vent and the press line shut the ullage holds a fixed mass, so
3963 every gram of liquid that leaves expands it: the outlet is as stiff as
3964 that ullage. Held at its start-of-step pressure it is an explicit
3965 coupling -- a full LE4 fuel tank drained through a quarter-psi head
3966 swung its drain 30 g/s each way, step after step, and leaked 2 g/s
3967 where it should have stopped. Closed here (``solve_steady(storage=)``):
3968 where the outlet lands with no liquid exchanged, and how far one probe's
3969 worth of draining moves it -- both by trial steps on a copy.
3970
3971 Only for a tank with liquid in it and a live line at its outlet; a tank
3972 whose outlet is a junction (:meth:`_through_outlets`) has none of its own.
3973 """
3974 if sim.state.liquid_mass <= DRY_MASS or sim.id in self._through_outlets():
3975 return
3976 net = self.model.built.network
3977 node = sim.outlet_node
3978 if not any(
3979 branch_id not in cut
3980 and node in (branch.upstream, branch.downstream)
3981 and sim.ullage_node not in (branch.upstream, branch.downstream)
3982 for branch_id, branch in net.branches.items()
3983 ):
3984 return
3985 rho = max(sim.tank.liquid_density(sim.state), 1.0)
3986 volume = max(sim.tank.ullage_volume(sim.state), 1e-9)
3987 probe = STORAGE_PROBE * volume * rho / dt
3988
3989 def land(liquid_out: float = 0.0) -> float:
3990 trial = copy.copy(sim)
3991 trial.advance(
3992 dt,
3993 mdot_liquid_out=liquid_out,
3994 mdot_gas_in=0.0,
3995 mdot_gas_out=0.0,
3996 enthalpy_gas_in=sim.tank.gas_enthalpy(sim.state),
3997 )
3998 return trial.outlet_pressure
3999
4000 try:
4001 reference = land()
4002 slope = reference - land(probe)
4003 except Exception: # noqa: BLE001 - a vessel that cannot price it
4004 return # stays a fixed boundary, as it always was
4005 if slope > 0.0 and reference > 0.0:
4006 out[node] = (probe / slope, reference)
4007
4009 self,
4010 net: Network,
4011 signals: Mapping[str, float],
4012 dt: float,
4013 substeps: int = SUBSTEPS,
4014 ) -> SteadyResult:
4015 """One coupling step: solve the network, then move the vessels."""
4016 given = signals
4018 if self.setup.regulator_compressible_seat:
4019 signals = {**signals, SEAT_XT_SIGNAL: float(self.setup.regulator_xT)}
4020 if self._reliefs:
4021 signals = self._relief_signals(net, signals)
4022
4023 # Opening a main changes which branches exist, so the previous answer
4024 # describes a different circuit. Carrying it over is worse than starting
4025 # cold: Ready -> Fire warm-started onto a network whose mains had been
4026 # shut, and the solve returned no flow at all through open valves.
4027 # A tank that has run dry cannot push liquid, whatever its ullage
4028 # pressure says. The network sees a fixed-pressure boundary and keeps
4029 # delivering from it, so the engine went on making four kilonewtons out
4030 # of an empty pair of tanks. Only the session knows the inventory, so
4031 # only the session can say.
4032 dry = self._dry_branches() | self._fill_hoses
4033 isolated = frozenset(net.isolated(signals)) | dry
4034 if isolated != self._last_isolated:
4035 # Keep the pressures, drop the flows. The circuit changed, so the
4036 # old flows describe paths that may no longer exist -- and a flow of
4037 # exactly zero is the one starting point Newton cannot move off.
4038 # The pressures are still close to right everywhere the topology did
4039 # not change, which is most of the network, and rediscovering them
4040 # cold is what made flipping a valve cost fifty times a quiet tick.
4041 self._guess = {
4042 node: value for node, value in self._guess.items() if node in net.nodes
4043 }
4044 self._last_flows = {}
4045 self._last_isolated = isolated
4046
4047 # The ground at rest (_rest_ground) leaves the solve as a shut valve
4048 # would, but is no change of circuit: it carried nothing a step ago.
4049 resting = self._resting[0]
4050 storage = self._ullage_storage(dt, isolated | resting)
4051 result = self._solve(net, signals, dry | resting, storage)
4052 self._note_solve(result)
4053 self._accept(result)
4054 if self._reliefs and result.converged:
4055 # A relief decided on the last step's pressures may be on the wrong
4056 # side of its set or reseat pressure at this step's. If the solve
4057 # just made flips one, solve again with it flipped: a relief that
4058 # pops opens on the step its tank crossed set, not a step later.
4059 before = dict(self._relief_open)
4060 refreshed = self._relief_signals(net, signals, result.pressures)
4061 if self._relief_open != before:
4062 signals = refreshed
4063 isolated = frozenset(net.isolated(signals)) | dry
4064 if isolated != self._last_isolated:
4065 self._last_flows = {}
4066 self._last_isolated = isolated
4067 storage = self._ullage_storage(dt, isolated | resting)
4068 result = self._solve(net, signals, dry | resting, storage)
4069 self._note_solve(result)
4070 self._accept(result)
4071 result = self._close_chamber(net, signals, dry | resting, result, storage)
4072 # The vessels move on the last *converged* flows. A failed solve's
4073 # iterate is not a flow field -- Newton stopped wherever it stopped and
4074 # the node balances are not closed -- so integrating on it moves
4075 # propellant no path carried. With a good solve on this circuit the
4076 # step holds its flows (the solver tab says so); right after the
4077 # circuit changed there is none, because `_last_flows` was emptied
4078 # above, and the vessels exchange nothing for the step. This used to
4079 # fall back on the failed iterate's flows exactly there: the Ready ->
4080 # Fire step, or a tank just isolated dry, that the emptying is for.
4081 flows = self._last_flows
4082 # A tank that runs dry part-way through this step can give only what it
4083 # holds, but the solve sees a fixed-pressure boundary and delivers the
4084 # whole step's flow past it: on the ethalox stand, 17-25 g that reached
4085 # the engine and left no vessel. So the step ends where the first tank
4086 # does. Everything moves that far on these flows, and the rest of the
4087 # step is solved again with that tank's outlet isolated, as every step
4088 # after it is. A step on which no tank empties is the step it was.
4089 span = self._dry_cut(flows, dt)
4090 if result.converged:
4091 self._propagate_temperatures(result.pressures, flows, span)
4092
4093 into, out = crossing(net, flows, self._boundary_nodes())
4094 self._crossed_in += into * span
4095 self._crossed_out += out * span
4096 self._move_vessels(flows, self._held(result), span, substeps)
4097 if span < dt:
4098 return self._advance_once(net, given, dt - span, substeps)
4099 return result
4100
4101 def _dry_cut(self, flows: Mapping[str, float], dt: float) -> float:
4102 """How much of a ``dt`` step the tanks can feed these ``flows`` [s].
4103
4104 ``dt`` unless a tank still feeding would be drained before the step
4105 ends; then the time at which the first of them is. Only a tank above
4106 :data:`DRY_MASS` counts: one below it is already isolated
4107 (:meth:`_dry_branches`), so the remainder of a cut step cannot cut
4108 again on the same tank.
4109 """
4110 span = dt
4111 for sim in self.tanks.values():
4112 held = sim.state.liquid_mass
4113 if held <= DRY_MASS:
4114 continue
4115 arriving, leaving = self._split_at(sim.outlet_node, flows)
4116 if sim.id in self._liquid_fed:
4117 leaving -= arriving
4118 if leaving > 0.0 and leaving * span > held:
4119 span = held / leaving
4120 return span
4121
4122 def _note_solve(self, result: SteadyResult) -> None:
4123 """Count one network solve into this tick's solver record."""
4124 t = self._tick
4125 t["solves"] = t.get("solves", 0.0) + 1.0
4126 t["iterations"] = t.get("iterations", 0.0) + result.iterations
4127 t["iterations_max"] = max(t.get("iterations_max", 0.0), result.iterations)
4128 t["residual"] = max(t.get("residual", 0.0), result.residual_norm)
4129 continuity = max((abs(v) for v in result.mass_residuals.values()), default=0.0)
4130 t["continuity"] = max(t.get("continuity", 0.0), continuity)
4131 if not result.converged:
4132 t["failed"] = t.get("failed", 0.0) + 1.0
4133
4134 def _boundary_nodes(self) -> frozenset[str]:
4135 if self._boundary is None:
4136 vessels = {sim.ullage_node for sim in self.tanks.values()}
4137 vessels |= {sim.outlet_node for sim in self.tanks.values()}
4138 vessels |= {b.node for b in self.bottles.values()}
4139 self._boundary = boundary_nodes(self.model.built.network, vessels)
4140 return self._boundary
4141
4142 def inventory(self) -> float:
4143 """Fluid held in every vessel: liquid, ullage gas, vapour, bottles [kg]."""
4144 held = sum(
4145 sim.state.liquid_mass + sim.state.ullage.mass + sim.state.vapour_mass
4146 for sim in self.tanks.values()
4147 )
4148 return held + sum(b.state.mass for b in self.bottles.values())
4149
4150 def _ledger_totals(self) -> tuple[float, float, float]:
4151 """``(added, removed, guards)`` the vessels booked themselves [kg]."""
4152 added = sum(s.added_kg for s in self.tanks.values())
4153 added += sum(b.added_kg for b in self.bottles.values())
4154 removed = sum(b.dumped_kg for b in self.bottles.values())
4155 guards = sum(s.fixed_kg for s in self.tanks.values())
4156 guards += sum(b.fixed_kg for b in self.bottles.values())
4157 return added, removed, guards
4158
4159 def reset_balance(self) -> None:
4160 """Start the mass balance from the inventory as it stands: after the
4161 stand is put somewhere directly (prime, a restored frame) rather than
4162 reached through its boundary."""
4163 self._crossed_in = self._crossed_out = 0.0
4164 self._inventory0 = self.inventory()
4165 self._ledger0 = self._ledger_totals()
4166 self._guard_J0 = sum(s.fixed_J for s in self.tanks.values())
4167
4168 def _record_solver(self, couplings: int) -> None:
4169 """Close this tick's solver record."""
4170 if self._inventory0 is None:
4171 self.reset_balance()
4172 added, removed, guards = self._ledger_totals()
4173 added -= self._ledger0[0]
4174 removed -= self._ledger0[1]
4175 guards -= self._ledger0[2]
4176 crossed_in = self._crossed_in + added
4177 crossed_out = self._crossed_out + removed
4178 held = self.inventory()
4179 t = self._tick
4180 self.solver_log.append(
4182 t=round(self.t, 4),
4183 couplings=couplings,
4184 iterations=int(t.get("iterations", 0.0)),
4185 iterations_max=int(t.get("iterations_max", 0.0)),
4186 residual=float(t.get("residual", 0.0)),
4187 continuity=float(t.get("continuity", 0.0)),
4188 converged=t.get("failed", 0.0) == 0.0,
4189 chamber_residual_psi=float(t.get("chamber_gap", 0.0)) / PSI,
4190 inventory_kg=held,
4191 crossed_in_kg=crossed_in,
4192 crossed_out_kg=crossed_out,
4193 mass_error_kg=(held - float(self._inventory0 or 0.0))
4194 - (crossed_in - crossed_out),
4195 guard_kg=guards,
4196 guard_J=sum(s.fixed_J for s in self.tanks.values())
4197 - getattr(self, "_guard_J0", 0.0),
4198 )
4199 )
4200 self.watcher.observe(self, self.solver_log[-1])
4201 self._tick = {}
4202
4204 self,
4205 net: Network,
4206 signals: Mapping[str, float],
4207 pressures: Mapping[str, float] | None = None,
4208 ) -> dict[str, float]:
4209 """Each relief valve's lift for this coupling step, as signals.
4210
4211 From the pressure across it: ``pressures`` (a solve made at this step's
4212 vessel pressures) when given; otherwise a vessel boundary's own pressure
4213 where the valve's end is one, and the last converged solve's value at
4214 any other node. A node never yet solved leaves the valve where it was.
4215 See :mod:`feedtwin.comps.relief`.
4216 """
4217 out = dict(signals)
4218 for branch_id, branch in self._reliefs:
4219 component = branch.component
4220 ends = []
4221 for node_id in (branch.upstream, branch.downstream):
4222 if pressures is not None:
4223 ends.append(pressures.get(node_id))
4224 continue
4225 fixed = net.nodes[node_id].pressure
4226 value = fixed if fixed is not None else self._guess.get(node_id)
4227 ends.append(value)
4228 name = f"{component.id}.lift"
4229 if ends[0] is not None and ends[1] is not None:
4230 lift, is_open = component.lift_for(
4231 float(ends[0]) - float(ends[1]),
4232 self._relief_open.get(branch_id, False),
4233 )
4234 self._relief_open[branch_id] = is_open
4235 self._relief_lift[name] = lift
4236 out[name] = self._relief_lift.get(name, 0.0)
4237 return out
4238
4239 def _accept(self, result: SteadyResult) -> None:
4240 """Keep a converged solve as the next one's starting point."""
4241 if not result.converged:
4242 return
4243 self._last_flows = dict(result.flows)
4244 self._last_pressures = dict(result.pressures)
4245 # Pressures always; flows only where something was actually moving.
4246 #
4247 # A state with the whole panel shut solves to flows of exactly zero,
4248 # and handing those back as the next solve's starting point is the
4249 # one thing solve_steady's initial guess deliberately avoids: a
4250 # branch starting at zero flow has a zero derivative, so the first
4251 # Newton step cannot move it and the solve returns nothing. Going
4252 # Ready -> Fire did exactly that, and the mains opened onto a
4253 # network that reported no flow at all.
4254 self._guess = {
4255 **result.pressures,
4256 **{b: f for b, f in result.flows.items() if abs(f) > 1e-9},
4257 }
4258
4260 self,
4261 net: Network,
4262 signals: Mapping[str, float],
4263 isolate: frozenset[str],
4264 storage: Mapping[str, tuple[float, float]] | None,
4265 ) -> SteadyResult:
4266 """The network at this coupling step, warm-started from the last answer.
4267
4268 A solve with the ullages closed (``storage``) that fails is tried once
4269 more, from where the same network lands with them held: that solve is
4270 easy, and it puts Newton beside the answer. A failed solve holds the
4271 stand (:meth:`_held`), so the next step asks the same question and
4272 fails the same way, for good. A Fire from unpressed tanks did exactly
4273 that -- frozen with the chamber at 0 psig, where 384 iterations of the
4274 same solve converged and 9 + 4 by this route. Only a step that failed
4275 takes it, so every step that converges is the step it was.
4276 """
4277 result = solve_steady(
4278 net,
4279 signals=signals,
4280 tol=self.setup.network_tolerance,
4281 max_iterations=self.setup.max_iterations,
4282 raise_on_failure=False,
4283 guess=self._guess or None,
4284 isolate=isolate,
4285 storage=storage,
4286 report=False,
4287 )
4288 if result.converged or not storage:
4289 return result
4290 held = solve_steady(
4291 net,
4292 signals=signals,
4293 tol=self.setup.network_tolerance,
4294 max_iterations=self.setup.max_iterations,
4295 raise_on_failure=False,
4296 guess=self._guess or None,
4297 isolate=isolate,
4298 report=False,
4299 )
4300 if not held.converged:
4301 return result
4302 retried = solve_steady(
4303 net,
4304 signals=signals,
4305 tol=self.setup.network_tolerance,
4306 max_iterations=self.setup.max_iterations,
4307 raise_on_failure=False,
4308 guess={
4309 **held.pressures,
4310 **{b: f for b, f in held.flows.items() if abs(f) > 1e-9},
4311 },
4312 isolate=isolate,
4313 storage=storage,
4314 report=False,
4315 )
4316 return retried if retried.converged else result
4317
4318 def _held(self, result: SteadyResult) -> Mapping[str, float]:
4319 """The node pressures a step acts on and shows.
4320
4321 The solve's own when it converged. A failed solve's iterate is not a
4322 pressure field either, so every node it was solving for stays where the
4323 last converged solve left it; only the boundaries -- vessels,
4324 atmosphere, the chamber -- are taken as they stand, because the session
4325 set those itself. Before any solve has converged there is nothing to
4326 hold and the iterate is all there is.
4327 """
4328 if result.converged or not self._last_pressures:
4329 return result.pressures
4330 nodes = self.model.built.network.nodes
4331 return {
4332 node: (
4333 value if nodes[node].is_fixed else self._last_pressures.get(node, value)
4334 )
4335 for node, value in result.pressures.items()
4336 }
4337
4339 self,
4340 net: Network,
4341 signals: Mapping[str, float],
4342 dry: frozenset[str],
4343 result: SteadyResult,
4344 storage: Mapping[str, tuple[float, float]] | None = None,
4345 ) -> SteadyResult:
4346 """Find the chamber pressure the network and the engine agree on.
4347
4348 With the chamber pinned at ``p`` the network delivers some flow; the
4349 engine turns that flow into a chamber pressure ``g(p)`` through c*
4350 and the throat. The two agree where ``g(p) = p``. ``g`` falls as ``p``
4351 rises (a higher chamber takes injector drop away), it is above
4352 ambient at ambient whenever propellant is arriving, and it is ambient
4353 at the feed pressure where nothing flows -- so a root is bracketed
4354 and regula falsi with a bisection guard finds it in a handful of
4355 solves. A step where the previous answer still holds costs nothing
4356 extra: the first solve is the one already made.
4357
4358 Sets ``_chamber_guess``, the chamber node, and ``_last_chamber``.
4359 """
4360 ports = self.model.built.engine_ports
4361 if self.model.chamber is None or "chamber" not in ports:
4362 self._chamber(self._last_flows)
4363 self._last_chamber = None
4364 return result
4365 node = ports["chamber"]
4366 model = self.model.chamber
4367
4368 def into(flows: Mapping[str, float], branch_id: str) -> float:
4369 if not branch_id:
4370 return 0.0
4371 branch = net.branches[branch_id]
4372 sign = 1.0 if branch.downstream == node else -1.0
4373 return max(sign * flows.get(branch_id, 0.0), 0.0)
4374
4375 def equilibrium(flows: Mapping[str, float]) -> ChamberResult:
4376 return model.evaluate(
4377 into(flows, ports.get("oxidiser", "")),
4378 into(flows, ports.get("fuel", "")),
4379 )
4380
4381 def mismatch(res: SteadyResult, p: float) -> tuple[ChamberResult, float]:
4382 flows = res.flows if res.converged else self._last_flows
4383 eq = equilibrium(flows)
4384 return eq, max(eq.pressure, AMBIENT) - p
4385
4386 def solve_at(p: float) -> SteadyResult:
4387 net.nodes[node].pressure = p
4388 res = self._solve(net, signals, dry, storage)
4389 self._note_solve(res)
4390 self._accept(res)
4391 return res
4392
4393 p = max(self._chamber_guess, AMBIENT)
4394 eq, g = mismatch(result, p)
4395 tolerance = self.setup.chamber_tolerance_psi * PSI
4396 if abs(g) > tolerance:
4397 feed = max(
4398 [sim.outlet_pressure for sim in self.tanks.values()] + [p, AMBIENT]
4399 )
4400 lo, g_lo = (p, g) if g > 0.0 else (AMBIENT, None)
4401 hi, g_hi = (p, g) if g < 0.0 else (feed, None)
4402 p_next = min(max(p + g, AMBIENT), feed)
4403 for _ in range(CHAMBER_ITERATIONS):
4404 result = solve_at(p_next)
4405 p = p_next
4406 eq, g = mismatch(result, p)
4407 if abs(g) <= tolerance:
4408 break
4409 if g > 0.0:
4410 lo, g_lo = p, g
4411 else:
4412 hi, g_hi = p, g
4413 if g_lo is None or g_hi is None or g_hi == g_lo:
4414 p_next = 0.5 * (lo + hi)
4415 else:
4416 p_next = hi - g_hi * (hi - lo) / (g_hi - g_lo)
4417 if not lo < p_next < hi:
4418 p_next = 0.5 * (lo + hi)
4419 if hi - lo < tolerance:
4420 break
4421 self._chamber_guess = p
4422 net.nodes[node].pressure = p
4423 if result.converged:
4424 # An injector card read off its hull counts as extrapolated too.
4425 eq = fold_injectors(eq, net, ports, result.flows, result.pressures)
4426 self._last_chamber = eq
4427 self._tick["chamber_gap"] = max(self._tick.get("chamber_gap", 0.0), abs(g))
4428 return result
4429
4431 self,
4432 flows: Mapping[str, float],
4433 pressures: Mapping[str, float],
4434 dt: float,
4435 substeps: int = SUBSTEPS,
4436 ) -> None:
4437 """Integrate every vessel over ``dt`` with the flows it is given.
4438
4439 The ground at rest is left where it is (:attr:`Setup.ground_rests`):
4440 what :meth:`_rest_ground` took out of the solve, and any cart vessel
4441 these flows do not touch and no built-in press is acting on."""
4442 inner = dt / substeps
4443 resting = set(self._resting[1])
4444 if self.setup.ground_rests:
4445 ground = self.ground
4446 for sim in self.tanks.values():
4447 if (
4448 sim.id in ground
4449 and self._split_at(sim.ullage_node, flows) == (0.0, 0.0)
4450 and self._outlet_split(sim, flows) == (0.0, 0.0)
4451 and self._supply_press(sim, inner)[0] <= 0.0
4452 ):
4453 resting.add(sim.id)
4454 for b_id, bottle in self.bottles.items():
4455 if (
4456 b_id in ground
4457 and not (bottle.filling or bottle.venting)
4458 and self._split_at(bottle.node, flows) == (0.0, 0.0)
4459 ):
4460 resting.add(b_id)
4461 tanks = [sim for sim in self.tanks.values() if sim.id not in resting]
4462 bottles = {k: b for k, b in self.bottles.items() if k not in resting}
4463 sealed = self._sealed(self._last_isolated)
4464 for sim in tanks:
4465 sim.sealed = sim.id in sealed
4466 # What each tank on a transfer line is being sent through it: the
4467 # liquid leaving the tanks at its other end. An arrival at an outlet
4468 # beyond that came from somewhere else -- the chamber pushing back at
4469 # Fire into tanks nobody pressed -- and is not propellant. Credited
4470 # whole, it put 0.28 kg of "ethanol" into a full LE4 fuel tank in one
4471 # step and the tank overfilled.
4472 through = self._through_outlets()
4473 supplies = self.model.built.supplies
4474 sent_to: dict[str, float] = {}
4475 for supply, fed in supplies.items():
4476 if supply not in self.tanks:
4477 continue
4478 given = self._outlet_split(self.tanks[supply], flows, through)[1]
4479 back = 0.0
4480 for tank_id in fed:
4481 if tank_id in self.tanks:
4482 sent_to[tank_id] = sent_to.get(tank_id, 0.0) + given
4483 back += self._outlet_split(self.tanks[tank_id], flows, through)[1]
4484 sent_to[supply] = sent_to.get(supply, 0.0) + back
4485 vented = {sim.id: self._vent_fraction(sim.ullage_node, flows) for sim in tanks}
4486 for _ in range(substeps):
4487 refused = 0.0
4488 sent: dict[str, float] = {}
4489 for sim in tanks:
4490 gas_in, gas_out = self._split_at(sim.ullage_node, flows)
4491 liquid_in, liquid_out = self._outlet_split(sim, flows, through)
4492 arrived = liquid_in
4493 if sim.id not in self._liquid_fed:
4494 # Only a tank a cart tank loads takes liquid in; anywhere
4495 # else an arrival at an outlet is not propellant (the
4496 # chamber pushing back at an abort) and is not credited.
4497 liquid_in = 0.0
4498 else:
4499 liquid_in = min(liquid_in, sent_to.get(sim.id, 0.0))
4500 # What it refused is booked, as a give beyond what a tank holds
4501 # is: the network carried it across the boundary, and unbooked it
4502 # read as mass lost -- 15 g at the Fire of a sealed, nearly empty
4503 # LOX tank that the open main let the nozzle's air into.
4504 turned_away = arrived - liquid_in
4505 if turned_away > 0.0:
4506 sim.fixed_kg -= turned_away * inner
4507 sim.refused_kg += turned_away * inner
4508 # Pressurant leaving for another vessel, not a vent.
4509 sent[sim.id] = gas_out * (1.0 - vented.get(sim.id, 0.0))
4510 pressed, target, h_cart = self._supply_press(sim, inner)
4511 taken = sim.advance(
4512 inner,
4513 mdot_liquid_out=liquid_out,
4514 mdot_gas_in=gas_in + pressed,
4515 mdot_gas_out=gas_out,
4516 enthalpy_gas_in=(
4517 self._pressurant_enthalpy(sim)
4518 if pressed <= 0.0
4519 else (
4520 gas_in * self._pressurant_enthalpy(sim) + pressed * h_cart
4521 )
4522 / (gas_in + pressed)
4523 ),
4524 supply_pressure=(
4525 target if pressed > 0.0 else self._supply_to(sim, pressures)
4526 ),
4527 stirring=self.setup.fill_stirring,
4528 vent_fraction=vented.get(sim.id, 0.0),
4529 mdot_liquid_in=liquid_in,
4530 liquid_in_temperature=self._arriving_liquid_T(sim, flows),
4531 )
4532 # The stand-in press is refused first: nothing was debited for
4533 # it. What it did put in came from the cart, off the drawing.
4534 stand_in_refused = min(taken, pressed)
4535 sim.added_kg += (pressed - stand_in_refused) * inner
4536 refused += taken - stand_in_refused
4537 # Give back what no tank would take. A tank that has caught up with
4538 # its supply stops taking gas mid-tick, but the solve that debited
4539 # the bottle happened before that -- so without this the pressurant
4540 # is simply destroyed, and it is destroyed *most* when the tank sits
4541 # closest to the regulator, which is exactly the operating point a
4542 # burn spends all its time at. Helium, whose line losses are small
4543 # enough to park the tank within a few psi of the outlet, lost half
4544 # its bottle this way.
4545 draws = {
4546 b_id: -self._net_into(bottle.node, flows)
4547 for b_id, bottle in bottles.items()
4548 }
4549 total = sum(d for d in draws.values() if d > 0.0)
4550 # The bottles take back at most what they gave. The rest of what
4551 # was refused came from another tank -- two tanks on one press
4552 # manifold trade gas whenever they differ -- and goes back to it.
4553 # It used to be handed to the bottle too, whose draw cannot go
4554 # negative, so it vanished: joined straight to its press valves,
4555 # LE4's pair destroyed 7 g/s and emptied the COPV in its settle.
4556 to_bottles = min(refused, total)
4557 # Gas the solve pushes *into* a bottle whose GSE is not drawn --
4558 # back through a regulator sitting above lockup, a trickle -- has
4559 # nowhere to go in a bottle model that only blows down, and was
4560 # dropped by the clamp on its draw. It goes back to the tanks that
4561 # sent it, like gas a tank refuses.
4562 bounced = 0.0
4563 for b_id, bottle in bottles.items():
4564 draw = draws[b_id]
4565 if draw < 0.0 and b_id not in self._drawn_fill:
4566 bounced -= draw
4567 draw = 0.0
4568 if b_id in self._drawn_fill and draw < 0.0:
4569 # Charged through the drawing: what the network delivers
4570 # arrives, priced where it came from.
4571 bottle.advance(
4572 inner,
4573 mdot_out=0.0,
4574 mdot_in=-draw,
4575 enthalpy_in=self._bottle_inflow_enthalpy(bottle),
4576 stirring=self.setup.fill_stirring,
4577 )
4578 continue
4579 if to_bottles > 0.0 and total > 0.0 and draw > 0.0:
4580 draw -= to_bottles * (draw / total)
4581 bottle.advance(inner, mdot_out=draw, stirring=self.setup.fill_stirring)
4582 excess = refused - to_bottles + bounced
4583 senders = sum(sent.values())
4584 if excess > 0.0 and senders > 0.0:
4585 for sim in tanks:
4586 sim.take_back(excess * sent[sim.id] / senders * inner)
4587
4588 def _find_fill_stops(self) -> dict[str, str]:
4589 """For each tank a cart tank loads: the first valve on the transfer line,
4590 from the cart's side. Hand or actuated; none if the line has no valve."""
4591 built = self.model.built
4592 net = built.network
4593 owner = {
4594 branch: sid
4595 for sid, branches in built.branches_of.items()
4596 if sid in built.actuators
4597 for branch in branches
4598 }
4599 adjacent: dict[str, list[tuple[str, str]]] = {}
4600 for branch in net.branches.values():
4601 adjacent.setdefault(branch.upstream, []).append(
4602 (branch.downstream, branch.id)
4603 )
4604 adjacent.setdefault(branch.downstream, []).append(
4605 (branch.upstream, branch.id)
4606 )
4607 out: dict[str, str] = {}
4608 for supply, fed in built.supplies.items():
4609 start = built.tanks[supply].outlet
4610 parent: dict[str, tuple[str, str]] = {}
4611 seen = {start}
4612 frontier = [start]
4613 targets = {built.tanks[t].outlet: t for t in fed} | {
4614 built.tanks[t].ullage: t for t in fed
4615 }
4616 while frontier:
4617 here = frontier.pop(0)
4618 for there, branch_id in adjacent.get(here, ()):
4619 if there in seen:
4620 continue
4621 seen.add(there)
4622 parent[there] = (here, branch_id)
4623 if there in targets:
4624 path: list[str] = []
4625 step = there
4626 while step != start:
4627 step, via = parent[step]
4628 path.append(via)
4629 valve = next(
4630 (owner[b] for b in reversed(path) if b in owner), ""
4631 )
4632 if valve:
4633 out.setdefault(targets[there], valve)
4634 self._fill_lines[targets[there]] = self._fill_lines.get(
4635 targets[there], frozenset()
4636 ) | frozenset(path)
4637 continue
4638 if net.nodes[there].pressure is not None:
4639 continue
4640 frontier.append(there)
4641 return out
4642
4643 def _loading(self, sim: TankSim) -> bool:
4644 """The stand is in this tank's fill state ("Fuel Fill", "Ox Fill")."""
4645 name = self.state.lower()
4646 side = propellant_side(sim.tank.liquid.name)
4647 words = ("ox", "lox") if side == "lox" else ("fuel", "eth")
4648 return "fill" in name and any(w in name for w in words)
4649
4650 def _stop_full_loads(self) -> None:
4651 """The crew's hand on a drawn load's transfer valve.
4652
4653 Shut until the tank's fill state ("Fuel Fill") is selected, opened for
4654 the load, shut again once the tank holds it -- as the built-in load
4655 stops at its full fraction. A transfer valve left at rest is open (it is
4656 plumbed both sides), and a cart tank above the flight tank then siphons
4657 into it before anyone has started the fill. Acts only when what the
4658 crew would do changes, so a hand on the P&ID in between is kept.
4659 """
4660 labels = {n.id: n.label or n.id for n in self.model.diagram.nodes}
4661 for tank_id, valve in self._fill_stops.items():
4662 sim = self.tanks[tank_id]
4663 sim.full_fraction = self.setup.full_fraction
4664 sim.load_kg = self.fire_loads().get(tank_id, 0.0)
4665 full = sim.state.liquid_mass >= sim._wanted()
4666 wanted = self._loading(sim) and not full
4667 if self._fill_crew.get(valve) == wanted:
4668 continue
4669 self._fill_crew[valve] = wanted
4670 self.forced[valve] = 1.0 if wanted else 0.0
4671 if full and tank_id not in self._fill_stopped:
4672 self._fill_stopped.add(tank_id)
4673 self.assumptions.append(
4674 f"{sim.label} holds its load ({sim.state.liquid_mass:.2f} kg): "
4675 f"{labels.get(valve, valve)} "
4676 "shut, as the crew shuts the fill."
4677 )
4678 note = (
4679 f"{labels.get(valve, valve)} is the crew's: shut until "
4680 f"{sim.label}'s fill state, opened for the load, shut at it."
4681 )
4682 if note not in self.assumptions:
4683 self.assumptions.append(note)
4684
4686 self, sim: TankSim, dt: float = 0.0
4687 ) -> tuple[float, float, float]:
4688 """The cart's press on a ground supply tank whose press line is not
4689 drawn: ``(rate [kg/s], target [Pa], arriving enthalpy [J/kg])``.
4690
4691 A fuel transfer tank is pressed to the pressure its drawing gives while
4692 the table holds its press actuator ("Fuel Fill Press") open. When a
4693 valve on the drawing is bound to that actuator, the network presses it
4694 and this is zero; so is it for every tank that is not the cart's.
4695 """
4696 if sim.id not in self.model.built.supplies:
4697 return 0.0, 0.0, 0.0
4698 node = sim.node
4699 drawn = getattr(node, "params", {}).get("pressure") if node else None
4700 if drawn is None:
4701 return 0.0, 0.0, 0.0
4702 side = propellant_side(sim.tank.liquid.name)
4703 if getattr(node, "drawn_as", "") == "DEWAR":
4704 # A dewar's pressure-building circuit holds it whatever the table
4705 # says: it is the dewar's own, not a valve on the stand.
4706 pressing = ["its pressure-building circuit"]
4707 else:
4708 opened = self.machine.open_actuators(self.state)
4709 pressing = [
4710 a
4711 for a in opened
4712 if {"fill", "press"} <= _words(a)
4713 and side in _words(a)
4714 and a not in self.binding.to_symbol
4715 ]
4716 if not pressing:
4717 return 0.0, 0.0, 0.0
4718 target = float(drawn.si)
4719 now = sim.pressure
4720 held = sim.state.ullage.mass + sim.state.vapour_mass
4721 if now >= target or held <= 0.0:
4722 return 0.0, target, 0.0
4723 wanted = held * (target / max(now, 1.0) - 1.0)
4724 # A dewar's circuit is a regulator: it makes up the deficit as it opens
4725 # (the vessel refuses what would carry it past). The cart's press line
4726 # on a transfer tank takes its time.
4727 span = dt if getattr(node, "drawn_as", "") == "DEWAR" and dt > 0.0 else 0.0
4728 rate = wanted / max(span or self.setup.supply_press_s, 1e-3)
4729 h_cart = float(sim.tank.gas.get("h", p=target, T=self.setup.fill_supply_T))
4730 note = (
4731 f"{sim.label} is held at its drawn {say(target, 1)} by "
4732 f"{pressing[0]} (not on the drawing; Setup supply_press_s "
4733 f"{self.setup.supply_press_s:g} s)."
4734 if pressing[0].startswith("its ")
4735 else f"{sim.label} is pressed to its drawn {say(target, 1)} while "
4736 f"{pressing[0]} is open, by the cart's press line (not on the "
4737 f"drawing; Setup supply_press_s {self.setup.supply_press_s:g} s)."
4738 )
4739 if note not in self.assumptions:
4740 self.assumptions.append(note)
4741 return rate, target, h_cart
4742
4744 self, sim: TankSim, flows: Mapping[str, float]
4745 ) -> float | None:
4746 """Mass-weighted temperature of what arrives at a tank's outlet [K]."""
4747 net = self.model.built.network
4748 total = weighted = 0.0
4749 for branch_id, branch in net.branches.items():
4750 flow = flows.get(branch_id, 0.0)
4751 if branch.downstream == sim.outlet_node and flow > 0.0:
4752 source = branch.upstream
4753 elif branch.upstream == sim.outlet_node and flow < 0.0:
4754 source = branch.downstream
4755 else:
4756 continue
4757 total += abs(flow)
4758 weighted += abs(flow) * net.nodes[source].temperature
4759 return weighted / total if total > 0.0 else None
4760
4761 def _sealed(self, isolated: frozenset[str]) -> frozenset[str]:
4762 """Tanks whose ullage has no open gas path to the air.
4763
4764 Walked from the ullage through every branch the solve did not take out
4765 (``isolated``: shut valves, dry outlets) to a fixed-pressure boundary
4766 that is not a vessel -- a vent, an open-ended valve. A vessel stops the
4767 walk: another tank or bottle is not the air, and the liquid below an
4768 ullage is not a gas path. With the vent shut and the press line closed,
4769 a tank is sealed (:attr:`TankSim.sealed`). Remembered per shut set.
4770 """
4771 cached = self._sealed_cache.get(isolated)
4772 if cached is not None:
4773 return cached
4774 net = self.model.built.network
4775 vessels = {sim.ullage_node for sim in self.tanks.values()}
4776 vessels |= {sim.outlet_node for sim in self.tanks.values()}
4777 vessels |= {b.node for b in self.bottles.values()}
4778 links: dict[str, list[str]] = {}
4779 for branch_id, branch in net.branches.items():
4780 if branch_id in isolated:
4781 continue
4782 links.setdefault(branch.upstream, []).append(branch.downstream)
4783 links.setdefault(branch.downstream, []).append(branch.upstream)
4784 sealed = set()
4785 for sim in self.tanks.values():
4786 seen = {sim.ullage_node}
4787 frontier = [sim.ullage_node]
4788 open_air = False
4789 while frontier and not open_air:
4790 here = frontier.pop()
4791 for there in links.get(here, ()):
4792 if there in seen or there in vessels:
4793 continue
4794 seen.add(there)
4795 if net.nodes[there].is_fixed:
4796 open_air = True
4797 break
4798 frontier.append(there)
4799 if not open_air:
4800 sealed.add(sim.id)
4801 found = frozenset(sealed)
4802 self._sealed_cache[isolated] = found
4803 return found
4804
4805 def _venting(self, node: str, flows: Mapping[str, float]) -> bool:
4806 """More of the gas at ``node`` leaves for the sky than arrives or is
4807 sent on to another vessel."""
4808 gas_in, gas_out = self._split_at(node, flows)
4809 if gas_out <= gas_in:
4810 return False
4811 vented = gas_out * self._vent_fraction(node, flows)
4812 return vented > gas_in + (gas_out - vented)
4813
4814 def _vent_fraction(self, node: str, flows: Mapping[str, float]) -> float:
4815 """Share of the gas leaving ``node`` that ends at a vent, 0..1.
4816
4817 Follow the flow out of the node through the network, splitting at
4818 each junction in proportion to what leaves it, until it reaches a
4819 fixed-pressure boundary that is not a vessel (a vent to atmosphere:
4820 counted), another vessel (not counted -- it will be back), or a dead
4821 end (not counted). Species do not exist in the network, so this is
4822 the only way a tank can know whether the vapour it let go is gone.
4823 """
4824 net = self.model.built.network
4825 vessels = {sim.ullage_node for sim in self.tanks.values()}
4826 vessels |= {sim.outlet_node for sim in self.tanks.values()}
4827 vessels |= {b.node for b in self.bottles.values()}
4828
4829 def outgoing(here: str) -> list[tuple[float, str]]:
4830 out: list[tuple[float, str]] = []
4831 for branch in net.branches.values():
4832 flow = flows.get(branch.id, 0.0)
4833 if branch.upstream == here and flow > 0.0:
4834 out.append((flow, branch.downstream))
4835 elif branch.downstream == here and flow < 0.0:
4836 out.append((-flow, branch.upstream))
4837 return out
4838
4839 vented = 0.0
4840 frontier: list[tuple[str, float]] = [(node, 1.0)]
4841 hops = 0
4842 while frontier and hops < 200:
4843 here, weight = frontier.pop()
4844 hops += 1
4845 outs = outgoing(here)
4846 total = sum(flow for flow, _ in outs)
4847 if total <= 0.0:
4848 continue
4849 for flow, far in outs:
4850 share = weight * flow / total
4851 if far in vessels:
4852 continue
4853 if net.nodes[far].pressure is not None:
4854 vented += share
4855 else:
4856 frontier.append((far, share))
4857 return min(vented, 1.0)
4858
4859 def _apply_vessel_pressures(self) -> None:
4860 net = self.model.built.network
4861 through = self._through_outlets()
4862 for sim in self.tanks.values():
4863 net.nodes[sim.ullage_node].pressure = sim.pressure
4864 net.nodes[sim.outlet_node].pressure = (
4865 None if sim.id in through else sim.outlet_pressure
4866 )
4867 for bottle in self.bottles.values():
4868 net.nodes[bottle.node].pressure = bottle.pressure
4869 # A fill valve's free port is the tanker's hose, not the air. The
4870 # drawing infers a vent from any valve plumbed at one end, so with
4871 # LOX Fill open the tank drained a few hundred grams a second *out*
4872 # through its own fill valve while the commanded fill put liquid in.
4873 # Until the tanker is on the drawing the hose sits at the tank's own
4874 # pressure, so nothing moves through it either way; the fill itself
4875 # is the commanded rate.
4876 for stub, vessel_node in self._fill_stubs.items():
4877 net.nodes[stub].pressure = net.nodes[vessel_node].pressure
4878
4879 def _find_fill_stubs(self) -> dict[str, str]:
4880 """Free boundary node of each fill valve -> the vessel node it feeds."""
4881 net = self.model.built.network
4882 vessel_nodes = {sim.outlet_node for sim in self.tanks.values()}
4883 vessel_nodes |= {sim.ullage_node for sim in self.tanks.values()}
4884 vessel_nodes |= {b.node for b in self.bottles.values()}
4885 out: dict[str, str] = {}
4886 for actuator, symbol in self.binding.to_symbol.items():
4887 if "fill" not in actuator.lower() or symbol not in net.branches:
4888 continue
4889 valve = net.branches[symbol]
4890 ends = (valve.upstream, valve.downstream)
4891 stubs = [
4892 n
4893 for n in ends
4894 if net.nodes[n].pressure is not None and n not in vessel_nodes
4895 ]
4896 if len(stubs) != 1:
4897 continue
4898 stub = stubs[0]
4899 # Walk from the other end to the first vessel node.
4900 frontier = [n for n in ends if n != stub]
4901 seen = set(frontier) | {stub}
4902 found = ""
4903 while frontier and not found:
4904 here = frontier.pop()
4905 if here in vessel_nodes:
4906 found = here
4907 break
4908 for branch in net.branches.values():
4909 if branch.id == symbol:
4910 continue
4911 for a, b in (
4912 (branch.upstream, branch.downstream),
4913 (branch.downstream, branch.upstream),
4914 ):
4915 if a == here and b not in seen:
4916 seen.add(b)
4917 frontier.append(b)
4918 if found:
4919 out[stub] = found
4920 return out
4921
4922 def _leaking_notes(self) -> list[str]:
4923 """Where a vehicle tank's propellant is going, when it is not the engine.
4924
4925 On LE4 the cart's FD-ROT-G -- a dump nothing in the state table
4926 commands, resting open as drawn -- emptied the flight fuel tank through
4927 its fill line in about ten seconds after T-0, and nothing on screen said
4928 where the fuel went (2026-10-09). This names the way out: each branch
4929 carrying that propellant across the stand's boundary, and whether the
4930 state table drives it. Read off the last solve; changes nothing.
4931 """
4932 if self._firing():
4933 return []
4934 net = self.model.built.network
4935 boundary = self._boundary_nodes()
4936 owner = {b: sid for sid, bs in self.model.built.branches_of.items() for b in bs}
4937 labels = {n.id: n.label or n.id for n in self.model.diagram.nodes}
4938 commanded = set(self.binding.to_symbol.values())
4939 ground = self.ground
4940 notes: list[str] = []
4941 for sim in self.tanks.values():
4942 if sim.id in ground or sim.empty or sim.filling:
4943 continue
4944 arriving, leaving = self._split_at(sim.outlet_node, self._last_flows)
4945 rate = leaving - arriving
4946 if rate < LEAK_NOTE_KG_S:
4947 continue
4948 species = net.nodes[sim.outlet_node].fluid
4949 exits: list[str] = []
4950 for branch_id, branch in net.branches.items():
4951 flow = self._last_flows.get(branch_id, 0.0)
4952 if branch.downstream in boundary and branch.upstream not in boundary:
4953 inside, out_flow = branch.upstream, flow
4954 elif branch.upstream in boundary and branch.downstream not in boundary:
4955 inside, out_flow = branch.downstream, -flow
4956 else:
4957 continue
4958 if out_flow < LEAK_NOTE_KG_S or net.nodes[inside].fluid != species:
4959 continue
4960 symbol = owner.get(branch_id, branch_id)
4961 name = labels.get(symbol, symbol)
4962 if symbol not in commanded:
4963 name += " (nothing in the state table commands it)"
4964 if name not in exits:
4965 exits.append(name)
4966 where = f": out through {', '.join(exits)}" if exits else ""
4967 notes.append(
4968 f"{sim.label} is losing {species} at {rate:.2f} kg/s with the "
4969 f"engine cold{where}."
4970 )
4971 return notes
4972
4973 def _split_at(self, node: str, flows: Mapping[str, float]) -> tuple[float, float]:
4974 """Mass arriving at and leaving a node [kg/s], kept apart.
4975
4976 A tank being filled has pressurant coming in one port and vent gas going
4977 out another at the same time. Netting them loses the energy difference,
4978 and the sign of the net says nothing about either.
4979 """
4980 net = self.model.built.network
4981 arriving = leaving = 0.0
4982 for branch_id, branch in net.branches.items():
4983 flow = flows.get(branch_id, 0.0)
4984 if branch.downstream == node:
4985 signed = flow
4986 elif branch.upstream == node:
4987 signed = -flow
4988 else:
4989 continue
4990 if signed >= 0.0:
4991 arriving += signed
4992 else:
4993 leaving += -signed
4994 return arriving, leaving
4995
4997 self,
4998 sim: TankSim,
4999 flows: Mapping[str, float],
5000 through: frozenset[str] | None = None,
5001 ) -> tuple[float, float]:
5002 """Liquid arriving at and leaving a tank's outlet [kg/s]: nothing for a
5003 dry tank whose outlet is a junction (:meth:`_through_outlets`), whose
5004 flows pass the tank by."""
5005 if sim.id in (self._through_outlets() if through is None else through):
5006 return 0.0, 0.0
5007 return self._split_at(sim.outlet_node, flows)
5008
5009 def _net_into(self, node: str, flows: Mapping[str, float]) -> float:
5010 arriving, leaving = self._split_at(node, flows)
5011 return arriving - leaving
5012
5013 def step(self, dt: float) -> Sample:
5014 """What the operator sees ``dt`` seconds later.
5015
5016 Integrated now, Fire included, as a run of :attr:`Setup.live_step`
5017 steps -- the Study's grid, with nothing folded to save wall clock. A
5018 stand too stiff for real time runs in slow motion rather than on a
5019 coarser scheme (docs/PHYSICS-BENCHMARK.md 3.10). A tripped stand holds
5020 the frame it tripped on.
5021 """
5022 if self.tripped:
5023 # The stand has failed. Nothing moves until it is reset; the
5024 # frame on display is the one it failed on.
5025 if self._shown is None:
5026 self._shown = self._integrate(1e-4)
5027 return self._shown
5028 # A panel tick is a run of study-sized steps, so the console and the
5029 # Study tab integrate on the same grid.
5030 dt = max(min(dt, MAX_STEP), 1e-4)
5031 steps = max(int(round(dt / self.setup.live_step)), 1)
5032 inner = dt / steps
5033 for _ in range(steps):
5034 self._shown = self._integrate(inner)
5035 if self.tripped:
5036 break
5037 # `steps` is clamped to at least 1, so the loop above always assigned a
5038 # frame -- including on the tick that trips the stand, which breaks
5039 # after the assignment, not before.
5040 assert self._shown is not None
5041 return self._shown
5042
5043 def _integrate(self, dt: float) -> Sample:
5044 """Advance the stand by ``dt`` seconds and return where it is."""
5045 dt = max(min(dt, MAX_STEP), 1e-4)
5046 self._tick = {}
5047 self._stop_full_loads()
5048 signals = self.signals(dt)
5049 net = self.model.built.network
5050 net.gravity = self.setup.body_acceleration
5051
5052 loads = self.fire_loads()
5053 for sim in self.tanks.values():
5054 sim.filling = self._fills(sim) and sim.id not in self._drawn_fill
5055 sim.full_fraction = self.setup.full_fraction
5056 sim.load_kg = loads.get(sim.id, 0.0)
5057 sim.charge_gamma = self.setup.charge_gamma
5058 sim.supply_band = self.setup.supply_band
5059 sim.stir_band = self.setup.stir_band
5060 sim.gravity = self.setup.body_acceleration
5061 # A dewar transfer that has to chill the wall, or a pour.
5062 cryogen = sim.state.liquid_temperature < CRYOGENIC_K
5063 sim.fill_seconds = (
5064 self.setup.tank_fill_s if cryogen else self.setup.fuel_fill_s
5065 )
5066 sim.chill_seconds = self.setup.load_chill_s
5067 sim.dewar_pressure = (
5068 from_psig(self.setup.dewar_psi) if self.setup.dewar_psi > 0.0 else 0.0
5069 )
5070 sim.fill_line_bore = self.setup.dewar_line_bore_mm * 1e-3
5071 sim.fill_line_length = self.setup.dewar_line_length_m
5072 sim.fill_cv = self.setup.dewar_fill_cv
5073 # The cart's vent valve, on every GSE vent the drawing does not size.
5074 for branch in self.model.built.gse_vents:
5075 net.branches[branch].component.p["Cv"] = self.setup.gse_vent_cv
5076 # The GSE side of the bottle is not on the drawing, so its fill and
5077 # vent are the table's own GSE actuators, read as the DAQ reads them:
5078 # `GSE High Press Control` charges it (GN2 High Press), `GSE High
5079 # Press Vent` dumps it (GSE Abort, Emergency Abort). GN2 High Vent
5080 # opens `GN2 Vent`, which is on the drawing -- the regulated manifold
5081 # -- and the network vents exactly what that valve reaches. It used
5082 # to match on the state's name and drain the bottle in GN2 High Vent
5083 # through a valve the table never opens there.
5084 opened = self.machine.open_actuators(self.state)
5085 ground = self.ground
5086 for bottle in self.bottles.values():
5087 # A bottle the drawing charges (the cart drawn on its GSE page) is
5088 # filled and dumped by the network, through the valves drawn there.
5089 # A cart's own vessel (its K-bottles, its dewar) is the supply, and
5090 # the built-in charge stands in for the vehicle's alone: it once
5091 # "charged" a LOX dewar to the COPV target as if it were gas.
5092 off_drawing = bottle.id not in self._drawn_fill and bottle.id not in ground
5093 bottle.filling = off_drawing and GSE_CHARGE in opened
5094 bottle.fill_supply_T = self.setup.fill_supply_T
5095 bottle.venting = off_drawing and GSE_DUMP in opened
5096 bottle.target = from_psig(self.setup.copv_target_psi)
5097 bottle.fill_seconds = self.setup.copv_fill_s
5098
5099 self._rest_ground(signals)
5100 resting = self._resting[1]
5101
5102 # How many times to re-solve inside this tick. Chosen from how fast the
5103 # vessels moved last time: quiet states cost one solve, a press
5104 # transient costs a handful, and nothing else has to know.
5105 coupling = 1
5106 if self._last_change > 0.0:
5107 coupling = (
5108 int(
5109 self._last_change
5110 * dt
5111 / self._last_dt
5112 / self.setup.max_coupled_change
5113 )
5114 + 1
5115 )
5116 # ...and never coarser than the regulator-ullage time constant allows.
5117 # The change-based rule above reacts to motion it has already seen; the
5118 # time constant says how fast the loop *can* move, before it does.
5119 tau = self._coupling_timescale(signals, dt, press=False)
5120 if tau > 0.0:
5121 coupling = max(coupling, int(dt / (self.setup.coupling_safety * tau)) + 1)
5122 # ...and never let one coupling step move more than MAX_COUPLED_CHANGE
5123 # of an ullage's mass. The RC estimate uses the regulator's droop
5124 # slope, which is the loop's stiffness *near lockup*; wide open, a
5125 # Cv 0.8 regulator passes half a kilogram a second into a fuel ullage
5126 # of 0.43 L holding twenty grams -- a third of the ullage in one 14 ms
5127 # step -- and the tank overshot lockup by 43 psi on the step that
5128 # crossed it. Sized from the flows the last solve produced, so it
5129 # sees the press coming rather than reacting to it.
5130 #
5131 # Gas a tank vents to atmosphere is not counted. A vent ends at a
5132 # fixed pressure, and the solve closes the ullage against it
5133 # implicitly (`_ullage_storage`), so it needs no finer coupling. A LOX
5134 # tank topped to its load in Ox Fill passes ~20 g/s of boil-off
5135 # through a 0.45 g ullage to its vent; counted, it asked for nine
5136 # solves of the whole stand every 20 ms and the fill ran at a third
5137 # of real time.
5138 for sim in self.tanks.values():
5139 if sim.id in resting:
5140 continue
5141 gas_in, gas_out = self._split_at(sim.ullage_node, self._last_flows)
5142 if gas_out > gas_in:
5143 gas_out *= 1.0 - self._vent_fraction(sim.ullage_node, self._last_flows)
5144 rate = max(gas_in, gas_out)
5145 inventory = sim.state.ullage.mass
5146 if rate > 0.0 and inventory > 0.0:
5147 coupling = max(
5148 coupling,
5149 int(dt * rate / (inventory * self.setup.max_mass_step)) + 1,
5150 )
5151 coupling = min(coupling, MAX_COUPLING_STEPS)
5152 # ...and the press path's own constant (`_press_path_timescale`), last
5153 # and apart. It is about how often the network is re-solved, not about
5154 # how finely the vessels integrate: those were already stepped as
5155 # finely as the rules above ask. So the extra solves it adds take
5156 # fewer vessel sub-steps each, and every vessel step stays exactly as
5157 # fine as it would have been without it. Four sub-steps of each of
5158 # nine couplings had taken a guarded helium burn to 0.6 ms vessel steps
5159 # and spent most of the tick on them. (Thinning the sub-steps of
5160 # couplings the mass rule asked for instead moved the chamber
5161 # benchmark by 13 psi: those are the vessels' own stiffness.)
5162 vessel_couplings = coupling
5163 press_tau = self._coupling_timescale(signals, dt)
5164 if 0.0 < press_tau < tau or (tau <= 0.0 and press_tau > 0.0):
5165 coupling = max(
5166 coupling, int(dt / (self.setup.coupling_safety * press_tau)) + 1
5167 )
5168 coupling = min(coupling, MAX_COUPLING_STEPS)
5169 inner_dt = dt / coupling
5170 substeps = max(1, -(-SUBSTEPS * vessel_couplings // coupling))
5171 before = self._vessel_pressures()
5172
5173 result = None
5174 started = time.monotonic()
5175 couplings_done = 0
5176 for index in range(coupling):
5177 remaining = coupling - index
5178 couplings_done += 1
5179 # Out of time: fold what is left of the tick into one last step
5180 # rather than stalling the cockpit for the rest of the budget.
5181 if index and time.monotonic() - started > self.setup.tick_budget:
5182 result = self._advance_once(
5183 net, signals, inner_dt * remaining, substeps
5184 )
5185 break
5186 result = self._advance_once(net, signals, inner_dt, substeps)
5187
5188 assert result is not None
5189 chamber = self._last_chamber
5190 after = self._vessel_pressures()
5191 self._last_change = max(
5192 (abs(after[k] - before[k]) / max(before[k], 1.0) for k in before),
5193 default=0.0,
5194 )
5195 self._last_dt = max(dt, 1e-6)
5196
5197 # Only *stub* nodes get the held value. A vessel boundary is never
5198 # trapped -- its pressure comes from the vessel, not from the solve --
5199 # and freezing one would nail a tank's transducer to whatever it read
5200 # the first time a neighbouring branch went quiet.
5201 vessels = {sim.ullage_node for sim in self.tanks.values()}
5202 vessels |= {sim.outlet_node for sim in self.tanks.values()}
5203 vessels |= {b.node for b in self.bottles.values()}
5204 held = set(result.indeterminate_nodes) - vessels
5205
5206 # A failed solve shows, and leaves as the trapped value, what the last
5207 # converged one found -- not the iterate it stopped on.
5208 solved = self._held(result)
5209 pressures = dict(solved)
5210 for node in held:
5211 pressures[node] = self._trapped.get(node, AMBIENT)
5212 pressures.update(self._dome_readings)
5213 for node, value in solved.items():
5214 if node not in held:
5215 self._trapped[node] = value
5216 self.t += dt
5217 if chamber is not None and chamber.thrust > 0.0:
5218 self._lit_at = self.t
5219 self._check_limits()
5220 self._burnout_check()
5221 self._record_solver(couplings_done)
5222 sample = Sample(
5223 t=round(self.t, 4),
5224 state=self.state,
5225 pressures=pressures,
5226 temperatures={
5227 node_id: node.temperature
5228 for node_id, node in self.model.built.network.nodes.items()
5229 },
5230 flows=dict(self._last_flows),
5231 # A relief's lift is decided per coupling step, inside the solve
5232 # loop; the frame shows where the last one left it.
5233 signals={**signals, **self._relief_lift} if self._relief_lift else signals,
5234 converged=result.converged,
5235 tanks={sim.id: sim.readouts() for sim in self.tanks.values()},
5236 chamber=chamber,
5237 balance=None,
5238 notes=tuple(self._notes()),
5239 )
5240 self.history.append(sample)
5241 return sample
5242
5243 def _supply_to(self, sim: TankSim, pressures: Mapping[str, float]) -> float:
5244 """Pressure of whatever is feeding this ullage [Pa], or zero.
5245
5246 The highest upstream node across a branch carrying gas *into* the
5247 ullage. Zero when nothing is, which leaves the vessel unclamped.
5248 """
5249 net = self.model.built.network
5250 best = 0.0
5251 for branch in net.branches.values():
5252 if branch.downstream != sim.ullage_node:
5253 continue
5254 upstream = pressures.get(branch.upstream)
5255 if upstream is not None:
5256 best = max(best, upstream)
5257 return best
5258
5259 def _pressurant_enthalpy(self, sim: TankSim) -> float:
5260 """Enthalpy of the gas arriving at this ullage [J/kg].
5261
5262 **A regulator is a throttle, so enthalpy is what carries across it**, not
5263 temperature and not entropy. The gas that reaches the tank has the
5264 specific enthalpy it had in the bottle; its *temperature* is then
5265 whatever that enthalpy corresponds to at the delivery pressure, and that
5266 is a real-gas question with an answer that reverses between fluids.
5267
5268 Nitrogen at room temperature sits above its Joule-Thomson inversion
5269 curve, so throttling from 4500 psi to 500 cools it about thirty kelvin.
5270 Helium's inversion temperature is around forty-five kelvin, far below
5271 anything on a pad, so the same expansion *warms* it about seventeen. A
5272 tank being pressed with helium therefore receives gas nearly fifty
5273 kelvin warmer than the same tank on nitrogen -- which is most of why the
5274 two behave so differently out of a small bottle.
5275
5276 Assuming a fixed delivery temperature, as this used to, erases that
5277 entirely: both gases arrive at 293 K and the only thing left telling
5278 them apart is molar mass.
5279 """
5280 # What actually reached this ullage's node this tick, mass-weighted
5281 # over every branch feeding it. The walk carries enthalpy from
5282 # whichever node the gas *came from* -- the bottle through the
5283 # regulator, or the other tank through the shared press manifold --
5284 # and adds the line-wall pickup when that model is on.
5285 #
5286 # This used to read the walk only with the line walls on and quote the
5287 # bottle otherwise, on the argument that an adiabatic path conserves
5288 # enthalpy so the two agree. They agree only for gas that came *from
5289 # the bottle*. Two primed tanks sitting on one press manifold at the
5290 # same pressure trade gas back and forth every step, and gas arriving
5291 # from the fuel tank's 293 K ullage was priced at the bottle's
5292 # enthalpy: helium's is higher at 4500 psi than at 550 (it warms
5293 # through a throttle), so every exchange pumped energy into both
5294 # ullages and the pair warmed six kelvin and rose fifteen psi above
5295 # lockup with nothing flowing through the regulator. Nitrogen ran the
5296 # same error the other way and cooled.
5297 arrived = self.arriving_enthalpy.get(sim.ullage_node)
5298 if arrived is not None:
5299 return float(arrived)
5300
5301 # Nothing walked to this node this tick (no flow above the walk's
5302 # floor, or a property gap): fall back to the bottle it hangs off.
5303 supply = self._bottle_for(sim)
5304 if supply is not None:
5305 try:
5306 return float(supply.volume.enthalpy(supply.state))
5307 except Exception: # noqa: BLE001 - a property gap must not stop a tick
5308 pass
5309 try:
5310 return float(
5311 sim.tank.gas.get("h", p=from_psig(self.setup.dome_psi), T=293.15)
5312 )
5313 except Exception: # noqa: BLE001 - a property gap must not stop a tick
5314 return 0.0
5315
5316 def _bottle_inflow_enthalpy(self, bottle: BottleSim) -> float:
5317 """Specific enthalpy of gas the drawing delivers into a bottle [J/kg]:
5318 the walk's arrival at its node, else cart gas at the fill temperature."""
5319 arrived = self.arriving_enthalpy.get(bottle.node)
5320 if arrived is not None:
5321 return float(arrived)
5322 return float(
5323 bottle.volume.fluid.get(
5324 "h", p=max(bottle.pressure, AMBIENT), T=self.setup.fill_supply_T
5325 )
5326 )
5327
5328 def _bottle_for(self, sim: TankSim) -> BottleSim | None:
5329 """The bottle feeding this ullage, if one is reachable.
5330
5331 Matched on species rather than walked: a stand has one pressurant, and a
5332 walk would have to cross the regulator's dome branch to find it.
5333 """
5334 wanted = sim.tank.gas.name
5335 # The vehicle's own bottle first: the cart's K-bottles hold the same
5336 # gas and are not what presses a flight tank.
5337 ground = self.ground
5338 ordered = sorted(self.bottles.values(), key=lambda b: b.id in ground)
5339 for bottle in ordered:
5340 if bottle.volume.fluid.name == wanted:
5341 return bottle
5342 return next(iter(ordered), None)
5343
5344 def _fills(self, sim: TankSim) -> bool:
5345 """Whether the current state is filling this tank. Never a cart's tank:
5346 that is where a load comes from, pre-loaded.
5347
5348 Matched on the state's name against the tank's fluid, because fill comes
5349 from a tanker that is not on the drawing -- see FILL_RATE. The rule is
5350 :func:`~feedtwin.session.statemachine.fills`, which an edited table is
5351 checked against.
5352 """
5353 if "fill" not in self.state.lower() or sim.id in self.ground:
5354 return False
5355 net = self.model.built.network
5356 species = net.nodes[sim.outlet_node].fluid
5357 return fills(self.state, "lox" if species == "oxygen" else "fuel")
5358
5359 def _build_line_walls(self) -> None:
5360 """One lumped wall per line, started at the temperature its line holds.
5361
5362 This is the whole of the "soak" question, answered without a soak model.
5363 A wall left to sit converges on the temperature of the fluid inside it,
5364 so that is where it starts -- and it falls out per line for free:
5365
5366 * a line full of liquid oxygen has been sitting near 90 K, so its
5367 fittings are cold and have little heat to give;
5368 * the ullage side above that tank holds cold vapour, cooler than the room
5369 and nowhere near the liquid;
5370 * a pressurant line that has only ever seen ambient gas is at ambient,
5371 and is the one with real heat in it.
5372
5373 Three very different wall temperatures, none of them assumed. Tracking
5374 how a stand drifts while it sits would need an ambient boundary, an
5375 insulation state and an hours-long clock on every line, and would land
5376 in the same place this does.
5377 """
5378 self.walls: dict[str, LineWall] = {}
5379 self.wall_temperature: dict[str, float] = {}
5380 #: Specific enthalpy the walk delivered to each node this tick [J/kg].
5381 #: Read by `_pressurant_enthalpy` so a tank is fed what actually reached
5382 #: it rather than what left the bottle.
5383 self.arriving_enthalpy: dict[str, float] = {}
5384 net = self.model.built.network
5385 for branch_id, branch in net.branches.items():
5386 component = branch.component
5387 runs = wall_runs(component)
5388 if not runs:
5389 continue
5390 bore = wall_bore(runs)
5391 area = sum(math.pi * b * max(length, b) for b, length in runs)
5392 # One weighed figure for the whole run beats every estimate below.
5393 weighed = component.p.get("line_mass", 0.0)
5394 if weighed > 0.0:
5395 self.walls[branch_id] = LineWall(mass=weighed, area=area, bore=bore)
5396 continue
5397 thickness = component.p.get("wall_thickness", 0.0)
5398 fittings = component.p.get("fitting_mass", 0.0)
5399 if fittings <= 0.0:
5400 # No weighed figure, so fall back to what the user counted.
5401 # Mass wins when both are given: somebody who went and weighed
5402 # the run has better information than a bore-scaled estimate.
5403 fittings = fitting_metal(component.p.get("fitting_count", 0.0), bore)
5404 tube = 0.0
5405 if thickness > 0.0:
5406 for b, length in runs:
5407 if length > 0.0:
5408 outer = b + 2.0 * thickness
5409 tube += (
5410 math.pi / 4.0 * (outer**2 - b**2) * length
5411 ) * STAINLESS_DENSITY
5412 mass = tube + fittings
5413 if mass <= 0.0:
5414 continue
5415 self.walls[branch_id] = LineWall(mass=mass, area=area, bore=bore)
5416 # Temperatures are *not* set here. At build time a node still carries
5417 # whatever the drawing declared, so seeding the metal now would put the
5418 # line off a LOX ullage at liquid temperature rather than vapour
5419 # temperature. `_propagate_temperatures` seeds each wall on the first
5420 # tick it has settled node temperatures to seed it from, which costs one
5421 # tick of no wall heat and gets the three tiers -- liquid, ullage
5422 # vapour, ambient pressurant -- right without a soak model.
5423
5425 self,
5426 fluid: Fluid,
5427 pressure: float,
5428 enthalpy: float,
5429 landed: float,
5430 node_id: str,
5431 ) -> float:
5432 """A gas node the walk landed inside the two-phase dome, held as
5433 saturated vapour :data:`_DEW_MARGIN_K` above its dew point.
5434
5435 Venting cold pressurant expands it down its own saturation line: GN2
5436 off a chilled LOX tank, throttled to an atmosphere, landed at 77.87 K
5437 and 107.6 kPa -- nitrogen's boiling point there -- and every solve
5438 after it failed on its first property call (the state is on the line,
5439 so liquid or vapour is not said). The lines carry one phase, so a
5440 mostly-vapour landing (quality at least a half) is taken as the
5441 vapour it nearly is; the fog it would really carry is not modelled,
5442 and the notes say so once. A mostly-liquid landing -- a flashing
5443 liquid -- is left where it was.
5444 """
5445 try:
5446 h_dew = fluid.get("h", p=pressure, q=1.0)
5447 h_bubble = fluid.get("h", p=pressure, q=0.0)
5448 except (ValueError, PropertyError):
5449 return landed # supercritical or out of range: no dome to land in
5450 if not h_bubble < enthalpy <= h_dew:
5451 return landed
5452 if (enthalpy - h_bubble) < 0.5 * (h_dew - h_bubble):
5453 return landed
5454 dew = fluid.get("T", p=pressure, q=1.0)
5455 note = (
5456 "Vent gas expanded to its dew point; lines carry one phase, so it is "
5457 f"held as saturated vapour {_DEW_MARGIN_K:g} K above it (the "
5458 "condensate it would carry is not modelled)."
5459 )
5460 if note not in self.assumptions:
5461 self.assumptions.append(note)
5462 return max(landed, dew + _DEW_MARGIN_K)
5463
5465 self,
5466 pressures: Mapping[str, float],
5467 flows: Mapping[str, float],
5468 dt: float = 0.0,
5469 ) -> None:
5470 """Carry enthalpy along the flow path, so every node has a real temperature.
5471
5472 Node temperatures were set once at build time and never written again.
5473 The vessel models track cooling carefully -- a LOX ullage at 268.7 K
5474 where the drawing said 293.15 -- and the network then metered gas out of
5475 that node at the temperature it was born with. A 24.5 K error is about
5476 9% on density and 4.5% on the flow through it, and it grew through a
5477 burn, so it was a drifting error rather than a constant one.
5478
5479 **Enthalpy, not temperature, is what propagates.** Every component here
5480 is adiabatic, and an adiabatic component with no shaft work conserves
5481 enthalpy -- which is exactly the argument this codebase already makes for
5482 the regulator, and the reason nitrogen cools ~30 K through it while
5483 helium warms ~17 K. Carrying ``h`` and re-solving ``T(h, p)`` at each
5484 node's own pressure therefore gets Joule-Thomson right at *every*
5485 throttle for free, rather than special-casing the one we thought about.
5486
5487 What it does not model: heat into the line walls, and viscous heating.
5488 Both are small against a five-second burn and neither is free to add --
5489 a wall needs its own thermal state per line.
5490
5491 Lagged by one tick, deliberately. This runs *after* the solve and
5492 annotates the network, so it cannot perturb the Newton iteration; the
5493 next tick's solve picks up the new temperatures. Same arrangement as the
5494 chamber boundary, and for the same reason.
5495 """
5496 net = self.model.built.network
5497 # Cleared, not carried: a node that stops being fed this tick must fall
5498 # back to the bottle rather than keep quoting last tick's arrival.
5499 self.arriving_enthalpy.clear()
5500 known: dict[str, float] = {}
5501 #: Heat each line gave its stream this pass, and the stream's inlet
5502 #: temperature, so the repay step knows what the wall is relaxing
5503 #: toward and cannot step past it. [(W, K)]
5504 heat_drawn: dict[str, tuple[float, float]] = {}
5505
5506 # Seeds: anything whose temperature is a state we already integrate.
5507 for sim in self.tanks.values():
5508 known[sim.ullage_node] = sim.tank.gas_temperature(sim.state)
5509 known[sim.outlet_node] = sim.state.liquid_temperature
5510 for bottle in self.bottles.values():
5511 known[bottle.node] = bottle.volume.temperature(bottle.state)
5512
5513 for node_id, temperature in known.items():
5514 node = net.nodes.get(node_id)
5515 if node is not None and temperature > 0.0:
5516 node.temperature = temperature
5517
5518 # Then walk outwards along the flow, mass-weighting the arrivals. A few
5519 # sweeps is plenty for a stand -- the longest path from a bottle to the
5520 # injector face is under a dozen branches -- and bounding it keeps a
5521 # recirculating drawing from spinning here.
5522 # Which lines feed each node, in branch order. The flows are fixed for
5523 # the whole walk, so this is worked out once rather than by asking
5524 # every line on the stand about every node on every sweep.
5525 feeding: dict[str, list[tuple[str, float, str]]] = {}
5526 for branch_id, branch in net.branches.items():
5527 mdot = flows.get(branch_id, 0.0)
5528 if abs(mdot) < _TEMPERATURE_MIN_FLOW:
5529 continue
5530 source = branch.upstream if mdot > 0.0 else branch.downstream
5531 sink = branch.downstream if mdot > 0.0 else branch.upstream
5532 if source not in net.nodes:
5533 continue
5534 feeding.setdefault(sink, []).append((branch_id, mdot, source))
5535
5536 for _ in range(_TEMPERATURE_SWEEPS):
5537 settled = True
5538 for node_id, node in net.nodes.items():
5539 pinned = node_id in known
5540 arriving: list[tuple[float, float]] = []
5541 for branch_id, mdot, source in feeding.get(node_id, ()):
5542 upstream = net.nodes[source]
5543 p_up = pressures.get(source)
5544 if p_up is None or p_up <= 0.0:
5545 continue
5546 try:
5547 # The network's own instance: a fresh Fluid builds fresh
5548 # CoolProp states, and doing that per branch per sweep was
5549 # a third of a burning tick.
5550 fluid = net.fluid(upstream.fluid)
5551 T_in = upstream.temperature
5552 h = fluid.get("h", p=p_up, T=T_in)
5553 # The line's own metal, if it has any and the run wants
5554 # it. Enthalpy is still what propagates; this adds the
5555 # heat the wall gave the stream on the way through, so
5556 # Joule-Thomson and wall pickup compose rather than
5557 # compete for the same term.
5558 wall = self.walls.get(branch_id)
5559 seeded = self.wall_temperature.get(branch_id)
5560 if (
5561 wall is not None
5562 and seeded is not None
5563 and self.setup.line_walls
5564 ):
5565 exchange = wall.exchange(
5566 mdot=mdot,
5567 wall_temperature=seeded,
5568 inlet_temperature=T_in,
5569 density=fluid.get("rho", p=p_up, T=T_in),
5570 viscosity=fluid.get("mu", p=p_up, T=T_in),
5571 conductivity=fluid.get("k", p=p_up, T=T_in),
5572 heat_capacity=fluid.get("cp", p=p_up, T=T_in),
5573 )
5574 h += exchange.heat / max(abs(mdot), 1e-12)
5575 heat_drawn[branch_id] = (exchange.heat, T_in)
5576 except (ValueError, PropertyError):
5577 continue
5578 arriving.append((abs(mdot), h))
5579 if not arriving:
5580 continue
5581 total = sum(mdot for mdot, _ in arriving)
5582 if total <= 0.0:
5583 continue
5584 enthalpy = sum(mdot * h for mdot, h in arriving) / total
5585 self.arriving_enthalpy[node_id] = enthalpy
5586 if pinned:
5587 # A vessel node's temperature is a state this session
5588 # integrates; the walk may not overwrite it. What arrives
5589 # there is still worth keeping -- it is what the tank is
5590 # being fed, wall pickup and all, and `_pressurant_enthalpy`
5591 # reads it.
5592 continue
5593 p_here = pressures.get(node_id)
5594 if p_here is None or p_here <= 0.0:
5595 continue
5596 try:
5597 # h is conserved across the component; T falls out of the
5598 # equation of state at this node's own pressure. That step
5599 # is the Joule-Thomson effect.
5600 fluid_here = net.fluid(node.fluid)
5601 landed = fluid_here.get("T", p=p_here, h=enthalpy)
5602 if node.phase != "liquid":
5603 landed = self._off_the_dew_line(
5604 fluid_here, p_here, enthalpy, landed, node_id
5605 )
5606 except (ValueError, PropertyError):
5607 continue
5608 if landed > 0.0 and abs(landed - node.temperature) > _TEMPERATURE_TOL:
5609 node.temperature = landed
5610 settled = False
5611 if settled:
5612 break
5613
5614 # Metal that has not been given a temperature yet takes the one the
5615 # fluid standing in it has, now that the walk has settled and the node
5616 # temperatures are real rather than whatever the drawing was built with.
5617 #
5618 # This is the whole of the "what temperature is the metal at?" model,
5619 # and it is deliberately this small. A LOX line is seeded at LOX
5620 # temperature, a line off the ullage at the ullage's, a pressurant line
5621 # at the bottle's -- because that is the fluid sitting in each at rest.
5622 # What it does *not* do is track how a stand cools while it sits: that
5623 # would need a soak model, an ambient film on every line, and a history
5624 # of how long the vehicle has been loaded, to predict a starting
5625 # temperature that the fluid inventory already tells us.
5626 for branch_id in self.walls:
5627 if branch_id in self.wall_temperature:
5628 continue
5629 line = net.branches.get(branch_id)
5630 at_rest = ROOM
5631 if line is not None:
5632 inlet = net.nodes.get(line.upstream)
5633 if inlet is not None:
5634 at_rest = inlet.temperature
5635 self.wall_temperature[branch_id] = at_rest if at_rest > 0.0 else ROOM
5636
5637 # The wall pays for what it gave. Integrated here rather than inside the
5638 # sweep so a wall cools once per tick on the heat it actually delivered,
5639 # not once per sweep on a figure that was still converging.
5640 for branch_id, (heat, stream) in heat_drawn.items():
5641 wall = self.walls.get(branch_id)
5642 if wall is None or dt <= 0.0:
5643 continue
5644 temperature = self.wall_temperature[branch_id]
5645 capacity = wall.capacity(temperature)
5646 if capacity <= 0.0:
5647 continue
5648 moved = temperature - heat * dt / capacity
5649 # Bounded by the wall reaching the stream: metal cannot drive itself
5650 # past the gas it is warming, and an over-long step must not let it.
5651 # Sign-agnostic, because this runs both ways -- warm gas through a
5652 # cold LOX line is the same equation with the heat reversed, and is
5653 # the frost on the downstream fittings.
5654 if (temperature - stream) * (moved - stream) < 0.0:
5655 moved = stream
5656 self.wall_temperature[branch_id] = moved
5657
5658 def _chamber(self, flows: Mapping[str, float]) -> None:
5659 """The chamber boundary when there is no engine model on the drawing."""
5660 ports = self.model.built.engine_ports
5661 net = self.model.built.network
5662 if self.model.chamber is not None and "chamber" in ports:
5663 return
5664 if not self._chamber_node:
5665 return
5666 # No engine model, so the ENGINE symbol's own `pressure` is the
5667 # boundary -- the honest "nobody has said what is on the end of the
5668 # pipe" case, and the back pressure a burn needs to flow against.
5669 #
5670 # But that number is a *design chamber pressure*, and a chamber only
5671 # holds it while it is lit. Applying it at rest put the whole
5672 # downstream leg at 350 psi on a cold stand with every valve shut,
5673 # which is what a gauge there would never show. Cold, the chamber is
5674 # open to atmosphere through its own nozzle.
5675 # "Is it lit?" asked of the flow, not of the state machine.
5676 #
5677 # The obvious test -- are the mains commanded open -- is defeated by
5678 # the shipped table, whose `Idle` row opens the fuel main. Propellant
5679 # actually arriving at the chamber is the physical question and does
5680 # not care what the table says.
5681 # Arriving, not touching: a flow out of the chamber is the boundary
5682 # pushing back up the feed, and counted as "lit" it held the chamber at
5683 # its design pressure after the burn, so an Engine Abort's open mains
5684 # drove 3 kg/s from a dead engine into the tanks.
5685 chamber = self._chamber_node
5686 arriving = 0.0
5687 for branch_id, branch in net.branches.items():
5688 flow = flows.get(branch_id, 0.0)
5689 if branch.downstream == chamber:
5690 arriving += max(flow, 0.0)
5691 elif branch.upstream == chamber:
5692 arriving += max(-flow, 0.0)
5693 # And only while the tanks can hold it: a fixed chamber above the feed
5694 # pushes back up the mains, which reads as cold, which lets the feed
5695 # flow, which reads as lit -- an Engine Abort on a 270 psia fuel tank
5696 # against a 385 psia drawing flipped every solve, and each backward
5697 # half took mass in from the chamber that no vessel held.
5698 # The vehicle's tanks: a cart dewar at 50 psig, or a transfer tank the
5699 # loads left dry, feeds no engine and kept a cold flow unlit.
5700 vehicle = set(self.vehicle_tanks)
5701 feeds = [
5702 sim.outlet_pressure
5703 for tank_id, sim in self.tanks.items()
5704 if tank_id in vehicle and not sim.empty
5705 ]
5706 lit = arriving > MIN_CHAMBER_FLOW and bool(feeds)
5707 lit = lit and min(feeds) > self._design_chamber
5708 net.nodes[chamber].pressure = self._design_chamber if lit else AMBIENT
5709
5710 def _burnout_check(self) -> None:
5711 """Burnout: a tank ran dry during Fire, so the sequence goes to Vent.
5712
5713 The stand's fire is timed and ends in Vent; the twin's ends when the
5714 propellant does. Without this the mains stayed open on empty tanks and
5715 both sat at lockup with the regulator holding them there, which the
5716 operator rightly read as "nothing vented". Vent is what the table
5717 says it is: both tank vents, both press valves, the manifold vent.
5718 """
5719 if not self.setup.auto_vent or self.state != "Fire":
5720 return
5721 # The vehicle's tanks only, as `burn` counts them: a cart's transfer
5722 # tank the loads left dry sent every Fire to Vent at T+0.00 s.
5723 vehicle = set(self.vehicle_tanks)
5724 dry = [
5725 sim
5726 for tank_id, sim in self.tanks.items()
5727 if tank_id in vehicle and sim.empty
5728 ]
5729 if not dry or not self.machine.can_go(self.state, "Vent"):
5730 return
5731 # T+ from Fire, not the stand clock: "T+302.7 s" after a 3.5 s burn
5732 # read as a five-minute one.
5733 lit = self.t - self._state_since
5734 names = ", ".join(sim.label for sim in dry)
5735 fired = self.lit_this_state
5736 self.command_state("Vent")
5737 # A tank drained through an engine that never lit, or with no engine
5738 # on the stand, is not a burnout: "Burnout" there read as a burn that
5739 # never happened (D-10), and the pad guide counted it as one.
5740 if fired:
5741 self.burnout = (
5742 f"Burnout at T+{lit:.2f} s: {names} ran dry, so the sequence "
5743 "went to Vent."
5744 )
5745 elif self.model.engine is None:
5746 self.burnout = (
5747 f"Flow test, no engine: {names} ran dry at T+{lit:.2f} s, so the "
5748 "sequence went to Vent."
5749 )
5750 else:
5751 self.burnout = (
5752 f"Not lit: {names} ran dry at T+{lit:.2f} s on a cold flow, so "
5753 "the sequence went to Vent."
5754 )
5755
5756 @property
5757 def lit_this_state(self) -> bool:
5758 """Whether the engine has made thrust since the present state began."""
5759 return self._lit_at is not None and self._lit_at > self._state_since
5760
5761 def _fire_notes(self) -> list[str]:
5762 """What Fire is actually doing, and what an abort's row leaves open.
5763
5764 Fire with nothing lit is a cold flow: tanks too low to start the
5765 engine push propellant through it at a few psi and no thrust, and the
5766 console called that "Burning" for as long as anyone let it (D-10).
5767 An abort the stand's table writes with a main open keeps feeding the
5768 engine; the twin does what the DAQ would (NEEDS-REPAIR.md), and says
5769 so where the operator is looking, with where to fix it (D-18).
5770 """
5771 out: list[str] = []
5772 held = self.t - self._state_since
5773 if self.state == "Fire" and held > COLD_FLOW_S and not self.lit_this_state:
5774 tanks = ", ".join(
5775 f"{sim.label} {say(sim.pressure)}"
5776 for k, sim in self.tanks.items()
5777 if k in self.vehicle_tanks
5778 )
5779 out.append(
5780 "Flow test, no engine on this stand: nothing burns."
5781 if self.model.engine is None
5782 else "Not lit: cold flow. The engine makes no thrust at these "
5783 f"tank pressures ({tanks}); abort and press the tanks first."
5784 )
5785 if self.machine.is_abort(self.state):
5786 positions = self.machine.positions.get(self.state, {})
5787 mains = sorted(
5788 a
5789 for a in self.machine.actuators
5790 if "main" in a.lower() and positions.get(a)
5791 )
5792 if mains:
5793 out.append(
5794 f"{self.state} leaves {' and '.join(mains)} open: the "
5795 "stand's state table says so. Fix the row on the State "
5796 "machine tab."
5797 )
5798 return out
5799
5800 def _check_limits(self) -> None:
5801 """Trip the stand if a vessel is over the pressure the drawing rates it for.
5802
5803 A hand-loaded regulator turned too far, a shut LOX tank left to boil,
5804 a fill with the vent closed: the model would happily carry a tank to
5805 the propellant's critical pressure and beyond. A real one lets go.
5806 Above its MAWP the stand stops, says which vessel and how far, and
5807 asks to be reset -- the operator learns what they did, and nothing
5808 pretends to run on past a failure.
5809 """
5810 if self.tripped:
5811 return
5812 # The vessel furthest over, not the first one looked at: two tanks
5813 # primed over MAWP together named the fuel tank at 935.7 psia while
5814 # the LOX tank sat at 963.5 (D-33).
5815 over = [
5816 (sim.pressure / sim.mawp, sim.id, sim.label, "tank", sim.pressure, sim.mawp)
5817 for sim in self.tanks.values()
5818 if sim.mawp > 0.0 and sim.pressure > sim.mawp
5819 ] + [
5820 (b.pressure / b.mawp, b.id, b.label, "bottle", b.pressure, b.mawp)
5821 for b in self.bottles.values()
5822 if b.mawp > 0.0 and b.pressure > b.mawp
5823 ]
5824 if not over:
5825 return
5826 _, vessel, label, kind, pressure, mawp = max(over, key=lambda o: o[0])
5827 self.trip = Trip(vessel, label, kind, self.t, pressure, mawp)
5828 # Past its rating, not failed: the limit is the drawing's burst over a
5829 # factor of safety, and "would have failed" was said at half the burst.
5830 # A relief drawn on the vessel at or above the limit can never lift
5831 # before it, and the operator should hear that rather than wonder why
5832 # the drawn relief did nothing.
5833 labels = {n.id: n.label or n.id for n in self.model.diagram.nodes}
5834 late = sorted(
5835 labels.get(branch_id, branch_id)
5836 for branch_id, branch in self._reliefs_on(vessel)
5837 if float(branch.component.set_pressure) >= mawp - ATMOSPHERE - 0.5 * PSI
5838 )
5839 reliefs = (
5840 f"{' and '.join(late)} {'is' if len(late) == 1 else 'are'} set at or "
5841 "above that limit and cannot lift before it. "
5842 if late
5843 else ""
5844 )
5845 self.tripped = (
5846 f"{label} reached {say(pressure, 1)}, over its {say(mawp, 1)} MAWP: "
5847 f"past its rating a real {kind} is at risk, so the stand stops here. "
5848 f"{reliefs}Reset the stand and start over."
5849 )
5850
5851 def _reliefs_on(self, vessel: str) -> list[tuple[str, Any]]:
5852 """The relief branches drawn on ``vessel``'s gas side: reached from its
5853 gas node without crossing a regulator, another vessel or another relief.
5854 A COPV's relief does not protect the tanks behind its regulator, nor one
5855 tank's relief the tank across the press manifold."""
5856 net = self.model.built.network
5857 if vessel in self.tanks:
5858 start = self.tanks[vessel].ullage_node
5859 elif vessel in self.bottles:
5860 start = self.bottles[vessel].node
5861 else:
5862 return []
5863 reliefs = dict(self._reliefs)
5864 found: dict[str, Any] = {}
5865 seen = {start}
5866 frontier = [start]
5867 while frontier:
5868 nxt: list[str] = []
5869 for node in frontier:
5870 for branch_id, branch in net.branches.items():
5871 if node not in (branch.upstream, branch.downstream):
5872 continue
5873 if branch_id in reliefs:
5874 found[branch_id] = branch
5875 continue
5876 if isinstance(branch.component, Regulator):
5877 continue
5878 other = (
5879 branch.upstream
5880 if branch.downstream == node
5881 else branch.downstream
5882 )
5883 if other in seen:
5884 continue
5885 seen.add(other)
5886 if not net.nodes[other].is_fixed:
5887 nxt.append(other)
5888 frontier = nxt
5889 return list(found.items())
5890
5891 def _notes(self) -> list[str]:
5892 out: list[str] = []
5893 if self.burnout and self.state == "Vent":
5894 out.append(self.burnout)
5895 out.extend(self._fire_notes())
5896 for sim in self.tanks.values():
5897 if sim.chilling:
5898 out.append(
5899 f"{sim.label} is chilling down: wall at "
5900 f"{sim.state.ullage.wall_temperature:.0f} K. The LOX flashes off "
5901 "and vents until the metal is at saturation; nothing collects yet."
5902 )
5903 elif sim.empty:
5904 out.append(f"{sim.label} is empty.")
5905 elif (
5906 sim.tank.fill_fraction(sim.state) < self.setup.low_tank
5907 and not sim.filling
5908 ):
5909 # "down to 2%" while the tank is loading read as a leak.
5910 out.append(
5911 f"{sim.label} is down to "
5912 f"{sim.tank.fill_fraction(sim.state) * 100:.0f}%."
5913 )
5914 readouts = sim.readouts()
5915 excess = readouts["wall_temperature_K"] - sim.state.liquid_temperature
5916 boiling = False
5917 if (
5918 not sim.empty
5919 and sim.tank.wall_boiling
5920 and excess > self.setup.warm_wall_K
5921 ):
5922 # Only while it actually can: a wall above saturation at the
5923 # tank's total pressure. Pressed to 38 bar the same warm wall
5924 # grows no bubbles and the note would be crying wolf.
5925 try:
5926 boiling = readouts["wall_temperature_K"] > sim.tank.liquid.get(
5927 "T", p=sim.pressure, q=0.0
5928 )
5929 except (
5930 Exception
5931 ): # noqa: BLE001 - above the critical point, say nothing
5932 boiling = False
5933 if (
5934 not sim.empty
5935 and sim.state.liquid_temperature < CRYOGENIC_K
5936 and excess > self.setup.warm_wall_K
5937 and boiling
5938 ):
5939 # The thing that turns a shut LOX tank into a runaway. A
5940 # warm wall boils the liquid it touches at kilowatts, and
5941 # with the vent shut that all goes into the ullage. The
5942 # operator's move is to keep venting until the frost forms;
5943 # the note says so before the gauge does.
5944 out.append(
5945 f"{sim.label} wall is {excess:.0f} K above the liquid and "
5946 "boiling it; keep the vent open until it chills, or the "
5947 "tank climbs hard the moment it shuts."
5948 )
5949 # The propellant's own state, not the tank's total pressure: 90 K
5950 # LOX under 745 psia of nitrogen is an ordinary compressed liquid,
5951 # and the note said "at its critical pressure, the model can go no
5952 # higher" of a tank already above it. What nears the critical point
5953 # is the propellant: its vapour's own partial pressure (a shut tank
5954 # boiled up on its own oxygen), or a liquid warmed toward it.
5955 near = False
5956 try:
5957 p_crit = float(sim.tank.liquid.critical_pressure)
5958 t_crit = float(sim.tank.liquid.critical_temperature)
5959 except Exception: # noqa: BLE001 - a measured table has no critical point
5960 p_crit = t_crit = 0.0
5961 if p_crit > 0.0:
5962 try:
5963 near = float(sim.tank.vapour_pressure(sim.state)) > 0.97 * p_crit
5964 except Exception: # noqa: BLE001 - no vapour state to read
5965 near = False
5966 if p_crit > 0.0 and not near and not sim.empty:
5967 hottest = max(
5968 sim.state.liquid_temperature, sim.state.surface_temperature or 0.0
5969 )
5970 try:
5971 near = hottest >= t_crit or (
5972 float(sim.tank.liquid.get("p", T=hottest, q=0.0))
5973 > 0.97 * p_crit
5974 )
5975 except Exception: # noqa: BLE001 - no saturation line to read
5976 near = False
5977 if near:
5978 # "No relief valve" only of a tank with none drawn on it: a
5979 # relief on the COPV, or on the other tank, does not lift for
5980 # this one (:meth:`_reliefs_on`).
5981 out.append(
5982 f"{sim.label}'s propellant is near its critical point "
5983 f"({say(p_crit)}, {t_crit:.1f} K), where the model's liquid "
5984 "properties end. "
5985 + (
5986 ""
5987 if self._reliefs_on(sim.id)
5988 else f"No relief valve is drawn on {sim.label}; a real "
5989 "tank would have lifted one long ago. "
5990 )
5991 + "Vent it."
5992 )
5993 out.extend(self._leaking_notes())
5994 ground = self.ground
5995 verbs = {GSE_CHARGE: "charge", GSE_DUMP: "dump"}
5996 for bottle in self.bottles.values():
5997 # Its GSE is not drawn, so only the table's own actuators fill and
5998 # dump it. Renamed or dropped from the CSV, they silently would not.
5999 undrawn = bottle.id not in self._drawn_fill and bottle.id not in ground
6000 missing = [
6001 name for name in verbs if undrawn and name not in self.machine.actuators
6002 ]
6003 if missing:
6004 out.append(
6005 f"{bottle.label}'s fill is not on the drawing and the state "
6006 f"table has no {' or '.join(repr(m) for m in missing)} row, "
6007 f"so no state can {' or '.join(verbs[m] for m in missing)} it."
6008 )
6009 if not bottle.charged:
6010 out.append(f"{bottle.label} is not charged.")
6011 elif (
6012 bottle.volume.temperature(bottle.state) - bottle.state.wall_temperature
6013 > self.setup.hot_bottle_K
6014 ):
6015 # The number an operator reads as a leak. An adiabatic fill
6016 # lands well above the steel and the pressure follows the gas
6017 # down as the two settle; naming it here is what stops the
6018 # next person hunting a leak through the regulator.
6019 excess = (
6020 bottle.volume.temperature(bottle.state)
6021 - bottle.state.wall_temperature
6022 )
6023 out.append(
6024 f"{bottle.label} is {excess:.0f} K hotter than its wall from the "
6025 "fill; it will sag as it cools -- that is the fill, not a leak. "
6026 "Top up before Ready."
6027 )
6028 elif (
6029 bottle.pressure < LOW_BOTTLE * bottle.target <= bottle.peak
6030 and not bottle.filling
6031 ):
6032 # Down from what it held, and not while it charges: the note
6033 # read "down to 107 psia, 3% of what it was filled to" through
6034 # the first quarter of every charge from empty. Down past the
6035 # line from above it, too: a bottle that never held more --
6036 # primed low at T-0, a charge stopped short, a pulse fill
6037 # paused -- read "down to 415 psia, 100% of the 415 psia it was
6038 # filled to" the moment it cooled a pascal.
6039 out.append(
6040 f"{bottle.label} is down to {say(bottle.pressure)}, "
6041 f"{bottle.pressure / bottle.peak * 100:.0f}% of the "
6042 f"{say(bottle.peak)} it was filled to."
6043 )
6044 return out
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
None advance(self, float dt, *, float mdot_out, float stirring=1.0, float mdot_in=0.0, float enthalpy_in=0.0)
Definition core.py:2116
VesselState _air(self, VesselState state)
Definition core.py:2192
VesselState _at_atmosphere(self, VesselState stepped)
Definition core.py:2205
float _net_into(self, str node, Mapping[str, float] flows)
Definition core.py:5009
None _chamber(self, Mapping[str, float] flows)
Definition core.py:5658
dict[str, str] builtin(self)
Definition core.py:2593
Sample step(self, float dt)
Definition core.py:5013
tuple[float, float] _split_at(self, str node, Mapping[str, float] flows)
Definition core.py:4973
float _loader_supply(self, object loader)
Definition core.py:3058
frozenset[str]|None _boundary
Definition core.py:2343
float|None _ullage_wall_T0(self, str tank_id, TankSim sim)
Definition core.py:3047
list[str] _leaking_notes(self)
Definition core.py:4922
dict[str, frozenset[str]] _find_cut_dome_ports(self)
Definition core.py:2611
bool _loading(self, TankSim sim)
Definition core.py:4643
tuple[str,...] vehicle_tanks(self)
Definition core.py:2570
dict[str, Any] _tank_wall_defaults(self)
Definition core.py:3096
frozenset[str] _dry_branches(self)
Definition core.py:3499
SteadyResult _solve(self, Network net, Mapping[str, float] signals, frozenset[str] isolate, Mapping[str, tuple[float, float]]|None storage)
Definition core.py:4265
dict[str, str] _fill_stubs
Definition core.py:2434
frozenset[str] _drawn_fill
Definition core.py:2453
None prime(self, *, float fill_fraction=0.95, float tank_psi=500.0, float copv_psi=0.0, str state="Ready", float hold_s=PAD_HOLD_S, Mapping[str, float]|None loads=None)
Definition core.py:2940
frozenset[str] _boundary_nodes(self)
Definition core.py:4134
Deque[SolverRecord] solver_log
Definition core.py:2339
None _accept(self, SteadyResult result)
Definition core.py:4239
list[PidNode] _cut_dome_regulators(self)
Definition core.py:2598
SteadyResult _advance_once(self, Network net, Mapping[str, float] signals, float dt, int substeps=SUBSTEPS)
Definition core.py:4014
float _off_the_dew_line(self, Fluid fluid, float pressure, float enthalpy, float landed, str node_id)
Definition core.py:5431
float _travel_time(self, str drawing_id)
Definition core.py:3452
dict[str, float] _regulator_slopes(self, frozenset[str] shut)
Definition core.py:3674
None set_valve(self, str drawing_id, bool is_open)
Definition core.py:3191
dict[str, tuple[float, float]] _ullage_storage(self, float dt, frozenset[str] isolated)
Definition core.py:3861
None _close_outlet(self, TankSim sim, float dt, set[str] cut, dict[str, tuple[float, float]] out)
Definition core.py:3959
None _move_vessels(self, Mapping[str, float] flows, Mapping[str, float] pressures, float dt, int substeps=SUBSTEPS)
Definition core.py:4436
frozenset[str] _through_outlets(self)
Definition core.py:3471
list[str] skip_chilldown(self, str tank_id="")
Definition core.py:3204
dict[str, float] fire_loads(self)
Definition core.py:2524
float _bottle_inflow_enthalpy(self, BottleSim bottle)
Definition core.py:5316
dict[str, str] _find_fill_stops(self)
Definition core.py:4588
float _press_path_timescale(self, TankSim sim, float capacitance, Mapping[str, float]|None signals, float dt=0.0)
Definition core.py:3738
float _vent_fraction(self, str node, Mapping[str, float] flows)
Definition core.py:4814
BottleSim|None _bottle_for(self, TankSim sim)
Definition core.py:5328
float _through_loader(self, DomeLoader loader, float psig_set)
Definition core.py:3423
float _slew(self, str drawing_id, float target, float dt)
Definition core.py:3437
float|None _arriving_liquid_T(self, TankSim sim, Mapping[str, float] flows)
Definition core.py:4745
dict[str, float] signals(self, float dt=0.0)
Definition core.py:3236
Sample _integrate(self, float dt)
Definition core.py:5043
tuple[float, float, float] _supply_press(self, TankSim sim, float dt=0.0)
Definition core.py:4687
None _rest_ground(self, Mapping[str, float] signals)
Definition core.py:3537
list[str] _fire_notes(self)
Definition core.py:5761
tuple[float, float] _outlet_split(self, TankSim sim, Mapping[str, float] flows, frozenset[str]|None through=None)
Definition core.py:5001
dict[str, float] _relief_signals(self, Network net, Mapping[str, float] signals, Mapping[str, float]|None pressures=None)
Definition core.py:4208
None release(self, str drawing_id="")
Definition core.py:3194
bool _fills(self, TankSim sim)
Definition core.py:5344
tuple[frozenset[str], frozenset[str]] _unreachable_ground(self, frozenset[str] shut)
Definition core.py:3558
float _coupling_timescale(self, Mapping[str, float]|None signals=None, float dt=0.0, bool press=True)
Definition core.py:3627
dict[str, str] _fill_stops
Definition core.py:2492
frozenset[str] _find_drawn_fills(self)
Definition core.py:2575
dict[str, float] peek_signals(self, *, bool loaded_dome=False)
Definition core.py:3328
list[str] short_loads(self)
Definition core.py:2543
float _pressurant_enthalpy(self, TankSim sim)
Definition core.py:5259
list[str] _notes(self)
Definition core.py:5891
float _gated_dome(self, DomeLine|None line, float live, Mapping[str, float] commanded)
Definition core.py:3358
float _dry_cut(self, Mapping[str, float] flows, float dt)
Definition core.py:4101
dict[str, float]|None _fire_loads
Definition core.py:2376
None _note_solve(self, SteadyResult result)
Definition core.py:4122
Mapping[str, float] _held(self, SteadyResult result)
Definition core.py:4318
tuple[float, float, float] _ledger_totals(self)
Definition core.py:4150
frozenset[str] _fill_hoses
Definition core.py:2442
list[tuple[str, Any]] _reliefs_on(self, str vessel)
Definition core.py:5851
bool _venting(self, str node, Mapping[str, float] flows)
Definition core.py:4805
float _supply_to(self, TankSim sim, Mapping[str, float] pressures)
Definition core.py:5243
None _propagate_temperatures(self, Mapping[str, float] pressures, Mapping[str, float] flows, float dt=0.0)
Definition core.py:5469
None _apply_vessel_pressures(self)
Definition core.py:4859
Callable[[], GasFilm]|None _film_estimator(self, Fluid gas, object node, CylindricalTank geometry)
Definition core.py:3074
None __init__(self, Model model, StateMachine machine, Binding binding, *, str state="Idle", Setup|None setup=None, Hookup|None hookup=None)
Definition core.py:2287
ChamberResult|None _last_chamber
Definition core.py:2384
dict[str, Any] _bottle_wall_defaults(self)
Definition core.py:3104
dict[str, float] _vessel_pressures(self)
Definition core.py:3854
None _record_solver(self, int couplings)
Definition core.py:4168
SteadyResult _close_chamber(self, Network net, Mapping[str, float] signals, frozenset[str] dry, SteadyResult result, Mapping[str, tuple[float, float]]|None storage=None)
Definition core.py:4345
dict[str, DomeLine] _find_cut_dome_lines(self)
Definition core.py:2652
frozenset[str] _sealed(self, frozenset[str] isolated)
Definition core.py:4761
frozenset[str] _liquid_fed
Definition core.py:2483
frozenset[str] _last_isolated
Definition core.py:2368
None command_state(self, str state)
Definition core.py:3152
dict[str, str] _find_fill_stubs(self)
Definition core.py:4879
float advance(self, float dt, *, float mdot_liquid_out, float mdot_gas_in, float mdot_gas_out, float enthalpy_gas_in, float supply_pressure=0.0, float stirring=1.0, float vent_fraction=1.0, float mdot_liquid_in=0.0, float|None liquid_in_temperature=None)
Definition core.py:1571
bool _dewar_load(self, float dt)
Definition core.py:1496
VesselState _chilled_ullage(self, TankState state, float wall)
Definition core.py:1393
float _gas_temperature_of(self, VesselState ullage)
Definition core.py:1922
float outlet_pressure(self)
Definition core.py:1243
float _chill_target(self, float|None pressure=None)
Definition core.py:1340
dict[str, float] readouts(self)
Definition core.py:1264
None _collect(self, float added)
Definition core.py:1432
float fill_line_flow(self, float drop)
Definition core.py:1464
VesselState _settled(self, VesselState ullage)
Definition core.py:1962
None _one_step(self, float dt, float mdot_liquid_out, float net_gas, float enthalpy, float stirring=1.0, float vapour_out=0.0)
Definition core.py:1785
None _ledger(self, TankRates rates, float dt, TankState landed, TankState|None start=None, tuple[float, float] drawn=(0.0, 0.0))
Definition core.py:1870
float _atmosphere_floor(self, VesselState|None ullage=None)
Definition core.py:1939
bool _chill(self, float dt)
Definition core.py:1292
float _specific_energy_at(self, float mass, float temperature)
Definition core.py:1902
float _pressurant_floor(self, float|None wall_temperature=None)
Definition core.py:1891
None take_back(self, float mass)
Definition core.py:1246
TOML_ALWAYS_INLINE path & append(const path &p)
Definition toml.hpp:3310
dict[str, str] builtin_rows(StateMachine machine, Model model, Collection[str]|None drawn=None)
Definition core.py:421
frozenset[str] _ground(Model model)
Definition core.py:306
dict[str, list[str]] _pressed_supplies(Model model)
Definition core.py:400
tuple[float, float, float] _vessel_wall(object node, float litres, dict[str, Any] defaults, list[str] assumptions, Callable[[], GasFilm]|None estimate=None)
Definition core.py:1114
float _skin_conductance(object node, float air_film, list[str] assumptions)
Definition core.py:1055
list[str] _bottle_ids(Model model)
Definition core.py:314
CylindricalTank _tank_geometry(float volume, float diameter)
Definition core.py:1022
CylindricalTank _bottle_geometry(float volume)
Definition core.py:1015
tuple[dict[str, str], dict[str, str]] drawn_fills(Model model)
Definition core.py:324
float _trip_limit(object node, float safety_factor, list[str] assumptions)
Definition core.py:1082
str _not_a_liquid_tank(str label, str species, float temperature, Exception exc)
Definition core.py:994
static double read(int32_t code)