nrl-tracker 1.5.0__py3-none-any.whl → 1.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,370 @@
1
+ """
2
+ Constrained Extended Kalman Filter (CEKF).
3
+
4
+ Extends the Extended Kalman Filter to enforce constraints on the state
5
+ estimate. Uses Lagrange multiplier method to project onto constraint manifold
6
+ while maintaining positive definite covariance.
7
+
8
+ References
9
+ ----------
10
+ .. [1] Simon, D. (2006). Optimal State Estimation: Kalman, H∞, and Nonlinear
11
+ Approaches. Wiley-Interscience.
12
+ .. [2] Simon, D. & Simon, D. L. (2010). Constrained Kalman filtering via
13
+ density function truncation. Journal of Guidance, Control, and Dynamics.
14
+ """
15
+
16
+ from typing import Callable, Optional
17
+
18
+ import numpy as np
19
+ from numpy.typing import ArrayLike, NDArray
20
+
21
+ from pytcl.dynamic_estimation.kalman.linear import KalmanPrediction, KalmanUpdate
22
+ from pytcl.dynamic_estimation.kalman.extended import ekf_predict, ekf_update
23
+
24
+
25
+ class ConstraintFunction:
26
+ """Base class for state constraints."""
27
+
28
+ def __init__(self, g: Callable[[NDArray], NDArray],
29
+ G: Optional[Callable[[NDArray], NDArray]] = None,
30
+ constraint_type: str = 'inequality'):
31
+ """
32
+ Define a constraint: g(x) ≤ 0 (inequality) or g(x) = 0 (equality).
33
+
34
+ Parameters
35
+ ----------
36
+ g : callable
37
+ Constraint function: g(x) -> scalar or array
38
+ Inequality: g(x) ≤ 0
39
+ Equality: g(x) = 0
40
+ G : callable, optional
41
+ Jacobian of g with respect to x: ∂g/∂x
42
+ If None, computed numerically.
43
+ constraint_type : {'inequality', 'equality'}
44
+ Constraint type.
45
+ """
46
+ self.g = g
47
+ self.G = G
48
+ self.constraint_type = constraint_type
49
+
50
+ def evaluate(self, x: NDArray) -> NDArray:
51
+ """Evaluate constraint at state x."""
52
+ return np.atleast_1d(np.asarray(self.g(x), dtype=np.float64))
53
+
54
+ def jacobian(self, x: NDArray) -> NDArray:
55
+ """Compute constraint Jacobian at x."""
56
+ if self.G is not None:
57
+ return np.atleast_2d(np.asarray(self.G(x), dtype=np.float64))
58
+ else:
59
+ # Numerical differentiation
60
+ eps = 1e-6
61
+ n = len(x)
62
+ g_x = self.evaluate(x)
63
+ m = len(g_x)
64
+ J = np.zeros((m, n))
65
+ for i in range(n):
66
+ x_plus = x.copy()
67
+ x_plus[i] += eps
68
+ g_plus = self.evaluate(x_plus)
69
+ J[:, i] = (g_plus - g_x) / eps
70
+ return J
71
+
72
+ def is_satisfied(self, x: NDArray, tol: float = 1e-6) -> bool:
73
+ """Check if constraint is satisfied."""
74
+ g_val = self.evaluate(x)
75
+ if self.constraint_type == 'inequality':
76
+ return np.all(g_val <= tol)
77
+ else: # equality
78
+ return np.allclose(g_val, 0, atol=tol)
79
+
80
+
81
+ class ConstrainedEKF:
82
+ """
83
+ Extended Kalman Filter with state constraints.
84
+
85
+ Enforces linear and/or nonlinear constraints on state estimate using
86
+ Lagrange multiplier method with covariance projection.
87
+
88
+ Attributes
89
+ ----------
90
+ constraints : list of ConstraintFunction
91
+ List of active constraints.
92
+ """
93
+
94
+ def __init__(self):
95
+ """Initialize Constrained EKF."""
96
+ self.constraints = []
97
+
98
+ def add_constraint(self, constraint: ConstraintFunction):
99
+ """
100
+ Add a constraint to the filter.
101
+
102
+ Parameters
103
+ ----------
104
+ constraint : ConstraintFunction
105
+ Constraint to enforce.
106
+ """
107
+ self.constraints.append(constraint)
108
+
109
+ def predict(
110
+ self,
111
+ x: ArrayLike,
112
+ P: ArrayLike,
113
+ f: Callable[[NDArray], NDArray],
114
+ F: ArrayLike,
115
+ Q: ArrayLike,
116
+ ) -> KalmanPrediction:
117
+ """
118
+ Constrained EKF prediction step.
119
+
120
+ Performs standard EKF prediction (constraints not enforced here,
121
+ only checked). Constraint enforcement happens in update step.
122
+
123
+ Parameters
124
+ ----------
125
+ x : array_like
126
+ Current state estimate, shape (n,).
127
+ P : array_like
128
+ Current state covariance, shape (n, n).
129
+ f : callable
130
+ Nonlinear state transition function.
131
+ F : array_like
132
+ Jacobian of f at current state.
133
+ Q : array_like
134
+ Process noise covariance, shape (n, n).
135
+
136
+ Returns
137
+ -------
138
+ result : KalmanPrediction
139
+ Predicted state and covariance.
140
+ """
141
+ return ekf_predict(x, P, f, F, Q)
142
+
143
+ def update(
144
+ self,
145
+ x: ArrayLike,
146
+ P: ArrayLike,
147
+ z: ArrayLike,
148
+ h: Callable[[NDArray], NDArray],
149
+ H: ArrayLike,
150
+ R: ArrayLike,
151
+ ) -> KalmanUpdate:
152
+ """
153
+ Constrained EKF update step.
154
+
155
+ Performs standard EKF update, then projects result onto constraint
156
+ manifold.
157
+
158
+ Parameters
159
+ ----------
160
+ x : array_like
161
+ Predicted state estimate, shape (n,).
162
+ P : array_like
163
+ Predicted state covariance, shape (n, n).
164
+ z : array_like
165
+ Measurement, shape (m,).
166
+ h : callable
167
+ Nonlinear measurement function.
168
+ H : array_like
169
+ Jacobian of h at current state.
170
+ R : array_like
171
+ Measurement noise covariance, shape (m, m).
172
+
173
+ Returns
174
+ -------
175
+ result : KalmanUpdate
176
+ Updated state and covariance (constrained).
177
+ """
178
+ # Standard EKF update
179
+ result = ekf_update(x, P, z, h, H, R)
180
+ x_upd = result.x
181
+ P_upd = result.P
182
+
183
+ # Apply constraint projection
184
+ if self.constraints:
185
+ x_upd, P_upd = self._project_onto_constraints(x_upd, P_upd)
186
+
187
+ return KalmanUpdate(x=x_upd, P=P_upd, y=result.y, S=result.S, K=result.K, likelihood=result.likelihood)
188
+
189
+ def _project_onto_constraints(
190
+ self,
191
+ x: NDArray,
192
+ P: NDArray,
193
+ max_iter: int = 10,
194
+ tol: float = 1e-6,
195
+ ) -> tuple[NDArray, NDArray]:
196
+ """
197
+ Project state and covariance onto constraint manifold.
198
+
199
+ Uses iterative Lagrange multiplier method with covariance
200
+ projection to enforce constraints while maintaining positive
201
+ definiteness.
202
+
203
+ Parameters
204
+ ----------
205
+ x : ndarray
206
+ State estimate, shape (n,).
207
+ P : ndarray
208
+ Covariance matrix, shape (n, n).
209
+ max_iter : int
210
+ Maximum iterations for constraint projection.
211
+ tol : float
212
+ Convergence tolerance.
213
+
214
+ Returns
215
+ -------
216
+ x_proj : ndarray
217
+ Constrained state estimate.
218
+ P_proj : ndarray
219
+ Projected covariance.
220
+ """
221
+ x_proj = x.copy()
222
+ P_proj = P.copy()
223
+
224
+ # Check which constraints are violated
225
+ violated = [c for c in self.constraints if not c.is_satisfied(x_proj)]
226
+
227
+ if not violated:
228
+ return x_proj, P_proj
229
+
230
+ # Iterative projection
231
+ for iteration in range(max_iter):
232
+ converged = True
233
+
234
+ for constraint in violated:
235
+ g_val = constraint.evaluate(x_proj)
236
+ G = constraint.jacobian(x_proj)
237
+
238
+ # Only process violated constraints
239
+ if constraint.constraint_type == 'inequality':
240
+ mask = g_val > tol
241
+ else:
242
+ mask = np.abs(g_val) > tol
243
+
244
+ if not np.any(mask):
245
+ continue
246
+
247
+ converged = False
248
+
249
+ # Lagrange multiplier for this constraint
250
+ # λ = -(G P Gᵀ + μ)⁻¹ G (x - x_ref)
251
+ # where x_ref is desired state (we use x)
252
+
253
+ GP = G @ P_proj
254
+ GPGt = GP @ G.T
255
+
256
+ # Add small regularization for numerical stability
257
+ mu = np.eye(GPGt.shape[0]) * 1e-6
258
+
259
+ try:
260
+ GPGt_inv = np.linalg.inv(GPGt + mu)
261
+ lam = -GPGt_inv @ (G @ x_proj + g_val)
262
+
263
+ # State correction
264
+ x_corr = P_proj @ G.T @ lam
265
+ x_proj = x_proj + x_corr
266
+
267
+ # Covariance projection
268
+ # P_proj = P - P G^T (G P G^T)^{-1} G P
269
+ P_proj = P_proj - GP.T @ GPGt_inv @ GP
270
+
271
+ # Ensure symmetry
272
+ P_proj = (P_proj + P_proj.T) / 2
273
+
274
+ # Enforce positive definiteness
275
+ eigvals, eigvecs = np.linalg.eigh(P_proj)
276
+ if np.any(eigvals < 0):
277
+ eigvals[eigvals < 1e-10] = 1e-10
278
+ P_proj = eigvecs @ np.diag(eigvals) @ eigvecs.T
279
+
280
+ except np.linalg.LinAlgError:
281
+ # If inversion fails, use pseudoinverse
282
+ GPGt_pinv = np.linalg.pinv(GPGt)
283
+ lam = -GPGt_pinv @ (G @ x_proj + g_val)
284
+ x_proj = x_proj + P_proj @ G.T @ lam
285
+
286
+ if converged:
287
+ break
288
+
289
+ return x_proj, P_proj
290
+
291
+
292
+ def constrained_ekf_predict(
293
+ x: ArrayLike,
294
+ P: ArrayLike,
295
+ f: Callable[[NDArray], NDArray],
296
+ F: ArrayLike,
297
+ Q: ArrayLike,
298
+ ) -> KalmanPrediction:
299
+ """
300
+ Convenience function for constrained EKF prediction.
301
+
302
+ Parameters
303
+ ----------
304
+ x : array_like
305
+ Current state estimate.
306
+ P : array_like
307
+ Current covariance.
308
+ f : callable
309
+ Nonlinear dynamics function.
310
+ F : array_like
311
+ Jacobian of f.
312
+ Q : array_like
313
+ Process noise covariance.
314
+
315
+ Returns
316
+ -------
317
+ result : KalmanPrediction
318
+ Predicted state and covariance.
319
+ """
320
+ cekf = ConstrainedEKF()
321
+ return cekf.predict(x, P, f, F, Q)
322
+
323
+
324
+ def constrained_ekf_update(
325
+ x: ArrayLike,
326
+ P: ArrayLike,
327
+ z: ArrayLike,
328
+ h: Callable[[NDArray], NDArray],
329
+ H: ArrayLike,
330
+ R: ArrayLike,
331
+ constraints: Optional[list] = None,
332
+ ) -> KalmanUpdate:
333
+ """
334
+ Convenience function for constrained EKF update.
335
+
336
+ Parameters
337
+ ----------
338
+ x : array_like
339
+ Predicted state.
340
+ P : array_like
341
+ Predicted covariance.
342
+ z : array_like
343
+ Measurement.
344
+ h : callable
345
+ Nonlinear measurement function.
346
+ H : array_like
347
+ Jacobian of h.
348
+ R : array_like
349
+ Measurement noise covariance.
350
+ constraints : list, optional
351
+ List of ConstraintFunction objects.
352
+
353
+ Returns
354
+ -------
355
+ result : KalmanUpdate
356
+ Updated state and covariance.
357
+ """
358
+ cekf = ConstrainedEKF()
359
+ if constraints:
360
+ for c in constraints:
361
+ cekf.add_constraint(c)
362
+ return cekf.update(x, P, z, h, H, R)
363
+
364
+
365
+ __all__ = [
366
+ "ConstraintFunction",
367
+ "ConstrainedEKF",
368
+ "constrained_ekf_predict",
369
+ "constrained_ekf_update",
370
+ ]