Directionality & Turn Analysis: Quantifying Heading Changes in Movement Trajectories

Directionality & Turn Analysis is the process of computing forward bearings, angular velocity, and turn severity categories from a time-ordered sequence of GPS coordinates — transforming raw position records into a structured representation of navigational intent. Without this layer, movement pipelines can tell you where an entity was but not how it navigated there: intersection compliance, U-turn frequency, and routing inefficiency remain invisible. This topic sits within Movement Pattern Extraction & Trajectory Analysis and feeds directly into fleet safety scoring, driver behavior modeling, and anomaly detection workflows.

Prerequisites Checklist

Complete these upstream stages before computing any directional metrics:

  • Python 3.10+pandas 2.x, numpy 1.26+, geopandas 0.14+, scipy 1.12+, pyproj 3.6+
  • Input schema: each record must carry track_id (trajectory identifier), ts (timezone-aware datetime), lat and lon (WGS84 decimal degrees). Optional: speed_kmh, heading_sensor (on-board compass reading)
  • Upstream stage: GPS precision & error handling must be complete — multipath-flagged points and positional outliers removed — before bearings are computed, because a single erroneous coordinate produces two false turns
  • Coordinate reference system: bearing computations use geographic coordinates (EPSG:4326) with spherical trigonometry. Distance and speed calculations must use a projected metric CRS — see coordinate reference system mapping for projection selection
  • Sampling regularity: resample trajectories to a uniform interval using time-series synchronization strategies before computing headings; irregular gaps amplify noise into false turn events

Failure-Mode Taxonomy

Failure source Mechanism Typical impact Mitigation
GPS multipath Reflected signals shift position by 5–50 m, creating phantom bearing changes Spurious sharp turns near buildings Remove points flagged by HDOP > 2.5 or speed > physical limit
Stationary GPS drift Satellite constellation shifts move a parked device 1–10 m, generating random azimuths Inflated turn counts at stops Mask dwell periods with stay-point detection before bearing computation
Wrap-around discontinuity Raw difference across 0°/360° boundary produces ±360° spikes Misclassified sharp turns Normalize differences to [−180, 180]
Over-sampling 10 Hz data captures steering micro-corrections Turn count inflated 3–8× vs. 1 Hz Downsample or apply Savitzky-Golay smoothing
Meridian crossing Planar bearing formula fails at ±180° Wrong azimuths for trans-Pacific routes Use pyproj.Geod.inv() for geodesic forward azimuth
Temporal misalignment Merged GPS and CAN-bus streams have unsynchronized clocks Angular velocity miscalculated by clock-offset factor Align with monotonic interpolation before merge
Fixed threshold generalization Urban car thresholds misclassify pedestrian pivots as U-turns High false-positive rate Calibrate per transport mode (see Calibration section)

Deterministic Pipeline Overview

The five stages below must run in order. Skipping or reordering stages amplifies GPS noise into classification errors.

Directionality & Turn Analysis pipeline Five sequential stages: Segment & Sort, Filter Stops, Compute Bearings, Derive Angular Velocity, Classify Turns connected by arrows 1. Segment & Sort 2. Filter Stops 3. Compute Bearings 4. Angular Velocity 5. Classify Turns

Stage 1 — Segment & Sort: Group records by track_id, sort by ts ascending. Remove duplicate timestamps (keep first) and runs of spatially identical consecutive points. Flag time gaps larger than twice the median interval as segment boundaries; split there rather than compute bearings across a gap.

Stage 2 — Filter Stops: Apply stay-point detection to identify dwell intervals. Mark or drop all points inside identified stay zones before computing headings. This is the single most impactful noise-reduction step: unfiltered stops can contribute 20–40% of all apparent sharp turns.

Stage 3 — Compute Bearings: Apply the spherical forward-azimuth formula (arctan2) to consecutive coordinate pairs. Output is a bearing column in [0, 360) for each point. See the implementation walkthrough for the correct argument ordering.

Stage 4 — Derive Angular Velocity: Compute wrapped heading differences normalized to [−180, 180], then divide by the time delta in seconds. Mask rows where dt ≤ 0. Optionally smooth with a Savitzky-Golay filter (window 5, polynomial 2) before thresholding.

Stage 5 — Classify Turns: Map angular velocity values to categorical turn types using mode-specific threshold tables. See the Calibration section for validated bins by transport mode.

Implementation Walkthrough

The function below handles the complete stages 3–5 in a single vectorized pass. It is schema-validated, defensive against NaN propagation, and handles the 0°/360° wrap-around correctly.

PYTHON
import numpy as np
import pandas as pd
from scipy.signal import savgol_filter


REQUIRED_COLS = {"track_id", "ts", "lat", "lon"}
TURN_THRESHOLDS = {
    "vehicle":    {"minor": 0.5, "standard": 3.0, "sharp": 8.0},
    "pedestrian": {"minor": 1.0, "standard": 5.0, "sharp": 15.0},
    "cycling":    {"minor": 0.8, "standard": 4.0, "sharp": 12.0},
}


def compute_turn_metrics(
    df: pd.DataFrame,
    mode: str = "vehicle",
    smooth: bool = True,
    sg_window: int = 5,
    sg_poly: int = 2,
) -> pd.DataFrame:
    """
    Compute bearing, heading difference, angular velocity, and turn class.

    Parameters
    ----------
    df : pd.DataFrame
        Must contain 'track_id', 'ts' (datetime64[ns, tz]), 'lat', 'lon'.
        Must already be sorted chronologically per track and stops filtered.
    mode : str
        Transport mode key — one of 'vehicle', 'pedestrian', 'cycling'.
    smooth : bool
        Apply Savitzky-Golay smoothing to angular velocity before classifying.
    sg_window, sg_poly : int
        Savitzky-Golay window length (odd) and polynomial order.

    Returns
    -------
    pd.DataFrame with added columns: bearing, heading_diff, dt, angular_velocity,
        angular_velocity_smooth (if smooth=True), turn_class.

    Raises
    ------
    ValueError  if required columns are missing or mode is unknown.
    """
    missing = REQUIRED_COLS - set(df.columns)
    if missing:
        raise ValueError(f"Missing required columns: {missing}")
    if mode not in TURN_THRESHOLDS:
        raise ValueError(f"Unknown mode '{mode}'. Choose from {list(TURN_THRESHOLDS)}")
    if df.empty or len(df) < 2:
        # Return with NaN columns — caller decides whether to skip or raise
        for col in ["bearing", "heading_diff", "dt", "angular_velocity", "turn_class"]:
            df = df.copy()
            df[col] = np.nan
        return df

    thresholds = TURN_THRESHOLDS[mode]
    df = df.copy()

    # Convert to radians for spherical formula
    lat1 = np.radians(df["lat"].shift(1))
    lon1 = np.radians(df["lon"].shift(1))
    lat2 = np.radians(df["lat"])
    lon2 = np.radians(df["lon"])
    d_lon = lon2 - lon1

    # Spherical forward-azimuth: arctan2(y, x)
    # y = sin(Δlon) * cos(lat2)
    # x = cos(lat1)*sin(lat2) − sin(lat1)*cos(lat2)*cos(Δlon)
    # IMPORTANT: argument order is arctan2(y, x) — swapping gives bearings offset by 90°
    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)
    df["bearing"] = np.degrees(np.arctan2(y, x)) % 360.0

    # Wrap heading difference to [−180, 180] to avoid 360° spike artefacts
    raw_diff = df["bearing"] - df["bearing"].shift(1)
    df["heading_diff"] = (raw_diff + 180.0) % 360.0 - 180.0

    # Time delta in seconds; mask zero/negative intervals (duplicates or out-of-order)
    df["dt"] = df["ts"].diff().dt.total_seconds()
    valid_dt = df["dt"] > 0

    df["angular_velocity"] = np.nan
    df.loc[valid_dt, "angular_velocity"] = (
        df.loc[valid_dt, "heading_diff"].abs() / df.loc[valid_dt, "dt"]
    )

    # Optional Savitzky-Golay smoothing on angular velocity
    av_col = "angular_velocity"
    if smooth and df["angular_velocity"].notna().sum() >= sg_window:
        filled = df["angular_velocity"].fillna(0.0).to_numpy()
        smoothed = savgol_filter(filled, window_length=sg_window, polyorder=sg_poly)
        df["angular_velocity_smooth"] = smoothed
        df.loc[~valid_dt, "angular_velocity_smooth"] = np.nan
        av_col = "angular_velocity_smooth"

    # Turn severity classification
    av = df[av_col]
    conditions = [
        av.between(thresholds["minor"], thresholds["standard"], inclusive="left"),
        av.between(thresholds["standard"], thresholds["sharp"], inclusive="left"),
        av >= thresholds["sharp"],
    ]
    choices = ["minor", "standard", "sharp"]
    df["turn_class"] = np.select(conditions, choices, default="none")
    # Rows without a valid dt remain unclassified
    df.loc[~valid_dt, "turn_class"] = np.nan

    return df

Key reliability points:

  • arctan2(y, x) argument order is critical. The formula above assigns y = sin(Δlon)·cos(lat2) and x = cos(lat1)·sin(lat2) − sin(lat1)·cos(lat2)·cos(Δlon). Swapping produces bearings offset by exactly 90°, which passes all unit tests unless you explicitly validate against a known bearing.
  • Wrap normalization prevents 360° spikes. The (diff + 180) % 360 − 180 formulation correctly handles transitions across 0°/360° — without it, a vehicle heading from 5° to 355° registers a −350° change instead of the correct −10°.
  • Never compute bearing across a trajectory segment boundary. If you split at large time gaps (Stage 1), ensure shift(1) does not reach across the split. Group-level application (.groupby("track_id").apply(...)) enforces this automatically.
  • Smoothing angular velocity, not raw bearings. Smoothing the bearing column introduces wrap-around discontinuities at 0°/360°. The Savitzky-Golay filter belongs on the angular velocity (or heading difference) signal, which is already bounded and continuous.

Geometric & Mathematical Grounding

The forward azimuth from point A to point B on a sphere is:

TEXT
θ = atan2(sin(Δλ)·cos(φ₂),  cos(φ₁)·sin(φ₂) − sin(φ₁)·cos(φ₂)·cos(Δλ))

where φ is latitude in radians and λ is longitude in radians. The result is in (−π, π]; adding 360 mod 360 maps it to [0°, 360°).

Angular velocity is the instantaneous rate of heading change:

TEXT
ω = |Δθ_wrapped| / Δt   (degrees per second)

where Δθ_wrapped is the heading difference normalized to [−180, 180] and Δt is the elapsed time in seconds. High angular velocity at low linear speed is a hallmark of tight parking manoeuvres or pedestrian pivots; the same angular velocity at highway speed indicates a lane-change or hazard avoidance event. Separating these cases requires pairing turn metrics with speed & acceleration profiling.

For distances greater than ~500 km, or for datasets crossing the ±180° meridian, replace the spherical formula with pyproj.Geod(ellps="WGS84").inv(lon1, lat1, lon2, lat2) which returns the geodesic forward azimuth on the WGS84 ellipsoid.

Calibration & Parameter Tuning

Turn severity thresholds are not universal. The table below lists validated starting points by transport mode; recalibrate using the 90th-percentile angular velocity distribution from your own dataset before deploying to production.

Transport mode Minor turn (°/s) Standard turn (°/s) Sharp / U-turn (°/s) Notes
Commercial vehicle 0.5 – 3.0 3.0 – 8.0 ≥ 8.0 Calibrated at 1 Hz; high-sided vehicles show lower peak ω for same road geometry
Passenger car 0.5 – 4.0 4.0 – 10.0 ≥ 10.0 Standard urban driving; roundabouts typically fall at 5–7 °/s
Cycling 0.8 – 4.5 4.5 – 12.0 ≥ 12.0 Cyclist pivots in bike racks read as sharp; filter by speed < 3 km/h first
Pedestrian 1.0 – 6.0 6.0 – 18.0 ≥ 18.0 Turning while stationary is common; always apply a minimum-displacement filter
Wildlife (large mammal) 0.3 – 2.0 2.0 – 6.0 ≥ 6.0 GPS fix rate typically 0.017–0.1 Hz; thresholds scale inversely with fix rate

Sampling-rate corrections: thresholds derived at 1 Hz do not transfer directly to 5 Hz data. At higher rates, multiply threshold values by the ratio of new_rate / reference_rate (e.g., 1 Hz thresholds × 5 for 5 Hz data) as a first approximation, then validate against labelled manoeuvre samples.

Segment-length minimum: classification is unreliable on segments shorter than three valid bearing intervals. Drop or flag turn events from short segments (fewer than 5 points after stop filtering) and exclude them from aggregate statistics.

Integration & Compatibility

Upstream dependencies:

Downstream consumers:

  • Speed & acceleration profiling — combine angular_velocity with speed_ms to build composite manoeuvre risk scores; a sharp turn at 60 km/h is categorically different from the same heading change at 5 km/h
  • Detecting U-turns specifically — see Detecting U-turns and directional shifts in fleet data for threshold calibration and false-positive suppression strategies tuned to commercial vehicle dynamics
  • Hidden Markov Model map matching — turn class sequences are strong evidence features for HMM state transitions between road segments
  • Driver behavior scoring — aggregate turn_class counts per trip form the directional component of composite safety indices

Output schema from compute_turn_metrics: the returned DataFrame adds bearing (°, [0, 360)), heading_diff (°, [−180, 180]), dt (seconds), angular_velocity (°/s), optionally angular_velocity_smooth (°/s), and turn_class (none | minor | standard | sharp | NaN). This schema is consumed directly by downstream aggregation and scoring stages.

FAQ

Why do I see 360° heading spikes even after filtering stops? These are wrap-around artefacts from computing raw differences across the 0°/360° boundary. Normalize heading differences to [−180, 180] using (diff + 180) % 360 − 180 before any further processing. The raw bearing_b − bearing_a approach is the most common implementation mistake in this domain.

Can I compute bearings directly from EPSG:4326 coordinates? Yes — the spherical arctan2 formula operates correctly on geographic lat/lon in radians. However, distance-based metrics (speed, displacement) must use a projected CRS or a haversine/geodesic formula. Never use planar Euclidean distance on raw WGS84 data — see coordinate reference system mapping for projection guidance.

What sampling rate gives the cleanest turn classification? 1–2 Hz is optimal for vehicle-level manoeuvre classification. Above 5 Hz, steering micro-corrections dominate and inflate turn counts by 3–8×. Below 0.2 Hz (typical wildlife GPS collars), sharp turns become aliased into apparent straight segments. Match your threshold table to your actual fix rate.

How do I handle trajectories that cross the ±180° meridian? Use pyproj.Geod(ellps="WGS84").inv(lon1, lat1, lon2, lat2) for geodesic forward azimuth. The standard spherical arctan2 formula produces incorrect bearings for trans-meridian segments because the longitude difference wraps sign unexpectedly.

Should I smooth bearings before or after classification? Smooth angular velocity — not raw bearings — using a Savitzky-Golay filter before thresholding. Smoothing raw bearings introduces discontinuities at the 0°/360° boundary; smoothing the velocity signal (which is already bounded and continuous) avoids this entirely. Use sg_window=5, sg_poly=2 as a starting point and increase the window if noise remains dominant.


Related:

Back to Movement Pattern Extraction & Trajectory Analysis