{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Kalman Filters: From Fundamentals to Advanced Filtering\n", "\n", "This notebook provides a comprehensive, hands-on introduction to Kalman filtering using PyTCL (Python Tracker Component Library). \n", "\n", "## What You'll Learn\n", "\n", "1. **Linear Kalman Filter (KF)** - Optimal recursive estimation for linear systems\n", "2. **Extended Kalman Filter (EKF)** - First-order nonlinear approximation using Jacobians\n", "3. **Unscented Kalman Filter (UKF)** - Deterministic sigma-point methods for accurate nonlinear handling\n", "4. **Square-Root Filters** - Numerically stable variants that maintain positive definiteness\n", "5. **Interactive Parameter Tuning** - Explore how Q and R affect filter performance\n", "\n", "## Key Concepts You'll Master\n", "\n", "- **Predict-Update cycle**: How filters recursively estimate state\n", "- **Covariance matrices**: What P, Q, R represent (uncertainty)\n", "- **Belief propagation**: Combining prior knowledge with new measurements\n", "- **Filter divergence**: When and why filters fail\n", "- **Real-world tradeoffs**: Accuracy vs computational cost\n", "\n", "## Prerequisites\n", "\n", "```bash\n", "pip install nrl-tracker matplotlib numpy scipy\n", "```\n", "\n", "## Estimated Time: 30-40 minutes\n", "\n", "Try running each section sequentially, modify parameters to experiment, and attempt the exercises at the end!" ] }, { "cell_type": "code", "execution_count": 14, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "✓ PyTCL Kalman filter modules and Plotly imported successfully\n" ] } ], "source": [ "import numpy as np\n", "import plotly.graph_objects as go\n", "from plotly.subplots import make_subplots\n", "import warnings\n", "warnings.filterwarnings('ignore')\n", "\n", "# Import PyTCL Kalman filter components\n", "from pytcl.dynamic_estimation.kalman import (\n", " kf_predict, kf_update,\n", " ekf_predict, ekf_update,\n", " ukf_predict, ukf_update\n", ")\n", "\n", "# Import square-root filter functions\n", "from pytcl.dynamic_estimation.kalman.square_root import (\n", " srkf_predict, srkf_update\n", ")\n", "\n", "np.random.seed(42)\n", "\n", "# Plotly dark theme template\n", "dark_template = go.layout.Template()\n", "dark_template.layout = go.Layout(\n", " paper_bgcolor='#0d1117',\n", " plot_bgcolor='#0d1117',\n", " font=dict(color='#e6edf3'),\n", " xaxis=dict(gridcolor='#30363d', zerolinecolor='#30363d'),\n", " yaxis=dict(gridcolor='#30363d', zerolinecolor='#30363d'),\n", ")\n", "\n", "print(\"✓ PyTCL Kalman filter modules and Plotly imported successfully\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## The Kalman Filter: Core Concepts\n", "\n", "### Why Do We Need the Kalman Filter?\n", "\n", "In the real world, we have:\n", "- **Imperfect measurements** - GPS has noise, sensors drift\n", "- **Incomplete observations** - We can't measure everything\n", "- **Dynamic systems** - Things change over time\n", "\n", "The Kalman filter **optimally combines** prior predictions with new measurements to estimate the true state.\n", "\n", "### The Two-Step Recursive Algorithm\n", "\n", "The Kalman filter alternates between:\n", "\n", "**1. PREDICT** - Where do we expect the system to be based on physics?\n", "- Uses a process model: $\\hat{x}^-_k = F \\hat{x}_{k-1}$\n", "- Tracks uncertainty growing over time: $P^-_k = F P_{k-1} F^T + Q$\n", "\n", "**2. UPDATE** - Now incorporate the new measurement, what do we believe?\n", "- Compute Kalman gain: $K_k = P^-_k H^T (H P^-_k H^T + R)^{-1}$\n", "- Blend prediction with measurement: $\\hat{x}_k = \\hat{x}^-_k + K_k (z_k - H\\hat{x}^-_k)$\n", "- Reduce uncertainty: $P_k = (I - K_k H) P^-_k$\n", "\n", "### Key Parameters to Tune\n", "\n", "| Parameter | Meaning | Effect |\n", "|-----------|---------|--------|\n", "| **Q** | Process noise covariance | How much does the model lie? |\n", "| **R** | Measurement noise covariance | How much do measurements lie? |\n", "| **F** | State transition matrix | Physics of the system |\n", "| **H** | Observation matrix | What can we measure? |\n", "\n", "**Rule of thumb:** The Kalman filter balances trust in the model (Q) vs trust in measurements (R)." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 1. Linear Kalman Filter\n", "\n", "The Kalman filter is the optimal estimator for linear Gaussian systems. It consists of two steps:\n", "\n", "**Prediction:**\n", "$$\\hat{x}_{k|k-1} = F \\hat{x}_{k-1|k-1}$$\n", "$$P_{k|k-1} = F P_{k-1|k-1} F^T + Q$$\n", "\n", "**Update:**\n", "$$K = P_{k|k-1} H^T (H P_{k|k-1} H^T + R)^{-1}$$\n", "$$\\hat{x}_{k|k} = \\hat{x}_{k|k-1} + K(z - H\\hat{x}_{k|k-1})$$\n", "$$P_{k|k} = (I - KH) P_{k|k-1}$$\n", "\n", "### Example: Tracking a Moving Object" ] }, { "cell_type": "code", "execution_count": 15, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "State dimension: 4 (position + velocity in 2D)\n", "Measurement dimension: 2 (position only)\n", "Process noise (Q) magnitude: 0.020\n", "Measurement noise (R) magnitude: 2.000\n" ] } ], "source": [ "# System parameters\n", "dt = 0.1 # Time step [seconds]\n", "n_steps = 100 # Number of time steps\n", "\n", "# STATE DEFINITION: [x, vx, y, vy]\n", "# x, y: position in meters\n", "# vx, vy: velocity components in m/s\n", "# This is a 4-dimensional state space (constant velocity model)\n", "\n", "# STATE TRANSITION MATRIX: x_{k+1} = F @ x_k\n", "# Models constant velocity: new_pos = old_pos + velocity * dt\n", "F = np.array([\n", " [1, dt, 0, 0], # x_{k+1} = x_k + vx_k * dt\n", " [0, 1, 0, 0], # vx_{k+1} = vx_k (no acceleration)\n", " [0, 0, 1, dt], # y_{k+1} = y_k + vy_k * dt\n", " [0, 0, 0, 1] # vy_{k+1} = vy_k\n", "])\n", "\n", "# PROCESS NOISE COVARIANCE: Q\n", "# Accounts for model uncertainty (e.g., unmodeled accelerations)\n", "# Sets diagonal to larger values for more process uncertainty\n", "q = 0.1 # Process noise intensity (tune this!)\n", "Q = q * np.array([\n", " [dt**3/3, dt**2/2, 0, 0], # Higher power for position uncertainty\n", " [dt**2/2, dt, 0, 0],\n", " [0, 0, dt**3/3, dt**2/2],\n", " [0, 0, dt**2/2, dt]\n", "])\n", "\n", "# MEASUREMENT MATRIX: z_k = H @ x_k\n", "# We observe position (x, y) but NOT velocity\n", "H = np.array([\n", " [1, 0, 0, 0], # Measure x position\n", " [0, 0, 1, 0] # Measure y position\n", "])\n", "\n", "# MEASUREMENT NOISE COVARIANCE: R\n", "# Sensor uncertainty (GPS noise, radar error, etc.)\n", "# Diagonal elements are variance of measurement error\n", "R = np.eye(2) * 1.0 # 1 meter measurement standard deviation\n", "\n", "# Print diagnostics\n", "print(f\"State dimension: {F.shape[0]} (position + velocity in 2D)\")\n", "print(f\"Measurement dimension: {H.shape[0]} (position only)\")\n", "print(f\"Process noise (Q) magnitude: {np.trace(Q):.3f}\")\n", "print(f\"Measurement noise (R) magnitude: {np.trace(R):.3f}\")" ] }, { "cell_type": "code", "execution_count": 16, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Generated 100 measurements\n" ] } ], "source": [ "# Generate ground truth trajectory\n", "true_state = np.array([0.0, 1.0, 0.0, 0.5]) # Start at origin, moving diagonally\n", "true_states = [true_state.copy()]\n", "measurements = []\n", "\n", "for _ in range(n_steps):\n", " # Propagate true state\n", " true_state = F @ true_state + np.random.multivariate_normal(np.zeros(4), Q)\n", " true_states.append(true_state.copy())\n", " \n", " # Generate measurement\n", " z = H @ true_state + np.random.multivariate_normal(np.zeros(2), R)\n", " measurements.append(z)\n", "\n", "true_states = np.array(true_states)\n", "measurements = np.array(measurements)\n", "\n", "print(f\"Generated {len(measurements)} measurements\")" ] }, { "cell_type": "code", "execution_count": 17, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Final position estimate: (3.22, 14.53)\n", "Final position uncertainty (1σ): (0.363, 0.363)\n" ] } ], "source": [ "# Run Kalman filter\n", "x_est = np.array([0.0, 0.0, 0.0, 0.0]) # Initial estimate\n", "P_est = np.eye(4) * 10.0 # Initial covariance (uncertain)\n", "\n", "estimates = [x_est.copy()]\n", "covariances = [P_est.copy()]\n", "\n", "for z in measurements:\n", " # Predict\n", " pred = kf_predict(x_est, P_est, F, Q)\n", " x_pred, P_pred = pred.x, pred.P\n", " \n", " # Update\n", " upd = kf_update(x_pred, P_pred, z, H, R)\n", " x_est, P_est = upd.x, upd.P\n", " \n", " estimates.append(x_est.copy())\n", " covariances.append(P_est.copy())\n", "\n", "estimates = np.array(estimates)\n", "covariances = np.array(covariances)\n", "\n", "print(f\"Final position estimate: ({estimates[-1, 0]:.2f}, {estimates[-1, 2]:.2f})\")\n", "print(f\"Final position uncertainty (1σ): ({np.sqrt(covariances[-1, 0, 0]):.3f}, {np.sqrt(covariances[-1, 2, 2]):.3f})\")" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Visualization with Plotly\n", "fig = make_subplots(\n", " rows=1, cols=2,\n", " subplot_titles=('Kalman Filter: 2D Trajectory Tracking', 'Estimation Error vs Model Confidence'),\n", " horizontal_spacing=0.12\n", ")\n", "\n", "# Calculate error and uncertainty\n", "pos_error = np.sqrt((estimates[1:, 0] - true_states[1:, 0])**2 + \n", " (estimates[1:, 2] - true_states[1:, 2])**2)\n", "pos_uncertainty = np.sqrt(covariances[1:, 0, 0] + covariances[1:, 2, 2])\n", "time = np.arange(len(pos_error)) * dt\n", "\n", "# Left plot: 2D trajectory\n", "fig.add_trace(\n", " go.Scatter(x=true_states[:, 0], y=true_states[:, 2], mode='lines',\n", " name='True trajectory', line=dict(color='#00ff88', width=2.5)),\n", " row=1, col=1\n", ")\n", "fig.add_trace(\n", " go.Scatter(x=measurements[:, 0], y=measurements[:, 1], mode='markers',\n", " name='Measurements', marker=dict(color='#ff4757', size=5, opacity=0.4)),\n", " row=1, col=1\n", ")\n", "fig.add_trace(\n", " go.Scatter(x=estimates[:, 0], y=estimates[:, 2], mode='lines',\n", " name='KF estimate', line=dict(color='#00d4ff', width=2, dash='dash')),\n", " row=1, col=1\n", ")\n", "\n", "# Right plot: Error vs Uncertainty\n", "fig.add_trace(\n", " go.Scatter(x=time, y=2*pos_uncertainty, mode='lines', fill='tozeroy',\n", " name='2σ uncertainty', line=dict(color='#00d4ff', width=0),\n", " fillcolor='rgba(0, 212, 255, 0.3)'),\n", " row=1, col=2\n", ")\n", "fig.add_trace(\n", " go.Scatter(x=time, y=pos_error, mode='lines',\n", " name='Position error', line=dict(color='#00d4ff', width=2.5)),\n", " row=1, col=2\n", ")\n", "\n", "fig.update_layout(\n", " template=dark_template,\n", " height=450,\n", " showlegend=True,\n", " legend=dict(x=0.5, y=-0.2, xanchor='center', orientation='h')\n", ")\n", "fig.update_xaxes(title_text='X position (m)', row=1, col=1)\n", "fig.update_yaxes(title_text='Y position (m)', row=1, col=1)\n", "fig.update_xaxes(title_text='Time (s)', row=1, col=2)\n", "fig.update_yaxes(title_text='Error magnitude (m)', row=1, col=2)\n", "\n", "fig.show()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 2. Extended Kalman Filter (EKF)\n", "\n", "When the system dynamics or measurements are **nonlinear**, we must adapt the linear Kalman filter. The **Extended Kalman Filter** linearizes the nonlinear functions around the current estimate.\n", "\n", "### Nonlinear System Model\n", "\n", "$$x_{k+1} = f(x_k, w_k)$$\n", "$$z_k = h(x_k, v_k)$$\n", "\n", "where $f(\\cdot)$ and $h(\\cdot)$ are arbitrary nonlinear functions.\n", "\n", "### EKF Algorithm\n", "\n", "**Predict Step** (linearize around predicted state):\n", "$$F_k = \\left.\\frac{\\partial f}{\\partial x}\\right|_{\\hat{x}_{k-1}} \\quad \\text{(Jacobian of } f \\text{)}$$\n", "$$\\hat{x}^-_k = f(\\hat{x}_{k-1})$$\n", "$$P^-_k = F_k P_{k-1} F_k^T + Q$$\n", "\n", "**Update Step** (linearize around predicted state):\n", "$$H_k = \\left.\\frac{\\partial h}{\\partial x}\\right|_{\\hat{x}^-_k} \\quad \\text{(Jacobian of } h \\text{)}$$\n", "$$K_k = P^-_k H_k^T (H_k P^-_k H_k^T + R)^{-1}$$\n", "$$\\hat{x}_k = \\hat{x}^-_k + K_k (z_k - h(\\hat{x}^-_k))$$\n", "$$P_k = (I - K_k H_k) P^-_k$$\n", "\n", "### Limitations\n", "\n", "- Only uses **first-order** (linear) approximation\n", "- Performance degrades with high nonlinearity\n", "- Jacobian computation can be error-prone\n", "- Can diverge if linearization is poor\n", "\n", "### Example: Tracking with Range-Bearing Measurements\n", "\n", "Radar provides nonlinear measurements: $(r, \\theta) = (\\text{range}, \\text{bearing})$ instead of Cartesian position (x, y)." ] }, { "cell_type": "code", "execution_count": 18, "metadata": {}, "outputs": [], "source": [ "# Nonlinear measurement model: range and bearing from origin\n", "def h_radar(x):\n", " \"\"\"Radar measurement: range and bearing.\"\"\"\n", " px, vx, py, vy = x\n", " r = np.sqrt(px**2 + py**2)\n", " theta = np.arctan2(py, px)\n", " return np.array([r, theta])\n", "\n", "def H_radar(x):\n", " \"\"\"Jacobian of radar measurement.\"\"\"\n", " px, vx, py, vy = x\n", " r = np.sqrt(px**2 + py**2)\n", " if r < 1e-10:\n", " r = 1e-10\n", " return np.array([\n", " [px/r, 0, py/r, 0],\n", " [-py/r**2, 0, px/r**2, 0]\n", " ])\n", "\n", "# Measurement noise for range-bearing\n", "R_radar = np.diag([0.5**2, np.radians(2)**2]) # 0.5m range, 2 deg bearing\n", "\n", "# Generate radar measurements\n", "radar_measurements = []\n", "for state in true_states[1:]:\n", " z_true = h_radar(state)\n", " z = z_true + np.random.multivariate_normal(np.zeros(2), R_radar)\n", " radar_measurements.append(z)\n", "\n", "radar_measurements = np.array(radar_measurements)" ] }, { "cell_type": "code", "execution_count": 19, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "EKF final position: (3.56, 15.06)\n", "True final position: (3.47, 15.08)\n" ] } ], "source": [ "# Run EKF with radar measurements\n", "x_ekf = np.array([1.0, 0.0, 1.0, 0.0]) # Start with offset estimate\n", "P_ekf = np.eye(4) * 10.0\n", "\n", "# Linear dynamics (constant velocity)\n", "def f_cv(x):\n", " return F @ x\n", "\n", "ekf_estimates = [x_ekf.copy()]\n", "\n", "for z in radar_measurements:\n", " # Predict (linear dynamics)\n", " pred = ekf_predict(x_ekf, P_ekf, f_cv, F, Q)\n", " x_pred, P_pred = pred.x, pred.P\n", " \n", " # Update (nonlinear measurement)\n", " upd = ekf_update(x_pred, P_pred, z, h_radar, H_radar(x_pred), R_radar)\n", " x_ekf, P_ekf = upd.x, upd.P\n", " \n", " ekf_estimates.append(x_ekf.copy())\n", "\n", "ekf_estimates = np.array(ekf_estimates)\n", "\n", "print(f\"EKF final position: ({ekf_estimates[-1, 0]:.2f}, {ekf_estimates[-1, 2]:.2f})\")\n", "print(f\"True final position: ({true_states[-1, 0]:.2f}, {true_states[-1, 2]:.2f})\")" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Compare KF (with position measurements) vs EKF (with radar measurements)\n", "fig = go.Figure()\n", "\n", "# Plot trajectories\n", "fig.add_trace(\n", " go.Scatter(x=true_states[:, 0], y=true_states[:, 2], mode='lines',\n", " name='True trajectory', line=dict(color='#00ff88', width=2.5))\n", ")\n", "fig.add_trace(\n", " go.Scatter(x=estimates[:, 0], y=estimates[:, 2], mode='lines',\n", " name='KF (position meas.)', line=dict(color='#00d4ff', width=2, dash='dash'))\n", ")\n", "fig.add_trace(\n", " go.Scatter(x=ekf_estimates[:, 0], y=ekf_estimates[:, 2], mode='lines',\n", " name='EKF (radar meas.)', line=dict(color='#ff4757', width=2, dash='dot'))\n", ")\n", "\n", "# Plot radar position at origin\n", "fig.add_trace(\n", " go.Scatter(x=[0], y=[0], mode='markers', name='Radar',\n", " marker=dict(color='#ffb800', size=15, symbol='triangle-up',\n", " line=dict(color='white', width=2)))\n", ")\n", "\n", "# Plot radar measurement rays (every 10th measurement)\n", "for i in range(0, len(radar_measurements), 10):\n", " r, theta = radar_measurements[i]\n", " ray_end_x = r * np.cos(theta)\n", " ray_end_y = r * np.sin(theta)\n", " fig.add_trace(\n", " go.Scatter(x=[0, ray_end_x], y=[0, ray_end_y], mode='lines',\n", " line=dict(color='rgba(255, 183, 0, 0.15)', width=0.8),\n", " showlegend=False, hoverinfo='skip')\n", " )\n", "\n", "fig.update_layout(\n", " template=dark_template,\n", " title='Linear KF vs Extended KF: Handling Nonlinear Measurements',\n", " xaxis_title='X position (m)',\n", " yaxis_title='Y position (m)',\n", " height=550,\n", " showlegend=True,\n", " yaxis=dict(scaleanchor='x', scaleratio=1)\n", ")\n", "\n", "fig.show()\n", "\n", "# Print comparison\n", "ekf_pos_error = np.sqrt((ekf_estimates[1:, 0] - true_states[1:, 0])**2 + \n", " (ekf_estimates[1:, 2] - true_states[1:, 2])**2)\n", "kf_pos_error = np.sqrt((estimates[1:, 0] - true_states[1:, 0])**2 + \n", " (estimates[1:, 2] - true_states[1:, 2])**2)\n", "print(f\"KF RMSE (position meas.): {np.sqrt(np.mean(kf_pos_error**2)):.4f} m\")\n", "print(f\"EKF RMSE (radar meas.): {np.sqrt(np.mean(ekf_pos_error**2)):.4f} m\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 3. Unscented Kalman Filter (UKF)\n", "\n", "The **Unscented Kalman Filter** uses a clever trick to handle nonlinearity without explicit Jacobian computation. It uses \"sigma points\" (carefully chosen sample points) to capture the mean and covariance of a nonlinear transformation.\n", "\n", "### Key Insight: The Unscented Transform\n", "\n", "Instead of linearizing (approximating the function), we **sample the function**:\n", "\n", "1. Generate 2n+1 sigma points around the current estimate\n", "2. Propagate each sigma point through the nonlinear function\n", "3. Compute mean and covariance from the transformed sigma points\n", "\n", "### Why This Works Better\n", "\n", "- **Second-order accuracy**: Captures up to 2nd-order Taylor terms (vs 1st for EKF)\n", "- **No Jacobians needed**: Purely sampling-based, numerical derivative-free\n", "- **More stable**: Works well with highly nonlinear systems\n", "- **Same computational cost** as EKF (both O(n³) due to matrix operations)\n", "\n", "### UKF Algorithm Structure\n", "\n", "```\n", "For each time step:\n", " 1. Generate sigma points from (x̂, P)\n", " 2. Predict: Propagate each sigma point through f(·)\n", " - Compute mean and covariance of predictions\n", " 3. Generate measurement sigma points\n", " 4. Update: Weight by measurement likelihood\n", " - Blend predictions with measurements using Kalman gain\n", "```\n", "\n", "### When to Use UKF vs EKF\n", "\n", "| Scenario | Choice | Reason |\n", "|----------|--------|--------|\n", "| Radar/bearing measurements | UKF | Often nonlinear, avoids Jacobian errors |\n", "| Maneuvering targets | UKF | Handles sharp turns better |\n", "| Well-behaved linear systems | KF | Simpler, lower cost |\n", "| Real-time systems | EKF or UKF | Both feasible; UKF more robust |" ] }, { "cell_type": "code", "execution_count": 20, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "UKF final position: (3.56, 15.05)\n" ] } ], "source": [ "# Run UKF with radar measurements\n", "x_ukf = np.array([1.0, 0.0, 1.0, 0.0])\n", "P_ukf = np.eye(4) * 10.0\n", "\n", "ukf_estimates = [x_ukf.copy()]\n", "\n", "for z in radar_measurements:\n", " # Predict\n", " pred = ukf_predict(x_ukf, P_ukf, f_cv, Q)\n", " x_pred, P_pred = pred.x, pred.P\n", " \n", " # Update\n", " upd = ukf_update(x_pred, P_pred, z, h_radar, R_radar)\n", " x_ukf, P_ukf = upd.x, upd.P\n", " \n", " ukf_estimates.append(x_ukf.copy())\n", "\n", "ukf_estimates = np.array(ukf_estimates)\n", "\n", "print(f\"UKF final position: ({ukf_estimates[-1, 0]:.2f}, {ukf_estimates[-1, 2]:.2f})\")" ] }, { "cell_type": "code", "execution_count": 21, "metadata": {}, "outputs": [ { "data": { "application/vnd.plotly.v1+json": { "config": { "plotlyServerURL": "https://plot.ly" }, "data": [ { "hovertemplate": "Time: %{x:.2f}s
Error: %{y:.4f}m", "line": { "color": "#ff6b6b", "width": 2.5 }, "mode": "lines", "name": "EKF (RMSE: 0.3773 m)", "type": "scatter", "x": { "bdata": "AAAAAAAAAACamZmZmZm5P5qZmZmZmck/NDMzMzMz0z+amZmZmZnZPwAAAAAAAOA/NDMzMzMz4z9nZmZmZmbmP5qZmZmZmek/zczMzMzM7D8AAAAAAADwP5qZmZmZmfE/NDMzMzMz8z/NzMzMzMz0P2dmZmZmZvY/AAAAAAAA+D+amZmZmZn5PzQzMzMzM/s/zczMzMzM/D9nZmZmZmb+PwAAAAAAAABAzczMzMzMAECamZmZmZkBQGdmZmZmZgJANDMzMzMzA0AAAAAAAAAEQM3MzMzMzARAmpmZmZmZBUBnZmZmZmYGQDQzMzMzMwdAAAAAAAAACEDNzMzMzMwIQJqZmZmZmQlAZ2ZmZmZmCkA0MzMzMzMLQAAAAAAAAAxAzczMzMzMDECamZmZmZkNQGdmZmZmZg5ANDMzMzMzD0AAAAAAAAAQQGdmZmZmZhBAzczMzMzMEEAzMzMzMzMRQJqZmZmZmRFAAAAAAAAAEkBnZmZmZmYSQM3MzMzMzBJANDMzMzMzE0CamZmZmZkTQAAAAAAAABRAZ2ZmZmZmFEDNzMzMzMwUQDQzMzMzMxVAmpmZmZmZFUAAAAAAAAAWQGdmZmZmZhZAzczMzMzMFkA0MzMzMzMXQJqZmZmZmRdAAAAAAAAAGEBnZmZmZmYYQM3MzMzMzBhANDMzMzMzGUCamZmZmZkZQAAAAAAAABpAZ2ZmZmZmGkDNzMzMzMwaQDQzMzMzMxtAmpmZmZmZG0AAAAAAAAAcQGdmZmZmZhxAzczMzMzMHEA0MzMzMzMdQJqZmZmZmR1AAAAAAAAAHkBnZmZmZmYeQM3MzMzMzB5ANDMzMzMzH0CamZmZmZkfQAAAAAAAACBAMzMzMzMzIEBnZmZmZmYgQJqZmZmZmSBAzczMzMzMIEAAAAAAAAAhQDMzMzMzMyFAZ2ZmZmZmIUCamZmZmZkhQM3MzMzMzCFAAAAAAAAAIkAzMzMzMzMiQGdmZmZmZiJAmpmZmZmZIkDNzMzMzMwiQAAAAAAAACNANDMzMzMzI0BnZmZmZmYjQJqZmZmZmSNAzczMzMzMI0A=", "dtype": "f8" }, "y": { "bdata": "NX/GLx455D8hOq5KtmnmP0jIJOtaIOI/PEQnbMev3z/LNYfEFzzKP9J2y7IRctU/XsG1/6fg1z9BP+aSnsncP6fn/6SL8uI/2L7X6w3t5D+c8EoBoarnP9ksWwNku+k/bP7jKMWX6T/+GKJs0cPqP5q0tc+Cfeo/oaPdocp07D/a4bl7eyvqP67vDB9jPOo/Zlh35Qpu5T+GK+PmhdvlP0EpGjCafuI/SGk79+/A3z+ejCPjrafbP5YiWRXNseI/wX4CdXOS4D9frcb1wGHRP/5/7wr848k/8kQCH8i2uz9PijUzXF3DPzYq9ThBRso/vIkSWwbExz8IHsl9guPKP6A+0y4y6cQ/DWjZwZ6mvj9uzle99ATBP3tVPwfjfbc/He/I7ryWuD/VhEx+XbDGP+rdYVqRPc0/Mq3qlAk0wT9ZcF5BP72gPx7i2AT5Oq8/q/EqOw/VxD/+k/liNgbCP3IzG1eMsKY/DeUJPlhmsz8WGGUnUWTDP4ji8Q/XDrE/mYsdIbn7vD8XPYJ0i4nEP3NDsRZ91rk/b8cCskWsyT8Ays5s51jQP6SfJFK0yNM/hVtmfSaI3j9NxJbsLa/aPwUrafYJENw/MB2LhLoC2z96m3UaYvDXP1HxVafsENg/1r4pd9+J0z/hnv81kPvYP0HIgrQBrNQ/qnAdJ7dN0T8WHI1WZ+/RP+mUMitiI9A/m524g4PL0T+rsdpXD5bIP8QtQClYIL4/0tb9W6BVzT9BQMc8GiC/PwA28aWJgbg/gorpqsoCxj+9NBpFfo3LP7cD0hNhFc4/pNr8E2wn0D/KXm28yNK9P8leRj43kMU/yCMDWjMSxj9g7nG5DObPP7un0dfcINE/g5SJ8vBwzj9TLx47y3vPP/jkV2VIA9Q/fnlh3WQ52T+ylckzIAjVP9/XRc3xPNg/i975YnJ1zj9DoGl+C7PDP9q1oaVQxsc/FEp9vPsRrT+AK2ZdyIG0PzfUDlw6X8E/pNCJ6L/5wj/VNaZqOeSoP5essfRborI/B1WXqxh/yT8nw6WLXR69P7wF9kBPBLI/lSGxZLyutz8=", "dtype": "f8" } }, { "hovertemplate": "Time: %{x:.2f}s
Error: %{y:.4f}m", "line": { "color": "#4ecdc4", "width": 2.5 }, "mode": "lines", "name": "UKF (RMSE: 1.0151 m)", "type": "scatter", "x": { "bdata": "AAAAAAAAAACamZmZmZm5P5qZmZmZmck/NDMzMzMz0z+amZmZmZnZPwAAAAAAAOA/NDMzMzMz4z9nZmZmZmbmP5qZmZmZmek/zczMzMzM7D8AAAAAAADwP5qZmZmZmfE/NDMzMzMz8z/NzMzMzMz0P2dmZmZmZvY/AAAAAAAA+D+amZmZmZn5PzQzMzMzM/s/zczMzMzM/D9nZmZmZmb+PwAAAAAAAABAzczMzMzMAECamZmZmZkBQGdmZmZmZgJANDMzMzMzA0AAAAAAAAAEQM3MzMzMzARAmpmZmZmZBUBnZmZmZmYGQDQzMzMzMwdAAAAAAAAACEDNzMzMzMwIQJqZmZmZmQlAZ2ZmZmZmCkA0MzMzMzMLQAAAAAAAAAxAzczMzMzMDECamZmZmZkNQGdmZmZmZg5ANDMzMzMzD0AAAAAAAAAQQGdmZmZmZhBAzczMzMzMEEAzMzMzMzMRQJqZmZmZmRFAAAAAAAAAEkBnZmZmZmYSQM3MzMzMzBJANDMzMzMzE0CamZmZmZkTQAAAAAAAABRAZ2ZmZmZmFEDNzMzMzMwUQDQzMzMzMxVAmpmZmZmZFUAAAAAAAAAWQGdmZmZmZhZAzczMzMzMFkA0MzMzMzMXQJqZmZmZmRdAAAAAAAAAGEBnZmZmZmYYQM3MzMzMzBhANDMzMzMzGUCamZmZmZkZQAAAAAAAABpAZ2ZmZmZmGkDNzMzMzMwaQDQzMzMzMxtAmpmZmZmZG0AAAAAAAAAcQGdmZmZmZhxAzczMzMzMHEA0MzMzMzMdQJqZmZmZmR1AAAAAAAAAHkBnZmZmZmYeQM3MzMzMzB5ANDMzMzMzH0CamZmZmZkfQAAAAAAAACBAMzMzMzMzIEBnZmZmZmYgQJqZmZmZmSBAzczMzMzMIEAAAAAAAAAhQDMzMzMzMyFAZ2ZmZmZmIUCamZmZmZkhQM3MzMzMzCFAAAAAAAAAIkAzMzMzMzMiQGdmZmZmZiJAmpmZmZmZIkDNzMzMzMwiQAAAAAAAACNANDMzMzMzI0BnZmZmZmYjQJqZmZmZmSNAzczMzMzMI0A=", "dtype": "f8" }, "y": { "bdata": "cvpF/xYX3z/OL8yQfsPlP0LvdtaVNdg/r0SzmzPIuz9OEQPI1tvcPxwh0mX7QfY/ccj8sLd5BkCjVJuIABANQGEJNFmLpg5AhTUqSGhcDkAoRZtPDJkLQLXMRniY8glAJ/ccTFIwB0DEpe+gx0cCQCiHgdq6Wv0/gNKU0m089D/pLTUulpjvPzXCFJwYIeQ/6yQaZI7C3j+/caPMxZPDP93KOC553Ko/ykscquRQwT8D5mWi76zQP4cdMMnHx+I/7ZVy1Xil5D+YPnfNFp7fP1r1WeIoH+A/GX1ITuJG0D93UXbPtK/QPzWdVYExxM4/pIPi/HyS0j8vk6psvX7SP9AyEVT1adU/uIpzPlaW1T+MEtAfkFLiP7FS9Lgxodk/aojxTFvt2T/sg19GqCzgP3qaXRd+8+E/vWVUXFO52z/DFRLw3+jSP5jo/Ev3Z9M/Asbf+nQJ2D8Ev8UeYfXUP2qMhqggMso/6hNA7FzxyT+erutsiNnQPzyhfG/SlMQ/3hBsHOKmwj8QTGkirmjCP9jOBZXMpLs/Ek4gHOdHyz9UiVrUFJLRP8OxP/hDrdQ/2kAPO0Xl3j9dGimYQKnaP/g+eREetds/i0t1kLVe2j/1oEDqgBrXPzkxVIbHF9c/rXxXHi6Q0j+Vt341M+LXP0KYxFFSktM/DKFet0Yz0D+AB+unAfrQP4Qm7Zlu784/LxtaN82Z0T8KX0TH5DLJPyWixHj4UcA/019sCF78zj/kbyjtsKzAP0VS6MwWDro/f5ll9t7yxj+q73Brf2LMP6nei06Mmc4/vPbo8WhE0D+r4moZSKO9PwnQYe95QcU/bBlrkVD4xT+wiVTRNsrPPwJIRhiqFtE/ZpLntLp6zj+ZIuQLSH3PP4HHH1ZQAdQ/RE0S0ZY62T/yEO6EwwXVP1jaSl4ZOdg/yUrGb9Rbzj+85OELNrPDP0Exw7AK18c/hiCWtPvQrT88MsnUvMW0P9yAO9qjZ8E/dgCk39Xgwj+Q3RYlrj6oPyOkPAjSNLI/LrYDJOBeyT8rhXq/0EK9P5qZJTClJ7I/AkdpKAyytz8=", "dtype": "f8" } }, { "fill": "tozeroy", "fillcolor": "rgba(76, 175, 80, 0.2)", "hoverinfo": "skip", "line": { "color": "rgba(78, 205, 196, 0)" }, "name": "UKF advantage", "type": "scatter", "x": { "bdata": "AAAAAAAAAACamZmZmZm5P5qZmZmZmck/NDMzMzMz0z+amZmZmZnZPwAAAAAAAOA/NDMzMzMz4z9nZmZmZmbmP5qZmZmZmek/zczMzMzM7D8AAAAAAADwP5qZmZmZmfE/NDMzMzMz8z/NzMzMzMz0P2dmZmZmZvY/AAAAAAAA+D+amZmZmZn5PzQzMzMzM/s/zczMzMzM/D9nZmZmZmb+PwAAAAAAAABAzczMzMzMAECamZmZmZkBQGdmZmZmZgJANDMzMzMzA0AAAAAAAAAEQM3MzMzMzARAmpmZmZmZBUBnZmZmZmYGQDQzMzMzMwdAAAAAAAAACEDNzMzMzMwIQJqZmZmZmQlAZ2ZmZmZmCkA0MzMzMzMLQAAAAAAAAAxAzczMzMzMDECamZmZmZkNQGdmZmZmZg5ANDMzMzMzD0AAAAAAAAAQQGdmZmZmZhBAzczMzMzMEEAzMzMzMzMRQJqZmZmZmRFAAAAAAAAAEkBnZmZmZmYSQM3MzMzMzBJANDMzMzMzE0CamZmZmZkTQAAAAAAAABRAZ2ZmZmZmFEDNzMzMzMwUQDQzMzMzMxVAmpmZmZmZFUAAAAAAAAAWQGdmZmZmZhZAzczMzMzMFkA0MzMzMzMXQJqZmZmZmRdAAAAAAAAAGEBnZmZmZmYYQM3MzMzMzBhANDMzMzMzGUCamZmZmZkZQAAAAAAAABpAZ2ZmZmZmGkDNzMzMzMwaQDQzMzMzMxtAmpmZmZmZG0AAAAAAAAAcQGdmZmZmZhxAzczMzMzMHEA0MzMzMzMdQJqZmZmZmR1AAAAAAAAAHkBnZmZmZmYeQM3MzMzMzB5ANDMzMzMzH0CamZmZmZkfQAAAAAAAACBAMzMzMzMzIEBnZmZmZmYgQJqZmZmZmSBAzczMzMzMIEAAAAAAAAAhQDMzMzMzMyFAZ2ZmZmZmIUCamZmZmZkhQM3MzMzMzCFAAAAAAAAAIkAzMzMzMzMiQGdmZmZmZiJAmpmZmZmZIkDNzMzMzMwiQAAAAAAAACNANDMzMzMzI0BnZmZmZmYjQJqZmZmZmSNAzczMzMzMI0A=", "dtype": "f8" }, "y": { "bdata": "cvpF/xYX3z/OL8yQfsPlP0LvdtaVNdg/r0SzmzPIuz/LNYfEFzzKP9J2y7IRctU/XsG1/6fg1z9BP+aSnsncP6fn/6SL8uI/2L7X6w3t5D+c8EoBoarnP9ksWwNku+k/bP7jKMWX6T/+GKJs0cPqP5q0tc+Cfeo/oaPdocp07D/a4bl7eyvqPzXCFJwYIeQ/6yQaZI7C3j+/caPMxZPDP93KOC553Ko/ykscquRQwT8D5mWi76zQP5YiWRXNseI/wX4CdXOS4D9frcb1wGHRP/5/7wr848k/8kQCH8i2uz9PijUzXF3DPzYq9ThBRso/vIkSWwbExz8IHsl9guPKP6A+0y4y6cQ/DWjZwZ6mvj9uzle99ATBP3tVPwfjfbc/He/I7ryWuD/VhEx+XbDGP+rdYVqRPc0/Mq3qlAk0wT9ZcF5BP72gPx7i2AT5Oq8/q/EqOw/VxD/+k/liNgbCP3IzG1eMsKY/DeUJPlhmsz8WGGUnUWTDP4ji8Q/XDrE/mYsdIbn7vD8QTGkirmjCP3NDsRZ91rk/b8cCskWsyT8Ays5s51jQP6SfJFK0yNM/hVtmfSaI3j9dGimYQKnaP/g+eREetds/i0t1kLVe2j/1oEDqgBrXPzkxVIbHF9c/rXxXHi6Q0j+Vt341M+LXP0KYxFFSktM/DKFet0Yz0D+AB+unAfrQP4Qm7Zlu784/LxtaN82Z0T+rsdpXD5bIP8QtQClYIL4/0tb9W6BVzT9BQMc8GiC/PwA28aWJgbg/gorpqsoCxj+9NBpFfo3LP7cD0hNhFc4/pNr8E2wn0D+r4moZSKO9PwnQYe95QcU/bBlrkVD4xT+wiVTRNsrPPwJIRhiqFtE/g5SJ8vBwzj9TLx47y3vPP4HHH1ZQAdQ/fnlh3WQ52T/yEO6EwwXVP1jaSl4ZOdg/yUrGb9Rbzj9DoGl+C7PDP9q1oaVQxsc/FEp9vPsRrT+AK2ZdyIG0PzfUDlw6X8E/dgCk39Xgwj+Q3RYlrj6oPyOkPAjSNLI/LrYDJOBeyT8nw6WLXR69P7wF9kBPBLI/lSGxZLyutz8=", "dtype": "f8" } } ], "layout": { "height": 500, "hovermode": "x unified", "legend": { "bgcolor": "rgba(0,0,0,0.5)", "x": 0.5, "xanchor": "center", "y": 1, "yanchor": "top" }, "template": { "layout": { "font": { "color": "#e6edf3" }, "paper_bgcolor": "#0d1117", "plot_bgcolor": "#0d1117", "xaxis": { "gridcolor": "#30363d", "zerolinecolor": "#30363d" }, "yaxis": { "gridcolor": "#30363d", "zerolinecolor": "#30363d" } } }, "title": { "text": "Extended Kalman Filter vs Unscented Kalman Filter" }, "xaxis": { "title": { "text": "Time (s)" } }, "yaxis": { "title": { "text": "Position Error (m)" } } } } }, "metadata": {}, "output_type": "display_data" }, { "name": "stdout", "output_type": "stream", "text": [ "\n", "=== FILTER COMPARISON ===\n", "EKF mean error: 0.3055 m, RMSE: 0.3773 m\n", "UKF mean error: 0.5632 m, RMSE: 1.0151 m\n", "UKF improvement: -169.0% better RMSE\n" ] } ], "source": [ "# Compare EKF vs UKF\n", "ekf_error = np.sqrt((ekf_estimates[1:, 0] - true_states[1:, 0])**2 + \n", " (ekf_estimates[1:, 2] - true_states[1:, 2])**2)\n", "ukf_error = np.sqrt((ukf_estimates[1:, 0] - true_states[1:, 0])**2 + \n", " (ukf_estimates[1:, 2] - true_states[1:, 2])**2)\n", "\n", "ekf_rmse = np.sqrt(np.mean(ekf_error**2))\n", "ukf_rmse = np.sqrt(np.mean(ukf_error**2))\n", "\n", "# Plotly visualization\n", "fig = go.Figure()\n", "\n", "time_vec = np.arange(len(ekf_error)) * dt\n", "\n", "# Add EKF error\n", "fig.add_trace(go.Scatter(\n", " x=time_vec, y=ekf_error,\n", " mode='lines',\n", " name=f'EKF (RMSE: {ekf_rmse:.4f} m)',\n", " line=dict(color='#ff6b6b', width=2.5),\n", " hovertemplate='Time: %{x:.2f}s
Error: %{y:.4f}m'\n", "))\n", "\n", "# Add UKF error\n", "fig.add_trace(go.Scatter(\n", " x=time_vec, y=ukf_error,\n", " mode='lines',\n", " name=f'UKF (RMSE: {ukf_rmse:.4f} m)',\n", " line=dict(color='#4ecdc4', width=2.5),\n", " hovertemplate='Time: %{x:.2f}s
Error: %{y:.4f}m'\n", "))\n", "\n", "# Add UKF advantage region (where UKF performs better)\n", "ukf_better = np.where(ukf_error <= ekf_error, ukf_error, ekf_error)\n", "fig.add_trace(go.Scatter(\n", " x=time_vec, y=ukf_better,\n", " fill='tozeroy',\n", " name='UKF advantage',\n", " line=dict(color='rgba(78, 205, 196, 0)'),\n", " fillcolor='rgba(76, 175, 80, 0.2)',\n", " hoverinfo='skip'\n", "))\n", "\n", "fig.update_layout(\n", " template=dark_template,\n", " title='Extended Kalman Filter vs Unscented Kalman Filter',\n", " xaxis_title='Time (s)',\n", " yaxis_title='Position Error (m)',\n", " height=500,\n", " hovermode='x unified',\n", " legend=dict(x=0.5, y=1.0, xanchor='center', yanchor='top', bgcolor='rgba(0,0,0,0.5)')\n", ")\n", "\n", "fig.show()\n", "\n", "print(f\"\\n=== FILTER COMPARISON ===\")\n", "print(f\"EKF mean error: {np.mean(ekf_error):.4f} m, RMSE: {ekf_rmse:.4f} m\")\n", "print(f\"UKF mean error: {np.mean(ukf_error):.4f} m, RMSE: {ukf_rmse:.4f} m\")\n", "print(f\"UKF improvement: {(1 - ukf_rmse/ekf_rmse)*100:.1f}% better RMSE\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 4. Square-Root Kalman Filter (SR-KF)\n", "\n", "The **Square-Root KF** works with the **Cholesky factor** $S$ where $P = S S^T$ instead of the covariance matrix $P$ directly.\n", "\n", "### Why Square-Root Form?\n", "\n", "**Standard KF Problem**: \n", "- Covariance matrix $P$ can become non-positive-definite due to numerical errors\n", "- Computing $P^{-1}$ amplifies round-off errors\n", "- Can diverge or produce negative variances\n", "\n", "**SR-KF Solution**:\n", "- Work with $S$ (Cholesky decomposition) instead\n", "- Guaranteed positive semi-definiteness: $P = SS^T > 0$ (always)\n", "- Better numerical conditioning\n", "- Smaller numerical errors propagate better\n", "\n", "### Mathematics\n", "\n", "Instead of updating $P_k$, update $S_k$ using specialized algorithms:\n", "- **QR-decomposition-based updates** (Givens rotations)\n", "- **Cholesky updates** (rank-1 and rank-2 updates)\n", "- Result: Same covariance but computed more reliably\n", "\n", "### When to Use SR Filters\n", "\n", "- **Ill-conditioned problems**: Stiff systems, large state dimensions\n", "- **Long-running simulations**: Numerical drift accumulates over time\n", "- **Baseline measurements**: Guarantees positive-definite estimate\n", "- **Production systems**: More robust than standard KF" ] }, { "cell_type": "code", "execution_count": 11, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "\n", "=== SQUARE-ROOT KF COMPARISON ===\n", "Standard KF position RMSE: 5.3819\n", "Square-Root KF position RMSE: 5.3819\n", "Difference: 0.000000 (should be very small - demonstrates equivalence)\n" ] } ], "source": [ "# Run Square-Root KF\n", "# Note: SR-KF works with Cholesky factors of covariances, not covariances directly\n", "x_sr = np.array([0.0, 0.0, 0.0, 0.0])\n", "P_sr_init = np.eye(4) * 10.0\n", "S_sr = np.linalg.cholesky(P_sr_init) # Cholesky factor of initial covariance\n", "\n", "# Compute Cholesky factors of Q and R\n", "S_Q = np.linalg.cholesky(Q) # Cholesky factor of process noise\n", "S_R = np.linalg.cholesky(R) # Cholesky factor of measurement noise\n", "\n", "sr_estimates = [x_sr.copy()]\n", "\n", "for z in measurements:\n", " # Predict: Pass Cholesky factors instead of covariances\n", " pred = srkf_predict(x_sr, S_sr, F, S_Q)\n", " x_pred, S_pred = pred.x, pred.S\n", " \n", " # Update: Pass Cholesky factors instead of covariances\n", " upd = srkf_update(x_pred, S_pred, z, H, S_R)\n", " x_sr, S_sr = upd.x, upd.S\n", " \n", " sr_estimates.append(x_sr.copy())\n", "\n", "sr_estimates = np.array(sr_estimates)\n", "\n", "# Compare with standard KF\n", "kf_rmse = np.sqrt(np.mean((estimates[1:, :2] - true_states[1:, [0, 2]])**2))\n", "sr_rmse = np.sqrt(np.mean((sr_estimates[1:, :2] - true_states[1:, [0, 2]])**2))\n", "\n", "print(f\"\\n=== SQUARE-ROOT KF COMPARISON ===\")\n", "print(f\"Standard KF position RMSE: {kf_rmse:.4f}\")\n", "print(f\"Square-Root KF position RMSE: {sr_rmse:.4f}\")\n", "print(f\"Difference: {abs(kf_rmse - sr_rmse):.6f} (should be very small - demonstrates equivalence)\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Parameter Tuning: Interactive Exploration\n", "\n", "The Kalman filter's performance is highly sensitive to the noise covariances **Q** and **R**. Let's explore how they affect tracking quality." ] }, { "cell_type": "code", "execution_count": 22, "metadata": {}, "outputs": [ { "data": { "application/vnd.plotly.v1+json": { "config": { "plotlyServerURL": "https://plot.ly" }, "data": [ { "colorbar": { "title": { "text": "RMSE (m)" } }, "colorscale": [ [ 0, "rgb(0,104,55)" ], [ 0.1, "rgb(26,152,80)" ], [ 0.2, "rgb(102,189,99)" ], [ 0.3, "rgb(166,217,106)" ], [ 0.4, "rgb(217,239,139)" ], [ 0.5, "rgb(255,255,191)" ], [ 0.6, "rgb(254,224,139)" ], [ 0.7, "rgb(253,174,97)" ], [ 0.8, "rgb(244,109,67)" ], [ 0.9, "rgb(215,48,39)" ], [ 1, "rgb(165,0,38)" ] ], "customdata": [ [ "Q mult: 0.01
R mult: 0.1
RMSE: 0.4017m", "Q mult: 0.01
R mult: 0.5
RMSE: 0.4762m", "Q mult: 0.01
R mult: 1.0
RMSE: 0.4972m", "Q mult: 0.01
R mult: 2.0
RMSE: 0.5081m" ], [ "Q mult: 0.1
R mult: 0.1
RMSE: 0.2543m", "Q mult: 0.1
R mult: 0.5
RMSE: 0.3658m", "Q mult: 0.1
R mult: 1.0
RMSE: 0.4017m", "Q mult: 0.1
R mult: 2.0
RMSE: 0.4357m" ], [ "Q mult: 1.0
R mult: 0.1
RMSE: 0.1337m", "Q mult: 1.0
R mult: 0.5
RMSE: 0.2311m", "Q mult: 1.0
R mult: 1.0
RMSE: 0.2543m", "Q mult: 1.0
R mult: 2.0
RMSE: 0.3087m" ], [ "Q mult: 10.0
R mult: 0.1
RMSE: 0.1391m", "Q mult: 10.0
R mult: 0.5
RMSE: 0.0557m", "Q mult: 10.0
R mult: 1.0
RMSE: 0.1337m", "Q mult: 10.0
R mult: 2.0
RMSE: 0.1924m" ] ], "hovertemplate": "%{customdata}", "text": { "bdata": "7nw/NV662T8QWDm0yHbePwIrhxbZzt8/qMZLN4lB4D+oxks3iUHQPwaBlUOLbNc/7nw/NV662T+BlUOLbOfbP/T91HjpJsE/xSCwcmiRzT+oxks3iUHQPy2yne+nxtM/mG4Sg8DKwT956SYxCKysP/T91HjpJsE/+n5qvHSTyD8=", "dtype": "f8", "shape": "4, 4" }, "textfont": { "size": 10 }, "texttemplate": "%{text:.3f}", "type": "heatmap", "x": [ "0.1", "0.5", "1.0", "2.0" ], "y": [ "0.01", "0.10", "1.00", "10.00" ], "z": { "bdata": "hIie/Nm12T9c0xbFqXrePzBa1Xzf0t8/1ud632hC4D88JEgcOUfQP8CfoG3Gadc/wMf3ADa22T/suDmrsOLbPzD0X9e8G8E/8N/bjUWVzT+oHO7BOEfQPwAbP3BNwdM/iPsKODnNwT9AM20LkYesP2jUYNe8G8E/OMCfQ4mfyD8=", "dtype": "f8", "shape": "4, 4" } } ], "layout": { "height": 450, "template": { "layout": { "font": { "color": "#e6edf3" }, "paper_bgcolor": "#0d1117", "plot_bgcolor": "#0d1117", "xaxis": { "gridcolor": "#30363d", "zerolinecolor": "#30363d" }, "yaxis": { "gridcolor": "#30363d", "zerolinecolor": "#30363d" } } }, "title": { "text": "Kalman Filter Tuning: Final Position RMSE (lower is better)" }, "width": 700, "xaxis": { "title": { "text": "Measurement Noise Multiplier (R)" } }, "yaxis": { "title": { "text": "Process Noise Multiplier (Q)" } } } } }, "metadata": {}, "output_type": "display_data" }, { "name": "stdout", "output_type": "stream", "text": [ "\n", "=== TUNING GUIDANCE ===\n", "Best Q multiplier: 10.0\n", "Best R multiplier: 0.5\n", "Minimum RMSE: 0.0557 m\n", "\n", "Tuning Rule: Balance Q (trust model) vs R (trust sensors)\n", "Too high Q → filter ignores model → sluggish\n", "Too low Q → filter trusts model too much → diverges\n" ] } ], "source": [ "# Parameter tuning: Grid search over Q and R values\n", "q_values = [0.01, 0.1, 1.0, 10.0]\n", "R_multiplier = [0.1, 0.5, 1.0, 2.0]\n", "\n", "results = np.zeros((len(q_values), len(R_multiplier)))\n", "\n", "for i, q_test in enumerate(q_values):\n", " for j, r_mult in enumerate(R_multiplier):\n", " # Adjust process and measurement noise\n", " Q_test = q_test * Q / 0.1 # Normalize relative to default\n", " R_test = R * r_mult\n", " \n", " # Run filter\n", " x_tune = np.array([0.0, 0.0, 0.0, 0.0])\n", " P_tune = np.eye(4) * 10.0\n", " \n", " for z in measurements:\n", " pred = kf_predict(x_tune, P_tune, F, Q_test)\n", " x_pred, P_pred = pred.x, pred.P\n", " upd = kf_update(x_pred, P_pred, z, H, R_test)\n", " x_tune, P_tune = upd.x, upd.P\n", " \n", " # Compute RMSE\n", " pos_error = np.sqrt((np.array([x_tune[0], x_tune[2]]) - \n", " np.array([true_states[-1, 0], true_states[-1, 2]]))**2)\n", " results[i, j] = np.mean(pos_error)\n", "\n", "# Create heatmap with Plotly\n", "hover_text = []\n", "for i in range(len(q_values)):\n", " row = []\n", " for j in range(len(R_multiplier)):\n", " row.append(f\"Q mult: {q_values[i]}
R mult: {R_multiplier[j]}
RMSE: {results[i, j]:.4f}m\")\n", " hover_text.append(row)\n", "\n", "fig = go.Figure(data=go.Heatmap(\n", " z=results,\n", " x=[f'{r:.1f}' for r in R_multiplier],\n", " y=[f'{q:.2f}' for q in q_values],\n", " colorscale='RdYlGn_r',\n", " text=np.round(results, 3),\n", " texttemplate='%{text:.3f}',\n", " textfont={\"size\": 10},\n", " hovertemplate='%{customdata}',\n", " customdata=hover_text,\n", " colorbar=dict(title='RMSE (m)')\n", "))\n", "\n", "fig.update_layout(\n", " template=dark_template,\n", " title='Kalman Filter Tuning: Final Position RMSE (lower is better)',\n", " xaxis_title='Measurement Noise Multiplier (R)',\n", " yaxis_title='Process Noise Multiplier (Q)',\n", " height=450,\n", " width=700\n", ")\n", "\n", "fig.show()\n", "\n", "print(\"\\n=== TUNING GUIDANCE ===\")\n", "best_idx = np.unravel_index(np.argmin(results), results.shape)\n", "print(f\"Best Q multiplier: {q_values[best_idx[0]]}\")\n", "print(f\"Best R multiplier: {R_multiplier[best_idx[1]]}\")\n", "print(f\"Minimum RMSE: {results[best_idx]:.4f} m\")\n", "print(f\"\\nTuning Rule: Balance Q (trust model) vs R (trust sensors)\")\n", "print(f\"Too high Q → filter ignores model → sluggish\")\n", "print(f\"Too low Q → filter trusts model too much → diverges\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 5. Filter Comparison Summary\n", "\n", "### Performance Characteristics\n", "\n", "| Filter | Linearity | Order | Jacobian | Stability | Cost | Best For |\n", "|--------|-----------|-------|----------|-----------|------|----------|\n", "| **KF** | Linear | - | N/A | Good | Low | GPS, simple dynamics |\n", "| **EKF** | Nonlinear | 1st | Yes | Moderate | Low | Radar, mild nonlinearity |\n", "| **UKF** | Nonlinear | 2nd | No | Better | Low | Nonlinear sensors, maneuvering |\n", "| **SR-KF** | Linear | - | N/A | Excellent | Low | Long-running, ill-conditioned |\n", "| **SR-UKF** | Nonlinear | 2nd | No | Excellent | Low | Non-linear + numerical robustness |\n", "| **IMM** | Multi-model | Varies | Depends | Good | High | Target maneuvering modes |\n", "\n", "### Decision Tree for Filter Selection\n", "\n", "**Q: Are your system dynamics nonlinear?**\n", "- No → Use **Kalman Filter** ✓\n", "- Yes → Go to next question\n", "\n", "**Q: Do you need high accuracy or have ill-conditioned problems?**\n", "- Yes → Use **SR-UKF** (best robustness)\n", "- No → Go to next question\n", "\n", "**Q: Can you easily compute Jacobians?**\n", "- Yes → Use **EKF** (simpler, lower cost)\n", "- No → Use **UKF** (no Jacobians needed)\n", "\n", "### Key Tuning Parameters Across All Filters\n", "\n", "| Parameter | Effect | How to Tune |\n", "|-----------|--------|------------|\n", "| **Q** (Process noise) | ↑Q = trust model less | Start with 0.01 of state variance |\n", "| **R** (Measurement noise) | ↑R = trust sensors less | Use sensor specs, iterate if needed |\n", "| **P₀** (Initial covariance) | High = start uncertain | Use 10-100× expected error |\n", "| **α** (UKF spreading) | Affects sigma points | Typical: 0.001-0.01 for nearly linear |" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Exercises & Challenges\n", "\n", "### Exercise 1: Process Noise Sensitivity (level 1)\n", "**Objective**: Understand how process noise (Q) affects tracking performance\n", "\n", "**Task**: \n", "1. Increase `q` from 0.1 to 0.5, 1.0, and 5.0\n", "2. Run the KF for each value\n", "3. Plot convergence speed vs final estimate accuracy\n", "4. **Question**: What happens to the filter's responsiveness to target maneuvers?\n", "\n", "---\n", "\n", "### Exercise 2: Measurement Outlier Rejection (level 2)\n", "**Objective**: Make the filter robust to sensor failures\n", "\n", "**Task**:\n", "1. Inject 5 large outliers in `measurements` (e.g., 10× normal noise)\n", "2. Implement **Chi-squared gating**: Reject measurements where $(z - h(\\hat{x}))^T (HPH^T + R)^{-1} (z - h(\\hat{x})) > \\chi^2_{0.95}$\n", "3. Compare filter performance with/without gating\n", "4. **Challenge**: Can you make it adaptive (learn outlier statistics)?\n", "\n", "---\n", "\n", "### Exercise 3: Maneuvering Target Tracking (level 3)\n", "**Objective**: Handle nonlinear trajectories (circles, spirals)\n", "\n", "**Task**:\n", "1. Create ground truth that performs a **circular maneuver** (constant turn rate)\n", "2. Use coordinated turn rate model: $\\dot{x} = v \\cos(\\psi), \\dot{\\psi} = \\omega_z$\n", "3. Implement tracking with UKF vs EKF\n", "4. **Metric**: RMSE during turn vs straight-line segments\n", "5. **Deep dive**: Why does UKF outperform EKF here?\n", "\n", "---\n", "\n", "### Exercise 4: Consistency Check (NEES) (level 4)\n", "**Objective**: Verify the filter is consistent (uncertainty estimates match actual errors)\n", "\n", "**Task**:\n", "1. Compute the Normalized Estimation Error Squared (NEES): \n", " $$\\epsilon_k = (x_k - \\hat{x}_k)^T P_k^{-1} (x_k - \\hat{x}_k)$$\n", "2. Ideally, $\\epsilon_k \\sim \\chi^2_4$ (4-dimensional)\n", "3. Run a Monte Carlo simulation (100 trials of the full scenario)\n", "4. Plot histogram of NEES values; check if it matches $\\chi^2$ distribution\n", "5. **Interpretation**: If filter is under-confident, NEES ≫ 4; if over-confident, NEES ≪ 4\n", "\n", "---\n", "\n", "### Exercise 5: Multi-Sensor Fusion (level 4)\n", "**Objective**: Combine multiple sensors with different noise profiles\n", "\n", "**Task**:\n", "1. Create a **GPS sensor** (slow, accurate): $R_{GPS} = 1.0$ (1m std)\n", "2. Create a **dead-reckoning system** (fast, drifts): $R_{DR} = 10.0$ (10m std) \n", "3. Implement a filter that accepts measurements from both at different rates\n", "4. **Bonus**: Use an Interacting Multiple Model (IMM) filter to handle GPS outages\n", "5. **Real-world scenario**: GPS loss in tunnel → rely on dead-reckoning during gap\n", "\n", "---\n", "\n", "## Key Takeaways\n", "\n", "✅ **Kalman filters are optimal for linear-Gaussian systems** \n", "✅ **EKF extends to nonlinear systems via linearization** \n", "✅ **UKF better handles highly nonlinear systems without Jacobians** \n", "✅ **Square-root variants improve numerical stability** \n", "✅ **Filter tuning (Q, R) is an art—validate with real data** \n", "✅ **Consistency checks (NEES) verify filter correctness** \n", "\n", "## Next Steps\n", "\n", "→ Explore **covariance intersection** for decentralized fusion \n", "→ Study **particle filters** for non-Gaussian distributions \n", "→ Implement **IMM filters** for maneuvering target tracking" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## References & Further Reading\n", "\n", "### Core Textbooks\n", "1. **Bar-Shalom, Y., Li, X. R., & Kirubarajan, T.** (2001). *Estimation with Applications to Tracking and Navigation: Theory, Algorithms and Software*. Wiley. \n", " - **Coverage**: The definitive reference, covers KF, EKF, measurement-to-track association\n", " - **Best for**: Advanced practitioners, comprehensive theory\n", "\n", "2. **Simon, D.** (2006). *Optimal State Estimation: Kalman, H∞, and Nonlinear Approaches*. John Wiley & Sons.\n", " - **Coverage**: KF, EKF, UKF, H∞ filtering, practical implementation\n", " - **Best for**: Engineers wanting both theory and practical guidance\n", "\n", "3. **Welch, G., & Bishop, G.** (2006). *An Introduction to the Kalman Filter*. UNC Chapel Hill (free PDF).\n", " - **Coverage**: Gentle introduction, intuitively explained\n", " - **Best for**: Students, beginners\n", "\n", "### Advanced Topics\n", "4. **Sarkka, S.** (2013). *Bayesian Filtering and Smoothing*. Cambridge University Press.\n", " - **Coverage**: Probabilistic interpretation, sequential filtering, GPU-friendly algorithms\n", " - **Best for**: Researchers, particle filters, GPU implementation\n", "\n", "5. **Kleeman, L.** (1995). *Understanding and Applying Kalman Filtering*. \n", " - **Coverage**: EKF applications, real-world tuning advice\n", " - **Best for**: Practitioners in robotics/navigation\n", "\n", "### Key Papers\n", "- **Julier, S. J., & Uhlmann, J. K.** (2004). \"Unscented Filtering and Nonlinear Estimation.\" *Proceedings of the IEEE*, 92(3), 401-422.\n", "- **Bierman, G. J.** (1977). \"Factorization Methods for Discrete Sequential Estimation.\" Academic Press.\n", " - **Note**: Foundational work on square-root filters\n", "\n", "### PyTCL Library Documentation\n", "- **Dynamic Estimation Module**: See `pytcl.dynamic_estimation` for available filter implementations\n", "- **API Reference**: Full documentation at [NRL Tracker GitHub](https://github.com/nedonatelli/TCL)\n", "- **Example Gallery**: Additional examples in `examples/` directory\n", "\n", "### Online Resources\n", "- **MIT OpenCourseWare**: 6.041 Probabilistic Systems Analysis (free lectures on Bayesian inference)\n", "- **Andrew Ng's Machine Learning Course**: Covers HMMs and Kalman filters intuitively\n", "- **MATLAB Documentation**: Excellent visual guides on Kalman filter concepts\n", "\n", "### Recommended Learning Path\n", "\n", "```\n", "1. Week 1: Linear Kalman Filter\n", " → Read: Welch & Bishop introduction\n", " → Code: Constant velocity 1D tracking\n", " → Exercise 1: Tune Q and R by hand\n", "\n", "2. Week 2: Extended Kalman Filter\n", " → Read: Simon Ch. 6 on EKF\n", " → Code: Nonlinear radar measurements\n", " → Exercise 3: Compare EKF vs UKF\n", "\n", "3. Week 3: Unscented Kalman Filter\n", " → Read: Julier & Uhlmann paper\n", " → Code: Highly nonlinear system\n", " → Exercise: Verify NEES consistency\n", "\n", "4. Week 4: Applications\n", " → Read: Real-world case studies\n", " → Code: Multi-sensor fusion (Exercise 5)\n", " → Project: Your own tracking application\n", "```" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.13.0" } }, "nbformat": 4, "nbformat_minor": 4 }