1"""Phase 11: reading a drawing and building a network from it.
3The two graphs are not the same shape, and every test here is about a place
4where a naive relabelling would go wrong: an inline symbol that has to become a
5branch, a tank that is one symbol and two pressures, a transducer that is a
6label rather than plumbing, and a control regulator that sets a setpoint rather
10from __future__
import annotations
13from pathlib
import Path
26PSI = 6894.757293168361
36 "SV-LOX-PRESS.command": 1.0,
37 "SV-FUEL-PRESS.command": 1.0,
38 "SV-LOX-VENT.command": 0.0,
39 "SV-FUEL-VENT.command": 0.0,
40 "SV-GN2-VENT.command": 0.0,
41 "SV-LOX-FILL.command": 0.0,
44 Path(__file__).resolve().parents[3]
48 /
"ethalox_stand.json"
50needs_stand = pytest.mark.skipif(
not STAND.exists(), reason=
"stand drawing absent")
53def P(value: float, unit: str, source: str =
"manufacturer") -> dict[str, object]:
54 return {
"value": value,
"unit": unit,
"source": source,
"reference":
""}
57def node(nid: str, ctype: str, label: str, **data: object) -> dict[str, object]:
60 "position": {
"x": 0,
"y": 0},
61 "data": {
"componentType": ctype,
"label": label, **data},
65def edge(eid: str, a: str, b: str, **params: object) -> dict[str, object]:
70 "data": {
"lineType":
"pipe",
"params": params
or LINE},
75 "length":
P(1.0,
"m"),
77 "roughness":
P(0.0015,
"mm"),
82 """Bottle, valve, tank. The smallest thing that is a feed system."""
90 params={
"pressure":
P(4500,
"psi"),
"temperature":
P(293.15,
"K")},
96 params={
"Cv":
P(6.0,
"Cv"),
"bore":
P(12.7,
"mm")},
104 "pressure":
P(500,
"psi"),
105 "temperature":
P(90.0,
"K"),
106 "volume":
P(17.5,
"L"),
109 node(
"PT1",
"PT",
"PT-OX-UP", attachedTo=
"T1"),
111 "edges": [
edge(
"e1",
"KB",
"V1"),
edge(
"e2",
"V1",
"T1")],
119 drawing = read_diagram(
minimal(), name=
"minimal")
120 assert len(drawing.nodes) == 4
121 assert len(drawing.edges) == 2
122 bottle = drawing.node(
"KB")
123 assert bottle
is not None and bottle.is_source
126 assert bottle.params[
"pressure"].unit ==
"psig"
127 assert bottle.params[
"pressure"].si == pytest.approx(4500 * PSI + 101325.0)
131 """The drawing always asks for a source, so a missing one is a format
132 mismatch -- and calling it a default would turn that into a number nobody
135 payload[
"nodes"][0][
"data"][
"params"][
"pressure"].pop(
"source")
136 with pytest.raises(DiagramError, match=
"source"):
137 read_diagram(payload, name=
"minimal")
142 payload[
"edges"].append(
edge(
"ghost",
"KB",
"MISSING"))
143 with pytest.raises(DiagramError, match=
"not on the drawing"):
144 read_diagram(payload, name=
"minimal")
148 """A drawing missing its tank solves as an empty system and reports zero
149 flow, which looks like an answer."""
151 payload[
"nodes"] = [n
for n
in payload[
"nodes"]
if n[
"id"] !=
"KB"]
152 payload[
"edges"] = [e
for e
in payload[
"edges"]
if e[
"source"] !=
"KB"]
153 drawing = read_diagram(payload, name=
"minimal")
156 n
for n
in payload[
"nodes"]
if n[
"data"][
"componentType"] !=
"TANK"
158 payload[
"edges"] = []
159 with pytest.raises(DiagramError, match=
"declares a pressure"):
160 build_network(read_diagram(payload, name=
"minimal"))
168 """The case that matters. A drawing with six valves that solves as a
169 drawing with none converges beautifully and means nothing."""
170 built = build_network(read_diagram(
minimal(), name=
"minimal"))
171 assert "V1" in built.network.branches
172 assert "V1.in" in built.network.nodes
173 assert "V1.out" in built.network.nodes
175 assert built.network.branches[
"e1"].downstream ==
"V1.in"
176 assert built.network.branches[
"e2"].upstream ==
"V1.out"
180 """Pressurant arrives at the top and propellant leaves the bottom."""
181 built = build_network(read_diagram(
minimal(), name=
"minimal"))
182 ports = built.tanks[
"T1"]
183 assert ports.ullage
in built.network.nodes
184 assert ports.outlet
in built.network.nodes
185 ullage = built.network.nodes[ports.ullage].pressure
186 outlet = built.network.nodes[ports.outlet].pressure
187 assert ullage
is not None and outlet
is not None
190 assert outlet > ullage
191 assert (outlet - ullage) / PSI == pytest.approx(1.5, abs=1.5)
195 """``lineType: bend`` is the hardline Bend, whose only correlation model is
196 ``rounded``. It used to ask for a ``darcy`` bend, which has no builder, so
197 the drawing could not be built at all."""
201 payload[
"edges"][0][
"data"] = {
203 "params": {
"bore":
P(9.5,
"mm"),
"bend_radius":
P(30.0,
"mm")},
205 built = build_network(read_diagram(payload, name=
"minimal"))
206 assert isinstance(built.network.branches[
"e1"].component, Bend)
210 built = build_network(read_diagram(
minimal(), name=
"minimal"))
211 assert built.network.branches[
"e2"].downstream == built.tanks[
"T1"].ullage
215 """Making one a node puts a fictitious branch in every mass balance."""
216 built = build_network(read_diagram(
minimal(), name=
"minimal"))
217 assert "PT1" not in built.network.nodes
218 assert [i.tag
for i
in built.instruments] == [
"PT-OX-UP"]
219 assert built.instruments[0].node == built.tanks[
"T1"].ullage
224 payload[
"nodes"][1][
"data"][
"componentType"] =
"ROT"
225 built = build_network(read_diagram(payload, name=
"minimal"))
226 assert built.actuators == {
"V1":
"MAN-01.command"}
230 """A hand valve is not something a state machine can command: it is turned
231 on the P&ID, and the binding never offers it to an actuator. Plumbed both
232 sides or not, it rests shut until a hand opens it."""
233 built = build_network(read_diagram(
minimal(), name=
"minimal"))
234 assert built.hand_valves == frozenset({
"V1"})
235 assert built.rest[
"V1"] == 0.0
236 assert built.valve_roles.get(
"V1")
is None
244 built = build_network(load_diagram(STAND))
245 assert len(built.network.nodes) > 15
246 result = solve_steady(
250 raise_on_failure=
False,
252 assert result.converged
253 assert result.max_mass_residual < 1e-9
257 i.tag: (result.pressures[i.node] - 101325.0) / PSI
for i
in built.instruments
259 assert readings[
"PT-GN2-HI"] == pytest.approx(4500.0, abs=1.0)
262 assert readings[
"PT-GN2-REG"] == pytest.approx(500.0, abs=5.0)
264 assert readings[
"PT-OX-DN"] < readings[
"PT-OX-UP"] - 10.0
269 """A dome line carries almost nothing; what it carries is a number."""
270 built = build_network(load_diagram(STAND))
271 assert "PR_C" not in built.network.branches
272 assert any(
"loads the dome" in w
for w
in built.warnings)
273 assert any(s.endswith(
".dome")
for s
in built.actuators.values())
278 built = build_network(load_diagram(STAND))
280 for command
in (0.0, 0.25, 1.0):
281 result = solve_steady(
283 signals={**FIRING,
"MV-OX.command": command,
"MV-FU.command": command},
285 raise_on_failure=
False,
287 assert result.converged
288 flows.append(result.flows[
"l_ox2"])
289 assert flows[0] < flows[1] < flows[2]
294 """A drawing that omits a bore should produce an answer with a visible hole
295 in it, not a confident one."""
298 built = build_network(load_diagram(STAND))
300 (b.component.instance.id, name)
301 for b
in built.network.branches.values()
302 for name
in assumed_params(b.component.instance.params)
304 assert assumed,
"the drawing does not state everything, and should say so"
305 assert all(isinstance(a, tuple)
for a
in assumed)
310 """The bug this test exists for was worth 25x in density.
312 A feed line downstream of a LOX tank carries oxygen at 90 K. Inherit the
313 species and leave the temperature at an ambient default and the line solves
314 as *gaseous* oxygen at 46 kg/m^3 instead of liquid at 1140 -- so the flow
315 comes out confidently wrong rather than obviously so, and the oxidiser leg
316 of a bigger line reads as flowing less than the fuel leg of a smaller one.
318 built = build_network(load_diagram(STAND))
323 for node_id
in (
"OXT.out",
"MVO.in",
"MVO.out",
"ENG"):
324 node = built.network.nodes[node_id]
325 assert node.fluid ==
"oxygen"
326 assert node.temperature == pytest.approx(90.0, abs=1.0)
328 result = solve_steady(
332 raise_on_failure=
False,
334 assert result.converged
338 assert abs(result.flows[
"l_ox2"]) > 1.5 * abs(result.flows[
"l_fu2"])
345 """Downstream of a tank is a whole propellant leg. Defaulting it silently
346 reprices every line on that side, and nothing in the answer looks wrong."""
348 tank = next(n
for n
in raw[
"nodes"]
if n[
"id"] ==
"T1")
349 del tank[
"data"][
"fluid"]
350 built = build_network(read_diagram(raw))
352 "does not say what it holds" in w
and "OX-TANK" in w
for w
in built.warnings
357 """pid-designer colours a symbol by role -- fuel, lox, pressurant. That is
358 not a species, and the difference is worth spelling out rather than letting
359 somebody wonder why the colour they set had no effect."""
361 tank = next(n
for n
in raw[
"nodes"]
if n[
"id"] ==
"T1")
362 del tank[
"data"][
"fluid"]
363 tank[
"data"][
"fluidType"] =
"lox"
364 built = build_network(read_diagram(raw))
365 note = next(w
for w
in built.warnings
if "does not say what it holds" in w)
366 assert "'lox'" in note
and "role rather than a fluid" in note
370 """The expensive one. A tank named for a cryogen, holding its species, at
371 the ambient default: oxygen is then ~40 kg/m^3 where LOX is 1140, so every
372 density and flow on that leg is out by thirty and the solve still
373 converges on plausible-looking numbers."""
375 tank = next(n
for n
in raw[
"nodes"]
if n[
"id"] ==
"T1")
376 del tank[
"data"][
"params"][
"temperature"]
377 built = build_network(read_diagram(raw))
378 note = next(w
for w
in built.warnings
if "is a gas, not a liquid" in w)
379 assert "OX-TANK" in note
382 assert "condenses at about 14" in note
386 """LOX at 90 K is exactly right, and a warning here would train people to
387 ignore the one above."""
388 built = build_network(read_diagram(
minimal()))
389 assert not any(
"is a gas, not a liquid" in w
for w
in built.warnings)
390 assert not any(
"does not say what it holds" in w
for w
in built.warnings)
394 """Nitrogen at ambient is a gas because it is supposed to be. The check is
395 for tanks, and a K-bottle is not one."""
396 built = build_network(read_diagram(
minimal()))
397 assert not any(
"KB-N2" in w
and "gas, not a liquid" in w
for w
in built.warnings)
403def _engine(oxidiser: str =
"oxygen", fuel: str =
"ethanol") -> object:
408 injector_type=
"impinging",
409 oxidiser=InjectorSide(oxidiser, 99e-6, 2.2e-3, DischargeModel(), 26),
410 fuel=InjectorSide(fuel, 75e-6, 1.9e-3, DischargeModel(), 26),
412 design_mixture_ratio=1.65,
417 """Two tanks into one engine -- the smallest thing an engine can sit on."""
426 "pressure":
P(500,
"psi"),
427 "temperature":
P(90.0,
"K"),
428 "volume":
P(17.5,
"L"),
437 "pressure":
P(500,
"psi"),
438 "temperature":
P(293.15,
"K"),
439 "volume":
P(17.5,
"L"),
442 node(
"ENG",
"ENGINE",
"ENG-01", params={
"pressure":
P(350,
"psi")}),
444 "edges": [
edge(
"l1",
"OXT",
"ENG"),
edge(
"l2",
"FUT",
"ENG")],
450 assert built.engine_ports[
"oxidiser"] ==
"ENG.oxidiser.injector"
451 assert built.engine_ports[
"fuel"] ==
"ENG.fuel.injector"
452 assert not any(
"no injector leg" in w
for w
in built.warnings)
456 """It used to be skipped in silence, which left the engine with one leg and
457 a mixture ratio taken against a zero -- and every number after it looked
458 perfectly ordinary."""
460 fuel_tank = next(n
for n
in raw[
"nodes"]
if n[
"id"] ==
"FUT")
461 fuel_tank[
"data"][
"fluid"] =
"methanol"
462 built = build_network(read_diagram(raw), engine=
_engine())
464 assert "fuel" not in built.engine_ports
466 "neither oxygen nor ethanol" in w
and "FU-TANK" in w
for w
in built.warnings
468 assert any(
"no fuel leg" in w
for w
in built.warnings)
472 """Attaching a config to a drawing that has no engine leaves the run
473 uncoupled. Reported, because 'coupled' is a claim about the physics."""
475 assert built.engine_ports == {}
476 assert any(
"no engine symbol" in w
for w
in built.warnings)
480 """The drawing pid-designer tells you to make for a vent.
482 Its `components/pid/vents.ts` says so outright: "A valve open on one side is
483 a vent to atmosphere. Not a symbol you place." It draws the arrow and passes
484 its own checks on that basis -- but the inference feeds the arrow and the
485 checks panel, not the export, so this arrived here as a valve with a dead end
486 behind it and the network would not solve.
489 def p(value, unit, source):
490 return {
"value": value,
"unit": unit,
"source": source}
497 "position": {
"x": 0,
"y": 0},
499 "componentType":
"TANK",
503 "volume":
p(8.67,
"L",
"measured"),
504 "pressure":
p(550,
"psi",
"estimated"),
505 "temperature":
p(293.15,
"K",
"measured"),
512 "position": {
"x": 0,
"y": 300},
514 "componentType":
"KBOTTLE",
518 "pressure":
p(4500,
"psi",
"manufacturer"),
519 "temperature":
p(293.15,
"K",
"measured"),
520 "volume":
p(4.687,
"L",
"measured"),
527 "position": {
"x": 200,
"y": 300},
529 "componentType":
"SOL",
533 "Cv":
p(1.7,
"Cv",
"measured"),
534 "bore":
p(6.35,
"mm",
"manufacturer"),
541 "position": {
"x": 200,
"y": 0},
543 "componentType":
"SOL",
547 "Cv":
p(3.8,
"Cv",
"estimated"),
548 "bore":
p(9.53,
"mm",
"manufacturer"),
558 "type":
"smoothstep",
562 "length":
p(0.5,
"m",
"estimated"),
563 "bore":
p(6.35,
"mm",
"manufacturer"),
564 "roughness":
p(0.0015,
"mm",
"manufacturer"),
565 "K_minor":
p(0.5,
"-",
"estimated"),
573 "type":
"smoothstep",
577 "length":
p(0.5,
"m",
"estimated"),
578 "bore":
p(6.35,
"mm",
"manufacturer"),
579 "roughness":
p(0.0015,
"mm",
"manufacturer"),
580 "K_minor":
p(0.0,
"-",
"estimated"),
588 "type":
"smoothstep",
592 "length":
p(0.4,
"m",
"estimated"),
593 "bore":
p(9.53,
"mm",
"manufacturer"),
594 "roughness":
p(0.0015,
"mm",
"manufacturer"),
595 "K_minor":
p(0.5,
"-",
"estimated"),
605 free = built.network.nodes[
"SV.out"]
606 assert free.pressure
is not None,
"the open side must become a boundary"
607 assert free.pressure == pytest.approx(101325.0)
608 built.network.validate()
612 """Inferring a boundary is a real modelling decision, so it is said aloud."""
614 assert any(
"venting to atmosphere" in w
and "SV-VENT" in w
for w
in built.warnings)
620 built = build_network(read_diagram(doc))
621 assert built.network.nodes[
"SVP.in"].pressure
is None
622 assert built.network.nodes[
"SVP.out"].pressure
is None
623 assert not any(
"SV-PRESS" in w
for w
in built.warnings)
627 """The rule is valves only, for pid-designer's stated reason: a blanked tee
628 branch or spare manifold port is a plug, and plugs are not drawn."""
634 "position": {
"x": 400,
"y": 300},
636 "componentType":
"MANIFOLD",
640 "Cv": {
"value": 8.0,
"unit":
"Cv",
"source":
"estimated"},
641 "bore": {
"value": 10.92,
"unit":
"mm",
"source":
"manufacturer"},
651 "type":
"smoothstep",
655 "length": {
"value": 0.3,
"unit":
"m",
"source":
"estimated"},
656 "bore": {
"value": 10.92,
"unit":
"mm",
"source":
"manufacturer"},
660 "source":
"manufacturer",
662 "K_minor": {
"value": 0.0,
"unit":
"-",
"source":
"estimated"},
667 built = build_network(read_diagram(doc))
668 assert not any(
"MF-1" in w
for w
in built.warnings)
672 """A main valve's downstream PT is the pressure that sets injector delta-p.
674 Instruments resolve to a symbol's *upstream* node by default, which is right
675 for most taps and cannot express this one. Without `side: downstream` the
676 only clip that resolved past a main valve was the ENGINE symbol -- a fixed
677 boundary at the chamber -- so the gauge sat at design chamber pressure
678 forever, including on a cold stand with every valve shut.
680 diagram = read_diagram(json.loads(STAND.read_text()))
681 built = build_network(diagram)
682 downstream = {i.tag: i.node
for i
in built.instruments
if "-DN" in i.tag}
683 assert downstream,
"the shipped stand should carry downstream transducers"
684 for tag, node
in downstream.items():
685 assert node.endswith(
".out"), f
"{tag} reads {node}, not a downstream node"
686 assert node
not in (
"ENG",
"ENGINE"), f
"{tag} is reading the chamber boundary"
690 """The option must not change where an unmarked instrument reads.
692 Every instrument, not just the transducers. This stripped `PT` alone and
693 then asserted over the whole list, so the first thermocouple to ask for a
694 downstream node failed a test about transducers.
696 doc = json.loads(STAND.read_text())
697 for node
in doc.get(
"nodes", []):
698 data = node.get(
"data", {})
699 if data.get(
"componentType")
in (
"PT",
"TC",
"RTD",
"FM"):
700 data.pop(
"options",
None)
701 built = build_network(read_diagram(doc))
702 for instrument
in built.instruments:
703 assert not instrument.node.endswith(
705 ), f
"{instrument.tag} moved downstream without being asked"
709 """A TC clips to a node and reads it, the same way a transducer does."""
710 built = build_network(read_diagram(json.loads(STAND.read_text())))
711 thermal = [i
for i
in built.instruments
if i.type
in (
"TC",
"RTD")]
712 assert thermal,
"the shipped stand should carry thermocouples"
713 for instrument
in thermal:
714 assert instrument.node
in built.network.nodes
723 """The property layer's fallback -- below the critical temperature means
724 liquid -- is right for LOX and wrong for a vapour line. Only the drawing
725 knows which side of the tank a line is on, so the drawing says."""
726 built = build_network(read_diagram(json.loads(STAND.read_text())))
728 for ports
in built.tanks.values():
729 assert net.nodes[ports.ullage].phase ==
"gas"
730 assert net.nodes[ports.outlet].phase ==
"liquid"
732 gas = [n
for n
in net.nodes.values()
if n.phase ==
"gas"]
733 liquid = [n
for n
in net.nodes.values()
if n.phase ==
"liquid"]
734 assert len(gas) >= 4
and len(liquid) >= 2
736 assert node.fluid
in {
739 }, f
"{node.id} is gas but holds {node.fluid}"
741 assert node.fluid
in {
744 }, f
"{node.id} is liquid but holds {node.fluid}"
748 """Ethanol vapour at 400 K and 1 bar -- well above its 351 K boiling point
749 and well below its 514 K critical temperature. Undeclared it is priced as
750 liquid ethanol at ~700 kg/m^3; declared gas, at (p, T) it is ~1.4."""
754 ethanol = Fluid(
"ethanol")
755 guessed = conditions_from_fluid(ethanol, 1.0e5, 400.0)
756 declared = conditions_from_fluid(ethanol, 1.0e5, 400.0, phase=
"gas")
757 assert guessed.rho > 600.0,
"the temperature rule prices this as liquid"
758 assert declared.rho < 5.0,
"the declaration must win"
759 still_liquid = conditions_from_fluid(ethanol, 1.0e5, 400.0, phase=
"liquid")
760 assert still_liquid.rho == pytest.approx(guessed.rho)
764 """rho g h with h from the drawn volume and bore at the stated fill --
765 not a bare 0.75 on an assumed 152 mm bore, which is what this was."""
770 built = build_network(read_diagram(
minimal(), name=
"minimal"))
771 ports = built.tanks[
"T1"]
773 head = net.nodes[ports.outlet].pressure - net.nodes[ports.ullage].pressure
775 n
for n
in read_diagram(
minimal(), name=
"minimal").nodes
if n.id ==
"T1"
777 volume = tank.params[
"volume"].si
778 bore = tank.params[
"diameter"].si
if "diameter" in tank.params
else 0.1524
779 geometry = cylindrical_from_volume(volume, bore)
780 level = level_of_volume(geometry, STATIC_HEAD_FILL * geometry.total_volume)
781 rho = Fluid(net.nodes[ports.outlet].fluid).get(
782 "rho", T=net.nodes[ports.outlet].temperature, q=0.0
784 assert head == pytest.approx(rho * 9.80665 * level, rel=1e-9)
785 if "diameter" not in tank.params:
786 assert any(
"static head assumes" in w
for w
in built.warnings)
dict _valve_with_one_side_open()
None test_a_tank_that_states_its_temperature_is_left_alone()
None test_a_transducer_can_tap_the_downstream_side_of_a_valve()
None test_a_declared_gas_is_priced_as_gas_below_its_critical_temperature()
None test_both_injector_legs_are_attached_by_fluid()
None test_valve_position_moves_the_flow()
None test_pressurant_lands_on_the_ullage_not_the_liquid_side()
None test_the_inferred_vent_is_reported()
None test_a_parameter_with_no_provenance_is_refused()
None test_the_stand_drawing_builds_and_solves()
object _engine(str oxidiser="oxygen", str fuel="ethanol")
None test_a_valve_with_one_side_open_vents_to_atmosphere()
None test_a_liquid_tank_solved_as_a_gas_is_reported()
dict[str, object] node(str nid, str ctype, str label, **object data)
None test_an_inline_symbol_becomes_a_branch_with_a_node_either_side()
dict[str, object] _two_leg_stand()
None test_an_engine_with_no_symbol_to_land_on_says_so()
None test_a_drawing_reads_into_typed_records()
None test_a_manual_valve_is_turned_by_hand_never_by_the_table()
None test_a_valve_plumbed_both_ends_is_not_a_vent()
None test_temperature_travels_with_the_fluid()
None test_a_transducer_is_a_label_not_plumbing()
None test_a_line_matching_neither_propellant_is_reported()
None test_every_unstated_number_is_marked_as_such()
dict[str, object] minimal()
None test_a_spare_manifold_port_is_a_plug_not_a_vent()
None test_a_tank_with_no_fluid_says_so()
None test_commandable_valves_are_found()
None test_the_control_regulator_sets_a_setpoint_rather_than_carrying_flow()
None test_static_head_comes_from_the_tanks_own_geometry()
dict[str, object] P(float value, str unit, str source="manufacturer")
None test_upstream_is_still_the_default()
None test_a_drawing_with_no_source_is_refused()
None test_a_line_to_nowhere_is_refused()
None test_a_bend_line_builds_the_geometry_aware_bend()
dict[str, object] edge(str eid, str a, str b, **object params)
None test_thermocouples_and_rtds_are_instruments_like_any_other()
None test_ullage_side_nodes_are_declared_gas_and_the_outlet_liquid()
None test_a_role_without_a_fluid_is_named_as_a_role()
None test_a_tank_is_one_symbol_and_two_pressures()
None test_a_warm_pressurant_bottle_is_not_warned_about()