nrl-tracker 1.9.2__py3-none-any.whl → 1.11.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.
pytcl/gpu/ekf.py ADDED
@@ -0,0 +1,433 @@
1
+ """
2
+ GPU-accelerated Extended Kalman Filter using CuPy.
3
+
4
+ This module provides GPU-accelerated implementations of the Extended Kalman
5
+ Filter (EKF) for batch processing of multiple tracks with nonlinear dynamics.
6
+
7
+ The EKF handles nonlinear systems by linearizing around the current estimate:
8
+ x_k = f(x_{k-1}) + w (nonlinear dynamics)
9
+ z_k = h(x_k) + v (nonlinear measurement)
10
+
11
+ Key Features
12
+ ------------
13
+ - Batch processing of multiple tracks with same or different dynamics
14
+ - Support for user-provided Jacobian functions
15
+ - Numerical Jacobian computation when analytic unavailable
16
+ - Memory-efficient operations using CuPy
17
+
18
+ Examples
19
+ --------
20
+ >>> from pytcl.gpu.ekf import batch_ekf_predict, batch_ekf_update
21
+ >>> import numpy as np
22
+ >>>
23
+ >>> # Define nonlinear dynamics (on CPU, applied per-particle)
24
+ >>> def f_dynamics(x):
25
+ ... return np.array([x[0] + x[1], x[1] * 0.99])
26
+ >>>
27
+ >>> def F_jacobian(x):
28
+ ... return np.array([[1, 1], [0, 0.99]])
29
+ >>>
30
+ >>> # Batch prediction
31
+ >>> x_pred, P_pred = batch_ekf_predict(x, P, f_dynamics, F_jacobian, Q)
32
+ """
33
+
34
+ from typing import Any, Callable, NamedTuple, Optional
35
+
36
+ import numpy as np
37
+ from numpy.typing import ArrayLike, NDArray
38
+
39
+ from pytcl.core.optional_deps import import_optional, requires
40
+ from pytcl.gpu.utils import ensure_gpu_array, to_cpu
41
+
42
+
43
+ class BatchEKFPrediction(NamedTuple):
44
+ """Result of batch EKF prediction.
45
+
46
+ Attributes
47
+ ----------
48
+ x : ndarray
49
+ Predicted state estimates, shape (n_tracks, state_dim).
50
+ P : ndarray
51
+ Predicted covariances, shape (n_tracks, state_dim, state_dim).
52
+ """
53
+
54
+ x: NDArray[np.floating]
55
+ P: NDArray[np.floating]
56
+
57
+
58
+ class BatchEKFUpdate(NamedTuple):
59
+ """Result of batch EKF update.
60
+
61
+ Attributes
62
+ ----------
63
+ x : ndarray
64
+ Updated state estimates.
65
+ P : ndarray
66
+ Updated covariances.
67
+ y : ndarray
68
+ Innovations.
69
+ S : ndarray
70
+ Innovation covariances.
71
+ K : ndarray
72
+ Kalman gains.
73
+ likelihood : ndarray
74
+ Measurement likelihoods.
75
+ """
76
+
77
+ x: NDArray[np.floating]
78
+ P: NDArray[np.floating]
79
+ y: NDArray[np.floating]
80
+ S: NDArray[np.floating]
81
+ K: NDArray[np.floating]
82
+ likelihood: NDArray[np.floating]
83
+
84
+
85
+ def _compute_numerical_jacobian(
86
+ f: Callable[[NDArray[np.floating[Any]]], NDArray[np.floating[Any]]],
87
+ x: NDArray[np.floating[Any]],
88
+ eps: float = 1e-7,
89
+ ) -> NDArray[np.floating[Any]]:
90
+ """
91
+ Compute numerical Jacobian using central differences.
92
+
93
+ Parameters
94
+ ----------
95
+ f : callable
96
+ Function to differentiate.
97
+ x : ndarray
98
+ Point at which to evaluate Jacobian.
99
+ eps : float
100
+ Finite difference step size.
101
+
102
+ Returns
103
+ -------
104
+ J : ndarray
105
+ Jacobian matrix, shape (output_dim, input_dim).
106
+ """
107
+ x = np.asarray(x).flatten()
108
+ n = len(x)
109
+ f0 = np.asarray(f(x)).flatten()
110
+ m = len(f0)
111
+
112
+ J = np.zeros((m, n))
113
+ for i in range(n):
114
+ x_plus = x.copy()
115
+ x_minus = x.copy()
116
+ x_plus[i] += eps
117
+ x_minus[i] -= eps
118
+ f_plus = np.asarray(f(x_plus)).flatten()
119
+ f_minus = np.asarray(f(x_minus)).flatten()
120
+ J[:, i] = (f_plus - f_minus) / (2 * eps)
121
+
122
+ return J
123
+
124
+
125
+ @requires("cupy", extra="gpu", feature="GPU Extended Kalman filter")
126
+ def batch_ekf_predict(
127
+ x: ArrayLike,
128
+ P: ArrayLike,
129
+ f: Callable[[NDArray[np.floating[Any]]], NDArray[np.floating[Any]]],
130
+ F_jacobian: Optional[
131
+ Callable[[NDArray[np.floating[Any]]], NDArray[np.floating[Any]]]
132
+ ],
133
+ Q: ArrayLike,
134
+ ) -> BatchEKFPrediction:
135
+ """
136
+ Batch EKF prediction for multiple tracks.
137
+
138
+ Parameters
139
+ ----------
140
+ x : array_like
141
+ Current state estimates, shape (n_tracks, state_dim).
142
+ P : array_like
143
+ Current covariances, shape (n_tracks, state_dim, state_dim).
144
+ f : callable
145
+ Nonlinear dynamics function f(x) -> x_next.
146
+ Applied to each track's state vector.
147
+ F_jacobian : callable or None
148
+ Jacobian of dynamics df/dx. If None, computed numerically.
149
+ Q : array_like
150
+ Process noise covariance, shape (state_dim, state_dim)
151
+ or (n_tracks, state_dim, state_dim).
152
+
153
+ Returns
154
+ -------
155
+ result : BatchEKFPrediction
156
+ Predicted states and covariances.
157
+
158
+ Notes
159
+ -----
160
+ The nonlinear dynamics are applied on CPU (Python function), then
161
+ covariance propagation is performed on GPU. This is efficient when
162
+ the number of tracks is large relative to the cost of the dynamics.
163
+ """
164
+ cp = import_optional("cupy", extra="gpu", feature="GPU Extended Kalman filter")
165
+
166
+ # Convert to numpy for dynamics evaluation
167
+ x_np = np.asarray(x)
168
+ P_gpu = ensure_gpu_array(P, dtype=cp.float64)
169
+ Q_gpu = ensure_gpu_array(Q, dtype=cp.float64)
170
+
171
+ n_tracks = x_np.shape[0]
172
+ state_dim = x_np.shape[1]
173
+
174
+ # Apply nonlinear dynamics to each track (on CPU)
175
+ x_pred_np = np.zeros_like(x_np)
176
+ F_matrices = np.zeros((n_tracks, state_dim, state_dim))
177
+
178
+ for i in range(n_tracks):
179
+ x_i = x_np[i]
180
+ x_pred_np[i] = f(x_i)
181
+
182
+ # Compute Jacobian
183
+ if F_jacobian is not None:
184
+ F_matrices[i] = F_jacobian(x_i)
185
+ else:
186
+ F_matrices[i] = _compute_numerical_jacobian(f, x_i)
187
+
188
+ # Move to GPU
189
+ x_pred_gpu = ensure_gpu_array(x_pred_np, dtype=cp.float64)
190
+ F_gpu = ensure_gpu_array(F_matrices, dtype=cp.float64)
191
+
192
+ # Handle Q dimensions
193
+ if Q_gpu.ndim == 2:
194
+ Q_batch = cp.broadcast_to(Q_gpu, (n_tracks, state_dim, state_dim))
195
+ else:
196
+ Q_batch = Q_gpu
197
+
198
+ # Covariance prediction on GPU: P_pred = F @ P @ F' + Q
199
+ FP = cp.einsum("nij,njk->nik", F_gpu, P_gpu)
200
+ P_pred = cp.einsum("nij,nkj->nik", FP, F_gpu) + Q_batch
201
+
202
+ # Ensure symmetry
203
+ P_pred = (P_pred + cp.swapaxes(P_pred, -2, -1)) / 2
204
+
205
+ return BatchEKFPrediction(x=x_pred_gpu, P=P_pred)
206
+
207
+
208
+ @requires("cupy", extra="gpu", feature="GPU Extended Kalman filter")
209
+ def batch_ekf_update(
210
+ x: ArrayLike,
211
+ P: ArrayLike,
212
+ z: ArrayLike,
213
+ h: Callable[[NDArray[np.floating[Any]]], NDArray[np.floating[Any]]],
214
+ H_jacobian: Optional[
215
+ Callable[[NDArray[np.floating[Any]]], NDArray[np.floating[Any]]]
216
+ ],
217
+ R: ArrayLike,
218
+ ) -> BatchEKFUpdate:
219
+ """
220
+ Batch EKF update for multiple tracks.
221
+
222
+ Parameters
223
+ ----------
224
+ x : array_like
225
+ Predicted state estimates, shape (n_tracks, state_dim).
226
+ P : array_like
227
+ Predicted covariances, shape (n_tracks, state_dim, state_dim).
228
+ z : array_like
229
+ Measurements, shape (n_tracks, meas_dim).
230
+ h : callable
231
+ Nonlinear measurement function h(x) -> z_predicted.
232
+ H_jacobian : callable or None
233
+ Jacobian of measurement function dh/dx. If None, computed numerically.
234
+ R : array_like
235
+ Measurement noise covariance.
236
+
237
+ Returns
238
+ -------
239
+ result : BatchEKFUpdate
240
+ Update results including states, covariances, and statistics.
241
+ """
242
+ cp = import_optional("cupy", extra="gpu", feature="GPU Extended Kalman filter")
243
+
244
+ # Convert to numpy for measurement evaluation
245
+ x_np = np.asarray(to_cpu(x))
246
+ z_np = np.asarray(z)
247
+ P_gpu = ensure_gpu_array(P, dtype=cp.float64)
248
+ z_gpu = ensure_gpu_array(z, dtype=cp.float64)
249
+ R_gpu = ensure_gpu_array(R, dtype=cp.float64)
250
+
251
+ n_tracks = x_np.shape[0]
252
+ state_dim = x_np.shape[1]
253
+ meas_dim = z_np.shape[1]
254
+
255
+ # Evaluate measurement function and Jacobian for each track
256
+ z_pred_np = np.zeros((n_tracks, meas_dim))
257
+ H_matrices = np.zeros((n_tracks, meas_dim, state_dim))
258
+
259
+ for i in range(n_tracks):
260
+ x_i = x_np[i]
261
+ z_pred_np[i] = h(x_i)
262
+
263
+ if H_jacobian is not None:
264
+ H_matrices[i] = H_jacobian(x_i)
265
+ else:
266
+ H_matrices[i] = _compute_numerical_jacobian(h, x_i)
267
+
268
+ # Move to GPU
269
+ x_gpu = ensure_gpu_array(x_np, dtype=cp.float64)
270
+ z_pred_gpu = ensure_gpu_array(z_pred_np, dtype=cp.float64)
271
+ H_gpu = ensure_gpu_array(H_matrices, dtype=cp.float64)
272
+
273
+ # Handle R dimensions
274
+ if R_gpu.ndim == 2:
275
+ R_batch = cp.broadcast_to(R_gpu, (n_tracks, meas_dim, meas_dim))
276
+ else:
277
+ R_batch = R_gpu
278
+
279
+ # Innovation
280
+ y = z_gpu - z_pred_gpu
281
+
282
+ # Innovation covariance: S = H @ P @ H' + R
283
+ HP = cp.einsum("nij,njk->nik", H_gpu, P_gpu)
284
+ S = cp.einsum("nij,nkj->nik", HP, H_gpu) + R_batch
285
+
286
+ # Kalman gain: K = P @ H' @ S^{-1}
287
+ PHT = cp.einsum("nij,nkj->nik", P_gpu, H_gpu)
288
+ S_inv = cp.linalg.inv(S)
289
+ K = cp.einsum("nij,njk->nik", PHT, S_inv)
290
+
291
+ # Updated state
292
+ x_upd = x_gpu + cp.einsum("nij,nj->ni", K, y)
293
+
294
+ # Updated covariance (Joseph form)
295
+ eye = cp.eye(state_dim, dtype=cp.float64)
296
+ I_KH = eye - cp.einsum("nij,njk->nik", K, H_gpu)
297
+ P_upd = cp.einsum("nij,njk->nik", I_KH, P_gpu)
298
+ P_upd = cp.einsum("nij,nkj->nik", P_upd, I_KH)
299
+ KRK = cp.einsum("nij,njk,nlk->nil", K, R_batch, K)
300
+ P_upd = P_upd + KRK
301
+
302
+ # Ensure symmetry
303
+ P_upd = (P_upd + cp.swapaxes(P_upd, -2, -1)) / 2
304
+
305
+ # Likelihoods
306
+ mahal_sq = cp.einsum("ni,nij,nj->n", y, S_inv, y)
307
+ sign, logdet = cp.linalg.slogdet(S)
308
+ log_likelihood = -0.5 * (mahal_sq + logdet + meas_dim * np.log(2 * np.pi))
309
+ likelihood = cp.exp(log_likelihood)
310
+
311
+ return BatchEKFUpdate(
312
+ x=x_upd,
313
+ P=P_upd,
314
+ y=y,
315
+ S=S,
316
+ K=K,
317
+ likelihood=likelihood,
318
+ )
319
+
320
+
321
+ class CuPyExtendedKalmanFilter:
322
+ """
323
+ GPU-accelerated Extended Kalman Filter for batch processing.
324
+
325
+ Parameters
326
+ ----------
327
+ state_dim : int
328
+ Dimension of state vector.
329
+ meas_dim : int
330
+ Dimension of measurement vector.
331
+ f : callable
332
+ Nonlinear dynamics function f(x) -> x_next.
333
+ h : callable
334
+ Nonlinear measurement function h(x) -> z.
335
+ F_jacobian : callable, optional
336
+ Jacobian of dynamics. If None, computed numerically.
337
+ H_jacobian : callable, optional
338
+ Jacobian of measurement. If None, computed numerically.
339
+ Q : array_like, optional
340
+ Process noise covariance.
341
+ R : array_like, optional
342
+ Measurement noise covariance.
343
+
344
+ Examples
345
+ --------
346
+ >>> import numpy as np
347
+ >>> from pytcl.gpu.ekf import CuPyExtendedKalmanFilter
348
+ >>>
349
+ >>> # Nonlinear dynamics
350
+ >>> def f(x):
351
+ ... return np.array([x[0] + x[1], x[1] * 0.99])
352
+ >>>
353
+ >>> def h(x):
354
+ ... return np.array([np.sqrt(x[0]**2 + x[1]**2)])
355
+ >>>
356
+ >>> ekf = CuPyExtendedKalmanFilter(
357
+ ... state_dim=2, meas_dim=1,
358
+ ... f=f, h=h,
359
+ ... Q=np.eye(2) * 0.01,
360
+ ... R=np.array([[0.1]]),
361
+ ... )
362
+ """
363
+
364
+ @requires("cupy", extra="gpu", feature="GPU Extended Kalman filter")
365
+ def __init__(
366
+ self,
367
+ state_dim: int,
368
+ meas_dim: int,
369
+ f: Callable[[NDArray[np.floating[Any]]], NDArray[np.floating[Any]]],
370
+ h: Callable[[NDArray[np.floating[Any]]], NDArray[np.floating[Any]]],
371
+ F_jacobian: Optional[
372
+ Callable[[NDArray[np.floating[Any]]], NDArray[np.floating[Any]]]
373
+ ] = None,
374
+ H_jacobian: Optional[
375
+ Callable[[NDArray[np.floating[Any]]], NDArray[np.floating[Any]]]
376
+ ] = None,
377
+ Q: Optional[ArrayLike] = None,
378
+ R: Optional[ArrayLike] = None,
379
+ ):
380
+ cp = import_optional("cupy", extra="gpu", feature="GPU Extended Kalman filter")
381
+
382
+ self.state_dim = state_dim
383
+ self.meas_dim = meas_dim
384
+ self.f = f
385
+ self.h = h
386
+ self.F_jacobian = F_jacobian
387
+ self.H_jacobian = H_jacobian
388
+
389
+ if Q is None:
390
+ self.Q = cp.eye(state_dim, dtype=cp.float64) * 0.01
391
+ else:
392
+ self.Q = ensure_gpu_array(Q, dtype=cp.float64)
393
+
394
+ if R is None:
395
+ self.R = cp.eye(meas_dim, dtype=cp.float64)
396
+ else:
397
+ self.R = ensure_gpu_array(R, dtype=cp.float64)
398
+
399
+ def predict(
400
+ self,
401
+ x: ArrayLike,
402
+ P: ArrayLike,
403
+ ) -> BatchEKFPrediction:
404
+ """Perform batch EKF prediction."""
405
+ return batch_ekf_predict(x, P, self.f, self.F_jacobian, self.Q)
406
+
407
+ def update(
408
+ self,
409
+ x: ArrayLike,
410
+ P: ArrayLike,
411
+ z: ArrayLike,
412
+ ) -> BatchEKFUpdate:
413
+ """Perform batch EKF update."""
414
+ return batch_ekf_update(x, P, z, self.h, self.H_jacobian, self.R)
415
+
416
+ def predict_update(
417
+ self,
418
+ x: ArrayLike,
419
+ P: ArrayLike,
420
+ z: ArrayLike,
421
+ ) -> BatchEKFUpdate:
422
+ """Combined prediction and update."""
423
+ pred = self.predict(x, P)
424
+ return self.update(pred.x, pred.P, z)
425
+
426
+
427
+ __all__ = [
428
+ "BatchEKFPrediction",
429
+ "BatchEKFUpdate",
430
+ "batch_ekf_predict",
431
+ "batch_ekf_update",
432
+ "CuPyExtendedKalmanFilter",
433
+ ]