STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
segments.py
Go to the documentation of this file.
1"""A line's loss, as the drawing states it.
2
3A P&ID line is rarely one bore of one length. It is a run: some tube, an elbow,
4a reducer, more tube, a tee. `pid-designer` records exactly that -- an itemised
5list of segments, each with a tube size, a bore, a developed length and a
6counted list of fittings -- and this module is the shape that list arrives in.
7
8How a loss is known, and which one wins
9---------------------------------------
10A segment declares one method. They are ordered by authority, and exactly one is
11in force -- the vocabulary and the order are the drawing's, reproduced here
12rather than reinvented:
13
141. ``curve`` -- Delta p against mdot from a cold flow. Beats everything.
152. ``measured_K`` -- one K fitted from a run. Same authority, less data.
163. ``itemised`` -- tube size, length, fittings. The correlations are used here.
174. ``lumped_K`` -- one K somebody estimated.
185. ``unstated`` -- nothing said; defaulted, and reported unchecked.
19
20The point of the order is that "how well do we know this line" stops being a
21matter of opinion. A line that has been flowed has a number that beats every
22correlation; a line that has not been built yet has an itemised estimate. Both
23are first-class, and which one is in force is never ambiguous.
24
25The trap this module exists to avoid
26------------------------------------
27**A fitting's K already contains its own friction.** Crane TP-410, Hooper 2K and
28Darby 3K all price the whole fitting -- the flow through it, not just the extra
29loss over an equivalent length of tube. So if a run is measured end to end and
30that number is used as ``L`` *while* the elbows in it are also counted as K, the
31elbows get paid for twice.
32
33On a 1.6 m run with three long-radius elbows at r/D 1.5 in a 10 mm bore, the
34arcs are about 71 mm of the total: a 4.4% over-count of length. Friction is
35roughly half the loss on a run like that, so it is about 2% on Delta p -- the
36same order as miscounting an elbow, and in the same direction every time.
37
38So the friction term uses the **tube** length, and fittings contribute **K
39only**. A fitting's body length is not a loss input; it is recorded for the cut
40list, which is the fabricator's problem. That is what :attr:`LineSegment.length_basis`
41is for: ``"tube"`` means the stated length is already just tube, and
42``"overall"`` means it is end to end and the fitting bodies have to come out of
43it before it is used as ``L``.
44"""
45
46from __future__ import annotations
47
48from dataclasses import dataclass
49
50from feedtwin.model.curve import Curve
51from feedtwin.model.param import Param
52
53#: Loss methods, ordered by authority. Lower index wins.
54LOSS_METHODS: tuple[str, ...] = (
55 "curve",
56 "measured_K",
57 "itemised",
58 "lumped_K",
59 "unstated",
60)
61
62#: What a segment means when it does not say.
63#:
64#: The drawing's own default. A segment exists because somebody started
65#: itemising it, so reading a silent one as ``unstated`` would throw away the
66#: fittings they had already counted.
67DEFAULT_METHOD = "itemised"
68
69
70def method_rank(method: str) -> int:
71 """Where a method sits on the ladder. Unknown methods rank last."""
72 try:
73 return LOSS_METHODS.index(method)
74 except ValueError:
75 return len(LOSS_METHODS)
76
77
78@dataclass(frozen=True, slots=True)
79class Fitting:
80 """One kind of fitting, counted.
81
82 ``kind`` is the drawing's vocabulary, which is deliberately identical to
83 :func:`feedtwin.comps.correlations.registered_fittings` -- no mapping table
84 stands between the drawing and the correlation. A kind this library cannot
85 price is kept and reported rather than dropped: the drawing is allowed to be
86 ahead of the solver, and a silently ignored elbow is worse than a warning.
87 """
88
89 kind: str
90 count: int = 1
91 bore: float = 0.0
92 """Through-bore [m]. Zero means "use the segment's bore"."""
93 length: float = 0.0
94 """Body length [m]. **Not a loss input** -- see the module docstring."""
95 engagement: float = 0.0
96 """Thread engagement [m]. For the cut list, not the solve."""
97 K: float = 0.0
98 """An explicit K for this fitting, overriding the correlation. Zero means
99 "price it"."""
100 bend_diameters: float = 0.0
101 """A bend's centerline radius over its bore, r/D. Zero means "not stated",
102 and the bend correlation uses its own default. Comes off the drawing's
103 centerline sketch, where the radius was drawn."""
104 angle: float = 0.0
105 """A bend's turn [deg]. Zero means "not stated"; the correlation assumes
106 a right angle."""
107
108
109@dataclass(frozen=True, slots=True)
111 """One length of one bore, with whatever is fitted to it."""
112
113 id: str
114 method: str = DEFAULT_METHOD
115 bore: Param | None = None
116 length: Param | None = None
117 roughness: Param | None = None
118 elevation_change: Param | None = None
119 K: Param | None = None
120 length_basis: str = "tube"
121 tube_size: str = ""
122 standard: str = ""
123 fittings: tuple[Fitting, ...] = ()
124 curve: Curve | None = None
125 """Measured Delta p against mdot. A :class:`~feedtwin.model.curve.Curve`,
126 so it carries its own units and provenance and is evaluated in SI like
127 every other curve in the library."""
128
129 @property
130 def has_curve(self) -> bool:
131 return self.curve is not None
132
133 @property
134 def bore_si(self) -> float:
135 """Flow diameter [m], or zero if the segment does not state one."""
136 return self.bore.si if self.bore is not None else 0.0
137
138 @property
139 def stated_length(self) -> float:
140 """The length as written [m], whatever its basis."""
141 return self.length.si if self.length is not None else 0.0
142
143 def tube_length(self) -> float:
144 """Straight tube in this segment [m], with fitting bodies removed.
145
146 With ``length_basis == "overall"`` the stated length runs end to end, so
147 each fitting's body less its thread engagement comes out -- otherwise the
148 fittings are paid for twice, once as K and once as the tube they
149 displace.
150
151 This refuses to guess. If the basis is overall and any fitting has no
152 body length, the stated length is returned whole; the reader has already
153 warned about it. A partial subtraction is a wrong number that looks
154 careful, which is worse than a stated one that is flagged.
155 """
156 stated = self.stated_length
157 if self.length_basis != "overall" or not self.fittings:
158 return stated
159 if any(f.length <= 0.0 for f in self.fittings):
160 return stated
161 bodies = sum((f.length - f.engagement) * f.count for f in self.fittings)
162 return max(stated - bodies, 0.0)
163
164
165@dataclass(frozen=True, slots=True)
167 """Every segment on one line, and the method in force across it."""
168
169 segments: tuple[LineSegment, ...] = ()
170 warnings: tuple[str, ...] = ()
171
172 def __bool__(self) -> bool:
173 return bool(self.segments)
174
175 def __len__(self) -> int:
176 return len(self.segments)
177
178 @property
179 def method(self) -> str:
180 """The most authoritative method any segment declares.
181
182 One line, one label. A run that is half flowed and half estimated is
183 reported as well as its *best*-known part claims -- which is why this is
184 a maximum over the ladder and not a mixture. It is a description of the
185 line, not the recipe: each segment is still evaluated by its own method.
186 """
187 if not self.segments:
188 return "unstated"
189 return min((s.method for s in self.segments), key=method_rank)
190
191 @property
192 def weakest_method(self) -> str:
193 """The least authoritative method on the line.
194
195 The one worth acting on. A run is only as trustworthy as its worst
196 segment, so this is what a "which lines still need flowing" report reads.
197 """
198 if not self.segments:
199 return "unstated"
200 return max((s.method for s in self.segments), key=method_rank)
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
int method_rank(str method)
Definition segments.py:70