STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
test_pid.py
Go to the documentation of this file.
1"""Phase 11: reading a drawing and building a network from it.
2
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
7than carrying flow.
8"""
9
10from __future__ import annotations
11
12import json
13from pathlib import Path
14
15import pytest
16
17from feedtwin.pid import (
18 Diagram,
19 DiagramError,
20 build_network,
21 load_diagram,
22 read_diagram,
23)
24from feedtwin.solve.steady import solve_steady
25
26PSI = 6894.757293168361
27
28#: A realistic firing state for the shipped stand: mains and press open, every
29#: vent shut. Naming the whole valve state matters now that the drawing carries
30#: vents -- leaving them unstated defaults them open, which is a stand venting
31#: its tanks to atmosphere while it fires, and no useful thing can be asserted
32#: about that operating point.
33FIRING = {
34 "MV-OX.command": 1.0,
35 "MV-FU.command": 1.0,
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,
42}
43STAND = (
44 Path(__file__).resolve().parents[3]
45 / "feed-twin"
46 / "backend"
47 / "diagrams"
48 / "ethalox_stand.json"
49)
50needs_stand = pytest.mark.skipif(not STAND.exists(), reason="stand drawing absent")
51
52
53def P(value: float, unit: str, source: str = "manufacturer") -> dict[str, object]:
54 return {"value": value, "unit": unit, "source": source, "reference": ""}
55
56
57def node(nid: str, ctype: str, label: str, **data: object) -> dict[str, object]:
58 return {
59 "id": nid,
60 "position": {"x": 0, "y": 0},
61 "data": {"componentType": ctype, "label": label, **data},
62 }
63
64
65def edge(eid: str, a: str, b: str, **params: object) -> dict[str, object]:
66 return {
67 "id": eid,
68 "source": a,
69 "target": b,
70 "data": {"lineType": "pipe", "params": params or LINE},
71 }
72
73
74LINE = {
75 "length": P(1.0, "m"),
76 "bore": P(9.5, "mm"),
77 "roughness": P(0.0015, "mm"),
78}
79
80
81def minimal() -> dict[str, object]:
82 """Bottle, valve, tank. The smallest thing that is a feed system."""
83 return {
84 "nodes": [
85 node(
86 "KB",
87 "KBOTTLE",
88 "KB-N2",
89 fluid="nitrogen",
90 params={"pressure": P(4500, "psi"), "temperature": P(293.15, "K")},
91 ),
92 node(
93 "V1",
94 "MAN",
95 "MAN-01",
96 params={"Cv": P(6.0, "Cv"), "bore": P(12.7, "mm")},
97 ),
98 node(
99 "T1",
100 "TANK",
101 "OX-TANK",
102 fluid="oxygen",
103 params={
104 "pressure": P(500, "psi"),
105 "temperature": P(90.0, "K"),
106 "volume": P(17.5, "L"),
107 },
108 ),
109 node("PT1", "PT", "PT-OX-UP", attachedTo="T1"),
110 ],
111 "edges": [edge("e1", "KB", "V1"), edge("e2", "V1", "T1")],
112 }
113
114
115# ------------------------------------------------------------ reading
116
117
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
124 # Drawn "4500 psi" is what the bottle's gauge reads: 4500 psig
125 # (feedtwin.model.pressure), one atmosphere more in absolute pascals.
126 assert bottle.params["pressure"].unit == "psig"
127 assert bottle.params["pressure"].si == pytest.approx(4500 * PSI + 101325.0)
128
129
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
133 checked."""
134 payload = minimal()
135 payload["nodes"][0]["data"]["params"]["pressure"].pop("source") # type: ignore[index]
136 with pytest.raises(DiagramError, match="source"):
137 read_diagram(payload, name="minimal")
138
139
141 payload = minimal()
142 payload["edges"].append(edge("ghost", "KB", "MISSING")) # type: ignore[attr-defined]
143 with pytest.raises(DiagramError, match="not on the drawing"):
144 read_diagram(payload, name="minimal")
145
146
148 """A drawing missing its tank solves as an empty system and reports zero
149 flow, which looks like an answer."""
150 payload = minimal()
151 payload["nodes"] = [n for n in payload["nodes"] if n["id"] != "KB"] # type: ignore[index]
152 payload["edges"] = [e for e in payload["edges"] if e["source"] != "KB"] # type: ignore[index]
153 drawing = read_diagram(payload, name="minimal")
154 # The tank is still a source, so this one builds; strip it too and it must not.
155 payload["nodes"] = [
156 n for n in payload["nodes"] if n["data"]["componentType"] != "TANK" # type: ignore[index]
157 ]
158 payload["edges"] = []
159 with pytest.raises(DiagramError, match="declares a pressure"):
160 build_network(read_diagram(payload, name="minimal"))
161 assert drawing.nodes
162
163
164# ------------------------------------------------- the topology rewrite
165
166
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
174 # And the lines that met at it attach to those, not to the symbol.
175 assert built.network.branches["e1"].downstream == "V1.in"
176 assert built.network.branches["e2"].upstream == "V1.out"
177
178
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
188 # The outlet sits below the ullage by the liquid column: LOX is heavy, so
189 # this is a real number, not a rounding artefact.
190 assert outlet > ullage
191 assert (outlet - ullage) / PSI == pytest.approx(1.5, abs=1.5)
192
193
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."""
198 from feedtwin.comps.elements import Bend
199
200 payload = minimal()
201 payload["edges"][0]["data"] = { # type: ignore[index]
202 "lineType": "bend",
203 "params": {"bore": P(9.5, "mm"), "bend_radius": P(30.0, "mm")},
204 }
205 built = build_network(read_diagram(payload, name="minimal"))
206 assert isinstance(built.network.branches["e1"].component, Bend)
207
208
210 built = build_network(read_diagram(minimal(), name="minimal"))
211 assert built.network.branches["e2"].downstream == built.tanks["T1"].ullage
212
213
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
220
221
223 payload = minimal()
224 payload["nodes"][1]["data"]["componentType"] = "ROT" # type: ignore[index]
225 built = build_network(read_diagram(payload, name="minimal"))
226 assert built.actuators == {"V1": "MAN-01.command"}
227
228
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
237
238
239# --------------------------------------------------- the real drawing
240
241
242@needs_stand
244 built = build_network(load_diagram(STAND))
245 assert len(built.network.nodes) > 15
246 result = solve_steady(
247 built.network,
248 signals=FIRING,
249 tol=1e-5,
250 raise_on_failure=False,
251 )
252 assert result.converged
253 assert result.max_mass_residual < 1e-9
254
255 # The gauges read what the drawing says: its pressures are gauge.
256 readings = {
257 i.tag: (result.pressures[i.node] - 101325.0) / PSI for i in built.instruments
258 }
259 assert readings["PT-GN2-HI"] == pytest.approx(4500.0, abs=1.0)
260 # The dome regulator holds its dome (450 psig on the control regulator)
261 # plus the 1092-50's 50 psi bias.
262 assert readings["PT-GN2-REG"] == pytest.approx(500.0, abs=5.0)
263 # And the feed lines cost something on the way to the injector.
264 assert readings["PT-OX-DN"] < readings["PT-OX-UP"] - 10.0
265
266
267@needs_stand
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())
274
275
276@needs_stand
278 built = build_network(load_diagram(STAND))
279 flows = []
280 for command in (0.0, 0.25, 1.0):
281 result = solve_steady(
282 built.network,
283 signals={**FIRING, "MV-OX.command": command, "MV-FU.command": command},
284 tol=1e-5,
285 raise_on_failure=False,
286 )
287 assert result.converged
288 flows.append(result.flows["l_ox2"])
289 assert flows[0] < flows[1] < flows[2]
290
291
292@needs_stand
294 """A drawing that omits a bore should produce an answer with a visible hole
295 in it, not a confident one."""
296 from feedtwin.model.param import assumed_params
297
298 built = build_network(load_diagram(STAND))
299 assumed = {
300 (b.component.instance.id, name)
301 for b in built.network.branches.values()
302 for name in assumed_params(b.component.instance.params)
303 }
304 assert assumed, "the drawing does not state everything, and should say so"
305 assert all(isinstance(a, tuple) for a in assumed)
306
307
308@needs_stand
310 """The bug this test exists for was worth 25x in density.
311
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.
317 """
318 built = build_network(load_diagram(STAND))
319 # Every node on the ox leg, tank outlet to injector face. The check valve
320 # that used to sit in the middle is not on the real stand and is gone; what
321 # matters is that the species and the 90 K reach the *last* one, since that
322 # is where an ambient default would do its damage.
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)
327
328 result = solve_steady(
329 built.network,
330 signals=FIRING,
331 tol=1e-5,
332 raise_on_failure=False,
333 )
334 assert result.converged
335 # The oxidiser must carry more -- but note *why*, because the old reason
336 # went stale: both legs are now the same 1/2 in. tube, so it is not the
337 # line. It is that the engine runs at O/F 1.65 and LOX is the denser fluid.
338 assert abs(result.flows["l_ox2"]) > 1.5 * abs(result.flows["l_fu2"])
339
340
341# ------------------------------------------- what the drawing did not say
342
343
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."""
347 raw = minimal()
348 tank = next(n for n in raw["nodes"] if n["id"] == "T1") # type: ignore[index,union-attr]
349 del tank["data"]["fluid"] # type: ignore[index]
350 built = build_network(read_diagram(raw))
351 assert any(
352 "does not say what it holds" in w and "OX-TANK" in w for w in built.warnings
353 )
354
355
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."""
360 raw = minimal()
361 tank = next(n for n in raw["nodes"] if n["id"] == "T1") # type: ignore[index,union-attr]
362 del tank["data"]["fluid"] # type: ignore[index]
363 tank["data"]["fluidType"] = "lox" # type: ignore[index]
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
367
368
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."""
374 raw = minimal()
375 tank = next(n for n in raw["nodes"] if n["id"] == "T1") # type: ignore[index,union-attr]
376 del tank["data"]["params"]["temperature"] # type: ignore[index]
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
380 # The saturation temperature at tank pressure is the number the drawing is
381 # missing, so it has to be in the message rather than left to be looked up.
382 assert "condenses at about 14" in note
383
384
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)
391
392
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)
398
399
400# ------------------------------------------------ attaching a real engine
401
402
403def _engine(oxidiser: str = "oxygen", fuel: str = "ethanol") -> object:
404 from feedtwin.engine.design import DischargeModel, EngineDesign, InjectorSide
405
406 return EngineDesign(
407 name="fixture",
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),
411 throat_area=1.7e-3,
412 design_mixture_ratio=1.65,
413 )
414
415
416def _two_leg_stand() -> dict[str, object]:
417 """Two tanks into one engine -- the smallest thing an engine can sit on."""
418 return {
419 "nodes": [
420 node(
421 "OXT",
422 "TANK",
423 "OX-TANK",
424 fluid="oxygen",
425 params={
426 "pressure": P(500, "psi"),
427 "temperature": P(90.0, "K"),
428 "volume": P(17.5, "L"),
429 },
430 ),
431 node(
432 "FUT",
433 "TANK",
434 "FU-TANK",
435 fluid="ethanol",
436 params={
437 "pressure": P(500, "psi"),
438 "temperature": P(293.15, "K"),
439 "volume": P(17.5, "L"),
440 },
441 ),
442 node("ENG", "ENGINE", "ENG-01", params={"pressure": P(350, "psi")}),
443 ],
444 "edges": [edge("l1", "OXT", "ENG"), edge("l2", "FUT", "ENG")],
445 }
446
447
449 built = build_network(read_diagram(_two_leg_stand()), engine=_engine())
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)
453
454
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."""
459 raw = _two_leg_stand()
460 fuel_tank = next(n for n in raw["nodes"] if n["id"] == "FUT") # type: ignore[index,union-attr]
461 fuel_tank["data"]["fluid"] = "methanol" # type: ignore[index]
462 built = build_network(read_diagram(raw), engine=_engine())
463
464 assert "fuel" not in built.engine_ports
465 assert any(
466 "neither oxygen nor ethanol" in w and "FU-TANK" in w for w in built.warnings
467 )
468 assert any("no fuel leg" in w for w in built.warnings)
469
470
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."""
474 built = build_network(read_diagram(minimal()), engine=_engine())
475 assert built.engine_ports == {}
476 assert any("no engine symbol" in w for w in built.warnings)
477
478
480 """The drawing pid-designer tells you to make for a vent.
481
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.
487 """
488
489 def p(value, unit, source):
490 return {"value": value, "unit": unit, "source": source}
491
492 return {
493 "nodes": [
494 {
495 "id": "TK",
496 "type": "TANK",
497 "position": {"x": 0, "y": 0},
498 "data": {
499 "componentType": "TANK",
500 "label": "TK-FU",
501 "fluid": "ethanol",
502 "params": {
503 "volume": p(8.67, "L", "measured"),
504 "pressure": p(550, "psi", "estimated"),
505 "temperature": p(293.15, "K", "measured"),
506 },
507 },
508 },
509 {
510 "id": "KB",
511 "type": "KBOTTLE",
512 "position": {"x": 0, "y": 300},
513 "data": {
514 "componentType": "KBOTTLE",
515 "label": "KB-HE",
516 "fluid": "helium",
517 "params": {
518 "pressure": p(4500, "psi", "manufacturer"),
519 "temperature": p(293.15, "K", "measured"),
520 "volume": p(4.687, "L", "measured"),
521 },
522 },
523 },
524 {
525 "id": "SVP",
526 "type": "SOL",
527 "position": {"x": 200, "y": 300},
528 "data": {
529 "componentType": "SOL",
530 "label": "SV-PRESS",
531 "fluid": "helium",
532 "params": {
533 "Cv": p(1.7, "Cv", "measured"),
534 "bore": p(6.35, "mm", "manufacturer"),
535 },
536 },
537 },
538 {
539 "id": "SV",
540 "type": "SOL",
541 "position": {"x": 200, "y": 0},
542 "data": {
543 "componentType": "SOL",
544 "label": "SV-VENT",
545 "fluid": "ethanol",
546 "params": {
547 "Cv": p(3.8, "Cv", "estimated"),
548 "bore": p(9.53, "mm", "manufacturer"),
549 },
550 },
551 },
552 ],
553 "edges": [
554 {
555 "id": "l_p1",
556 "source": "KB",
557 "target": "SVP",
558 "type": "smoothstep",
559 "data": {
560 "lineType": "pipe",
561 "params": {
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"),
566 },
567 },
568 },
569 {
570 "id": "l_p2",
571 "source": "SVP",
572 "target": "TK",
573 "type": "smoothstep",
574 "data": {
575 "lineType": "pipe",
576 "params": {
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"),
581 },
582 },
583 },
584 {
585 "id": "l_v",
586 "source": "TK",
587 "target": "SV",
588 "type": "smoothstep",
589 "data": {
590 "lineType": "pipe",
591 "params": {
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"),
596 },
597 },
598 },
599 ],
600 }
601
602
604 built = build_network(read_diagram(_valve_with_one_side_open()))
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()
609
610
612 """Inferring a boundary is a real modelling decision, so it is said aloud."""
613 built = build_network(read_diagram(_valve_with_one_side_open()))
614 assert any("venting to atmosphere" in w and "SV-VENT" in w for w in built.warnings)
615
616
619 # SV-PRESS has a line on each side; it must not acquire an ambient boundary.
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)
624
625
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."""
630 doc["nodes"].append(
631 {
632 "id": "MF",
633 "type": "MANIFOLD",
634 "position": {"x": 400, "y": 300},
635 "data": {
636 "componentType": "MANIFOLD",
637 "label": "MF-1",
638 "fluid": "helium",
639 "params": {
640 "Cv": {"value": 8.0, "unit": "Cv", "source": "estimated"},
641 "bore": {"value": 10.92, "unit": "mm", "source": "manufacturer"},
642 },
643 },
644 }
645 )
646 doc["edges"].append(
647 {
648 "id": "l_mf",
649 "source": "SVP",
650 "target": "MF",
651 "type": "smoothstep",
652 "data": {
653 "lineType": "pipe",
654 "params": {
655 "length": {"value": 0.3, "unit": "m", "source": "estimated"},
656 "bore": {"value": 10.92, "unit": "mm", "source": "manufacturer"},
657 "roughness": {
658 "value": 0.0015,
659 "unit": "mm",
660 "source": "manufacturer",
661 },
662 "K_minor": {"value": 0.0, "unit": "-", "source": "estimated"},
663 },
664 },
665 }
666 )
667 built = build_network(read_diagram(doc))
668 assert not any("MF-1" in w for w in built.warnings)
669
670
672 """A main valve's downstream PT is the pressure that sets injector delta-p.
673
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.
679 """
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"
687
688
690 """The option must not change where an unmarked instrument reads.
691
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.
695 """
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(
704 ".out"
705 ), f"{instrument.tag} moved downstream without being asked"
706
707
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
715
716
717# ---------------------------------------------------------------------------
718# Phase is declared by topology, not guessed from temperature
719# ---------------------------------------------------------------------------
720
721
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())))
727 net = built.network
728 for ports in built.tanks.values():
729 assert net.nodes[ports.ullage].phase == "gas"
730 assert net.nodes[ports.outlet].phase == "liquid"
731 # Every node the pressurant reaches, and every vent, is gas.
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
735 for node in gas:
736 assert node.fluid in {
737 "nitrogen",
738 "helium",
739 }, f"{node.id} is gas but holds {node.fluid}"
740 for node in liquid:
741 assert node.fluid in {
742 "oxygen",
743 "ethanol",
744 }, f"{node.id} is liquid but holds {node.fluid}"
745
746
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."""
751 from feedtwin.comps.elements import conditions_from_fluid
752 from feedtwin.props import Fluid
753
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)
761
762
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."""
766 from feedtwin.pid.network import STATIC_HEAD_FILL
767 from feedtwin.props import Fluid
768 from feedtwin.vessels.geometry import cylindrical_from_volume, level_of_volume
769
770 built = build_network(read_diagram(minimal(), name="minimal"))
771 ports = built.tanks["T1"]
772 net = built.network
773 head = net.nodes[ports.outlet].pressure - net.nodes[ports.ullage].pressure
774 tank = next(
775 n for n in read_diagram(minimal(), name="minimal").nodes if n.id == "T1"
776 )
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
783 )
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)
int32_t p
Definition flight_e2e.c:78
dict _valve_with_one_side_open()
Definition test_pid.py:479
None test_a_tank_that_states_its_temperature_is_left_alone()
Definition test_pid.py:385
None test_a_transducer_can_tap_the_downstream_side_of_a_valve()
Definition test_pid.py:671
None test_a_declared_gas_is_priced_as_gas_below_its_critical_temperature()
Definition test_pid.py:747
None test_both_injector_legs_are_attached_by_fluid()
Definition test_pid.py:448
None test_valve_position_moves_the_flow()
Definition test_pid.py:277
None test_pressurant_lands_on_the_ullage_not_the_liquid_side()
Definition test_pid.py:209
None test_the_inferred_vent_is_reported()
Definition test_pid.py:611
None test_a_parameter_with_no_provenance_is_refused()
Definition test_pid.py:130
None test_the_stand_drawing_builds_and_solves()
Definition test_pid.py:243
object _engine(str oxidiser="oxygen", str fuel="ethanol")
Definition test_pid.py:403
None test_a_valve_with_one_side_open_vents_to_atmosphere()
Definition test_pid.py:603
None test_a_liquid_tank_solved_as_a_gas_is_reported()
Definition test_pid.py:369
dict[str, object] node(str nid, str ctype, str label, **object data)
Definition test_pid.py:57
None test_an_inline_symbol_becomes_a_branch_with_a_node_either_side()
Definition test_pid.py:167
dict[str, object] _two_leg_stand()
Definition test_pid.py:416
None test_an_engine_with_no_symbol_to_land_on_says_so()
Definition test_pid.py:471
None test_a_drawing_reads_into_typed_records()
Definition test_pid.py:118
None test_a_manual_valve_is_turned_by_hand_never_by_the_table()
Definition test_pid.py:229
None test_a_valve_plumbed_both_ends_is_not_a_vent()
Definition test_pid.py:617
None test_temperature_travels_with_the_fluid()
Definition test_pid.py:309
None test_a_transducer_is_a_label_not_plumbing()
Definition test_pid.py:214
None test_a_line_matching_neither_propellant_is_reported()
Definition test_pid.py:455
None test_every_unstated_number_is_marked_as_such()
Definition test_pid.py:293
dict[str, object] minimal()
Definition test_pid.py:81
None test_a_spare_manifold_port_is_a_plug_not_a_vent()
Definition test_pid.py:626
None test_a_tank_with_no_fluid_says_so()
Definition test_pid.py:344
None test_commandable_valves_are_found()
Definition test_pid.py:222
None test_the_control_regulator_sets_a_setpoint_rather_than_carrying_flow()
Definition test_pid.py:268
None test_static_head_comes_from_the_tanks_own_geometry()
Definition test_pid.py:763
dict[str, object] P(float value, str unit, str source="manufacturer")
Definition test_pid.py:53
None test_upstream_is_still_the_default()
Definition test_pid.py:689
None test_a_drawing_with_no_source_is_refused()
Definition test_pid.py:147
None test_a_line_to_nowhere_is_refused()
Definition test_pid.py:140
None test_a_bend_line_builds_the_geometry_aware_bend()
Definition test_pid.py:194
dict[str, object] edge(str eid, str a, str b, **object params)
Definition test_pid.py:65
None test_thermocouples_and_rtds_are_instruments_like_any_other()
Definition test_pid.py:708
None test_ullage_side_nodes_are_declared_gas_and_the_outlet_liquid()
Definition test_pid.py:722
None test_a_role_without_a_fluid_is_named_as_a_role()
Definition test_pid.py:356
None test_a_tank_is_one_symbol_and_two_pressures()
Definition test_pid.py:179
None test_a_warm_pressurant_bottle_is_not_warned_about()
Definition test_pid.py:393