39from typing
import Any, Dict, List, Mapping, Optional, Tuple
45 PSI, SIDES, arr, inp, model_block, network_of, node_p, node_T, out, scalar, series_t, unavailable,
86def iec_gas_flow(p1_pa: float, p2_pa: float, rho1: float, gamma: float, cv: float,
87 xT: float = DEFAULT_XT) -> Dict[str, Any]:
88 """Mass flow [kg/s] a valve of flow coefficient ``cv`` (US Cv) passes wide open from ``p1`` to
89 ``p2``, by IEC 60534-2-1 (turbulent, Fp = 1). Returns {mdot, choked, Y, x, x_choke}."""
90 from fluids.control_valve
import N6, Cv_to_Kv, is_choked_turbulent_g
92 if not all(math.isfinite(v)
for v
in (p1_pa, p2_pa, rho1, gamma, cv, xT))
or p1_pa <= 0
or rho1 <= 0 \
93 or cv <= 0
or gamma <= 0:
94 return {
"mdot": math.nan,
"choked":
None,
"Y": math.nan,
"x": math.nan,
"x_choke": math.nan}
95 x =
max(0.0, (p1_pa - p2_pa) / p1_pa)
96 f_gamma = gamma / 1.40
97 x_choke = f_gamma * xT
98 choked = bool(is_choked_turbulent_g(x, f_gamma, xT))
if x > 0
else False
99 x_eff =
min(x, x_choke)
100 Y = 1.0 - x_eff / (3.0 * f_gamma * xT)
102 w_kg_h = N6 * Cv_to_Kv(cv) * Y * math.sqrt(x_eff * (p1_pa / 1e3) * rho1)
103 return {
"mdot": w_kg_h / 3600.0,
"choked": choked,
"Y": Y,
"x": x,
"x_choke": x_choke}
106def iec_gas_drop(mdot: float, p1_pa: float, rho1: float, gamma: float, cv: float,
107 xT: float = DEFAULT_XT) -> float:
108 """Pressure drop [Pa] that passes ``mdot`` [kg/s] through a valve of ``cv`` by IEC 60534-2-1,
109 the inverse of :func:`iec_gas_flow`. NaN when the flow exceeds the choked capacity."""
110 if not (math.isfinite(mdot)
and mdot >= 0.0):
115 if not math.isfinite(full[
"mdot"])
or mdot > full[
"mdot"]:
117 from scipy.optimize
import brentq
119 x_hi = full[
"x_choke"]
120 p2_hi = p1_pa * (1.0 - x_hi)
121 f =
lambda p2:
iec_gas_flow(p1_pa, p2, rho1, gamma, cv, xT)[
"mdot"] - mdot
124 p2 = brentq(f, p2_hi, p1_pa, xtol=1e-10 * p1_pa, rtol=4 * np.finfo(float).eps)
125 return float(p1_pa - p2)
157 """{name: (SI value, provenance)} of the regulator branch's component on the prepared model,
158 plus ``dome`` (the control regulator's loading at the dial) and the loader's own law."""
161 raise ValueError(
"the prepared model is needed for the regulator's drawing parameters")
162 comp = built.network.branches[reg_id].component
163 params: Dict[str, Tuple[float, str]] = {}
164 for name
in (
"Cv",
"bore",
"supply_coefficient",
"flow_droop",
"rated_flow",
165 "dome_bias",
"dome_pressure",
"setpoint",
"min_inlet_differential",
"xT"):
167 params[name] = (float(comp.p[name]),
_provenance(comp, name))
170 setup = getattr(prep,
"setup",
None)
171 dome_psig = getattr(setup,
"dome_psi",
None)
172 if dome_psig
is None:
173 dome_psig = (getattr(prep,
"derived", {})
or {}).get(
"dome_psig")
174 loader = next((ld
for ld
in getattr(built,
"dome_loaders", {}).values()
175 if str(getattr(ld,
"signal",
"")).startswith(f
"{comp.id}.")),
None)
176 if dome_psig
is not None:
177 params[
"dial"] = (float(from_psig(float(dome_psig))),
178 "Layer X: the control-regulator dial solved for the target lockup (prepare.dome_for_lockup)")
179 if loader
is not None:
180 lc = loader.component
181 params[
"loader_bias"] = (float(lc.p.get(
"dome_bias", 0.0)),
_provenance(lc,
"dome_bias"))
182 params[
"loader_supply_coefficient"] = (float(lc.p.get(
"supply_coefficient", 0.0)),
184 params[
"_loader_supply_node"] = (math.nan, str(loader.supply_node))
185 params[
"_loader_id"] = (math.nan, str(loader.id))
204def build_regulator(result: Mapping[str, Any], prep: Any =
None, config: Any =
None, *,
205 reg_id: Optional[str] =
None, xT: Optional[float] =
None,
206 params: Optional[Mapping[str, Tuple[float, str]]] =
None,
207 gas: Optional[str] =
None) -> Dict[str, Any]:
208 """``diagnostics.regulator``. Never raises.
210 ``params`` ({name: (SI value, provenance)}, the names of :func:`regulator_params`) and ``gas``
211 stand in for ``prep`` (tests, or a saved run re-read without its model). ``xT`` overrides the
212 drawing's / :data:`DEFAULT_XT`.
215 net = network_of(result)
220 return unavailable(
"no regulator on the recorded network")
221 br = net[
"branches"][rid]
224 p_in = node_p(net, br[
"from"], n) * PSI
225 p_out = node_p(net, br[
"to"], n) * PSI
226 T_in = node_T(net, br[
"from"], n)
227 mdot = arr(br.get(
"mdot"), n)
229 def val(name: str, default: float = 0.0) -> float:
230 return float(prm[name][0])
if name
in prm
else default
232 cv = val(
"Cv", float(br.get(
"cv")
or math.nan))
233 x_t = float(xT)
if xT
is not None else val(
"xT", DEFAULT_XT)
234 x_t_prov = (
"restated for this diagnostic" if xT
is not None else
235 prm[
"xT"][1]
if "xT" in prm
else
236 "assumed: the regulator's x_T is not published or drawn; 0.7 is feedtwin's Valve default "
237 "and AUDIT 9.6 C3's value")
241 dome = np.full(n, val(
"dial")) + val(
"loader_bias")
242 s_l = val(
"loader_supply_coefficient")
243 supply_node = prm.get(
"_loader_supply_node", (0,
""))[1]
244 if s_l
and supply_node:
245 dome = dome + s_l * (SUPPLY_ZERO - node_p(net, supply_node, n) * PSI)
246 dome_basis = prm[
"dial"][1]
247 elif "dome_pressure" in prm
and val(
"dome_pressure") > 0:
248 dome = np.full(n, val(
"dome_pressure"))
249 dome_basis = prm[
"dome_pressure"][1]
251 dome = np.full(n, math.nan)
252 dome_basis =
"no dome: a hand-loaded setpoint"
253 bias = val(
"dome_bias")
254 p_set = np.where(np.isfinite(dome), dome + bias, val(
"setpoint", math.nan))
255 S = val(
"supply_coefficient")
256 spe = S * (SUPPLY_ZERO - p_in)
257 D, rated = val(
"flow_droop"), val(
"rated_flow")
258 droop = D * np.abs(mdot) / rated
if rated > 0
else np.zeros(n)
259 target = p_set + spe - droop
260 residual = p_out - target
261 flowing = np.nan_to_num(mdot) > 1e-9
262 wide_open = flowing & (residual < -MODE_BAND * p_set)
264 cap = np.full(n, math.nan)
265 choked: List[Optional[bool]] = []
266 x_series = np.full(n, math.nan)
267 rho_in = np.full(n, math.nan)
269 rho, gamma =
gas_state(fluid, p_in[k], T_in[k])
if math.isfinite(p_in[k] + T_in[k])
else (math.nan, math.nan)
271 r =
iec_gas_flow(p_in[k], p_out[k], rho, gamma, cv, x_t)
274 choked.append(r[
"choked"])
275 with np.errstate(divide=
"ignore", invalid=
"ignore"):
276 use = np.where(cap > 0, np.abs(mdot) / cap, np.nan)
279 firing = np.asarray([bool(x)
for x
in (result.get(
"series")
or {}).get(
"firing")
or [
False] * n])
280 idx = np.flatnonzero(firing & flowing)
283 a, b = idx[0], idx[-1]
284 split = {
"t0": scalar(t[a]),
"t1": scalar(t[b]),
285 "outlet_rise_psi": scalar((p_out[b] - p_out[a]) / PSI),
286 "spe_psi": scalar((spe[b] - spe[a]) / PSI),
287 "droop_psi": scalar(-(droop[b] - droop[a]) / PSI),
288 "dome_psi": scalar((dome[b] - dome[a]) / PSI)
if np.isfinite(dome[[a, b]]).all()
else None,
289 "residual_psi": scalar(((p_out[b] - p_out[a]) - (target[b] - target[a])) / PSI)}
291 def pv(name: str, unit: str, scale: float = 1.0) -> Dict[str, Any]:
292 v, prov = prm.get(name, (math.nan,
"not on the drawing (library default 0)"))
293 return inp(v / scale
if math.isfinite(v)
else None, unit, prov)
296 "dome regulator: drawn outlet law + IEC 60534-2-1 capacity",
297 "feedtwin.comps.regulator.Regulator.outlet_setpoint (the drawing's coefficients); " + IEC_SOURCE,
299 "outlet law evaluated on the twin's recorded regulator-inlet node pressure and branch flow",
300 "dome = control-regulator dial + its bias + its own supply effect at its supply node (zero flow), "
301 "as feedtwin.session loads it",
302 f
"wide open = flowing and the outlet more than {MODE_BAND:.1%} of the setpoint under the law "
303 "(an assumed tolerance, the size of feedtwin Regulator.is_choked's shut/regulating band; "
304 "feedtwin's is_saturated itself has none)",
305 "capacity at this instant's inlet and outlet node pressures, wide open; turbulent; Fp = 1 "
306 "(no reducer data on the drawing)",
307 "inlet density and gamma = cp/cv from CoolProp (real gas) at the inlet node's pressure and "
309 "supply effect measured from zero inlet (gauge): outlet = dome + bias - S x inlet, as "
310 "feedtwin.comps.regulator.Regulator.supply_effect has it (the team, 2026-10-07)",
313 "Cv": pv(
"Cv",
"US gpm/psi^0.5"),
314 "xT": inp(x_t,
"-", x_t_prov),
315 "supply_coefficient": pv(
"supply_coefficient",
"psi/psi"),
316 "flow_droop": pv(
"flow_droop",
"psi", PSI),
317 "rated_flow": pv(
"rated_flow",
"kg/s"),
318 "dome_bias": pv(
"dome_bias",
"psi", PSI),
319 "dome": inp(scalar(np.nanmean(dome) / PSI)
if np.isfinite(dome).any()
else None,
"psia", dome_basis),
320 "gas": inp(fluid,
"",
"drawing (the regulator inlet node's fluid)"),
321 "wide_open_band": inp(MODE_BAND,
"fraction of setpoint",
322 "assumed: tolerance before 'wide open'; the size of feedtwin "
323 "Regulator.is_choked's band, no source for this use"),
328 "label": br.get(
"label")
or rid,
330 "inlet_psia": out(p_in / PSI),
331 "outlet_psia": out(p_out / PSI),
332 "inlet_T_K": out(T_in),
333 "inlet_rho_kg_m3": out(rho_in),
335 "setpoint_psia": out(p_set / PSI),
336 "dome_psia": out(dome / PSI),
337 "target_psia": out(target / PSI),
338 "residual_psi": out(residual / PSI),
339 "capacity_mdot": out(cap),
340 "use_frac": out(use),
342 "droop_psi": out(droop / PSI),
343 "spe_psi": out(spe / PSI),
345 "wide_open": [bool(w)
for w
in wide_open],
346 "any_wide_open": bool(wide_open.any()),
347 "use_frac_max": scalar(np.nanmax(use))
if np.isfinite(use).any()
else None,
352 except Exception
as exc:
353 return unavailable(f
"{type(exc).__name__}: {exc}")
376def build_solenoids(result: Mapping[str, Any], prep: Any =
None, config: Any =
None, *,
377 gas: Optional[str] =
None, xT: Optional[float] =
None) -> Any:
378 """``diagnostics.solenoids``: one row per solenoid between the regulator and a tank. Never
379 raises (a failure is ``{available: false, error}`` in place of the list)."""
381 net = network_of(result)
386 return unavailable(
"no regulator on the recorded network")
388 rows: List[Dict[str, Any]] = []
391 if not seg
or tank
is None:
393 reg_out = net[
"branches"][reg][
"to"]
394 reg_to_tank = node_p(net, reg_out, n) - node_p(net, tank, n)
396 br = net[
"branches"][bid]
397 if br.get(
"kind")
not in (
"solenoid",
"valve"):
399 dp = arr(br.get(
"dp_psi"), n)
if br.get(
"dp_psi")
is not None else \
400 node_p(net, br[
"from"], n) - node_p(net, br[
"to"], n)
401 cv = float(br.get(
"cv")
or math.nan)
403 cv_prov =
"drawing (recorded network)"
404 xt_prov =
"restated for this diagnostic" if xT
is not None else \
405 "assumed: feedtwin Valve default 0.7 (no x_T on the drawing)"
406 if built
is not None:
408 comp = built.network.branches[bid].component
409 cv = float(comp.p.get(
"Cv", cv))
411 if x_t
is None and "xT" in comp.p:
412 x_t = float(comp.p[
"xT"])
416 x_t = DEFAULT_XT
if x_t
is None else x_t
418 mdot = arr(br.get(
"mdot"), n)
419 p1 = node_p(net, br[
"from"], n) * PSI
420 T1 = node_T(net, br[
"from"], n)
421 iec = np.full(n, math.nan)
422 iec2 = np.full(n, math.nan)
424 if not (math.isfinite(mdot[k])
and mdot[k] > 1e-9
and math.isfinite(p1[k] + T1[k])):
425 if math.isfinite(mdot[k])
and abs(mdot[k]) <= 1e-9:
426 iec[k] = iec2[k] = 0.0
428 rho, gamma =
gas_state(fluid, p1[k], T1[k])
429 iec[k] =
iec_gas_drop(mdot[k], p1[k], rho, gamma, cv, x_t) / PSI
430 iec2[k] =
iec_gas_drop(mdot[k], p1[k], rho, gamma, 2.0 * cv, x_t) / PSI
431 with np.errstate(divide=
"ignore", invalid=
"ignore"):
432 share = np.where(np.abs(reg_to_tank) > 1e-9, dp / reg_to_tank, np.nan)
433 firing = np.asarray([bool(x)
for x
in (result.get(
"series")
or {}).get(
"firing")
or [
False] * n])
434 live = firing & np.isfinite(dp)
436 "id": bid,
"label": br.get(
"label")
or bid,
"side": side,
"cv": scalar(cv),
437 "cv_provenance": cv_prov,
438 "dp_psi": out(dp),
"share_of_reg_to_tank": out(share),
439 "reg_to_tank_psi": out(reg_to_tank),
440 "dp_iec_psi": out(iec),
"dp_iec_2cv_psi": out(iec2),
441 "dp_max_psi": scalar(np.nanmax(dp[live]))
if live.any()
else None,
442 "share_max": scalar(np.nanmax(share[live]))
if live.any()
and np.isfinite(share[live]).any()
else None,
443 "model": model_block(
444 "press solenoid drop: twin vs IEC 60534-2-1",
446 [
"dp_psi is the twin's own branch drop (feedtwin Valve, the Cv law at inlet density)",
447 "dp_iec_psi: IEC 60534 drop at the recorded flow and the inlet node's CoolProp state",
448 "dp_iec_2cv_psi: the same flow through twice the Cv (flows held: a first-order "
449 "what-if, not a re-burn)"],
450 {
"Cv": inp(cv,
"US gpm/psi^0.5", cv_prov),
451 "xT": inp(x_t,
"-", xt_prov)}),
454 except Exception
as exc:
455 return unavailable(f
"{type(exc).__name__}: {exc}")
Dict[str, Any] build_regulator(Mapping[str, Any] result, Any prep=None, Any config=None, *, Optional[str] reg_id=None, Optional[float] xT=None, Optional[Mapping[str, Tuple[float, str]]] params=None, Optional[str] gas=None)