Computing Great-Circle Speed From AIS Fixes
Speed between two AIS fixes is distance over time, and both terms have a trap. The distance is on a curved, flattened surface and the obvious subtraction of coordinates is wrong by a factor that varies with latitude. The time comes from a reconstructed timestamp that can be equal, or negative, between consecutive rows. This page gets both right, within the speed and heading workflow.
Root cause: a degree of longitude shrinks with latitude
Subtracting latitude and longitude and applying Pythagoras treats the two axes as equivalent. They are not: one degree of latitude is about 111 km everywhere, while one degree of longitude is 111 km at the equator, 78 km at 45°, and 0 at the pole.
import numpy as np
def wrong_euclidean_km(lat1, lon1, lat2, lon2):
"""The mistake, written out. Wrong by cos(latitude) on the longitude axis."""
return np.hypot(lat2 - lat1, lon2 - lon1) * 111.32
print(wrong_euclidean_km(60.0, 5.0, 60.0, 6.0)) # 111.32 km — claimed
# The true east-west distance at 60°N is 111.32 * cos(60°) = 55.66 km.
# The computed speed is therefore exactly 2× the real one at this latitude.
At 60° N — the Norwegian coast, the northern North Sea, the Gulf of Alaska — every east-west speed computed this way is double. The error is smooth, systematic and latitude-dependent, so it survives averaging and produces a fleet whose apparent speed rises with latitude.
Haversine fixes that by treating the earth as a sphere, and introduces a smaller error of its own: the earth is flattened by about 1/298, so a spherical distance differs from the true ellipsoidal one by up to roughly 0.5%. At AIS speeds and typical fix intervals that is a few hundredths of a knot — negligible for a traffic study, and not negligible for a fuel model where the whole effect being measured may be a few per cent.
Step-by-step with production code
1. Vectorised geodesic, with the time guard
import logging
import numpy as np
import pandas as pd
from pyproj import Geod
logger = logging.getLogger(__name__)
GEOD = Geod(ellps="WGS84")
MS_TO_KN = 1.94384
def fix_to_fix_speed_kn(df: pd.DataFrame, *, method: str = "geodesic") -> pd.Series:
"""Speed between consecutive fixes of the same vessel, in knots.
`df` must carry mmsi, lat, lon and a timezone-aware timestamp. Rows are
sorted internally; the returned Series is aligned to the input index.
"""
required = {"mmsi", "lat", "lon", "timestamp"}
if missing := required - set(df.columns):
raise ValueError(f"missing columns: {sorted(missing)}")
d = df.sort_values(["mmsi", "timestamp"])
same = d["mmsi"].to_numpy()[1:] == d["mmsi"].to_numpy()[:-1]
dt_s = np.diff(d["timestamp"].astype("int64").to_numpy()) / 1e9
lat, lon = d["lat"].to_numpy(), d["lon"].to_numpy()
if method == "geodesic":
_, _, dist_m = GEOD.inv(lon[:-1], lat[:-1], lon[1:], lat[1:])
elif method == "haversine":
dist_m = _haversine_m(lat[:-1], lon[:-1], lat[1:], lon[1:])
else:
raise ValueError(f"unknown method {method!r}")
with np.errstate(divide="ignore", invalid="ignore"):
kn = (np.abs(dist_m) / dt_s) * MS_TO_KN
kn[~same] = np.nan
bad_dt = dt_s <= 0
if bad_dt.any():
logger.warning("%d consecutive pair(s) with a non-positive time interval — "
"duplicates or clock skew upstream", int(bad_dt.sum()))
kn[bad_dt] = np.nan
out = pd.Series(np.concatenate([[np.nan], kn]), index=d.index, name="speed_kn")
return out.reindex(df.index)
def _haversine_m(lat1, lon1, lat2, lon2, radius_m: float = 6_371_008.8):
p1, p2 = np.radians(lat1), np.radians(lat2)
dp, dl = p2 - p1, np.radians(lon2 - lon1)
a = np.sin(dp / 2) ** 2 + np.cos(p1) * np.cos(p2) * np.sin(dl / 2) ** 2
return 2 * radius_m * np.arcsin(np.sqrt(a))
GEOD.inv accepts arrays, which is the whole reason to use pyproj rather than a per-row loop: a million fixes take about a second vectorised and several minutes row by row. The non-positive interval warning is not decorative — duplicate timestamps after deduplication, and clock skew between a receiver and an aggregator, both produce them regularly on a real feed.
2. Choose the method from the use, and record it
| Use | Method | Why |
|---|---|---|
| Traffic density, routeing studies | haversine | 0.5% is far below the other uncertainties |
| Outlier rejection | haversine | the threshold is an order of magnitude above the error |
| Fuel and emissions modelling | geodesic | the effect being measured is often a few per cent |
| Regulatory speed compliance | geodesic | a disputed figure must not depend on the earth model |
| Anything crossing 70° latitude | geodesic | convergence makes spherical error less predictable |
The recording matters as much as the choice. Two years of speeds computed by haversine with six months of geodesic spliced in has a discontinuity of a few hundredths of a knot at the join — invisible, and enough to produce a spurious trend in a fleet-efficiency study.
3. Cross-check against the reported speed
The transponder reports its own speed over ground, derived from the GNSS receiver’s Doppler solution rather than from position differences. It is an independent measurement, and comparing the two is nearly free:
def speed_residual_kn(df: pd.DataFrame) -> pd.Series:
"""Reported speed minus computed speed. Should be small and unbiased."""
computed = fix_to_fix_speed_kn(df)
resid = df["sog_kn"] - computed
finite = resid[np.isfinite(resid)]
if len(finite) > 100:
logger.info("speed residual: median %.2f kn, p95 |%.2f| kn",
float(finite.median()), float(finite.abs().quantile(0.95)))
return resid
A residual centred on zero with a spread of a few tenths of a knot is a healthy feed. A residual with a bias means one of the two is systematically wrong: a positive bias, where reported exceeds computed, usually means position fixes are being dropped so the computed speed averages over a longer, straighter path than the vessel took.
Verification and acceptance test
import pytest
def test_a_known_leg_recovers_its_speed():
"""A 60 nm leg in exactly 4 hours is 15 knots, by definition."""
lon1, lat1 = -75.0, 38.0
lon2, lat2, _ = GEOD.fwd(lon1, lat1, 90.0, 60.0 * 1852.0)
df = pd.DataFrame({
"mmsi": [1, 1],
"lat": [lat1, lat2], "lon": [lon1, lon2],
"timestamp": pd.to_datetime(["2026-03-14T00:00Z", "2026-03-14T04:00Z"]),
})
kn = fix_to_fix_speed_kn(df).iloc[1]
assert abs(kn - 15.0) < 0.01, f"recovered {kn:.3f} kn"
def test_euclidean_would_fail_the_same_test():
"""Guard: the wrong method must not pass, or the test proves nothing."""
err = wrong_euclidean_km(60.0, 5.0, 60.0, 6.0) / (111.32 * np.cos(np.radians(60.0)))
assert err > 1.9, "the Euclidean error at 60°N should be roughly a factor of two"
def test_duplicate_timestamps_do_not_produce_infinity(dupe_track):
kn = fix_to_fix_speed_kn(dupe_track)
assert np.isfinite(kn.dropna()).all(), "a zero time interval leaked an infinity"
The middle test is the fleet habit again: feeding the machinery input it must reject, so a check that quietly stopped working cannot pass by finding nothing.
Fix interval sets the ceiling on what speed can mean
Speed derived from two positions is an average over the interval between them, and the interval on an AIS feed varies by a factor of ninety. That makes “the speed” a quantity whose meaning changes across the same dataset, and no amount of care in the arithmetic fixes it.
At a two-second interval the derived speed is close to instantaneous and is dominated by position noise: a five-metre GNSS error over two seconds is 4.9 knots of spurious speed. At a three-minute interval the same error contributes 0.05 knots, and the derived speed is a genuine average — but it has smoothed away every manoeuvre shorter than three minutes.
def noise_speed_kn(position_sigma_m: float, interval_s: float) -> float:
"""Spurious speed produced by position error alone, at this fix interval."""
if interval_s <= 0:
raise ValueError("interval must be positive")
return (position_sigma_m * np.sqrt(2) / interval_s) * MS_TO_KN
for dt in (2, 10, 60, 180):
print(f"{dt:>4} s: {noise_speed_kn(5.0, dt):.2f} kn of noise")
# 2 s: 6.87 kn
# 10 s: 1.37 kn
# 60 s: 0.23 kn
# 180 s: 0.08 kn
Nearly seven knots of noise on a two-second interval is not a hypothetical: it is why raw fix-to-fix speed on a fast-reporting vessel is visibly noisier than the transponder’s own reported figure, which comes from a Doppler solution and does not have this problem.
The practical response is to derive speed over a distance rather than over a fixed number of fixes — accumulate consecutive segments until the vessel has moved far enough that position noise is a small fraction of the distance, then divide. That makes the averaging window adaptive: short in time for a fast vessel, long for a slow one, and constant in the quantity that actually governs the error.
Edge cases and gotchas
- The antimeridian. A vessel crossing 180° has a longitude difference of about 360°, and every naive method produces a distance of half the earth’s circumference.
Geod.invhandles it correctly; a hand-rolled haversine that does not normalise the longitude difference does not. - Timestamps must be timezone-aware. Mixing naive and aware datetimes raises in pandas; mixing two naive series in different local times does not, and produces intervals wrong by whole hours. Normalise to UTC at ingestion, as the timestamp drift guide sets out.
- Speed and course must be computed from the same pair. Deriving speed from fixes i and i+1 while deriving course from i-1 and i+1 produces a kinematic record that is internally inconsistent, and any model relating the two will find structure that is an artefact of the offset.
Related
- Speed and Heading Profiling for Maritime Analytics — the parent workflow, including why speed over ground is not speed through water
- Handling AIS Timestamp Drift and Clock Skew — the denominator this calculation depends on
- Filtering Invalid AIS Positions and SOG Outliers — the implied-speed check built on this function