Skip to content

Analysis

Use the analysis namespace to inspect controllability, observability, poles, transfer response, Gramians, Lyapunov solutions, and finite invariant zeros.

The namespace includes:

  • ctrb() and obsv() for controllability and observability matrices
  • poles() for eigenvalue inspection
  • evalfr(), freqresp(), and dcgain() for transfer evaluation
  • ctrb_gramian() and obsv_gramian() for continuous/discrete, finite/infinite Gramians
  • lyap() and dlyap() for continuous and discrete Lyapunov equation solvers
  • zeros() for finite invariant zeros of square or rectangular LTI systems
  • quadratic_rollout_cost() for exact finite-horizon discrete feedback costs

Minimal Example

import jax

jax.config.update("jax_enable_x64", True)

import jax.numpy as jnp
import contrax as cx

sys = cx.ss(
    jnp.array([[0.0, 1.0], [-2.0, -0.4]]),
    jnp.array([[0.0], [1.0]]),
    jnp.array([[1.0, 0.0]]),
    jnp.zeros((1, 1)),
)

poles = cx.poles(sys)
dc = cx.dcgain(sys)
Wc = cx.ctrb_gramian(sys, t=2.0).X

Conventions

  • poles(sys) returns the system eigenvalues
  • evalfr(sys, point) evaluates the transfer map at one complex point
  • freqresp(sys, omega) evaluates over a frequency grid
  • dcgain(sys) evaluates the zero-frequency or steady-state gain

The transfer helpers evaluate state-space realizations directly.

Transform Behavior

Array-based analysis helpers compose with jit and batching when the underlying linear algebra operation supports those transformations.

Gramian helpers support jit, vmap, JVP and VJP. Discrete finite horizon counts are static Python integers. Continuous finite horizons use a matrix exponential; infinite horizons differentiate the Lyapunov equation implicitly.

Gramians and Horizons

With no horizon argument, ctrb_gramian(sys) and obsv_gramian(sys) solve the stable infinite-horizon equation. The default uses Lyapunov doubling. Both continuous and discrete systems must be strictly stable for an infinite-horizon Gramian; a marginal or unstable model returns invalid_problem even if a general dense Lyapunov equation could have a finite solution.

Specify t=... for a continuous integration horizon, or num_steps=... for a discrete sum over k=0,...,N−1. The arguments are distinct to avoid confusing seconds and step counts. Finite horizons support marginal and unstable models; zero returns the zero Gramian. Discrete sums have per-step weighting and do not include a sample-time factor. Controllability sums use A^k B B.T (A^k).T; observability sums use (A^k).T C.T C A^k.

Both functions return LyapunovResult: the Gramian is .X, with .status, .successful and .residual_norm diagnostics. throw=True checks failure in eager or compiled execution. The finite-horizon residual checks the corresponding Gramian equation with its terminal forcing term. For finite discrete horizons, .num_steps is the requested term count; infinite solves report doubling steps.

Select an infinite-horizon equation algorithm using solver=cx.solvers.Lyapunov(...). Finite discrete horizons use binary composition; finite continuous horizons use Van Loan's block exponential. Very long continuous horizons can overflow the block exponential even for a stable model; use the infinite-horizon solve when that is the intended quantity.

Lyapunov Equations

lyap(A, Q) solves the continuous Lyapunov equation \(A X + X A^\top + Q = 0\). dlyap(A, Q) solves the discrete form \(A X A^\top - X + Q = 0\).

Both return LyapunovResult; use .X for the matrix and .successful or .status to check the solve. The default uses squared Smith iteration for stable systems and validates the equation residual. Automatic dense fallback is restricted to at most eight states. It supports small nonstable equations; for larger nonstable equations select the dense solver explicitly:

result = cx.dlyap(A, Q, solver=cx.solvers.Lyapunov(method="dense"))
X = result.X

Dense solves need O(n^4) memory and O(n^6) work. The stable doubling route uses O(n^2) storage and O(n^3) work per step. For continuous equations, a Cayley transform supplies the discrete equation. method="doubling" requires a stable state matrix and reports invalid_problem otherwise.

Both equations require float64 mode and real square matrices. The solution is unique when no eigenvalue sum is zero (continuous) or product is one (discrete). This condition also permits nonstable systems. Forward and reverse derivatives solve the implicit matrix equation; they do not differentiate through the stopping loop. Use vmap for independent equations.

throw=False returns failure diagnostics; throw=True raises through Equinox checks in eager or compiled execution. Singular equations and nonconvergent iterations must be handled before using their solutions. The returned residual is a Frobenius norm; success requires it to be at most atol + rtol * scale, where scale sums norms of the individual equation terms. This bounds backward error; it does not bound forward error for an ill-conditioned equation.

Finite Invariant Zeros

zeros(sys) computes finite rank drops of the Rosenbrock system pencil relative to its normal rank. It accepts square and rectangular realizations, including zero, singular and full-rank feedthrough matrices. For a minimal realization these are transmission zeros. Nonminimal realizations can include decoupling zeros absent from the minimal transfer realization.

For square invertible \(D\), the finite pencil reduces to \(A - B D^{-1} C - \lambda I\).

The general algorithm uses SVD-based orthogonal staircase reductions of the algebraic output constraints, followed by removal of right singular structure. The remaining finite pencil has identity coefficient for the spectral parameter and uses jnp.linalg.eigvals. SciPy and SLICOT are offline references. The formulation follows the finite-pencil reduction described by SLICOT AB08ND. Validation includes its rectangular nonminimal example and the transposed system, as well as independent generalized eigenvalues for regular square pencils.

Zero computation uses host-side rank decisions and requires float64 precision; it cannot be jitted, vmapped, or differentiated. Tolerances determine numerical rank, so check sensitivity to rtol, atol, and realization scaling near rank transitions. The result contains finite zeros; it omits infinite zeros and Kronecker indices.

Quadratic Feedback Costs

quadratic_rollout_cost(sys, K, Q, R, x0, num_steps=N) computes

\[ \sum_{k=0}^{N-1} (x_k^\top Q x_k + u_k^\top R u_k) + x_N^\top Q_{\mathrm{terminal}} x_N,\qquad u_k=-Kx_k. \]

Omit Q_terminal for zero terminal cost. Q and R weight each step independently of the discrete system's sample time. Binary Gramian composition uses O(log N) matrix products and O(n²) storage, without retaining a trajectory. Stability is unnecessary for a finite horizon, but long unstable horizons may overflow. It returns a scalar for one (n,) initial state; use vmap for batches:

cost = cx.quadratic_rollout_cost(
    sys_d, K, Q, R, x0, num_steps=100, Q_terminal=Q,
)
costs = jax.vmap(lambda x: cx.quadratic_rollout_cost(
    sys_d, K, Q, R, x, num_steps=100,
))(initial_states)

The horizon is a static nonnegative Python integer. Zero steps return only the optional initial-state terminal cost. Float32 and float64 inputs are supported. The cost composes with jit, vmap, JVP, and VJP. Validation compares values and gain derivatives with an explicit state/input trajectory sum.

Numerical Notes

Finite-horizon Gramians use a Van Loan block-matrix exponential construction and require jax_enable_x64=True.

When comparing realizations, prefer invariant checks such as rank, conditioning, pole location, and transfer behavior. Exact matrix equality is only meaningful when the realization basis is intentionally identical.

  • Systems for the models these helpers inspect
  • Simulation for time-domain validation alongside frequency- domain or structural checks
  • JAX-native workflows for broader end-to-end usage patterns

contrax.analysis

Structural and transfer analysis helpers.

ctrb(sys: DiscLTI | ContLTI) -> Array

Construct the controllability matrix of a state-space system.

Forms [B, AB, A^2 B, ..., A^{n-1} B] for a continuous or discrete linear system.

Use the singular values or a tolerance-aware rank of this matrix to assess controllability for a particular model and scaling.

Parameters:

Name Type Description Default
sys DiscLTI | ContLTI

Continuous or discrete LTI system.

required

Returns:

Name Type Description
Array Array

Controllability matrix. Shape: (n, n * m).

Source code in contrax/analysis.py
def ctrb(sys: DiscLTI | ContLTI) -> Array:
    """Construct the controllability matrix of a state-space system.

    Forms `[B, AB, A^2 B, ..., A^{n-1} B]` for a continuous or discrete linear
    system.

    Use the singular values or a tolerance-aware rank of this matrix to assess
    controllability for a particular model and scaling.

    Args:
        sys: Continuous or discrete LTI system.

    Returns:
        Array: Controllability matrix. Shape: `(n, n * m)`.
    """
    A, B = sys.A, sys.B
    n = A.shape[0]

    def step(block, _):
        return A @ block, block

    _, blocks = jax.lax.scan(step, B, None, length=n)
    return jnp.transpose(blocks, (1, 0, 2)).reshape(n, n * B.shape[1])

ctrb_gramian(sys: ContLTI | DiscLTI, *, t: float | Array | None = None, num_steps: int | None = None, solver: Lyapunov | None = None, throw: bool = False) -> LyapunovResult

Compute a continuous or discrete controllability Gramian.

With no horizon, solves the infinite-horizon equation for a strictly stable system. Set t for a continuous finite integral or num_steps for a discrete finite sum; finite horizons also apply to marginal or unstable systems. Requires float64 precision. A discrete horizon is a static Python integer, and each discrete sum term has unit weight.

Parameters:

Name Type Description Default
sys ContLTI | DiscLTI

Continuous or discrete LTI realization.

required
t float | Array | None

Nonnegative continuous-time integration horizon.

None
num_steps int | None

Nonnegative number of discrete Gramian terms.

None
solver Lyapunov | None

Infinite-horizon Lyapunov configuration; defaults to stable doubling.

None
throw bool

Raise on numerical failure or a nonstable infinite-horizon model.

False

Returns:

Name Type Description
LyapunovResult LyapunovResult

Gramian X, validated status and equation residual. Finite discrete num_steps reports the horizon term count.

Examples:

>>> import jax
>>> jax.config.update("jax_enable_x64", True)
>>> import contrax as cx
>>> sys = cx.dss([[0.8]], [[1.0]], [[1.0]], [[0.0]], dt=1)
>>> result = ctrb_gramian(sys, num_steps=20)
>>> Wc = result.X
Source code in contrax/_gramians.py
def ctrb_gramian(
    sys: ContLTI | DiscLTI,
    *,
    t: float | Array | None = None,
    num_steps: int | None = None,
    solver: Lyapunov | None = None,
    throw: bool = False,
) -> LyapunovResult:
    """Compute a continuous or discrete controllability Gramian.

    With no horizon, solves the infinite-horizon equation for a strictly stable
    system. Set `t` for a continuous finite integral or `num_steps` for a
    discrete finite sum; finite horizons also apply to marginal or unstable
    systems. Requires float64 precision. A discrete horizon is a static Python
    integer, and each discrete sum term has unit weight.

    Args:
        sys: Continuous or discrete LTI realization.
        t: Nonnegative continuous-time integration horizon.
        num_steps: Nonnegative number of discrete Gramian terms.
        solver: Infinite-horizon Lyapunov configuration; defaults to stable doubling.
        throw: Raise on numerical failure or a nonstable infinite-horizon model.

    Returns:
        LyapunovResult: Gramian X, validated status and equation residual.
            Finite discrete num_steps reports the horizon term count.

    Examples:
        >>> import jax
        >>> jax.config.update("jax_enable_x64", True)
        >>> import contrax as cx
        >>> sys = cx.dss([[0.8]], [[1.0]], [[1.0]], [[0.0]], dt=1)
        >>> result = ctrb_gramian(sys, num_steps=20)
        >>> Wc = result.X
    """
    return _gramian(
        sys, observable=False, t=t, num_steps=num_steps, solver=solver, throw=throw
    )

dcgain(sys: DiscLTI | ContLTI) -> Array

Return the zero-frequency or steady-state gain of an LTI system.

For continuous systems this evaluates G(0). For discrete systems this evaluates G(1).

The corresponding resolvent must be nonsingular at zero frequency.

Parameters:

Name Type Description Default
sys DiscLTI | ContLTI

Continuous or discrete LTI system.

required

Returns:

Name Type Description
Array Array

DC gain matrix. Shape: (p, m).

Source code in contrax/analysis.py
def dcgain(sys: DiscLTI | ContLTI) -> Array:
    """Return the zero-frequency or steady-state gain of an LTI system.

    For continuous systems this evaluates `G(0)`. For discrete systems this
    evaluates `G(1)`.

    The corresponding resolvent must be nonsingular at zero frequency.

    Args:
        sys: Continuous or discrete LTI system.

    Returns:
        Array: DC gain matrix. Shape: `(p, m)`.
    """
    point = 0.0 if isinstance(sys, ContLTI) else 1.0
    return evalfr(sys, point)

dlyap(A: Array, Q: Array, *, solver: Lyapunov | None = None, throw: bool = False) -> LyapunovResult

Solve the discrete Lyapunov equation AXA^T - X + Q = 0.

The default uses squared Smith iteration, with residual validation and dense fallback bounded to eight states. Select Lyapunov(method="dense") for general uniquely solvable equations, including unstable systems. Uniqueness requires every eigenvalue product lambda_i * lambda_j to differ from one. Symmetric Q gives symmetric X.

Requires float64 precision. JAX derivatives follow the implicit equation solve. Automatic dense fallback is limited to eight states; larger systems use the doubling method.

Parameters:

Name Type Description Default
A Array

Real state transition matrix. Shape: (n, n).

required
Q Array

Real forcing matrix. Shape: (n, n).

required
solver Lyapunov | None

Algorithm, tolerances and iteration limit.

None
throw bool

Raise on numerical failure, including under jit.

False

Returns:

Name Type Description
LyapunovResult LyapunovResult

Solution X, status, iteration count and residual norm.

Examples:

>>> import jax
>>> jax.config.update("jax_enable_x64", True)
>>> import contrax as cx
>>> import jax.numpy as jnp
>>> result = cx.dlyap(0.5 * jnp.eye(2), jnp.eye(2))
>>> bool(result.successful)
True
Source code in contrax/analysis.py
def dlyap(
    A: Array, Q: Array, *, solver: Lyapunov | None = None, throw: bool = False
) -> LyapunovResult:
    """Solve the discrete Lyapunov equation AXA^T - X + Q = 0.

    The default uses squared Smith iteration, with residual validation and
    dense fallback bounded to eight states. Select `Lyapunov(method="dense")`
    for general uniquely solvable equations, including unstable systems.
    Uniqueness requires every eigenvalue product `lambda_i * lambda_j` to
    differ from one. Symmetric Q gives symmetric X.

    Requires float64 precision. JAX derivatives follow the implicit equation
    solve. Automatic dense fallback is limited to eight states; larger systems
    use the doubling method.

    Args:
        A: Real state transition matrix. Shape: `(n, n)`.
        Q: Real forcing matrix. Shape: `(n, n)`.
        solver: Algorithm, tolerances and iteration limit.
        throw: Raise on numerical failure, including under `jit`.

    Returns:
        LyapunovResult: Solution `X`, status, iteration count and residual norm.

    Examples:
        >>> import jax
        >>> jax.config.update("jax_enable_x64", True)
        >>> import contrax as cx
        >>> import jax.numpy as jnp
        >>> result = cx.dlyap(0.5 * jnp.eye(2), jnp.eye(2))
        >>> bool(result.successful)
        True
    """
    return lyapunov(A, Q, continuous=False, solver=solver, throw=throw)

evalfr(sys: DiscLTI | ContLTI, point: complex | Array) -> Array

Evaluate the transfer matrix at a complex frequency point.

For continuous systems this computes G(s) = C (sI - A)^-1 B + D. For discrete systems it computes G(z) = C (zI - A)^-1 B + D.

Use point evaluation for spot checks or as the basis of a frequency-response calculation. The resolvent must be nonsingular; evaluation at a realization pole may return nonfinite values even when a nonminimal realization has a removable cancellation. The matrices and point determine the promoted complex dtype.

Parameters:

Name Type Description Default
sys DiscLTI | ContLTI

Continuous or discrete LTI system.

required
point complex | Array

Complex evaluation point s or z. Shape: scalar.

required

Returns:

Name Type Description
Array Array

Transfer matrix evaluated at point. Shape: (p, m).

Source code in contrax/analysis.py
def evalfr(sys: DiscLTI | ContLTI, point: complex | Array) -> Array:
    """Evaluate the transfer matrix at a complex frequency point.

    For continuous systems this computes `G(s) = C (sI - A)^-1 B + D`. For
    discrete systems it computes `G(z) = C (zI - A)^-1 B + D`.

    Use point evaluation for spot checks or as the basis of a frequency-response
    calculation. The resolvent must be nonsingular; evaluation at a realization
    pole may return nonfinite values even when a nonminimal realization has a
    removable cancellation. The matrices and point determine the promoted
    complex dtype.

    Args:
        sys: Continuous or discrete LTI system.
        point: Complex evaluation point `s` or `z`. Shape: scalar.

    Returns:
        Array: Transfer matrix evaluated at `point`. Shape: `(p, m)`.
    """
    if not isinstance(sys, (ContLTI, DiscLTI)):
        raise TypeError("evalfr requires a continuous or discrete LTI model")
    point = jnp.asarray(point, dtype=jnp.result_type(sys.A, point, 1j))
    if point.ndim != 0:
        raise ValueError("point must be scalar; use vmap or freqresp for a grid")
    n = sys.A.shape[0]
    identity = jnp.eye(n, dtype=point.dtype)
    A = sys.A.astype(point.dtype)
    B = sys.B.astype(point.dtype)
    C = sys.C.astype(point.dtype)
    D = sys.D.astype(point.dtype)
    resolvent_rhs = jnp.linalg.solve(point * identity - A, B)
    return C @ resolvent_rhs + D

freqresp(sys: DiscLTI | ContLTI, omega: Array) -> Array

Evaluate the frequency response over angular frequencies.

Continuous systems are evaluated on the imaginary axis at s = j omega. Discrete systems are evaluated on the unit circle at z = exp(j omega dt).

Returns the complex transfer matrix at each frequency. An empty frequency vector returns shape (0, p, m).

Parameters:

Name Type Description Default
sys DiscLTI | ContLTI

Continuous or discrete LTI system.

required
omega Array

Angular frequencies in rad/s. Shape: (k,).

required

Returns:

Name Type Description
Array Array

Complex frequency response. Shape: (k, p, m).

Source code in contrax/analysis.py
def freqresp(sys: DiscLTI | ContLTI, omega: Array) -> Array:
    """Evaluate the frequency response over angular frequencies.

    Continuous systems are evaluated on the imaginary axis at `s = j omega`.
    Discrete systems are evaluated on the unit circle at
    `z = exp(j omega dt)`.

    Returns the complex transfer matrix at each frequency. An empty frequency
    vector returns shape `(0, p, m)`.

    Args:
        sys: Continuous or discrete LTI system.
        omega: Angular frequencies in rad/s. Shape: `(k,)`.

    Returns:
        Array: Complex frequency response. Shape: `(k, p, m)`.
    """
    omega = jnp.asarray(omega)
    if omega.ndim != 1 or jnp.iscomplexobj(omega):
        raise ValueError("omega must be a real vector of angular frequencies")
    omega = omega.astype(jnp.result_type(sys.A.real, omega, 1.0))
    if isinstance(sys, ContLTI):
        points = 1j * omega
    else:
        points = jnp.exp(1j * omega * sys.dt)
    return jax.vmap(lambda point: evalfr(sys, point))(points)

lyap(A: Array, Q: Array, *, solver: Lyapunov | None = None, throw: bool = False) -> LyapunovResult

Solve the continuous Lyapunov equation AX + XA^T + Q = 0.

The default uses squared Smith iteration after a Cayley transform, with residual validation and dense fallback bounded to eight states. Select Lyapunov(method="dense") for general uniquely solvable equations, including unstable systems. Uniqueness requires every eigenvalue sum lambda_i + lambda_j to be nonzero. Symmetric Q gives symmetric X.

Requires float64 precision. JAX derivatives follow the implicit equation solve. Numerical failures return diagnostics unless throw=True.

Parameters:

Name Type Description Default
A Array

Real state matrix. Shape: (n, n).

required
Q Array

Real forcing matrix. Shape: (n, n).

required
solver Lyapunov | None

Algorithm, tolerances and iteration limit.

None
throw bool

Raise on numerical failure, including under jit.

False

Returns:

Name Type Description
LyapunovResult LyapunovResult

Solution X, status, iteration count and residual norm.

Examples:

>>> import jax
>>> jax.config.update("jax_enable_x64", True)
>>> import contrax as cx
>>> import jax.numpy as jnp
>>> result = cx.lyap(-jnp.eye(2), jnp.eye(2))
>>> bool(result.successful)
True
Source code in contrax/analysis.py
def lyap(
    A: Array, Q: Array, *, solver: Lyapunov | None = None, throw: bool = False
) -> LyapunovResult:
    """Solve the continuous Lyapunov equation AX + XA^T + Q = 0.

    The default uses squared Smith iteration after a Cayley transform, with
    residual validation and dense fallback bounded to eight states. Select
    `Lyapunov(method="dense")` for general uniquely solvable equations,
    including unstable systems. Uniqueness requires every eigenvalue sum
    `lambda_i + lambda_j` to be nonzero. Symmetric Q gives symmetric X.

    Requires float64 precision. JAX derivatives follow the implicit equation
    solve.
    Numerical failures return diagnostics unless `throw=True`.

    Args:
        A: Real state matrix. Shape: `(n, n)`.
        Q: Real forcing matrix. Shape: `(n, n)`.
        solver: Algorithm, tolerances and iteration limit.
        throw: Raise on numerical failure, including under `jit`.

    Returns:
        LyapunovResult: Solution `X`, status, iteration count and residual norm.

    Examples:
        >>> import jax
        >>> jax.config.update("jax_enable_x64", True)
        >>> import contrax as cx
        >>> import jax.numpy as jnp
        >>> result = cx.lyap(-jnp.eye(2), jnp.eye(2))
        >>> bool(result.successful)
        True
    """
    return lyapunov(A, Q, continuous=True, solver=solver, throw=throw)

obsv(sys: DiscLTI | ContLTI) -> Array

Construct the observability matrix of a state-space system.

Forms [C; CA; CA^2; ...; CA^{n-1}] for a continuous or discrete linear system.

Use the singular values or a tolerance-aware rank of this matrix to assess observability for a particular model and scaling.

Parameters:

Name Type Description Default
sys DiscLTI | ContLTI

Continuous or discrete LTI system.

required

Returns:

Name Type Description
Array Array

Observability matrix. Shape: (n * p, n).

Source code in contrax/analysis.py
def obsv(sys: DiscLTI | ContLTI) -> Array:
    """Construct the observability matrix of a state-space system.

    Forms `[C; CA; CA^2; ...; CA^{n-1}]` for a continuous or discrete linear
    system.

    Use the singular values or a tolerance-aware rank of this matrix to assess
    observability for a particular model and scaling.

    Args:
        sys: Continuous or discrete LTI system.

    Returns:
        Array: Observability matrix. Shape: `(n * p, n)`.
    """
    A, C = sys.A, sys.C
    n = A.shape[0]

    def step(block, _):
        return block @ A, block

    _, blocks = jax.lax.scan(step, C, None, length=n)
    return blocks.reshape(n * C.shape[0], n)

obsv_gramian(sys: ContLTI | DiscLTI, *, t: float | Array | None = None, num_steps: int | None = None, solver: Lyapunov | None = None, throw: bool = False) -> LyapunovResult

Compute a continuous or discrete observability Gramian.

Uses the dual equation with forcing C.T @ C and transposed state dynamics. With no horizon, the system must be strictly stable. Set t for a finite continuous integral or num_steps for a finite discrete sum; zero horizons are valid. Requires float64 precision. A discrete horizon is a static Python integer, and each discrete sum term has unit weight.

Parameters:

Name Type Description Default
sys ContLTI | DiscLTI

Continuous or discrete LTI realization.

required
t float | Array | None

Nonnegative continuous-time integration horizon.

None
num_steps int | None

Nonnegative number of discrete Gramian terms.

None
solver Lyapunov | None

Infinite-horizon Lyapunov configuration; defaults to stable doubling.

None
throw bool

Raise on numerical failure or a nonstable infinite-horizon model.

False

Returns:

Name Type Description
LyapunovResult LyapunovResult

Gramian X, validated status and equation residual. Finite discrete num_steps reports the horizon term count.

Examples:

>>> import jax
>>> jax.config.update("jax_enable_x64", True)
>>> import contrax as cx
>>> sys = cx.ss([[-1.0]], [[1.0]], [[1.0]], [[0.0]])
>>> result = obsv_gramian(sys, t=2.0)
>>> Wo = result.X
Source code in contrax/_gramians.py
def obsv_gramian(
    sys: ContLTI | DiscLTI,
    *,
    t: float | Array | None = None,
    num_steps: int | None = None,
    solver: Lyapunov | None = None,
    throw: bool = False,
) -> LyapunovResult:
    """Compute a continuous or discrete observability Gramian.

    Uses the dual equation with forcing ``C.T @ C`` and transposed state
    dynamics. With no horizon, the system must be strictly stable. Set `t` for
    a finite continuous integral or `num_steps` for a finite discrete sum; zero
    horizons are valid. Requires float64 precision. A discrete horizon is a
    static Python integer, and each discrete sum term has unit weight.

    Args:
        sys: Continuous or discrete LTI realization.
        t: Nonnegative continuous-time integration horizon.
        num_steps: Nonnegative number of discrete Gramian terms.
        solver: Infinite-horizon Lyapunov configuration; defaults to stable doubling.
        throw: Raise on numerical failure or a nonstable infinite-horizon model.

    Returns:
        LyapunovResult: Gramian X, validated status and equation residual.
            Finite discrete num_steps reports the horizon term count.

    Examples:
        >>> import jax
        >>> jax.config.update("jax_enable_x64", True)
        >>> import contrax as cx
        >>> sys = cx.ss([[-1.0]], [[1.0]], [[1.0]], [[0.0]])
        >>> result = obsv_gramian(sys, t=2.0)
        >>> Wo = result.X
    """
    return _gramian(
        sys, observable=True, t=t, num_steps=num_steps, solver=solver, throw=throw
    )

poles(sys: DiscLTI | ContLTI) -> Array

Return the poles of a state-space system.

The poles are the eigenvalues of the state matrix A. The returned dtype is generally complex, including for real systems with real poles.

Parameters:

Name Type Description Default
sys DiscLTI | ContLTI

Continuous or discrete LTI system.

required

Returns:

Name Type Description
Array Array

System poles. Shape: (n,).

Source code in contrax/analysis.py
def poles(sys: DiscLTI | ContLTI) -> Array:
    """Return the poles of a state-space system.

    The poles are the eigenvalues of the state matrix `A`. The returned dtype
    is generally complex, including for real systems with real poles.

    Args:
        sys: Continuous or discrete LTI system.

    Returns:
        Array: System poles. Shape: `(n,)`.
    """
    return jnp.linalg.eigvals(sys.A)

quadratic_rollout_cost(sys: DiscLTI, K: Array, Q: Array, R: Array, x0: Array, *, num_steps: int, Q_terminal: Array | None = None) -> Array

Evaluate a finite-horizon discrete LTI feedback cost.

Computes sum(x_k.T @ Q @ x_k + u_k.T @ R @ u_k) for k=0,...,N-1, with u_k = -K @ x_k, and optionally adds x_N.T @ Q_terminal @ x_N. Binary Gramian composition uses O(log N) matrix products and O(n**2) storage. num_steps is a static nonnegative Python integer; the system matrices, gain, costs, and initial state are JAX inputs. Long horizons with unstable closed-loop dynamics can overflow.

Parameters:

Name Type Description Default
sys DiscLTI

Discrete LTI system with n states and m inputs.

required
K Array

Feedback gain. Shape: (m, n).

required
Q Array

State cost. Shape: (n, n).

required
R Array

Input cost. Shape: (m, m).

required
x0 Array

Initial state. Shape: (n,).

required
num_steps int

Number of cost terms/transitions. Zero is valid.

required
Q_terminal Array | None

Optional terminal-state cost. Shape: (n, n).

None

Returns:

Name Type Description
Array Array

Scalar quadratic cost for the single initial state.

Raises:

Type Description
TypeError

For a continuous model or noninteger horizon.

ValueError

For a negative horizon or inconsistent shapes.

Examples:

>>> import jax
>>> jax.config.update("jax_enable_x64", True)
>>> import contrax as cx
>>> import jax.numpy as jnp
>>> sys = cx.dss(jnp.eye(1), jnp.eye(1), jnp.eye(1), jnp.zeros((1, 1)), 1)
>>> cx.quadratic_rollout_cost(sys, jnp.zeros((1, 1)), jnp.eye(1),
...                           jnp.eye(1), jnp.ones(1), num_steps=3)
Array(3., dtype=float64)
Source code in contrax/_quadratic.py
def quadratic_rollout_cost(
    sys: DiscLTI,
    K: Array,
    Q: Array,
    R: Array,
    x0: Array,
    *,
    num_steps: int,
    Q_terminal: Array | None = None,
) -> Array:
    """Evaluate a finite-horizon discrete LTI feedback cost.

    Computes ``sum(x_k.T @ Q @ x_k + u_k.T @ R @ u_k)`` for ``k=0,...,N-1``,
    with ``u_k = -K @ x_k``, and optionally adds
    ``x_N.T @ Q_terminal @ x_N``. Binary Gramian composition uses O(log N)
    matrix products and O(n**2) storage. `num_steps` is a static nonnegative
    Python integer; the system matrices, gain, costs, and initial state are JAX
    inputs. Long horizons with unstable closed-loop dynamics can overflow.

    Args:
        sys: Discrete LTI system with n states and m inputs.
        K: Feedback gain. Shape: `(m, n)`.
        Q: State cost. Shape: `(n, n)`.
        R: Input cost. Shape: `(m, m)`.
        x0: Initial state. Shape: `(n,)`.
        num_steps: Number of cost terms/transitions. Zero is valid.
        Q_terminal: Optional terminal-state cost. Shape: `(n, n)`.

    Returns:
        Array: Scalar quadratic cost for the single initial state.

    Raises:
        TypeError: For a continuous model or noninteger horizon.
        ValueError: For a negative horizon or inconsistent shapes.

    Examples:
        >>> import jax
        >>> jax.config.update("jax_enable_x64", True)
        >>> import contrax as cx
        >>> import jax.numpy as jnp
        >>> sys = cx.dss(jnp.eye(1), jnp.eye(1), jnp.eye(1), jnp.zeros((1, 1)), 1)
        >>> cx.quadratic_rollout_cost(sys, jnp.zeros((1, 1)), jnp.eye(1),
        ...                           jnp.eye(1), jnp.ones(1), num_steps=3)
        Array(3., dtype=float64)
    """
    if not isinstance(sys, DiscLTI):
        raise TypeError("quadratic_rollout_cost requires a discrete LTI system")
    if not isinstance(num_steps, int) or isinstance(num_steps, bool):
        raise TypeError("num_steps must be a static Python integer")
    if num_steps < 0:
        raise ValueError("num_steps must be nonnegative")
    K, Q, R, x0 = (jnp.asarray(x) for x in (K, Q, R, x0))
    n, m = sys.B.shape
    if K.shape != (m, n) or Q.shape != (n, n) or R.shape != (m, m) or x0.shape != (n,):
        raise ValueError("K, Q, R and x0 must have shapes (m,n), (n,n), (m,m), (n,)")
    terminal = jnp.zeros_like(Q) if Q_terminal is None else jnp.asarray(Q_terminal)
    if terminal.shape != (n, n):
        raise ValueError("Q_terminal must have shape (n, n)")
    A = sys.A - sys.B @ K
    W = Q + K.T @ R @ K
    phi, gramian = _compose_gramian(A, W, num_steps)
    gramian = gramian + phi.T @ terminal @ phi
    return x0 @ ((gramian + gramian.T) / 2) @ x0

zeros(sys: ContLTI | DiscLTI, *, rtol: float | None = None, atol: float = 0.0) -> Array

Return finite invariant zeros of a real continuous or discrete LTI system.

Computes rank drops of the Rosenbrock pencil relative to its normal rank for square or rectangular systems with any feedthrough rank. For minimal realizations, invariant zeros coincide with transmission zeros. Nonminimal realizations can also have decoupling zeros absent from a minimal transfer realization. The result contains finite zeros; infinite zeros are omitted.

Orthogonal staircase reduction removes output constraints and infinite structure. A controllability staircase removes remaining right singular structure, then JAX eigensolvers solve the finite reduced pencil. Rank- dependent shapes and host-side decisions make this a design-time analysis routine; it does not support jit, vmap, or differentiation. Requires float64 mode. Rank decisions depend on scaling, so set tolerances for nearly rank-deficient realizations and compare transfer behavior after rescaling.

Parameters:

Name Type Description Default
sys ContLTI | DiscLTI

Real continuous or discrete LTI realization.

required
rtol float | None

Relative numerical rank tolerance. Defaults to ten times machine epsilon times the larger Rosenbrock pencil dimension.

None
atol float

Absolute rank threshold. Nonnegative.

0.0

Returns:

Name Type Description
Array Array

Finite invariant zeros including multiplicities. Complex shape (k,).

Raises:

Type Description
ValueError

For complex/nonfinite matrices or invalid rank tolerances.

Examples:

>>> import jax
>>> jax.config.update("jax_enable_x64", True)
>>> import contrax as cx
>>> import jax.numpy as jnp
>>> sys = cx.ss(jnp.array([[-2.]]), jnp.ones((1, 1)),
...             jnp.array([[3.]]), jnp.array([[2.]]))
>>> cx.zeros(sys)
Array([-3.5+0.j], dtype=complex128)
Source code in contrax/_zeros.py
def zeros(
    sys: ContLTI | DiscLTI, *, rtol: float | None = None, atol: float = 0.0
) -> Array:
    """Return finite invariant zeros of a real continuous or discrete LTI system.

    Computes rank drops of the Rosenbrock pencil relative to its normal rank
    for square or rectangular systems with any feedthrough rank. For minimal
    realizations, invariant zeros coincide with transmission zeros. Nonminimal
    realizations can also have decoupling zeros absent from a minimal transfer
    realization. The result contains finite zeros; infinite zeros are omitted.

    Orthogonal staircase reduction removes output constraints and infinite
    structure. A controllability staircase removes remaining right singular
    structure, then JAX eigensolvers solve the finite reduced pencil. Rank-
    dependent shapes and host-side decisions make this a design-time analysis
    routine; it does not support `jit`, `vmap`, or differentiation. Requires
    float64 mode. Rank decisions depend on scaling, so set tolerances for nearly
    rank-deficient realizations and compare transfer behavior after rescaling.

    Args:
        sys: Real continuous or discrete LTI realization.
        rtol: Relative numerical rank tolerance. Defaults to ten times machine
            epsilon times the larger Rosenbrock pencil dimension.
        atol: Absolute rank threshold. Nonnegative.

    Returns:
        Array: Finite invariant zeros including multiplicities. Complex shape `(k,)`.

    Raises:
        ValueError: For complex/nonfinite matrices or invalid rank tolerances.

    Examples:
        >>> import jax
        >>> jax.config.update("jax_enable_x64", True)
        >>> import contrax as cx
        >>> import jax.numpy as jnp
        >>> sys = cx.ss(jnp.array([[-2.]]), jnp.ones((1, 1)),
        ...             jnp.array([[3.]]), jnp.array([[2.]]))
        >>> cx.zeros(sys)
        Array([-3.5+0.j], dtype=complex128)
    """
    require_x64("zeros")
    A, B, C, D = sys.A, sys.B, sys.C, sys.D
    if any(
        jnp.iscomplexobj(x) or not bool(jnp.all(jnp.isfinite(x))) for x in (A, B, C, D)
    ):
        raise ValueError("zeros requires finite real matrices")
    n, m, p = A.shape[0], B.shape[1], C.shape[0]
    rtol = (
        float(jnp.finfo(A.dtype).eps) * max(n + m, n + p, 1) * 10
        if rtol is None
        else rtol
    )
    if not math.isfinite(rtol) or not math.isfinite(atol) or rtol < 0 or atol < 0:
        raise ValueError("rank tolerances must be finite and nonnegative")
    compound = jnp.block([[A, B], [C, D]])
    scale = float(jnp.linalg.norm(compound))
    threshold = atol + rtol * scale

    # Reference: Emami-Naeini & Van Dooren (1982), "Computation of Zeros of
    # Linear Multivariable Systems", Automatica 18, 415-430; SLICOT AB08ND.
    # SVD supplies rank-revealing orthogonal coordinates. Eliminate the
    # nonsingular D block, then restrict x to ker(C_remaining). The discarded
    # state rows become the next algebraic output constraints. Each such step
    # removes at least one state (infinite structure), preserving finite rank
    # drops. Once constraints vanish, remove the right singular staircase.
    for _ in range(n + 1):
        if A.shape[0] == 0:
            return jnp.empty((0,), dtype=jnp.complex128)
        U, values, Vh, rank = _svd(D, threshold)
        B = B @ Vh.T
        C = U.T @ C
        if rank:
            A = A - (B[:, :rank] / values[:rank]) @ C[:rank]
        B = B[:, rank:]
        C = C[rank:]
        # Use an absolute floor from the original pencil to distinguish exact
        # structural zeros from roundoff created by orthogonal transformations.
        threshold = max(threshold, atol + rtol * float(jnp.linalg.norm(A)))
        _, _, Vh, rank_c = _svd(C, threshold)
        if rank_c == 0:
            finite = _uncontrollable_pencil(A, B, threshold)
            return jnp.linalg.eigvals(finite).astype(jnp.complex128)
        constrained, free = Vh[:rank_c].T, Vh[rank_c:].T
        A_free, B_free = free.T @ A @ free, free.T @ B
        C, D = constrained.T @ A @ free, constrained.T @ B
        A, B = A_free, B_free
    raise RuntimeError("invariant-zero staircase did not terminate")