STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
regulator.py
Go to the documentation of this file.
1"""Pressure regulators, modelled on what a datasheet actually tells you.
2
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.
9
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.
16
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.
23
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
31the stand.
32
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
38will not.
39
40.. code-block:: text
41
42 p_set = p_dome + bias dome-loaded
43 p_set = setpoint hand-loaded, or a fixed bench setting
44
45What the datasheet does give you is two more coefficients, and they cover the
46two physically distinct effects:
47
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
52inlet, written
53
54.. code-block:: toml
55
56 supply_coefficient = { value = 17, unit = "psi/1000psi" }
57
58It is measured from **zero inlet**, not from a full bottle: the outlet is
59
60.. code-block:: text
61
62 p_out = p_dome + bias - S . p_in (p_in gauge)
63
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
71to guess at.
72
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
80is what gets typed in.
81
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
89number.
90
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.
94
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
101
102.. code-block:: text
103
104 (p_up - p_dn) - dp(mdot) = 0 with dp(mdot) = p_up - p_set
105
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.
110
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.
118"""
119
120from __future__ import annotations
121
122import math
123
124from fluids.fittings import Cv_to_K
125
126from feedtwin.comps.base import (
127 FlowConditions,
128 HydraulicComponent,
129 Violation,
130 register_builder,
131)
132from feedtwin.comps.iec_gas import XT_TYPICAL, GasCv, iec_gamma, is_gas
133from feedtwin.model.spec import SpecError
134
135#: Resistance a regulator shows to reverse flow [Pa/(kg/s)].
136#:
137#: Large enough that reverse flow is negligible against the forward flows on a
138#: stand -- 1e-6 kg/s backwards costs 10 kPa -- and small enough to keep the
139#: Jacobian conditioned next to pipe slopes of 1e5 to 1e7. A finite slope
140#: rather than a hard block because Newton needs something to descend; a true
141#: one-way constraint is a complementarity problem and this network solve is
142#: not one.
143REVERSE_STIFFNESS = 1.0e10
144
145#: Where the supply-pressure effect is measured from [Pa abs]: zero gauge
146#: inlet. ``p_out = p_dome + bias - S (p_in - this)``.
147SUPPLY_ZERO = 101325.0
148
149#: Signal that turns on the compressible seat, its value the xT a regulator
150#: that declares none takes. A session sends it while
151#: ``Setup.regulator_compressible_seat`` is on (the default since 2026-10-08);
152#: absent or zero, the seat is the incompressible Cv law, as it always was.
153SEAT_XT_SIGNAL = "regulator_seat_xT"
154
155SEAT_MODEL_NAME = "regulator seat, compressible (IEC 60534-2-1)"
156SEAT_MODEL_SOURCE = (
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"
161)
162
163
164def _dynamic_head(mdot: float, bore: float, rho: float) -> float:
165 import math
166
167 if bore <= 0.0 or rho <= 0.0:
168 return 0.0
169 area = math.pi * bore * bore / 4.0
170 v = mdot / (rho * area)
171 return 0.5 * rho * v * v
172
173
175 """A pressure regulator holding an outlet setpoint.
176
177 The outlet it defends is
178
179 .. code-block:: text
180
181 p_set - S . p_in supply-pressure effect, p_in gauge
182 - D . (|mdot| / mdot_rated) flow droop
183
184 clamped by two physical limits: it can never raise pressure, and it can
185 never pass more than a wide-open seat allows.
186
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.
191 """
192
193 def commanded_setpoint(self, flow: FlowConditions) -> float:
194 """What this regulator has been told to hold [Pa], before any droop.
195
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.
200 """
201 bias = self.p.get("dome_bias", 0.0)
202 dome = self.signal(flow, "dome", self.p.get("dome_pressure", 0.0))
203 if dome > 0.0:
204 return dome + bias
205 return self.p["setpoint"]
206
207 def supply_effect(self, flow: FlowConditions) -> float:
208 """The supply-pressure term [Pa]: ``-S p_in``, the inlet in gauge.
209
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
212 bottle falls.
213 """
214 supply = self.p.get("supply_coefficient", 0.0)
215 return supply * (SUPPLY_ZERO - flow.p_upstream)
216
217 def outlet_setpoint(self, mdot: float, flow: FlowConditions) -> float:
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)
221
222 target = self.commanded_setpoint(flow) + self.supply_effect(flow)
223 if rated > 0.0:
224 target -= droop * (abs(mdot) / rated)
225 return target
226
227 def lockup_pressure(self, flow: FlowConditions | None = None) -> float:
228 """Outlet at zero flow [Pa]: setpoint plus the seat's overshoot.
229
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.
234
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
240 step had no root.)
241 """
242 if flow is None:
243 return self.p["setpoint"] + self.p.get("lockup_rise", 0.0)
244 return self.outlet_setpoint(0.0, flow) + self.p.get("lockup_rise", 0.0)
245
246 # ------------------------------------------------------------- the seat
247
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.
250
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.
253 """
254 xT_signal = self.signal(flow, SEAT_XT_SIGNAL, 0.0)
255 if xT_signal <= 0.0 or not is_gas(flow):
256 return None
257 xT = float(self.p.get("xT", xT_signal))
258 return GasCv(float(self.p["Cv"]), float(self.p["bore"]), xT)
259
260 def _seat_dp(self, magnitude: float, flow: FlowConditions) -> float:
261 """Drop the wide-open seat takes at ``magnitude`` [Pa]."""
262 seat = self._gas_seat(flow)
263 if seat is None:
264 # Incompressible: K rho v^2 / 2 at the inlet density. No expansion
265 # factor and no choke -- see AUDIT.md 5.3; 25 % high on GN2 near
266 # burnout. Off is this, exactly.
267 return Cv_to_K(self.p["Cv"], self.p["bore"]) * _dynamic_head(
268 magnitude, self.p["bore"], flow.rho
269 )
270 return seat.drop(magnitude, flow.p_upstream, flow.rho, iec_gamma(flow))
271
273 self, flow: FlowConditions, xT: float = XT_TYPICAL
274 ) -> float | None:
275 """Choked gas flow through the wide-open seat [kg/s], IEC 60534-2-1.
276
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.
280 """
281 if not is_gas(flow):
282 return None
283 seat = GasCv(
284 float(self.p["Cv"]), float(self.p["bore"]), float(self.p.get("xT", xT))
285 )
286 return seat.capacity(flow.p_upstream, flow.rho, iec_gamma(flow))
287
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.
290
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).
295 """
296 seat = self._gas_seat(flow)
297 if seat is None:
298 return None
299 gamma = iec_gamma(flow)
300 p_in = flow.p_upstream
301 capacity = seat.capacity(p_in, flow.rho, gamma)
302 if capacity <= 0.0:
303 return None
304 wide_open = p_in - self.outlet_setpoint(capacity, flow)
305 if dp_available >= max(seat.x_critical(gamma) * p_in, wide_open):
306 return capacity
307 return None
308
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")
312 cv = self.instance.params.get("Cv")
313 inputs: dict[str, object] = {
314 "xT": (
315 {
316 "value": param.value,
317 "unit": param.unit,
318 "provenance": f"{param.source.value}: {param.reference}",
319 }
320 if param is not None
321 else {
322 "value": xT,
323 "unit": "-",
324 "provenance": "assumed: IEC 60534 typical",
325 }
326 ),
327 }
328 if cv is not None:
329 inputs["Cv"] = {
330 "value": cv.value,
331 "unit": cv.unit,
332 "provenance": f"{cv.source.value}: {cv.reference}",
333 }
334 return {
335 "name": SEAT_MODEL_NAME,
336 "source": SEAT_MODEL_SOURCE,
337 "assumptions": [
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",
344 ],
345 "inputs": inputs,
346 }
347
348 # ---------------------------------------------------------- the branch
349
350 def pressure_drop(self, mdot: float, flow: FlowConditions) -> float:
351 p_in = flow.p_upstream
352 magnitude = abs(mdot)
353
354 if magnitude == 0.0:
355 # Shut. Downstream sits at lockup, unless the inlet is below it.
356 return max(p_in - self.lockup_pressure(flow), 0.0)
357
358 # The most the seat can pass wide open sets the floor on the drop. Below
359 # that the regulator is saturated: it is a hole, not a regulator, and
360 # the outlet is whatever the line gives it.
361 seat_dp = self._seat_dp(magnitude, flow)
362
363 target = self.outlet_setpoint(mdot, flow) + self._creep(magnitude)
364 required = p_in - target
365
366 # A regulator cannot raise pressure and cannot drop less than its own
367 # seat does. Whichever binds, wins.
368 return max(required, seat_dp, 0.0)
369
370 def _creep(self, magnitude: float) -> float:
371 """The part of the lockup rise still on the outlet at this flow [Pa].
372
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.
379 """
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:
384 return 0.0
385 paid = magnitude * droop / rated
386 return rise - paid if paid < rise else 0.0
387
388 def total_dp(self, mdot: float, flow: FlowConditions) -> float:
389 """Signed, and **not** the base class's mirror of the forward loss.
390
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``.
397
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.
406
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.
411 """
412 if mdot >= 0.0:
413 return self.pressure_drop(mdot, flow) + self.static_head(flow)
414 shut = max(flow.p_upstream - self.lockup_pressure(flow), 0.0)
415 return shut + REVERSE_STIFFNESS * mdot + self.static_head(flow)
416
417 def flow_ceiling(self, flow: FlowConditions) -> float | None:
418 """Zero: what a shut regulator passes.
419
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.
422 """
423 return 0.0
424
425 def is_choked(self, dp_available: float, flow: FlowConditions) -> bool:
426 """Whether the regulator has shut because downstream is already high.
427
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.
434
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.
438 """
439 setpoint = self.outlet_setpoint(0.0, flow)
440 # Shut above *lockup*, the outlet at zero flow, which carries the rise
441 # (the creep ramps it in; see _creep). No rise, the setpoint as before.
442 required = flow.p_upstream - setpoint - self._creep(0.0)
443 # The band is a *fraction of the setpoint*, not a fixed pascal. With a
444 # 1 Pa threshold a regulator whose downstream is sitting on its
445 # setpoint -- which is exactly where a pressurised tank sits -- flips
446 # between "shut" and "regulating" from one step to the next. Each flip
447 # swaps the branch equation for a different one, and the network solve
448 # is asked to converge on a discontinuity. Half a percent is small
449 # against any real regulator's droop and wide enough that the mode is
450 # decided rather than argued over.
451 return dp_available < required - 0.005 * setpoint
452
453 def is_saturated(self, mdot: float, flow: FlowConditions) -> bool:
454 """Whether the regulator has run out of authority at this operating point.
455
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.
460 """
461 if mdot == 0.0:
462 return False
463 seat_dp = self._seat_dp(abs(mdot), flow)
464 return seat_dp > (flow.p_upstream - self.outlet_setpoint(mdot, flow))
465
466 def diagnostics(self, mdot: float, flow: FlowConditions) -> dict[str, float]:
467 dp = self.pressure_drop(mdot, flow)
468 target = self.outlet_setpoint(mdot, flow)
469 commanded = self.commanded_setpoint(flow)
470 return {
471 "setpoint": commanded,
472 "dome_pressure": self.signal(
473 flow, "dome", self.p.get("dome_pressure", 0.0)
474 ),
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,
481 "shut": (
482 1.0
483 if self.is_choked(flow.p_upstream - (flow.p_upstream - dp), flow)
484 else 0.0
485 ),
486 "dp": dp,
487 "rated_multiple": self._rated_multiple(mdot),
488 }
489
490 def _rated_multiple(self, mdot: float) -> float:
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
495
496 def check(self) -> list[Violation]:
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:
500 out.append(
501 Violation(
502 self.id,
503 "rated_flow",
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.",
508 severity="warning",
509 )
510 )
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:
514 out.append(
515 Violation(
516 self.id,
517 name,
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.",
522 severity="warning",
523 )
524 )
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:
528 out.append(
529 Violation(
530 self.id,
531 "flow_droop",
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 "
540 "deliberate.",
541 severity="warning",
542 )
543 )
544 if droop > 0.0 and rated <= 0.0:
545 out.append(
546 Violation(
547 self.id,
548 "rated_flow",
549 "flow_droop is set but rated_flow is zero, so the droop "
550 "term has no scale and is silently doing nothing.",
551 )
552 )
553 if self.instance.params.get("inlet_reference") is not None and (
554 self.p.get("inlet_reference", 0.0) > 0.0
555 ):
556 out.append(
557 Violation(
558 self.id,
559 "inlet_reference",
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.",
563 severity="warning",
564 )
565 )
566 if (
567 self.p.get("dome_pressure", 0.0) <= 0.0
568 and self.p.get("dome_bias", 0.0) > 0.0
569 ):
570 out.append(
571 Violation(
572 self.id,
573 "dome_bias",
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.",
578 severity="warning",
579 )
580 )
581 return out
582
583 def envelope_violations(self, mdot: float, flow: FlowConditions) -> list[Violation]:
584 """Limits broken at *this* operating point, unlike :meth:`check`.
585
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.
588 """
589 out: list[Violation] = []
590 dp = self.pressure_drop(mdot, flow)
591 minimum = self.p.get("min_inlet_differential", 0.0)
592 if minimum > 0.0 and dp < minimum:
593 out.append(
594 Violation(
595 self.id,
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.",
600 )
601 )
602 droop = self.p.get("flow_droop", 0.0)
603 multiple = self._rated_multiple(mdot)
604 if droop > 0.0 and multiple > 1.0:
605 # Flagged, never clamped: the physics stays the linear law, the run
606 # says it is past what the datasheet characterised.
607 out.append(
608 Violation(
609 self.id,
610 "rated_flow",
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.",
616 severity="warning",
617 )
618 )
619 if self.is_saturated(mdot, flow):
620 out.append(
621 Violation(
622 self.id,
623 "capacity",
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.",
627 )
628 )
629 return out
630
631
633 """Perfect setpoint, unlimited capacity. For sanity checks and first passes.
634
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.
639 """
640
641 def outlet_setpoint(self, mdot: float, flow: FlowConditions) -> float:
642 return self.commanded_setpoint(flow)
643
644 def pressure_drop(self, mdot: float, flow: FlowConditions) -> float:
645 return max(flow.p_upstream - self.commanded_setpoint(flow), 0.0)
646
647 def is_saturated(self, mdot: float, flow: FlowConditions) -> bool:
648 return False
649
650
652 """Outlet against flow, read from a datasheet curve or a flow bench.
653
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.
657 """
658
659 def outlet_setpoint(self, mdot: float, flow: FlowConditions) -> float:
660 curve = self.instance.curves.get("outlet_mdot")
661 if curve is None:
662 raise SpecError(
663 f"{self.id}: model 'curve' needs an 'outlet_mdot' curve -- "
664 "outlet pressure against mass flow, off the datasheet."
665 )
666 target = float(curve(abs(mdot)))
667 # A dome-loaded unit's curve is measured at one dome setting; moving the
668 # dome shifts the whole curve by the same amount.
669 target += self.commanded_setpoint(flow) - self.p["setpoint"]
670 # The supply term still applies, from zero inlet like the droop form's.
671 return target + self.supply_effect(flow)
672
673
674def _measured_regulator(instance: object) -> HydraulicComponent:
675 from feedtwin.comps.elements import MeasuredElement
676
677 return MeasuredElement(instance) # type: ignore[arg-type]
678
679
680register_builder("regulator", "ideal", IdealRegulator)
681register_builder("regulator", "droop", Regulator)
682register_builder("regulator", "curve", CurveRegulator)
683register_builder("regulator", "measured", _measured_regulator)
#define max(a, b)
Definition Arduino.h:43
float static_head(self, FlowConditions flow)
Definition base.py:203
float pressure_drop(self, float mdot, FlowConditions flow)
Definition base.py:190
bool is_choked(self, float dp_available, FlowConditions flow)
Definition base.py:304
float signal(self, FlowConditions flow, str name, float default=1.0)
Definition base.py:229
float outlet_setpoint(self, float mdot, FlowConditions flow)
Definition regulator.py:659
float outlet_setpoint(self, float mdot, FlowConditions flow)
Definition regulator.py:641
float pressure_drop(self, float mdot, FlowConditions flow)
Definition regulator.py:644
bool is_saturated(self, float mdot, FlowConditions flow)
Definition regulator.py:647
float|None pinned_flow(self, float dp_available, FlowConditions flow)
Definition regulator.py:288
float _rated_multiple(self, float mdot)
Definition regulator.py:490
float supply_effect(self, FlowConditions flow)
Definition regulator.py:207
list[Violation] check(self)
Definition regulator.py:496
dict[str, float] diagnostics(self, float mdot, FlowConditions flow)
Definition regulator.py:466
float total_dp(self, float mdot, FlowConditions flow)
Definition regulator.py:388
float|None flow_ceiling(self, FlowConditions flow)
Definition regulator.py:417
float _creep(self, float magnitude)
Definition regulator.py:370
list[Violation] envelope_violations(self, float mdot, FlowConditions flow)
Definition regulator.py:583
float outlet_setpoint(self, float mdot, FlowConditions flow)
Definition regulator.py:217
float|None seat_capacity(self, FlowConditions flow, float xT=XT_TYPICAL)
Definition regulator.py:274
GasCv|None _gas_seat(self, FlowConditions flow)
Definition regulator.py:248
dict[str, object] seat_model(self, float xT=XT_TYPICAL)
Definition regulator.py:309
bool is_saturated(self, float mdot, FlowConditions flow)
Definition regulator.py:453
float _seat_dp(self, float magnitude, FlowConditions flow)
Definition regulator.py:260
float pressure_drop(self, float mdot, FlowConditions flow)
Definition regulator.py:350
float lockup_pressure(self, FlowConditions|None flow=None)
Definition regulator.py:227
float commanded_setpoint(self, FlowConditions flow)
Definition regulator.py:193
bool is_choked(self, float dp_available, FlowConditions flow)
Definition regulator.py:425
float _dynamic_head(float mdot, float bore, float rho)
Definition regulator.py:164
HydraulicComponent _measured_regulator(object instance)
Definition regulator.py:674