Steady States¶
op_engine.steady_state finds an equilibrium y* with rhs(t, y*) = 0, such
as the endemic state of an epidemic model, without a long burn-in
integration. It uses pseudo-transient continuation: each iteration takes an
implicit-Euler step (I/dt - J) delta = rhs(y) and grows dt as the residual
falls, so early iterations follow the dynamics and later ones become Newton
steps.
A first solve¶
import numpy as np
from op_engine import require_steady_state, steady_state
a = np.array([[-2.0, 1.0], [0.5, -1.0]])
b = np.array([1.0, 2.0])
result = require_steady_state(steady_state(lambda t, y: a @ y + b, np.zeros(2)))
print(result.state, int(result.iterations))
steady_state returns a SteadyStateResult whose diagnostics are arrays:
converged, iterations, residual_norm, step_norm, dt, and
invariant_drift. require_steady_state raises
SteadyStateConvergenceError when the solve did not converge.
Epidemic models: sinks and conserved totals¶
Two features of epidemic models need declaring.
- Absorbing states. A cumulative counter (deaths, incidence) never reaches
a zero derivative while its inflow is positive. Pass it as
fixed, by index or mask: it keeps its starting value and is excluded from the convergence test.sink_states(rhs, samples)finds the states no derivative depends on. - Conserved totals. When births balance deaths (
birth = mu * N), the total population is conserved, so the steady states form a family indexed by it and the Jacobian is singular. Pass the conserved directions asinvariants: each rowwholdsw @ yat its starting value exactly, and the solve stays well posed asdtgrows. conserved_quantities(stoichiometry)returns the structural laws of a reaction network, such asS + I + Rfor a closed SIR.linear_invariants(rhs, samples)also finds totals conserved because rates balance, which births make invisible to the stoichiometry.
from op_engine import linear_invariants, sink_states
beta, gamma, mu, omega = 0.4, 0.1, 1 / (70 * 365), 1 / 365
def sirs(t, y):
s, i, r, deaths = y
n = s + i + r
infection = beta * s * i / n
return np.array([
mu * n - infection - mu * s + omega * r,
infection - (gamma + mu) * i,
gamma * i - (mu + omega) * r,
mu * i,
])
rng = np.random.default_rng(0)
samples = [rng.uniform(10.0, 3000.0, 4) for _ in range(3)]
sinks = sink_states(sirs, samples) # [False, False, False, True]
invariants = linear_invariants(sirs, samples, fixed=sinks) # S + I + R
y0 = np.array([4990.0, 10.0, 0.0, 0.0])
endemic = require_steady_state(
steady_state(sirs, y0, fixed=sinks, invariants=invariants)
)
print(endemic.state[:3], float(endemic.invariant_drift))
linear_invariants is numerical: a mode decaying more slowly than tol
(default 1e-6) times the fastest rate looks conserved, and slow modes limit
the accuracy of the rows it returns. When you know an invariant, pass the
exact row (here [[1, 1, 1, 0]]) and use linear_invariants to confirm that
nothing else is conserved.
For an op_system model, use the compiled RHS. The reaction network gives the structural laws:
from op_system import compile_spec
from op_engine import conserved_quantities, from_compiled_rhs
compiled = compile_spec({
"kind": "transitions",
"state": ["S", "I", "R"],
"transitions": [
{"name": "infect", "from": "S", "to": "I", "rate": "b * I / (S + I + R)"},
{"name": "recover", "from": "I", "to": "R", "rate": "g"},
{"name": "wane", "from": "R", "to": "S", "rate": "w"},
],
})
params = {"b": 0.4, "g": 0.1, "w": 0.01}
network = from_compiled_rhs(compiled, params)
closed = conserved_quantities(network.stoichiometry) # one row: S + I + R
def rhs(t, y):
return np.asarray(compiled.eval_fn(t, y, **params))
sirs_state = require_steady_state(
steady_state(rhs, np.array([990.0, 10.0, 0.0]), invariants=closed)
).state
Local linear-stability evidence¶
local_stability examines the linearization at a candidate equilibrium without
changing how steady_state solves for that candidate. Let y* be an
n-coordinate state and let f(t, y) be the right-hand side, with units of
state per unit time. The diagnostic first evaluates every coordinate of
f(t, y*). For the free-coordinate set A and positive residual tolerances
r_i, it accepts the candidate only when
sqrt(mean((f_i(t, y*) / r_i)**2 for i in A)) <= 1.
Residuals at fixed coordinates do not enter this test, because a cumulative
counter can have nonzero flow at an equilibrium of the other coordinates. The
result retains both the complete residual and the tested free-coordinate
residual. An unconverged SteadyStateResult remains a solve failure even when
its state passes a separately chosen residual threshold.
The following two-state system has Jacobian eigenvalues -1 +/- 2i, in inverse
days. Its local perturbations therefore decay with a leading e-folding time of
one day.
import numpy as np
from op_engine import LocalStabilityConfig, local_stability
jacobian = np.array([[-1.0, -2.0], [2.0, -1.0]])
candidate = np.zeros(2)
controls = LocalStabilityConfig(
residual_tolerance=1e-10,
spectral_absolute_tolerance=1e-10,
spectral_relative_tolerance=1e-8,
boundary_tolerance=1e-12,
)
stability = local_stability(
lambda _t, state: jacobian @ state,
candidate,
config=controls,
jacobian=lambda _t, _state: jacobian,
jacobian_reference="analytic matrix in this example",
coordinates=("x", "y"),
time_unit="day",
)
assert stability.classification.value == "asymptotically_stable"
assert np.isclose(stability.leading_timescale, 1.0)
The configuration has no scientific defaults. residual_tolerance has the
units of the corresponding right-hand-side coordinate;
spectral_absolute_tolerance has inverse-time units;
spectral_relative_tolerance is dimensionless; and boundary_tolerance has
the units of the state coordinate. Each residual or boundary tolerance may be
a scalar or an n-vector. Central finite differences use the configured
finite_difference_step, in state units, or record the default perturbation
cbrt(machine epsilon) * max(abs(y_i), 1) for each coordinate.
Jacobian sources and constraints¶
For a local stability interpretation, f must be continuously differentiable
near y* and autonomous. If f depends explicitly on time, the returned
matrix and eigenvalues describe only the frozen time supplied as t; they do
not establish stability of the nonautonomous system.
The diagnostic accepts one of three mutually exclusive Jacobian sources:
- an
op_system.SymbolicDerivativesartifact, evaluated in its declared state order and recorded with its structural model digest; - a dense analytic callback with a required
jacobian_reference; or - an explicit central finite-difference run when neither source is supplied.
A failed symbolic evaluation remains a Jacobian failure. It does not silently
switch to finite differences. All successful right-hand-side and Jacobian
evaluations must return NumPy arrays with the candidate's shape and floating
dtype. When an op_system artifact is supplied, the caller must also ensure
that the RHS and parameter bindings represent that same compiled model. The
diagnostic verifies the artifact's state order and structural digest, but it
cannot prove the identity of an arbitrary Python RHS callback.
Fixed coordinates and declared linear invariants restrict admissible local
perturbations. If E_F selects the fixed coordinates and the rows of W
contain the invariants, the diagnostic forms C = [E_F; W] and obtains an
orthonormal basis Q for ker(C) by singular value decomposition. It analyzes
the tangent Jacobian
J_T = Q.T @ J @ Q,
where J is the complete n by n Jacobian. The result retains the complete,
free-coordinate and tangent spectra. It also checks that W annihilates the
free-coordinate Jacobian within the reported scale-aware tolerance. Dependent
constraint rows and failed invariant checks return an explicit failure instead
of removing modes. A stable tangent spectrum therefore means asymptotic
stability of the linearization on the declared constraint manifold, not in the
unrestricted state space.
For tangent eigenvalues lambda, the reported spectral band is
tau = spectral_absolute_tolerance
+ spectral_relative_tolerance * max(abs(lambda)).
The result is asymptotically_stable only when every real part is strictly
less than -tau, and it is unstable when any real part is strictly greater
than tau. If every real part lies within the closed band, the result reports
neutral_spectrum; a mixture of critical and decaying modes reports
nonhyperbolic. Neither critical result establishes nonlinear stability. For
example, a zero linearization can occur at an attracting cubic root and in a
nilpotent system with growing solutions. Equality with either threshold is
treated as critical.
Eigenvalues of repeated, defective or strongly nonnormal matrices can be sensitive to floating-point perturbations even when the reported values lie outside the chosen band. The diagnostic does not estimate eigenvalue condition numbers, transient amplification or a pseudospectrum. Examine those properties separately when the model or coordinates make nonnormality plausible.
A leading e-folding time is reported only for a strictly stable or unstable
interior classification. A stable result uses
-1 / max(real(lambda)); an unstable result uses
1 / max(real(lambda)). The caller supplies the time-unit label.
Boundaries and execution scope¶
Set nonnegative=True, or provide a boolean mask, only for coordinates with a
one-sided non-negative domain. If a free coordinate lies within its declared
boundary tolerance, the result reports the affected coordinates and suppresses
an interior classification. A supplied Jacobian can still provide descriptive
spectral evidence, but a tangent-cone or complementarity analysis is required
for a boundary conclusion. The central-difference route stops before probing
across such a boundary.
Boundary declarations refer to the supplied coordinate system. A zero boundary in natural population or concentration coordinates is not a finite point after a logarithmic transformation, so a result in log coordinates cannot be used as the corresponding natural-coordinate boundary analysis.
local_stability is an eager, dense NumPy CPU diagnostic. It is not JIT
traceable, differentiable, or qualified as a CUDA or portable Array API
eigensolver. Transfer another backend's state explicitly at the caller
boundary, for example with np.asarray(jax.device_get(state)). This host-only
scope does not change the native JAX behavior of steady_state.
The returned status distinguishes residual, solve, Jacobian, constraint,
eigensolver, non-finite, boundary and zero-dimensional outcomes. Inspect the
status before using the optional classification or timescale. Numerical
eigenvalues support a local linearized conclusion under these declarations;
they do not certify nonlinear or global stability.
Convergence¶
A solve converges when two conditions hold, with the scale atol + |y| per
state:
- the scaled residual RMS is at most
residual_tol; - the Newton correction, scaled the same way, is at most
step_tol.
The second condition matters for slowly decaying modes. Near a steady state
the error is about residual / rate, so a residual of 1e-9 along a mode
decaying at 1e-7 per day still leaves an error of about 1e-2. The Newton
correction measures that error directly, and the final correction is applied
to the returned state.
Pseudo-transient continuation converges to a steady state: started next to
an unstable one, such as the disease-free state, it can stop there. The dt
schedule holds the step through bounded residual growth, as it does while
an epidemic takes off. A small dt0 (the default is 1 time unit) lets the
iterates follow the dynamics toward a stable equilibrium. Candidates with
non-finite states or residuals, or with scaled or unscaled residual growth
above max_growth, are rejected: the previous state and residual are retained,
and dt is halved for the next attempt. Rejections count toward
max_iterations; a smaller dt0 or a short time integration can provide a
better starting point if the budget is insufficient. Warm starts from a nearby
solution converge fastest; raise dt0 for those.
For compartment populations or concentrations, pass nonnegative=True to
reject negative candidates, including the final Newton correction. A boolean
mask constrains selected states when the model also contains signed variables:
result = steady_state(rhs, y0, invariants=totals, nonnegative=True)
This guard is especially useful for fast take-off from a seeded disease-free state, where a large pseudo-time step can overshoot into negative populations or select the unstable equilibrium. Rejected steps preserve the invariant totals; the solver does not clip states. Signs remain unrestricted by default. The guard helps follow a physical trajectory but cannot guarantee convergence to a particular equilibrium or avoid an iteration limit.
SteadyStateConfig collects the controls: dt0, dt_max, min_growth,
max_growth, max_iterations, residual_tol, step_tol, and atol. The
default tolerances assume double precision.
The safeguard retains pseudo-transient continuation for the coupled system. Brent's method and bisection need a scalar sign-changing bracket, which a general RHS does not provide. The existing curvature-weighted grid utilities are spatial preprocessing tools. For background on constraints and step control, see projected pseudo-transient continuation and PETSc's pseudo-time step verification.
JAX: jit and vmap¶
Pass an automatic-differentiation Jacobian and loop=jax.lax.fori_loop. The
loop compiles the iteration once, where the default Python loop would unroll
max_iterations copies of it. Invariants and the fixed mask are setup data,
so build them outside the traced function.
import jax
import jax.numpy as jnp
from op_engine import SteadyStateConfig
jax.config.update("jax_enable_x64", True)
total = np.ones((1, 3))
def endemic_state(beta, y0):
def rhs(t, y):
s, i, r = y[0], y[1], y[2]
n = s + i + r
return jnp.stack([
mu * n - beta * s * i / n - mu * s + omega * r,
beta * s * i / n - (gamma + mu) * i,
gamma * i - (mu + omega) * r,
])
result = steady_state(
rhs,
y0,
jacobian=lambda t, y: jax.jacfwd(lambda z: rhs(t, z))(y),
invariants=total,
config=SteadyStateConfig(max_iterations=60),
loop=jax.lax.fori_loop,
)
return result.state, result.converged
states, converged = jax.jit(jax.vmap(endemic_state, in_axes=(0, None)))(
jnp.array([0.3, 0.4, 0.5]), jnp.array([4990.0, 10.0, 0.0])
)
Without a jacobian, steady_state uses forward differences, which cost one
RHS evaluation per state per iteration.