1"""A stand that exists in time.
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.
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:
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.
24None of that is scripted. It falls out of integrating the vessels against a
25network solved at each instant.
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:
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``.
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.
45from __future__
import annotations
51from collections
import deque
52from dataclasses
import dataclass, field, replace
53from typing
import Any, Callable, Collection, Deque, Mapping
56 DEFAULT_FRICTION_METHOD,
57 darcy_friction_factor,
77from fluids.fittings
import Cv_to_K
115MAX_COUPLED_CHANGE = 0.04
124_TEMPERATURE_SWEEPS = 12
126_TEMPERATURE_MIN_FLOW = 1.0e-6
128_TEMPERATURE_TOL = 0.05
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."""
142MIN_CHAMBER_FLOW = ENGINE_MIN_CHAMBER_FLOW
161MAX_COUPLING_STEPS = 400
219LEAK_NOTE_KG_S = 0.005
230CHAMBER_TOL = 0.5 * PSI
231CHAMBER_ITERATIONS = 12
294FILL_SUPPLY_T = 293.15
300GSE_CHARGE =
"GSE High Press Control"
301GSE_DUMP =
"GSE High Press Vent"
303BOTTLE_TYPES = frozenset({
"KBOTTLE",
"DEWAR"})
307 """Drawing ids off the vehicle (:attr:`Session.ground`)."""
308 vehicle = model.built.vehicle
311 return frozenset(n.id
for n
in model.diagram.nodes
if n.id
not in vehicle)
315 """The drawing's bottles, in the order the session builds them."""
316 net = model.built.network
319 for n
in model.diagram.nodes
320 if n.type
in BOTTLE_TYPES
and n.id
in net.nodes
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.
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`)."""
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}
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"}:
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)
353 def reaches(starts: set[str], kinds: set[str], own: str) -> str:
354 seen, frontier = set(starts), list(starts)
356 here = frontier.pop()
357 for there
in neighbours.get(here, ()):
361 symbol = place.get(there,
"")
365 and types.get(symbol)
in kinds
366 and pages.get(symbol) != pages.get(own)
369 if net.nodes[there].pressure
is not None:
371 frontier.append(there)
374 tanks: dict[str, str] = {}
376 vehicle_tank: supply_tank
377 for supply_tank, fed
in built.supplies.items()
378 for vehicle_tank
in fed
380 for tank_id, ports
in built.tanks.items():
381 if tank_id
in built.supplies:
383 supply = fed_by.get(tank_id)
or reaches(
384 {ports.ullage, ports.outlet}, {
"DEWAR"}, tank_id
387 tanks[tank_id] = supply
388 bottles: dict[str, str] = {}
391 if bottle_id
in ground:
393 start = built.node_of.get(bottle_id, bottle_id)
394 supply = reaches({start}, {
"KBOTTLE",
"DEWAR"}, bottle_id)
396 bottles[bottle_id] = supply
397 return tanks, bottles
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."""
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:
412 if node.params.get(
"pressure")
is None or node.drawn_as ==
"DEWAR":
414 species =
Fluid(built.network.nodes[ports.outlet].fluid).name
415 out.setdefault(propellant_side(species), []).append(node.label
or tank_id)
420 machine: StateMachine, model: Model, drawn: Collection[str] |
None =
None
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.
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."""
437 drawn = {*tanks, *bottles}
439 labels = {n.id: n.label
or n.id
for n
in model.diagram.nodes}
443 if b
not in drawn
and b
not in ground
446 out: dict[str, str] = {}
447 for row
in machine.actuators:
449 sides = sorted(words & set(pressed))
450 if row == GSE_CHARGE
and charged:
452 f
"Charges {', '.join(charged)} by the twin's built-in fill: "
453 "nothing on the stand fills it."
455 elif row == GSE_DUMP
and charged:
457 f
"Dumps {', '.join(charged)} by the twin's built-in vent: "
458 "nothing on the stand fills it."
460 elif {
"fill",
"press"} <= words
and sides:
461 supplies = [label
for side
in sides
for label
in pressed[side]]
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."
477PRESS_PATH_FD = 1.0e-3
482STORAGE_PROBE = 1.0e-3
486PRESS_PATH_DRAIN_FRACTION = 0.1
489FILL_LINE_ITERATIONS = 4
492FILL_LINE_ROUGHNESS = 1.5e-6
517MAX_MASS_FRACTION = 0.02
527MAX_TEMPERATURE_FRACTION = 0.03
543LIQUID_ROUNDOFF = 1.0e-12
546MAX_VESSEL_STEPS = 200
571 """What the operator can dial before and during a run.
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
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)."""
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)."""
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."""
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."""
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.
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
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."""
632 tank_fill_s: float = 120.0
633 """Seconds to take a cryogen tank from empty to :data:`FULL_FRACTION`.
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.
642 Only read with :attr:`dewar_psi` at zero; with a dewar the load takes as
643 long as its line delivers."""
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).
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.
660 Zero: the fixed-rate load of :attr:`tank_fill_s` and
661 :attr:`load_chill_s`, which is the model before this existed."""
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."""
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."""
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.
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
691 gse_vent_cv: float = 0.5
692 """Flow coefficient of the cart's vent valve [Cv] (operator: ~0.5).
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."""
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
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."""
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.
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."""
723 ullage_collapse: bool =
True
724 """Model heat leaving the ullage into the propellant surface.
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.
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
735 ullage_vapour: bool =
True
736 """Propellant vapour in the ullage -- boil-off and condensation.
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`."""
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."""
758 line_walls: bool =
True
759 """Heat a line's own metal gives the gas flowing through it.
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%.
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."""
776 chilldown: float = 100.0
777 """Liquid-to-wall conductance [W/(m^2.K)]. Zero disables chilldown.
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.
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.
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."""
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.
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.
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
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 = (
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.
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.
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].
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."""
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."""
993 label: str, species: str, temperature: float, exc: Exception
995 """Why a tank could not start, in the drawing's terms."""
997 critical =
Fluid(species).critical_temperature
999 critical = float(
"nan")
1000 if critical == critical
and temperature > critical:
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."
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."
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)
1023 """A barrel of the right volume at the drawing's diameter, 2:1 heads.
1025 Delegates to the library so the drawing reader's static head and this
1026 vessel agree on where the liquid surface is.
1028 return cylindrical_from_volume(volume, diameter)
1041 "kg_per_litre": 8.0 / 17.5,
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)",
1047 "kg_per_litre": 60.0 / 44.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",
1056 """Overall air-to-wall conductance per unit skin [W/(m^2.K)].
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.
1063 params = getattr(node,
"params", {})
or {}
1064 label = getattr(node,
"label",
"")
or getattr(node,
"id",
"vessel")
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
1076 f
"{label} insulation: {t * 1e3:.0f} mm on the drawing, conductivity not "
1077 f
"specified; {k} W/(m·K) assumed (fiberglass)."
1079 return 1.0 / (1.0 / air_film + t / k)
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.
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.
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.
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
1100 f
"{label} trip limit: burst pressure / {sf:g} across the wall "
1101 f
"({say(across + ATMOSPHERE, 1)})."
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
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.
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.
1122 params = getattr(node,
"params", {})
or {}
1123 label = getattr(node,
"label",
"")
or getattr(node,
"id",
"vessel")
1124 missing: list[str] = []
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)
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"])
1138 ha_ref * (
max(litres * 1e3, 1e-6) / litres_ref) ** (2.0 / 3.0),
1142 if estimate
is not None and "wall_conductance" in missing:
1145 except Exception
as exc:
1147 f
"{label} wall film: not estimable ({exc}); per-litre default "
1148 f
"{conductance:.1f} W/K used."
1151 conductance = film.hA
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})."
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)}."
1164 return mass, capacity, conductance
1169 """One propellant tank, integrating."""
1178 filling: bool =
False
1180 fill_seconds: float = 30.0
1183 chill_seconds: float = 0.0
1185 chilling: bool =
False
1187 chill_boiled: float = 0.0
1190 dewar_pressure: float = 0.0
1193 fill_line_bore: float = 7.75e-3
1194 fill_line_length: float = 3.0
1195 fill_cv: float = 0.019
1197 fill_flow: float = 0.0
1203 full_fraction: float = 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."""
1218 added_kg: float = 0.0
1219 fixed_kg: float = 0.0
1220 fixed_J: float = 0.0
1223 sealed: bool =
False
1227 floored_kg: float = 0.0
1231 vent_drawn_kg: float = 0.0
1233 _drawn_piece: tuple[float, float] = (0.0, 0.0)
1236 refused_kg: float = 0.0
1247 """Return pressurant this ullage sent out that no vessel took [kg].
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`.
1254 ullage = self.
state.ullage
1255 self.
state = replace(
1259 mass=ullage.mass + mass,
1260 energy=ullage.energy + mass * self.tank.gas_enthalpy(self.
state),
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),
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
1280 "volume_L": self.tank.geometry.total_volume * 1e3,
1281 "chilling": 1.0
if self.
chilling else 0.0,
1285 "surface_temperature_K": (
1286 self.
state.surface_temperature
1287 if self.
state.surface_temperature
is not None
1288 else self.
state.liquid_temperature
1293 """Spend this step's pour on the wall, if the wall still wants it.
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`.
1303 Returns whether the step was spent chilling.
1308 or state.liquid_mass > 1e-3
1309 or state.liquid_temperature >= CRYOGENIC_K
1313 walls = [state.ullage.wall_temperature]
1314 if state.wetted_wall_temperature
is not None:
1315 walls.append(state.wetted_wall_temperature)
1319 if max(walls) <= target + CHILLED_BAND:
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)
1326 h_fg = latent_heat(self.tank.liquid, state.liquid_temperature)
1327 except (ValueError, PropertyError):
1331 self.
state = replace(
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
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."""
1345 target = self.tank.liquid.get(
1346 "T", p=self.
pressure if pressure
is None else pressure, q=0.0
1348 except (ValueError, PropertyError):
1349 target = self.
state.liquid_temperature
1350 return float(target) +
max(self.tank.boiling_onset, 0.0)
1353 """Put the wall where a chilldown leaves it, now.
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.
1366 if state.liquid_temperature >= CRYOGENIC_K:
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:
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)
1378 h_fg = latent_heat(self.tank.liquid, state.liquid_temperature)
1379 except (ValueError, PropertyError):
1383 self.
state = replace(
1386 wetted_wall_temperature=(
1387 cooled[1]
if state.wetted_wall_temperature
is not None else None
1394 """The ullage a chill leaves: at the chilled wall's temperature, at the
1395 pressure it holds now.
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`).
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
1416 return VesselState(mass=mass, energy=energy * mass, wall_temperature=wall)
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."""
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)
1433 """Put ``added`` kg of the load into the liquid."""
1443 rho_l = self.tank.liquid_density(self.
state)
1446 except (ValueError, PropertyError):
1448 ullage = self.
state.ullage
1452 self.
state = replace(
1454 ullage=replace(ullage, energy=ullage.energy + work),
1455 liquid_mass=self.
state.liquid_mass + added,
1457 surface_temperature=(
1459 if self.
state.surface_temperature
is None
1460 else self.
state.liquid_temperature
1465 """What the dewar's fill line passes at a pressure drop [kg/s].
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.
1474 if drop <= 0.0
or bore <= 0.0:
1477 rho = self.tank.liquid_density(state)
1479 mu = self.tank.liquid.get(
"mu", T=state.liquid_temperature, q=0.0)
1480 except (ValueError, PropertyError):
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)
1486 for _
in range(FILL_LINE_ITERATIONS):
1487 flow = area * math.sqrt(
1490 Re = reynolds(flow, bore, rho, mu)
1491 friction = darcy_friction_factor(
1492 Re, FILL_LINE_ROUGHNESS / bore, DEFAULT_FRICTION_METHOD
1497 """One step of a cryogen load pushed in by the dewar.
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.
1509 Returns whether the step was spent chilling.
1512 room = self.
_wanted() - state.liquid_mass
1513 if room <= 0.0
or dt <= 0.0:
1517 if state.liquid_mass <= 1e-3:
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:
1532 fall = arriving * h_fg / capacity
1533 if all(w - fall >= target
for w
in walls):
1537 cooled = [w - fall
for w
in walls]
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)
1544 self.
state = replace(
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
1550 vapour_mass=state.vapour_mass + boiled,
1552 collected =
min(arriving - boiled, room)
1562 mdot_liquid_out: 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,
1572 """One vessel step, with the flows the network just produced.
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.
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.
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.
1594 and self.
state.liquid_temperature < CRYOGENIC_K
1601 if self.
state.liquid_mass < wanted:
1603 added =
min(wanted * dt / span, wanted - self.
state.liquid_mass)
1605 self.
fill_flow = added / dt
if dt > 0.0
else 0.0
1617 if supply_pressure > 0.0
and mdot_gas_in > 0.0:
1637 over = self.
pressure - (supply_pressure + band)
1639 refused = mdot_gas_in
1651 held = self.
state.ullage.mass + self.
state.vapour_mass
1653 (supply_pressure + band - self.
pressure)
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
1672 and supply_pressure > 0.0
1675 stirring = stirring
if pressing
else 1.0
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)
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)
1703 mdot_liquid_out -= mdot_liquid_in
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
1730 if vented > vapour_out
and dt > 0.0
and self.
state.vapour_mass > 0.0:
1733 ) / dt +
max(mdot_gas_in, 0.0)
1734 over =
min(pressurant_out, vented - vapour_out) - spare
1736 shift =
min(over, self.
state.vapour_mass / dt - vapour_out)
1739 pressurant_out -= shift
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
1750 enthalpy = enthalpy_gas_in
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
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)
1771 for _
in range(steps):
1773 inner, mdot_liquid_out, net_gas, enthalpy, stirring, vapour_out
1780 mdot_liquid_out: float,
1783 stirring: float = 1.0,
1784 vapour_out: float = 0.0,
1786 """One vessel step that lands somewhere the gas can actually be.
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.
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``,
1803 while done < 1 << halvings:
1804 remaining = dt / (1 << halvings)
1805 rates = self.tank.rates(
1807 mdot_liquid_out=mdot_liquid_out,
1808 mdot_gas_in=net_gas,
1809 enthalpy_gas_in=enthalpy,
1812 stirring=stirring
if net_gas > 0.0
else 1.0,
1813 mdot_vapour_out=vapour_out,
1815 stepped = self.tank.step(self.
state, rates, remaining)
1816 liquid = stepped.liquid_mass
1817 if liquid <= LIQUID_ROUNDOFF * self.
state.liquid_mass:
1821 candidate = replace(
1823 ullage=self.
_settled(stepped.ullage),
1829 if halvings == STEP_RETRIES:
1835 self.
state = candidate
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
1846 left = dt - done * dt / (1 << halvings)
1850 self.
state = replace(
1854 energy=self.tank.gas.get(
"u", p=AMBIENT, T=wall) * mass,
1855 wall_temperature=wall,
1868 start: TankState |
None =
None,
1869 drawn: tuple[float, float] = (0.0, 0.0),
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
1877 def total(state: TankState) -> float:
1878 return state.liquid_mass + state.ullage.mass + state.vapour_mass
1880 promised = (rates.ullage.mass + rates.liquid_mass + rates.vapour_mass) * dt
1881 self.
fixed_kg += total(landed) - total(was) - promised - drawn[0]
1883 landed.ullage.energy
1885 - rates.ullage.energy * dt
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)
1896 self.
state.ullage.wall_temperature
1897 if wall_temperature
is None
1898 else wall_temperature
1900 return float(self.tank.gas.get(
"rho", p=AMBIENT, T=wall)) * volume
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.
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.
1914 volume =
max(self.tank.ullage_volume(self.
state), 1e-9)
1917 return float(self.tank.gas.get(
"u", rho=mass / volume, T=temperature))
1918 except (ValueError, PropertyError):
1920 return float(self.tank.gas.get(
"u", p=AMBIENT, T=temperature))
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:
1928 temperature = float(
1930 "T", rho=ullage.mass / volume, u=ullage.energy / ullage.mass
1933 if temperature > 0.0:
1935 except (ValueError, PropertyError):
1937 return ullage.wall_temperature
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.
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`).
1950 held = ullage
if ullage
is not None else self.
state.ullage
1951 volume =
max(self.tank.ullage_volume(self.
state), 1e-9)
1953 vapour = self.tank.vapour_pressure(self.
state)
1954 except (ValueError, PropertyError):
1956 partial = AMBIENT - vapour
1960 return float(self.tank.gas.get(
"rho", p=partial, T=temperature)) * volume
1962 def _settled(self, ullage: VesselState) -> VesselState:
1963 """Keep the ullage inside the states the gas can actually occupy.
1965 Two floors, and both are physics rather than numerics.
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.
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.
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.
1987 restate_at = ullage.wall_temperature
1990 if drawn_through_vent:
2008 if self.
state.vapour_mass <= 0.0:
2010 floor =
min(floor, self.
state.ullage.mass)
2014 except (ValueError, PropertyError):
2017 floor =
max(floor, physical)
2026 floor =
min(floor, self.
state.ullage.mass)
2027 elif self.
state.vapour_mass > 0.0:
2030 except (ValueError, PropertyError):
2038 floor =
min(floor, self.
state.ullage.mass)
2039 mass =
max(ullage.mass, floor)
2040 if mass == ullage.mass:
2045 if not drawn_through_vent:
2052 if drawn_through_vent:
2053 grown = mass - ullage.mass
2057 energy=specific * mass,
2058 wall_temperature=ullage.wall_temperature,
2064 """A pressurant bottle: filled from GSE, then blowing down.
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.
2076 filling: bool =
False
2077 venting: bool =
False
2078 target: float = from_psig(4500.0)
2079 fill_seconds: float = 9.7
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."""
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."""
2093 added_kg: float = 0.0
2094 dumped_kg: float = 0.0
2095 fixed_kg: float = 0.0
2105 """How full, against the target it is being filled to."""
2113 stirring: float = 1.0,
2114 mdot_in: float = 0.0,
2115 enthalpy_in: float = 0.0,
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."""
2132 full = self.volume.initial_state(
2133 pressure=self.
target, temperature=self.
state.wall_temperature
2136 added =
min(full.mass * dt / span, full.mass - self.
state.mass)
2145 enthalpy = self.volume.fluid.get(
2152 rates = self.volume.rates(
2155 enthalpy_in=enthalpy,
2156 mdot_out=
max(mdot_out, 0.0),
2159 self.
state = self.volume.step(self.
state, rates, dt)
2162 drawn =
max(mdot_out, 0.0)
2170 if excess > 0.0
and dt > 0.0:
2172 mdot_out =
max(mdot_out, dump)
2174 if self.
state.mass <= 1e-6
and mdot_in <= 0.0:
2178 rates = self.volume.rates(
2180 mdot_out=
max(mdot_out, 0.0),
2182 enthalpy_in=enthalpy_in,
2186 rates = self.volume.rates(self.
state, mdot_out=
max(mdot_out, 0.0))
2187 stepped = self.volume.step(self.
state, rates, dt)
2189 self.
fixed_kg += floored.mass - stepped.mass
2190 self.
state = floored
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)."""
2197 self.volume.temperature(state)
2198 if state.mass > 1e-9
2199 else state.wall_temperature
2201 except (ValueError, PropertyError):
2202 gas_T = state.wall_temperature
2203 return self.volume.initial_state(pressure=AMBIENT, temperature=gas_T)
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.
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.
2214 air = self.
_air(stepped)
2215 if stepped.mass >= air.mass:
2220 wall_temperature=stepped.wall_temperature,
2224@dataclass(frozen=True, slots=True)
2226 """A vessel over the pressure its drawing rates it for: why the stand stopped.
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.
2234 """Drawing id of the vessel."""
2237 """``tank`` or ``bottle``."""
2239 """Session clock at the step that found it [s]."""
2241 """Vessel pressure then, absolute [Pa]."""
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."""
2249 """One instant of the session, as the app draws it."""
2253 pressures: Mapping[str, float]
2254 temperatures: Mapping[str, float]
2255 """Node temperature [K] at this instant.
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]
2263 tanks: Mapping[str, dict[str, float]]
2264 chamber: ChamberResult |
None =
None
2265 balance: MixtureBalance |
None =
None
2266 notes: tuple[str, ...] = ()
2270 """A stand, live. Built once, ticked forever.
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.
2281 machine: StateMachine,
2284 state: str =
"Idle",
2285 setup: Setup |
None =
None,
2286 hookup: Hookup |
None =
None,
2288 self.
id = uuid.uuid4().hex[:12]
2298 {k.id: k.psig
for k
in hookup.knobs}
if hookup
is not None else {}
2300 self.
state = state
if state
in machine.states
else machine.states[0]
2337 self.
history: Deque[Sample] = deque(maxlen=HISTORY)
2372 self.
_resting: tuple[frozenset[str], frozenset[str]] = (
2378 frozenset[str] |
None, tuple[frozenset[str], frozenset[str]]
2407 net_nodes = self.
model.built.network.nodes
2412 candidates = [self.
model.built.engine_ports.get(
"chamber",
"")]
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")
2418 for anchor
in candidates:
2419 if anchor
and anchor
in net_nodes:
2422 net_nodes[anchor].pressure = AMBIENT
2441 net = self.
model.built.network
2444 for branch_id, branch
in net.branches.items()
2467 raw_cut = model.meta.get(
"ground_cut")
2468 cut = [str(c)
for c
in raw_cut]
if isinstance(raw_cut, list)
else []
2470 "The drawn GSE is ignored (Configuration: Ignore the drawn GSE): "
2472 f
"{', '.join(cut)} and the rest of the cart are not simulated"
2474 else "this drawing has no GSE off the vehicle to cut"
2476 +
". The bottle charge, the loads and the dome are the GSE "
2477 "Controls settings, as on a drawing of the rocket alone."
2484 t
for fed
in self.
model.built.supplies.values()
for t
in fed
2485 ) | frozenset(self.
model.built.supplies)
2509 for branch_id, branch
in self.
model.built.network.branches.items()
2510 if getattr(branch.component,
"type",
"") ==
"relief_valve"
2519 """Drawing ids of the ground support: everything off the vehicle
2520 (:func:`feedtwin.pid.roles.vehicle_ids`). Empty for a drawing that is
2525 """Propellant a fire is loaded with, per vehicle tank [kg].
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.
2534 load = getattr(self.
model.engine,
"fire_load",
None)
or {}
2536 tank_id: float(load[side])
2538 if (side := propellant_side(self.
tanks[tank_id].tank.liquid.name))
2544 """Each vehicle tank too small for the engine's fire load, said.
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.
2552 for tank_id, kg
in self.
fire_loads().items():
2553 sim = self.
tanks[tank_id]
2554 fraction = self.
setup.full_fraction
2556 sim.tank.geometry.total_volume
2558 * sim.tank.liquid_density(sim.state)
2560 if held < kg - 0.005:
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."
2571 """The tanks the engine burns from: every tank, less the ground's."""
2573 return tuple(t
for t
in self.
tanks if t
not in ground)
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}
2580 for tank_id, supply
in tanks.items():
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."
2585 for bottle_id, supply
in bottles.items():
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."
2590 return frozenset({*tanks, *bottles})
2594 """The table's rows this stand acts on by name with no valve wired
2595 (:func:`builtin_rows`), on the fills it found."""
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()}
2605 for node
in self.
model.diagram.nodes
2606 if node.type ==
"PR" and node.options.get(
"domeLoaded") ==
"yes"
2608 and built.actuators.get(node.id,
"")
not in loaded | {
""}
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]] = {}
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)
2634 seen: set[str] = set()
2636 here = frontier.pop()
2637 if here
in seen
or here
not in by_id
or here == node.id:
2640 if by_id[here].type
not in stop:
2641 frontier.extend(adjacent.get(here, ()))
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)
2649 out[node.id] = frozenset(places)
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.
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] = {}
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)
2684 seen: set[str] = set()
2685 couplings: set[str] = set()
2686 frontier = list(ports)
2688 here = frontier.pop()
2689 if here
in seen
or here
not in by_id
or here == regulator:
2692 if by_id[here].type ==
"QD":
2695 if by_id[here].type
not in barrier:
2696 frontier.extend(adjacent.get(here, ()))
2698 sid
for sid
in seen
if sid
in built.actuators
and sid
not in couplings
2700 if len(couplings) != 1
or not valves:
2703 sid: sum(1
for e
in diagram.edges
if sid
in (e.source, e.target))
2706 out[regulator] = DomeLine(
2708 loader=next(iter(couplings)),
2710 sid: frozenset(o
for o
in adjacent.get(sid, ())
if o
in seen)
2715 vents=frozenset(v
for v
in valves
if lines_on[v] == 1),
2720 """Turn the drawing's tanks and bottles into integrable vessels.
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
2727 by_id = {n.id: n
for n
in self.
model.diagram.nodes}
2728 net = self.
model.built.network
2730 for drawing_id, ports
in self.
model.built.tanks.items():
2731 node = by_id.get(drawing_id)
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:
2740 f
"{node.label} diameter: {bore * 1e3:.0f} mm assumed (not on the "
2741 "drawing); sets the liquid column height and outlet head."
2744 species = net.nodes[ports.outlet].fluid
2745 temperature = net.nodes[ports.outlet].temperature
2749 pressurant =
Fluid(net.nodes[ports.ullage].fluid)
2751 wall_mass, wall_capacity, wall_conductance =
_vessel_wall(
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,
2772 self.
setup.surface_layer_m
if self.
setup.stratification
else 0.0
2774 surface_mixing=self.
setup.surface_mixing,
2775 wall_by_level=self.
setup.ullage_wall_by_level,
2779 ambient_conductance=(
2781 if getattr(node,
"drawn_as",
"") ==
"DEWAR"
2786 ambient_temperature=self.
setup.ambient_T,
2789 wall_boiling=self.
setup.ullage_vapour
and self.
setup.wall_boiling,
2792 label = node.label
or drawing_id
2794 start = tank.initial_state(
2797 liquid_temperature=temperature,
2798 gas_temperature=293.15,
2800 except ValueError
as exc:
2817 ullage_node=ports.ullage,
2818 outlet_node=ports.outlet,
2819 full_fraction=self.
setup.full_fraction,
2821 if drawing_id
in self.
ground:
2825 sim = self.
tanks[drawing_id]
2826 loaded = sim._wanted()
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(
2836 liquid_temperature=temperature,
2840 gas_temperature=293.15,
2841 contact_time=PAD_HOLD_S
if dewar
else 0.0,
2849 sim.state = replace(
2851 ullage=replace(sim.state.ullage, wall_temperature=temperature),
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.")
2860 for node
in self.
model.diagram.nodes:
2861 if node.type
not in BOTTLE_TYPES
or node.id
not in net.nodes:
2863 volume = node.params.get(
"volume")
2864 pressure = node.params.get(
"pressure")
2865 temp_param = node.params.get(
"temperature")
2867 volume.si
if volume
is not None else self.
setup.bottle_volume_L * 1e-3
2871 f
"{node.label} volume: {litres * 1e3:.2f} L assumed -- the "
2872 "stand's 45 scf SCBA COPV (not on the drawing); sets the "
2875 wall_mass, wall_capacity, wall_conductance =
_vessel_wall(
2885 Fluid(net.nodes[node.id].fluid),
2887 wall_mass=wall_mass,
2888 wall_capacity=wall_capacity,
2889 wall_conductance=wall_conductance,
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
2902 delivered = bool(self.
setup.bottle_delivered)
or (
2903 node.id
in self.
ground and node.type ==
"KBOTTLE"
2907 label=node.label
or node.id,
2909 state=gas.initial_state(
2910 pressure=rated
if delivered
else AMBIENT,
2911 temperature=ambient_T,
2918 from_psig(self.
setup.copv_target_psi)
2919 if self.
setup.copv_target_psi > 0.0
2922 fill_seconds=self.
setup.copv_fill_s,
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,
2941 """Put the stand where it is at T-0, without rehearsing the pad.
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.
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.
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.
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.
2970 loads = dict(loads
or {})
2971 unknown = sorted(set(loads) - set(self.
tanks))
2974 f
"loads names {', '.join(unknown)}, which this drawing has no "
2975 f
"tank for (tanks: {', '.join(sorted(self.tanks)) or 'none'})"
2978 for tank_id, sim
in self.
tanks.items():
2979 if tank_id
in ground
and tank_id
not in loads:
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:
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"
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),
3007 if upper
is not None:
3010 sim.state = replace(
3012 ullage=replace(sim.state.ullage, wall_temperature=upper),
3015 f
"{sim.label} upper wall at T-0: {upper:.1f} K (Setup), "
3016 "not the pressurant's 293.15 K."
3021 for bottle
in self.
bottles.values():
3022 if bottle.id
in ground:
3025 bottle.state = bottle.volume.initial_state(
3026 pressure=from_psig(copv_psi), temperature=293.15
3032 bottle.peak = bottle.pressure
3033 bottle.charged =
True
3034 if state
in self.
machine.states
and state != self.
state:
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:
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)
3059 """Pressure feeding a dome control regulator [Pa].
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.
3067 for bottle
in sorted(self.
bottles.values(), key=
lambda b: b.id
in ground):
3069 return float(bottle.pressure)
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:
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
3084 def estimate() -> GasFilm:
3085 return still_gas_conductance(
3088 self.
setup.ambient_T,
3090 geometry.wetted_area(geometry.height),
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"],
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"],
3113 """Push the thermal knobs in ``setup`` onto vessels already built.
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.
3119 for sim
in self.
tanks.values():
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
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
3141 tank.surface_layer <= 0.0
and sim.state.surface_temperature
is not None
3143 sim.state = replace(sim.state, surface_temperature=
None)
3145 sim.node, float(self.
setup.ambient_leak), []
3147 tank.ambient_temperature = float(self.
setup.ambient_T)
3148 tank.wall_boiling = bool(
3149 self.
setup.ullage_vapour
and self.
setup.wall_boiling
3153 if state
not in self.
machine.states:
3154 raise ValueError(f
"no state {state!r}")
3156 raise PermissionError(
3157 f
"{self.state} cannot go to {state}. From here: "
3158 f
"{', '.join(self.machine.targets(self.state))}"
3171 for symbol
in self.
binding.to_symbol.values():
3172 self.
forced.pop(symbol,
None)
3176 """Valves a person has taken from the state table, by drawing id.
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.
3187 if self.
forced.get(valve) == (1.0
if opened
else 0.0)
3189 return sorted(v
for v
in self.
forced if v
not in crew)
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
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."""
3199 self.
forced.pop(drawing_id,
None)
3202 self.
forced.pop(valve,
None)
3205 """Chill a cryogen tank's wall now rather than wait for the load to.
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.
3215 if tank_id
and tank_id
not in self.
tanks:
3216 raise KeyError(f
"no tank {tank_id!r}")
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:
3222 flashed = sim.chill_boiled
3223 if not sim.skip_chill():
3225 chilled.append(sim.label)
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."
3236 def signals(self, dt: float = 0.0) -> dict[str, float]:
3237 """What the components read this tick: the state, then hand overrides.
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.
3249 built = self.
model.built
3250 out: dict[str, float] = {}
3252 lines = {line.loader: line
for line
in built.dome_lines.values()}
3263 if self.
hookup is not None:
3267 labels = {n.id: n.label
or n.id
for n
in self.
model.diagram.nodes}
3268 for knob
in self.
hookup.knobs:
3273 self.
setup.copv_target_psi
3274 if knob.id == CHARGE
3275 else self.
knobs.get(knob.id, knob.psig)
3278 for regulator
in knob.regulators:
3279 loader = built.dome_loaders.get(regulator)
3280 if loader
is not None:
3282 lines.get(regulator),
3286 elif regulator
in labels:
3287 out[f
"{labels[regulator]}.dome"] = from_psig(psig_set)
3289 for loader
in built.dome_loaders.values():
3291 lines.get(loader.id),
3295 if not built.dome_loaders:
3297 (s
for s
in built.actuators.values()
if s.endswith(
".dome")),
""
3300 out[dome_signal] = from_psig(self.
setup.dome_psi)
3305 signal = built.actuators[regulator]
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:
3314 for drawing_id, signal
in built.actuators.items():
3315 if signal.endswith(
".dome"):
3322 if drawing_id
in self.
forced
3323 else commanded.get(drawing_id, built.rest.get(drawing_id, 0.0))
3325 out[signal] = self.
_slew(drawing_id, target, dt)
3329 """What the components would read now, moving nothing.
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.
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).
3357 self, line: DomeLine |
None, live: float, commanded: Mapping[str, 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].
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.
3369 built = self.
model.built
3371 def is_open(valve: str) -> bool:
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
3380 def region(starts: frozenset[str] | set[str]) -> set[str]:
3381 reached: set[str] = set()
3382 frontier = list(starts)
3384 here = frontier.pop()
3388 if here
in line.valves
and not is_open(here):
3390 frontier.extend(line.adjacent.get(here, ()))
3393 port_side = region(line.ports)
3396 elif any(v
in port_side
and is_open(v)
for v
in line.vents):
3399 held = self.
_dome_held.get(line.loaded, AMBIENT)
3403 loader_side = region({line.loader}) - port_side
3404 for symbols, value
in ((port_side, held), (loader_side, live)):
3405 for symbol
in symbols:
3407 built.node_of.get(symbol),
3411 if node
and node
in built.network.nodes:
3413 for instrument
in built.instruments:
3414 for symbols, value
in ((port_side, held), (loader_side, live)):
3416 built.node_of.get(sym) == instrument.node
3417 or instrument.node
in (f
"{sym}.in", f
"{sym}.out")
3424 """The dome a control regulator set to ``psig_set`` gives, from its
3426 built = self.
model.built
3428 if supply <= 0.0
or not isinstance(loader.component, Regulator):
3429 return from_psig(psig_set)
3430 conditions = built.network.conditions(
3433 {f
"{loader.component.id}.dome": from_psig(psig_set)},
3435 return float(loader.component.outlet_setpoint(0.0, conditions))
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)
3444 step = 1.0
if travel <= 0.0
else dt / travel
3445 if abs(target - current) <= step:
3448 current += step
if target > current
else -step
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:
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)
3464 seconds = float(found)
3466 self.
_travel[drawing_id] = seconds
3472 """Dry tanks whose outlet other lines pass through, by tank id.
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.
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):
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)
3497 return frozenset(out)
3500 """Feed branches leaving a tank with nothing left in it."""
3501 net = self.
model.built.network
3502 out: set[str] = set()
3504 for sim
in self.
tanks.values():
3505 if sim.state.liquid_mass > DRY_MASS
or sim.id
in through:
3507 filling = self.
_fill_lines.get(sim.id, frozenset())
3521 for branch_id, branch
in net.branches.items():
3522 if sim.outlet_node
not in (branch.upstream, branch.downstream):
3524 if branch_id
in filling
and loading:
3527 return frozenset(out)
3530 """Propellant reached the engine on the last solve."""
3531 ports = self.
model.built.engine_ports
3533 self.
_last_flows.get(ports.get(side,
""), 0.0) > 0.0
3534 for side
in (
"oxidiser",
"fuel")
3538 """Decide which ground support rests this step (``self._resting``).
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`.)
3544 nothing: tuple[frozenset[str], frozenset[str]] = (frozenset(), frozenset())
3548 net = self.
model.built.network
3549 shut = frozenset(net.isolated(signals)) | self.
_dry_branches()
3557 self, shut: frozenset[str]
3558 ) -> tuple[frozenset[str], frozenset[str]]:
3559 """Ground branches and vessels with no open path to the vehicle.
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.
3566 net = self.
model.built.network
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()}
3575 for sim
in self.
tanks.values()
3576 if sim.id
not in ground
3577 for node
in (sim.ullage_node, sim.outlet_node)
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")
3583 adjacent: dict[str, list[str]] = {}
3584 for branch_id, branch
in net.branches.items():
3585 if branch_id
in shut:
3587 adjacent.setdefault(branch.upstream, []).append(branch.downstream)
3588 adjacent.setdefault(branch.downstream, []).append(branch.upstream)
3589 reached = set(seeds)
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:
3599 if net.nodes[there].is_fixed
and there
not in vessels:
3603 branches = frozenset(
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
3612 for sim
in self.
tanks.values()
3613 if sim.id
in ground
and sim.ullage_node
not in reached
3617 for b
in self.
bottles.values()
3618 if b.id
in ground
and b.node
not in reached
3620 return branches, frozenset(resting)
3624 signals: Mapping[str, float] |
None =
None,
3628 """Shortest ullage RC time constant on the stand [s], or 0.
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
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.
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).
3648 resting_branches, resting = self.
_resting
3649 net = self.
model.built.network
3651 frozenset(net.isolated(signals)) | self.
_dry_branches() | resting_branches
3655 for sim
in self.
tanks.values():
3656 if sim.id
in resting:
3661 volume = sim.tank.ullage_volume(sim.state)
3664 capacitance = sim.state.ullage.mass / p
3665 resistance = slopes.get(sim.ullage_node, 0.0)
3666 if resistance > 0.0:
3667 tau =
min(tau, capacitance * resistance)
3672 return tau
if tau < float(
"inf")
else 0.0
3675 """Each ullage's stiffest regulator slope, ``flow_droop / rated_flow``
3676 [Pa/(kg/s)], over the regulators it is joined to.
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.
3686 net = self.
model.built.network
3687 fixed = frozenset(n
for n, node
in net.nodes.items()
if node.is_fixed)
3692 adjacent: dict[str, list[tuple[str, str]]] = {}
3693 for branch_id, branch
in net.branches.items():
3694 if branch_id
in shut:
3696 adjacent.setdefault(branch.upstream, []).append(
3697 (branch_id, branch.downstream)
3699 adjacent.setdefault(branch.downstream, []).append(
3700 (branch_id, branch.upstream)
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__:
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
3716 found: list[float] = []
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:
3725 if there
not in fixed:
3728 out[start] =
min(found)
3736 signals: Mapping[str, float] |
None,
3739 """``C / G`` for one ullage while its tank is delivering liquid [s].
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.
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.
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.
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.
3766 Infinity when there is nothing to measure.
3768 net = self.
model.built.network
3770 volume = sim.tank.ullage_volume(sim.state)
3771 if liquid_out <= 0.0
or volume <= 0.0:
3773 rho_liquid =
max(sim.tank.liquid_density(sim.state), 1.0)
3775 drained = sim.pressure * liquid_out / rho_liquid / volume * dt
3784 shut = frozenset(net.isolated(signals)) | self.
_dry_branches()
3785 dead = shut | {d.branch
for d
in net.dead_ends(exclude=shut)}
3787 for branch_id, branch
in net.branches.items():
3788 if sim.ullage_node
not in (branch.upstream, branch.downstream):
3790 if branch_id
in dead:
3792 if sim.outlet_node
in (branch.upstream, branch.downstream):
3796 if branch.downstream == sim.ullage_node
3797 else branch.downstream
3799 if net.nodes[far].pressure
is None:
3800 paths.append((branch_id, branch))
3806 if branch.downstream == sim.ullage_node
3807 else branch.downstream
3811 for _, branch
in paths
3815 band = self.
setup.supply_band * supply
3816 if supply <= 0.0
or supply - sim.pressure > band:
3823 if supply - AMBIENT <= band:
3825 if drained < PRESS_PATH_DRAIN_FRACTION * band:
3827 rho_gas = sim.state.ullage.mass / volume
3828 needed = rho_gas * liquid_out / rho_liquid
3830 for branch_id, branch
in paths:
3831 upstream = self.
_guess.get(branch.upstream)
3832 if upstream
is None or upstream <= 0.0:
3837 step = flow * PRESS_PATH_FD
3839 conditions = net.conditions(
3840 branch.upstream, upstream, dict(signals
or {})
3843 branch.component.total_dp(flow + step, conditions)
3844 - branch.component.total_dp(flow - step, conditions)
3851 admittance += 1.0 / slope
3852 return capacitance / admittance
if admittance > 0.0
else float(
"inf")
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()})
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.
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
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.
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.
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.
3897 out: dict[str, tuple[float, float]] = {}
3900 net = self.
model.built.network
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
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)
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:
3918 if not live
or held <= 0.0:
3925 def land(gas_in: float = 0.0, gas_out: float = 0.0) -> float:
3926 trial = copy.copy(sim)
3929 mdot_liquid_in=liquid_in,
3930 mdot_liquid_out=liquid_out,
3932 mdot_gas_out=gas_out,
3933 enthalpy_gas_in=enthalpy,
3935 return trial.pressure
3937 probe = STORAGE_PROBE * held / dt
3941 slope = reference - land(gas_out=probe)
3944 land(gas_in=probe) - reference,
3945 reference - land(gas_out=probe),
3949 if slope > 0.0
and reference > 0.0:
3950 out[node] = (probe / slope, reference)
3958 out: dict[str, tuple[float, float]],
3960 """Close a shut-in tank's outlet over the step, as its ullage is closed.
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.
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.
3976 net = self.
model.built.network
3977 node = sim.outlet_node
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()
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
3989 def land(liquid_out: float = 0.0) -> float:
3990 trial = copy.copy(sim)
3993 mdot_liquid_out=liquid_out,
3996 enthalpy_gas_in=sim.tank.gas_enthalpy(sim.state),
3998 return trial.outlet_pressure
4002 slope = reference - land(probe)
4005 if slope > 0.0
and reference > 0.0:
4006 out[node] = (probe / slope, reference)
4011 signals: Mapping[str, float],
4013 substeps: int = SUBSTEPS,
4015 """One coupling step: solve the network, then move the vessels."""
4018 if self.
setup.regulator_compressible_seat:
4019 signals = {**signals, SEAT_XT_SIGNAL: float(self.
setup.regulator_xT)}
4033 isolated = frozenset(net.isolated(signals)) | dry
4042 node: value
for node, value
in self.
_guess.items()
if node
in net.nodes
4051 result = self.
_solve(net, signals, dry | resting, storage)
4054 if self.
_reliefs and result.converged:
4063 isolated = frozenset(net.isolated(signals)) | dry
4068 result = self.
_solve(net, signals, dry | resting, storage)
4071 result = self.
_close_chamber(net, signals, dry | resting, result, storage)
4090 if result.converged:
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].
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.
4111 for sim
in self.
tanks.values():
4112 held = sim.state.liquid_mass
4113 if held <= DRY_MASS:
4115 arriving, leaving = self.
_split_at(sim.outlet_node, flows)
4118 if leaving > 0.0
and leaving * span > held:
4119 span = held / leaving
4123 """Count one network solve into this tick's solver record."""
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
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)
4143 """Fluid held in every vessel: liquid, ullage gas, vapour, bottles [kg]."""
4145 sim.state.liquid_mass + sim.state.ullage.mass + sim.state.vapour_mass
4146 for sim
in self.
tanks.values()
4148 return held + sum(b.state.mass
for b
in self.
bottles.values())
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
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."""
4169 """Close this tick's solver record."""
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,
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),
4196 guard_J=sum(s.fixed_J
for s
in self.
tanks.values())
4197 - getattr(self,
"_guard_J0", 0.0),
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.
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`.
4218 for branch_id, branch
in self.
_reliefs:
4219 component = branch.component
4221 for node_id
in (branch.upstream, branch.downstream):
4222 if pressures
is not None:
4223 ends.append(pressures.get(node_id))
4225 fixed = net.nodes[node_id].pressure
4226 value = fixed
if fixed
is not None else self.
_guess.get(node_id)
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]),
4240 """Keep a converged solve as the next one's starting point."""
4241 if not result.converged:
4256 **{b: f
for b, f
in result.flows.items()
if abs(f) > 1e-9},
4262 signals: Mapping[str, float],
4263 isolate: frozenset[str],
4264 storage: Mapping[str, tuple[float, float]] |
None,
4266 """The network at this coupling step, warm-started from the last answer.
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.
4277 result = solve_steady(
4280 tol=self.
setup.network_tolerance,
4281 max_iterations=self.
setup.max_iterations,
4282 raise_on_failure=
False,
4283 guess=self.
_guess or None,
4288 if result.converged
or not storage:
4290 held = solve_steady(
4293 tol=self.
setup.network_tolerance,
4294 max_iterations=self.
setup.max_iterations,
4295 raise_on_failure=
False,
4296 guess=self.
_guess or None,
4300 if not held.converged:
4302 retried = solve_steady(
4305 tol=self.
setup.network_tolerance,
4306 max_iterations=self.
setup.max_iterations,
4307 raise_on_failure=
False,
4310 **{b: f
for b, f
in held.flows.items()
if abs(f) > 1e-9},
4316 return retried
if retried.converged
else result
4318 def _held(self, result: SteadyResult) -> Mapping[str, float]:
4319 """The node pressures a step acts on and shows.
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.
4329 return result.pressures
4330 nodes = self.
model.built.network.nodes
4333 value
if nodes[node].is_fixed
else self.
_last_pressures.get(node, value)
4335 for node, value
in result.pressures.items()
4341 signals: Mapping[str, float],
4342 dry: frozenset[str],
4343 result: SteadyResult,
4344 storage: Mapping[str, tuple[float, float]] |
None =
None,
4346 """Find the chamber pressure the network and the engine agree on.
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.
4358 Sets ``_chamber_guess``, the chamber node, and ``_last_chamber``.
4360 ports = self.
model.built.engine_ports
4361 if self.
model.chamber
is None or "chamber" not in ports:
4365 node = ports[
"chamber"]
4366 model = self.
model.chamber
4368 def into(flows: Mapping[str, float], branch_id: str) -> float:
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)
4375 def equilibrium(flows: Mapping[str, float]) -> ChamberResult:
4376 return model.evaluate(
4377 into(flows, ports.get(
"oxidiser",
"")),
4378 into(flows, ports.get(
"fuel",
"")),
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
4386 def solve_at(p: float) -> SteadyResult:
4387 net.nodes[node].pressure = p
4388 res = self.
_solve(net, signals, dry, storage)
4394 eq, g = mismatch(result, p)
4395 tolerance = self.
setup.chamber_tolerance_psi * PSI
4396 if abs(g) > tolerance:
4398 [sim.outlet_pressure
for sim
in self.
tanks.values()] + [p, AMBIENT]
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)
4406 eq, g = mismatch(result, p)
4407 if abs(g) <= tolerance:
4413 if g_lo
is None or g_hi
is None or g_hi == g_lo:
4414 p_next = 0.5 * (lo + hi)
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:
4422 net.nodes[node].pressure = p
4423 if result.converged:
4425 eq = fold_injectors(eq, net, ports, result.flows, result.pressures)
4427 self.
_tick[
"chamber_gap"] =
max(self.
_tick.get(
"chamber_gap", 0.0), abs(g))
4432 flows: Mapping[str, float],
4433 pressures: Mapping[str, float],
4435 substeps: int = SUBSTEPS,
4437 """Integrate every vessel over ``dt`` with the flows it is given.
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
4444 if self.
setup.ground_rests:
4446 for sim
in self.
tanks.values():
4449 and self.
_split_at(sim.ullage_node, flows) == (0.0, 0.0)
4454 for b_id, bottle
in self.
bottles.items():
4457 and not (bottle.filling
or bottle.venting)
4458 and self.
_split_at(bottle.node, flows) == (0.0, 0.0)
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}
4465 sim.sealed = sim.id
in sealed
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:
4481 if tank_id
in self.
tanks:
4482 sent_to[tank_id] = sent_to.get(tank_id, 0.0) + given
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):
4488 sent: dict[str, float] = {}
4490 gas_in, gas_out = self.
_split_at(sim.ullage_node, flows)
4491 liquid_in, liquid_out = self.
_outlet_split(sim, flows, through)
4499 liquid_in =
min(liquid_in, sent_to.get(sim.id, 0.0))
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
4509 sent[sim.id] = gas_out * (1.0 - vented.get(sim.id, 0.0))
4511 taken = sim.advance(
4513 mdot_liquid_out=liquid_out,
4514 mdot_gas_in=gas_in + pressed,
4515 mdot_gas_out=gas_out,
4522 / (gas_in + pressed)
4525 target
if pressed > 0.0
else self.
_supply_to(sim, pressures)
4527 stirring=self.
setup.fill_stirring,
4528 vent_fraction=vented.get(sim.id, 0.0),
4529 mdot_liquid_in=liquid_in,
4534 stand_in_refused =
min(taken, pressed)
4535 sim.added_kg += (pressed - stand_in_refused) * inner
4536 refused += taken - stand_in_refused
4546 b_id: -self.
_net_into(bottle.node, flows)
4547 for b_id, bottle
in bottles.items()
4549 total = sum(d
for d
in draws.values()
if d > 0.0)
4556 to_bottles =
min(refused, total)
4563 for b_id, bottle
in bottles.items():
4576 stirring=self.
setup.fill_stirring,
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:
4586 sim.take_back(excess * sent[sim.id] / senders * inner)
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
4595 for sid, branches
in built.branches_of.items()
4596 if sid
in built.actuators
4597 for branch
in branches
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)
4604 adjacent.setdefault(branch.downstream, []).append(
4605 (branch.upstream, branch.id)
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]] = {}
4613 targets = {built.tanks[t].outlet: t
for t
in fed} | {
4614 built.tanks[t].ullage: t
for t
in fed
4617 here = frontier.pop(0)
4618 for there, branch_id
in adjacent.get(here, ()):
4622 parent[there] = (here, branch_id)
4623 if there
in targets:
4624 path: list[str] = []
4626 while step != start:
4627 step, via = parent[step]
4630 (owner[b]
for b
in reversed(path)
if b
in owner),
""
4633 out.setdefault(targets[there], valve)
4635 targets[there], frozenset()
4638 if net.nodes[there].pressure
is not None:
4640 frontier.append(there)
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)
4651 """The crew's hand on a drawn load's transfer valve.
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.
4660 labels = {n.id: n.label
or n.id
for n
in self.
model.diagram.nodes}
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
4670 self.
forced[valve] = 1.0
if wanted
else 0.0
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."
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."
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])``.
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.
4696 if sim.id
not in self.
model.built.supplies:
4697 return 0.0, 0.0, 0.0
4699 drawn = getattr(node,
"params", {}).get(
"pressure")
if node
else None
4701 return 0.0, 0.0, 0.0
4702 side = propellant_side(sim.tank.liquid.name)
4703 if getattr(node,
"drawn_as",
"") ==
"DEWAR":
4706 pressing = [
"its pressure-building circuit"]
4712 if {
"fill",
"press"} <= _words(a)
4713 and side
in _words(a)
4714 and a
not in self.
binding.to_symbol
4717 return 0.0, 0.0, 0.0
4718 target = float(drawn.si)
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)
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))
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)."
4741 return rate, target, h_cart
4744 self, sim: TankSim, flows: Mapping[str, float]
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
4758 weighted += abs(flow) * net.nodes[source].temperature
4759 return weighted / total
if total > 0.0
else None
4761 def _sealed(self, isolated: frozenset[str]) -> frozenset[str]:
4762 """Tanks whose ullage has no open gas path to the air.
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.
4772 if cached
is not None:
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:
4782 links.setdefault(branch.upstream, []).append(branch.downstream)
4783 links.setdefault(branch.downstream, []).append(branch.upstream)
4785 for sim
in self.
tanks.values():
4786 seen = {sim.ullage_node}
4787 frontier = [sim.ullage_node]
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:
4795 if net.nodes[there].is_fixed:
4798 frontier.append(there)
4801 found = frozenset(sealed)
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:
4812 return vented > gas_in + (gas_out - vented)
4815 """Share of the gas leaving ``node`` that ends at a vent, 0..1.
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.
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()}
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))
4840 frontier: list[tuple[str, float]] = [(node, 1.0)]
4842 while frontier
and hops < 200:
4843 here, weight = frontier.pop()
4845 outs = outgoing(here)
4846 total = sum(flow
for flow, _
in outs)
4849 for flow, far
in outs:
4850 share = weight * flow / total
4853 if net.nodes[far].pressure
is not None:
4856 frontier.append((far, share))
4857 return min(vented, 1.0)
4860 net = self.
model.built.network
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
4867 for bottle
in self.
bottles.values():
4868 net.nodes[bottle.node].pressure = bottle.pressure
4876 for stub, vessel_node
in self.
_fill_stubs.items():
4877 net.nodes[stub].pressure = net.nodes[vessel_node].pressure
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:
4889 valve = net.branches[symbol]
4890 ends = (valve.upstream, valve.downstream)
4894 if net.nodes[n].pressure
is not None and n
not in vessel_nodes
4900 frontier = [n
for n
in ends
if n != stub]
4901 seen = set(frontier) | {stub}
4903 while frontier
and not found:
4904 here = frontier.pop()
4905 if here
in vessel_nodes:
4908 for branch
in net.branches.values():
4909 if branch.id == symbol:
4912 (branch.upstream, branch.downstream),
4913 (branch.downstream, branch.upstream),
4915 if a == here
and b
not in seen:
4923 """Where a vehicle tank's propellant is going, when it is not the engine.
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.
4934 net = self.
model.built.network
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())
4940 notes: list[str] = []
4941 for sim
in self.
tanks.values():
4942 if sim.id
in ground
or sim.empty
or sim.filling:
4945 rate = leaving - arriving
4946 if rate < LEAK_NOTE_KG_S:
4948 species = net.nodes[sim.outlet_node].fluid
4949 exits: list[str] = []
4950 for branch_id, branch
in net.branches.items():
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
4958 if out_flow < LEAK_NOTE_KG_S
or net.nodes[inside].fluid != species:
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:
4966 where = f
": out through {', '.join(exits)}" if exits
else ""
4968 f
"{sim.label} is losing {species} at {rate:.2f} kg/s with the "
4969 f
"engine cold{where}."
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.
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.
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:
4986 elif branch.upstream == node:
4994 return arriving, leaving
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."""
5007 return self.
_split_at(sim.outlet_node, flows)
5009 def _net_into(self, node: str, flows: Mapping[str, float]) -> float:
5010 arriving, leaving = self.
_split_at(node, flows)
5011 return arriving - leaving
5013 def step(self, dt: float) -> Sample:
5014 """What the operator sees ``dt`` seconds later.
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.
5030 dt =
max(
min(dt, MAX_STEP), 1e-4)
5031 steps =
max(int(round(dt / self.
setup.live_step)), 1)
5033 for _
in range(steps):
5040 assert self.
_shown is not None
5044 """Advance the stand by ``dt`` seconds and return where it is."""
5045 dt =
max(
min(dt, MAX_STEP), 1e-4)
5049 net = self.
model.built.network
5050 net.gravity = self.
setup.body_acceleration
5053 for sim
in self.
tanks.values():
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
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
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
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
5074 for branch
in self.
model.built.gse_vents:
5075 net.branches[branch].component.p[
"Cv"] = self.
setup.gse_vent_cv
5086 for bottle
in self.
bottles.values():
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
5112 / self.
setup.max_coupled_change
5121 coupling =
max(coupling, int(dt / (self.
setup.coupling_safety * tau)) + 1)
5138 for sim
in self.
tanks.values():
5139 if sim.id
in resting:
5142 if gas_out > gas_in:
5144 rate =
max(gas_in, gas_out)
5145 inventory = sim.state.ullage.mass
5146 if rate > 0.0
and inventory > 0.0:
5149 int(dt * rate / (inventory * self.
setup.max_mass_step)) + 1,
5151 coupling =
min(coupling, MAX_COUPLING_STEPS)
5162 vessel_couplings = coupling
5164 if 0.0 < press_tau < tau
or (tau <= 0.0
and press_tau > 0.0):
5166 coupling, int(dt / (self.
setup.coupling_safety * press_tau)) + 1
5168 coupling =
min(coupling, MAX_COUPLING_STEPS)
5169 inner_dt = dt / coupling
5170 substeps =
max(1, -(-SUBSTEPS * vessel_couplings // coupling))
5174 started = time.monotonic()
5176 for index
in range(coupling):
5177 remaining = coupling - index
5181 if index
and time.monotonic() - started > self.
setup.tick_budget:
5183 net, signals, inner_dt * remaining, substeps
5186 result = self.
_advance_once(net, signals, inner_dt, substeps)
5188 assert result
is not None
5192 (abs(after[k] - before[k]) /
max(before[k], 1.0)
for k
in before),
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
5208 solved = self.
_held(result)
5209 pressures = dict(solved)
5211 pressures[node] = self.
_trapped.get(node, AMBIENT)
5213 for node, value
in solved.items():
5214 if node
not in held:
5217 if chamber
is not None and chamber.thrust > 0.0:
5225 pressures=pressures,
5227 node_id: node.temperature
5228 for node_id, node
in self.
model.built.network.nodes.items()
5234 converged=result.converged,
5235 tanks={sim.id: sim.readouts()
for sim
in self.
tanks.values()},
5238 notes=tuple(self.
_notes()),
5243 def _supply_to(self, sim: TankSim, pressures: Mapping[str, float]) -> float:
5244 """Pressure of whatever is feeding this ullage [Pa], or zero.
5246 The highest upstream node across a branch carrying gas *into* the
5247 ullage. Zero when nothing is, which leaves the vessel unclamped.
5249 net = self.
model.built.network
5251 for branch
in net.branches.values():
5252 if branch.downstream != sim.ullage_node:
5254 upstream = pressures.get(branch.upstream)
5255 if upstream
is not None:
5256 best =
max(best, upstream)
5260 """Enthalpy of the gas arriving at this ullage [J/kg].
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.
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.
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.
5298 if arrived
is not None:
5299 return float(arrived)
5304 if supply
is not None:
5306 return float(supply.volume.enthalpy(supply.state))
5311 sim.tank.gas.get(
"h", p=from_psig(self.
setup.dome_psi), T=293.15)
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."""
5320 if arrived
is not None:
5321 return float(arrived)
5323 bottle.volume.fluid.get(
5324 "h", p=
max(bottle.pressure, AMBIENT), T=self.
setup.fill_supply_T
5329 """The bottle feeding this ullage, if one is reachable.
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.
5334 wanted = sim.tank.gas.name
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:
5342 return next(iter(ordered),
None)
5345 """Whether the current state is filling this tank. Never a cart's tank:
5346 that is where a load comes from, pre-loaded.
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
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")
5360 """One lumped wall per line, started at the temperature its line holds.
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:
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.
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.
5384 net = self.
model.built.network
5385 for branch_id, branch
in net.branches.items():
5386 component = branch.component
5387 runs = wall_runs(component)
5390 bore = wall_bore(runs)
5391 area = sum(math.pi * b *
max(length, b)
for b, length
in runs)
5393 weighed = component.p.get(
"line_mass", 0.0)
5395 self.
walls[branch_id] =
LineWall(mass=weighed, area=area, bore=bore)
5397 thickness = component.p.get(
"wall_thickness", 0.0)
5398 fittings = component.p.get(
"fitting_mass", 0.0)
5403 fittings = fitting_metal(component.p.get(
"fitting_count", 0.0), bore)
5406 for b, length
in runs:
5408 outer = b + 2.0 * thickness
5410 math.pi / 4.0 * (outer**2 - b**2) * length
5411 ) * STAINLESS_DENSITY
5412 mass = tube + fittings
5415 self.
walls[branch_id] =
LineWall(mass=mass, area=area, bore=bore)
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.
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.
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):
5450 if not h_bubble < enthalpy <= h_dew:
5452 if (enthalpy - h_bubble) < 0.5 * (h_dew - h_bubble):
5454 dew = fluid.get(
"T", p=pressure, q=1.0)
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)."
5462 return max(landed, dew + _DEW_MARGIN_K)
5466 pressures: Mapping[str, float],
5467 flows: Mapping[str, float],
5470 """Carry enthalpy along the flow path, so every node has a real temperature.
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.
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.
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.
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.
5496 net = self.
model.built.network
5500 known: dict[str, float] = {}
5504 heat_drawn: dict[str, tuple[float, float]] = {}
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)
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
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:
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:
5534 feeding.setdefault(sink, []).append((branch_id, mdot, source))
5536 for _
in range(_TEMPERATURE_SWEEPS):
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:
5550 fluid = net.fluid(upstream.fluid)
5551 T_in = upstream.temperature
5552 h = fluid.get(
"h", p=p_up, T=T_in)
5558 wall = self.
walls.get(branch_id)
5562 and seeded
is not None
5563 and self.
setup.line_walls
5565 exchange = wall.exchange(
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),
5574 h += exchange.heat /
max(abs(mdot), 1e-12)
5575 heat_drawn[branch_id] = (exchange.heat, T_in)
5576 except (ValueError, PropertyError):
5578 arriving.append((abs(mdot), h))
5581 total = sum(mdot
for mdot, _
in arriving)
5584 enthalpy = sum(mdot * h
for mdot, h
in arriving) / total
5593 p_here = pressures.get(node_id)
5594 if p_here
is None or p_here <= 0.0:
5600 fluid_here = net.fluid(node.fluid)
5601 landed = fluid_here.get(
"T", p=p_here, h=enthalpy)
5602 if node.phase !=
"liquid":
5604 fluid_here, p_here, enthalpy, landed, node_id
5606 except (ValueError, PropertyError):
5608 if landed > 0.0
and abs(landed - node.temperature) > _TEMPERATURE_TOL:
5609 node.temperature = landed
5626 for branch_id
in self.
walls:
5629 line = net.branches.get(branch_id)
5631 if line
is not None:
5632 inlet = net.nodes.get(line.upstream)
5633 if inlet
is not None:
5634 at_rest = inlet.temperature
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:
5645 capacity = wall.capacity(temperature)
5648 moved = temperature - heat * dt / capacity
5654 if (temperature - stream) * (moved - stream) < 0.0:
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:
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)
5703 for tank_id, sim
in self.
tanks.items()
5704 if tank_id
in vehicle
and not sim.empty
5706 lit = arriving > MIN_CHAMBER_FLOW
and bool(feeds)
5708 net.nodes[chamber].pressure = self.
_design_chamber if lit
else AMBIENT
5711 """Burnout: a tank ran dry during Fire, so the sequence goes to Vent.
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.
5719 if not self.
setup.auto_vent
or self.
state !=
"Fire":
5726 for tank_id, sim
in self.
tanks.items()
5727 if tank_id
in vehicle
and sim.empty
5729 if not dry
or not self.
machine.can_go(self.
state,
"Vent"):
5734 names =
", ".join(sim.label
for sim
in dry)
5742 f
"Burnout at T+{lit:.2f} s: {names} ran dry, so the sequence "
5745 elif self.
model.engine
is None:
5747 f
"Flow test, no engine: {names} ran dry at T+{lit:.2f} s, so the "
5748 "sequence went to Vent."
5752 f
"Not lit: {names} ran dry at T+{lit:.2f} s on a cold flow, so "
5753 "the sequence went to Vent."
5758 """Whether the engine has made thrust since the present state began."""
5762 """What Fire is actually doing, and what an abort's row leaves open.
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).
5775 f
"{sim.label} {say(sim.pressure)}"
5776 for k, sim
in self.
tanks.items()
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."
5789 for a
in self.
machine.actuators
5790 if "main" in a.lower()
and positions.get(a)
5794 f
"{self.state} leaves {' and '.join(mains)} open: the "
5795 "stand's state table says so. Fix the row on the State "
5801 """Trip the stand if a vessel is over the pressure the drawing rates it for.
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.
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
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
5826 _, vessel, label, kind, pressure, mawp =
max(over, key=
lambda o: o[0])
5827 self.
trip =
Trip(vessel, label, kind, self.
t, pressure, mawp)
5833 labels = {n.id: n.label
or n.id
for n
in self.
model.diagram.nodes}
5835 labels.get(branch_id, branch_id)
5837 if float(branch.component.set_pressure) >= mawp - ATMOSPHERE - 0.5 * PSI
5840 f
"{' and '.join(late)} {'is' if len(late) == 1 else 'are'} set at or "
5841 "above that limit and cannot lift before it. "
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."
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
5860 start = self.
bottles[vessel].node
5864 found: dict[str, Any] = {}
5869 for node
in frontier:
5870 for branch_id, branch
in net.branches.items():
5871 if node
not in (branch.upstream, branch.downstream):
5873 if branch_id
in reliefs:
5874 found[branch_id] = branch
5876 if isinstance(branch.component, Regulator):
5880 if branch.downstream == node
5881 else branch.downstream
5886 if not net.nodes[other].is_fixed:
5889 return list(found.items())
5896 for sim
in self.
tanks.values():
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."
5904 out.append(f
"{sim.label} is empty.")
5906 sim.tank.fill_fraction(sim.state) < self.
setup.low_tank
5911 f
"{sim.label} is down to "
5912 f
"{sim.tank.fill_fraction(sim.state) * 100:.0f}%."
5914 readouts = sim.readouts()
5915 excess = readouts[
"wall_temperature_K"] - sim.state.liquid_temperature
5919 and sim.tank.wall_boiling
5920 and excess > self.
setup.warm_wall_K
5926 boiling = readouts[
"wall_temperature_K"] > sim.tank.liquid.get(
5927 "T", p=sim.pressure, q=0.0
5935 and sim.state.liquid_temperature < CRYOGENIC_K
5936 and excess > self.
setup.warm_wall_K
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."
5957 p_crit = float(sim.tank.liquid.critical_pressure)
5958 t_crit = float(sim.tank.liquid.critical_temperature)
5960 p_crit = t_crit = 0.0
5963 near = float(sim.tank.vapour_pressure(sim.state)) > 0.97 * p_crit
5966 if p_crit > 0.0
and not near
and not sim.empty:
5968 sim.state.liquid_temperature, sim.state.surface_temperature
or 0.0
5971 near = hottest >= t_crit
or (
5972 float(sim.tank.liquid.get(
"p", T=hottest, q=0.0))
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 "
5988 else f
"No relief valve is drawn on {sim.label}; a real "
5989 "tank would have lifted one long ago. "
5995 verbs = {GSE_CHARGE:
"charge", GSE_DUMP:
"dump"}
5996 for bottle
in self.
bottles.values():
5999 undrawn = bottle.id
not in self.
_drawn_fill and bottle.id
not in ground
6001 name
for name
in verbs
if undrawn
and name
not in self.
machine.actuators
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."
6009 if not bottle.charged:
6010 out.append(f
"{bottle.label} is not charged.")
6012 bottle.volume.temperature(bottle.state) - bottle.state.wall_temperature
6013 > self.
setup.hot_bottle_K
6020 bottle.volume.temperature(bottle.state)
6021 - bottle.state.wall_temperature
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."
6029 bottle.pressure < LOW_BOTTLE * bottle.target <= bottle.peak
6030 and not bottle.filling
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."
None advance(self, float dt, *, float mdot_out, float stirring=1.0, float mdot_in=0.0, float enthalpy_in=0.0)
VesselState _air(self, VesselState state)
VesselState _at_atmosphere(self, VesselState stepped)
float _net_into(self, str node, Mapping[str, float] flows)
None _chamber(self, Mapping[str, float] flows)
dict[str, str] builtin(self)
Sample step(self, float dt)
tuple[float, float] _split_at(self, str node, Mapping[str, float] flows)
float _loader_supply(self, object loader)
frozenset[str]|None _boundary
float|None _ullage_wall_T0(self, str tank_id, TankSim sim)
list[str] _leaking_notes(self)
dict[str, frozenset[str]] _find_cut_dome_ports(self)
bool _loading(self, TankSim sim)
tuple[str,...] vehicle_tanks(self)
dict[str, Any] _tank_wall_defaults(self)
frozenset[str] _dry_branches(self)
None _build_line_walls(self)
SteadyResult _solve(self, Network net, Mapping[str, float] signals, frozenset[str] isolate, Mapping[str, tuple[float, float]]|None storage)
dict[str, str] _fill_stubs
frozenset[str] _drawn_fill
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)
frozenset[str] _boundary_nodes(self)
Deque[SolverRecord] solver_log
None _accept(self, SteadyResult result)
list[PidNode] _cut_dome_regulators(self)
SteadyResult _advance_once(self, Network net, Mapping[str, float] signals, float dt, int substeps=SUBSTEPS)
float _off_the_dew_line(self, Fluid fluid, float pressure, float enthalpy, float landed, str node_id)
float _travel_time(self, str drawing_id)
dict[str, float] _regulator_slopes(self, frozenset[str] shut)
None _burnout_check(self)
None set_valve(self, str drawing_id, bool is_open)
dict[str, tuple[float, float]] _ullage_storage(self, float dt, frozenset[str] isolated)
None _close_outlet(self, TankSim sim, float dt, set[str] cut, dict[str, tuple[float, float]] out)
None _move_vessels(self, Mapping[str, float] flows, Mapping[str, float] pressures, float dt, int substeps=SUBSTEPS)
frozenset[str] _through_outlets(self)
list[str] skip_chilldown(self, str tank_id="")
dict[str, float] fire_loads(self)
float _bottle_inflow_enthalpy(self, BottleSim bottle)
dict[str, str] _find_fill_stops(self)
float _press_path_timescale(self, TankSim sim, float capacitance, Mapping[str, float]|None signals, float dt=0.0)
bool lit_this_state(self)
None _stop_full_loads(self)
float _vent_fraction(self, str node, Mapping[str, float] flows)
BottleSim|None _bottle_for(self, TankSim sim)
float _through_loader(self, DomeLoader loader, float psig_set)
float _slew(self, str drawing_id, float target, float dt)
float|None _arriving_liquid_T(self, TankSim sim, Mapping[str, float] flows)
dict[str, float] signals(self, float dt=0.0)
Sample _integrate(self, float dt)
tuple[float, float, float] _supply_press(self, TankSim sim, float dt=0.0)
None _rest_ground(self, Mapping[str, float] signals)
list[str] _fire_notes(self)
tuple[float, float] _outlet_split(self, TankSim sim, Mapping[str, float] flows, frozenset[str]|None through=None)
dict[str, float] _relief_signals(self, Network net, Mapping[str, float] signals, Mapping[str, float]|None pressures=None)
None release(self, str drawing_id="")
bool _fills(self, TankSim sim)
tuple[frozenset[str], frozenset[str]] _unreachable_ground(self, frozenset[str] shut)
float _coupling_timescale(self, Mapping[str, float]|None signals=None, float dt=0.0, bool press=True)
dict[str, str] _fill_stops
frozenset[str] _find_drawn_fills(self)
dict[str, float] peek_signals(self, *, bool loaded_dome=False)
list[str] short_loads(self)
float _pressurant_enthalpy(self, TankSim sim)
float _gated_dome(self, DomeLine|None line, float live, Mapping[str, float] commanded)
float _dry_cut(self, Mapping[str, float] flows, float dt)
dict[str, float]|None _fire_loads
None _note_solve(self, SteadyResult result)
Mapping[str, float] _held(self, SteadyResult result)
tuple[float, float, float] _ledger_totals(self)
None _build_vessels(self)
frozenset[str] _fill_hoses
list[tuple[str, Any]] _reliefs_on(self, str vessel)
bool _venting(self, str node, Mapping[str, float] flows)
float _supply_to(self, TankSim sim, Mapping[str, float] pressures)
None _propagate_temperatures(self, Mapping[str, float] pressures, Mapping[str, float] flows, float dt=0.0)
None _apply_vessel_pressures(self)
Callable[[], GasFilm]|None _film_estimator(self, Fluid gas, object node, CylindricalTank geometry)
None __init__(self, Model model, StateMachine machine, Binding binding, *, str state="Idle", Setup|None setup=None, Hookup|None hookup=None)
ChamberResult|None _last_chamber
dict[str, Any] _bottle_wall_defaults(self)
dict[str, float] _vessel_pressures(self)
None _record_solver(self, int couplings)
SteadyResult _close_chamber(self, Network net, Mapping[str, float] signals, frozenset[str] dry, SteadyResult result, Mapping[str, tuple[float, float]]|None storage=None)
dict[str, DomeLine] _find_cut_dome_lines(self)
frozenset[str] _sealed(self, frozenset[str] isolated)
frozenset[str] _liquid_fed
frozenset[str] _last_isolated
None command_state(self, str state)
dict[str, str] _find_fill_stubs(self)
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)
bool _dewar_load(self, float dt)
VesselState _chilled_ullage(self, TankState state, float wall)
float _gas_temperature_of(self, VesselState ullage)
float outlet_pressure(self)
float _chill_target(self, float|None pressure=None)
dict[str, float] readouts(self)
None _collect(self, float added)
float fill_line_flow(self, float drop)
VesselState _settled(self, VesselState ullage)
float load_target_kg(self)
None _one_step(self, float dt, float mdot_liquid_out, float net_gas, float enthalpy, float stirring=1.0, float vapour_out=0.0)
None _ledger(self, TankRates rates, float dt, TankState landed, TankState|None start=None, tuple[float, float] drawn=(0.0, 0.0))
float _atmosphere_floor(self, VesselState|None ullage=None)
bool _chill(self, float dt)
float _specific_energy_at(self, float mass, float temperature)
float _pressurant_floor(self, float|None wall_temperature=None)
None take_back(self, float mass)
TOML_ALWAYS_INLINE path & append(const path &p)
dict[str, str] builtin_rows(StateMachine machine, Model model, Collection[str]|None drawn=None)
frozenset[str] _ground(Model model)
dict[str, list[str]] _pressed_supplies(Model model)
tuple[float, float, float] _vessel_wall(object node, float litres, dict[str, Any] defaults, list[str] assumptions, Callable[[], GasFilm]|None estimate=None)
float _skin_conductance(object node, float air_film, list[str] assumptions)
list[str] _bottle_ids(Model model)
CylindricalTank _tank_geometry(float volume, float diameter)
CylindricalTank _bottle_geometry(float volume)
tuple[dict[str, str], dict[str, str]] drawn_fills(Model model)
float _trip_limit(object node, float safety_factor, list[str] assumptions)
str _not_a_liquid_tank(str label, str species, float temperature, Exception exc)
static double read(int32_t code)