Skip to content

Systems

Build continuous and discrete LTI models, nonlinear models, and port-Hamiltonian systems for simulation, analysis, control, and estimation.

ss and dss promote matrix inputs to a common floating dtype while preserving explicit float32 arrays when x64 mode is enabled. Integer inputs become floating arrays. dss stores its sample time in the resulting real matrix dtype. Inconsistent matrix shapes raise ValueError, including during tracing.

Nonlinear and PHS constructors also require finite positive real scalar sample periods when dt is supplied. Dimension metadata must be nonnegative static integers. The default canonical PHS structure requires a positive even state dimension; separable models require a positive integer number of coordinates. Block sizes and indices are static integers, and block-matrix entries outside the declared grid raise an error. project_psd explicitly projects real square matrices with a finite nonnegative eigenvalue floor; it supports JAX transforms where the eigendecomposition is differentiable.

Discrete NonlinearSystem objects expose step(t, x, u) for a terminal state and numerical diagnostics. Models made by sample_system retain their adaptive integration status in that result. Recursive estimators require discrete transitions: sample a continuous vector field before passing its model to EKF, UKF or MHE. See Simulation for interpolation, tolerances, integration limits and adjoint selection.

The namespace includes:

  • continuous and discrete LTI models: ContLTI, DiscLTI, ss(), dss()
  • nonlinear and port-Hamiltonian model objects: NonlinearSystem, PHSSystem, nonlinear_system(), phs_system(), schedule_phs()
  • PHS structure helpers: canonical_J() to define the symplectic structure map, phs_to_ss() to extract the local state-space linearization around an operating point
  • structured-system helpers: partition_state(), block_observation(), block_matrix(), symmetrize_matrix(), project_psd(), phs_diagnostics()
  • discretization and local model construction: c2d(), linearize(), linearize_ss()
  • simple state-space composition: series(), parallel(), plus operator overloads on ContLTI and DiscLTI

Minimal Example

Use the systems namespace to construct and transform models:

import jax

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

import jax.numpy as jnp
import contrax.systems as cxs

sys_c = cxs.ss(
    jnp.array([[0.0, 1.0], [0.0, 0.0]]),
    jnp.array([[0.0], [1.0]]),
    jnp.eye(2),
    jnp.zeros((2, 1)),
)
sys_d = cxs.c2d(sys_c, dt=0.05)

Canonical Model Forms

LTI And Nonlinear Systems

These equations describe common LTI and nonlinear models.

\[ \dot{x} = A x + B u, \qquad y = C x + D u \]
\[ x_{k+1} = A x_k + B u_k, \qquad y_k = C x_k + D u_k \]
\[ \dot{x} = f(t, x, u), \qquad y = h(t, x, u) \]

Port-Hamiltonian Systems

PHSSystem represents structured nonlinear dynamics.

\[ \dot{x} = \bigl(J - R(x)\bigr)\nabla H(x) + G(x)u \]

For scheduled coefficients, schedule_phs() binds an exogenous context into that canonical model:

\[ \dot{x} = \bigl(J - R(t, x, \theta(t))\bigr)\nabla H(x) + G(t, x, \theta(t))u \]

Conventions

  • A: state matrix with shape (n, n)
  • B: input matrix with shape (n, m)
  • C: output matrix with shape (p, n)
  • D: feedthrough matrix with shape (p, m)
  • DiscLTI.dt: scalar sample time stored as an array leaf

For nonlinear models, the reusable model-object contract is:

  • dynamics(t, x, u) for the transition or vector field
  • output(t, x, u) for the output or measurement map

Continuous and discrete LTI systems use the same matrix field names.

PHSSystem uses these fields:

  • H(x) is the storage function
  • R(x) is the dissipation map when present
  • G(x) is the input map when present
  • J(x) defaults to the canonical symplectic structure unless you override it
  • schedule_phs() is the extension path when dissipation or input maps depend on observed scheduling context
  • schedule_phs() can also bind scheduled structure maps J(t, x, context) when the structure itself varies with observed context

With the default J=None, the state has even dimension and ordering (q, p). Dynamics apply the canonical block swap/sign change directly and omit absent dissipation/input matrix products. Explicit J(x) and scheduled structure maps retain their supplied values and derivatives.

Structured Helpers

The structured helpers cover common operations on nonlinear and port-Hamiltonian models:

Use:

  • partition_state(x, block_sizes) to split a structured state into consecutive blocks
  • block_observation(block_sizes, block_indices) to build partial-state output maps from those blocks
  • block_matrix(row_block_sizes, col_block_sizes, blocks) to assemble dense structured input, process, or observation maps from block entries
  • symmetrize_matrix(M) and project_psd(M) when dissipation or covariance matrices pick up small numerical asymmetry or PSD drift
  • phs_diagnostics(sys, x, u) for local skew-symmetry, dissipation, and instantaneous power-balance diagnostics

The helpers operate on arrays and model data, and can be combined to build larger structured models.

Port-Hamiltonian Structure Helpers

canonical_J(n_dofs) returns the \(2n_{dofs} \times 2n_{dofs}\) canonical symplectic structure matrix:

\[ J_c = \begin{bmatrix} 0 & I_{n_{dofs}} \\ -I_{n_{dofs}} & 0 \end{bmatrix} \]

Use it when a PHSSystem has the standard canonical structure map.

phs_to_ss(sys, x_eq, u_eq) linearizes a PHS at an operating point and returns a ContLTI for a continuous model or a DiscLTI retaining an explicit discrete period. For a continuous model, this converts a structured nonlinear PHS model to the linear control design path (lqr, place, kalman_gain, and related APIs):

lti = cx.phs_to_ss(phs_sys, x_eq=jnp.zeros(4), u_eq=jnp.zeros(1))
design = cx.lqr(cx.c2d(lti, dt=0.01), Q, R)

Linearization And Discretization

linearize() maps nonlinear dynamics to a local LTI model; linearize_ss is an alias.

Use:

  • linearize(f, x0, u0) when you have a plain dynamics callable with signature (t, x, u) → x_dot; returns a ContLTI with full-state output by default
  • linearize(f, x0, u0, output=h) when you also want a specific output map h(x, u) → y; A, B, C, D are all computed and packed into the returned ContLTI
  • linearize(sys, x0, u0) when you have a reusable NonlinearSystem or PHSSystem; the system's own dynamics and output callables are used directly

A discrete model returns a DiscLTI with the original sample period. This includes ZOH models made by sample_system; continuous models and bare vector fields return ContLTI. An LTI input retains its realization. The result models perturbations around the operating point, without storing constant offsets. State/input operating points must be vectors. For FOH endpoint pairs or other structured inputs, use jax.jacfwd on the model callbacks directly. Sampled models retain the derivative support of their integration adjoint.

The local linear model is the usual first-order expansion around an operating point:

\[ \delta \dot{x} \approx A\,\delta x + B\,\delta u, \qquad \delta y \approx C\,\delta x + D\,\delta u \]

with

\[ A = \left.\frac{\partial f}{\partial x}\right|_{(x_0, u_0)}, \quad B = \left.\frac{\partial f}{\partial u}\right|_{(x_0, u_0)}, \quad C = \left.\frac{\partial h}{\partial x}\right|_{(x_0, u_0)}, \quad D = \left.\frac{\partial h}{\partial u}\right|_{(x_0, u_0)} \]

Then use c2d() when the downstream workflow is discrete-time design or simulation.

c2d converts continuous LTI matrices. For nonlinear/PHS vector fields, sample_system integrates dynamics over each sample interval and returns a discrete model with the same canonical dynamics/output callback contract. Both require a real, finite positive sample period. dss stores a supplied period in the real matrix precision; c2d promotes explicit higher-precision array periods together with its computed realization.

The c2d() methods are:

  • zoh: exact held-input discretization through the native differentiable matrix exponential; supports JVP, VJP and higher derivatives
  • tustin: a convenience bilinear transform
Pipeline from nonlinear dynamics through linearization, discretization, LQR design, and closed-loop simulation
The main model-preparation path: start with a nonlinear or structured model, build a local ContLTI with linearize(), discretize it with c2d(), then move into controller design and validation.

Interconnection

Compose LTI models with state-space algebra:

Use:

  • series(sys2, sys1) or sys2 @ sys1 for feedforward composition
  • parallel(sys1, sys2) or sys1 + sys2 for summed-output composition
  • sys1 - sys2 for parallel subtraction

Interconnected systems must share a time domain and, for discrete models, a sample period. The composition helpers validate these conditions. In addition, discrete interconnections require matching dt.

State ordering is explicit:

  • series(sys2, sys1) puts sys1 states first, then sys2
  • parallel(sys1, sys2) puts sys1 states first, then sys2

Transform Behavior

System containers are pytrees and compose with jit and vmap when their shapes are compatible. This supports vmapped linearization, batched controller design, and compiled design-and-simulation objectives.

The important user-facing transform contracts here are:

  • linearize() and linearize_ss() are designed to compose with jit and vmap
  • DiscLTI.dt is an array leaf, so batches can include different sample periods
  • composition helpers use state-space algebra with checked family, shape and matching-period contracts; period mismatch checks remain active under jit

Numerical Notes

Importing the package leaves JAX x64 mode unchanged. Precision-sensitive helpers such as c2d() require jax_enable_x64=True.

linearize() and linearize_ss() approximate behavior near the chosen operating point. Accuracy can decrease farther from that point.

The structured-system diagnostics report local algebraic properties at the supplied state and input. Interpret them with the model assumptions when assessing passivity or robustness.

contrax.systems

Public systems namespace.

This module gathers the system-model concepts that users typically look for first: LTI containers and constructors, nonlinear model containers, basic interconnection, and linearization helpers.

ContLTI

Bases: Module

Continuous-time LTI system: ẋ = Ax + Bu, y = Cx + Du.

Source code in contrax/core.py
class ContLTI(eqx.Module):
    """Continuous-time LTI system: ẋ = Ax + Bu, y = Cx + Du."""

    A: Array  # (n, n)
    B: Array  # (n, m)
    C: Array  # (p, n)
    D: Array  # (p, m)

    def __matmul__(self, other):
        from contrax.interconnect import series

        return series(self, other)

    def __add__(self, other):
        from contrax.interconnect import parallel

        return parallel(self, other)

    def __sub__(self, other):
        from contrax.interconnect import parallel

        return parallel(self, other, sign=-1.0)

DiscLTI

Bases: Module

Discrete-time LTI system: x_{k+1} = Ax_k + Bu_k, y_k = Cx_k + Du_k.

Source code in contrax/core.py
class DiscLTI(eqx.Module):
    """Discrete-time LTI system: x_{k+1} = Ax_k + Bu_k, y_k = Cx_k + Du_k."""

    A: Array  # (n, n) — state transition matrix (Phi)
    B: Array  # (n, m) — input matrix (Gamma)
    C: Array  # (p, n)
    D: Array  # (p, m)
    dt: Array  # scalar — sampling period as plain JAX array, NOT static

    def __matmul__(self, other):
        from contrax.interconnect import series

        return series(self, other)

    def __add__(self, other):
        from contrax.interconnect import parallel

        return parallel(self, other)

    def __sub__(self, other):
        from contrax.interconnect import parallel

        return parallel(self, other, sign=-1.0)

NonlinearSystem

Bases: Module

Container for nonlinear dynamics and an optional output map.

Define dynamics with dynamics(t, x, u) and the measured output with output(t, x, u).

Estimators can use the model's dynamics, input-dependent output, and sample period directly. Recursive-estimator callables use the same (t, x, u) signature and require a separate observation function. For discrete models, step(t, x, u) returns the next state with numerical diagnostics.

Source code in contrax/nonlinear.py
class NonlinearSystem(eqx.Module):
    """Container for nonlinear dynamics and an optional output map.

    Define dynamics with `dynamics(t, x, u)` and the measured output with
    `output(t, x, u)`.

    Estimators can use the model's dynamics, input-dependent output, and sample
    period directly. Recursive-estimator callables use the same `(t, x, u)`
    signature and require a separate observation function. For discrete models,
    `step(t, x, u)` returns the next state with numerical diagnostics.
    """

    dynamics: Callable
    observation: Callable | None = None
    dt: Array | None = None
    state_dim: int | None = eqx.field(static=True, default=None)
    input_dim: int | None = eqx.field(static=True, default=None)
    output_dim: int | None = eqx.field(static=True, default=None)
    _step_fn: Callable | None = None
    _input_shape: tuple[int, ...] | None = eqx.field(static=True, default=None)

    def output(self, t: Array | float, x: Array, u: Array) -> Array:
        """Evaluate the observation map, defaulting to the full state."""
        if self.observation is None:
            return x
        return self.observation(t, x, u)

    def step(self, t: Array | float, x: Array, u: Array) -> SampleResult:
        """Evaluate a discrete transition and retain integration diagnostics.

        Close over the model in jax.jit, or use Equinox filter_jit on this bound
        method. For sampled models, the model's integration adjoint determines
        derivative support; ordinary discrete transitions use their JAX graph.

        Args:
            t: Transition start time. Shape: scalar.
            x: Current state. Shape: `(n,)`.
            u: Transition input, or an FOH endpoint pair for a sampled FOH model.

        Returns:
            SampleResult: Next state, integration status and step count.
        """
        if self.dt is None:
            raise ValueError(
                "step requires a discrete system; sample the vector field first"
            )
        if self._step_fn is not None:
            return self._step_fn(t, x, u)
        next_x = self.dynamics(t, x, u)
        return SampleResult(
            next_x, jnp.where(jnp.all(jnp.isfinite(next_x)), 0, 3), jnp.asarray(1)
        )

output(t: Array | float, x: Array, u: Array) -> Array

Evaluate the observation map, defaulting to the full state.

Source code in contrax/nonlinear.py
def output(self, t: Array | float, x: Array, u: Array) -> Array:
    """Evaluate the observation map, defaulting to the full state."""
    if self.observation is None:
        return x
    return self.observation(t, x, u)

step(t: Array | float, x: Array, u: Array) -> SampleResult

Evaluate a discrete transition and retain integration diagnostics.

Close over the model in jax.jit, or use Equinox filter_jit on this bound method. For sampled models, the model's integration adjoint determines derivative support; ordinary discrete transitions use their JAX graph.

Parameters:

Name Type Description Default
t Array | float

Transition start time. Shape: scalar.

required
x Array

Current state. Shape: (n,).

required
u Array

Transition input, or an FOH endpoint pair for a sampled FOH model.

required

Returns:

Name Type Description
SampleResult SampleResult

Next state, integration status and step count.

Source code in contrax/nonlinear.py
def step(self, t: Array | float, x: Array, u: Array) -> SampleResult:
    """Evaluate a discrete transition and retain integration diagnostics.

    Close over the model in jax.jit, or use Equinox filter_jit on this bound
    method. For sampled models, the model's integration adjoint determines
    derivative support; ordinary discrete transitions use their JAX graph.

    Args:
        t: Transition start time. Shape: scalar.
        x: Current state. Shape: `(n,)`.
        u: Transition input, or an FOH endpoint pair for a sampled FOH model.

    Returns:
        SampleResult: Next state, integration status and step count.
    """
    if self.dt is None:
        raise ValueError(
            "step requires a discrete system; sample the vector field first"
        )
    if self._step_fn is not None:
        return self._step_fn(t, x, u)
    next_x = self.dynamics(t, x, u)
    return SampleResult(
        next_x, jnp.where(jnp.all(jnp.isfinite(next_x)), 0, 3), jnp.asarray(1)
    )

PHSSystem

Bases: Module

Port-Hamiltonian system with user-supplied storage and structure maps.

Source code in contrax/phs.py
class PHSSystem(eqx.Module):
    """Port-Hamiltonian system with user-supplied storage and structure maps."""

    H: Callable[[Array], Array]
    R: Callable[[Array], Array] | None = None
    G: Callable[[Array], Array] | None = None
    J: Callable[[Array], Array] | None = None
    observation: Callable | None = None
    dt: Array | None = None
    # Dimension metadata. static=True is appropriate here because these are
    # structural integers, not array leaves. Consequence: vmapping over a batch
    # of PHSSystem instances requires all systems in the batch to share the same
    # (state_dim, input_dim, output_dim) values or JAX will retrace per shape.
    state_dim: int | None = eqx.field(static=True, default=None)
    input_dim: int | None = eqx.field(static=True, default=None)
    output_dim: int | None = eqx.field(static=True, default=None)

    def dynamics(self, t: Array | float, x: Array, u: Array) -> Array:
        """Evaluate the port-Hamiltonian vector field."""
        del t  # State-only structure maps; use schedule_phs for time dependence.
        dim = x.shape[0]
        grad_H = jax.grad(self.H)(x)
        if self.J is None:
            if dim == 0 or dim % 2:
                raise ValueError(
                    "Canonical PHS states must have positive even dimension"
                )
            # Canonical J @ grad(H) is a block swap/sign change; no dense J or
            # zero dissipation/input matrices are needed for this structure.
            half = dim // 2
            value = jnp.concatenate([grad_H[half:], -grad_H[:half]])
        else:
            value = self.J(x) @ grad_H
        if self.R is not None:
            value = value - self.R(x) @ grad_H
        if self.G is not None:
            value = value + self.G(x) @ u
        return value

    def output(self, t: Array | float, x: Array, u: Array) -> Array:
        """Evaluate the observation map, defaulting to the full state."""
        if self.observation is None:
            return x
        return self.observation(t, x, u)

    def as_nonlinear_system(self) -> NonlinearSystem:
        """Return the model as a generic nonlinear system."""
        return NonlinearSystem(
            dynamics=self.dynamics,
            observation=self.output,
            dt=self.dt,
            state_dim=self.state_dim,
            input_dim=self.input_dim,
            output_dim=self.output_dim,
        )

as_nonlinear_system() -> NonlinearSystem

Return the model as a generic nonlinear system.

Source code in contrax/phs.py
def as_nonlinear_system(self) -> NonlinearSystem:
    """Return the model as a generic nonlinear system."""
    return NonlinearSystem(
        dynamics=self.dynamics,
        observation=self.output,
        dt=self.dt,
        state_dim=self.state_dim,
        input_dim=self.input_dim,
        output_dim=self.output_dim,
    )

dynamics(t: Array | float, x: Array, u: Array) -> Array

Evaluate the port-Hamiltonian vector field.

Source code in contrax/phs.py
def dynamics(self, t: Array | float, x: Array, u: Array) -> Array:
    """Evaluate the port-Hamiltonian vector field."""
    del t  # State-only structure maps; use schedule_phs for time dependence.
    dim = x.shape[0]
    grad_H = jax.grad(self.H)(x)
    if self.J is None:
        if dim == 0 or dim % 2:
            raise ValueError(
                "Canonical PHS states must have positive even dimension"
            )
        # Canonical J @ grad(H) is a block swap/sign change; no dense J or
        # zero dissipation/input matrices are needed for this structure.
        half = dim // 2
        value = jnp.concatenate([grad_H[half:], -grad_H[:half]])
    else:
        value = self.J(x) @ grad_H
    if self.R is not None:
        value = value - self.R(x) @ grad_H
    if self.G is not None:
        value = value + self.G(x) @ u
    return value

output(t: Array | float, x: Array, u: Array) -> Array

Evaluate the observation map, defaulting to the full state.

Source code in contrax/phs.py
def output(self, t: Array | float, x: Array, u: Array) -> Array:
    """Evaluate the observation map, defaulting to the full state."""
    if self.observation is None:
        return x
    return self.observation(t, x, u)

SeparablePHSSystem

Bases: PHSSystem

PHSSystem subtype for separable Hamiltonians H(q,p) = V(q) + T(p).

Stores V and T as separate callables so structure-preserving integrators such as Störmer-Verlet can use their gradients directly.

The stored H is always the derived V(q) + T(p). All PHS behaviour (dynamics, diagnostics, simulation) falls through to PHSSystem unchanged.

Create instances with separable_phs_system(), which validates the fields and derives the Hamiltonian from V and T.

Source code in contrax/phs.py
class SeparablePHSSystem(PHSSystem):
    """PHSSystem subtype for separable Hamiltonians H(q,p) = V(q) + T(p).

    Stores `V` and `T` as separate callables so structure-preserving integrators
    such as Störmer-Verlet can use their gradients directly.

    The stored `H` is always the derived `V(q) + T(p)`.  All PHS behaviour
    (dynamics, diagnostics, simulation) falls through to `PHSSystem` unchanged.

    Create instances with `separable_phs_system()`, which validates the fields
    and derives the Hamiltonian from `V` and `T`.
    """

    V: Callable[[Array], Array] | None = None
    T: Callable[[Array], Array] | None = None
    n_dofs: int = eqx.field(static=True, default=0)

    def __check_init__(self) -> None:
        if self.V is None or self.T is None:
            raise ValueError(
                "SeparablePHSSystem requires V and T. "
                "Use separable_phs_system() to construct."
            )
        if self.n_dofs < 1:
            raise ValueError(
                f"n_dofs must be >= 1, got {self.n_dofs}. "
                "Use separable_phs_system() to construct."
            )

as_nonlinear_system() -> NonlinearSystem

Return the model as a generic nonlinear system.

Source code in contrax/phs.py
def as_nonlinear_system(self) -> NonlinearSystem:
    """Return the model as a generic nonlinear system."""
    return NonlinearSystem(
        dynamics=self.dynamics,
        observation=self.output,
        dt=self.dt,
        state_dim=self.state_dim,
        input_dim=self.input_dim,
        output_dim=self.output_dim,
    )

dynamics(t: Array | float, x: Array, u: Array) -> Array

Evaluate the port-Hamiltonian vector field.

Source code in contrax/phs.py
def dynamics(self, t: Array | float, x: Array, u: Array) -> Array:
    """Evaluate the port-Hamiltonian vector field."""
    del t  # State-only structure maps; use schedule_phs for time dependence.
    dim = x.shape[0]
    grad_H = jax.grad(self.H)(x)
    if self.J is None:
        if dim == 0 or dim % 2:
            raise ValueError(
                "Canonical PHS states must have positive even dimension"
            )
        # Canonical J @ grad(H) is a block swap/sign change; no dense J or
        # zero dissipation/input matrices are needed for this structure.
        half = dim // 2
        value = jnp.concatenate([grad_H[half:], -grad_H[:half]])
    else:
        value = self.J(x) @ grad_H
    if self.R is not None:
        value = value - self.R(x) @ grad_H
    if self.G is not None:
        value = value + self.G(x) @ u
    return value

output(t: Array | float, x: Array, u: Array) -> Array

Evaluate the observation map, defaulting to the full state.

Source code in contrax/phs.py
def output(self, t: Array | float, x: Array, u: Array) -> Array:
    """Evaluate the observation map, defaulting to the full state."""
    if self.observation is None:
        return x
    return self.observation(t, x, u)

block_matrix(row_block_sizes: Sequence[int], col_block_sizes: Sequence[int], blocks: dict[tuple[int, int], ArrayLike] | None = None, *, dtype: dtype | None = None) -> Array

Assemble blocks with positive static sizes and (row, column) index keys.

Entries outside the declared grid or with incorrect shapes raise ValueError. Omitted blocks are zero; provided blocks determine the promoted dtype.

Source code in contrax/phs.py
def block_matrix(
    row_block_sizes: Sequence[int],
    col_block_sizes: Sequence[int],
    blocks: dict[tuple[int, int], ArrayLike] | None = None,
    *,
    dtype: jnp.dtype | None = None,
) -> Array:
    """Assemble blocks with positive static sizes and (row, column) index keys.

    Entries outside the declared grid or with incorrect shapes raise ValueError.
    Omitted blocks are zero; provided blocks determine the promoted dtype.
    """
    row_sizes = _block_sizes(row_block_sizes)
    col_sizes = _block_sizes(col_block_sizes)

    block_entries = {} if blocks is None else dict(blocks)
    for key in block_entries:
        if not isinstance(key, tuple) or len(key) != 2:
            raise ValueError("Block keys must be (row, column) index pairs")
        i, j = (dimension(index, "block index") for index in key)
        if i is None or j is None or i >= len(row_sizes) or j >= len(col_sizes):
            raise ValueError("Block index lies outside the declared block grid")

    # First pass: determine the fully promoted dtype across all user-supplied blocks.
    result_dtype = jnp.float32 if dtype is None else dtype
    for block_val in block_entries.values():
        result_dtype = jnp.result_type(result_dtype, jnp.asarray(block_val).dtype)

    # Second pass: build rows with a consistent dtype so zero-fill blocks match.
    rows = []
    for i, row_size in enumerate(row_sizes):
        row = []
        for j, col_size in enumerate(col_sizes):
            if (i, j) in block_entries:
                block = jnp.asarray(block_entries[(i, j)], dtype=result_dtype)
                if block.shape != (row_size, col_size):
                    raise ValueError(
                        f"Block {(i, j)} has shape {block.shape}, expected "
                        f"{(row_size, col_size)}."
                    )
            else:
                block = jnp.zeros((row_size, col_size), dtype=result_dtype)
            row.append(block)
        rows.append(row)

    return jnp.block(rows)

block_observation(block_sizes: Sequence[int], block_indices: Sequence[int]) -> Callable[[Array | float, Array, Array], Array]

Concatenate selected blocks, using positive static sizes and valid indices.

Source code in contrax/phs.py
def block_observation(
    block_sizes: Sequence[int],
    block_indices: Sequence[int],
) -> Callable[[Array | float, Array, Array], Array]:
    """Concatenate selected blocks, using positive static sizes and valid indices."""
    sizes = _block_sizes(block_sizes)
    indices = tuple(dimension(index, "block index") for index in block_indices)
    if not indices:
        raise ValueError("block_indices must not be empty.")
    if any(index is None or index >= len(sizes) for index in indices):
        raise ValueError("block_indices must refer to valid state blocks.")

    def output(t: Array | float, x: Array, u: Array) -> Array:
        del t, u
        parts = partition_state(x, sizes)
        return jnp.concatenate([parts[index] for index in indices], axis=0)

    return output

c2d(sys: ContLTI, dt: float, method: str = 'zoh') -> DiscLTI

Discretize a continuous-time state-space system.

Converts a ContLTI into a DiscLTI using either zero-order hold or the bilinear Tustin transform.

The zero-order-hold path uses the JAX matrix exponential, including for derivatives. Requires float64 precision.

Parameters:

Name Type Description Default
sys ContLTI

Continuous-time system.

required
dt float

Sampling period. Shape: scalar.

required
method str

Discretization method. Supported values are "zoh" and "tustin".

'zoh'

Returns:

Type Description
DiscLTI

DiscLTI: A discrete-time realization with the requested sample time and the selected hold/frequency mapping.

Raises:

Type Description
ValueError

If method is not one of the supported discretizations.

Examples:

>>> import jax
>>> jax.config.update("jax_enable_x64", True)
>>> import jax.numpy as jnp
>>> import contrax as cx
>>> sys_c = cx.ss(
...     jnp.array([[0.0, 1.0], [0.0, 0.0]]),
...     jnp.array([[0.0], [1.0]]),
...     jnp.eye(2),
...     jnp.zeros((2, 1)),
... )
>>> sys_d = cx.c2d(sys_c, dt=0.05)
Source code in contrax/core.py
def c2d(sys: ContLTI, dt: float, method: str = "zoh") -> DiscLTI:
    """Discretize a continuous-time state-space system.

    Converts a [ContLTI][contrax.systems.ContLTI] into a
    [DiscLTI][contrax.systems.DiscLTI] using either zero-order hold or the
    bilinear Tustin transform.

    The zero-order-hold path uses the JAX matrix exponential, including for
    derivatives. Requires float64 precision.

    Args:
        sys: Continuous-time system.
        dt: Sampling period. Shape: scalar.
        method: Discretization method. Supported values are `"zoh"` and
            `"tustin"`.

    Returns:
        [DiscLTI][contrax.systems.DiscLTI]: A discrete-time realization with
            the requested sample time and the selected hold/frequency mapping.

    Raises:
        ValueError: If `method` is not one of the supported discretizations.

    Examples:
        >>> import jax
        >>> jax.config.update("jax_enable_x64", True)
        >>> import jax.numpy as jnp
        >>> import contrax as cx
        >>> sys_c = cx.ss(
        ...     jnp.array([[0.0, 1.0], [0.0, 0.0]]),
        ...     jnp.array([[0.0], [1.0]]),
        ...     jnp.eye(2),
        ...     jnp.zeros((2, 1)),
        ... )
        >>> sys_d = cx.c2d(sys_c, dt=0.05)
    """
    if not isinstance(sys, ContLTI):
        raise TypeError(
            "c2d requires continuous LTI; use sample_system for nonlinear models"
        )
    require_x64("c2d")
    # Keep weak scalar periods in the model precision, while promoting explicit
    # higher-precision array periods consistently with the computed matrices.
    dt, _ = _period(dt, jnp.result_type(sys.A.real, dt, 1.0))
    if method == "zoh":
        return _c2d_zoh(sys, dt)
    elif method == "tustin":
        return _c2d_tustin(sys, dt)
    else:
        raise ValueError(f"Unknown discretization method: {method!r}")

canonical_J(n_dofs: int, *, dtype: dtype | None = None) -> Array

Construct the (2n, 2n) canonical structure for a positive static integer n.

Source code in contrax/phs.py
def canonical_J(n_dofs: int, *, dtype: jnp.dtype | None = None) -> Array:
    """Construct the (2n, 2n) canonical structure for a positive static integer n."""
    n_dofs = dimension(n_dofs, "n_dofs", minimum=1)
    if n_dofs is None:
        raise ValueError("n_dofs must be positive.")
    dtype = jnp.float32 if dtype is None else dtype
    eye = jnp.eye(n_dofs, dtype=dtype)
    zeros = jnp.zeros((n_dofs, n_dofs), dtype=dtype)
    return jnp.block([[zeros, eye], [-eye, zeros]])

dss(A: ArrayLike, B: ArrayLike, C: ArrayLike, D: ArrayLike, dt: ArrayLike) -> DiscLTI

Construct a discrete-time state-space system.

Wraps discrete-time matrices in a DiscLTI pytree with MATLAB-familiar ss(A, B, C, D, dt) ergonomics.

Stores validated matrices and the sampling period in a DiscLTI pytree. The sample period is an array leaf, so it can vary across batched systems. Matrix dtypes are promoted together, preserving explicit float32 data. The sample time uses the resulting real matrix dtype.

Parameters:

Name Type Description Default
A ArrayLike

State transition matrix. Shape: (n, n).

required
B ArrayLike

Input matrix. Shape: (n, m).

required
C ArrayLike

Output matrix. Shape: (p, n).

required
D ArrayLike

Feedthrough matrix. Shape: (p, m).

required
dt ArrayLike

Finite positive sampling period. Shape: scalar.

required

Returns:

Type Description
DiscLTI

DiscLTI: A discrete-time system with array-valued A, B, C, D, and dt fields.

Raises:

Type Description
ValueError

If matrix shapes or the real, positive sample time are invalid.

Examples:

>>> import jax.numpy as jnp
>>> import contrax as cx
>>> sys = cx.dss(
...     jnp.array([[1.0, 0.1], [0.0, 1.0]]),
...     jnp.array([[0.0], [0.1]]),
...     jnp.eye(2),
...     jnp.zeros((2, 1)),
...     dt=0.1,
... )
Source code in contrax/core.py
def dss(
    A: ArrayLike,
    B: ArrayLike,
    C: ArrayLike,
    D: ArrayLike,
    dt: ArrayLike,
) -> DiscLTI:
    """Construct a discrete-time state-space system.

    Wraps discrete-time matrices in a
    [DiscLTI][contrax.systems.DiscLTI] pytree with MATLAB-familiar
    `ss(A, B, C, D, dt)` ergonomics.

    Stores validated matrices and the sampling period in a `DiscLTI` pytree.
    The sample period is an array leaf, so it can vary across batched systems.
    Matrix dtypes are promoted together, preserving explicit float32 data.
    The sample time uses the resulting real matrix dtype.

    Args:
        A: State transition matrix. Shape: `(n, n)`.
        B: Input matrix. Shape: `(n, m)`.
        C: Output matrix. Shape: `(p, n)`.
        D: Feedthrough matrix. Shape: `(p, m)`.
        dt: Finite positive sampling period. Shape: scalar.

    Returns:
        [DiscLTI][contrax.systems.DiscLTI]: A discrete-time system with
            array-valued `A`, `B`, `C`, `D`, and `dt` fields.

    Raises:
        ValueError: If matrix shapes or the real, positive sample time are invalid.

    Examples:
        >>> import jax.numpy as jnp
        >>> import contrax as cx
        >>> sys = cx.dss(
        ...     jnp.array([[1.0, 0.1], [0.0, 1.0]]),
        ...     jnp.array([[0.0], [0.1]]),
        ...     jnp.eye(2),
        ...     jnp.zeros((2, 1)),
        ...     dt=0.1,
        ... )
    """
    A, B, C, D = _lti_arrays(A, B, C, D)
    dt, invalid = _period(dt, A.real.dtype)
    result = DiscLTI(A=A, B=B, C=C, D=D, dt=dt)
    return eqx.error_if(result, invalid, "dt must be finite and positive")

linearize(model_or_f: DynamicsLike, x0: Array, u0: Array, *, output: OutputFn | None = None, t: ArrayLike = 0.0) -> ContLTI | DiscLTI

Linearize dynamics and output maps, preserving a model's time domain.

Computes A, B, C, and D Jacobians at (x0, u0) using forward-mode automatic differentiation and returns them as a ContLTI or DiscLTI. A discrete model supplies its sample period; plain dynamics callables are interpreted as continuous vector fields. Supports jit, vmap and derivatives whenever the underlying callbacks do. Sampled models inherit their integration adjoint's transform support. State and input operating points must be vectors; use jacfwd directly for structured inputs such as FOH endpoint pairs.

Use the local model for discretization and controller design. For example:

sys_lin = cx.linearize(nonlinear_sys, x_eq, u_eq)
sys_d   = cx.c2d(sys_lin, dt=0.05)
K       = cx.lqr(sys_d, Q, R).K
cx.simulate(nonlinear_sys, x0, lambda t, x: -K @ x, ...)

Parameters:

Name Type Description Default
model_or_f DynamicsLike

A continuous/discrete LTI or nonlinear system, or a pure JAX vector field with signature (t, x, u) → x_dot.

required
x0 Array

State operating point. Shape: (n,).

required
u0 Array

Input operating point. Shape: (m,).

required
output OutputFn | None

Output function (x, u) → y for plain dynamics callables. When omitted, defaults to the full state (C = I, D = 0). Omit this when passing a system model with its own output map.

None
t ArrayLike

Operating-point time. Relevant for time-varying system models.

0.0

Returns:

Type Description
ContLTI | DiscLTI

ContLTI | DiscLTI: Local perturbation model with Jacobians A, B, C, D. Discrete models retain their sample period. Constant operating-point offsets are not stored; states/inputs represent deviations from x0/u0.

Examples:

>>> import jax.numpy as jnp
>>> import contrax as cx
>>> def dynamics(t, x, u):
...     return jnp.array([x[1], -jnp.sin(x[0]) + u[0]])
>>> sys_lin = cx.linearize(dynamics, jnp.zeros(2), jnp.zeros(1))
Source code in contrax/core.py
def linearize(
    model_or_f: DynamicsLike,
    x0: Array,
    u0: Array,
    *,
    output: OutputFn | None = None,
    t: ArrayLike = 0.0,
) -> ContLTI | DiscLTI:
    """Linearize dynamics and output maps, preserving a model's time domain.

    Computes `A`, `B`, `C`, and `D` Jacobians at `(x0, u0)` using forward-mode
    automatic differentiation and returns them as a
    [ContLTI][contrax.systems.ContLTI] or [DiscLTI][contrax.systems.DiscLTI].
    A discrete model supplies its sample period; plain dynamics callables are
    interpreted as continuous vector fields. Supports jit, vmap and derivatives
    whenever the underlying callbacks do. Sampled models inherit their integration
    adjoint's transform support. State and input operating points must be vectors;
    use jacfwd directly for structured inputs such as FOH endpoint pairs.

    Use the local model for discretization and controller design. For example:

    ```python
    sys_lin = cx.linearize(nonlinear_sys, x_eq, u_eq)
    sys_d   = cx.c2d(sys_lin, dt=0.05)
    K       = cx.lqr(sys_d, Q, R).K
    cx.simulate(nonlinear_sys, x0, lambda t, x: -K @ x, ...)
    ```

    Args:
        model_or_f: A continuous/discrete LTI or nonlinear system, or a pure JAX
            vector field with signature `(t, x, u) → x_dot`.
        x0: State operating point. Shape: `(n,)`.
        u0: Input operating point. Shape: `(m,)`.
        output: Output function `(x, u) → y` for plain dynamics callables.
            When omitted, defaults to the full state (`C = I`, `D = 0`).
            Omit this when passing a system model with its own output map.
        t: Operating-point time. Relevant for time-varying system models.

    Returns:
        ContLTI | DiscLTI: Local perturbation model with Jacobians A, B, C, D.
            Discrete models retain their sample period. Constant operating-point
            offsets are not stored; states/inputs represent deviations from x0/u0.

    Examples:
        >>> import jax.numpy as jnp
        >>> import contrax as cx
        >>> def dynamics(t, x, u):
        ...     return jnp.array([x[1], -jnp.sin(x[0]) + u[0]])
        >>> sys_lin = cx.linearize(dynamics, jnp.zeros(2), jnp.zeros(1))
    """
    x0, u0 = jnp.asarray(x0), jnp.asarray(u0)
    if x0.ndim != 1 or u0.ndim != 1:
        raise ValueError("x0 and u0 must be vectors; use jacfwd for structured inputs")
    dtype = jnp.result_type(x0, u0, 1.0)
    x0, u0 = x0.astype(dtype), u0.astype(dtype)
    t_arr = jnp.asarray(t, dtype=x0.real.dtype)
    if t_arr.ndim != 0:
        raise ValueError("t must be scalar")

    if isinstance(model_or_f, (ContLTI, DiscLTI)):
        if output is not None:
            raise ValueError("linearize(sys, x0, u0) does not accept output=.")
        if x0.shape != (model_or_f.A.shape[0],) or u0.shape != (model_or_f.B.shape[1],):
            raise ValueError("Operating-point dimensions must match the LTI model")
        return model_or_f

    if _is_system_model(model_or_f):
        if output is not None:
            raise ValueError("linearize(sys, x0, u0) does not accept output=.")
        f = model_or_f.dynamics
        h = model_or_f.output
    else:
        f = model_or_f
        if output is not None:

            def h(t_: Array, x: Array, u_: Array) -> Array:
                del t_
                return output(x, u_)
        else:

            def h(t_: Array, x: Array, u_: Array) -> Array:
                del t_, u_
                return x

    A = jax.jacfwd(f, argnums=1)(t_arr, x0, u0)
    B = jax.jacfwd(f, argnums=2)(t_arr, x0, u0)
    C = jax.jacfwd(h, argnums=1)(t_arr, x0, u0)
    D = jax.jacfwd(h, argnums=2)(t_arr, x0, u0)
    dt = _system_dt(model_or_f) if _is_system_model(model_or_f) else None
    return ss(A, B, C, D) if dt is None else dss(A, B, C, D, dt)

nonlinear_system(dynamics: Callable, output: Callable | None = None, *, observation: Callable | None = None, dt: ArrayLike | None = None, state_dim: int | None = None, input_dim: int | None = None, output_dim: int | None = None) -> NonlinearSystem

Construct a reusable nonlinear system model.

The model supplies dynamics with signature dynamics(t, x, u) and an optional output map with signature output(t, x, u). Use it for nonlinear simulation, linearization, and estimation.

Parameters:

Name Type Description Default
dynamics Callable

State transition or vector-field callable with signature (t, x, u).

required
output Callable | None

Optional output/measurement map with signature (t, x, u). When omitted, the full state is used as the output.

None
observation Callable | None

Deprecated synonym for output. Pass only one of output or observation.

None
dt ArrayLike | None

Optional finite positive real scalar sample time. Omit for continuous models.

None
state_dim int | None

Optional nonnegative static integer state dimension.

None
input_dim int | None

Optional static input dimension. Enables omitted zero controls in recursive estimators; otherwise provide us for controlled models.

None
output_dim int | None

Optional static output dimension hint. Same caveat as state_dim.

None

Returns:

Type Description
NonlinearSystem

NonlinearSystem: A reusable nonlinear system model.

Source code in contrax/nonlinear.py
def nonlinear_system(
    dynamics: Callable,
    output: Callable | None = None,
    *,
    observation: Callable | None = None,
    dt: ArrayLike | None = None,
    state_dim: int | None = None,
    input_dim: int | None = None,
    output_dim: int | None = None,
) -> NonlinearSystem:
    """Construct a reusable nonlinear system model.

    The model supplies dynamics with signature `dynamics(t, x, u)` and an
    optional output map with signature `output(t, x, u)`. Use it for nonlinear
    simulation, linearization, and estimation.

    Args:
        dynamics: State transition or vector-field callable with signature
            `(t, x, u)`.
        output: Optional output/measurement map with signature `(t, x, u)`.
            When omitted, the full state is used as the output.
        observation: Deprecated synonym for `output`. Pass only one of
            `output` or `observation`.
        dt: Optional finite positive real scalar sample time. Omit for
            continuous models.
        state_dim: Optional nonnegative static integer state dimension.
        input_dim: Optional static input dimension. Enables omitted zero controls
            in recursive estimators; otherwise provide us for controlled models.
        output_dim: Optional static output dimension hint. Same caveat as
            `state_dim`.

    Returns:
        [NonlinearSystem][contrax.systems.NonlinearSystem]: A reusable
            nonlinear system model.
    """
    if output is not None and observation is not None:
        raise ValueError("Pass only one of output= or observation=.")
    observation = output if output is not None else observation
    dt_arr, invalid = (None, False) if dt is None else sample_period(dt)
    result = NonlinearSystem(
        dynamics=dynamics,
        observation=observation,
        dt=dt_arr,
        state_dim=dimension(state_dim, "state_dim"),
        input_dim=dimension(input_dim, "input_dim"),
        output_dim=dimension(output_dim, "output_dim"),
    )
    return (
        result
        if dt is None
        else eqx.error_if(result, invalid, "dt must be finite and positive")
    )

parallel(sys1: DiscLTI | ContLTI, sys2: DiscLTI | ContLTI, *, sign: float = 1.0) -> DiscLTI | ContLTI

Connect two systems in parallel and sum their outputs.

Parameters:

Name Type Description Default
sys1 DiscLTI | ContLTI

First system.

required
sys2 DiscLTI | ContLTI

Second system.

required
sign float

Output sign applied to sys2. Use -1.0 for subtraction.

1.0

Returns:

Type Description
DiscLTI | ContLTI

DiscLTI | ContLTI: Interconnected system.

Source code in contrax/interconnect.py
def parallel(
    sys1: DiscLTI | ContLTI,
    sys2: DiscLTI | ContLTI,
    *,
    sign: float = 1.0,
) -> DiscLTI | ContLTI:
    """Connect two systems in parallel and sum their outputs.

    Args:
        sys1: First system.
        sys2: Second system.
        sign: Output sign applied to `sys2`. Use `-1.0` for subtraction.

    Returns:
        [DiscLTI][contrax.systems.DiscLTI] |
            [ContLTI][contrax.systems.ContLTI]: Interconnected system.
    """
    mismatch = _check_same_family(sys1, sys2)
    if sys1.B.shape[1] != sys2.B.shape[1]:
        raise ValueError("Parallel interconnection requires matching input dimensions.")
    if sys1.C.shape[0] != sys2.C.shape[0]:
        raise ValueError(
            "Parallel interconnection requires matching output dimensions."
        )

    A1, B1, C1, D1 = sys1.A, sys1.B, sys1.C, sys1.D
    A2, B2, C2, D2 = sys2.A, sys2.B, sys2.C, sys2.D
    n1, n2 = A1.shape[0], A2.shape[0]
    sign_arr = jnp.asarray(sign, dtype=jnp.result_type(A1, A2, D1, D2, sign, 1.0))
    if sign_arr.ndim != 0:
        raise ValueError("sign must be scalar")

    A = jnp.block(
        [
            [A1, jnp.zeros((n1, n2), dtype=A1.dtype)],
            [jnp.zeros((n2, n1), dtype=A2.dtype), A2],
        ]
    )
    B = jnp.vstack([B1, B2])
    C = jnp.hstack([C1, sign_arr * C2])
    D = D1 + sign_arr * D2

    if isinstance(sys1, DiscLTI):
        result = DiscLTI(A=A, B=B, C=C, D=D, dt=sys1.dt)
        return eqx.error_if(
            result, mismatch, "Discrete interconnections require matching dt"
        )
    return ContLTI(A=A, B=B, C=C, D=D)

partition_state(x: ArrayLike, block_sizes: Sequence[int]) -> tuple[Array, ...]

Split a vector into blocks with positive static integer sizes summing to n.

Source code in contrax/phs.py
def partition_state(x: ArrayLike, block_sizes: Sequence[int]) -> tuple[Array, ...]:
    """Split a vector into blocks with positive static integer sizes summing to n."""
    x = jnp.asarray(x)
    sizes = _block_sizes(block_sizes)
    if x.ndim != 1:
        raise ValueError("partition_state() expects a one-dimensional state vector.")
    total = sum(sizes)
    if x.shape[0] != total:
        raise ValueError(
            f"State length {x.shape[0]} does not match block_sizes sum {total}."
        )
    offsets = [0]
    for size in sizes:
        offsets.append(offsets[-1] + size)
    return tuple(x[offsets[i] : offsets[i + 1]] for i in range(len(sizes)))

phs_diagnostics(sys: PHSSystem, x: ArrayLike, u: ArrayLike | None = None) -> PHSStructureDiagnostics

Evaluate local structure and power-balance diagnostics for a PHS state.

Source code in contrax/phs.py
def phs_diagnostics(
    sys: PHSSystem,
    x: ArrayLike,
    u: ArrayLike | None = None,
) -> PHSStructureDiagnostics:
    """Evaluate local structure and power-balance diagnostics for a PHS state."""
    x = jnp.asarray(x)
    u_arr = jnp.zeros((0,), dtype=x.dtype) if u is None else jnp.asarray(u)
    dim = x.shape[0]
    J = sys.J(x) if sys.J is not None else canonical_J(dim // 2, dtype=x.dtype)
    R = sys.R(x) if sys.R is not None else jnp.zeros((dim, dim), dtype=x.dtype)
    G = sys.G(x) if sys.G is not None else _zero_input_matrix(x, u_arr)
    grad_H = jax.grad(sys.H)(x)
    R_sym = symmetrize_matrix(R)
    # Reuse already-computed J, R, G rather than calling sys.dynamics() again.
    Gu = G @ u_arr
    xdot = (J - R) @ grad_H + Gu
    storage_rate = jnp.vdot(grad_H, xdot)
    dissipation_power = jnp.vdot(grad_H, R_sym @ grad_H)
    supplied_power = jnp.vdot(grad_H, Gu)
    # Standard PHS energy balance: dH/dt = supply - dissipation
    # => supplied_power - dissipation_power - storage_rate = 0
    return PHSStructureDiagnostics(
        skew_symmetry_error=jnp.max(jnp.abs(J + J.T)),
        dissipation_symmetry_error=jnp.max(jnp.abs(R - R.T)),
        min_dissipation_eigenvalue=jnp.min(jnp.linalg.eigvalsh(R_sym)),
        storage_rate=storage_rate,
        dissipation_power=dissipation_power,
        supplied_power=supplied_power,
        power_balance_residual=supplied_power - dissipation_power - storage_rate,
    )

phs_system(H: Callable[[Array], Array], *, R: Callable[[Array], Array] | None = None, G: Callable[[Array], Array] | None = None, J: Callable[[Array], Array] | None = None, output: Callable | None = None, observation: Callable | None = None, dt: ArrayLike | None = None, state_dim: int | None = None, input_dim: int | None = None, output_dim: int | None = None) -> PHSSystem

Construct a port-Hamiltonian system object.

Parameters:

Name Type Description Default
H Callable[[Array], Array]

Storage/Hamiltonian function.

required
R Callable[[Array], Array] | None

Optional dissipation map.

None
G Callable[[Array], Array] | None

Optional input map.

None
J Callable[[Array], Array] | None

Optional structure map.

None
output Callable | None

Optional output/measurement map with signature (t, x, u).

None
observation Callable | None

Deprecated synonym for output. Pass only one of output or observation.

None
dt ArrayLike | None

Optional finite positive real scalar sample time.

None
state_dim int | None

Optional nonnegative static integer; positive even for default J.

None
input_dim int | None

Optional nonnegative static integer input dimension.

None
output_dim int | None

Optional nonnegative static integer output dimension.

None

Returns:

Type Description
PHSSystem

PHSSystem: A reusable port-Hamiltonian model.

Source code in contrax/phs.py
def phs_system(
    H: Callable[[Array], Array],
    *,
    R: Callable[[Array], Array] | None = None,
    G: Callable[[Array], Array] | None = None,
    J: Callable[[Array], Array] | None = None,
    output: Callable | None = None,
    observation: Callable | None = None,
    dt: ArrayLike | None = None,
    state_dim: int | None = None,
    input_dim: int | None = None,
    output_dim: int | None = None,
) -> PHSSystem:
    """Construct a port-Hamiltonian system object.

    Args:
        H: Storage/Hamiltonian function.
        R: Optional dissipation map.
        G: Optional input map.
        J: Optional structure map.
        output: Optional output/measurement map with signature `(t, x, u)`.
        observation: Deprecated synonym for `output`. Pass only one of
            `output` or `observation`.
        dt: Optional finite positive real scalar sample time.
        state_dim: Optional nonnegative static integer; positive even for default J.
        input_dim: Optional nonnegative static integer input dimension.
        output_dim: Optional nonnegative static integer output dimension.

    Returns:
        [PHSSystem][contrax.systems.PHSSystem]: A reusable port-Hamiltonian
            model.
    """
    if output is not None and observation is not None:
        raise ValueError("Pass only one of output= or observation=.")
    observation = output if output is not None else observation
    dt_arr, invalid = (None, False) if dt is None else sample_period(dt)
    state_dim = dimension(state_dim, "state_dim")
    if J is None and state_dim is not None and (state_dim == 0 or state_dim % 2):
        raise ValueError("Default canonical J requires a positive even state_dim")
    result = PHSSystem(
        H=H,
        R=R,
        G=G,
        J=J,
        observation=observation,
        dt=dt_arr,
        state_dim=state_dim,
        input_dim=dimension(input_dim, "input_dim"),
        output_dim=dimension(output_dim, "output_dim"),
    )
    return (
        result
        if dt is None
        else eqx.error_if(result, invalid, "dt must be finite and positive")
    )

phs_to_ss(sys: PHSSystem, x_eq: Array, u_eq: Array) -> ContLTI | DiscLTI

Linearize a port-Hamiltonian model, preserving its time domain.

Computes the Jacobian linearization of the PHS dynamics and observation map at (x_eq, u_eq). The result models state/input/output deviations; constant operating-point offsets are omitted. Continuous models use delta_x_dot = A delta_x + B delta_u; discrete models use delta_x_next.

For a linear PHS with quadratic Hamiltonian H(x) = ½ x^T M x, the linearization is exact and gives A = (J - R) M, B = G.

Calls linearize_ss on the system's nonlinear representation at time zero and returns the local LTI model.

Parameters:

Name Type Description Default
sys PHSSystem

Port-Hamiltonian system.

required
x_eq Array

Equilibrium state. Shape: (n,).

required
u_eq Array

Equilibrium input. Shape: (m,).

required

Returns:

Type Description
ContLTI | DiscLTI

ContLTI | DiscLTI: Local LTI approximation at (x_eq, u_eq), retaining the model's discrete sample period when supplied.

Examples:

>>> import jax.numpy as jnp
>>> import contrax as cx
>>> def H(x): return 0.5 * jnp.dot(x, x)
>>> phs = cx.phs_system(H, state_dim=2, input_dim=1)
>>> lti = cx.phs_to_ss(phs, jnp.zeros(2), jnp.zeros(1))
>>> isinstance(lti, cx.ContLTI)
True
Source code in contrax/phs.py
def phs_to_ss(
    sys: PHSSystem,
    x_eq: Array,
    u_eq: Array,
) -> ContLTI | DiscLTI:
    """Linearize a port-Hamiltonian model, preserving its time domain.

    Computes the Jacobian linearization of the PHS dynamics and observation map
    at `(x_eq, u_eq)`. The result models state/input/output deviations; constant
    operating-point offsets are omitted. Continuous models use
    delta_x_dot = A delta_x + B delta_u; discrete models use delta_x_next.

    For a linear PHS with quadratic Hamiltonian `H(x) = ½ x^T M x`, the
    linearization is exact and gives `A = (J - R) M`, `B = G`.

    Calls `linearize_ss` on the system's nonlinear representation at time zero
    and returns the local LTI model.

    Args:
        sys: Port-Hamiltonian system.
        x_eq: Equilibrium state. Shape: `(n,)`.
        u_eq: Equilibrium input. Shape: `(m,)`.

    Returns:
        ContLTI | DiscLTI: Local LTI approximation at `(x_eq, u_eq)`, retaining
            the model's discrete sample period when supplied.

    Examples:
        >>> import jax.numpy as jnp
        >>> import contrax as cx
        >>> def H(x): return 0.5 * jnp.dot(x, x)
        >>> phs = cx.phs_system(H, state_dim=2, input_dim=1)
        >>> lti = cx.phs_to_ss(phs, jnp.zeros(2), jnp.zeros(1))
        >>> isinstance(lti, cx.ContLTI)
        True
    """
    from contrax.core import linearize_ss

    nl = sys.as_nonlinear_system()
    return linearize_ss(nl, x_eq, u_eq, t=0.0)

project_psd(M: ArrayLike, *, min_eigenvalue: float = 0.0) -> Array

Project a real square matrix onto the symmetric PSD cone.

min_eigenvalue must be a finite nonnegative scalar. Supports JAX transforms where the eigendecomposition is differentiable; repeated eigenvalues and the clipping boundary can make derivatives undefined.

Source code in contrax/phs.py
def project_psd(
    M: ArrayLike,
    *,
    min_eigenvalue: float = 0.0,
) -> Array:
    """Project a real square matrix onto the symmetric PSD cone.

    min_eigenvalue must be a finite nonnegative scalar. Supports JAX transforms
    where the eigendecomposition is differentiable; repeated eigenvalues and
    the clipping boundary can make derivatives undefined.
    """
    M, floor = _positive_inputs(M, min_eigenvalue)
    M = symmetrize_matrix(M)
    eigvals, eigvecs = jnp.linalg.eigh(M)
    clipped = jnp.maximum(eigvals, floor)
    return symmetrize_matrix((eigvecs * clipped) @ eigvecs.T)

schedule_phs(sys: PHSSystem, context_fn: Callable[[Array | float], Any], *, J: Callable[[Array | float, Array, Any], Array] | None = None, R: Callable[[Array | float, Array, Any], Array] | None = None, G: Callable[[Array | float, Array, Any], Array] | None = None, observation: Callable[[Array | float, Array, Array, Any], Array] | None = None) -> NonlinearSystem

Bind an exogenous schedule/context to a canonical PHS model.

The returned object is a NonlinearSystem accepted by simulation, estimation, and linearization functions.

context_fn(t) should be a pure JAX-friendly callable returning any pytree of observed scheduling/context variables, such as a scale parameter theta(t).

Parameters:

Name Type Description Default
sys PHSSystem

Canonical PHS model with state-only H(x) and optional state-only J(x) / R(x) / G(x) fallbacks.

required
context_fn Callable[[Array | float], Any]

Callable mapping time to observed scheduling/context data.

required
J Callable[[Array | float, Array, Any], Array] | None

Optional scheduled structure map J(t, x, context).

None
R Callable[[Array | float, Array, Any], Array] | None

Optional scheduled dissipation map R(t, x, context).

None
G Callable[[Array | float, Array, Any], Array] | None

Optional scheduled input map G(t, x, context).

None
observation Callable[[Array | float, Array, Array, Any], Array] | None

Optional scheduled observation map observation(t, x, u, context).

None

Returns:

Type Description
NonlinearSystem

NonlinearSystem: A scheduled nonlinear system with the context bound.

Source code in contrax/phs.py
def schedule_phs(
    sys: PHSSystem,
    context_fn: Callable[[Array | float], Any],
    *,
    J: Callable[[Array | float, Array, Any], Array] | None = None,
    R: Callable[[Array | float, Array, Any], Array] | None = None,
    G: Callable[[Array | float, Array, Any], Array] | None = None,
    observation: Callable[[Array | float, Array, Array, Any], Array] | None = None,
) -> NonlinearSystem:
    """Bind an exogenous schedule/context to a canonical PHS model.

    The returned object is a [NonlinearSystem][contrax.systems.NonlinearSystem]
    accepted by simulation, estimation, and linearization functions.

    `context_fn(t)` should be a pure JAX-friendly callable returning any pytree
    of observed scheduling/context variables, such as a scale parameter
    `theta(t)`.

    Args:
        sys: Canonical PHS model with state-only `H(x)` and optional state-only
            `J(x)` / `R(x)` / `G(x)` fallbacks.
        context_fn: Callable mapping time to observed scheduling/context data.
        J: Optional scheduled structure map `J(t, x, context)`.
        R: Optional scheduled dissipation map `R(t, x, context)`.
        G: Optional scheduled input map `G(t, x, context)`.
        observation: Optional scheduled observation map
            `observation(t, x, u, context)`.

    Returns:
        [NonlinearSystem][contrax.systems.NonlinearSystem]: A scheduled
            nonlinear system with the context bound.
    """

    def dynamics(t: Array | float, x: Array, u: Array) -> Array:
        context = context_fn(t)
        dim = x.shape[0]
        J_mat = (
            J(t, x, context)
            if J is not None
            else (
                sys.J(x) if sys.J is not None else canonical_J(dim // 2, dtype=x.dtype)
            )
        )
        R_mat = (
            R(t, x, context)
            if R is not None
            else (
                sys.R(x) if sys.R is not None else jnp.zeros((dim, dim), dtype=x.dtype)
            )
        )
        G_mat = (
            G(t, x, context)
            if G is not None
            else (sys.G(x) if sys.G is not None else _zero_input_matrix(x, u))
        )
        grad_H = jax.grad(sys.H)(x)
        return (J_mat - R_mat) @ grad_H + G_mat @ u

    def scheduled_observation(t: Array | float, x: Array, u: Array) -> Array:
        if observation is not None:
            context = context_fn(t)
            return observation(t, x, u, context)
        if sys.observation is None:
            return x
        return sys.observation(t, x, u)

    return NonlinearSystem(
        dynamics=dynamics,
        observation=scheduled_observation,
        dt=sys.dt,
        state_dim=sys.state_dim,
        input_dim=sys.input_dim,
        output_dim=sys.output_dim,
    )

separable_phs_system(V: Callable[[Array], Array], T: Callable[[Array], Array], *, n_dofs: int, R: Callable[[Array], Array] | None = None, G: Callable[[Array], Array] | None = None, J: Callable[[Array], Array] | None = None, output: Callable | None = None, input_dim: int | None = None, output_dim: int | None = None) -> SeparablePHSSystem

Construct a separable port-Hamiltonian system.

Builds a SeparablePHSSystem whose Hamiltonian is H(q, p) = V(q) + T(p). The potential and kinetic energy remain separate model fields. sample_system(method="sv") requires this type with the default canonical J; other methods use the ordinary PHSSystem interface.

Parameters:

Name Type Description Default
V Callable[[Array], Array]

Potential energy V(q). Maps (n_dofs,) -> scalar.

required
T Callable[[Array], Array]

Kinetic energy T(p). Maps (n_dofs,) -> scalar.

required
n_dofs int

Number of generalized coordinates. state_dim = 2 * n_dofs.

required
R Callable[[Array], Array] | None

Optional dissipation map R(x) returning a (2*n_dofs, 2*n_dofs) positive-semidefinite matrix.

None
G Callable[[Array], Array] | None

Optional input map G(x) returning a (2*n_dofs, input_dim) matrix.

None
J Callable[[Array], Array] | None

Optional structure map. Defaults to canonical_J(n_dofs).

None
output Callable | None

Optional measurement map with signature (t, x, u) -> y.

None
input_dim int | None

Optional static input dimension metadata.

None
output_dim int | None

Optional static output dimension metadata.

None

Returns:

Type Description
SeparablePHSSystem
SeparablePHSSystem

derived from V and T.

Examples:

>>> import jax.numpy as jnp
>>> import contrax as cx
>>> def V(q): return 0.5 * jnp.dot(q, q)
>>> def T(p): return 0.5 * jnp.dot(p, p)
>>> sys = cx.separable_phs_system(V, T, n_dofs=1, input_dim=1)
>>> isinstance(sys, cx.PHSSystem)
True
Source code in contrax/phs.py
def separable_phs_system(
    V: Callable[[Array], Array],
    T: Callable[[Array], Array],
    *,
    n_dofs: int,
    R: Callable[[Array], Array] | None = None,
    G: Callable[[Array], Array] | None = None,
    J: Callable[[Array], Array] | None = None,
    output: Callable | None = None,
    input_dim: int | None = None,
    output_dim: int | None = None,
) -> SeparablePHSSystem:
    """Construct a separable port-Hamiltonian system.

    Builds a [SeparablePHSSystem][contrax.systems.SeparablePHSSystem] whose
    Hamiltonian is `H(q, p) = V(q) + T(p)`. The potential and kinetic energy
    remain separate model fields. `sample_system(method="sv")` requires this
    type with the default canonical `J`; other methods use the ordinary
    `PHSSystem` interface.

    Args:
        V: Potential energy `V(q)`. Maps `(n_dofs,) -> scalar`.
        T: Kinetic energy `T(p)`. Maps `(n_dofs,) -> scalar`.
        n_dofs: Number of generalized coordinates. `state_dim = 2 * n_dofs`.
        R: Optional dissipation map `R(x)` returning a `(2*n_dofs, 2*n_dofs)`
            positive-semidefinite matrix.
        G: Optional input map `G(x)` returning a `(2*n_dofs, input_dim)` matrix.
        J: Optional structure map. Defaults to `canonical_J(n_dofs)`.
        output: Optional measurement map with signature `(t, x, u) -> y`.
        input_dim: Optional static input dimension metadata.
        output_dim: Optional static output dimension metadata.

    Returns:
        [SeparablePHSSystem][contrax.systems.SeparablePHSSystem] with `H`
        derived from `V` and `T`.

    Examples:
        >>> import jax.numpy as jnp
        >>> import contrax as cx
        >>> def V(q): return 0.5 * jnp.dot(q, q)
        >>> def T(p): return 0.5 * jnp.dot(p, p)
        >>> sys = cx.separable_phs_system(V, T, n_dofs=1, input_dim=1)
        >>> isinstance(sys, cx.PHSSystem)
        True
    """
    n_dofs = dimension(n_dofs, "n_dofs", minimum=1)
    if n_dofs is None:
        raise ValueError(f"n_dofs must be >= 1, got {n_dofs}.")

    def H(x: Array) -> Array:
        q, p = x[:n_dofs], x[n_dofs:]
        return V(q) + T(p)

    return SeparablePHSSystem(
        H=H,
        V=V,
        T=T,
        n_dofs=n_dofs,
        R=R,
        G=G,
        J=J,
        observation=output,
        dt=None,
        state_dim=2 * n_dofs,
        input_dim=dimension(input_dim, "input_dim"),
        output_dim=dimension(output_dim, "output_dim"),
    )

series(sys2: DiscLTI | ContLTI, sys1: DiscLTI | ContLTI) -> DiscLTI | ContLTI

Connect two systems in series as sys2 @ sys1.

The output of sys1 feeds the input of sys2. State ordering follows the MATLAB-style convention: sys1 states first, then sys2 states.

Parameters:

Name Type Description Default
sys2 DiscLTI | ContLTI

Downstream system.

required
sys1 DiscLTI | ContLTI

Upstream system.

required

Returns:

Type Description
DiscLTI | ContLTI

DiscLTI | ContLTI: Interconnected system.

Source code in contrax/interconnect.py
def series(sys2: DiscLTI | ContLTI, sys1: DiscLTI | ContLTI) -> DiscLTI | ContLTI:
    """Connect two systems in series as `sys2 @ sys1`.

    The output of `sys1` feeds the input of `sys2`. State ordering follows the
    MATLAB-style convention: `sys1` states first, then `sys2` states.

    Args:
        sys2: Downstream system.
        sys1: Upstream system.

    Returns:
        [DiscLTI][contrax.systems.DiscLTI] |
            [ContLTI][contrax.systems.ContLTI]: Interconnected system.
    """
    mismatch = _check_same_family(sys2, sys1)
    if sys1.C.shape[0] != sys2.B.shape[1]:
        raise ValueError(
            "Series interconnection requires sys1 output dimension to match "
            "sys2 input dimension."
        )

    A1, B1, C1, D1 = sys1.A, sys1.B, sys1.C, sys1.D
    A2, B2, C2, D2 = sys2.A, sys2.B, sys2.C, sys2.D
    n1, n2 = A1.shape[0], A2.shape[0]

    A = jnp.block(
        [
            [A1, jnp.zeros((n1, n2), dtype=A1.dtype)],
            [B2 @ C1, A2],
        ]
    )
    B = jnp.vstack([B1, B2 @ D1])
    C = jnp.hstack([D2 @ C1, C2])
    D = D2 @ D1

    if isinstance(sys1, DiscLTI):
        result = DiscLTI(A=A, B=B, C=C, D=D, dt=sys1.dt)
        return eqx.error_if(
            result, mismatch, "Discrete interconnections require matching dt"
        )
    return ContLTI(A=A, B=B, C=C, D=D)

ss(A: ArrayLike, B: ArrayLike, C: ArrayLike, D: ArrayLike) -> ContLTI

Construct a continuous-time state-space system.

Wraps continuous-time matrices in a ContLTI pytree with MATLAB-familiar ss(A, B, C, D) ergonomics.

Stores validated matrices in a data-only ContLTI pytree. Fixed-shape matrix inputs can participate in JAX transformations. Matrix dtypes are promoted together; explicit float32 arrays retain their precision even when float64 mode is enabled. Integer matrices become floating.

Parameters:

Name Type Description Default
A ArrayLike

State matrix. Shape: (n, n).

required
B ArrayLike

Input matrix. Shape: (n, m).

required
C ArrayLike

Output matrix. Shape: (p, n).

required
D ArrayLike

Feedthrough matrix. Shape: (p, m).

required

Returns:

Type Description
ContLTI

ContLTI: A continuous-time system with array-valued A, B, C, and D fields.

Raises:

Type Description
ValueError

If the matrix shapes are inconsistent.

Examples:

>>> import jax.numpy as jnp
>>> import contrax as cx
>>> sys = cx.ss(
...     jnp.array([[0.0, 1.0], [0.0, 0.0]]),
...     jnp.array([[0.0], [1.0]]),
...     jnp.eye(2),
...     jnp.zeros((2, 1)),
... )
Source code in contrax/core.py
def ss(A: ArrayLike, B: ArrayLike, C: ArrayLike, D: ArrayLike) -> ContLTI:
    """Construct a continuous-time state-space system.

    Wraps continuous-time matrices in a
    [ContLTI][contrax.systems.ContLTI] pytree with MATLAB-familiar
    `ss(A, B, C, D)` ergonomics.

    Stores validated matrices in a data-only `ContLTI` pytree. Fixed-shape
    matrix inputs can participate in JAX transformations.
    Matrix dtypes are promoted together; explicit float32 arrays retain their
    precision even when float64 mode is enabled. Integer matrices become floating.

    Args:
        A: State matrix. Shape: `(n, n)`.
        B: Input matrix. Shape: `(n, m)`.
        C: Output matrix. Shape: `(p, n)`.
        D: Feedthrough matrix. Shape: `(p, m)`.

    Returns:
        [ContLTI][contrax.systems.ContLTI]: A continuous-time system with
            array-valued `A`, `B`, `C`, and `D` fields.

    Raises:
        ValueError: If the matrix shapes are inconsistent.

    Examples:
        >>> import jax.numpy as jnp
        >>> import contrax as cx
        >>> sys = cx.ss(
        ...     jnp.array([[0.0, 1.0], [0.0, 0.0]]),
        ...     jnp.array([[0.0], [1.0]]),
        ...     jnp.eye(2),
        ...     jnp.zeros((2, 1)),
        ... )
    """
    A, B, C, D = _lti_arrays(A, B, C, D)
    return ContLTI(A=A, B=B, C=C, D=D)

symmetrize_matrix(M: ArrayLike) -> Array

Return the symmetric part 0.5 * (M + M.T) of a square matrix.

Source code in contrax/phs.py
def symmetrize_matrix(M: ArrayLike) -> Array:
    """Return the symmetric part `0.5 * (M + M.T)` of a square matrix."""
    M = jnp.asarray(M)
    if M.ndim != 2 or M.shape[0] != M.shape[1]:
        raise ValueError("symmetrize_matrix() expects a square matrix.")
    return 0.5 * (M + M.T)

Optional python-control conversion

Install the optional bridge dependency with uv add control, then import from_python_control and to_python_control from contrax.compat.python_control. Both functions convert state-space realizations on the host; they do not support JAX transforms. Enable JAX x64 before conversion to retain source float64 arrays. Transfer functions must first be converted with control.ss.

An explicitly continuous source stays continuous. Positive discrete periods are preserved or overridden by dt=. A source with dt=True requires a positive explicit period; dt=None also requires an override, with zero selecting a continuous model. Negative, nonfinite, complex and boolean overrides are invalid for these timebase selections.