1"""Propellant vapour in the ullage, and what it does to a pressure trace.
3The tank model without this is two species with one interface: pressurant above,
4liquid below, nothing crossing. That is a good model of a warm-gas system on a
5short burn and a poor one of a cryogen, for a reason worth stating precisely.
7**Where the interfacial heat goes.** The ullage loses heat to the cold surface --
8:mod:`feedtwin.vessels.collapse` computes how much. Without a vapour model that
9heat has exactly one place to go: raising the bulk liquid temperature. For
10ethanol at 293 K that is right, and it is a few millikelvin over a burn. For LOX
11sitting at its normal boiling point it is wrong, because a saturated liquid
12cannot warm. It **boils**, at ``mdot = q / h_fg``, and the mass goes into the
13ullage where it carries its own partial pressure.
15That single difference is the one that decides three things a propulsion engineer
18* **A cryogenic tank vent.** Without vapour, opening a vent empties the ullage
19 gas and the trace collapses in tens of milliseconds -- 2.65 g of helium is
20 simply not much. With it, the falling pressure drives boil-off that replenishes
21 the ullage, and the tank vents for as long as there is liquid and heat. The
22 difference is not a correction, it is the shape of the curve.
23* **Pressurant consumption.** Vapour is free ullage. A tank that boils needs less
24 pressurant to hold pressure than one that does not, so a COPV sized against the
25 no-vapour model is sized conservatively -- which is the safe direction, but not
26 a free one when the bottle is already the limiting item.
27* **Nitrogen over LOX.** Condensation, the case :mod:`collapse` names as moving
28 "an order of magnitude more energy per unit area than sensible cooling does".
29 It is the same interface running backwards, and it is why the GN2 curves in the
30 COPV study are optimistic.
32Why partial pressures and not a mixture EOS
33-------------------------------------------
34The ullage is treated as pressurant and vapour sharing a volume and a
35temperature, each on its own equation of state, with **Dalton's law** summing the
36partials. Not a real mixture EOS.
38That is a deliberate approximation and it is a good one here: at ullage
39conditions -- a few tens of bar, well above both species' critical temperature
40for helium and far from the vapour's critical point -- the mixture is nearly
41ideal in the sense that matters, while a real mixture EOS costs a CoolProp
42mixture model per evaluation and a whole class of convergence failures at the
43composition extremes this model spends most of its time at (pure pressurant at
44t=0, pure vapour in a vented tank). The error is a percent-level fugacity
45correction; the cost avoided is a solver that stops converging.
47Off by default, and why
48-----------------------
49:class:`NoVapour` is the default, so nothing changes for an existing model unless
50someone asks. Two reasons, and the second is the honest one:
521. For a storable at room temperature it genuinely is negligible -- ethanol's
53 vapour pressure at 293 K is 5.8 kPa against a 38 bar ullage, 0.015%.
542. It is the more fragile model. Saturation properties near a critical point,
55 a tank driven below its triple point by a fast vent, a liquid that runs out
56 mid-step -- these are real states an integrator will visit, and a run that
57 fails is worse than a run that is 2% optimistic. Turning it on should be a
58 decision with a reason behind it.
61from __future__
import annotations
63from dataclasses
import dataclass
64from typing
import Callable, Protocol, runtime_checkable
69@dataclass(frozen=True, slots=True)
71 """What crossed the interface this instant.
74 mdot: Evaporation rate [kg/s]. Positive leaves the liquid for the
75 ullage; negative is condensation.
76 latent_power: Heat consumed by the phase change [W]. Always the part of
77 the interfacial heat that did *not* warm the liquid, so a caller can
78 split the budget without recomputing ``h_fg``.
82 latent_power: float = 0.0
87 """How a liquid surface exchanges mass with the ullage above it."""
96 liquid_temperature: float,
97 ullage_pressure: float,
98 vapour_partial_pressure: float,
99 interface_area: float,
100 heat_to_interface: float,
102 """Mass and latent heat crossing the surface.
105 liquid: The propellant, for its saturation properties.
106 liquid_temperature: Bulk liquid temperature [K].
107 ullage_pressure: Total ullage pressure [Pa].
108 vapour_partial_pressure: The propellant's own partial pressure in
109 the ullage [Pa]. Below saturation the surface evaporates; above
111 interface_area: Liquid surface area [m^2].
112 heat_to_interface: Heat arriving at the surface from the ullage [W],
113 positive into the liquid. This is the energy available to boil.
118@dataclass(frozen=True, slots=True)
120 """The ullage is pressurant only. The default, and a named choice.
122 Correct for a storable well below its boiling point, and the conservative
123 choice for a cryogen: without boil-off the model demands more pressurant to
124 hold a given pressure than reality will, so a COPV sized on it is sized
134 liquid_temperature: float,
135 ullage_pressure: float,
136 vapour_partial_pressure: float,
137 interface_area: float,
138 heat_to_interface: float,
143@dataclass(frozen=True, slots=True)
145 """A saturated liquid surface: interfacial heat becomes latent heat.
147 The physical statement is one line -- **a liquid at its saturation
148 temperature cannot warm, so heat arriving at it boils liquid instead** --
149 and everything else follows:
153 mdot_evap = q_interface / h_fg(T_liquid)
155 with ``h_fg`` the latent heat at the liquid's own temperature. Heat leaving
156 the surface under a vapour partial pressure above saturation condenses at
157 the same rate with the sign reversed. Heat whose direction disagrees with
158 the partial pressures -- leaving a subsaturated surface, or arriving at a
159 supersaturated one -- changes no phase and is all sensible.
161 ``saturated_fraction`` is the one knob, and it is a physical statement
162 rather than a tuning factor: how much of the interfacial heat load the
163 surface answers with phase change rather than sensible warming. It is 1.0
164 for a liquid genuinely at its boiling point -- a vented LOX tank -- and less
165 for a subcooled one, where the surface must first be brought to saturation.
166 A subcooled liquid with this model at 1.0 will boil when it should warm, so
167 the default is to compute the subcooling and let it fall out.
170 saturated_fraction: float = 1.0
171 name: str =
"saturated"
177 liquid_temperature: float,
178 ullage_pressure: float,
179 vapour_partial_pressure: float,
180 interface_area: float,
181 heat_to_interface: float,
183 if interface_area <= 0.0:
187 p_sat = liquid.get(
"p", T=liquid_temperature, q=0.0)
188 except (ValueError, PropertyError):
201 driving = (p_sat - vapour_partial_pressure) / p_sat
213 if heat_to_interface * driving <= 0.0:
217 latent_power = heat_to_interface * share
218 return VapourExchange(mdot=latent_power / h_fg, latent_power=latent_power)
222 """``h_fg`` [J/kg] at a temperature, from the saturation line.
224 The difference of the two saturated enthalpies rather than a correlation, so
225 it comes from the same equation of state as everything else and goes to zero
226 at the critical point on its own.
228 h_vapour = liquid.get(
"h", T=temperature, q=1.0)
229 h_liquid = liquid.get(
"h", T=temperature, q=0.0)
230 return h_vapour - h_liquid
233_MODELS: dict[str, Callable[[], VapourModel]] = {
235 "saturated": SaturatedVapour,
240 """Add a named vapour model.
242 The registry exists for the same reason :mod:`feedtwin.vessels.collapse` has
243 one: condensation of nitrogen over LOX, interfacial mass-transfer
244 correlations and a fitted boil-off rate are all real models with different
245 assumptions, and each should arrive as a named thing a run report can print
246 rather than as a coefficient inside this one.
248 _MODELS[name] = build
252 if name
not in _MODELS:
254 f
"no vapour model {name!r}; available: {', '.join(sorted(_MODELS))}"
256 return _MODELS[name]()
260 return sorted(_MODELS)
VapourExchange exchange(self, *, Fluid liquid, float liquid_temperature, float ullage_pressure, float vapour_partial_pressure, float interface_area, float heat_to_interface)
VapourExchange exchange(self, *, Fluid liquid, float liquid_temperature, float ullage_pressure, float vapour_partial_pressure, float interface_area, float heat_to_interface)
VapourExchange exchange(self, *, Fluid liquid, float liquid_temperature, float ullage_pressure, float vapour_partial_pressure, float interface_area, float heat_to_interface)
list[str] registered_vapour_models()
None register_vapour_model(str name, Callable[[], VapourModel] build)
VapourModel vapour_model(str name)
float latent_heat(Fluid liquid, float temperature)