1"""The steady network solve: pressure at every node, flow in every branch.
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::
7 node i : sum(flows in) - sum(flows out) - demand = 0
8 branch b : (p_up - p_dn) - dp(mdot) = 0
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.
16Two things about this problem bite, and both are handled explicitly rather than
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`.
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.
29from __future__
import annotations
33from dataclasses
import dataclass, field
34from typing
import Iterable, Mapping
37from scipy.sparse
import csc_matrix
38from scipy.sparse.linalg
import splu
53MIN_BRANCH_SLOPE = 1.0e-3
64LOCAL_TOLERANCE = 1.0e-2
66LOCAL_SCALE_FLOOR = 1.0e5
73@dataclass(frozen=True, slots=True)
75 """What a converged (or failed) steady solve produced."""
77 pressures: dict[str, float]
78 """Pressure at every node [Pa], fixed nodes included."""
80 flows: dict[str, float]
81 """Mass flow in every branch [kg/s], positive upstream to downstream."""
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."""
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."""
94 diagnostics: dict[str, dict[str, float]] = field(default_factory=dict)
95 """Per-branch derived quantities -- velocity, Reynolds number, regime."""
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."""
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."""
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."""
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."""
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
130 stack: dict[str, str |
None] = field(default_factory=dict)
131 """Versions of the physics stack that produced this, for reproducibility."""
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]
144 """Newton did not converge, with what it was doing when it gave up."""
146 def __init__(self, result: SteadyResult, detail: str) ->
None:
148 f
"the steady solve did not converge after {result.iterations} "
149 f
"iterations (residual {result.residual_norm:.3e}): {detail}"
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,
167 """Solve a network for its steady operating point.
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
213 A :class:`SteadyResult`, including mass-conservation residuals whether
216 started = time.perf_counter()
226 isolated = network.isolated(signals) | set(isolate
or ())
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
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)
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)
262 for node_id
in stores:
264 x[node_index[node_id]] = network.nodes[node_id].pressure
or 0.0
266 for node_id, row
in node_index.items():
267 if node_id
in stores:
269 value = guess.get(node_id)
270 if value
is not None and value > 0.0:
272 for branch_id, row
in branch_index.items():
273 value = guess.get(branch_id)
274 if value
is not None:
276 base_scale =
_row_scales(network, free, branch_ids, initial_flow)
282 residual = np.zeros(n_unknowns)
284 unresolvable = np.zeros(n_unknowns, dtype=bool)
286 for iterations
in range(1, max_iterations + 1):
293 branch_rows =
_branch_rows(network, x, node_index, branch_index, signals)
295 network, x, node_index, branch_index, branch_rows, stores
308 network, x, node_index, branch_index, branch_rows, base_scale, tol
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
332 if not raise_on_failure:
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.",
357 network, node_index, branch_index, branch_rows, rows, cols, stores
361 step = splu(jac).solve(-residual)
362 except (RuntimeError, ValueError)
as exc:
369 if not raise_on_failure:
386 f
"the Jacobian could not be factorised ({exc}). This usually "
387 "means a node with no path to a fixed pressure.",
426 if not converged
and raise_on_failure:
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.",
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.
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.
457 for end
in (network.branches[b].upstream, network.branches[b].downstream)
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
471 network: Network, free: list[str], branch_ids: list[str], initial_flow: float
473 """Pressures between the boundary values, flows small but non-zero.
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.
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
482 x = np.empty(len(free) + len(branch_ids))
483 x[: len(free)] = reference
484 x[len(free) :] = initial_flow
489 network: Network, free: list[str], branch_ids: list[str], initial_flow: float
491 """A magnitude for each equation, so one tolerance can judge them all.
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".
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.
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])
508 return np.array([m_scale] * len(free) + [p_scale] * len(branch_ids))
514 node_index: dict[str, int],
515 branch_index: dict[str, int],
516 branch_rows: dict[str, _BranchRow],
520 """:func:`_row_scales`, with each branch also held against its own pressures.
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.
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.
538 for branch_id, row
in branch_index.items():
539 if branch_rows[branch_id].choked:
541 branch = network.branches[branch_id]
543 up =
_pressure_at(network, x, node_index, branch.upstream)
544 down =
_pressure_at(network, x, node_index, branch.downstream)
547 local =
max(abs(up), abs(down), LOCAL_SCALE_FLOOR)
548 scale[row] =
min(base[row], LOCAL_TOLERANCE * local / tol)
553 """Are the node pressures in this iterate actually pressures?
555 Only the node block of the unknown vector: a *branch* flow is legitimately
556 negative, which is just flow the other way.
558 return bool(len(free) == 0
or np.all(x[: len(free)] > 0.0))
562 residual: np.ndarray,
564 ignore: np.ndarray |
None =
None,
566 """Worst row, scaled. Rows in ``ignore`` are excluded.
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.
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.
580 if residual.size == 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
589 network: Network, x: np.ndarray, node_index: dict[str, int], node_id: str
592 row = node_index.get(node_id)
595 pressure = network.nodes[node_id].pressure
597 raise KeyError(node_id)
601@dataclass(frozen=True, slots=True)
603 """One branch's equation, and the derivatives the Jacobian needs.
605 Two forms, chosen per branch per iteration:
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.
626_CHOKE_STIFFNESS = 1.0e8
635 signals: Mapping[str, float] |
None =
None,
637 """Assemble one branch's equation, in whichever form applies.
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
648 branch = network.branches[branch_id]
649 component = branch.component
650 available = p_up - p_dn
651 reverse = available < 0.0
and component.symmetric
653 conditions = network.conditions(branch.downstream, p_dn, dict(signals
or {}))
655 conditions = network.conditions(branch.upstream, p_up, dict(signals
or {}))
657 drive, p_in = (-available, p_dn)
if reverse
else (available, p_up)
658 ceiling = component.flow_ceiling(conditions)
660 def choked_row(limit: float |
None =
None) -> _BranchRow:
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)
670 residual=_CHOKE_STIFFNESS * (-pinned - mdot),
671 d_mdot=-_CHOKE_STIFFNESS,
677 residual=_CHOKE_STIFFNESS * (pinned - mdot),
678 d_mdot=-_CHOKE_STIFFNESS,
686 pinned = component.pinned_flow(drive, conditions)
687 if pinned
is not None:
688 return choked_row(pinned)
692 if ceiling
is not None and component.is_choked(drive, conditions):
695 h =
max(abs(mdot) * _FD_REL, _FD_ABS)
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)
716 if ceiling
is not None and ceiling > 0.0
and drive > 0.0:
718 if ceiling
is None or ceiling <= 0.0:
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)
728 residual=available - drop,
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]
750 _pressure_at(network, x, node_index, branch.downstream),
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,
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)
768 for branch_id, row
in branch_index.items():
769 residual[row] = rows[branch_id].residual
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,
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)
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
796 node_index: dict[str, int],
797 branch_index: dict[str, int],
798 stores: Mapping[str, tuple[float, float]] |
None,
799 residual: np.ndarray,
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):
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
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.
823 Computed once. The sparsity of a hydraulic network never changes during a
824 solve -- only the values in it do.
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:
834 cols.append(branch_index[branch.id])
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:
841 cols.append(node_index[end])
846 for node_id
in stores
or ():
847 rows.append(node_index[node_id])
848 cols.append(node_index[node_id])
855 node_index: dict[str, int],
856 branch_index: dict[str, int],
857 branch_rows: dict[str, _BranchRow],
860 stores: Mapping[str, tuple[float, float]] |
None =
None,
862 values: list[float] = []
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:
868 values.append(float(sign))
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),
877 if end
in node_index:
878 values.append(derivative)
880 if abs(slope) < MIN_BRANCH_SLOPE:
881 slope = -MIN_BRANCH_SLOPE
if slope <= 0.0
else MIN_BRANCH_SLOPE
884 for admittance, _reference
in (stores
or {}).values():
885 values.append(-admittance)
887 n = len(node_index) + len(branch_index)
888 return csc_matrix((values, (rows, cols)), shape=(n, n))
895 residual: 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,
903 """Take the largest fraction of the Newton step that improves things.
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.
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.
920 trial = x + fraction * step
921 if np.all(trial[: len(node_index)] > 0.0):
924 network, trial, node_index, branch_index, signals, stores
927 trial_residual =
None
928 if trial_residual
is not None:
929 if _scaled_norm(trial_residual, scale, ignore) < reference:
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)
957 stubs: list[DeadEnd],
959 stores: Mapping[str, tuple[float, float]] |
None =
None,
962 signals: Mapping[str, float] |
None =
None,
964 """Package a solution vector as a result.
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.
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
980 from feedtwin
import stack_versions
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]
988 node_index = {n: i
for i, n
in enumerate(free)}
989 branch_index = {b: len(free) + i
for i, b
in enumerate(solved)}
993 for node_id
in network.nodes
994 if node_id
not in stub_nodes
996 flows = {b: float(x[i])
for b, i
in branch_index.items()}
1000 for branch_id
in isolated:
1001 flows[branch_id] = 0.0
1012 indeterminate: list[str] = []
1013 indeterminate_nodes: list[str] = []
1014 pending = list(stubs)
1017 deferred: list[DeadEnd] = []
1018 for dead
in pending:
1019 live_p = pressures.get(dead.live_end)
1021 deferred.append(dead)
1031 pressures[dead.node] = live_p
1032 flows[dead.branch] = 0.0
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
1041 flows[dead.branch] = 0.0
1053 holds = branch.component.pressure_drop(0.0, conditions) > 0.0
1059 if holds
or dead.branch
in isolated
or dead.live_end
in indeterminate_nodes:
1066 indeterminate.append(dead.branch)
1067 indeterminate_nodes.append(dead.node)
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)
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
1099 diagnostics: dict[str, dict[str, float]] = {}
1100 regularised: list[str] = []
1101 choked: list[str] = []
1104 pressures=pressures,
1106 converged=converged,
1107 iterations=iterations,
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,
1116 branch_rows =
_branch_rows(network, x, node_index, branch_index, signals)
1122 for branch_id, branch
in network.branches.items():
1123 conditions = network.conditions(
1125 pressures.get(branch.upstream, 0.0)
or 1.0e5,
1126 dict(signals
or {}),
1129 diagnostics[branch_id] = branch.component.diagnostics(
1130 flows[branch_id], conditions
1133 diagnostics[branch_id] = {}
1134 if branch_id
in branch_rows:
1135 row = branch_rows[branch_id]
1137 choked.append(branch_id)
1138 elif abs(row.d_mdot) < MIN_BRANCH_SLOPE:
1139 regularised.append(branch_id)
1142 pressures=pressures,
1144 converged=converged,
1145 iterations=iterations,
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(),
None __init__(self, SteadyResult result, str detail)
float max_mass_residual(self)
float dp(self, str branch_id, Network network)
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)
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)
np.ndarray _initial_guess(Network network, list[str] free, list[str] branch_ids, float initial_flow)
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)
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)
_BranchRow _branch_row(Network network, str branch_id, float mdot, float p_up, float p_dn, Mapping[str, float]|None signals=None)
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)
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)
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)
float _pressure_at(Network network, np.ndarray x, dict[str, int] node_index, str node_id)
np.ndarray _row_scales(Network network, list[str] free, list[str] branch_ids, float initial_flow)
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)
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)
dict[str, tuple[float, float]] _stores(Network network, Mapping[str, tuple[float, float]]|None storage, list[str] branch_ids)
float _scaled_norm(np.ndarray residual, np.ndarray scale, np.ndarray|None ignore=None)
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)
bool _physical(np.ndarray x, list[str] free)