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

  1. Compute forward azimuths: Calculate the bearing between each consecutive GPS pair using vectorized trigonometry.
  2. Normalize & smooth: Wrap bearings to [-180°, 180°] and apply a rolling median (window=5–7) to suppress multipath noise.
  3. Calculate delta bearings: Compute signed differences between consecutive smoothed bearings, explicitly handling the ±180° wrap-around to avoid artificial spikes.
  4. 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.
  5. 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.

PYTHON
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_thresh to <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.