STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_relief.py
Go to the documentation of this file.
1"""The pressure relief valve (``relief_valve``, model ``spring``).
2
3Checked against things that are not this code:
4
5* its gas capacity, choked and not, against ``fluids``' own implementation of
6 IEC 60534-2-1 (``size_control_valve_g``), which returns the Cv that passes a
7 flow -- the valve's own Cv must come back;
8* its lift law against ISO 4126-1's terms, by hand: shut below set, open at set,
9 shut again only below set x (1 - blowdown);
10* on the helium hot-fire drawing, a regulator failed wide open into the fuel
11 tank: the tank must be held under set x (1 + overpressure), and with the
12 regulator back it must vent down past set to the reseating pressure before the
13 valve shuts.
14
15And the drawing contract: an RV that declares no set pressure never lifts, so
16it is read as shut -- no branch at all -- and the build says so. (It used to be
17built as an always-open Cv valve: a hole to atmosphere on the tank it guards,
18which on a pid-designer drawing like LE4's meant the stand could never hold
19press.)
20"""
21
22from __future__ import annotations
23
24import copy
25import json
26from dataclasses import replace
27from pathlib import Path
28
29import pytest
30from fluids.control_valve import size_control_valve_g
31from fluids.fittings import Kv_to_Cv
32
33from feedtwin.comps import build_component, conditions_from_fluid
34from feedtwin.comps.iec_gas import GasCv
35from feedtwin.comps.relief import ReliefValve
36from feedtwin.model import ComponentInstance, Param, Provenance
37from feedtwin.pid import read_diagram
38from feedtwin.props import Fluid
39from feedtwin.session import PSI, assemble_model, load_machine
40from feedtwin.session.burn import burn_setup, open_session
41from feedtwin.session.gauge import ATMOSPHERE, from_psig
42
43STAR = Path(__file__).resolve().parents[3]
44DRAWINGS = STAR / "feed-twin" / "backend" / "diagrams"
45HE = DRAWINGS / "copv_study_he.json"
46TABLES = STAR / "feed-twin" / "backend" / "statemachines"
47
48needs_drawing = pytest.mark.skipif(
49 not (HE.exists() and TABLES.is_dir()), reason="helium drawing or tables absent"
50)
51
52SET_PSI = 750.0
53BORE_MM = 10.92
54
55
56def _p(value: float, unit: str) -> Param:
57 return Param(value, unit, Provenance.ESTIMATED, "test")
58
59
60def _relief(cv: float = 5.0, **extra: Param) -> ReliefValve:
61 params = {
62 "Cv": _p(cv, "Cv"),
63 "bore": _p(BORE_MM, "mm"),
64 "set_pressure": _p(SET_PSI, "psi"),
65 **extra,
66 }
67 component = build_component(
68 ComponentInstance.build("RV-1", "relief_valve", params, model="spring")
69 )
70 assert isinstance(component, ReliefValve)
71 return component
72
73
74def _helium(p: float, T: float = 293.15, lift: float = 1.0): # type: ignore[no-untyped-def]
75 return conditions_from_fluid(
76 Fluid("helium"), p, T, {"RV-1.lift": lift}, phase="gas"
77 )
78
79
80# ------------------------------------------------------------- the component
81
82
83@pytest.mark.parametrize("x", [None, 0.40, 0.10])
85 """Choked (x None: discharge to atmosphere) and not: ``fluids`` sizes the
86 valve for the flow this one passes, and returns this valve's Cv."""
87 valve = _relief(cv=5.0)
88 p1 = from_psig(SET_PSI * 1.10) # the relieving pressure
89 flow = _helium(p1)
90 p2 = ATMOSPHERE if x is None else p1 * (1.0 - x)
91 if x is None:
92 assert valve.is_choked(p1 - p2, flow)
93 mdot = valve.flow_ceiling(flow)
94 assert mdot == pytest.approx(valve.rated_capacity(flow), rel=1e-12)
95 else:
96 assert not valve.is_choked(p1 - p2, flow)
97 # The flow IEC gives at this drop, and the valve's drop at that flow.
98 gas = GasCv(5.0, BORE_MM / 1e3, 0.70)
99 mdot = gas.flow(p1, flow.rho, flow.gamma_ideal, p1 - p2)
100 assert flow.gamma_ideal == pytest.approx(5.0 / 3.0, rel=1e-5)
101 assert valve.pressure_drop(mdot, flow) == pytest.approx(p1 - p2, rel=1e-9)
102 assert mdot is not None and mdot > 0.0
103 molar_mass = 4.002602
104 rho_normal = ATMOSPHERE * molar_mass * 1e-3 / (8.314462618 * 273.15)
105 sized = size_control_valve_g(
106 T=293.15,
107 MW=molar_mass,
108 mu=flow.mu,
109 gamma=5.0 / 3.0,
110 Z=Fluid("helium").get("Z", p=p1, T=293.15),
111 P1=p1,
112 P2=p2,
113 Q=mdot / rho_normal, # fluids' Q is normal m^3/s at 0 degC (its N9)
114 D1=BORE_MM / 1e3,
115 D2=BORE_MM / 1e3,
116 d=BORE_MM / 1e3,
117 xT=0.70,
118 allow_choked=True,
119 )
120 # IEC's tabulated N6 against the Cv definition the library scales by: 0.15 %.
121 assert Kv_to_Cv(sized) == pytest.approx(5.0, rel=3e-3)
122
123
125 """W = N6 C (2/3) sqrt(F_gamma xT p1 rho1), IEC 60534-2-1 with N6 = 2.73
126 (Cv; kg/h, kPa, kg/m^3), helium (gamma 5/3) at 825 psig and 293.15 K."""
127 valve = _relief(cv=4.0)
128 p1 = from_psig(825.0)
129 flow = _helium(p1)
130 rho1 = Fluid("helium").get("rho", p=p1, T=293.15)
131 hand = (
132 2.73 * 4.0 * (2.0 / 3.0) * ((5.0 / 3.0) / 1.40 * 0.70 * p1 / 1e3 * rho1) ** 0.5
133 )
134 assert valve.rated_capacity(flow) == pytest.approx(hand / 3600.0, rel=3e-3)
135
136
138 valve = _relief(
139 overpressure=Param(0.10, "-", Provenance.ESTIMATED, "test"),
140 blowdown=Param(0.07, "-", Provenance.ESTIMATED, "test"),
141 )
142 p_set = SET_PSI * PSI
143 reseat, full = 0.93 * p_set, 1.10 * p_set
144 assert valve.reseat_pressure == pytest.approx(reseat)
145 assert valve.full_lift_pressure == pytest.approx(full)
146
147 # Rising from shut: nothing until set; it does not open at reseat.
148 for fraction in (0.5, 0.93, 0.99, 0.9999):
149 assert valve.lift_for(fraction * p_set, was_open=False) == (0.0, False)
150 lift, is_open = valve.lift_for(p_set, was_open=False)
151 assert is_open and lift == pytest.approx((p_set - reseat) / (full - reseat))
152 assert valve.lift_for(full, True) == (1.0, True)
153 assert valve.lift_for(1.3 * p_set, True) == (1.0, True)
154 # Falling while open: still open (and lifting less) below set...
155 lift, is_open = valve.lift_for(0.96 * p_set, was_open=True)
156 assert is_open and 0.0 < lift < 0.41
157 # ...until below reseat.
158 assert valve.lift_for(0.929 * p_set, was_open=True) == (0.0, False)
159 # Same pressure, other history: the hysteresis loop.
160 assert valve.lift_for(0.96 * p_set, was_open=False) == (0.0, False)
161
162
164 valve = _relief()
165 assert valve.isolates({}) and valve.isolates(None)
166 assert valve.isolates({"RV-1.lift": 0.0})
167 assert not valve.isolates({"RV-1.lift": 0.5})
168 # Half lift, half the Cv: half the choked flow.
169 p1 = from_psig(800.0)
170 full = valve.flow_ceiling(_helium(p1, lift=1.0))
171 half = valve.flow_ceiling(_helium(p1, lift=0.5))
172 assert half == pytest.approx(0.5 * full, rel=1e-6) # type: ignore[operator]
173
174
176 model = _relief().model()
177 assert "ISO 4126-1" in model["source"] and "IEC 60534-2-1" in model["source"]
178 assert model["assumptions"]
179 inputs = model["inputs"]
180 assert inputs["set_pressure"]["value"] == SET_PSI
181 assert inputs["blowdown"]["provenance"].startswith("default")
182 assert inputs["xT"]["provenance"].startswith("default")
183
184
185# ----------------------------------------------------------------- drawings
186
187
188def _drawing(rv_params: dict | None) -> dict: # type: ignore[type-arg]
189 payload = copy.deepcopy(json.loads(HE.read_text()))
190 if rv_params is None:
191 return payload
192 payload["nodes"].append(
193 {
194 "id": "RV_FU",
195 "position": {"x": 600, "y": 520},
196 "data": {
197 "componentType": "RV",
198 "label": "RV-FUEL",
199 "fluid": "helium",
200 "params": rv_params,
201 },
202 }
203 )
204 payload["edges"].append(
205 {
206 "id": "l_rv",
207 "source": "FUT",
208 "target": "RV_FU",
209 "data": {
210 "lineType": "pipe",
211 "params": {
212 "length": {
213 "value": 0.1,
214 "unit": "m",
215 "source": "estimated",
216 "reference": "test",
217 },
218 "bore": {
219 "value": BORE_MM,
220 "unit": "mm",
221 "source": "estimated",
222 "reference": "test",
223 },
224 },
225 },
226 }
227 )
228 return payload
229
230
231def _rv_params(cv: float, set_psi: float | None) -> dict: # type: ignore[type-arg]
232 params = {
233 "Cv": {"value": cv, "unit": "Cv", "source": "estimated", "reference": "test"},
234 "bore": {
235 "value": BORE_MM,
236 "unit": "mm",
237 "source": "estimated",
238 "reference": "test",
239 },
240 }
241 if set_psi is not None:
242 params["set_pressure"] = {
243 "value": set_psi,
244 "unit": "psi",
245 "source": "estimated",
246 "reference": "test",
247 }
248 return params
249
250
251@needs_drawing
253 model = assemble_model(
254 read_diagram(_drawing(_rv_params(5.0, None)), name="rv"), diagram_id="rv"
255 )
256 # A relief that never lifts is a branch that is not there: no hole to
257 # atmosphere on the fuel tank, and its line ends at a capped port.
258 assert "RV_FU" not in model.built.network.branches
259 assert "l_rv" not in model.built.network.branches
260 assert any(
261 w.startswith("RV-FUEL") and "read as shut" in w for w in model.report.warnings
262 )
263
264
265@needs_drawing
267 model = assemble_model(
268 read_diagram(_drawing(_rv_params(5.0, SET_PSI)), name="rv"), diagram_id="rv"
269 )
270 component = model.built.network.branches["RV_FU"].component
271 assert isinstance(component, ReliefValve)
272 assert not any("not a relief model" in w for w in model.report.warnings)
273 assert model.built.network.branches["l_rv"].upstream == "FUT"
274 assert model.built.network.nodes["RV_FU.in"].phase == "gas"
275
276
277@needs_drawing
279 for name in ("copv_study_he", "copv_study_gn2", "ethalox_stand"):
280 path = DRAWINGS / f"{name}.json"
281 if not path.exists():
282 continue
283 model = assemble_model(
284 read_diagram(json.loads(path.read_text()), name=name), diagram_id=name
285 )
286 assert not any(
287 getattr(b.component, "type", "") == "relief_valve"
288 for b in model.built.network.branches.values()
289 ), name
290 session = open_session(model, load_machine(tables=TABLES), setup=burn_setup())
291 assert session._reliefs == []
292
293
294@needs_drawing
296 """The dome regulator failed wide open (dome above the bottle) into the fuel
297 tank alone, a relief set at 750 psig on the tank. Half full, so the ullage
298 is litres rather than the shipped 0.43 L and the test runs in seconds.
299
300 Below set the relief is shut. Failed open, the tank pops it and is held
301 under set + 10 % (the overpressure at which the valve is at full lift) with
302 the relief passing what the regulator delivers. Regulator restored, the tank
303 vents down *past* set to the reseating pressure before the valve shuts.
304 """
305 cv = 8.0
306 model = assemble_model(
307 read_diagram(_drawing(_rv_params(cv, SET_PSI)), name="rv"), diagram_id="rv"
308 )
309 session = open_session(model, load_machine(tables=TABLES), setup=burn_setup())
310 session.prime(fill_fraction=0.5, tank_psi=550.0, copv_psi=4500.0, state="Ready")
311 session.set_valve("SV_FUEL_PRESS", True)
312 session.set_valve("SV_LOX_PRESS", False)
313 tank = session.tanks["FUT"]
314 relief = model.built.network.branches["RV_FU"].component
315 assert isinstance(relief, ReliefValve)
316 p_set = from_psig(SET_PSI)
317 reseat = ATMOSPHERE + relief.reseat_pressure
318 full = ATMOSPHERE + relief.full_lift_pressure
319
320 def held() -> float:
321 return tank.state.ullage.mass + tank.state.vapour_mass
322
323 def run(seconds: float): # type: ignore[no-untyped-def]
324 rows = []
325 for _ in range(int(round(seconds / 0.02))):
326 before = held()
327 sample = session.step(0.02)
328 rows.append(
329 (
330 tank.pressure,
331 sample.signals.get("RV-FUEL.lift", 0.0),
332 sample.flows.get("RV_FU", 0.0),
333 sample.flows.get("PR_D", 0.0),
334 (held() - before) / 0.02,
335 )
336 )
337 assert not session.tripped
338 return rows
339
340 # Regulating normally: the relief never moves.
341 quiet = run(0.2)
342 assert all(p < p_set for p, *_ in quiet)
343 assert all(lift == 0.0 and flow == 0.0 for _, lift, flow, *_ in quiet)
344
345 # Failed open.
346 session.setup = replace(session.setup, dome_psi=6000.0)
347 failed = run(0.6)
348 peak = max(p for p, *_ in failed)
349 assert peak > p_set, "the regulator never carried the tank to the relief"
350 assert peak < full, (peak / PSI, full / PSI)
351 # And well inside it: the valve pops on the coupling step its tank crossed
352 # set (the session re-solves when a relief flips), not a 20 ms tick later.
353 assert peak < p_set + 0.5 * (full - p_set), (peak / PSI, full / PSI)
354 assert any(lift > 0.0 for _, lift, *_ in failed)
355 # Held, part-lifted, carrying most of what the regulator delivers; the
356 # ullage keeps the rest (in - out = what it gained: the vent is booked). The
357 # flows are the tick's last coupling step and the gain is the whole tick's,
358 # hence the band; an unbooked vent would gain all of the inflow.
359 for p, lift, out, inflow, gained in failed[-5:]:
360 assert 0.0 < lift < 1.0
361 assert 0.5 * inflow < out < inflow
362 assert inflow - out == pytest.approx(gained, rel=0.15, abs=1e-4)
363
364 # Regulator restored: vents past set, shuts near reseat, and stays shut.
365 session.setup = replace(session.setup, dome_psi=500.0)
366 restored = run(0.6)
367 shut_at = next(i for i, (_, lift, *_) in enumerate(restored) if lift == 0.0)
368 assert restored[shut_at][0] < p_set - 0.5 * (p_set - reseat)
369 assert restored[shut_at][0] > reseat - 0.04 * (p_set - ATMOSPHERE)
370 assert all(lift == 0.0 and flow == 0.0 for _, lift, flow, *_ in restored[shut_at:])
#define max(a, b)
Definition Arduino.h:43
Definition run.py:1
None test_a_relief_holds_the_fuel_tank_when_the_regulator_fails_open()
Param _p(float value, str unit)
ReliefValve _relief(float cv=5.0, **Param extra)
None test_shut_below_set_opens_at_set_and_reseats_with_hysteresis()
None test_without_a_session_a_relief_is_shut()
dict _drawing(dict|None rv_params)
None test_the_model_block_names_its_sources_and_inputs()
None test_rated_capacity_by_hand()
dict _rv_params(float cv, float|None set_psi)
None test_shipped_drawings_have_no_relief_and_build_as_before()
None test_gas_flow_is_iec_60534_as_fluids_implements_it(float|None x)
_helium(float p, float T=293.15, float lift=1.0)
None test_an_rv_with_a_set_pressure_is_a_relief_on_the_ullage()
None test_an_rv_without_a_set_pressure_is_shut_and_says_so()