1"""The network: nodes that hold a pressure, branches that carry a flow.
3A feed system is a graph. Nodes are places where pressure is defined -- a tank
4outlet, a tee, an injector face -- and branches are the components between them.
5Every unknown is one of two things: a node pressure or a branch mass flow.
7Two kinds of equation close the system, and between them they are the whole
10* **Mass balance** at every free node -- what flows in flows out.
11* **A pressure relation** on every branch -- ``p_up - p_dn = dp(mdot)``, where
12 ``dp`` is whatever the component says.
14Boundary conditions are nodes whose pressure is *fixed*: a pressurised tank, an
15ambient vent, a chamber. They contribute no mass balance, because whatever they
16need flows in or out of them by definition. A network with no fixed node has no
17reference pressure and is singular -- which is checked and reported, rather than
18discovered as a linear algebra failure.
20Scope, stated plainly: this is **steady, isothermal, single-phase** flow. Node
21temperatures are given rather than solved, so there is no energy equation here.
22Phase 05 adds the gas side and tank thermodynamics; Phase 07 makes it transient.
23A network that boils inside a line is outside what this can say.
26from __future__
import annotations
28from dataclasses
import dataclass, field
29from typing
import Iterable, Mapping
31from feedtwin.comps import FlowConditions, HydraulicComponent, Violation
36 """A network is malformed -- dangling branch, no reference pressure."""
41 """A point in the network where a pressure is defined.
44 id: Unique identifier. These are the tags on the P&ID.
45 fluid: Species name or alias -- resolved through the property layer.
46 temperature: Static temperature [K]. Given, not solved: this phase is
48 pressure: Fixed pressure [Pa] for a boundary node, or ``None`` for a
49 free node whose pressure is solved.
50 demand: Mass flow drawn *out* of this node by something outside the
51 network [kg/s]. How an engine's appetite enters a feed-system solve
52 without modelling the engine.
58 pressure: float |
None =
None
60 phase: str |
None =
None
61 """``"liquid"``, ``"gas"``, or ``None`` for "decide from temperature".
63 Declared by whatever built the network -- a drawing knows which side of a
64 tank a line is on -- so a subcritical *vapour* line can say it is gas and
65 stop being priced as the liquid it would otherwise be taken for. See
66 :func:`feedtwin.comps.elements.conditions_from_fluid`."""
75 """A component connecting two nodes, carrying one mass flow.
77 Flow is positive from ``upstream`` to ``downstream``. That is a labelling
78 convention, not a constraint -- a solved branch may well carry a negative
79 flow, which simply means it runs the other way, and check valves are
80 specifically written to notice.
84 component: HydraulicComponent
89@dataclass(frozen=True, slots=True)
91 """A branch that can carry no flow, and the node hanging off it.
93 Not a modelling mistake -- it is most of a real feed system. A pressure
94 transducer port, a capped fill quick-disconnect, a shut relief valve: each
95 is a legitimate stub whose flow is zero by mass balance and whose pressure
98 They are peeled off before the solve rather than solved for. Their answer is
99 exact and needs no iteration, they would otherwise sit on the derivative
100 floor and be reported as anomalies on every run, and every one removed is
101 two unknowns the Newton step does not carry.
107 """The node the stub hangs from -- solved normally."""
110@dataclass(slots=True)
112 """A feed system: nodes, branches, and the fluids in them."""
114 nodes: dict[str, Node] = field(default_factory=dict)
115 branches: dict[str, Branch] = field(default_factory=dict)
117 multiphase: bool =
False
118 """Let the property layer decide phase from ``(p, T)``.
120 Off. A declared liquid is evaluated on its saturated-liquid line, so a leg
121 can never silently come back as a gas at a thirtieth of the density because
122 a temperature reached it wrong -- which has happened twice here, from two
123 different causes, and both times read as a flow discrepancy rather than a
126 Turn it on when flashing and cavitation are the subject rather than the
127 hazard. See :func:`feedtwin.comps.elements.conditions_from_fluid`.
130 gravity: float = 9.80665
131 """Acceleration along the elevation axis that liquid columns feel [m/s^2].
132 Standard gravity unless a caller sets it -- a vehicle in flight, through
133 :attr:`feedtwin.session.core.Setup.body_acceleration`. Every branch's static
134 head uses it, through :class:`~feedtwin.comps.base.FlowConditions`."""
136 _fluids: dict[str, Fluid] = field(default_factory=dict, repr=
False)
137 _stubs: dict[tuple[object, ...], tuple[DeadEnd, ...]] = field(
138 default_factory=dict, repr=
False, compare=
False
149 pressure: float |
None =
None,
154 node =
Node(id, fluid, temperature, pressure, demand)
155 self.
nodes[id] = node
159 self, id: str, component: HydraulicComponent, upstream: str, downstream: str
163 for end
in (upstream, downstream):
164 if end
not in self.
nodes:
166 f
"branch {id!r} connects to unknown node {end!r}; "
167 f
"nodes are: {', '.join(sorted(self.nodes)) or '(none)'}"
169 if upstream == downstream:
170 raise NetworkError(f
"branch {id!r} starts and ends at {upstream!r}")
171 branch =
Branch(id, component, upstream, downstream)
179 """Nodes whose pressure is solved, in a stable order."""
180 return [n
for n
in self.
nodes if not self.
nodes[n].is_fixed]
184 return [n
for n
in self.
nodes if self.
nodes[n].is_fixed]
187 """Branches touching a node, with +1 for inflow and -1 for outflow."""
188 out: list[tuple[Branch, int]] = []
189 for branch
in self.
branches.values():
190 if branch.downstream == node_id:
191 out.append((branch, +1))
192 if branch.upstream == node_id:
193 out.append((branch, -1))
196 def isolated(self, signals: Mapping[str, float] |
None =
None) -> set[str]:
197 """Branches commanded shut, which carry no flow and are not solved for.
199 See :meth:`feedtwin.comps.base.HydraulicComponent.isolates`. Removing
200 them is what turns "a shut valve" from a near-singular row into what it
201 physically is -- no connection.
205 for branch_id, branch
in self.
branches.items()
206 if branch.component.isolates(signals)
209 def dead_ends(self, exclude: Iterable[str] |
None =
None) -> list[DeadEnd]:
210 """Stubs that can carry no flow, peeled outermost-first.
213 exclude: Branches already removed from the solve -- shut valves.
214 A node left with one live branch once those are gone is a stub
215 exactly as if nothing had ever been drawn on its other side,
216 which is why this takes them rather than being told twice.
218 Found iteratively, because stubs chain: a plenum port feeding a short
219 line into a capped quick-disconnect is two branches deep, and removing
220 the outer one is what makes the inner one a stub in turn.
222 The returned order is safe to back-fill in reverse -- each entry's live
223 end is either solved or an earlier entry.
225 Remembered per shut set, wiring and which nodes are boundaries: a live
226 session asks the same question every solve, and peeling a two-page
227 stand costs a millisecond each time.
230 frozenset(exclude
or ()),
231 tuple((b.id, b.upstream, b.downstream)
for b
in self.
branches.values()),
233 (node_id, node.is_fixed
or node.demand != 0.0)
234 for node_id, node
in self.
nodes.items()
237 known = self.
_stubs.get(key)
238 if known
is not None:
240 found = self.
_peel(set(key[0]))
241 if len(self.
_stubs) >= 64:
243 self.
_stubs[key] = tuple(found)
246 def _peel(self, removed: set[str]) -> list[DeadEnd]:
247 """:meth:`dead_ends`, worked out."""
248 remaining = {b: v
for b, v
in self.
branches.items()
if b
not in removed}
249 found: list[DeadEnd] = []
252 degree: dict[str, int] = {}
253 for branch
in remaining.values():
254 for end
in (branch.upstream, branch.downstream):
255 degree[end] = degree.get(end, 0) + 1
258 for node_id, node
in self.
nodes.items():
259 if node.is_fixed
or node.demand != 0.0:
261 if degree.get(node_id, 0) != 1:
265 for b
in remaining.values()
266 if node_id
in (b.upstream, b.downstream)
269 branch.downstream
if branch.upstream == node_id
else branch.upstream
271 found.append(
DeadEnd(node=node_id, branch=branch.id, live_end=live))
272 del remaining[branch.id]
284 taken = {d.node
for d
in found}
285 for node_id, node
in self.
nodes.items():
286 if node.is_fixed
or node.demand != 0.0
or node_id
in taken:
288 if degree.get(node_id, 0) != 0:
294 if b.id
in removed
and node_id
in (b.upstream, b.downstream)
298 if bridge
is not None:
301 if bridge.upstream == node_id
304 found.append(
DeadEnd(node=node_id, branch=bridge.id, live_end=live))
314 if deadend
is not None:
315 found.append(deadend)
323 self, node_id: str, found: list[DeadEnd], removed: set[str]
325 """A stub entry for the last node of an island, or ``None``.
327 Walks the branches already peeled, starting at ``node_id``, to the first
328 node a removed branch (a shut valve) touches, and takes the live end
329 across that branch -- the pressure the island would see if the valve
330 opened, which is what a stub hung off a shut valve already reads. An
331 island with no shut valve on it at all has no pressure to inherit; it
332 is given its own peeled neighbour, and the back-fill reports it as
333 undefined rather than the solve failing.
335 peeled = {d.branch
for d
in found}
340 for branch
in self.
branches.values():
341 if here
not in (branch.upstream, branch.downstream):
344 branch.downstream
if branch.upstream == here
else branch.upstream
346 if branch.id
in removed:
347 return DeadEnd(node=node_id, branch=branch.id, live_end=other)
348 if branch.id
in peeled
and other
not in seen:
355 if b.id
in peeled
and node_id
in (b.upstream, b.downstream)
362 incident.downstream
if incident.upstream == node_id
else incident.upstream
364 return DeadEnd(node=node_id, branch=incident.id, live_end=other)
366 def fluid(self, name: str) -> Fluid:
367 """A cached :class:`~feedtwin.props.Fluid`, one per species."""
368 cached = self.
_fluids.get(name)
375 self, node_id: str, pressure: float, signals: dict[str, float] |
None =
None
377 """Fluid conditions at a node, for evaluating a branch leaving it."""
380 node = self.
nodes[node_id]
381 return conditions_from_fluid(
382 self.
fluid(node.fluid),
394 """Every structural problem that can be found without solving.
397 NetworkError: with all problems listed together, because fixing a
398 network six errors at a time beats six solve attempts.
400 problems: list[str] = []
403 problems.append(
" the network has no branches")
407 " no node has a fixed pressure. Without one there is no "
408 "reference and every pressure is arbitrary -- the system is "
409 "singular. Fix a tank or an ambient vent."
412 touched = {b.upstream
for b
in self.
branches.values()} | {
413 b.downstream
for b
in self.
branches.values()
415 for node_id
in sorted(set(self.
nodes) - touched):
416 if self.
nodes[node_id].pressure
is not None:
422 f
" node {node_id!r} has no branches attached; it cannot take part"
430 for node_id
in sorted(self.
nodes):
433 except Exception
as exc:
434 problems.append(f
" node {node_id!r}: {exc}")
437 raise NetworkError(
"this network cannot be solved:\n" +
"\n".join(problems))
440 """Design limits broken by any component in the network.
442 Configuration only, so it runs before a solve. A bend inside its
443 minimum radius does not stop the network solving; it stops the hardware
444 being right, which is a different and equally worth-knowing thing.
446 return [v
for b
in self.
branches.values()
for v
in b.component.check()]
450 f
"Network({len(self.nodes)} nodes, {len(self.branches)} branches, "
451 f
"{len(self.fixed_nodes)} fixed)"
Fluid fluid(self, str name)
DeadEnd|None _island_exit(self, str node_id, list[DeadEnd] found, set[str] removed)
list[tuple[Branch, int]] branches_at(self, str node_id)
Node add_node(self, str id, str fluid, float temperature, *, float|None pressure=None, float demand=0.0)
Branch add_branch(self, str id, HydraulicComponent component, str upstream, str downstream)
FlowConditions conditions(self, str node_id, float pressure, dict[str, float]|None signals=None)
list[DeadEnd] _peel(self, set[str] removed)
set[str] isolated(self, Mapping[str, float]|None signals=None)
list[str] free_nodes(self)
list[DeadEnd] dead_ends(self, Iterable[str]|None exclude=None)
list[Violation] check(self)