Calculating Instantaneous Speed from Discrete GPS Points
To compute instantaneous speed from discrete GPS fixes, calculate the geodesic distance between consecutive coordinate pairs, divide by the elapsed seconds between their UTC timestamps, and apply noise masking to suppress multipath spikes. Because a GNSS receiver samples a moving object at discrete moments rather than continuously, the result is a finite-difference approximation — the mean speed across the sampling interval — not a true limit-based derivative. In practice this distinction matters only when analysing rapid events such as emergency braking; for logistics, fleet scoring, and urban mobility analytics the approximation is sufficient once the pipeline handles irregular sampling, timezone hazards, and GPS drift correctly.
Why this calculation is error-prone
Speed is a first derivative of position. Any error in the input positions is amplified when you divide a small noisy distance by a small time interval, so the quality of the output is tightly coupled to the upstream GPS precision and error handling stage. Consumer-grade GNSS receivers in open sky achieve 2–5 m horizontal accuracy at 1 Hz. A vehicle travelling at 30 m/s covers 30 m per sample; positional jitter of ±3 m therefore injects roughly ±10% variance into the raw speed estimate before any smoothing is applied.
Three root causes produce the worst artefacts:
- Multipath reflection in urban canyons shifts a point 5–30 m sideways for one epoch, then snaps back. This generates a phantom high-speed excursion that looks like a genuine acceleration spike in the derivative.
- Clock jitter and timezone errors corrupt the denominator. A timestamp in local time accidentally treated as UTC produces a time delta that is off by hours, giving absurd speed values without raising a Python exception.
- Zero or negative time deltas from device buffering or log merging cause divide-by-zero or negative speed values that propagate silently through
pandasarithmetic asinforNaN.
These failure modes sit within the broader speed and acceleration profiling workflow, which also covers Savitzky-Golay smoothing, entity-level groupby patterns, and CRS projection for acceleration calculations.
Core pipeline steps
- Parse and normalise timestamps to UTC. Coerce all timestamp columns to timezone-aware
datetime64[ns, UTC]usingpd.to_datetime(..., utc=True)so thatdiff()produces monotonic, daylight-saving-safe deltas. - Sort by entity and time. Multi-entity logs must be sorted within each
entity_idgroup before shifting; out-of-order rows produce negative deltas and phantom reverse movement. - Compute haversine distance between consecutive pairs. Vectorise across the entire frame using
shift(1)alignment — no Python-level loop. The haversine formula gives metre-accurate distances for inter-sample gaps under 50 km without requiring a projected CRS. - Divide distance by elapsed seconds, masking invalid intervals. Guard against
dt <= 0, NaN on the first row of each entity, and gaps that exceed the expected sampling cadence.
Production-ready Python implementation
The function below is fully vectorised — no iterrows, no Python-level loops. It handles unsorted inputs, multi-entity frames, missing coordinate values, duplicate timestamps, and trajectory gaps in one pass.
import numpy as np
import pandas as pd
from scipy.signal import savgol_filter
def haversine_vectorized(
lat1: np.ndarray,
lon1: np.ndarray,
lat2: np.ndarray,
lon2: np.ndarray,
) -> np.ndarray:
"""
Return great-circle distance in metres between two arrays of WGS84 points.
Uses the spherical haversine formula (error < 0.3% for distances under 50 km).
Inputs are NumPy arrays of equal length; NaN propagates through cleanly.
"""
R = 6_371_000.0 # IUGG mean radius in metres
phi1 = np.radians(lat1)
phi2 = np.radians(lat2)
dphi = np.radians(lat2 - lat1)
dlambda = np.radians(lon2 - lon1)
a = (
np.sin(dphi / 2.0) ** 2
+ np.cos(phi1) * np.cos(phi2) * np.sin(dlambda / 2.0) ** 2
)
return 2.0 * R * np.arctan2(np.sqrt(a), np.sqrt(1.0 - a))
def calculate_speed(
df: pd.DataFrame,
lat_col: str = "lat",
lon_col: str = "lon",
time_col: str = "timestamp",
entity_col: str | None = None,
max_speed_ms: float = 83.3, # ~300 km/h hard ceiling
min_dist_m: float = 0.5, # below this, treat as stationary
sg_window: int = 11, # Savitzky-Golay window (must be odd)
sg_poly: int = 3,
) -> pd.DataFrame:
"""
Compute smoothed speed (m/s) from discrete GPS fixes.
Parameters
----------
df : pd.DataFrame
Must contain lat_col, lon_col, time_col. If entity_col is provided,
sorting and derivative calculations are scoped to each entity.
max_speed_ms : float
Hard ceiling in m/s. Rows exceeding this are treated as GPS artefacts
and replaced with NaN before smoothing.
min_dist_m : float
Minimum inter-sample distance below which speed is set to 0.0
(suppresses stationary GPS jitter).
sg_window : int
Savitzky-Golay window length. Must be odd and >= sg_poly + 2.
Auto-incremented by 1 if an even value is passed.
sg_poly : int
SG polynomial order. 3 is standard for vehicle kinematics.
Returns
-------
pd.DataFrame
Original frame with added columns: 'dist_m', 'dt_s',
'speed_raw_mps', 'speed_mps' (smoothed).
Raises
------
ValueError
If required columns are missing or the frame is empty.
"""
required = {lat_col, lon_col, time_col}
missing = required - set(df.columns)
if missing:
raise ValueError(f"Missing required columns: {missing}")
if df.empty:
raise ValueError("Input DataFrame is empty.")
df = df.copy()
# ── Step 1: UTC timestamp normalisation ───────────────────────────
# utc=True coerces naive timestamps to UTC and converts tz-aware ones.
# This eliminates daylight-saving-time discontinuities in diff().
df[time_col] = pd.to_datetime(df[time_col], utc=True)
# Sort within each entity (or globally if no entity column).
sort_keys = ([entity_col, time_col] if entity_col else [time_col])
df = df.sort_values(sort_keys).reset_index(drop=True)
# ── Step 2: Align consecutive pairs with shift(1) ─────────────────
# Group-aware shift so entity boundaries never bleed into each other.
if entity_col:
grp = df.groupby(entity_col, sort=False)
lat_prev = grp[lat_col].shift(1)
lon_prev = grp[lon_col].shift(1)
dt_s = grp[time_col].diff().dt.total_seconds()
else:
lat_prev = df[lat_col].shift(1)
lon_prev = df[lon_col].shift(1)
dt_s = df[time_col].diff().dt.total_seconds()
# ── Step 3: Vectorised haversine distance ─────────────────────────
# IMPORTANT: pass positional arrays, not Series, so NaN propagates
# correctly through arctan2 without raising a casting warning.
dist_m = haversine_vectorized(
lat_prev.to_numpy(),
lon_prev.to_numpy(),
df[lat_col].to_numpy(),
df[lon_col].to_numpy(),
)
# Sub-threshold movement → treat as stationary (clamp to 0.0)
dist_m = np.where(dist_m < min_dist_m, 0.0, dist_m)
# ── Step 4: Speed division with validity mask ─────────────────────
# Mask: dt > 0, not NaN, and not the first row of each entity.
valid = (dt_s > 0) & dt_s.notna()
speed_raw = np.full(len(df), np.nan)
speed_raw[valid] = dist_m[valid] / dt_s[valid]
# Hard-ceiling outlier masking (GPS multipath / buffered flush artefacts)
speed_raw = np.where(speed_raw > max_speed_ms, np.nan, speed_raw)
df["dist_m"] = dist_m
df["dt_s"] = dt_s.to_numpy()
df["speed_raw_mps"] = speed_raw
# ── Step 5: Savitzky-Golay smoothing per entity ───────────────────
# SG preserves genuine acceleration peaks better than a simple EMA.
# window_length must be odd; auto-correct even caller values.
wl = sg_window if sg_window % 2 == 1 else sg_window + 1
def _sg_smooth(series: pd.Series) -> np.ndarray:
vals = series.fillna(0.0).to_numpy(dtype=float)
effective_wl = min(wl, len(vals) if len(vals) % 2 == 1 else len(vals) - 1)
if effective_wl < sg_poly + 2 or effective_wl < 3:
return vals # segment too short to filter
return savgol_filter(vals, window_length=effective_wl, polyorder=sg_poly,
mode="nearest")
if entity_col:
df["speed_mps"] = (
df.groupby(entity_col, sort=False)["speed_raw_mps"]
.transform(_sg_smooth)
)
else:
df["speed_mps"] = _sg_smooth(df["speed_raw_mps"])
return df
Validation block
Run these checks immediately after calling calculate_speed. Each assertion targets a distinct failure mode: unit confusion, projection errors, and smoothing over-reach.
def validate_speed_output(df: pd.DataFrame, max_speed_ms: float = 83.3) -> None:
"""
Sanity-check the output of calculate_speed().
Raises AssertionError with a descriptive message on failure.
"""
assert "speed_mps" in df.columns, "Missing 'speed_mps' column — did calculate_speed() run?"
non_nan = df["speed_mps"].dropna()
assert len(non_nan) > 0, "All speed values are NaN — check timestamp parsing and entity sorting."
assert (non_nan >= 0).all(), (
f"Negative speed values found: min={non_nan.min():.3f} m/s. "
"Likely cause: unsorted timestamps or swapped lat/lon columns."
)
assert (non_nan <= max_speed_ms).all(), (
f"Speed exceeds ceiling {max_speed_ms} m/s (max found: {non_nan.max():.2f}). "
"Increase max_speed_ms or investigate GPS multipath artefacts."
)
nan_frac = df["speed_mps"].isna().mean()
assert nan_frac < 0.10, (
f"{nan_frac:.1%} of speed values are NaN — unusually high. "
"Check for duplicate timestamps, excessive gaps, or missing entities."
)
# Coordinate bounds — catches axis-order swaps introduced upstream
assert df["lat"].between(-90, 90).all(), (
"Latitude values out of range — possible lon/lat column swap."
)
assert df["lon"].between(-180, 180).all(), (
"Longitude values out of range."
)
print(
f"Speed validation passed. "
f"Points: {len(df)}, NaN rate: {nan_frac:.1%}, "
f"Speed range: {non_nan.min():.2f}–{non_nan.max():.2f} m/s."
)
Key assertions:
- Non-negative speeds. Negative values almost always indicate unsorted timestamps or a swapped lat/lon column that produces the coordinate-pair ordering expected by
shift(1). - Ceiling check. Any speed above the
max_speed_msthreshold that survived the masking step indicates uncorrected GPS drift in the input. - NaN fraction. Isolated NaN on the first row of each entity is expected and normal. A fraction above 10% points to systematic timestamp problems, duplicate coordinate pairs, or gaps in the telemetry stream that should be handled at the time-series synchronization stage.
Common mistakes and gotchas
-
Computing haversine in degree-space and calling it “metric”. The haversine formula itself returns metres correctly — the mistake is applying Euclidean distance directly to latitude and longitude columns (e.g.
np.sqrt((lat2-lat1)**2 + (lon2-lon1)**2)) and treating the result as metres. One degree of longitude near the equator is ~111 km; near 60° N it is ~55 km. The error is systematic and transport-mode-dependent. -
Using
iterrows()orapply(lambda row: ...)for the distance loop. Iterating row-by-row over a million-point GPS log is 100–1000× slower than the vectorisedshift(1)+ NumPy approach. Even a 10 Hz fleet log with 100 vehicles generating 24 h of data reaches ~86 million rows;iterrowsmakes this infeasible. -
Ignoring entity boundaries when shifting. If
dfcontains multiple vehicles and you calldf["lat"].shift(1)without agroupby, the last fix of vehicle A becomes the “previous point” for the first fix of vehicle B. The resulting distance is the straight-line gap between two unrelated locations, producing a phantom multi-kilometre excursion in the derivative. -
Treating naive timestamps as UTC.
pandassilently accepts naive timestamps indiff(). If the device logs in local time (e.g. CET = UTC+1) and you forget theutc=Trueflag, the deltas are still numerically correct — but a log spanning a daylight-saving boundary will contain a one-hour gap or a negative delta that invalidates the entire night segment. -
Choosing
window_lengthwithout checking series length.scipy.signal.savgol_filterraises aValueErrorifwindow_lengthexceeds the series length. Short trajectory segments — such as a vehicle with only three GPS fixes in a log — silently fail if the guard oneffective_wlis omitted. The function above guards this explicitly; replicate the guard if you callsavgol_filterdirectly. -
Skipping the stationary-drift floor. A stopped vehicle still accumulates 0.5–2 m of positional jitter per second from satellite geometry fluctuations. Without the
min_dist_mfloor, the pipeline reports 0.5–2 m/s for a parked truck. Downstream stay-point detection algorithms and driver behaviour scoring both depend on accurate zero-speed identification.
Related
- Speed & Acceleration Profiling — parent reference covering finite-difference acceleration, Savitzky-Golay parameter selection, and full kinematic feature engineering for multi-entity fleets.
- Handling GPS Drift in Raw Trajectory Logs — upstream cleaning stage; uncorrected drift directly inflates speed estimates.
- GPS Precision & Error Handling — full taxonomy of GNSS error sources, DOP geometry, and filter selection.
- Stay-Point Detection Algorithms — downstream consumer of speed output; depends on accurate zero-speed windows to identify dwell events.
- Detecting U-Turns and Directional Shifts in Fleet Data — speed is a prerequisite input for turn-angle calculations that distinguish genuine manoeuvres from GPS noise.