Lab 7: Kalman Filter

Objective

The purpose of this lab is to implement a Kalman Filter to supplement the slowly-updating ToF sensor with a faster state estimate, allowing the PID controller to run at a higher effective rate. The VL53L1X ToF sensor is limited to ~10–20 Hz in practice, but the Artemis control loop runs much faster. The Kalman Filter predicts the car's position and velocity at every loop iteration using a state-space model of the car's dynamics, and corrects that estimate each time a new ToF reading arrives. This way, the PID controller always has a fresh, smooth position estimate to work with, even between sensor updates.

Part 1: Estimate Drag and Momentum

To build the state-space model, I need to estimate the drag coefficient d and momentum term m. These come directly from a step response experiment: drive the car toward a wall at a constant PWM input and observe how speed evolves over time.

Step Response Setup

I chose u = 150 PWM as the step input, which is approximately 59% of the maximum PWM (255) and consistent with the PWM used in Lab 5, keeping the dynamics comparable. I placed the car roughly 4 feet from the wall with a foam cushion as a buffer. The ToF sensor was set to short-distance mode for a higher sampling rate (~20 Hz), and the control loop was made non-blocking so the Artemis logs data as fast as sensor readings arrive rather than blocking on each measurement.

The step response Arduino code drives the car at a constant PWM, takes non-blocking ToF readings, and auto-brakes when the car gets within STOP_DISTANCE of the wall:

void runStepResponse() {
    if (sensor2.checkForDataReady()) {
        distance2 = sensor2.getDistance();
        sensor2.clearInterrupt();
        sensor2.stopRanging();
        sensor2.startRanging();
 
        if (distance2 < STOP_DISTANCE) {
            stop();
            step_running = false;
            return;
        }
 
        if (arr_index < ARRAY_SIZE) {
            T_arr[arr_index]                 = millis();
            Measured_distance_arr[arr_index] = distance2;
            Control_speed_arr[arr_index]     = STEP_PWM;
            arr_index++;
        }
    }
    forward(STEP_PWM);
}
Non-blocking step response loop. forward() is called every iteration regardless of sensor readiness, while data is only logged when a new reading arrives.

On the Python side, data is collected over BLE and saved immediately to a pickle file so it persists across kernel restarts:

data = {"T": T_arr, "D": Distance_arr, "U": MotorInput_arr}
with open("step_response.pkl", "wb") as f:
    pickle.dump(data, f)
 
# Load back after restart:
with open("step_response.pkl", "rb") as f:
    data = pickle.load(f)
T_arr, Distance_arr, MotorInput_arr = data["T"], data["D"], data["U"]
Saving and loading step response data using pickle, so the data survives kernel restarts.

Initial Attempt: Long Distance Mode (141 inches)

My first attempt started 141 inches from the wall using long-distance ToF mode. The sensor produced a large distance spike at the start (an unstable reading before the sensor stabilized), and long-distance mode is limited to ~10 Hz, so only 21 samples were collected over 2 seconds. The speed curve plateaued almost immediately, suggesting the car reached steady state faster than the sensor could capture the ramp-up.

Initial step response at 141 inches, long distance mode
Figure 1. First step response attempt at 141 inches. The distance spike at t≈0.15s is a bad sensor reading (physically impossible jump from ~800 to 3700 mm). The speed curve appears flat immediately — the car already reached steady state before the sensor could capture the acceleration phase. Switching to short-distance mode and starting closer fixed both issues.

Step Response Video

Step response run. Car drives toward wall at constant PWM 150, with foam buffer to prevent damage.

Step Response Graphs

The computed speed is derived from the ToF distance data as: speed = -ΔDistance / Δtime (negated because distance decreases as the car moves forward). The motor input is a constant horizontal line at 150 PWM — this is the step input u(t) that the car's dynamics respond to.

The speed curve clearly shows the car accelerating from rest and approaching a plateau, consistent with a first-order system reaching terminal velocity where motor force balances drag. My car hit the foam buffer at approximately the 10th sample, so only the first 10 samples (indices 0–9) are used for extracting steady-state speed.

Step response: ToF distance and computed speed vs time
Figure 2. Step response at ~4 feet from wall, short-distance mode. Top: ToF distance decreasing as car approaches wall. Middle: Computed speed rising from 0 to ~2000 mm/s, then crashing at ~1.0s when the car hits the foam buffer. Only indices 0–9 (before the crash) are used for extracting d and m.

Extracting d and m

From the pre-crash portion of the speed data, I extracted the three key values needed:

speed_clean = speed[:10]
T_clean     = T_speed[:10]
 
v_ss = np.mean(speed_clean[6:10])
u    = 150
 
d = u / v_ss
 
v_90   = 0.9 * v_ss
idx_90 = np.argmax(speed_clean >= v_90)
t_90   = T_clean[idx_90]
 
m = -d * t_90 / np.log(1 - 0.9)
Computing drag d from steady-state speed and momentum m from the 90% rise time. Only the pre-crash indices 0–9 are used.
Speed table: index, time (s), speed (mm/s) for all 20 samples
Figure 3. Computed speed at each sample midpoint. Indices 0–9 show the forward acceleration phase used for extracting d and m. Index 0 shows a small negative speed due to a noisy first ToF reading. Indices 10 onward reflect the car bouncing off the foam buffer.

The resulting values are:

  • Steady-state speed vss = 2017.1 mm/s — the plateau speed where motor force exactly balances drag
  • 90% rise time t90% = 0.6370 s — the time at which speed reaches 90% of vss
  • Speed at 90% rise time v90% = 1815.4 mm/s
  • d = u / vss = 150 / 2017.1 = 0.0744
  • m = −d · t90% / ln(1 − 0.9) = 0.0206

As a sanity check: the velocity decay time constant is m/d = 0.0206/0.0744 ≈ 0.277 s, meaning if the motors were cut, speed would decay with a ~0.28 s time constant. This is physically reasonable for a small lightweight RC car.

Part 2: Initialize Kalman Filter (Python)

State-Space Model

The car's equation of motion is m&ddot;x; = u − d˙x;, which rearranges to &ddot;x; = −(d/m)˙x; + (1/m)u. With state vector x = [position, velocity]T, the continuous-time state-space matrices are:

A = np.array([[0,    1  ],
              [0,  -d/m ]])   # system dynamics: how state evolves on its own
 
B = np.array([[0  ],
              [1/m]])          # input matrix: how PWM affects state
Continuous-time A (2×2) and B (2×1). The top row of A encodes ṗ = ṗ (trivially true), and the bottom row encodes the physics: acceleration = −(d/m)·velocity + (1/m)·u.

Discretization

The total step response run time was approximately 2 seconds with 21 samples, giving an inter-sample interval of about 0.095 s. I use ΔT = 0.1 s as the sampling time. Discretization uses the first-order Euler approximation: Ad = I + ΔT·A and Bd = ΔT·B.

Delta_T = 0.1
n = 2
Ad = np.eye(n) + Delta_T * A
Bd = Delta_T * B
First-order Euler discretization. Ad is 2×2 and Bd is 2×1, matching the 2-dimensional state space (position and velocity) and scalar input (PWM).

C Matrix

The measurement matrix C maps the full state vector to what the ToF sensor can actually observe. The ToF measures positive distance, but the state convention defines position as negative distance from the wall (car moving toward wall is positive). C = [−1, 0] flips the sign to match: y = Cx = −position = +distance. The zero in the second column reflects that velocity is never directly measured — the Kalman Filter must estimate it.

C = np.array([[-1, 0]])
C is 1×2 (m×n where m=1 sensor, n=2 states). If a second sensor measured velocity directly, C would be 2×2.

State Initialization and Noise Covariance

TOF = np.array(Distance_arr)
x   = np.array([[-TOF[0]], [0]])   # [negative distance, 0 velocity]
 
sigma_1 = 20    # process noise: position uncertainty (mm)
sigma_2 = 20    # process noise: velocity uncertainty (mm/s)
sigma_3 = 20    # sensor noise: ToF measurement noise (mm)
 
Sigma_u = np.array([[sigma_1**2, 0         ],
                     [0,          sigma_2**2]])
 
Sigma_z = np.array([[sigma_3**2]])
State initialized to first ToF reading with zero velocity (car at rest). Sigma_u (2×2) is process noise — how much we distrust the dynamics model. Sigma_z (1×1) is sensor noise — how much we distrust the ToF reading. Their relative magnitudes determine how much the filter trusts the model vs. the sensor.

Kalman Filter Function

def kf(mu, sigma, u, y):
    mu_p    = Ad.dot(mu) + Bd.dot(u)
    sigma_p = Ad.dot(sigma.dot(Ad.T)) + Sigma_u
 
    sigma_m = C.dot(sigma_p.dot(C.T)) + Sigma_z
    kf_gain = sigma_p.dot(C.T.dot(np.linalg.inv(sigma_m)))
 
    y_m   = y - C.dot(mu_p)
    mu    = mu_p + kf_gain.dot(y_m)
    sigma = (np.eye(n) - kf_gain.dot(C)).dot(sigma_p)
 
    return mu, sigma
Two-step Kalman Filter: predict (propagate state forward using model) then update (correct prediction using sensor measurement). The Kalman gain weights how much to trust the sensor vs. the model.

Part 3: Implement and Test Kalman Filter (Jupyter)

Before deploying to the Artemis, I tested the Kalman Filter in Python by running it on the previously collected Lab 5 PID data. The filter runs on the raw ToF readings offline, and the KF estimate is overlaid on the raw distance for comparison:

mu    = np.array([[-D[0]], [0]])
sigma = np.eye(n)
kf_positions = []
 
for i in range(len(D)):
    y         = np.array([[-D[i]]])
    mu, sigma = kf(mu, sigma, u_k, y)
    kf_positions.append(-mu[0, 0])
Offline KF test loop. Each ToF sample is fed in as a measurement update; the KF estimate is converted back to positive distance for plotting.

The KF output (purple) is smoother than the raw ToF (blue) while still tracking the same trajectory — confirming the filter parameters and sign conventions are correct before moving to the Artemis.

KF estimated distance vs raw ToF distance
Figure 4. Kalman Filter estimate (purple) overlaid on raw ToF distance (blue) from a PID run. The KF output is noticeably smoother — the staircase pattern of the slow 20 Hz ToF is replaced by a continuously interpolated estimate. Both curves converge to the 304 mm setpoint, confirming the filter is tracking correctly.

Part 4: Kalman Filter on the Artemis

The Kalman Filter is implemented on the Artemis using the BasicLinearAlgebra library. Unlike the offline Python test, the filter now runs in real-time: the predict step executes every control loop iteration (fast), while the update step only runs when a new ToF reading is available (slow, ~20 Hz). This lets the PID controller use a continuously-updated state estimate between sensor readings rather than waiting for the next ToF sample.

KF Predict and Update (Arduino)

void kf_predict(float u_pwm) {
    unsigned long now = millis();
    float dt = (now - last_predict_time) / 1000.0f;
    last_predict_time = now;
    if (dt <= 0 || dt > 0.5) return;
 
    Matrix<2,2> Ad = {1,      dt,
                       0, 1 - (D_DRAG/M_MOM)*dt};
    Matrix<2,1> Bd = {0, dt/M_MOM};
 
    mu    = Ad * mu + Bd * u_pwm;
    Sigma = Ad * Sigma * ~Ad + Sigma_u;
}
 
void kf_update(float tof_mm) {
    Matrix<1,1> y_m     = {-tof_mm};
    Matrix<1,1> sigma_m = C_mat * Sigma * ~C_mat + Sigma_z;
    Matrix<2,1> kf_gain = Sigma * ~C_mat * Inverse(sigma_m);
 
    mu = mu + kf_gain * (y_m - C_mat * mu);
 
    Matrix<2,2> I_mat = {1, 0, 0, 1};
    Sigma = (I_mat - kf_gain * C_mat) * Sigma;
}
 
float kf_distance() {
    return -mu(0, 0);
}
kf_predict() recomputes Ad and Bd from the actual elapsed time dt, giving accurate discretization even when loop timing varies. Eye<2>() was replaced with an explicit identity matrix to avoid a compiler error with this version of BasicLinearAlgebra.

KF + PID Control Loop

void runKFPIDController() {
    kf_predict((float)abs(linear_control_speed));
 
    if (sensor2.checkForDataReady()) {
        distance2 = sensor2.getDistance();
        sensor2.clearInterrupt();
        sensor2.stopRanging();
        sensor2.startRanging();
 
        if (!kf_initialized) {
            mu(0, 0)          = -(float)distance2;
            mu(1, 0)          = 0;
            last_predict_time = millis();
            kf_initialized    = true;
            return;
        }
        kf_update((float)distance2);
    }
 
    if (!kf_initialized) return;
 
    float kf_dist = kf_distance();
    if (kf_dist < 0 || kf_dist > 5000) kf_dist = (float)distance2;
 
    linear_error         = (int)(kf_dist - LINEAR_SETPOINT);
    linear_sum_error    += linear_error;
    int derivative       = linear_error - linear_previous_error;
    linear_control_speed = (int)(linear_Kp*linear_error
                               + linear_Ki*linear_sum_error
                               + linear_Kd*derivative);
    linear_previous_error = linear_error;
    linear_control_speed  = constrain(abs(linear_control_speed), MIN_SPEED, MAX_SPEED);
 
    if (arr_index < ARRAY_SIZE) {
        T_arr[arr_index]                 = millis();
        Measured_distance_arr[arr_index] = distance2;
        KF_distance_arr[arr_index]       = (int)kf_dist;
        Error_arr[arr_index]             = linear_error;
        Control_speed_arr[arr_index]     = linear_control_speed;
        arr_index++;
    }
 
    if (abs(linear_error) < 20) {
        stop();
    } else if (linear_error > 0) {
        forward(linear_control_speed);
    } else {
        backward(linear_control_speed);
    }
}
The full KF+PID loop. kf_predict() runs every iteration; kf_update() only runs when checkForDataReady() is true. The PID uses the KF estimate rather than raw ToF, giving smoother control between sensor updates. Both raw ToF and KF estimate are logged for comparison.

Result: KF + PID at PWM 150

KF + PID running on the Artemis at PWM 150 with Kp = 0.05, Ki = 0.0, Kd = 0.7. The car drives toward the wall and stops at the 304 mm setpoint using the Kalman Filter estimate as the PID input.

References