STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
segments.py
Go to the documentation of this file.
1"""Read a line's segment list off a P&ID edge.
2
3`pid-designer` writes ``data.segments`` on an edge: the run broken into
4segments, each with a tube size, a bore, a developed length and a counted list
5of fittings. Until this module existed the reader stopped at ``data.params``, so
6an itemised line arrived at the solver as an *unstated* one and got a default
7with a shrug -- while the drawing's own editor was greying out the line-level
8``Length`` and ``Bore`` fields and saying "superseded by the segments below".
9The field marked superseded was the only one being read.
10
11The shapes are :mod:`feedtwin.model.segments`, which is also where the physics
12of the thing is written down. This module is only the boundary: JSON in,
13provenance checked, dataclasses out.
14"""
15
16from __future__ import annotations
17
18from typing import Any, Mapping, Sequence
19
20from feedtwin.model import Param, Provenance
21from feedtwin.model.curve import Curve, CurveError
22from feedtwin.model.segments import (
23 DEFAULT_METHOD,
24 LOSS_METHODS,
25 Fitting,
26 LineLoss,
27 LineSegment,
28)
29from feedtwin.pid.errors import DiagramError
30
31_PROVENANCE = {p.value: p for p in Provenance}
32
33#: Length bases the drawing may declare. Anything else is read as ``"tube"``,
34#: which is the conservative reading: it subtracts nothing, so it can only
35#: over-count length, never invent tube that is not there.
36_BASES = frozenset({"tube", "overall"})
37
38
39def read_segments(raw: Any, where: str) -> LineLoss:
40 """Read ``data.segments`` off an edge.
41
42 Absent, empty or malformed gives an empty :class:`LineLoss`, which the
43 caller reads as "this line says nothing" and falls back to the line-level
44 parameters -- the behaviour every existing drawing already gets.
45
46 Raises:
47 DiagramError: a segment states a value with no recognised provenance.
48 Same contract as the reader's own parameters, and for the same
49 reason: a number whose source is unrecognised means the document
50 came from something other than this drawing tool, and quietly
51 defaulting it turns a format mismatch into a figure nobody checked.
52 """
53 if not isinstance(raw, Sequence) or isinstance(raw, (str, bytes)):
54 return LineLoss()
55
56 read: list[tuple[LineSegment, str, str, list[str]]] = []
57 known = _known_fittings()
58
59 for index, entry in enumerate(raw):
60 if not isinstance(entry, Mapping):
61 continue
62 seg_id = str(entry.get("id", "") or f"s{index + 1}")
63 place = f"{where} segment {seg_id}"
64 warnings: list[str] = []
65
66 method = str(entry.get("method") or DEFAULT_METHOD)
67 if method not in LOSS_METHODS:
68 warnings.append(
69 f"{place}: loss method {method!r} is not one of "
70 f"{', '.join(LOSS_METHODS)}; read as {DEFAULT_METHOD!r}"
71 )
72 method = DEFAULT_METHOD
73
74 fittings, fitting_warnings = _fittings(entry.get("fittings"), place, known)
75 warnings.extend(fitting_warnings)
76
77 basis = str(entry.get("lengthBasis") or "tube")
78 segment = LineSegment(
79 id=seg_id,
80 method=method,
81 bore=_param(entry.get("bore"), "bore", place),
82 length=_param(entry.get("length"), "length", place),
83 roughness=_param(entry.get("roughness"), "roughness", place),
84 elevation_change=_param(
85 entry.get("elevation_change"), "elevation_change", place
86 ),
87 K=_param(entry.get("K"), "K", place),
88 length_basis=basis if basis in _BASES else "tube",
89 tube_size=str(entry.get("tubeSize", "") or ""),
90 standard=str(entry.get("standard", "") or ""),
91 fittings=fittings,
92 curve=_curve(entry.get("curve"), place, warnings),
93 )
94 read.append((segment, basis, place, warnings))
95
96 # A segment with no bore of its own is sized at the narrowest bore stated
97 # on the run (`SegmentedPipe`), so what its missing bore costs is known
98 # only once the whole list is.
99 stated = [segment.bore_si for segment, *_ in read if segment.bore_si > 0.0]
100 run_bore = min(stated) if stated else 0.0
101 said: list[str] = []
102 for segment, basis, place, warnings in read:
103 said.extend(warnings)
104 said.extend(_check(segment, basis, place, run_bore))
105 return LineLoss(segments=tuple(s for s, *_ in read), warnings=tuple(said))
106
107
109 segment: LineSegment, basis: str, place: str, run_bore: float = 0.0
110) -> list[str]:
111 """What the drawing said that will not survive contact with the solver.
112
113 Every one of these is a case where the segment still produces a number --
114 the point is that the number is not the one the drawing appears to promise,
115 so it is said out loud rather than absorbed. ``run_bore`` is the narrowest
116 bore stated anywhere on the run [m], zero for none: what the solver sizes a
117 segment with no bore of its own at.
118 """
119 out: list[str] = []
120 unbored = segment.bore is None or segment.bore.si <= 0.0
121 if basis == "overall" and segment.fittings:
122 if any(f.length <= 0.0 for f in segment.fittings):
123 out.append(
124 f"{place}: length is end-to-end but a fitting states no body "
125 "length, so the tube length cannot be worked out; the stated "
126 "length is used whole and those fittings are counted twice"
127 )
128 elif segment.tube_length() <= 0.0:
129 out.append(
130 f"{place}: the fitting bodies are as long as the run, leaving "
131 "no tube; friction on this segment is zero and only the "
132 "fittings contribute"
133 )
134 if segment.method == "curve" and not segment.has_curve:
135 out.append(
136 f"{place}: declares a measured curve but carries no usable points; "
137 "it falls back down the ladder"
138 )
139 if segment.method == "measured_K" and segment.K is None:
140 out.append(
141 f"{place}: declares a measured K but states no K; it falls back "
142 "down the ladder"
143 )
144 if segment.method == "lumped_K" and segment.K is None:
145 out.append(f"{place}: declares a lumped K but states no K; it is unstated")
146 if segment.method == "itemised":
147 if segment.length is None:
148 out.append(
149 f"{place}: itemised but states no length, so it contributes "
150 "its fittings and no pipe friction"
151 )
152 if unbored and run_bore > 0.0:
153 # Split before its diameter was given: priced, at a bore the drawing
154 # gave somewhere else. This used to say "contributes nothing",
155 # which the solver does not do.
156 out.append(
157 f"{place}: itemised but states no bore; its friction and "
158 "fittings are sized at the run's narrowest stated bore, "
159 f"{run_bore * 1e3:.2f} mm, which the drawing does not say"
160 )
161 elif unbored:
162 # A stated 0 mm is no bore too; it used to pass here and the run
163 # then contributed nothing, silently (LE4's fuel tank-to-main run).
164 out.append(
165 f"{place}: itemised but states no bore, so neither its friction "
166 "nor its fittings can be sized; it contributes nothing"
167 )
168 if segment.method in ("measured_K", "lumped_K") and segment.K is not None:
169 # A K multiplies a dynamic head, and a head needs a bore.
170 if unbored and run_bore > 0.0:
171 out.append(
172 f"{place}: states a K but no bore; the K is referred to the "
173 f"run's narrowest stated bore, {run_bore * 1e3:.2f} mm, which "
174 "the drawing does not say"
175 )
176 elif unbored:
177 out.append(
178 f"{place}: states a K but no bore to refer it to, here or "
179 "anywhere on the run; it contributes nothing"
180 )
181 return out
182
183
184def _curve(raw: Any, where: str, warnings: list[str]) -> Curve | None:
185 """A ``{mdot, mdotUnit, dp, dpUnit}`` table, or None.
186
187 A curve is the top of the ladder, so a malformed one is *not* silently
188 dropped to a lower rung -- it is reported, and then dropped, because a run
189 somebody flowed and recorded badly is a thing to go and fix rather than to
190 quietly replace with a correlation.
191 """
192 if not isinstance(raw, Mapping):
193 return None
194 x, y = _floats(raw.get("mdot")), _floats(raw.get("dp"))
195 if not x or not y:
196 return None
197 try:
198 return Curve(
199 x=tuple(x),
200 y=tuple(y),
201 x_unit=str(raw.get("mdotUnit", "kg/s") or "kg/s"),
202 y_unit=str(raw.get("dpUnit", "Pa") or "Pa"),
203 # A curve came off a flow bench by definition -- that is what makes
204 # it a curve rather than an estimate.
205 source=Provenance.MEASURED,
206 reference=str(raw.get("reference", "") or ""),
207 )
208 except (CurveError, TypeError, ValueError) as exc:
209 warnings.append(f"{where}: the measured curve cannot be read ({exc})")
210 return None
211
212
213def _param(entry: Any, name: str, where: str) -> Param | None:
214 """One ``{value, unit, source, reference}`` block, or None if unstated."""
215 if not isinstance(entry, Mapping) or entry.get("value") is None:
216 return None
217 source = str(entry.get("source", ""))
218 if source not in _PROVENANCE:
219 raise DiagramError(
220 f"{where}: {name} has source {source!r}; expected one of "
221 f"{', '.join(sorted(_PROVENANCE))}"
222 )
223 try:
224 return Param(
225 value=float(entry["value"]),
226 unit=str(entry.get("unit", "-")),
227 source=_PROVENANCE[source],
228 reference=str(entry.get("reference", "")),
229 )
230 except (KeyError, TypeError, ValueError) as exc:
231 raise DiagramError(f"{where}: {name} is malformed ({exc})")
232
233
235 raw: Any, where: str, known: frozenset[str]
236) -> tuple[tuple[Fitting, ...], list[str]]:
237 out: list[Fitting] = []
238 warnings: list[str] = []
239 if not isinstance(raw, Sequence) or isinstance(raw, (str, bytes)):
240 return (), warnings
241 for entry in raw:
242 if not isinstance(entry, Mapping):
243 continue
244 kind = str(entry.get("kind", "")).strip()
245 if not kind:
246 continue
247 count = _count(entry.get("count"))
248 if kind not in known:
249 warnings.append(
250 f"{where}: fitting {kind!r} is not one this library can price; "
251 "it is carried through but contributes no loss"
252 )
253 if count == 0:
254 continue
255 out.append(
256 Fitting(
257 kind=kind,
258 count=count,
259 # Millimetres on the wire: these are catalogue numbers, which
260 # the drawing stores as plain mm rather than as provenanced
261 # parameters, because they come from the part and not from
262 # anyone's judgement.
263 bore=_mm(entry.get("boreMm")),
264 length=_mm(entry.get("lengthMm")),
265 engagement=_mm(entry.get("engagementMm")),
266 K=_plain(entry.get("K")),
267 # A sketched bend carries its geometry; a counted elbow does
268 # not, and the correlation's defaults stand.
269 bend_diameters=_plain(entry.get("bendDiameters")),
270 angle=_plain(entry.get("angleDeg")),
271 )
272 )
273 return tuple(out), warnings
274
275
276def _count(value: Any) -> int:
277 try:
278 return max(int(value), 0) if value is not None else 1
279 except (TypeError, ValueError):
280 return 1
281
282
283def _mm(value: Any) -> float:
284 try:
285 return float(value) / 1000.0 if value is not None else 0.0
286 except (TypeError, ValueError):
287 return 0.0
288
289
290def _plain(value: Any) -> float:
291 if isinstance(value, Mapping):
292 value = value.get("value")
293 try:
294 return float(value) if value is not None else 0.0
295 except (TypeError, ValueError):
296 return 0.0
297
298
299def _floats(raw: Any) -> list[float]:
300 """A list of numbers, or nothing. One bad entry voids the list.
301
302 Half a curve is not a curve -- interpolating across a hole in it would put
303 a straight line through whatever the drawing failed to record.
304 """
305 if not isinstance(raw, Sequence) or isinstance(raw, (str, bytes)):
306 return []
307 out: list[float] = []
308 for value in raw:
309 try:
310 out.append(float(value))
311 except (TypeError, ValueError):
312 return []
313 return out
314
315
316def _known_fittings() -> frozenset[str]:
317 # Imported here rather than at module scope: feedtwin.comps pulls in
318 # CoolProp, and feedtwin.pid.document is imported by tools that only want
319 # to read a drawing.
320 from feedtwin.comps.correlations import registered_fittings
321
322 return frozenset(registered_fittings())
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
float _plain(Any value)
Definition segments.py:290
list[str] _check(LineSegment segment, str basis, str place, float run_bore=0.0)
Definition segments.py:110
LineLoss read_segments(Any raw, str where)
Definition segments.py:39
frozenset[str] _known_fittings()
Definition segments.py:316
float _mm(Any value)
Definition segments.py:283
int _count(Any value)
Definition segments.py:276
Curve|None _curve(Any raw, str where, list[str] warnings)
Definition segments.py:184
list[float] _floats(Any raw)
Definition segments.py:299
tuple[tuple[Fitting,...], list[str]] _fittings(Any raw, str where, frozenset[str] known)
Definition segments.py:236
Param|None _param(Any entry, str name, str where)
Definition segments.py:213