Detecting U-turns and directional shifts in fleet data
Computing sequential heading changes between GPS pings, applying spatial-temporal filters to isolate true maneuvers from positional noise, and enforcing geometric thresholds are the three pillars of reliable U-turn detection. The pipeline calculates forward azimuths between consecutive trajectory points, smooths the bearing series to suppress GPS jitter, then flags segments where the cumulative angular change exceeds roughly 150° while the vehicle remains within a constrained displacement radius (typically under 50 m) and a short time window (under 45 s). This approach scales efficiently across high-frequency telematics streams and integrates directly into movement pattern extraction pipelines.
Why this happens
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. GPS precision and error handling problems propagate directly into bearing calculations: a 5 m position error at a 10 m inter-ping distance creates a 30° phantom bearing shift. Without smoothing and spatial constraints, any naïve threshold triggers constantly on urban road segments where signal bounce is chronic.
The root cause of missed or false detections almost always falls into one of three buckets: using raw WGS84 distances for spatial constraints (degrees are not meters), computing bearing deltas without wrapping to [-180°, 180°] (producing artificial 360° spikes at the north-south boundary), or applying a fixed time window to data with variable sampling rate (a device dropping to 30 s intervals stores far less heading information per maneuver than one pinging every second).
Detection pipeline overview
The full detection runs in four deterministic stages:
- Compute forward azimuths — haversine-based bearing between each consecutive GPS pair, vectorized across the whole fleet.
- Smooth per vehicle — rolling median (window 5–7 pings) applied within each vehicle group to suppress multipath jitter while preserving sharp directional transitions.
- Accumulate with constraints — state machine per vehicle tracks cumulative absolute bearing change, resetting whenever the vehicle exceeds the spatial radius or time window from the maneuver’s start point.
- Flag and validate — mark the point range as a U-turn when cumulative change meets the angular threshold with all spatial-temporal constraints simultaneously satisfied.
The diagram below shows how each stage feeds the next, with the two constraint resets that prevent false positives from slow turns and long arcs.
Production-ready Python implementation
The following implementation uses geopandas and numpy. Project your data to a local metric CRS (e.g. UTM) before calling detect_uturns so that .distance() returns metres. CRS transformation best practices covers the full pyproj/geopandas projection workflow.
import numpy as np
import pandas as pd
import geopandas as gpd
from typing import Optional
def compute_bearing(
lat1: np.ndarray,
lon1: np.ndarray,
lat2: np.ndarray,
lon2: np.ndarray,
) -> np.ndarray:
"""
Vectorized haversine-based forward azimuth in degrees [0, 360).
Parameters
----------
lat1, lon1 : array-like Origin point latitudes / longitudes in decimal degrees.
lat2, lon2 : array-like Destination point latitudes / longitudes.
Returns
-------
np.ndarray Bearing array in [0, 360). NaN where inputs are NaN.
"""
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: gpd.GeoDataFrame,
angular_thresh: float,
dist_thresh: float,
time_thresh: float,
) -> np.ndarray:
"""
State-machine U-turn evaluator for a single vehicle trajectory.
Expects `group` to be sorted by timestamp and to have columns:
- geometry (projected, metres)
- timestamp (datetime64)
- delta_bearing (float, degrees, normalised to [-180, 180])
Returns a boolean array of length len(group).
"""
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 accumulator when spatial or temporal bounds are exceeded.
# This prevents slow wide arcs and parking manoeuvres from triggering.
if dt_sec > time_thresh or dist_m > dist_thresh:
cum_angle = 0.0
start_idx = i
continue
delta = group["delta_bearing"].iloc[i]
if np.isnan(delta):
continue
cum_angle += abs(delta)
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: gpd.GeoDataFrame,
angular_thresh: float = 150.0,
dist_thresh: float = 40.0,
time_thresh: float = 45.0,
window: int = 5,
vehicle_col: str = "vehicle_id",
time_col: str = "timestamp",
) -> gpd.GeoDataFrame:
"""
Detect U-turns in a fleet trajectory GeoDataFrame.
Parameters
----------
gdf : GeoDataFrame with projected geometry (metres), datetime timestamps,
and a vehicle identifier column.
angular_thresh: Cumulative bearing change (degrees) required to flag a U-turn.
dist_thresh : Maximum displacement (metres) from manoeuvre start to end.
time_thresh : Maximum duration (seconds) of the manoeuvre window.
window : Rolling-median window size for bearing smoothing (pings).
vehicle_col : Column name for vehicle grouping.
time_col : Column name for the timestamp series.
Returns
-------
GeoDataFrame with an appended boolean column `is_uturn`.
Raises
------
ValueError If required columns are missing or CRS is geographic (not projected).
"""
required = {"geometry", vehicle_col, time_col}
missing = required - set(gdf.columns)
if missing:
raise ValueError(f"Missing required columns: {missing}")
if gdf.crs is not None and gdf.crs.is_geographic:
raise ValueError(
"GeoDataFrame must use a projected CRS (metres). "
"Re-project with gdf.to_crs(epsg=<utm_zone>) before calling detect_uturns."
)
gdf = gdf.rename(columns={time_col: "timestamp", vehicle_col: "vehicle_id"})
gdf = gdf.sort_values(["vehicle_id", "timestamp"]).copy()
# Extract WGS84 coords for bearing calc (geometry may be projected)
wgs84 = gdf.to_crs(epsg=4326)
lons = wgs84.geometry.x.values
lats = wgs84.geometry.y.values
bearings = compute_bearing(lats[:-1], lons[:-1], lats[1:], lons[1:])
gdf["bearing"] = np.append(bearings, np.nan)
# Group-wise rolling median suppresses per-ping multipath spikes
gdf["smooth_bearing"] = gdf.groupby("vehicle_id")["bearing"].transform(
lambda x: x.rolling(window, min_periods=1, center=True).median()
)
# Signed delta normalised to [-180, 180] — critical to avoid wrap-around artefacts
prev = gdf.groupby("vehicle_id")["smooth_bearing"].shift(1)
delta = gdf["smooth_bearing"] - prev
gdf["delta_bearing"] = (delta + 180) % 360 - 180
# Per-vehicle state machine
results = (
gdf.groupby("vehicle_id", group_keys=False)
.apply(lambda grp: pd.Series(
_detect_uturns_group(grp, angular_thresh, dist_thresh, time_thresh),
index=grp.index,
))
)
gdf["is_uturn"] = results
return gdf.rename(columns={"timestamp": time_col, "vehicle_id": vehicle_col})
The numpy.arctan2 function provides numerically stable quadrant resolution. The rolling median preserves sharp directional transitions better than a rolling mean because it is not pulled by outlier pings. For production deployments on million-row trajectory frames, consider replacing the Python-level for loop in _detect_uturns_group with a Numba-compiled JIT function to achieve sub-second latency per vehicle.
Validation block
After running detect_uturns, verify the output before feeding it into downstream scoring or routing checks:
# Shape check — no rows should be dropped
assert len(result) == len(gdf), "Row count changed — check groupby/explode alignment."
# At least one U-turn found (sanity check for realistic fleet data)
uturn_count = result["is_uturn"].sum()
assert uturn_count > 0, "No U-turns detected — verify CRS projection and threshold values."
# Spatial constraint sanity: flagged segments should cluster near road centrelines
flagged = result[result["is_uturn"]]
print(f"U-turns flagged: {uturn_count} pings across {flagged['vehicle_id'].nunique()} vehicles")
# Bearing column integrity: NaN only at the last ping per vehicle
nan_bearings = result["bearing"].isna().sum()
expected_nans = result["vehicle_id"].nunique()
assert nan_bearings == expected_nans, (
f"Unexpected NaN bearings: {nan_bearings} (expected {expected_nans})"
)
For spatial QA, export flagged points with result[result["is_uturn"]].to_file("uturn_flags.gpkg") and inspect in QGIS against a base map. True U-turns on arterials look like tight reversal clusters; false positives typically appear in parking lots or motorway merges where the spatial constraint should be tightened.
Common mistakes and gotchas
- Computing distances in
EPSG:4326. Degree-based distances are meaningless for a 40 m spatial threshold — re-project to a local UTM zone or a national metric CRS before calling the function. Thedetect_uturnsguard raisesValueErrorif it detects a geographic CRS. - Skipping the [-180°, 180°] wrap. Without normalisation, a bearing transition from 359° to 1° produces a delta of −358° rather than +2°, which immediately overflows any reasonable angular threshold and flags the entire trajectory.
- Using
iterrowsfor the accumulation loop. Row-by-row Python iteration over large GeoDataFrames is 50–200× slower than vectorized operations. Even the state-machine loop in_detect_uturns_groupis limited to the per-vehicle slice; the outer groupby is vectorized. - Ignoring idle periods. Vehicles stopped in traffic can exhibit micro-heading jitter of 20–40° per ping from GPS noise alone. Apply a minimum speed threshold (>5 km/h from instantaneous speed calculations) before accumulating angular change.
- Fixed window on variable-rate data. A 5-ping rolling median on 1 Hz data smooths over 5 s; on 10 s data, it covers 50 s and erases legitimate short manoeuvres. Scale
windowto the nominal inter-ping interval, or convert to a time-based rolling window usingpd.DataFrame.rolling(window="15s"). - Not resetting after a successful flag. Failing to reset
start_idxandcum_angleafter marking a U-turn causes the accumulator to spill into the next manoeuvre, producing spurious chained flags downstream.
Parameter tuning by transport mode
Different vehicle types and operating environments require different threshold calibration. The table below gives starting values validated against labelled telematics datasets:
| Mode | angular_thresh |
dist_thresh (m) |
time_thresh (s) |
Notes |
|---|---|---|---|---|
| Urban delivery van | 150° | 40 | 45 | Tight streets; frequent loading-bay reversals |
| Long-haul truck | 160° | 60 | 60 | Wide turning radius; needs looser spatial bound |
| Motorcycle / courier | 145° | 30 | 30 | Agile; U-turns complete faster and in less space |
| Rural route bus | 165° | 80 | 90 | Terminus loops can span up to 70 m |
| Passenger car (suburban) | 155° | 45 | 45 | Baseline; roundabouts filtered at this dist_thresh |
Run a labelled sample of 500–1000 known manoeuvres through the detector and plot precision/recall curves across a grid of angular_thresh × dist_thresh values to find the Pareto-optimal setting for your specific fleet and road network.
Frequently asked questions
What angular threshold reliably separates U-turns from sharp turns? 150°–170° cumulative bearing change catches near-reversals while excluding standard 90° turns and roundabout arcs. Values below 140° produce too many false positives on curved roads; values above 170° miss gradual U-turn arcs common on wide arterials.
How do I distinguish a U-turn from a roundabout traversal?
Roundabouts often exceed 180° cumulative bearing change but violate the spatial displacement constraint. Setting dist_thresh to 35 m or less separates true reversals from circular intersections in urban environments.
How should I handle variable telematics sampling rates? Skip angular accumulation when the inter-ping interval exceeds twice the nominal sampling rate. This prevents tunneling gaps or device sleep intervals from creating artificial bearing jumps that overflow the threshold.
Can this approach work in real-time streaming pipelines? Yes. The per-vehicle state machine is stateless across trajectory segments. Maintain a sliding buffer of 15–30 pings per vehicle in Kafka Streams or Redis, applying the same spatial-temporal accumulator logic on ingestion. For time-series synchronization across multi-sensor telematics feeds, align timestamps before feeding the buffer.
Why not just compare first and last bearing in a window? A first-to-last comparison misses the path shape: a vehicle could drive a large arc and return to a similar heading without executing a U-turn. Cumulative accumulation over the intermediate pings captures the actual trajectory geometry.
Related
- Directionality & Turn Analysis — parent cluster covering bearing computation, turn classification, and heading-based segmentation
- Calculating instantaneous speed from discrete GPS points — speed thresholds used to gate idle-period filtering
- Handling GPS drift in raw trajectory logs — upstream noise removal that improves bearing quality
- Implementing DBSCAN for stay-point clustering — complementary technique for identifying stops near U-turn locations
- Downsampling high-frequency GPS tracks — how sampling rate choices affect angular accumulation fidelity
Back to Directionality & Turn Analysis