1"""A closed volume of gas, with the thermodynamics done exactly.
3A COPV, a tank ullage and a manifold plenum are the same object at different
4sizes: a fixed volume holding a mass of gas at some energy. Everything
5interesting about a pressure-fed system starts here, because what the regulator
6sees is this vessel's pressure and what the vessel's pressure does over a burn
7is mostly a question of how much it cools.
9State is ``(mass, internal energy)``, not ``(mass, temperature)``
10--------------------------------------------------------------
11The energy balance of a vessel losing gas is exactly
15 dm/dt = mdot_in - mdot_out
16 dU/dt = mdot_in . h_in - mdot_out . h(vessel) + Qdot
18in *internal energy*. Writing it in temperature instead needs
19``du = cv dT + (du/drho)_T drho``, and dropping that second term -- which is
20what a ``T``-based state quietly does -- is wrong for a real gas by exactly the
21amount that makes a COPV interesting. Since ``(rho, u)`` is a state pair the
22property layer can address directly, tracking ``U`` costs nothing and keeps the
25Why blowdown cooling matters
26----------------------------
27Gas leaving a bottle does work on the gas behind it, so the bottle cools, so its
28pressure falls faster than mass alone would suggest. Model it as isothermal and
29you over-predict how long the bottle holds regulator inlet pressure. Model it as
30adiabatic and you under-predict, because the vessel wall is a heat reservoir
31several times the gas's own heat capacity and it fights the cooling the whole
32way down. Neither bound is the answer; the wall is, which is why
33:class:`GasVolume` carries one.
36from __future__
import annotations
38from dataclasses
import dataclass
39from typing
import Callable
48@dataclass(frozen=True, slots=True)
50 """The exact state of a closed gas volume.
54 energy: **Total** internal energy [J], not specific. Total is what the
55 balance integrates, and keeping it that way avoids dividing by a
56 mass that is on its way to zero.
57 wall_temperature: Lumped vessel-wall temperature [K]. Equal to the gas
58 temperature at rest; it lags during a fast blowdown, which is the
59 whole reason it is tracked.
64 wall_temperature: float = 293.15
67 """Internal energy per unit mass [J/kg]."""
69 raise ValueError(
"an empty vessel has no specific energy")
70 return self.energy / self.mass
73@dataclass(frozen=True, slots=True)
75 """Time derivatives of a vessel's state. What Phase 07 integrates."""
83 wall_temperature: float = 0.0
84 """dT_wall/dt [K/s]."""
86 heat_from_wall: float = 0.0
87 """Heat the wall gave the gas [W]. Reported rather than inferred, because
88 it is the term that decides how fast a bottle droops and it should be
89 visible in a result rather than buried in an energy total."""
93 """A fixed volume of gas: a COPV, an ullage, a plenum.
97 volume: Internal volume [m^3].
98 wall_mass: Mass of vessel wall in thermal contact with the gas [kg].
99 Zero gives adiabatic behaviour, which is a bound rather than an
101 wall_capacity: Wall specific heat [J/(kg.K)]. Aluminium is about 900,
102 steel about 500, a carbon-overwrapped liner somewhere between and
104 wall_conductance: Overall gas-to-wall conductance ``hA`` [W/K]. The
105 least-known number here by some way; it is what
106 :meth:`~feedtwin.vessels.volume.GasVolume.rates` multiplies the
107 gas-to-wall temperature difference by.
115 wall_mass: float = 0.0,
116 wall_capacity: float = 0.0,
117 wall_conductance: float = 0.0,
120 raise ValueError(f
"volume must be positive, got {volume}")
129 def density(self, state: VesselState) -> float:
130 return state.mass / self.
volume
133 """Pressure [Pa] from mass and energy, through the real-gas EOS."""
134 return self.
fluid.get(
"p", rho=self.
density(state), u=state.specific_energy())
137 return self.
fluid.get(
"T", rho=self.
density(state), u=state.specific_energy())
140 """Specific enthalpy of the gas in the vessel [J/kg].
142 This is what leaves through an outlet: gas exits carrying its enthalpy,
143 not its internal energy, and the difference is the flow work that cools
146 return self.
fluid.get(
"h", rho=self.
density(state), u=state.specific_energy())
149 return self.
fluid.get(
"Z", rho=self.
density(state), u=state.specific_energy())
151 def initial_state(self, pressure: float, temperature: float) -> VesselState:
152 """Build a state from the two numbers anyone actually knows.
154 Nobody fills a bottle to an internal energy. They fill it to a pressure
155 at a temperature, and this converts that into the state the balance
159 ValueError: the state is a liquid. ``(rho, u)`` is an excellent set
160 of state variables for a gas and a poor one for a liquid --
161 liquid density is roughly 180x less sensitive to pressure, so
162 recovering pressure from density amplifies any error in it by
163 the same factor. On the exact equation of state the round trip
164 is still perfect; through interpolated tables a liquid comes
165 back 0.1-0.7% out where a gas is within 1e-4%. Catching it here
166 beats discovering it as pressures that are quietly slightly
169 phase = self.
fluid.phase(p=pressure, T=temperature)
170 if phase.is_liquid_like:
172 f
"{self.fluid.name} at {say(pressure, 1)} and "
173 f
"{temperature:.4g} K is a liquid ({phase.value}). A GasVolume "
174 "tracks a gas; liquid inventory belongs to a tank's liquid "
175 "volume, not to its ullage."
177 rho = self.
fluid.get(
"rho", p=pressure, T=temperature)
178 u = self.
fluid.get(
"u", p=pressure, T=temperature)
181 energy=u * rho * self.
volume,
182 wall_temperature=temperature,
191 mdot_out: float = 0.0,
192 mdot_in: float = 0.0,
193 enthalpy_in: float = 0.0,
194 heat_in: float = 0.0,
195 stirring: float = 1.0,
197 """Time derivatives, for an integrator to march.
200 state: Where the vessel is now.
201 mdot_out: Gas leaving [kg/s]. It carries the vessel's own enthalpy.
202 mdot_in: Gas arriving [kg/s].
203 enthalpy_in: Specific enthalpy of arriving gas [J/kg]. Matters:
204 warm pressurant into a cold ullage is a heat source, and it is
205 the mechanism behind ullage collapse.
206 heat_in: External heat [W] -- ambient through insulation, say.
207 Wall exchange is computed here and need not be included.
208 stirring: Multiplier on the gas-to-wall conductance, 1 by default.
209 ``wall_conductance`` is the still-gas figure; a charge jet
210 stirs the vessel and forced convection off it runs several
211 times natural. Named and passed in rather than inferred from
212 ``mdot_in`` so a caller says when it applies and a run can
213 report what was assumed.
219 * (state.wall_temperature - gas_T)
223 mdot_in * enthalpy_in - mdot_out * self.
enthalpy(state) + heat_in + q_wall
227 d_wall = -q_wall / capacity
if capacity > 0.0
else 0.0
230 mass=mdot_in - mdot_out,
232 wall_temperature=d_wall,
233 heat_from_wall=q_wall,
236 def step(self, state: VesselState, rates: Rates, dt: float) -> VesselState:
237 """One explicit Euler step. For demonstration and simple sweeps.
239 Phase 07 replaces this with a proper stiff integrator; it exists so that
240 the vessel model can be exercised and validated now rather than waiting
241 on a solver that does not exist yet.
244 mass=state.mass + rates.mass * dt,
245 energy=state.energy + rates.energy * dt,
246 wall_temperature=state.wall_temperature + rates.wall_temperature * dt,
252 mdot: float | Callable[[VesselState, float], float],
256 heat_in: float = 0.0,
257 ) -> list[tuple[float, VesselState]]:
258 """March a blowdown and return ``(time, state)`` samples.
260 ``mdot`` is either a constant or a callable of ``(state, t)`` -- the
261 latter is how a real orifice or regulator gets coupled in before the
262 network solver does it properly.
264 Stops early if the vessel empties, which is a physical end rather than
267 dt = duration / steps
268 history = [(0.0, state)]
269 for i
in range(steps):
270 if state.mass <= 1e-9:
272 flow = mdot(state, i * dt)
if callable(mdot)
else mdot
274 state, self.
rates(state, mdot_out=flow, heat_in=heat_in), dt
276 history.append(((i + 1) * dt, state))
279 def with_wall(self, mass: float, capacity: float, conductance: float) -> GasVolume:
280 """A copy with a different wall. Handy for bounding a real vessel
281 between adiabatic and isothermal without rebuilding it."""
286 wall_capacity=capacity,
287 wall_conductance=conductance,
291 return f
"GasVolume({self.fluid.name!r}, {self.volume * 1e3:.2f} L)"
None __init__(self, Fluid fluid, float volume, *, float wall_mass=0.0, float wall_capacity=0.0, float wall_conductance=0.0)
Rates rates(self, VesselState state, *, float mdot_out=0.0, float mdot_in=0.0, float enthalpy_in=0.0, float heat_in=0.0, float stirring=1.0)
float density(self, VesselState state)
float enthalpy(self, VesselState state)
VesselState step(self, VesselState state, Rates rates, float dt)
float pressure(self, VesselState state)
float temperature(self, VesselState state)
float compressibility(self, VesselState state)
GasVolume with_wall(self, float mass, float capacity, float conductance)
list[tuple[float, VesselState]] blowdown(self, VesselState state, float|Callable[[VesselState, float], float] mdot, float duration, *, int steps=2000, float heat_in=0.0)
VesselState initial_state(self, float pressure, float temperature)
float specific_energy(self)