STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
relief.py
Go to the documentation of this file.
1"""A spring-loaded pressure relief valve: shut until the pressure says otherwise.
2
3Until this existed a drawn ``RV`` became a plain Cv valve with no actuator, so it
4was *open* -- a permanent vent hung off whatever it was drawn on -- and the only
5overpressure protection the twin had was tripping the stand at a vessel's MAWP
6(EngineDesign/docs/layerx/AUDIT.md 5.2, 9.6 E). A drawn RV that declares a
7``set_pressure`` now builds as this; one that does not keeps the old behaviour and
8the build says it is not a relief model (:func:`feedtwin.pid.network.build_network`).
9
10What it does
11------------
12Terms are ISO 4126-1 (*Safety devices for protection against excessive pressure
13-- Part 1: Safety valves*): **set pressure** (the valve begins to open),
14**overpressure** (the rise above set at which it reaches its rated lift, as a
15fraction of set), **blowdown** (set less the **reseating pressure**, as a
16fraction of set).
17
18.. code-block:: text
19
20 shut: opens once dp_act >= p_set
21 open: lift = (dp_act - p_reseat) / (p_full - p_reseat), clipped to [0, 1]
22 shuts once dp_act < p_reseat
23 p_reseat = p_set (1 - blowdown), p_full = p_set (1 + overpressure)
24
25``dp_act`` is the pressure **across** the valve, inlet over outlet: a
26conventional (unbalanced) spring valve's set pressure is referenced to its
27outlet, so a superimposed back pressure raises the inlet pressure it opens at.
28Discharging to atmosphere, it is the gauge pressure the valve is stamped with.
29The loop between opening at ``p_set`` and shutting at ``p_reseat`` is the
30hysteresis; inside it the lift follows the pressure, so a valve that has popped
31throttles down continuously to its seat rather than chattering between shut and
32wide open.
33
34The lift is a state, decided by the session once per coupling step from the
35pressures of the last solve (:meth:`ReliefValve.lift_for`; the session's
36``_relief_signals``), and handed to the component as the ``<id>.lift`` signal --
37the same way a solenoid's position is. A component evaluated without a session
38(a steady solve) reads no signal and is **shut**, which is a relief valve's
39normal state.
40
41The flow
42--------
43Through ``Cv x lift`` (linear), and for a gas by IEC 60534-2-1 with its expansion
44factor and choke (:mod:`feedtwin.comps.iec_gas`): the rated capacity is the
45choked flow at full lift, ``W = N6 C (2/3) sqrt(F_gamma xT p1 rho1)`` at the
46relieving pressure ``p1 = p_set (1 + overpressure) + p_out``. A liquid relief
47takes the plain valve's incompressible Cv law and its IEC liquid choke.
48
49Sizing basis
50------------
51API 520 Part I (*Sizing, Selection, and Installation of Pressure-relieving
52Devices -- Sizing and Selection*) sizes a gas relief at critical flow as
53``W = C(k) Kd A p1 sqrt(M / (Z T))``, by orifice area and certified discharge
54coefficient; ISO 4126-1 rates the same way. This valve is sized by the Cv a
55datasheet gives instead, so ``Kd A`` is not an input. The choked capacity above
56has API 520's form in ``p1``, ``M``, ``Z`` and ``T`` (``rho1 = p1 M / (Z R T)``);
57only the dependence on ``k`` differs -- IEC's ``sqrt(F_gamma)`` against API's
58``C(k)``, which puts helium 2.9 % higher relative to nitrogen here
59(``sqrt(1.667/1.40)`` = 1.091 against ``C(1.667)/C(1.40)`` = 1.061). A relief
60whose datasheet gives ``Kd`` and an orifice area, not a Cv, needs an area model.
61"""
62
63from __future__ import annotations
64
65from typing import Any, Mapping
66
67from fluids.fittings import Cv_to_K
68
69from feedtwin.comps.base import FlowConditions, Violation, register_builder
70from feedtwin.comps.elements import SHUT_POSITION, Valve, _dynamic_head
71from feedtwin.comps.iec_gas import XT_TYPICAL, GasCv, iec_gamma, is_gas
72
73#: What a session calls a relief's lift signal: ``"<component id>.lift"``.
74LIFT = "lift"
75
76MODEL_NAME = "spring relief valve (ISO 4126-1 terms, IEC 60534-2-1 gas flow)"
77MODEL_SOURCE = (
78 "ISO 4126-1, Safety devices for protection against excessive pressure - "
79 "Part 1: Safety valves (set pressure, overpressure, blowdown, reseating "
80 "pressure); API 520 Part I, Sizing, Selection, and Installation of "
81 "Pressure-relieving Devices (sizing basis: choked gas capacity scaling as "
82 "p1 sqrt(M/(Z T)), here through the datasheet Cv rather than Kd A); "
83 "IEC 60534-2-1:2011, Industrial-process control valves - Part 2-1: "
84 "Flow capacity - Sizing equations for fluid flow under installed conditions "
85 "(compressible flow: Y = 1 - x/(3 F_gamma xT), choked at x = F_gamma xT)"
86)
87
88
90 symmetric = False # a relief lifts on its own inlet; never mirrored
91 """A pressure-actuated relief valve. See the module docstring."""
92
93 # ------------------------------------------------------------ the law
94
95 @property
96 def set_pressure(self) -> float:
97 """Differential at which it begins to open [Pa]."""
98 return float(self.p["set_pressure"])
99
100 @property
101 def reseat_pressure(self) -> float:
102 """Differential below which an open valve shuts [Pa]."""
103 return self.set_pressure * (1.0 - float(self.p.get("blowdown", 0.0)))
104
105 @property
106 def full_lift_pressure(self) -> float:
107 """Differential at which it reaches rated lift [Pa]."""
108 return self.set_pressure * (1.0 + float(self.p.get("overpressure", 0.0)))
109
110 def lift_for(self, dp_actuating: float, was_open: bool) -> tuple[float, bool]:
111 """``(lift, open)`` for the pressure across the valve, given its history.
112
113 Shut, it opens once ``dp_actuating`` reaches the set pressure; open, it
114 stays open until ``dp_actuating`` falls below the reseating pressure. The
115 lift is proportional between the reseating pressure and full lift.
116 """
117 if was_open:
118 is_open = dp_actuating >= self.reseat_pressure
119 else:
120 is_open = dp_actuating >= self.set_pressure
121 if not is_open:
122 return 0.0, False
123 span = self.full_lift_pressure - self.reseat_pressure
124 if span <= 0.0:
125 return 1.0, True
126 lift = (dp_actuating - self.reseat_pressure) / span
127 return min(max(lift, 0.0), 1.0), True
128
129 # --------------------------------------------------------- the valve
130
131 def _lift(self, signals: Mapping[str, float]) -> float:
132 for key in (f"{self.id}.{LIFT}", LIFT):
133 if key in signals:
134 return min(max(float(signals[key]), 0.0), 1.0)
135 return 0.0 # no session deciding it: a relief valve sits shut
136
137 def isolates(self, signals: Mapping[str, float] | None = None) -> bool:
138 return self._lift(signals or {}) <= SHUT_POSITION
139
140 def _gas(self, flow: FlowConditions) -> GasCv | None:
141 if not is_gas(flow):
142 return None
143 cv = self.effective_cv(self._lift(flow.signals))
144 return GasCv(cv, float(self.p["bore"]), float(self.p.get("xT", XT_TYPICAL)))
145
146 def pressure_drop(self, mdot: float, flow: FlowConditions) -> float:
147 gas = self._gas(flow)
148 if gas is not None:
149 return gas.drop(mdot, flow.p_upstream, flow.rho, iec_gamma(flow))
150 Cv = self.effective_cv(self._lift(flow.signals))
151 bore = float(self.p["bore"])
152 return Cv_to_K(Cv, bore) * _dynamic_head(mdot, bore, flow.rho)
153
154 def _gas_choke(self, flow: FlowConditions) -> tuple[float, float] | None:
155 if not is_gas(flow):
156 return None
157 gamma = iec_gamma(flow)
158 xT = float(self.p.get("xT", XT_TYPICAL))
159 return GasCv(1.0, 1.0, xT).x_critical(gamma), gamma / 1.40
160
161 def flow_ceiling(self, flow: FlowConditions) -> float | None:
162 gas = self._gas(flow)
163 if gas is None:
164 return None
165 return gas.capacity(flow.p_upstream, flow.rho, iec_gamma(flow))
166
167 def rated_capacity(self, flow: FlowConditions) -> float | None:
168 """Choked gas flow at full lift [kg/s] for the inlet state ``flow``.
169
170 Pass the conditions at the relieving pressure, ``full_lift_pressure``
171 plus the outlet's, for the valve's rated capacity. ``None`` for a liquid.
172 """
173 if not is_gas(flow):
174 return None
175 gas = GasCv(
176 float(self.p["Cv"]),
177 float(self.p["bore"]),
178 float(self.p.get("xT", XT_TYPICAL)),
179 )
180 return gas.capacity(flow.p_upstream, flow.rho, iec_gamma(flow))
181
182 def diagnostics(self, mdot: float, flow: FlowConditions) -> dict[str, float]:
183 lift = self._lift(flow.signals)
184 out: dict[str, float] = {
185 "lift": lift,
186 "Cv": self.effective_cv(lift),
187 "dp": self.pressure_drop(mdot, flow),
188 "set_pressure": self.set_pressure,
189 "reseat_pressure": self.reseat_pressure,
190 "full_lift_pressure": self.full_lift_pressure,
191 }
192 ceiling = self.flow_ceiling(flow)
193 if ceiling is not None:
194 out["capacity"] = ceiling
195 return out
196
197 def check(self) -> list[Violation]:
198 out: list[Violation] = []
199 if self.set_pressure <= 0.0:
200 out.append(
201 Violation(self.id, "set_pressure", "set pressure is not positive")
202 )
203 blowdown = float(self.p.get("blowdown", 0.0))
204 if not 0.0 <= blowdown < 1.0:
205 out.append(
206 Violation(
207 self.id,
208 "blowdown",
209 f"blowdown {blowdown:g} is outside [0, 1): the valve would "
210 "reseat at or below zero",
211 )
212 )
213 for name in ("Cv", "overpressure", "blowdown", "xT"):
214 param = self.instance.params.get(name)
215 if param is not None and param.source.is_assumed:
216 out.append(
217 Violation(
218 self.id,
219 name,
220 f"{name} is {param.source.value}, not from the valve's "
221 "datasheet; it sets how much this relief can pass.",
222 severity="warning",
223 )
224 )
225 return out
226
227 # ------------------------------------------------------------ report
228
229 def model(self) -> dict[str, Any]:
230 """The run record's ``model`` block for this valve."""
231 inputs: dict[str, Any] = {}
232 for name in ("set_pressure", "overpressure", "blowdown", "Cv", "bore", "xT"):
233 param = self.instance.params.get(name)
234 if param is None:
235 continue
236 inputs[name] = {
237 "value": param.value,
238 "unit": param.unit,
239 "provenance": f"{param.source.value}: {param.reference}".rstrip(": "),
240 }
241 return {
242 "name": MODEL_NAME,
243 "source": MODEL_SOURCE,
244 "assumptions": [
245 "conventional spring valve: opens on the pressure across it "
246 "(inlet over outlet), so back pressure raises the inlet set point",
247 "lift linear in pressure between the reseating pressure and full "
248 "lift at set x (1 + overpressure); Cv linear in lift",
249 "no opening or closing time: the lift follows the pressure each "
250 "coupling step (relief pop and reseat dynamics not modelled)",
251 "gas flow per IEC 60534-2-1 with the ideal-gas ratio of specific "
252 "heats; scale from the Cv definition (fluids Cv_to_K)",
253 "decided from the previous coupling step's pressures (one coupling "
254 "step of lag, milliseconds)",
255 ],
256 "inputs": inputs,
257 }
258
259
260register_builder("relief_valve", "spring", ReliefValve)
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
float|None flow_ceiling(self, FlowConditions flow)
Definition base.py:279
float pressure_drop(self, float mdot, FlowConditions flow)
Definition base.py:190
GasCv|None _gas(self, FlowConditions flow)
Definition elements.py:633
float effective_cv(self, float position)
Definition elements.py:564
float pressure_drop(self, float mdot, FlowConditions flow)
Definition relief.py:146
dict[str, float] diagnostics(self, float mdot, FlowConditions flow)
Definition relief.py:182
float|None flow_ceiling(self, FlowConditions flow)
Definition relief.py:161
GasCv|None _gas(self, FlowConditions flow)
Definition relief.py:140
bool isolates(self, Mapping[str, float]|None signals=None)
Definition relief.py:137
list[Violation] check(self)
Definition relief.py:197
float _lift(self, Mapping[str, float] signals)
Definition relief.py:131
tuple[float, float]|None _gas_choke(self, FlowConditions flow)
Definition relief.py:154
tuple[float, bool] lift_for(self, float dp_actuating, bool was_open)
Definition relief.py:110
float|None rated_capacity(self, FlowConditions flow)
Definition relief.py:167