1"""The liquid-side components: pipe, fitting, orifice, valve, check valve.
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.
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.
14from __future__
import annotations
17from typing
import Any, Mapping
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
30 InfeasibleOperatingPoint,
42 DEFAULT_FRICTION_METHOD,
45 darcy_friction_factor,
56"""Standard gravity [m/s^2], by definition."""
74_CHOKE_COEFFICIENT: float |
None =
None
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(
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)
105 return _CHOKE_COEFFICIENT
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."""
112XT_DEFAULT = XT_TYPICAL
113"""Default pressure-drop ratio factor at choked flow.
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."""
122 """rho . v^2 / 2 [Pa] -- the quantity every K multiplies."""
123 v = velocity(mdot, bore, rho)
124 return 0.5 * rho * v * v
133 """A straight run: Darcy friction, lumped minor losses, and elevation.
135 ``dp = (f L / D + K_minor) . rho v^2 / 2``, plus ``rho g dz`` of static head.
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.
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.
155 """``rho g dz``. Not a loss -- it is recovered coming back down, and it
156 keeps its sign when the flow reverses."""
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(
164 self.
p[
"roughness"] / bore,
165 self.
opt.get(
"friction", DEFAULT_FRICTION_METHOD),
167 K = fd * self.
p[
"length"] / bore + self.
p.get(
"K_minor", 0.0)
171 return flow.rho * flow.gravity * self.
p.get(
"elevation_change", 0.0)
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)
178 "velocity": velocity(mdot, bore, flow.rho),
180 "friction_factor": darcy_friction_factor(
182 self.
p[
"roughness"] / bore,
183 self.
opt.get(
"friction", DEFAULT_FRICTION_METHOD),
185 "dp_friction": friction,
187 "laminar": 1.0
if 0.0 < Re < LAMINAR_LIMIT
else 0.0,
197 """One fitting, with its own resistance coefficient and its own provenance.
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.
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
212 extra: dict[str, float] = {
214 for name
in (
"angle",
"bore2",
"bend_diameters")
222 extra[
"angle"] = math.degrees(extra[
"angle"])
224 bore=bore, Re=Re, roughness=self.
p[
"roughness"], fd=fd, params=extra
226 return fitting_K(self.
opt[
"kind"], ctx)
237 def diagnostics(self, mdot: float, flow: FlowConditions) -> dict[str, float]:
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),
251 """A bend in hardline, resisted according to how tight it actually is.
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.
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
264 Minimum bend radius is checked but never enforced. See :meth:`check`.
269 def _K(self, mdot: float, flow: FlowConditions) -> float:
270 bore = self.
p[
"bore"]
271 Re = reynolds(mdot, bore, flow.rho, flow.mu)
275 angle=math.degrees(self.
p[
"angle"]),
276 rc=self.
p[
"bend_radius"],
278 roughness=self.
p[
"roughness"],
291 def diagnostics(self, mdot: float, flow: FlowConditions) -> dict[str, float]:
292 bore = self.
p[
"bore"]
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),
301 """Is this bend tighter than the tube allows?
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.
307 limit = self.
p.get(
"min_bend_radius")
308 radius = self.
p[
"bend_radius"]
309 if limit
is None or radius >= limit:
312 od = self.
p.get(
"outer_diameter")
314 f
", {radius / od:.2f}x OD against a {limit / od:.2f}x limit" if od
else ""
319 limit=
"min_bend_radius",
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."
335 """A flexible hose: rougher than tube, crimped ends, and a bend limit.
337 Three things separate it from hardline, and all three change the answer:
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.
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.
357 bore = self.
p[
"bore"]
358 Re = reynolds(mdot, bore, flow.rho, flow.mu)
362 radius = self.
p.get(
"installed_bend_radius")
363 if radius
and radius > 0.0:
366 friction_factor_curved(
367 Re=Re, Di=bore, Dc=2.0 * radius, roughness=self.
p[
"roughness"]
371 fd = darcy_friction_factor(
372 Re, self.
p[
"roughness"] / bore, DEFAULT_FRICTION_METHOD
375 if self.
opt.get(
"construction") ==
"convoluted":
376 fd *= self.
p.get(
"convolution_factor", 1.0)
380 bore = self.
p[
"bore"]
382 K = fd * self.
p[
"length"] / bore + self.
p.get(
"end_fitting_K", 0.0)
385 def diagnostics(self, mdot: float, flow: FlowConditions) -> dict[str, float]:
386 bore = self.
p[
"bore"]
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,
396 radius = self.
p.get(
"installed_bend_radius")
398 out[
"r_over_D"] = radius / bore
402 """Is the hose routed tighter than it may be bent?
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.
410 radius = self.
p.get(
"installed_bend_radius")
414 violations: list[Violation] = []
415 static = self.
p.get(
"min_bend_radius")
416 if static
and radius < static:
420 limit=
"min_bend_radius",
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."
429 dynamic = self.
p.get(
"min_bend_radius_dynamic")
430 if dynamic
and radius < dynamic
and (
not static
or radius >= static):
434 limit=
"min_bend_radius_dynamic",
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."
452 """A restriction with a fixed discharge coefficient.
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.
463 return math.pi * d * d / 4.0
466 effective = self.
p[
"Cd"] * self.
_area()
467 if effective <= 0.0
or flow.rho <= 0.0:
469 return (mdot / effective) ** 2 / (2.0 * flow.rho)
471 def diagnostics(self, mdot: float, flow: FlowConditions) -> dict[str, float]:
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,
482 """A metering orifice, with the discharge coefficient from ISO 5167.
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.
492 METER =
"ISO 5167 orifice"
500 except Exception
as exc:
502 return p1 - float(p2)
505 return differential_pressure_meter_solver(
506 D=self.
p[
"pipe_bore"],
512 meter_type=self.
METER,
524 def diagnostics(self, mdot: float, flow: FlowConditions) -> dict[str, float]:
527 "beta": self.
p[
"bore"] / self.
p[
"pipe_bore"],
528 "Re": reynolds(mdot, self.
p[
"pipe_bore"], flow.rho, flow.mu),
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,
547SHUT_POSITION = 1.0e-3
551 """A valve sized by flow coefficient, with position and choking.
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.
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.
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")
569 if shape ==
"tabulated":
570 curve = self.
instance.curves.get(
"cv_position")
573 f
"{self.id}: characteristic is 'tabulated' but no 'cv_position' "
576 fraction = curve(position) /
max(self.
p[
"Cv"], 1e-30)
578 fn = CHARACTERISTICS.get(shape)
581 f
"{self.id}: unknown characteristic {shape!r}; available: "
582 f
"{', '.join(sorted(CHARACTERISTICS))}, tabulated"
584 fraction = float(fn(position))
586 leak = self.
p.get(
"leak_closed", 0.0)
587 return max(self.
p[
"Cv"] * fraction, leak)
589 def isolates(self, signals: Mapping[str, float] |
None =
None) -> bool:
590 """Commanded shut, so the branch is an open circuit this solve.
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.
597 for key
in (f
"{self.id}.command",
"command"):
598 if signals
and key
in signals:
599 position = float(signals[key])
601 return position <= SHUT_POSITION
604 gas = self.
_gas(flow)
606 return gas.drop(mdot, flow.p_upstream, flow.rho, iec_gamma(flow))
607 position = self.
signal(flow,
"command", 1.0)
609 bore = self.
p[
"bore"]
610 K = Cv_to_K(Cv, bore)
633 def _gas(self, flow: FlowConditions) -> GasCv |
None:
634 """This valve as IEC 60534 sees a gas, or None on a liquid."""
640 return GasCv(Cv, self.
p[
"bore"], self.
p.get(
"xT", XT_DEFAULT))
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.
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).
652 if flow.gamma <= 0.0
or flow.p_upstream <= 0.0
or flow.rho <= 0.0:
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
661 """Choked mass flow through the valve [kg/s], or None for a liquid.
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).
668 gas = self.
_gas(flow)
671 return gas.capacity(flow.p_upstream, flow.rho, iec_gamma(flow))
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."""
686 * math.sqrt(choke[0] * (flow.p_upstream / 1000.0) * flow.rho)
689 def is_choked(self, dp_available: float, flow: FlowConditions) -> bool:
693 return limit
is not None and dp_available >= limit
695 return dp_available >= x_crit * flow.p_upstream
697 def choked_dp(self, flow: FlowConditions) -> float |
None:
698 """Pressure drop at which the liquid flow chokes, per IEC 60534.
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.
704 if flow.p_sat <= 0.0
or flow.p_crit <= 0.0
or flow.p_upstream <= 0.0:
706 p2_choke = cv.control_valve_choke_P_l(
712 return flow.p_upstream - float(p2_choke)
714 def diagnostics(self, mdot: float, flow: FlowConditions) -> dict[str, float]:
715 position = self.
signal(flow,
"command", 1.0)
717 out: dict[str, float] = {
718 "position": position,
721 "velocity": velocity(mdot, self.
p[
"bore"], flow.rho),
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)
737GPM_PER_IN2_AT_1PSI = 38.0
741 """The Cv a discharge coefficient and a bore [m] amount to.
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.
748 area_in2 = math.pi * (bore / 0.0254) ** 2 / 4.0
749 return GPM_PER_IN2_AT_1PSI * cd * area_in2
753 """A valve given as a discharge coefficient.
755 The Cv equivalent is computed once from ``Cd`` and ``bore`` and then it is
756 the Cv valve in every respect.
759 def __init__(self, *args: Any, **kwargs: Any) ->
None:
761 if "Cv" not in self.
p:
766 """Forward flow through a Cv; reverse flow through a seat leak.
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.
776 bore = self.
p[
"bore"]
777 forward = mdot >= 0.0
778 Cv = self.
p[
"Cv"]
if forward
else self.
p[
"leak_reverse"]
780 return head + (self.
p[
"cracking_pressure"]
if forward
else 0.0)
790 """Zero: what a check valve short of its cracking pressure passes.
791 Consulted only when :meth:`is_choked` says it has not cracked."""
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"]
798 def diagnostics(self, mdot: float, flow: FlowConditions) -> dict[str, float]:
800 "open": 1.0
if mdot > 0.0
else 0.0,
802 "velocity": velocity(mdot, self.
p[
"bore"], flow.rho),
807 """A check valve given as a discharge coefficient. See :class:`ValveCd`."""
809 def __init__(self, *args: Any, **kwargs: Any) ->
None:
811 if "Cv" not in self.
p:
821 """Pressure drop read from a measured curve, superseding every correlation.
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.
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.
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.
844 def __init__(self, instance: ComponentInstance) ->
None:
846 curve = instance.curves.get(
"dp_mdot")
849 f
"{instance.id}: model 'measured' needs a 'dp_mdot' curve "
850 "(mass flow against pressure drop)"
857 def diagnostics(self, mdot: float, flow: FlowConditions) -> dict[str, float]:
858 low, high = self.
curve.x_range_si
863 "extrapolated": 0.0
if low <= abs(mdot) <= high
else 1.0,
873 """A run the drawing itemised: several bores, several lengths, real fittings.
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::
880 dp = sum_i (f_i L_i / D_i + sum_j K_ij) . rho v_i^2 / 2
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.
888 Two things it does that a per-segment sum does not do by itself:
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.
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.
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.
908 def __init__(self, instance: ComponentInstance) ->
None:
910 if not instance.segments:
912 f
"{instance.id}: model 'segmented' needs a segment list; the "
913 "drawing did not itemise this line, so it wants model 'darcy'"
917 self.
_method = self.
opt.get(
"friction", DEFAULT_FRICTION_METHOD)
922 bores = [s.bore_si
for s
in self.
segments if s.bore_si > 0.0]
928 s.elevation_change.si
930 if s.elevation_change
is not None
933 sum(stated)
if stated
else self.
p.get(
"elevation_change", 0.0)
940 for index, segment
in enumerate(self.
segments):
949 self, segment: LineSegment, mdot: float, flow: FlowConditions
951 if segment.method ==
"curve" and segment.curve
is not None:
952 return _curve_dp(segment.curve, abs(mdot))
954 bore = segment.bore_si
or self.
_bore
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":
973 segment.roughness.si
if segment.roughness
is not None else self.
_roughness
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)
982 self, segment: LineSegment, bore: float, Re: float, fd: float, rough: float
984 """Every fitting on one segment, summed, referred to the segment bore.
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.
993 for fitting
in segment.fittings:
994 if fitting.count <= 0:
997 total += fitting.K * fitting.count
999 own = fitting.bore
if fitting.bore > 0.0
else bore
1003 for name, value
in (
1004 (
"bend_diameters", fitting.bend_diameters),
1005 (
"angle", fitting.angle),
1012 bore=own, Re=Re, roughness=rough, fd=fd, params=geometry
1022 K *= (bore / own) ** 4
1023 total += K * fitting.count
1027 """The reducer or expander implied by the next segment's bore.
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.
1033 if index + 1 >= len(self.
segments):
1037 if here <= 0.0
or there <= 0.0
or abs(here - there) < 1e-9:
1043 small =
min(here, there)
1044 Re = reynolds(mdot, small, flow.rho, flow.mu)
1046 kind =
"contraction" if there < here
else "expansion"
1054 params={
"bore2": there},
1062 """``(bore, length)`` of each sized segment as installed, end to end [m].
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.
1074 runs: list[tuple[float, float]] = []
1076 bore = segment.bore_si
or self.
_bore
1079 length = segment.stated_length
1080 if segment.length_basis !=
"overall":
1082 max(f.length - f.engagement, 0.0) * f.count
1083 for f
in segment.fittings
1085 runs.append((bore, length))
1090 def diagnostics(self, mdot: float, flow: FlowConditions) -> dict[str, float]:
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)
1098 velocity(mdot, self.
_bore, flow.rho)
if self.
_bore > 0.0
else 0.0
1101 "dp_friction": loss,
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,
1113 """A measured curve, continued quadratically past the ends it was measured over.
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.
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.
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.
1132 low, high = curve.x_range_si
1133 if low <= mdot <= high:
1135 edge = high
if mdot > high
else low
1137 return curve(low)
if mdot < low
else curve(high)
1138 return float(curve(edge) * (mdot / edge) ** 2)
1141def _segmented(instance: ComponentInstance) -> HydraulicComponent:
1145def _measured(instance: ComponentInstance) -> HydraulicComponent:
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)
1173 signals: Mapping[str, float] |
None =
None,
1175 multiphase: bool =
False,
1176 phase: str |
None =
None,
1177 gravity: float = GRAVITY,
1179 """Build :class:`FlowConditions` from a :class:`~feedtwin.props.Fluid`.
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.
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.
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.
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
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.
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.
1214 if not isinstance(fluid, Fluid):
1215 raise TypeError(f
"expected a feedtwin.props.Fluid, got {type(fluid).__name__}")
1217 price_liquid =
not multiphase
and phase !=
"gas"
1219 if cached
is not None:
1220 rho, mu, gamma, p_sat, molar_mass = cached
1226 p_crit=fluid.critical_pressure,
1229 r_specific=R_UNIVERSAL / molar_mass
if molar_mass > 0.0
else 0.0,
1230 signals=dict(signals
or {}),
1235 p_sat = float(fluid.get(
"p", T=T, q=0.0))
1236 except (PropertyError, ValueError):
1242 if not math.isfinite(p_sat):
1245 molar_mass = fluid.constants()[
"molar_mass"]
1247 rho = float(fluid.get(
"rho", p=p, T=T))
1248 except PropertyError:
1255 rho = float(fluid.get(
"rho", p=p, T=T))
1258 mu=float(fluid.get(
"mu", p=p, T=T)),
1261 p_crit=fluid.critical_pressure,
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 {}),
1274DEW_POINT_MARGIN_K = 0.05
1278 """``T``, or just above the dew point at ``p`` if ``T`` is not above it.
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."""
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
1292_IDEAL_GAMMA_CACHE: dict[tuple[str, float], float] = {}
1296_IDEAL_GAMMA_RESOLUTION = 1.0
1300 """cp/cv at 1 kPa and ``T``: the ideal-gas limit, or 0 when unpriceable.
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.
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:
1313 value = float(fluid.get(
"gamma", p=GAMMA_IDEAL_PRESSURE, T=key[1]))
1314 except (PropertyError, ValueError):
1316 if not math.isfinite(value)
or value <= 1.0:
1318 _IDEAL_GAMMA_CACHE[key] = value
1324 tuple[str, float], tuple[float, float, float, float, float] |
None
1330_CACHE_RESOLUTION = 0.01
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.
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)``.
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.
1348 if not isinstance(fluid, Fluid)
or T <= 0.0
or T >= fluid.critical_temperature:
1350 key = (fluid.name, round(T / _CACHE_RESOLUTION) * _CACHE_RESOLUTION)
1351 if key
in _LIQUID_CACHE:
1352 return _LIQUID_CACHE[key]
1355 state: tuple[float, float, float, float, float] |
None
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"]),
1364 except (PropertyError, ValueError, KeyError):
1366 _LIQUID_CACHE[key] = state
float static_head(self, FlowConditions flow)
float pressure_drop(self, float mdot, FlowConditions flow)
float signal(self, FlowConditions flow, str name, float default=1.0)
list[Violation] check(self)
dict[str, float] diagnostics(self, float mdot, FlowConditions flow)
float _K(self, float mdot, FlowConditions flow)
float pressure_drop(self, float mdot, FlowConditions flow)
None __init__(self, *Any args, **Any kwargs)
dict[str, float] diagnostics(self, float mdot, FlowConditions flow)
bool is_choked(self, float dp_available, FlowConditions flow)
float pressure_drop(self, float mdot, FlowConditions flow)
float|None flow_ceiling(self, FlowConditions flow)
dict[str, float] diagnostics(self, float mdot, FlowConditions flow)
float _K(self, float mdot, FlowConditions flow)
float pressure_drop(self, float mdot, FlowConditions flow)
float _friction_factor(self, float mdot, FlowConditions flow)
dict[str, float] diagnostics(self, float mdot, FlowConditions flow)
list[Violation] check(self)
float pressure_drop(self, float mdot, FlowConditions flow)
dict[str, float] diagnostics(self, float mdot, FlowConditions flow)
float pressure_drop(self, float mdot, FlowConditions flow)
None __init__(self, ComponentInstance instance)
float pressure_drop(self, float mdot, FlowConditions flow)
dict[str, float] diagnostics(self, float mdot, FlowConditions flow)
float _solve_downstream(self, float p1, float mdot, FlowConditions flow)
float pressure_drop(self, float mdot, FlowConditions flow)
dict[str, float] diagnostics(self, float mdot, FlowConditions flow)
float _elevation(self, FlowConditions flow)
tuple[float, float] _friction(self, float mdot, FlowConditions flow)
dict[str, float] diagnostics(self, float mdot, FlowConditions flow)
float pressure_drop(self, float mdot, FlowConditions flow)
float static_head(self, FlowConditions flow)
dict[str, float] diagnostics(self, float mdot, FlowConditions flow)
float pressure_drop(self, float mdot, FlowConditions flow)
float static_head(self, FlowConditions flow)
float _transition_dp(self, int index, float mdot, FlowConditions flow)
float _segment_dp(self, LineSegment segment, float mdot, FlowConditions flow)
tuple[tuple[float, float],...] wetted_runs(self)
None __init__(self, ComponentInstance instance)
float _fittings_K(self, LineSegment segment, float bore, float Re, float fd, float rough)
None __init__(self, *Any args, **Any kwargs)
float|None choked_dp(self, FlowConditions flow)
float|None flow_ceiling(self, FlowConditions flow)
GasCv|None _gas(self, FlowConditions flow)
float pressure_drop(self, float mdot, FlowConditions flow)
bool isolates(self, Mapping[str, float]|None signals=None)
dict[str, float] diagnostics(self, float mdot, FlowConditions flow)
tuple[float, float]|None _gas_choke(self, FlowConditions flow)
float|None _legacy_liquid_ceiling(self, FlowConditions flow)
float effective_cv(self, float position)
bool is_choked(self, float dp_available, FlowConditions flow)
float cv_from_cd(float cd, float bore)
HydraulicComponent _measured(ComponentInstance instance)
float _off_the_dew_point(Fluid fluid, float p, float T)
tuple[float, float, float, float, float]|None _liquid_state(object fluid, float T)
float _curve_dp(Curve curve, float mdot)
float _ideal_gamma(Fluid fluid, float T)
float _dynamic_head(float mdot, float bore, float rho)
HydraulicComponent _segmented(ComponentInstance instance)
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)