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:

  1. Compute forward azimuths — haversine-based bearing between each consecutive GPS pair, vectorized across the whole fleet.
  2. Smooth per vehicle — rolling median (window 5–7 pings) applied within each vehicle group to suppress multipath jitter while preserving sharp directional transitions.
  3. 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.
  4. 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.

U-turn detection pipeline stages Four boxes labelled Compute Bearings, Smooth (rolling median), Accumulate + Constrain, and Flag U-turn connected left to right by arrows. Two dashed arrows labelled Spatial reset and Temporal reset flow from the third box back to the start of accumulation. Compute Bearings Smooth (rolling median) Accumulate + Constrain (spatial / temporal) Flag U-turn Spatial / temporal reset

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.

PYTHON
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:

PYTHON
# 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. The detect_uturns guard raises ValueError if 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 iterrows for 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_group is 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 window to the nominal inter-ping interval, or convert to a time-based rolling window using pd.DataFrame.rolling(window="15s").
  • Not resetting after a successful flag. Failing to reset start_idx and cum_angle after 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

Back to Directionality & Turn Analysis