Kalman Filter Comparison

This example demonstrates different Kalman filter variants for target tracking.

Overview

This example compares three Kalman filter implementations:

  1. Linear Kalman Filter (KF) - For linear state-space models with Gaussian noise

  2. Extended Kalman Filter (EKF) - Linearizes nonlinear models around current estimate

  3. 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