Detecting U-turns and directional shifts in fleet data
Detecting U-turns and directional shifts in fleet data requires computing sequential heading changes between GPS pings, applying spatial-temporal filters to isolate true maneuvers from positional noise, and enforcing geometric thresholds. The standard pipeline calculates forward azimuths between consecutive trajectory points, smooths the bearing series to suppress GPS jitter, and flags segments where the cumulative angular change exceeds ~150° while the vehicle remains within a constrained displacement radius (typically <50 m) and short time window (<45 s). This approach scales efficiently across high-frequency telematics streams and integrates directly into automated movement analytics pipelines.
Core Geometry & Threshold Design
Fleet telematics is inherently noisy. Raw coordinate deltas rarely produce clean 180° reversals due to multipath errors, urban canyon signal degradation, and variable sampling intervals (1–30 s). Reliable detection decouples heading from raw coordinate differences using three calibrated parameters:
- Angular threshold: 150°–170° cumulative bearing change
- Spatial constraint: Maximum displacement radius (e.g., <50 m) to exclude wide arcs, roundabouts, or multi-lane merges
- Temporal window: Maximum duration (e.g., <45 s) to filter parking maneuvers, slow-speed navigation, or idle periods
Bearing calculations must account for spherical geometry. Using a haversine-based forward azimuth formula ensures accuracy across latitudes. Once bearings are computed, a rolling median or Savitzky-Golay filter removes high-frequency jitter. The directional shift is detected by evaluating the signed difference between consecutive smoothed bearings, normalized to [-180°, 180°]. A U-turn is confirmed when the cumulative absolute change crosses the angular threshold while the vehicle stays within the spatial and temporal bounds. For broader context on trajectory segmentation and feature engineering, see Movement Pattern Extraction & Trajectory Analysis and Directionality & Turn Analysis.
Step-by-Step Detection Logic
- Compute forward azimuths: Calculate the bearing between each consecutive GPS pair using vectorized trigonometry.
- Normalize & smooth: Wrap bearings to [-180°, 180°] and apply a rolling median (window=5–7) to suppress multipath noise.
- Calculate delta bearings: Compute signed differences between consecutive smoothed bearings, explicitly handling the ±180° wrap-around to avoid artificial spikes.
- Accumulate & constrain: Track cumulative absolute change per vehicle. Reset the accumulator if the vehicle exceeds the spatial radius or time window relative to the maneuver’s start point.
- Flag & validate: Mark segments where cumulative change ≥ threshold and spatial/temporal constraints are simultaneously satisfied.
Production-Ready Python Implementation
The following implementation uses geopandas and numpy for vectorized operations. It computes bearings, applies a rolling median, handles angular wrap-around, and evaluates spatial-temporal constraints per vehicle.
Prerequisite: Project your data to a local metric CRS (e.g., UTM) before execution so .distance() returns meters. See the GeoPandas projections guide for CRS transformation workflows.
import numpy as np
import pandas as pd
import geopandas as gpd
def compute_bearing(lat1, lon1, lat2, lon2):
"""Vectorized forward azimuth calculation in degrees [0, 360)."""
lat1, lon1, lat2, lon2 = map(np.radians, [lat1, lon1, lat2, lon2])
d_lon = lon2 - lon1
y = np.sin(d_lon) * np.cos(lat2)
x = np.cos(lat1) * np.sin(lat2) - np.sin(lat1) * np.cos(lat2) * np.cos(d_lon)
return np.degrees(np.arctan2(y, x)) % 360.0
def _detect_uturns_group(group, angular_thresh, dist_thresh, time_thresh):
"""State-machine evaluator per vehicle trajectory."""
is_uturn = np.zeros(len(group), dtype=bool)
if len(group) < 3:
return is_uturn
timestamps = group['timestamp'].values
cum_angle = 0.0
start_idx = 0
for i in range(1, len(group)):
dt_sec = (timestamps[i] - timestamps[start_idx]) / np.timedelta64(1, 's')
dist_m = group.geometry.iloc[i].distance(group.geometry.iloc[start_idx])
# Reset if spatial or temporal bounds are breached
if dt_sec > time_thresh or dist_m > dist_thresh:
cum_angle = 0.0
start_idx = i
continue
# Accumulate smoothed angular change
cum_angle += abs(group['delta_bearing'].iloc[i])
if cum_angle >= angular_thresh:
is_uturn[start_idx:i+1] = True
cum_angle = 0.0
start_idx = i + 1
return is_uturn
def detect_uturns(gdf, angular_thresh=150.0, dist_thresh=40.0, time_thresh=45.0, window=5):
"""
Detect U-turns in a fleet trajectory GeoDataFrame.
Expects: 'geometry' (projected), 'timestamp' (datetime), 'vehicle_id'
Returns: GDF with appended 'is_uturn' boolean column.
"""
gdf = gdf.sort_values(['vehicle_id', 'timestamp']).copy()
# Extract coordinates for vectorized bearing calc
lons = gdf.geometry.x.values
lats = gdf.geometry.y.values
bearings = compute_bearing(lats[:-1], lons[:-1], lats[1:], lons[1:])
gdf['bearing'] = np.append(bearings, np.nan)
# Smooth bearings
gdf['smooth_bearing'] = gdf.groupby('vehicle_id')['bearing'].transform(
lambda x: x.rolling(window, min_periods=1, center=True).median()
)
# Compute signed delta with ±180° wrap handling
prev = gdf.groupby('vehicle_id')['smooth_bearing'].shift(1)
delta = gdf['smooth_bearing'] - prev
delta = (delta + 180) % 360 - 180 # Normalize to [-180, 180]
gdf['delta_bearing'] = delta
# Apply state-machine detection per vehicle
gdf['is_uturn'] = gdf.groupby('vehicle_id').apply(
lambda grp: _detect_uturns_group(grp, angular_thresh, dist_thresh, time_thresh)
).explode().reset_index(drop=True)
return gdf
The numpy.arctan2 function provides numerically stable quadrant resolution, while the rolling median preserves sharp directional changes better than linear smoothing. For production deployments, consider replacing the groupby.apply loop with a Numba-compiled JIT function to achieve sub-second latency on million-row trajectories.
Validation & Edge Cases
- Urban canyon drift: GPS multipath can artificially inflate bearing variance. Pair heading detection with HDOP/VDOP filters or require ≥3 consecutive pings before triggering a flag.
- Roundabouts vs. U-turns: Roundabouts often exceed 180° cumulative change but violate the spatial constraint. Tightening
dist_threshto <35 m typically separates true reversals from circular intersections. - Variable sampling rates: Telematics devices often drop pings during tunneling or switch to 10–30 s intervals. Interpolate missing bearings linearly or skip accumulation when
dt > 2×the nominal sampling rate. - Idle filtering: Vehicles idling in traffic may exhibit micro-heading jitter. Apply a minimum speed threshold (e.g., >5 km/h) before evaluating angular accumulation.
Pipeline Integration & Scaling
This detection logic is stateless per trajectory segment, making it ideal for batch ETL or streaming architectures. In Apache Spark or Dask, partition by vehicle_id and time windows to parallelize the bearing computation and rolling median. For real-time telemetry, maintain a sliding buffer of the last 15–30 pings per vehicle in Redis or Kafka Streams, applying the same spatial-temporal accumulator logic on ingestion.
When integrated into broader Directionality & Turn Analysis workflows, U-turn flags feed directly into routing compliance dashboards, driver safety scoring, and last-mile delivery optimization models.