Filtering Invalid AIS Positions and SOG Outliers
An AIS feed contains three distinct kinds of bad position, and treating them with one statistical filter removes the wrong things. Defined sentinels decode to coordinates in the Arctic Ocean and are not outliers at all — they are the protocol saying “unavailable”. Encoding-range violations are corrupt bits. Kinematically impossible jumps are real reports with a wrong position, and identifying which of two fixes is wrong takes more than a threshold. This page is the three-layer filter, within the AIS data quality workflow.
Root cause: the protocol has “not available” values that look like data
AIS packs position into a fixed-width integer field, and the specification reserves particular values for “not available”. Decoded naively they become coordinates:
| Field | Sentinel (raw) | Decodes to | What it means |
|---|---|---|---|
| Longitude | 0x6791AC0 | 181.0° | position not available |
| Latitude | 0x3412140 | 91.0° | position not available |
| Speed over ground | 1023 | 102.3 kn | speed not available |
| Speed over ground | 1022 | 102.2 kn | 102.2 kn or higher |
| Course over ground | 3600 | 360.0° | course not available |
| True heading | 511 | 511° | heading not available |
| Rate of turn | −128 | — | not available |
The longitude sentinel is the one that causes the most damage, because 181° is outside the valid range and many decoders wrap it to −179°, producing a position in the Bering Sea. A pipeline with no sentinel check accumulates a dense artificial cluster there, and a fleet-wide analysis finds a “shipping hotspot” in open water.
import numpy as np
import pandas as pd
SENTINELS = {
"lon": 181.0,
"lat": 91.0,
"sog_kn": 102.3,
"cog_deg": 360.0,
"heading_deg": 511.0,
}
def sentinel_mask(df: pd.DataFrame) -> pd.Series:
"""True where any field carries its defined not-available value."""
m = pd.Series(False, index=df.index)
for col, value in SENTINELS.items():
if col in df.columns:
m |= np.isclose(df[col], value, atol=1e-6)
return m
These are not outliers to be trimmed. They are the vessel telling you it has no fix, and the correct response is to record “no position” rather than to interpolate over the gap as though a report were missing.
Step-by-step filter with production code
1. Bounds, from the encoding rather than from geography
import logging
logger = logging.getLogger(__name__)
BOUNDS = {
"lat": (-90.0, 90.0),
"lon": (-180.0, 180.0),
"sog_kn": (0.0, 102.1), # 102.2 and 102.3 are sentinels, handled above
"cog_deg": (0.0, 359.9),
}
def out_of_range_mask(df: pd.DataFrame) -> pd.Series:
m = pd.Series(False, index=df.index)
for col, (lo, hi) in BOUNDS.items():
if col in df.columns:
bad = (df[col] < lo) | (df[col] > hi)
if bad.any():
logger.info("%s: %d value(s) outside [%s, %s]", col, int(bad.sum()), lo, hi)
m |= bad
return m
These are encoding limits, not survey limits. Restricting longitude to a study area belongs elsewhere: a vessel legitimately leaving the area is not corrupt data, and conflating the two makes the corruption rate a function of where the study area is drawn.
2. Implied speed, and deciding which fix is wrong
A pair of consecutive fixes implies a speed. When that speed is impossible, one of the two positions is wrong — and naively dropping the second one is right about half the time.
from pyproj import Geod
GEOD = Geod(ellps="WGS84")
MAX_SPEED_KN = {"cargo": 30.0, "tanker": 22.0, "passenger": 45.0, "default": 40.0}
def implied_speed_kn(df: pd.DataFrame) -> pd.Series:
"""Speed implied by the distance and time between consecutive fixes."""
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
_, _, dist_m = GEOD.inv(d["lon"].to_numpy()[:-1], d["lat"].to_numpy()[:-1],
d["lon"].to_numpy()[1:], d["lat"].to_numpy()[1:])
with np.errstate(divide="ignore", invalid="ignore"):
kn = (dist_m / np.maximum(dt_s, 1e-9)) * 1.94384
kn[~same | (dt_s <= 0)] = np.nan
return pd.Series(np.concatenate([[np.nan], kn]), index=d.index)
def blame_the_outlier(df: pd.DataFrame, limit_kn: float) -> pd.Series:
"""Of two fixes forming an impossible jump, mark the one that is isolated.
A single bad fix produces two impossible transitions — in and out again.
A genuine course change produces one. Counting them separates the cases.
"""
v = implied_speed_kn(df)
bad_in = v > limit_kn
bad_out = bad_in.shift(-1, fill_value=False)
isolated = bad_in & bad_out # impossible both in and out: this fix is wrong
logger.info("implied-speed: %d isolated bad fix(es), %d single transitions",
int(isolated.sum()), int((bad_in & ~bad_out).sum()))
return isolated
The isolation test is what makes this better than a threshold. One wrong position produces two impossible transitions, one arriving and one leaving; a vessel that genuinely accelerated, or a gap that makes a legitimate move look fast, produces only one. Marking only the doubly-impossible fixes removes the spurious positions and leaves the ambiguous transitions for the gap logic to handle.
3. Cross-check the reported speed
The reported speed over ground is an independent measurement of the same quantity, and disagreement between it and the implied speed is diagnostic:
def speed_disagreement(df: pd.DataFrame) -> pd.Series:
"""Absolute difference between reported and implied speed, in knots."""
return (df["sog_kn"] - implied_speed_kn(df)).abs()
A vessel reporting 12 knots whose positions imply 600 is almost certainly a position error, because the speed field comes from the same GNSS receiver but a different part of the message and rarely corrupts identically. A vessel reporting 102.2 — the “at or above” sentinel — with an implied speed of 8 is a transponder problem. The two numbers agreeing at an impossible value is the signature of deliberate spoofing rather than corruption, which is where spoofing detection takes over.
Verification and acceptance test
import pytest
def test_sentinels_are_not_counted_as_outliers(feed):
s = sentinel_mask(feed)
assert s.any(), "the test feed contains no sentinels — the check is unexercised"
flagged = blame_the_outlier(feed[~s], MAX_SPEED_KN["default"])
assert not flagged.any(), (
"an implied-speed outlier survived sentinel removal — the sentinel list is incomplete")
def test_a_single_injected_bad_fix_is_isolated_and_removed(clean_track):
t = clean_track.copy()
t.loc[t.index[10], ["lat", "lon"]] = [t.lat.iloc[10] + 3.0, t.lon.iloc[10] + 3.0]
flagged = blame_the_outlier(t, MAX_SPEED_KN["default"])
assert flagged.iloc[10], "the injected fix was not detected"
assert flagged.sum() == 1, f"{flagged.sum()} fixes flagged; only one is wrong"
The second assertion — exactly one — is what keeps the filter from becoming greedy. A threshold-only implementation flags both fixes either side of the injected one and fails it.
Rejection rates are a monitoring signal, not waste
Each of the three layers has its own rate, and the three move for different reasons. Exporting them separately turns the filter from a cleaning step into the pipeline’s earliest quality instrument.
Sentinel rate tracks how many vessels have a GNSS fix. It rises in areas with poor sky view, during jamming, and when a fleet of older transponders enters the area. A step change with no weather or geography behind it usually means a new source was added to the feed.
Range-violation rate tracks link corruption, and on a clean HTTPS-delivered feed it should be essentially zero. Anything above about one in ten thousand means bytes are being mangled between the source and the decoder, and the place to look is the transport rather than the data.
Implied-speed rejection rate tracks the environment: multipath in a port with tall structures, spoofing in contested waters, and GNSS degradation generally. It is the only one of the three that carries operational meaning rather than infrastructure meaning.
def export_filter_rates(counts: dict[str, int], total: int) -> dict[str, float]:
"""Per-layer rejection rates, for the metrics endpoint."""
if total <= 0:
raise ValueError("total must be positive")
rates = {k: v / total for k, v in counts.items()}
if rates.get("range", 0.0) > 1e-4:
logger.warning("range-violation rate %.4f%% — suspect the transport, not the feed",
rates["range"] * 100)
return rates
Rolling the three into one “bad data” percentage loses all of that, and it is the shape the metric takes by default because one number is easier to put on a dashboard. The monitoring topic makes the same argument about gate residuals: the aggregate is the number you look at, and the breakdown is the number you act on.
Edge cases and gotchas
- A stationary vessel produces zero-distance pairs at high report rates. Dividing by a two-second interval is fine; dividing by a sub-second one after deduplication has merged timestamps is not. Guard the denominator and treat identical timestamps as duplicates rather than as instantaneous motion.
- Speed 102.2 means “102.2 knots or greater”, not “not available”. Only 102.3 is the unavailable sentinel. Treating 102.2 as missing discards the fastest vessels, which are exactly the ones a traffic study cares about.
- The implied-speed bound is per vessel class, and the class comes from the static message. Before the first type-5 message arrives, the class is unknown, and using a generous default rather than skipping the check is the safer choice — a missed outlier is recoverable, a deleted fast ferry is not.
Related
- Interpolating Gaps in AIS Vessel Trajectories — what happens to the gaps this filter creates
- Detecting AIS Spoofing and GPS Jamming — where an impossible fix stops being corruption and starts being deception
- AIS Data Quality & Gap Filling — the parent workflow and its QC thresholds