1"""Turning a drawing into a network.
3The two graphs are not the same shape, and that is the whole problem this
6A **P&ID** puts hardware on symbols *and* on lines: a ball valve is a symbol, a
7tube run is a line, and both resist flow. A **feed-twin network** puts pressures
8on nodes and hardware on branches, exclusively. So the transform is not a
9relabelling, it is a rewrite:
15 tank / K-bottle / dewar a fixed-pressure node
16 junction / manifold a free node
17 valve, regulator, check a BRANCH, with a node spliced either side
18 line between two symbols a branch
19 transducer, gauge, RTD nothing -- an observer on the node it clips to
20 text, section box nothing
22The interesting case is the third. An inline symbol has to become a branch, so
23the two lines that met at it are cut and its own node pair takes their place.
24Get that wrong and a drawing with six valves solves as a drawing with none,
25which converges beautifully and means nothing.
27Instruments are deliberately *not* dead-end stubs here. Phase 04 can peel a
28stub exactly, but a transducer is not plumbing at all -- it is a label on a
29place. Making it a node would put a fictitious branch in every mass balance and
30report an anomaly on every run.
33from __future__
import annotations
36from dataclasses
import dataclass, field, replace
37from typing
import Callable, Iterable, Mapping
59BRANCH_KINDS: dict[str, tuple[str, str]] = {
60 "MAN": (
"valve",
"cv"),
61 "ROT": (
"valve",
"cv"),
62 "SOL": (
"valve",
"cv"),
63 "MOV": (
"valve",
"cv"),
64 "PR": (
"regulator",
"droop"),
65 "RV": (
"valve",
"cv"),
66 "CV": (
"check_valve",
"cv"),
67 "QD": (
"fitting",
"K"),
70LINE_KINDS: dict[str, tuple[str, str]] = {
71 "pipe": (
"pipe",
"darcy"),
72 "flex_hose": (
"flex_hose",
"darcy"),
75 "bend": (
"bend",
"rounded"),
76 "fitting": (
"fitting",
"K"),
82FALLBACKS: dict[str, dict[str, tuple[float, str]]] = {
83 "valve": {
"Cv": (4.0,
"Cv"),
"bore": (9.5,
"mm")},
84 "relief_valve": {
"Cv": (4.0,
"Cv"),
"bore": (9.5,
"mm")},
88 "cracking_pressure": (3.0,
"psi"),
97 "setpoint": (500.0,
"psia"),
100 "flow_droop": (20.0,
"psi"),
101 "rated_flow": (0.05,
"kg/s"),
103 "fitting": {
"bore": (9.5,
"mm")},
104 "pipe": {
"length": (1.0,
"m"),
"bore": (9.5,
"mm"),
"roughness": (0.0015,
"mm")},
106 "length": (0.5,
"m"),
108 "roughness": (0.0015,
"mm"),
109 "installed_bend_radius": (150.0,
"mm"),
110 "min_bend_radius": (100.0,
"mm"),
114 "bend_radius": (30.0,
"mm"),
115 "angle": (90.0,
"deg"),
116 "roughness": (0.0015,
"mm"),
126SINK_TYPES = frozenset({
"ENGINE",
"INJECTOR",
"VENT"})
132ENGINE_TYPES = frozenset({
"ENGINE",
"INJECTOR"})
143AMBIENT_TYPES = frozenset({
"VENT"})
152STATIC_HEAD_FILL = 0.95
155DEFAULT_TANK_BORE = 0.1524
157DEFAULT_TANK_LITRES = 0.0175
180VENTING_VALVE_TYPES = frozenset({
"MAN",
"ROT",
"SOL",
"MOV",
"RV"})
185ACTUATED_VALVE_TYPES = frozenset({
"ROT",
"SOL",
"MOV"})
196HAND_VALVE_TYPES = frozenset({
"MAN"})
199@dataclass(frozen=True, slots=True)
201 """A transducer, and the network node it reads."""
209@dataclass(frozen=True, slots=True)
211 """Where a drawing symbol sits, so the app can draw the same picture."""
221@dataclass(frozen=True, slots=True)
223 """A tank is one symbol and two places.
225 Pressurant arrives at the top and propellant leaves the bottom, and they are
226 at different pressures by the height of the liquid column. Modelled as one
227 node, the regulator would be feeding the same point the propellant leaves
228 from -- the tank would have no inventory and no head, and the solve would be
229 asked to balance an inflow against an outflow at a single pressure.
231 Which port a line lands on is read from the fluid at its other end: a line
232 carrying something other than what the tank holds is the pressurant side.
233 That is more robust than reading the handle it was dropped on, and it is
234 right for the same reason the drawing colours lines by fluid.
241@dataclass(frozen=True, slots=True)
243 """The hand-loaded regulator that sets a dome-loaded one's dome.
245 It is lifted out of the flow network -- its outlet goes to a dome port and
246 carries almost nothing -- but lifting it out is not the same as deleting
247 it. The loader hangs off the *same* supply as the regulator it controls, so
248 its own outlet rides up as that supply decays, and the dome rides with it.
249 Dropping the component and keeping only its setpoint pins the dome to a
250 constant, which is the one thing a dome on a blowing-down bottle does not
251 do: it turns the supply-pressure effect off for the whole stand and hides
252 it as a modelling choice nobody made.
254 Carried here so a caller can evaluate the dome against the supply pressure
255 of the moment, with the loader's own declared coefficients, rather than
256 re-deriving the formula somewhere else.
262 component: HydraulicComponent
269@dataclass(frozen=True, slots=True)
271 """A dome-loading line drawn with valves on it: the loader, the line's
272 symbols and how they join, and which of them gate or vent it.
274 The dome follows its loader only while the line is open between them.
275 Shut, the dome holds what was in it -- which is how a stand loads its
276 regulator on the pad and then keeps it through the burn with the GSE
277 disconnected -- and an open vent on the regulator's side empties it.
285 adjacent: Mapping[str, frozenset[str]]
287 ports: frozenset[str]
289 valves: frozenset[str]
290 vents: frozenset[str]
293@dataclass(frozen=True, slots=True)
295 """A network, plus everything needed to draw and drive it."""
299 node_of: Mapping[str, str] = field(default_factory=dict)
301 branches_of: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
302 instruments: tuple[Instrument, ...] = ()
303 placements: tuple[Placement, ...] = ()
305 tanks: Mapping[str, TankPorts] = field(default_factory=dict)
308 engine_ports: Mapping[str, str] = field(default_factory=dict)
310 actuators: Mapping[str, str] = field(default_factory=dict)
312 dome_loaders: Mapping[str, DomeLoader] = field(default_factory=dict)
313 warnings: tuple[str, ...] = ()
317 valve_roles: Mapping[str, frozenset[str]] = field(default_factory=dict)
321 gse_vents: tuple[str, ...] = ()
324 mated: tuple[tuple[str, str], ...] = ()
328 rest: Mapping[str, float] = field(default_factory=dict)
330 hand_valves: frozenset[str] = frozenset()
333 vehicle: frozenset[str] |
None =
None
337 supplies: Mapping[str, frozenset[str]] = field(default_factory=dict)
339 dome_lines: Mapping[str, DomeLine] = field(default_factory=dict)
342def _accepted_options(kind: str, options: Mapping[str, str]) -> dict[str, str]:
343 """Keep only the options the physics component declares.
345 A drawing carries more than a solve needs -- how many ports a tank lid has,
346 which side of the umbilical a QD is, whether a regulator is dome loaded.
347 Those are real and belong on the drawing; passing them through would be
348 rejected at load, and dropping them silently would lose the ones the
349 transform *does* read. So they are filtered here and read explicitly where
355 spec = get_component_spec(kind)
358 allowed = {o.name
for o
in spec.options}
359 return {k: v
for k, v
in options.items()
if k
in allowed}
374_SUPERSEDED_BY_SEGMENTS = frozenset({
"length",
"bore",
"K_minor"})
377def _superseded_warnings(edge: PidEdge) -> list[str]:
378 """Say which line-level numbers the segment list overrode.
380 Worth a line in the run report rather than a silent drop: somebody typed
381 those, and if the segments are wrong the stale line-level value is the first
384 leftover = sorted(_SUPERSEDED_BY_SEGMENTS & set(edge.params))
388 f
"{name} = {edge.params[name].value:g} {edge.params[name].unit}"
392 f
"{edge.id}: the run is itemised into {len(edge.segments)} segment(s), "
393 f
"so the line-level {stated} is superseded and not used"
401 params: Mapping[str, Param],
402 options: Mapping[str, str],
404 segments: tuple[LineSegment, ...] = (),
405) -> HydraulicComponent:
406 filled = dict(params)
407 for name, (value, unit)
in FALLBACKS.get(kind, {}).items():
408 if name
not in filled:
409 filled[name] =
Param(
413 f
"not on the drawing; feed-twin fallback for a {kind}",
418 instance = ComponentInstance.build(
422 options=_accepted_options(kind, options),
426 except SpecError
as exc:
429 raise SpecError(f
"{tag}: {exc}")
from exc
430 return build_component(instance)
436 default_temperature: float = 293.15,
437 default_fluid: str =
"nitrogen",
438 engine: object |
None =
None,
439 multiphase: bool =
False,
441 """Build a solvable network from a drawing.
444 DiagramError: the drawing cannot be a feed system -- no source, or an
445 inline symbol with the wrong number of connections. Refused rather
446 than patched: a drawing missing its tank solves as an empty system
447 and reports zero flow, which looks like an answer.
450 multiphase: Let the property layer decide phase from ``(p, T)``. Off,
451 so a declared liquid is solved as a liquid. See
452 :attr:`feedtwin.solve.network.Network.multiphase`.
454 by_id = {n.id: n
for n
in diagram.nodes}
455 net =
Network(multiphase=multiphase)
456 node_of: dict[str, str] = {}
457 branches_of: dict[str, tuple[str, ...]] = {}
458 instruments: list[Instrument] = []
459 tanks: dict[str, TankPorts] = {}
460 placements: list[Placement] = []
461 actuators: dict[str, str] = {}
462 rest: dict[str, float] = {}
463 hand: set[str] = set()
464 warnings: list[str] = []
465 vents: list[str] = []
466 vent_ids: set[str] = set()
467 unsized: list[tuple[str, str]] = []
468 direct: list[tuple[str, str, str]] = []
469 into_engine: dict[str, str] = {}
472 landed: set[str] = set()
480 _vent_reach(diagram) | _tank_reliefs(diagram) | _top_port_reach(diagram)
482 fluids, temperatures = _fluid_of(diagram, _pressurant_reach(diagram) | _vent_side)
487 pressurant = _pressurant_reach(diagram)
493 pressurant_species = next(
494 (n.fluid
for n
in diagram.nodes
if n.type ==
"KBOTTLE" and n.fluid),
496 (n.fluid
for n
in diagram.nodes
if n.type ==
"DEWAR" and n.fluid),
501 ullage_side = pressurant | _vent_side
503 for node
in diagram.nodes:
504 if node.drawn_as ==
"TANK":
506 f
"{node.label or node.id} is drawn as a TANK but holds {node.fluid} "
507 "above its critical temperature, so it can only be gas: read as the "
508 "pressurant supply (a Pressurant bottle, KBOTTLE). Draw it as one to "
509 "make that explicit."
512 vehicles = _separate_vehicles(diagram)
513 if len(vehicles) > 1:
515 _and([f
"{e.label or e.id} (page {e.page or 'Main'})" for e
in engines])
516 for engines
in vehicles
519 f
"{diagram.name}: this drawing holds {len(vehicles)} separate vehicles, "
520 f
"each with its own engine: {named}. Every page is built into one "
521 "stand, so two versions of a rocket on one drawing -- pid-designer's "
522 "Duplicate page -- run as two vehicles sharing their tags, and no "
523 "valve binds. Keep one version on this drawing and save the other "
524 "as a drawing of its own."
528 sources = [n
for n
in diagram.nodes
if n.is_source]
531 f
"{diagram.name}: nothing on this drawing declares a pressure. A "
532 "feed solve starts at a tank, a K-bottle or a dewar; add one, or "
533 "this is a schematic rather than a system."
536 for node
in diagram.nodes:
537 if node.is_annotation
or node.is_instrument
or node.is_inline:
539 fluid = fluids.get(node.id, default_fluid)
540 temperature = _temperature(node, temperatures.get(node.id, default_temperature))
541 if node.type ==
"TANK":
547 fluid = pressurant_species
548 temperature = _temperature(node, default_temperature)
550 if node.is_source
or node.type
in SINK_TYPES:
551 pressure = _pressure(node)
552 if pressure
is None and node.type
in AMBIENT_TYPES:
557 default = 500.0
if node.is_source
else 350.0
558 pressure = default * 6894.757293168361
560 f
"{node.label} has no pressure on the drawing; assumed "
563 net.add_node(node.id, fluid, temperature, pressure=pressure)
565 net.add_node(node.id, fluid, temperature)
566 node_of[node.id] = node.id
568 if node.type ==
"TANK":
582 outlet = f
"{node.id}.out"
583 contents = fluids.get(node.id, default_fluid)
584 liquid_T = _temperature(
585 node, temperatures.get(node.id, default_temperature)
587 head, head_warnings = _column_head(node, contents, liquid_T)
588 warnings.extend(head_warnings)
589 base = _pressure(node)
or 500.0 * 6894.757293168361
590 warnings.extend(_tank_warnings(node, contents, liquid_T, base))
591 net.add_node(outlet, contents, liquid_T, pressure=base + head)
592 tanks[node.id] = TankPorts(ullage=node.id, outlet=outlet)
599 dome_loaders = _dome_loaders(diagram)
600 built_loaders: dict[str, DomeLoader] = {}
601 for loader, loaded
in dome_loaders.items():
603 f
"{by_id[loader].label} loads the dome of {by_id[loaded].label}; "
604 "read as a setpoint, not as a flow path."
609 for node
in diagram.nodes:
610 if not node.is_inline
or node.id
in dome_loaders:
612 kind, model = _kind_and_model(node)
613 if node.type ==
"RV" and kind !=
"relief_valve":
621 f
"{node.label} is a relief valve with no set_pressure, so it is "
622 "read as shut -- a relief that never lifts. Give it a "
623 "set_pressure (and its Cv) to model one that lifts and reseats."
626 fluid = fluids.get(node.id, default_fluid)
627 temperature = _temperature(node, temperatures.get(node.id, default_temperature))
628 upstream, downstream = f
"{node.id}.in", f
"{node.id}.out"
629 net.add_node(upstream, fluid, temperature)
630 net.add_node(downstream, fluid, temperature)
631 node_of[node.id] = upstream
633 options = dict(node.options)
634 params = dict(node.params)
635 if node.type ==
"PR" and options.get(
"domeLoaded") ==
"yes":
638 Param(50.0,
"psi", Provenance.DEFAULT,
"dome-loaded; bias unstated"),
641 for loader, loaded_id
in dome_loaders.items():
642 if loaded_id != node.id:
644 setpoint = by_id[loader].params.get(
"setpoint")
645 if setpoint
is not None:
646 params[
"dome_pressure"] = setpoint
647 elif not _hand_loaded(by_id[loader]):
651 actuators[loader] = f
"{node.label}.dome"
652 built_loaders[loader] = _build_dome_loader(
653 by_id[loader], f
"{node.label}.dome", diagram, node_of
656 if loaded_by
is None:
662 actuators[node.id] = f
"{node.label}.dome"
663 component = _instance(node.label, kind, model, params, options)
664 net.add_branch(node.id, component, upstream, downstream)
665 branches_of[node.id] = (node.id,)
667 Placement(node.id, node.label, node.type, node.x, node.y, fluid)
669 if node.type
in ACTUATED_VALVE_TYPES:
670 actuators[node.id] = f
"{node.label}.command"
671 rest[node.id] = 1.0
if options.get(
"failState") ==
"open" else 0.0
672 elif node.type
in HAND_VALVE_TYPES:
673 actuators[node.id] = f
"{node.label}.command"
679 tagged: dict[str, list[PidNode]] = {}
680 for node
in diagram.nodes:
681 if node.type
in ACTUATED_VALVE_TYPES
and node.id
in net.branches
and node.label:
682 tagged.setdefault(node.label, []).append(node)
683 for tag, valves
in sorted(tagged.items()):
685 pages = sorted({v.page
or "Main" for v
in valves})
687 f
"{tag} is the tag of {len(valves)} valves (page "
688 f
"{_and(pages)}): the state table drives them as one signal, "
689 "and binds neither by its job. Give each its own tag."
695 edge_ids = {edge.id
for edge
in diagram.edges}
696 on_lines: list[PidNode] = []
697 for node
in diagram.nodes:
698 if node.id
in dome_loaders:
699 placements.append(Placement(node.id, node.label, node.type, node.x, node.y))
700 if not node.is_instrument:
702 if node.attached_to
in edge_ids
and node.attached_to
not in by_id:
703 on_lines.append(node)
705 anchor = _instrument_anchor(node, by_id, node_of, diagram)
708 f
"{node.label} is not clipped to anything and reads nothing."
711 instruments.append(Instrument(node.id, node.label, node.type, anchor))
712 placements.append(Placement(node.id, node.label, node.type, node.x, node.y))
718 plumbing: list[PidEdge] = []
721 line_ends: dict[str, tuple[str, str]] = {}
722 for edge
in diagram.edges:
723 source = by_id.get(edge.source)
724 target = by_id.get(edge.target)
725 if source
is None or target
is None:
727 if source.is_instrument
or target.is_instrument:
729 if source.is_annotation
or target.is_annotation:
731 if edge.source
in dome_loaders
or edge.target
in dome_loaders:
733 if (source.type ==
"PR" and edge.source_handle == DOME_HANDLE)
or (
734 target.type ==
"PR" and edge.target_handle == DOME_HANDLE
742 plumbing.append(edge)
743 by_port, port_warnings = _sides_by_port(diagram, plumbing)
744 warnings.extend(port_warnings)
746 for edge
in plumbing:
747 source = by_id[edge.source]
748 target = by_id[edge.target]
754 outgoing=by_port.get((edge.id, source.id),
True),
756 downstream = _attach(
761 outgoing=by_port.get((edge.id, target.id),
False),
763 if upstream
not in net.nodes
or downstream
not in net.nodes:
765 if upstream == downstream:
767 landed.update((upstream, downstream))
768 line_ends[edge.id] = (upstream, downstream)
770 kind, model = LINE_KINDS.get(edge.line_type, LINE_KINDS[
"pipe"])
773 line_params: Mapping[str, Param] = edge.params
774 line_options: Mapping[str, str] = edge.options
775 segments = edge.segments.segments
776 stated_bore = line_params.get(
"bore")
777 if not segments
and stated_bore
is not None and stated_bore.si <= 0.0:
784 f
"{edge.id} ({source.label or source.id} -> "
785 f
"{target.label or target.id}): states a bore of "
786 f
"{stated_bore.value:g} {stated_bore.unit}, which no flow can "
787 "pass; give the line its bore"
792 kind, model =
"pipe",
"segmented"
795 for name, value
in edge.params.items()
796 if name
not in _SUPERSEDED_BY_SEGMENTS
798 line_options = _accepted_options(kind, edge.options)
799 warnings.extend(edge.segments.warnings)
800 warnings.extend(_superseded_warnings(edge))
801 elif kind
in {
"pipe",
"flex_hose"}
and not (
802 "length" in line_params
or "bore" in line_params
810 if engine
is not None and (
811 source.type
in ENGINE_TYPES
or target.type
in ENGINE_TYPES
814 into_engine[edge.id] = (
815 upstream
if target.type
in ENGINE_TYPES
else downstream
818 direct.append((edge.id, upstream, downstream))
820 elif kind
in {
"pipe",
"flex_hose"}
and not (
821 "length" in line_params
and "bore" in line_params
825 (edge.id, f
"{source.label or source.id} -> {target.label or target.id}")
827 component = _instance(
828 edge.id, kind, model, line_params, line_options, segments=segments
830 net.add_branch(edge.id, component, upstream, downstream)
831 branches_of[edge.id] = (edge.id,)
836 for node
in on_lines:
837 ends = line_ends.get(node.attached_to)
840 f
"{node.label} is clipped to a line the twin does not solve as "
841 "plumbing (a dome or clip line), and reads nothing."
844 anchor = ends[0]
if node.attached_at < 0.5
else ends[1]
845 instruments.append(Instrument(node.id, node.label, node.type, anchor))
846 placements.append(Placement(node.id, node.label, node.type, node.x, node.y))
851 mates, mate_warnings = _mate_disconnects(diagram, net, landed)
852 warnings.extend(mate_warnings)
854 mated = tuple((a, b)
for a, b
in (tuple(m[0].
split(
":")[1:3])
for m
in mates))
857 edges_by_id = {e.id: e
for e
in diagram.edges}
858 rename, shorted, kept = _join_direct(net, direct)
859 for edge_id, upstream, downstream
in kept:
862 if edge_id.startswith(
"mate:"):
863 _, a, b = edge_id.split(
":")
864 source, target = by_id[a], by_id[b]
866 line = edges_by_id[edge_id]
867 source, target = by_id[line.source], by_id[line.target]
870 _instance(edge_id,
"pipe",
"darcy", {}, {}),
874 branches_of[edge_id] = (edge_id,)
876 (edge_id, f
"{source.label or source.id} -> {target.label or target.id}")
878 node_of = {k: rename.get(v, v)
for k, v
in node_of.items()}
879 instruments = [replace(i, node=rename.get(i.node, i.node))
for i
in instruments]
881 k: replace(v, supply_node=rename.get(v.supply_node, v.supply_node))
882 for k, v
in built_loaders.items()
884 into_engine = {k: rename.get(v, v)
for k, v
in into_engine.items()}
885 for symbol_id
in shorted:
886 shown = by_id[symbol_id].label
if symbol_id
in by_id
else symbol_id
888 f
"{shown or symbol_id} has both sides joined "
889 "directly to the same place, so it carries nothing and is left out."
891 branches_of.pop(symbol_id,
None)
895 dome_lines = _dome_lines(diagram, dome_loaders)
896 on_dome_lines = {sid
for line
in dome_lines.values()
for sid
in line.adjacent}
897 on_dome_lines |= _unloaded_dome_valves(diagram, dome_loaders)
904 for node
in diagram.nodes:
906 node.type
not in VENTING_VALVE_TYPES
907 or node.id
in dome_loaders
908 or node.id
in on_dome_lines
911 if node.id
not in net.branches:
914 branch = net.branches[node.id]
917 for other
in net.branches.values()
918 if other
is not branch
919 for end
in (other.upstream, other.downstream)
924 attached |= set(into_engine.values())
927 for port
in (branch.upstream, branch.downstream)
928 if port
not in attached
932 port = net.nodes[free[0]]
933 if port.pressure
is not None:
935 net.nodes[free[0]] = replace(port, pressure=AMBIENT)
936 vents.append(node.label
or node.id)
937 vent_ids.add(node.id)
939 resting_shut: list[str] = []
940 for valve_id
in sorted(hand):
941 drawn = str(by_id[valve_id].options.get(
"normalPosition",
"")
or "")
942 rest[valve_id] = 1.0
if drawn ==
"open" else 0.0
944 resting_shut.append(by_id[valve_id].label
or valve_id)
947 f
"{len(resting_shut)} hand valve(s) rest shut until opened by hand: "
948 f
"{', '.join(resting_shut)}. A hand valve that stands open is drawn "
949 "so: pid-designer's Normal position set to Normally open "
950 "(normalPosition: open)."
955 f
"{len(vents)} valve(s) have one side open and are read as venting "
956 f
"to atmosphere: {', '.join(sorted(vents))}. That is how a P&ID "
957 "draws a vent; add a VENT symbol if you meant something else."
966 tank_contents = {fluids.get(tank_id, default_fluid)
for tank_id
in tanks}
967 for drawing_id, anchor
in node_of.items():
968 if drawing_id
in tanks:
970 if drawing_id
in ullage_side:
972 elif fluids.get(drawing_id,
"")
in tank_contents:
975 fluids.get(drawing_id, default_fluid) == pressurant_species
976 and pressurant_species
not in tank_contents
989 for candidate
in (anchor, f
"{drawing_id}.in", f
"{drawing_id}.out"):
990 found = net.nodes.get(candidate)
991 if found
is not None:
993 for ports
in tanks.values():
994 net.nodes[ports.ullage].phase =
"gas"
995 net.nodes[ports.outlet].phase =
"liquid"
999 def wet(edge_id: str) -> bool:
1000 branch = net.branches.get(edge_id)
1001 return branch
is not None and "liquid" in (
1002 net.nodes[branch.upstream].phase,
1003 net.nodes[branch.downstream].phase,
1006 feeds = [name
for edge_id, name
in unsized
if wet(edge_id)]
1007 tube = FALLBACKS[
"pipe"]
1009 f
"{len(unsized)} line(s) give only part of their size, or join two "
1010 "fixed pressures directly, and are solved with feed-twin's fallback "
1011 f
"tube for what is missing ({tube['length'][0]:g} m of "
1012 f
"{tube['bore'][0]:g} mm)"
1014 f
". Propellant lines among them, which set what the engine "
1015 f
"sees: {', '.join(feeds)}"
1019 + f
". All: {', '.join(name for _, name in unsized)}. Give them their size."
1027 engine_ports: dict[str, str] = {}
1028 if engine
is not None:
1029 engine_ports, engine_warnings = _attach_engine(
1035 default_temperature,
1036 into_engine=into_engine,
1038 warnings.extend(engine_warnings)
1044 vehicle = vehicle_ids(diagram)
1045 ground = ground_ids(diagram)
1046 supplies = _supplies(net, tanks, ground)
1047 shut_by_hand = frozenset(v
for v
in hand
if rest.get(v, 1.0) < 0.5)
1048 roles = _valve_roles(
1058 roles.update(_dome_valve_roles(dome_lines, ground))
1059 gse = _gse_vents(net, diagram, tanks, fluids, engine_ports, roles)
1060 gse_unsized = tuple(qd.id
for qd, _
in gse
if not {
"Cv",
"Cd"} & set(qd.params))
1067 node.label
or node.id
1068 for node
in diagram.nodes
1069 if node.type ==
"QD"
1070 and node.id
in net.branches
1071 and isinstance(net.branches[node.id].component, Fitting)
1075 f
"{len(elbows)} disconnect(s) have no Cv or Cd and are priced as a "
1076 "long-radius 90-degree elbow (K ~0.25), far less loss than a real "
1077 f
"quick disconnect: {', '.join(elbows)}. Give each its datasheet Cv."
1081 actuators[qd.id] = f
"{qd.label}.command"
1082 roles = _valve_roles(
1089 gse=frozenset(qd.id
for qd, _
in gse),
1093 roles.update(_dome_valve_roles(dome_lines, ground))
1095 f
"No vent valve is drawn on {_and(sorted(t for _, t in gse))}, "
1096 f
"so {_and(sorted(qd.label for qd, _ in gse))} on the tank top "
1097 f
"{'is' if len(gse) == 1 else 'are'} read as where the GSE vent "
1098 "couples: a valve to atmosphere the state machine's vent opens. The GSE side is not on the drawing, so "
1099 "its size is the disconnect's own Cv/Cd if it has one and the "
1100 "cart's vent valve (the session's setup) otherwise. Draw the vent valve to say what it is."
1103 return BuiltNetwork(
1106 engine_ports=engine_ports,
1108 branches_of=branches_of,
1109 instruments=tuple(instruments),
1110 placements=tuple(placements),
1111 actuators=actuators,
1112 dome_loaders=built_loaders,
1113 warnings=tuple(warnings),
1115 gse_vents=gse_unsized,
1118 hand_valves=frozenset(hand),
1119 vehicle=
None if not ground
else vehicle,
1121 dome_lines=dome_lines,
1125def _separate_vehicles(diagram: Diagram) -> list[list[PidNode]]:
1126 """The drawing's engines, grouped by the drawn-line piece each sits on.
1128 One group is one vehicle: an engine and its injector, or a cluster on one
1129 feed, are joined by lines and stay together. More than one group is more
1130 than one rocket on the drawing -- most often the same rocket twice, a
1131 hotfire and a launch version on two pages -- which :func:`build_network`
1132 refuses rather than run as a stand whose shared tags bind nothing.
1134 parent: dict[str, str] = {}
1136 def find(node: str) -> str:
1137 while parent.get(node, node) != node:
1141 for edge
in diagram.edges:
1142 a, b =
find(edge.source),
find(edge.target)
1145 groups: dict[str, list[PidNode]] = {}
1146 for node
in diagram.nodes:
1147 if node.type
in ENGINE_TYPES:
1148 groups.setdefault(
find(node.id), []).append(node)
1149 return list(groups.values())
1152def _mate_disconnects(
1153 diagram: Diagram, net: Network, landed: set[str]
1154) -> tuple[list[tuple[str, str, str]], list[str]]:
1155 """Paired quick-disconnects, as direct connections between their free sides.
1157 pid-designer has no off-page connector. A line from the rocket page to the
1158 GSE page is drawn as a disconnect on each page, the two paired through
1159 ``options.pairedWith`` -- the same coupling the stand has, a half on the
1160 vehicle and a half on the cart. Read separately, each half has a free side
1161 and the two pages are two unconnected systems; a GSE drawing then cannot
1162 feed the vehicle at all. Mated, the free sides are one place and the two
1163 halves' own losses stay in series.
1165 Pairing need not be written on both halves (pid-designer saves only the
1166 half that was edited); two halves naming different partners are a drawing
1167 error and are left unmated, said so. A half that has lines on both of its
1168 sides, or none, has no free side to mate and is left as it is. Two halves a
1169 drawn line already joins are coupled by it, and are left as drawn.
1171 Returns ``("mate:<a>:<b>", free side of a, free side of b)`` per pair, in
1172 the shape of the direct connections ``_join_direct`` takes.
1174 by_id = {n.id: n
for n
in diagram.nodes}
1176 def partner(node: PidNode) -> str:
1177 found = (node.options.get(
"pairedWith")
or "").strip()
1178 return "" if found
in {
"",
"none"}
else found
1180 pairs: set[tuple[str, str]] = set()
1181 warnings: list[str] = []
1182 for node
in diagram.nodes:
1183 if node.type !=
"QD":
1185 other = partner(node)
1188 label = node.label
or node.id
1189 mate = by_id.get(other)
1190 if mate
is None or mate.type !=
"QD":
1192 f
"{label} is paired with {other!r}, which is not a disconnect on "
1193 "this drawing: left unmated."
1196 back = partner(mate)
1197 if back
and back != node.id:
1199 f
"{label} is paired with {mate.label or mate.id}, which is paired "
1200 f
"with {back!r}: left unmated. Pair the two halves with each other."
1203 a, b = sorted((node.id, mate.id))
1210 joined = {frozenset((e.source, e.target))
for e
in diagram.edges}
1211 out: list[tuple[str, str, str]] = []
1212 for a, b
in sorted(pairs):
1213 if frozenset((a, b))
in joined:
1215 free: dict[str, list[str]] = {}
1217 ends = (f
"{qd}.in", f
"{qd}.out")
1218 free[qd] = [e
for e
in ends
if e
in net.nodes
and e
not in landed]
1219 lone = [q
for q
in (a, b)
if len(free[q]) != 1]
1222 f
"{_and(sorted(by_id[q].label or q for q in lone))} "
1223 f
"{'has' if len(lone) == 1 else 'have'} no single free side to "
1224 f
"couple: {by_id[a].label or a} and {by_id[b].label or b} left unmated."
1227 out.append((f
"mate:{a}:{b}", free[a][0], free[b][0]))
1228 pages = {by_id[a].page
or "Main", by_id[b].page
or "Main"}
1230 f
"{by_id[a].label or a} and {by_id[b].label or b} are paired, so they "
1231 "are read as one mated coupling"
1232 + (f
" joining pages {_and(sorted(pages))}." if len(pages) > 1
else ".")
1234 return out, warnings
1238 net: Network, direct: list[tuple[str, str, str]]
1239) -> tuple[dict[str, str], list[str], list[tuple[str, str, str]]]:
1240 """Make each direct connection's two ends one network node.
1242 Where one end is a fixed pressure -- a tank port, a bottle, atmosphere --
1243 the joined place keeps it: a valve screwed into a tank sees the tank. Two
1244 fixed pressures cannot be one place, so such a line is handed back to be
1245 built as a line. Returns the renaming (merged node -> the node it became),
1246 the branches left joining a place to itself (dropped), and the lines kept.
1248 parent: dict[str, str] = {}
1250 def find(node: str) -> str:
1251 while parent.get(node, node) != node:
1255 kept: list[tuple[str, str, str]] = []
1256 for edge_id, a, b
in direct:
1260 a_fixed = net.nodes[ra].pressure
is not None
1261 b_fixed = net.nodes[rb].pressure
is not None
1262 if a_fixed
and b_fixed:
1263 kept.append((edge_id, a, b))
1269 rename = {node:
find(node)
for node
in parent}
1270 shorted: list[str] = []
1271 for branch_id, branch
in list(net.branches.items()):
1272 branch.upstream = rename.get(branch.upstream, branch.upstream)
1273 branch.downstream = rename.get(branch.downstream, branch.downstream)
1274 if branch.upstream == branch.downstream:
1275 del net.branches[branch_id]
1276 shorted.append(branch_id)
1278 net.nodes.pop(node,
None)
1279 kept = [(e, rename.get(a, a), rename.get(b, b))
for e, a, b
in kept]
1280 return rename, shorted, kept
1283def _and(items: list[str]) -> str:
1284 return items[0]
if len(items) == 1
else ", ".join(items[:-1]) +
" and " + items[-1]
1289_OXIDISERS = frozenset({
"oxygen",
"nitrousoxide",
"n2o",
"hydrogenperoxide"})
1295 actuators: Mapping[str, str],
1296 tanks: Mapping[str, TankPorts],
1297 fluids: Mapping[str, str],
1298 engine_ports: Mapping[str, str],
1300 gse: frozenset[str] = frozenset(),
1301 ground: frozenset[str] = frozenset(),
1302 shut: frozenset[str] = frozenset(),
1303) -> dict[str, frozenset[str]]:
1304 """What each commandable valve does, from what its two sides reach.
1306 A valve between the regulated supply and a tank's ullage *is* that tank's
1307 press valve, whatever the drawing calls it; one between a tank's outlet and
1308 the engine is its main; one between an ullage and atmosphere is its vent.
1309 A state machine names its valves in a crew's words ("Fuel Press") and a
1310 drawing in a draughtsman's ("FU_SOL_R"), and when the words do not meet the
1311 plumbing still does -- so this is what lets a drawing nobody wrote for the
1312 table still be driven by it (see :func:`feedtwin.session.statemachine.bind`).
1314 Each side is walked through pipes, fittings, manifolds, regulators and check
1315 valves, but not through another commandable valve or a relief, and stops at
1316 the first fixed pressure it meets. A valve whose sides do not settle one job
1317 gets no role; nothing is guessed. ``gse`` names disconnects read as GSE
1318 vents (see :func:`_gse_vents`), which are commandable valves here too.
1322 valves = {n.id
for n
in diagram.nodes
if n.type
in ACTUATED_VALVE_TYPES} | gse
1325 stops = valves | shut | {n.id
for n
in diagram.nodes
if n.type ==
"RV"}
1326 reach = _reacher(net, diagram, tanks, engine_ports, stops)
1327 dewars = {n.id
for n
in diagram.nodes
if n.type ==
"DEWAR"}
1329 def side(tank: str) -> str:
1330 return _side(tank, diagram, fluids)
1333 a: dict[str, set[str]], b: dict[str, set[str]], on_ground: bool =
False
1334 ) -> frozenset[str] |
None:
1338 flight_in = {t
for t
in a[
"ullage"] | a[
"outlet"]
if t
not in ground}
1341 and ({t
for t
in b[
"outlet"]
if t
in ground}
or (b[
"supply"] & dewars))
1344 return frozenset({side(next(iter(flight_in))),
"fill"})
1345 if len(a[
"ullage"]) == 1
and next(iter(a[
"ullage"]))
in ground:
1346 tank = next(iter(a[
"ullage"]))
1347 if b[
"supply"]
and not a[
"supply"]:
1348 return frozenset({side(tank),
"fill",
"press"})
1349 if b[
"ambient"]
and not (a[
"supply"]
or b[
"supply"]):
1350 return frozenset({side(tank),
"fill",
"vent"})
1352 len(a[
"outlet"]) == 1
1354 and not (a[
"ullage"]
or a[
"supply"]
or b[
"supply"])
1357 return frozenset({side(next(iter(a[
"outlet"]))),
"dump"})
1358 if len(a[
"ullage"]) == 1
and b[
"supply"]
and not a[
"supply"]:
1359 return frozenset({side(next(iter(a[
"ullage"]))),
"press"})
1360 if len(a[
"outlet"]) == 1
and b[
"engine"]:
1361 return frozenset({side(next(iter(a[
"outlet"]))),
"main"})
1362 if len(a[
"ullage"]) == 1
and b[
"ambient"]
and not (a[
"supply"]
or b[
"supply"]):
1363 return frozenset({side(next(iter(a[
"ullage"]))),
"vent"})
1367 and not any(a[k]
or b[k]
for k
in (
"ullage",
"outlet"))
1369 return frozenset({
"gn2",
"vent"})
1372 roles: dict[str, frozenset[str]] = {}
1373 for drawing_id
in actuators:
1374 if drawing_id
not in valves
or drawing_id
not in net.branches:
1378 branch = net.branches[drawing_id]
1379 a, b = reach(branch.upstream), reach(branch.downstream)
1380 here = drawing_id
in ground
1381 found = job(a, b, here)
or job(b, a, here)
1383 roles[drawing_id] = found
1387Reach = Callable[[str], dict[str, set[str]]]
1391 net: Network, tanks: Mapping[str, TankPorts], ground: frozenset[str]
1392) -> dict[str, frozenset[str]]:
1393 """Ground tanks that load a vehicle tank, and the vehicle tanks each reaches.
1395 Walked on the built network -- so across mated disconnects -- from the
1396 ground tank's outlet, through every branch (a fill valve is how the load is
1397 commanded, not whether the line exists), stopping at any fixed pressure.
1398 A vehicle tank met on the way is loaded from it.
1402 adjacent: dict[str, list[str]] = {}
1403 for branch
in net.branches.values():
1404 adjacent.setdefault(branch.upstream, []).append(branch.downstream)
1405 adjacent.setdefault(branch.downstream, []).append(branch.upstream)
1407 for tank_id, ports
in tanks.items():
1408 port_of[ports.ullage] = tank_id
1409 port_of[ports.outlet] = tank_id
1410 out: dict[str, frozenset[str]] = {}
1411 for tank_id, ports
in tanks.items():
1412 if tank_id
not in ground:
1414 reached: set[str] = set()
1415 seen = {ports.outlet}
1416 frontier = [ports.outlet]
1418 here = frontier.pop()
1419 for there
in adjacent.get(here, ()):
1423 owner = port_of.get(there)
1424 if owner
is not None and owner
not in ground:
1426 if net.nodes[there].pressure
is not None:
1428 frontier.append(there)
1430 out[tank_id] = frozenset(reached)
1437 tanks: Mapping[str, TankPorts],
1438 engine_ports: Mapping[str, str],
1439 stops: set[str] | frozenset[str],
1441 """A walk from one network node to the fixed pressures it meets.
1443 Through every branch but ``stops``, stopping at each boundary, and sorting
1444 what it met into tank ullages and outlets, supplies, the engine and
1447 adjacent: dict[str, list[str]] = {}
1448 for branch
in net.branches.values():
1449 if branch.id
in stops:
1451 adjacent.setdefault(branch.upstream, []).append(branch.downstream)
1452 adjacent.setdefault(branch.downstream, []).append(branch.upstream)
1454 ullage = {ports.ullage: tank
for tank, ports
in tanks.items()}
1455 outlet = {ports.outlet: tank
for tank, ports
in tanks.items()}
1456 supply = {n.id
for n
in diagram.nodes
if n.type
in {
"KBOTTLE",
"DEWAR"}}
1457 engine = {n.id
for n
in diagram.nodes
if n.type
in ENGINE_TYPES}
1458 if engine_ports.get(
"chamber"):
1459 engine.add(engine_ports[
"chamber"])
1461 def reach(start: str) -> dict[str, set[str]]:
1462 found: dict[str, set[str]] = {
1473 node = net.nodes.get(here)
1474 if node
is not None and node.pressure
is not None:
1476 found[
"ullage"].add(ullage[here])
1477 elif here
in outlet:
1478 found[
"outlet"].add(outlet[here])
1479 elif here
in supply:
1480 found[
"supply"].add(here)
1481 elif here
in engine:
1482 found[
"engine"].add(here)
1483 elif abs(node.pressure - AMBIENT) < 1.0:
1484 found[
"ambient"].add(here)
1487 for nxt
in adjacent.get(here, ()):
1496def propellant_side(species: str) -> str:
1497 """``lox`` or ``fuel``: which leg of a stand a propellant is on, in the
1498 state machines' words. The cockpit asks this rather than reading a tank's
1499 label, which on LE4 is "TK-3" and says nothing."""
1500 compact = species.lower().replace(
" ",
"").replace(
"-",
"")
1501 return "lox" if compact
in _OXIDISERS
else "fuel"
1504def _side(tank: str, diagram: Diagram, fluids: Mapping[str, str]) -> str:
1505 """``lox`` or ``fuel``: which leg of the stand a tank is on."""
1506 by_id = {n.id: n
for n
in diagram.nodes}
1507 return propellant_side(
1508 fluids.get(tank,
"")
or (by_id[tank].fluid
if tank
in by_id
else "")
1515 tanks: Mapping[str, TankPorts],
1516 fluids: Mapping[str, str],
1517 engine_ports: Mapping[str, str],
1518 roles: Mapping[str, frozenset[str]],
1519) -> list[tuple[PidNode, str]]:
1520 """Disconnects on a tank's top that are where its GSE vent couples.
1522 A flight tank often carries no vent valve of its own: the vent line comes
1523 off the ground cart through a quick-disconnect on the tank's top manifold,
1524 and the valve that opens it is on the cart. The drawing then shows a QD
1525 with nothing beyond it -- LE4's QD_OVA and QD_FVA. Read as capped, which a
1526 disconnected QD is, the tank cannot vent at all: the pad's fill boils its
1527 ullage up to the critical pressure and the sequence never gets past it.
1529 So a QD is read as the GSE vent, a valve to atmosphere, when all of these
1530 hold, and otherwise stays capped exactly as before:
1532 * one of its ends is plumbed and the other is not;
1533 * the plumbed end reaches one tank's ullage and nothing else fixed -- no
1534 outlet, no supply, no engine, no atmosphere (a vent already drawn);
1535 * no commandable valve on the drawing already vents that leg; and
1536 * it is the only such QD on that tank.
1538 Returns each QD read this way with the tank it vents. The network is
1539 edited in place: the QD's branch becomes a valve and its free end
1542 by_id = {n.id: n
for n
in diagram.nodes}
1543 labels = {n.id: n.label
or n.id
for n
in diagram.nodes}
1545 next(iter(role - {
"vent"}))
1546 for role
in roles.values()
1547 if "vent" in role
and len(role) == 2
1549 stops = {n.id
for n
in diagram.nodes
if n.type
in {
"ROT",
"SOL",
"RV"}}
1550 candidates: dict[str, list[tuple[PidNode, str]]] = {}
1551 for node
in diagram.nodes:
1552 if node.type !=
"QD" or node.id
not in net.branches:
1554 branch = net.branches[node.id]
1557 for other
in net.branches.values()
1558 if other
is not branch
1559 for end
in (other.upstream, other.downstream)
1561 ends = (branch.upstream, branch.downstream)
1562 free = [end
for end
in ends
if end
not in attached]
1563 if len(free) != 1
or net.nodes[free[0]].pressure
is not None:
1565 plumbed = ends[1]
if free[0] == ends[0]
else ends[0]
1566 found = _reacher(net, diagram, tanks, engine_ports, stops | {node.id})(plumbed)
1567 if len(found[
"ullage"]) != 1
or any(
1568 found[k]
for k
in (
"outlet",
"supply",
"engine",
"ambient")
1571 tank = next(iter(found[
"ullage"]))
1572 if _side(tank, diagram, fluids)
in vented:
1574 candidates.setdefault(tank, []).append((node, free[0]))
1576 out: list[tuple[PidNode, str]] = []
1577 for tank, found_qds
in candidates.items():
1578 if len(found_qds) != 1:
1580 node, vent_end = found_qds[0]
1581 model =
"cd" if "Cd" in node.params
else "cv"
1582 net.branches[node.id].component = _instance(
1583 node.label,
"valve", model, node.params, node.options
1585 net.nodes[vent_end] = replace(
1586 net.nodes[vent_end], pressure=AMBIENT, phase=
"gas"
1588 out.append((node, labels.get(tank, tank)))
1597PORT_SIDES: Mapping[str, bool] = {
"l":
False,
"r":
True}
1608DIRECTED_TYPES = frozenset({
"CV",
"PR"})
1612 diagram: Diagram, plumbing: list[PidEdge]
1613) -> tuple[dict[tuple[str, str], bool], list[str]]:
1614 """Where a line's direction cannot say which side of a symbol it is on.
1616 A line's direction is read as flow -- out of the symbol it leaves, into the
1617 one it reaches -- which is right for a drawing authored in flow order and is
1618 kept wherever it works. But pid-designer's lines have no direction ("walking
1619 any line in either direction", its ``checks.ts``); ``source`` is only the
1620 end the mouse started at. Draw a press line from the tank to its solenoid
1621 and both of the solenoid's lines *arrive*: both land on its inlet, the
1622 outlet hangs open, and step 4b reads the press valve as a vent from the
1623 regulator to atmosphere.
1625 So where direction leaves one side of an inline symbol with nothing on it,
1626 and every line on the symbol says which of its ports it was drawn on, the
1627 port decides instead. A symmetric valve with lines on both sides reads
1628 exactly as before, ports or no ports. A directed one (:data:`DIRECTED_TYPES`)
1629 is read by its ports whenever they are all drawn: there the port is the
1630 valve's direction, and the end the mouse started at is not.
1632 Returns ``{(edge id, node id): outgoing}`` overrides, and one warning per
1633 symbol read this way.
1635 ends: dict[str, list[tuple[str, bool, str]]] = {}
1636 for edge
in plumbing:
1637 ends.setdefault(edge.source, []).append((edge.id,
True, edge.source_handle))
1638 ends.setdefault(edge.target, []).append((edge.id,
False, edge.target_handle))
1640 overrides: dict[tuple[str, str], bool] = {}
1641 warnings: list[str] = []
1642 for node
in diagram.nodes:
1643 here = ends.get(node.id, [])
1644 if not node.is_inline
or len(here) < 2:
1646 ports = {port
for _, _, port
in here}
1647 if ports != set(PORT_SIDES):
1649 if len({outgoing
for _, outgoing, _
in here}) != 1:
1652 if node.type
not in DIRECTED_TYPES
or all(
1653 outgoing == PORT_SIDES[port]
for _, outgoing, port
in here
1656 for edge_id, _, port
in here:
1657 overrides[(edge_id, node.id)] = PORT_SIDES[port]
1659 f
"{node.label or node.id}: its lines run against its ports, so "
1660 "its inlet and outlet were read from the ports (l in, r out). "
1661 "If the flow really enters on r, it is drawn backwards: turn it "
1665 for edge_id, _, port
in here:
1666 overrides[(edge_id, node.id)] = PORT_SIDES[port]
1667 drawn =
"into" if not here[0][1]
else "out of"
1669 f
"{node.label or node.id}: every line was drawn {drawn} it, so its "
1670 "inlet and outlet were read from the ports the lines were drawn on "
1673 return overrides, warnings
1679 ullage_side: frozenset[str],
1680 tanks: Mapping[str, TankPorts],
1684 """Which network node a line's end lands on.
1686 An inline symbol has a node either side, so a line leaving it attaches to
1687 its outlet and a line arriving attaches to its inlet.
1689 A tank has two, and the choice is made by *topology*: a line whose other end
1690 reaches either a gas supply or atmosphere is an ullage line -- pressurant in
1691 at the top, vent out at the top. Deciding it by fluid instead -- "the line
1692 carrying something else" -- breaks on both of the cases it is most needed
1693 for: a cold flow where the ox tank holds LN2 and the pressurant is GN2, and
1694 any vent at all, whose line carries the tank's own contents.
1697 return f
"{node.id}.out" if outgoing
else f
"{node.id}.in"
1698 ports = tanks.get(node.id)
1701 return ports.ullage
if other.id
in ullage_side
else ports.outlet
1704def _reach(diagram: Diagram, start: frozenset[str]) -> frozenset[str]:
1705 """Symbols reachable from these kinds without passing through a vessel.
1707 A tank is a barrier because it is where the walk's meaning changes: the
1708 pressurant side of a tank and its liquid side are different places, and a
1709 search that walked straight through would call the whole system one.
1711 return _walk(diagram, [n.id
for n
in diagram.nodes
if n.type
in start])
1714def _walk(diagram: Diagram, starts: Iterable[str]) -> frozenset[str]:
1715 """These symbols and everything they reach short of a vessel or an engine."""
1716 adjacency: dict[str, list[str]] = {}
1717 for edge
in diagram.edges:
1718 adjacency.setdefault(edge.source, []).append(edge.target)
1719 adjacency.setdefault(edge.target, []).append(edge.source)
1720 types = {n.id: n.type
for n
in diagram.nodes}
1722 seen: set[str] = set()
1723 queue = list(starts)
1726 current = queue.pop(0)
1727 for neighbour
in adjacency.get(current, []):
1728 if neighbour
in seen:
1731 if types.get(neighbour)
not in {
"TANK",
"ENGINE",
"INJECTOR"}:
1732 queue.append(neighbour)
1733 return frozenset(seen)
1736def _pressurant_reach(diagram: Diagram) -> frozenset[str]:
1737 """Symbols the gas supplies reach without going through a tank."""
1738 return _reach(diagram, frozenset({
"KBOTTLE",
"DEWAR"}))
1741def _vent_reach(diagram: Diagram) -> frozenset[str]:
1742 """Symbols that reach atmosphere without going through a tank.
1744 A tank's vent belongs on its **ullage**, not its outlet, and nothing about
1745 the fluid says so -- a LOX tank's vent line carries oxygen exactly like its
1746 feed line does. Topology settles it: the line that ends at atmosphere is
1747 the one that takes gas off the top.
1749 Landing it on the liquid side instead is silent and ruinous. The tank never
1750 relieves, so filling one compresses its ullage to hundreds of psi, and a
1751 vent state does nothing at all -- which is most of what a pad sequence is
1753 return _reach(diagram, AMBIENT_TYPES)
1756def _top_port_reach(diagram: Diagram) -> frozenset[str]:
1757 """What is drawn off a tank's top port, and what that reaches short of a
1760 Topology alone reads a tank line as ullage when it reaches a supply or
1761 atmosphere. A manifold on the tank's top carrying only a transducer, a
1762 capped vent disconnect and a relief reaches neither, so it landed on the
1763 tank's *liquid outlet*: LE4's LOX vent manifold read as full of liquid, its
1764 transducer read the outlet, and the disconnect the GSE vents through could
1765 only ever have dumped LOX. The port the line was drawn on says where it is.
1766 Only an upright tank's ``t`` ports (a turned-over tank's ``b`` ports) are
1767 read this way; at any other turn the port says nothing about up, and
1768 topology decides as before.
1770 tanks = {n.id: n
for n
in diagram.nodes
if n.type ==
"TANK"}
1771 types = {n.id: n.type
for n
in diagram.nodes}
1773 def on_top(tank: PidNode, handle: str) -> bool:
1774 side, number = handle[:1], handle[1:]
1775 if side
not in {
"t",
"b"}
or not (number ==
"" or number.isdigit()):
1777 if tank.rotation == 0.0:
1779 if tank.rotation == 180.0:
1783 starts: set[str] = set()
1784 for edge
in diagram.edges:
1785 for end, handle, other
in (
1786 (edge.source, edge.source_handle, edge.target),
1787 (edge.target, edge.target_handle, edge.source),
1789 tank = tanks.get(end)
1790 if tank
is None or not on_top(tank, handle):
1792 if types.get(other)
not in {
"TANK",
"ENGINE",
"INJECTOR"}:
1794 return _walk(diagram, starts)
if starts
else frozenset()
1797def _tank_reliefs(diagram: Diagram) -> frozenset[str]:
1798 """Relief valves -- RVs that declare a ``set_pressure`` -- drawn onto a tank.
1800 They relieve the ullage. A relief exhausting through its own free port has
1801 no VENT symbol for :func:`_vent_reach` to find, so without this its line
1802 landed on the tank's *outlet* and the relief would have dumped liquid. An RV
1803 with no set pressure is not a relief model and keeps its old attachment.
1805 tanks = {n.id
for n
in diagram.nodes
if n.type ==
"TANK"}
1807 n.id
for n
in diagram.nodes
if n.type ==
"RV" and "set_pressure" in n.params
1809 found: set[str] = set()
1810 for edge
in diagram.edges:
1811 if edge.source
in reliefs
and edge.target
in tanks:
1812 found.add(edge.source)
1813 elif edge.target
in reliefs
and edge.source
in tanks:
1814 found.add(edge.target)
1815 return frozenset(found)
1818def _build_dome_loader(
1822 node_of: Mapping[str, str],
1824 """Build the control regulator and note which node supplies it.
1826 Its supply is whatever it is drawn connected to that is not the dome it
1827 loads -- normally the same manifold the dome regulator feeds from, which is
1828 the whole reason its outlet moves during a burn.
1834 (e
for e
in diagram.edges
if node.id
in (e.source, e.target)),
1835 key=
lambda e: (e.target_handle
if e.target == node.id
else e.source_handle)
1838 for edge
in touching:
1839 other = edge.source
if edge.target == node.id
else edge.target
1840 if other
and other
in node_of:
1841 supply = node_of[other]
1843 kind, model = BRANCH_KINDS.get(node.type, (
"regulator",
"droop"))
1844 component = _instance(
1845 node.label, kind, model, dict(node.params), dict(node.options)
1848 id=node.id, component=component, signal=signal, supply_node=supply
1854 loaders: Mapping[str, str],
1855) -> dict[str, DomeLine]:
1856 """The dome lines drawn from a dome port with a valve on them.
1858 Walked from the line on the regulator's ``dome`` handle, through junctions,
1859 valves, instruments and paired disconnects, to the loader; stopping at
1860 vessels, the engine and reliefs, as :func:`_dome_loaders` walks it. A line
1861 with no valve on it has nothing to gate: the dome follows its loader as it
1862 always did, and none is returned for it.
1864 by_id = {n.id: n
for n
in diagram.nodes}
1865 adjacent: dict[str, set[str]] = {}
1866 for edge
in diagram.edges:
1867 adjacent.setdefault(edge.source, set()).add(edge.target)
1868 adjacent.setdefault(edge.target, set()).add(edge.source)
1869 for node
in diagram.nodes:
1870 mate = str(node.options.get(
"pairedWith",
"")
or "").strip()
1871 if node.type ==
"QD" and mate
and mate !=
"none" and mate
in by_id:
1872 adjacent.setdefault(node.id, set()).add(mate)
1873 adjacent.setdefault(mate, set()).add(node.id)
1874 barrier = {
"TANK",
"KBOTTLE",
"DEWAR",
"RV",
"VENT"} | ENGINE_TYPES
1875 out: dict[str, DomeLine] = {}
1876 for loader, loaded
in loaders.items():
1878 edge.target
if edge.source == loaded
else edge.source
1879 for edge
in diagram.edges
1880 if (edge.source == loaded
and edge.source_handle == DOME_HANDLE)
1881 or (edge.target == loaded
and edge.target_handle == DOME_HANDLE)
1885 seen: set[str] = set()
1886 frontier = list(ports)
1888 here = frontier.pop()
1889 if here
in seen
or here
not in by_id
or here == loaded:
1892 if here == loader
or by_id[here].type
in barrier:
1894 frontier.extend(adjacent.get(here, ()))
1895 if loader
not in seen:
1900 if by_id[sid].type
in ACTUATED_VALVE_TYPES | HAND_VALVE_TYPES
1907 sid: sum(1
for e
in diagram.edges
if sid
in (e.source, e.target))
1910 out[loaded] = DomeLine(
1914 sid: frozenset(o
for o
in adjacent.get(sid, ())
if o
in seen)
1919 vents=frozenset(v
for v
in valves
if lines_on[v] == 1),
1924def _unloaded_dome_valves(
1925 diagram: Diagram, loaders: Mapping[str, str]
1927 """Valves on the dome line of a dome-loaded regulator nothing drawn loads.
1929 :func:`_dome_lines` finds a dome line by its loader. With none on the
1930 drawing -- the loader was on the cart and the cart was cut
1931 (``ignore_gse``), or the dome is filled off the drawing -- the line is still
1932 drawn up to the coupling its gas comes in through, and the valves on it
1933 fell through to step 4b: the rocket's dome isolation solenoid was reported
1934 as a vent to atmosphere, its dome-side node (and the dome transducer on it)
1935 pinned there. Walked as the session walks a cut dome line
1936 (``Session._find_cut_dome_lines``): from the ``dome`` handle through
1937 junctions, instruments and on/off valves, stopping at a disconnect or any
1938 other inline symbol. A walk that reaches a vessel, a boundary or another
1939 regulator is no dome line of its own -- the dome is tapped off plumbing --
1940 and is left to step 4b as before.
1942 loaded = set(loaders.values())
1943 by_id = {n.id: n
for n
in diagram.nodes}
1944 adjacent: dict[str, set[str]] = {}
1945 for edge
in diagram.edges:
1946 adjacent.setdefault(edge.source, set()).add(edge.target)
1947 adjacent.setdefault(edge.target, set()).add(edge.source)
1948 tapped_types = SOURCE_TYPES | BOUNDARY_TYPES | {
"PR"}
1949 stop = INLINE_TYPES - VENTING_VALVE_TYPES | {
"RV"}
1950 out: set[str] = set()
1951 for node
in diagram.nodes:
1954 or node.options.get(
"domeLoaded") !=
"yes"
1955 or node.id
in loaded
1959 edge.target
if edge.source == node.id
else edge.source
1960 for edge
in diagram.edges
1961 if (edge.source == node.id
and edge.source_handle == DOME_HANDLE)
1962 or (edge.target == node.id
and edge.target_handle == DOME_HANDLE)
1964 seen: set[str] = set()
1966 while frontier
and not tapped:
1967 here = frontier.pop()
1968 if here
in seen
or here
not in by_id
or here == node.id:
1971 kind = by_id[here].type
1972 if kind
in tapped_types:
1974 elif kind
not in stop:
1975 frontier.extend(adjacent.get(here, ()))
1978 sid
for sid
in seen
if by_id[sid].type
in VENTING_VALVE_TYPES - {
"RV"}
1980 return frozenset(out)
1983def _dome_valve_roles(
1984 lines: Mapping[str, DomeLine], ground: frozenset[str]
1985) -> dict[str, frozenset[str]]:
1986 """The cart's valve on a dome line is the table's "GSE Med Press Control":
1987 the medium-pressure circuit that loads the regulator's dome, between the
1988 high-pressure charge and the low-pressure pneumatics."""
1989 out: dict[str, frozenset[str]] = {}
1990 for line
in lines.values():
1991 for valve
in line.valves - line.vents:
1993 out[valve] = frozenset({
"gse",
"med",
"press",
"control"})
1997def _hand_loaded(node: PidNode) -> bool:
1998 """A regulator the drawing gives no setting at all: set by a hand on its
1999 knob, not by a number on the sheet (GSE regulators usually are)."""
2000 return node.type ==
"PR" and not ({
"setpoint",
"dome_pressure"} & set(node.params))
2003def _dome_loaders(diagram: Diagram) -> dict[str, str]:
2004 """``{control regulator id: the regulator whose dome it loads}``.
2006 A regulator drawn connected to a dome-loaded regulator, where the second
2007 declares ``domeLoaded: yes``, is loading its dome. Anything more clever --
2008 inferring it from geometry, or from which port a line lands on -- would
2009 guess; this reads what the drawing states.
2011 regulators = {n.id: n
for n
in diagram.nodes
if n.type ==
"PR"}
2012 loaded = {n.id
for n
in regulators.values()
if n.options.get(
"domeLoaded") ==
"yes"}
2013 out: dict[str, str] = {}
2014 for edge
in diagram.edges:
2015 for a, b
in ((edge.source, edge.target), (edge.target, edge.source)):
2016 if a
in regulators
and a
not in loaded
and b
in loaded:
2025 by_id = {n.id: n
for n
in diagram.nodes}
2026 adjacent: dict[str, set[str]] = {}
2027 for edge
in diagram.edges:
2028 adjacent.setdefault(edge.source, set()).add(edge.target)
2029 adjacent.setdefault(edge.target, set()).add(edge.source)
2030 for node
in diagram.nodes:
2031 mate = str(node.options.get(
"pairedWith",
"")
or "").strip()
2032 if node.type ==
"QD" and mate
and mate !=
"none" and mate
in by_id:
2033 adjacent.setdefault(node.id, set()).add(mate)
2034 adjacent.setdefault(mate, set()).add(node.id)
2035 barrier = {
"TANK",
"KBOTTLE",
"DEWAR",
"RV",
"VENT"} | ENGINE_TYPES
2036 for b
in sorted(loaded - set(out.values())):
2038 edge.target
if edge.source == b
else edge.source
2039 for edge
in diagram.edges
2040 if (edge.source == b
and edge.source_handle == DOME_HANDLE)
2041 or (edge.target == b
and edge.target_handle == DOME_HANDLE)
2043 found: set[str] = set()
2045 frontier = list(starts)
2047 here = frontier.pop()
2048 if here
in seen
or here
not in by_id:
2051 kind = by_id[here].type
2057 frontier.extend(adjacent.get(here, ()))
2059 out[found.pop()] = b
2067 fluids: Mapping[str, str],
2068 temperatures: Mapping[str, float],
2069 default_temperature: float,
2071 into_engine: Mapping[str, str] = {},
2072) -> tuple[dict[str, str], list[str]]:
2073 """Replace the engine boundary with a real injector face and chamber.
2075 The drawing says *where* the engine is and which lines reach it; the engine
2076 config says what it is. Neither alone is enough, which is why this needs
2077 both and why the symbol carries a reference rather than a copy of the
2078 injector geometry -- a drawing is not where a datasheet lives.
2080 Which leg is which is decided by the fluid on the line, not by the order the
2081 lines were drawn in. Getting that backwards would feed oxidiser through the
2082 fuel orifices, which is smaller, so the model would quietly run rich.
2086 warnings: list[str] = []
2087 labels = {n.id: n.label
or n.id
for n
in diagram.nodes}
2088 symbol = next((n
for n
in diagram.nodes
if n.type
in ENGINE_TYPES),
None)
2091 "An engine config was supplied but the drawing has no engine "
2092 "symbol to attach it to, so the run is uncoupled: nothing sets "
2093 "chamber pressure and the feed system discharges to whatever the "
2094 "drawing's end pressure says."
2097 ox_leg, fuel_leg = injector_legs(engine)
2098 ox_species = getattr(engine,
"oxidiser").propellant
2099 fuel_species = getattr(engine,
"fuel").propellant
2101 chamber = f
"{symbol.id}.chamber"
2102 if chamber
not in net.nodes:
2105 fluids.get(symbol.id, ox_species),
2106 temperatures.get(symbol.id, default_temperature),
2107 pressure=net.nodes[symbol.id].pressure,
2110 ports: dict[str, str] = {
"chamber": chamber}
2114 for edge
in diagram.edges:
2117 if edge.target == symbol.id
2118 else edge.target
if edge.source == symbol.id
else None
2120 if other
is None or (
2121 edge.id
not in net.branches
and edge.id
not in into_engine
2124 species = fluids.get(other,
"")
2125 if species == ox_species:
2126 leg, side = ox_leg,
"oxidiser"
2127 elif species == fuel_species:
2128 leg, side = fuel_leg,
"fuel"
2135 f
"The line from {labels.get(other, other)} reaches the "
2136 f
"engine carrying {species or 'nothing identifiable'}, which is "
2137 f
"neither {ox_species} nor {fuel_species}. It is not connected "
2138 "to an injector leg. Check the fluid on that line, or on the "
2139 "tank that feeds it."
2143 if edge.id
in into_engine:
2146 branch_id = f
"{symbol.id}.{side}.injector"
2147 if branch_id
not in net.branches:
2148 net.add_branch(branch_id, leg, into_engine[edge.id], chamber)
2149 ports[side] = branch_id
2151 face = f
"{symbol.id}.{side}"
2152 if face
not in net.nodes:
2161 temperatures.get(other, default_temperature),
2163 net.nodes[face].phase =
"liquid"
2164 branch = net.branches[edge.id]
2165 if branch.downstream == symbol.id:
2166 branch.downstream = face
2167 elif branch.upstream == symbol.id:
2168 branch.upstream = face
2170 branch_id = f
"{symbol.id}.{side}.injector"
2171 if branch_id
not in net.branches:
2172 net.add_branch(branch_id, leg, face, chamber)
2173 ports[side] = branch_id
2175 for side, species
in ((
"oxidiser", ox_species), (
"fuel", fuel_species)):
2176 if side
not in ports:
2178 f
"No line reaching {symbol.label} carries {species}, so the "
2179 f
"engine has no {side} leg. The chamber cannot be solved from "
2180 "the feed system this way -- the run will report an uncoupled "
2181 "boundary rather than a mixture ratio taken against a missing "
2186 if "oxidiser" in ports
or "fuel" in ports:
2187 for branch
in net.branches.values():
2188 if symbol.id
in (branch.upstream, branch.downstream):
2191 net.nodes.pop(symbol.id,
None)
2192 return ports, warnings
2196 node: PidNode, fluid: str, temperature: float
2197) -> tuple[float, list[str]]:
2198 """Static head of the liquid column [Pa], ``rho g h``, and what was assumed.
2200 The level comes from the tank's own geometry -- the drawn volume and bore,
2201 2:1 heads, filled to :data:`STATIC_HEAD_FILL` -- inverted the same way the
2202 cockpit inverts it, so the two agree. A metre of LOX is about 1.6 psi:
2203 small against a 500 psi tank and not nothing against a 130 psi feed-line
2206 This used to carry a factor of 0.75 and an assumed 152 mm bore with no
2207 provenance on either. The bore is now the drawing's, and the fill fraction
2208 is a named constant with a stated basis, and both are reported when a
2211 notes: list[str] = []
2212 label = node.label
or node.id
2213 volume = node.params.get(
"volume")
2214 diameter = node.params.get(
"diameter")
2215 litres = volume.si
if volume
is not None else DEFAULT_TANK_LITRES
2216 bore = diameter.si
if diameter
is not None else DEFAULT_TANK_BORE
2219 f
"{label} has no volume on the drawing; its static head assumes "
2220 f
"{DEFAULT_TANK_LITRES * 1e3:.1f} L."
2222 if diameter
is None:
2224 f
"{label} has no diameter on the drawing; its static head assumes a "
2225 f
"{DEFAULT_TANK_BORE * 1e3:.0f} mm bore."
2227 geometry = cylindrical_from_volume(litres, bore)
2228 level = level_of_volume(geometry, STATIC_HEAD_FILL * geometry.total_volume)
2232 density = Fluid(fluid).get(
"rho", T=temperature, q=0.0)
2236 f
"{label}: no liquid density for {fluid} at {temperature:.0f} K; its "
2237 "static head assumes water."
2239 return density * 9.80665 * level, notes
2246FLUID_ROLES = frozenset({
"fuel",
"lox",
"oxidizer",
"pressurant"})
2250 node: PidNode, fluid: str, temperature: float, pressure: float
2252 """Things about a propellant tank that are almost certainly a drawing gap.
2254 Two of them, and the second is the one that costs an afternoon.
2256 A tank with **no fluid** on the drawing takes the network default, which is
2257 nitrogen. Downstream of a tank that is the whole leg, so a silent default
2258 here quietly reprices an entire propellant line.
2260 A tank whose fluid is **not liquid at its stated temperature** is nearly
2261 always a missing temperature rather than a design choice. Oxygen at the
2262 ambient default is a gas at about 40 kg/m^3 where liquid oxygen is 1140 --
2263 a factor of thirty on every density, and therefore on every flow, in a
2264 system that still converges and still reports plausible numbers. The
2265 saturation temperature is named because that is the number the drawing is
2269 label = node.label
or node.id
2274 f
" The drawing colours it {role!r}, which is a role rather than a "
2275 "fluid -- set the fluid itself."
2276 if role
in FLUID_ROLES
2280 f
"{label} does not say what it holds, so it is being solved as "
2288 probe = Fluid(fluid)
2289 if probe.phase(p=pressure, T=temperature).is_liquid_like:
2291 if node.params.get(
"temperature")
is not None:
2297 f
"{label} holds {fluid} at {temperature:.0f} K, which is a gas, "
2298 "and is drawn as a TANK, so it is read as a propellant tank and "
2299 "nothing on the drawing supplies the press lines. If it is the "
2300 "pressurant, draw it as a Pressurant bottle (KBOTTLE)."
2303 saturation = probe.get(
"T", p=pressure, q=0.0)
2308 f
"{label} holds {fluid} at the assumed {temperature:.0f} K, where it is "
2309 f
"a gas, not a liquid. At {say(pressure)} it "
2310 f
"condenses at about {saturation:.0f} K. If this is a propellant tank "
2311 "the drawing is missing its temperature, and every density and flow on "
2312 "that leg is wrong by the ratio of gas to liquid."
2317def _kind_and_model(node: PidNode) -> tuple[str, str]:
2318 """Which catalogue component and fidelity model a symbol becomes.
2320 The table is :data:`BRANCH_KINDS`; the one thing that moves it is how the
2321 drawing gave the coefficient. A valve or a check valve with a ``Cd`` is
2322 the ``cd`` model -- the number the team measured, not the Cv the drawing
2323 derived beside it for readers that could not. A disconnect is a fitting
2324 with a K unless the drawing gave it a coefficient, in which case it is a
2325 full-open valve, which is what a QD is hydraulically.
2327 kind, model = BRANCH_KINDS[node.type]
2328 if node.type ==
"RV" and "set_pressure" in node.params:
2331 return "relief_valve",
"spring"
2332 has_cd =
"Cd" in node.params
2333 has_cv =
"Cv" in node.params
2334 if node.type ==
"QD" and (has_cd
or has_cv):
2335 return "valve",
"cd" if has_cd
else "cv"
2336 if kind
in {
"valve",
"check_valve"}
and has_cd:
2341def _pressure(node: PidNode) -> float |
None:
2342 param = node.params.get(
"pressure")
2343 if param
is None and node.type
in ENGINE_TYPES:
2346 param = node.params.get(
"chamber_pressure")
2347 return param.si
if param
is not None else None
2350def _temperature(node: PidNode, fallback: float) -> float:
2351 param = node.params.get(
"temperature")
2352 return param.si
if param
is not None else fallback
2355def _instrument_anchor(
2357 by_id: Mapping[str, PidNode],
2358 node_of: Mapping[str, str],
2361 """Which network node a transducer reads.
2363 Its declared clip first; failing that, whatever it is drawn connected to.
2365 A transducer clipped to an inline symbol reads that symbol's **upstream**
2366 side by default, because that is the side most taps are on -- a regulator's
2367 inlet gauge, a valve's supply side.
2369 ``options.side = "downstream"`` reads the other one, and the stand has a real
2370 need for it: a main valve's *downstream* PT is the pressure that sets
2371 injector delta-p, and it is one of the numbers a propulsion engineer looks at
2372 first. Without this the only clip that resolved past a main valve was the
2373 ENGINE symbol -- which is a fixed-pressure boundary at the chamber, so the
2374 gauge read the design chamber pressure forever, including at rest with the
2375 stand cold and every valve shut. That is how PT-OX-DN and PT-FU-DN came to
2376 sit at 420 psi on a stand holding nothing.
2378 if node.attached_to
and node.attached_to
in node_of:
2379 anchor = node_of[node.attached_to]
2380 if node.options.get(
"side",
"upstream") ==
"downstream":
2381 target = by_id.get(node.attached_to)
2382 if target
is not None and target.is_inline:
2383 return f
"{target.id}.out"
2385 for edge
in diagram.edges:
2388 if edge.source == node.id
2389 else edge.source
if edge.target == node.id
else None
2391 if other
is not None and other
in node_of:
2392 return node_of[other]
2397 diagram: Diagram, ullage_side: frozenset[str] = frozenset()
2398) -> tuple[dict[str, str], dict[str, float]]:
2399 """Spread each source's declared fluid *and its temperature* through what it
2402 The temperature travels with the fluid, and it has to. A feed line
2403 downstream of a LOX tank carries oxygen at 90 K; inherit the species and
2404 leave the temperature at an ambient default and the line is solved as
2405 *gaseous* oxygen at 46 kg/m^3 instead of liquid at 1140 -- a factor of 25
2406 in density, which shows up as a flow rate that is confidently wrong rather
2409 The same walk pid-designer does on the drawing, repeated here rather than
2410 imported, because the drawing may arrive from a file rather than from the
2411 app. Barriers stop it: an engine has fuel on one port and oxidiser on the
2412 other, and a walk that carried on through would paint the whole fuel side
2421 barriers = {
"ENGINE",
"INJECTOR",
"TANK"}
2422 adjacency: dict[str, list[str]] = {}
2423 for edge
in diagram.edges:
2424 adjacency.setdefault(edge.source, []).append(edge.target)
2425 adjacency.setdefault(edge.target, []).append(edge.source)
2427 types = {n.id: n.type
for n
in diagram.nodes}
2428 out: dict[str, str] = {}
2429 temps: dict[str, float] = {}
2431 def spread(seeds: list[PidNode], skip: frozenset[str] = frozenset()) ->
None:
2436 queue: list[tuple[str, str, float]] = []
2438 declared = seed.params.get(
"temperature")
2439 queue.append((seed.id, seed.fluid, declared.si
if declared
else 0.0))
2441 current, fluid, temperature = queue.pop(0)
2442 for neighbour
in adjacency.get(current, []):
2443 if neighbour
in skip:
2445 known = out.get(neighbour)
2446 if known
is not None:
2459 if known == fluid
and temperature > 0.0
and neighbour
not in temps:
2460 temps[neighbour] = temperature
2461 if types.get(neighbour)
not in barriers:
2462 queue.append((neighbour, fluid, temperature))
2464 out[neighbour] = fluid
2465 if temperature > 0.0:
2466 temps[neighbour] = temperature
2467 if types.get(neighbour)
not in barriers:
2468 queue.append((neighbour, fluid, temperature))
2476 declared = [n
for n
in diagram.nodes
if n.fluid]
2477 for node
in declared:
2478 out[node.id] = node.fluid
2479 own = node.params.get(
"temperature")
2481 temps[node.id] = own.si
2482 spread([n
for n
in declared
if n.type !=
"TANK"])
2488 spread([n
for n
in declared
if n.type ==
"TANK"], skip=ullage_side)
static route_t * find(uint16_t pin)
static std::vector< string_t > split(const string_t &reference_string)
split the string input to reference tokens