Interpolating Missing GPS Points with Kalman Filters
A Kalman filter fills GPS dropouts by replacing naive straight-line assumptions with a recursive Bayesian estimator that models vehicle kinematics and sensor uncertainty. During signal loss only the prediction step runs, advancing the trajectory while inflating positional covariance; when a valid ping returns, the update step fuses prediction and measurement weighted by their relative confidence. The result is a statistically optimal, temporally continuous trace with an explicit uncertainty column that downstream analytics can consume directly.
Why This Happens
Raw telemetry streams in urban mobility pipelines rarely arrive at fixed intervals. Multipath reflections in dense street canyons, tunnel outages, and aggressive device power-saving modes create irregular gaps that break standard resampling routines. These dropouts are the core problem addressed by Gap Filling in Sparse Trajectories, which sits at the foundation of Temporal Aggregation & Window Mapping.
Geometric methods — linear and cubic splines — know nothing about momentum. They connect the last known point to the next observed point as if the vehicle teleported along a smooth curve, producing unrealistic artefacts across even 10-second gaps. A state-space model respects physics: the filter’s motion model encodes that position changes with velocity and that velocity changes slowly, so predictions drift realistically under uncertainty rather than drawing implausible straight lines.
The diagram below shows the predict-update cycle across a 3-ping dropout.
Core Mitigation Pipeline
- Project to a metric CRS — reproject WGS84 coordinates to EPSG:3857 or a local UTM zone so that state velocities carry metre-per-second units rather than meaningless decimal-degree deltas.
- Build the constant-velocity state-space model — define the state vector, transition matrix parameterised by elapsed
Δt, observation matrix, and noise covariancesQandR. - Run the predict-update loop — for every timestamp: always execute the prediction step; execute the update step only when a non-null measurement is present.
- Attach uncertainty metadata and reproject — store the combined positional standard deviation per row, then reproject output back to WGS84 for storage or downstream joins.
Production-Ready Python Implementation
The implementation below uses filterpy and projects through pyproj so all state units are metres and m/s. It handles irregular timestamps, skips missing measurements, returns a dense trajectory aligned to the original index, and raises informative errors on degenerate inputs.
import numpy as np
import pandas as pd
from filterpy.kalman import KalmanFilter
from pyproj import Transformer
# NOTE: All distance/velocity arithmetic is performed in EPSG:3857 (metres).
# Raw WGS84 degrees are never fed into the filter — doing so makes Q and R
# dimensionally meaningless and silently degrades accuracy.
def interpolate_gps_kalman(
df: pd.DataFrame,
lat_col: str = "lat",
lon_col: str = "lon",
time_col: str = "timestamp",
process_noise: float = 0.5, # Q scale: m²/s⁴ — tune per transport mode
measurement_noise: float = 25.0, # R: m² — ~5 m CEP50 for consumer GNSS
) -> pd.DataFrame:
"""
Fill missing GPS fixes in *df* using a 2D constant-velocity Kalman filter.
Parameters
----------
df : pd.DataFrame
Must have columns *lat_col*, *lon_col*, *time_col*.
Rows where lat/lon are NaN are treated as dropout ticks.
The DataFrame must be sorted by *time_col* and contain at least
two non-null fixes for initialisation.
process_noise : float
Scale factor for the process noise covariance Q (m²/s⁴).
Higher values allow the filter to track sharper manoeuvres.
measurement_noise : float
GPS measurement variance in m² (R diagonal).
Derive from receiver CEP50: R ≈ (CEP50)².
Returns
-------
pd.DataFrame
Copy of *df* with 'lat', 'lon' filled and two new columns:
'is_interpolated' (bool) and 'kf_uncertainty_m' (float, metres std-dev).
"""
if df.empty:
raise ValueError("Input DataFrame is empty.")
required = {lat_col, lon_col, time_col}
missing = required - set(df.columns)
if missing:
raise ValueError(f"Missing required columns: {missing}")
df = df.copy().sort_values(time_col).reset_index(drop=True)
# Identify the first non-null fix for initialisation
valid_mask = df[lat_col].notna() & df[lon_col].notna()
if valid_mask.sum() < 2:
raise ValueError("Need at least 2 non-null GPS fixes to initialise the filter.")
first_valid = df.loc[valid_mask].iloc[0]
# --- Project to metric CRS (EPSG:3857) ---
to_metric = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True)
to_wgs84 = Transformer.from_crs("EPSG:3857", "EPSG:4326", always_xy=True)
init_x, init_y = to_metric.transform(first_valid[lon_col], first_valid[lat_col])
# --- Initialise 2D constant-velocity filter ---
# State: [x, y, vx, vy] (metres, m/s in EPSG:3857)
kf = KalmanFilter(dim_x=4, dim_z=2)
kf.x = np.array([init_x, init_y, 0.0, 0.0], dtype=float)
kf.P = np.eye(4) * 50.0 # Initial state uncertainty (m² / (m/s)²)
kf.H = np.array([[1, 0, 0, 0], # Observe x position
[0, 1, 0, 0]]) # Observe y position
kf.R = np.eye(2) * measurement_noise
kf.Q = np.eye(4) * process_noise
# F is set per-tick to handle variable Δt
out_lats = df[lat_col].values.copy().astype(float)
out_lons = df[lon_col].values.copy().astype(float)
out_unc = np.full(len(df), np.nan)
is_interp = np.zeros(len(df), dtype=bool)
timestamps = pd.to_datetime(df[time_col])
for i in range(len(df)):
if i == 0:
# No prediction at the very first tick; just record init state
out_x, out_y = kf.x[0], kf.x[1]
out_unc[i] = float(np.sqrt(kf.P[0, 0] + kf.P[1, 1]))
# If first row has no fix, mark interpolated
if not valid_mask.iloc[i]:
is_interp[i] = True
lon_out, lat_out = to_wgs84.transform(out_x, out_y)
out_lats[i], out_lons[i] = lat_out, lon_out
continue
dt = (timestamps.iloc[i] - timestamps.iloc[i - 1]).total_seconds()
dt = max(dt, 0.1) # Guard against duplicate timestamps
# Time-varying transition matrix
kf.F = np.array([
[1, 0, dt, 0],
[0, 1, 0, dt],
[0, 0, 1, 0],
[0, 0, 0, 1],
], dtype=float)
# Prediction step — always runs
kf.predict()
# Update step — only when a valid fix exists
if valid_mask.iloc[i]:
meas_x, meas_y = to_metric.transform(
df[lon_col].iloc[i], df[lat_col].iloc[i]
)
kf.update(np.array([meas_x, meas_y]))
else:
is_interp[i] = True
out_unc[i] = float(np.sqrt(kf.P[0, 0] + kf.P[1, 1]))
lon_out, lat_out = to_wgs84.transform(kf.x[0], kf.x[1])
out_lats[i], out_lons[i] = lat_out, lon_out
df[lat_col] = out_lats
df[lon_col] = out_lons
df["is_interpolated"] = is_interp
df["kf_uncertainty_m"] = out_unc
return df
Validation Block
After running interpolate_gps_kalman, confirm the output before passing it downstream:
import numpy as np
def validate_kalman_output(result: pd.DataFrame) -> None:
# No NaN coordinates should remain
assert result["lat"].notna().all(), "NaN latitudes remain after interpolation"
assert result["lon"].notna().all(), "NaN longitudes remain after interpolation"
# Coordinates must stay in plausible WGS84 range
assert result["lat"].between(-90, 90).all(), "Latitude out of range"
assert result["lon"].between(-180, 180).all(), "Longitude out of range"
# Uncertainty column must be non-negative and finite
assert (result["kf_uncertainty_m"] >= 0).all(), "Negative uncertainty values"
assert np.isfinite(result["kf_uncertainty_m"]).all(), "Non-finite uncertainty"
# Log interpolation rate for monitoring
interp_rate = result["is_interpolated"].mean()
if interp_rate > 0.30:
import warnings
warnings.warn(
f"High interpolation rate: {interp_rate:.1%} of fixes are synthetic. "
"Verify gap classification before aggregating.",
stacklevel=2,
)
print(
f"Validation passed — {len(result)} rows, "
f"{result['is_interpolated'].sum()} interpolated "
f"({interp_rate:.1%}), "
f"max uncertainty {result['kf_uncertainty_m'].max():.1f} m"
)
- Expected shape: same row count as input, two new columns added.
is_interpolatedfraction above 30 % signals that gap classification upstream needs review before these rows feed into rolling statistics for mobility metrics.kf_uncertainty_mshould collapse after each real fix; a monotonically growing value with no collapses means no valid measurements were processed.- Spot-check the projection round-trip: project a known fix to EPSG:3857, run it through the filter for one tick with no dropout, and confirm the output lat/lon matches the input to within 0.5 m.
Common Mistakes & Gotchas
- Running the filter on raw WGS84 degrees. State velocity units become degrees/second, making
QandRdimensionally incoherent. Always project to a metric CRS before initialising the state vector — this is flagged explicitly in every code comment above. - Forgetting to update
Fper tick. A fixed transition matrix hardcoded with a constantdtsilently corrupts trajectories with irregular sampling. The loop above recomputesFat every step from the actual elapsed seconds. - Treating interpolated rows the same as observed rows in aggregations. Downstream dynamic time-binning strategies and ETA models should weight or exclude rows where
is_interpolated=True. The uncertainty column makes this weighting continuous rather than binary. - Using a single global
Qacross all device types. A pedestrian’sQ(slow acceleration budget, ~0.05 m²/s⁴) destroys accuracy when applied to a courier van (0.5–2.0 m²/s⁴). Segment by transport mode and tune separately; see speed and acceleration profiling for mode-specific velocity priors. - Initialising with a NaN fix. If the very first row is a dropout the initial state is undefined. The implementation above finds the first valid fix and initialises from there; anything before that fix is dead-reckoned backward only if you explicitly reverse the time axis — the simpler default is to mark those early rows as interpolated and flag them.
- Neglecting numerical stability on long dropouts. After many predict-only steps
Pgrows large. IfH P Hᵀ + Rapproaches singularity (rare with diagonalRbut possible with poorly conditionedQ), the matrix inversion insidefilterpyraises aLinAlgError. Add a maximum-gap cap: classify gaps longer than, say, 5 minutes asunrecoverablein the upstream gap-filling pipeline and reset the filter state after each break.
Related
- Gap Filling in Sparse Trajectories — parent overview covering gap classification, PCHIP, and spline alternatives
- Dynamic Time-Binning Strategies — how bin boundaries must align with verified data continuity
- Rolling Statistics for Mobility Metrics — windowed aggregations that depend on the uniform spacing this filter produces
- Handling GPS Drift in Raw Trajectory Logs — pre-filter cleaning that removes outlier pings before the Kalman step
- Speed and Acceleration Profiling — transport-mode velocity priors for tuning process noise
Q