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.

Five causes of AIS tracks jumping and the fix for each. five causes of a teleportcausefixsentinel position treated as datacheck lat 91, lon 181, 0ยฐยท0ยฐ eachshared or mistyped MMSIcluster the positionsa single bad fixdrop out-and-back pairsa coverage gap joined acrosssplit into segmentsplanar longitude differenceuse a geodesic distanceOnly the third is an outlier in the usual sense spatialworkflow.io
Five causes, five fixes; only one of them is an outlier in the usual sense.

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ยฐ.

A single outlier fix against a coverage gap. outliertwo fast steps: out and backcoverage gapone long, slow-looking legDelete the first, split at the second; doing either the other way makes it worse spatialworkflow.io
An outlier jumps out and back; a gap is a single long leg. They need opposite treatments.

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.

From every implied speed over 40 knots in a day of Danish AIS to the jumps that are outliers. Danish AIS, 1 June 2025steps over 40 kn50,978the rest moved under 1 kmรท 20โ€ฆthat also moved over 1 km2,492รท 9.3out-and-back outliers269band width is log-scaled1,379 of the 2,492 come from one fishing vessel reporting longitude 0 all day spatialworkflow.io
A speed threshold alone flags whole-second noise; a jump is a speed and a distance.

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.

How one vessel and two vessels sharing an MMSI differ in the track they produce. telling a shared MMSI from one long voyageone vesseltwo sharing an MMSIthe trackone coherent pathtwo separated groupsspeed between pointsโœ“plausible throughoutโœ•impossible betweenthe clustersone cloudtwo comparable cloudswhat to doโœ“keep as isโœ“split the identityTwo comparably sized, well-separated clusters are the signature spatialworkflow.io
Within each cluster the speeds are plausible; only the leg between them is not.

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.

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.