1"""Phase 11b: a line's loss as the drawing itemises it.
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.
10So these tests are mostly about *which* number won.
13from __future__
import annotations
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."""
34def measured(value: float, unit: str) -> dict[str, object]:
35 return {
"value": value,
"unit": unit,
"source":
"measured"}
38def pipe(segments, **params) -> object:
39 return build_component(
40 ComponentInstance.build(
41 "L1",
"pipe", params, model=
"segmented", segments=segments
50 """The whole point. `PidEdge.segments` used not to exist."""
56 assert loss.segments[0].bore_si == pytest.approx(0.0102)
57 assert loss.segments[0].method ==
"itemised",
"a silent segment is itemised"
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()
67 """Same contract as every other parameter: no source, no number.
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.
72 with pytest.raises(DiagramError, match=
"source"):
73 read_segments([{
"id":
"s1",
"bore": {
"value": 10.2,
"unit":
"mm"}}],
"edge L1")
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"
104 """psi on the wire, Pa in the solver. Reading it as Pa would be 6895x wrong."""
105 loss = read_segments(
111 "mdot": [0.0, 0.5, 1.0],
113 "dp": [0.0, 10.0, 40.0],
120 curve = loss.segments[0].curve
121 assert curve
is not None
122 assert curve(0.75) == pytest.approx(25.0 * 6894.757293168361)
126 """A run somebody flowed and recorded badly is a thing to go and fix."""
127 loss = read_segments(
132 "curve": {
"mdot": [1.0, 1.0],
"dp": [0.0, 1.0]},
137 assert loss.segments[0].curve
is None
138 assert any(
"cannot be read" in w
for w
in loss.warnings)
142 """The drawing is allowed to be ahead of the solver. A dropped elbow is not."""
143 loss = read_segments(
149 "fittings": [{
"kind":
"venturi",
"count": 1}],
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)
162 """The new path must not move an answer the old path already got right.
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.
167 plain = build_component(
168 ComponentInstance.build(
172 "length":
Param(1.6,
"m", Provenance.MEASURED),
173 "bore":
Param(10.2,
"mm", Provenance.MEASURED),
178 loss = read_segments(
182 assert pipe(loss.segments).pressure_drop(1.0, FLOW) == plain.pressure_drop(
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
196 loss = read_segments(
200 assert pipe(loss.segments).pressure_drop(mdot, FLOW) == pytest.approx(hand)
204 """A fitting's K already contains its own friction.
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.
210 rows = [{
"kind":
"elbow_90",
"count": 3,
"lengthMm": 30,
"engagementMm": 6}]
217 as_tube = read_segments([{**base,
"lengthBasis":
"tube"}],
"edge L1")
218 end_to_end = read_segments([{**base,
"lengthBasis":
"overall"}],
"edge L1")
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)
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)
230 """A partial subtraction is a wrong number that looks careful."""
231 loss = read_segments(
237 "lengthBasis":
"overall",
239 {
"kind":
"elbow_90",
"count": 1,
"lengthMm": 30},
240 {
"kind":
"tee_run",
"count": 1},
246 assert loss.segments[0].tube_length() == pytest.approx(1.0)
247 assert any(
"counted twice" in w
for w
in loss.warnings)
251 """Two adjacent segments of different bore *are* the transition.
253 Making somebody add a row for it is a step they can forget, and then the run
256 straight = read_segments(
263 stepped = read_segments(
270 assert pipe(stepped.segments).pressure_drop(1.0, FLOW) >
pipe(
272 ).pressure_drop(1.0, FLOW)
276 """Why this class exists.
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.
282 stepped = read_segments(
289 lumped = read_segments(
293 "bore":
measured((10.2 + 7.75) / 2,
"mm"),
299 real =
pipe(stepped.segments).pressure_drop(1.0, FLOW)
300 mean =
pipe(lumped.segments).pressure_drop(1.0, FLOW)
302 assert (real - mean) / real > 0.25
306 """Where a velocity limit bites and where cavitation starts."""
307 loss = read_segments(
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)
319 assert diagnostics[
"segments"] == 2.0
323 """Top of the ladder. The curve's number is used, not a friction factor."""
324 loss = read_segments(
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"
348 """A clamped curve takes the flow dependence out of the branch equation.
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.
353 loss = read_segments(
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)
377 with pytest.raises(SpecError, match=
"segment list"):
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."""
387 "name":
"segment smoke",
394 "fluidType":
"nitrogen",
396 "pressure":
measured(2000.0,
"psi"),
401 {
"id":
"n2",
"type":
"VENT",
"data": {
"label":
"V-01"}},
414 "segments": segments,
422 """The drawing greys those fields out. Summing both is the double-count."""
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"
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)
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)
450 """The spec would drop it anyway -- but silently, and 9.9 m is a lot to lose.
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.
460 network = build_network(read_diagram(doc))
462 "superseded" in w
and "9.9 m" in w
for w
in network.warnings
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)
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
484 def run(bend: dict) -> float:
485 loss = read_segments(
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}],
496 fitting = loss.segments[0].fittings[0]
497 return fitting.bend_diameters, fitting.angle
499 assert run({
"bendDiameters": 1.5,
"angleDeg": 90.0}) == (1.5, 90.0)
500 assert run({}) == (0.0, 0.0)
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)."""
512 def seg(sid: str, bore_mm: float) -> dict[str, object]:
515 "method":
"itemised",
516 "bore": {
"value": bore_mm,
"unit":
"mm",
"source":
"measured"},
517 "length": {
"value": 0.4,
"unit":
"m",
"source":
"measured"},
519 {
"kind":
"bend",
"count": 2,
"angleDeg": 45.0,
"bendDiameters": 3.5}
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))
528 assert line.pressure_drop(0.2, FLOW) > 0.0
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."""
540 loss = read_segments(
544 "method":
"itemised",
545 "bore": {
"value": 10.9,
"unit":
"mm",
"source":
"measured"},
546 "length": {
"value": 0.7,
"unit":
"m",
"source":
"measured"},
548 {
"kind":
"bend",
"count": 3,
"angleDeg": 45.0,
"bendDiameters": 3.5}
554 line =
pipe(loss.segments, roughness=
Param(0.03,
"mm", Provenance.DEFAULT,
""))
556 dp = line.pressure_drop(trickle, FLOW)
557 assert dp > 0.0
and dp < 1.0
559 import fluids.fittings
as ft
562 ctx = FittingContext(
567 params={
"angle": 45.0,
"bend_diameters": 3.5},
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
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()