STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
network.py
Go to the documentation of this file.
1"""Turning a drawing into a network.
2
3The two graphs are not the same shape, and that is the whole problem this
4module solves.
5
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:
10
11.. code-block:: text
12
13 P&ID network
14 ---- -------
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
21
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.
26
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.
31"""
32
33from __future__ import annotations
34
35import math
36from dataclasses import dataclass, field, replace
37from typing import Callable, Iterable, Mapping
38
39from feedtwin.comps import HydraulicComponent, build_component
40from feedtwin.comps.elements import Fitting
41from feedtwin.model.component import ComponentInstance
42from feedtwin.model.param import Param, Provenance
43from feedtwin.model.pressure import say
44from feedtwin.model.segments import LineSegment
45from feedtwin.pid.document import (
46 BOUNDARY_TYPES,
47 INLINE_TYPES,
48 SOURCE_TYPES,
49 Diagram,
50 DiagramError,
51 PidEdge,
52 PidNode,
53)
54from feedtwin.pid.roles import ground_ids, vehicle_ids
55from feedtwin.solve.network import Network
56from feedtwin.vessels.geometry import cylindrical_from_volume, level_of_volume
57
58#: P&ID component type to (feed-twin type, fidelity model).
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"),
68}
69
70LINE_KINDS: dict[str, tuple[str, str]] = {
71 "pipe": ("pipe", "darcy"),
72 "flex_hose": ("flex_hose", "darcy"),
73 # The geometry-aware hardline bend; "rounded" is its correlation model
74 # (there is no darcy bend, and asking for one could not be built).
75 "bend": ("bend", "rounded"),
76 "fitting": ("fitting", "K"),
77}
78
79#: Filled in when the drawing does not say. Every one is `DEFAULT`, so the run
80#: report counts it and the app shows it -- a drawing that omits a bore should
81#: produce an answer with a visible hole in it, not a confident number.
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")},
85 "check_valve": {
86 "Cv": (4.0, "Cv"),
87 "bore": (9.5, "mm"),
88 "cracking_pressure": (3.0, "psi"),
89 },
90 # flow_droop and rated_flow are here because without them a regulator's
91 # branch equation is satisfied by *every* mass flow -- see
92 # feedtwin.comps.regulator. A drawing that omits them would otherwise
93 # produce a singular Jacobian rather than an answer.
94 "regulator": {
95 # Absolute, and spelled so: bare "psi" is absolute in the unit table, but
96 # the assumptions table shows the unit, and a person reads psia.
97 "setpoint": (500.0, "psia"),
98 "Cv": (0.8, "Cv"),
99 "bore": (7.75, "mm"),
100 "flow_droop": (20.0, "psi"),
101 "rated_flow": (0.05, "kg/s"),
102 },
103 "fitting": {"bore": (9.5, "mm")},
104 "pipe": {"length": (1.0, "m"), "bore": (9.5, "mm"), "roughness": (0.0015, "mm")},
105 "flex_hose": {
106 "length": (0.5, "m"),
107 "bore": (9.5, "mm"),
108 "roughness": (0.0015, "mm"),
109 "installed_bend_radius": (150.0, "mm"),
110 "min_bend_radius": (100.0, "mm"),
111 },
112 "bend": {
113 "bore": (9.5, "mm"),
114 "bend_radius": (30.0, "mm"),
115 "angle": (90.0, "deg"),
116 "roughness": (0.0015, "mm"),
117 },
118}
119
120
121#: Where the feed system ends. An engine is not a place the fluid sits, it is
122#: the pressure the fluid is delivered against -- so it becomes a fixed-pressure
123#: boundary rather than a free node. Left free, both propellant legs would
124#: dead-end into it and mass balance would demand that one leg flow backwards
125#: into the other, which converges and is nonsense.
126SINK_TYPES = frozenset({"ENGINE", "INJECTOR", "VENT"})
127
128#: Where an *engine* attaches. A strict subset of SINK_TYPES, and the two must
129#: stay apart: SINK_TYPES answers "is this a fixed-pressure boundary", which a
130#: vent also is, while this answers "is this the thing the injector bolts to".
131#: Conflating them tried to build an injector face onto a vent stack.
132ENGINE_TYPES = frozenset({"ENGINE", "INJECTOR"})
133
134#: pid-designer's name for a dome-loaded regulator's loading port
135#: (``components/pid/ports.ts``: ``['l', 'r', 'dome']``).
136DOME_HANDLE = "dome"
137
138#: Where the system discharges to atmosphere. A vent valve with nothing on its
139#: downstream side is a dead end that carries no flow, so venting does nothing
140#: -- which is the difference between a tank that blows down when you open its
141#: vent and one that sits there. Fixed at ambient unless the drawing says
142#: otherwise, because that is what a vent line ends at.
143AMBIENT_TYPES = frozenset({"VENT"})
144
145#: Standard atmosphere [Pa].
146AMBIENT = 101325.0
147
148#: Fill fraction the static head of a tank is taken at when the drawing gives a
149#: volume but no level -- the uncoupled steady build's only view of the liquid
150#: column. Matches the study's T-0 prime, so a steady build and the first frame
151#: of a study agree on what the outlet transducer reads.
152STATIC_HEAD_FILL = 0.95
153#: Tank bore assumed when the drawing omits one [m]. 6 in. tube, the stand's
154#: usual. Reported as an assumption whenever it is used.
155DEFAULT_TANK_BORE = 0.1524
156#: Tank volume assumed when the drawing omits one [m^3]. Reported likewise.
157DEFAULT_TANK_LITRES = 0.0175
158
159#: Valve symbols that vent to atmosphere when one side is left unplumbed.
160#:
161#: The two design tools had each solved venting, separately, and neither knew
162#: about the other's answer. feed-twin required an explicit ``VENT`` symbol (see
163#: AMBIENT_TYPES above). pid-designer infers it -- ``components/pid/vents.ts``:
164#: *"A valve open on one side is a vent to atmosphere. Not a symbol you place. A
165#: vent valve is drawn as a valve with nothing on its downstream side, which is
166#: what a P&ID already does and what people already draw without being asked."*
167#: It draws the arrow and passes its own checks on that basis.
168#:
169#: But the inference never reached the saved file -- it feeds the vent arrow and
170#: the checks panel, not the export -- so the drawing a user is *told* to make
171#: for a vent arrived here as a valve with a dead end behind it, and the network
172#: failed to solve at all. Reading the same rule here means both apps read one
173#: drawing the same way, and neither has to remember to say it twice.
174#:
175#: The rule is deliberately narrow, and narrow for pid-designer's stated reason:
176#: **valves only**, and only with exactly one port in use. A spare manifold port
177#: or a blanked tee branch is a plug, and plugs are not drawn -- inferring an
178#: open boundary from one would model a tank venting through a fitting that
179#: holds pressure.
180VENTING_VALVE_TYPES = frozenset({"MAN", "ROT", "SOL", "MOV", "RV"})
181
182#: Valves the state machine can command: solenoid, rotary-actuated and
183#: motorised. Each rests where the drawing's ``failState`` puts it (shut unless
184#: it says "open") until the table commands it.
185ACTUATED_VALVE_TYPES = frozenset({"ROT", "SOL", "MOV"})
186
187#: Valves a person turns. Never commanded by the state machine -- the table
188#: does not know a hand valve exists -- but turned on the P&ID by hand. Every
189#: one rests **shut** until somebody opens it (the team's rule, 2026-10-07: a
190#: hand valve is shut unless the procedure opens it, as the crew opens the fuel
191#: transfer valve for Fuel Fill), unless the drawing says
192#: ``options.normalPosition: open``. They used to have no position at all,
193#: which a valve reads as wide open: every hand bleed on a GSE page was a
194#: permanent hole to atmosphere, and a fuel tank vented through its cart's hand
195#: vent could never hold press.
196HAND_VALVE_TYPES = frozenset({"MAN"})
197
198
199@dataclass(frozen=True, slots=True)
200class Instrument:
201 """A transducer, and the network node it reads."""
202
203 id: str
204 tag: str
205 type: str
206 node: str
207
208
209@dataclass(frozen=True, slots=True)
210class Placement:
211 """Where a drawing symbol sits, so the app can draw the same picture."""
212
213 id: str
214 tag: str
215 type: str
216 x: float
217 y: float
218 fluid: str = ""
219
220
221@dataclass(frozen=True, slots=True)
222class TankPorts:
223 """A tank is one symbol and two places.
224
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.
230
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.
235 """
236
237 ullage: str
238 outlet: str
239
240
241@dataclass(frozen=True, slots=True)
242class DomeLoader:
243 """The hand-loaded regulator that sets a dome-loaded one's dome.
244
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.
253
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.
257 """
258
259 #: Drawing id of the control regulator.
260 id: str
261 #: The built regulator, evaluated at zero flow to get its outlet.
262 component: HydraulicComponent
263 #: Signal name it drives, e.g. ``"PR-DOME.dome"``.
264 signal: str
265 #: Network node whose pressure feeds it.
266 supply_node: str
267
268
269@dataclass(frozen=True, slots=True)
270class DomeLine:
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.
273
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.
278 """
279
280 #: The dome-loaded regulator, and the hand-loaded one that loads it.
281 loaded: str
282 loader: str
283 #: The line as drawn: symbol -> the symbols it joins (paired disconnects
284 #: joined to their mates). The loaded regulator itself is not in it.
285 adjacent: Mapping[str, frozenset[str]]
286 #: What is drawn on the regulator's dome port: where a walk starts.
287 ports: frozenset[str]
288 #: Commandable valves on the line, and those of them open to atmosphere.
289 valves: frozenset[str]
290 vents: frozenset[str]
291
292
293@dataclass(frozen=True, slots=True)
294class BuiltNetwork:
295 """A network, plus everything needed to draw and drive it."""
296
297 network: Network
298 #: Network node id for each drawing symbol that became one.
299 node_of: Mapping[str, str] = field(default_factory=dict)
300 #: Branch ids contributed by each drawing symbol or line.
301 branches_of: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
302 instruments: tuple[Instrument, ...] = ()
303 placements: tuple[Placement, ...] = ()
304 #: Drawing id to its two network nodes, for every tank.
305 tanks: Mapping[str, TankPorts] = field(default_factory=dict)
306 #: ``{"chamber": node, "oxidiser": branch, "fuel": branch}`` once an engine
307 #: has been attached. Empty when the drawing's engine is still a boundary.
308 engine_ports: Mapping[str, str] = field(default_factory=dict)
309 #: Commandable valves: drawing id to the signal name components read.
310 actuators: Mapping[str, str] = field(default_factory=dict)
311 #: Control regulators that set a dome, by drawing id. See :class:`DomeLoader`.
312 dome_loaders: Mapping[str, DomeLoader] = field(default_factory=dict)
313 warnings: tuple[str, ...] = ()
314 #: What each commandable valve does, read off the network: drawing id to
315 #: words like ``{"fuel", "press"}`` or ``{"lox", "main"}``. Empty for a
316 #: valve whose job the topology does not settle. See :func:`_valve_roles`.
317 valve_roles: Mapping[str, frozenset[str]] = field(default_factory=dict)
318 #: Branches of the disconnects read as GSE vents (see :func:`_gse_vents`)
319 #: whose size the drawing does not give. The valve that sets it is on the
320 #: cart, so a session sizes them from its own setup (``gse_vent_cv``).
321 gse_vents: tuple[str, ...] = ()
322 #: Paired disconnects read as mated couplings, by drawing id: where the
323 #: drawing's pages (rocket, GSE) join. See :func:`_mate_disconnects`.
324 mated: tuple[tuple[str, str], ...] = ()
325 #: Where each commandable valve rests until something commands it, 0..1, by
326 #: drawing id: an actuated valve at its ``failState``, a hand valve at its
327 #: ``normalPosition`` or where its plumbing puts it (HAND_VALVE_TYPES).
328 rest: Mapping[str, float] = field(default_factory=dict)
329 #: Hand valves, by drawing id: turned by a person, never by the table.
330 hand_valves: frozenset[str] = frozenset()
331 #: Drawing ids on the vehicle (:func:`feedtwin.pid.roles.vehicle_ids`);
332 #: ``None`` when the drawing is one piece, which is all vehicle.
333 vehicle: frozenset[str] | None = None
334 #: Ground-support tanks that load a vehicle tank through the drawing: a
335 #: fuel transfer tank piped through a mated disconnect into the flight
336 #: tank's fill port. Supply tank id -> the vehicle tanks it reaches.
337 supplies: Mapping[str, frozenset[str]] = field(default_factory=dict)
338 #: Dome lines with valves on them, by the dome-loaded regulator's id.
339 dome_lines: Mapping[str, DomeLine] = field(default_factory=dict)
340
341
342def _accepted_options(kind: str, options: Mapping[str, str]) -> dict[str, str]:
343 """Keep only the options the physics component declares.
344
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
350 they matter.
351 """
352 from feedtwin.model.spec import SpecError, get_component_spec
353
354 try:
355 spec = get_component_spec(kind)
356 except SpecError:
357 return {}
358 allowed = {o.name for o in spec.options}
359 return {k: v for k, v in options.items() if k in allowed}
360
361
362#: Line-level parameters a segment list supersedes.
363#:
364#: Not merged, not averaged, not used as a cross-check: dropped. The drawing's
365#: editor greys these out the moment a line gets its first segment, so a value
366#: still sitting in one is a leftover from before the run was itemised. Summing
367#: the segments *and* the line-level length is the double-count this whole path
368#: exists to avoid.
369#:
370#: The spec would drop them anyway -- none of them belongs to the ``segmented``
371#: model -- but it drops them silently, and a 9.9 m length quietly evaporating
372#: is the kind of thing this library says out loud. Hence both the explicit
373#: filter and :func:`_superseded_warnings`.
374_SUPERSEDED_BY_SEGMENTS = frozenset({"length", "bore", "K_minor"})
375
376
377def _superseded_warnings(edge: PidEdge) -> list[str]:
378 """Say which line-level numbers the segment list overrode.
379
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
382 place to look.
383 """
384 leftover = sorted(_SUPERSEDED_BY_SEGMENTS & set(edge.params))
385 if not leftover:
386 return []
387 stated = ", ".join(
388 f"{name} = {edge.params[name].value:g} {edge.params[name].unit}"
389 for name in leftover
390 )
391 return [
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"
394 ]
395
396
397def _instance(
398 tag: str,
399 kind: str,
400 model: str,
401 params: Mapping[str, Param],
402 options: Mapping[str, str],
403 *,
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(
410 value,
411 unit,
412 Provenance.DEFAULT,
413 f"not on the drawing; feed-twin fallback for a {kind}",
414 )
415 from feedtwin.model.spec import SpecError
416
417 try:
418 instance = ComponentInstance.build(
419 tag,
420 kind,
421 filled,
422 options=_accepted_options(kind, options),
423 model=model,
424 segments=segments,
425 )
426 except SpecError as exc:
427 # The spec knows the kind ("valve"), not which one; on a drawing with
428 # five solenoids that is no help. Named here, where the tag is known.
429 raise SpecError(f"{tag}: {exc}") from exc
430 return build_component(instance)
431
432
433def build_network(
434 diagram: Diagram,
435 *,
436 default_temperature: float = 293.15,
437 default_fluid: str = "nitrogen",
438 engine: object | None = None,
439 multiphase: bool = False,
440) -> BuiltNetwork:
441 """Build a solvable network from a drawing.
442
443 Raises:
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.
448
449 Args:
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`.
453 """
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] = {}
470 #: Every network node a drawn line landed on: what tells a disconnect's
471 #: plumbed side from its free one when it is mated (_mate_disconnects).
472 landed: set[str] = set()
473
474 # Where the pressurant network and the vent stacks reach. Both are ullage
475 # side, and a tank must not paint either with its contents.
476 # A relief valve (an RV with a set pressure) drawn straight onto a tank
477 # relieves its ullage, whatever is drawn beyond it; see _tank_reliefs.
478 # So is whatever is drawn off a tank's top port; see _top_port_reach.
479 _vent_side = (
480 _vent_reach(diagram) | _tank_reliefs(diagram) | _top_port_reach(diagram)
481 )
482 fluids, temperatures = _fluid_of(diagram, _pressurant_reach(diagram) | _vent_side)
483 # Which symbols the pressurant network reaches without passing through a
484 # tank. Topology, not fluid identity: on a cold flow the ox tank holds LN2
485 # and the pressurant is GN2, so "the line carrying something else is the
486 # pressurant one" stops working exactly when you most want it to.
487 pressurant = _pressurant_reach(diagram)
488 # What the gas supplies hold. Every ullage on the drawing is filled with
489 # this, whatever the tanks below them contain.
490 # A K-bottle first: a dewar is a liquid supply (a LOX dewar listed before
491 # the cart's nitrogen made every ullage on the drawing oxygen), and only a
492 # drawing whose only gas source is a dewar takes its species.
493 pressurant_species = next(
494 (n.fluid for n in diagram.nodes if n.type == "KBOTTLE" and n.fluid),
495 next(
496 (n.fluid for n in diagram.nodes if n.type == "DEWAR" and n.fluid),
497 default_fluid,
498 ),
499 )
500 # Vent lines are ullage lines too; see _vent_reach. So is a tank's relief.
501 ullage_side = pressurant | _vent_side
502
503 for node in diagram.nodes:
504 if node.drawn_as == "TANK":
505 warnings.append(
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."
510 )
511
512 vehicles = _separate_vehicles(diagram)
513 if len(vehicles) > 1:
514 named = "; ".join(
515 _and([f"{e.label or e.id} (page {e.page or 'Main'})" for e in engines])
516 for engines in vehicles
517 )
518 raise DiagramError(
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."
525 )
526
527 # 1. Places. Sources are boundaries; junctions and manifolds are free.
528 sources = [n for n in diagram.nodes if n.is_source]
529 if not sources:
530 raise DiagramError(
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."
534 )
535
536 for node in diagram.nodes:
537 if node.is_annotation or node.is_instrument or node.is_inline:
538 continue
539 fluid = fluids.get(node.id, default_fluid)
540 temperature = _temperature(node, temperatures.get(node.id, default_temperature))
541 if node.type == "TANK":
542 # The tank symbol's node *is* its ullage, and an ullage holds
543 # pressurant, not propellant. Painting it with the tank's contents
544 # priced a vent line as liquid oxygen at 1140 kg/m^3 instead of
545 # nitrogen at forty -- so venting a tank moved thirty times the mass
546 # it should, and the solve had a boundary it could not reconcile.
547 fluid = pressurant_species
548 temperature = _temperature(node, default_temperature)
549
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:
553 # A vent ends at atmosphere. That is not an assumption worth
554 # warning about -- it is what the symbol means.
555 pressure = AMBIENT
556 if pressure is None:
557 default = 500.0 if node.is_source else 350.0
558 pressure = default * 6894.757293168361
559 warnings.append(
560 f"{node.label} has no pressure on the drawing; assumed "
561 f"{say(pressure)}."
562 )
563 net.add_node(node.id, fluid, temperature, pressure=pressure)
564 else:
565 net.add_node(node.id, fluid, temperature)
566 node_of[node.id] = node.id
567
568 if node.type == "TANK":
569 # The drawing's pressure and temperature are the tank's *nominal*
570 # values -- what is written on the symbol -- and a steady solve
571 # takes them as its boundary, which is what a nominal is for. A
572 # transient does not: the cockpit starts every tank vented and
573 # empty and presses it from the bottle, so a drawing's operating
574 # pressure never becomes an initial condition there.
575 #
576 # A tank is two boundary pressures, not two nodes with a pipe
577 # between them. The vessel sets both -- ullage at the top, ullage
578 # plus the liquid column at the outlet -- so a connecting branch
579 # would be a resistance that does not exist, and a branch with no
580 # resistance sits permanently on the derivative floor and stops the
581 # whole network converging.
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)
586 )
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)
593
594 # A dome-loaded regulator is set by a second, hand-loaded one upstream --
595 # the dome control regulator. That one is *not* in the flow path: its
596 # outlet goes to a dome port and carries almost nothing. So it is lifted
597 # out of the network entirely and its setpoint becomes the dome pressure
598 # of the regulator it loads, which is what the knob on the stand does.
599 dome_loaders = _dome_loaders(diagram)
600 built_loaders: dict[str, DomeLoader] = {}
601 for loader, loaded in dome_loaders.items():
602 warnings.append(
603 f"{by_id[loader].label} loads the dome of {by_id[loaded].label}; "
604 "read as a setpoint, not as a flow path."
605 )
606
607 # 2. Inline symbols become branches with a node spliced either side. The
608 # two lines that met at the symbol will attach to those, not to it.
609 for node in diagram.nodes:
610 if not node.is_inline or node.id in dome_loaders:
611 continue
612 kind, model = _kind_and_model(node)
613 if node.type == "RV" and kind != "relief_valve":
614 # A relief valve is shut until its set pressure, and with no set
615 # pressure it never lifts -- which, for the network, is a branch
616 # that is not there. It used to be built as a plain Cv valve that is
617 # always open: a hole to atmosphere on every tank it guards, so a
618 # stand could not hold press at all. Its lines now end at a capped
619 # port, exactly like a shut valve's.
620 warnings.append(
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."
624 )
625 continue
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
632
633 options = dict(node.options)
634 params = dict(node.params)
635 if node.type == "PR" and options.get("domeLoaded") == "yes":
636 params.setdefault(
637 "dome_bias",
638 Param(50.0, "psi", Provenance.DEFAULT, "dome-loaded; bias unstated"),
639 )
640 loaded_by = None
641 for loader, loaded_id in dome_loaders.items():
642 if loaded_id != node.id:
643 continue
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]):
648 continue
649 # A loader with no setpoint on the drawing is hand-loaded: the
650 # dome knob sets it, through its own outlet.
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
654 )
655 loaded_by = loader
656 if loaded_by is None:
657 # Nothing drawn loads the dome -- it is filled through a QD, or
658 # off-drawing -- so the dome is the operator's setting, and its
659 # signal is how they set it: the cockpit's dome knob and Layer
660 # X's lockup solve both drive `<tag>.dome`. Without it the dome
661 # sat at the drawn value whatever the knob said.
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,)
666 placements.append(
667 Placement(node.id, node.label, node.type, node.x, node.y, fluid)
668 )
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"
674 hand.add(node.id)
675
676 # Two valves the table drives under one tag share one signal: whatever the
677 # state machine says, it says to both, and a role two valves share binds
678 # neither. pid-designer checks tags per page, so a copied page passes there.
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()):
684 if len(valves) > 1:
685 pages = sorted({v.page or "Main" for v in valves})
686 warnings.append(
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."
690 )
691
692 # 3. Instruments observe. They join the network at the place they clip to.
693 # One clipped to a *line* (pid-designer clips probes to lines too) waits
694 # for step 4, which is where a line's two ends become network nodes.
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:
701 continue
702 if node.attached_to in edge_ids and node.attached_to not in by_id:
703 on_lines.append(node)
704 continue
705 anchor = _instrument_anchor(node, by_id, node_of, diagram)
706 if anchor is None:
707 warnings.append(
708 f"{node.label} is not clipped to anything and reads nothing."
709 )
710 continue
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))
713
714 # 4. Lines. An end that landed on an inline symbol is redirected to that
715 # symbol's own node -- into it if the line arrives, out of it if the
716 # line leaves. Unless the direction leaves a symbol one-sided and the
717 # ports say better; see _sides_by_port.
718 plumbing: list[PidEdge] = []
719 #: Each landed line's two ends, source end first: where a probe clipped to
720 #: the line reads.
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:
726 continue
727 if source.is_instrument or target.is_instrument:
728 continue # a clip line, not plumbing
729 if source.is_annotation or target.is_annotation:
730 continue
731 if edge.source in dome_loaders or edge.target in dome_loaders:
732 continue # a dome line, not a flow path
733 if (source.type == "PR" and edge.source_handle == DOME_HANDLE) or (
734 target.type == "PR" and edge.target_handle == DOME_HANDLE
735 ):
736 # Drawn onto a regulator's dome port: loading gas, not feed. It
737 # used to land on the regulator's inlet or outlet like any line,
738 # which plumbed the dome-control line -- through its solenoids and
739 # the mated disconnects, back to the GSE control regulator -- into
740 # the tank press manifold.
741 continue
742 plumbing.append(edge)
743 by_port, port_warnings = _sides_by_port(diagram, plumbing)
744 warnings.extend(port_warnings)
745
746 for edge in plumbing:
747 source = by_id[edge.source]
748 target = by_id[edge.target]
749 upstream = _attach(
750 source,
751 target,
752 ullage_side,
753 tanks,
754 outgoing=by_port.get((edge.id, source.id), True),
755 )
756 downstream = _attach(
757 target,
758 source,
759 ullage_side,
760 tanks,
761 outgoing=by_port.get((edge.id, target.id), False),
762 )
763 if upstream not in net.nodes or downstream not in net.nodes:
764 continue
765 if upstream == downstream:
766 continue
767 landed.update((upstream, downstream))
768 line_ends[edge.id] = (upstream, downstream)
769
770 kind, model = LINE_KINDS.get(edge.line_type, LINE_KINDS["pipe"])
771 # Named apart from the symbol loop's `params`/`options` above: this
772 # is the line's, and shadowing those two has bitten this file before.
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:
778 # A bore of 0 is a line no flow can pass, and it built without a
779 # word: a steady solve through it stalls on its first step. Said,
780 # not replaced -- Layer X reads the drawn 0 as "not a line" and
781 # leaves it out of the inertance (`diag.stability.drawing_feed`),
782 # which a fallback bore here would quietly contradict.
783 warnings.append(
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"
788 )
789 if segments:
790 # The drawing itemised this run, so the itemised model is the one
791 # in force and the geometry comes from the segments.
792 kind, model = "pipe", "segmented"
793 line_params = {
794 name: value
795 for name, value in edge.params.items()
796 if name not in _SUPERSEDED_BY_SEGMENTS
797 }
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
803 ):
804 # A line with no size is a direct connection: two parts drawn
805 # apart that are screwed together on the stand, a tank's port
806 # into its valve. It has no length to lose pressure over, so it is
807 # not a branch -- its two ends are one place (see _join_direct).
808 # It used to be solved as the fallback tube, 1 m of 9.5 mm, which
809 # cost a drawn tank-valve-engine feed 94 psi it does not have.
810 if engine is not None and (
811 source.type in ENGINE_TYPES or target.type in ENGINE_TYPES
812 ):
813 # Straight into the injector: the leg starts at this end.
814 into_engine[edge.id] = (
815 upstream if target.type in ENGINE_TYPES else downstream
816 )
817 else:
818 direct.append((edge.id, upstream, downstream))
819 continue
820 elif kind in {"pipe", "flex_hose"} and not (
821 "length" in line_params and "bore" in line_params
822 ):
823 # Half a size: the half the drawing left out is the fallback's.
824 unsized.append(
825 (edge.id, f"{source.label or source.id} -> {target.label or target.id}")
826 )
827 component = _instance(
828 edge.id, kind, model, line_params, line_options, segments=segments
829 )
830 net.add_branch(edge.id, component, upstream, downstream)
831 branches_of[edge.id] = (edge.id,)
832
833 # 3b. Probes clipped to a line read the end of it they sit nearer to: the
834 # line is one pipe, and its ends are the places the network solves.
835 # Before the direct joins below, which rename the ends with the rest.
836 for node in on_lines:
837 ends = line_ends.get(node.attached_to)
838 if ends is None:
839 warnings.append(
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."
842 )
843 continue
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))
847
848 # 4a. Disconnects paired across the drawing's pages are mated: the rocket
849 # half and the GSE half are one coupling, so their free sides are one
850 # place. A drawing that pairs nothing builds exactly as before.
851 mates, mate_warnings = _mate_disconnects(diagram, net, landed)
852 warnings.extend(mate_warnings)
853 direct.extend(mates)
854 mated = tuple((a, b) for a, b in (tuple(m[0].split(":")[1:3]) for m in mates))
855
856 if direct:
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:
860 # Two fixed pressures -- a bottle straight into a tank -- cannot be
861 # one place. The line stays a line, on the fallback tube.
862 if edge_id.startswith("mate:"):
863 _, a, b = edge_id.split(":")
864 source, target = by_id[a], by_id[b]
865 else:
866 line = edges_by_id[edge_id]
867 source, target = by_id[line.source], by_id[line.target]
868 net.add_branch(
869 edge_id,
870 _instance(edge_id, "pipe", "darcy", {}, {}),
871 upstream,
872 downstream,
873 )
874 branches_of[edge_id] = (edge_id,)
875 unsized.append(
876 (edge_id, f"{source.label or source.id} -> {target.label or target.id}")
877 )
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]
880 built_loaders = {
881 k: replace(v, supply_node=rename.get(v.supply_node, v.supply_node))
882 for k, v in built_loaders.items()
883 }
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
887 warnings.append(
888 f"{shown or symbol_id} has both sides joined "
889 "directly to the same place, so it carries nothing and is left out."
890 )
891 branches_of.pop(symbol_id, None)
892
893 # Dome lines with valves on them: the valves gate the dome (DomeLine).
894 # Their symbols are loading gas, read off the drawing, not vents of feed.
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)
898
899 # 4b. A valve with one side unplumbed is a vent to atmosphere.
900 #
901 # Read off the drawing rather than demanded as a second statement of it
902 # -- see VENTING_VALVE_TYPES for why, and for how the two tools came to
903 # disagree about this.
904 for node in diagram.nodes:
905 if (
906 node.type not in VENTING_VALVE_TYPES
907 or node.id in dome_loaders
908 or node.id in on_dome_lines
909 ):
910 continue
911 if node.id not in net.branches:
912 continue # never became a branch (instrument clip, annotation)
913 # Which of the valve's own two nodes did a line actually land on?
914 branch = net.branches[node.id]
915 attached = {
916 end
917 for other in net.branches.values()
918 if other is not branch
919 for end in (other.upstream, other.downstream)
920 }
921 # Joined straight into the engine: plumbed, by an injector leg that is
922 # added below. Missing this read a LOX main drawn into the engine as a
923 # vent and dumped the tank overboard at 14 kg/s.
924 attached |= set(into_engine.values())
925 free = [
926 port
927 for port in (branch.upstream, branch.downstream)
928 if port not in attached
929 ]
930 if len(free) != 1:
931 continue # plumbed both ends, or undrawn entirely -- not a vent
932 port = net.nodes[free[0]]
933 if port.pressure is not None:
934 continue
935 net.nodes[free[0]] = replace(port, pressure=AMBIENT)
936 vents.append(node.label or node.id)
937 vent_ids.add(node.id)
938
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
943 if drawn != "open":
944 resting_shut.append(by_id[valve_id].label or valve_id)
945 if resting_shut:
946 warnings.append(
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)."
951 )
952
953 if vents:
954 warnings.append(
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."
958 )
959
960 # 4c. What each node holds, declared from topology rather than guessed
961 # from temperature. The pressurant reach and the vent stacks are gas;
962 # a tank's outlet and everything painted with a tank's contents is
963 # liquid. The property layer's fallback rule -- "below the critical
964 # temperature means liquid" -- is right for LOX and wrong for an
965 # ethanol *vapour* line, and only the drawing knows which is which.
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:
969 continue
970 if drawing_id in ullage_side:
971 hint = "gas"
972 elif fluids.get(drawing_id, "") in tank_contents:
973 hint = "liquid"
974 elif (
975 fluids.get(drawing_id, default_fluid) == pressurant_species
976 and pressurant_species not in tank_contents
977 ):
978 # The pressurant where the reaches above do not go -- a vent run
979 # through a mated QD on the cart, say -- is still gas when no tank
980 # holds it as a liquid. Left undeclared it fell to the temperature
981 # rule, and GN2 vented off a chilling LOX tank was priced as liquid
982 # nitrogen (414-730 kg/m^3 against ~2.7) once it passed 126 K: the
983 # vent's back-pressure vanished mid-load. (A cold flow, where a
984 # tank holds the pressurant's own species, cannot be told apart
985 # by fluid and still falls to the rule.)
986 hint = "gas"
987 else:
988 continue
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:
992 found.phase = hint
993 for ports in tanks.values():
994 net.nodes[ports.ullage].phase = "gas"
995 net.nodes[ports.outlet].phase = "liquid"
996
997 if unsized:
998
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,
1004 )
1005
1006 feeds = [name for edge_id, name in unsized if wet(edge_id)]
1007 tube = FALLBACKS["pipe"]
1008 warnings.append(
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)"
1013 + (
1014 f". Propellant lines among them, which set what the engine "
1015 f"sees: {', '.join(feeds)}"
1016 if feeds
1017 else ""
1018 )
1019 + f". All: {', '.join(name for _, name in unsized)}. Give them their size."
1020 )
1021
1022 # 5. The engine. Without one it stays a fixed pressure boundary, which is
1023 # the honest thing to be: nobody has said what is on the end of the pipe.
1024 # With one, its injector face is real -- the lines that arrived at the
1025 # symbol now terminate on injector legs, and the chamber node behind them
1026 # is driven by the coupled solve rather than by a number somebody typed.
1027 engine_ports: dict[str, str] = {}
1028 if engine is not None:
1029 engine_ports, engine_warnings = _attach_engine(
1030 net,
1031 diagram,
1032 engine,
1033 fluids,
1034 temperatures,
1035 default_temperature,
1036 into_engine=into_engine,
1037 )
1038 warnings.extend(engine_warnings)
1039
1040 # 6. A tank with no vent valve drawn is vented through the GSE, beyond a
1041 # disconnect on its top; see _gse_vents.
1042 # 6b. Vehicle and ground support (feedtwin.pid.roles), and which ground
1043 # tanks load a vehicle tank through the drawing.
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(
1049 net,
1050 diagram,
1051 actuators,
1052 tanks,
1053 fluids,
1054 engine_ports,
1055 ground=ground,
1056 shut=shut_by_hand,
1057 )
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))
1061 # A disconnect with no coefficient stays a fitting of the spec's default
1062 # kind: a 5D long-radius 90-degree elbow, K ~0.25 -- a small fraction of a
1063 # real QD's loss (a Cv 2 QD in 9.5 mm is K 4.35). It is not invented away
1064 # here (no number for a QD is better sourced than another), it is said.
1065 # The GSE vents just read are sized by the cart's valve and are not holes.
1066 elbows = sorted(
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)
1072 )
1073 if elbows:
1074 warnings.append(
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."
1078 )
1079 if gse:
1080 for qd, _ in gse:
1081 actuators[qd.id] = f"{qd.label}.command"
1082 roles = _valve_roles(
1083 net,
1084 diagram,
1085 actuators,
1086 tanks,
1087 fluids,
1088 engine_ports,
1089 gse=frozenset(qd.id for qd, _ in gse),
1090 ground=ground,
1091 shut=shut_by_hand,
1092 )
1093 roles.update(_dome_valve_roles(dome_lines, ground))
1094 warnings.append(
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."
1101 )
1102
1103 return BuiltNetwork(
1104 network=net,
1105 tanks=tanks,
1106 engine_ports=engine_ports,
1107 node_of=node_of,
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),
1114 valve_roles=roles,
1115 gse_vents=gse_unsized,
1116 mated=mated,
1117 rest=rest,
1118 hand_valves=frozenset(hand),
1119 vehicle=None if not ground else vehicle,
1120 supplies=supplies,
1121 dome_lines=dome_lines,
1122 )
1123
1124
1125def _separate_vehicles(diagram: Diagram) -> list[list[PidNode]]:
1126 """The drawing's engines, grouped by the drawn-line piece each sits on.
1127
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.
1133 """
1134 parent: dict[str, str] = {}
1135
1136 def find(node: str) -> str:
1137 while parent.get(node, node) != node:
1138 node = parent[node]
1139 return node
1140
1141 for edge in diagram.edges:
1142 a, b = find(edge.source), find(edge.target)
1143 if a != b:
1144 parent[a] = b
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())
1150
1151
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.
1156
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.
1164
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.
1170
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.
1173 """
1174 by_id = {n.id: n for n in diagram.nodes}
1175
1176 def partner(node: PidNode) -> str:
1177 found = (node.options.get("pairedWith") or "").strip()
1178 return "" if found in {"", "none"} else found
1179
1180 pairs: set[tuple[str, str]] = set()
1181 warnings: list[str] = []
1182 for node in diagram.nodes:
1183 if node.type != "QD":
1184 continue
1185 other = partner(node)
1186 if not other:
1187 continue
1188 label = node.label or node.id
1189 mate = by_id.get(other)
1190 if mate is None or mate.type != "QD":
1191 warnings.append(
1192 f"{label} is paired with {other!r}, which is not a disconnect on "
1193 "this drawing: left unmated."
1194 )
1195 continue
1196 back = partner(mate)
1197 if back and back != node.id:
1198 warnings.append(
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."
1201 )
1202 continue
1203 a, b = sorted((node.id, mate.id))
1204 pairs.add((a, b))
1205
1206 # Two halves joined by a line of their own are coupled on the page already,
1207 # whatever the pair says -- pid-designer reads them so (its checks.ts) and
1208 # raises nothing. Mating them as well found no free side on either half
1209 # and warned of an uncoupled pair that the drawing had in fact coupled.
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:
1214 continue
1215 free: dict[str, list[str]] = {}
1216 for qd in (a, b):
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]
1220 if lone:
1221 warnings.append(
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."
1225 )
1226 continue
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"}
1229 warnings.append(
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 ".")
1233 )
1234 return out, warnings
1235
1236
1237def _join_direct(
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.
1241
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.
1247 """
1248 parent: dict[str, str] = {}
1249
1250 def find(node: str) -> str:
1251 while parent.get(node, node) != node:
1252 node = parent[node]
1253 return node
1254
1255 kept: list[tuple[str, str, str]] = []
1256 for edge_id, a, b in direct:
1257 ra, rb = find(a), find(b)
1258 if ra == rb:
1259 continue
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))
1264 continue
1265 if b_fixed:
1266 ra, rb = rb, ra
1267 parent[rb] = ra
1268
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)
1277 for node in rename:
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
1281
1282
1283def _and(items: list[str]) -> str:
1284 return items[0] if len(items) == 1 else ", ".join(items[:-1]) + " and " + items[-1]
1285
1286
1287#: Propellant species read as the oxidiser side of a stand; everything else a
1288#: tank holds is fuel. Matches the state machines' vocabulary ("LOX ...").
1289_OXIDISERS = frozenset({"oxygen", "nitrousoxide", "n2o", "hydrogenperoxide"})
1290
1291
1292def _valve_roles(
1293 net: Network,
1294 diagram: Diagram,
1295 actuators: Mapping[str, str],
1296 tanks: Mapping[str, TankPorts],
1297 fluids: Mapping[str, str],
1298 engine_ports: Mapping[str, str],
1299 *,
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.
1305
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`).
1313
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.
1319 """
1320 # Valves stop a walk; a dome-loaded regulator carries a `.dome` signal and
1321 # is an actuator too, but the press line runs through it.
1322 valves = {n.id for n in diagram.nodes if n.type in ACTUATED_VALVE_TYPES} | gse
1323 # A hand valve resting shut separates what is either side of it, as an
1324 # actuated valve does; one resting open is a length of pipe.
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"}
1328
1329 def side(tank: str) -> str:
1330 return _side(tank, diagram, fluids)
1331
1332 def job(
1333 a: dict[str, set[str]], b: dict[str, set[str]], on_ground: bool = False
1334 ) -> frozenset[str] | None:
1335 # Ground support first: a cart tank's press and vent are "fill" press
1336 # and vent ("Fuel Fill Press"), not the flight tank's, and the valve
1337 # that lets the cart's propellant into a flight tank is its fill.
1338 flight_in = {t for t in a["ullage"] | a["outlet"] if t not in ground}
1339 if (
1340 len(flight_in) == 1
1341 and ({t for t in b["outlet"] if t in ground} or (b["supply"] & dewars))
1342 and not b["engine"]
1343 ):
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"})
1351 if (
1352 len(a["outlet"]) == 1
1353 and b["ambient"]
1354 and not (a["ullage"] or a["supply"] or b["supply"])
1355 and on_ground
1356 ):
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"})
1364 if (
1365 a["supply"]
1366 and b["ambient"]
1367 and not any(a[k] or b[k] for k in ("ullage", "outlet"))
1368 ):
1369 return frozenset({"gn2", "vent"})
1370 return None
1371
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:
1375 continue
1376 # The branch's own ends: a valve joined directly to a tank has the
1377 # tank's node for a side, not "<id>.in".
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)
1382 if found:
1383 roles[drawing_id] = found
1384 return roles
1385
1386
1387Reach = Callable[[str], dict[str, set[str]]]
1388
1389
1390def _supplies(
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.
1394
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.
1399 """
1400 if not ground:
1401 return {}
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)
1406 port_of = {}
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:
1413 continue
1414 reached: set[str] = set()
1415 seen = {ports.outlet}
1416 frontier = [ports.outlet]
1417 while frontier:
1418 here = frontier.pop()
1419 for there in adjacent.get(here, ()):
1420 if there in seen:
1421 continue
1422 seen.add(there)
1423 owner = port_of.get(there)
1424 if owner is not None and owner not in ground:
1425 reached.add(owner)
1426 if net.nodes[there].pressure is not None:
1427 continue
1428 frontier.append(there)
1429 if reached:
1430 out[tank_id] = frozenset(reached)
1431 return out
1432
1433
1434def _reacher(
1435 net: Network,
1436 diagram: Diagram,
1437 tanks: Mapping[str, TankPorts],
1438 engine_ports: Mapping[str, str],
1439 stops: set[str] | frozenset[str],
1440) -> Reach:
1441 """A walk from one network node to the fixed pressures it meets.
1442
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
1445 atmosphere.
1446 """
1447 adjacent: dict[str, list[str]] = {}
1448 for branch in net.branches.values():
1449 if branch.id in stops:
1450 continue
1451 adjacent.setdefault(branch.upstream, []).append(branch.downstream)
1452 adjacent.setdefault(branch.downstream, []).append(branch.upstream)
1453
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"])
1460
1461 def reach(start: str) -> dict[str, set[str]]:
1462 found: dict[str, set[str]] = {
1463 "ullage": set(),
1464 "outlet": set(),
1465 "supply": set(),
1466 "engine": set(),
1467 "ambient": set(),
1468 }
1469 seen = {start}
1470 queue = [start]
1471 while queue:
1472 here = queue.pop()
1473 node = net.nodes.get(here)
1474 if node is not None and node.pressure is not None:
1475 if here in ullage:
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)
1485 if here != start:
1486 continue # a boundary: what lies beyond it is somebody else's
1487 for nxt in adjacent.get(here, ()):
1488 if nxt not in seen:
1489 seen.add(nxt)
1490 queue.append(nxt)
1491 return found
1492
1493 return reach
1494
1495
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"
1502
1503
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 "")
1509 )
1510
1511
1512def _gse_vents(
1513 net: Network,
1514 diagram: Diagram,
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.
1521
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.
1528
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:
1531
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.
1537
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
1540 atmosphere.
1541 """
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}
1544 vented = {
1545 next(iter(role - {"vent"}))
1546 for role in roles.values()
1547 if "vent" in role and len(role) == 2
1548 }
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:
1553 continue
1554 branch = net.branches[node.id]
1555 attached = {
1556 end
1557 for other in net.branches.values()
1558 if other is not branch
1559 for end in (other.upstream, other.downstream)
1560 }
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:
1564 continue
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")
1569 ):
1570 continue
1571 tank = next(iter(found["ullage"]))
1572 if _side(tank, diagram, fluids) in vented:
1573 continue
1574 candidates.setdefault(tank, []).append((node, free[0]))
1575
1576 out: list[tuple[PidNode, str]] = []
1577 for tank, found_qds in candidates.items():
1578 if len(found_qds) != 1:
1579 continue # which of them the cart couples to is not on the drawing
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
1584 )
1585 net.nodes[vent_end] = replace(
1586 net.nodes[vent_end], pressure=AMBIENT, phase="gas"
1587 )
1588 out.append((node, labels.get(tank, tank)))
1589 return out
1590
1591
1592#: Which side of a two-port symbol each of pid-designer's ports is. Its inline
1593#: symbols (SOL, MAN, ROT, RV, CV, QD, PR) all have exactly ``l`` and ``r``, and
1594#: its check valve's artwork and checks both say ``l`` is the inlet
1595#: (``CV_INLET`` in its ``ports.ts``). For a symmetric valve the choice only has
1596#: to put the two lines on different sides.
1597PORT_SIDES: Mapping[str, bool] = {"l": False, "r": True} # True: the outlet
1598
1599#: Inline symbols whose two sides are not interchangeable, so the port a line
1600#: is on decides its side whenever every line on the symbol says which port it
1601#: was drawn on -- not only when the direction leaves a side empty. A check
1602#: valve's inlet is ``l`` (pid-designer's ``CV_INLET``, its arrow, and its
1603#: ``cv-backwards`` check); a regulator's is ``l`` by the same convention, which
1604#: is how every regulator on the team's drawings is plumbed. Read by direction
1605#: instead, a CV whose lines were both dragged from the far end -- correct on
1606#: the page, nothing flagged -- sat with the bottle on its outlet and passed
1607#: nothing, while one pid-designer flags as backwards flowed.
1608DIRECTED_TYPES = frozenset({"CV", "PR"})
1609
1610
1611def _sides_by_port(
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.
1615
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.
1624
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.
1631
1632 Returns ``{(edge id, node id): outgoing}`` overrides, and one warning per
1633 symbol read this way.
1634 """
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))
1639
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:
1645 continue
1646 ports = {port for _, _, port in here}
1647 if ports != set(PORT_SIDES):
1648 continue # nothing to read, or not pid-designer's two ports
1649 if len({outgoing for _, outgoing, _ in here}) != 1:
1650 # Lines on both sides already: the direction works -- unless the
1651 # symbol has a direction of its own and the lines say otherwise.
1652 if node.type not in DIRECTED_TYPES or all(
1653 outgoing == PORT_SIDES[port] for _, outgoing, port in here
1654 ):
1655 continue
1656 for edge_id, _, port in here:
1657 overrides[(edge_id, node.id)] = PORT_SIDES[port]
1658 warnings.append(
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 "
1662 "180 degrees."
1663 )
1664 continue
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"
1668 warnings.append(
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 "
1671 "(l in, r out)."
1672 )
1673 return overrides, warnings
1674
1675
1676def _attach(
1677 node: PidNode,
1678 other: PidNode,
1679 ullage_side: frozenset[str],
1680 tanks: Mapping[str, TankPorts],
1681 *,
1682 outgoing: bool,
1683) -> str:
1684 """Which network node a line's end lands on.
1685
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.
1688
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.
1695 """
1696 if node.is_inline:
1697 return f"{node.id}.out" if outgoing else f"{node.id}.in"
1698 ports = tanks.get(node.id)
1699 if ports is None:
1700 return node.id
1701 return ports.ullage if other.id in ullage_side else ports.outlet
1702
1703
1704def _reach(diagram: Diagram, start: frozenset[str]) -> frozenset[str]:
1705 """Symbols reachable from these kinds without passing through a vessel.
1706
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.
1710 """
1711 return _walk(diagram, [n.id for n in diagram.nodes if n.type in start])
1712
1713
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}
1721
1722 seen: set[str] = set()
1723 queue = list(starts)
1724 seen.update(queue)
1725 while queue:
1726 current = queue.pop(0)
1727 for neighbour in adjacency.get(current, []):
1728 if neighbour in seen:
1729 continue
1730 seen.add(neighbour)
1731 if types.get(neighbour) not in {"TANK", "ENGINE", "INJECTOR"}:
1732 queue.append(neighbour)
1733 return frozenset(seen)
1734
1735
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"}))
1739
1740
1741def _vent_reach(diagram: Diagram) -> frozenset[str]:
1742 """Symbols that reach atmosphere without going through a tank.
1743
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.
1748
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
1752 for."""
1753 return _reach(diagram, AMBIENT_TYPES)
1754
1755
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
1758 vessel.
1759
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.
1769 """
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}
1772
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()):
1776 return False
1777 if tank.rotation == 0.0:
1778 return side == "t"
1779 if tank.rotation == 180.0:
1780 return side == "b"
1781 return False
1782
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),
1788 ):
1789 tank = tanks.get(end)
1790 if tank is None or not on_top(tank, handle):
1791 continue
1792 if types.get(other) not in {"TANK", "ENGINE", "INJECTOR"}:
1793 starts.add(other)
1794 return _walk(diagram, starts) if starts else frozenset()
1795
1796
1797def _tank_reliefs(diagram: Diagram) -> frozenset[str]:
1798 """Relief valves -- RVs that declare a ``set_pressure`` -- drawn onto a tank.
1799
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.
1804 """
1805 tanks = {n.id for n in diagram.nodes if n.type == "TANK"}
1806 reliefs = {
1807 n.id for n in diagram.nodes if n.type == "RV" and "set_pressure" in n.params
1808 }
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)
1816
1817
1818def _build_dome_loader(
1819 node: PidNode,
1820 signal: str,
1821 diagram: Diagram,
1822 node_of: Mapping[str, str],
1823) -> DomeLoader:
1824 """Build the control regulator and note which node supplies it.
1825
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.
1829 """
1830 supply = ""
1831 # Its inlet port first: on a dome line drawn through solenoids and a mated
1832 # disconnect, the first line found can be the dome side.
1833 touching = sorted(
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)
1836 != "l",
1837 )
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]
1842 break
1843 kind, model = BRANCH_KINDS.get(node.type, ("regulator", "droop"))
1844 component = _instance(
1845 node.label, kind, model, dict(node.params), dict(node.options)
1846 )
1847 return DomeLoader(
1848 id=node.id, component=component, signal=signal, supply_node=supply
1849 )
1850
1851
1852def _dome_lines(
1853 diagram: Diagram,
1854 loaders: Mapping[str, str],
1855) -> dict[str, DomeLine]:
1856 """The dome lines drawn from a dome port with a valve on them.
1857
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.
1863 """
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():
1877 ports = frozenset(
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)
1882 )
1883 if not ports:
1884 continue
1885 seen: set[str] = set()
1886 frontier = list(ports)
1887 while frontier:
1888 here = frontier.pop()
1889 if here in seen or here not in by_id or here == loaded:
1890 continue
1891 seen.add(here)
1892 if here == loader or by_id[here].type in barrier:
1893 continue
1894 frontier.extend(adjacent.get(here, ()))
1895 if loader not in seen:
1896 continue
1897 valves = frozenset(
1898 sid
1899 for sid in seen
1900 if by_id[sid].type in ACTUATED_VALVE_TYPES | HAND_VALVE_TYPES
1901 )
1902 if not valves:
1903 continue
1904 # A valve with one line drawn to it vents the line: read off the
1905 # drawing, since the flow network never sees a dome line.
1906 lines_on = {
1907 sid: sum(1 for e in diagram.edges if sid in (e.source, e.target))
1908 for sid in valves
1909 }
1910 out[loaded] = DomeLine(
1911 loaded=loaded,
1912 loader=loader,
1913 adjacent={
1914 sid: frozenset(o for o in adjacent.get(sid, ()) if o in seen)
1915 for sid in seen
1916 },
1917 ports=ports,
1918 valves=valves,
1919 vents=frozenset(v for v in valves if lines_on[v] == 1),
1920 )
1921 return out
1922
1923
1924def _unloaded_dome_valves(
1925 diagram: Diagram, loaders: Mapping[str, str]
1926) -> frozenset[str]:
1927 """Valves on the dome line of a dome-loaded regulator nothing drawn loads.
1928
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.
1941 """
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:
1952 if (
1953 node.type != "PR"
1954 or node.options.get("domeLoaded") != "yes"
1955 or node.id in loaded
1956 ):
1957 continue
1958 frontier = [
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)
1963 ]
1964 seen: set[str] = set()
1965 tapped = False
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:
1969 continue
1970 seen.add(here)
1971 kind = by_id[here].type
1972 if kind in tapped_types:
1973 tapped = True
1974 elif kind not in stop:
1975 frontier.extend(adjacent.get(here, ()))
1976 if not tapped:
1977 out |= {
1978 sid for sid in seen if by_id[sid].type in VENTING_VALVE_TYPES - {"RV"}
1979 }
1980 return frozenset(out)
1981
1982
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:
1992 if valve in ground:
1993 out[valve] = frozenset({"gse", "med", "press", "control"})
1994 return out
1995
1996
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))
2001
2002
2003def _dome_loaders(diagram: Diagram) -> dict[str, str]:
2004 """``{control regulator id: the regulator whose dome it loads}``.
2005
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.
2010 """
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:
2017 out[a] = b
2018 # A dome line is often longer than one line: the control regulator on the
2019 # GSE cart, its solenoid, a mated disconnect, the vehicle's dome solenoid,
2020 # then the dome port. Walk it from the port -- the line drawn on the
2021 # regulator's ``dome`` handle -- through junctions, valves and paired
2022 # disconnects, and the one regulator it reaches is the loader. Stops at
2023 # vessels, the engine and reliefs; more than one regulator is ambiguous and
2024 # left as the operator's knob.
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())):
2037 starts = [
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)
2042 ]
2043 found: set[str] = set()
2044 seen = {b}
2045 frontier = list(starts)
2046 while frontier:
2047 here = frontier.pop()
2048 if here in seen or here not in by_id:
2049 continue
2050 seen.add(here)
2051 kind = by_id[here].type
2052 if kind == "PR":
2053 found.add(here)
2054 continue
2055 if kind in barrier:
2056 continue
2057 frontier.extend(adjacent.get(here, ()))
2058 if len(found) == 1:
2059 out[found.pop()] = b
2060 return out
2061
2062
2063def _attach_engine(
2064 net: Network,
2065 diagram: Diagram,
2066 engine: object,
2067 fluids: Mapping[str, str],
2068 temperatures: Mapping[str, float],
2069 default_temperature: float,
2070 *,
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.
2074
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.
2079
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.
2083 """
2084 from feedtwin.engine.component import injector_legs
2085
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)
2089 if symbol is None:
2090 return {}, [
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."
2095 ]
2096
2097 ox_leg, fuel_leg = injector_legs(engine) # type: ignore[arg-type]
2098 ox_species = getattr(engine, "oxidiser").propellant
2099 fuel_species = getattr(engine, "fuel").propellant
2100
2101 chamber = f"{symbol.id}.chamber"
2102 if chamber not in net.nodes:
2103 net.add_node(
2104 chamber,
2105 fluids.get(symbol.id, ox_species),
2106 temperatures.get(symbol.id, default_temperature),
2107 pressure=net.nodes[symbol.id].pressure,
2108 )
2109
2110 ports: dict[str, str] = {"chamber": chamber}
2111
2112 # Each line that arrived at the engine now stops at an injector face, and an
2113 # injector leg carries it the rest of the way into the chamber.
2114 for edge in diagram.edges:
2115 other = (
2116 edge.source
2117 if edge.target == symbol.id
2118 else edge.target if edge.source == symbol.id else None
2119 )
2120 if other is None or (
2121 edge.id not in net.branches and edge.id not in into_engine
2122 ):
2123 continue
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"
2129 else:
2130 # Silence here was a real trap: a line whose fluid matches neither
2131 # propellant used to be skipped, leaving the engine with one leg
2132 # and a mixture ratio computed against a zero. Every number
2133 # downstream stayed plausible.
2134 warnings.append(
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."
2140 )
2141 continue
2142
2143 if edge.id in into_engine:
2144 # Drawn straight into the engine with no line between: the
2145 # injector leg starts at the part it is joined to.
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
2150 continue
2151 face = f"{symbol.id}.{side}"
2152 if face not in net.nodes:
2153 # The face is at the temperature of the line arriving at it, not
2154 # the engine symbol's. An engine is a barrier in the fluid walk, so
2155 # it is painted by whichever propellant reached it first -- and
2156 # inheriting that would ask the property layer for ethanol at 90 K,
2157 # which is a solid.
2158 net.add_node(
2159 face,
2160 species,
2161 temperatures.get(other, default_temperature),
2162 )
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
2169
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
2174
2175 for side, species in (("oxidiser", ox_species), ("fuel", fuel_species)):
2176 if side not in ports:
2177 warnings.append(
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 "
2182 "flow."
2183 )
2184
2185 # The old boundary node is now behind the injector and takes no part.
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):
2189 break
2190 else:
2191 net.nodes.pop(symbol.id, None)
2192 return ports, warnings
2193
2194
2195def _column_head(
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.
2199
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
2204 budget.
2205
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
2209 default is used.
2210 """
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
2217 if volume is None:
2218 notes.append(
2219 f"{label} has no volume on the drawing; its static head assumes "
2220 f"{DEFAULT_TANK_LITRES * 1e3:.1f} L."
2221 )
2222 if diameter is None:
2223 notes.append(
2224 f"{label} has no diameter on the drawing; its static head assumes a "
2225 f"{DEFAULT_TANK_BORE * 1e3:.0f} mm bore."
2226 )
2227 geometry = cylindrical_from_volume(litres, bore)
2228 level = level_of_volume(geometry, STATIC_HEAD_FILL * geometry.total_volume)
2229 try:
2230 from feedtwin.props import Fluid
2231
2232 density = Fluid(fluid).get("rho", T=temperature, q=0.0)
2233 except Exception: # noqa: BLE001 - a property gap must not stop a build
2234 density = 1000.0
2235 notes.append(
2236 f"{label}: no liquid density for {fluid} at {temperature:.0f} K; its "
2237 "static head assumes water."
2238 )
2239 return density * 9.80665 * level, notes
2240
2241
2242#: Roles pid-designer colours a line by. They are not species -- "fuel" is a
2243#: role, ethanol is a fluid -- but a drawing that carries only the role still
2244#: says something useful, and saying nothing about it is worse than saying it is
2245#: not enough.
2246FLUID_ROLES = frozenset({"fuel", "lox", "oxidizer", "pressurant"})
2247
2248
2249def _tank_warnings(
2250 node: PidNode, fluid: str, temperature: float, pressure: float
2251) -> list[str]:
2252 """Things about a propellant tank that are almost certainly a drawing gap.
2253
2254 Two of them, and the second is the one that costs an afternoon.
2255
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.
2259
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
2266 missing.
2267 """
2268 out: list[str] = []
2269 label = node.label or node.id
2270
2271 if not node.fluid:
2272 role = node.role
2273 hint = (
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
2277 else ""
2278 )
2279 out.append(
2280 f"{label} does not say what it holds, so it is being solved as "
2281 f"{fluid}.{hint}"
2282 )
2283 return out
2284
2285 try:
2286 from feedtwin.props import Fluid
2287
2288 probe = Fluid(fluid)
2289 if probe.phase(p=pressure, T=temperature).is_liquid_like:
2290 return out
2291 if node.params.get("temperature") is not None:
2292 # Stated, and still a gas: not a missing temperature but a vessel
2293 # of gas drawn with the propellant-tank symbol. pid-designer offers
2294 # "copv" as a tank *wall material*, which makes this easy to do --
2295 # and then nothing on the drawing supplies the press lines.
2296 out.append(
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)."
2301 )
2302 return out
2303 saturation = probe.get("T", p=pressure, q=0.0)
2304 except Exception: # noqa: BLE001 - a property gap must not stop a build
2305 return out
2306
2307 out.append(
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."
2313 )
2314 return out
2315
2316
2317def _kind_and_model(node: PidNode) -> tuple[str, str]:
2318 """Which catalogue component and fidelity model a symbol becomes.
2319
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.
2326 """
2327 kind, model = BRANCH_KINDS[node.type]
2328 if node.type == "RV" and "set_pressure" in node.params:
2329 # A relief that says when it lifts is a relief. One that does not stays
2330 # the Cv valve it always was (and build_network says so).
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:
2337 return kind, "cd"
2338 return kind, model
2339
2340
2341def _pressure(node: PidNode) -> float | None:
2342 param = node.params.get("pressure")
2343 if param is None and node.type in ENGINE_TYPES:
2344 # pid-designer's engine symbol asks for "Chamber pressure" and saves it
2345 # as `chamber_pressure`; the drawings written by hand say `pressure`.
2346 param = node.params.get("chamber_pressure")
2347 return param.si if param is not None else None
2348
2349
2350def _temperature(node: PidNode, fallback: float) -> float:
2351 param = node.params.get("temperature")
2352 return param.si if param is not None else fallback
2353
2354
2355def _instrument_anchor(
2356 node: PidNode,
2357 by_id: Mapping[str, PidNode],
2358 node_of: Mapping[str, str],
2359 diagram: Diagram,
2360) -> str | None:
2361 """Which network node a transducer reads.
2362
2363 Its declared clip first; failing that, whatever it is drawn connected to.
2364
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.
2368
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.
2377 """
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"
2384 return anchor
2385 for edge in diagram.edges:
2386 other = (
2387 edge.target
2388 if edge.source == node.id
2389 else edge.source if edge.target == node.id else None
2390 )
2391 if other is not None and other in node_of:
2392 return node_of[other]
2393 return None
2394
2395
2396def _fluid_of(
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
2400 reaches.
2401
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
2407 than obviously so.
2408
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
2413 as oxidiser.
2414 """
2415 # A tank is a barrier for the same reason an engine is: two different
2416 # fluids meet there legitimately. Pressurant arrives at the ullage and
2417 # propellant leaves the bottom, so a walk that carried on through would
2418 # paint the whole pressurant network as LOX -- and then every line from
2419 # the bottle to the tank would land on the tank's liquid side, leaving the
2420 # ullage with nothing attached to it.
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)
2426
2427 types = {n.id: n.type for n in diagram.nodes}
2428 out: dict[str, str] = {}
2429 temps: dict[str, float] = {}
2430
2431 def spread(seeds: list[PidNode], skip: frozenset[str] = frozenset()) -> None:
2432 # Seeds are enqueued unconditionally: what a symbol *declares* always
2433 # wins over what reached it. A tank painted nitrogen because the
2434 # pressurant line got there first is a tank whose own contents have
2435 # been overwritten by the gas pushing on them.
2436 queue: list[tuple[str, str, float]] = []
2437 for seed in seeds:
2438 declared = seed.params.get("temperature")
2439 queue.append((seed.id, seed.fluid, declared.si if declared else 0.0))
2440 while queue:
2441 current, fluid, temperature = queue.pop(0)
2442 for neighbour in adjacency.get(current, []):
2443 if neighbour in skip:
2444 continue
2445 known = out.get(neighbour)
2446 if known is not None:
2447 # Already painted. If it was painted by a *declaration*
2448 # rather than by this walk, it may still be missing a
2449 # temperature -- a drawing that labels every valve with its
2450 # fluid, which is good practice, turns each one into a seed
2451 # that carries no temperature of its own. Skipping it
2452 # outright pins the whole line downstream at the ambient
2453 # default, so a LOX feed solves as gaseous oxygen at 40
2454 # kg/m^3 instead of liquid at 1140.
2455 #
2456 # Only across the *same* fluid: inheriting a temperature
2457 # from a line carrying something else is how an ethanol face
2458 # ends up at 90 K.
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))
2463 continue
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))
2469
2470 # Two passes, not one interleaved walk. A single breadth-first search from
2471 # every source at once assigns by *distance*, so a manifold one hop from the
2472 # ox tank and three from the bottle comes out as oxygen -- and then the
2473 # pressurant line into that tank lands on its liquid side. Supplies fill the
2474 # plumbing between themselves and whatever they feed; tanks then fill what
2475 # leaves them.
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")
2480 if own is not None:
2481 temps[node.id] = own.si
2482 spread([n for n in declared if n.type != "TANK"])
2483 # A tank spreads its *contents* down its outlet, and must not spread them up
2484 # its vent: what leaves the top of a tank is the gas pushing on the
2485 # propellant, not the propellant. Painting a vent line with the tank's
2486 # contents priced it as liquid, and a vent then moved thirty times the mass
2487 # it should.
2488 spread([n for n in declared if n.type == "TANK"], skip=ullage_side)
2489 return out, temps
static route_t * find(uint16_t pin)
Definition board_exti.c:20
static std::vector< string_t > split(const string_t &reference_string)
split the string input to reference tokens
Definition json.hpp:14471