STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_regulator_seat.py
Go to the documentation of this file.
1"""The regulator's compressible seat (``Setup.regulator_compressible_seat``).
2
3The seat a dome regulator saturates against was ``K rho v^2 / 2`` at the inlet
4density: no expansion factor, no choke (EngineDesign/docs/layerx/AUDIT.md 5.3).
5On GN2 near burnout that passes 0.398 kg/s across the 441 psi a 1092-50 has, where
6IEC 60534-2-1 with its expansion factor passes 0.319 (AUDIT.md 9.6 C3, by hand,
7Cv 0.8, xT 0.7 assumed, gamma 1.40). On -- the default since 2026-10-08, pinned off
8in the benchmark Study -- it reproduces that; off it is the old law exactly; on, a
9regulator that is regulating -- helium at the LE4's hot-fire flows -- does not move.
10
11States are the audit's (``scratchpad/audit/drawings/regcalc.py`` at the twin's
12recorded ``PR_D.in``): GN2 at 3.62 s, 1054.78 psia and 199.95 K, outlet target
13614.174 psia; He at 0.55 s and 3.478 s.
14"""
15
16from __future__ import annotations
17
18import json
19from pathlib import Path
20
21import pytest
22from fluids.control_valve import size_control_valve_g
23from fluids.fittings import Cv_to_K, Kv_to_Cv
24
25from feedtwin.comps import build_component, conditions_from_fluid
26from feedtwin.comps.iec_gas import XT_TYPICAL
27from feedtwin.comps.regulator import SEAT_XT_SIGNAL, Regulator, _dynamic_head
28from feedtwin.model import ComponentInstance, Param, Provenance
29from feedtwin.props import Fluid
30from feedtwin.session import PSI, Setup
31from feedtwin.session.burn import burn_setup
32from feedtwin.solve.network import Network
33from feedtwin.solve.steady import solve_steady
34
35STAR = Path(__file__).resolve().parents[3]
36HE = STAR / "feed-twin" / "backend" / "diagrams" / "copv_study_he.json"
37
38CV = 0.8
39BORE = 0.23 * 0.0254 # TB 1031's 0.23 in orifice, as on the drawing
40ON = {SEAT_XT_SIGNAL: XT_TYPICAL}
41
42
43def _p(value: float, unit: str) -> Param:
44 return Param(value, unit, Provenance.ESTIMATED, "test")
45
46
47def _regulator(setpoint_psia: float, droop: bool = True) -> Regulator:
48 params = {"setpoint": _p(setpoint_psia, "psi"), "Cv": _p(CV, "Cv")}
49 params["bore"] = _p(BORE, "m")
50 if droop:
51 params["flow_droop"] = _p(8.3, "psi")
52 params["rated_flow"] = _p(0.09646, "kg/s")
53 component = build_component(
54 ComponentInstance.build("PR-1", "regulator", params, model="droop")
55 )
56 assert isinstance(component, Regulator)
57 return component
58
59
60def _gas(name: str, psia: float, T: float, signals=None): # type: ignore[no-untyped-def]
61 return conditions_from_fluid(Fluid(name), psia * PSI, T, signals, phase="gas")
62
63
65 """The team turned it on (2026-10-08); the benchmark keeps the law its
66 expectations were stated at."""
67 assert Setup().regulator_compressible_seat is True
68 assert burn_setup().regulator_compressible_seat is False
69 assert Setup().regulator_xT == pytest.approx(0.70)
70
71
73 """Without the signal the saturated drop is K rho v^2/2 at the inlet density,
74 bit for bit, however the gas is priced."""
75 reg = _regulator(614.174, droop=False)
76 flow = _gas("nitrogen", 1054.78, 199.95)
77 for mdot in (0.30, 0.45, 0.70):
78 # The pre-opt-in expression, verbatim.
79 old = Cv_to_K(CV, BORE) * _dynamic_head(mdot, BORE, flow.rho)
80 required = flow.p_upstream - reg.outlet_setpoint(mdot, flow)
81 assert reg.pressure_drop(mdot, flow) == max(required, old)
82 assert reg.pinned_flow(1.0e9, flow) is None
83
84
85def _saturation_flow(reg: Regulator, flow) -> float: # type: ignore[no-untyped-def]
86 """The flow at which the wide-open seat takes the whole available drop."""
87 lo, hi = 1e-6, 5.0
88 for _ in range(200):
89 mid = 0.5 * (lo + hi)
90 lo, hi = (lo, mid) if reg.is_saturated(mid, flow) else (mid, hi)
91 return 0.5 * (lo + hi)
92
93
95 """441 psi available at 1054.78 psia, 199.95 K: 0.398 kg/s incompressible,
96 0.319 with IEC 60534-2-1's expansion factor (x = 0.418, Y = 0.801)."""
97 reg = _regulator(614.174, droop=False) # the audit's outlet target, flat
98 off = _saturation_flow(reg, _gas("nitrogen", 1054.78, 199.95))
99 on = _saturation_flow(reg, _gas("nitrogen", 1054.78, 199.95, ON))
100 assert off == pytest.approx(0.398, abs=0.001)
101 assert on == pytest.approx(0.319, abs=0.001)
102 # Independently: fluids' IEC 60534-2-1 sizing returns the seat's Cv for the
103 # flow the compressible seat passes at that drop.
104 p1, p2 = 1054.78 * PSI, 614.174 * PSI
105 flow = _gas("nitrogen", 1054.78, 199.95)
106 molar_mass = 28.0134
107 rho_normal = 101325.0 * molar_mass * 1e-3 / (8.314462618 * 273.15)
108 kv = size_control_valve_g(
109 T=199.95,
110 MW=molar_mass,
111 mu=flow.mu,
112 gamma=1.40,
113 Z=Fluid("nitrogen").get("Z", p=p1, T=199.95),
114 P1=p1,
115 P2=p2,
116 Q=on / rho_normal,
117 D1=BORE,
118 D2=BORE,
119 d=BORE,
120 xT=XT_TYPICAL,
121 allow_choked=True,
122 )
123 assert Kv_to_Cv(kv) == pytest.approx(CV, rel=3e-3)
124
125
126@pytest.mark.parametrize(
127 "psia, T, mdot, target",
128 [(4081.45, 281.9, 0.02154, 583.512), (1633.75, 200.24, 0.03492, 623.972)],
129)
131 psia: float, T: float, mdot: float, target: float
132) -> None:
133 """Regulating at 5-18 % of its choked capacity, the seat never binds: the
134 outlet is the droop law with the opt-in on or off (< 0.1 psi; it is 0)."""
135 reg = _regulator(target + 8.3 * mdot / 0.09646) # outlet target at this flow
136 off = _gas("helium", psia, T)
137 on = _gas("helium", psia, T, ON)
138 outlet_off = off.p_upstream - reg.pressure_drop(mdot, off)
139 outlet_on = on.p_upstream - reg.pressure_drop(mdot, on)
140 assert outlet_off == pytest.approx(target * PSI, abs=0.01 * PSI)
141 assert abs(outlet_on - outlet_off) < 0.1 * PSI
142 assert not reg.is_saturated(mdot, on)
143 capacity = reg.seat_capacity(on)
144 assert capacity is not None and 0.03 < mdot / capacity < 0.20
145 # Not choked at the drop it regulates across: nothing pinned.
146 assert reg.pinned_flow(on.p_upstream - target * PSI, on) is None
147
148
150 """A GN2 bottle at 4039.76 psia, 284.84 K straight into a tank at 100 psia:
151 the regulator is wide open and past F_gamma xT. The solve must carry the
152 IEC choked flow, N6 C (2/3) sqrt(F_gamma xT p1 rho1) with N6 = 2.73
153 (Cv; kg/h, kPa, kg/m^3); the incompressible law passes far more."""
154
155 def solve(signals): # type: ignore[no-untyped-def]
156 net = Network()
157 net.add_node("bottle", "nitrogen", 284.84, pressure=4039.76 * PSI)
158 net.add_node("tank", "nitrogen", 284.84, pressure=100.0 * PSI)
159 for node in net.nodes.values():
160 node.phase = "gas"
161 net.add_branch("PR", _regulator(600.0), "bottle", "tank")
162 return solve_steady(net, signals=signals, tol=1e-10, max_iterations=200)
163
164 rho1 = Fluid("nitrogen").get("rho", p=4039.76 * PSI, T=284.84)
165 hand = 2.73 * CV * (2.0 / 3.0) * (0.70 * 4039.76 * PSI / 1e3 * rho1) ** 0.5
166 hand /= 3600.0
167 on = solve(ON)
168 assert on.converged
169 assert on.flows["PR"] == pytest.approx(hand, rel=3e-3)
170 off = solve({})
171 assert off.converged
172 assert off.flows["PR"] > 1.3 * on.flows["PR"]
173
174
176 model = _regulator(600.0).seat_model()
177 assert "IEC 60534-2-1" in model["source"]
178 inputs = model["inputs"]
179 assert inputs["xT"]["value"] == XT_TYPICAL # type: ignore[index]
180 assert "assumed" in inputs["xT"]["provenance"] # type: ignore[index]
181
182
183@pytest.mark.skipif(not HE.exists(), reason="helium drawing absent")
185 from feedtwin.pid import read_diagram
186 from feedtwin.session import assemble_model
187
188 model = assemble_model(
189 read_diagram(json.loads(HE.read_text()), name="he"), diagram_id="he"
190 )
191 reg = model.built.network.branches["PR_D"].component
192 assert isinstance(reg, Regulator) and "xT" not in reg.p
193 flow = _gas("nitrogen", 1054.78, 199.95, {SEAT_XT_SIGNAL: 0.5})
194 capacity_05 = reg.seat_capacity(flow, xT=0.5)
195 assert capacity_05 is not None
196 assert reg.pinned_flow(1054.0 * PSI, flow) == pytest.approx(capacity_05)
197
198
199ENGINE = STAR / "EngineDesign" / "configs" / "ethalox_doublet_7000N.yaml"
200CEA = STAR / "EngineDesign" / "output" / "cache" / "cea_cache_LOX_Ethanol_3D.npz"
201TABLES = STAR / "feed-twin" / "backend" / "statemachines"
202
203
204@pytest.mark.skipif(
205 not (HE.exists() and ENGINE.exists() and CEA.exists() and TABLES.is_dir()),
206 reason="helium drawing, engine, CEA table or tables absent",
207)
209 """The helium hot-fire drawing burning: every tank, regulator-outlet and
210 injector pressure within 0.1 psi with the opt-in on (it regulates
211 throughout, so it is in fact identical)."""
212 import yaml
213
214 from feedtwin.engine.importer import engine_from_config
215 from feedtwin.pid import read_diagram
216 from feedtwin.session import assemble_model, load_machine
217 from feedtwin.session.burn import BurnPlan, open_session, run_burn
218
219 def fire(seat: bool): # type: ignore[no-untyped-def]
220 design = engine_from_config(yaml.safe_load(ENGINE.read_text()), name="7000N")
221 model = assemble_model(
222 read_diagram(json.loads(HE.read_text()), name="he"),
223 diagram_id="he",
224 engine=design,
225 cea_cache=str(CEA),
226 )
227 session = open_session(
228 model,
229 load_machine(tables=TABLES),
230 setup=burn_setup(regulator_compressible_seat=seat),
231 )
232 plan = BurnPlan(
233 tank_psi=550.0,
234 loads={"OXT": 6.0, "FUT": 4.0},
235 settle=False,
236 lead_in_s=0.1,
237 horizon_s=0.3,
238 )
239 return run_burn(session, plan)
240
241 off, on = fire(False), fire(True)
242 assert len(on.t) == len(off.t)
243 for node, column in off.pressure.items():
244 for a, b in zip(column, on.pressure[node]):
245 assert abs(a - b) < 0.1 * PSI, node
246 for tank, columns in off.tank.items():
247 for a, b in zip(columns["pressure_Pa"], on.tank[tank]["pressure_Pa"]):
248 assert abs(a - b) < 0.1 * PSI, tank
#define max(a, b)
Definition Arduino.h:43
None test_on_helium_at_le4_flows_the_outlet_does_not_move(float psia, float T, float mdot, float target)
None test_the_drawn_1092_has_no_xt_so_the_setup_value_is_used()
None test_wide_open_and_choked_the_solve_pins_the_capacity()
_gas(str name, float psia, float T, signals=None)
None test_off_the_seat_is_the_incompressible_law_exactly()
Regulator _regulator(float setpoint_psia, bool droop=True)
None test_on_in_the_cockpit_and_off_in_the_benchmark_study()
None test_a_helium_burn_is_unchanged_with_the_seat_on()
float _saturation_flow(Regulator reg, flow)
None test_gn2_capacity_near_burnout_falls_as_the_audit_says()
Param _p(float value, str unit)