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.

What the Checksum Actually Covers A single NMEA line laid out horizontally and divided into labelled segments. A leading TAG block carrying a station identifier and a timestamp is marked as outside the protected range and as having its own separate checksum that many parsers ignore. The bang delimiter is marked as excluded. The sentence body, from AIVDM through the payload and the fill-bit digit, is highlighted as the only protected range. The asterisk and the two hexadecimal checksum characters are marked as excluded. A trailing receiver timestamp appended by an aggregator is marked as outside the range entirely, and a note observes that a parser splitting on commas will happily treat it as an extra field. Beneath, a second note records that XOR is blind to transposed characters, so two swapped payload characters decode to a different position and pass the check. \s:STN1,c:171…*4A\ ! AIVDM,1,1,,A,15M67FC000G?ufbE…,0 * 36 ,1710000123.44 the only protected range TAG block own checksum receiver stamp an extra comma field XOR is commutative, so it is blind to transposed characters. Swap two adjacent payload characters and the checksum is unchanged. The sentence passes, decodes cleanly, and reports a different position — which is why a passing checksum is a framing check, not a correctness guarantee. The bounds check on the decoded coordinate is what catches that, and it belongs downstream of here.

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.

Three Faults, Three Rejection Profiles Three stacked bars, one per fault, broken down by rejection reason. The lossy VHF link bar is almost entirely checksum mismatch with a small sliver of missing start delimiter. The misconfigured aggregator bar is almost entirely unknown talker, with the checksum passing on every sentence because the data itself is intact. The reader framing bug bar is almost entirely missing start delimiter and over-length, produced by a buffer that splits the stream at the wrong boundary. A note states that all three present as a single elevated bad-sentence rate, and that only the per-reason breakdown separates a radio problem from a configuration problem from a bug in your own code. the same “5% bad sentences”, three different faults lossy VHF link checksum mismatch misconfigured source unknown talker — the data itself is intact reader framing bug no start delimiter over 82 chars One number cannot distinguish a radio problem from a configuration problem from a bug in your own reader. The breakdown costs a Counter and it is the difference between a fix and an investigation.

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.

Validate Per Fragment, Before the Cache Two ingestion chains. In the correct chain, sentences arrive, each fragment is validated individually, failing fragments are discarded and counted by reason, and only clean fragments enter the reassembly cache, so the assembled payload is built entirely from verified characters. In the incorrect chain, fragments enter the cache unvalidated, are concatenated, and the assembled payload is checked afterwards; but the assembled payload has no checksum of its own, so the check cannot be performed and a corrupted fragment produces a payload that decodes cleanly to a wrong position. A note adds that the eviction counter means lost in transit in the first chain and rejected after arrival in the second, which are different faults with different owners. validate first — correct sentences in validate per fragment cache decode discarded and counted by reason eviction counter means: lost in transit validate after reassembly — cannot work sentences in cache validate joined payload — no checksum decode a plausible wrong position and nothing to detect it with The assembled payload has no checksum of its own. Only the fragments do, and only before they are joined.

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_LONG as 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.

Up: Parsing AIS NMEA Sentences With Python