STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_wall.py
Go to the documentation of this file.
1"""Phase 15: the heat a line's own metal gives the gas flowing through it.
2
3Every component in this model was adiabatic, which is the assumption a stand
4disproves in the most visible way available: run a blowdown and frost, then
5icicles, form on the fittings downstream of the regulator. Frost on the metal
6is not decoration -- it is the evidence that the metal got cold, and metal only
7gets cold by giving its heat to something. That something is the gas.
8
9What is tested here is the giving, not the sitting. A line that has been loaded
10for an hour is colder than one loaded a minute ago, and none of that is modelled
11on purpose: predicting it needs an ambient film, an insulation state and a clock,
12to arrive at a starting temperature that the fluid standing in the line already
13tells us.
14"""
15
16from __future__ import annotations
17
18import math
19
20import pytest
21
22from feedtwin.comps.wall import (
23 STAINLESS_DENSITY,
24 LineWall,
25 stainless_capacity,
26)
27
28
29def a_line(mass: float = 0.4, length: float = 0.5, bore: float = 0.0109) -> LineWall:
30 return LineWall(mass=mass, area=math.pi * bore * length, bore=bore)
31
32
33#: Nitrogen near 200 K and 30 bar, which is what a press line actually carries.
34N2 = dict(density=50.0, viscosity=1.29e-5, conductivity=0.0185, heat_capacity=1080.0)
35
36
38 """Stainless holds a fraction of its room-temperature heat when it is cold.
39
40 This is the whole reason a LOX line and a pressurant line cannot share a
41 number. 316 at 77 K holds about 190 J/(kg.K) against 500 at room
42 temperature -- a factor of 2.6 -- so a model with one constant cp is wrong
43 by that factor on exactly the lines where the metal is coldest.
44 """
45
46 @pytest.mark.parametrize(
47 "temperature, expected",
48 [(77.0, 190.0), (100.0, 230.0), (200.0, 400.0), (293.0, 494.0), (300.0, 500.0)],
49 )
50 def test_matches_nist_values(self, temperature: float, expected: float) -> None:
51 assert stainless_capacity(temperature) == pytest.approx(expected, abs=1.0)
52
54 ratio = stainless_capacity(293.0) / stainless_capacity(77.0)
55 assert 2.0 < ratio < 3.5, f"316 cp should fall ~2.6x by 77 K, got {ratio:.2f}"
56
57 def test_rises_monotonically(self) -> None:
58 temps = [20.0, 50.0, 77.0, 100.0, 150.0, 200.0, 250.0, 300.0, 400.0]
59 values = [stainless_capacity(t) for t in temps]
60 assert values == sorted(values)
61
63 """Beyond the table it holds the end value rather than extrapolating a
64 piecewise fit into a negative heat capacity."""
65 assert stainless_capacity(1.0) == pytest.approx(stainless_capacity(20.0))
66 assert stainless_capacity(9_000.0) == pytest.approx(stainless_capacity(400.0))
67
69 assert a_line(mass=0.8).capacity(293.0) == pytest.approx(
70 2.0 * a_line(mass=0.4).capacity(293.0)
71 )
72
73
75 def test_no_flow_no_heat(self) -> None:
76 """The single most important guard in this file.
77
78 With no flow there is no convection, and a wall that leaked heat into a
79 stationary node would warm a stand that is just sitting there -- which
80 is the soak model this deliberately does not have, arriving by the back
81 door and wrong.
82 """
83 out = a_line().exchange(
84 mdot=0.0, wall_temperature=293.0, inlet_temperature=100.0, **N2
85 )
86 assert out.heat == 0.0
87 assert out.effectiveness == 0.0
88
90 out = a_line().exchange(
91 mdot=0.15, wall_temperature=293.0, inlet_temperature=200.0, **N2
92 )
93 assert out.heat > 0.0
94 assert 200.0 < out.outlet_temperature < 293.0
95
97 """Runs both ways. A LOX-cold line chills the gas put through it, which
98 is the chilldown of a downstream fitting rather than a special case."""
99 out = a_line().exchange(
100 mdot=0.15, wall_temperature=95.0, inlet_temperature=290.0, **N2
101 )
102 assert out.heat < 0.0
103 assert 95.0 < out.outlet_temperature < 290.0
104
106 """Effectiveness is bounded by one, so the stream can approach the metal
107 and never pass it, whatever the geometry."""
108 for mass, length in ((50.0, 100.0), (0.01, 0.01)):
109 out = a_line(mass=mass, length=length).exchange(
110 mdot=1e-4, wall_temperature=293.0, inlet_temperature=100.0, **N2
111 )
112 assert out.outlet_temperature <= 293.0 + 1e-9
113 assert 0.0 <= out.effectiveness <= 1.0
114
116 """NTU falls as mdot rises: a faster stream has less residence time, so
117 each kilogram picks up less even though the total heat goes up."""
118 slow = a_line().exchange(
119 mdot=0.02, wall_temperature=293.0, inlet_temperature=150.0, **N2
120 )
121 fast = a_line().exchange(
122 mdot=0.40, wall_temperature=293.0, inlet_temperature=150.0, **N2
123 )
124 assert slow.effectiveness > fast.effectiveness
125 assert slow.outlet_temperature > fast.outlet_temperature
126 assert fast.heat > slow.heat, "total heat still rises with flow"
127
129 short = a_line(length=0.1).exchange(
130 mdot=0.15, wall_temperature=293.0, inlet_temperature=150.0, **N2
131 )
132 long = a_line(length=2.0).exchange(
133 mdot=0.15, wall_temperature=293.0, inlet_temperature=150.0, **N2
134 )
135 assert long.effectiveness > short.effectiveness
136
138 """Q = mdot.cp.dT, or the wall's book-keeping does not close."""
139 out = a_line().exchange(
140 mdot=0.15, wall_temperature=293.0, inlet_temperature=180.0, **N2
141 )
142 assert out.heat == pytest.approx(
143 0.15 * N2["heat_capacity"] * (out.outlet_temperature - 180.0), rel=1e-9
144 )
145
147 """A negative mdot is the solver's sign convention, not a different
148 physical situation; the line still picks up heat from its metal."""
149 forward = a_line().exchange(
150 mdot=0.15, wall_temperature=293.0, inlet_temperature=180.0, **N2
151 )
152 back = a_line().exchange(
153 mdot=-0.15, wall_temperature=293.0, inlet_temperature=180.0, **N2
154 )
155 assert back.outlet_temperature == pytest.approx(forward.outlet_temperature)
156 assert back.heat == pytest.approx(forward.heat)
157
159 """Below the laminar limit Dittus-Boelter does not apply. It is not merely
160 inaccurate there -- it tends to zero with flow, so a trickle would come
161 out predicting no heat transfer at all, when the real answer is the
162 fully-developed laminar value.
163 """
164 thick = dict(N2, viscosity=5.0e-3) # syrup, to force Re well under the limit
165 out = a_line().exchange(
166 mdot=1e-3, wall_temperature=293.0, inlet_temperature=150.0, **thick
167 )
168 assert out.heat > 0.0
169 assert out.effectiveness > 0.0
170
171 @pytest.mark.parametrize("bad", ["density", "viscosity", "conductivity"])
172 def test_missing_property_is_not_a_crash(self, bad: str) -> None:
173 """A property gap must cost the line its heat, not the tick its life."""
174 props = dict(N2, **{bad: 0.0})
175 out = a_line().exchange(
176 mdot=0.15, wall_temperature=293.0, inlet_temperature=150.0, **props
177 )
178 assert out.heat == 0.0
179
180
182 """The tube's own metal, which is the part a drawing can compute."""
183
185 """1/2 in. x 0.035 in. wall 316: about 265 g/m, so 255 mm is ~67 g --
186 against roughly 200 g for the two unions holding it on. The fittings
187 dominating the tube is the reason `fitting_mass` exists at all.
188 """
189 bore, thickness, length = 0.010922, 0.000889, 1.0
190 outer = bore + 2.0 * thickness
191 mass = math.pi / 4.0 * (outer**2 - bore**2) * length * STAINLESS_DENSITY
192 assert mass == pytest.approx(0.265, abs=0.02)
193
194
196 """The numbers behind the answer given to the stand's own observation.
197
198 Two paths could warm the gas: the room, through frost and the tube wall
199 (Path B), and the line's own thermal mass (Path A). Only one of them is
200 worth modelling, and the arithmetic here is why.
201 """
202
204 """Path B. Still air onto 0.4 m^2 of cold pipe is a few watts; even a
205 generous frosted-surface figure is tens. Against a 150 g/s stream that
206 is hundredths of a kelvin -- so the room is not what warms the gas.
207 """
208 mdot, cp = 0.15, 1080.0
209 still = 6.0 * 0.4 * (293.0 - 200.0) / 100.0 # ~2 W/m2K over 0.4 m2
210 frosted = 32.0 # W, a deliberately generous frosted-surface figure
211 for watts in (still, frosted):
212 rise = watts / (mdot * cp)
213 assert rise < 0.25, f"{watts:.0f} W gave {rise:.3f} K"
214
216 """Path A. The same stream through 0.4 kg of room-temperature steel
217 picks up kelvins, not hundredths -- an order of magnitude that decides
218 which one gets a model.
219 """
220 out = a_line(mass=0.4).exchange(
221 mdot=0.15, wall_temperature=293.0, inlet_temperature=200.0, **N2
222 )
223 rise = out.outlet_temperature - 200.0
224 assert rise > 1.0, f"path A should be kelvins, got {rise:.2f} K"
225
226 def test_the_metal_runs_out(self) -> None:
227 """And it is a first-run effect. The wall has a fixed number of joules
228 in it; spend them and the second run of the day starts colder, because
229 only the room recharges it and the room is Path B.
230 """
231 wall = a_line(mass=0.4)
232 temperature, given = 293.0, 0.0
233 for _ in range(120): # 6 s at 50 ms
234 out = wall.exchange(
235 mdot=0.15, wall_temperature=temperature, inlet_temperature=200.0, **N2
236 )
237 given += out.heat * 0.05
238 temperature -= out.heat * 0.05 / wall.capacity(temperature)
239 assert temperature < 293.0 - 10.0, "the metal must visibly cool"
240 assert given == pytest.approx(
241 wall.capacity(0.5 * (293.0 + temperature)) * (293.0 - temperature), rel=0.02
242 ), "joules out of the metal must equal joules into the gas"
243
244
246 """One transition, not two. Friction switched at 2040 and the Nusselt
247 number at 2300, so a line between them was turbulent to one correlation
248 and laminar to the other."""
249 from feedtwin.comps import wall
250 from feedtwin.comps.correlations import LAMINAR_LIMIT
251
252 assert wall.LAMINAR_LIMIT is LAMINAR_LIMIT
253 line = a_line()
254
255 # Straddle the one limit: just below is Nu = 3.66 exactly, just above is not.
256 def at(re):
257 mu = 4.0 * 0.15 / (math.pi * line.bore * re)
258 return line.exchange(
259 mdot=0.15,
260 wall_temperature=293.0,
261 inlet_temperature=200.0,
262 **dict(N2, viscosity=mu),
263 )
264
265 below, above = at(LAMINAR_LIMIT * 0.99), at(LAMINAR_LIMIT * 1.01)
266 nu_below = (
267 -math.log(1 - below.effectiveness)
268 * 0.15
269 * N2["heat_capacity"]
270 * line.bore
271 / (N2["conductivity"] * line.area)
272 )
273 assert nu_below == pytest.approx(3.66, rel=1e-6)
274 assert above.effectiveness != pytest.approx(below.effectiveness, rel=1e-3)
None test_rises_monotonically(self)
Definition test_wall.py:57
None test_clamped_outside_the_table(self)
Definition test_wall.py:62
None test_capacity_scales_with_mass(self)
Definition test_wall.py:68
None test_cold_metal_holds_far_less(self)
Definition test_wall.py:53
None test_matches_nist_values(self, float temperature, float expected)
Definition test_wall.py:50
None test_missing_property_is_not_a_crash(self, str bad)
Definition test_wall.py:172
None test_gas_never_overshoots_the_wall(self)
Definition test_wall.py:105
None test_heat_agrees_with_the_temperature_rise(self)
Definition test_wall.py:137
None test_reverse_flow_is_treated_by_magnitude(self)
Definition test_wall.py:146
None test_cold_wall_cools_warm_gas(self)
Definition test_wall.py:96
None test_laminar_uses_a_fixed_nusselt(self)
Definition test_wall.py:158
None test_warm_wall_warms_cold_gas(self)
Definition test_wall.py:89
None test_more_flow_means_less_warming_per_kilogram(self)
Definition test_wall.py:115
None test_more_metal_area_means_more_pickup(self)
Definition test_wall.py:128
None test_no_flow_no_heat(self)
Definition test_wall.py:75
None test_half_inch_tube_mass_is_right(self)
Definition test_wall.py:184
None test_the_lines_own_metal_is_not(self)
Definition test_wall.py:215
None test_the_room_is_negligible_over_a_burn(self)
Definition test_wall.py:203
T & at(const key_type &key)
Definition json.hpp:19110
LineWall a_line(float mass=0.4, float length=0.5, float bore=0.0109)
Definition test_wall.py:29
None test_the_wall_uses_the_same_laminar_limit_as_friction()
Definition test_wall.py:245
static self_test_reply_t exchange(void *context, uint16_t id, uint8_t mode, uint32_t max_hz, self_test_spi_frame_t *frames, uint8_t count, uint32_t budget_ms)