From ef61e3dbc5d56d8dbc1d2678abfe9b71386c8f3f Mon Sep 17 00:00:00 2001 From: Florian Pfaff <6773539+FlorianPfaff@users.noreply.github.com> Date: Mon, 31 Aug 2026 05:08:25 +0800 Subject: [PATCH 1/2] Validate Kalman noise covariances --- src/pyrecest/filters/kalman_filter.py | 31 ++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/pyrecest/filters/kalman_filter.py b/src/pyrecest/filters/kalman_filter.py index 4bd54ee67..0c756695b 100644 --- a/src/pyrecest/filters/kalman_filter.py +++ b/src/pyrecest/filters/kalman_filter.py @@ -46,21 +46,29 @@ def _get_optional_model_attribute(model, *names): return None -def _validate_kalman_state_covariance(covariance, dim): +def _validate_kalman_covariance(covariance, *, name, dim=None): """Validate a Kalman covariance without leaving the active backend.""" covariance = validate_covariance_matrix( covariance, - name="state.covariance", + name=name, dim=dim, allow_scalar=True, check_symmetric=True, ) eigenvalues = linalg.eigvalsh(covariance) if not bool(backend_all(eigenvalues >= -_STATE_COVARIANCE_EIGENVALUE_ATOL)): - raise ValueError("state.covariance must be positive semidefinite.") + raise ValueError(f"{name} must be positive semidefinite.") return covariance +def _validate_kalman_state_covariance(covariance, dim): + return _validate_kalman_covariance( + covariance, + name="state.covariance", + dim=dim, + ) + + class KalmanFilter(AbstractFilter, EuclideanFilterMixin): """Kalman filter for linear Gaussian Euclidean state-space models. @@ -168,6 +176,11 @@ def predict_linear( sys_input : array-like, shape (n,), optional Additive deterministic input ``u``. """ + sys_noise_cov = _validate_kalman_covariance( + sys_noise_cov, + name="sys_noise_cov", + dim=self.dim, + ) new_mean, new_covariance = linear_gaussian_predict( mean=self._filter_state.mu, covariance=self._filter_state.C, @@ -246,6 +259,10 @@ def update_identity( def innovation_linear(self, measurement, measurement_matrix, meas_noise): """Return innovation and innovation covariance for a linear measurement.""" + meas_noise = _validate_kalman_covariance( + meas_noise, + name="meas_noise", + ) return linear_gaussian_innovation( self._filter_state.mu, self._filter_state.C, @@ -298,6 +315,10 @@ def update_linear( action : str, optional Caller-defined diagnostic label for the update action. """ + meas_noise = _validate_kalman_covariance( + meas_noise, + name="meas_noise", + ) result = linear_gaussian_update( mean=self._filter_state.mu, covariance=self._filter_state.C, @@ -341,6 +362,10 @@ def update_linear_robust( ``"none"``. With ``robust_update=None`` and ``gate_threshold`` set, measurements above the gate are rejected and the prior state is kept. """ + meas_noise = _validate_kalman_covariance( + meas_noise, + name="meas_noise", + ) result = linear_gaussian_update_robust( mean=self._filter_state.mu, covariance=self._filter_state.C, From 26b565aea15a967210c1826bd7ecce4d60b4c99a Mon Sep 17 00:00:00 2001 From: Florian Pfaff <6773539+FlorianPfaff@users.noreply.github.com> Date: Mon, 31 Aug 2026 05:08:46 +0800 Subject: [PATCH 2/2] Add Kalman noise covariance regression tests --- ...lman_filter_noise_covariance_validation.py | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/filters/test_kalman_filter_noise_covariance_validation.py diff --git a/tests/filters/test_kalman_filter_noise_covariance_validation.py b/tests/filters/test_kalman_filter_noise_covariance_validation.py new file mode 100644 index 000000000..804a97ea3 --- /dev/null +++ b/tests/filters/test_kalman_filter_noise_covariance_validation.py @@ -0,0 +1,74 @@ +import numpy as np +import pytest + +from pyrecest import backend +from pyrecest.filters import KalmanFilter + + +def _one_dimensional_filter(): + return KalmanFilter((backend.array([0.0]), backend.array([[1.0]]))) + + +def _assert_unit_state_unchanged(kalman_filter): + np.testing.assert_allclose(backend.to_numpy(kalman_filter.filter_state.mu), [0.0]) + np.testing.assert_allclose( + backend.to_numpy(kalman_filter.filter_state.C), + [[1.0]], + ) + + +def test_predict_rejects_non_psd_process_noise_atomically(): + kalman_filter = _one_dimensional_filter() + + with pytest.raises(ValueError, match="sys_noise_cov must be positive semidefinite"): + kalman_filter.predict_identity(backend.array([[-2.0]])) + + _assert_unit_state_unchanged(kalman_filter) + + +def test_update_rejects_non_psd_measurement_noise_atomically(): + kalman_filter = _one_dimensional_filter() + + with pytest.raises(ValueError, match="meas_noise must be positive semidefinite"): + kalman_filter.update_identity( + backend.array([[-0.5]]), + backend.array([0.0]), + ) + + _assert_unit_state_unchanged(kalman_filter) + + +def test_innovation_rejects_non_psd_measurement_noise(): + kalman_filter = _one_dimensional_filter() + + with pytest.raises(ValueError, match="meas_noise must be positive semidefinite"): + kalman_filter.innovation_linear( + backend.array([0.0]), + backend.array([[1.0]]), + backend.array([[-0.5]]), + ) + + +def test_robust_update_rejects_non_psd_measurement_noise_atomically(): + kalman_filter = _one_dimensional_filter() + + with pytest.raises(ValueError, match="meas_noise must be positive semidefinite"): + kalman_filter.update_linear_robust( + backend.array([0.0]), + backend.array([[1.0]]), + backend.array([[-0.5]]), + ) + + _assert_unit_state_unchanged(kalman_filter) + + +def test_singular_noise_covariances_remain_supported(): + kalman_filter = _one_dimensional_filter() + + kalman_filter.predict_identity(backend.array([[0.0]])) + kalman_filter.update_identity( + backend.array([[0.0]]), + backend.array([0.0]), + ) + + np.testing.assert_allclose(backend.to_numpy(kalman_filter.filter_state.C), [[0.0]])