1"""Pressure regulators, modelled on what a datasheet actually tells you.
3A regulator is not a restriction. A pipe's pressure drop is a function of the
4flow through it; a regulator's outlet is a *setpoint* it defends, and the drop
5across it is whatever the inlet happens to be minus that setpoint. Modelled as
6a resistance it is wrong in the most misleading way -- it will happily report a
7tank pressure that tracks COPV pressure, which is the exact behaviour a
8regulator exists to prevent.
10Why there is no force balance here
11----------------------------------
12An earlier plan for this module proposed deriving droop from a force balance
13across the sensing diaphragm: dome pressure on the diaphragm against spring
14preload, seat and stem areas, flow force on the poppet. That was abandoned, and
15the reason is worth recording so nobody rebuilds it.
17The balance needs the diaphragm's **effective area as a function of lift**, and
18effective area depends on how the convolution rolls under load. No vendor
19publishes it, it changes with the elastomer and with age, and it cannot be
20recovered from an outline drawing. Building the model means inventing three
21unmeasurable numbers and tuning them until the answer reproduces the one number
22the datasheet already gave you. More machinery, less confidence.
24How a dome-loaded regulator is actually set
25--------------------------------------------
26A dome regulator has no knob. Its outlet follows the pressure in its dome, and
27the dome is fed by a second, hand-loaded regulator upstream -- the *control*
28regulator. Turning that knob is how the setpoint moves, during a test, by hand.
29So the dome pressure is a **signal** here, not a fixed parameter: a scenario can
30ramp it, and an operator can change it mid-run, which is exactly what happens on
33Between the dome and the outlet sits a **bias**. The Aqua Environment 1092-50 in
34use here delivers 50 psi *above* what its dome is loaded to, by design. Modelled
35as a parameter rather than folded into the setpoint, because it is a property of
36the regulator and the dome pressure is a property of the test: fold them
37together and every scenario has to remember to add 50, and one day one of them
42 p_set = p_dome + bias dome-loaded
43 p_set = setpoint hand-loaded, or a fixed bench setting
45What the datasheet does give you is two more coefficients, and they cover the
46two physically distinct effects:
48**Supply-pressure effect.** Outlet *rises* as inlet *falls*, because inlet
49pressure helps hold the poppet shut against the seat and that help goes away.
50Datasheets quote it as a rate, and it is declared here in the units they quote
51it in -- the Aqua Environment 1092-50 in use here is **17 psi per 1000 psi** of
56 supply_coefficient = { value = 17, unit = "psi/1000psi" }
58It is measured from **zero inlet**, not from a full bottle: the outlet is
62 p_out = p_dome + bias - S . p_in (p_in gauge)
64so a 1092-50 loaded to 500 psi with 4000 psi behind it holds
65``500 + 50 - 4 x 17 = 482`` psi, and it climbs from there as the bottle falls
66-- from the first gram, not from some charge pressure onwards. (The team,
672026-10-07. An earlier version measured the term from an ``inlet_reference``
68or the COPV charge, which zeroed it at a full bottle and put the whole effect
69on the far side of that pressure.) Over a COPV decay from 4500 to 1500 psi it
70moves the outlet up 51 psi -- about 6% on chamber pressure, and the wrong sign
73The unit matters more than it looks. Outlet-pressure-per-inlet-pressure is
74dimensionless by arithmetic, and an earlier version of this module took it as a
75bare ``0.0147`` with unit ``-``. Nobody can check that against a datasheet:
7617 psi/1000 psi, 1.7 psi/100 psi and a slipped decimal all look alike once the
77units are gone, and the number that reaches the solver is three keystrokes from
78being wrong by a factor of ten. It now carries its own ``pressure_ratio``
79dimension, so ``-`` is rejected where one belongs and the vendor's own wording
82**Flow droop.** Outlet *falls* as flow rises, because the poppet must lift and
83lifting takes a pressure difference across the sensing element. This is a
84*separate* coefficient, and it turns out to be structurally load-bearing rather
85than a refinement -- see below. It is tempting to think the seat's Cv already produces
86it -- it does not. Cv sets where the regulator saturates and falls off a cliff;
87at a tenth of rated flow the wide-open seat loss is a couple of psi while a real
88unit has already drooped by ten. Both are modelled, and they are not the same
91The force balance is not forbidden, it is *deferred*: if a fitted droop
92coefficient turns out to vary with something two coefficients cannot see, that
93is the evidence that would justify building one.
95Why zero flow droop is not "no droop" but "no answer"
96-----------------------------------------------------
97A regulator with ``flow_droop = 0`` holds its outlet at exactly the setpoint no
98matter what passes through it. Put one between a COPV and a tank -- both of
99whose pressures are states, hence boundary pressures at any instant -- and the
100branch equation the network solver assembles is
104 (p_up - p_dn) - dp(mdot) = 0 with dp(mdot) = p_up - p_set
106which is satisfied for **every** value of ``mdot``. The flow is genuinely
107indeterminate, the branch's Jacobian row is exactly zero, and Newton has nothing
108to converge on. That is not a numerical quirk to regularise away; it is the
109model saying that a perfect regulator does not determine its own flow.
111Flow droop is what closes it. A non-zero coefficient gives the branch a slope of
112``flow_droop / rated_flow`` and the flow becomes uniquely determined by how much
113droop the downstream pressure is showing -- which is exactly the physical
114mechanism. So :meth:`Regulator.check` reports a zero coefficient as an error
115rather than a warning when the intent is a transient, and
116:class:`IdealRegulator` exists precisely so that "I want a perfect setpoint" is a
117deliberate, named choice with its consequence understood.
120from __future__
import annotations
124from fluids.fittings
import Cv_to_K
143REVERSE_STIFFNESS = 1.0e10
147SUPPLY_ZERO = 101325.0
153SEAT_XT_SIGNAL =
"regulator_seat_xT"
155SEAT_MODEL_NAME =
"regulator seat, compressible (IEC 60534-2-1)"
157 "IEC 60534-2-1:2011, Industrial-process control valves - Part 2-1: Flow "
158 "capacity - Sizing equations for fluid flow under installed conditions: "
159 "W = N6 C Y sqrt(x p1 rho1), Y = 1 - x/(3 F_gamma xT), F_gamma = gamma/1.40, "
160 "choked at x >= F_gamma xT"
167 if bore <= 0.0
or rho <= 0.0:
169 area = math.pi * bore * bore / 4.0
170 v = mdot / (rho * area)
171 return 0.5 * rho * v * v
175 """A pressure regulator holding an outlet setpoint.
177 The outlet it defends is
181 p_set - S . p_in supply-pressure effect, p_in gauge
182 - D . (|mdot| / mdot_rated) flow droop
184 clamped by two physical limits: it can never raise pressure, and it can
185 never pass more than a wide-open seat allows.
187 Both coefficients default to zero with ``source = "default"``, so a
188 regulator declared without them behaves as an ideal one *and says so* in the
189 provenance report. That is deliberate: an unstated droop should look like a
190 missing measurement, not like a regulator that happens to be perfect.
194 """What this regulator has been told to hold [Pa], before any droop.
196 For a dome-loaded unit that is the dome pressure plus the regulator's
197 own bias; for anything else it is the setpoint as configured. The dome
198 pressure is read as a *signal* first, so a scenario or an operator can
199 move it during a run the way a hand on the control regulator does.
201 bias = self.
p.get(
"dome_bias", 0.0)
202 dome = self.
signal(flow,
"dome", self.
p.get(
"dome_pressure", 0.0))
205 return self.
p[
"setpoint"]
208 """The supply-pressure term [Pa]: ``-S p_in``, the inlet in gauge.
210 Measured from zero inlet, never from a charge pressure: at 4000 psi a
211 1092-50 sits ``4 x 17`` psi under dome plus bias, and comes up as the
214 supply = self.
p.get(
"supply_coefficient", 0.0)
215 return supply * (SUPPLY_ZERO - flow.p_upstream)
218 """The pressure this regulator is trying to hold right now [Pa]."""
219 droop = self.
p.get(
"flow_droop", 0.0)
220 rated = self.
p.get(
"rated_flow", 0.0)
224 target -= droop * (abs(mdot) / rated)
228 """Outlet at zero flow [Pa]: setpoint plus the seat's overshoot.
230 Real seats do not close at exactly the setpoint -- the outlet creeps up
231 until the poppet seals. It matters because lockup, not setpoint, is what
232 a downstream relief valve and a burst disc actually see between firings,
233 and on a dome-loaded unit it moves with the dome.
235 The base is the outlet the regulator holds at zero flow,
236 ``outlet_setpoint(0)``, so it carries the supply-pressure effect: the
237 inlet pushing on the poppet does not stop when the flow does, and the
238 branch is continuous across zero flow. (Leaving it out stepped the
239 branch by the whole supply term at zero flow, and a tank inside that
243 return self.
p[
"setpoint"] + self.
p.get(
"lockup_rise", 0.0)
248 def _gas_seat(self, flow: FlowConditions) -> GasCv |
None:
249 """The seat as IEC 60534-2-1 sees a gas, or None for the old law.
251 On only when the session sends :data:`SEAT_XT_SIGNAL` (the opt-in) and
252 the inlet is a gas. The drawing's own ``xT`` wins over the signal's.
254 xT_signal = self.
signal(flow, SEAT_XT_SIGNAL, 0.0)
255 if xT_signal <= 0.0
or not is_gas(flow):
257 xT = float(self.
p.get(
"xT", xT_signal))
258 return GasCv(float(self.
p[
"Cv"]), float(self.
p[
"bore"]), xT)
260 def _seat_dp(self, magnitude: float, flow: FlowConditions) -> float:
261 """Drop the wide-open seat takes at ``magnitude`` [Pa]."""
268 magnitude, self.
p[
"bore"], flow.rho
270 return seat.drop(magnitude, flow.p_upstream, flow.rho, iec_gamma(flow))
273 self, flow: FlowConditions, xT: float = XT_TYPICAL
275 """Choked gas flow through the wide-open seat [kg/s], IEC 60534-2-1.
277 What the regulator can pass at this inlet whatever is downstream: a
278 diagnostic (how close to wide open a burn runs), on whichever seat law
279 the solve uses. The drawing's ``xT`` wins over ``xT``. None for a liquid.
284 float(self.
p[
"Cv"]), float(self.
p[
"bore"]), float(self.
p.get(
"xT", xT))
286 return seat.capacity(flow.p_upstream, flow.rho, iec_gamma(flow))
288 def pinned_flow(self, dp_available: float, flow: FlowConditions) -> float |
None:
289 """The choked flow, when the compressible seat is wide open and choked.
291 Wide open: the downstream is below what the regulator would hold even at
292 that flow, ``p_in - target(W_c)``. Choked: the drop ratio is past
293 ``F_gamma xT``. Both, and the flow is the seat's capacity whatever
294 downstream does. Never with the seat off (the old law has no choke).
299 gamma = iec_gamma(flow)
300 p_in = flow.p_upstream
301 capacity = seat.capacity(p_in, flow.rho, gamma)
305 if dp_available >=
max(seat.x_critical(gamma) * p_in, wide_open):
309 def seat_model(self, xT: float = XT_TYPICAL) -> dict[str, object]:
310 """The run record's ``model`` block for the compressible seat."""
311 param = self.
instance.params.get(
"xT")
313 inputs: dict[str, object] = {
316 "value": param.value,
318 "provenance": f
"{param.source.value}: {param.reference}",
324 "provenance":
"assumed: IEC 60534 typical",
332 "provenance": f
"{cv.source.value}: {cv.reference}",
335 "name": SEAT_MODEL_NAME,
336 "source": SEAT_MODEL_SOURCE,
338 "the wide-open seat is a control valve of the regulator's Cv",
339 "gamma is the ideal-gas ratio of specific heats (cp/cv at 1 kPa)",
340 "scale from the Cv definition (fluids Cv_to_K), so x -> 0 is the "
341 "incompressible law exactly",
342 "only the wide-open (saturated) regulator is affected; while it "
343 "regulates, its outlet is the droop law",
351 p_in = flow.p_upstream
352 magnitude = abs(mdot)
361 seat_dp = self.
_seat_dp(magnitude, flow)
364 required = p_in - target
368 return max(required, seat_dp, 0.0)
370 def _creep(self, magnitude: float) -> float:
371 """The part of the lockup rise still on the outlet at this flow [Pa].
373 All of it at zero flow (lockup), none once the flow has paid it back at
374 the droop's own rate (``rise . rated / droop``). Without the ramp the
375 rise stepped the branch at zero flow -- lockup there, the droop line
376 just above -- and a tank sitting between the setpoint and lockup, which
377 is where one held at lockup rests, had no root. No rise, no creep: a
378 regulator that declares none is exactly what it was.
380 rise = self.
p.get(
"lockup_rise", 0.0)
381 droop = self.
p.get(
"flow_droop", 0.0)
382 rated = self.
p.get(
"rated_flow", 0.0)
383 if rise <= 0.0
or droop <= 0.0
or rated <= 0.0:
385 paid = magnitude * droop / rated
386 return rise - paid
if paid < rise
else 0.0
388 def total_dp(self, mdot: float, flow: FlowConditions) -> float:
389 """Signed, and **not** the base class's mirror of the forward loss.
391 :meth:`HydraulicComponent.total_dp` negates the loss for reverse flow,
392 which is right for anything symmetric: run a pipe backwards and the
393 pressure falls backwards by the same amount. A regulator is not
394 symmetric. Its forward "loss" is the whole difference between a 4500 psi
395 bottle and a 550 psi setpoint, so mirroring it puts a step of
396 ``2 (p_in - p_set)`` -- eight thousand psi -- across ``mdot = 0``.
398 The finite difference the solver takes across that step reports a slope
399 of order 1e16 Pa/(kg/s). One Newton iterate landing near zero flow on
400 the regulator is then enough to destroy the solve: the step it computes
401 is meaningless, the backtracking finds nothing better, and the run
402 stalls just above tolerance while the network quietly settles into
403 gas circulating backwards out of one tank and into the other. It cost
404 117 ticks out of 141 on a helium burn, and it presents as the tank
405 draining to atmosphere while the bottle stays full.
407 Forward, this is the base class. Backward, the poppet is on its seat:
408 continuous with the forward branch at zero and steeply resistant, so
409 Newton is pushed back toward non-negative flow rather than finding a
410 mirrored pressure source on the far side of a cliff.
415 return shut + REVERSE_STIFFNESS * mdot + self.
static_head(flow)
418 """Zero: what a shut regulator passes.
420 Consulted by the solver only when :meth:`is_choked` says the regulator
421 has closed, so the constant is not a claim that a regulator never flows.
425 def is_choked(self, dp_available: float, flow: FlowConditions) -> bool:
426 """Whether the regulator has shut because downstream is already high.
428 A regulator is a one-way device. Turn the control regulator down below
429 what the tank is already sitting at and the dome reg simply closes --
430 it cannot pull the tank back down, and nothing flows until the tank is
431 vented or drained past the new setting. That is what happens on the
432 stand, and it is what makes "turn the knob down and watch" a question
433 the model can answer.
435 Without this the branch equation asks for a pressure drop larger than
436 the network has, which has no solution and diverges rather than
437 reporting a shut valve.
442 required = flow.p_upstream - setpoint - self.
_creep(0.0)
451 return dp_available < required - 0.005 * setpoint
454 """Whether the regulator has run out of authority at this operating point.
456 True means the outlet is being set by the seat rather than by the
457 setpoint -- the regulator is wide open and no longer regulating. Worth
458 surfacing loudly: the pressure it reports is still a real number, and it
459 is no longer the number anybody designed for.
463 seat_dp = self.
_seat_dp(abs(mdot), flow)
466 def diagnostics(self, mdot: float, flow: FlowConditions) -> dict[str, float]:
471 "setpoint": commanded,
472 "dome_pressure": self.
signal(
473 flow,
"dome", self.
p.get(
"dome_pressure", 0.0)
475 "dome_bias": self.
p.get(
"dome_bias", 0.0),
476 "outlet_target": target,
477 "outlet_actual": flow.p_upstream - dp,
478 "droop_from_setpoint": (flow.p_upstream - dp) - commanded,
479 "inlet_differential": flow.p_upstream - (flow.p_upstream - dp),
480 "saturated": 1.0
if self.
is_saturated(mdot, flow)
else 0.0,
483 if self.
is_choked(flow.p_upstream - (flow.p_upstream - dp), flow)
491 """``|mdot| / rated_flow``: how far along the datasheet's droop line
492 this flow is (0 when the regulator has no droop scale)."""
493 rated = self.
p.get(
"rated_flow", 0.0)
494 return abs(mdot) / rated
if rated > 0.0
else 0.0
497 out: list[Violation] = []
498 rated_param = self.
instance.params.get(
"rated_flow")
499 if rated_param
is not None and rated_param.source.is_assumed:
504 f
"rated_flow is {rated_param.source.value}, not measured. The "
505 "droop is quoted at the rated flow and scales with it, so an "
506 "assumed one moves every regulated pressure; a flow past it "
507 "extrapolates the datasheet's line.",
511 for name
in (
"supply_coefficient",
"flow_droop"):
512 param = self.
instance.params.get(name)
513 if param
is not None and param.source.is_assumed:
518 f
"{name} is {param.source.value}, not measured. A "
519 "regulator's droop is the one number its datasheet "
520 "always gives; leaving it assumed throws away the only "
521 "characterisation that exists.",
525 droop = self.
p.get(
"flow_droop", 0.0)
526 rated = self.
p.get(
"rated_flow", 0.0)
527 if droop <= 0.0
or rated <= 0.0:
532 "flow droop is zero, so this regulator holds its setpoint "
533 "exactly at any flow. Between two pressure boundaries that "
534 "makes its branch flow mathematically indeterminate -- the "
535 "branch equation is satisfied for every mass flow and the "
536 "Jacobian row is identically zero. Steady solves against a "
537 "fixed downstream demand still work; a transient will not "
538 "converge. Give it the datasheet's droop-at-rated-flow, or "
539 "declare model='ideal' to say the perfect setpoint is "
544 if droop > 0.0
and rated <= 0.0:
549 "flow_droop is set but rated_flow is zero, so the droop "
550 "term has no scale and is silently doing nothing.",
553 if self.
instance.params.get(
"inlet_reference")
is not None and (
554 self.
p.get(
"inlet_reference", 0.0) > 0.0
560 "inlet_reference is not used: the supply-pressure effect is "
561 "measured from zero inlet (dome + bias - S x inlet), not from "
562 "a charge pressure. Drop it from the drawing.",
567 self.
p.get(
"dome_pressure", 0.0) <= 0.0
568 and self.
p.get(
"dome_bias", 0.0) > 0.0
574 "a dome bias is declared but no dome pressure, so the bias "
575 "does nothing. Either load the dome -- a `dome` signal, or "
576 "`dome_pressure` -- or drop the bias. A 50 psi offset that "
577 "silently is not applied is worse than not declaring one.",
584 """Limits broken at *this* operating point, unlike :meth:`check`.
586 Separate because a regulator can be configured perfectly and still be
587 outside its envelope on a given run, and a solver wants to know which.
589 out: list[Violation] = []
591 minimum = self.
p.get(
"min_inlet_differential", 0.0)
592 if minimum > 0.0
and dp < minimum:
596 "min_inlet_differential",
597 f
"only {dp / 6894.757:.1f} psi across the regulator, below "
598 f
"the {minimum / 6894.757:.1f} psi it needs to control. The "
599 "outlet is not being regulated at this point.",
602 droop = self.
p.get(
"flow_droop", 0.0)
604 if droop > 0.0
and multiple > 1.0:
611 f
"running at {multiple:.2f}x its rated flow "
612 f
"({abs(mdot):.4g} kg/s against "
613 f
"{self.p['rated_flow']:.4g} kg/s): the droop there, "
614 f
"{droop * multiple / 6894.757:.1f} psi, is the datasheet's "
615 "line extrapolated past where it was measured.",
624 f
"wide open at {abs(mdot):.4g} kg/s and still not holding "
625 "setpoint; the seat is the restriction. Outlet pressure "
626 "here is set by the line, not by the regulator.",
633 """Perfect setpoint, unlimited capacity. For sanity checks and first passes.
635 Kept as a *named model* rather than a regulator with zero coefficients so
636 that a report can distinguish "we chose not to model droop" from "nobody
637 filled in the droop". Those look identical in the numbers and are very
638 different in what they mean.
652 """Outlet against flow, read from a datasheet curve or a flow bench.
654 Supersedes the two-coefficient form when a real curve exists, which is the
655 same principle as every other ``measured`` model in this library: data in
656 front of a correlation, never blended with it.
660 curve = self.
instance.curves.get(
"outlet_mdot")
663 f
"{self.id}: model 'curve' needs an 'outlet_mdot' curve -- "
664 "outlet pressure against mass flow, off the datasheet."
666 target = float(curve(abs(mdot)))
680register_builder(
"regulator",
"ideal", IdealRegulator)
681register_builder(
"regulator",
"droop", Regulator)
682register_builder(
"regulator",
"curve", CurveRegulator)
683register_builder(
"regulator",
"measured", _measured_regulator)
float static_head(self, FlowConditions flow)
float pressure_drop(self, float mdot, FlowConditions flow)
bool is_choked(self, float dp_available, FlowConditions flow)
float signal(self, FlowConditions flow, str name, float default=1.0)
float outlet_setpoint(self, float mdot, FlowConditions flow)
float outlet_setpoint(self, float mdot, FlowConditions flow)
float pressure_drop(self, float mdot, FlowConditions flow)
bool is_saturated(self, float mdot, FlowConditions flow)
float|None pinned_flow(self, float dp_available, FlowConditions flow)
float _rated_multiple(self, float mdot)
float supply_effect(self, FlowConditions flow)
list[Violation] check(self)
dict[str, float] diagnostics(self, float mdot, FlowConditions flow)
float total_dp(self, float mdot, FlowConditions flow)
float|None flow_ceiling(self, FlowConditions flow)
float _creep(self, float magnitude)
list[Violation] envelope_violations(self, float mdot, FlowConditions flow)
float outlet_setpoint(self, float mdot, FlowConditions flow)
float|None seat_capacity(self, FlowConditions flow, float xT=XT_TYPICAL)
GasCv|None _gas_seat(self, FlowConditions flow)
dict[str, object] seat_model(self, float xT=XT_TYPICAL)
bool is_saturated(self, float mdot, FlowConditions flow)
float _seat_dp(self, float magnitude, FlowConditions flow)
float pressure_drop(self, float mdot, FlowConditions flow)
float lockup_pressure(self, FlowConditions|None flow=None)
float commanded_setpoint(self, FlowConditions flow)
bool is_choked(self, float dp_available, FlowConditions flow)
float _dynamic_head(float mdot, float bore, float rho)
HydraulicComponent _measured_regulator(object instance)