STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
tank.py
Go to the documentation of this file.
1"""A propellant tank: liquid below, pressurant above, coupled at the surface.
2
3This is the component that decides whether a pressure-fed system works, and the
4one where the interesting mistakes live. Three of them are structural enough to
5be worth stating before any code.
6
7**The ullage is not a fixed volume.** Liquid leaves, so the gas expands, so it
8cools, so the pressurant demand is larger than "volume divided by density" says.
9:class:`~feedtwin.vessels.volume.GasVolume` assumes a fixed volume and cannot be
10reused unmodified; the expansion work term is what this module adds.
11
12**The liquid's state variables are not the gas's.** The ullage is tracked as
13``(mass, internal energy)`` because a real gas's energy balance is only exact in
14``u``. The liquid is tracked as ``(mass, temperature)`` -- the opposite choice,
15for the opposite reason: liquid density is about 180x less sensitive to pressure
16than a gas's, so recovering pressure from ``(rho, u)`` amplifies any error in
17density by that factor. Each phase gets the state pair its physics is
18well-conditioned in. Forcing one convention on both is tidier and wrong.
19
20**Ullage collapse shows up as pressurant demand, not as a pressure drop.** With
21a regulator holding tank pressure, heat lost to the liquid is made up by more
22gas, which drains the COPV faster, which lowers regulator inlet, which moves the
23outlet through the supply-pressure coefficient. Collapse and droop are one loop.
24A tank model that reports pressure and not consumption hides the entire effect.
25"""
26
27from __future__ import annotations
28
29from dataclasses import dataclass, replace
30
31from feedtwin.model.pressure import say
32from feedtwin.props import Fluid
33from feedtwin.props.errors import PropertyError
34from feedtwin.vessels.collapse import (
35 CollapseModel,
36 ConductionCollapse,
37 LiquidThermal,
38 NoCollapse,
39)
40from feedtwin.vessels.geometry import TankGeometry, level_of_volume
41from feedtwin.vessels.vapour import NoVapour, VapourModel, latent_heat
42from feedtwin.vessels.volume import GRAVITY, Rates, VesselState
43
44#: Universal gas constant [J/(mol.K)].
45R_UNIVERSAL = 8.314462618
46
47
48class UllageCondensed(ValueError):
49 """The ullage is no longer a gas the property layer can evaluate.
50
51 Its own exception because the two causes need different responses: a real
52 long hold wants a repressurisation in the scenario, a numerical overshoot
53 wants a smaller step. A generic ValueError makes a caller guess.
54 """
55
56
57@dataclass(frozen=True, slots=True)
59 """Everything needed to continue a tank's history.
60
61 Args:
62 ullage: Gas state -- mass, total internal energy, wall temperature.
63 liquid_mass: Propellant remaining [kg].
64 liquid_temperature: Bulk liquid temperature [K].
65 contact_time: Seconds the current interface has been exposed to warm
66 gas. Reset by a repressurisation, which is why it is state and not
67 a clock reading -- see :mod:`feedtwin.vessels.collapse`.
68 """
69
70 ullage: VesselState
71 liquid_mass: float
72 liquid_temperature: float
73 contact_time: float = 0.0
74 vapour_mass: float = 0.0
75 """Propellant vapour in the ullage [kg]. Zero unless a vapour model is
76 fitted -- see :mod:`feedtwin.vessels.vapour`. Kept apart from
77 ``ullage.mass`` rather than summed into it because the two species have
78 different equations of state and Dalton wants them separately."""
79 wetted_wall_temperature: float | None = None
80 """Temperature of the wall under the liquid [K], when it is tracked apart
81 from the wall above it. ``None`` -- the default, and every state built
82 before this existed -- means one lumped wall for both faces, which is what
83 the model always did.
84
85 Two nodes exist because one cannot describe a loaded cryogenic tank. Its
86 wetted wall has chilled to the liquid; the wall above the ullage has not.
87 Lumped at liquid temperature the ullage face condenses the pressurant
88 (nitrogen at 39 bar saturates at 113 K, well above a 90 K wall); lumped at
89 ambient the wetted face boils the liquid at kilowatts. The two lumps are
90 split by wetted-area fraction at the state that created them and are not
91 conducted between -- along a thin wall over a tank's height that is a
92 twenty-minute time constant, which is a hold and not a burn."""
93 surface_temperature: float | None = None
94 """Temperature of the liquid's surface layer [K], when it is tracked apart
95 from the bulk. ``None`` -- the default -- is a well-mixed liquid.
96
97 A quiescent cryogen stratifies. Heat leaking in through the wetted wall
98 rides up the wall in a natural-convection boundary layer and pools at
99 the surface; the bulk below stays cold. The ullage sees the *surface*,
100 so the vapour pressure follows the surface temperature, and a shut LOX
101 tank climbs at tens of psi a minute -- neither the ~100 psi/min of a
102 model that boils the whole leak nor the ~0.3 psi/min of one that warms
103 all fifteen kilograms. See :attr:`Tank.surface_layer`."""
104
105 def repressurised(self) -> TankState:
106 """The same state with the interface clock reset.
107
108 What a repressurisation immediately before ignition buys: fresh gas
109 scrubs the chilled surface layer away, and the sqrt(t) flux restarts at
110 its steepest. Modelling the reset is the difference between predicting
111 a pad hold correctly and predicting it as if the tank had just been
112 filled.
113 """
114 return replace(self, contact_time=0.0)
115
116
117@dataclass(frozen=True, slots=True)
119 """Time derivatives of a tank's state, plus the terms worth seeing."""
120
121 ullage: Rates
122 liquid_mass: float
123 """dm_liquid/dt [kg/s]. Negative while draining."""
124
125 liquid_temperature: float
126 """dT_liquid/dt [K/s]."""
127
128 contact_time: float = 1.0
129 """d(contact time)/dt. One, except across a repressurisation event."""
130
131 vapour_mass: float = 0.0
132 """d(vapour mass)/dt [kg/s]. Positive is boil-off."""
133
134 evaporation: float = 0.0
135 """Mass crossing the interface [kg/s], positive out of the liquid. Same
136 number as :attr:`vapour_mass` today and reported separately because they
137 stop being the same as soon as vapour can leave through a vent."""
138
139 latent_power: float = 0.0
140 """The part of the interfacial heat that went into phase change [W]. The
141 remainder warmed the liquid; reporting the split is how you tell a boiling
142 tank from a warming one without inferring it from two other numbers."""
143
144 heat_to_wetted_wall: float = 0.0
145 """Heat the liquid drew from the wetted wall [W]. Chilldown, when it is on;
146 zero when ``wetted_conductance`` is zero, which is the default."""
147
148 heat_to_liquid: float = 0.0
149 """Heat the ullage lost to the liquid surface [W]. Reported rather than
150 buried, because it is the term that sets pressurant consumption and it
151 should be visible in a result."""
152
153 expansion_power: float = 0.0
154 """Work the ullage does pushing the liquid surface down [W]. The term that
155 a fixed-volume vessel model does not have."""
156 wetted_wall_temperature: float = 0.0
157 """d(wetted wall temperature)/dt [K/s]. Zero with one lumped wall."""
158
159 surface_temperature: float = 0.0
160 """dT_surface/dt [K/s]; zero when the surface layer is not tracked."""
161
162
163class Tank:
164 """A partly-filled propellant tank.
165
166 Args:
167 liquid: The propellant.
168 gas: The pressurant. A separate fluid: this is a two-species model with
169 one interface, not a mixture. Vapour in the ullage is a Phase 14
170 refinement and the seam for it is :attr:`collapse`.
171 geometry: Tank shape -- see :mod:`feedtwin.vessels.geometry`.
172 collapse: Interfacial heat transfer model. Defaults to transient
173 conduction, which is a lower bound; ``NoCollapse()`` disables it,
174 and either choice is recorded rather than assumed.
175 wall_mass: Mass of tank wall in thermal contact with the ullage [kg].
176 wall_capacity: Wall specific heat [J/(kg.K)].
177 wall_conductance: Ullage-to-wall conductance hA [W/K].
178 vapour: Propellant vapour in the ullage. Defaults to
179 :class:`~feedtwin.vessels.vapour.NoVapour`, which is exactly the
180 two-species behaviour this class had before the model existed.
181 wetted_conductance: Liquid-to-wall conductance per unit wetted area
182 [W/(m^2.K)]. **Chilldown.** Zero by default, which removes the term
183 entirely; give it a value and a warm wall dumps heat into the
184 liquid, which with a vapour model fitted is what boils a cryogen off
185 during a load. Per unit area rather than lumped because the wetted
186 area is a strong function of fill and the geometry already knows it.
187 ambient_conductance: Air-to-wall film per unit outer area [W/(m^2.K)].
188 **Heat leak.** Zero by default, which is a wall that has no outside
189 -- once chilled it stays chilled and a loaded tank sits forever.
190 Give it a value and the room leaks heat into the metal, split
191 between the wetted and ullage-side lumps by area: the wetted wall
192 passes it to the liquid through ``wetted_conductance``, and with a
193 vapour model fitted a saturated cryogen boils, so a LOX tank with
194 its vent shut climbs -- which is what one does.
195 ambient_temperature: The room [K].
196 wall_boiling: Let heat arriving at a superheated wetted wall boil the
197 liquid **at the wall**, straight into the ullage, rather than
198 warming the bulk. Off by default. Nucleate boiling needs the wall
199 above saturation at the tank's *total* pressure -- a bubble has to
200 push the whole ullage back -- so a LOX tank at atmosphere with
201 its vent shut boils and climbs, and the same tank pressed to
202 38 bar of helium does not: its liquid is a hundred kelvin
203 subcooled at that pressure and the leak warms the bulk instead.
204 Both are what the stand does. Needs a vapour model fitted.
205 """
206
208 self,
209 liquid: Fluid,
210 gas: Fluid,
211 geometry: TankGeometry,
212 *,
213 collapse: CollapseModel | None = None,
214 wall_mass: float = 0.0,
215 wall_capacity: float = 0.0,
216 wall_conductance: float = 0.0,
217 vapour: VapourModel | None = None,
218 wetted_conductance: float = 0.0,
219 ambient_conductance: float = 0.0,
220 ambient_temperature: float = 293.15,
221 wall_boiling: bool = False,
222 nucleate_conductance: float = 0.0,
223 leidenfrost_superheat: float = float("inf"),
224 boiling_onset: float = 0.0,
225 surface_layer: float = 0.0,
226 surface_mixing: float = 0.0,
227 wall_by_level: bool = False,
228 ) -> None:
229 self.liquid = liquid
230 self.gas = gas
231 self.geometry = geometry
232 self.collapse: CollapseModel = (
233 ConductionCollapse() if collapse is None else collapse
234 )
235 self.wall_mass = wall_mass
236 self.wall_capacity = wall_capacity
237 self.wall_conductance = wall_conductance
238 self.vapour: VapourModel = NoVapour() if vapour is None else vapour
239 self.wetted_conductance = wetted_conductance
240 self.ambient_conductance = ambient_conductance
241 self.ambient_temperature = ambient_temperature
242 self.wall_boiling = wall_boiling
243 #: Wetted-wall conductance once the liquid wets the metal (nucleate
244 #: boiling), and the wall superheat below which it does. Zero / inf:
245 #: ``wetted_conductance`` applies at every superheat, as before.
246 self.nucleate_conductance = nucleate_conductance
247 self.leidenfrost_superheat = leidenfrost_superheat
248 #: Wall superheat above saturation needed before the wetted wall
249 #: boils rather than warming the liquid [K]. Zero: any superheat boils,
250 #: as before. Incipience for a cryogen on smooth metal is a few kelvin.
251 self.boiling_onset = boiling_onset
252 #: Thickness of the stratified surface layer [m]; zero is a well-mixed
253 #: liquid (the old model). Wetted-wall heat that does not boil, and the
254 #: sensible part of the interfacial heat, warm this layer rather than
255 #: the bulk. Only states carrying ``surface_temperature`` use it.
256 self.surface_layer = surface_layer
257 #: Conductance per unit interface area between the layer and the bulk
258 #: [W/(m^2.K)] -- conduction and whatever mixing there is.
259 self.surface_mixing = surface_mixing
260 #: Scale the ullage-to-wall conductance by the share of the wall that is
261 #: dry -- the part the ullage actually touches. Off: ``wall_conductance``
262 #: is the whole tank's, at every fill, as before. On a 95 % full LOX
263 #: tank that is twenty times the dry wall, and a pressed ullage lost
264 #: half its pressure in six seconds of Ready.
265 self.wall_by_level = wall_by_level
266 self._r_vapour: float | None = None
267 # The last level asked for, by the exact liquid volume it was asked at.
268 # A step asks the same question a dozen times, and each answer is a
269 # root solve on the geometry; an exact key keeps it bit-identical.
270 self._level_memo: tuple[float, float] = (-1.0, 0.0)
271 self._critical: tuple[float, float] | None = None
272 # Same idea for the properties a step reads over and over: the liquid
273 # density by the exact temperature it was asked at, and the ullage's
274 # properties by the state they were read off. A TankState is frozen, so
275 # holding it as the key is exact; this only skips asking again.
276 self._density_memo: tuple[float, float] = (-1.0, 0.0)
277 self._ullage_memo: tuple[TankState | None, dict[str, float]] = (None, {})
278
279 # --------------------------------------------------------------- geometry
280
281 def liquid_volume(self, state: TankState) -> float:
282 """Volume the propellant occupies [m^3], at its own density."""
283 rho = self.liquid_density(state)
284 return state.liquid_mass / rho if rho > 0.0 else 0.0
285
286 def ullage_volume(self, state: TankState) -> float:
287 """What is left for the gas [m^3].
288
289 Raises:
290 ValueError: the tank is overfull. A negative ullage is not a small
291 error to clip -- it means the load, the density or the geometry
292 disagree, and continuing would silently produce a pressure from
293 a volume that does not exist.
294 """
295 volume = self.geometry.total_volume - self.liquid_volume(state)
296 if volume <= 0.0:
297 raise ValueError(
298 f"tank is overfull: {state.liquid_mass:.4g} kg of "
299 f"{self.liquid.name} occupies "
300 f"{self.liquid_volume(state) * 1e3:.4g} L in a "
301 f"{self.geometry.total_volume * 1e3:.4g} L tank, leaving no "
302 "ullage. Check the propellant load, the liquid temperature, or "
303 "the tank geometry."
304 )
305 return volume
306
307 def level(self, state: TankState) -> float:
308 """Height of the liquid surface above the tank's lowest point [m]."""
309 volume = self.liquid_volume(state)
310 memo = self._level_memo
311 if memo[0] == volume:
312 return memo[1]
313 level = level_of_volume(self.geometry, volume)
314 self._level_memo = (volume, level)
315 return level
316
317 def interface_area(self, state: TankState) -> float:
318 """Liquid surface area [m^2]. Falls away as a head empties."""
319 return self.geometry.cross_section(self.level(state))
320
321 def fill_fraction(self, state: TankState) -> float:
322 return self.liquid_volume(state) / self.geometry.total_volume
323
324 # ------------------------------------------------------------- properties
325
326 def _ullage(self, state: TankState, prop: str) -> float:
327 """One ullage property from ``(rho, u)``, with a legible failure.
328
329 An ullage driven cold and dense enough stops being a gas, and the
330 property layer says so in terms of its own internals -- a bare
331 ``rhoV is invalid`` several frames down inside CoolProp. That is a real
332 physical event with two very different causes and it should name both:
333 a long unpressurised hold genuinely does chill an ullage into a
334 liquid-like state, and a too-large integration step gets there
335 spuriously. Either way, continuing would report a pressure from a state
336 this model does not represent.
337 """
338 known, values = self._ullage_memo
339 if known is state:
340 cached = values.get(prop)
341 if cached is not None:
342 return cached
343 else:
344 values = {}
345 self._ullage_memo = (state, values)
346 rho = state.ullage.mass / self.ullage_volume(state)
347 try:
348 value = self.gas.get(prop, rho=rho, u=state.ullage.specific_energy())
349 values[prop] = value
350 return value
351 except (ValueError, PropertyError) as exc:
352 raise UllageCondensed(
353 f"{self.gas.name} ullage at {rho:.4g} kg/m^3 and "
354 f"{state.ullage.specific_energy() / 1e3:.4g} kJ/kg is no longer "
355 f"a gas this model can evaluate ({exc}). Either the ullage has "
356 "genuinely chilled into a liquid-like state -- a long hold with "
357 "no repressurisation will do that -- or the integration step is "
358 "too large and has overshot. Check the step size first."
359 ) from exc
360
361 def pressure(self, state: TankState) -> float:
362 """Ullage pressure [Pa]. What the regulator sees and the outlet feeds.
363
364 Pressurant plus propellant vapour, by Dalton. With no vapour model
365 fitted the second term is identically zero and this is the pressurant
366 pressure alone, exactly as before.
367 """
368 return self._ullage(state, "p") + self.vapour_pressure(state)
369
370 def vapour_pressure(self, state: TankState) -> float:
371 """The propellant vapour's own partial pressure in the ullage [Pa].
372
373 Its own equation of state at the shared ullage temperature and its own
374 partial density -- not a mixture EOS. See
375 :mod:`feedtwin.vessels.vapour` for why that approximation is the right
376 one here.
377 """
378 if state.vapour_mass <= 0.0:
379 return 0.0
380 volume = self.ullage_volume(state)
381 if volume <= 0.0:
382 return 0.0
383 density = state.vapour_mass / volume
384 temperature = self._ullage(state, "T")
385 try:
386 partial = self.liquid.get("p", rho=density, T=temperature)
387 # The equation of state is asked for a state it is poor at.
388 #
389 # A trace of vapour in a large ullage is a very low density -- a
390 # microgram of oxygen in half a litre is 0.002 kg/m^3 -- and the
391 # Helmholtz formulation extrapolates badly down there: measured on
392 # CoolProp's oxygen, 1 ug in this tank returns **-6057 psi**, while
393 # 10 ug and everything above it is clean and linear. One negative
394 # sample is enough to take the whole tank pressure negative and
395 # every solve after it with it.
396 #
397 # Below about 1 kg/m^3 the gas is ideal to well under a tenth of a
398 # percent anyway, so the ideal answer is not a fallback, it is the
399 # better number. Falling back on a *non-physical* result rather than
400 # on a density threshold means this also catches whatever else the
401 # backend does badly at the edges.
402 if partial > 0.0:
403 return partial
404 except (ValueError, PropertyError):
405 pass
406 try:
407 return density * self._vapour_r_specific() * temperature
408 except (ValueError, PropertyError):
409 # A vapour state the equation of state will not answer for. Zero is
410 # wrong but bounded; inventing a pressure here would be worse, and
411 # the alternative -- raising -- would kill a run for a term that is
412 # a fraction of a percent whenever it is well behaved.
413 return 0.0
414
415 def gas_temperature(self, state: TankState) -> float:
416 return self._ullage(state, "T")
417
418 def gas_enthalpy(self, state: TankState) -> float:
419 return self._ullage(state, "h")
420
421 def _vapour_r_specific(self) -> float:
422 """Specific gas constant of the propellant vapour [J/(kg.K)]: R / M.
423
424 This used to be read off the equation of state at one bar and 300 K,
425 "where every propellant here is a well-behaved gas". Ethanol is not:
426 at one bar and 300 K it is a liquid at 780 kg/m^3, the "gas constant"
427 came out at 0.43 J/(kg.K) instead of 181, and a fuel tank primed with
428 its equilibrium vapour was seeded with 124 grams of ethanol vapour in
429 a 2.6 litre ullage -- half its gas by mass, which then slowed its vent
430 to twice the real time. The molar mass is what the ideal-gas fallback
431 actually needs, and the equation of state knows it for any species.
432 """
433 if self._r_vapour is None:
434 self._r_vapour = R_UNIVERSAL / self.liquid.get(
435 "molar_mass", T=300.0, p=1.0e5
436 )
437 return self._r_vapour
438
439 def liquid_density(self, state: TankState) -> float:
440 """Liquid density [kg/m^3], on the saturation line at its temperature.
441
442 Saturated rather than at tank pressure: for a cryogen the compressed-
443 liquid correction is a fraction of a percent, while asking for a
444 pressure the liquid is not actually at risks landing outside a tabular
445 backend's envelope for no gain.
446 """
447 T = state.liquid_temperature
448 memo = self._density_memo
449 if memo[0] == T:
450 return memo[1]
451 rho = self.liquid.get("rho", T=T, q=0.0)
452 self._density_memo = (T, rho)
453 return rho
454
455 def liquid_thermal(self, state: TankState) -> LiquidThermal:
456 T = state.liquid_temperature
457 return LiquidThermal(
458 conductivity=self.liquid.get("k", T=T, q=0.0),
459 density=self.liquid.get("rho", T=T, q=0.0),
460 heat_capacity=self.liquid.get("cp", T=T, q=0.0),
461 )
462
463 def outlet_pressure(self, state: TankState, gravity: float = GRAVITY) -> float:
464 """Pressure at the tank outlet [Pa]: ullage plus the liquid column.
465
466 The head a full tank adds is not negligible against a feed-system
467 budget -- a metre of LOX is about 1.6 psi -- and it decays over a burn,
468 so a model that leaves it out predicts a drifting error rather than a
469 constant one. ``gravity`` is what the column feels along the tank's
470 axis: standard gravity on a stand, several times it in a vehicle under
471 thrust.
472 """
473 return self.pressure(state) + self.liquid_density(state) * gravity * self.level(
474 state
475 )
476
477 # ---------------------------------------------------------- initial state
478
480 self,
481 *,
482 pressure: float,
483 liquid_mass: float,
484 liquid_temperature: float,
485 gas_temperature: float | None = None,
486 contact_time: float = 0.0,
487 split_wall: bool = False,
488 ) -> TankState:
489 """Build a state from what is actually known before a test.
490
491 A propellant load, a liquid temperature, a tank pressure, and the
492 pressurant temperature. Everything else follows.
493
494 ``contact_time`` is how long the interface has existed [s]; a tank that
495 was loaded minutes ago is not one whose surface appeared this instant,
496 and the collapse flux goes as ``1/sqrt(t)``. ``split_wall`` gives the
497 wall under the liquid its own temperature -- the liquid's -- while the
498 wall above stays at the gas temperature. Off, a single lump at the gas
499 temperature, exactly as before.
500 """
501 rho_l = self.liquid.get("rho", T=liquid_temperature, q=0.0)
502 volume = self.geometry.total_volume - liquid_mass / rho_l
503 if volume <= 0.0:
504 raise ValueError(
505 f"{liquid_mass:.4g} kg of {self.liquid.name} at "
506 f"{liquid_temperature:.4g} K does not fit in a "
507 f"{self.geometry.total_volume * 1e3:.4g} L tank"
508 )
509 T_gas = liquid_temperature if gas_temperature is None else gas_temperature
510 # A loaded tank whose interface has existed for a while already holds
511 # its propellant's vapour at the liquid's saturation pressure, and the
512 # pressurant makes up the *rest* of the stated total. Built with no
513 # vapour, a saturated cryogen boils it off after the regulator has
514 # locked up at the total, and nothing can take the extra 15 psi back
515 # out -- the trace then opens above lockup. Only with a vapour model
516 # fitted, only with liquid present, and only where the pressurant is
517 # still the bulk of the ullage.
518 p_vapour = 0.0
519 if (
520 liquid_mass > 0.0
521 and contact_time > 0.0
522 and not isinstance(self.vapour, NoVapour)
523 ):
524 try:
525 p_sat = self.liquid.get("p", T=liquid_temperature, q=0.0)
526 except (ValueError, PropertyError):
527 p_sat = 0.0
528 if 0.0 < p_sat < 0.5 * pressure:
529 p_vapour = p_sat
530 p_gas = pressure - p_vapour
531 phase = self.gas.phase(p=p_gas, T=T_gas)
532 if phase.is_liquid_like:
533 raise ValueError(
534 f"pressurant {self.gas.name} at {say(p_gas, 1)} and "
535 f"{T_gas:.4g} K is a liquid ({phase.value}); an ullage holds gas"
536 )
537 rho_g = self.gas.get("rho", p=p_gas, T=T_gas)
538 u_g = self.gas.get("u", p=p_gas, T=T_gas)
539 vapour_mass = (
540 p_vapour * volume / (self._vapour_r_specific() * T_gas)
541 if p_vapour > 0.0
542 else 0.0
543 )
544 return TankState(
545 ullage=VesselState(
546 mass=rho_g * volume,
547 energy=u_g * rho_g * volume,
548 wall_temperature=T_gas,
549 ),
550 liquid_mass=liquid_mass,
551 liquid_temperature=liquid_temperature,
552 contact_time=max(contact_time, 0.0),
553 vapour_mass=vapour_mass,
554 wetted_wall_temperature=liquid_temperature if split_wall else None,
555 surface_temperature=(
556 liquid_temperature if self.surface_layer > 0.0 else None
557 ),
558 )
559
560 def dry_wall_fraction(self, state: TankState) -> float:
561 """Share of the inner wall above the liquid [-]: what the ullage touches."""
562 total = self.geometry.wetted_area(self.geometry.height)
563 if total <= 0.0:
564 return 1.0
565 wet = self.geometry.wetted_area(self.level(state))
566 return min(max((total - wet) / total, 0.0), 1.0)
567
568 def surface_mass(self, state: TankState, rho_l: float | None = None) -> float:
569 """Liquid in the surface layer [kg]: a slab ``surface_layer`` deep over
570 the interface, never more than the liquid there is."""
571 if self.surface_layer <= 0.0 or state.liquid_mass <= 0.0:
572 return 0.0
573 rho = self.liquid_density(state) if rho_l is None else rho_l
574 slab = rho * self.interface_area(state) * self.surface_layer
575 return min(slab, state.liquid_mass)
576
577 def _critical_point(self) -> tuple[float, float]:
578 """``(T_crit, p_crit)`` of the propellant, read once; infinite for a
579 measured table with no critical point."""
580 if self._critical is None:
581 try:
582 self._critical = (
583 float(self.liquid.critical_temperature),
584 float(self.liquid.critical_pressure),
585 )
586 except Exception: # noqa: BLE001 - a table has no critical point
587 self._critical = (float("inf"), float("inf"))
588 return self._critical
589
590 # ------------------------------------------------------------------ rates
591
592 def rates(
593 self,
594 state: TankState,
595 *,
596 mdot_liquid_out: float = 0.0,
597 mdot_gas_in: float = 0.0,
598 enthalpy_gas_in: float = 0.0,
599 heat_in: float = 0.0,
600 stirring: float = 1.0,
601 mdot_vapour_out: float = 0.0,
602 ) -> TankRates:
603 """Time derivatives, for an integrator to march.
604
605 Args:
606 mdot_liquid_out: Propellant leaving [kg/s].
607 mdot_gas_in: Pressurant arriving [kg/s].
608 enthalpy_gas_in: Specific enthalpy of arriving pressurant [J/kg].
609 Warm gas into a cold ullage is a heat source and the number
610 that makes helium behave differently from nitrogen.
611 heat_in: External heat into the ullage [W].
612 stirring: Multiplier on the ullage-to-wall conductance, 1 by
613 default; see :meth:`GasVolume.rates`.
614 mdot_vapour_out: Propellant vapour leaving through a vent [kg/s].
615 A well-mixed ullage vents pressurant and vapour in proportion,
616 and the caller knows the split. Without this a venting LOX
617 tank lost only its pressurant: the vapour it boiled stayed,
618 and a tank with a 3/8 in vent wide open climbed to 500 psig. Wall exchange and
619 interfacial loss are computed here and must not be included.
620 """
621 volume = self.ullage_volume(state)
622 T_gas = self.gas_temperature(state)
623 pressure = self.pressure(state)
624
625 # The surface the ullage sees: the stratified layer when there is one.
626 layered = self.surface_layer > 0.0 and state.surface_temperature is not None
627 T_surface = (
628 state.surface_temperature
629 if layered and state.surface_temperature is not None
630 else state.liquid_temperature
631 )
632
633 two_walls = state.wetted_wall_temperature is not None
634 T_wetted = (
635 state.wetted_wall_temperature
636 if state.wetted_wall_temperature is not None
637 else state.ullage.wall_temperature
638 )
639 # `stirring` scales the still-gas conductance while a charge jet is
640 # stirring the ullage; 1 is exactly the old behaviour.
641 conductance = self.wall_conductance
642 if self.wall_by_level and conductance > 0.0:
643 conductance *= self.dry_wall_fraction(state)
644 q_wall = (
645 conductance * max(stirring, 0.0) * (state.ullage.wall_temperature - T_gas)
646 )
647
648 q_liquid = self.collapse.heat_rate(
649 area=self.interface_area(state),
650 gas_temperature=T_gas,
651 liquid_temperature=T_surface,
652 liquid=self.liquid_thermal(state),
653 elapsed=state.contact_time,
654 )
655
656 # The ullage grows at the rate the liquid leaves, and does p.dV work
657 # on the receding surface. This is the term a fixed-volume vessel
658 # does not have, and it is why an ullage cools while being fed.
659 rho_l = self.liquid_density(state)
660 dV_dt = mdot_liquid_out / rho_l if rho_l > 0.0 else 0.0
661 expansion_power = pressure * dV_dt
662
663 # ---- chilldown: the wetted wall ---------------------------------
664 #
665 # The wall term above is ullage-to-wall. This is the other face of the
666 # same wall, the part under liquid, and it is what chills down. Zero
667 # unless someone asked for it.
668 q_wetted = 0.0
669 # Saturation at the tank's *total* pressure: what the wall has to be
670 # above to boil, and what the boiling regime is measured from.
671 # One saturation call per step is what the layer and the regimes
672 # cost; skip it when nothing can use it -- a wall no warmer than its
673 # liquid has no regime to pick and nothing to boil, and a state with
674 # no surface layer has no cap to check.
675 T_sat_total = float("inf")
676 wants_sat = state.liquid_mass > 0.0 and not isinstance(self.vapour, NoVapour)
677 if wants_sat and not (
678 (self.wetted_conductance > 0.0 and T_wetted > state.liquid_temperature)
679 or (self.surface_layer > 0.0 and state.surface_temperature is not None)
680 ):
681 wants_sat = False
682 if wants_sat:
683 try:
684 T_sat_total = self.liquid.get("T", p=pressure, q=0.0)
685 except (ValueError, PropertyError):
686 T_sat_total = float("inf")
687 if self.wetted_conductance > 0.0:
688 wetted = self.geometry.wetted_area(self.level(state))
689 # Two boiling regimes. A wall far above saturation is insulated by
690 # its own vapour film (film boiling, ~100 W/m2K on a cryogen); once
691 # the superheat falls under the Leidenfrost point the liquid wets
692 # the metal and nucleate boiling runs at thousands. The second is
693 # why the end of a chilldown is quick. Opt-in: with no nucleate
694 # value the film value applies throughout, as it always did.
695 h = self.wetted_conductance
696 superheat = T_wetted - T_sat_total
697 if (
698 self.nucleate_conductance > 0.0
699 and 0.0 < superheat < self.leidenfrost_superheat
700 ):
701 h = self.nucleate_conductance
702 q_wetted = h * wetted * (T_wetted - state.liquid_temperature)
703
704 # ---- boiling at the wall ------------------------------------------
705 #
706 # A wetted wall above saturation at the tank's total pressure grows
707 # bubbles, and what they carry into the ullage never warms the bulk.
708 # Off unless asked for; needs a vapour model, since the vapour has to
709 # go somewhere.
710 wall_boil = 0.0
711 q_boil = 0.0
712 if (
713 self.wall_boiling
714 and q_wetted > 0.0
715 and state.liquid_mass > 0.0
716 and not isinstance(self.vapour, NoVapour)
717 ):
718 try:
719 h_fg = latent_heat(self.liquid, state.liquid_temperature)
720 except (ValueError, PropertyError):
721 h_fg = 0.0
722 if T_wetted > T_sat_total + self.boiling_onset and h_fg > 0.0:
723 q_boil = q_wetted
724 wall_boil = q_boil / h_fg
725
726 # ---- where the interfacial heat goes ----------------------------
727 #
728 # Everything arriving at the surface -- from the ullage above and the
729 # wall below -- is either latent or sensible. A saturated liquid takes
730 # it as latent and boils; a subcooled one takes it as sensible and
731 # warms. The vapour model decides the split, and with NoVapour fitted
732 # it is all sensible, which is what this method did before. Heat the
733 # wall has already turned to vapour is not on the table here.
734 #
735 # With a surface layer, the wall's convective heat that never reaches
736 # the surface (`to_bulk`, below) is not on it either. It used to be:
737 # the exchange priced it as latent at the surface and the layer was
738 # then debited for it a second time on its way to the bulk -- a wall
739 # colder than the liquid made the surface condense vapour it did not
740 # have, and warmed the layer above everything around it.
741 m_surface = m_bulk = 0.0
742 q_wall_conv = q_wetted - q_boil
743 to_bulk = 0.0
744 if layered:
745 m_surface = self.surface_mass(state, rho_l)
746 m_bulk = state.liquid_mass - m_surface
747 if m_surface > 0.0 and m_bulk > 1e-6:
748 # Liquid warmed at the wall arrives at the surface no hotter
749 # than the wall that warmed it. The wall's convective heat
750 # therefore feeds the layer only while the layer is below the
751 # wall; past that it can only thicken the layer, which in a
752 # slab of fixed depth means it goes to the bulk. Without this
753 # bound a wall a few kelvin warm, dumping its stored heat into
754 # a few hundred grams, put the surface tens of kelvin above
755 # the metal. The ullage's heat needs no such guard: the
756 # collapse term already follows T_gas - T_surface and turns
757 # off on its own.
758 span = T_wetted - state.liquid_temperature
759 reach = (
760 min(max((T_wetted - T_surface) / span, 0.0), 1.0)
761 if span > 1e-9
762 else 0.0
763 )
764 to_bulk = q_wall_conv * (1.0 - reach)
765 q_interface = q_liquid + q_wetted - q_boil - to_bulk
766 exchange = self.vapour.exchange(
767 liquid=self.liquid,
768 liquid_temperature=T_surface,
769 ullage_pressure=pressure,
770 vapour_partial_pressure=self.vapour_pressure(state),
771 interface_area=self.interface_area(state),
772 heat_to_interface=q_interface,
773 )
774
775 # The liquid warms by what is left after the phase change. Small over a
776 # burn -- a few millikelvin -- and carried because over a pad hold it
777 # is not.
778 cp_l = self.liquid.get("cp", T=state.liquid_temperature, q=0.0)
779 heat_capacity_l = state.liquid_mass * cp_l
780 sensible = q_interface - exchange.latent_power
781 d_T_liquid = sensible / heat_capacity_l if heat_capacity_l > 0.0 else 0.0
782 d_T_surface = 0.0
783 if layered:
784 # Stratification. What the wall did not boil rides up to the
785 # surface in the boundary layer, the ullage's heat lands there
786 # too, and the slab of liquid at the top takes all of it as
787 # sensible heat -- fifteen kilograms do not warm, a few hundred
788 # grams do, and the vapour pressure follows *them*. The bulk
789 # sees only what leaks down through the layer, and the wall's
790 # heat that never reached the surface (`to_bulk`, above).
791 q_mix = (
792 self.surface_mixing
793 * self.interface_area(state)
794 * (T_surface - state.liquid_temperature)
795 )
796 if m_surface > 0.0 and m_bulk > 1e-6:
797 net = sensible - q_mix
798 # A surface at saturation for the tank's total pressure does
799 # not superheat: it boils. Once the layer is there, the heat
800 # that would have warmed it evaporates it instead, and the
801 # vapour carries the pressure up. Also kept clear of the
802 # critical point, where the saturation line -- and every
803 # property call on it -- ends. Without both a warm dry wall
804 # ran the layer to 154.6 K and the tank froze at 0 psig.
805 T_crit, p_crit = self._critical_point()
806 cap = min(T_sat_total, T_crit - 2.0)
807 if net > 0.0 and T_surface >= cap:
808 # Only while there is a saturation line to boil along:
809 # below the critical pressure, and with the vapour not
810 # already at saturation over the surface. Past either
811 # the model has no phase change to offer (h_fg -> 0 at
812 # the critical point made this term infinite once) and
813 # the heat goes to the bulk; the tank sits at the
814 # propellant's critical pressure, as it always has.
815 try:
816 p_sat_cap = self.liquid.get("p", T=min(T_surface, cap), q=0.0)
817 h_fg_s = latent_heat(self.liquid, min(T_surface, cap))
818 except (ValueError, PropertyError):
819 p_sat_cap, h_fg_s = 0.0, 0.0
820 boils = (
821 h_fg_s > 0.0
822 and pressure < 0.97 * p_crit
823 and self.vapour_pressure(state) < p_sat_cap
824 )
825 if boils:
826 wall_boil += net / h_fg_s
827 q_boil += net
828 else:
829 to_bulk += net
830 net = 0.0
831 d_T_surface = net / (m_surface * cp_l)
832 d_T_liquid = (to_bulk + q_mix) / (m_bulk * cp_l)
833 elif heat_capacity_l > 0.0:
834 # Down to the last of the liquid: one node, and the surface
835 # is pulled onto it.
836 d_T_liquid = sensible / heat_capacity_l
837 d_T_surface = d_T_liquid + (state.liquid_temperature - T_surface)
838
839 # Boil-off deliberately contributes **no** term to this balance, and
840 # the reason is a trap worth naming.
841 #
842 # `ullage.energy` is the *pressurant's* internal energy, in the
843 # pressurant's reference state. Saturated-vapour enthalpy comes out of
844 # the propellant's equation of state, in the propellant's reference
845 # state, and the two references are unrelated: at 90 K, LOX saturated
846 # liquid sits at -133.7 kJ/kg while helium at ullage conditions is
847 # +628.6 kJ/kg. Adding one into the other is not a small error, it is a
848 # category error -- and with only a couple of grams of helium in a LOX
849 # ullage it drives the state to a negative pressure inside one step.
850 #
851 # So the vapour is tracked for **mass and partial pressure**, not as a
852 # second energy carrier. The energy that made it has already left this
853 # balance through `q_liquid`. What is not captured is the thermal energy
854 # the vapour carries back into the ullage, which biases the ullage cool.
855 # That is a bounded, stated approximation; the alternative is a genuine
856 # two-species energy state, which is a larger change than this phase.
857 d_energy = (
858 mdot_gas_in * enthalpy_gas_in
859 + heat_in
860 + q_wall
861 - q_liquid
862 - expansion_power
863 )
864
865 # ---- the room: heat leaking in through the outer skin -------------
866 #
867 # Zero unless asked for. Split between the two lumps by the area each
868 # presents to the air, so the wetted wall gets most of it on a full
869 # tank and passes it on to the liquid through `wetted_conductance`.
870 total_area = self.geometry.wetted_area(self.geometry.height)
871 wetted_area = self.geometry.wetted_area(self.level(state))
872 q_ambient_ullage = q_ambient_wetted = 0.0
873 if self.ambient_conductance > 0.0 and total_area > 0.0:
874 if two_walls:
875 q_ambient_wetted = (
877 * wetted_area
878 * (self.ambient_temperature - T_wetted)
879 )
880 q_ambient_ullage = (
882 * (total_area - wetted_area)
883 * (self.ambient_temperature - state.ullage.wall_temperature)
884 )
885 else:
886 q_ambient_ullage = (
888 * total_area
889 * (self.ambient_temperature - state.ullage.wall_temperature)
890 )
891
892 # The wall pays for what it gave and takes what the room gives it.
893 # With one lump it pays for both faces; with two, each lump pays for
894 # its own face, the metal split between them by the wetted-area
895 # fraction of the tank as it stands now.
896 capacity = self.wall_mass * self.wall_capacity
897 d_wetted_wall = 0.0
898 if capacity <= 0.0:
899 d_wall = 0.0
900 elif two_walls:
901 wet_frac = wetted_area / total_area if total_area > 0.0 else 0.0
902 wet_frac = min(max(wet_frac, 1e-3), 1.0 - 1e-3)
903 d_wall = (q_ambient_ullage - q_wall) / (capacity * (1.0 - wet_frac))
904 d_wetted_wall = (q_ambient_wetted - q_wetted) / (capacity * wet_frac)
905 else:
906 d_wall = (q_ambient_ullage - q_wall - q_wetted) / capacity
907
908 return TankRates(
909 ullage=Rates(
910 mass=mdot_gas_in,
911 energy=d_energy,
912 wall_temperature=d_wall,
913 heat_from_wall=q_wall,
914 ),
915 # Liquid leaves through the outlet and, when it boils, through the
916 # surface as well. Boil-off is real propellant loss: a tank that
917 # has been venting has less in it than the load sheet says.
918 liquid_mass=-mdot_liquid_out - exchange.mdot - wall_boil,
919 liquid_temperature=d_T_liquid,
920 contact_time=1.0,
921 vapour_mass=exchange.mdot + wall_boil - max(mdot_vapour_out, 0.0),
922 evaporation=exchange.mdot,
923 latent_power=exchange.latent_power,
924 heat_to_wetted_wall=q_wetted,
925 heat_to_liquid=q_liquid,
926 expansion_power=expansion_power,
927 wetted_wall_temperature=d_wetted_wall,
928 surface_temperature=d_T_surface,
929 )
930
931 def step(self, state: TankState, rates: TankRates, dt: float) -> TankState:
932 """One explicit Euler step.
933
934 Explicit on purpose, and stable only because the caller makes it so:
935 the session sub-steps the vessels, sizes its coupling from the
936 regulator-ullage time constant and the ullage's mass, and closes the
937 press path implicitly inside the network solve (ullage storage).
938
939 Rebuilt with ``replace``: a field added to :class:`TankState` later is
940 carried through the step rather than reset to its default every step,
941 which is how ``vapour_mass`` was once zeroed (docs/PHYSICS-BENCHMARK.md
942 4.3).
943 """
944 return replace(
945 state,
946 ullage=replace(
947 state.ullage,
948 mass=state.ullage.mass + rates.ullage.mass * dt,
949 energy=state.ullage.energy + rates.ullage.energy * dt,
950 wall_temperature=state.ullage.wall_temperature
951 + rates.ullage.wall_temperature * dt,
952 ),
953 liquid_mass=state.liquid_mass + rates.liquid_mass * dt,
954 liquid_temperature=state.liquid_temperature + rates.liquid_temperature * dt,
955 contact_time=state.contact_time + rates.contact_time * dt,
956 vapour_mass=max(state.vapour_mass + rates.vapour_mass * dt, 0.0),
957 wetted_wall_temperature=(
958 None
959 if state.wetted_wall_temperature is None
960 else state.wetted_wall_temperature + rates.wetted_wall_temperature * dt
961 ),
962 surface_temperature=(
963 None
964 if state.surface_temperature is None
965 else state.surface_temperature + rates.surface_temperature * dt
966 ),
967 )
968
970 self,
971 state: TankState,
972 mdot_liquid: float,
973 duration: float,
974 *,
975 inlet_temperature: float,
976 steps: int = 500,
977 ) -> dict[str, float]:
978 """How much pressurant it takes to hold pressure through an expulsion.
979
980 The question a pressurisation system is actually sized by, and the one
981 where ullage collapse shows itself: with pressure held constant the
982 collapse never appears in the pressure trace at all, only in this
983 number.
984
985 Solves for the gas inflow that keeps ullage pressure at its starting
986 value, marching the real energy balance -- expansion work, interfacial
987 loss and wall exchange included.
988
989 Returns:
990 ``mass`` [kg] of pressurant consumed, the ``heat_to_liquid`` [J]
991 lost at the interface over the run, the ``expansion_work`` [J], and
992 the ``pressure_error`` [Pa] left at the end, which should be small
993 and is reported so it can be checked rather than trusted.
994 """
995 target = self.pressure(state)
996 h_in = self.gas.get("h", p=target, T=inlet_temperature)
997 dt = duration / steps
998 total_gas = 0.0
999 total_heat = 0.0
1000 total_work = 0.0
1001
1002 for _ in range(steps):
1003 if state.liquid_mass <= 0.0:
1004 break
1005 # Bisect on the inflow that holds pressure across this step. The
1006 # relation is monotonic -- more gas in, higher pressure -- so a
1007 # bracket is guaranteed and a dozen iterations is plenty.
1008 lo, hi = 0.0, max(mdot_liquid, 1e-6)
1009 for _ in range(60):
1010 mid = 0.5 * (lo + hi)
1011 rates = self.rates(
1012 state,
1013 mdot_liquid_out=mdot_liquid,
1014 mdot_gas_in=mid,
1015 enthalpy_gas_in=h_in,
1016 )
1017 trial = self.step(state, rates, dt)
1018 if self.pressure(trial) < target:
1019 lo = mid
1020 else:
1021 hi = mid
1022 if hi - lo < 1e-12:
1023 break
1024 if hi == max(mdot_liquid, 1e-6) and lo == hi:
1025 break
1026 # Widen if even the ceiling cannot hold pressure.
1027 if lo == hi == max(mdot_liquid, 1e-6):
1028 hi *= 2.0
1029 mdot_gas = 0.5 * (lo + hi)
1030 rates = self.rates(
1031 state,
1032 mdot_liquid_out=mdot_liquid,
1033 mdot_gas_in=mdot_gas,
1034 enthalpy_gas_in=h_in,
1035 )
1036 state = self.step(state, rates, dt)
1037 total_gas += mdot_gas * dt
1038 total_heat += rates.heat_to_liquid * dt
1039 total_work += rates.expansion_power * dt
1040
1041 return {
1042 "mass": total_gas,
1043 "heat_to_liquid": total_heat,
1044 "expansion_work": total_work,
1045 "pressure_error": self.pressure(state) - target,
1046 "liquid_remaining": state.liquid_mass,
1047 }
1048
1049 def __repr__(self) -> str:
1050 return (
1051 f"Tank({self.liquid.name!r}/{self.gas.name!r}, "
1052 f"{self.geometry.total_volume * 1e3:.2f} L, "
1053 f"collapse={self.collapse.name!r})"
1054 )
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
TankState repressurised(self)
Definition tank.py:105
LiquidThermal liquid_thermal(self, TankState state)
Definition tank.py:455
float outlet_pressure(self, TankState state, float gravity=GRAVITY)
Definition tank.py:463
float _ullage(self, TankState state, str prop)
Definition tank.py:326
float gas_temperature(self, TankState state)
Definition tank.py:415
TankRates rates(self, TankState state, *, float mdot_liquid_out=0.0, float mdot_gas_in=0.0, float enthalpy_gas_in=0.0, float heat_in=0.0, float stirring=1.0, float mdot_vapour_out=0.0)
Definition tank.py:602
float fill_fraction(self, TankState state)
Definition tank.py:321
float surface_mass(self, TankState state, float|None rho_l=None)
Definition tank.py:568
float ullage_volume(self, TankState state)
Definition tank.py:286
float _vapour_r_specific(self)
Definition tank.py:421
TankState initial_state(self, *, float pressure, float liquid_mass, float liquid_temperature, float|None gas_temperature=None, float contact_time=0.0, bool split_wall=False)
Definition tank.py:488
float pressure(self, TankState state)
Definition tank.py:361
float level(self, TankState state)
Definition tank.py:307
dict[str, float] pressurant_for_expulsion(self, TankState state, float mdot_liquid, float duration, *, float inlet_temperature, int steps=500)
Definition tank.py:977
None __init__(self, Fluid liquid, Fluid gas, TankGeometry geometry, *, CollapseModel|None collapse=None, float wall_mass=0.0, float wall_capacity=0.0, float wall_conductance=0.0, VapourModel|None vapour=None, float wetted_conductance=0.0, float ambient_conductance=0.0, float ambient_temperature=293.15, bool wall_boiling=False, float nucleate_conductance=0.0, float leidenfrost_superheat=float("inf"), float boiling_onset=0.0, float surface_layer=0.0, float surface_mixing=0.0, bool wall_by_level=False)
Definition tank.py:228
float interface_area(self, TankState state)
Definition tank.py:317
float liquid_density(self, TankState state)
Definition tank.py:439
float gas_enthalpy(self, TankState state)
Definition tank.py:418
tuple[float, float] _critical_point(self)
Definition tank.py:577
tuple[float, float]|None _critical
Definition tank.py:271
float vapour_pressure(self, TankState state)
Definition tank.py:370
float dry_wall_fraction(self, TankState state)
Definition tank.py:560
float liquid_volume(self, TankState state)
Definition tank.py:281
TankState step(self, TankState state, TankRates rates, float dt)
Definition tank.py:931
static self_test_reply_t exchange(void *context, uint16_t id, uint8_t mode, uint32_t max_hz, self_test_spi_frame_t *frames, uint8_t count, uint32_t budget_ms)