Calculating Instantaneous vs Average Speed from GPS Traces

This page extends the speed profiling from raw GPS coordinates cluster by resolving a specific fork that trips up many fleet engineers: instantaneous speed and average speed are computed from the same coordinate stream but have fundamentally different noise profiles, aggregation semantics, and downstream uses. Instantaneous speed approximates the first derivative of position — useful for harsh-event detection and map-matching velocity constraints — while average speed is a macroscopic segment metric needed for route efficiency scoring and SLA compliance. Conflating them, or computing either without appropriate filtering, produces physically impossible values that corrupt driver scoring, ETAs, and the stop-detection boundary logic that depends on reliable low-speed regions.


The maths behind each metric

Instantaneous vs segment-aware average speed Two horizontal timelines share the same GPS trace. The upper timeline highlights individual consecutive point pairs used for instantaneous speed. The lower timeline highlights only the active (moving) segments used for average speed, with idle gaps greyed out. INSTANTANEOUS SPEED SEGMENT-AWARE AVERAGE SPEED d(Pᵢ, Pᵢ₊₁) Δtᵢ v(t) ≈ d / Δt idle / parked active segment (Σd / Σt) excluded (idle)

Instantaneous speed approximates the first derivative of position:

v(t) ≈ d(Pᵢ, Pᵢ₊₁) / Δtᵢ

where d is the geodesic (haversine) distance in metres and Δtᵢ is the time delta in seconds. Because consumer GPS positional error ranges from 2–15 m under open sky and can exceed 50 m in urban canyons, the raw derivative regularly produces physically impossible spikes. A 10 m positional jump in a 1-second window implies 36 km/h even for a stationary vehicle.

Segment-aware average speed is a macroscopic metric:

V_avg = Σ(dᵢ, active) / Σ(Δtᵢ, active)

It remains stable across noisy traces, but only when the sum excludes idle periods. Including parked intervals, traffic-light waits, or signal-loss gaps in the denominator depresses the result and breaks ETA prediction models. For a broader treatment of where these metrics slot into the full pipeline, see the trajectory analysis overview.


Compatibility and configuration requirements

Dependency Minimum version Notes
Python 3.10 match-statement guards and zoneinfo needed for timestamp handling
pandas 2.0 Copy-on-write semantics; dt.total_seconds() behaviour fixed
numpy 1.24 np.radians vectorisation stable
pyproj / geopy optional Only needed if switching from haversine to Vincenty ellipsoidal distances
Input CRS WGS84 (EPSG:4326) Mixed projections silently corrupt haversine results — validate upstream via CRS normalisation
Timestamp format timezone-aware datetime pandas.Timestamp with UTC tzinfo; naive timestamps cause .diff() to misfire across DST boundaries
Sampling rate 1–10 Hz Below 0.1 Hz, instantaneous speed becomes meaningless; above 10 Hz, apply a Kalman pre-filter from GPS noise reduction

Production-ready implementation

The class below is self-contained and copy-paste ready. It exposes both metrics through a single .compute() call, handles all edge cases (zero deltas, NaN gaps, timezone-aware timestamps), and returns an annotated DataFrame alongside a scalar average speed.

import numpy as np
import pandas as pd
from dataclasses import dataclass, field
from typing import Tuple


# ---------------------------------------------------------------------------
# Haversine kernel — vectorised, no external dependencies
# ---------------------------------------------------------------------------

def _haversine_m(
    lat1: np.ndarray,
    lon1: np.ndarray,
    lat2: np.ndarray,
    lon2: np.ndarray,
) -> np.ndarray:
    """
    Great-circle distance in metres between consecutive coordinate pairs.

    Uses the mean Earth radius of 6 371 000 m (±0.3 % error vs. WGS84
    ellipsoid — acceptable for speed derivation at fleet scale).
    """
    R = 6_371_000.0
    phi1, phi2 = np.radians(lat1), np.radians(lat2)
    dphi    = np.radians(lat2 - lat1)
    dlambda = np.radians(lon2 - lon1)

    a = (
        np.sin(dphi / 2) ** 2
        + np.cos(phi1) * np.cos(phi2) * np.sin(dlambda / 2) ** 2
    )
    return R * 2 * np.arctan2(np.sqrt(a), np.sqrt(1.0 - a))


# ---------------------------------------------------------------------------
# Tunable parameters — adjust per fleet type
# ---------------------------------------------------------------------------

@dataclass
class SpeedProfileConfig:
    smoothing_window: int = 3
    """
    Rolling-median window size (number of GPS samples).
    3 is the minimum that suppresses single-point spikes without
    introducing lag.  Raise to 5 for 10 Hz streams; keep at 3 for 1 Hz.
    """

    idle_threshold_ms: float = 2.5
    """
    Speed floor in m/s below which a sample is classified as idle/stationary.
    2.5 m/s ≈ 9 km/h.  Increase to 4.0 m/s for motorway-only fleets;
    decrease to 1.5 m/s for urban delivery or micro-mobility.
    """

    speed_cap_ms: float = 50.0
    """
    Hard kinematic cap in m/s (50 m/s = 180 km/h).  Samples above this
    after smoothing are clamped, not dropped, so segment continuity is
    preserved.  Adjust for specialist vehicles (e.g., 83 m/s for rail).
    """

    min_dt_s: float = 0.05
    """
    Minimum time delta in seconds before a sample is treated as a
    zero-duration duplicate and excluded.  0.05 s guards against
    sub-sample-accurate timestamps on some OBD-II loggers.
    """


# ---------------------------------------------------------------------------
# Main class
# ---------------------------------------------------------------------------

class GPSSpeedProfiler:
    """
    Derives instantaneous (smoothed) and segment-aware average speed
    from a GPS trace supplied as a pandas DataFrame.

    Expected input columns
    ----------------------
    timestamp : timezone-aware pandas Timestamp (UTC recommended)
    lat       : float, WGS84 latitude in decimal degrees
    lon       : float, WGS84 longitude in decimal degrees

    Output columns added to the DataFrame
    --------------------------------------
    dist_m          : haversine distance from previous point (metres)
    dt_s            : time delta from previous point (seconds)
    raw_speed_ms    : unsmoothed instantaneous speed (m/s)
    smoothed_speed_ms : rolling-median smoothed speed (m/s)
    smoothed_speed_kmh : smoothed speed in km/h (convenience column)
    is_active       : bool — True when smoothed speed > idle_threshold_ms
    """

    def __init__(self, config: SpeedProfileConfig | None = None) -> None:
        self.cfg = config or SpeedProfileConfig()

    def compute(self, df: pd.DataFrame) -> Tuple[pd.DataFrame, float]:
        """
        Parameters
        ----------
        df : DataFrame with columns [timestamp, lat, lon], one device.
             Multi-device traces must be split and processed per device.

        Returns
        -------
        df_out        : Annotated DataFrame (copy; original is not mutated)
        avg_speed_kmh : Segment-aware average speed in km/h (float)
        """
        df = df.sort_values("timestamp").copy()

        # --- Time deltas ---------------------------------------------------
        dt = df["timestamp"].diff().dt.total_seconds()
        # First row has no predecessor; also guard sub-threshold duplicates
        df["dt_s"] = np.where(dt < self.cfg.min_dt_s, np.nan, dt)

        # --- Haversine distances -------------------------------------------
        lats = df["lat"].to_numpy()
        lons = df["lon"].to_numpy()

        # Shift arrays to compare row i with row i-1
        dist = _haversine_m(lats[:-1], lons[:-1], lats[1:], lons[1:])
        df["dist_m"] = np.concatenate(([np.nan], dist))

        # --- Raw instantaneous speed (m/s) --------------------------------
        # NaN propagates where dt_s is NaN — intentional
        df["raw_speed_ms"] = df["dist_m"] / df["dt_s"]

        # --- Rolling-median smoothing -------------------------------------
        # centre=True distributes lag symmetrically; min_periods=1 avoids
        # NaN bleed at trace boundaries
        df["smoothed_speed_ms"] = (
            df["raw_speed_ms"]
            .rolling(window=self.cfg.smoothing_window, center=True, min_periods=1)
            .median()
        )

        # Hard kinematic cap — clamp, not drop
        df["smoothed_speed_ms"] = df["smoothed_speed_ms"].clip(
            lower=0.0, upper=self.cfg.speed_cap_ms
        )

        df["smoothed_speed_kmh"] = df["smoothed_speed_ms"] * 3.6

        # --- Segment classification ----------------------------------------
        df["is_active"] = df["smoothed_speed_ms"] > self.cfg.idle_threshold_ms

        # --- Segment-aware average speed ----------------------------------
        active = df[df["is_active"]]
        total_dist_m  = active["dist_m"].sum(min_count=1)
        total_time_s  = active["dt_s"].sum(min_count=1)

        if pd.isna(total_dist_m) or total_time_s == 0:
            avg_speed_kmh = 0.0
        else:
            avg_speed_kmh = (total_dist_m / total_time_s) * 3.6

        return df, avg_speed_kmh

Execution and tuning guidelines

Running the profiler:

import pandas as pd
from your_module import GPSSpeedProfiler, SpeedProfileConfig

# Load a single-device trace — timestamps must be tz-aware
df = pd.read_parquet("vehicle_42_trace.parquet")
df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True)

profiler = GPSSpeedProfiler()
annotated_df, avg_kmh = profiler.compute(df)

print(f"Segment-aware average speed: {avg_kmh:.1f} km/h")
print(annotated_df[["timestamp", "smoothed_speed_kmh", "is_active"]].head(10))

Key parameter knobs and their effects:

Parameter Default Effect of raising Effect of lowering
smoothing_window 3 Flatter speed curve; more lag; better for 10 Hz streams Preserves transients; susceptible to single-point multipath spikes
idle_threshold_ms 2.5 m/s Excludes more slow-speed driving (e.g. parking-lot approach) from average Includes near-stationary creep; pushes average speed down
speed_cap_ms 50 m/s Fewer samples clamped; outliers propagate further Risk of clamping legitimate peak speeds on faster vehicles
min_dt_s 0.05 s Treats more consecutive samples as duplicates; gaps in distance column Sub-0.05 s timestamps survive; noisy for OBD-II loggers with ms precision

Multi-device usage: Group the raw DataFrame by device_id and call .compute() on each group. Avoid processing cross-device traces in a single call — inter-device row boundaries produce bogus distances and delta times.

Timestamp pitfalls: If your telemetry pipeline uses mixed OBD-II and mobile device clocks, synchronise timestamps before calling the profiler. Clock offsets as small as 500 ms inflate instantaneous speed by 20–40 % at 1 Hz sampling.


Common pitfalls

Pitfall 1 — Including idle time in the average speed denominator

A naive total_distance / total_time over a full trace silently includes parked periods, red-light waits, and loading-dock dwell. On a typical last-mile delivery route, vehicles are stationary for 30–50 % of shift time. Without the is_active mask, the computed average speed is 40–60 % below the true driving average, making SLA targets look unachievable and depressing driver performance scores unfairly. Always filter to active segments before aggregating.

Pitfall 2 — Applying a smoothing filter after thresholding, not before

Filtering outliers from raw_speed_ms and then applying the rolling median reverses the correct order. Thresholding on raw values drops legitimate low-speed samples (e.g., a vehicle decelerating through 10 km/h before stopping). Smooth first; threshold second. The GPSSpeedProfiler.compute() method enforces this order.

Pitfall 3 — Using planar distance at fleet scale

A Euclidean sqrt((Δlat)² + (Δlon)²) distance formula works well for movements under 100 m in mid-latitudes, but at fleet scale — thousands of vehicles, routes spanning hundreds of kilometres — the compounding error becomes material. Near the poles or equator the error grows faster. The vectorised haversine kernel in this guide is fast enough for any practical fleet size; there is no reason to fall back to planar approximation. If sub-centimetre accuracy is required (rare in telematics), switch to a Vincenty ellipsoidal calculation via geopy.


Downstream application guide

Choosing between instantaneous and average speed depends entirely on what the consuming system needs:

Use case Metric Rationale
Harsh braking / acceleration alerts Instantaneous (smoothed) Captures transient kinematics; average would miss sub-second events
Map-matching velocity constraints Instantaneous (capped) Road-network speed limits need per-segment velocity; see HMM map matching
Route efficiency and SLA compliance Average (segment-aware) Normalises for traffic variance and idle time
Driver scoring and fuel optimisation Both Average drives consistency score; instantaneous peaks drive event score
Stop-detection boundary identification Instantaneous Transition from moving to stationary must be detected sample-by-sample
ETA model training features Average per segment Stable, low-variance feature; instantaneous is too noisy as a raw ML input