Skip to content

Commit 2b2d59b

Browse files
authored
Fix Fourier RHM validation and prediction atomicity (#5443)
* Harden Fourier RHM validation and prediction atomicity * Add Fourier RHM validation regressions
1 parent dd227b2 commit 2b2d59b

2 files changed

Lines changed: 163 additions & 28 deletions

File tree

src/pyrecest/filters/fourier_rhm_tracker.py

Lines changed: 82 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from __future__ import annotations
22

3+
from math import isfinite as python_isfinite
34
from numbers import Integral
45

6+
import numpy as np
57
import pyrecest.backend
68

79
# pylint: disable=no-name-in-module,no-member,redefined-builtin,duplicate-code
@@ -21,6 +23,7 @@
2123
zeros,
2224
zeros_like,
2325
)
26+
from pyrecest.numerics import assert_covariance_matrix
2427
from pyrecest.sampling.sigma_points import MerweScaledSigmaPoints
2528

2629
from .abstract_extended_object_tracker import AbstractExtendedObjectTracker
@@ -30,6 +33,19 @@ def _pol2cart(phi, radius=1.0):
3033
return radius * stack((cos(phi), sin(phi)))
3134

3235

36+
def _ensure_finite_real_array(value, name):
37+
"""Reject non-finite or complex arrays before they enter the RHM recursion."""
38+
try:
39+
host_value = np.asarray(pyrecest.backend.to_numpy(value))
40+
if np.iscomplexobj(host_value):
41+
raise ValueError
42+
finite = bool(np.all(np.isfinite(host_value)))
43+
except (TypeError, ValueError, OverflowError, RuntimeError) as exc:
44+
raise ValueError(f"{name} must contain only finite real values") from exc
45+
if not finite:
46+
raise ValueError(f"{name} must contain only finite real values")
47+
48+
3349
class FourierRHMTracker(
3450
AbstractExtendedObjectTracker
3551
): # pylint: disable=too-many-instance-attributes
@@ -78,10 +94,16 @@ def __init__(
7894
self.n_harmonics = self._as_integer(n_harmonics, "n_harmonics", 0)
7995
self.n_fourier_coefficients = 2 * self.n_harmonics + 1
8096
self.state_dim = self.n_fourier_coefficients + 2
97+
self.covariance_regularization = self._as_finite_float(
98+
covariance_regularization, "covariance_regularization"
99+
)
100+
if self.covariance_regularization < 0.0:
101+
raise ValueError("covariance_regularization must be non-negative")
81102

82103
if fourier_coefficients is None:
104+
initial_radius = self._as_finite_float(initial_radius, "initial_radius")
83105
fourier_coefficients = zeros(self.n_fourier_coefficients)
84-
fourier_coefficients[0] = 2.0 * float(initial_radius)
106+
fourier_coefficients[0] = 2.0 * initial_radius
85107
self.fourier_coefficients = self._as_vector(
86108
fourier_coefficients,
87109
self.n_fourier_coefficients,
@@ -109,21 +131,19 @@ def __init__(
109131
covariance, self.state_dim, "covariance"
110132
)
111133
self._validate_positive_definite(
112-
self.covariance + covariance_regularization * eye(self.state_dim),
134+
self.covariance
135+
+ self.covariance_regularization * eye(self.state_dim),
113136
"covariance",
114137
)
115138

116-
self.scale_mean = float(scale_mean)
117-
self.scale_variance = float(scale_variance)
139+
self.scale_mean = self._as_finite_float(scale_mean, "scale_mean")
140+
self.scale_variance = self._as_finite_float(scale_variance, "scale_variance")
118141
if self.scale_variance < 0.0:
119142
raise ValueError("scale_variance must be non-negative")
120143

121-
self.ukf_alpha = float(ukf_alpha)
122-
self.ukf_beta = float(ukf_beta)
123-
self.ukf_kappa = float(ukf_kappa)
124-
self.covariance_regularization = float(covariance_regularization)
125-
if self.covariance_regularization < 0.0:
126-
raise ValueError("covariance_regularization must be non-negative")
144+
self.ukf_alpha = self._as_finite_float(ukf_alpha, "ukf_alpha")
145+
self.ukf_beta = self._as_finite_float(ukf_beta, "ukf_beta")
146+
self.ukf_kappa = self._as_finite_float(ukf_kappa, "ukf_kappa")
127147

128148
self.latest_pseudo_measurement = None
129149
self.latest_innovation_covariance = None
@@ -159,11 +179,22 @@ def _as_integer(value, name, minimum):
159179
raise ValueError(f"{name} must be at least {minimum}")
160180
return integer
161181

182+
@staticmethod
183+
def _as_finite_float(value, name):
184+
try:
185+
scalar = float(value)
186+
except (TypeError, ValueError, OverflowError) as exc:
187+
raise ValueError(f"{name} must be a finite scalar") from exc
188+
if not python_isfinite(scalar):
189+
raise ValueError(f"{name} must be a finite scalar")
190+
return scalar
191+
162192
@staticmethod
163193
def _as_vector(value, dim, name):
164194
vector = array(value).reshape(-1)
165195
if vector.shape != (dim,):
166196
raise ValueError(f"{name} must have shape ({dim},)")
197+
_ensure_finite_real_array(vector, name)
167198
return vector
168199

169200
@classmethod
@@ -177,22 +208,25 @@ def _as_square_matrix(cls, value, dim, name):
177208
matrix = diag(matrix)
178209
if matrix.shape != (dim, dim):
179210
raise ValueError(f"{name} must have shape ({dim}, {dim})")
180-
return cls._symmetrize(matrix)
211+
return assert_covariance_matrix(matrix, name=name, dim=dim)
181212

182213
@staticmethod
183214
def _normalize_measurements(measurements):
184215
measurements = array(measurements)
185216
if measurements.ndim == 1:
186217
if measurements.shape[0] != 2:
187218
raise ValueError("A single measurement vector must have shape (2,)")
188-
return reshape(measurements, (2, 1))
189-
if measurements.ndim != 2:
219+
normalized = reshape(measurements, (2, 1))
220+
elif measurements.ndim != 2:
190221
raise ValueError("measurements must be a vector or a two-dimensional array")
191-
if measurements.shape[0] == 2:
192-
return measurements
193-
if measurements.shape[1] == 2:
194-
return measurements.T
195-
raise ValueError("measurements must have shape (2, n) or (n, 2)")
222+
elif measurements.shape[0] == 2:
223+
normalized = measurements
224+
elif measurements.shape[1] == 2:
225+
normalized = measurements.T
226+
else:
227+
raise ValueError("measurements must have shape (2, n) or (n, 2)")
228+
_ensure_finite_real_array(normalized, "measurements")
229+
return normalized
196230

197231
def _state_vector(self):
198232
return concatenate([self.fourier_coefficients, self.kinematic_state])
@@ -243,9 +277,12 @@ def get_contour_points(self, n=100):
243277
def predict_identity(self, sys_noise=None):
244278
if sys_noise is None:
245279
sys_noise = zeros((self.state_dim, self.state_dim))
246-
self.covariance = self._symmetrize(
247-
self.covariance
248-
+ self._as_square_matrix(sys_noise, self.state_dim, "sys_noise")
280+
sys_noise = self._as_square_matrix(sys_noise, self.state_dim, "sys_noise")
281+
predicted_covariance = self._symmetrize(self.covariance + sys_noise)
282+
self.covariance = assert_covariance_matrix(
283+
predicted_covariance,
284+
name="predicted_covariance",
285+
dim=self.state_dim,
249286
)
250287
if self.log_prior_estimates:
251288
self.store_prior_estimates()
@@ -258,16 +295,30 @@ def predict_linear(self, system_matrix, sys_noise=None, inputs=None):
258295
raise ValueError(
259296
f"system_matrix must have shape ({self.state_dim}, {self.state_dim})"
260297
)
298+
_ensure_finite_real_array(system_matrix, "system_matrix")
299+
261300
state = system_matrix @ self._state_vector()
262301
if inputs is not None:
263302
state = state + self._as_vector(inputs, self.state_dim, "inputs")
264-
self._set_state_vector(state)
303+
state = self._as_vector(state, self.state_dim, "predicted_state")
304+
265305
if sys_noise is None:
266306
sys_noise = zeros((self.state_dim, self.state_dim))
267-
self.covariance = self._symmetrize(
268-
system_matrix @ self.covariance @ system_matrix.T
269-
+ self._as_square_matrix(sys_noise, self.state_dim, "sys_noise")
307+
sys_noise = self._as_square_matrix(sys_noise, self.state_dim, "sys_noise")
308+
predicted_covariance = self._symmetrize(
309+
system_matrix @ self.covariance @ system_matrix.T + sys_noise
310+
)
311+
predicted_covariance = assert_covariance_matrix(
312+
predicted_covariance,
313+
name="predicted_covariance",
314+
dim=self.state_dim,
270315
)
316+
317+
# Commit only after every failure-prone validation succeeds. In particular,
318+
# invalid process noise must not leave the state advanced while covariance
319+
# remains at the previous time step.
320+
self._set_state_vector(state)
321+
self.covariance = predicted_covariance
271322
if self.log_prior_estimates:
272323
self.store_prior_estimates()
273324
if self.log_prior_extents:
@@ -311,7 +362,8 @@ def _update_single(self, measurement, meas_noise_cov, scale_mean, scale_variance
311362
augmented_covariance = linalg.block_diag(self.covariance, noise_covariance)
312363
augmented_dim = augmented_mean.shape[0]
313364
augmented_covariance = self._symmetrize(
314-
augmented_covariance + self.covariance_regularization * eye(augmented_dim)
365+
augmented_covariance
366+
+ self.covariance_regularization * eye(augmented_dim)
315367
)
316368

317369
sigma_points = MerweScaledSigmaPoints(
@@ -378,6 +430,8 @@ def update(
378430
scale_mean = self.scale_mean
379431
if scale_variance is None:
380432
scale_variance = self.scale_variance
433+
scale_mean = self._as_finite_float(scale_mean, "scale_mean")
434+
scale_variance = self._as_finite_float(scale_variance, "scale_variance")
381435
if scale_variance < 0.0:
382436
raise ValueError("scale_variance must be non-negative")
383437

@@ -386,8 +440,8 @@ def update(
386440
self._update_single(
387441
measurements[:, measurement_index],
388442
meas_noise_cov,
389-
float(scale_mean),
390-
float(scale_variance),
443+
scale_mean,
444+
scale_variance,
391445
)
392446

393447
if self.log_posterior_estimates:
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import unittest
2+
3+
import numpy as np
4+
import numpy.testing as npt
5+
6+
# pylint: disable=no-name-in-module,no-member
7+
import pyrecest.backend
8+
from pyrecest.backend import array, eye, zeros
9+
from pyrecest.filters import FourierRHMTracker
10+
11+
12+
@unittest.skipIf(
13+
pyrecest.backend.__backend_name__ != "numpy",
14+
reason="Fourier RHM tracker validation tests use numpy.testing assertions",
15+
)
16+
class TestFourierRHMTrackerValidation(unittest.TestCase):
17+
def test_predict_linear_validation_failure_is_atomic(self):
18+
tracker = FourierRHMTracker(1)
19+
state_before = tracker.get_point_estimate().copy()
20+
covariance_before = tracker.covariance.copy()
21+
system_matrix = 2.0 * eye(tracker.state_dim)
22+
23+
with self.assertRaises(ValueError):
24+
tracker.predict_linear(system_matrix, sys_noise=zeros((2, 2)))
25+
26+
npt.assert_allclose(tracker.get_point_estimate(), state_before)
27+
npt.assert_allclose(tracker.covariance, covariance_before)
28+
29+
def test_process_noise_covariance_is_not_silently_symmetrized(self):
30+
tracker = FourierRHMTracker(1)
31+
covariance_before = tracker.covariance.copy()
32+
asymmetric_noise = eye(tracker.state_dim)
33+
asymmetric_noise[0, 1] = 0.5
34+
35+
with self.assertRaises(ValueError):
36+
tracker.predict_identity(asymmetric_noise)
37+
38+
npt.assert_allclose(tracker.covariance, covariance_before)
39+
40+
def test_update_rejects_nonfinite_measurement_noise_atomically(self):
41+
tracker = FourierRHMTracker(0)
42+
state_before = tracker.get_point_estimate().copy()
43+
covariance_before = tracker.covariance.copy()
44+
invalid_noise = array([[0.01, 0.0], [0.0, np.nan]])
45+
46+
with self.assertRaises(ValueError):
47+
tracker.update(array([2.0, 0.0]), meas_noise_cov=invalid_noise)
48+
49+
npt.assert_allclose(tracker.get_point_estimate(), state_before)
50+
npt.assert_allclose(tracker.covariance, covariance_before)
51+
52+
def test_constructor_rejects_nonfinite_scalar_controls(self):
53+
for keyword in (
54+
"scale_mean",
55+
"scale_variance",
56+
"ukf_alpha",
57+
"ukf_beta",
58+
"ukf_kappa",
59+
"covariance_regularization",
60+
):
61+
with self.subTest(keyword=keyword), self.assertRaises(ValueError):
62+
FourierRHMTracker(0, **{keyword: np.nan})
63+
64+
def test_update_rejects_nonfinite_scale_overrides(self):
65+
tracker = FourierRHMTracker(0)
66+
state_before = tracker.get_point_estimate().copy()
67+
covariance_before = tracker.covariance.copy()
68+
69+
for keyword in ("scale_mean", "scale_variance"):
70+
with self.subTest(keyword=keyword), self.assertRaises(ValueError):
71+
tracker.update(
72+
array([2.0, 0.0]),
73+
meas_noise_cov=0.01 * eye(2),
74+
**{keyword: np.nan},
75+
)
76+
npt.assert_allclose(tracker.get_point_estimate(), state_before)
77+
npt.assert_allclose(tracker.covariance, covariance_before)
78+
79+
80+
if __name__ == "__main__":
81+
unittest.main()

0 commit comments

Comments
 (0)