1"""The regulator: spec-sheet droop, and the two effects it is made of.
3Everything here is checked against numbers a datasheet actually prints. The
4model has no geometry in it on purpose -- see
5:mod:`feedtwin.comps.regulator` for why a diaphragm force balance was
6abandoned rather than deferred out of laziness.
8The team's dome regulator is the worked example throughout: the Aqua
9Environment 1092-50, 500 psi setpoint, 17 psi of outlet rise per 1000 psi of
10inlet decay -- declared in the datasheet's own units, not as a bare ratio.
13from __future__
import annotations
18from fluids.fittings
import Cv_to_K
28PSI = get_unit(
"psi").factor
31GAS =
FlowConditions(rho=39.0, mu=1.78e-5, p_upstream=4500 * PSI, temperature=293.15)
33M = Provenance.MANUFACTURER
36def make(model: str =
"droop", **overrides: object) -> Regulator:
38 "setpoint":
Param(500.0,
"psi", Provenance.MANUFACTURER,
"PR-01 datasheet"),
39 "supply_coefficient":
Param(
42 Provenance.MANUFACTURER,
43 "Aqua 1092-50: 17 psi per 1000 psi inlet",
45 "Cv":
Param(0.8,
"Cv", Provenance.MANUFACTURER,
"datasheet"),
46 "bore":
Param(7.75,
"mm", Provenance.MANUFACTURER,
"3/8 seat"),
48 for key, value
in overrides.items():
50 built = build_component(
51 ComponentInstance.build(
"PR-01",
"regulator", params, model=model)
53 assert isinstance(built, Regulator)
57def outlet(reg: Regulator, mdot: float, p_in: float) -> float:
58 flow =
FlowConditions(rho=39.0, mu=1.78e-5, p_upstream=p_in, temperature=293.15)
59 return p_in - reg.pressure_drop(mdot, flow)
63 """An inlet the way the stand reads it [psig] -> absolute [Pa]."""
64 return psig * PSI + SUPPLY_ZERO
71 """dome + bias - S x inlet, the inlet in gauge: a 1092-50 loaded to 500 with
72 4000 psi of COPV behind it holds 500 + 50 - 4 x 14.7 = 491.2 psi. Not the
73 setpoint at some charge pressure and the effect only beyond it (the team,
76 supply_coefficient=
Param(14.7,
"psi/1000psi", M,
"LE4 drawing"),
77 dome_pressure=
Param(500.0,
"psi", M,
"dome"),
78 dome_bias=
Param(50.0,
"psi", M,
"1092-50"),
80 assert outlet(reg, 0.01,
gauge(4000.0)) == pytest.approx(491.2 * PSI, rel=1e-9)
83 rho=1.2, mu=1.78e-5, p_upstream=
gauge(0.0), temperature=293.15
85 assert reg.outlet_setpoint(0.0, empty) == pytest.approx(550.0 * PSI, rel=1e-9)
89 """The sign people guess wrong: outlet goes UP as inlet goes DOWN."""
91 assert outlet(reg, 0.01, 1500 * PSI) >
outlet(reg, 0.01, 4500 * PSI)
95 """17 psi per 1000 psi, checked as the quoted rate and over a full decay."""
97 per_1000 =
outlet(reg, 0.01, 3500 * PSI) -
outlet(reg, 0.01, 4500 * PSI)
98 assert per_1000 / PSI == pytest.approx(17.0, rel=1e-6)
102 assert full / PSI == pytest.approx(500.0 - 4.5 * 17.0, rel=1e-9)
103 assert (empty - full) / PSI == pytest.approx(51.0, rel=1e-6)
107 """psi/1000psi, psi/100psi and psi/psi are the same number, and `-` is not.
109 The whole point of giving this parameter its own dimension: a bare ratio
110 reads the same whether the author meant 17 per thousand or 17 per hundred,
111 and the solver cannot tell. Spelling the unit out makes the three agree and
112 makes the fourth an error at load.
114 at =
lambda p:
outlet(p, 0.01, 1500 * PSI)
115 a =
at(
make(supply_coefficient=
Param(17.0,
"psi/1000psi", M,
"")))
116 b =
at(
make(supply_coefficient=
Param(1.7,
"psi/100psi", M,
"")))
117 c =
at(
make(supply_coefficient=
Param(0.017,
"psi/psi", M,
"")))
118 assert a == pytest.approx(b) == pytest.approx(c)
120 with pytest.raises(SpecError, match=
"dimensionless"):
121 make(supply_coefficient=
Param(0.017,
"-", M,
"bare ratio"))
125 """An old drawing's ``inlet_reference`` still loads, moves nothing, and
126 check() says to drop it."""
127 drawn =
make(inlet_reference=
Param(4500.0,
"psi", M,
"COPV service pressure"))
130 assert outlet(drawn, 0.01, p_in) ==
outlet(plain, 0.01, p_in)
131 assert "inlet_reference" in [v.limit
for v
in drawn.check()]
132 assert "inlet_reference" not in [v.limit
for v
in plain.check()]
139 """Cv does not produce it. This is the correction Rev D of the plan needed.
141 At a tenth of rated flow the wide-open seat loses a couple of psi while a
142 real unit has already drooped by ten. If Cv alone produced droop, these two
143 regulators -- identical but for ``flow_droop`` -- would agree.
147 flow_droop=
Param(10.0,
"psi", Provenance.MANUFACTURER,
"droop at rated"),
148 rated_flow=
Param(0.10,
"kg/s", Provenance.MANUFACTURER,
"rated"),
150 p_without =
outlet(without, 0.01, 4500 * PSI)
151 p_with =
outlet(with_droop, 0.01, 4500 * PSI)
152 assert p_without - p_with == pytest.approx(1.0 * PSI, rel=1e-6)
157 flow_droop=
Param(10.0,
"psi", Provenance.MANUFACTURER,
"droop"),
158 rated_flow=
Param(0.10,
"kg/s", Provenance.MANUFACTURER,
"rated"),
162 base =
outlet(reg, 0.0, 4500 * PSI)
163 for fraction
in (0.25, 0.5, 1.0):
164 sag = base -
outlet(reg, fraction * 0.10, 4500 * PSI)
165 assert sag / PSI == pytest.approx(10.0 * fraction, rel=1e-3)
169 reg =
make(flow_droop=
Param(10.0,
"psi", Provenance.MANUFACTURER,
"droop"))
170 assert "rated_flow" in [v.limit
for v
in reg.check()]
174 """Inlet decay lifts the outlet; flow pulls it down. Both, at once."""
176 flow_droop=
Param(51.0,
"psi", Provenance.MANUFACTURER,
"droop at rated"),
177 rated_flow=
Param(0.10,
"kg/s", Provenance.MANUFACTURER,
"rated"),
180 assert outlet(reg, 0.10,
gauge(1500.0)) / PSI == pytest.approx(423.5, rel=1e-3)
182 assert outlet(reg, 0.10,
gauge(1500.0)) == pytest.approx(
191 """Inlet below setpoint: the outlet follows the inlet down.
193 It does not track the inlet *exactly*, and that is right rather than a
194 rounding error -- a wide-open regulator is still a restriction, so the
195 outlet sits one seat-loss below the inlet. What must never happen is an
196 outlet above the inlet, which is what a naive ``p_in - (p_in - setpoint)``
197 would produce the moment the bottle falls below setpoint.
201 dp = reg.pressure_drop(0.01, flow)
203 assert outlet(reg, 0.01, 300 * PSI) < 300 * PSI
207 Cv_to_K(reg.p[
"Cv"], reg.p[
"bore"])
210 * (0.01 / (39.0 * math.pi * reg.p[
"bore"] ** 2 / 4.0)) ** 2
212 assert dp == pytest.approx(seat, rel=1e-9)
216 """What a downstream relief valve actually sees between firings."""
217 reg =
make(lockup_rise=
Param(25.0,
"psi", Provenance.MANUFACTURER,
"lockup"))
219 assert outlet(reg, 0.0,
gauge(4500.0)) / PSI == pytest.approx(448.5, rel=1e-9)
222 assert outlet(reg, 0.0,
gauge(4500.0)) > reg.outlet_setpoint(0.0, flow)
226 """Past its capacity a regulator is a hole, and should say so."""
229 assert not reg.is_saturated(0.01, small)
230 assert reg.is_saturated(2.0, small)
232 violations = reg.envelope_violations(2.0, small)
233 assert any(v.limit ==
"capacity" for v
in violations)
240 dp = reg.pressure_drop(0.5, flow)
241 assert dp > 600 * PSI - 500 * PSI
242 assert reg.is_saturated(0.5, flow)
246 """A datasheet droop is quoted at its rated flow. LE4's regulator ran at
247 2.7-3.2x its (default) 0.05 kg/s for the whole burn, extrapolating 54-64 psi
248 of droop, and nothing said so. The physics stays linear; the envelope says
249 how far past the characterisation it is."""
251 flow_droop=
Param(20.0,
"psi", M,
"droop at rated"),
252 rated_flow=
Param(0.05,
"kg/s", M,
"rated"),
255 assert not any(v.limit ==
"rated_flow" for v
in reg.envelope_violations(0.05, flow))
256 past = [v
for v
in reg.envelope_violations(0.1355, flow)
if v.limit ==
"rated_flow"]
257 assert past
and past[0].severity ==
"warning"
258 assert "2.71x" in past[0].detail
and "54.2 psi" in past[0].detail
259 assert reg.diagnostics(0.1355, flow)[
"rated_multiple"] == pytest.approx(2.71)
261 at_rated = reg.outlet_setpoint(0.05, flow)
262 assert at_rated - reg.outlet_setpoint(0.1355, flow) == pytest.approx(
268 """The droop scales with rated_flow; a fallback one is as unmeasured as a
271 flow_droop=
Param(20.0,
"psi", M,
"droop at rated"),
272 rated_flow=
Param(0.05,
"kg/s", Provenance.DEFAULT,
"fallback"),
274 warnings = [v
for v
in reg.check()
if v.limit ==
"rated_flow"]
275 assert warnings
and warnings[0].severity ==
"warning"
277 flow_droop=
Param(20.0,
"psi", M,
"droop at rated"),
278 rated_flow=
Param(0.05,
"kg/s", M,
"rated"),
280 assert not [v
for v
in measured.check()
if v.limit ==
"rated_flow"]
285 min_inlet_differential=
Param(50.0,
"psi", Provenance.MANUFACTURER,
"dropout")
287 ok = reg.envelope_violations(0.01,
FlowConditions(39.0, 1.78e-5, 4500 * PSI))
288 assert not any(v.limit ==
"min_inlet_differential" for v
in ok)
290 starved = reg.envelope_violations(0.01,
FlowConditions(39.0, 1.78e-5, 520 * PSI))
291 assert any(v.limit ==
"min_inlet_differential" for v
in starved)
295 """An unstated coefficient must not look like a perfect regulator."""
297 supply_coefficient=
Param(0.0,
"psi/psi", Provenance.DEFAULT,
"not filled in"),
299 warnings = [v
for v
in reg.check()
if v.limit ==
"supply_coefficient"]
300 assert warnings
and warnings[0].severity ==
"warning"
307 """Distinguishable from a droop model with zero coefficients, on purpose."""
308 reg =
make(model=
"ideal")
309 assert isinstance(reg, IdealRegulator)
310 assert outlet(reg, 0.01, 4500 * PSI) == pytest.approx(500 * PSI)
311 assert outlet(reg, 5.00, 1000 * PSI) == pytest.approx(500 * PSI)
312 assert not reg.is_saturated(5.0, GAS)
316 flat =
make(supply_coefficient=
Param(0.0,
"psi/psi", M,
"none"))
317 assert outlet(flat, 0.01, 4500 * PSI) == pytest.approx(
318 outlet(
make(model=
"ideal"), 0.01, 4500 * PSI), rel=1e-9
325 assert out[
"droop_from_setpoint"] / PSI == pytest.approx(-25.5, abs=0.1)
326 assert out[
"outlet_actual"] == pytest.approx(out[
"outlet_target"], rel=1e-9)
327 assert out[
"saturated"] == 0.0
335 dome_pressure=
Param(450.0,
"psi", M,
"control regulator setting"),
336 dome_bias=
Param(50.0,
"psi", M,
"Aqua Environment 1092-50, +50 psi"),
342 """The 1092-50 delivers 50 psi above whatever its dome is loaded to."""
344 assert reg.commanded_setpoint(GAS) / PSI == pytest.approx(500.0, rel=1e-9)
347 assert outlet(reg, 0.0001,
gauge(4500.0)) / PSI == pytest.approx(423.5, abs=0.1)
351 """Two regulators, same dome, different bias. They must differ by the bias.
353 Folding the bias into the dome setting would make every scenario responsible
354 for remembering to add 50 psi, and one day one of them would not.
357 unbiased =
make(dome_pressure=
Param(450.0,
"psi", M,
"same dome"))
358 difference = biased.commanded_setpoint(GAS) - unbiased.commanded_setpoint(GAS)
359 assert difference / PSI == pytest.approx(50.0, rel=1e-9)
363 """The dome is a signal, because on the stand it is a hand on a knob."""
366 def at_dome(psi_value: float) -> float:
370 p_upstream=4500 * PSI,
371 signals={
"PR-01.dome": psi_value * PSI},
373 return reg.commanded_setpoint(flow) / PSI
375 assert at_dome(400.0) == pytest.approx(450.0, rel=1e-9)
376 assert at_dome(520.0) == pytest.approx(570.0, rel=1e-9)
378 assert at_dome(400.0) < at_dome(450.0) < at_dome(520.0)
386 p_upstream=4500 * PSI,
387 signals={
"PR-01.dome": 300.0 * PSI},
389 assert reg.commanded_setpoint(flow) / PSI == pytest.approx(350.0, rel=1e-9)
393 """Both droop terms are relative to what the regulator was *told* to hold."""
395 flow_droop=
Param(20.0,
"psi", M,
"at rated"),
396 rated_flow=
Param(0.05,
"kg/s", M,
"rated"),
401 p_upstream=
gauge(4500.0),
402 signals={
"PR-01.dome": 400.0 * PSI},
404 commanded = reg.commanded_setpoint(flow) / PSI
405 assert commanded == pytest.approx(450.0)
408 dp = reg.pressure_drop(0.025, flow)
409 held = (
gauge(4500.0) - dp) / PSI
410 assert held == pytest.approx(commanded - 76.5 - 10.0, abs=0.1)
414 """What a downstream relief sees between firings moves with the knob."""
419 p_upstream=
gauge(4500.0),
420 signals={
"PR-01.dome": 400.0 * PSI},
422 assert reg.lockup_pressure(flow) / PSI == pytest.approx(475.0 - 76.5, rel=1e-9)
429 """Outlet just backwards, at, and just forwards of zero flow [psi]."""
430 flow =
FlowConditions(rho=39.0, mu=1.78e-5, p_upstream=p_in, temperature=293.15)
432 (p_in - reg.total_dp(m, flow)) / PSI
for m
in (-1.0e-12, 0.0, 1.0e-12)
437 """The supply effect is the inlet pushing on the poppet; it does not stop when the
438 flow does. Leaving it out of lockup put a step of the whole supply term in the
439 branch at zero flow -- 76 psi with a full bottle, now that it is measured from
440 zero -- and a tank inside that step has no root.
443 for p_in, expected
in ((2000.0, 466.0), (4500.0, 423.5)):
445 assert zero == pytest.approx(expected, abs=1e-6)
447 assert back == pytest.approx(zero, abs=1e-3)
448 assert ahead == pytest.approx(zero, abs=1e-3)
452 reg =
make(lockup_rise=
Param(25.0,
"psi", M,
"seat creep"))
454 assert zero == pytest.approx(466.0 + 25.0, abs=1e-6)
458 """A zero coefficient or the ideal model: nothing comes off for the inlet."""
459 zero =
Param(0.0,
"psi/psi", M,
"none")
460 for reg
in (
make(supply_coefficient=zero),
make(
"ideal")):
461 for p_in
in (900.0, 2000.0, 4500.0):
466 """A 50 psi offset that silently is not applied is worse than none."""
467 reg =
make(dome_bias=
Param(50.0,
"psi", M,
"1092-50"))
468 assert "dome_bias" in [v.limit
for v
in reg.check()]
474 dome_pressure=
Param(450.0,
"psi", M,
"dome"),
475 dome_bias=
Param(50.0,
"psi", M,
"bias"),
477 assert outlet(reg, 0.01, 4500 * PSI) / PSI == pytest.approx(500.0, rel=1e-9)
481 """With zero flow droop the branch equation ``(p_up - p_dn) - dp(mdot) = 0``
482 is satisfied for *every* mass flow, so its Jacobian row is identically zero
483 and the flow is genuinely undetermined. ``check()`` must say so before
484 anyone spends an afternoon on a solve that will not converge. (Found by the
485 old transient integrator, and kept when it went.)
488 "setpoint":
Param(500.0,
"psi", M,
"x"),
489 "supply_coefficient":
Param(17.0,
"psi/1000psi", M,
"x"),
490 "Cv":
Param(0.8,
"Cv", M,
"x"),
491 "bore":
Param(7.75,
"mm", M,
"x"),
493 flat = build_component(
494 ComponentInstance.build(
"PR-01",
"regulator", params, model=
"droop")
496 flow =
FlowConditions(rho=39.0, mu=1.78e-5, p_upstream=4500 * PSI)
500 flat.pressure_drop(0.01 + h, flow) - flat.pressure_drop(0.01 - h, flow)
503 assert "flow_droop" in [v.limit
for v
in flat.check()]
505 drooping = build_component(
506 ComponentInstance.build(
511 "flow_droop":
Param(20.0,
"psi", M,
"at rated"),
512 "rated_flow":
Param(0.05,
"kg/s", M,
"rated"),
518 drooping.pressure_drop(0.01 + h, flow) - drooping.pressure_drop(0.01 - h, flow)
520 assert slope == pytest.approx(20.0 * PSI / 0.05, rel=1e-3)
521 assert "flow_droop" not in [v.limit
for v
in drooping.check()]
T & at(const key_type &key)
None test_a_signal_beats_the_configured_dome()
None test_an_assumed_rated_flow_is_flagged()
None test_the_two_effects_oppose_each_other()
None test_lockup_sets_the_no_flow_outlet()
None test_the_control_regulator_can_be_turned_during_a_run()
None test_a_regulator_never_raises_pressure()
None test_an_ideal_regulator_still_honours_the_dome()
None test_lockup_follows_the_dome()
None test_droop_without_a_rated_flow_is_flagged()
None test_saturated_outlet_is_set_by_the_seat()
None test_diagnostics_report_the_droop_from_setpoint()
float outlet(Regulator reg, float mdot, float p_in)
None test_assumed_droop_is_flagged_as_a_missing_measurement()
None test_a_perfect_regulator_makes_its_branch_indeterminate()
None test_the_supply_effect_is_measured_from_zero_inlet()
None test_a_dome_regulator_outlet_is_dome_plus_bias()
None test_flow_droop_is_linear_in_rated_fraction()
None test_the_datasheet_rate_can_be_written_three_ways()
None test_saturation_is_reported_not_hidden()
None test_with_no_supply_effect_lockup_is_the_setpoint()
None test_supply_effect_matches_the_datasheet_number()
None test_lockup_carries_the_supply_effect_so_the_outlet_is_continuous()
None test_a_bias_with_no_dome_is_flagged()
None test_dropout_below_minimum_differential()
Regulator make(str model="droop", **object overrides)
None test_flow_droop_is_a_separate_coefficient()
None test_droop_and_ideal_agree_with_no_supply_effect()
None test_droop_rides_on_the_dome_setting_not_on_the_configured_setpoint()
None test_running_past_the_rated_flow_is_flagged_not_clamped()
None test_a_drawn_inlet_reference_is_not_used_and_is_flagged()
tuple[float, float, float] outlet_either_side(Regulator reg, float p_in)
Regulator dome_loaded(**object overrides)
None test_ideal_model_is_flat_and_named()
None test_lockup_still_adds_the_seat_creep()
None test_outlet_rises_as_the_bottle_empties()
None test_the_bias_is_not_folded_into_the_setpoint()