STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
wall.py
Go to the documentation of this file.
1"""The metal a line is made of, and the heat it gives the gas flowing through it.
2
3Every component in this library is otherwise adiabatic, and for a liquid leg over
4a five-second burn that is fine. For the pressurant path it is not, and the
5evidence is visible on the stand: **icicles form on the fittings downstream of
6the regulator.**
7
8Those icicles are worth reading carefully, because they point at the mechanism
9that matters and not the one they look like. Frost on the outside proves the
10*metal got cold*. The metal got cold because the gas took its heat. Heat coming
11back the other way -- room air into a frosted fitting -- is the small term:
12measured against the drawn press path it is 6 W in still air and 32 W with a
13generous frosting coefficient, which is **0.04 to 0.18 K** of gas warming. The
14frost cannot carry much either; ambient at 50% RH holds about 4.3 g of water per
15cubic metre, so even 10 W of latent heat would need cubic metres per second of
16air stripped dry by natural convection.
17
18The line's own thermal mass is the real term. Nitrogen leaves the regulator at
19251 K into metal sitting at 293 K, and on the drawn 255 mm press path that is
20about 420 g of tube and fittings holding 200 J/K. Worked through as a heat
21exchanger it is **6.1 K at ignition and 4.4 K averaged over the burn -- 14% of
22the 30.3 K Joule-Thomson drop.** On a plausible real stand, a metre of line with
23ten fittings, it is 11.3 K, or 37%.
24
25What this deliberately does *not* model
26---------------------------------------
27**Soak.** No heat transfer with no flow, and no tracking of how a line drifts
28towards ambient while a stand sits. That was a considered scope decision rather
29than an omission: modelling it well means an ambient boundary, insulation state
30and an hours-long clock on every line, and modelling it badly means inventing a
31starting temperature and dressing it as physics.
32
33Instead the wall **starts at the temperature of the fluid its line holds at
34rest**, which is what a soak model would converge to anyway and needs no clock.
35That single choice is what makes a cryogenic stand come out right: a line full of
36liquid oxygen has been sitting at about 90 K and its fittings are cold, a line
37off the *top* of that tank holds ullage gas and is at whatever the ullage is --
38cool if the tank has been sitting, near ambient if it was just pressed -- and a
39pressurant line that has only ever seen bottle gas is at ambient. Three very
40different wall temperatures, none of them assumed: each is just the line's own
41fluid, read off the network once its temperatures have settled.
42
43Read off the network, specifically, and not off the drawing. At build time a
44tank node still carries whatever the drawing declared, which for a LOX tank is
45liquid temperature -- so seeding there would put the metal of a vent line 200 K
46below the vapour actually standing in it. `Session._propagate_temperatures`
47seeds each wall on the first tick it has real node temperatures to seed from,
48which costs one tick of no wall heat and gets all three tiers right.
49
50The consequence is that this is a **first-run effect**, correctly. Fire twice
51back to back and the second run sees a wall already near the gas temperature, so
52it warms the gas by under 2 K instead of 6. The stand behaves that way too.
53"""
54
55from __future__ import annotations
56
57import math
58from dataclasses import dataclass
59
60from feedtwin.comps.base import HydraulicComponent
61from feedtwin.comps.correlations import LAMINAR_LIMIT
62from feedtwin.comps.elements import SegmentedPipe
63
64#: Specific heat of austenitic stainless [J/(kg.K)] against temperature [K].
65#:
66#: Not a constant, and the reason is cryogenic: 316 falls from about 500 J/(kg.K)
67#: at room temperature to roughly 190 at liquid-nitrogen temperature. A LOX
68#: line's wall holds **two and a half times less** heat than a room-temperature
69#: constant would claim, so using one would overstate what a cold line can give
70#: back by that factor. Piecewise-linear through NIST's cryogenic material
71#: values, which is well inside the spread between 304 and 316 anyway.
72_STAINLESS_CP = (
73 (20.0, 16.0),
74 (50.0, 80.0),
75 (77.0, 190.0),
76 (100.0, 230.0),
77 (150.0, 320.0),
78 (200.0, 400.0),
79 (250.0, 460.0),
80 (300.0, 500.0),
81 (400.0, 530.0),
82)
83
84#: Density of austenitic stainless [kg/m^3]. Varies by under 1% over this range.
85STAINLESS_DENSITY = 8000.0
86
87#: Mass of one 1/2 in. tube union in 316 [kg], and the bore it belongs to [m].
88#:
89#: Measured points on the Swagelok range are 32 g at 1/4 in., 62 g at 3/8 in. and
90#: 113 g at 1/2 in.; fitted, a body goes as bore^1.82, which is near enough the
91#: square to write it that way and say so.
92UNION_MASS = 0.113
93UNION_BORE = 0.01092
94
95
96def fitting_metal(count: float, bore: float) -> float:
97 """Metal in ``count`` tube fittings on a line of this ``bore`` [kg].
98
99 A *tube* fitting, which is the thing most of a stand is made of and the
100 wrong answer in two places. A fitting on the high-pressure side of the
101 regulator carries much more metal for the same bore, because it is holding
102 4500 psi rather than 500; and a valve body is several unions' worth. Both
103 are why `fitting_mass` exists as an override -- count on the low-pressure
104 side, weigh on the high.
105 """
106 if count <= 0.0 or bore <= 0.0:
107 return 0.0
108 return count * UNION_MASS * (bore / UNION_BORE) ** 2
109
110
111def wall_runs(component: HydraulicComponent) -> tuple[tuple[float, float], ...]:
112 """``(bore, length)`` of each piece of a line a wall wraps [m].
113
114 A plain line carries its geometry in ``p``. A line the drawing itemised does
115 not: the segment list supersedes the line-level bore and length (see
116 ``feedtwin.pid.network._SUPERSEDED_BY_SEGMENTS``), so the geometry is the
117 segments'. Reading only ``p`` left every itemised line without a wall -- on a
118 stand drawn with pid-designer's centerline sketch, every line. Empty when
119 nothing on the line states a bore: a wall cannot be sized on no bore.
120 """
121 if isinstance(component, SegmentedPipe):
122 return component.wetted_runs()
123 bore = component.p.get("bore", 0.0)
124 if bore <= 0.0:
125 return ()
126 return ((bore, component.p.get("length", 0.0)),)
127
128
129def wall_bore(runs: tuple[tuple[float, float], ...]) -> float:
130 """The one bore a :class:`LineWall` takes for its film coefficient [m].
131
132 One run: its own bore, exactly. Several: the length-weighted mean, which is
133 an approximation and says so -- the film goes as ``D^-1.8`` in turbulence
134 and not at all in laminar flow, so no single bore is exact for both. No
135 shipped stand has a multi-bore itemised gas line.
136 """
137 if len(runs) == 1:
138 return runs[0][0]
139 total = sum(length for _, length in runs)
140 if total <= 0.0:
141 return min(bore for bore, _ in runs)
142 return sum(bore * length for bore, length in runs) / total
143
144
145def stainless_capacity(temperature: float) -> float:
146 """``c_p`` of the wall [J/(kg.K)] at a temperature."""
147 if temperature <= _STAINLESS_CP[0][0]:
148 return _STAINLESS_CP[0][1]
149 if temperature >= _STAINLESS_CP[-1][0]:
150 return _STAINLESS_CP[-1][1]
151 for (t0, c0), (t1, c1) in zip(_STAINLESS_CP, _STAINLESS_CP[1:]):
152 if t0 <= temperature <= t1:
153 return c0 + (c1 - c0) * (temperature - t0) / (t1 - t0)
154 return _STAINLESS_CP[-1][1]
155
156
157@dataclass(frozen=True, slots=True)
159 """What one line's metal did to the gas crossing it."""
160
161 heat: float = 0.0
162 """Heat into the fluid [W]. Positive warms the fluid and cools the wall."""
163
164 outlet_temperature: float = 0.0
165 """Fluid temperature leaving the line [K]."""
166
167 effectiveness: float = 0.0
168 """``1 - exp(-NTU)``. The fraction of the wall-to-fluid temperature
169 difference the fluid actually picks up in one pass; near zero for a short
170 line at high flow, near one for a long line at low flow."""
171
172
173@dataclass(frozen=True, slots=True)
175 """A lumped thermal mass wrapped around one line.
176
177 One temperature for the whole run, and **the honest justification for that
178 covers the tube but not the fittings.** Across a 0.9 mm tube wall the Biot
179 number is 0.17 and the metal really is near enough isothermal: against a
180 1-D conduction solution the lumped answer is exact to two figures. A
181 fitting body is 5-8 mm of steel, which is Bi 0.9 to 1.5, and there the
182 lumped model **over-credits the heat by 19% at 5 mm and 32% at 8 mm** over
183 a six-second burn, because the core cannot reach the bore in time. Steel's
184 diffusion depth is 1.9 mm at 1 s and 4.7 mm at 6 s.
185
186 That error is knowingly carried, for a reason that has to be measured
187 rather than assumed: **the model is film-limited, not mass-limited.**
188 Doubling the metal on the shipped stand moves end-of-burn tank pressure by
189 6 psi out of 52, so a 20% over-credit on the fitting mass is worth two or
190 three. Resolving it would mean a second node, a fitting-thickness
191 parameter, and a conduction coupling, to move a number by less than the
192 uncertainty in how many fittings are actually on the stand. If a drawing
193 ever lands in the mass-limited regime -- thin lines, few fittings, high
194 flow -- that trade stops holding, and the benchmark's saturation check is
195 what catches it.
196
197 Args:
198 mass: Metal in thermal contact with the flow [kg].
199 area: Wetted internal area [m^2].
200 bore: Internal diameter [m], for the convection correlation.
201 """
202
203 mass: float
204 area: float
205 bore: float
206
207 @property
208 def capacity_at(self) -> float:
209 """Placeholder to keep the dataclass slotted; use :meth:`capacity`."""
210 return self.mass
211
212 def capacity(self, temperature: float) -> float:
213 """Thermal capacity of the wall [J/K] at its current temperature."""
214 return self.mass * stainless_capacity(temperature)
215
217 self,
218 *,
219 mdot: float,
220 wall_temperature: float,
221 inlet_temperature: float,
222 density: float,
223 viscosity: float,
224 conductivity: float,
225 heat_capacity: float,
226 ) -> WallExchange:
227 """Heat the wall gives the stream, and the temperature it leaves at.
228
229 Treated as a single-stream heat exchanger against a wall at one
230 temperature: the film coefficient comes from Dittus-Boelter, and the
231 outlet follows from the number of transfer units rather than from
232 applying a heat rate to an inlet state. Using ``Q = h.A.dT`` directly
233 would let a long line at low flow hand the gas more than the wall-to-gas
234 difference, which is not a small error -- it is thermodynamically
235 impossible.
236
237 **No flow, no exchange.** A still line does not trade heat with its
238 metal here; see the module docstring for why soak is out of scope.
239 """
240 if abs(mdot) < 1e-9 or self.area <= 0.0 or heat_capacity <= 0.0:
241 return WallExchange(outlet_temperature=inlet_temperature)
242 if density <= 0.0 or viscosity <= 0.0 or conductivity <= 0.0:
243 return WallExchange(outlet_temperature=inlet_temperature)
244
245 area_flow = math.pi * self.bore * self.bore / 4.0
246 velocity = abs(mdot) / (density * area_flow)
247 reynolds = density * velocity * self.bore / viscosity
248 prandtl = heat_capacity * viscosity / conductivity
249 if reynolds < LAMINAR_LIMIT:
250 # Laminar, constant wall temperature. A fully developed round tube
251 # sits at Nu = 3.66 and no correlation improves on that.
252 nusselt = 3.66
253 else:
254 # Dittus-Boelter with the cooling exponent: the fluid here is
255 # usually colder than the wall on the pressurant path and warmer on
256 # a chilled one, so the exponent is chosen per direction.
257 exponent = 0.4 if wall_temperature > inlet_temperature else 0.3
258 nusselt = 0.023 * reynolds**0.8 * prandtl**exponent
259 film = nusselt * conductivity / self.bore
260
261 ntu = film * self.area / (abs(mdot) * heat_capacity)
262 effectiveness = 1.0 - math.exp(-min(ntu, 40.0))
263 rise = effectiveness * (wall_temperature - inlet_temperature)
264 return WallExchange(
265 heat=abs(mdot) * heat_capacity * rise,
266 outlet_temperature=inlet_temperature + rise,
267 effectiveness=effectiveness,
268 )
#define min(a, b)
Definition Arduino.h:42
WallExchange exchange(self, *, float mdot, float wall_temperature, float inlet_temperature, float density, float viscosity, float conductivity, float heat_capacity)
Definition wall.py:226
float capacity_at(self)
Definition wall.py:208
float capacity(self, float temperature)
Definition wall.py:212
float fitting_metal(float count, float bore)
Definition wall.py:96
float stainless_capacity(float temperature)
Definition wall.py:145
tuple[tuple[float, float],...] wall_runs(HydraulicComponent component)
Definition wall.py:111
float wall_bore(tuple[tuple[float, float],...] runs)
Definition wall.py:129