STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
vapour.py
Go to the documentation of this file.
1"""Propellant vapour in the ullage, and what it does to a pressure trace.
2
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.
6
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.
14
15That single difference is the one that decides three things a propulsion engineer
16actually asks about:
17
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.
31
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.
37
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.
46
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:
51
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.
59"""
60
61from __future__ import annotations
62
63from dataclasses import dataclass
64from typing import Callable, Protocol, runtime_checkable
65
66from feedtwin.props import Fluid, PropertyError
67
68
69@dataclass(frozen=True, slots=True)
71 """What crossed the interface this instant.
72
73 Args:
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``.
79 """
80
81 mdot: float = 0.0
82 latent_power: float = 0.0
83
84
85@runtime_checkable
86class VapourModel(Protocol):
87 """How a liquid surface exchanges mass with the ullage above it."""
88
89 @property
90 def name(self) -> str: ...
91
93 self,
94 *,
95 liquid: Fluid,
96 liquid_temperature: float,
97 ullage_pressure: float,
98 vapour_partial_pressure: float,
99 interface_area: float,
100 heat_to_interface: float,
101 ) -> VapourExchange:
102 """Mass and latent heat crossing the surface.
103
104 Args:
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
110 it, it condenses.
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.
114 """
115 ...
116
117
118@dataclass(frozen=True, slots=True)
120 """The ullage is pressurant only. The default, and a named choice.
121
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
125 large.
126 """
127
128 name: str = "none"
129
131 self,
132 *,
133 liquid: Fluid,
134 liquid_temperature: float,
135 ullage_pressure: float,
136 vapour_partial_pressure: float,
137 interface_area: float,
138 heat_to_interface: float,
139 ) -> VapourExchange:
140 return VapourExchange()
141
142
143@dataclass(frozen=True, slots=True)
145 """A saturated liquid surface: interfacial heat becomes latent heat.
146
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:
150
151 .. code-block:: text
152
153 mdot_evap = q_interface / h_fg(T_liquid)
154
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.
160
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.
168 """
169
170 saturated_fraction: float = 1.0
171 name: str = "saturated"
172
174 self,
175 *,
176 liquid: Fluid,
177 liquid_temperature: float,
178 ullage_pressure: float,
179 vapour_partial_pressure: float,
180 interface_area: float,
181 heat_to_interface: float,
182 ) -> VapourExchange:
183 if interface_area <= 0.0:
184 return VapourExchange()
185 try:
186 h_fg = latent_heat(liquid, liquid_temperature)
187 p_sat = liquid.get("p", T=liquid_temperature, q=0.0)
188 except (ValueError, PropertyError):
189 # Off the saturation line entirely -- above the critical point, or
190 # below the triple point after a violent vent. There is no phase
191 # change to compute and saying so beats inventing one.
192 return VapourExchange()
193 if h_fg <= 0.0:
194 return VapourExchange()
195
196 # How saturated the surface actually is. A liquid whose own vapour
197 # pressure already exceeds its partial pressure in the ullage is boiling;
198 # one held well below is subcooled and warms instead.
199 if p_sat <= 0.0:
200 return VapourExchange()
201 driving = (p_sat - vapour_partial_pressure) / p_sat
202 # Which way the mass goes is the partial pressures' to say, and the heat
203 # can only carry it that way. Heat arriving at a subsaturated surface
204 # evaporates it; heat leaving one under a supersaturated ullage is the
205 # latent heat of the vapour condensing on it. The other two cases have
206 # no phase change to pay for: a subsaturated surface that loses heat
207 # cools (it does not condense vapour that is not there -- with none in
208 # the ullage that made liquid out of nothing), and one that gains heat
209 # under a supersaturated ullage warms until its own vapour pressure
210 # meets the partial pressure. The product of the two signs used to
211 # decide it, which got three of the four cases wrong: both of those,
212 # and heat leaving a supersaturated surface, which it evaporated.
213 if heat_to_interface * driving <= 0.0:
214 return VapourExchange()
215 share = self.saturated_fraction * min(abs(driving), 1.0)
216
217 latent_power = heat_to_interface * share
218 return VapourExchange(mdot=latent_power / h_fg, latent_power=latent_power)
219
220
221def latent_heat(liquid: Fluid, temperature: float) -> float:
222 """``h_fg`` [J/kg] at a temperature, from the saturation line.
223
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.
227 """
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
231
232
233_MODELS: dict[str, Callable[[], VapourModel]] = {
234 "none": NoVapour,
235 "saturated": SaturatedVapour,
236}
237
238
239def register_vapour_model(name: str, build: Callable[[], VapourModel]) -> None:
240 """Add a named vapour model.
241
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.
247 """
248 _MODELS[name] = build
249
250
251def vapour_model(name: str) -> VapourModel:
252 if name not in _MODELS:
253 raise KeyError(
254 f"no vapour model {name!r}; available: {', '.join(sorted(_MODELS))}"
255 )
256 return _MODELS[name]()
257
258
259def registered_vapour_models() -> list[str]:
260 return sorted(_MODELS)
#define min(a, b)
Definition Arduino.h:42
VapourExchange exchange(self, *, Fluid liquid, float liquid_temperature, float ullage_pressure, float vapour_partial_pressure, float interface_area, float heat_to_interface)
Definition vapour.py:139
VapourExchange exchange(self, *, Fluid liquid, float liquid_temperature, float ullage_pressure, float vapour_partial_pressure, float interface_area, float heat_to_interface)
Definition vapour.py:182
VapourExchange exchange(self, *, Fluid liquid, float liquid_temperature, float ullage_pressure, float vapour_partial_pressure, float interface_area, float heat_to_interface)
Definition vapour.py:101
list[str] registered_vapour_models()
Definition vapour.py:259
None register_vapour_model(str name, Callable[[], VapourModel] build)
Definition vapour.py:239
VapourModel vapour_model(str name)
Definition vapour.py:251
float latent_heat(Fluid liquid, float temperature)
Definition vapour.py:221