STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_segments.py
Go to the documentation of this file.
1"""Phase 11b: a line's loss as the drawing itemises it.
2
3The bug these guard against is not a wrong correlation. It is a *silently
4discarded* one: ``data.segments`` was written by the drawing editor and never
5read, so a line somebody had itemised down to the elbow arrived at the solver as
6an unstated line and got a default. The drawing was greying out the line-level
7Length and Bore and saying "superseded by the segments below", and the
8superseded field was the only one being read.
9
10So these tests are mostly about *which* number won.
11"""
12
13from __future__ import annotations
14
15import math
16
17import pytest
18
19from feedtwin.comps import build_component
20from feedtwin.comps.base import FlowConditions
21from feedtwin.comps.correlations import darcy_friction_factor
22from feedtwin.model import ComponentInstance, Param, Provenance
23from feedtwin.model.segments import LineLoss, LineSegment
24from feedtwin.pid import DiagramError, read_diagram
25from feedtwin.pid.segments import read_segments
26
27RHO, MU = 789.0, 1.2e-3
28"""Ethanol at room temperature, near enough. The numbers below are hand-worked
29against these, so they are constants of the test and not of the fluid."""
30
31FLOW = FlowConditions(rho=RHO, mu=MU, p_upstream=4.0e6, temperature=293.15)
32
33
34def measured(value: float, unit: str) -> dict[str, object]:
35 return {"value": value, "unit": unit, "source": "measured"}
36
37
38def pipe(segments, **params) -> object:
39 return build_component(
40 ComponentInstance.build(
41 "L1", "pipe", params, model="segmented", segments=segments
42 )
43 )
44
45
46# --------------------------------------------------------------- the reader
47
48
50 """The whole point. `PidEdge.segments` used not to exist."""
51 loss = read_segments(
52 [{"id": "s1", "bore": measured(10.2, "mm"), "length": measured(1.6, "m")}],
53 "edge L1",
54 )
55 assert len(loss) == 1
56 assert loss.segments[0].bore_si == pytest.approx(0.0102)
57 assert loss.segments[0].method == "itemised", "a silent segment is itemised"
58
59
61 """Every drawing that shipped before this has no segments and must not move."""
62 for raw in (None, [], {}, "segments", 7):
63 assert read_segments(raw, "edge L1") == LineLoss()
64
65
67 """Same contract as every other parameter: no source, no number.
68
69 A silent default here would turn a format mismatch -- a document written by
70 something that is not this drawing tool -- into a figure nobody checked.
71 """
72 with pytest.raises(DiagramError, match="source"):
73 read_segments([{"id": "s1", "bore": {"value": 10.2, "unit": "mm"}}], "edge L1")
74
75
77 loss = read_segments(
78 [
79 {
80 "id": "a",
81 "method": "curve",
82 "bore": measured(10.0, "mm"),
83 "curve": {
84 "mdot": [0.0, 1.0],
85 "mdotUnit": "kg/s",
86 "dp": [0.0, 10.0],
87 "dpUnit": "psi",
88 },
89 },
90 {
91 "id": "b",
92 "method": "lumped_K",
93 "bore": measured(10.0, "mm"),
94 "K": measured(2.0, "-"),
95 },
96 ],
97 "edge L1",
98 )
99 assert loss.method == "curve", "the line is as well known as its best segment"
100 assert loss.weakest_method == "lumped_K", "and only as trustworthy as its worst"
101
102
104 """psi on the wire, Pa in the solver. Reading it as Pa would be 6895x wrong."""
105 loss = read_segments(
106 [
107 {
108 "id": "s",
109 "method": "curve",
110 "curve": {
111 "mdot": [0.0, 0.5, 1.0],
112 "mdotUnit": "kg/s",
113 "dp": [0.0, 10.0, 40.0],
114 "dpUnit": "psi",
115 },
116 }
117 ],
118 "edge L1",
119 )
120 curve = loss.segments[0].curve
121 assert curve is not None
122 assert curve(0.75) == pytest.approx(25.0 * 6894.757293168361)
123
124
126 """A run somebody flowed and recorded badly is a thing to go and fix."""
127 loss = read_segments(
128 [
129 {
130 "id": "s",
131 "method": "curve",
132 "curve": {"mdot": [1.0, 1.0], "dp": [0.0, 1.0]},
133 }
134 ],
135 "edge L1",
136 )
137 assert loss.segments[0].curve is None
138 assert any("cannot be read" in w for w in loss.warnings)
139
140
142 """The drawing is allowed to be ahead of the solver. A dropped elbow is not."""
143 loss = read_segments(
144 [
145 {
146 "id": "s",
147 "bore": measured(10.0, "mm"),
148 "length": measured(1.0, "m"),
149 "fittings": [{"kind": "venturi", "count": 1}],
150 }
151 ],
152 "edge L1",
153 )
154 assert [f.kind for f in loss.segments[0].fittings] == ["venturi"]
155 assert any("venturi" in w and "price" in w for w in loss.warnings)
156
157
158# ------------------------------------------------------------- the physics
159
160
162 """The new path must not move an answer the old path already got right.
163
164 Bit-identical, not approximately: a segmented run of one segment is the same
165 equation with the same friction factor at the same Reynolds number.
166 """
167 plain = build_component(
168 ComponentInstance.build(
169 "L1",
170 "pipe",
171 {
172 "length": Param(1.6, "m", Provenance.MEASURED),
173 "bore": Param(10.2, "mm", Provenance.MEASURED),
174 },
175 model="darcy",
176 )
177 )
178 loss = read_segments(
179 [{"id": "s", "bore": measured(10.2, "mm"), "length": measured(1.6, "m")}],
180 "edge L1",
181 )
182 assert pipe(loss.segments).pressure_drop(1.0, FLOW) == plain.pressure_drop(
183 1.0, FLOW
184 )
185
186
188 """Against the equation, not against the other code path."""
189 bore, length, mdot = 0.0102, 1.6, 1.0
190 area = math.pi * bore * bore / 4.0
191 v = mdot / (RHO * area)
192 Re = RHO * v * bore / MU
193 fd = darcy_friction_factor(Re, 1.5e-6 / bore, "Clamond")
194 hand = fd * length / bore * 0.5 * RHO * v * v
195
196 loss = read_segments(
197 [{"id": "s", "bore": measured(10.2, "mm"), "length": measured(1.6, "m")}],
198 "edge L1",
199 )
200 assert pipe(loss.segments).pressure_drop(mdot, FLOW) == pytest.approx(hand)
201
202
204 """A fitting's K already contains its own friction.
205
206 Counting the elbow's own arc as tube *and* pricing the elbow pays for it
207 twice. Three long-radius elbows in a 1.6 m run are about 72 mm, which is
208 the several percent of dp this asserts is actually removed.
209 """
210 rows = [{"kind": "elbow_90", "count": 3, "lengthMm": 30, "engagementMm": 6}]
211 base = {
212 "id": "s",
213 "bore": measured(10.2, "mm"),
214 "length": measured(1.6, "m"),
215 "fittings": rows,
216 }
217 as_tube = read_segments([{**base, "lengthBasis": "tube"}], "edge L1")
218 end_to_end = read_segments([{**base, "lengthBasis": "overall"}], "edge L1")
219
220 assert as_tube.segments[0].tube_length() == pytest.approx(1.6)
221 assert end_to_end.segments[0].tube_length() == pytest.approx(1.6 - 3 * 0.024)
222
223 over = pipe(end_to_end.segments).pressure_drop(1.0, FLOW)
224 tube = pipe(as_tube.segments).pressure_drop(1.0, FLOW)
225 assert over < tube, "the end-to-end run has less tube in it, so less friction"
226 assert (tube - over) / over == pytest.approx(0.0369, abs=0.002)
227
228
230 """A partial subtraction is a wrong number that looks careful."""
231 loss = read_segments(
232 [
233 {
234 "id": "s",
235 "bore": measured(10.0, "mm"),
236 "length": measured(1.0, "m"),
237 "lengthBasis": "overall",
238 "fittings": [
239 {"kind": "elbow_90", "count": 1, "lengthMm": 30},
240 {"kind": "tee_run", "count": 1},
241 ],
242 }
243 ],
244 "edge L1",
245 )
246 assert loss.segments[0].tube_length() == pytest.approx(1.0)
247 assert any("counted twice" in w for w in loss.warnings)
248
249
251 """Two adjacent segments of different bore *are* the transition.
252
253 Making somebody add a row for it is a step they can forget, and then the run
254 is quietly cheap.
255 """
256 straight = read_segments(
257 [
258 {"id": "a", "bore": measured(10.2, "mm"), "length": measured(0.8, "m")},
259 {"id": "b", "bore": measured(10.2, "mm"), "length": measured(0.8, "m")},
260 ],
261 "edge L1",
262 )
263 stepped = read_segments(
264 [
265 {"id": "a", "bore": measured(10.2, "mm"), "length": measured(0.8, "m")},
266 {"id": "b", "bore": measured(7.75, "mm"), "length": measured(0.8, "m")},
267 ],
268 "edge L1",
269 )
270 assert pipe(stepped.segments).pressure_drop(1.0, FLOW) > pipe(
271 straight.segments
272 ).pressure_drop(1.0, FLOW)
273
274
276 """Why this class exists.
277
278 Velocity head goes as D^-4 and L/D adds a fifth power, so the loss of a run
279 that steps bore is nowhere near the loss of the same run at its mean bore.
280 Lumping under-predicts by tens of percent, always in the unsafe direction.
281 """
282 stepped = read_segments(
283 [
284 {"id": "a", "bore": measured(10.2, "mm"), "length": measured(0.8, "m")},
285 {"id": "b", "bore": measured(7.75, "mm"), "length": measured(0.8, "m")},
286 ],
287 "edge L1",
288 )
289 lumped = read_segments(
290 [
291 {
292 "id": "a",
293 "bore": measured((10.2 + 7.75) / 2, "mm"),
294 "length": measured(1.6, "m"),
295 }
296 ],
297 "edge L1",
298 )
299 real = pipe(stepped.segments).pressure_drop(1.0, FLOW)
300 mean = pipe(lumped.segments).pressure_drop(1.0, FLOW)
301 assert mean < real
302 assert (real - mean) / real > 0.25
303
304
306 """Where a velocity limit bites and where cavitation starts."""
307 loss = read_segments(
308 [
309 {"id": "a", "bore": measured(10.2, "mm"), "length": measured(0.8, "m")},
310 {"id": "b", "bore": measured(7.75, "mm"), "length": measured(0.8, "m")},
311 ],
312 "edge L1",
313 )
314 diagnostics = pipe(loss.segments).diagnostics(1.0, FLOW)
315 assert diagnostics["bore_min"] == pytest.approx(0.00775)
316 assert diagnostics["velocity"] == pytest.approx(
317 1.0 / (RHO * math.pi * 0.00775**2 / 4.0)
318 )
319 assert diagnostics["segments"] == 2.0
320
321
323 """Top of the ladder. The curve's number is used, not a friction factor."""
324 loss = read_segments(
325 [
326 {
327 "id": "s",
328 "method": "curve",
329 "bore": measured(10.0, "mm"),
330 "length": measured(50.0, "m"),
331 "curve": {
332 "mdot": [0.0, 2.0],
333 "mdotUnit": "kg/s",
334 "dp": [0.0, 1.0],
335 "dpUnit": "psi",
336 },
337 }
338 ],
339 "edge L1",
340 )
341 dp = pipe(loss.segments).pressure_drop(1.0, FLOW)
342 assert dp == pytest.approx(
343 0.5 * 6894.757293168361
344 ), "fifty metres of correlation must not leak past a measured curve"
345
346
348 """A clamped curve takes the flow dependence out of the branch equation.
349
350 That is a singular Jacobian, not a conservative answer -- so past the last
351 measured point the turbulent law the curve is a measurement *of* carries it.
352 """
353 loss = read_segments(
354 [
355 {
356 "id": "s",
357 "method": "curve",
358 "curve": {
359 "mdot": [0.0, 1.0],
360 "mdotUnit": "kg/s",
361 "dp": [0.0, 100.0],
362 "dpUnit": "Pa",
363 },
364 }
365 ],
366 "edge L1",
367 )
368 line = pipe(loss.segments)
369 assert line.pressure_drop(1.0, FLOW) == pytest.approx(100.0)
370 assert line.pressure_drop(2.0, FLOW) == pytest.approx(400.0)
371 assert line.pressure_drop(1.5, FLOW) > line.pressure_drop(1.0, FLOW)
372
373
375 from feedtwin.model import SpecError
376
377 with pytest.raises(SpecError, match="segment list"):
378 pipe(())
379
380
381# --------------------------------------------------------- through the drawing
382
383
384def _drawing(segments: list[dict[str, object]]) -> dict[str, object]:
385 """Two bottles and a line, which is the smallest thing that has an edge."""
386 return {
387 "name": "segment smoke",
388 "nodes": [
389 {
390 "id": "n1",
391 "type": "KBOTTLE",
392 "data": {
393 "label": "K-01",
394 "fluidType": "nitrogen",
395 "params": {
396 "pressure": measured(2000.0, "psi"),
397 "volume": measured(50.0, "L"),
398 },
399 },
400 },
401 {"id": "n2", "type": "VENT", "data": {"label": "V-01"}},
402 ],
403 "edges": [
404 {
405 "id": "L1",
406 "source": "n1",
407 "target": "n2",
408 "data": {
409 "lineType": "pipe",
410 "params": {
411 "length": measured(9.9, "m"),
412 "bore": measured(25.0, "mm"),
413 },
414 "segments": segments,
415 },
416 }
417 ],
418 }
419
420
422 """The drawing greys those fields out. Summing both is the double-count."""
423 doc = _drawing(
424 [{"id": "s", "bore": measured(6.0, "mm"), "length": measured(0.4, "m")}]
425 )
426 built = read_diagram(doc)
427 edge = next(e for e in built.edges if e.id == "L1")
428 assert len(edge.segments) == 1
429 assert edge.params["length"].si == pytest.approx(9.9), "still on the drawing"
430
431 from feedtwin.pid import build_network
432
433 network = build_network(built)
434 line = network.network.branches["L1"].component
435 assert line.instance.model == "segmented"
436 assert "length" not in line.p, "9.9 m of leftover must not reach the solver"
437 assert line.diagnostics(0.01, FLOW)["length_tube"] == pytest.approx(0.4)
438
439
441 from feedtwin.pid import build_network
442
443 network = build_network(read_diagram(_drawing([])))
444 line = network.network.branches["L1"].component
445 assert line.instance.model == "darcy"
446 assert line.p["length"] == pytest.approx(9.9)
447
448
450 """The spec would drop it anyway -- but silently, and 9.9 m is a lot to lose.
451
452 If the segments turn out to be wrong, the stale line-level value is the
453 first place anybody would look, so the run report has to mention it.
454 """
455 from feedtwin.pid import build_network
456
457 doc = _drawing(
458 [{"id": "s", "bore": measured(6.0, "mm"), "length": measured(0.4, "m")}]
459 )
460 network = build_network(read_diagram(doc))
461 assert any(
462 "superseded" in w and "9.9 m" in w for w in network.warnings
463 ), network.warnings
464
465
467 from feedtwin.pid import build_network
468
469 doc = _drawing(
470 [{"id": "s", "bore": measured(6.0, "mm"), "length": measured(0.4, "m")}]
471 )
472 doc["edges"][0]["data"]["params"] = {}
473 network = build_network(read_diagram(doc))
474 assert not any("superseded" in w for w in network.warnings)
475
476
478 """A bend from the centerline sketch carries r/D and the angle it turns.
479 The reader keeps both, and the run prices the bend on them rather than
480 on the correlation's defaults -- a tight bend and a sweep are not the
481 same fitting."""
482 from feedtwin.pid.segments import read_segments
483
484 def run(bend: dict) -> float:
485 loss = read_segments(
486 [
487 {
488 "id": "s1",
489 "bore": {"value": 10.0, "unit": "mm", "source": "measured"},
490 "length": {"value": 1.0, "unit": "m", "source": "measured"},
491 "fittings": [{"kind": "bend", "count": 1, **bend}],
492 }
493 ],
494 "L1",
495 )
496 fitting = loss.segments[0].fittings[0]
497 return fitting.bend_diameters, fitting.angle # type: ignore[return-value]
498
499 assert run({"bendDiameters": 1.5, "angleDeg": 90.0}) == (1.5, 90.0)
500 assert run({}) == (0.0, 0.0)
501
502
504 """A capped run -- a fill line behind a shut QD, a vent leg -- is back-filled
505 by the solver at exactly zero flow, and opening a stand prices every run at
506 rest. Bends and bore changes are K times a dynamic head that is zero there,
507 but the bend correlation (Rennels, via fluids) refuses a Reynolds number of
508 zero, and the ValueError escaped: a drawing with an itemised run on a capped
509 branch could not open as a stand at all (a 500 on POST /api/session)."""
510 from feedtwin.pid.segments import read_segments
511
512 def seg(sid: str, bore_mm: float) -> dict[str, object]:
513 return {
514 "id": sid,
515 "method": "itemised",
516 "bore": {"value": bore_mm, "unit": "mm", "source": "measured"},
517 "length": {"value": 0.4, "unit": "m", "source": "measured"},
518 "fittings": [
519 {"kind": "bend", "count": 2, "angleDeg": 45.0, "bendDiameters": 3.5}
520 ],
521 }
522
523 loss = read_segments([seg("s1", 10.9), seg("s2", 7.7)], "L1")
524 line = pipe(loss.segments, roughness=Param(0.03, "mm", Provenance.DEFAULT, ""))
525 assert line.pressure_drop(0.0, FLOW) == 0.0
526 assert line.total_dp(0.0, FLOW) == pytest.approx(line.static_head(FLOW))
527 # And it still prices a real flow: both bends and the contraction count.
528 assert line.pressure_drop(0.2, FLOW) > 0.0
529
530
532 """Below Re ~ 5 fluids' Rennels bend computes its own friction factor with
533 Clamond's turbulent formula, which takes a log of a negative number and
534 raises. A solve reporting its result at a near-zero flow (a press line
535 settling, a tank topping up) hit that and the error escaped the solve. The
536 adapter now hands Rennels the friction factor the run already has --
537 laminar-correct -- whenever its own refuses, and only then."""
538 from feedtwin.pid.segments import read_segments
539
540 loss = read_segments(
541 [
542 {
543 "id": "s1",
544 "method": "itemised",
545 "bore": {"value": 10.9, "unit": "mm", "source": "measured"},
546 "length": {"value": 0.7, "unit": "m", "source": "measured"},
547 "fittings": [
548 {"kind": "bend", "count": 3, "angleDeg": 45.0, "bendDiameters": 3.5}
549 ],
550 }
551 ],
552 "L1",
553 )
554 line = pipe(loss.segments, roughness=Param(0.03, "mm", Provenance.DEFAULT, ""))
555 trickle = 1.0e-9 # kg/s: Re ~ 1e-4 in ethanol
556 dp = line.pressure_drop(trickle, FLOW)
557 assert dp > 0.0 and dp < 1.0
558 # Where Rennels already worked, nothing changes.
559 import fluids.fittings as ft
560 from feedtwin.comps.correlations import FittingContext, fitting_K
561
562 ctx = FittingContext(
563 bore=0.0109,
564 Re=5.0e4,
565 roughness=3e-5,
566 fd=0.02,
567 params={"angle": 45.0, "bend_diameters": 3.5},
568 )
569 assert fitting_K("bend", ctx) == ft.bend_rounded(
570 Di=0.0109, angle=45.0, Re=5.0e4, roughness=3e-5, bend_diameters=3.5
571 )
Definition run.py:1
None test_a_fitting_body_is_not_also_tube()
None test_the_ladder_reports_the_best_and_the_worst()
None test_nothing_is_said_when_there_was_nothing_to_supersede()
None test_a_trickle_through_an_itemised_run_is_priced_not_raised()
None test_the_bore_is_inside_the_sum_not_averaged()
None test_a_malformed_curve_is_reported_not_swallowed()
None test_a_segmented_line_needs_a_segment_list()
None test_an_unknown_fitting_is_carried_and_reported()
None test_a_curve_keeps_its_units()
dict[str, object] _drawing(list[dict[str, object]] segments)
dict[str, object] measured(float value, str unit)
None test_velocity_is_reported_at_the_tightest_bore()
None test_a_superseded_line_level_length_is_said_out_loud()
None test_a_value_with_no_provenance_is_refused()
None test_a_segment_list_is_read_at_all()
None test_a_curve_is_continued_quadratically_not_clamped()
None test_a_measured_segment_beats_the_correlation()
None test_friction_matches_darcy_weisbach_worked_by_hand()
None test_the_line_level_length_is_superseded_not_added()
None test_an_itemised_run_at_zero_flow_drops_nothing_and_does_not_raise()
None test_an_unmeasurable_body_length_refuses_to_guess()
None test_a_sketched_bend_is_priced_at_its_own_radius_and_angle()
None test_a_line_with_no_segments_is_empty_not_defaulted()
None test_a_bore_change_is_a_reducer_nobody_drew()
None test_one_segment_is_the_darcy_pipe_exactly()
object pipe(segments, **params)
None test_a_drawing_without_segments_still_builds_a_darcy_pipe()