STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
feedtwin.solve.steady Namespace Reference

Classes

class  SteadyResult
class  ConvergenceError
class  _BranchRow

Functions

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)
dict[str, tuple[float, float]] _stores (Network network, Mapping[str, tuple[float, float]]|None storage, list[str] branch_ids)
np.ndarray _initial_guess (Network network, list[str] free, list[str] branch_ids, float initial_flow)
np.ndarray _row_scales (Network network, list[str] free, list[str] branch_ids, float initial_flow)
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)
bool _physical (np.ndarray x, list[str] free)
float _scaled_norm (np.ndarray residual, np.ndarray scale, np.ndarray|None ignore=None)
float _pressure_at (Network network, np.ndarray x, dict[str, int] node_index, str node_id)
_BranchRow _branch_row (Network network, str branch_id, float mdot, float p_up, float p_dn, Mapping[str, float]|None signals=None)
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)
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 _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)
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)
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)
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 _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)
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)

Variables

float MIN_BRANCH_SLOPE = 1.0e-3
float INITIAL_FLOW = 1.0e-3
float LOCAL_TOLERANCE = 1.0e-2
float LOCAL_SCALE_FLOOR = 1.0e5
float _FD_REL = 1.0e-6
float _FD_ABS = 1.0e-9
float _CHOKE_STIFFNESS = 1.0e8

Detailed Description

The steady network solve: pressure at every node, flow in every branch.

The system is small, square and highly structured. Unknowns are one pressure per
free node and one mass flow per branch; equations are one mass balance per free
node and one pressure relation per branch::

    node i    :  sum(flows in) - sum(flows out) - demand = 0
    branch b  :  (p_up - p_dn) - dp(mdot) = 0

Almost all of the Jacobian is constant. The mass-balance rows are the incidence
matrix -- plus and minus ones that never change. The branch rows are +1 on the
upstream pressure, -1 on the downstream, and a single nonlinear entry:
``d(dp)/d(mdot)``, one scalar per branch. So a Newton step costs one derivative
per component and a sparse solve, and nothing has to differentiate a correlation.

Two things about this problem bite, and both are handled explicitly rather than
hoped away:

**Zero flow is a singular point.** Loss goes as roughly ``mdot^2``, so its
derivative vanishes at zero and the branch row goes with it. A guess that starts
at zero flow cannot take a first step. Solved by starting away from zero and by
flooring the derivative -- see :data:`MIN_BRANCH_SLOPE`.

**Newton overshoots.** An undamped step from a poor guess can send a pressure
negative, at which point the property layer refuses and rightly so. Solved with
backtracking: halve the step until the residual actually improves.

Function Documentation

◆ _assemble_residual()

np.ndarray feedtwin.solve.steady._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 )
protected
Mass balances plus already-built branch rows. No component evaluation.

Definition at line 756 of file steady.py.

References _mass_rows().

Referenced by solve_steady().

◆ _branch_row()

_BranchRow feedtwin.solve.steady._branch_row ( Network network,
str branch_id,
float mdot,
float p_up,
float p_dn,
Mapping[str, float] | None signals = None )
protected
Assemble one branch's equation, in whichever form applies.

A symmetric component (``component.symmetric``) is priced at the node the
fluid actually comes from: when the drawn downstream is the higher
pressure, the conditions are the downstream node's and the choke test reads
the drop the other way. It used to be priced at the drawn upstream whatever
the gradient, and a reverse drop never choked: a valve drawn against its
flow passed 3-8x less than the same valve drawn with it, both converged.
One-way laws (check valve, regulator, relief, injector) keep the drawing's
direction.

Definition at line 629 of file steady.py.

References max.

Referenced by _branch_rows().

◆ _branch_rows()

dict[str, _BranchRow] feedtwin.solve.steady._branch_rows ( Network network,
np.ndarray x,
dict[str, int] node_index,
dict[str, int] branch_index,
Mapping[str, float] | None signals = None )
protected

Definition at line 735 of file steady.py.

References _branch_row(), and _pressure_at().

Referenced by _residuals(), _result(), and solve_steady().

◆ _damped_update()

np.ndarray feedtwin.solve.steady._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 )
protected
Take the largest fraction of the Newton step that improves things.

An undamped step from a crude guess routinely sends a pressure negative, at
which point the property layer refuses -- correctly, but the solve should
back off rather than fall over. Backtracking also handles the case where
the step is simply too long, which quadratic losses make common.

``ignore`` must be the *same* mask the convergence test uses. Measuring
improvement against a norm that includes a row nothing can improve makes
every step look like a failure: the search backtracks to almost nothing,
the iteration crawls, and the solve stops just above whatever tolerance was
asked for -- tracking it exactly, which is the tell.

Definition at line 891 of file steady.py.

References _residuals(), and _scaled_norm().

Referenced by solve_steady().

◆ _incidence_pattern()

tuple[list[int], list[int]] feedtwin.solve.steady._incidence_pattern ( Network network,
dict[str, int] node_index,
dict[str, int] branch_index,
Mapping[str, tuple[float, float]] | None stores = None )
protected
Row and column indices of every structurally non-zero Jacobian entry.

Computed once. The sparsity of a hydraulic network never changes during a
solve -- only the values in it do.

Definition at line 815 of file steady.py.

Referenced by solve_steady().

◆ _initial_guess()

np.ndarray feedtwin.solve.steady._initial_guess ( Network network,
list[str] free,
list[str] branch_ids,
float initial_flow )
protected
Pressures between the boundary values, flows small but non-zero.

Deliberately crude. A network solve is not very sensitive to where it starts
once the first step is possible, and the thing that makes a first step
impossible is zero flow rather than a poor pressure guess.

Definition at line 470 of file steady.py.

Referenced by solve_steady().

◆ _jacobian()

csc_matrix feedtwin.solve.steady._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 )
protected

Definition at line 853 of file steady.py.

Referenced by solve_steady().

◆ _local_scales()

np.ndarray feedtwin.solve.steady._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 )
protected
:func:`_row_scales`, with each branch also held against its own pressures.

One pressure scale for every branch -- the highest fixed pressure in the
network -- let a branch on a stand with a 6,000 psi K-bottle sit half a
psi off its relation and call it converged. A liquid drain driven by its
head (0.45 psi) then never left the solve's 1 g/s starting guess: the
first residual was already "small", so Newton took no step.

So a branch must also close to :data:`LOCAL_TOLERANCE` of the larger of
its two end pressures (never less than a bar). It only ever tightens, and
only where the stand's own scale is the looser: a tight solve is judged as
before, and a choked charge into an empty bottle -- which a finite-
difference Jacobian closes to about a tenth of a percent and no further --
is not asked for more than a percent. Nor is any choked row: its residual is a
flow error in pressure units by an arbitrary stiffness
(:data:`_CHOKE_STIFFNESS`), so a share of a pressure says nothing about it.

Definition at line 511 of file steady.py.

References _pressure_at(), max, and min.

Referenced by solve_steady().

◆ _mass_rows()

None feedtwin.solve.steady._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 )
protected
Fill the mass-balance rows of ``residual``: inflow minus demand, minus
what a stored node keeps (``admittance * (p - reference)``).

Definition at line 793 of file steady.py.

Referenced by _assemble_residual(), and _residuals().

◆ _physical()

bool feedtwin.solve.steady._physical ( np.ndarray x,
list[str] free )
protected
Are the node pressures in this iterate actually pressures?

Only the node block of the unknown vector: a *branch* flow is legitimately
negative, which is just flow the other way.

Definition at line 552 of file steady.py.

Referenced by solve_steady().

◆ _pressure_at()

float feedtwin.solve.steady._pressure_at ( Network network,
np.ndarray x,
dict[str, int] node_index,
str node_id )
protected

Definition at line 588 of file steady.py.

Referenced by _branch_rows(), _local_scales(), and _result().

◆ _residuals()

np.ndarray feedtwin.solve.steady._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 )
protected
Residual from scratch. Used where the derivatives are not wanted.

Definition at line 774 of file steady.py.

References _branch_rows(), and _mass_rows().

Referenced by _damped_update().

◆ _result()

SteadyResult feedtwin.solve.steady._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 )
protected
Package a solution vector as a result.

``signals`` must be the solve's own: the report re-evaluates every branch
at the answer, and without them a dome-loaded regulator reported its drawn
setpoint and a part-open valve reported itself wide open.

``isolated`` is not optional and must be the same set the solve used. This
function rebuilds the branch ordering to read ``x`` back, and that ordering
is ``[b for b in branches if b not in stubs | isolated]`` -- so a caller
that omits it gets a *longer* list than the vector it is indexing into.
The failure is an IndexError on a good day and silently misattributed flows
on a bad one, where every branch after the first isolated valve reports its
neighbour's flow. It defaulted to empty until the convergence-failure path,
which is the one place that omitted it, started raising on a stand with a
shut valve on it.

Definition at line 950 of file steady.py.

References _branch_rows(), and _pressure_at().

Referenced by solve_steady().

◆ _row_scales()

np.ndarray feedtwin.solve.steady._row_scales ( Network network,
list[str] free,
list[str] branch_ids,
float initial_flow )
protected
A magnitude for each equation, so one tolerance can judge them all.

The residual vector is in mixed units: mass balances are kg/s, branch
relations are Pa. Comparing both against a single absolute tolerance means
``tol = 1e-6`` demands pressure to a micropascal -- about 1e-12 relative on
a 30 bar system, which is far below what a finite-difference Jacobian can
deliver, so the solve grinds without ever "converging".

Scaling by row does not touch the Newton step -- ``J^-1 F`` is invariant
under row scaling -- so this changes only the convergence test, and changes
it from arbitrary to meaningful.

Definition at line 488 of file steady.py.

References max.

Referenced by solve_steady().

◆ _scaled_norm()

float feedtwin.solve.steady._scaled_norm ( np.ndarray residual,
np.ndarray scale,
np.ndarray | None ignore = None )
protected
Worst row, scaled. Rows in ``ignore`` are excluded.

Exclusion is not leniency. A branch whose ``d(dp)/d(mdot)`` has hit
:data:`MIN_BRANCH_SLOPE` is one the Jacobian *cannot* resolve -- a fully
open isolation valve carrying zero flow because the regulator downstream is
holding has a genuinely flat row, and no step size moves it. Letting that
one row veto the whole solve means a correct answer, with mass closing to
1e-14, is reported as a failure.

Two things keep this from hiding a real fault: mass-balance rows are never
excluded, so conservation still has to hold everywhere; and every excluded
branch is listed in :attr:`SteadyResult.regularised_branches`, so a solve
standing on twenty of them says so.

Definition at line 561 of file steady.py.

Referenced by _damped_update(), and solve_steady().

◆ _stores()

dict[str, tuple[float, float]] feedtwin.solve.steady._stores ( Network network,
Mapping[str, tuple[float, float]] | None storage,
list[str] branch_ids )
protected
The ``storage`` entries that take part in this solve, in a fixed order.

A boundary node with a positive admittance and at least one branch still
carrying flow. One cut off by shut valves keeps its fixed pressure, so a
stand with everything shut solves exactly as it would without storage.

Definition at line 441 of file steady.py.

Referenced by solve_steady().

◆ solve_steady()

SteadyResult feedtwin.solve.steady.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 )
Solve a network for its steady operating point.

Args:
    network: The system. Validated first.
    tol: Convergence tolerance on the scaled residual.
    max_iterations: Newton iteration cap.
    initial_flow: Starting flow for every branch [kg/s]. Non-zero on
        purpose -- see the module docstring.
    raise_on_failure: Raise :class:`ConvergenceError` on failure. Set false
        to inspect a failed result instead, which is what a sweep wants.
    isolate: Extra branches to treat as open circuits, on top of the
        valves that report themselves shut. For what a caller knows and the
        network cannot -- an empty tank's outlet being the case.
    guess: Starting point, as ``{node or branch id: pressure or flow}``.
        A transient re-solves the same network a few thousand times at
        states that barely differ, and starting each one from a cold
        1e-3 kg/s throws that away -- it costs iterations, and on a stiff
        network it can land the search in a different basin than the step
        before, which reads as a convergence failure at one instant and
        nowhere else. Unknown ids are ignored, so a guess from a slightly
        different network is safe to pass.
    signals: Control inputs -- ``{"MV-01.command": 0.4}`` for a part-open
        valve. Every component that reads a signal sees the same mapping,
        so a scenario's timeline reaches the physics without the solver
        knowing what any particular signal means. Omitted, each component
        falls back to its own default, which for a valve is fully open.
    storage: Boundary nodes to close implicitly, as ``{node id:
        (admittance [kg/s per Pa], reference pressure [Pa])}``. A vessel
        behind a wide line is a boundary only for as long as the flow it
        receives cannot move it; when it can, the step that holds it at
        its start-of-step pressure is an explicit coupling, and a small
        ullage behind a Cv 4 solenoid flip-flops against it. A node listed
        here becomes an unknown with a storage term in its mass balance,
        ``inflow - demand - admittance * (p - reference) = 0``: backward
        Euler on the vessel, where ``reference`` is where it would land
        with no flow and ``admittance`` is ``C / dt``. Its solved pressure
        is the vessel's end-of-step pressure. Nodes not fixed, or with no
        branch left carrying flow, are ignored; omitted, nothing changes.
    report: Fill the result's per-branch report -- ``diagnostics``,
        ``regularised_branches``, ``violations``, ``stack`` -- which takes
        one more evaluation of every branch at the answer. A live session
        reads none of it and solves a few hundred times a second of stand,
        so it asks for none; pressures, flows and the mass residuals are
        the same either way.

Returns:
    A :class:`SteadyResult`, including mass-conservation residuals whether
    or not it converged.

Definition at line 154 of file steady.py.

References _assemble_residual(), _branch_rows(), _damped_update(), _incidence_pattern(), _initial_guess(), _jacobian(), _local_scales(), _physical(), _result(), _row_scales(), _scaled_norm(), and _stores().

Variable Documentation

◆ _CHOKE_STIFFNESS

float feedtwin.solve.steady._CHOKE_STIFFNESS = 1.0e8
protected

Definition at line 626 of file steady.py.

◆ _FD_ABS

float feedtwin.solve.steady._FD_ABS = 1.0e-9
protected

Definition at line 70 of file steady.py.

◆ _FD_REL

float feedtwin.solve.steady._FD_REL = 1.0e-6
protected

Definition at line 69 of file steady.py.

◆ INITIAL_FLOW

float feedtwin.solve.steady.INITIAL_FLOW = 1.0e-3

Definition at line 58 of file steady.py.

◆ LOCAL_SCALE_FLOOR

float feedtwin.solve.steady.LOCAL_SCALE_FLOOR = 1.0e5

Definition at line 66 of file steady.py.

◆ LOCAL_TOLERANCE

float feedtwin.solve.steady.LOCAL_TOLERANCE = 1.0e-2

Definition at line 64 of file steady.py.

◆ MIN_BRANCH_SLOPE

float feedtwin.solve.steady.MIN_BRANCH_SLOPE = 1.0e-3

Definition at line 53 of file steady.py.