STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
steady.py
Go to the documentation of this file.
1"""The steady network solve: pressure at every node, flow in every branch.
2
3The system is small, square and highly structured. Unknowns are one pressure per
4free node and one mass flow per branch; equations are one mass balance per free
5node and one pressure relation per branch::
6
7 node i : sum(flows in) - sum(flows out) - demand = 0
8 branch b : (p_up - p_dn) - dp(mdot) = 0
9
10Almost all of the Jacobian is constant. The mass-balance rows are the incidence
11matrix -- plus and minus ones that never change. The branch rows are +1 on the
12upstream pressure, -1 on the downstream, and a single nonlinear entry:
13``d(dp)/d(mdot)``, one scalar per branch. So a Newton step costs one derivative
14per component and a sparse solve, and nothing has to differentiate a correlation.
15
16Two things about this problem bite, and both are handled explicitly rather than
17hoped away:
18
19**Zero flow is a singular point.** Loss goes as roughly ``mdot^2``, so its
20derivative vanishes at zero and the branch row goes with it. A guess that starts
21at zero flow cannot take a first step. Solved by starting away from zero and by
22flooring the derivative -- see :data:`MIN_BRANCH_SLOPE`.
23
24**Newton overshoots.** An undamped step from a poor guess can send a pressure
25negative, at which point the property layer refuses and rightly so. Solved with
26backtracking: halve the step until the residual actually improves.
27"""
28
29from __future__ import annotations
30
31import math
32import time
33from dataclasses import dataclass, field
34from typing import Iterable, Mapping
35
36import numpy as np
37from scipy.sparse import csc_matrix
38from scipy.sparse.linalg import splu
39
40from feedtwin.comps import Violation
41from feedtwin.model.pressure import say
42from feedtwin.solve.network import DeadEnd, Network, NetworkError
43
44#: Floor on ``|d(dp)/d(mdot)|`` [Pa/(kg/s)] when building the Jacobian.
45#:
46#: A regularisation, and named as one. Quadratic losses have zero slope at zero
47#: flow, which makes that branch's row singular; without a floor a network
48#: containing a shut valve cannot take a Newton step. It biases nothing at the
49#: solution -- it only appears where the true slope is smaller than this, which
50#: is to say where the branch is carrying essentially nothing -- and
51#: :attr:`SteadyResult.regularised_branches` reports every branch where it was
52#: still active at convergence, so it can never be silently load-bearing.
53MIN_BRANCH_SLOPE = 1.0e-3
54
55#: Where every branch's flow starts when no guess is given [kg/s]. Non-zero on
56#: purpose (see the module docstring). A converged branch still exactly here
57#: never moved -- the session's watcher says so (``feedtwin.session.watch``).
58INITIAL_FLOW = 1.0e-3
59
60#: How closely every branch's relation must close against its own pressure
61#: (:func:`_local_scales`), whatever else is on the stand: a percent, so
62#: 0.15 psi on a branch at atmosphere. Not tighter: a gas charge into an
63#: empty COPV closes to about a tenth of a percent and stalls there.
64LOCAL_TOLERANCE = 1.0e-2
65#: The least pressure a branch is held against for that [Pa]: a bar.
66LOCAL_SCALE_FLOOR = 1.0e5
67
68#: Relative perturbation for the finite-difference derivative.
69_FD_REL = 1.0e-6
70_FD_ABS = 1.0e-9
71
72
73@dataclass(frozen=True, slots=True)
75 """What a converged (or failed) steady solve produced."""
76
77 pressures: dict[str, float]
78 """Pressure at every node [Pa], fixed nodes included."""
79
80 flows: dict[str, float]
81 """Mass flow in every branch [kg/s], positive upstream to downstream."""
82
83 converged: bool
84 iterations: int
85 residual_norm: float
86 """Infinity norm of the final residual, scaled by each row's own magnitude
87 so it is dimensionless. :attr:`mass_residuals` is the physical measure."""
88
89 mass_residuals: dict[str, float] = field(default_factory=dict)
90 """Net mass imbalance at each free node [kg/s]. Reported on every run,
91 never assumed: a solve that converged on a system that does not conserve
92 mass has found the wrong answer confidently."""
93
94 diagnostics: dict[str, dict[str, float]] = field(default_factory=dict)
95 """Per-branch derived quantities -- velocity, Reynolds number, regime."""
96
97 violations: list[Violation] = field(default_factory=list)
98 dead_ends: list[str] = field(default_factory=list)
99 """Branches carrying no flow by construction -- transducer ports, capped
100 fill lines, shut reliefs. Solved exactly rather than iterated, and listed
101 here so it is visible which parts of the network took no part."""
102
103 indeterminate_dead_ends: list[str] = field(default_factory=list)
104 """Stubs whose pressure is genuinely not defined at zero flow, because the
105 component still holds a drop there -- a check valve behind its cracking
106 pressure, or a regulator with nothing drawing through it. Their node
107 pressure is reported as the live end's and should be read as an upper
108 bound, not a solution."""
109
110 indeterminate_nodes: list[str] = field(default_factory=list)
111 """The *nodes* those stubs hang off, which is what a caller displaying a
112 transducer needs. Reported alongside the branch ids rather than instead of
113 them because the two are used for different things, and a caller that
114 reached for one and got the other silently showed the wrong pressure on
115 every trapped line."""
116
117 regularised_branches: list[str] = field(default_factory=list)
118 """Branches where the derivative floor was still active at convergence.
119 Usually means a branch carrying essentially no flow, which is worth knowing
120 and is exactly what a shut valve looks like."""
121
122 choked_branches: list[str] = field(default_factory=list)
123 """Branches solved as ``mdot = ceiling`` rather than ``dp = f(mdot)``,
124 because the available drop took them past choking. Reported because it
125 changes what the answer means: opening the downstream side further buys
126 nothing, and the pressure downstream of one of these is not set by this
127 branch at all."""
128
129 elapsed: float = 0.0
130 stack: dict[str, str | None] = field(default_factory=dict)
131 """Versions of the physics stack that produced this, for reproducibility."""
132
133 @property
134 def max_mass_residual(self) -> float:
135 return max((abs(v) for v in self.mass_residuals.values()), default=0.0)
136
137 def dp(self, branch_id: str, network: Network) -> float:
138 """Pressure drop across one branch [Pa], from the solved pressures."""
139 branch = network.branches[branch_id]
140 return self.pressures[branch.upstream] - self.pressures[branch.downstream]
141
142
143class ConvergenceError(RuntimeError):
144 """Newton did not converge, with what it was doing when it gave up."""
145
146 def __init__(self, result: SteadyResult, detail: str) -> None:
147 super().__init__(
148 f"the steady solve did not converge after {result.iterations} "
149 f"iterations (residual {result.residual_norm:.3e}): {detail}"
150 )
151 self.result = result
152
153
155 network: Network,
156 *,
157 tol: float = 1.0e-6,
158 max_iterations: int = 100,
159 initial_flow: float = INITIAL_FLOW,
160 raise_on_failure: bool = True,
161 signals: Mapping[str, float] | None = None,
162 guess: Mapping[str, float] | None = None,
163 isolate: Iterable[str] | None = None,
164 storage: Mapping[str, tuple[float, float]] | None = None,
165 report: bool = True,
166) -> SteadyResult:
167 """Solve a network for its steady operating point.
168
169 Args:
170 network: The system. Validated first.
171 tol: Convergence tolerance on the scaled residual.
172 max_iterations: Newton iteration cap.
173 initial_flow: Starting flow for every branch [kg/s]. Non-zero on
174 purpose -- see the module docstring.
175 raise_on_failure: Raise :class:`ConvergenceError` on failure. Set false
176 to inspect a failed result instead, which is what a sweep wants.
177 isolate: Extra branches to treat as open circuits, on top of the
178 valves that report themselves shut. For what a caller knows and the
179 network cannot -- an empty tank's outlet being the case.
180 guess: Starting point, as ``{node or branch id: pressure or flow}``.
181 A transient re-solves the same network a few thousand times at
182 states that barely differ, and starting each one from a cold
183 1e-3 kg/s throws that away -- it costs iterations, and on a stiff
184 network it can land the search in a different basin than the step
185 before, which reads as a convergence failure at one instant and
186 nowhere else. Unknown ids are ignored, so a guess from a slightly
187 different network is safe to pass.
188 signals: Control inputs -- ``{"MV-01.command": 0.4}`` for a part-open
189 valve. Every component that reads a signal sees the same mapping,
190 so a scenario's timeline reaches the physics without the solver
191 knowing what any particular signal means. Omitted, each component
192 falls back to its own default, which for a valve is fully open.
193 storage: Boundary nodes to close implicitly, as ``{node id:
194 (admittance [kg/s per Pa], reference pressure [Pa])}``. A vessel
195 behind a wide line is a boundary only for as long as the flow it
196 receives cannot move it; when it can, the step that holds it at
197 its start-of-step pressure is an explicit coupling, and a small
198 ullage behind a Cv 4 solenoid flip-flops against it. A node listed
199 here becomes an unknown with a storage term in its mass balance,
200 ``inflow - demand - admittance * (p - reference) = 0``: backward
201 Euler on the vessel, where ``reference`` is where it would land
202 with no flow and ``admittance`` is ``C / dt``. Its solved pressure
203 is the vessel's end-of-step pressure. Nodes not fixed, or with no
204 branch left carrying flow, are ignored; omitted, nothing changes.
205 report: Fill the result's per-branch report -- ``diagnostics``,
206 ``regularised_branches``, ``violations``, ``stack`` -- which takes
207 one more evaluation of every branch at the answer. A live session
208 reads none of it and solves a few hundred times a second of stand,
209 so it asks for none; pressures, flows and the mass residuals are
210 the same either way.
211
212 Returns:
213 A :class:`SteadyResult`, including mass-conservation residuals whether
214 or not it converged.
215 """
216 started = time.perf_counter()
217 network.validate()
218
219 # A valve commanded shut is an open circuit, not a very small orifice, so
220 # it leaves the unknowns entirely -- and taking it out is usually what makes
221 # the rest of its line a stub. See Network.isolated.
222 # Shut valves, plus anything the caller knows cannot flow. A live session
223 # adds the feed lines of a tank that has run dry: the network would happily
224 # keep pushing pressure-driven flow out of an empty vessel, because nothing
225 # in a pressure boundary knows there is nothing left behind it.
226 isolated = network.isolated(signals) | set(isolate or ())
227
228 # Stubs carry no flow by mass balance, so they are removed from the unknowns
229 # and back-filled exactly afterwards. Most of a real feed system is stubs.
230 stubs = network.dead_ends(exclude=isolated)
231 stub_nodes = {d.node for d in stubs}
232 stub_branches = {d.branch for d in stubs} | isolated
233
234 free = [n for n in network.free_nodes if n not in stub_nodes]
235 branch_ids = [b for b in network.branches if b not in stub_branches]
236 stores = _stores(network, storage, branch_ids)
237 free += list(stores)
238 node_index = {n: i for i, n in enumerate(free)}
239 branch_index = {b: len(free) + i for i, b in enumerate(branch_ids)}
240 n_unknowns = len(free) + len(branch_ids)
241
242 if n_unknowns == 0:
243 # Every branch is a stub or a shut valve, so nothing flows anywhere.
244 # That is not a degenerate network -- it is a stand sitting in Armed
245 # with the whole panel closed, which is where a stand spends most of its
246 # life. The answer is zero flow and back-filled pressures, and raising
247 # here made the most ordinary state on the pad an error.
248 return _result(
249 network,
250 np.zeros(0),
251 True,
252 0,
253 0.0,
254 started,
255 stubs,
256 isolated,
257 report=report,
258 signals=signals,
259 )
260
261 x = _initial_guess(network, free, branch_ids, initial_flow)
262 for node_id in stores:
263 # A stored node starts where its vessel is; a guess is for free nodes.
264 x[node_index[node_id]] = network.nodes[node_id].pressure or 0.0
265 if guess:
266 for node_id, row in node_index.items():
267 if node_id in stores:
268 continue
269 value = guess.get(node_id)
270 if value is not None and value > 0.0:
271 x[row] = value
272 for branch_id, row in branch_index.items():
273 value = guess.get(branch_id)
274 if value is not None:
275 x[row] = value
276 base_scale = _row_scales(network, free, branch_ids, initial_flow)
277 scale = base_scale
278
279 rows, cols = _incidence_pattern(network, node_index, branch_index, stores)
280
281 converged = False
282 residual = np.zeros(n_unknowns)
283 iterations = 0
284 unresolvable = np.zeros(n_unknowns, dtype=bool)
285
286 for iterations in range(1, max_iterations + 1):
287 # One pass builds both halves. A `_BranchRow` carries the residual *and*
288 # its derivatives precisely so that the finite differences behind them
289 # are taken once; computing the residual and the Jacobian in separate
290 # passes doubled the number of component evaluations, which on a
291 # five-branch transient was most of the run time.
292 try:
293 branch_rows = _branch_rows(network, x, node_index, branch_index, signals)
294 residual = _assemble_residual(
295 network, x, node_index, branch_index, branch_rows, stores
296 )
297 except Exception: # noqa: BLE001
298 # Same contract as the update below: a component refusing the point
299 # the last step landed on is a failed solve, not a crash. Guarding
300 # only the update left this evaluation as the one place an
301 # unreachable operating point could still escape a solve the caller
302 # asked not to raise -- and it is the place it actually escaped
303 # from, because the update's own backtracking hands back a
304 # last-resort step it never got to evaluate.
305 converged = False
306 break
307 scale = _local_scales(
308 network, x, node_index, branch_index, branch_rows, base_scale, tol
309 )
310 unresolvable[:] = False
311 for branch_id, branch_row in branch_index.items():
312 entry = branch_rows[branch_id]
313 if not entry.choked and abs(entry.d_mdot) < MIN_BRANCH_SLOPE:
314 unresolvable[branch_row] = True
315 norm = _scaled_norm(residual, scale, unresolvable)
316 if norm < tol:
317 # A residual can go to zero on a state that is not a pressure.
318 #
319 # Newton knows nothing about absolute zero; a stiff branch -- a
320 # regulator's reverse-flow stiffness, say -- can hand it a step that
321 # lands a node at a large negative pressure, and the residual there
322 # is perfectly small. Nothing downstream survives it: the back-fill
323 # asks the property layer for a fluid at -6428 bar and CoolProp is
324 # the thing that finally objects, several frames from the cause,
325 # after the solve has already been reported as converged.
326 #
327 # An absolute pressure at or below zero is not a solution, so this
328 # is a failed solve like any other and is reported as one.
329 if _physical(x, free):
330 converged = True
331 break
332 if not raise_on_failure:
333 converged = False
334 break
335 raise ConvergenceError(
336 _result(
337 network,
338 x,
339 False,
340 iterations,
341 norm,
342 started,
343 stubs,
344 isolated,
345 stores,
346 report=report,
347 signals=signals,
348 ),
349 "the residual converged on a state with a non-physical "
350 f"pressure ({say(min(x[: len(free)]), 1)}). A "
351 "branch is almost certainly stiff enough to have thrown the "
352 "step past zero -- check for reverse flow through a regulator "
353 "or a relief valve.",
354 )
355
356 jac = _jacobian(
357 network, node_index, branch_index, branch_rows, rows, cols, stores
358 )
359
360 try:
361 step = splu(jac).solve(-residual)
362 except (RuntimeError, ValueError) as exc:
363 # Same contract as the two guards around it: a caller that asked
364 # not to be raised at must not be raised at. A singular Jacobian is
365 # a failed solve like any other -- the live cockpit holds its last
366 # good answer through it and the next tick usually factorises fine,
367 # whereas an exception here kills the run at the instant it is
368 # meant to be showing something.
369 if not raise_on_failure:
370 converged = False
371 break
372 raise ConvergenceError(
373 _result(
374 network,
375 x,
376 False,
377 iterations,
378 norm,
379 started,
380 stubs,
381 isolated,
382 stores,
383 report=report,
384 signals=signals,
385 ),
386 f"the Jacobian could not be factorised ({exc}). This usually "
387 "means a node with no path to a fixed pressure.",
388 ) from exc
389
390 try:
391 x = _damped_update(
392 network,
393 x,
394 step,
395 residual,
396 scale,
397 node_index,
398 branch_index,
399 signals,
400 unresolvable,
401 stores,
402 )
403 except Exception: # noqa: BLE001 - see below
404 # A property call refusing mid-update is a failed solve, not a
405 # crash. raise_on_failure=False has to mean it: a live simulation
406 # calls this every tick and must be able to hold its last good
407 # answer through one hard instant rather than dying at it.
408 converged = False
409 break
410
411 norm = _scaled_norm(residual, scale, unresolvable)
412 result = _result(
413 network,
414 x,
415 converged,
416 iterations,
417 norm,
418 started,
419 stubs,
420 isolated,
421 stores,
422 report=report,
423 signals=signals,
424 )
425
426 if not converged and raise_on_failure:
427 raise ConvergenceError(
428 result,
429 "the residual stopped improving. Check that flow can actually reach "
430 "every node, and that no component is being asked for an operating "
431 "point it cannot reach.",
432 )
433 return result
434
435
436# ---------------------------------------------------------------------------
437# assembly
438# ---------------------------------------------------------------------------
439
440
442 network: Network,
443 storage: Mapping[str, tuple[float, float]] | None,
444 branch_ids: list[str],
445) -> dict[str, tuple[float, float]]:
446 """The ``storage`` entries that take part in this solve, in a fixed order.
447
448 A boundary node with a positive admittance and at least one branch still
449 carrying flow. One cut off by shut valves keeps its fixed pressure, so a
450 stand with everything shut solves exactly as it would without storage.
451 """
452 if not storage:
453 return {}
454 live = {
455 end
456 for b in branch_ids
457 for end in (network.branches[b].upstream, network.branches[b].downstream)
458 }
459 return {
460 node_id: (float(admittance), float(reference))
461 for node_id, (admittance, reference) in storage.items()
462 if node_id in network.nodes
463 and network.nodes[node_id].is_fixed
464 and node_id in live
465 and admittance > 0.0
466 and reference > 0.0
467 }
468
469
471 network: Network, free: list[str], branch_ids: list[str], initial_flow: float
472) -> np.ndarray:
473 """Pressures between the boundary values, flows small but non-zero.
474
475 Deliberately crude. A network solve is not very sensitive to where it starts
476 once the first step is possible, and the thing that makes a first step
477 impossible is zero flow rather than a poor pressure guess.
478 """
479 fixed = [network.nodes[n].pressure or 0.0 for n in network.fixed_nodes]
480 reference = float(np.mean(fixed)) if fixed else 1.0e5
481
482 x = np.empty(len(free) + len(branch_ids))
483 x[: len(free)] = reference
484 x[len(free) :] = initial_flow
485 return x
486
487
489 network: Network, free: list[str], branch_ids: list[str], initial_flow: float
490) -> np.ndarray:
491 """A magnitude for each equation, so one tolerance can judge them all.
492
493 The residual vector is in mixed units: mass balances are kg/s, branch
494 relations are Pa. Comparing both against a single absolute tolerance means
495 ``tol = 1e-6`` demands pressure to a micropascal -- about 1e-12 relative on
496 a 30 bar system, which is far below what a finite-difference Jacobian can
497 deliver, so the solve grinds without ever "converging".
498
499 Scaling by row does not touch the Newton step -- ``J^-1 F`` is invariant
500 under row scaling -- so this changes only the convergence test, and changes
501 it from arbitrary to meaningful.
502 """
503 pressures = [abs(network.nodes[n].pressure or 0.0) for n in network.fixed_nodes]
504 p_scale = max(pressures) if pressures else 1.0e5
505 demands = [abs(network.nodes[n].demand) for n in free]
506 m_scale = max([*demands, initial_flow, 1.0e-3])
507
508 return np.array([m_scale] * len(free) + [p_scale] * len(branch_ids))
509
510
512 network: Network,
513 x: np.ndarray,
514 node_index: dict[str, int],
515 branch_index: dict[str, int],
516 branch_rows: dict[str, _BranchRow],
517 base: np.ndarray,
518 tol: float,
519) -> np.ndarray:
520 """:func:`_row_scales`, with each branch also held against its own pressures.
521
522 One pressure scale for every branch -- the highest fixed pressure in the
523 network -- let a branch on a stand with a 6,000 psi K-bottle sit half a
524 psi off its relation and call it converged. A liquid drain driven by its
525 head (0.45 psi) then never left the solve's 1 g/s starting guess: the
526 first residual was already "small", so Newton took no step.
527
528 So a branch must also close to :data:`LOCAL_TOLERANCE` of the larger of
529 its two end pressures (never less than a bar). It only ever tightens, and
530 only where the stand's own scale is the looser: a tight solve is judged as
531 before, and a choked charge into an empty bottle -- which a finite-
532 difference Jacobian closes to about a tenth of a percent and no further --
533 is not asked for more than a percent. Nor is any choked row: its residual is a
534 flow error in pressure units by an arbitrary stiffness
535 (:data:`_CHOKE_STIFFNESS`), so a share of a pressure says nothing about it.
536 """
537 scale = base.copy()
538 for branch_id, row in branch_index.items():
539 if branch_rows[branch_id].choked:
540 continue
541 branch = network.branches[branch_id]
542 try:
543 up = _pressure_at(network, x, node_index, branch.upstream)
544 down = _pressure_at(network, x, node_index, branch.downstream)
545 except KeyError:
546 continue
547 local = max(abs(up), abs(down), LOCAL_SCALE_FLOOR)
548 scale[row] = min(base[row], LOCAL_TOLERANCE * local / tol)
549 return scale
550
551
552def _physical(x: np.ndarray, free: list[str]) -> bool:
553 """Are the node pressures in this iterate actually pressures?
554
555 Only the node block of the unknown vector: a *branch* flow is legitimately
556 negative, which is just flow the other way.
557 """
558 return bool(len(free) == 0 or np.all(x[: len(free)] > 0.0))
559
560
562 residual: np.ndarray,
563 scale: np.ndarray,
564 ignore: np.ndarray | None = None,
565) -> float:
566 """Worst row, scaled. Rows in ``ignore`` are excluded.
567
568 Exclusion is not leniency. A branch whose ``d(dp)/d(mdot)`` has hit
569 :data:`MIN_BRANCH_SLOPE` is one the Jacobian *cannot* resolve -- a fully
570 open isolation valve carrying zero flow because the regulator downstream is
571 holding has a genuinely flat row, and no step size moves it. Letting that
572 one row veto the whole solve means a correct answer, with mass closing to
573 1e-14, is reported as a failure.
574
575 Two things keep this from hiding a real fault: mass-balance rows are never
576 excluded, so conservation still has to hold everywhere; and every excluded
577 branch is listed in :attr:`SteadyResult.regularised_branches`, so a solve
578 standing on twenty of them says so.
579 """
580 if residual.size == 0:
581 return 0.0
582 scaled = np.abs(residual / scale)
583 if ignore is not None and ignore.any():
584 scaled = scaled[~ignore]
585 return float(np.max(scaled)) if scaled.size else 0.0
586
587
589 network: Network, x: np.ndarray, node_index: dict[str, int], node_id: str
590) -> float:
591 # The unknowns first: a stored boundary is one for the length of a solve.
592 row = node_index.get(node_id)
593 if row is not None:
594 return float(x[row])
595 pressure = network.nodes[node_id].pressure
596 if pressure is None:
597 raise KeyError(node_id)
598 return pressure
599
600
601@dataclass(frozen=True, slots=True)
603 """One branch's equation, and the derivatives the Jacobian needs.
604
605 Two forms, chosen per branch per iteration:
606
607 ``dp`` ``(p_up - p_dn) - dp(mdot) = 0``. The usual one.
608 ``choked`` ``K (mdot_ceiling - mdot) = 0``. Written when a component
609 reports that the available drop has taken it past choking, at
610 which point mass flow no longer depends on downstream pressure
611 and the dp form is not invertible. ``K`` carries the row into
612 pressure units so it is scaled like its neighbours.
613 """
614
615 residual: float
616 d_mdot: float
617 d_p_up: float
618 d_p_dn: float
619 choked: bool = False
620
621
622#: Stiffness [Pa/(kg/s)] converting a choked branch's flow equation into the
623#: pressure units the rest of the residual vector is in. Any positive value
624#: gives the same root; this one keeps the row's magnitude comparable to a
625#: pressure row so the shared convergence test stays meaningful.
626_CHOKE_STIFFNESS = 1.0e8
627
628
630 network: Network,
631 branch_id: str,
632 mdot: float,
633 p_up: float,
634 p_dn: float,
635 signals: Mapping[str, float] | None = None,
636) -> _BranchRow:
637 """Assemble one branch's equation, in whichever form applies.
638
639 A symmetric component (``component.symmetric``) is priced at the node the
640 fluid actually comes from: when the drawn downstream is the higher
641 pressure, the conditions are the downstream node's and the choke test reads
642 the drop the other way. It used to be priced at the drawn upstream whatever
643 the gradient, and a reverse drop never choked: a valve drawn against its
644 flow passed 3-8x less than the same valve drawn with it, both converged.
645 One-way laws (check valve, regulator, relief, injector) keep the drawing's
646 direction.
647 """
648 branch = network.branches[branch_id]
649 component = branch.component
650 available = p_up - p_dn
651 reverse = available < 0.0 and component.symmetric
652 if reverse:
653 conditions = network.conditions(branch.downstream, p_dn, dict(signals or {}))
654 else:
655 conditions = network.conditions(branch.upstream, p_up, dict(signals or {}))
656 # The drop from the inlet the component is priced at, and that inlet.
657 drive, p_in = (-available, p_dn) if reverse else (available, p_up)
658 ceiling = component.flow_ceiling(conditions)
659
660 def choked_row(limit: float | None = None) -> _BranchRow:
661 # Choked: the flow is pinned and downstream pressure is decoupled. The
662 # ceiling is proportional to the inlet pressure, which is the only
663 # pressure this row depends on at all. Reverse, the pin is negative and
664 # the inlet is the drawn downstream.
665 pinned = ceiling if limit is None else limit
666 assert pinned is not None
667 slope = _CHOKE_STIFFNESS * (pinned / p_in if p_in > 0.0 else 0.0)
668 if reverse:
669 return _BranchRow(
670 residual=_CHOKE_STIFFNESS * (-pinned - mdot),
671 d_mdot=-_CHOKE_STIFFNESS,
672 d_p_up=0.0,
673 d_p_dn=-slope,
674 choked=True,
675 )
676 return _BranchRow(
677 residual=_CHOKE_STIFFNESS * (pinned - mdot),
678 d_mdot=-_CHOKE_STIFFNESS,
679 d_p_up=slope,
680 d_p_dn=0.0,
681 choked=True,
682 )
683
684 # A component that pins its own flow at this drop (the opt-in compressible
685 # regulator seat, choked wide open). None for everything else.
686 pinned = component.pinned_flow(drive, conditions)
687 if pinned is not None:
688 return choked_row(pinned)
689
690 # The component reads the pressures and says the drop has carried it past
691 # its critical ratio (or, for a regulator, that it has shut -- ceiling 0).
692 if ceiling is not None and component.is_choked(drive, conditions):
693 return choked_row()
694
695 h = max(abs(mdot) * _FD_REL, _FD_ABS)
696 try:
697 hi = component.total_dp(mdot + h, conditions)
698 lo = component.total_dp(mdot - h, conditions)
699 slope = (hi - lo) / (2.0 * h)
700 drop = component.total_dp(mdot, conditions)
701 except Exception:
702 # The component cannot price this flow -- a gas orifice asked to pass
703 # more than sonic. If the gradient is forward, that *is* choking and
704 # the pinned row is the honest equation. If it is not, the iterate is
705 # simply wrong-signed and the row must stay invertible so Newton can
706 # turn it round; the flow is priced just under the ceiling to get a
707 # slope out of the component at all.
708 #
709 # This used to pin the row whenever the *flow guess* reached the
710 # ceiling, gradient or no gradient. On a helium press manifold that
711 # let a solenoid carry sonic flow *up* a 16 psi gradient into a tank
712 # already above its regulator's setpoint -- converged, because a pinned
713 # row does not know what downstream is doing -- and two such
714 # sub-steps put 26 psi into a three-gram ullage. Repeated, the tank
715 # ratcheted to 745 psig against a 550 psig regulator.
716 if ceiling is not None and ceiling > 0.0 and drive > 0.0:
717 return choked_row()
718 if ceiling is None or ceiling <= 0.0:
719 raise
720 clipped = math.copysign(0.999 * ceiling, mdot if mdot != 0.0 else 1.0)
721 h = max(abs(clipped) * _FD_REL, _FD_ABS)
722 hi = component.total_dp(clipped + h, conditions)
723 lo = component.total_dp(clipped - h, conditions)
724 slope = (hi - lo) / (2.0 * h)
725 drop = component.total_dp(clipped, conditions)
726
727 return _BranchRow(
728 residual=available - drop,
729 d_mdot=-slope,
730 d_p_up=1.0,
731 d_p_dn=-1.0,
732 )
733
734
736 network: Network,
737 x: np.ndarray,
738 node_index: dict[str, int],
739 branch_index: dict[str, int],
740 signals: Mapping[str, float] | None = None,
741) -> dict[str, _BranchRow]:
742 rows: dict[str, _BranchRow] = {}
743 for branch_id, row in branch_index.items():
744 branch = network.branches[branch_id]
745 rows[branch_id] = _branch_row(
746 network,
747 branch_id,
748 float(x[row]),
749 _pressure_at(network, x, node_index, branch.upstream),
750 _pressure_at(network, x, node_index, branch.downstream),
751 signals,
752 )
753 return rows
754
755
757 network: Network,
758 x: np.ndarray,
759 node_index: dict[str, int],
760 branch_index: dict[str, int],
761 rows: dict[str, _BranchRow],
762 stores: Mapping[str, tuple[float, float]] | None = None,
763) -> np.ndarray:
764 """Mass balances plus already-built branch rows. No component evaluation."""
765 residual = np.zeros(len(x))
766 _mass_rows(network, x, node_index, branch_index, stores, residual)
767
768 for branch_id, row in branch_index.items():
769 residual[row] = rows[branch_id].residual
770
771 return residual
772
773
775 network: Network,
776 x: np.ndarray,
777 node_index: dict[str, int],
778 branch_index: dict[str, int],
779 signals: Mapping[str, float] | None = None,
780 stores: Mapping[str, tuple[float, float]] | None = None,
781) -> np.ndarray:
782 """Residual from scratch. Used where the derivatives are not wanted."""
783 residual = np.zeros(len(x))
784 _mass_rows(network, x, node_index, branch_index, stores, residual)
785
786 rows = _branch_rows(network, x, node_index, branch_index, signals)
787 for branch_id, row in branch_index.items():
788 residual[row] = rows[branch_id].residual
789
790 return residual
791
792
794 network: Network,
795 x: np.ndarray,
796 node_index: dict[str, int],
797 branch_index: dict[str, int],
798 stores: Mapping[str, tuple[float, float]] | None,
799 residual: np.ndarray,
800) -> None:
801 """Fill the mass-balance rows of ``residual``: inflow minus demand, minus
802 what a stored node keeps (``admittance * (p - reference)``)."""
803 for node_id, row in node_index.items():
804 total = -network.nodes[node_id].demand
805 for branch, sign in network.branches_at(node_id):
806 # Pruned stubs carry zero flow and contribute nothing to a balance.
807 if branch.id in branch_index:
808 total += sign * x[branch_index[branch.id]]
809 if stores and node_id in stores:
810 admittance, reference = stores[node_id]
811 total -= admittance * (x[row] - reference)
812 residual[row] = total
813
814
816 network: Network,
817 node_index: dict[str, int],
818 branch_index: dict[str, int],
819 stores: Mapping[str, tuple[float, float]] | None = None,
820) -> tuple[list[int], list[int]]:
821 """Row and column indices of every structurally non-zero Jacobian entry.
822
823 Computed once. The sparsity of a hydraulic network never changes during a
824 solve -- only the values in it do.
825 """
826 rows: list[int] = []
827 cols: list[int] = []
828
829 for node_id, row in node_index.items():
830 for branch, _sign in network.branches_at(node_id):
831 if branch.id not in branch_index:
832 continue
833 rows.append(row)
834 cols.append(branch_index[branch.id])
835
836 for branch_id, row in branch_index.items():
837 branch = network.branches[branch_id]
838 for end in (branch.upstream, branch.downstream):
839 if end in node_index:
840 rows.append(row)
841 cols.append(node_index[end])
842 rows.append(row)
843 cols.append(row)
844
845 # A stored node's balance depends on its own pressure.
846 for node_id in stores or ():
847 rows.append(node_index[node_id])
848 cols.append(node_index[node_id])
849
850 return rows, cols
851
852
854 network: Network,
855 node_index: dict[str, int],
856 branch_index: dict[str, int],
857 branch_rows: dict[str, _BranchRow],
858 rows: list[int],
859 cols: list[int],
860 stores: Mapping[str, tuple[float, float]] | None = None,
861) -> csc_matrix:
862 values: list[float] = []
863
864 for node_id in node_index:
865 for branch, sign in network.branches_at(node_id):
866 if branch.id not in branch_index:
867 continue
868 values.append(float(sign))
869
870 for branch_id in branch_index:
871 branch = network.branches[branch_id]
872 row = branch_rows[branch_id]
873 for end, derivative in (
874 (branch.upstream, row.d_p_up),
875 (branch.downstream, row.d_p_dn),
876 ):
877 if end in node_index:
878 values.append(derivative)
879 slope = row.d_mdot
880 if abs(slope) < MIN_BRANCH_SLOPE:
881 slope = -MIN_BRANCH_SLOPE if slope <= 0.0 else MIN_BRANCH_SLOPE
882 values.append(slope)
883
884 for admittance, _reference in (stores or {}).values():
885 values.append(-admittance)
886
887 n = len(node_index) + len(branch_index)
888 return csc_matrix((values, (rows, cols)), shape=(n, n))
889
890
892 network: Network,
893 x: np.ndarray,
894 step: np.ndarray,
895 residual: np.ndarray,
896 scale: np.ndarray,
897 node_index: dict[str, int],
898 branch_index: dict[str, int],
899 signals: Mapping[str, float] | None = None,
900 ignore: np.ndarray | None = None,
901 stores: Mapping[str, tuple[float, float]] | None = None,
902) -> np.ndarray:
903 """Take the largest fraction of the Newton step that improves things.
904
905 An undamped step from a crude guess routinely sends a pressure negative, at
906 which point the property layer refuses -- correctly, but the solve should
907 back off rather than fall over. Backtracking also handles the case where
908 the step is simply too long, which quadratic losses make common.
909
910 ``ignore`` must be the *same* mask the convergence test uses. Measuring
911 improvement against a norm that includes a row nothing can improve makes
912 every step look like a failure: the search backtracks to almost nothing,
913 the iteration crawls, and the solve stops just above whatever tolerance was
914 asked for -- tracking it exactly, which is the tell.
915 """
916 reference = _scaled_norm(residual, scale, ignore)
917 fraction = 1.0
918
919 for _ in range(20):
920 trial = x + fraction * step
921 if np.all(trial[: len(node_index)] > 0.0):
922 try:
923 trial_residual = _residuals(
924 network, trial, node_index, branch_index, signals, stores
925 )
926 except Exception:
927 trial_residual = None
928 if trial_residual is not None:
929 if _scaled_norm(trial_residual, scale, ignore) < reference:
930 return trial
931 fraction *= 0.5
932
933 # Nothing improved. Take a small step anyway rather than stalling; the
934 # iteration cap is what ultimately stops a solve that is going nowhere.
935 #
936 # Clamped to keep every pressure positive, which the backtracking above
937 # already does and this used to skip. An unclamped last-resort step is rare
938 # and, when it fires, catastrophic: it lands a node below absolute zero, the
939 # property layer refuses on the *next* iteration, and the exception escapes
940 # a solve the caller asked not to raise. A near-vacuum boundary -- a tank
941 # sitting at atmosphere while a 4500 psi bottle is opened onto it -- hits it
942 # every time.
943 trial = x + 0.01 * step
944 pressures = trial[: len(node_index)]
945 floor = np.maximum(x[: len(node_index)] * 0.01, 1.0)
946 trial[: len(node_index)] = np.where(pressures > 0.0, pressures, floor)
947 return trial
948
949
951 network: Network,
952 x: np.ndarray,
953 converged: bool,
954 iterations: int,
955 norm: float,
956 started: float,
957 stubs: list[DeadEnd],
958 isolated: set[str],
959 stores: Mapping[str, tuple[float, float]] | None = None,
960 *,
961 report: bool = True,
962 signals: Mapping[str, float] | None = None,
963) -> SteadyResult:
964 """Package a solution vector as a result.
965
966 ``signals`` must be the solve's own: the report re-evaluates every branch
967 at the answer, and without them a dome-loaded regulator reported its drawn
968 setpoint and a part-open valve reported itself wide open.
969
970 ``isolated`` is not optional and must be the same set the solve used. This
971 function rebuilds the branch ordering to read ``x`` back, and that ordering
972 is ``[b for b in branches if b not in stubs | isolated]`` -- so a caller
973 that omits it gets a *longer* list than the vector it is indexing into.
974 The failure is an IndexError on a good day and silently misattributed flows
975 on a bad one, where every branch after the first isolated valve reports its
976 neighbour's flow. It defaulted to empty until the convergence-failure path,
977 which is the one place that omitted it, started raising on a stand with a
978 shut valve on it.
979 """
980 from feedtwin import stack_versions
981
982 stub_nodes = {d.node for d in stubs}
983 stub_branches = {d.branch for d in stubs} | isolated
984 free = [n for n in network.free_nodes if n not in stub_nodes]
985 free += list(stores or ())
986 solved = [b for b in network.branches if b not in stub_branches]
987
988 node_index = {n: i for i, n in enumerate(free)}
989 branch_index = {b: len(free) + i for i, b in enumerate(solved)}
990
991 pressures = {
992 node_id: _pressure_at(network, x, node_index, node_id)
993 for node_id in network.nodes
994 if node_id not in stub_nodes
995 }
996 flows = {b: float(x[i]) for b, i in branch_index.items()}
997 # A shut valve passes nothing. Stated here rather than left to the
998 # back-fill, because an isolated branch is not always a stub -- both its
999 # ends can be live parts of the network with no path between them.
1000 for branch_id in isolated:
1001 flows[branch_id] = 0.0
1002
1003 # Back-fill the stubs. Zero flow, so the only pressure change across one is
1004 # its static head.
1005 #
1006 # Repeated passes rather than one reversed sweep. Peeling is outermost-first
1007 # for the ordinary degree-one case, but a node cut off entirely by a shut
1008 # valve is found in a later pass and can be the live end of something found
1009 # earlier -- so the single ordering that used to work no longer exists.
1010 # Looping until nothing more can be filled is order-independent and stops
1011 # in as many passes as the longest chain.
1012 indeterminate: list[str] = []
1013 indeterminate_nodes: list[str] = []
1014 pending = list(stubs)
1015 while pending:
1016 progressed = False
1017 deferred: list[DeadEnd] = []
1018 for dead in pending:
1019 live_p = pressures.get(dead.live_end)
1020 if live_p is None:
1021 deferred.append(dead)
1022 continue
1023 if live_p <= 0.0:
1024 # The live end is not at a pressure, so there is no fluid state
1025 # to ask about and nothing sensible to hang off it. This is only
1026 # reachable while building the *result of a failed solve* --
1027 # `converged` is already False by the time we get here -- and
1028 # the failure should surface as "did not converge", not as
1029 # CoolProp objecting to -6428 bar several frames away from the
1030 # branch that caused it.
1031 pressures[dead.node] = live_p
1032 flows[dead.branch] = 0.0
1033 progressed = True
1034 continue
1035 branch = network.branches[dead.branch]
1036 conditions = network.conditions(dead.live_end, live_p)
1037 head = branch.component.static_head(conditions)
1038 pressures[dead.node] = (
1039 live_p - head if branch.upstream == dead.live_end else live_p + head
1040 )
1041 flows[dead.branch] = 0.0
1042 # A component that still holds a drop at zero flow -- a check valve
1043 # behind its cracking pressure -- leaves the stub genuinely
1044 # undefined. Report the live end's pressure and say it is an upper
1045 # bound.
1046 #
1047 # So does a shut valve. A stub reached only across one (a leg
1048 # between two shut valves, a capped line behind one) holds whatever
1049 # was trapped in it, not the pressure across the seat -- and a ball
1050 # valve's drop at zero flow is identically zero, so the test above
1051 # never caught it: a transducer there followed the tank.
1052 try:
1053 holds = branch.component.pressure_drop(0.0, conditions) > 0.0
1054 except Exception: # noqa: BLE001 - the back-fill must not fail the solve
1055 # A component that cannot price zero flow cannot say the stub
1056 # is defined either; a measured curve off its range raised out
1057 # of a solve asked not to raise from here.
1058 holds = True
1059 if holds or dead.branch in isolated or dead.live_end in indeterminate_nodes:
1060 # Undefined-ness travels. A regulator with nothing drawing
1061 # through it leaves its outlet undefined; the manifold that
1062 # outlet feeds is then no better known, and reporting *it* as
1063 # solved put bottle pressure on a transducer tagged for the
1064 # regulated line. Stubs are filled in dependency order, so
1065 # checking the live end here is enough to carry it along.
1066 indeterminate.append(dead.branch)
1067 indeterminate_nodes.append(dead.node)
1068 progressed = True
1069 if not progressed:
1070 # A closed loop of stubs, which cannot happen for a drawn system but
1071 # would spin here forever if it did. Give them the reference
1072 # pressure and mark them undefined rather than hang.
1073 for dead in deferred:
1074 pressures.setdefault(dead.node, 101325.0)
1075 flows[dead.branch] = 0.0
1076 indeterminate.append(dead.branch)
1077 indeterminate_nodes.append(dead.node)
1078 break
1079 pending = deferred
1080
1081 # One pass over the branches, in their order, so each node's sum is taken
1082 # in the order `branches_at` gives -- the same number, without walking
1083 # every branch once per node.
1084 totals = {node_id: -network.nodes[node_id].demand for node_id in free}
1085 for branch in network.branches.values():
1086 flow = flows.get(branch.id, 0.0)
1087 if branch.downstream in totals:
1088 totals[branch.downstream] += flow
1089 if branch.upstream in totals:
1090 totals[branch.upstream] -= flow
1091 mass_residuals: dict[str, float] = {}
1092 for node_id in free:
1093 total = totals[node_id]
1094 if stores and node_id in stores:
1095 admittance, reference = stores[node_id]
1096 total -= admittance * (pressures[node_id] - reference)
1097 mass_residuals[node_id] = total
1098
1099 diagnostics: dict[str, dict[str, float]] = {}
1100 regularised: list[str] = []
1101 choked: list[str] = []
1102 if not report:
1103 return SteadyResult(
1104 pressures=pressures,
1105 flows=flows,
1106 converged=converged,
1107 iterations=iterations,
1108 residual_norm=norm,
1109 mass_residuals=mass_residuals,
1110 dead_ends=sorted(stub_branches),
1111 indeterminate_dead_ends=sorted(indeterminate),
1112 indeterminate_nodes=sorted(indeterminate_nodes),
1113 elapsed=time.perf_counter() - started,
1114 )
1115 try:
1116 branch_rows = _branch_rows(network, x, node_index, branch_index, signals)
1117 except Exception: # noqa: BLE001 - the report must not fail the solve
1118 # Classifying branches (regularised, choked) re-evaluates every one at
1119 # the answer. A correlation refusing there is a reporting gap, not a
1120 # failed solve: before this guard it escaped a solve asked not to raise.
1121 branch_rows = {}
1122 for branch_id, branch in network.branches.items():
1123 conditions = network.conditions(
1124 branch.upstream,
1125 pressures.get(branch.upstream, 0.0) or 1.0e5,
1126 dict(signals or {}),
1127 )
1128 try:
1129 diagnostics[branch_id] = branch.component.diagnostics(
1130 flows[branch_id], conditions
1131 )
1132 except Exception:
1133 diagnostics[branch_id] = {}
1134 if branch_id in branch_rows:
1135 row = branch_rows[branch_id]
1136 if row.choked:
1137 choked.append(branch_id)
1138 elif abs(row.d_mdot) < MIN_BRANCH_SLOPE:
1139 regularised.append(branch_id)
1140
1141 return SteadyResult(
1142 pressures=pressures,
1143 flows=flows,
1144 converged=converged,
1145 iterations=iterations,
1146 residual_norm=norm,
1147 mass_residuals=mass_residuals,
1148 dead_ends=sorted(stub_branches),
1149 indeterminate_dead_ends=sorted(indeterminate),
1150 indeterminate_nodes=sorted(indeterminate_nodes),
1151 diagnostics=diagnostics,
1152 violations=network.check(),
1153 regularised_branches=sorted(regularised),
1154 choked_branches=sorted(choked),
1155 elapsed=time.perf_counter() - started,
1156 stack=stack_versions(),
1157 )
#define min(a, b)
Definition Arduino.h:42
#define max(a, b)
Definition Arduino.h:43
None __init__(self, SteadyResult result, str detail)
Definition steady.py:146
float dp(self, str branch_id, Network network)
Definition steady.py:137
dict[str, _BranchRow] _branch_rows(Network network, np.ndarray x, dict[str, int] node_index, dict[str, int] branch_index, Mapping[str, float]|None signals=None)
Definition steady.py:741
None _mass_rows(Network network, np.ndarray x, dict[str, int] node_index, dict[str, int] branch_index, Mapping[str, tuple[float, float]]|None stores, np.ndarray residual)
Definition steady.py:800
np.ndarray _initial_guess(Network network, list[str] free, list[str] branch_ids, float initial_flow)
Definition steady.py:472
np.ndarray _residuals(Network network, np.ndarray x, dict[str, int] node_index, dict[str, int] branch_index, Mapping[str, float]|None signals=None, Mapping[str, tuple[float, float]]|None stores=None)
Definition steady.py:781
tuple[list[int], list[int]] _incidence_pattern(Network network, dict[str, int] node_index, dict[str, int] branch_index, Mapping[str, tuple[float, float]]|None stores=None)
Definition steady.py:820
_BranchRow _branch_row(Network network, str branch_id, float mdot, float p_up, float p_dn, Mapping[str, float]|None signals=None)
Definition steady.py:636
SteadyResult solve_steady(Network network, *, float tol=1.0e-6, int max_iterations=100, float initial_flow=INITIAL_FLOW, bool raise_on_failure=True, Mapping[str, float]|None signals=None, Mapping[str, float]|None guess=None, Iterable[str]|None isolate=None, Mapping[str, tuple[float, float]]|None storage=None, bool report=True)
Definition steady.py:166
csc_matrix _jacobian(Network network, dict[str, int] node_index, dict[str, int] branch_index, dict[str, _BranchRow] branch_rows, list[int] rows, list[int] cols, Mapping[str, tuple[float, float]]|None stores=None)
Definition steady.py:861
np.ndarray _local_scales(Network network, np.ndarray x, dict[str, int] node_index, dict[str, int] branch_index, dict[str, _BranchRow] branch_rows, np.ndarray base, float tol)
Definition steady.py:519
float _pressure_at(Network network, np.ndarray x, dict[str, int] node_index, str node_id)
Definition steady.py:590
np.ndarray _row_scales(Network network, list[str] free, list[str] branch_ids, float initial_flow)
Definition steady.py:490
np.ndarray _assemble_residual(Network network, np.ndarray x, dict[str, int] node_index, dict[str, int] branch_index, dict[str, _BranchRow] rows, Mapping[str, tuple[float, float]]|None stores=None)
Definition steady.py:763
np.ndarray _damped_update(Network network, np.ndarray x, np.ndarray step, np.ndarray residual, np.ndarray scale, dict[str, int] node_index, dict[str, int] branch_index, Mapping[str, float]|None signals=None, np.ndarray|None ignore=None, Mapping[str, tuple[float, float]]|None stores=None)
Definition steady.py:902
dict[str, tuple[float, float]] _stores(Network network, Mapping[str, tuple[float, float]]|None storage, list[str] branch_ids)
Definition steady.py:445
float _scaled_norm(np.ndarray residual, np.ndarray scale, np.ndarray|None ignore=None)
Definition steady.py:565
SteadyResult _result(Network network, np.ndarray x, bool converged, int iterations, float norm, float started, list[DeadEnd] stubs, set[str] isolated, Mapping[str, tuple[float, float]]|None stores=None, *, bool report=True, Mapping[str, float]|None signals=None)
Definition steady.py:963
bool _physical(np.ndarray x, list[str] free)
Definition steady.py:552