Extended Kalman Filter for Heading-Aware GPS Smoothing

The linear filter described in Kalman filtering for GPS noise reduction tracks position and velocity components, and it works well on straight roads. Its weakness is corners: a constant-velocity model has no notion of turning, so a genuine turn is an unmodelled acceleration that the filter smooths through. The result is a path that cuts the corner, and at low sampling rates it cuts it by enough to matter.

A constant-turn-rate-and-velocity model fixes this by putting heading and turn rate in the state. The motion model is then non-linear, which is what makes it an extended Kalman filter rather than a linear one. This page covers the state design, the Jacobian, the angle wrapping that catches everyone, and the honest answer about when the extra complexity is worth it.


Compatibility and Configuration Requirements

Requirement Value Notes
CRS Projected, metres The motion model is Cartesian; degrees make the turn rate meaningless
numpy ≥ 1.26 Small dense linear algebra
Measurement Position, optionally Doppler speed and course Speed and course make heading observable immediately
Sampling Δt from timestamps, never assumed The propagation is non-linear in Δt
Speed threshold ~1 m/s Below it, heading and turn rate are unobservable

The State and the Model

The state is five numbers: position, speed, heading and turn rate.

x = [px, py, v, psi, psi_dot]

Propagation over Δt, for a non-zero turn rate:

px' = px + (v / psi_dot) * ( sin(psi + psi_dot*dt) - sin(psi) )
py' = py + (v / psi_dot) * ( cos(psi) - cos(psi + psi_dot*dt) )
v'  = v
psi' = psi + psi_dot * dt
psi_dot' = psi_dot
from __future__ import annotations

import numpy as np

EPS = 1e-4          # turn rates below this are treated as straight-line motion


def wrap(a: np.ndarray | float):
    """Normalise an angle or array of angles into (-pi, pi]."""
    return (a + np.pi) % (2 * np.pi) - np.pi


def propagate(x: np.ndarray, dt: float) -> np.ndarray:
    px, py, v, psi, w = x
    if abs(w) < EPS:                       # straight line: the general form divides by zero
        return np.array([px + v * dt * np.cos(psi),
                         py + v * dt * np.sin(psi),
                         v, wrap(psi), w])
    return np.array([
        px + (v / w) * (np.sin(psi + w * dt) - np.sin(psi)),
        py + (v / w) * (np.cos(psi) - np.cos(psi + w * dt)),
        v,
        wrap(psi + w * dt),
        w,
    ])


def jacobian(x: np.ndarray, dt: float) -> np.ndarray:
    """Partial derivatives of `propagate` with respect to the state."""
    px, py, v, psi, w = x
    F = np.eye(5)
    F[3, 4] = dt
    if abs(w) < EPS:
        F[0, 2] = dt * np.cos(psi); F[0, 3] = -v * dt * np.sin(psi)
        F[1, 2] = dt * np.sin(psi); F[1, 3] = v * dt * np.cos(psi)
        return F
    s0, s1 = np.sin(psi), np.sin(psi + w * dt)
    c0, c1 = np.cos(psi), np.cos(psi + w * dt)
    F[0, 2] = (s1 - s0) / w
    F[0, 3] = (v / w) * (c1 - c0)
    F[0, 4] = (v * dt / w) * c1 - (v / w**2) * (s1 - s0)
    F[1, 2] = (c0 - c1) / w
    F[1, 3] = (v / w) * (s1 - s0)
    F[1, 4] = (v * dt / w) * s1 - (v / w**2) * (c0 - c1)
    return F

The abs(w) < EPS branch is not an optimisation. The general form divides by the turn rate, and a vehicle driving straight has a turn rate that is numerically indistinguishable from zero — so without the branch the filter produces infinities on every motorway.

What the heading state buys at a corner A vehicle turns right at a junction with fixes every ten seconds. The constant-velocity filter cuts the corner by 19 metres, placing the smoothed path across the junction interior. The constant-turn-rate filter follows the turn to within 4 metres. Right turn at a junction, fixes every 10 s road centreline CTRV — 4 m constant velocity — 19 m At 1 Hz both filters are within a few metres. At 10 s the gap opens to the width of a junction. Nineteen metres is enough to put the smoothed path on the wrong arm of the junction after matching.

Angle Wrapping

The single most common EKF bug in this application is the heading residual. When the measured course is 359 degrees and the predicted heading is 1 degree, the naive difference is 358 degrees, and the filter applies an enormous correction that spins the state.

Wrap every angular difference before it enters the update:

def update(x, P, z, H, R, angle_idx: int | None = 3):
    """Standard EKF update, with the angular residual wrapped."""
    y = z - H @ x
    if angle_idx is not None:
        y[angle_idx] = wrap(y[angle_idx])
    S = H @ P @ H.T + R
    K = P @ H.T @ np.linalg.inv(S)
    x_new = x + K @ y
    x_new[3] = wrap(x_new[3])
    P_new = (np.eye(len(x)) - K @ H) @ P
    return x_new, P_new

Two wraps, not one. The residual is wrapped so the correction is the short way round, and the updated state is wrapped so the heading stays in range rather than accumulating past pi over a long sequence of left turns.

The symptom of a missing wrap is characteristic: the filter behaves perfectly for most of a trace and then produces a violent excursion at one point, always where the heading crossed north. It is easy to mistake for a GPS spike, which is why it survives so long in so many implementations.

The bug that only appears facing north Filtered heading across forty fixes as a vehicle turns through north. With the residual wrapped, the heading passes smoothly from 350 degrees to 10. Without it, the filter applies a 340-degree correction at the crossing and the state swings wildly for several fixes before recovering. Filtered heading through a north crossing 360° 180° wrapped — smooth through north unwrapped — 340° correction, then ringing The filter is correct everywhere except at the crossing, which is why the bug reads as a data problem. Test with a synthetic trace that turns through north; a corpus that never does will not reveal it.

Tuning the Turn-Rate Noise

The CTRV filter has one more tuning parameter than the linear one, and it is the one that decides the filter’s character. The turn-rate process noise says how quickly the vehicle’s rate of turn can change.

Too small and the filter believes the turn rate is nearly constant, so it enters a corner late and leaves it late — overshooting on both sides. The trace looks smooth and follows a path the vehicle did not take.

Too large and the turn rate tracks measurement noise, so the filtered heading oscillates and the position wobbles along a straight road.

The physically motivated starting point is the maximum angular acceleration the vehicle class can produce. A van can change its turn rate by roughly 0.5 rad/s²; an articulated truck by considerably less. Setting the process noise standard deviation to that value over the sampling interval gives a filter that can follow anything the vehicle can physically do and nothing more.

def Q_ctrv(dt: float, sigma_a: float = 0.6, sigma_alpha: float = 0.4) -> np.ndarray:
    """Process noise for linear acceleration and angular acceleration."""
    Q = np.zeros((5, 5))
    Q[2, 2] = (sigma_a * dt) ** 2          # speed
    Q[3, 3] = (0.5 * sigma_alpha * dt**2) ** 2   # heading
    Q[4, 4] = (sigma_alpha * dt) ** 2      # turn rate
    return Q

Validate by measuring, not by looking. Take a corpus of high-rate traces, downsample, filter, and measure the positional error at corners specifically — the mean over a whole trace is dominated by straight sections where every filter agrees.


When It Is Worth It

The EKF costs more than the linear filter in three ways: more code, more tuning parameters, and a failure mode at zero speed that the linear filter does not have. It is worth paying when corners matter and the sampling interval is long.

At 1 Hz, a constant-velocity filter cuts a typical urban corner by two to four metres, which is comfortably inside the receiver’s own error and changes no downstream answer. At 10 seconds it cuts by ten to twenty; at 30 seconds by tens of metres, which is enough to put a matched path on the wrong arm of a junction.

The zero-speed failure deserves care. Below about a metre per second the turn rate is unobservable and the heading state will drift freely, producing a parked vehicle that slowly rotates. Freeze both states below the threshold:

if x[2] < 1.0:
    x[4] = 0.0                       # turn rate
    P[3:5, :] = P[:, 3:5] = 0.0      # and stop their covariance growing
Where the extra complexity starts paying Median positional error at corners against sampling interval. At one second both filters are within four metres. The constant-velocity filter's error grows to 38 metres at 30-second sampling while the constant-turn-rate filter stays under 9. Median error at corners, by sampling interval 40 m 20 m 0 constant velocity CTRV where the EKF earns its complexity 1 s 10 s 30 s On a 1 Hz fleet the linear filter is the right answer and the EKF is complexity without benefit. Past ten seconds the corner error alone justifies the extra state and the extra tuning parameter.

Common Pitfalls Specific to This Technique

No small-turn-rate branch. Division by a turn rate that is numerically zero on every straight road, producing infinities the first time the filter meets a motorway.

Unwrapped heading residual. Works for months and then produces one violent excursion, always at a north crossing, and always mistaken for a GPS spike.

Filtering in degrees. The motion model is Cartesian and the turn rate is in radians per second; mixing units gives a filter that appears tuned and is not.

Leaving heading unconstrained at rest. A parked vehicle rotates in the output, which then produces spurious heading changes for anything reading the smoothed course.