STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
volume.py
Go to the documentation of this file.
1"""A closed volume of gas, with the thermodynamics done exactly.
2
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.
8
9State is ``(mass, internal energy)``, not ``(mass, temperature)``
10--------------------------------------------------------------
11The energy balance of a vessel losing gas is exactly
12
13.. code-block:: text
14
15 dm/dt = mdot_in - mdot_out
16 dU/dt = mdot_in . h_in - mdot_out . h(vessel) + Qdot
17
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
23balance exact.
24
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.
34"""
35
36from __future__ import annotations
37
38from dataclasses import dataclass
39from typing import Callable
40
41from feedtwin.model.pressure import say
42from feedtwin.props import Fluid
43
44#: Standard acceleration due to gravity, by definition [m/s^2].
45GRAVITY = 9.80665
46
47
48@dataclass(frozen=True, slots=True)
50 """The exact state of a closed gas volume.
51
52 Args:
53 mass: Gas mass [kg].
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.
60 """
61
62 mass: float
63 energy: float
64 wall_temperature: float = 293.15
65
66 def specific_energy(self) -> float:
67 """Internal energy per unit mass [J/kg]."""
68 if self.mass <= 0.0:
69 raise ValueError("an empty vessel has no specific energy")
70 return self.energy / self.mass
71
72
73@dataclass(frozen=True, slots=True)
74class Rates:
75 """Time derivatives of a vessel's state. What Phase 07 integrates."""
76
77 mass: float
78 """dm/dt [kg/s]."""
79
80 energy: float
81 """dU/dt [W]."""
82
83 wall_temperature: float = 0.0
84 """dT_wall/dt [K/s]."""
85
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."""
90
91
93 """A fixed volume of gas: a COPV, an ullage, a plenum.
94
95 Args:
96 fluid: The gas.
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
100 answer.
101 wall_capacity: Wall specific heat [J/(kg.K)]. Aluminium is about 900,
102 steel about 500, a carbon-overwrapped liner somewhere between and
103 worth measuring.
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.
108 """
109
111 self,
112 fluid: Fluid,
113 volume: float,
114 *,
115 wall_mass: float = 0.0,
116 wall_capacity: float = 0.0,
117 wall_conductance: float = 0.0,
118 ) -> None:
119 if volume <= 0.0:
120 raise ValueError(f"volume must be positive, got {volume}")
121 self.fluid = fluid
122 self.volume = volume
123 self.wall_mass = wall_mass
124 self.wall_capacity = wall_capacity
125 self.wall_conductance = wall_conductance
126
127 # ------------------------------------------------------------- the state
128
129 def density(self, state: VesselState) -> float:
130 return state.mass / self.volume
131
132 def pressure(self, state: VesselState) -> float:
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())
135
136 def temperature(self, state: VesselState) -> float:
137 return self.fluid.get("T", rho=self.density(state), u=state.specific_energy())
138
139 def enthalpy(self, state: VesselState) -> float:
140 """Specific enthalpy of the gas in the vessel [J/kg].
141
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
144 what stays behind.
145 """
146 return self.fluid.get("h", rho=self.density(state), u=state.specific_energy())
147
148 def compressibility(self, state: VesselState) -> float:
149 return self.fluid.get("Z", rho=self.density(state), u=state.specific_energy())
150
151 def initial_state(self, pressure: float, temperature: float) -> VesselState:
152 """Build a state from the two numbers anyone actually knows.
153
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
156 needs.
157
158 Raises:
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
167 wrong.
168 """
169 phase = self.fluid.phase(p=pressure, T=temperature)
170 if phase.is_liquid_like:
171 raise ValueError(
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."
176 )
177 rho = self.fluid.get("rho", p=pressure, T=temperature)
178 u = self.fluid.get("u", p=pressure, T=temperature)
179 return VesselState(
180 mass=rho * self.volume,
181 energy=u * rho * self.volume,
182 wall_temperature=temperature,
183 )
184
185 # ------------------------------------------------------------- the rates
186
187 def rates(
188 self,
189 state: VesselState,
190 *,
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,
196 ) -> Rates:
197 """Time derivatives, for an integrator to march.
198
199 Args:
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.
214 """
215 gas_T = self.temperature(state)
216 q_wall = (
218 * max(stirring, 0.0)
219 * (state.wall_temperature - gas_T)
220 )
221
222 d_energy = (
223 mdot_in * enthalpy_in - mdot_out * self.enthalpy(state) + heat_in + q_wall
224 )
225
226 capacity = self.wall_mass * self.wall_capacity
227 d_wall = -q_wall / capacity if capacity > 0.0 else 0.0
228
229 return Rates(
230 mass=mdot_in - mdot_out,
231 energy=d_energy,
232 wall_temperature=d_wall,
233 heat_from_wall=q_wall,
234 )
235
236 def step(self, state: VesselState, rates: Rates, dt: float) -> VesselState:
237 """One explicit Euler step. For demonstration and simple sweeps.
238
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.
242 """
243 return VesselState(
244 mass=state.mass + rates.mass * dt,
245 energy=state.energy + rates.energy * dt,
246 wall_temperature=state.wall_temperature + rates.wall_temperature * dt,
247 )
248
250 self,
251 state: VesselState,
252 mdot: float | Callable[[VesselState, float], float],
253 duration: float,
254 *,
255 steps: int = 2000,
256 heat_in: float = 0.0,
257 ) -> list[tuple[float, VesselState]]:
258 """March a blowdown and return ``(time, state)`` samples.
259
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.
263
264 Stops early if the vessel empties, which is a physical end rather than
265 an error.
266 """
267 dt = duration / steps
268 history = [(0.0, state)]
269 for i in range(steps):
270 if state.mass <= 1e-9:
271 break
272 flow = mdot(state, i * dt) if callable(mdot) else mdot
273 state = self.step(
274 state, self.rates(state, mdot_out=flow, heat_in=heat_in), dt
275 )
276 history.append(((i + 1) * dt, state))
277 return history
278
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."""
282 return GasVolume(
283 self.fluid,
284 self.volume,
285 wall_mass=mass,
286 wall_capacity=capacity,
287 wall_conductance=conductance,
288 )
289
290 def __repr__(self) -> str:
291 return f"GasVolume({self.fluid.name!r}, {self.volume * 1e3:.2f} L)"
#define max(a, b)
Definition Arduino.h:43
None __init__(self, Fluid fluid, float volume, *, float wall_mass=0.0, float wall_capacity=0.0, float wall_conductance=0.0)
Definition volume.py:118
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)
Definition volume.py:196
float density(self, VesselState state)
Definition volume.py:129
float enthalpy(self, VesselState state)
Definition volume.py:139
VesselState step(self, VesselState state, Rates rates, float dt)
Definition volume.py:236
float pressure(self, VesselState state)
Definition volume.py:132
float temperature(self, VesselState state)
Definition volume.py:136
float compressibility(self, VesselState state)
Definition volume.py:148
GasVolume with_wall(self, float mass, float capacity, float conductance)
Definition volume.py:279
list[tuple[float, VesselState]] blowdown(self, VesselState state, float|Callable[[VesselState, float], float] mdot, float duration, *, int steps=2000, float heat_in=0.0)
Definition volume.py:257
VesselState initial_state(self, float pressure, float temperature)
Definition volume.py:151