STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
correlations.py
Go to the documentation of this file.
1"""Friction factors and fitting losses, as registries rather than choices.
2
3Two registries, both extensible from outside this package, both wrapping
4``fluids`` rather than reimplementing it.
5
6**Friction factors.** Colebrook is implicit and has to be iterated; a dozen
7explicit approximations to it exist, and they disagree in the third significant
8figure. Which one a project uses is a modelling decision, not a fact, so it is
9an *option* on the component -- ``pipe.options.friction`` -- and changing it is
10a config edit.
11
12**Fitting losses.** Crane TP-410's resistance coefficients, plus the Hooper 2K
13and Darby 3K methods, all of which ``fluids`` implements and validates against
14the source books. Every fitting here is a thin adapter that unpacks a uniform
15context into the arguments one of those functions wants -- deliberately thin, so
16that what this package contributes is the plumbing and never the physics.
17
18Nothing in this module computes a loss coefficient from first principles. If a
19number here disagrees with Crane, it is this module's adapter that is wrong.
20"""
21
22from __future__ import annotations
23
24import math
25from dataclasses import dataclass, field
26from typing import Callable, Mapping
27
28import fluids.fittings as ft
29from fluids.friction import ft_Crane, friction_factor, friction_factor_methods
30
31#: Explicit friction-factor correlations, by the name used in a config.
32#:
33#: Asked of ``fluids`` rather than listed here, so the two cannot disagree. A
34#: hand-maintained list is how ``"Blasius"`` ended up in an early draft of this
35#: file: a real correlation, a real function in ``fluids``, and not a name
36#: ``friction_factor`` accepts -- which failed only when something selected it.
37#:
38#: All are Darcy friction factors. Clamond is the default: it solves the
39#: Colebrook equation essentially exactly rather than approximating it, and is
40#: fast enough that there is no reason to approximate.
41FRICTION_METHODS: tuple[str, ...] = tuple(
42 sorted(friction_factor_methods(Re=1.0e5, eD=1.0e-4))
43)
44
45DEFAULT_FRICTION_METHOD = "Clamond"
46
47#: Below this Reynolds number the flow is laminar and f = 64/Re exactly.
48#: ``fluids`` applies the same transition internally; it is named here because
49#: components report which regime they were in, and a silent regime switch is
50#: worth being able to see.
51LAMINAR_LIMIT = 2040.0
52"""Laminar-turbulent transition, from fluids.friction.LAMINAR_TRANSITION_PIPE."""
53
54
55def darcy_friction_factor(Re: float, roughness_ratio: float, method: str) -> float:
56 """Darcy friction factor from a named correlation.
57
58 Args:
59 Re: Reynolds number.
60 roughness_ratio: Absolute roughness over bore, e/D.
61 method: One of :data:`FRICTION_METHODS`.
62
63 Zero and negative Reynolds numbers return zero rather than raising: a
64 solver's first iterate legitimately passes through zero flow, and a
65 component with no flow through it has no frictional loss.
66
67 Below the transition the answer is 64/Re regardless of which turbulent
68 correlation was named -- ``fluids`` applies that itself, and it matters
69 because a solve sweeping down toward zero flow passes through the laminar
70 regime whether or not the design point is anywhere near it.
71 """
72 if Re <= 0.0:
73 return 0.0
74 if method not in FRICTION_METHODS:
75 raise ValueError(
76 f"unknown friction method {method!r}; available: "
77 f"{', '.join(FRICTION_METHODS)}"
78 )
79 return float(friction_factor(Re=Re, eD=roughness_ratio, Method=method))
80
81
82def reynolds(mdot: float, bore: float, rho: float, mu: float) -> float:
83 """Reynolds number from mass flow. ``Re = 4 m / (pi D mu)``.
84
85 Written in terms of mass flow rather than velocity because that is what a
86 network solver carries, and going via velocity would divide by density and
87 then multiply by it again.
88 """
89 if bore <= 0.0 or mu <= 0.0:
90 return 0.0
91 return abs(4.0 * mdot / (math.pi * bore * mu))
92
93
94def velocity(mdot: float, bore: float, rho: float) -> float:
95 """Bulk velocity [m/s] through a circular bore."""
96 if bore <= 0.0 or rho <= 0.0:
97 return 0.0
98 return mdot / (rho * math.pi * bore * bore / 4.0)
99
100
101@dataclass(frozen=True, slots=True)
103 """What every fitting correlation is given.
104
105 A uniform shape, so the registry can hold functions whose underlying
106 ``fluids`` signatures differ wildly -- a rounded bend wants an angle and a
107 bend radius, a contraction wants two diameters, an exit wants nothing.
108 """
109
110 bore: float
111 """Bore of the run the fitting sits in [m]."""
112
113 Re: float
114 roughness: float
115 fd: float
116 """Friction factor of the attached pipe, which several Crane correlations
117 scale with."""
118
119 params: Mapping[str, float] = field(default_factory=dict)
120 """Fitting-specific geometry: ``angle`` [deg, as ``fluids`` takes it],
121 ``bore2`` [m], ``bend_diameters`` [-]."""
122
123 def get(self, name: str, default: float) -> float:
124 return float(self.params.get(name, default))
125
126
127#: A fitting correlation: context in, resistance coefficient out.
128FittingK = Callable[[FittingContext], float]
129
130_FITTINGS: dict[str, FittingK] = {}
131
132
133def register_fitting(name: str, fn: FittingK) -> None:
134 """Register a fitting correlation. Replacing a name is allowed."""
135 _FITTINGS[name] = fn
136
137
138def get_fitting(name: str) -> FittingK:
139 if name not in _FITTINGS:
140 raise ValueError(
141 f"unknown fitting {name!r}; registered: {', '.join(registered_fittings())}"
142 )
143 return _FITTINGS[name]
144
145
146def registered_fittings() -> list[str]:
147 return sorted(_FITTINGS)
148
149
150def fitting_K(name: str, ctx: FittingContext) -> float:
151 """Resistance coefficient of one fitting, referred to ``ctx.bore``."""
152 return float(get_fitting(name)(ctx))
153
154
155# --------------------------------------------------------------------------
156# The shipped set. Each is an adapter onto fluids; none computes anything.
157# --------------------------------------------------------------------------
158
159
160def _bend(ctx: FittingContext, angle: float) -> float:
161 def rennels(Re: float | None, fd: float | None = None) -> float:
162 return float(
163 ft.bend_rounded(
164 Di=ctx.bore,
165 angle=ctx.get("angle", angle),
166 fd=fd,
167 roughness=ctx.roughness,
168 bend_diameters=ctx.get("bend_diameters", 5.0),
169 Re=Re,
170 )
171 )
172
173 try:
174 return rennels(ctx.Re or None)
175 except ValueError:
176 # Below Re ~ 5 Rennels computes its own friction factor with Clamond's
177 # turbulent formula, which takes the log of a negative number. Hand it
178 # the line's own -- laminar-correct -- instead, and only then: where it
179 # works, the number is exactly fluids' as before.
180 if not ctx.fd > 0.0:
181 raise
182 return rennels(None, fd=ctx.fd)
183
184
185def _contraction(ctx: FittingContext) -> float:
186 return float(
187 ft.contraction_sharp(
188 Di1=ctx.bore, Di2=ctx.get("bore2", ctx.bore * 0.5), Re=ctx.Re or None
189 )
190 )
191
192
193def _expansion(ctx: FittingContext) -> float:
194 return float(
195 ft.diffuser_sharp(
196 Di1=ctx.bore, Di2=ctx.get("bore2", ctx.bore * 2.0), Re=ctx.Re or None
197 )
198 )
199
200
201def _crane(constant: float) -> FittingK:
202 """A Crane coefficient expressed as a multiple of the pipe friction factor.
203
204 Crane TP-410 gives many fittings as ``K = n . f_T``, where f_T is the
205 fully-turbulent friction factor *of the pipe size* -- ``fluids.fittings.
206 ft_Crane(D)``, a constant of the bore, not the line's friction factor at its
207 own Reynolds number. Scaling ``ctx.fd`` instead put a 30 f_T elbow anywhere
208 from 0.55 to 1.09 against Crane's 0.885 in 9.5 mm tube, and laminar flow
209 (64/Re) made it larger still.
210 """
211
212 def K(ctx: FittingContext) -> float:
213 return constant * float(ft_Crane(ctx.bore))
214
215 return K
216
217
218register_fitting("elbow_90", lambda ctx: _bend(ctx, 90.0))
219register_fitting("elbow_45", lambda ctx: _bend(ctx, 45.0))
220register_fitting("bend", lambda ctx: _bend(ctx, ctx.get("angle", 90.0)))
221register_fitting("contraction", _contraction)
222register_fitting("expansion", _expansion)
223register_fitting("entrance_sharp", lambda ctx: float(ft.entrance_sharp()))
224register_fitting("exit", lambda ctx: float(ft.exit_normal()))
225# A segment's tee carries one flow and knows nothing of the other leg's, so it
226# is Crane's *unsplit* tee: flow straight through the run 20 f_T, flow turning
227# through the branch 60 f_T (TP-410's standard-tee multiples). The split-flow
228# correlations (K_{run,branch}_{converging,diverging}_Crane) need the other
229# leg's flow and direction; these used to read the converging pair at a split
230# of 0.5/0.5 -- a ratio of one, all of the flow from the branch -- which gave
231# the branch 0.41 (Crane 1.77) and a run carrying nothing 0.525.
232register_fitting("tee_run", _crane(20.0))
233register_fitting("tee_branch", _crane(60.0))
235 "ball_valve_full",
236 lambda ctx: float(ft.K_ball_valve_Crane(ctx.bore, ctx.bore, 0.0)),
237)
239 "gate_valve_full",
240 lambda ctx: float(ft.K_gate_valve_Crane(ctx.bore, ctx.bore, 0.0)),
241)
243 "globe_valve", lambda ctx: float(ft.K_globe_valve_Crane(ctx.bore, ctx.bore))
244)
246 "swing_check", lambda ctx: float(ft.K_swing_check_valve_Crane(ctx.bore))
247)
248# Crane's f_T multiples, for fittings given that way in the book.
249register_fitting("elbow_90_crane", _crane(30.0))
250register_fitting("elbow_45_crane", _crane(16.0))
float get(self, str name, float default)
float _expansion(FittingContext ctx)
float _bend(FittingContext ctx, float angle)
float velocity(float mdot, float bore, float rho)
float reynolds(float mdot, float bore, float rho, float mu)
float _contraction(FittingContext ctx)
float darcy_friction_factor(float Re, float roughness_ratio, str method)
None register_fitting(str name, FittingK fn)
FittingK _crane(float constant)
FittingK get_fitting(str name)
float fitting_K(str name, FittingContext ctx)