STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_gas_choking.py
Go to the documentation of this file.
1"""A valve on a gas line, at and past its critical pressure ratio.
2
3Every vent and every press solenoid on a stand is a valve passing gas, and until
4this existed none of them could choke: `Valve.choked_dp` is the IEC 60534 *liquid*
5flashing model and returns None for a gas, so the base class reported no ceiling
6and the incompressible Cv law ran unbounded. Measured on a 550 psi tank vented to
7atmosphere, that was 2.5x to 5.5x the sonic mass flow depending on bore.
8
9Below the choke a gas valve is IEC 60534-2-1 with its expansion factor too
10(``feedtwin.comps.iec_gas.GasCv``): the incompressible law up to a separate
11ceiling never met it, and the flow *fell* 36 % as the downstream did.
12"""
13
14from __future__ import annotations
15
16import fluids.control_valve as cv_lib
17import pytest
18
19from feedtwin.comps import build_component, conditions_from_fluid
20from feedtwin.comps.base import FlowConditions
21from feedtwin.model import ComponentInstance, Param, Provenance
22from feedtwin.props import Fluid
23from feedtwin.solve.network import Network
24from feedtwin.solve.steady import solve_steady
25
26PSI = 6894.757293168361
27R_UNIVERSAL_J = 8.31446261815324 * 1000.0 # CODATA, matching feedtwin.comps.base
28KV_PER_CV = 1.0 / 1.1560992283536566
29
30HELIUM = (4.0026, 1.667)
31NITROGEN = (28.014, 1.40)
32
33
34def valve(Cv: float, bore_mm: float, **params) -> object:
35 fields = {
36 "Cv": Param(Cv, "Cv", Provenance.ESTIMATED),
37 "bore": Param(bore_mm, "mm", Provenance.MANUFACTURER),
38 }
39 fields.update(params)
40 return build_component(ComponentInstance.build("V", "valve", fields, model="cv"))
41
42
43def gas(mw: float, gamma: float, p_upstream: float, temperature: float):
44 r_specific = R_UNIVERSAL_J / mw
45 return FlowConditions(
46 rho=p_upstream / (r_specific * temperature),
47 mu=2.0e-5,
48 p_upstream=p_upstream,
49 temperature=temperature,
50 gamma=gamma,
51 r_specific=r_specific,
52 # Priced as a gas, with the ideal-gas ratio IEC's F_gamma is built from
53 # (conditions_from_fluid fills it for every gas-priced node).
54 gamma_ideal=gamma,
55 )
56
57
58#: How close the library's IEC adapter sits to `fluids`' own implementation.
59#: Constant at every drop and every gas: it is the scale alone, the Cv
60#: definition (Cv_to_K) against IEC's tabulated N9, 0.112 %.
61SCALE = 2e-3
62
63
64def library_mdot(Cv, mw, gamma, p1, temperature, bore, p2=101325.0) -> float:
65 """What `fluids` says the same valve passes at ``p2``. The reference.
66
67 ``fluids`` takes Q as volume at **0 degC** and 1 atm (its docstring, and the
68 N9 it uses). This reference used to read it at 15 degC, exactly as the
69 valve's own calibration did -- so the two agreed to 1e-6 while both were
70 5.4 % low.
71 """
72 r_specific = R_UNIVERSAL_J / mw
73 rho_std = 101325.0 / (r_specific * 273.15)
74 sized = cv_lib.size_control_valve_g(
75 T=temperature,
76 MW=mw,
77 mu=2.0e-5,
78 gamma=gamma,
79 Z=1.0,
80 P1=p1,
81 P2=p2,
82 Q=1.0,
83 D1=bore,
84 D2=bore,
85 d=bore,
86 allow_choked=True,
87 full_output=True,
88 )
89 return rho_std * (Cv * KV_PER_CV) / sized["Kv"]
90
91
92CASES = [
93 ("helium 3/8in vent", 3.8, *HELIUM, 550 * PSI, 293.15, 0.00953),
94 ("helium regulator", 1.6, *HELIUM, 4500 * PSI, 293.15, 0.00775),
95 ("nitrogen 3/8in", 8.0, *NITROGEN, 1000 * PSI, 293.15, 0.00953),
96 ("nitrogen cold 1/4in", 1.7, *NITROGEN, 200 * PSI, 250.0, 0.00635),
97 ("helium 1in dump", 60.0, *HELIUM, 550 * PSI, 293.15, 0.0254),
98]
99
100
101@pytest.mark.parametrize("name,Cv,mw,gamma,p1,T,bore", CASES)
102def test_the_ceiling_matches_the_library(name, Cv, mw, gamma, p1, T, bore) -> None:
103 """Against `fluids`' own IEC 60534 implementation, not against arithmetic.
104
105 The coefficient is calibrated from that library rather than transcribed,
106 because the published N6 depends on which unit set *and* which flow
107 coefficient the table is written for -- pairing N6 = 27.3 with Cv and kPa
108 over-predicts by 10.5x, which is how this was first written.
109 """
110 got = valve(Cv, bore * 1000.0).flow_ceiling(gas(mw, gamma, p1, T))
111 assert got == pytest.approx(library_mdot(Cv, mw, gamma, p1, T, bore), rel=SCALE)
112
113
115 v, flow = valve(3.8, 9.53), gas(*HELIUM, 550 * PSI, 293.15)
116 assert v.is_choked(550 * PSI - 101325.0, flow) is True
117
118
120 """Choking starts at x = (gamma/1.40) * xT, not at any drop."""
121 v, flow = valve(3.8, 9.53), gas(*HELIUM, 550 * PSI, 293.15)
122 assert v.is_choked(0.05 * 550 * PSI, flow) is False
123
124
126 """F_gamma carries the specific-heat ratio; it is why the two gases differ."""
127 dp = 0.75 * 550 * PSI
128 assert valve(3.8, 9.53).is_choked(dp, gas(*NITROGEN, 550 * PSI, 293.15)) is True
129 assert valve(3.8, 9.53).is_choked(dp, gas(*HELIUM, 550 * PSI, 293.15)) is False
130
131
133 """LOX through a main valve must not acquire a sonic limit."""
134 liquid = FlowConditions(
135 rho=1141.0, mu=1.9e-4, p_upstream=550 * PSI, temperature=90.0
136 )
137 assert valve(26.1, 12.7).flow_ceiling(liquid) is None
138
139
140#: R037, deferred: a network-priced liquid still carries the old gas ceiling,
141#: because removing it lets a drawing with no engine model empty its LOX tank in
142#: the coupling step the session holds the chamber at ambient (see
143#: feedtwin.comps.elements._CHOKE_COEFFICIENT). Strict: these turn red when the
144#: session closes that chamber and the ceiling goes, and must then be unmarked.
145R037_DEFERRED = pytest.mark.xfail(
146 strict=True, reason="R037 deferred: liquids keep the old gas ceiling"
147)
148
149
150@R037_DEFERRED
151@pytest.mark.parametrize(
152 "species,T", [("oxygen", 90.2), ("ethanol", 293.0)], ids=["LOX", "ethanol"]
153)
155 species: str, T: float
156) -> None:
157 """The conditions a network builds for a liquid carry the saturated
158 liquid's cp/cv (LOX 1.83, ethanol 1.19), and the old gas test (gamma > 0)
159 read that as a gas: every liquid valve got a sonic ceiling at
160 x = gamma/1.4 * 0.7, and a LOX dump to atmosphere drained at 0.69x IEC."""
161 flow = conditions_from_fluid(Fluid(species), 300 * PSI, T)
162 assert flow.gamma > 0.0, "the trap: a liquid's cp/cv is positive too"
163 v = valve(1.0, 9.5)
164 assert v.flow_ceiling(flow) is None
165 assert not v.is_choked(0.99 * 300 * PSI, flow) or v.choked_dp(flow) is not None
166
167
168@R037_DEFERRED
169@pytest.mark.parametrize(
170 "species,T,p1_psia",
171 [("oxygen", 90.2, 300.0), ("oxygen", 90.2, 600.0), ("ethanol", 293.0, 600.0)],
172)
174 species: str, T: float, p1_psia: float
175) -> None:
176 """Solved through a network, a Cv 1 valve discharging liquid to atmosphere
177 passes what fluids' IEC 60534 liquid sizing gives for the same drop
178 (non-choked form: the incompressible law is the one the twin solves; the
179 IEC liquid choke is reported by ``choked_dp``, not imposed)."""
180 p1 = p1_psia * PSI
181 net = Network()
182 net.add_node("tank", species, T, pressure=p1)
183 net.add_node("atm", species, T, pressure=101325.0)
184 for node in net.nodes.values():
185 node.phase = "liquid"
186 net.add_branch("V", valve(1.0, 9.5), "tank", "atm") # type: ignore[arg-type]
187 result = solve_steady(net)
188 assert result.converged
189 flow = conditions_from_fluid(Fluid(species), p1, T)
190 Q = 1e-3
191 kv = cv_lib.size_control_valve_l(
192 rho=flow.rho,
193 Psat=flow.p_sat,
194 Pc=flow.p_crit,
195 mu=flow.mu,
196 P1=p1,
197 P2=101325.0,
198 Q=Q,
199 D1=0.0095,
200 D2=0.0095,
201 d=0.0095,
202 allow_choked=False,
203 )
204 expected = flow.rho * Q * KV_PER_CV / kv
205 assert result.flows["V"] == pytest.approx(expected, rel=SCALE)
206
207
208def _swept(p1: float, p2: float, Cv: float = 4.0) -> float:
209 """A Cv 4 valve between two fixed nitrogen pressures, solved."""
210 net = Network()
211 net.add_node("up", "nitrogen", 293.0, pressure=p1)
212 net.add_node("dn", "nitrogen", 293.0, pressure=p2)
213 for node in net.nodes.values():
214 node.phase = "gas"
215 net.add_branch("V", valve(Cv, 9.5), "up", "dn") # type: ignore[arg-type]
216 result = solve_steady(net)
217 assert result.converged
218 return result.flows["V"]
219
220
222 """IEC 60534-2-1: W = N6 C Y sqrt(x p1 rho1), Y = 1 - x/(3 F_gamma xT).
223
224 The plain valve used Y = 1 below a separately calibrated ceiling: at
225 500 psia, 140 psia downstream it passed 0.956 kg/s (IEC 0.628, 1.52x), and
226 at 130 psia 0.609 -- flow *falling* 36 % as the downstream fell 10 psi.
227 """
228 p1 = 500 * PSI
229 downstream = [450, 400, 300, 200, 160, 150, 145, 140, 135, 130, 120, 100, 14.7]
230 flows = [_swept(p1, p2 * PSI) for p2 in downstream]
231 for before, after in zip(flows, flows[1:]):
232 assert after >= before * (1.0 - 1e-9), "flow must not fall as dp rises"
233 for p2, flow in zip(downstream, flows):
234 reference = library_mdot(4.0, *NITROGEN, p1, 293.0, 0.0095, p2=p2 * PSI)
235 # Real nitrogen (Z ~ 1.0) against fluids' ideal gas (Z = 1): Z enters
236 # IEC's sqrt(x p1 rho1) through rho1, a few tenths of a percent here.
237 z = conditions_from_fluid(Fluid("nitrogen"), p1, 293.0, phase="gas").rho
238 ideal = p1 / (R_UNIVERSAL_J / NITROGEN[0] * 293.0)
239 assert flow == pytest.approx(reference * (z / ideal) ** 0.5, rel=SCALE), p2
240
241
243 """Just under the critical ratio the flow is within a hair of the ceiling;
244 the old law was 50 % above it there."""
245 p1 = 500 * PSI
246 flow = conditions_from_fluid(Fluid("nitrogen"), p1, 293.0, phase="gas")
247 v = valve(4.0, 9.5)
248 ceiling = v.flow_ceiling(flow)
249 x_crit = (flow.gamma_ideal / 1.40) * 0.7
250 near = _swept(p1, p1 * (1.0 - 0.999 * x_crit))
251 assert near == pytest.approx(ceiling, rel=1e-5)
252 assert near <= ceiling
253
254
256 """Nothing flows, so there is nothing to limit -- and a zero ceiling would
257 pin the branch at zero rather than let `isolates()` remove it."""
258 v = valve(3.8, 9.53, leak_closed=Param(0.0, "Cv", Provenance.ESTIMATED))
259 flow = gas(*HELIUM, 550 * PSI, 293.15)
260 object.__setattr__(flow, "signals", {"V.command": 0.0})
261 assert v.flow_ceiling(flow) is None
262
263
265 """A butterfly (xT ~ 0.3) chokes lower and passes less than a globe."""
266 flow = gas(*HELIUM, 550 * PSI, 293.15)
267 globe = valve(3.8, 9.53).flow_ceiling(flow)
268 butterfly = valve(
269 3.8, 9.53, xT=Param(0.3, "-", Provenance.MANUFACTURER)
270 ).flow_ceiling(flow)
271 assert butterfly < globe
None test_a_choked_gas_valve_meets_its_ceiling_continuously()
None test_a_liquid_valve_to_atmosphere_is_the_iec_liquid_law(str species, float T, float p1_psia)
None test_a_liquid_valve_still_has_no_gas_ceiling()
None test_the_ceiling_matches_the_library(name, Cv, mw, gamma, p1, T, bore)
float library_mdot(Cv, mw, gamma, p1, temperature, bore, p2=101325.0)
gas(float mw, float gamma, float p_upstream, float temperature)
None test_a_gas_vent_to_atmosphere_reports_choked()
None test_a_liquid_priced_by_the_network_has_no_gas_ceiling(str species, float T)
float _swept(float p1, float p2, float Cv=4.0)
None test_helium_and_nitrogen_choke_at_different_ratios()
object valve(float Cv, float bore_mm, **params)
None test_a_shut_valve_has_no_ceiling()
None test_a_small_pressure_ratio_is_not_choked()
None test_gas_flow_rises_continuously_to_the_choke_and_stays()