"""Ready-to-use Kalman-filter model assembly.
A :class:`KalmanModel` bundles a state-space system with its process,
measurement, and initial covariances into the exact matrices a Kalman filter
consumes. The system can be discretized at a chosen sample time during assembly,
and the covariance dimensions are validated against the system so the resulting
model is internally consistent.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import numpy as np
import numpy.typing as npt
from vane.sysid.covariance_init import CovarianceSet
from vane.sysid.state_space import StateSpace
__all__ = ["KalmanModel", "build_kalman_model"]
[docs]
@dataclass
class KalmanModel:
"""A state-space system with Kalman covariances, ready for filtering.
Parameters
----------
a : npt.NDArray[np.float64]
State matrix, shape ``(n_states, n_states)``.
b, c, d : npt.NDArray[np.float64] or None
Input, output, and feedthrough matrices.
q, r, p0 : npt.NDArray[np.float64]
Process-noise, measurement-noise, and initial state covariances.
dt : float or None
Sample time; ``None`` for a continuous-time model.
state_names, output_names : list[str]
State and output names carried from the source system.
"""
a: npt.NDArray[np.float64]
b: npt.NDArray[np.float64] | None
c: npt.NDArray[np.float64] | None
d: npt.NDArray[np.float64] | None
q: npt.NDArray[np.float64]
r: npt.NDArray[np.float64]
p0: npt.NDArray[np.float64]
dt: float | None = None
state_names: list[str] = field(default_factory=list)
output_names: list[str] = field(default_factory=list)
[docs]
def build_kalman_model(
state_space: StateSpace,
covariances: CovarianceSet,
*,
dt: float | None = None,
) -> KalmanModel:
"""Assemble a Kalman model from a state-space system and covariances.
Parameters
----------
state_space : StateSpace
The continuous- or discrete-time system.
covariances : CovarianceSet
The process, measurement, and initial covariances.
dt : float or None, optional
When given and the system is continuous, discretize it at this sample time
before assembly.
Returns
-------
KalmanModel
The assembled, dimension-checked model.
Raises
------
ValueError
If ``dt`` is given for an already-discrete system, or the covariance
dimensions do not match the system.
"""
if dt is not None and state_space.is_discrete:
msg = (
f"state_space is already discrete (dt={state_space.dt}); cannot "
f"re-discretize with dt={dt}"
)
raise ValueError(msg)
system = state_space.discretized(dt) if dt is not None else state_space
_validate_covariance_dimensions(system, covariances)
return KalmanModel(
a=system.a,
b=system.b,
c=system.c,
d=system.d,
q=covariances.q,
r=covariances.r,
p0=covariances.p0,
dt=system.dt,
state_names=list(system.state_names),
output_names=list(system.output_names),
)
def _validate_covariance_dimensions(
system: StateSpace, covariances: CovarianceSet
) -> None:
"""Validate that covariance shapes match the system dimensions."""
n = system.n_states
if covariances.q.shape != (n, n):
msg = f"Q must have shape {(n, n)}, got {covariances.q.shape}"
raise ValueError(msg)
if covariances.p0.shape != (n, n):
msg = f"P0 must have shape {(n, n)}, got {covariances.p0.shape}"
raise ValueError(msg)
# Validate R against the output count unconditionally: a system without C has
# zero outputs, so a non-empty R is an inconsistency rather than skipped.
p = system.n_outputs
if covariances.r.shape != (p, p):
msg = f"R must have shape {(p, p)}, got {covariances.r.shape}"
raise ValueError(msg)