Fast Robots

ECE 5160 Spring 2026

← Back to Labs

Lab 7: Kalman Filtering

Due: TBD

Overview

This lab performs system identification on the open-loop wall approach and uses the identified discrete model to implement and evaluate a Kalman filter in simulation (Jupyter) and onboard (Artemis). The Kalman filter estimates both distance-to-wall and velocity from time-of-flight (ToF) measurements and the signed motor command.

System identification

I modeled the open-loop approach to the wall as first-order in velocity, with state x = [distance to wall (mm); velocity (mm/s)]ᵀ. Distance decreases when the car drives toward the wall, so steady-state velocity is negative in my sign convention (v = d(distance)/dt).

The experiment used the fully assembled differential-drive robot with a SparkFun Artemis as the main controller, a VL53L1X ToF sensor facing the wall for distance, and the onboard ICM-20948 IMU (present on the stack; not the primary signal for this wall-distance KF, which uses ToF + PWM).

Open-loop step response: TOF distance, speed estimate, and signed PWM vs time
Figure 1: Lab 7 open-loop step response: TOF distance (mm), finite-difference / fit speed (m/s), and signed PWM vs time (relative time from trim).

I logged an open-loop constant-PWM run (Lab 5 style), trimmed the ends (start_cut = 3, end_cut = 35), converted time to seconds, and took a finite-difference speed. I fit an exponential to the speed in FIT_T_RANGE = (0, 3.2) s and read off steady-state speed and a rise time at 80% of v_ss (my notebook uses RISE_FRAC = 0.80, not 90%).

Speed-only view with exponential fit, v_ss, and 80% v_ss lines
Figure 2 Speed-only view with exponential fit, v_ss, and 80% v_ss lines.

From the exponential fit I obtained v_ss ≈ −2538.06 mm/s and t_rise ≈ 2.7614 s at 80% of v_ss. Treating the step input as normalized u = 1 at STEP_PWM = max_pwm = 100, the notebook identifies a positive drag-style scalar d = 1 / |v_ss| ≈ 0.000394 s/mm and a momentum parameter m from m = −d · t_rise / ln(1 − RISE_FRAC), which yields the −d/m ≈ −0.5828 1/s entry I used in the A matrix (matching the discrete Ad diagonal I use at dt = 0.01 s).

The identification run used STEP_PWM = 100, which is in the same signed PWM units as the Lab 5 wall runs and is a “similar magnitude” step (large enough to show a clear first-order rise in speed without saturating the entire drive range).

For the wall approach model with positive PWM pulling distance down, I use a negative input column in B (mm/s² per PWM). That matches both the Jupyter discrete Bd and the firmware.

Code (step-response / identification setup) (from my notebook):

Python
# step response trimming + fit settings
start_cut = 3
end_cut = 35

FIT_T_RANGE = (0, 3.2)
RISE_FRAC = 0.80

# ... compute v_ss, t_rise from the fit ...
d = 1.0 / abs(v_ss)
v_rise = v_ss * RISE_FRAC

# momentum-ish parameter from rise time
m = -d * t_rise / np.log(1.0 - RISE_FRAC)

Discrete model used later (Jupyter KF + Artemis), same notebook / hand calculations:

Python
# discrete model used in both Jupyter + firmware
dt = 0.01

Ad = [
    [1.0, dt],
    [0.0, 0.994172],
]
Bd = [
    [0.0],
    [-0.147926],
]

# measures distance (TOF)
C = [[1, 0]]

Here C = [[1, 0]] picks the first state (distance), because that is what the ToF measures. The handout’s [[−1, 0]] example corresponds to a different position sign convention; in this report, a larger ToF reading means the robot is farther from the wall, so the measurement is y = (1)·distance + noise.

For Jupyter, the handout’s discrete model uses a fixed discretization period dt = 0.01 s (100 Hz), which is the sampling time embedded in the Ad and Bd values above.

Kalman filter simulation (Jupyter)

I pulled time, raw ToF, and signed PWM from a straight wall run (wall_t_ms, wall_dist_mm / wall_raw_mm, wall_pwm). To match the lab handout, I linearly interpolated ToF and PWM onto a common 10 ms grid and called a lecture-style kf(mu, sigma, u, y) each step, with u = pwm / STEP_PWM_REF and B_unit = Bd * step_size so the discrete input matches Bd × (actual PWM).

ToF and PWM do not share the same native timestamps or count per second, so interpolation onto a single time vector ensures every index k has a paired (u_k, y_k) for the KF loop (equal-length requirement).

The handout’s input scaling is implemented as u = pwm_actual / STEP_PWM_REF with B_unit = Bd × step_size, so B_unit·u equals Bd·pwm (a unit step in identification maps cleanly to the real PWM command used on the robot).

Process and measurement covariances:

Σu = diag(σ₁², σ₂²) with σ₁ = 1 mm, σ₂ = 100 mm/s
Σz = [[σ₃²]] with σ₃ = 50 mm for my baseline run

Jupyter Kalman filter simulation results
Figure 3: Jupyter KF on interpolated grid: raw + interpolated ToF vs KF distance; raw / interp finite-difference velocity vs KF velocity; interpolated PWM vs logged samples.

Code (kf + covariances) (from my notebook):

Python
import numpy as np

# noise tuning
sigma_1 = 1.0
sigma_2 = 100.0
sigma_3 = 50.0

Sigma_u = np.diag([sigma_1**2, sigma_2**2])
Sigma_z = np.array([[sigma_3**2]])

def kf(mu, sigma, u, y, Ad, B_unit, C, Sigma_u, Sigma_z):
    # keep shapes consistent (2x1 state, 2x2 covariance)
    mu = np.asarray(mu, dtype=float).reshape(2, 1)
    sigma = np.asarray(sigma, dtype=float).reshape(2, 2)
    u_m = np.array([[float(u)]])

    # predict
    mu_p = Ad @ mu + B_unit @ u_m
    sigma_p = Ad @ sigma @ Ad.T + Sigma_u

    # update (distance measurement)
    sigma_m = C @ sigma_p @ C.T + Sigma_z
    kkf_gain = sigma_p @ C.T @ np.linalg.inv(sigma_m)
    y_m = np.array([[float(y)]]) - C @ mu_p

    mu = mu_p + kkf_gain @ y_m
    sigma = (np.eye(2) - kkf_gain @ C) @ sigma_p
    return mu, sigma

State initialization uses μ₀ = [distance_mm, velocity_mm/s]ᵀ. In practice, the first valid ToF measurement sets distance = ToF[0], velocity is initialized to 0, and Σ₀ is chosen with high initial uncertainty consistent with the chosen Σz on distance and σ₂² on velocity.

I then swept σ₃ ∈ {1, 50, 100} mm with Σu fixed. Smaller σ₃ tracks interpolated ToF more tightly; larger σ₃ smooths more. That matches intuition: Σz is how much I trust the sensor vs the model prediction.

Parameters affecting the filter

Kalman filter performance depends on the identified dynamics (Ad, Bd), sampling time (dt in Jupyter vs dt_s onboard), initialization (μ₀, Σ₀), process noise (Σu via σ₁, σ₂), measurement noise (Σz via σ₃), interpolation assumptions, sign / measurement mapping (C), trim windows, and the relationship between the MCU poll rate and the ToF update rate (how often prediction runs without a measurement update).

Sigma_3 sweep Kalman filter results
Figure 4: Overlay of KF distance and velocity for σ₃ = 1, 50, 100 mm with fixed Σu.

Onboard Kalman filter (Artemis)

I removed the linear ToF extrapolation and fused raw ToF in a Kalman filter every time a new range is ready, while predicting every PID loop iteration using variable dt_s from millis() (clamped if needed). The PID error uses the KF distance wall_kf_mu(0,0), not the raw reading.

This wall-distance Kalman filter gives a smoother, less noisy estimate of range than raw ToF, so the PID sees a stable “distance to wall” while still updating on real measurements. That reduces chatter and false corrections near the stop band, which is important for reliably stopping without overshooting due to spike range readings.

Onboard KF + PID plot: raw ToF vs KF distance and residual
Figure 5: Onboard run: raw ToF vs KF distance.
Video: kf_demo_video.mp4.

Following the lab instructions, I validated the onboard KF with debugging plots (raw vs KF distance and residual) and a short demo video before trying to speed up the approach. Linear ToF extrapolation was removed first so the Kalman filter, not the extrapolator, defines the distance estimate used by the PID.

Code (Kalman predict + update pattern) (from my firmware):

C++
#include <BasicLinearAlgebra.h>
using namespace BLA;

// process noise (picked to keep velocity from blowing up)
static const Matrix<2, 2> WALL_KF_SIGMA_U = {
  1.0f,    0.0f,
  0.0f, 10000.0f
};

// ... dt_s computed from millis(), clamped if needed ...
Matrix<2, 2> Ad = { 1.0f, dt_s,
                   0.0f, 1.0f - 0.5828f * dt_s };
Matrix<2, 1> Bd = { 0.0f,
                   -14.7926f * dt_s };

Matrix<2, 1> mu_p = Ad * wall_kf_mu;
mu_p(1, 0) += Bd(1, 0) * (float)wall_last_pwm;

Matrix<2, 2> Sigma_p = Ad * wall_kf_Sigma * ~Ad + WALL_KF_SIGMA_U;

Measurement update uses C = [1, 0], σ_z = 2500 (i.e. 50 mm std) on distance, and Joseph-style (I − K C) Σ_p via Kmat * Cmat. The BLE log is WALL:<ms>,<raw>,<kf>,<err>,<pwm> so Jupyter can plot raw vs KF on the same axes.

On the Artemis, Ad/Bd are recomputed using the measured loop interval dt_s, which is the embedded discretization of the same continuous model (as opposed to Jupyter’s fixed dt = 0.01 s).

PID on KF distance:

C++
error = dist_est - (float)setpoint_mm;