Kalman Filter Comparison
This example demonstrates different Kalman filter variants for target tracking.
Overview
This example compares three Kalman filter implementations:
Linear Kalman Filter (KF) - For linear state-space models with Gaussian noise
Extended Kalman Filter (EKF) - Linearizes nonlinear models around current estimate
Unscented Kalman Filter (UKF) - Uses sigma points for better nonlinear approximation
Key Concepts
State estimation: Estimating position and velocity from noisy measurements
Filter consistency: NEES/NIS statistics for filter tuning validation
Measurement models: Linear vs nonlinear (range-bearing) measurements
Process noise: Modeling uncertainty in the motion model
Code Highlights
The example demonstrates:
Creating state transition matrices with
f_constant_velocity()Process noise covariance with
q_constant_velocity()Sigma point generation with
sigma_points_merwe()Filter predict/update cycles with
kf_predict(),kf_update()UKF operations with
ukf_predict(),ukf_update()
Source Code
1"""
2Kalman Filter Comparison Example.
3
4This example demonstrates:
51. Linear Kalman Filter for constant velocity tracking
62. Extended Kalman Filter (EKF) for nonlinear measurements
73. Unscented Kalman Filter (UKF) for highly nonlinear systems
84. Filter consistency checking with NEES/NIS
95. Comparison of filter performance
10
11Run with: python examples/kalman_filter_comparison.py
12"""
13
14import sys
15from pathlib import Path
16
17sys.path.insert(0, str(Path(__file__).parent.parent))
18
19# Output directory for generated plots
20OUTPUT_DIR = Path(__file__).parent.parent / "docs" / "_static" / "images" / "examples"
21OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
22
23import os
24from typing import List, Tuple # noqa: E402
25
26import numpy as np # noqa: E402
27import plotly.graph_objects as go # noqa: E402
28from plotly.subplots import make_subplots # noqa: E402
29
30from pytcl.dynamic_estimation import (
31 kf_predict,
32 kf_update,
33 sigma_points_merwe,
34 ukf_predict,
35 ukf_update,
36)
37from pytcl.dynamic_models import ( # noqa: E402
38 f_constant_velocity,
39 q_constant_velocity,
40)
41
42SHOW_PLOTS = os.environ.get("PYTCL_SHOW_PLOTS", "1") != "0"
43
44
45def generate_trajectory(
46 n_steps: int = 100,
47 dt: float = 1.0,
48) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
49 """
50 Generate a 2D constant velocity trajectory with nonlinear measurements.
51
52 Returns
53 -------
54 true_states : ndarray, shape (n_steps, 4)
55 True states [x, vx, y, vy] at each time step.
56 linear_measurements : ndarray, shape (n_steps, 2)
57 Linear measurements [x, y] with noise.
58 nonlinear_measurements : ndarray, shape (n_steps, 2)
59 Nonlinear measurements [range, bearing] with noise.
60 """
61 # Initial state: position (100, 50), velocity (2, 1) m/s
62 x0 = np.array([100.0, 2.0, 50.0, 1.0])
63
64 # State transition
65 F = f_constant_velocity(dt, 2)
66
67 # Generate true trajectory
68 true_states = np.zeros((n_steps, 4))
69 true_states[0] = x0
70
71 for k in range(1, n_steps):
72 true_states[k] = F @ true_states[k - 1]
73
74 # Measurement noise
75 R_linear = np.diag([5.0**2, 5.0**2]) # Position noise std = 5m
76 R_nonlinear = np.diag([10.0**2, np.radians(2.0) ** 2]) # Range 10m, bearing 2 deg
77
78 # Generate measurements
79 linear_measurements = np.zeros((n_steps, 2))
80 nonlinear_measurements = np.zeros((n_steps, 2))
81
82 for k in range(n_steps):
83 # True position
84 x, y = true_states[k, 0], true_states[k, 2]
85
86 # Linear measurement: direct position
87 linear_measurements[k] = np.array([x, y]) + np.random.multivariate_normal(
88 [0, 0], R_linear
89 )
90
91 # Nonlinear measurement: range and bearing from origin
92 r = np.sqrt(x**2 + y**2)
93 theta = np.arctan2(y, x)
94 nonlinear_measurements[k] = np.array(
95 [r, theta]
96 ) + np.random.multivariate_normal([0, 0], R_nonlinear)
97
98 return true_states, linear_measurements, nonlinear_measurements
99
100
101def run_linear_kf(
102 measurements: np.ndarray,
103 dt: float = 1.0,
104 process_noise: float = 0.1,
105) -> Tuple[np.ndarray, np.ndarray, List[float], List[float]]:
106 """
107 Run linear Kalman filter on position measurements.
108
109 Returns state estimates, covariances, NEES values, and NIS values.
110 """
111 n_steps = len(measurements)
112
113 # System matrices
114 F = f_constant_velocity(dt, 2)
115 Q = q_constant_velocity(dt, process_noise, 2)
116 H = np.array([[1, 0, 0, 0], [0, 0, 1, 0]]) # Measure x, y
117 R = np.diag([5.0**2, 5.0**2])
118
119 # Initial state and covariance
120 x = np.array([measurements[0, 0], 0.0, measurements[0, 1], 0.0])
121 P = np.diag([25.0, 10.0, 25.0, 10.0])
122
123 # Storage
124 estimates = np.zeros((n_steps, 4))
125 covariances = np.zeros((n_steps, 4, 4))
126 nis_values = []
127
128 estimates[0] = x
129 covariances[0] = P
130
131 for k in range(1, n_steps):
132 # Predict
133 x, P = kf_predict(x, P, F, Q)
134
135 # Update
136 z = measurements[k]
137 result = kf_update(x, P, z, H, R)
138 x, P = result.x, result.P
139
140 estimates[k] = x
141 covariances[k] = P
142
143 # NIS: innovation squared normalized by innovation covariance
144 nis_val = float(result.y.T @ np.linalg.solve(result.S, result.y))
145 nis_values.append(nis_val)
146
147 return estimates, covariances, nis_values
148
149
150def nonlinear_measurement(x: np.ndarray) -> np.ndarray:
151 """Nonlinear measurement function: h(x) = [range, bearing]."""
152 px, py = x[0], x[2]
153 r = np.sqrt(px**2 + py**2)
154 theta = np.arctan2(py, px)
155 return np.array([r, theta])
156
157
158def measurement_jacobian(x: np.ndarray) -> np.ndarray:
159 """Jacobian of nonlinear measurement function."""
160 px, py = x[0], x[2]
161 r = np.sqrt(px**2 + py**2)
162 r2 = r**2
163
164 # H = dh/dx
165 H = np.zeros((2, 4))
166 H[0, 0] = px / r # dr/dx
167 H[0, 2] = py / r # dr/dy
168 H[1, 0] = -py / r2 # dtheta/dx
169 H[1, 2] = px / r2 # dtheta/dy
170
171 return H
172
173
174def run_ekf(
175 measurements: np.ndarray,
176 dt: float = 1.0,
177 process_noise: float = 0.1,
178) -> Tuple[np.ndarray, np.ndarray, List[float]]:
179 """
180 Run Extended Kalman Filter on range-bearing measurements.
181
182 Returns state estimates, covariances, and NIS values.
183 """
184 n_steps = len(measurements)
185
186 # System matrices (linear dynamics, nonlinear measurements)
187 F = f_constant_velocity(dt, 2)
188 Q = q_constant_velocity(dt, process_noise, 2)
189 R = np.diag([10.0**2, np.radians(2.0) ** 2])
190
191 # Initialize from first measurement
192 r0, theta0 = measurements[0]
193 x0 = r0 * np.cos(theta0)
194 y0 = r0 * np.sin(theta0)
195 x = np.array([x0, 0.0, y0, 0.0])
196 P = np.diag([100.0, 10.0, 100.0, 10.0])
197
198 # Storage
199 estimates = np.zeros((n_steps, 4))
200 covariances = np.zeros((n_steps, 4, 4))
201 nis_values = []
202
203 estimates[0] = x
204 covariances[0] = P
205
206 for k in range(1, n_steps):
207 # Predict (linear dynamics - use standard KF predict)
208 x, P = kf_predict(x, P, F, Q)
209
210 # Update with nonlinear measurement (manual EKF update)
211 z = measurements[k]
212 H = measurement_jacobian(x)
213 z_pred = nonlinear_measurement(x)
214
215 # Angle wrapping for bearing innovation
216 y = z - z_pred
217 y[1] = np.arctan2(np.sin(y[1]), np.cos(y[1])) # Wrap to [-pi, pi]
218
219 # Innovation covariance
220 S = H @ P @ H.T + R
221
222 # Kalman gain
223 K = P @ H.T @ np.linalg.inv(S)
224
225 # Update
226 x = x + K @ y
227 P = (np.eye(4) - K @ H) @ P
228
229 estimates[k] = x
230 covariances[k] = P
231
232 # NIS
233 nis_val = float(y.T @ np.linalg.solve(S, y))
234 nis_values.append(nis_val)
235
236 return estimates, covariances, nis_values
237
238
239def run_ukf(
240 measurements: np.ndarray,
241 dt: float = 1.0,
242 process_noise: float = 0.1,
243) -> Tuple[np.ndarray, np.ndarray, List[float]]:
244 """
245 Run Unscented Kalman Filter on range-bearing measurements.
246
247 Returns state estimates, covariances, and NIS values.
248 """
249 n_steps = len(measurements)
250
251 # System matrices
252 F = f_constant_velocity(dt, 2)
253 Q = q_constant_velocity(dt, process_noise, 2)
254 R = np.diag([10.0**2, np.radians(2.0) ** 2])
255
256 # UKF parameters
257 alpha = 1e-3
258 beta = 2.0
259 kappa = 0.0
260
261 # Initialize from first measurement
262 r0, theta0 = measurements[0]
263 x0 = r0 * np.cos(theta0)
264 y0 = r0 * np.sin(theta0)
265 x = np.array([x0, 0.0, y0, 0.0])
266 P = np.diag([100.0, 10.0, 100.0, 10.0])
267
268 # Storage
269 estimates = np.zeros((n_steps, 4))
270 covariances = np.zeros((n_steps, 4, 4))
271 nis_values = []
272
273 estimates[0] = x
274 covariances[0] = P
275
276 # State transition function (linear, but UKF uses it as a function)
277 def f(x):
278 return F @ x
279
280 for k in range(1, n_steps):
281 # Generate sigma points
282 sigma_pts, Wm, Wc = sigma_points_merwe(x, P, alpha, beta, kappa)
283
284 # Predict step using UKF
285 x, P = ukf_predict(x, P, f, Q, alpha, beta, kappa)
286
287 # Update with nonlinear measurement
288 z = measurements[k]
289 result = ukf_update(
290 x,
291 P,
292 z,
293 nonlinear_measurement,
294 R,
295 alpha,
296 beta,
297 kappa,
298 )
299 x, P = result.x, result.P
300
301 estimates[k] = x
302 covariances[k] = P
303
304 # NIS
305 nis_val = float(result.y.T @ np.linalg.solve(result.S, result.y))
306 nis_values.append(nis_val)
307
308 return estimates, covariances, nis_values
309
310
311def compute_metrics(
312 true_states: np.ndarray,
313 estimates: np.ndarray,
314 covariances: np.ndarray,
315) -> dict:
316 """Compute performance metrics."""
317 # Position RMSE
318 pos_errors = estimates[:, [0, 2]] - true_states[:, [0, 2]]
319 pos_rmse = np.sqrt(np.mean(np.sum(pos_errors**2, axis=1)))
320
321 # Velocity RMSE
322 vel_errors = estimates[:, [1, 3]] - true_states[:, [1, 3]]
323 vel_rmse = np.sqrt(np.mean(np.sum(vel_errors**2, axis=1)))
324
325 # NEES (Normalized Estimation Error Squared)
326 nees_values = []
327 for k in range(len(true_states)):
328 err = estimates[k] - true_states[k]
329 P = covariances[k]
330 nees_val = float(err.T @ np.linalg.solve(P, err))
331 nees_values.append(nees_val)
332
333 avg_nees = np.mean(nees_values)
334
335 return {
336 "pos_rmse": pos_rmse,
337 "vel_rmse": vel_rmse,
338 "avg_nees": avg_nees,
339 "nees_values": nees_values,
340 }
341
342
343def plot_results(
344 true_states: np.ndarray,
345 linear_meas: np.ndarray,
346 kf_est: np.ndarray,
347 ekf_est: np.ndarray,
348 ukf_est: np.ndarray,
349 kf_metrics: dict,
350 ekf_metrics: dict,
351 ukf_metrics: dict,
352) -> None:
353 """Create comparison plots."""
354 fig = make_subplots(
355 rows=2,
356 cols=2,
357 subplot_titles=(
358 "Trajectory Comparison",
359 "Position Error Over Time",
360 "NEES Comparison",
361 "Filter Performance Summary",
362 ),
363 )
364
365 # Trajectory plot
366 fig.add_trace(
367 go.Scatter(
368 x=true_states[:, 0],
369 y=true_states[:, 2],
370 mode="lines",
371 name="True",
372 line=dict(color="black", width=2),
373 ),
374 row=1,
375 col=1,
376 )
377 fig.add_trace(
378 go.Scatter(
379 x=linear_meas[:, 0],
380 y=linear_meas[:, 1],
381 mode="markers",
382 name="Measurements",
383 marker=dict(color="gray", size=3, opacity=0.5),
384 ),
385 row=1,
386 col=1,
387 )
388 fig.add_trace(
389 go.Scatter(
390 x=kf_est[:, 0],
391 y=kf_est[:, 2],
392 mode="lines",
393 name="KF",
394 line=dict(color="blue", width=1.5),
395 ),
396 row=1,
397 col=1,
398 )
399 fig.add_trace(
400 go.Scatter(
401 x=ekf_est[:, 0],
402 y=ekf_est[:, 2],
403 mode="lines",
404 name="EKF",
405 line=dict(color="red", width=1.5),
406 ),
407 row=1,
408 col=1,
409 )
410 fig.add_trace(
411 go.Scatter(
412 x=ukf_est[:, 0],
413 y=ukf_est[:, 2],
414 mode="lines",
415 name="UKF",
416 line=dict(color="green", width=1.5),
417 ),
418 row=1,
419 col=1,
420 )
421
422 # Position error over time
423 time = np.arange(len(true_states))
424 kf_pos_err = np.sqrt(
425 (kf_est[:, 0] - true_states[:, 0]) ** 2
426 + (kf_est[:, 2] - true_states[:, 2]) ** 2
427 )
428 ekf_pos_err = np.sqrt(
429 (ekf_est[:, 0] - true_states[:, 0]) ** 2
430 + (ekf_est[:, 2] - true_states[:, 2]) ** 2
431 )
432 ukf_pos_err = np.sqrt(
433 (ukf_est[:, 0] - true_states[:, 0]) ** 2
434 + (ukf_est[:, 2] - true_states[:, 2]) ** 2
435 )
436
437 fig.add_trace(
438 go.Scatter(x=time, y=kf_pos_err, name="KF Error", line=dict(color="blue")),
439 row=1,
440 col=2,
441 )
442 fig.add_trace(
443 go.Scatter(x=time, y=ekf_pos_err, name="EKF Error", line=dict(color="red")),
444 row=1,
445 col=2,
446 )
447 fig.add_trace(
448 go.Scatter(x=time, y=ukf_pos_err, name="UKF Error", line=dict(color="green")),
449 row=1,
450 col=2,
451 )
452
453 # NEES comparison
454 fig.add_trace(
455 go.Scatter(
456 x=time,
457 y=kf_metrics["nees_values"],
458 name="KF NEES",
459 line=dict(color="blue"),
460 ),
461 row=2,
462 col=1,
463 )
464 fig.add_trace(
465 go.Scatter(
466 x=time,
467 y=ekf_metrics["nees_values"],
468 name="EKF NEES",
469 line=dict(color="red"),
470 ),
471 row=2,
472 col=1,
473 )
474 fig.add_trace(
475 go.Scatter(
476 x=time,
477 y=ukf_metrics["nees_values"],
478 name="UKF NEES",
479 line=dict(color="green"),
480 ),
481 row=2,
482 col=1,
483 )
484 # NEES expected value line (state dimension = 4)
485 fig.add_trace(
486 go.Scatter(
487 x=[0, len(time)],
488 y=[4, 4],
489 name="Expected NEES",
490 line=dict(color="black", dash="dash"),
491 ),
492 row=2,
493 col=1,
494 )
495
496 # Summary bar chart
497 filters = ["KF (linear)", "EKF", "UKF"]
498 pos_rmses = [
499 kf_metrics["pos_rmse"],
500 ekf_metrics["pos_rmse"],
501 ukf_metrics["pos_rmse"],
502 ]
503
504 fig.add_trace(
505 go.Bar(
506 x=filters,
507 y=pos_rmses,
508 name="Position RMSE (m)",
509 marker_color=["blue", "red", "green"],
510 ),
511 row=2,
512 col=2,
513 )
514
515 fig.update_layout(
516 title="Kalman Filter Comparison: KF vs EKF vs UKF",
517 height=800,
518 width=1200,
519 showlegend=True,
520 )
521
522 fig.update_xaxes(title_text="X Position (m)", row=1, col=1)
523 fig.update_yaxes(title_text="Y Position (m)", row=1, col=1)
524 fig.update_xaxes(title_text="Time Step", row=1, col=2)
525 fig.update_yaxes(title_text="Position Error (m)", row=1, col=2)
526 fig.update_xaxes(title_text="Time Step", row=2, col=1)
527 fig.update_yaxes(title_text="NEES", row=2, col=1)
528 fig.update_xaxes(title_text="Filter Type", row=2, col=2)
529 fig.update_yaxes(title_text="RMSE (m)", row=2, col=2)
530
531 output_path = OUTPUT_DIR / "kalman_filter_comparison.html"
532 fig.write_html(
533 str(output_path), include_plotlyjs="cdn", div_id=Path(output_path).stem
534 )
535 print(f"\nInteractive plot saved to {output_path}")
536 if SHOW_PLOTS:
537 fig.show()
538 else:
539 OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
540 fig.write_html(
541 str(OUTPUT_DIR / "kalman_filter_comparison.html"),
542 include_plotlyjs="cdn",
543 div_id="kalman_filter_comparison",
544 )
545
546
547def main() -> None:
548 """Run Kalman filter comparison."""
549 print("Kalman Filter Comparison Example")
550 print("=" * 60)
551
552 np.random.seed(42)
553
554 # Generate trajectory and measurements
555 print("\nGenerating trajectory and measurements...")
556 n_steps = 100
557 dt = 1.0
558 true_states, linear_meas, nonlinear_meas = generate_trajectory(n_steps, dt)
559
560 print(f" {n_steps} time steps, dt = {dt}s")
561 print(f" Initial position: ({true_states[0, 0]:.1f}, {true_states[0, 2]:.1f}) m")
562 print(f" Final position: ({true_states[-1, 0]:.1f}, {true_states[-1, 2]:.1f}) m")
563
564 # Run filters
565 print("\nRunning filters...")
566
567 print(" Linear Kalman Filter (position measurements)...")
568 kf_est, kf_cov, kf_nis = run_linear_kf(linear_meas, dt)
569
570 print(" Extended Kalman Filter (range-bearing measurements)...")
571 ekf_est, ekf_cov, ekf_nis = run_ekf(nonlinear_meas, dt)
572
573 print(" Unscented Kalman Filter (range-bearing measurements)...")
574 ukf_est, ukf_cov, ukf_nis = run_ukf(nonlinear_meas, dt)
575
576 # Compute metrics
577 print("\nComputing performance metrics...")
578 kf_metrics = compute_metrics(true_states, kf_est, kf_cov)
579 ekf_metrics = compute_metrics(true_states, ekf_est, ekf_cov)
580 ukf_metrics = compute_metrics(true_states, ukf_est, ukf_cov)
581
582 # Print results
583 print("\n" + "=" * 60)
584 print("RESULTS")
585 print("=" * 60)
586
587 print("\nLinear Kalman Filter (with position measurements):")
588 print(f" Position RMSE: {kf_metrics['pos_rmse']:.2f} m")
589 print(f" Velocity RMSE: {kf_metrics['vel_rmse']:.2f} m/s")
590 print(f" Average NEES: {kf_metrics['avg_nees']:.2f} (expected: 4.0)")
591
592 print("\nExtended Kalman Filter (with range-bearing measurements):")
593 print(f" Position RMSE: {ekf_metrics['pos_rmse']:.2f} m")
594 print(f" Velocity RMSE: {ekf_metrics['vel_rmse']:.2f} m/s")
595 print(f" Average NEES: {ekf_metrics['avg_nees']:.2f} (expected: 4.0)")
596
597 print("\nUnscented Kalman Filter (with range-bearing measurements):")
598 print(f" Position RMSE: {ukf_metrics['pos_rmse']:.2f} m")
599 print(f" Velocity RMSE: {ukf_metrics['vel_rmse']:.2f} m/s")
600 print(f" Average NEES: {ukf_metrics['avg_nees']:.2f} (expected: 4.0)")
601
602 print("\n" + "-" * 60)
603 print("Note: KF uses linear (x,y) measurements, while EKF/UKF use")
604 print("nonlinear (range, bearing) measurements. EKF and UKF should")
605 print("perform similarly for this mildly nonlinear problem.")
606 print("-" * 60)
607
608 # Plot results
609 plot_results(
610 true_states,
611 linear_meas,
612 kf_est,
613 ekf_est,
614 ukf_est,
615 kf_metrics,
616 ekf_metrics,
617 ukf_metrics,
618 )
619
620 print("\nDone!")
621
622
623if __name__ == "__main__":
624 main()
Running the Example
python examples/kalman_filter_comparison.py
See Also
Filter Uncertainty Visualization - Covariance ellipse visualization
Advanced Filters Comparison - EKF, Gaussian Sum, Rao-Blackwellized PF
Smoothers and Information Filters - RTS smoother and information filters