STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
network.py
Go to the documentation of this file.
1"""The network: nodes that hold a pressure, branches that carry a flow.
2
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.
6
7Two kinds of equation close the system, and between them they are the whole
8steady solve:
9
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.
13
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.
19
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.
24"""
25
26from __future__ import annotations
27
28from dataclasses import dataclass, field
29from typing import Iterable, Mapping
30
31from feedtwin.comps import FlowConditions, HydraulicComponent, Violation
32from feedtwin.props import Fluid
33
34
35class NetworkError(ValueError):
36 """A network is malformed -- dangling branch, no reference pressure."""
37
38
39@dataclass(slots=True)
40class Node:
41 """A point in the network where a pressure is defined.
42
43 Args:
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
47 isothermal.
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.
53 """
54
55 id: str
56 fluid: str
57 temperature: float
58 pressure: float | None = None
59 demand: float = 0.0
60 phase: str | None = None
61 """``"liquid"``, ``"gas"``, or ``None`` for "decide from temperature".
62
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`."""
67
68 @property
69 def is_fixed(self) -> bool:
70 return self.pressure is not None
71
72
73@dataclass(slots=True)
74class Branch:
75 """A component connecting two nodes, carrying one mass flow.
76
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.
81 """
82
83 id: str
84 component: HydraulicComponent
85 upstream: str
86 downstream: str
87
88
89@dataclass(frozen=True, slots=True)
90class DeadEnd:
91 """A branch that can carry no flow, and the node hanging off it.
92
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
96 follows its live end.
97
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.
102 """
103
104 node: str
105 branch: str
106 live_end: str
107 """The node the stub hangs from -- solved normally."""
108
109
110@dataclass(slots=True)
112 """A feed system: nodes, branches, and the fluids in them."""
113
114 nodes: dict[str, Node] = field(default_factory=dict)
115 branches: dict[str, Branch] = field(default_factory=dict)
116
117 multiphase: bool = False
118 """Let the property layer decide phase from ``(p, T)``.
119
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
124 property lookup.
125
126 Turn it on when flashing and cavitation are the subject rather than the
127 hazard. See :func:`feedtwin.comps.elements.conditions_from_fluid`.
128 """
129
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`."""
135
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
139 )
140
141 # ------------------------------------------------------------ construction
142
144 self,
145 id: str,
146 fluid: str,
147 temperature: float,
148 *,
149 pressure: float | None = None,
150 demand: float = 0.0,
151 ) -> Node:
152 if id in self.nodes:
153 raise NetworkError(f"duplicate node {id!r}")
154 node = Node(id, fluid, temperature, pressure, demand)
155 self.nodes[id] = node
156 return node
157
159 self, id: str, component: HydraulicComponent, upstream: str, downstream: str
160 ) -> Branch:
161 if id in self.branches:
162 raise NetworkError(f"duplicate branch {id!r}")
163 for end in (upstream, downstream):
164 if end not in self.nodes:
165 raise NetworkError(
166 f"branch {id!r} connects to unknown node {end!r}; "
167 f"nodes are: {', '.join(sorted(self.nodes)) or '(none)'}"
168 )
169 if upstream == downstream:
170 raise NetworkError(f"branch {id!r} starts and ends at {upstream!r}")
171 branch = Branch(id, component, upstream, downstream)
172 self.branches[id] = branch
173 return branch
174
175 # ------------------------------------------------------------- inspection
176
177 @property
178 def free_nodes(self) -> list[str]:
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]
181
182 @property
183 def fixed_nodes(self) -> list[str]:
184 return [n for n in self.nodes if self.nodes[n].is_fixed]
185
186 def branches_at(self, node_id: str) -> list[tuple[Branch, int]]:
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))
194 return out
195
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.
198
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.
202 """
203 return {
204 branch_id
205 for branch_id, branch in self.branches.items()
206 if branch.component.isolates(signals)
207 }
208
209 def dead_ends(self, exclude: Iterable[str] | None = None) -> list[DeadEnd]:
210 """Stubs that can carry no flow, peeled outermost-first.
211
212 Args:
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.
217
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.
221
222 The returned order is safe to back-fill in reverse -- each entry's live
223 end is either solved or an earlier entry.
224
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.
228 """
229 key = (
230 frozenset(exclude or ()),
231 tuple((b.id, b.upstream, b.downstream) for b in self.branches.values()),
232 tuple(
233 (node_id, node.is_fixed or node.demand != 0.0)
234 for node_id, node in self.nodes.items()
235 ),
236 )
237 known = self._stubs.get(key)
238 if known is not None:
239 return list(known)
240 found = self._peel(set(key[0]))
241 if len(self._stubs) >= 64:
242 self._stubs.clear()
243 self._stubs[key] = tuple(found)
244 return found
245
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] = []
250
251 while True:
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
256
257 peeled = False
258 for node_id, node in self.nodes.items():
259 if node.is_fixed or node.demand != 0.0:
260 continue
261 if degree.get(node_id, 0) != 1:
262 continue
263 branch = next(
264 b
265 for b in remaining.values()
266 if node_id in (b.upstream, b.downstream)
267 )
268 live = (
269 branch.downstream if branch.upstream == node_id else branch.upstream
270 )
271 found.append(DeadEnd(node=node_id, branch=branch.id, live_end=live))
272 del remaining[branch.id]
273 peeled = True
274 break
275
276 if peeled:
277 continue
278
279 # Nothing left with one live branch. What can remain is a node with
280 # *none* -- the far side of a shut valve, cut off from the network
281 # entirely. It has no equation, so leaving it among the unknowns
282 # makes the Jacobian singular; it is a stub whose live end lies
283 # across the branch that was removed.
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:
287 continue
288 if degree.get(node_id, 0) != 0:
289 continue
290 bridge = next(
291 (
292 b
293 for b in self.branches.values()
294 if b.id in removed and node_id in (b.upstream, b.downstream)
295 ),
296 None,
297 )
298 if bridge is not None:
299 live = (
300 bridge.downstream
301 if bridge.upstream == node_id
302 else bridge.upstream
303 )
304 found.append(DeadEnd(node=node_id, branch=bridge.id, live_end=live))
305 peeled = True
306 break
307 # No shut valve touches it, but it was not always alone: it is
308 # the last node of an *island* -- a run cut off by a shut valve
309 # further along (a dome solenoid, then a capped QD). Peeled from
310 # both ends, the stubs met here and left it with neither an
311 # equation nor a live end. Its live end is across the shut valve
312 # that cut the island off; walk the peeled stubs to find it.
313 deadend = self._island_exit(node_id, found, removed)
314 if deadend is not None:
315 found.append(deadend)
316 peeled = True
317 break
318
319 if not peeled:
320 return found
321
323 self, node_id: str, found: list[DeadEnd], removed: set[str]
324 ) -> DeadEnd | None:
325 """A stub entry for the last node of an island, or ``None``.
326
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.
334 """
335 peeled = {d.branch for d in found}
336 seen = {node_id}
337 queue = [node_id]
338 while queue:
339 here = queue.pop(0)
340 for branch in self.branches.values():
341 if here not in (branch.upstream, branch.downstream):
342 continue
343 other = (
344 branch.downstream if branch.upstream == here else branch.upstream
345 )
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:
349 seen.add(other)
350 queue.append(other)
351 incident = next(
352 (
353 b
354 for b in self.branches.values()
355 if b.id in peeled and node_id in (b.upstream, b.downstream)
356 ),
357 None,
358 )
359 if incident is None:
360 return None
361 other = (
362 incident.downstream if incident.upstream == node_id else incident.upstream
363 )
364 return DeadEnd(node=node_id, branch=incident.id, live_end=other)
365
366 def fluid(self, name: str) -> Fluid:
367 """A cached :class:`~feedtwin.props.Fluid`, one per species."""
368 cached = self._fluids.get(name)
369 if cached is None:
370 cached = Fluid(name)
371 self._fluids[name] = cached
372 return cached
373
375 self, node_id: str, pressure: float, signals: dict[str, float] | None = None
376 ) -> FlowConditions:
377 """Fluid conditions at a node, for evaluating a branch leaving it."""
378 from feedtwin.comps import conditions_from_fluid
379
380 node = self.nodes[node_id]
381 return conditions_from_fluid(
382 self.fluid(node.fluid),
383 pressure,
384 node.temperature,
385 signals,
386 multiphase=self.multiphase,
387 phase=node.phase,
388 gravity=self.gravity,
389 )
390
391 # ------------------------------------------------------------- validation
392
393 def validate(self) -> None:
394 """Every structural problem that can be found without solving.
395
396 Raises:
397 NetworkError: with all problems listed together, because fixing a
398 network six errors at a time beats six solve attempts.
399 """
400 problems: list[str] = []
401
402 if not self.branches:
403 problems.append(" the network has no branches")
404
405 if not self.fixed_nodes:
406 problems.append(
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."
410 )
411
412 touched = {b.upstream for b in self.branches.values()} | {
413 b.downstream for b in self.branches.values()
414 }
415 for node_id in sorted(set(self.nodes) - touched):
416 if self.nodes[node_id].pressure is not None:
417 # A vessel's port with nothing drawn on it -- a dewar's top whose
418 # only line is a relief with no set pressure -- is a boundary
419 # nobody reads: harmless, and not a reason to refuse the stand.
420 continue
421 problems.append(
422 f" node {node_id!r} has no branches attached; it cannot take part"
423 )
424
425 # Dead-ended stubs are deliberately NOT an error. A transducer port, a
426 # capped fill QD and a shut relief valve are all one-branch nodes with
427 # no demand, and all of them are correct hardware. They are peeled off
428 # by dead_ends() and back-filled exactly after the solve.
429
430 for node_id in sorted(self.nodes):
431 try:
432 self.fluid(self.nodes[node_id].fluid)
433 except Exception as exc:
434 problems.append(f" node {node_id!r}: {exc}")
435
436 if problems:
437 raise NetworkError("this network cannot be solved:\n" + "\n".join(problems))
438
439 def check(self) -> list[Violation]:
440 """Design limits broken by any component in the network.
441
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.
445 """
446 return [v for b in self.branches.values() for v in b.component.check()]
447
448 def __str__(self) -> str:
449 return (
450 f"Network({len(self.nodes)} nodes, {len(self.branches)} branches, "
451 f"{len(self.fixed_nodes)} fixed)"
452 )
Fluid fluid(self, str name)
Definition network.py:366
DeadEnd|None _island_exit(self, str node_id, list[DeadEnd] found, set[str] removed)
Definition network.py:324
list[tuple[Branch, int]] branches_at(self, str node_id)
Definition network.py:186
Node add_node(self, str id, str fluid, float temperature, *, float|None pressure=None, float demand=0.0)
Definition network.py:151
Branch add_branch(self, str id, HydraulicComponent component, str upstream, str downstream)
Definition network.py:160
FlowConditions conditions(self, str node_id, float pressure, dict[str, float]|None signals=None)
Definition network.py:376
list[DeadEnd] _peel(self, set[str] removed)
Definition network.py:246
set[str] isolated(self, Mapping[str, float]|None signals=None)
Definition network.py:196
list[str] free_nodes(self)
Definition network.py:178
list[DeadEnd] dead_ends(self, Iterable[str]|None exclude=None)
Definition network.py:209
list[Violation] check(self)
Definition network.py:439