1"""Phase 15: the heat a line's own metal gives the gas flowing through it.
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.
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
16from __future__
import annotations
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)
34N2 = dict(density=50.0, viscosity=1.29e-5, conductivity=0.0185, heat_capacity=1080.0)
38 """Stainless holds a fraction of its room-temperature heat when it is cold.
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.
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)],
51 assert stainless_capacity(temperature) == pytest.approx(expected, abs=1.0)
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}"
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)
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))
69 assert a_line(mass=0.8).capacity(293.0) == pytest.approx(
70 2.0 *
a_line(mass=0.4).capacity(293.0)
76 """The single most important guard in this file.
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
84 mdot=0.0, wall_temperature=293.0, inlet_temperature=100.0, **N2
86 assert out.heat == 0.0
87 assert out.effectiveness == 0.0
91 mdot=0.15, wall_temperature=293.0, inlet_temperature=200.0, **N2
94 assert 200.0 < out.outlet_temperature < 293.0
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."""
100 mdot=0.15, wall_temperature=95.0, inlet_temperature=290.0, **N2
102 assert out.heat < 0.0
103 assert 95.0 < out.outlet_temperature < 290.0
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)):
110 mdot=1e-4, wall_temperature=293.0, inlet_temperature=100.0, **N2
112 assert out.outlet_temperature <= 293.0 + 1e-9
113 assert 0.0 <= out.effectiveness <= 1.0
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."""
119 mdot=0.02, wall_temperature=293.0, inlet_temperature=150.0, **N2
122 mdot=0.40, wall_temperature=293.0, inlet_temperature=150.0, **N2
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"
130 mdot=0.15, wall_temperature=293.0, inlet_temperature=150.0, **N2
133 mdot=0.15, wall_temperature=293.0, inlet_temperature=150.0, **N2
135 assert long.effectiveness > short.effectiveness
138 """Q = mdot.cp.dT, or the wall's book-keeping does not close."""
140 mdot=0.15, wall_temperature=293.0, inlet_temperature=180.0, **N2
142 assert out.heat == pytest.approx(
143 0.15 * N2[
"heat_capacity"] * (out.outlet_temperature - 180.0), rel=1e-9
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."""
150 mdot=0.15, wall_temperature=293.0, inlet_temperature=180.0, **N2
153 mdot=-0.15, wall_temperature=293.0, inlet_temperature=180.0, **N2
155 assert back.outlet_temperature == pytest.approx(forward.outlet_temperature)
156 assert back.heat == pytest.approx(forward.heat)
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.
164 thick = dict(N2, viscosity=5.0e-3)
166 mdot=1e-3, wall_temperature=293.0, inlet_temperature=150.0, **thick
168 assert out.heat > 0.0
169 assert out.effectiveness > 0.0
171 @pytest.mark.parametrize("bad", ["density", "viscosity", "conductivity"])
173 """A property gap must cost the line its heat, not the tick its life."""
174 props = dict(N2, **{bad: 0.0})
176 mdot=0.15, wall_temperature=293.0, inlet_temperature=150.0, **props
178 assert out.heat == 0.0
182 """The tube's own metal, which is the part a drawing can compute."""
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.
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)
196 """The numbers behind the answer given to the stand's own observation.
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.
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.
208 mdot, cp = 0.15, 1080.0
209 still = 6.0 * 0.4 * (293.0 - 200.0) / 100.0
211 for watts
in (still, frosted):
212 rise = watts / (mdot * cp)
213 assert rise < 0.25, f
"{watts:.0f} W gave {rise:.3f} K"
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.
221 mdot=0.15, wall_temperature=293.0, inlet_temperature=200.0, **N2
223 rise = out.outlet_temperature - 200.0
224 assert rise > 1.0, f
"path A should be kelvins, got {rise:.2f} K"
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.
232 temperature, given = 293.0, 0.0
235 mdot=0.15, wall_temperature=temperature, inlet_temperature=200.0, **N2
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"
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."""
252 assert wall.LAMINAR_LIMIT
is LAMINAR_LIMIT
257 mu = 4.0 * 0.15 / (math.pi * line.bore * re)
258 return line.exchange(
260 wall_temperature=293.0,
261 inlet_temperature=200.0,
262 **dict(N2, viscosity=mu),
265 below, above =
at(LAMINAR_LIMIT * 0.99),
at(LAMINAR_LIMIT * 1.01)
267 -math.log(1 - below.effectiveness)
269 * N2[
"heat_capacity"]
271 / (N2[
"conductivity"] * line.area)
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)
None test_clamped_outside_the_table(self)
None test_capacity_scales_with_mass(self)
None test_cold_metal_holds_far_less(self)
None test_matches_nist_values(self, float temperature, float expected)
None test_missing_property_is_not_a_crash(self, str bad)
None test_gas_never_overshoots_the_wall(self)
None test_heat_agrees_with_the_temperature_rise(self)
None test_reverse_flow_is_treated_by_magnitude(self)
None test_cold_wall_cools_warm_gas(self)
None test_laminar_uses_a_fixed_nusselt(self)
None test_warm_wall_warms_cold_gas(self)
None test_more_flow_means_less_warming_per_kilogram(self)
None test_more_metal_area_means_more_pickup(self)
None test_no_flow_no_heat(self)
None test_half_inch_tube_mass_is_right(self)
None test_the_lines_own_metal_is_not(self)
None test_the_room_is_negligible_over_a_burn(self)
None test_the_metal_runs_out(self)
T & at(const key_type &key)
LineWall a_line(float mass=0.4, float length=0.5, float bore=0.0109)
None test_the_wall_uses_the_same_laminar_limit_as_friction()
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)