Validating NMEA Checksums and Talker IDs
The NMEA checksum is four characters at the end of every sentence and it is the cheapest quality signal an AIS pipeline has. It is also narrower than most implementations assume: it protects a specific character range, says nothing about several fields that commonly go wrong, and a validator that reduces it to a boolean throws away the information that makes it diagnostic. This page is a validator that reports why, within the NMEA parsing workflow.
Root cause: the checksum covers the characters between the delimiters
An NMEA sentence is !AIVDM,1,1,,A,15M67FC000G?ufbEKnvOvag003v,036. The checksum is the two hex digits after the asterisk, and it is the XOR of every character strictly between the leading !or$and the` — the delimiters themselves are excluded, and so is everything after the asterisk.
def nmea_checksum(sentence: str) -> int:
"""XOR of the characters between the start delimiter and the asterisk."""
body = sentence[1:sentence.index("*")]
acc = 0
for ch in body:
acc ^= ord(ch)
return acc
What that protects is the framing and the payload characters. What it does not protect is anything the transport adds: a receiver timestamp appended after the checksum, a source tag prefixed before the !, or a TAG block (\\s:station,c:1710000000*4A\\!AIVDM,...) which carries its own separate checksum that many parsers ignore entirely.
It is also a weak check by construction. An XOR over bytes catches every single-bit error and every odd number of bit flips in the same position, and misses any pair of errors that cancel — including the common case of two characters swapped, which XOR is completely blind to because XOR is commutative. A sentence with two adjacent payload characters transposed passes its checksum and decodes to a different vessel position.
Step-by-step validator
1. Report the reason, not a boolean
import logging
from dataclasses import dataclass
from enum import Enum
logger = logging.getLogger(__name__)
class Reject(str, Enum):
OK = "ok"
NO_START = "no_start_delimiter"
NO_STAR = "no_checksum_delimiter"
BAD_HEX = "checksum_not_hex"
MISMATCH = "checksum_mismatch"
UNKNOWN_TALKER = "unknown_talker"
TOO_LONG = "over_82_chars"
@dataclass(frozen=True)
class Checked:
reason: Reject
talker: str = ""
body: str = ""
@property
def ok(self) -> bool:
return self.reason is Reject.OK
The enum is the whole design. A pipeline that counts rejections by reason can tell a lossy radio link (mostly MISMATCH) from a misconfigured source (mostly UNKNOWN_TALKER) from a framing bug in its own reader (mostly NO_START), and those three have completely different fixes. A boolean makes all three look like “bad data”.
2. The validator itself
KNOWN_TALKERS = frozenset({
"AI", # AIS mobile/base station — the common case
"AB", # AIS base station
"AD", # AIS dependent base station
"AN", # AIS aids to navigation
"AR", # AIS receiving station
"AS", # AIS limited base station
"AT", # AIS transmitting station
"AX", # AIS repeater
"BS", # base station, used by some aggregators
"SA", # physical shore AIS station
})
MAX_LEN = 82 # NMEA 0183 sentence limit, delimiters included
def check_sentence(raw: str) -> Checked:
"""Validate framing, checksum and talker. Never repairs, only reports."""
s = raw.strip()
if len(s) > MAX_LEN:
return Checked(Reject.TOO_LONG)
if not s or s[0] not in "!$":
return Checked(Reject.NO_START)
star = s.rfind("*")
if star < 0 or star + 3 > len(s):
return Checked(Reject.NO_STAR)
try:
given = int(s[star + 1:star + 3], 16)
except ValueError:
return Checked(Reject.BAD_HEX)
acc = 0
for ch in s[1:star]:
acc ^= ord(ch)
if acc != given:
return Checked(Reject.MISMATCH)
talker = s[1:3]
if talker not in KNOWN_TALKERS:
return Checked(Reject.UNKNOWN_TALKER, talker=talker)
return Checked(Reject.OK, talker=talker, body=s[1:star])
Using rfind for the asterisk rather than find is deliberate: an aggregator that appends a timestamp after the checksum leaves the original asterisk in place, and find would locate a * inside a TAG block instead. Checking length before anything else costs nothing and catches a concatenated-line bug that would otherwise present as a checksum mismatch.
3. Count by reason and act on the ratio
from collections import Counter
class SentenceStats:
"""Per-reason rejection counters, and the ratios that name the fault."""
def __init__(self) -> None:
self._c: Counter[Reject] = Counter()
def record(self, checked: Checked) -> None:
self._c[checked.reason] += 1
def report(self) -> dict[str, float]:
total = sum(self._c.values()) or 1
out = {r.value: self._c[r] / total for r in Reject}
if out[Reject.MISMATCH.value] > 0.02:
logger.warning("checksum mismatch rate %.1f%% — suspect the radio link "
"or the serial line, not the parser",
out[Reject.MISMATCH.value] * 100)
if out[Reject.UNKNOWN_TALKER.value] > 0.001:
top = self._c.most_common(1)
logger.warning("unknown talkers present (%s) — a source is sending "
"sentences this pipeline does not expect", top)
return out
Thresholds differ by feed. A terrestrial VHF receiver in a busy port routinely shows 1–3% mismatches and that is normal; a satellite feed delivered over HTTPS should show essentially zero, and 1% there means something is corrupting data in the pipeline rather than on the air.
Verification and acceptance test
import pytest
VALID = "!AIVDM,1,1,,A,15M67FC000G?ufbE`KnvOvag003v,0*36"
def test_a_known_good_sentence_passes():
assert check_sentence(VALID).ok
@pytest.mark.parametrize("mangled,expected", [
(VALID.replace("!", ""), Reject.NO_START),
(VALID.split("*")[0], Reject.NO_STAR),
(VALID[:-2] + "ZZ", Reject.BAD_HEX),
(VALID[:-2] + "37", Reject.MISMATCH),
(VALID.replace("AIVDM", "XXVDM"), Reject.UNKNOWN_TALKER),
])
def test_each_failure_mode_is_reported_distinctly(mangled, expected):
assert check_sentence(mangled).reason is expected
def test_transposition_is_invisible_to_the_checksum():
"""Documents the limit: XOR cannot see a swap, so the bounds check must."""
body = VALID[1:VALID.index("*")]
swapped = body[:20] + body[21] + body[20] + body[22:]
forged = "!" + swapped + VALID[VALID.index("*"):]
assert check_sentence(forged).ok, "a transposition should still pass — that is the point"
The last test asserts a weakness rather than a strength, and it is the most valuable one in the file: it stops a future reader from assuming the checksum guarantees content, and it fails loudly if someone replaces XOR with something stronger without updating the downstream assumptions.
Where the validator belongs, and what it must not do
The validator sits at the very front of the pipeline, before fragment reassembly and before decoding, and its position carries two constraints that are easy to violate.
It must not repair. A sentence failing its checksum has unknown content — the corruption could be anywhere in the protected range, including in the fragment count, the sequence ID or the payload. Any repair is a guess, and a guessed AIS position is worse than a missing one because it enters the archive looking like an observation. The correct action is to discard, count and move on.
It must not reassemble. A multi-part sentence is validated per fragment: each fragment carries its own checksum over its own characters, and the assembled payload has no checksum at all. Validating after reassembly means a corrupt fragment has already been concatenated into a payload that will decode to something, and the fragment cache has no way to know. Validate first, and let only clean fragments into the cache.
The ordering has a useful consequence for the eviction counter described in the parent topic. If fragments are validated before caching, a rising eviction rate means fragments are being lost in transit; if validation happens after, it means fragments are being rejected after arrival, and those are different faults with different owners. Putting the validator first makes the eviction counter mean one thing.
One more constraint is worth stating because it is a performance trap rather than a correctness one. This function runs on every sentence at the full feed rate — thousands per second on a busy regional feed — so it must not allocate. The implementation above builds no lists, compiles no regular expressions and creates one small frozen dataclass per sentence; a regex-based validator doing the same job is roughly an order of magnitude slower and becomes the pipeline’s bottleneck at satellite-feed rates.
Edge cases and gotchas
- Lower-case hex is legal. Some sources emit
*3a.int(..., 16)handles it; a string comparison against an upper-cased computed value does not, and that mistake shows up as a mismatch rate of exactly the fraction of checksums containing a letter. - The 82-character limit is routinely exceeded. Long TAG blocks push real sentences past it. Treat
TOO_LONGas a warning counter rather than a hard reject unless you control the source, or a legitimate feed is silently dropped. $and!are both valid start delimiters and mean different things:$for talker-originated sentences,!for encapsulated ones such as AIVDM. Rejecting$outright drops the GPS sentences interleaved on the same serial line, which the timestamp reconstruction may need.
Related
- Parsing AIS NMEA Sentences With Python — the parent workflow this validator guards
- Step-by-Step AIS Message Decoding in Python — what happens to a sentence that passes
- Deduplicating AIS Messages in Real-Time Streams — the next stage, and why it keys on the decoded fields rather than the raw line