Skip to content

Estimation

Use the estimation namespace for recursive filters, smoothers, and fixed-window optimization.

The namespace includes:

  • kalman() and kalman_gain() for linear Gaussian filtering and steady-state estimator design
  • kalman_predict(), kalman_update(), kalman_step() for one-step runtime loops
  • ekf() and ukf() for nonlinear filtering
  • ekf_predict(), ekf_update(), ekf_step() for one-step nonlinear loops
  • rts() and uks() for offline smoothing
  • innovation_diagnostics(), likelihood_diagnostics(), ukf_diagnostics(), and smoother_diagnostics() for routine estimator-health summaries
  • mhe_objective(), mhe(), mhe_warm_start(), and soft_quadratic_penalty() for fixed-window optimization-based estimation
  • positive_exp(), positive_softplus(), spd_from_cholesky_raw(), and diagonal_spd() for lightweight constrained parameterization

Minimal Example

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,
)
result = cx.kalman(sys, jnp.zeros((20, 2)), Q=0.001 * jnp.eye(2), R=0.01 * jnp.eye(2))
Estimation pipeline from model and measurements through filtering, smoothing, and moving-horizon estimation
Estimation workflow: batch filters produce filtered trajectories, smoothers use future observations, and MHE optimizes an explicit fixed-horizon objective.

Conventions

Batch kalman(), ekf(), and ukf() use an update-first convention: (x0, P0) is the prior on x_0, and the first step updates with y_0.

Linear filtering accepts us=(T, m) for both feedthrough at k and the transition from k to k+1. EKF uses the same convention. kalman and ekf accept a (T,) has_measurement mask; missing observations may contain NaNs. Skipped updates leave the prior unchanged and return zero innovations. The final predicted transition is not included in KalmanResult.

Joseph covariance updates are the default for linear filtering and EKF:

\[ P^+ = (I-KH)P^-(I-KH)^\top + K R K^\top. \]

Here H is C for the linear filter. Select compact form with cx.solvers.Kalman(covariance_update="compact") or cx.solvers.EKF(covariance_update="compact"). Pass consistent dtypes for the model, noise, initial conditions, and observations; float32 workflows retain float32. For a controlled linear smoother, pass the forward controls to rts(..., us=us).

Estimator Equations

The batch pass starts from prior (x0, P0) on x_0, updates with y_0, and then predicts the prior on x_1.

For linear discrete models, the state and observation maps are

\[ x_{k+1} = A x_k + B u_k + w_k, \qquad y_k = C x_k + D u_k + v_k \]

with process noise covariance Q and measurement noise covariance R.

Linear Kalman Filter

kalman() implements the standard discrete Gaussian filter:

\[ \hat{y}_k^- = C \hat{x}_k^- + D u_k, \qquad S_k = C P_k^- C^\top + R \]
\[ K_k = P_k^- C^\top S_k^{-1} \]
\[ \hat{x}_k^+ = \hat{x}_k^- + K_k \bigl(y_k - \hat{y}_k^-\bigr), \qquad P_k^+ = (I - K_k C) P_k^- \]
\[ \hat{x}_{k+1}^- = A \hat{x}_k^+ + B u_k, \qquad P_{k+1}^- = A P_k^+ A^\top + Q \]

Extended Kalman Filter

For nonlinear models

\[ x_{k+1} = f(t_k, x_k, u_k) + w_k, \qquad y_k = h(t_k, x_k, u_k) + v_k \]

ekf() linearizes the transition and observation maps with JAX Jacobians:

\[ H_k = \left.\frac{\partial h}{\partial x}\right|_{(t_k, \hat{x}_k^-, u_k)}, \qquad F_k = \left.\frac{\partial f}{\partial x}\right|_{(t_k, \hat{x}_k^+, u_k)} \]
\[ \hat{y}_k^- = h(t_k, \hat{x}_k^-, u_k), \qquad S_k = H_k P_k^- H_k^\top + R \]
\[ K_k = P_k^- H_k^\top S_k^{-1} \]
\[ \hat{x}_k^+ = \hat{x}_k^- + K_k \bigl(y_k - \hat{y}_k^-\bigr), \qquad P_k^+ = (I - K_k H_k) P_k^- \]
\[ \hat{x}_{k+1}^- = f(t_k, \hat{x}_k^+, u_k), \qquad P_{k+1}^- = F_k P_k^+ F_k^\top + Q \]

Pass solver=cx.solvers.EKF(num_iter=3) to ekf, ekf_update or ekf_step to relinearize around each update iterate while keeping the prediction covariance fixed. All three use the same configuration.

Unscented Kalman Filter

ukf() avoids local Jacobians and instead propagates sigma points. Given dimension \(n\) and scaling parameter \(\lambda\),

\[ \chi^{(0)} = \hat{x}, \qquad \chi^{(i)} = \hat{x} \pm \left[\sqrt{(n + \lambda) P}\right]_i \]

At the update stage, sigma points from the current prior \((\hat{x}_k^-, P_k^-)\) are pushed through the observation map:

\[ \gamma_k^{(i)} = h(t_k, \chi_k^{(i)}, u_k), \qquad \hat{y}_k^- = \sum_i W_i^{(m)} \gamma_k^{(i)} \]
\[ S_k = \sum_i W_i^{(c)} \bigl(\gamma_k^{(i)} - \hat{y}_k^-\bigr) \bigl(\gamma_k^{(i)} - \hat{y}_k^-\bigr)^\top + R \]
\[ P_{xy,k} = \sum_i W_i^{(c)} \bigl(\chi_k^{(i)} - \hat{x}_k^-\bigr) \bigl(\gamma_k^{(i)} - \hat{y}_k^-\bigr)^\top, \qquad K_k = P_{xy,k} S_k^{-1} \]
\[ \hat{x}_k^+ = \hat{x}_k^- + K_k \bigl(y_k - \hat{y}_k^-\bigr), \qquad P_k^+ = P_k^- - K_k S_k K_k^\top \]

For prediction, sigma points from \((\hat{x}_k^+, P_k^+)\) are pushed through the transition map:

\[ \xi_k^{(i)} = f(t_k, \chi_k^{(i)}, u_k), \qquad \hat{x}_{k+1}^- = \sum_i W_i^{(m)} \xi_k^{(i)} \]
\[ P_{k+1}^- = \sum_i W_i^{(c)} \bigl(\xi_k^{(i)} - \hat{x}_{k+1}^-\bigr) \bigl(\xi_k^{(i)} - \hat{x}_{k+1}^-\bigr)^\top + Q \]

Smoothers

rts() is the linear Rauch-Tung-Striebel smoother on top of KalmanResult:

\[ G_k = P_k^+ A^\top (P_{k+1}^-)^{-1} \]
\[ \hat{x}_k^s = \hat{x}_k^+ + G_k \bigl(\hat{x}_{k+1}^s - \hat{x}_{k+1}^-\bigr) \]
\[ P_k^s = P_k^+ + G_k \bigl(P_{k+1}^s - P_{k+1}^-\bigr) G_k^\top \]

uks() applies the same backward shape to the unscented filter, but replaces \(A P_k^+\) with an unscented cross-covariance:

\[ P_{x_k x_{k+1}} = \sum_i W_i^{(c)} \bigl(\chi_k^{(i)} - \hat{x}_k^+\bigr) \bigl(\xi_k^{(i)} - \hat{x}_{k+1}^-\bigr)^\top \]
\[ G_k = P_{x_k x_{k+1}} (P_{k+1}^-)^{-1} \]
\[ \hat{x}_k^s = \hat{x}_k^+ + G_k \bigl(\hat{x}_{k+1}^s - \hat{x}_{k+1}^-\bigr), \qquad P_k^s = P_k^+ + G_k \bigl(P_{k+1}^s - P_{k+1}^-\bigr) G_k^\top \]

Moving-Horizon Estimation

mhe_objective() and mhe() expose the fixed-window optimization form:

\[ J_{\mathrm{MHE}} = \|x_0 - x_{\mathrm{prior}}\|_{P^{-1}}^2 + \sum_{k=0}^{N-2}\|x_{k+1} - f(t_k, x_k, u_k)\|_{Q^{-1}}^2 + \sum_{k=0}^{N-1}\|y_k - h(t_k, x_k, u_k)\|_{R^{-1}}^2 \]

The objective uses the same model and noise assumptions as the recursive filters, expressed as a cost over the full horizon.

Pass a discrete LTI or nonlinear system directly: mhe(model, xs_init, us, ys, x_prior, P_prior, Q_noise, R_noise). The window contains N states and measurements, with N−1 transition inputs; a one-state window is valid. For input-dependent measurements, measurement_us supplies N controls; its default holds the last transition input at the final observation (zero for N=1). For sampled FOH models, transition inputs have shape (N-1, 2, m) and measurement_us has shape (N, 2, m). Each observation uses its pair's left endpoint. The default terminal pair holds the last right endpoint at both ends. ts supplies explicit sample times for time-dependent models. Callable transitions accept f(x, u) and require observation=h separately.

The arrival, process, and observation covariances must be finite, symmetric, and positive definite. Invalid covariances report invalid_problem, even if the optimizer converges.

Native Optimistix minimizers are accepted through solver=. The wrapper retains the best finite accepted iterate when the iteration limit is reached and returns .status, .successful, .num_steps, .gradient_norm, .initial_cost, and .final_cost. Success requires solver convergence and valid result checks; a finite cost reduction alone does not establish convergence. throw=True enables checked convergence failures.

Implicit JVP/VJP derivatives include model, noise and explicit parameter data passed through optimizer arguments. They assume a converged stationary point with a nonsingular Hessian. Check convergence before interpreting derivatives of a limited or ill-conditioned solve.

Some Optimistix LBFGS versions that allocate history in JAX's default dtype cannot initialize float32 trajectories while x64 mode is enabled. The wrapper preserves the arrays and reports this configuration error. A validated option for small float32 windows in that context is native optx.BFGS(rtol=1e-6, atol=1e-6); its dense Hessian storage grows quadratically in the window's variable count. Alternatively, select a float32 JAX context before solving. JAX global precision settings are not changed by the estimator.

For rolling-window use:

  • mhe_warm_start(xs, ...) shifts a previous solution forward by one step to build the next initial guess
  • soft_quadratic_penalty(residuals, weight) is a small helper for soft state, terminal, or envelope penalties inside extra_cost

Scalar and matrix penalty weights represent cost or precision weighting: w * sum(r**2) or sum(r.T @ W @ r). For covariance-weighted residuals, apply an explicit factor solve before the penalty.

Explicit Linear Filter Algorithms

Ordinary kalman uses innovation Cholesky solves and Joseph covariance updates. The innovation covariance must be positive definite. Choose another algorithm explicitly when its assumptions fit the workflow:

import jax
jax.config.update("jax_enable_x64", True)
import jax.numpy as jnp
import contrax as cx

sys = cx.dss([[0.9]], [[1.0]], [[1.0]], [[0.0]], dt=0.1)
Q, R = 0.01 * jnp.eye(1), 0.1 * jnp.eye(1)
ys = jnp.zeros((20, 1))
design = cx.kalman_gain(sys, Q, R)
steady = cx.kalman(sys, ys, Q=Q, R=R,
                   solver=cx.solvers.SteadyStateKalman(design))
information = cx.kalman(sys, ys, Q=Q, R=jnp.diag(R),
                       solver=cx.solvers.InformationKalman())
assert steady.successful & information.successful

design.P is the predicted covariance before the first measurement update. Steady-state mode uses it as the default P0 and computes the Joseph posterior covariance once. It validates the gain, Riccati fixed point, initialization and estimator stability. A mismatched design or missing observation reports invalid_problem, with optional checked failure through throw=True. SteadyStateKalman uses the supplied design on each call. To differentiate a loss with respect to model or noise parameters, construct kalman_gain inside that differentiable workflow and pass its result to the solver. A design held fixed while its model or noise data changes is inconsistent.

Information mode takes a positive variance vector (p,) and factors matrices in state dimension after whitening the prior. It forms no prior inverse and no p-by-p innovation matrix. This can reduce work and storage for wide observation maps and avoid loss of small diagonal noise in a dense innovation matrix. It requires positive-definite priors at observed steps; covariance-form filtering also supports semidefinite priors with nonsingular innovations. Information mode retains the usual controls, whole-observation masks, zero/identity initialization defaults, and float32/float64 precision. Supply positive-definite covariances; numerical failures are returned in the result. Both options are accepted by the linear online update and combined-step helpers as well as batch filtering.

One-Step Helper Signatures

All batch filters take (model, ys, *, Q, R, us=None, x0=None, P0=None). Initial means default to zero and covariances to identity; both describe the prior at the first observation. Explicit steady-state mode instead defaults the covariance to its design's predicted covariance. Omitted controls use the model's input dimension. Set input_dim on controlled nonlinear models or provide us. Sampled FOH models accept (T, 2, m) endpoint pairs.

The one-step helpers take (model, x, P) for predictions and (model, x, P, y) for updates and combined steps. Noise and controls are keyword-only, with the same Q, R and u names throughout.

# predict: advances state by one step
prediction = cx.ekf_predict(model, x, P, u=u, Q=Q_noise)
x_pred, P_pred = prediction.x, prediction.P
prediction = cx.kalman_predict(sys, x, P, Q=Q_noise, u=None)  # u optional
x_pred, P_pred = prediction.x, prediction.P

# update: fuses a new measurement
update = cx.ekf_update(model, x_pred, P_pred, y, R=R_noise)
x, P, innov = update.x, update.P, update.innovation
update = cx.kalman_update(sys, x_pred, P_pred, y, R=R_noise, u=None)
x, P, innov = update.x, update.P, update.innovation

# combined predict-update step
update = cx.ekf_step(model, x, P, y, u=u, Q=Q_noise, R=R_noise)
x, P, innov = update.x, update.P, update.innovation
update = cx.kalman_step(sys, x, P, y, Q=Q_noise, R=R_noise, u=None)
x, P, innov = update.x, update.P, update.innovation

has_measurement=False can be passed to all step helpers to skip the update (useful for streams with missing samples inside jax.lax.scan).

For an online predict-update step, u belongs to the transition from the previous posterior. Pass u_measurement= separately when the input used by the observation has changed. For time-dependent EKF models, t is the transition start time and measurement_time is the observation time; the default observation time adds the model's sample period when available.

Model Inputs

For nonlinear estimation, the main docs path is reusable system models such as NonlinearSystem or PHSSystem.

That keeps simulation, linearization, and estimation on one shared model object instead of redefining the same dynamics in several places.

The intended public workflow is:

sys_c = cx.nonlinear_system(f_continuous, output=h, dt=None)
sys_d = cx.sample_system(sys_c, dt=0.1)
result = cx.ukf(sys_d, ys, Q=Q_noise, R=R_noise, us=us, x0=x0, P0=P0)
smoothed = cx.uks(result)

ukf() returns UKFResult, which includes predicted measurements, innovation covariances, per-step likelihood terms, and the prediction quantities used by uks().

The filter result retains .has_measurement. Missing observations have zero innovation, predicted measurement, innovation covariance and likelihood placeholders. ukf_diagnostics excludes them from solves and averages. The individual diagnostics functions also accept has_measurement=. With no observations, aggregate means and extrema are NaN, likelihood totals are zero, and nonfinite remains false when all observations are missing.

For continuous dynamics with discrete observations, sample_system() integrates the continuous vector field over one sample interval and returns a discrete-time NonlinearSystem that can be passed directly to ekf() or ukf().

Transform Behavior

Batch filters, online helpers, and smoothers expose JAX scalar .status and .successful diagnostics. Online prediction results have .x and .P; updates also have .innovation. Invalid noise or initial covariances return invalid_problem; nonfinite outputs or loss of positive semidefiniteness return numerical_failure. Sampled-model integration failures retain their backend status, including iteration limits. Shape/configuration errors raise immediately.

throw=False returns these diagnostics. With throw=True, failure raises during execution, including under jit when the caller selects only a state field. Covariance validation uses dtype-scaled roundoff tolerances. Status decisions are nondifferentiable; objectives on states, covariances and likelihoods retain their transform contracts.

Use the one-step helpers when measurements arrive in a runtime loop or when the filter needs to live inside a larger jax.lax.scan.

The important transform contracts are:

  • kalman(), ekf(), and ukf() are batch scans over fixed-shape sequences
  • the one-step helpers expose missing-measurement handling without Python branching on traced values
  • mhe_objective() is a pure cost function over an explicit candidate trajectory, which makes it suitable for JAX-native optimization loops

Numerical Notes

kalman_gain() is the estimator dual of discrete LQR and returns the measurement-update gain used by kalman_update().

rts() requires the same Q used by the forward kalman() pass so the smoother uses the same process model. Pass zeros for deterministic dynamics.

mhe_objective() exposes the fixed-window cost directly. mhe() minimizes it with a configured optimizer and returns convergence diagnostics.

Diagnostics

Estimation diagnostics summarize routine filter and smoother health:

  • innovation_diagnostics(innovations, innovation_covariances) computes per-step normalized innovation squared (NIS), innovation norms, and covariance conditioning summaries
  • likelihood_diagnostics(log_likelihood_terms) summarizes innovation-form Gaussian log-likelihood terms
  • ukf_diagnostics(result) builds the standard innovation and likelihood summaries directly from a UKFResult
  • innovation_rms(result) gives a quick residual-scale summary for either KalmanResult or UKFResult
  • smoother_diagnostics(smoothed, filtered) checks whether the smoother satisfied its covariance-reduction guarantee (P_smooth ≤ P_filtered) and reports the worst-case violation, state correction magnitudes, and PSD health of the smoothed covariances. Accepts RTSResult paired with either KalmanResult or UKFResult.

Use these summaries for filter tuning and regression checks. Statistical consistency still depends on the model, noise assumptions, and data.

Parameterization Helpers

Constrained-parameterization helpers support estimation and tuning workflows:

  • positive_exp(raw) and positive_softplus(raw) map unconstrained values to positive ones
  • spd_from_cholesky_raw(raw) maps an unconstrained square matrix to an SPD matrix through a lower-triangular Cholesky factor
  • diagonal_spd(raw_diagonal) builds a diagonal SPD matrix from unconstrained diagonal parameters

These helpers accept real inputs and support jit, vmap and forward/reverse differentiation. Integer raw parameters are promoted to floating point; floating inputs retain their precision unless an explicitly higher-precision floor is provided. Floors must be finite, nonnegative scalars. Cholesky maps require a square matrix and diagonal_spd requires a vector. An overridden triangular diagonal participates in dtype promotion, so fractional entries are retained.

Positive definiteness holds in exact arithmetic. With a zero floor, very negative parameters can underflow to zero; exponential overflow and ill-conditioned Cholesky factors can also defeat floating-point definiteness. The helpers apply the requested transform directly.

contrax.estimation

Kalman filtering, smoothing, and moving-horizon estimation.

ekf(model_or_f: DynamicsLike, ys: Array, *, Q: Array, R: Array, us: Array | None = None, x0: Array | None = None, P0: Array | None = None, observation: ObservationFn | None = None, has_measurement: Array | None = None, solver: EKF | None = None, throw: bool = False) -> KalmanResult

Run an extended Kalman filter for discrete nonlinear dynamics and observations.

Computes transition and observation Jacobians with automatic differentiation.

Joseph covariance updates and one measurement linearization are the defaults. Solver configuration selects compact covariance form or iterated updates. Gradients flow through the filter recursion and its JAX-defined model functions.

Parameters:

Name Type Description Default
model_or_f DynamicsLike

Nonlinear system model or dynamics function.

required
Q Array

Process noise covariance. Shape: (n, n).

required
R Array

Measurement noise covariance. Shape: (p, p).

required
ys Array

Measurement sequence. Shape: (T, p).

required
us Array | None

Optional controls, shape (T, m) or (T, 2, m) for sampled FOH. Defaults to zero controls; declare model.input_dim or supply us for controlled nonlinear models.

None
x0 Array | None

Initial prior mean, shape (n,). Defaults to zeros.

None
P0 Array | None

Initial prior covariance, shape (n, n). Defaults to identity.

None
observation ObservationFn | None

Observation function h(x) for plain dynamics callables. Omit this when passing a system model with its own output map.

None
has_measurement Array | None

Whole-observation availability flags. Shape: (T,). Missing observations may contain NaNs and return zero innovations.

None
solver EKF | None

Joseph covariance form and iterated measurement-update settings.

None
throw bool

Raise on numerical failure; otherwise inspect status.

False

Returns:

Type Description
KalmanResult

KalmanResult: A bundle containing filtered means, covariances, and innovations.

Examples:

>>> import jax.numpy as jnp
>>> import contrax as cx
>>> def f(t, x, u):
...     return jnp.array([x[0] + 0.1 * x[1], x[1] + u[0]])
>>> def h(x):
...     return x[:1]
>>> result = cx.ekf(
...     f, jnp.zeros((10, 1)), Q=1e-3 * jnp.eye(2), R=1e-2 * jnp.eye(1),
...     us=jnp.zeros((10, 1)), observation=h,
... )
Source code in contrax/_ekf.py
def ekf(
    model_or_f: DynamicsLike,
    ys: Array,
    *,
    Q: Array,
    R: Array,
    us: Array | None = None,
    x0: Array | None = None,
    P0: Array | None = None,
    observation: ObservationFn | None = None,
    has_measurement: Array | None = None,
    solver: EKF | None = None,
    throw: bool = False,
) -> KalmanResult:
    """Run an extended Kalman filter for discrete nonlinear dynamics and observations.

    Computes transition and observation Jacobians with automatic
    differentiation.

    Joseph covariance updates and one measurement linearization are the defaults.
    Solver configuration selects compact covariance form or iterated updates.
    Gradients flow through the filter recursion and its JAX-defined model
    functions.

    Args:
        model_or_f: Nonlinear system model or dynamics function.
        Q: Process noise covariance. Shape: `(n, n)`.
        R: Measurement noise covariance. Shape: `(p, p)`.
        ys: Measurement sequence. Shape: `(T, p)`.
        us: Optional controls, shape `(T, m)` or `(T, 2, m)` for sampled FOH.
            Defaults to zero controls; declare model.input_dim or supply us for
            controlled nonlinear models.
        x0: Initial prior mean, shape `(n,)`. Defaults to zeros.
        P0: Initial prior covariance, shape `(n, n)`. Defaults to identity.
        observation: Observation function `h(x)` for plain dynamics callables.
            Omit this when passing a system model with its own output map.
        has_measurement: Whole-observation availability flags. Shape: `(T,)`.
            Missing observations may contain NaNs and return zero innovations.
        solver: Joseph covariance form and iterated measurement-update settings.

        throw: Raise on numerical failure; otherwise inspect status.

    Returns:
        [KalmanResult][contrax.types.KalmanResult]: A bundle containing
            filtered means, covariances, and innovations.

    Examples:
        >>> import jax.numpy as jnp
        >>> import contrax as cx
        >>> def f(t, x, u):
        ...     return jnp.array([x[0] + 0.1 * x[1], x[1] + u[0]])
        >>> def h(x):
        ...     return x[:1]
        >>> result = cx.ekf(
        ...     f, jnp.zeros((10, 1)), Q=1e-3 * jnp.eye(2), R=1e-2 * jnp.eye(1),
        ...     us=jnp.zeros((10, 1)), observation=h,
        ... )
    """
    if _is_system_model(model_or_f):
        if observation is not None:
            raise ValueError("ekf(sys, ...) does not accept observation=.")
        obs = model_or_f
    else:
        if observation is None:
            raise ValueError(
                "ekf(f, ..., observation=...) requires an observation function."
            )
        obs = observation

    ys, Q, R, us, x0, P0, flags = prepare_filter(
        model_or_f, ys, Q, R, us, x0, P0, has_measurement
    )
    dt = _system_dt(model_or_f)
    ts = jnp.arange(ys.shape[0], dtype=x0.dtype)
    if dt is not None:
        ts = ts * jnp.asarray(dt, dtype=x0.dtype)

    # Update-then-predict: x0/P0 is the prior on x_0.
    def update_predict(carry, inputs):
        x_prior, P_prior, code = carry
        y, u, t, available, k = inputs
        x_post, P_post, innov = ekf_update(
            obs,
            x_prior,
            P_prior,
            y,
            R=R,
            u=u,
            t=t,
            has_measurement=available,
            solver=solver,
        )

        def advance():
            prediction = _predict(model_or_f, x_post, P_post, Q, u, t)
            return (
                prediction.x,
                prediction.P,
                prediction.solver_status.astype(code.dtype),
            )

        x_next, P_next, next_code = jax.lax.cond(
            k + 1 < ys.shape[0],
            advance,
            lambda: (x_post, P_post, jnp.zeros_like(code)),
        )
        code = jnp.where(code != 0, code, next_code)
        return (x_next, P_next, code), (x_post, P_post, innov)

    (_, _, code), (x_hats, Ps, innovations) = jax.lax.scan(
        update_predict,
        (x0, P0, input_status(P0, Q, R)),
        (ys, us, ts, flags, jnp.arange(ys.shape[0])),
    )
    result = KalmanResult(
        xs=x_hats,
        Ps=Ps,
        innovations=innovations,
        has_measurement=flags,
        solver_status=code,
    )
    return checked(result, throw, "EKF filter failed; inspect status")

ekf_predict(model_or_f: DynamicsLike, x: Array, P: Array, *, Q: Array, u: Array | None = None, t: Array | float = 0.0, throw: bool = False) -> FilterPredictionResult

Predict one extended Kalman filter step.

Parameters:

Name Type Description Default
model_or_f DynamicsLike

A NonlinearSystem or a plain dynamics function with signature (t, x, u) -> x_next. Both signatures are now the same, so sys.dynamics can be passed directly when using a system model.

required
x Array

Filtered mean at the previous time step. Shape: (n,).

required
P Array

Filtered covariance at the previous time step. Shape: (n, n).

required
u Array | None

Current input. Shape: (m,).

None
Q Array

Process noise covariance. Shape: (n, n).

required
t Array | float

Current sample time.

0.0
throw bool

Raise on numerical failure; otherwise inspect status.

False

Returns:

Name Type Description
FilterPredictionResult FilterPredictionResult

Mean x, covariance P, status and successful.

Source code in contrax/_ekf.py
def ekf_predict(
    model_or_f: DynamicsLike,
    x: Array,
    P: Array,
    *,
    Q: Array,
    u: Array | None = None,
    t: Array | float = 0.0,
    throw: bool = False,
) -> FilterPredictionResult:
    """Predict one extended Kalman filter step.

    Args:
        model_or_f: A `NonlinearSystem` or a plain dynamics function with
            signature `(t, x, u) -> x_next`. Both signatures are now the same,
            so `sys.dynamics` can be passed directly when using a system model.
        x: Filtered mean at the previous time step. Shape: `(n,)`.
        P: Filtered covariance at the previous time step. Shape: `(n, n)`.
        u: Current input. Shape: `(m,)`.
        Q: Process noise covariance. Shape: `(n, n)`.
        t: Current sample time.

        throw: Raise on numerical failure; otherwise inspect status.

    Returns:
        FilterPredictionResult: Mean x, covariance P, status and successful.
    """
    x, P, Q = prepare_state(x, P, Q)
    u = zero_input(model_or_f, x.dtype) if u is None else u
    prediction = _predict(model_or_f, x, P, Q, u, t)
    valid = input_status(P, Q)
    code = jnp.where(valid != 0, valid, prediction.solver_status)
    result = FilterPredictionResult(prediction.x, prediction.P, code)
    return checked(result, throw, "EKF prediction failed; inspect status")

ekf_step(model_or_f: DynamicsLike, x: Array, P: Array, y: Array, *, Q: Array, R: Array, u: Array | None = None, t: Array | float = 0.0, has_measurement: Array | bool = True, solver: EKF | None = None, observation: ObservationFn | None = None, u_measurement: Array | None = None, measurement_time: Array | float | None = None, throw: bool = False) -> FilterUpdateResult

Run one predict-update step of an extended Kalman filter.

Parameters:

Name Type Description Default
model_or_f DynamicsLike

Nonlinear system model or dynamics function.

required
x Array

Filtered mean at the previous time step. Shape: (n,).

required
P Array

Filtered covariance at the previous time step. Shape: (n, n).

required
u Array | None

Current input. Shape: (m,).

None
t Array | float

Transition start time.

0.0
y Array

Measurement vector. Shape: (p,).

required
Q Array

Process noise covariance. Shape: (n, n).

required
R Array

Measurement noise covariance. Shape: (p, p).

required
has_measurement Array | bool

Whether to apply the measurement update.

True
solver EKF | None

Covariance update and number of iterated measurement passes.

None
observation ObservationFn | None

Observation function h(x) for plain dynamics callables. Omit this when passing a system model with its own output map.

None
u_measurement Array | None

Input at the measurement time, defaulting to u.

None
measurement_time Array | float | None

Observation time. Defaults to t plus the model's sample time when available, otherwise t.

None
throw bool

Raise on numerical failure; otherwise inspect status.

False

Returns:

Name Type Description
FilterUpdateResult FilterUpdateResult

Mean x, covariance P, innovation, status and successful.

Source code in contrax/_ekf.py
def ekf_step(
    model_or_f: DynamicsLike,
    x: Array,
    P: Array,
    y: Array,
    *,
    Q: Array,
    R: Array,
    u: Array | None = None,
    t: Array | float = 0.0,
    has_measurement: Array | bool = True,
    solver: EKF | None = None,
    observation: ObservationFn | None = None,
    u_measurement: Array | None = None,
    measurement_time: Array | float | None = None,
    throw: bool = False,
) -> FilterUpdateResult:
    """Run one predict-update step of an extended Kalman filter.

    Args:
        model_or_f: Nonlinear system model or dynamics function.
        x: Filtered mean at the previous time step. Shape: `(n,)`.
        P: Filtered covariance at the previous time step. Shape: `(n, n)`.
        u: Current input. Shape: `(m,)`.
        t: Transition start time.
        y: Measurement vector. Shape: `(p,)`.
        Q: Process noise covariance. Shape: `(n, n)`.
        R: Measurement noise covariance. Shape: `(p, p)`.
        has_measurement: Whether to apply the measurement update.
        solver: Covariance update and number of iterated measurement passes.
        observation: Observation function `h(x)` for plain dynamics callables.
            Omit this when passing a system model with its own output map.
        u_measurement: Input at the measurement time, defaulting to u.
        measurement_time: Observation time. Defaults to t plus the model's
            sample time when available, otherwise t.

        throw: Raise on numerical failure; otherwise inspect status.

    Returns:
        FilterUpdateResult: Mean x, covariance P, innovation, status and successful.
    """
    u = zero_input(model_or_f, x.dtype) if u is None else u
    if _is_system_model(model_or_f):
        if observation is not None:
            raise ValueError("ekf_step(sys, ...) does not accept observation=.")
        obs = model_or_f
    else:
        if observation is None:
            raise ValueError(
                "ekf_step(f, ..., observation=...) requires an observation function."
            )
        obs = observation
    prediction = ekf_predict(model_or_f, x, P, u=u, Q=Q, t=t)
    x_pred, P_pred = prediction
    if measurement_time is None:
        dt = _system_dt(model_or_f)
        measurement_time = t if dt is None else t + dt
    update = ekf_update(
        obs,
        x_pred,
        P_pred,
        y,
        R=R,
        u=u if u_measurement is None else u_measurement,
        t=measurement_time,
        has_measurement=has_measurement,
        solver=solver,
    )
    code = jnp.where(
        prediction.solver_status != 0, prediction.solver_status, update.solver_status
    )
    result = FilterUpdateResult(update.x, update.P, update.innovation, code)
    return checked(result, throw, "EKF step failed; inspect status")

ekf_update(model_or_h: ObservationLike, x_pred: Array, P_pred: Array, y: Array, *, R: Array, u: Array | None = None, t: Array | float = 0.0, has_measurement: Array | bool = True, solver: EKF | None = None, throw: bool = False) -> FilterUpdateResult

Update one extended Kalman filter step.

Parameters:

Name Type Description Default
model_or_h ObservationLike

Nonlinear system model or observation function.

required
x_pred Array

Predicted mean. Shape: (n,).

required
P_pred Array

Predicted covariance. Shape: (n, n).

required
y Array

Measurement vector. Shape: (p,).

required
R Array

Measurement noise covariance. Shape: (p, p).

required
u Array | None

Current input used by the observation model. Shape: (m,).

None
t Array | float

Current sample time.

0.0
has_measurement Array | bool

Whether to apply the measurement update.

True
solver EKF | None

Joseph covariance form and number of iterated linearizations. EKF(num_iter=1) gives the ordinary EKF update.

None
throw bool

Raise on numerical failure; otherwise inspect status.

False

Returns:

Name Type Description
FilterUpdateResult FilterUpdateResult

Mean x, covariance P, innovation and status; unpacks

FilterUpdateResult

as (x, P, innovation), with the filtered mean, filtered covariance,

FilterUpdateResult

and final measurement residual. Missing-measurement steps return the

FilterUpdateResult

prediction and a zero innovation.

Source code in contrax/_ekf.py
def ekf_update(
    model_or_h: ObservationLike,
    x_pred: Array,
    P_pred: Array,
    y: Array,
    *,
    R: Array,
    u: Array | None = None,
    t: Array | float = 0.0,
    has_measurement: Array | bool = True,
    solver: EKF | None = None,
    throw: bool = False,
) -> FilterUpdateResult:
    """Update one extended Kalman filter step.

    Args:
        model_or_h: Nonlinear system model or observation function.
        x_pred: Predicted mean. Shape: `(n,)`.
        P_pred: Predicted covariance. Shape: `(n, n)`.
        y: Measurement vector. Shape: `(p,)`.
        R: Measurement noise covariance. Shape: `(p, p)`.
        u: Current input used by the observation model. Shape: `(m,)`.
        t: Current sample time.
        has_measurement: Whether to apply the measurement update.
        solver: Joseph covariance form and number of iterated linearizations.
            `EKF(num_iter=1)` gives the ordinary EKF update.

        throw: Raise on numerical failure; otherwise inspect status.

    Returns:
        FilterUpdateResult: Mean x, covariance P, innovation and status; unpacks
        as (x, P, innovation), with the filtered mean, filtered covariance,
        and final measurement residual. Missing-measurement steps return the
        prediction and a zero innovation.
    """
    x_pred, P_pred, R = prepare_state(x_pred, P_pred, R, measurement=y)
    solver = EKF() if solver is None else solver
    if not isinstance(solver, EKF):
        raise TypeError("ekf_update solver must be contrax.solvers.EKF")

    if u is None:
        u = zero_input(model_or_h, x_pred.dtype)
    h = _coerce_observation(
        model_or_h,
        None if _is_system_model(model_or_h) else model_or_h,
    )

    # Reference: Jazwinski (1970), "Stochastic Processes and Filtering
    # Theory"; the iterated EKF relinearizes the measurement model around the
    # current update iterate while keeping the prediction covariance fixed.
    def one_iteration(x_lin, _):
        H = jax.jacfwd(h, argnums=1)(t, x_lin, u)
        if H.shape != (y.shape[0], x_pred.shape[0]):
            raise ValueError("The observation map must return a vector matching y")
        S = H @ P_pred @ H.T + R
        K = jnp.linalg.solve(S.T, (P_pred @ H.T).T).T
        observed = jnp.where(_measurement_flag(has_measurement), y, jnp.zeros_like(y))
        innov = observed - h(t, x_lin, u) + H @ (x_lin - x_pred)
        x_next = x_pred + K @ innov
        return x_next, (x_next, H, K, innov)

    def apply_update():
        x_final, (_, Hs, Ks, innovations) = jax.lax.scan(
            one_iteration,
            x_pred,
            xs=None,
            length=solver.num_iter,
        )
        P_final = _covariance_update(
            P_pred,
            Hs[-1],
            Ks[-1],
            R,
            solver.covariance_update,
        )
        return x_final, P_final, innovations[-1]

    def skip_update():
        return x_pred, P_pred, jnp.zeros_like(y)

    x, P, innovation = jax.lax.cond(
        _measurement_flag(has_measurement),
        apply_update,
        skip_update,
    )
    result = FilterUpdateResult(x, P, innovation, input_status(P_pred, R))
    return checked(result, throw, "EKF update failed; inspect status")

innovation_diagnostics(innovations: Array, innovation_covariances: Array, *, has_measurement: Array | None = None) -> InnovationDiagnostics

Summarize innovation magnitude and covariance conditioning.

The returned NIS values are the per-step normalized innovation squared terms v_k^T S_k^{-1} v_k. They are useful for routine filter health inspection; interpret them alongside the model and noise assumptions. Optional whole-observation flags exclude missing entries from summaries and covariance solves. Missing per-step values are zero placeholders. With no observations, summaries are NaN and nonfinite remains false.

Source code in contrax/_estimation_diagnostics.py
def innovation_diagnostics(
    innovations: Array,
    innovation_covariances: Array,
    *,
    has_measurement: Array | None = None,
) -> InnovationDiagnostics:
    """Summarize innovation magnitude and covariance conditioning.

    The returned NIS values are the per-step normalized innovation squared
    terms `v_k^T S_k^{-1} v_k`. They are useful for routine filter health
    inspection; interpret them alongside the model and noise assumptions.
    Optional whole-observation flags exclude missing entries from summaries and
    covariance solves. Missing per-step values are zero placeholders. With no
    observations, summaries are NaN and `nonfinite` remains false.
    """
    flags = _observations(innovations, has_measurement)
    innovations = jnp.where(flags[:, None], innovations, 0)
    innovation_covariances = jnp.where(
        flags[:, None, None],
        innovation_covariances,
        jnp.eye(innovations.shape[-1], dtype=innovations.dtype),
    )

    def step_nis(innovation, covariance):
        solved = jnp.linalg.solve(covariance, innovation[:, None])
        return (innovation[None, :] @ solved).squeeze()

    nis = jax.vmap(step_nis)(innovations, innovation_covariances)
    innovation_norms = jnp.linalg.norm(innovations, axis=-1)
    innovation_cov_condition_numbers = jnp.where(
        flags, jax.vmap(jnp.linalg.cond)(innovation_covariances), 0
    )
    mean_nis, _, max_nis = _masked_summary(nis, flags)
    mean_norm, _, max_norm = _masked_summary(innovation_norms, flags)
    _, _, max_condition = _masked_summary(innovation_cov_condition_numbers, flags)
    all_values = (
        jnp.isfinite(nis)
        & jnp.isfinite(innovation_norms)
        & jnp.isfinite(innovation_cov_condition_numbers)
    )
    return InnovationDiagnostics(
        nis=nis,
        innovation_norms=innovation_norms,
        innovation_cov_condition_numbers=innovation_cov_condition_numbers,
        mean_nis=mean_nis,
        max_nis=max_nis,
        mean_innovation_norm=mean_norm,
        max_innovation_norm=max_norm,
        max_innovation_cov_condition_number=max_condition,
        nonfinite=jnp.logical_not(jnp.all(all_values)),
    )

innovation_rms(result: KalmanResult | UKFResult) -> Array

Return observed innovation RMS; NaN if there are no observed components.

Source code in contrax/_estimation_diagnostics.py
def innovation_rms(result: KalmanResult | UKFResult) -> Array:
    """Return observed innovation RMS; NaN if there are no observed components."""
    observed = jnp.where(result.has_measurement[:, None], result.innovations, 0)
    count = jnp.sum(result.has_measurement) * result.innovations.shape[-1]
    return jnp.where(
        count > 0, jnp.sqrt(jnp.sum(observed**2) / jnp.maximum(count, 1)), jnp.nan
    )

kalman(sys: DiscLTI, ys: Array, *, Q: Array, R: Array, x0: Array | None = None, P0: Array | None = None, us: Array | None = None, has_measurement: Array | None = None, solver: Kalman | SteadyStateKalman | InformationKalman | None = None, throw: bool = False) -> KalmanResult

Run a linear discrete-time Kalman filter.

Filters a measurement sequence for a DiscLTI system using the standard predict-update recursion expressed as a pure lax.scan.

Innovation gains use Cholesky solves and posterior covariances use Joseph form by default. Innovation covariances must be positive definite. Gradients flow through the filter recursion, including covariance tuning. Inputs are promoted to a common floating dtype, including cached design data.

Parameters:

Name Type Description Default
sys DiscLTI

Discrete-time linear system.

required
Q Array

Process noise covariance. Shape: (n, n).

required
R Array

Measurement covariance (p, p), or a positive variance vector (p,) with InformationKalman, which requires positive-definite priors.

required
ys Array

Measurement sequence. Shape: (T, p).

required
x0 Array | None

Optional initial mean. Shape: (n,). Defaults to zeros.

None
P0 Array | None

Initial predicted covariance (n, n). Defaults to identity for covariance/information filtering, or design.P with SteadyStateKalman. A provided cached-mode P0 must match the design's predicted covariance.

None
us Array | None

Controls for feedthrough at k and transition k to k+1. Shape: (T, m). Defaults to zeros; the final transition is not returned.

None
has_measurement Array | None

Whole-observation availability flags. Shape: (T,). Missing observations may contain NaNs and return zero innovations.

None
solver Kalman | SteadyStateKalman | InformationKalman | None

Kalman (Joseph default, compact optional), InformationKalman for diagonal R and state-size solves, or SteadyStateKalman(design). Cached mode requires a fully observed stream and a gain/covariance consistent with A, C, Q and R; mismatch reports invalid_problem.

None
throw bool

Raise on numerical failure; otherwise inspect status.

False

Returns:

Type Description
KalmanResult

KalmanResult: A bundle containing filtered means xs, filtered covariances Ps, and measurement innovations innovations.

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,
... )
>>> result = cx.kalman(
...     sys, jnp.zeros((20, 2)), Q=1e-3 * jnp.eye(2), R=1e-2 * jnp.eye(2)
... )
Source code in contrax/_kalman.py
def kalman(
    sys: DiscLTI,
    ys: Array,  # (T, p) measurement sequence
    *,
    Q: Array,
    R: Array,
    x0: Array | None = None,
    P0: Array | None = None,
    us: Array | None = None,
    has_measurement: Array | None = None,
    solver: Kalman | SteadyStateKalman | InformationKalman | None = None,
    throw: bool = False,
) -> KalmanResult:
    """Run a linear discrete-time Kalman filter.

    Filters a measurement sequence for a
    [DiscLTI][contrax.systems.DiscLTI] system using the standard
    predict-update recursion expressed as a pure `lax.scan`.

    Innovation gains use Cholesky solves and posterior covariances use Joseph
    form by default. Innovation covariances must be positive definite. Gradients
    flow through the filter recursion, including covariance tuning. Inputs are
    promoted to a common floating dtype, including cached design data.

    Args:
        sys: Discrete-time linear system.
        Q: Process noise covariance. Shape: `(n, n)`.
        R: Measurement covariance `(p, p)`, or a positive variance vector `(p,)`
            with InformationKalman, which requires positive-definite priors.
        ys: Measurement sequence. Shape: `(T, p)`.
        x0: Optional initial mean. Shape: `(n,)`. Defaults to zeros.
        P0: Initial predicted covariance `(n, n)`. Defaults to identity for
            covariance/information filtering, or design.P with SteadyStateKalman.
            A provided cached-mode P0 must match the design's predicted covariance.
        us: Controls for feedthrough at k and transition k to k+1. Shape:
            `(T, m)`. Defaults to zeros; the final transition is not returned.
        has_measurement: Whole-observation availability flags. Shape: `(T,)`.
            Missing observations may contain NaNs and return zero innovations.
        solver: Kalman (Joseph default, compact optional), InformationKalman for
            diagonal R and state-size solves, or SteadyStateKalman(design).
            Cached mode requires a fully observed stream and a gain/covariance
            consistent with A, C, Q and R; mismatch reports invalid_problem.

        throw: Raise on numerical failure; otherwise inspect status.

    Returns:
        [KalmanResult][contrax.types.KalmanResult]: A bundle containing
            filtered means `xs`, filtered covariances `Ps`, and measurement
            innovations `innovations`.

    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,
        ... )
        >>> result = cx.kalman(
        ...     sys, jnp.zeros((20, 2)), Q=1e-3 * jnp.eye(2), R=1e-2 * jnp.eye(2)
        ... )
    """
    if not isinstance(sys, DiscLTI):
        raise TypeError("kalman requires a discrete LTI system")
    solver = Kalman() if solver is None else solver
    if not isinstance(solver, (Kalman, SteadyStateKalman, InformationKalman)):
        raise TypeError("solver must be Kalman, SteadyStateKalman or InformationKalman")
    information = isinstance(solver, InformationKalman)
    extra = ()
    if isinstance(solver, SteadyStateKalman):
        extra = (solver.design.K, solver.design.P)
        P0 = solver.design.P if P0 is None else P0
    ys, Q, R, us, x0, P0, flags = prepare_filter(
        sys,
        ys,
        Q,
        R,
        us,
        x0,
        P0,
        has_measurement,
        diagonal_R=information,
        extra_arrays=extra,
    )
    if isinstance(solver, SteadyStateKalman):
        result = cached_filter(sys, ys, Q, R, us, x0, P0, flags, solver)
        return checked(result, throw, "Kalman filter failed; inspect status")

    # Update-then-predict: x0/P0 is the prior on x_0.
    # Each scan step updates with y_k first (using the current prior), then
    # predicts forward to give the prior on x_{k+1}.
    def update_predict(carry, inputs):
        x_prior, P_prior = carry
        y, u, available, k = inputs
        x_post, P_post, innov = kalman_update(
            sys, x_prior, P_prior, y, R=R, u=u, has_measurement=available, solver=solver
        )
        x_next, P_next = jax.lax.cond(
            k + 1 < ys.shape[0],
            lambda: tuple(kalman_predict(sys, x_post, P_post, Q=Q, u=u)),
            lambda: (x_post, P_post),
        )
        return (x_next, P_next), (x_post, P_post, innov)

    _, (x_hats, Ps, innovations) = jax.lax.scan(
        update_predict, (x0, P0), (ys, us, flags, jnp.arange(ys.shape[0]))
    )
    result = KalmanResult(
        xs=x_hats,
        Ps=Ps,
        innovations=innovations,
        has_measurement=flags,
        solver_status=jnp.where(
            input_status(P0, Q) != 0, 2, noise_status(R, information)
        ),
    )
    return checked(result, throw, "Kalman filter failed; inspect status")

kalman_gain(sys: DiscLTI, Q: Array, R: Array, *, solver: DARE | None = None, throw: bool = False) -> KalmanGainResult

Design a steady-state Kalman filter gain for a discrete LTI system.

Solves the estimator-dual DARE for (A.T, C.T, Q, R) and returns the measurement-update gain for kalman_update().

Parameters:

Name Type Description Default
sys DiscLTI

Discrete-time linear system.

required
Q Array

Process noise covariance. Shape: (n, n).

required
R Array

Measurement noise covariance. Shape: (p, p).

required
solver DARE | None

Dual DARE solver configuration.

None
throw bool

Raise on failed Riccati design.

False

Returns:

Type Description
KalmanGainResult

KalmanGainResult: A bundle with update gain K, steady-state predicted covariance P, and estimator error-dynamics poles.

Examples:

>>> import jax
>>> jax.config.update("jax_enable_x64", True)
>>> 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,
... )
>>> design = cx.kalman_gain(sys, 0.001 * jnp.eye(2), 0.01 * jnp.eye(2))
Source code in contrax/_kalman_gain.py
def kalman_gain(
    sys: DiscLTI,
    Q: Array,
    R: Array,
    *,
    solver: DARE | None = None,
    throw: bool = False,
) -> KalmanGainResult:
    """Design a steady-state Kalman filter gain for a discrete LTI system.

    Solves the estimator-dual DARE for `(A.T, C.T, Q, R)` and returns the
    measurement-update gain for `kalman_update()`.

    Args:
        sys: Discrete-time linear system.
        Q: Process noise covariance. Shape: `(n, n)`.
        R: Measurement noise covariance. Shape: `(p, p)`.
        solver: Dual DARE solver configuration.
        throw: Raise on failed Riccati design.

    Returns:
        [KalmanGainResult][contrax.types.KalmanGainResult]: A bundle with
            update gain `K`, steady-state predicted covariance `P`, and
            estimator error-dynamics poles.

    Examples:
        >>> import jax
        >>> jax.config.update("jax_enable_x64", True)
        >>> 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,
        ... )
        >>> design = cx.kalman_gain(sys, 0.001 * jnp.eye(2), 0.01 * jnp.eye(2))
    """
    if not isinstance(sys, DiscLTI):
        raise TypeError("kalman_gain requires a discrete LTI system")
    design = dare(sys.A.T, sys.C.T, Q, R, solver=solver, throw=throw)
    P_pred = design.S
    innovation_cov = sys.C @ P_pred @ sys.C.T + R
    K = jnp.linalg.solve(innovation_cov.T, (P_pred @ sys.C.T).T).T
    closed_loop = sys.A - sys.A @ K @ sys.C
    return KalmanGainResult(
        K=K,
        P=P_pred,
        closed_loop=closed_loop,
        status=design.status,
        residual_norm=design.residual_norm,
        num_steps=design.num_steps,
    )

kalman_predict(sys: DiscLTI, x: Array, P: Array, *, Q: Array, u: Array | None = None, throw: bool = False) -> FilterPredictionResult

Predict one linear Kalman filter step.

Parameters:

Name Type Description Default
sys DiscLTI

Discrete-time linear system.

required
x Array

Filtered mean at the previous time step. Shape: (n,).

required
P Array

Filtered covariance at the previous time step. Shape: (n, n).

required
Q Array

Process noise covariance. Shape: (n, n).

required
u Array | None

Optional input for the transition. Shape: (m,). If omitted, the input term is zero.

None
throw bool

Raise on numerical failure; otherwise inspect status.

False

Returns:

Name Type Description
FilterPredictionResult FilterPredictionResult

Mean x, covariance P, status and successful.

Source code in contrax/_kalman.py
def kalman_predict(
    sys: DiscLTI,
    x: Array,
    P: Array,
    *,
    Q: Array,
    u: Array | None = None,
    throw: bool = False,
) -> FilterPredictionResult:
    """Predict one linear Kalman filter step.

    Args:
        sys: Discrete-time linear system.
        x: Filtered mean at the previous time step. Shape: `(n,)`.
        P: Filtered covariance at the previous time step. Shape: `(n, n)`.
        Q: Process noise covariance. Shape: `(n, n)`.
        u: Optional input for the transition. Shape: `(m,)`. If omitted, the
            input term is zero.

        throw: Raise on numerical failure; otherwise inspect status.

    Returns:
        FilterPredictionResult: Mean x, covariance P, status and successful.
    """
    x, P, Q = prepare_state(x, P, Q)
    u = _zero_input(sys, x) if u is None else u
    x_pred = sys.A @ x + sys.B @ u
    P_pred = _symmetrize(sys.A @ P @ sys.A.T + Q)
    result = FilterPredictionResult(x_pred, P_pred, input_status(P, Q))
    return checked(result, throw, "Kalman prediction failed; inspect status")

kalman_step(sys: DiscLTI, x: Array, P: Array, y: Array, *, Q: Array, R: Array, u: Array | None = None, has_measurement: Array | bool = True, solver: Kalman | SteadyStateKalman | InformationKalman | None = None, u_measurement: Array | None = None, throw: bool = False) -> FilterUpdateResult

Run one predict-update step of a linear Kalman filter.

The step can be called directly from a service loop or placed inside jax.lax.scan for a batch workflow.

Parameters:

Name Type Description Default
sys DiscLTI

Discrete-time linear system.

required
x Array

Filtered mean at the previous time step. Shape: (n,).

required
P Array

Filtered covariance at the previous time step. Shape: (n, n).

required
y Array

Measurement vector. Shape: (p,).

required
Q Array

Process noise covariance. Shape: (n, n).

required
R Array

Measurement covariance (p, p), or variances (p,) in information mode.

required
u Array | None

Optional input. Shape: (m,).

None
has_measurement Array | bool

Whether to apply the measurement update.

True
solver Kalman | SteadyStateKalman | InformationKalman | None

Covariance, information or explicit cached-design configuration, shared with batch filtering.

None
u_measurement Array | None

Input at the measurement time for direct feedthrough. Defaults to u; pass separately when consecutive controls differ.

None
throw bool

Raise on numerical failure; otherwise inspect status.

False

Returns:

Name Type Description
FilterUpdateResult FilterUpdateResult

Mean x, covariance P, innovation, status and successful.

Source code in contrax/_kalman.py
def kalman_step(
    sys: DiscLTI,
    x: Array,
    P: Array,
    y: Array,
    *,
    Q: Array,
    R: Array,
    u: Array | None = None,
    has_measurement: Array | bool = True,
    solver: Kalman | SteadyStateKalman | InformationKalman | None = None,
    u_measurement: Array | None = None,
    throw: bool = False,
) -> FilterUpdateResult:
    """Run one predict-update step of a linear Kalman filter.

    The step can be called directly from a service loop or placed inside
    `jax.lax.scan` for a batch workflow.

    Args:
        sys: Discrete-time linear system.
        x: Filtered mean at the previous time step. Shape: `(n,)`.
        P: Filtered covariance at the previous time step. Shape: `(n, n)`.
        y: Measurement vector. Shape: `(p,)`.
        Q: Process noise covariance. Shape: `(n, n)`.
        R: Measurement covariance `(p, p)`, or variances `(p,)` in information mode.
        u: Optional input. Shape: `(m,)`.
        has_measurement: Whether to apply the measurement update.
        solver: Covariance, information or explicit cached-design configuration,
            shared with batch filtering.
        u_measurement: Input at the measurement time for direct feedthrough.
            Defaults to u; pass separately when consecutive controls differ.

        throw: Raise on numerical failure; otherwise inspect status.

    Returns:
        FilterUpdateResult: Mean x, covariance P, innovation, status and successful.
    """
    prediction = kalman_predict(sys, x, P, Q=Q, u=u)
    x_pred, P_pred = prediction
    update = kalman_update(
        sys,
        x_pred,
        P_pred,
        y,
        R=R,
        u=u if u_measurement is None else u_measurement,
        has_measurement=has_measurement,
        solver=solver,
    )
    code = jnp.where(
        prediction.solver_status != 0, prediction.solver_status, update.solver_status
    )
    result = FilterUpdateResult(update.x, update.P, update.innovation, code)
    return checked(result, throw, "Kalman step failed; inspect status")

kalman_update(sys: DiscLTI, x_pred: Array, P_pred: Array, y: Array, *, R: Array, u: Array | None = None, has_measurement: Array | bool = True, solver: Kalman | SteadyStateKalman | InformationKalman | None = None, throw: bool = False) -> FilterUpdateResult

Update one linear Kalman filter step.

When has_measurement=False, returns the predicted state and covariance with a zero innovation. The flag may be a JAX scalar boolean, making this helper usable inside jax.lax.scan for streams with missing samples.

Parameters:

Name Type Description Default
sys DiscLTI

Discrete-time linear system.

required
x_pred Array

Predicted mean. Shape: (n,).

required
P_pred Array

Predicted covariance. Shape: (n, n).

required
y Array

Measurement vector. Shape: (p,).

required
R Array

Measurement noise covariance, shape (p, p), or a positive variance vector (p,) with InformationKalman.

required
u Array | None

Optional input for direct-feedthrough measurement models. Shape: (m,). If omitted, the feedthrough term is zero.

None
has_measurement Array | bool

Whether to apply the measurement update.

True
solver Kalman | SteadyStateKalman | InformationKalman | None

Kalman (Joseph by default), InformationKalman for diagonal R, or SteadyStateKalman for an explicit cached design. Cached mode requires P_pred to match design.P and an available observation.

None
throw bool

Raise on numerical failure; otherwise inspect status.

False

Returns:

Name Type Description
FilterUpdateResult FilterUpdateResult

Mean x, covariance P, innovation and status; unpacks

FilterUpdateResult

as (x, P, innovation), with the filtered mean, filtered covariance,

FilterUpdateResult

and measurement residual. Missing-measurement steps return a zero

FilterUpdateResult

innovation with the same shape as y.

Source code in contrax/_kalman.py
def kalman_update(
    sys: DiscLTI,
    x_pred: Array,
    P_pred: Array,
    y: Array,
    *,
    R: Array,
    u: Array | None = None,
    has_measurement: Array | bool = True,
    solver: Kalman | SteadyStateKalman | InformationKalman | None = None,
    throw: bool = False,
) -> FilterUpdateResult:
    """Update one linear Kalman filter step.

    When `has_measurement=False`, returns the predicted state and covariance
    with a zero innovation. The flag may be a JAX scalar boolean, making
    this helper usable inside `jax.lax.scan` for streams with missing samples.

    Args:
        sys: Discrete-time linear system.
        x_pred: Predicted mean. Shape: `(n,)`.
        P_pred: Predicted covariance. Shape: `(n, n)`.
        y: Measurement vector. Shape: `(p,)`.
        R: Measurement noise covariance, shape `(p, p)`, or a positive variance
            vector `(p,)` with InformationKalman.
        u: Optional input for direct-feedthrough measurement models. Shape:
            `(m,)`. If omitted, the feedthrough term is zero.
        has_measurement: Whether to apply the measurement update.
        solver: Kalman (Joseph by default), InformationKalman for diagonal R,
            or SteadyStateKalman for an explicit cached design. Cached mode
            requires P_pred to match design.P and an available observation.

        throw: Raise on numerical failure; otherwise inspect status.

    Returns:
        FilterUpdateResult: Mean x, covariance P, innovation and status; unpacks
        as (x, P, innovation), with the filtered mean, filtered covariance,
        and measurement residual. Missing-measurement steps return a zero
        innovation with the same shape as `y`.
    """
    solver = Kalman() if solver is None else solver
    if not isinstance(solver, (Kalman, SteadyStateKalman, InformationKalman)):
        raise TypeError("solver must be Kalman, SteadyStateKalman or InformationKalman")
    information = isinstance(solver, InformationKalman)
    x_pred, P_pred, R = prepare_state(
        x_pred,
        P_pred,
        R,
        measurement=y,
        diagonal_noise=information,
    )
    u = _zero_input(sys, x_pred) if u is None else u
    available = _measurement_flag(has_measurement)
    if information:
        result = information_update(sys, x_pred, P_pred, y, R, u, available)
        return checked(result, throw, "Kalman update failed; inspect status")
    if isinstance(solver, SteadyStateKalman):
        result = cached_update(sys, x_pred, P_pred, y, R, u, available, solver)
        return checked(result, throw, "Kalman update failed; inspect status")

    def apply_update():
        y_pred = sys.C @ x_pred + sys.D @ u
        S = sys.C @ P_pred @ sys.C.T + R
        factor = jnp.linalg.cholesky(S)
        K = cho_solve((factor, True), (P_pred @ sys.C.T).T).T
        # vmap lowers cond to selection; keep inactive NaN measurements out of
        # arithmetic as well as keeping all work inside the scalar branch.
        observed = jnp.where(_measurement_flag(has_measurement), y, jnp.zeros_like(y))
        innov = observed - y_pred
        x_new = x_pred + K @ innov
        P_new = _covariance_update(P_pred, sys.C, K, R, solver.covariance_update)
        return x_new, P_new, innov

    def skip_update():
        return x_pred, P_pred, jnp.zeros_like(y)

    x, P, innovation = jax.lax.cond(
        _measurement_flag(has_measurement),
        apply_update,
        skip_update,
    )
    result = FilterUpdateResult(x, P, innovation, input_status(P_pred, R))
    return checked(result, throw, "Kalman update failed; inspect status")

likelihood_diagnostics(log_likelihood_terms: Array, *, has_measurement: Array | None = None) -> LikelihoodDiagnostics

Summarize observed log likelihoods, excluding optional missing entries.

Missing terms are zero. With no observations, total is zero and mean/min/max are NaN; nonfinite flags numerical failure only in observed entries.

Source code in contrax/_estimation_diagnostics.py
def likelihood_diagnostics(
    log_likelihood_terms: Array,
    *,
    has_measurement: Array | None = None,
) -> LikelihoodDiagnostics:
    """Summarize observed log likelihoods, excluding optional missing entries.

    Missing terms are zero. With no observations, total is zero and mean/min/max
    are NaN; nonfinite flags numerical failure only in observed entries.
    """
    flags = _observations(log_likelihood_terms, has_measurement)
    log_likelihood_terms = jnp.where(flags, log_likelihood_terms, 0)
    mean, minimum, maximum = _masked_summary(log_likelihood_terms, flags)
    return LikelihoodDiagnostics(
        log_likelihood_terms=log_likelihood_terms,
        total_log_likelihood=jnp.sum(log_likelihood_terms),
        mean_log_likelihood=mean,
        min_log_likelihood=minimum,
        max_log_likelihood=maximum,
        nonfinite=jnp.logical_not(jnp.all(jnp.isfinite(log_likelihood_terms))),
    )

mhe(model_or_f: DiscLTI | SupportsNonlinearSystem | Callable[..., Array], xs_init: Array, us: Array, ys: Array, x_prior: Array, P_prior: Array, Q_noise: Array, R_noise: Array, *, observation: Callable[..., Array] | None = None, params: object | None = None, extra_cost: Callable[..., Array] | None = None, solver: AbstractMinimiser | None = None, max_steps: int = 256, ts: Array | None = None, measurement_us: Array | None = None, throw: bool = False) -> MHEResult

Solve a fixed-window moving-horizon-estimation problem.

Minimises mhe_objective over the candidate state trajectory xs_init using an optimistix solver. The default solver is optx.LBFGS(rtol=1e-6, atol=1e-6), which is well-suited to the deterministic, smooth, fixed-horizon objectives that arise in MHE.

The optimised terminal state final_state = xs[-1] is the natural input to a downstream controller or observer. For a linear-Gaussian model, the full trajectory xs matches the RTS smoother output within solver tolerance.

The LBFGS implementation is a baseline fixed-window optimizer. The best finite accepted iterate is retained on iteration limits; inspect solver_converged, status, and gradient_norm when assessing a result. Implicit JVP/VJP derivatives require a converged local stationary point with a nonsingular objective Hessian. Model, noise and optional parameter data are explicit optimizer arguments.

Parameters:

Name Type Description Default
model_or_f DiscLTI | SupportsNonlinearSystem | Callable[..., Array]

Discrete system model or callable transition f(x, u) (f(x, u, params) when params are supplied).

required
xs_init Array

Initial trajectory guess. Shape: (N, n), N >= 1. Using the Kalman filtered means as a warm start is recommended.

required
us Array

Transition inputs. Shape: (N-1, m), or (N-1, 2, m) for a sampled FOH model.

required
ys Array

Measurement sequence aligned with xs. Shape: (N, p).

required
x_prior Array

Prior mean for the start of the window. Shape: (n,).

required
P_prior Array

Positive-definite arrival covariance. Shape: (n, n).

required
Q_noise Array

Positive-definite process covariance. Shape: (n, n).

required
R_noise Array

Positive-definite measurement covariance. Shape: (p, p).

required
observation Callable[..., Array] | None

Required for callable transitions; h(x) or h(x, params).

None
params object | None

Optional parameters passed to f, h, and extra_cost.

None
extra_cost Callable[..., Array] | None

Optional callable adding additional smooth costs. Called as extra_cost(xs, us, ys) when params=None, and extra_cost(xs, us, ys, params) otherwise.

None
solver AbstractMinimiser | None

Optimistix minimiser. Defaults to optx.LBFGS(rtol=1e-6, atol=1e-6).

None
max_steps int

Maximum solver iterations. Defaults to 256.

256
ts Array | None

System-model sample times. Shape: (N,).

None
measurement_us Array | None

N observation inputs with the same trailing shape as us. Defaults to holding the final input endpoint at the final observation (zero for N=1). FOH observations use each pair's left input.

None
throw bool

Raise on failed convergence; otherwise return status diagnostics.

False

Returns:

Type Description
MHEResult

MHEResult: Estimated trajectory, terminal state, final cost, and convergence flag.

Examples:

>>> import jax.numpy as jnp
>>> import contrax as cx
>>> sys = cx.dss(
...     jnp.array([[0.8]]), jnp.zeros((1, 1)),
...     jnp.array([[1.0]]), jnp.zeros((1, 1)), dt=1.0,
... )
>>> ys = jnp.array([[0.1], [0.4], [0.6], [0.5], [0.4]])
>>> result = cx.mhe(
...     sys,
...     xs_init=jnp.zeros((5, 1)),
...     us=jnp.zeros((4, 1)),
...     ys=ys,
...     x_prior=jnp.zeros(1),
...     P_prior=jnp.eye(1),
...     Q_noise=0.05 * jnp.eye(1),
...     R_noise=0.2 * jnp.eye(1),
... )
Source code in contrax/_mhe.py
def mhe(
    model_or_f: DiscLTI | SupportsNonlinearSystem | Callable[..., Array],
    xs_init: Array,
    us: Array,
    ys: Array,
    x_prior: Array,
    P_prior: Array,
    Q_noise: Array,
    R_noise: Array,
    *,
    observation: Callable[..., Array] | None = None,
    params: object | None = None,
    extra_cost: Callable[..., Array] | None = None,
    solver: optx.AbstractMinimiser | None = None,
    max_steps: int = 256,
    ts: Array | None = None,
    measurement_us: Array | None = None,
    throw: bool = False,
) -> MHEResult:
    """Solve a fixed-window moving-horizon-estimation problem.

    Minimises `mhe_objective` over the candidate state trajectory `xs_init`
    using an `optimistix` solver.  The default solver is
    `optx.LBFGS(rtol=1e-6, atol=1e-6)`, which is well-suited to the
    deterministic, smooth, fixed-horizon objectives that arise in MHE.

    The optimised terminal state `final_state = xs[-1]` is the natural input to a
    downstream controller or observer.  For a linear-Gaussian model, the
    full trajectory `xs` matches the RTS smoother output within solver
    tolerance.

    The LBFGS implementation is a baseline fixed-window optimizer. The best
    finite accepted iterate is retained on iteration limits; inspect
    `solver_converged`, `status`, and `gradient_norm` when assessing a result.
    Implicit JVP/VJP derivatives require a converged local stationary point
    with a nonsingular objective Hessian.
    Model, noise and optional parameter data are explicit optimizer arguments.

    Args:
        model_or_f: Discrete system model or callable transition `f(x, u)`
            (`f(x, u, params)` when params are supplied).
        xs_init: Initial trajectory guess. Shape: `(N, n)`, N >= 1. Using the
            Kalman filtered means as a warm start is recommended.
        us: Transition inputs. Shape: `(N-1, m)`, or `(N-1, 2, m)`
            for a sampled FOH model.
        ys: Measurement sequence aligned with xs. Shape: `(N, p)`.
        x_prior: Prior mean for the start of the window.  Shape: `(n,)`.
        P_prior: Positive-definite arrival covariance. Shape: `(n, n)`.
        Q_noise: Positive-definite process covariance. Shape: `(n, n)`.
        R_noise: Positive-definite measurement covariance. Shape: `(p, p)`.
        observation: Required for callable transitions; `h(x)` or `h(x, params)`.
        params: Optional parameters passed to `f`, `h`, and `extra_cost`.
        extra_cost: Optional callable adding additional smooth costs.
            Called as `extra_cost(xs, us, ys)` when `params=None`, and
            `extra_cost(xs, us, ys, params)` otherwise.
        solver: Optimistix minimiser.  Defaults to
            `optx.LBFGS(rtol=1e-6, atol=1e-6)`.
        max_steps: Maximum solver iterations.  Defaults to 256.
        ts: System-model sample times. Shape: `(N,)`.
        measurement_us: N observation inputs with the same trailing shape as us.
            Defaults to holding the final input endpoint at the final observation
            (zero for N=1). FOH observations use each pair's left input.
        throw: Raise on failed convergence; otherwise return status diagnostics.

    Returns:
        [MHEResult][contrax.types.MHEResult]: Estimated trajectory, terminal
            state, final cost, and convergence flag.

    Examples:
        >>> import jax.numpy as jnp
        >>> import contrax as cx
        >>> sys = cx.dss(
        ...     jnp.array([[0.8]]), jnp.zeros((1, 1)),
        ...     jnp.array([[1.0]]), jnp.zeros((1, 1)), dt=1.0,
        ... )
        >>> ys = jnp.array([[0.1], [0.4], [0.6], [0.5], [0.4]])
        >>> result = cx.mhe(
        ...     sys,
        ...     xs_init=jnp.zeros((5, 1)),
        ...     us=jnp.zeros((4, 1)),
        ...     ys=ys,
        ...     x_prior=jnp.zeros(1),
        ...     P_prior=jnp.eye(1),
        ...     Q_noise=0.05 * jnp.eye(1),
        ...     R_noise=0.2 * jnp.eye(1),
        ... )
    """
    if solver is None:
        solver = optx.LBFGS(rtol=1e-6, atol=1e-6)
    if not isinstance(solver, optx.AbstractMinimiser):
        raise TypeError("solver must be an Optimistix minimiser")
    if not isinstance(max_steps, int) or max_steps < 0:
        raise ValueError("max_steps must be a nonnegative integer")
    validate_window(xs_init, us, ys, x_prior, P_prior, Q_noise, R_noise)
    valid_covariances = (
        covariance_valid(P_prior, strict=True)
        & covariance_valid(Q_noise, strict=True)
        & covariance_valid(R_noise, strict=True)
    )

    def fn(xs, args):
        (
            model,
            obs,
            us_,
            ys_,
            x_prior_,
            P_prior_,
            Q_noise_,
            R_noise_,
            params_,
            extra,
            ts_,
            measurement_us_,
        ) = args
        return mhe_objective(
            model,
            xs,
            us_,
            ys_,
            x_prior_,
            P_prior_,
            Q_noise_,
            R_noise_,
            observation=obs,
            params=params_,
            extra_cost=extra,
            ts=ts_,
            measurement_us=measurement_us_,
        )

    # All differentiable model/noise/parameter data is explicit optimizer data,
    # so the implicit optimality-system derivative includes those dependencies.
    args = (
        model_or_f,
        observation,
        us,
        ys,
        x_prior,
        P_prior,
        Q_noise,
        R_noise,
        params,
        extra_cost,
        ts,
        measurement_us,
    )
    initial_cost = fn(xs_init, args)
    try:
        sol = optx.minimise(
            fn,
            _BestSoFarMinimiser(solver),
            xs_init,
            args=args,
            max_steps=max_steps,
            throw=False,
        )
    except TypeError as error:
        # Optimistix LBFGS versions that allocate history without a dtype use
        # JAX's default precision, which can mismatch explicit float32 states.
        # Preserve the user's arrays and global precision configuration.
        if (
            type(solver) is optx.LBFGS
            and xs_init.dtype == jnp.float32
            and jax.config.jax_enable_x64
        ):
            raise ValueError(
                "Optimistix LBFGS could not initialize float32 MHE with x64 enabled. "
                "For a small window, pass "
                "solver=optimistix.BFGS(rtol=1e-6, atol=1e-6), "
                "or select a float32 JAX context explicitly. No precision was changed."
            ) from error
        raise
    xs_opt = sol.value
    final_cost = fn(xs_opt, args)
    finite = jnp.all(jnp.isfinite(xs_opt)) & jnp.isfinite(final_cost)
    converged = (sol.result == optx.RESULTS.successful) & valid_covariances
    status = jnp.where(
        ~finite,
        int(SolverStatus.numerical_failure),
        jnp.where(
            converged,
            int(SolverStatus.successful),
            jnp.where(
                sol.result == optx.RESULTS.nonlinear_max_steps_reached,
                int(SolverStatus.max_steps_reached),
                int(SolverStatus.numerical_failure),
            ),
        ),
    )
    status = jnp.where(valid_covariances, status, int(SolverStatus.invalid_problem))
    result = MHEResult(
        xs=xs_opt,
        final_cost=final_cost,
        solver_converged=converged,
        status=status,
        num_steps=sol.stats["num_steps"],
        gradient_norm=jnp.linalg.norm(jax.grad(fn)(xs_opt, args)),
        initial_cost=initial_cost,
    )
    return (
        eqx.error_if(
            result, status != 0, "MHE solve failed; inspect status and gradient_norm"
        )
        if throw
        else result
    )

mhe_objective(model_or_f: DiscLTI | SupportsNonlinearSystem | Callable[..., Array], xs: Array, us: Array, ys: Array, x_prior: Array, P_prior: Array, Q_noise: Array, R_noise: Array, *, observation: Callable[..., Array] | None = None, params: object | None = None, extra_cost: Callable[..., Array] | None = None, ts: Array | None = None, measurement_us: Array | None = None) -> Array

Evaluate a fixed-window moving-horizon-estimation objective.

The candidate state trajectory is explicit in xs, so callers can optimize it with a JAX optimizer. Accepts discrete LTI or nonlinear system models, or callable transitions with an explicit observation function. There are N states/measurements and N-1 transitions/inputs; traced inputs must retain fixed shapes.

The objective is a weighted nonlinear least-squares cost:

  • arrival cost on xs[0] - x_prior
  • process cost on xs[k + 1] - f(xs[k], us[k], params)
  • measurement cost on ys[k] - h(xs[k], params)
  • optional user extra_cost(xs, us, ys, params)

Parameters:

Name Type Description Default
model_or_f DiscLTI | SupportsNonlinearSystem | Callable[..., Array]

Discrete system model, or transition callable f(x, u) (f(x, u, params) when params are supplied).

required
xs Array

Candidate state trajectory. Shape: (N, n), N >= 1.

required
us Array

Transition inputs. Shape: (N-1, m), or (N-1, 2, m) for a sampled FOH model.

required
ys Array

Measurement sequence aligned with xs. Shape: (N, p).

required
x_prior Array

Prior state for the start of the window. Shape: (n,).

required
P_prior Array

Positive-definite arrival covariance. Shape: (n, n).

required
Q_noise Array

Positive-definite process covariance. Shape: (n, n).

required
R_noise Array

Positive-definite measurement covariance. Shape: (p, p).

required
observation Callable[..., Array] | None

Required for callable transitions; h(x) or h(x, params).

None
params object | None

Optional parameters passed to f, h, and extra_cost.

None
extra_cost Callable[..., Array] | None

Optional callable adding additional smooth costs. Called as extra_cost(xs, us, ys) when params=None, and extra_cost(xs, us, ys, params) otherwise.

None
ts Array | None

Sample times for system-model callables. Shape: (N,). Defaults to integer steps multiplied by the model sample period when available.

None
measurement_us Array | None

N observation inputs with the same trailing shape as us. Defaults to us with the final input endpoint held at the terminal observation (zero for N=1). FOH observations use each pair's left input.

None

Returns:

Name Type Description
Array Array

Scalar MHE cost.

Source code in contrax/_mhe.py
def mhe_objective(
    model_or_f: DiscLTI | SupportsNonlinearSystem | Callable[..., Array],
    xs: Array,
    us: Array,
    ys: Array,
    x_prior: Array,
    P_prior: Array,
    Q_noise: Array,
    R_noise: Array,
    *,
    observation: Callable[..., Array] | None = None,
    params: object | None = None,
    extra_cost: Callable[..., Array] | None = None,
    ts: Array | None = None,
    measurement_us: Array | None = None,
) -> Array:
    """Evaluate a fixed-window moving-horizon-estimation objective.

    The candidate state trajectory is explicit in `xs`, so callers can optimize
    it with a JAX optimizer. Accepts discrete LTI or nonlinear system models,
    or callable transitions with an explicit observation function. There are N
    states/measurements and N-1 transitions/inputs; traced inputs must retain
    fixed shapes.

    The objective is a weighted nonlinear least-squares cost:

    - arrival cost on `xs[0] - x_prior`
    - process cost on `xs[k + 1] - f(xs[k], us[k], params)`
    - measurement cost on `ys[k] - h(xs[k], params)`
    - optional user `extra_cost(xs, us, ys, params)`

    Args:
        model_or_f: Discrete system model, or transition callable `f(x, u)`
            (`f(x, u, params)` when params are supplied).
        xs: Candidate state trajectory. Shape: `(N, n)`, N >= 1.
        us: Transition inputs. Shape: `(N-1, m)`, or `(N-1, 2, m)`
            for a sampled FOH model.
        ys: Measurement sequence aligned with xs. Shape: `(N, p)`.
        x_prior: Prior state for the start of the window. Shape: `(n,)`.
        P_prior: Positive-definite arrival covariance. Shape: `(n, n)`.
        Q_noise: Positive-definite process covariance. Shape: `(n, n)`.
        R_noise: Positive-definite measurement covariance. Shape: `(p, p)`.
        observation: Required for callable transitions; `h(x)` or `h(x, params)`.
        params: Optional parameters passed to `f`, `h`, and `extra_cost`.
        extra_cost: Optional callable adding additional smooth costs.
            Called as `extra_cost(xs, us, ys)` when `params=None`, and
            `extra_cost(xs, us, ys, params)` otherwise.
        ts: Sample times for system-model callables. Shape: `(N,)`. Defaults to
            integer steps multiplied by the model sample period when available.
        measurement_us: N observation inputs with the same trailing shape as us.
            Defaults to us with the final input endpoint held at the terminal
            observation (zero for N=1). FOH observations use each pair's left input.

    Returns:
        Array: Scalar MHE cost.
    """
    validate_window(xs, us, ys, x_prior, P_prior, Q_noise, R_noise)
    arrival_residual = xs[0] - x_prior
    x_pred, y_pred = model_predictions(
        model_or_f,
        observation,
        xs,
        us,
        params,
        ts,
        measurement_us,
    )
    process_residuals = xs[1:] - x_pred
    measurement_residuals = ys - y_pred

    cost = (
        _weighted_sum_squares(arrival_residual[None], P_prior)
        + _weighted_sum_squares(process_residuals, Q_noise)
        + _weighted_sum_squares(measurement_residuals, R_noise)
    )
    if extra_cost is not None:
        if params is None:
            cost = cost + extra_cost(xs, us, ys)
        else:
            cost = cost + extra_cost(xs, us, ys, params)
    return cost

mhe_warm_start(xs: Array, *, transition: Callable[[Array, Array], Array] | None = None, terminal_input: Array | None = None) -> Array

Shift an MHE trajectory guess forward by one step.

By default, repeats the previous terminal state. Supply a transition and terminal_input to propagate that state; update the arrival prior separately when shifting the estimation window.

Source code in contrax/_mhe.py
def mhe_warm_start(
    xs: Array,
    *,
    transition: Callable[[Array, Array], Array] | None = None,
    terminal_input: Array | None = None,
) -> Array:
    """Shift an MHE trajectory guess forward by one step.

    By default, repeats the previous terminal state. Supply a transition and
    `terminal_input` to propagate that state; update the arrival prior
    separately when shifting the estimation window.
    """
    xs = jnp.asarray(xs)
    if xs.ndim < 2 or xs.shape[0] == 0:
        raise ValueError("mhe_warm_start() expects a state trajectory with time axis.")
    tail = xs[-1] if transition is None else transition(xs[-1], terminal_input)
    return jnp.concatenate([xs[1:], tail[None]], axis=0)

rts(sys: DiscLTI, result: KalmanResult, *, Q: Array, us: Array | None = None, throw: bool = False) -> RTSResult

Run a Rauch-Tung-Striebel smoother on filtered Kalman results.

Applies the standard backward smoothing pass to the output of kalman() using the same process model and process noise covariance.

Smooths a completed kalman() pass using the same process model and noise covariance. For deterministic dynamics, set Q to zero.

Parameters:

Name Type Description Default
sys DiscLTI

Discrete-time linear system used in the forward filter.

required
result KalmanResult

Output of kalman().

required
Q Array

Process noise covariance used by the forward filter.

required
us Array | None

Controls from the forward filter. Shape: (T, m). The last input is unused; defaults to zeros.

None
throw bool

Raise on numerical failure; otherwise inspect status.

False

Returns:

Type Description
RTSResult

RTSResult: A bundle containing smoothed state means and covariances.

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,
... )
>>> filtered = cx.kalman(
...     sys, jnp.zeros((20, 2)), Q=1e-3 * jnp.eye(2), R=1e-2 * jnp.eye(2)
... )
>>> smoothed = cx.rts(sys, filtered, Q=0.001 * jnp.eye(2))
Source code in contrax/_rts.py
def rts(
    sys: DiscLTI,
    result: KalmanResult,
    *,
    Q: Array,
    us: Array | None = None,
    throw: bool = False,
) -> RTSResult:
    """Run a Rauch-Tung-Striebel smoother on filtered Kalman results.

    Applies the standard backward smoothing pass to the output of `kalman()`
    using the same process model and process noise covariance.

    Smooths a completed `kalman()` pass using the same process model and noise
    covariance. For deterministic dynamics, set `Q` to zero.

    Args:
        sys: Discrete-time linear system used in the forward filter.
        result: Output of `kalman()`.
        Q: Process noise covariance used by the forward filter.
        us: Controls from the forward filter. Shape: `(T, m)`. The last input
            is unused; defaults to zeros.

        throw: Raise on numerical failure; otherwise inspect status.

    Returns:
        [RTSResult][contrax.types.RTSResult]: A bundle containing smoothed
            state means and covariances.

    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,
        ... )
        >>> filtered = cx.kalman(
        ...     sys, jnp.zeros((20, 2)), Q=1e-3 * jnp.eye(2), R=1e-2 * jnp.eye(2)
        ... )
        >>> smoothed = cx.rts(sys, filtered, Q=0.001 * jnp.eye(2))
    """
    if not isinstance(sys, DiscLTI):
        raise TypeError("rts requires a discrete LTI system")
    Q = jnp.asarray(Q)
    if Q.shape != sys.A.shape:
        raise ValueError("Q must match the model state dimensions")
    # Validate the incoming trajectory once, using a PD factorization fast
    # path with spectral fallback for singular PSD matrices.
    forward_status = state_status(
        result.xs, result.Ps, result.solver_status, trajectory=True, cholesky=True
    )
    forward_status = jnp.where(
        forward_status != 0,
        forward_status,
        jnp.where(jnp.all(jnp.isfinite(result.innovations)), 0, 3),
    )
    code = jnp.where(forward_status != 0, forward_status, input_status(Q))
    x_hats = result.xs  # (T, n)
    Ps = result.Ps  # (T, n, n)
    if x_hats.shape[0] == 0:
        return checked(
            RTSResult(xs=x_hats, Ps=Ps, solver_status=code),
            throw,
            "RTS smoother failed; inspect status",
        )
    us = (
        jnp.zeros((x_hats.shape[0], sys.B.shape[1]), dtype=x_hats.dtype)
        if us is None
        else us
    )
    if us.shape != (x_hats.shape[0], sys.B.shape[1]):
        raise ValueError("us must have shape (T, m)")

    def smooth_step(carry, inputs):
        x_s, P_s = carry
        x_f, P_f, u = inputs

        # Predicted mean/cov from forward pass
        x_pred = sys.A @ x_f + sys.B @ u
        P_pred = sys.A @ P_f @ sys.A.T + Q

        G = jnp.linalg.solve(
            P_pred.T, sys.A @ P_f.T
        ).T  # smoother gain: P_f A^T P_pred^{-1}
        x_s_new = x_f + G @ (x_s - x_pred)
        P_s_new = P_f + G @ (P_s - P_pred) @ G.T
        P_s_new = (P_s_new + P_s_new.T) / 2
        return (x_s_new, P_s_new), (x_s_new, P_s_new)

    # Initialise smoother at last filtered estimate
    init = (x_hats[-1], Ps[-1])
    # Scan backwards over t = T-2, ..., 0
    _, (x_smooth_rev, P_smooth_rev) = jax.lax.scan(
        smooth_step, init, (x_hats[:-1][::-1], Ps[:-1][::-1], us[:-1][::-1])
    )
    # Prepend the last (unsmoothed) estimate and reverse
    x_smooth = jnp.concatenate([x_smooth_rev[::-1], x_hats[-1:]], axis=0)
    P_smooth = jnp.concatenate([P_smooth_rev[::-1], Ps[-1:]], axis=0)
    smoothed = RTSResult(xs=x_smooth, Ps=P_smooth, solver_status=code)
    return checked(smoothed, throw, "RTS smoother failed; inspect status")

smoother_diagnostics(smoothed: RTSResult, filtered: KalmanResult | UKFResult) -> SmootherDiagnostics

Compute health diagnostics for an RTS or UKS smoother result.

Checks the covariance relation P_smooth ≤ P_filtered in the positive semidefinite sense and reports its worst violation. Empty inputs return empty per-step arrays and NaN extrema; nonfinite reflects invalid values in the supplied samples.

Parameters:

Name Type Description Default
smoothed RTSResult

Output of rts() or uks().

required
filtered KalmanResult | UKFResult

The forward filter result used to produce smoothed. Accepts both KalmanResult and UKFResult.

required

Returns:

Type Description
SmootherDiagnostics

SmootherDiagnostics: Diagnostic bundle. min_covariance_reduction < 0 is the primary health flag.

Examples:

>>> import jax.numpy as jnp
>>> import contrax as cx
>>> sys = cx.dss(
...     jnp.array([[0.9]]), jnp.zeros((1, 1)),
...     jnp.array([[1.0]]), jnp.zeros((1, 1)), dt=1.0,
... )
>>> filtered = cx.kalman(
...     sys, jnp.zeros((10, 1)), Q=1e-3 * jnp.eye(1), R=1e-2 * jnp.eye(1)
... )
>>> smoothed = cx.rts(sys, filtered, Q=0.001 * jnp.eye(1))
>>> diag = cx.smoother_diagnostics(smoothed, filtered)
Source code in contrax/_estimation_diagnostics.py
def smoother_diagnostics(
    smoothed: RTSResult,
    filtered: KalmanResult | UKFResult,
) -> SmootherDiagnostics:
    """Compute health diagnostics for an RTS or UKS smoother result.

    Checks the covariance relation ``P_smooth ≤ P_filtered`` in the positive
    semidefinite sense and reports its worst violation. Empty inputs return
    empty per-step arrays and NaN extrema; `nonfinite` reflects invalid values
    in the supplied samples.

    Args:
        smoothed: Output of `rts()` or `uks()`.
        filtered: The forward filter result used to produce ``smoothed``.
            Accepts both `KalmanResult` and `UKFResult`.

    Returns:
        [SmootherDiagnostics][contrax.types.SmootherDiagnostics]: Diagnostic
            bundle. ``min_covariance_reduction < 0`` is the primary health flag.

    Examples:
        >>> import jax.numpy as jnp
        >>> import contrax as cx
        >>> sys = cx.dss(
        ...     jnp.array([[0.9]]), jnp.zeros((1, 1)),
        ...     jnp.array([[1.0]]), jnp.zeros((1, 1)), dt=1.0,
        ... )
        >>> filtered = cx.kalman(
        ...     sys, jnp.zeros((10, 1)), Q=1e-3 * jnp.eye(1), R=1e-2 * jnp.eye(1)
        ... )
        >>> smoothed = cx.rts(sys, filtered, Q=0.001 * jnp.eye(1))
        >>> diag = cx.smoother_diagnostics(smoothed, filtered)
    """
    x_smooth = smoothed.xs  # (T, n)
    P_smooth = smoothed.Ps  # (T, n, n)
    x_filt = filtered.xs  # (T, n)
    P_filt = filtered.Ps  # (T, n, n)

    def _sym(M: Array) -> Array:
        return (M + M.T) / 2

    # Min eigenvalue of (P_f - P_s): >= 0 means smoother reduced uncertainty.
    cov_reduction = jax.vmap(
        lambda Pf, Ps: jnp.min(jnp.linalg.eigvalsh(_sym(Pf) - _sym(Ps)))
    )(P_filt, P_smooth)

    state_corrections = jnp.linalg.norm(x_smooth - x_filt, axis=-1)

    smoothed_min_eig = jax.vmap(lambda Ps: jnp.min(jnp.linalg.eigvalsh(_sym(Ps))))(
        P_smooth
    )

    nonfinite = jnp.logical_not(
        jnp.all(jnp.isfinite(x_smooth)) & jnp.all(jnp.isfinite(P_smooth))
    )

    return SmootherDiagnostics(
        covariance_reduction=cov_reduction,
        min_covariance_reduction=jnp.where(
            cov_reduction.size > 0, jnp.min(cov_reduction, initial=jnp.inf), jnp.nan
        ),
        state_corrections=state_corrections,
        max_state_correction=jnp.where(
            state_corrections.size > 0, jnp.max(state_corrections, initial=0), jnp.nan
        ),
        smoothed_min_eigenvalue=smoothed_min_eig,
        nonfinite=nonfinite,
    )

soft_quadratic_penalty(residuals: Array, weight: Array) -> Array

Return a quadratic soft penalty with matrix or scalar weighting.

A scalar weight multiplies the total squared residual norm; a matrix weight computes sum r.T @ weight @ r over the leading residual axes. The weight is a precision/cost weight. Choose a nonnegative scalar or a positive-semidefinite matrix to obtain a nonnegative penalty.

The penalty is differentiable with respect to residuals and weights.

Parameters:

Name Type Description Default
residuals Array

Real residual array, with final dimension n for matrix weights.

required
weight Array

Scalar weight or square cost matrix. Matrix shape: (n, n).

required

Returns:

Name Type Description
Array Array

Scalar weighted quadratic cost.

Source code in contrax/_mhe.py
def soft_quadratic_penalty(residuals: Array, weight: Array) -> Array:
    """Return a quadratic soft penalty with matrix or scalar weighting.

    A scalar weight multiplies the total squared residual norm; a matrix weight
    computes sum r.T @ weight @ r over the leading residual axes. The weight
    is a precision/cost weight. Choose a nonnegative scalar or a
    positive-semidefinite matrix to obtain a nonnegative penalty.

    The penalty is differentiable with respect to residuals and weights.

    Args:
        residuals: Real residual array, with final dimension n for matrix weights.
        weight: Scalar weight or square cost matrix. Matrix shape: `(n, n)`.

    Returns:
        Array: Scalar weighted quadratic cost.
    """
    residuals = jnp.asarray(residuals)
    weight = jnp.asarray(weight)
    if jnp.iscomplexobj(residuals) or jnp.iscomplexobj(weight):
        raise ValueError("soft_quadratic_penalty requires real residuals and weights")
    if weight.ndim == 0:
        return weight * jnp.sum(residuals**2)
    if (
        residuals.ndim < 1
        or weight.ndim != 2
        or weight.shape != (residuals.shape[-1], residuals.shape[-1])
    ):
        raise ValueError("matrix weight must match the final residual dimension")
    return jnp.einsum("...i,ij,...j->", residuals, weight, residuals)

ukf(model_or_f: DynamicsLike, ys: Array, *, Q: Array, R: Array, us: Array | None = None, x0: Array | None = None, P0: Array | None = None, observation: ObservationFn | None = None, has_measurement: Array | None = None, solver: UKF | None = None, throw: bool = False) -> UKFResult

Run an unscented Kalman filter for discrete nonlinear models.

Propagates means and covariances through nonlinear transition and observation models with a deterministic sigma-point transform.

Assumes additive process and measurement noise. Sigma-point and innovation covariances must be positive definite. One innovation Cholesky factor is used for both the gain and Gaussian likelihood.

Parameters:

Name Type Description Default
model_or_f DynamicsLike

Nonlinear system model or dynamics function.

required
Q Array

Process noise covariance. Shape: (n, n).

required
R Array

Measurement noise covariance. Shape: (p, p).

required
ys Array

Measurement sequence. Shape: (T, p).

required
us Array | None

Optional controls, shape (T, m) or (T, 2, m) for sampled FOH. Defaults to zero inputs; declare model.input_dim or pass us for controlled nonlinear models.

None
x0 Array | None

Initial prior mean, shape (n,). Defaults to zeros.

None
P0 Array | None

Initial prior covariance, shape (n, n). Defaults to identity.

None
observation ObservationFn | None

Observation function h(x) for plain dynamics callables. Omit this when passing a system model with its own output map.

None
has_measurement Array | None

Whole-observation flags, shape (T,). Missing samples may contain NaNs; their innovation, predicted measurement, innovation covariance and log-likelihood term are zero placeholders.

None
solver UKF | None

Sigma-point configuration; defaults to UKF().

None
throw bool

Raise on numerical failure; otherwise inspect status.

False

Returns:

Type Description
UKFResult

UKFResult: A bundle containing filtered means/covariances plus prediction, innovation, and likelihood intermediates useful for diagnostics and smoothing.

Examples:

>>> import jax.numpy as jnp
>>> import contrax as cx
>>> def f(t, x, u):
...     return jnp.array([x[0] + 0.1 * x[1], x[1] + u[0]])
>>> def h(x):
...     return jnp.array([x[0] ** 2])
>>> result = cx.ukf(
...     f, jnp.zeros((10, 1)), Q=1e-3 * jnp.eye(2), R=1e-2 * jnp.eye(1),
...     us=jnp.zeros((10, 1)), x0=jnp.ones(2), observation=h,
... )
Source code in contrax/_ukf.py
def ukf(
    model_or_f: DynamicsLike,
    ys: Array,
    *,
    Q: Array,
    R: Array,
    us: Array | None = None,
    x0: Array | None = None,
    P0: Array | None = None,
    observation: ObservationFn | None = None,
    has_measurement: Array | None = None,
    solver: UKF | None = None,
    throw: bool = False,
) -> UKFResult:
    """Run an unscented Kalman filter for discrete nonlinear models.

    Propagates means and covariances through nonlinear transition and
    observation models with a deterministic sigma-point transform.

    Assumes additive process and measurement noise. Sigma-point and innovation
    covariances must be positive definite. One innovation Cholesky factor is
    used for both the gain and Gaussian likelihood.

    Args:
        model_or_f: Nonlinear system model or dynamics function.
        Q: Process noise covariance. Shape: `(n, n)`.
        R: Measurement noise covariance. Shape: `(p, p)`.
        ys: Measurement sequence. Shape: `(T, p)`.
        us: Optional controls, shape `(T, m)` or `(T, 2, m)` for sampled FOH.
            Defaults to zero inputs; declare model.input_dim or pass us for
            controlled nonlinear models.
        x0: Initial prior mean, shape `(n,)`. Defaults to zeros.
        P0: Initial prior covariance, shape `(n, n)`. Defaults to identity.
        observation: Observation function `h(x)` for plain dynamics callables.
            Omit this when passing a system model with its own output map.
        has_measurement: Whole-observation flags, shape `(T,)`. Missing samples
            may contain NaNs; their innovation, predicted measurement,
            innovation covariance and log-likelihood term are zero placeholders.
        solver: Sigma-point configuration; defaults to `UKF()`.

        throw: Raise on numerical failure; otherwise inspect status.

    Returns:
        [UKFResult][contrax.types.UKFResult]: A bundle containing filtered
            means/covariances plus prediction, innovation, and likelihood
            intermediates useful for diagnostics and smoothing.

    Examples:
        >>> import jax.numpy as jnp
        >>> import contrax as cx
        >>> def f(t, x, u):
        ...     return jnp.array([x[0] + 0.1 * x[1], x[1] + u[0]])
        >>> def h(x):
        ...     return jnp.array([x[0] ** 2])
        >>> result = cx.ukf(
        ...     f, jnp.zeros((10, 1)), Q=1e-3 * jnp.eye(2), R=1e-2 * jnp.eye(1),
        ...     us=jnp.zeros((10, 1)), x0=jnp.ones(2), observation=h,
        ... )
    """

    solver = UKF() if solver is None else solver
    if not isinstance(solver, UKF):
        raise TypeError("ukf solver must be contrax.solvers.UKF")
    ys, Q, R, us, x0, P0, flags = prepare_filter(
        model_or_f, ys, Q, R, us, x0, P0, has_measurement
    )
    alpha, beta, kappa = solver.alpha, solver.beta, solver.kappa
    if x0.shape[0] + kappa <= 0:
        raise ValueError("UKF requires n+kappa > 0")
    if _is_system_model(model_or_f):
        if observation is not None:
            raise ValueError("ukf(sys, ...) does not accept observation=.")
        obs = model_or_f
    else:
        if observation is None:
            raise ValueError(
                "ukf(f, ..., observation=...) requires an observation function."
            )
        obs = observation

    f = _coerce_dynamics(model_or_f)
    h = _coerce_observation(
        obs,
        None if _is_system_model(obs) else obs,
    )
    dt = _system_dt(model_or_f)
    ts = jnp.arange(ys.shape[0], dtype=x0.dtype)
    if dt is not None:
        ts = ts * jnp.asarray(dt, dtype=x0.dtype)

    # Update-then-predict: x0/P0 is the prior on x_0.
    def update_predict(carry, inputs):
        x_prior, P_prior, code = carry
        y, u, t, available = inputs

        def apply_update():
            sigma_obs, Wm_obs, Wc_obs = _sigma_points(
                x_prior, P_prior, alpha, beta, kappa
            )
            y_sigma = jax.vmap(lambda s: h(t, s, u))(sigma_obs)
            if y_sigma.shape != (sigma_obs.shape[0], y.shape[0]):
                raise ValueError("The observation map must return a vector matching y")
            y_pred = jnp.einsum("i,ij->j", Wm_obs, y_sigma)
            y_centered = y_sigma - y_pred
            S = _symmetrize(_weighted_covariance(y_centered, Wc_obs) + R)
            Pxy = _cross_covariance(sigma_obs - x_prior, y_centered, Wc_obs)
            observed = jnp.where(available, y, jnp.zeros_like(y))
            innov = observed - y_pred
            # Reuse the innovation factor for gain and likelihood. The shared
            # triangular solve whitens both cross covariance and residual.
            L = jnp.linalg.cholesky(S)
            whitened = solve_triangular(
                L, jnp.concatenate([Pxy.T, innov[:, None]], axis=1), lower=True
            )
            K = solve_triangular(L.T, whitened[:, :-1], lower=False).T
            x_post = x_prior + K @ innov
            P_post = _symmetrize(P_prior - whitened[:, :-1].T @ whitened[:, :-1])
            logdet = 2 * jnp.sum(jnp.log(jnp.diag(L)))
            normalizer = y.shape[0] * jnp.log(jnp.asarray(2 * jnp.pi, dtype=S.dtype))
            log_likelihood = -0.5 * (
                jnp.sum(whitened[:, -1] ** 2) + logdet + normalizer
            )
            return x_post, P_post, innov, y_pred, S, log_likelihood

        def skip_update():
            return (
                x_prior,
                P_prior,
                jnp.zeros_like(y),
                jnp.zeros_like(y),
                jnp.zeros_like(R),
                jnp.zeros((), dtype=x_prior.dtype),
            )

        x_post, P_post, innov, y_pred, S, log_likelihood = jax.lax.cond(
            available, apply_update, skip_update
        )

        # Predict: unscented transform through f from the posterior.
        sigma, Wm, Wc = _sigma_points(x_post, P_post, alpha, beta, kappa)

        def transition(state):
            if callable(getattr(model_or_f, "step", None)):
                step = model_or_f.step(t, state, u)
                value = jnp.where(
                    step.successful, step.x, jnp.full_like(step.x, jnp.nan)
                )
                return value, step.status
            value = f(t, state, u)
            return value, jnp.where(jnp.all(jnp.isfinite(value)), 0, 3)

        sigma_pred, codes = jax.vmap(transition)(sigma)
        next_code = codes[jnp.argmax(codes != 0)]
        code = jnp.where(code != 0, code, next_code)
        x_next = jnp.einsum("i,ij->j", Wm, sigma_pred)
        sigma_pred_centered = sigma_pred - x_next
        P_next = _weighted_covariance(sigma_pred_centered, Wc) + Q
        P_next = _symmetrize(P_next)
        P_cross = _cross_covariance(sigma - x_post, sigma_pred_centered, Wc)

        return (x_next, P_next, code), (
            x_post,
            P_post,
            innov,
            y_pred,
            S,
            log_likelihood,
            x_next,
            P_next,
            P_cross,
        )

    (
        (_, _, code),
        (
            x_hats,
            Ps,
            innovations,
            predicted_measurements,
            innovation_covariances,
            log_likelihood_terms,
            predicted_state_means,
            predicted_state_covariances,
            transition_cross_covariances,
        ),
    ) = jax.lax.scan(
        update_predict,
        (x0, _symmetrize(P0), input_status(P0, Q, R)),
        (ys, us, ts, flags),
    )
    result = UKFResult(
        xs=x_hats,
        Ps=Ps,
        innovations=innovations,
        has_measurement=flags,
        predicted_measurements=predicted_measurements,
        innovation_covariances=innovation_covariances,
        log_likelihood_terms=log_likelihood_terms,
        predicted_state_means=predicted_state_means,
        predicted_state_covariances=predicted_state_covariances,
        transition_cross_covariances=transition_cross_covariances,
        solver_status=code,
    )
    return checked(result, throw, "UKF filter failed; inspect status")

ukf_diagnostics(result: UKFResult) -> tuple[InnovationDiagnostics, LikelihoodDiagnostics]

Build the standard diagnostics pair for a UKF result.

Source code in contrax/_estimation_diagnostics.py
def ukf_diagnostics(
    result: UKFResult,
) -> tuple[InnovationDiagnostics, LikelihoodDiagnostics]:
    """Build the standard diagnostics pair for a UKF result."""
    return (
        innovation_diagnostics(
            result.innovations,
            result.innovation_covariances,
            has_measurement=result.has_measurement,
        ),
        likelihood_diagnostics(
            result.log_likelihood_terms,
            has_measurement=result.has_measurement,
        ),
    )

uks(result: UKFResult, *, throw: bool = False) -> RTSResult

Run an unscented Rauch-Tung-Striebel-style smoother.

Uses the predicted means, covariances, and transition cross-covariances in UKFResult. Empty passes return empty results.

Source code in contrax/_ukf.py
def uks(
    result: UKFResult,
    *,
    throw: bool = False,
) -> RTSResult:
    """Run an unscented Rauch-Tung-Striebel-style smoother.

    Uses the predicted means, covariances, and transition cross-covariances in
    `UKFResult`. Empty passes return empty results.
    """
    x_hats = result.xs
    Ps = result.Ps
    if x_hats.shape[0] == 0:
        return checked(
            RTSResult(xs=x_hats, Ps=Ps, solver_status=result.status),
            throw,
            "UKS smoother failed; inspect status",
        )

    def smooth_step(carry, inputs):
        x_s, P_s = carry
        x_f, P_f, x_pred, P_pred, P_cross = inputs
        G = jnp.linalg.solve(P_pred.T, P_cross.T).T
        x_s_new = x_f + G @ (x_s - x_pred)
        P_s_new = _symmetrize(P_f + G @ (P_s - P_pred) @ G.T)
        return (x_s_new, P_s_new), (x_s_new, P_s_new)

    init = (x_hats[-1], Ps[-1])
    _, (x_smooth_rev, P_smooth_rev) = jax.lax.scan(
        smooth_step,
        init,
        (
            x_hats[:-1][::-1],
            Ps[:-1][::-1],
            result.predicted_state_means[:-1][::-1],
            result.predicted_state_covariances[:-1][::-1],
            result.transition_cross_covariances[:-1][::-1],
        ),
    )
    x_smooth = jnp.concatenate([x_smooth_rev[::-1], x_hats[-1:]], axis=0)
    P_smooth = jnp.concatenate([P_smooth_rev[::-1], Ps[-1:]], axis=0)
    smoothed = RTSResult(xs=x_smooth, Ps=P_smooth, solver_status=result.status)
    return checked(smoothed, throw, "UKS smoother failed; inspect status")