STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
elements.py
Go to the documentation of this file.
1"""The liquid-side components: pipe, fitting, orifice, valve, check valve.
2
3Every correlation here comes from ``fluids``, which is validated against the
4books it implements -- Crane TP-410, IEC 60534, ISO 5167. This module contributes
5the adapters and the bookkeeping, and deliberately nothing else. Where a number
6in this package disagrees with the source, the fault is in an adapter.
7
8The one thing worth reading before using any of these: **pressure drop is
9expressed as a function of mass flow**, not the other way round. A network solve
10carries flows and solves for pressures, so this is the direction that avoids an
11inner root-find per component per iteration.
12"""
13
14from __future__ import annotations
15
16import math
17from typing import Any, Mapping
18
19import fluids.control_valve as cv
20from fluids.fittings import Cv_to_K
21from fluids.fittings import bend_rounded as ft_bend_rounded
22from fluids.friction import friction_factor_curved
23from fluids.flow_meter import differential_pressure_meter_solver
24
25from feedtwin.props import Fluid, PropertyError
26from feedtwin.comps.base import (
27 R_UNIVERSAL,
28 FlowConditions,
29 HydraulicComponent,
30 InfeasibleOperatingPoint,
31 Violation,
32 register_builder,
33)
34from feedtwin.comps.iec_gas import (
35 GAMMA_IDEAL_PRESSURE,
36 XT_TYPICAL,
37 GasCv,
38 iec_gamma,
39 is_gas,
40)
42 DEFAULT_FRICTION_METHOD,
43 FittingContext,
44 LAMINAR_LIMIT,
45 darcy_friction_factor,
46 fitting_K,
47 reynolds,
48 velocity,
49)
50from feedtwin.model.component import ComponentInstance
51from feedtwin.model.curve import Curve
52from feedtwin.model.segments import LineSegment
53from feedtwin.model.spec import SpecError
54
55GRAVITY = 9.80665
56"""Standard gravity [m/s^2], by definition."""
57
58#: The plain valve's old choked-gas coefficient, still applied to a *liquid*.
59#:
60#: Calibrated once from ``fluids.control_valve.size_control_valve_g`` with a
61#: standard density at 15 degC (fluids states 0 degC, so it is 5.4 % low). Gas
62#: no longer reads it -- a gas-priced node goes through
63#: :class:`~feedtwin.comps.iec_gas.GasCv` -- but a liquid still meets it, because
64#: a liquid's cp/cv is positive and the old gas test was ``gamma > 0``: every
65#: liquid valve carries a "sonic" ceiling at ``x = gamma/1.4 . xT`` (LOX 0.915,
66#: ethanol 0.596) and drains to atmosphere at 0.54-0.69x the IEC liquid law.
67#: Kept, exactly, because removing it is not a component fix: on a drawing with
68#: no engine model the session holds the chamber at ambient for the coupling
69#: step in which the mains open (``Session._chamber`` decides "lit" from the
70#: previous step's flows), and this false ceiling is what kept that step from
71#: emptying the LOX tank -- without it LE4 (6)'s 8.9 kg went in 0.5 s at
72#: 18 kg/s. Deferred (R037): close the no-engine chamber within the step,
73#: then drop :func:`_legacy_liquid_ceiling` and gate on ``is_gas`` alone.
74_CHOKE_COEFFICIENT: float | None = None
75
76
77def _calibrate_choke() -> float:
78 """One call to ``fluids``, cached, giving the coefficient for Cv/kPa/kg-m^3."""
79 global _CHOKE_COEFFICIENT
80 if _CHOKE_COEFFICIENT is None:
81 MW, gamma, T, p1, bore = 4.0026, 1.667, 293.15, 3.792e6, 0.00953
82 r_specific = R_UNIVERSAL * 1000.0 / MW
83 rho1 = p1 / (r_specific * T)
84 rho_std = 101325.0 / (r_specific * 288.15)
85 sized: Any = cv.size_control_valve_g(
86 T=T,
87 MW=MW,
88 mu=2.0e-5,
89 gamma=gamma,
90 Z=1.0,
91 P1=p1,
92 P2=101325.0,
93 Q=1.0,
94 D1=bore,
95 D2=bore,
96 d=bore,
97 allow_choked=True,
98 full_output=True,
99 )
100 per_cv = rho_std * KV_PER_CV / float(sized["Kv"])
101 x = (gamma / 1.40) * XT_DEFAULT
102 _CHOKE_COEFFICIENT = per_cv / (
103 (2.0 / 3.0) * math.sqrt(x * (p1 / 1000.0) * rho1)
104 )
105 return _CHOKE_COEFFICIENT
106
107
108KV_PER_CV = 1.0 / 1.1560992283536566
109"""Kv per unit Cv. One Cv is one US gallon per minute of water at one psi; one Kv
110is one cubic metre per hour at one bar."""
111
112XT_DEFAULT = XT_TYPICAL
113"""Default pressure-drop ratio factor at choked flow.
114
115IEC 60534-2-1's value for a globe or ball valve with a flow-to-open plug, used
116when a datasheet does not give one. It is the single largest uncertainty in a
117choked-gas prediction -- a real xT ranges about 0.3 (butterfly) to 0.8 (some
118globe trims) -- so a valve whose xT matters should carry its own."""
119
120
121def _dynamic_head(mdot: float, bore: float, rho: float) -> float:
122 """rho . v^2 / 2 [Pa] -- the quantity every K multiplies."""
123 v = velocity(mdot, bore, rho)
124 return 0.5 * rho * v * v
125
126
127# ---------------------------------------------------------------------------
128# Pipe
129# ---------------------------------------------------------------------------
130
131
133 """A straight run: Darcy friction, lumped minor losses, and elevation.
134
135 ``dp = (f L / D + K_minor) . rho v^2 / 2``, plus ``rho g dz`` of static head.
136
137 The two are reported separately because they behave differently: friction
138 reverses with the flow, elevation does not. Omitting elevation is a real
139 error in a tall vehicle with a dense cryogen -- ten metres of LOX is about
140 1.6 bar -- and conflating it with the loss is a subtler one that only shows
141 up when a branch reverses.
142
143 ``K_minor`` is a deliberate shortcut for lumping fittings into a run. It
144 costs the per-fitting provenance that a :class:`Fitting` component keeps, so
145 it defaults to zero and is worth avoiding where the fittings are known.
146 """
147
148 symmetric = True
149
150 def pressure_drop(self, mdot: float, flow: FlowConditions) -> float:
151 friction, _ = self._friction(mdot, flow)
152 return friction
153
154 def static_head(self, flow: FlowConditions) -> float:
155 """``rho g dz``. Not a loss -- it is recovered coming back down, and it
156 keeps its sign when the flow reverses."""
157 return self._elevation(flow)
158
159 def _friction(self, mdot: float, flow: FlowConditions) -> tuple[float, float]:
160 bore = self.p["bore"]
161 Re = reynolds(mdot, bore, flow.rho, flow.mu)
162 fd = darcy_friction_factor(
163 Re,
164 self.p["roughness"] / bore,
165 self.opt.get("friction", DEFAULT_FRICTION_METHOD),
166 )
167 K = fd * self.p["length"] / bore + self.p.get("K_minor", 0.0)
168 return K * _dynamic_head(mdot, bore, flow.rho), Re
169
170 def _elevation(self, flow: FlowConditions) -> float:
171 return flow.rho * flow.gravity * self.p.get("elevation_change", 0.0)
172
173 def diagnostics(self, mdot: float, flow: FlowConditions) -> dict[str, float]:
174 bore = self.p["bore"]
175 Re = reynolds(mdot, bore, flow.rho, flow.mu)
176 friction, _ = self._friction(mdot, flow)
177 return {
178 "velocity": velocity(mdot, bore, flow.rho),
179 "Re": Re,
180 "friction_factor": darcy_friction_factor(
181 Re,
182 self.p["roughness"] / bore,
183 self.opt.get("friction", DEFAULT_FRICTION_METHOD),
184 ),
185 "dp_friction": friction,
186 "dp_elevation": self._elevation(flow),
187 "laminar": 1.0 if 0.0 < Re < LAMINAR_LIMIT else 0.0,
188 }
189
190
191# ---------------------------------------------------------------------------
192# Fitting
193# ---------------------------------------------------------------------------
194
195
197 """One fitting, with its own resistance coefficient and its own provenance.
198
199 A component rather than a number on a pipe, so an elbow is a thing on the
200 drawing with a Crane citation behind it. :class:`Pipe`'s ``K_minor`` is the
201 shortcut for when that is more bookkeeping than a run is worth.
202 """
203
204 symmetric = True
205
206 def _K(self, mdot: float, flow: FlowConditions) -> float:
207 bore = self.p["bore"]
208 Re = reynolds(mdot, bore, flow.rho, flow.mu)
209 fd = darcy_friction_factor(
210 Re, self.p["roughness"] / bore, DEFAULT_FRICTION_METHOD
211 )
212 extra: dict[str, float] = {
213 name: self.p[name]
214 for name in ("angle", "bore2", "bend_diameters")
215 if name in self.p
216 }
217 if "angle" in extra:
218 # An *angle* parameter arrives in SI, radians; the context carries
219 # degrees, which is what fluids' bend_rounded takes (and what a
220 # segment's drawn ``angleDeg`` already is). Passed straight through,
221 # a 90-degree bend priced as a 1.57-degree one.
222 extra["angle"] = math.degrees(extra["angle"])
223 ctx = FittingContext(
224 bore=bore, Re=Re, roughness=self.p["roughness"], fd=fd, params=extra
225 )
226 return fitting_K(self.opt["kind"], ctx)
227
228 def pressure_drop(self, mdot: float, flow: FlowConditions) -> float:
229 if mdot == 0.0:
230 # K times a dynamic head of zero, whatever K is -- and at Re = 0 a
231 # bend's correlation (Rennels) refuses to give one. The solver
232 # back-fills every capped stub at exactly zero flow, so a capped
233 # fill QD raised out of every solve of its drawing.
234 return 0.0
235 return self._K(mdot, flow) * _dynamic_head(mdot, self.p["bore"], flow.rho)
236
237 def diagnostics(self, mdot: float, flow: FlowConditions) -> dict[str, float]:
238 return {
239 "K": self._K(mdot, flow),
240 "velocity": velocity(mdot, self.p["bore"], flow.rho),
241 "Re": reynolds(mdot, self.p["bore"], flow.rho, flow.mu),
242 }
243
244
245# ---------------------------------------------------------------------------
246# Bend -- hardline, geometry-aware
247# ---------------------------------------------------------------------------
248
249
251 """A bend in hardline, resisted according to how tight it actually is.
252
253 ``fluids.bend_rounded`` takes the real centreline radius rather than a
254 nominal elbow class, which matters: the resistance of a bend is a strong
255 function of r/D, and a tube bent on whatever former was to hand is not a
256 long-radius elbow. Between r/D = 1 and r/D = 5 the loss changes severalfold.
257
258 The curvature's effect on friction is inside that correlation already, so
259 this deliberately does *not* add a curved-friction term on top -- that would
260 count the same physics twice. :class:`FlexHose` is the case where curved
261 friction is added, because there the whole run is bent rather than a
262 discrete arc of it.
263
264 Minimum bend radius is checked but never enforced. See :meth:`check`.
265 """
266
267 symmetric = True
268
269 def _K(self, mdot: float, flow: FlowConditions) -> float:
270 bore = self.p["bore"]
271 Re = reynolds(mdot, bore, flow.rho, flow.mu)
272 return float(
273 ft_bend_rounded(
274 Di=bore,
275 angle=math.degrees(self.p["angle"]),
276 rc=self.p["bend_radius"],
277 Re=Re or None,
278 roughness=self.p["roughness"],
279 )
280 )
281
282 def pressure_drop(self, mdot: float, flow: FlowConditions) -> float:
283 if mdot == 0.0:
284 # K times a dynamic head of zero, whatever K is -- and at Re = 0 a
285 # bend's correlation (Rennels) refuses to give one. The solver
286 # back-fills every capped stub at exactly zero flow, so a capped
287 # fill QD raised out of every solve of its drawing.
288 return 0.0
289 return self._K(mdot, flow) * _dynamic_head(mdot, self.p["bore"], flow.rho)
290
291 def diagnostics(self, mdot: float, flow: FlowConditions) -> dict[str, float]:
292 bore = self.p["bore"]
293 return {
294 "K": self._K(mdot, flow),
295 "r_over_D": self.p["bend_radius"] / bore,
296 "velocity": velocity(mdot, bore, flow.rho),
297 "Re": reynolds(mdot, bore, flow.rho, flow.mu),
298 }
299
300 def check(self) -> list[Violation]:
301 """Is this bend tighter than the tube allows?
302
303 Reported, not raised: a line bent too tight is buildable, and the model
304 should still solve so the cost is visible. Quoted against OD where one
305 is given, because that is how tube benders and hose datasheets state it.
306 """
307 limit = self.p.get("min_bend_radius")
308 radius = self.p["bend_radius"]
309 if limit is None or radius >= limit:
310 return []
311
312 od = self.p.get("outer_diameter")
313 ratio = (
314 f", {radius / od:.2f}x OD against a {limit / od:.2f}x limit" if od else ""
315 )
316 return [
317 Violation(
318 component=self.id,
319 limit="min_bend_radius",
320 detail=(
321 f"bent to {radius * 1000:.1f} mm centreline radius, tighter "
322 f"than the {limit * 1000:.1f} mm minimum{ratio}. The wall "
323 "thins and the section ovalises below this."
324 ),
325 )
326 ]
327
328
329# ---------------------------------------------------------------------------
330# Flex hose
331# ---------------------------------------------------------------------------
332
333
335 """A flexible hose: rougher than tube, crimped ends, and a bend limit.
336
337 Three things separate it from hardline, and all three change the answer:
338
339 * **It is rougher.** A smooth-bore liner is close to drawn tube; convoluted
340 metal hose is several times worse, because the convolutions are in the
341 flow path and not merely in the braid.
342 * **Its ends are fittings.** Two crimped transitions, which on a short hose
343 dominate the loss entirely.
344 * **It is usually bent.** When ``installed_bend_radius`` is set, friction is
345 computed for curved flow through
346 ``fluids.friction_factor_curved`` -- and unlike a discrete bend, the whole
347 developed length is curved, so this is where that term belongs.
348
349 Minimum bend radius is generally what actually constrains a routing, and is
350 quoted twice: a static limit, and a larger dynamic one for anything that
351 gimbals, grows thermally or vibrates.
352 """
353
354 symmetric = True
355
356 def _friction_factor(self, mdot: float, flow: FlowConditions) -> float:
357 bore = self.p["bore"]
358 Re = reynolds(mdot, bore, flow.rho, flow.mu)
359 if Re <= 0.0:
360 return 0.0
361
362 radius = self.p.get("installed_bend_radius")
363 if radius and radius > 0.0:
364 # Coil diameter is twice the centreline bend radius.
365 fd = float(
366 friction_factor_curved(
367 Re=Re, Di=bore, Dc=2.0 * radius, roughness=self.p["roughness"]
368 )
369 )
370 else:
371 fd = darcy_friction_factor(
372 Re, self.p["roughness"] / bore, DEFAULT_FRICTION_METHOD
373 )
374
375 if self.opt.get("construction") == "convoluted":
376 fd *= self.p.get("convolution_factor", 1.0)
377 return fd
378
379 def pressure_drop(self, mdot: float, flow: FlowConditions) -> float:
380 bore = self.p["bore"]
381 fd = self._friction_factor(mdot, flow)
382 K = fd * self.p["length"] / bore + self.p.get("end_fitting_K", 0.0)
383 return K * _dynamic_head(mdot, bore, flow.rho)
384
385 def diagnostics(self, mdot: float, flow: FlowConditions) -> dict[str, float]:
386 bore = self.p["bore"]
387 fd = self._friction_factor(mdot, flow)
388 head = _dynamic_head(mdot, bore, flow.rho)
389 out = {
390 "velocity": velocity(mdot, bore, flow.rho),
391 "Re": reynolds(mdot, bore, flow.rho, flow.mu),
392 "friction_factor": fd,
393 "dp_hose": fd * self.p["length"] / bore * head,
394 "dp_end_fittings": self.p.get("end_fitting_K", 0.0) * head,
395 }
396 radius = self.p.get("installed_bend_radius")
397 if radius:
398 out["r_over_D"] = radius / bore
399 return out
400
401 def check(self) -> list[Violation]:
402 """Is the hose routed tighter than it may be bent?
403
404 Both limits are checked. The dynamic one is a warning rather than an
405 error because whether a given hose actually moves in service is a fact
406 about the installation that the model does not know -- but a run inside
407 the static limit and outside the dynamic one is exactly the case worth
408 surfacing rather than silently passing.
409 """
410 radius = self.p.get("installed_bend_radius")
411 if not radius:
412 return []
413
414 violations: list[Violation] = []
415 static = self.p.get("min_bend_radius")
416 if static and radius < static:
417 violations.append(
418 Violation(
419 component=self.id,
420 limit="min_bend_radius",
421 detail=(
422 f"routed to {radius * 1000:.0f} mm, tighter than the "
423 f"{static * 1000:.0f} mm static minimum. A smooth-bore "
424 "liner collapses and a convoluted one fatigues."
425 ),
426 )
427 )
428
429 dynamic = self.p.get("min_bend_radius_dynamic")
430 if dynamic and radius < dynamic and (not static or radius >= static):
431 violations.append(
432 Violation(
433 component=self.id,
434 limit="min_bend_radius_dynamic",
435 severity="warning",
436 detail=(
437 f"routed to {radius * 1000:.0f} mm, inside the "
438 f"{dynamic * 1000:.0f} mm dynamic minimum. Acceptable "
439 "static; not if this run flexes in service."
440 ),
441 )
442 )
443 return violations
444
445
446# ---------------------------------------------------------------------------
447# Orifice
448# ---------------------------------------------------------------------------
449
450
452 """A restriction with a fixed discharge coefficient.
453
454 ``m = Cd . A . sqrt(2 rho dp)``, inverted. The simplest useful restriction
455 model and the right one when Cd has been measured -- which for an injector
456 element it usually has.
457 """
458
459 symmetric = True
460
461 def _area(self) -> float:
462 d = self.p["bore"]
463 return math.pi * d * d / 4.0
464
465 def pressure_drop(self, mdot: float, flow: FlowConditions) -> float:
466 effective = self.p["Cd"] * self._area()
467 if effective <= 0.0 or flow.rho <= 0.0:
468 return 0.0
469 return (mdot / effective) ** 2 / (2.0 * flow.rho)
470
471 def diagnostics(self, mdot: float, flow: FlowConditions) -> dict[str, float]:
472 dp = self.pressure_drop(mdot, flow)
473 return {
474 "dp": dp,
475 "beta": self.p["bore"] / self.p["pipe_bore"],
476 "velocity_throat": velocity(mdot, self.p["bore"], flow.rho),
477 "cavitation_margin": flow.p_upstream - dp - flow.p_sat,
478 }
479
480
482 """A metering orifice, with the discharge coefficient from ISO 5167.
483
484 Reader-Harris/Gallagher via ``fluids``: C depends on the beta ratio, the
485 Reynolds number and the tapping arrangement, so unlike :class:`OrificeCd`
486 this needs no measured coefficient -- which is the point of using a standard
487 geometry in the first place.
488 """
489
490 symmetric = True
491
492 METER = "ISO 5167 orifice"
493
494 def pressure_drop(self, mdot: float, flow: FlowConditions) -> float:
495 if mdot == 0.0:
496 return 0.0
497 p1 = flow.p_upstream
498 try:
499 p2 = self._solve_downstream(p1, mdot, flow)
500 except Exception as exc: # fluids raises several numeric types here
501 raise InfeasibleOperatingPoint(self.id, mdot, p1) from exc
502 return p1 - float(p2)
503
504 def _solve_downstream(self, p1: float, mdot: float, flow: FlowConditions) -> float:
505 return differential_pressure_meter_solver(
506 D=self.p["pipe_bore"],
507 D2=self.p["bore"],
508 rho=flow.rho,
509 mu=flow.mu,
510 m=abs(mdot),
511 P1=p1,
512 meter_type=self.METER,
513 taps="D",
514 # ISO 5167 sets the expansibility factor to 1 for an incompressible
515 # fluid, which is the whole scope of this phase. Left unset, fluids
516 # tries to compute it and needs an isentropic exponent that a liquid
517 # has no business supplying. The gas side arrives in Phase 05 and
518 # will pass a real k instead.
519 # Exactly 1, and passed as an int because that is what fluids'
520 # annotation accepts -- the value is exact either way.
521 epsilon_specified=1,
522 )
523
524 def diagnostics(self, mdot: float, flow: FlowConditions) -> dict[str, float]:
525 return {
526 "dp": self.pressure_drop(mdot, flow),
527 "beta": self.p["bore"] / self.p["pipe_bore"],
528 "Re": reynolds(mdot, self.p["pipe_bore"], flow.rho, flow.mu),
529 }
530
531
532# ---------------------------------------------------------------------------
533# Valve
534# ---------------------------------------------------------------------------
535
536#: Inherent characteristics: fraction of full Cv against fractional travel.
537#: From ``fluids.control_valve``; ``tabulated`` reads the instance's own curve.
538CHARACTERISTICS: dict[str, object] = {
539 "linear": cv.Cv_char_linear,
540 "equal_percentage": cv.Cv_char_equal_percentage,
541 "quick_opening": cv.Cv_char_quick_opening,
542}
543
544
545#: Travel at or below which a valve is treated as shut -- an open circuit
546#: rather than a very small orifice. See HydraulicComponent.isolates.
547SHUT_POSITION = 1.0e-3
548
549
551 """A valve sized by flow coefficient, with position and choking.
552
553 Loss comes from the Cv-to-K conversion in ``fluids``, which reproduces the
554 definition of Cv itself -- one US gallon per minute of water at one psi --
555 to better than 0.1%, independently of bore.
556
557 Position enters through the inherent characteristic. A closed valve keeps a
558 small ``leak_closed`` capacity: real seats leak, and a capacity of exactly
559 zero makes the network singular rather than shut.
560 """
561
562 symmetric = True
563
564 def effective_cv(self, position: float) -> float:
565 """Cv at a fractional travel between 0 and 1."""
566 position = min(max(position, 0.0), 1.0)
567 shape = self.opt.get("characteristic", "linear")
568
569 if shape == "tabulated":
570 curve = self.instance.curves.get("cv_position")
571 if curve is None:
572 raise SpecError(
573 f"{self.id}: characteristic is 'tabulated' but no 'cv_position' "
574 "curve was given"
575 )
576 fraction = curve(position) / max(self.p["Cv"], 1e-30)
577 else:
578 fn = CHARACTERISTICS.get(shape)
579 if fn is None:
580 raise SpecError(
581 f"{self.id}: unknown characteristic {shape!r}; available: "
582 f"{', '.join(sorted(CHARACTERISTICS))}, tabulated"
583 )
584 fraction = float(fn(position)) # type: ignore[operator]
585
586 leak = self.p.get("leak_closed", 0.0)
587 return max(self.p["Cv"] * fraction, leak)
588
589 def isolates(self, signals: Mapping[str, float] | None = None) -> bool:
590 """Commanded shut, so the branch is an open circuit this solve.
591
592 The threshold is on *travel*, not on the resulting Cv: a valve at 0.5%
593 open is shut as far as any stand is concerned, and leaving it in the
594 network buys a row Newton cannot move.
595 """
596 position = 1.0
597 for key in (f"{self.id}.command", "command"):
598 if signals and key in signals:
599 position = float(signals[key])
600 break
601 return position <= SHUT_POSITION
602
603 def pressure_drop(self, mdot: float, flow: FlowConditions) -> float:
604 gas = self._gas(flow)
605 if gas is not None:
606 return gas.drop(mdot, flow.p_upstream, flow.rho, iec_gamma(flow))
607 position = self.signal(flow, "command", 1.0)
608 Cv = self.effective_cv(position)
609 bore = self.p["bore"]
610 K = Cv_to_K(Cv, bore)
611 return K * _dynamic_head(mdot, bore, flow.rho)
612
613 # ------------------------------------------------------------------ gas
614 #
615 # A valve on a gas line is the common case on a stand -- every vent, every
616 # press solenoid. It is priced by IEC 60534-2-1 for compressible flow,
617 # ``W = N6 . C . Y . sqrt(x . p1 . rho1)`` with the expansion factor
618 # ``Y = 1 - x/(3 . F_gamma . xT)``, through :class:`~feedtwin.comps.iec_gas.GasCv`
619 # -- the same adapter the relief valve and the compressible regulator seat
620 # use, and within 0.11 % of ``fluids.control_valve.size_control_valve_g``
621 # at every drop (the Cv_to_K scale against IEC's N9). Flow rises
622 # continuously to the choke (Y = 2/3) and stays there.
623 #
624 # It used to take the incompressible Cv law (Y = 1) below a separately
625 # calibrated choked ceiling. The two never met: a Cv 4 valve on 500 psia
626 # nitrogen passed 1.52x IEC just short of the choke and then *lost* 36 % of
627 # its flow when the downstream fell another 10 psi, so a vent sped up as its
628 # tank fell, and some networks had two converged answers. The ceiling also
629 # read fluids' volumetric flow at 15 degC where fluids states 0 degC (5 %
630 # low). Gated on ``is_gas`` -- a node priced as a gas. A liquid keeps the old
631 # law and its false ceiling for now (_CHOKE_COEFFICIENT; R037, deferred).
632
633 def _gas(self, flow: FlowConditions) -> GasCv | None:
634 """This valve as IEC 60534 sees a gas, or None on a liquid."""
635 if not is_gas(flow):
636 return None
637 Cv = self.effective_cv(self.signal(flow, "command", 1.0))
638 if Cv <= 0.0:
639 return None # no capacity at all: the plain law, as before
640 return GasCv(Cv, self.p["bore"], self.p.get("xT", XT_DEFAULT))
641
642 def _gas_choke(self, flow: FlowConditions) -> tuple[float, float] | None:
643 """``(x_critical, F_gamma)`` for this fluid, or None if it is not a gas.
644
645 ``x`` is IEC 60534's pressure-drop ratio ``dp/p1``; choking begins at
646 ``F_gamma * xT``. ``F_gamma = gamma / 1.40`` carries the ideal-gas
647 specific-heat ratio relative to air, which is what makes helium (1.667)
648 choke at a different ratio from nitrogen (1.40).
649 """
650 if not is_gas(flow):
651 # Not a gas: the old test, kept for liquids (see _CHOKE_COEFFICIENT).
652 if flow.gamma <= 0.0 or flow.p_upstream <= 0.0 or flow.rho <= 0.0:
653 return None
654 F_gamma = flow.gamma / 1.40
655 return F_gamma * self.p.get("xT", XT_DEFAULT), F_gamma
656 gamma = iec_gamma(flow)
657 x_crit = GasCv(1.0, 1.0, self.p.get("xT", XT_DEFAULT)).x_critical(gamma)
658 return x_crit, gamma / 1.40
659
660 def flow_ceiling(self, flow: FlowConditions) -> float | None:
661 """Choked mass flow through the valve [kg/s], or None for a liquid.
662
663 IEC 60534-2-1 at the critical ratio ``x = F_gamma . xT``, where the
664 expansion factor is exactly ``2/3`` and the flow stops responding to
665 downstream pressure. A liquid should have no such ceiling, and still
666 has the old one (:func:`_legacy_liquid_ceiling`; deferred, R037).
667 """
668 gas = self._gas(flow)
669 if gas is None:
670 return self._legacy_liquid_ceiling(flow)
671 return gas.capacity(flow.p_upstream, flow.rho, iec_gamma(flow))
672
673 def _legacy_liquid_ceiling(self, flow: FlowConditions) -> float | None:
674 """The old calibrated "sonic" ceiling, on anything not priced as a gas.
675 Unchanged; see :data:`_CHOKE_COEFFICIENT` for why it stays for now."""
676 choke = self._gas_choke(flow)
677 if choke is None:
678 return None
679 Cv_eff = self.effective_cv(self.signal(flow, "command", 1.0))
680 if Cv_eff <= 0.0:
681 return None
682 return (
684 * Cv_eff
685 * (2.0 / 3.0)
686 * math.sqrt(choke[0] * (flow.p_upstream / 1000.0) * flow.rho)
687 )
688
689 def is_choked(self, dp_available: float, flow: FlowConditions) -> bool:
690 choke = self._gas_choke(flow)
691 if choke is None:
692 limit = self.choked_dp(flow)
693 return limit is not None and dp_available >= limit
694 x_crit, _ = choke
695 return dp_available >= x_crit * flow.p_upstream
696
697 def choked_dp(self, flow: FlowConditions) -> float | None:
698 """Pressure drop at which the liquid flow chokes, per IEC 60534.
699
700 ``None`` when the fluid state does not say -- no saturation pressure
701 given, which is the honest answer for an incompletely specified liquid.
702 A gas is handled by :meth:`flow_ceiling` instead.
703 """
704 if flow.p_sat <= 0.0 or flow.p_crit <= 0.0 or flow.p_upstream <= 0.0:
705 return None
706 p2_choke = cv.control_valve_choke_P_l(
707 Psat=flow.p_sat,
708 Pc=flow.p_crit,
709 FL=self.p["FL"],
710 P1=flow.p_upstream,
711 )
712 return flow.p_upstream - float(p2_choke)
713
714 def diagnostics(self, mdot: float, flow: FlowConditions) -> dict[str, float]:
715 position = self.signal(flow, "command", 1.0)
716 dp = self.pressure_drop(mdot, flow)
717 out: dict[str, float] = {
718 "position": position,
719 "Cv": self.effective_cv(position),
720 "dp": dp,
721 "velocity": velocity(mdot, self.p["bore"], flow.rho),
722 }
723 limit = self.choked_dp(flow)
724 if limit is not None:
725 out["dp_choked"] = limit
726 out["choked"] = 1.0 if dp >= limit else 0.0
727 if flow.p_sat > 0.0 and flow.p_upstream > flow.p_sat:
728 out["cavitation_index"] = float(
729 cv.cavitation_index(flow.p_upstream, flow.p_upstream - dp, flow.p_sat)
730 )
731 return out
732
733
734#: US gpm of water through one square inch at one psi, per unit Cd. From the
735#: orifice law: at dp = 6894.76 Pa and rho = 998 kg/m^3 the velocity is
736#: 3.717 m/s, so 6.4516e-4 m^2 passes 2.398e-3 m^3/s = 38.0 gpm.
737GPM_PER_IN2_AT_1PSI = 38.0
738
739
740def cv_from_cd(cd: float, bore: float) -> float:
741 """The Cv a discharge coefficient and a bore [m] amount to.
742
743 ``Cv = 38.0 . Cd . A[in^2]``. A team that measured a valve's Cd rather
744 than its Cv gets the same element with the number they have; the rest of
745 the valve -- position, characteristic, choking -- is unchanged, because it
746 is the same physics stated the other way round.
747 """
748 area_in2 = math.pi * (bore / 0.0254) ** 2 / 4.0
749 return GPM_PER_IN2_AT_1PSI * cd * area_in2
750
751
753 """A valve given as a discharge coefficient.
754
755 The Cv equivalent is computed once from ``Cd`` and ``bore`` and then it is
756 the Cv valve in every respect.
757 """
758
759 def __init__(self, *args: Any, **kwargs: Any) -> None:
760 super().__init__(*args, **kwargs)
761 if "Cv" not in self.p:
762 self.p["Cv"] = cv_from_cd(self.p["Cd"], self.p["bore"])
763
764
766 """Forward flow through a Cv; reverse flow through a seat leak.
767
768 The first component here that is not a smooth function of flow. Forward, it
769 is a valve with a cracking pressure to overcome; backward, it is very nearly
770 shut. That discontinuity at zero flow is exactly why the solver interface is
771 residual-based rather than ``dp(mdot)``, and it is the component to re-check
772 when Phase 04 settles that interface.
773 """
774
775 def pressure_drop(self, mdot: float, flow: FlowConditions) -> float:
776 bore = self.p["bore"]
777 forward = mdot >= 0.0
778 Cv = self.p["Cv"] if forward else self.p["leak_reverse"]
779 head = Cv_to_K(max(Cv, 1e-12), bore) * _dynamic_head(mdot, bore, flow.rho)
780 return head + (self.p["cracking_pressure"] if forward else 0.0)
781
782 # The cracking pressure steps the relation at zero flow: forward needs at
783 # least the crack, reverse (the seat leak) at most nothing. A drop between
784 # the two had no root, and every solve failed exactly where a tank pressed
785 # through a check valve comes to rest. Between them the valve is shut: the
786 # solver writes mdot = 0 (the ceiling) and leaves both pressures free --
787 # the closure the regulator already has for a downstream above lockup.
788
789 def flow_ceiling(self, flow: FlowConditions) -> float | None:
790 """Zero: what a check valve short of its cracking pressure passes.
791 Consulted only when :meth:`is_choked` says it has not cracked."""
792 return 0.0
793
794 def is_choked(self, dp_available: float, flow: FlowConditions) -> bool:
795 """Shut: a forward drop that has not reached the cracking pressure."""
796 return 0.0 <= dp_available < self.p["cracking_pressure"]
797
798 def diagnostics(self, mdot: float, flow: FlowConditions) -> dict[str, float]:
799 return {
800 "open": 1.0 if mdot > 0.0 else 0.0,
801 "dp": self.pressure_drop(mdot, flow),
802 "velocity": velocity(mdot, self.p["bore"], flow.rho),
803 }
804
805
807 """A check valve given as a discharge coefficient. See :class:`ValveCd`."""
808
809 def __init__(self, *args: Any, **kwargs: Any) -> None:
810 super().__init__(*args, **kwargs)
811 if "Cv" not in self.p:
812 self.p["Cv"] = cv_from_cd(self.p["Cd"], self.p["bore"])
813
814
815# ---------------------------------------------------------------------------
816# Measured -- available for any component type
817# ---------------------------------------------------------------------------
818
819
821 """Pressure drop read from a measured curve, superseding every correlation.
822
823 The component-level counterpart of the property layer's tabulated backend,
824 and the same principle: inside the range you measured, your data wins.
825
826 Unlike the property layer this does **not** fall through to a correlation
827 outside its range. A property has a defensible fallback in an equation of
828 state; a component's measured curve has no equivalent, and quietly
829 switching to a correlation halfway up a flow sweep would produce a kink
830 nobody asked for and nobody would see.
831
832 Off its ends it is continued quadratically (:func:`_curve_dp`, the same
833 continuation a measured segment gets): ``dp ~ mdot^2`` is the law the curve
834 is a measurement of, and it runs through zero. It used to raise there, and
835 flow benches rarely sweep from zero: a solve starts every branch at 1 g/s,
836 so a curve over 0.05-0.2 kg/s that bracketed the answer could not be
837 solved at all, and a capped stub's zero-flow back-fill raised out of a
838 solve asked not to raise. :meth:`diagnostics` flags a flow that was not
839 measured (``extrapolated``), so the excursion is visible, not silent.
840 """
841
842 symmetric = True
843
844 def __init__(self, instance: ComponentInstance) -> None:
845 super().__init__(instance)
846 curve = instance.curves.get("dp_mdot")
847 if curve is None:
848 raise SpecError(
849 f"{instance.id}: model 'measured' needs a 'dp_mdot' curve "
850 "(mass flow against pressure drop)"
851 )
852 self.curve: Curve = curve
853
854 def pressure_drop(self, mdot: float, flow: FlowConditions) -> float:
855 return _curve_dp(self.curve, abs(mdot))
856
857 def diagnostics(self, mdot: float, flow: FlowConditions) -> dict[str, float]:
858 low, high = self.curve.x_range_si
859 return {
860 "dp": self.pressure_drop(mdot, flow),
861 "mdot_min": low,
862 "mdot_max": high,
863 "extrapolated": 0.0 if low <= abs(mdot) <= high else 1.0,
864 }
865
866
867# ---------------------------------------------------------------------------
868# SegmentedPipe
869# ---------------------------------------------------------------------------
870
871
873 """A run the drawing itemised: several bores, several lengths, real fittings.
874
875 :class:`Pipe` is one bore of one length with its fittings lumped into
876 ``K_minor``. A real line is not that. It is 400 mm of 1/2 in. tube, three
877 elbows, a reducer, 900 mm of 3/8 in., a tee. The drawing records exactly
878 that, and this evaluates it segment by segment::
879
880 dp = sum_i (f_i L_i / D_i + sum_j K_ij) . rho v_i^2 / 2
881
882 with ``v_i`` the velocity in *that* segment's bore. The bore is inside the
883 sum for a reason: it enters as the fifth power (``v^2 ~ D^-4``, and ``L/D``
884 one more), so a run that steps from 1/2 in. to 3/8 in. halfway is nowhere
885 near the average of the two. Lumping this run into one equivalent bore is
886 the error this class exists to stop.
887
888 Two things it does that a per-segment sum does not do by itself:
889
890 **The tube length, not the assembly length.** A fitting's K already prices
891 the flow through the fitting, so the friction term must use the tube only.
892 :meth:`~feedtwin.model.segments.LineSegment.tube_length` takes the fitting
893 bodies out when the drawing says the length was measured end to end.
894
895 **Bore changes are contractions.** Two adjacent segments of different bore
896 *are* a reducer, whether or not anybody drew one. Deriving it is the only
897 safe choice: a reducer somebody forgot to add is silent, and on a 1/2 in.
898 to 3/8 in. step at 10 m/s it is worth about 0.1 bar -- larger than most of
899 the elbows that did get drawn.
900
901 Segments are evaluated by their own method, so a run that has been flowed in
902 one place and estimated in another gets the measurement where the
903 measurement exists. See :mod:`feedtwin.model.segments` for the ladder.
904 """
905
906 symmetric = True
907
908 def __init__(self, instance: ComponentInstance) -> None:
909 super().__init__(instance)
910 if not instance.segments:
911 raise SpecError(
912 f"{instance.id}: model 'segmented' needs a segment list; the "
913 "drawing did not itemise this line, so it wants model 'darcy'"
914 )
915 self.segments = instance.segments
916 self._roughness = self.p.get("roughness", 1.5e-6)
917 self._method = self.opt.get("friction", DEFAULT_FRICTION_METHOD)
918 # The bore the run's *reported* velocity and Reynolds number refer to.
919 # The tightest one, because that is where a velocity limit bites and
920 # where cavitation starts -- an average bore would report a number that
921 # is true nowhere on the line.
922 bores = [s.bore_si for s in self.segments if s.bore_si > 0.0]
923 self._bore = min(bores) if bores else 0.0
924 # Whole-run elevation, used only when no segment states its own. A
925 # drawing that records the rise per segment has already recorded the
926 # total, so adding the line-level value on top would count it twice.
927 stated = [
928 s.elevation_change.si
929 for s in self.segments
930 if s.elevation_change is not None
931 ]
933 sum(stated) if stated else self.p.get("elevation_change", 0.0)
934 )
935
936 # ------------------------------------------------------------ the physics
937
938 def pressure_drop(self, mdot: float, flow: FlowConditions) -> float:
939 total = 0.0
940 for index, segment in enumerate(self.segments):
941 total += self._segment_dp(segment, mdot, flow)
942 total += self._transition_dp(index, mdot, flow)
943 return total
944
945 def static_head(self, flow: FlowConditions) -> float:
946 return flow.rho * flow.gravity * self._elevation_m
947
949 self, segment: LineSegment, mdot: float, flow: FlowConditions
950 ) -> float:
951 if segment.method == "curve" and segment.curve is not None:
952 return _curve_dp(segment.curve, abs(mdot))
953
954 bore = segment.bore_si or self._bore
955 if bore <= 0.0:
956 # No bore anywhere on the line: nothing can be sized. The reader
957 # has already warned; contributing zero is the only honest answer,
958 # and it is visible in `segments_sized`.
959 return 0.0
960 if mdot == 0.0:
961 # Every K below multiplies a dynamic head of zero. Asked anyway, a
962 # bend's correlation (Rennels) refuses Re = 0, and a capped run is
963 # priced at exactly zero flow -- it stopped a stand opening at all.
964 return 0.0
965 head = _dynamic_head(mdot, bore, flow.rho)
966
967 if segment.method in ("measured_K", "lumped_K"):
968 return (segment.K.si if segment.K is not None else 0.0) * head
969 if segment.method != "itemised":
970 return 0.0
971
972 roughness = (
973 segment.roughness.si if segment.roughness is not None else self._roughness
974 )
975 Re = reynolds(mdot, bore, flow.rho, flow.mu)
976 fd = darcy_friction_factor(Re, roughness / bore, self._method)
977 K = fd * segment.tube_length() / bore
978 K += self._fittings_K(segment, bore, Re, fd, roughness)
979 return K * head
980
982 self, segment: LineSegment, bore: float, Re: float, fd: float, rough: float
983 ) -> float:
984 """Every fitting on one segment, summed, referred to the segment bore.
985
986 A fitting carrying its own measured K uses it: a number off a flow bench
987 beats every correlation, which is the same rule the segment ladder
988 applies one level up. One that carries its own bore is priced in that
989 bore but referred back to the segment's, because the K a solver adds to
990 ``f L / D`` has to multiply the same dynamic head as the friction does.
991 """
992 total = 0.0
993 for fitting in segment.fittings:
994 if fitting.count <= 0:
995 continue
996 if fitting.K > 0.0:
997 total += fitting.K * fitting.count
998 continue
999 own = fitting.bore if fitting.bore > 0.0 else bore
1000 try:
1001 geometry = {
1002 name: value
1003 for name, value in (
1004 ("bend_diameters", fitting.bend_diameters),
1005 ("angle", fitting.angle),
1006 )
1007 if value > 0.0
1008 }
1009 K = fitting_K(
1010 fitting.kind,
1012 bore=own, Re=Re, roughness=rough, fd=fd, params=geometry
1013 ),
1014 )
1015 except KeyError:
1016 # Not a kind this library prices. The reader warned; carrying on
1017 # with the rest of the run beats refusing to solve the line.
1018 continue
1019 if own != bore:
1020 # K scales with the dynamic head it was referred to, which goes
1021 # as 1/A^2, so as the fourth power of the bore ratio.
1022 K *= (bore / own) ** 4
1023 total += K * fitting.count
1024 return total
1025
1026 def _transition_dp(self, index: int, mdot: float, flow: FlowConditions) -> float:
1027 """The reducer or expander implied by the next segment's bore.
1028
1029 Derived rather than drawn. Two adjacent segments of different bore *are*
1030 the transition; making somebody add a row for it is a step they can
1031 forget, and then the run is quietly cheap.
1032 """
1033 if index + 1 >= len(self.segments):
1034 return 0.0
1035 here = self.segments[index].bore_si or self._bore
1036 there = self.segments[index + 1].bore_si or self._bore
1037 if here <= 0.0 or there <= 0.0 or abs(here - there) < 1e-9:
1038 return 0.0
1039 if mdot == 0.0:
1040 return 0.0 # K times a zero head; see `_segment_dp`
1041 # Both fluids' correlations refer K to the *smaller* bore, which is also
1042 # where the velocity is highest and the loss actually happens.
1043 small = min(here, there)
1044 Re = reynolds(mdot, small, flow.rho, flow.mu)
1045 fd = darcy_friction_factor(Re, self._roughness / small, self._method)
1046 kind = "contraction" if there < here else "expansion"
1047 K = fitting_K(
1048 kind,
1050 bore=here,
1051 Re=Re,
1052 roughness=self._roughness,
1053 fd=fd,
1054 params={"bore2": there},
1055 ),
1056 )
1057 return K * _dynamic_head(mdot, small, flow.rho)
1058
1059 # ------------------------------------------------------- what it is made of
1060
1061 def wetted_runs(self) -> tuple[tuple[float, float], ...]:
1062 """``(bore, length)`` of each sized segment as installed, end to end [m].
1063
1064 What the line's metal is wrapped around, for the line wall
1065 (:class:`~feedtwin.comps.wall.LineWall`), and deliberately *not*
1066 :meth:`~feedtwin.model.segments.LineSegment.tube_length`: that takes the
1067 fitting bodies out because their loss is already priced as a K, but the
1068 gas touches the inside of a bend or a union as much as the tube. On a
1069 ``tube`` basis the bodies (less thread engagement) are added back; on an
1070 ``overall`` basis the stated length already runs end to end. A segment
1071 with no bore anywhere on the line cannot be sized and is left out, as
1072 :meth:`_segment_dp` leaves it out of the loss.
1073 """
1074 runs: list[tuple[float, float]] = []
1075 for segment in self.segments:
1076 bore = segment.bore_si or self._bore
1077 if bore <= 0.0:
1078 continue
1079 length = segment.stated_length
1080 if segment.length_basis != "overall":
1081 length += sum(
1082 max(f.length - f.engagement, 0.0) * f.count
1083 for f in segment.fittings
1084 )
1085 runs.append((bore, length))
1086 return tuple(runs)
1087
1088 # ---------------------------------------------------------- what it shows
1089
1090 def diagnostics(self, mdot: float, flow: FlowConditions) -> dict[str, float]:
1091 loss = self.pressure_drop(mdot, flow)
1092 Re = reynolds(mdot, self._bore, flow.rho, flow.mu) if self._bore > 0.0 else 0.0
1093 sized = sum(1 for s in self.segments if (s.bore_si or self._bore) > 0.0)
1094 return {
1095 # At the tightest bore, so this is the worst velocity on the run and
1096 # the one worth comparing against a limit.
1097 "velocity": (
1098 velocity(mdot, self._bore, flow.rho) if self._bore > 0.0 else 0.0
1099 ),
1100 "Re": Re,
1101 "dp_friction": loss,
1102 "dp_elevation": self.static_head(flow),
1103 "bore_min": self._bore,
1104 "length_tube": sum(s.tube_length() for s in self.segments),
1105 "segments": float(len(self.segments)),
1106 "segments_sized": float(sized),
1107 "fittings": float(sum(f.count for s in self.segments for f in s.fittings)),
1108 "laminar": 1.0 if 0.0 < Re < LAMINAR_LIMIT else 0.0,
1109 }
1110
1111
1112def _curve_dp(curve: Curve, mdot: float) -> float:
1113 """A measured curve, continued quadratically past the ends it was measured over.
1114
1115 :class:`MeasuredElement` raises outside its range, and for a whole component
1116 declared ``measured`` that is right -- the run is the measurement, and there
1117 is nothing else to fall back on.
1118
1119 A *segment* is different in two ways. It is one part of a run whose other
1120 parts still have physics, so refusing the point throws away everything that
1121 is known about the rest of the line. And a Newton iterate overshoots by its
1122 nature: killing a transient because the solver probed 1.1x the top of a flow
1123 bench sweep would make a measured segment worse to have than no measurement.
1124
1125 So it is continued, and continued *quadratically* -- ``dp ~ mdot^2`` is the
1126 turbulent law the curve is a measurement of, so this is the shape the data
1127 already has rather than a shape invented to fill a gap. Clamping instead
1128 would be worse than either: a flat dp above the last point takes the flow
1129 dependence out of the branch equation, and a branch whose loss does not
1130 respond to its own flow makes the Jacobian singular.
1131 """
1132 low, high = curve.x_range_si
1133 if low <= mdot <= high:
1134 return curve(mdot)
1135 edge = high if mdot > high else low
1136 if edge <= 0.0:
1137 return curve(low) if mdot < low else curve(high)
1138 return float(curve(edge) * (mdot / edge) ** 2)
1139
1140
1141def _segmented(instance: ComponentInstance) -> HydraulicComponent:
1142 return SegmentedPipe(instance)
1143
1144
1145def _measured(instance: ComponentInstance) -> HydraulicComponent:
1146 return MeasuredElement(instance)
1147
1148
1149register_builder("pipe", "darcy", Pipe)
1150register_builder("pipe", "measured", _measured)
1151register_builder("pipe", "segmented", _segmented)
1152register_builder("fitting", "K", Fitting)
1153register_builder("bend", "rounded", Bend)
1154register_builder("bend", "measured", _measured)
1155register_builder("flex_hose", "darcy", FlexHose)
1156register_builder("flex_hose", "measured", _measured)
1157register_builder("fitting", "measured", _measured)
1158register_builder("orifice", "cd", OrificeCd)
1159register_builder("orifice", "iso5167", OrificeISO5167)
1160register_builder("orifice", "measured", _measured)
1161register_builder("valve", "cv", Valve)
1162register_builder("valve", "cd", ValveCd)
1163register_builder("valve", "measured", _measured)
1164register_builder("check_valve", "cv", CheckValve)
1165register_builder("check_valve", "cd", CheckValveCd)
1166register_builder("check_valve", "measured", _measured)
1167
1168
1170 fluid: object,
1171 p: float,
1172 T: float,
1173 signals: Mapping[str, float] | None = None,
1174 *,
1175 multiphase: bool = False,
1176 phase: str | None = None,
1177 gravity: float = GRAVITY,
1178) -> FlowConditions:
1179 """Build :class:`FlowConditions` from a :class:`~feedtwin.props.Fluid`.
1180
1181 ``phase`` is what the network *declares* the node to hold, and it wins over
1182 the temperature rule below: ``"gas"`` is priced at ``(p, T)`` whatever the
1183 temperature, ``"liquid"`` on the saturated-liquid line, ``None`` falls back
1184 to the rule. A drawing knows which side of a tank a line is on; a
1185 temperature does not, and an ethanol *vapour* line at 350 K is below
1186 ethanol's critical temperature and would otherwise be priced as liquid.
1187
1188 The bridge between Phase 01 and Phase 03, kept as a free function so that
1189 components never hold a fluid and stay testable with two floats.
1190
1191 Saturation pressure comes from the fluid where the state has one; above the
1192 critical temperature there is no saturation line, and reporting zero there
1193 disables the cavitation checks honestly rather than inventing a limit.
1194
1195 Phase, and why it is not inferred by default
1196 -------------------------------------------
1197 Asking the property layer for ``rho(p, T)`` lets it decide the phase, and
1198 when the temperature reaching it is wrong that decision is *silently*
1199 catastrophic: oxygen at the ambient default is a gas at 40 kg/m^3 where LOX
1200 is 1140, so the leg solves at a thirtieth of the density and every number
1201 after it stays plausible. It has happened twice here, from two different
1202 causes, and both times it looked like a flow-rate discrepancy rather than a
1203 property lookup.
1204
1205 So ``multiphase`` is **off**. With it off, a fluid that is below its
1206 critical temperature is evaluated on its saturated-liquid line at ``T`` --
1207 it cannot come back as a gas, whatever pressure the solve is currently
1208 guessing. Real feed lines run subcooled and a few per cent stiffer than
1209 saturated, which is well inside what a lumped ``K`` already assumes.
1210
1211 Turn it on when flashing and cavitation are the subject rather than the
1212 hazard. It is not ready for that yet, which is the other reason it is off.
1213 """
1214 if not isinstance(fluid, Fluid):
1215 raise TypeError(f"expected a feedtwin.props.Fluid, got {type(fluid).__name__}")
1216
1217 price_liquid = not multiphase and phase != "gas"
1218 cached = _liquid_state(fluid, T) if price_liquid else None
1219 if cached is not None:
1220 rho, mu, gamma, p_sat, molar_mass = cached
1221 return FlowConditions(
1222 rho=rho,
1223 mu=mu,
1224 p_upstream=p,
1225 p_sat=p_sat,
1226 p_crit=fluid.critical_pressure,
1227 temperature=T,
1228 gamma=gamma,
1229 r_specific=R_UNIVERSAL / molar_mass if molar_mass > 0.0 else 0.0,
1230 signals=dict(signals or {}),
1231 gravity=gravity,
1232 )
1233
1234 try:
1235 p_sat = float(fluid.get("p", T=T, q=0.0))
1236 except (PropertyError, ValueError):
1237 p_sat = 0.0
1238 # Above the critical point there is no saturation line, and a backend may
1239 # answer with a NaN rather than declining. Zero disables the cavitation
1240 # checks honestly; a NaN propagates into every comparison downstream and
1241 # silently answers "no" to all of them.
1242 if not math.isfinite(p_sat):
1243 p_sat = 0.0
1244
1245 molar_mass = fluid.constants()["molar_mass"]
1246 try:
1247 rho = float(fluid.get("rho", p=p, T=T))
1248 except PropertyError:
1249 # A gas line at its own dew point: on the saturation line (p, T) does not
1250 # say liquid or vapour, and no backend will answer. Cold pressurant
1251 # reaches it -- GN2 over a chilled LOX tank at 2.7 bar and 86.8 K stopped
1252 # every solve of an Ethalox Stand vent for 13 s. The line carries gas, so
1253 # it is priced as the saturated vapour it is, just off the line.
1254 T = _off_the_dew_point(fluid, p, T)
1255 rho = float(fluid.get("rho", p=p, T=T))
1256 return FlowConditions(
1257 rho=rho,
1258 mu=float(fluid.get("mu", p=p, T=T)),
1259 p_upstream=p,
1260 p_sat=p_sat,
1261 p_crit=fluid.critical_pressure,
1262 temperature=T,
1263 gamma=float(fluid.get("gamma", p=p, T=T)),
1264 r_specific=R_UNIVERSAL / molar_mass if molar_mass > 0.0 else 0.0,
1265 signals=dict(signals or {}),
1266 gravity=gravity,
1267 gamma_ideal=_ideal_gamma(fluid, T),
1268 )
1269
1270
1271#: How far above its dew point a gas line on the saturation line is priced [K]
1272#: (`conditions_from_fluid`): clear of the line the backends refuse, and well
1273#: inside any temperature the stand can resolve.
1274DEW_POINT_MARGIN_K = 0.05
1275
1276
1277def _off_the_dew_point(fluid: Fluid, p: float, T: float) -> float:
1278 """``T``, or just above the dew point at ``p`` if ``T`` is not above it.
1279
1280 Re-raises the property layer's refusal when the state is not a gas at its
1281 dew point -- a gas line below the line it cannot be helped from here."""
1282 try:
1283 dew = float(fluid.get("T", p=p, q=1.0))
1284 except (PropertyError, ValueError):
1285 raise PropertyError(f"{fluid.name} at p={p:.6g} Pa, T={T:.6g} K") from None
1286 if T > dew + DEW_POINT_MARGIN_K:
1287 raise PropertyError(f"{fluid.name} at p={p:.6g} Pa, T={T:.6g} K")
1288 return dew + DEW_POINT_MARGIN_K
1289
1290
1291#: Ideal-gas ratio of specific heats by (species, temperature). See _ideal_gamma.
1292_IDEAL_GAMMA_CACHE: dict[tuple[str, float], float] = {}
1293
1294#: Temperature resolution of that cache [K]. An ideal gas's cp moves by parts
1295#: per thousand over a kelvin; nitrogen's cp/cv is 1.400 from 90 K to 400 K.
1296_IDEAL_GAMMA_RESOLUTION = 1.0
1297
1298
1299def _ideal_gamma(fluid: Fluid, T: float) -> float:
1300 """cp/cv at 1 kPa and ``T``: the ideal-gas limit, or 0 when unpriceable.
1301
1302 For :mod:`feedtwin.comps.iec_gas` only (``FlowConditions.gamma_ideal``);
1303 nothing else reads it. Cached by species and whole kelvin, because a solve
1304 asks for the same few gas temperatures thousands of times.
1305 """
1306 if T <= 0.0:
1307 return 0.0
1308 key = (fluid.name, round(T / _IDEAL_GAMMA_RESOLUTION) * _IDEAL_GAMMA_RESOLUTION)
1309 cached = _IDEAL_GAMMA_CACHE.get(key)
1310 if cached is not None:
1311 return cached
1312 try:
1313 value = float(fluid.get("gamma", p=GAMMA_IDEAL_PRESSURE, T=key[1]))
1314 except (PropertyError, ValueError):
1315 value = 0.0
1316 if not math.isfinite(value) or value <= 1.0:
1317 value = 0.0
1318 _IDEAL_GAMMA_CACHE[key] = value
1319 return value
1320
1321
1322#: Saturated-liquid properties by (species, temperature). See _liquid_state.
1323_LIQUID_CACHE: dict[
1324 tuple[str, float], tuple[float, float, float, float, float] | None
1325] = {}
1326
1327#: Temperature resolution of that cache [K]. A hundredth of a kelvin moves a
1328#: liquid density by parts per million and is far below anything a stand knows
1329#: its propellant temperature to.
1330_CACHE_RESOLUTION = 0.01
1331
1332
1334 fluid: object, T: float
1335) -> tuple[float, float, float, float, float] | None:
1336 """``(rho, mu, gamma, p_sat, molar_mass)`` on the saturated-liquid line.
1337
1338 ``None`` when the fluid has no liquid line at this temperature -- above the
1339 critical point, which is where every pressurant lives -- and the caller
1340 falls back to evaluating at ``(p, T)``.
1341
1342 Cached because with phase inference off these depend on **temperature
1343 alone**: a Newton solve walks the pressures and holds the temperatures, so
1344 a feed line's properties are constant across the entire solve and were
1345 being recomputed a thousand times a tick. That was two thirds of the cost
1346 of a live tick, and none of it bought a different number.
1347 """
1348 if not isinstance(fluid, Fluid) or T <= 0.0 or T >= fluid.critical_temperature:
1349 return None
1350 key = (fluid.name, round(T / _CACHE_RESOLUTION) * _CACHE_RESOLUTION)
1351 if key in _LIQUID_CACHE:
1352 return _LIQUID_CACHE[key]
1353
1354 quantised = key[1]
1355 state: tuple[float, float, float, float, float] | None
1356 try:
1357 state = (
1358 float(fluid.get("rho", T=quantised, q=0.0)),
1359 float(fluid.get("mu", T=quantised, q=0.0)),
1360 float(fluid.get("gamma", T=quantised, q=0.0)),
1361 float(fluid.get("p", T=quantised, q=0.0)),
1362 float(fluid.constants()["molar_mass"]),
1363 )
1364 except (PropertyError, ValueError, KeyError):
1365 state = None
1366 _LIQUID_CACHE[key] = state
1367 return state
#define min(a, b)
Definition Arduino.h:42
#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
float signal(self, FlowConditions flow, str name, float default=1.0)
Definition base.py:229
list[Violation] check(self)
Definition elements.py:300
dict[str, float] diagnostics(self, float mdot, FlowConditions flow)
Definition elements.py:291
float _K(self, float mdot, FlowConditions flow)
Definition elements.py:269
float pressure_drop(self, float mdot, FlowConditions flow)
Definition elements.py:282
None __init__(self, *Any args, **Any kwargs)
Definition elements.py:809
dict[str, float] diagnostics(self, float mdot, FlowConditions flow)
Definition elements.py:798
bool is_choked(self, float dp_available, FlowConditions flow)
Definition elements.py:794
float pressure_drop(self, float mdot, FlowConditions flow)
Definition elements.py:775
float|None flow_ceiling(self, FlowConditions flow)
Definition elements.py:789
dict[str, float] diagnostics(self, float mdot, FlowConditions flow)
Definition elements.py:237
float _K(self, float mdot, FlowConditions flow)
Definition elements.py:206
float pressure_drop(self, float mdot, FlowConditions flow)
Definition elements.py:228
float _friction_factor(self, float mdot, FlowConditions flow)
Definition elements.py:356
dict[str, float] diagnostics(self, float mdot, FlowConditions flow)
Definition elements.py:385
list[Violation] check(self)
Definition elements.py:401
float pressure_drop(self, float mdot, FlowConditions flow)
Definition elements.py:379
dict[str, float] diagnostics(self, float mdot, FlowConditions flow)
Definition elements.py:857
float pressure_drop(self, float mdot, FlowConditions flow)
Definition elements.py:854
None __init__(self, ComponentInstance instance)
Definition elements.py:844
float pressure_drop(self, float mdot, FlowConditions flow)
Definition elements.py:465
dict[str, float] diagnostics(self, float mdot, FlowConditions flow)
Definition elements.py:471
float _solve_downstream(self, float p1, float mdot, FlowConditions flow)
Definition elements.py:504
float pressure_drop(self, float mdot, FlowConditions flow)
Definition elements.py:494
dict[str, float] diagnostics(self, float mdot, FlowConditions flow)
Definition elements.py:524
float _elevation(self, FlowConditions flow)
Definition elements.py:170
tuple[float, float] _friction(self, float mdot, FlowConditions flow)
Definition elements.py:159
dict[str, float] diagnostics(self, float mdot, FlowConditions flow)
Definition elements.py:173
float pressure_drop(self, float mdot, FlowConditions flow)
Definition elements.py:150
float static_head(self, FlowConditions flow)
Definition elements.py:154
dict[str, float] diagnostics(self, float mdot, FlowConditions flow)
Definition elements.py:1090
float pressure_drop(self, float mdot, FlowConditions flow)
Definition elements.py:938
float static_head(self, FlowConditions flow)
Definition elements.py:945
float _transition_dp(self, int index, float mdot, FlowConditions flow)
Definition elements.py:1026
float _segment_dp(self, LineSegment segment, float mdot, FlowConditions flow)
Definition elements.py:950
tuple[tuple[float, float],...] wetted_runs(self)
Definition elements.py:1061
None __init__(self, ComponentInstance instance)
Definition elements.py:908
float _fittings_K(self, LineSegment segment, float bore, float Re, float fd, float rough)
Definition elements.py:983
None __init__(self, *Any args, **Any kwargs)
Definition elements.py:759
float|None choked_dp(self, FlowConditions flow)
Definition elements.py:697
float|None flow_ceiling(self, FlowConditions flow)
Definition elements.py:660
GasCv|None _gas(self, FlowConditions flow)
Definition elements.py:633
float pressure_drop(self, float mdot, FlowConditions flow)
Definition elements.py:603
bool isolates(self, Mapping[str, float]|None signals=None)
Definition elements.py:589
dict[str, float] diagnostics(self, float mdot, FlowConditions flow)
Definition elements.py:714
tuple[float, float]|None _gas_choke(self, FlowConditions flow)
Definition elements.py:642
float|None _legacy_liquid_ceiling(self, FlowConditions flow)
Definition elements.py:673
float effective_cv(self, float position)
Definition elements.py:564
bool is_choked(self, float dp_available, FlowConditions flow)
Definition elements.py:689
float cv_from_cd(float cd, float bore)
Definition elements.py:740
HydraulicComponent _measured(ComponentInstance instance)
Definition elements.py:1145
float _off_the_dew_point(Fluid fluid, float p, float T)
Definition elements.py:1277
tuple[float, float, float, float, float]|None _liquid_state(object fluid, float T)
Definition elements.py:1335
float _curve_dp(Curve curve, float mdot)
Definition elements.py:1112
float _ideal_gamma(Fluid fluid, float T)
Definition elements.py:1299
float _dynamic_head(float mdot, float bore, float rho)
Definition elements.py:121
HydraulicComponent _segmented(ComponentInstance instance)
Definition elements.py:1141
FlowConditions conditions_from_fluid(object fluid, float p, float T, Mapping[str, float]|None signals=None, *, bool multiphase=False, str|None phase=None, float gravity=GRAVITY)
Definition elements.py:1178