AIS tracks jump across the world
On this page
Problem statement
A vessel track drawn from AIS runs from the North Sea to the South Atlantic and back within a minute, or crosses Africa in a straight line, or shows an implied speed of four hundred knots. On a map it is a long straight line through places no ship goes, and it is the single most recognisable AIS data problem.
There are five causes, and they need different fixes: a sentinel position treated as data, a shared or mistyped MMSI, a genuine positional outlier, a coverage gap joined across, and longitudes differenced naively at the antimeridian. Only the third is a data quality problem in the usual sense; the rest are structural.
Quick answer
Filter sentinels first, then flag by implied speed, then split rather than delete:
import numpy as np, pandas as pd
from pyproj import Geod
geod = Geod(ellps="WGS84")
df = df[(df.lat.abs() <= 90) & (df.lon.abs() <= 180)]
df = df[(df.lat != 91.0) & (df.lon != 181.0)] # AIS "not available"
n = len(df)
df = df.drop_duplicates(["mmsi", "timestamp", "lat", "lon"]) # one fix heard by several receivers
print(f"{n - len(df):,} exact duplicates dropped ({(n - len(df)) / n:.1%})")
df = df.sort_values(["mmsi", "timestamp"]).reset_index(drop=True)
lon, lat = df.lon.values, df.lat.values
_, _, dist = geod.inv(lon[:-1], lat[:-1], lon[1:], lat[1:])
dt = np.diff(df.timestamp.values).astype("timedelta64[s]").astype(float)
same = df.mmsi.values[:-1] == df.mmsi.values[1:]
with np.errstate(divide="ignore", invalid="ignore"):
speed = np.r_[np.nan, np.where(same & (dt > 0), dist / dt * 1.94384, np.nan)]
df["implied_kn"] = speed
df["gap_s"] = np.r_[np.nan, np.where(same, dt, np.nan)]
df["step_m"] = np.r_[np.nan, np.where(same, dist, np.nan)]
print(df.implied_kn.describe(percentiles=[.5, .9, .99, .999]).round(2))
8,039,835 exact duplicates dropped (41.3%)
count 11409443.00
mean 18.21
std 8707.51
min 0.00
50% 1.11
90% 14.11
99% 32.17
99.9% 114.82
max 14656144.90
Name: implied_kn, dtype: float64
geod.inv computes a geodesic distance, which is correct across the antimeridian and at any latitude. Differencing longitudes and multiplying by 111 km is the other way to produce a jump that is not there.
Sentinels, geodesic speed, a chosen threshold
1. Remove the sentinel positions
Latitude 91 and longitude 181 mean "position not available". They are valid numbers in the message format, they pass a range check that allows ยฑ91, and they place a vessel beyond the pole. Do not expect them in pairs: in the Danish day, 22,295 vessel messages carry latitude 91, every one of them with longitude 0, and none carries longitude 181. Test each field on its own.
A position near 0ยฐ, 0ยฐ deserves the same suspicion. One fishing vessel reported longitude 0.0 all day, with a latitude that stepped through 0.483, 0.500, 0.517 โ sixtieths of a degree โ and it alone produced over half the day's fast steps longer than a kilometre (Example 1).
2. Compute the implied speed geodesically
Great-circle distance between consecutive fixes, divided by the time between them. Guard against zero time differences and against differencing across a change of MMSI.
3. Look at the distribution before choosing a threshold
The 99.9th percentile tells you where the tail starts. A fleet of container ships and a fleet of fast ferries need different numbers, and a threshold picked without looking removes real data or leaves the jumps in. Look at what is fast, too:
fast = df.implied_kn > 40
print(f"{fast.sum():,} steps over 40 kn, on {df.mmsi[fast].nunique():,} of {df.mmsi.nunique():,} vessels")
print(f" {(fast & (df.step_m < 100)).sum():,} moved less than 100 m; "
f"{(fast & (df.gap_s <= 2)).sum():,} took 2 s or less")
print(f" {(fast & (df.step_m > 10_000)).sum():,} moved more than 10 km, "
f"on {df.mmsi[fast & (df.step_m > 10_000)].nunique()} vessels")
50,978 steps over 40 kn, on 2,600 of 6,990 vessels
35,783 moved less than 100 m; 38,214 took 2 s or less
567 moved more than 10 km, on 111 vessels
AIS timestamps here are whole seconds, so two fixes a second apart that differ by the receiver's rounding or a few metres of GPS noise imply 40 knots without anything having moved. Most of the "fast" steps are that. A jump is a speed and a distance: require both.
4. Check whether it is an identity problem
A single MMSI reporting alternately from two distant places at a plausible speed for each is two vessels sharing an identifier, not one vessel teleporting. The test is whether the positions cluster into two coherent tracks. The day's longest jumps belong to the cargo ship AAL BRISBANE, whose track in Danish waters is interrupted by single fixes off Vietnam, off Singapore and in the Bay of Biscay:
suspect = 636018135 # the cargo ship AAL BRISBANE, the longest jumps of the day
g = df[df.mmsi == suspect].sort_values("timestamp")
far = g[(g.lat < 50) | (g.lon > 30)]
print(f"{len(g):,} fixes; {len(far)} far from Danish waters, e.g.:")
print(far[["timestamp", "lat", "lon", "step_m"]].head(6).round(2).to_string(index=False))
13,641 fixes; 16 far from Danish waters, e.g.:
timestamp lat lon step_m
2025-06-01 00:45:07 8.82 106.83 9711720.56
2025-06-01 05:12:22 46.40 -7.42 1697648.97
2025-06-01 09:01:03 49.45 -3.95 1346390.87
2025-06-01 09:51:15 1.33 104.33 10064956.93
2025-06-01 10:16:11 46.71 -7.14 1734351.79
2025-06-01 16:23:03 46.26 -7.53 1704757.28
Sixteen scattered fixes, each alone, thousands of kilometres from a 13,641-fix track that no Danish receiver could have heard from there: outliers, not a second vessel. Example 3's clustering says the same.
5. Distinguish an outlier from a gap
A single bad fix between two good ones produces one jump out and one jump back. A coverage gap produces one long, slow-looking segment. The first is removed; the second is split.
6. Split rather than delete
Deleting a fix joins the two neighbours, which can create a new jump. Splitting the track into segments keeps the uncertainty where it belongs โ as a gap in the series.
7. Handle the antimeridian
geod.inv is correct there. A planar difference of longitudes is not: a vessel moving from 179.9ยฐE to 179.9ยฐW moves 0.2ยฐ, and the naive arithmetic makes it 359.8ยฐ.
Classifying jumps and detecting shared identities
The data used on this page
One real day of AIS: every message the Danish Maritime Authority's receivers logged on 1 June 2025 (aisdata.ais.dk, published under the Danish public data terms) โ 20,958,877 messages in a 3.8 GB CSV. The setup converts it once to Parquet with DuckDB and keeps the vessel messages (Class A and B transponders) as df, exactly as received: no cleaning, duplicates included.
import os
import zipfile
import duckdb
import pandas as pd
import requests
if not os.path.exists("ais.parquet"):
if not os.path.exists("aisdk-2025-06-01.zip"):
with requests.get("http://aisdata.ais.dk/aisdk-2025-06-01.zip", stream=True, timeout=600) as r:
r.raise_for_status()
with open("aisdk-2025-06-01.zip", "wb") as f:
for chunk in r.iter_content(1 << 22):
f.write(chunk)
zipfile.ZipFile("aisdk-2025-06-01.zip").extractall(".")
# day/month/year timestamps and quoted destinations: say so rather than let the sniffer guess
duckdb.execute("""COPY (SELECT strptime("# Timestamp", '%d/%m/%Y %H:%M:%S') AS timestamp,
"Type of mobile" AS mobile, MMSI AS mmsi, Latitude AS lat, Longitude AS lon,
SOG AS sog, Name AS name, "Ship type" AS ship_type
FROM read_csv('aisdk-2025-06-01.csv', header=true, quote='"',
types={'# Timestamp': 'VARCHAR'})) TO 'ais.parquet' (FORMAT parquet)""")
os.remove("aisdk-2025-06-01.csv")
ais = pd.read_parquet("ais.parquet", columns=["timestamp", "mobile", "mmsi", "lat", "lon", "sog"])
print(f"{len(ais):,} messages from {ais.mmsi.nunique():,} MMSIs:", ais.mobile.value_counts().to_dict())
df = ais[ais.mobile.isin(["Class A", "Class B"])].drop(columns="mobile").reset_index(drop=True)
print(f"{len(df):,} vessel messages; latitude 91: {(df.lat == 91).sum():,} (longitude there: "
f"{df.lon[df.lat == 91].unique().tolist()}); longitude 181: {(df.lon == 181).sum()}")
20,958,877 messages from 7,396 MMSIs: {'Class A': 17203983, 'Class B': 2303364, 'Base Station': 1262834, 'AtoN': 152725, 'SAR Airborne': 35963, 'Search and Rescue Transponder': 8}
19,507,347 vessel messages; latitude 91: 22,295 (longitude there: [0.0]); longitude 181: 0
Example 1 โ classify each jump
import numpy as np, pandas as pd
def classify_jumps(df, max_knots=40.0, min_step_m=1000.0):
out = []
# only vessels with a candidate: a speed and a distance, not whole-second noise
flagged = df.loc[(df["implied_kn"] > max_knots) & (df["step_m"] > min_step_m), "mmsi"].unique()
for mmsi, g in df[df.mmsi.isin(flagged)].sort_values("timestamp").groupby("mmsi", sort=False):
g = g.copy().reset_index(drop=True)
fast = (g["implied_kn"] > max_knots) & (g["step_m"] > min_step_m)
for i in np.flatnonzero(fast.values):
prev_fast = bool(fast.values[i - 1]) if i > 0 else False
next_fast = bool(fast.values[i + 1]) if i + 1 < len(g) else False
gap_s = float(g["gap_s"].values[i]) if "gap_s" in g else np.nan
kind = ("outlier" if next_fast and not prev_fast
else "gap" if gap_s > 3600
else "boundary")
out.append({"mmsi": mmsi, "index": i, "kind": kind,
"implied_kn": float(g["implied_kn"].values[i]),
"gap_s": gap_s})
return pd.DataFrame(out)
jumps = classify_jumps(df)
print(jumps["kind"].value_counts().to_string())
print("vessels behind the 'boundary' steps:", jumps[jumps.kind == "boundary"].mmsi.value_counts().head(3).to_dict())
lille = df[df.mmsi == 219003369] # the fishing vessel RS112 LILLE JUNE
print(f"219003369: {len(lille):,} fixes, longitude {lille.lon.unique().tolist()}, "
f"latitude {lille.lat.min():.3f}โ{lille.lat.max():.3f}")
kind
boundary 2220
outlier 269
gap 3
vessels behind the 'boundary' steps: {219003369: 1379, 636018135: 43, 209276000: 35}
219003369: 6,289 fixes, longitude [0.0], latitude 0.017โ0.983
An out-and-back pair is an outlier; a single fast step after a long gap is coverage. Of the day's jumps longer than a kilometre, 269 are out-and-back outliers and only 3 follow an hour-long gap; the 2,220 that are neither are mostly one vessel's broken position feed, the fishing boat stepping along longitude 0. Classifying before acting is what stops a cleaning rule from deleting real voyages.
Example 2 โ remove outliers, split at gaps
import numpy as np, pandas as pd
def recompute_kinematics(g):
lon, lat = g.lon.values, g.lat.values
_, _, d = geod.inv(lon[:-1], lat[:-1], lon[1:], lat[1:])
dt = np.diff(g.timestamp.values).astype("timedelta64[s]").astype(float)
with np.errstate(divide="ignore", invalid="ignore"):
g["implied_kn"] = np.r_[np.nan, np.where(dt > 0, d / dt * 1.94384, np.nan)]
g["gap_s"], g["step_m"] = np.r_[np.nan, dt], np.r_[np.nan, d]
return g
def clean_track(g, max_knots=40.0, max_gap_s=3600, min_step_m=1000.0):
g = g.sort_values("timestamp").reset_index(drop=True).copy()
# iteratively drop single fixes that jump out and back
for _ in range(5):
fast = ((g["implied_kn"] > max_knots) & (g["step_m"] > min_step_m)).values
drop = np.flatnonzero(fast[:-1] & fast[1:]) + 0
if not len(drop):
break
g = g.drop(index=drop).reset_index(drop=True)
g = recompute_kinematics(g)
g["segment"] = ((g["gap_s"].fillna(np.inf) > max_gap_s)
| ((g["implied_kn"] > max_knots) & (g["step_m"] > min_step_m))).cumsum()
return g
# pandas 3 no longer passes the grouping column to apply; it comes back as the index level
cleaned = df.groupby("mmsi").apply(clean_track).reset_index(level=0).reset_index(drop=True)
print(f"{len(df):,} โ {len(cleaned):,} fixes, "
f"{cleaned.groupby(['mmsi', 'segment']).ngroups:,} segments")
print(f"AAL BRISBANE: {(df.mmsi == suspect).sum():,} โ {(cleaned.mmsi == suspect).sum():,} fixes")
11,445,217 โ 11,444,889 fixes, 12,689 segments
AAL BRISBANE: 13,641 โ 13,606 fixes
Across the day that removes 328 of 11,445,217 fixes โ 35 of them AAL BRISBANE's โ and splits the tracks into 12,689 segments. Five iterations is enough in practice; two consecutive bad fixes need two passes, and more than that is usually an identity problem rather than noise.
Example 3 โ detect a shared MMSI
import numpy as np, pandas as pd
from sklearn.cluster import DBSCAN
def shared_mmsi_score(g, eps_km=200, min_samples=5):
"""Cluster the positions; two dense, well-separated clusters suggest two vessels."""
xy = np.radians(g[["lat", "lon"]].values)
db = DBSCAN(eps=eps_km / 6371.0, min_samples=min_samples,
metric="haversine").fit(xy)
labels = db.labels_
clusters = [c for c in set(labels) if c != -1]
if len(clusters) < 2:
return {"clusters": len(clusters), "suspect": False}
sizes = sorted((np.sum(labels == c) for c in clusters), reverse=True)
return {"clusters": len(clusters), "largest_two": [int(n) for n in sizes[:2]],
"suspect": bool(sizes[1] / sizes[0] > 0.2)}
print("AAL BRISBANE:", shared_mmsi_score(df[df.mmsi == suspect]))
AAL BRISBANE: {'clusters': 3, 'largest_two': [13617, 8], 'suspect': False}
Two comparably sized, well-separated clusters over a period during which no vessel could travel between them is strong evidence that the MMSI covers more than one transponder.
Sentinels, the antimeridian and shared MMSIs
Why sentinels look like data
The AIS message format reserves specific values to mean "not available": latitude 91, longitude 181, speed 102.3 knots, course 360ยฐ, heading 511ยฐ. All of them are inside the field's numeric range, so a decoder emits them as ordinary numbers and a range check that permits ยฑ91 lets them through. A vessel at latitude 91 plots beyond the pole, which on a Mercator map is a line off the top of the world.
Why the antimeridian produces a fake jump
Longitude differences are only distances after accounting for the wrap. A step from 179.9ยฐE to 179.9ยฐW is 0.2ยฐ of longitude, but 179.9 โ (โ179.9) is 359.8. A planar calculation therefore reports a jump of roughly forty thousand kilometres for a vessel that moved twenty. Using a geodesic function removes the whole class.
Why deleting a bad fix can create a new jump
Removing the offending point joins its neighbours. If the neighbours are far apart in time โ because the outlier was the only fix in a quiet period โ the new segment can itself exceed the speed threshold, and an iterative filter chases its own tail. Splitting into segments instead records the gap rather than bridging it.
Why a shared MMSI is common enough to check for
MMSIs are entered by hand into the transponder, they are reassigned when a vessel changes flag, and a small number of them are placeholder values that many installations share. A track built by grouping on MMSI alone therefore sometimes interleaves two vessels, and the result is exactly the teleporting pattern โ at a plausible speed within each cluster and an impossible one between them.
Zero time steps, anchored vessels and aircraft
- Guard against zero time differences. Duplicate timestamps give infinite speed.
- Do not difference across a change of MMSI. Group first.
- Anchored vessels swing. They are not stationary and they are not moving.
- Helicopters and aircraft appear in some feeds and are genuinely fast.
- Timestamps may be receiver time. Two receivers can disagree by seconds.
- Satellite AIS collides in busy areas, producing gaps where traffic is densest.
- Report every rule's count. A filter that drops 30% has made a decision.
- Keep the rejected fixes. They are the evidence for the identity checks.
Internal links
- How to clean AIS vessel tracks in Python โ the full pipeline
- Track has impossible speeds โ the same filter for GPS
- How to clean a GPS track in Python โ the shared rules
- A coastline or EEZ breaks at the antimeridian โ the seam in geometry rather than tracks
- How to calculate speed, distance and direction along a track โ computing implied speed
- How to split a GPS track into trips in Python โ segmenting rather than deleting
- Trajectories explained โ the data model
- How to measure distance accurately in Python โ why geodesic distance
FAQ
Why does my AIS track jump across the world?
Usually a sentinel position โ latitude 91 or longitude 181 โ treated as data, or a shared MMSI interleaving two vessels. Both look like one ship teleporting.
What speed threshold should I use?
Look at the percentiles of implied speed for your fleet first. Forty knots is above any merchant vessel and below some fast ferries.
Should I delete the bad fixes?
Delete single out-and-back outliers; split at gaps. Deleting a fix that sat alone in a quiet period joins its neighbours and can create a new jump.
Why does a vessel near 180ยฐ show a huge jump?
Because the longitude difference was computed planar. Use a geodesic distance function, which is correct across the antimeridian.
How do I tell an outlier from a coverage gap?
An outlier produces two consecutive fast steps โ out and back. A gap produces one long step after a long time interval.
Can two vessels share an MMSI?
Yes, through mistyping, reassignment and placeholder values. Cluster the positions: two comparably sized, well-separated clusters are the signature.