Patrick Lidstone
Self-hosted

AIS: native maritime GMSK decoder — 9600-baud HDLC on VHF, clean-room from IQ to AIVDM

A self-contained, dependency-light AIS receiver: 9600-baud GMSK on the two marine-VHF AIS channels, demodulated from complex baseband IQ by an FM discriminator, NRZI-decoded, HDLC-deframed (flag 0x7E, bit de-stuffing, CRC-16/X.25 FCS), then repackaged into standard !AIVDM NMEA sentences — a native, in-repo replacement for AIS-catcher's demodulator.

Rafe project · app/radio/aisdecode.py, app/radio/ais.py · reproduction spec (benchmark: ../rvqvoice.md)


Abstract

AIS — the Automatic Identification System, ITU-R M.1371 — is the transponder network by which ships continuously broadcast their identity, position, course and speed on two dedicated marine-VHF channels. Rafe decodes it two ways: the default path shells out to the external AIS-catcher binary over UDP, and the native path (aisdecode.py) does the whole physical layer in-repo — pure NumPy, no SoapySDR, no external demodulator — taking complex baseband IQ from a low-level SDR source and emitting the same !AIVDM sentences AIS-catcher would.

The native decoder is a clean-room implementation of the AIS physical and link layers: a GMSK modulator/demodulator (BT ≈ 0.4, modulation index h = 0.5) at 9600 baud; an FM-discriminator (differential-phase) bit-recovery front end with brute-force symbol-timing search; NRZI decoding; HDLC deframing with 0x7E flag hunting and five-ones bit de-stuffing; and a CRC-16/X.25 frame-check that rejects corrupt bursts. Valid frames' bit payloads are re-armoured into the NMEA 6-bit ASCII representation and formatted as !AIVDM,1,1,,<ch>,<payload>,<fill>*<cs> sentences. Those sentences then flow into exactly the same ingest path the UDP feeds use (ais.py): multipart reassembly, field-level decode by the pyais library, message-type dispatch, and aggregation of ship tracks by MMSI for the map.

The physical/link-layer mechanics are validated self-consistently by test_ais.py — 6-bit armour round-trip, NRZI differential property, HDLC/CRC frame-and-reject, AIVDM formatting, a full GMSK modulate → demodulate round-trip at three samples-per-symbol, survival under additive Gaussian noise, and the streaming chunked decoder. One honest caveat is carried in the source and repeated here (§7): the exact on-air octet bit order has not yet been confirmed against a real over-the-air capture, so the native backend ships disabled by default pending that verification. This document is written so a DSP-literate reader can reimplement the receiver from scratch; every load-bearing constant is inlined in full from the source, and the message-content bit-field maps (which the native core delegates to pyais) are tabulated from ITU-R M.1371.

The AIS toolbox and LIME AIS map feed in the Rafe UI


1. Where the native decoder sits

Two things share the name "AIS" in this repo, and the split matters for the rest of the document:

layer file what it does
physical + link (native) aisdecode.py IQ → GMSK demod → NRZI → HDLC/CRC → 6-bit armour → !AIVDM string. Stops at the armoured payload.
transport + application ais.py UDP listener, multipart reassembly, pyais field decode, MMSI aggregation, map push. Backend-agnostic.

ais.py does not care where an !AIVDM sentence came from — the local LimeSDR via the AIS-catcher subprocess, a networked Raspberry-Pi collector forwarding NMEA over UDP, or the native in-repo demodulator. All three converge on the same _on_nmea() handler. The native decoder's entire job is to manufacture, from raw IQ, byte-for-byte the same sentence AIS-catcher would have sent.

Critical boundary for this spec. The native core decodes the link layer and hands off a 6-bit-armoured payload inside an AIVDM sentence. It does not itself parse the AIS message bit-fields (nav status, latitude, ship name, …) — that is done downstream by the third-party pyais library (ais.py), msg = _ais_decode(*parts). Sections 5 and 6 therefore document the message-content layouts as the ITU-R M.1371 reference that pyais implements, clearly separated from the constants that live in Rafe's own code. Nothing in §5.4–§5.6 is a Rafe-invented constant; it is the on-air standard that the armoured payload carries.


2. Background

2.1 AIS the system

AIS is a self-organising VHF data network for collision avoidance and vessel traffic services. Every equipped ship transmits short bursts — most importantly a position report (its MMSI, latitude/longitude, speed over ground, course over ground, heading, navigational status) every few seconds to few minutes depending on speed and manoeuvre, and a static/voyage report (ship name, call sign, type, dimensions, draught, destination, ETA) every few minutes. Receivers ashore and afloat assemble these into a live traffic picture.

Two transmitter classes exist. Class A (SOLAS-mandated commercial vessels) transmits at 12.5 W using SOTDMA — Self-Organising Time-Division Multiple Access — in which each station listens, maps which of the 2250 time slots per minute (per channel) are free, reserves slots, and announces its future reservations so the network coordinates itself with no central controller. Class B (smaller/leisure craft) transmits at lower power using CSTDMA (carrier-sense TDMA) or SOTDMA, in a distinct set of message types. A minute is divided into 2250 slots of 26.67 ms each; one slot carries one nominal 256-bit burst at 9600 bit/s. Rafe's receiver is slot-agnostic — it does not model SOTDMA at all; it simply demodulates whatever bursts land in the IQ and lets CRC decide validity.

2.2 The two VHF channels

AIS uses two 25 kHz simplex channels in the marine VHF band:

  • AIS 1 — channel 87B — 161.975 MHz (Rafe's channel "A")
  • AIS 2 — channel 88B — 162.025 MHz (Rafe's channel "B")

They are 50 kHz apart, symmetric about 162.000 MHz, so a single SDR tuned to that centre with ≥ 50 kHz of usable bandwidth captures both at ±25 kHz. Rafe tunes exactly there and splits the two channels digitally (§4.1). Ship transmissions alternate between the two channels to double capacity and add frequency diversity.

2.3 GMSK

The modulation is Gaussian Minimum Shift Keying: a continuous-phase FSK in which the two tones are separated by exactly half the bit rate (minimum shift, modulation index h = 0.5), and the NRZ data is passed through a Gaussian pulse-shaping filter before it drives the phase, so the spectrum is compact and the phase trajectory is smooth (no discontinuities → low adjacent-channel splatter). The Gaussian filter's time-bandwidth product BT ≈ 0.4 for AIS controls how much each bit's energy smears into its neighbours (inter-symbol interference traded for bandwidth). Because h = 0.5, one bit advances or retards the carrier phase by exactly ±π/2; a simple FM discriminator (the derivative of phase) therefore recovers the data as a bipolar baseband signal whose sign is the bit — which is exactly what Rafe's demodulator does (§4.2).

AIS wraps its payload in HDLC framing — the same bit-oriented link layer as X.25 and amateur AX.25 packet radio:

  • A burst is delimited by the flag octet 0x7E = 01111110 at both ends.
  • The flag is the only place six consecutive 1s may appear; inside the frame the transmitter bit-stuffs a 0 after every run of five 1s so no data can imitate a flag. The receiver removes those stuffed zeros.
  • Frame integrity is a 16-bit Frame Check Sequence — CRC-16/X.25 (the HDLC FCS): CCITT polynomial 0x1021, reflected, initial value 0xFFFF, final inversion. A frame whose recomputed FCS disagrees with the received FCS is discarded.
  • The whole framed bit stream is line-coded NRZI (§2 below), and the receiver is preceded by a training sequence of alternating bits for AGC/clock acquisition.

This is deliberately AX.25-shaped; the amateur-radio reader can think of an AIS burst as an AX.25-style HDLC frame at 9600 baud with a fixed application payload.

2.5 AIVDM / NMEA armouring

The decoded payload is a run of binary bits, but the marine-electronics ecosystem (chart plotters, gpsd, pyais, AIS-catcher) speaks NMEA 0183 — an ASCII, comma-delimited sentence format. AIS binary is therefore armoured into printable ASCII: the bit stream is chopped into 6-bit groups, and each group's value (0–63) is mapped to one printable character (the "6-bit ASCII" or "AIVDM payload armour" table, §6.1). The armoured payload rides in an !AIVDM sentence together with a fragment count/index (for messages too long for one sentence), the receive channel, a fill-bits count (how many padding bits were added to reach a 6-bit boundary), and an XOR checksum. Rafe's native decoder produces these sentences; the rest of the pipeline consumes them exactly as it consumes AIS-catcher or remote-collector NMEA.


3. Signal structure — exact numbers

3.1 Channels, rate, modulation

parameter value source
Bit rate 9600 bit/s BAUD = 9600 (aisdecode.py)
Modulation GMSK, h = 0.5 phase = np.cumsum(shaped) * (math.pi / 2.0 / round(sps)) # h = 0.5 (aisdecode.py)
Gaussian BT 0.4 _gauss(sps, bt=0.4, span=4) (aisdecode.py); module docstring
Line code NRZI (0 → transition) nrzi_encode / nrzi_decode (aisdecode.py)
AIS 1 ("A") 161.975 MHz AIS_CH = {"A": 161.975e6, "B": 162.025e6} (aisdecode.py)
AIS 2 ("B") 162.025 MHz same
Capture centre 162.000 MHz AIS_CENTER = 162.0e6 (aisdecode.py)
Channel offset ±25 kHz "off": freq - center (aisdecode.py)

At the production sample-rate default (AIS_SAMPLE_RATE = 240000, ais.py) each channel is decimated to ~48 kHz (dec_rate=48000, aisdecode.py), giving 5.0 samples/symbol (240000 / 5 / 9600 = 5.0). The self-tests exercise sps ∈ {5, 10, 25} (test_ais.py).

3.2 Burst / frame layout

The full on-air burst Rafe builds and expects, in transmission order (hdlc_frame, aisdecode.py, returning TRAINING + flag + stuffed + flag + TRAINING):

┌──────────┬──────┬───────────────────────────────┬──────┬──────────┐
│ TRAINING │ FLAG │  payload bits ‖ 16-bit FCS     │ FLAG │ TRAINING │
│ 24 bits  │ 0x7E │  (bit-stuffed after 5 ones)    │ 0x7E │ 24 bits  │
│ 0101…01  │      │  168 (or 424, …) + 16 bits     │      │ 0101…01  │
└──────────┴──────┴───────────────────────────────┴──────┴──────────┘
                     └── whole stream then NRZI-encoded, then GMSK ──┘
  • TRAININGTRAINING = [0, 1] * 12 = a 24-bit 0101… preamble (aisdecode.py). Maximum transition density after NRZI, so AGC settles and symbol timing can be found. (The on-air AIS standard specifies a 24-bit preamble followed by an 8-bit start flag; Rafe's [0,1]*12 matches the 24-bit preamble length.)
  • FLAGFLAG = 0x7E sent MSB-first, 01111110 (aisdecode.py).
  • payload — the AIS message bits. A Class-A position report (types 1/2/3) is 168 bits; a type-5 static/voyage report is 424 bits; other types vary (§5.4). The tests use a 168-bit random payload (test_ais.py).
  • FCS — 16-bit CRC-16/X.25, appended LSB-first: body += [(fcs >> k) & 1 for k in range(16)] (aisdecode.py).
  • A trailing FLAG + TRAINING buffer follows the closing flag so the closing delimiter is never at the very edge where discriminator tail-loss could truncate it (comment, aisdecode.py).

The link layer imposes no fixed payload length; the CRC does the validation and the message type (first 6 bits of the payload) implies the expected length downstream.


4. Demodulation, step by step

Pipeline (RX), from wideband IQ to CRC-valid data bits:

 wideband IQ @ fs (centre 162.0 MHz)
   │  AisDualChannel.feed_iq
   ▼
 per channel:  NCO mix by ±25 kHz ─▶ 63-tap LPF (7 kHz) ─▶ decimate to ~48 kHz
   │  AisNativeDecoder.feed_iq  (buffer ~one burst)
   ▼
 gmsk_demod_data:
   differential phase  disc = ∠(iq[n]·conj(iq[n-1]))     (FM discriminator)
   ─▶ 1-symbol moving average (matched-ish)
   ─▶ resample to os = 8 samples/symbol
   ─▶ for each of 8 timing phases:  threshold (>0) ─▶ NRZI decode  ─▶ candidate bit stream
   ▼
 hdlc_deframe (per candidate):
   find 0x7E flags ─▶ segment ─▶ de-stuff ─▶ split payload / 16-bit FCS ─▶ CRC check
   ▼
 CRC-valid payload bits

4.1 Dual-channel front end (AisDualChannel, aisdecode.py)

Given wideband complex IQ sampled at fs and centred on AIS_CENTER, for each of the two AIS channels:

  1. NCO down-mix. Multiply by a complex exponential at that channel's offset from centre, using a continuous global sample index n (self._n) so phase is unbroken across feed blocks: mixed = iq * np.exp(-2j * np.pi * ch["off"] * n / self.fs) (aisdecode.py). For channel "A", off = 161.975e6 − 162.0e6 = −25 kHz; for "B", +25 kHz.
  2. Low-pass. Convolve with a 63-tap FIR, cutoff 7000 Hz (_lpf_taps(7000.0, self.fs), aisdecode.py). Taps come from scipy.signal.firwin(63, cutoff/(fs/2)) when SciPy is present, else a NumPy windowed-sinc × Hamming fallback (aisdecode.py). A 7 kHz one-sided passband (~14 kHz two-sided) selects one 9600-baud GMSK signal and rejects the other channel 50 kHz away.
  3. Decimate. Keep every decim-th sample (decim = round(fs / 48000)), with a running dphase so the decimation grid is continuous across blocks (aisdecode.py). Each channel then runs its own AisNativeDecoder at sps = fs_dec / BAUD.

4.2 GMSK bit recovery (gmsk_demod_data, aisdecode.py)

The demodulator is an FM discriminator, not a coherent receiver — appropriate for GMSK with h = 0.5, whose instantaneous frequency is the data.

  1. Differential phase (instantaneous frequency). disc = np.angle(iq[1:] * np.conj(iq[:-1])) (aisdecode.py). Each sample is the phase step between consecutive IQ samples; for GMSK that step is positive for a +1 symbol and negative for a −1.
  2. Matched-ish smoothing. A moving average one symbol wide integrates each symbol's energy and suppresses noise: disc = np.convolve(disc, np.ones(win)/win, mode="same"), win = round(sps) (aisdecode.py).
  3. Oversample to 8 phases. Resample the discriminator output to os = 8 samples per symbol by linear interpolation: pos = np.arange(nbits*os) * (sps/os); r = np.interp(pos, np.arange(disc.size), disc) (aisdecode.py).
  4. Symbol timing = brute force. Rather than a Gardner/Mueller timing-recovery loop, the decoder simply tries all eight sub-symbol phases and returns one candidate bit stream per phase: return [nrzi_decode((r[phase::os] > 0).astype(int)) for phase in range(os)] (aisdecode.py). AIS bursts are short with a stable clock, so a single fixed phase per burst is adequate; the correct phase is the one whose stream yields a CRC-valid frame (§4.4). Slicing is a plain threshold at zero (> 0).
  5. NRZI decode. Each thresholded stream is NRZI-decoded (§4.3). NRZI is polarity-agnostic — it depends only on transitions — so a spectrally inverted discriminator (I/Q swap, wrong-sideband tuning) is handled automatically with no extra logic (comment, aisdecode.py).

4.3 NRZI decode (nrzi_decode, aisdecode.py)

NRZI (Non-Return-to-Zero Inverted) encodes each data bit as transition or not: a data 0 flips the line level, a data 1 holds it (nrzi_encode, aisdecode.py: if b == 0: level ^= 1). Decoding inverts this — compare each level to the previous:

out, prev = [], levels[0] if len(levels) else 0
for lv in levels[1:]:
    out.append(1 if lv == prev else 0)   # same level → 1, changed → 0
    prev = lv

Two consequences the tests pin down: NRZI drops the first bit (there is no prior level to compare against — test_nrzi_is_differential asserts nrzi_decode(nrzi_encode(bits)) == bits[1:], test_ais.py); and because it is differential it is immune to an overall polarity flip, which is why §4.2 does not need to resolve discriminator sign. The training preamble supplies the leading transitions so no data bit is the sacrificed first bit.

4.4 HDLC deframe + CRC (hdlc_deframe, aisdecode.py)

Given one candidate NRZI-decoded stream:

  1. Flag hunt (bit-unaligned). Build the flag bit pattern MSB-first, flagpat = (0,1,1,1,1,1,1,0), and scan every bit position for an 8-bit window equal to it: flags = [i for i in range(n-7) if tuple(bits[i:i+8]) == flagpat] (aisdecode.py). No octet alignment is assumed.
  2. Segment. For each adjacent pair of flag positions (a, b), the candidate frame is seg = bits[a+8:b] — the bits strictly between two flags (aisdecode.py).
  3. De-stuff. Walk the segment removing the zero that follows any run of five 1s (aisdecode.py):
    de, ones = [], 0
    for bit in seg:
        if ones == 5:
            ones = 0
            if bit == 0:
                continue        # drop the stuffed 0
        de.append(bit)
        ones = ones + 1 if bit == 1 else 0
  4. Split and check. The last 16 de-stuffed bits are the FCS (interpreted LSB-first), the rest is the payload: payload, fcs_bits = de[:-16], de[-16:]; fcs_rx = sum(bit << k for k, bit in enumerate(fcs_bits)) (aisdecode.py). The frame is accepted only if _fcs(payload) == fcs_rx (aisdecode.py); every valid payload is collected. Segments shorter than 16 bits are skipped.

Because all eight timing phases from §4.2 are deframed and only CRC-valid payloads survive, the receiver needs no explicit timing decision — the CRC is both the frame validator and the timing/phase selector. decode_iq (aisdecode.py) runs this over every candidate stream, wraps each surviving payload as an AIVDM sentence (§5), and de-duplicates by sentence string.

4.5 The FCS itself (_fcs, aisdecode.py)

def _fcs(bits) -> int:
    crc = 0xFFFF
    for b in bits:
        crc ^= (int(b) & 1)
        crc = (crc >> 1) ^ 0x8408 if (crc & 1) else (crc >> 1)
    return crc ^ 0xFFFF

This is CRC-16/X.25 (a.k.a. the HDLC FCS): the CCITT polynomial 0x1021 reflected to 0x8408, initial value 0xFFFF, bit-reflected (LSB-first) input and output, final XOR 0xFFFF. It is computed bit-by-bit over the payload only; the transmitter appends it LSB-first (§3.2), and the receiver reconstructs it LSB-first (§4.4), so the two agree.

4.6 Streaming and buffering (AisNativeDecoder, aisdecode.py)

feed_iq concatenates incoming IQ into an internal buffer and only attempts a decode once it holds need = int(self.sps * 320) samples — roughly one maximum AIS burst of 320 bit-times (aisdecode.py). It then decodes the whole buffer, emits each new sentence through the on_nmea callback, and retains a burst-length tail (self._buf[-need:]) so a burst split across two feed calls is still seen whole (aisdecode.py). A per-decoder seen set suppresses duplicates and is capped (trimmed from 4000 to 2000 entries) to bound memory (aisdecode.py). test_streaming_decoder (test_ais.py) feeds a zero-padded burst in 600-sample chunks and confirms the 168-bit payload is recovered — validating the straddle-tail logic.


5. Decode, step by step (payload → ship track)

5.1 Re-armour to 6-bit ASCII (armor, aisdecode.py)

A CRC-valid payload is a list of bits. To become an NMEA sentence it is packed into 6-bit groups, most-significant bit first within each group, and each 0–63 value mapped to a printable character:

for i in range(0, len(bits) - 5, 6):
    v = 0
    for k in range(6):
        v = (v << 1) | int(bits[i + k])       # MSB-first within the group
    out.append(chr(v + 48 if v < 40 else v + 56))

The +48 / +56 split is the standard AIVDM armour: values 0–39 → ASCII 48–87 ('0''W'), values 40–63 → ASCII 96–119 ('`''w'), leaving the unprintable/awkward range ASCII 88–95 unused (§6.1). Only whole 6-bit groups are emitted (the len(bits) - 5 bound). The inverse dearmor (aisdecode.py) does v = ord(ch) - 48; if v > 40: v -= 8 then unpacks 6 bits MSB-first — this is the function the tests and pyais-side consumers use to recover payload bits from a sentence.

test_armor_roundtrip (test_ais.py) asserts dearmor(armor(bits)) == bits over 48 bits.

5.2 Build the AIVDM sentence (build_aivdm, aisdecode.py)

p = armor(payload_bits)
fill = (6 - (len(payload_bits) % 6)) % 6
body = f"AIVDM,1,1,,{channel},{p},{fill}"
return f"!{body}*{nmea_checksum(body):02X}"
  • Fragment count/index are hard-coded 1,1 — the native decoder always emits a single-fragment sentence and never splits long payloads across multiple AIVDM lines (see Limitations, §8).
  • channel is "A" or "B" from the receiving AIS channel (§3.1).
  • fill is the number of padding bits added to reach a 6-bit boundary ((6 − len%6) % 6), reported so the consumer can discard them.
  • checksum is the NMEA 0183 XOR of every character between ! and *, two uppercase hex digits (nmea_checksum, aisdecode.py): cs = 0; for c in body: cs ^= ord(c).

test_aivdm_format_and_checksum (test_ais.py) checks the !AIVDM,1,1,,A, prefix and that the trailing *CS matches the recomputed checksum.

5.3 Ingest, reassembly and dispatch (ais.py)

Every !-prefixed line — whether from the native decoder, the AIS-catcher subprocess, or a remote UDP collector — enters AisReceiver._on_nmea (ais.py):

  1. Parse the envelope !AIVDM,<count>,<idx>,<seq>,<chan>,<payload>,<fill>*CS into fields; read count and idx (ais.py).
  2. Multipart reassembly. If count > 1, buffer fragments keyed by the sequential message id f[3], and only proceed once all count fragments are present, ordered by index (ais.py). (Long messages — e.g. type 5 — are conventionally sent as two AIVDM fragments; reassembly here handles inbound multi-fragment feeds even though the native encoder never produces them.)
  3. Field decode via pyais. msg = _ais_decode(*parts); d = msg.asdict() (ais.py). This is where the message-type bit-fields of §5.4–§5.6 are actually parsed. Failures are swallowed (ais.py).
  4. Message-type dispatch and aggregation (_ingest, ais.py):
    • Ships are keyed by MMSI (d.get("mmsi")).
    • Position message types — POS_TYPES = {1, 2, 3, 18, 19, 27} (ais.py) — update lat/lon/SOG/COG/heading, with validity gates abs(lat) <= 90, abs(lon) <= 180, and rejection of the AIS "not available" sentinels lat == 91 / lon == 181 (ais.py).
    • Static message types (5, 24) carry shipname, cached per MMSI and attached to the track (ais.py); the name is trimmed of the AIS @ padding (rstrip("@ ")).
    • Stale ships (not heard for STALE_S = 900 s, i.e. 15 min) are pruned (ais.py); the UI receives at most 600 ships, most-recent first (ais.py).

The remainder of §5 documents the bit-field layouts pyais applies at step 3. These are ITU-R M.1371 definitions, not Rafe constants — included so the spec is self-contained end-to-end. Field offsets are bit positions within the de-armoured payload, MSB-first, bit 0 = first bit.

5.4 Position reports — types 1 / 2 / 3 (168 bits)

The Class-A position report. All three types share one layout (they differ only in the transmission trigger: 1 scheduled, 2 assigned, 3 special/response).

bits field interpretation
0–5 Message type 1, 2 or 3
6–7 Repeat indicator 0–3
8–37 MMSI 30-bit Maritime Mobile Service Identity
38–41 Navigation status 0 = under way using engine, 1 = at anchor, 5 = moored, 8 = sailing, 15 = undefined, …
42–49 Rate of turn (ROT) signed; AIS ROTAIS = 4.733·√(°/min)
50–59 Speed over ground (SOG) in 0.1 knot units; 1023 = not available
60 Position accuracy 1 = DGPS-grade (<10 m)
61–88 Longitude signed 28-bit, in 1/10000 minute; 181° (= 108600000) = not available
89–115 Latitude signed 27-bit, in 1/10000 minute; 91° (= 54600000) = not available
116–127 Course over ground (COG) in 0.1°; 3600 = not available
128–136 True heading in (0–359); 511 = not available
137–142 Time stamp UTC second (0–59); 60 = N/A
143–144 Manoeuvre indicator
145–147 Spare
148 RAIM flag
149–167 Radio/SOTDMA status slot-timing state

Rafe reads SOG as speed, COG as course, heading as heading from the pyais dict (ais.py); the lat == 91 / lon == 181 sentinel guard (ais.py) is exactly the degrees-domain form of the 91°/181° "not available" codes above (after pyais scales the raw 1/10000-minute integer to degrees).

5.5 Static and voyage data — type 5 (424 bits)

The Class-A static/voyage report — the longest common message, and the reason for multipart AIVDM (2 fragments).

bits field interpretation
0–5 Message type 5
6–7 Repeat indicator
8–37 MMSI 30-bit
38–39 AIS version 0 = ITU-R M.1371-1
40–69 IMO number 30-bit
70–111 Call sign 7 × 6-bit ASCII characters (§6.2)
112–231 Vessel name 20 × 6-bit ASCII characters
232–239 Ship/cargo type code
240–248 Dimension to bow (A) metres
249–257 Dimension to stern (B) metres
258–263 Dimension to port (C) metres
264–269 Dimension to starboard (D) metres
270–273 EPFD type GPS/GLONASS/…
274–277 ETA month
278–282 ETA day
283–287 ETA hour
288–293 ETA minute
294–301 Draught in 0.1 m
302–421 Destination 20 × 6-bit ASCII characters
422 DTE
423 Spare

Text fields (name, call sign, destination) use the 6-bit ASCII character set of §6.2 and are @-padded to fixed length; Rafe strips that padding when caching the name (rstrip("@ "), ais.py).

5.6 Class-B — types 18 / 19 / 24

Class-B equipment (smaller vessels) uses distinct message types:

  • Type 18 — Class-B position report (168 bits). Same essential position fields as type 1 (MMSI, SOG at 0.1 kn, position accuracy, lon/lat at 1/10000 min, COG at 0.1°, true heading at 1°, timestamp) but with a Class-B "capability" flag block (bits 141–147: unit/display/DSC/band/message-22/assigned/RAIM) in place of the nav-status/manoeuvre fields. In POS_TYPES (ais.py).
  • Type 19 — Class-B extended position report (312 bits). Type-18 position fields plus the static block: 20-character ship name (bits 143–262), ship type, and A/B/C/D dimensions — a position and identity in one message. In POS_TYPES.
  • Type 24 — Class-B static data report (160 bits), two parts. Split because Class-B cannot fit all static data in one slot. Part A (part_number = 0) carries the 20-character ship name; Part B (part_number = 1) carries ship type, vendor ID, call sign, and A/B/C/D dimensions (or, for a buddy/auxiliary craft, the mothership MMSI). Rafe consumes type 24 for the shipname field (ais.py); it is not in POS_TYPES, so it updates the name of an already-tracked ship but does not by itself place a marker (ais.py).

Type 27 (long-range position, 96 bits) is also in POS_TYPES (ais.py) — a coarse, low-rate position for satellite AIS. Its SOG/COG resolutions are coarser (1 kn / 1°) and its lat/lon are in 1/10 minute, but pyais normalises all of these to the same speed/course/lat/lon keys Rafe reads.


6. Constants & tables

6.1 The 6-bit ASCII payload armour (AIVDM) — full 64-entry table

The mapping used by armor / dearmor (aisdecode.py). Value = 6-bit group (0–63); character = the printable ASCII sent in the sentence.

armor:   char = chr(value + 48)            if value < 40      # values 0..39 → '0'..'W'  (ASCII 48..87)
         char = chr(value + 56)            if value >= 40     # values 40..63 → '`'..'w'  (ASCII 96..119)
dearmor: value = ord(char) - 48 ; value -= 8 if value > 40    # inverse

The complete table — all 64 entries, no ellipsis — as value:'char':

 0:'0'   1:'1'   2:'2'   3:'3'   4:'4'   5:'5'   6:'6'   7:'7'
 8:'8'   9:'9'  10:':'  11:';'  12:'<'  13:'='  14:'>'  15:'?'
16:'@'  17:'A'  18:'B'  19:'C'  20:'D'  21:'E'  22:'F'  23:'G'
24:'H'  25:'I'  26:'J'  27:'K'  28:'L'  29:'M'  30:'N'  31:'O'
32:'P'  33:'Q'  34:'R'  35:'S'  36:'T'  37:'U'  38:'V'  39:'W'
40:'`'  41:'a'  42:'b'  43:'c'  44:'d'  45:'e'  46:'f'  47:'g'
48:'h'  49:'i'  50:'j'  51:'k'  52:'l'  53:'m'  54:'n'  55:'o'
56:'p'  57:'q'  58:'r'  59:'s'  60:'t'  61:'u'  62:'v'  63:'w'

As a single ordered string (string index = 6-bit value):

0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVW`abcdefghijklmnopqrstuvw
value range ASCII code range characters
0 … 39 48 … 87 0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVW
40 … 63 96 … 119 `abcdefghijklmnopqrstuvw

Boundary points: value 0'0' (48+0); value 39'W' (48+39=87); value 40'`' (40+56=96); value 63'w' (63+56=119). The gap ASCII 88–95 (X Y Z [ \ ] ^ _) is skipped, which is exactly the NMEA "6-bit binary → printable" convention shared with gpsd and pyais.

Worked armour example. The three 6-bit groups [1, 5, 29] — binary 000001 000101 011101 — armour to the characters 1, 5, M: value 1 → chr(48+1) = '1', value 5 → chr(48+5) = '5', value 29 → chr(48+29) = 'M', giving the payload fragment 15M. The inverse dearmor("15M") recovers those 18 bits exactly (ord('1')−48 = 1, ord('5')−48 = 5, ord('M')−48 = 29; none is

40, so no −8 correction) → [1, 5, 29]. test_armor_roundtrip (test_ais.py) checks this round-trip over 48 random bits. (This is also why real Class-A type-1 position AIVDM payloads begin with 1: message type 1 = 6-bit value 1 → leading '1'.)

6.2 The 6-bit text character set (message content)

Distinct from §6.1: within type-5/19/24 payloads, text fields (name, call sign, destination) are their own 6-bit code — value 0–63 → the character set below (ITU-R M.1371 Table 44), 64 entries in value order:

@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_ !"#$%&'()*+,-./0123456789:;<=>?

i.e. value 0 → @ (the pad), 1–26 → AZ, 27 → [, 28 → \, 29 → ], 30 → ^, 31 → _, 32 → space, 33–47 → !"#$%&'()*+,-./, 48–57 → 09, 58–63 → :;<=>?. Rafe does not implement this table itself — pyais does — but it strips the @ padding when caching a name (ais.py). Do not confuse §6.1 (transport armour) with §6.2 (message-content charset); they are different tables applied at different layers.

6.3 CRC-16/X.25 frame-check sequence

parameter value
Name CRC-16/X.25 (HDLC FCS)
Polynomial 0x1021 (CCITT), reflected form 0x8408 (aisdecode.py)
Initial value 0xFFFF (aisdecode.py)
Reflect in / out yes (processed LSB-first)
Final XOR 0xFFFF (aisdecode.py)
Placement 16 bits after payload, LSB-first on the wire (aisdecode.py)
Check recomputed over de-stuffed payload; frame dropped on mismatch (aisdecode.py)

The full bit-serial routine (from _fcs) is: crc = 0xFFFF; for each payload bit b, crc ^= (b & 1) then crc = (crc >> 1) ^ 0x8408 if the shifted-out LSB was 1 else crc >>= 1; finally return crc ^ 0xFFFF.

constant value source file
BAUD 9600 aisdecode.py
FLAG 0x7E (01111110, MSB-first) aisdecode.py
TRAINING [0, 1] * 12 (24-bit 0101…) aisdecode.py
_HDLC_LSB_FIRST Truedeclared, not yet wired in (see §7) aisdecode.py
Bit-stuff rule insert 0 after five consecutive 1s aisdecode.py
NRZI rule data 0 → transition, data 1 → hold aisdecode.py

6.5 GMSK / DSP constants

constant value source file
Gaussian BT 0.4 aisdecode.py
Gaussian span 4 symbols aisdecode.py
Gaussian σ sqrt(ln 2) / (2π·BT) aisdecode.py
Modulation index h 0.5 (phase step ±π/2 per bit) aisdecode.py
Demod oversample os 8 timing phases aisdecode.py
Discriminator ∠(iq[1:]·conj(iq[:-1])) aisdecode.py
Matched-ish MA width round(sps) samples aisdecode.py
Slice threshold > 0 aisdecode.py
Channel LPF 63-tap FIR, cutoff 7000 Hz aisdecode.py
Decimation target 48000 Hz aisdecode.py
Burst buffer int(sps · 320) samples aisdecode.py

6.6 RF and transport constants

constant value source file
AIS_CENTER 162.0e6 Hz aisdecode.py
AIS_CH["A"] 161.975e6 Hz (AIS 1, ch 87B) aisdecode.py
AIS_CH["B"] 162.025e6 Hz (AIS 2, ch 88B) aisdecode.py
AIS_UDP_PORT 10110 (env AIS_UDP_PORT) ais.py
AIS_BACKEND "catcher" default ("native" opt-in) ais.py
AIS_SAMPLE_RATE 240000 Hz (env) ais.py
STALE_S 900 s (15 min) ais.py
POS_TYPES {1, 2, 3, 18, 19, 27} ais.py
lat/lon sentinels rejected lat == 91, lon == 181 ais.py

6.7 Coordinate and kinematic scaling (message content, ITU-R M.1371)

Applied by pyais; the raw payload integers, before scaling:

quantity raw units scale to engineering "not available"
Latitude 1/10000 minute, signed 27-bit ÷ (10000 · 60) → degrees 91° (raw 54600000)
Longitude 1/10000 minute, signed 28-bit ÷ (10000 · 60) → degrees 181° (raw 108600000)
SOG 0.1 knot ÷ 10 → knots 1023
COG 0.1 degree ÷ 10 → degrees 3600
True heading 1 degree as-is 511
ROT AISROT (ROT/4.733)² °/min, signed −128 / 128

6.8 IQ capture formats (read_iq_file, aisdecode.py)

fmt on-disk conversion to complex64
cf32 interleaved complex float32 direct (np.fromfile(..., complex64))
cu8 RTL-SDR unsigned 8-bit I/Q (uint8 − 127.5) / 127.5, then I + jQ
ci16 signed 16-bit I/Q int16 / 32768.0, then I + jQ

7. Interoperability & validation

7.1 In-repo self-tests (test_ais.py)

The test module validates the DSP/framing chain end-to-end and self-consistently (there is a transmit path — ais_modulate, aisdecode.py — built purely for this):

test what it proves
test_armor_roundtrip 6-bit armour ↔︎ de-armour is lossless
test_nrzi_is_differential NRZI decode inverts encode (minus the sacrificed first bit)
test_hdlc_crc_frame_and_reject a framed payload round-trips and a single flipped bit is CRC-rejected
test_aivdm_format_and_checksum !AIVDM,1,1,,A,… envelope + XOR checksum correct
test_gmsk_roundtrip_all_sps 168-bit payload survives GMSK modulate→demod at sps ∈ {5,10,25}
test_gmsk_survives_awgn same, with complex AWGN (σ = 0.2) added to the burst
test_streaming_decoder chunked feed_iq (600-sample blocks over a zero-padded burst) recovers the payload

Together these exercise: armour, NRZI, HDLC stuffing/deframing, CRC accept and reject, GMSK modulation and FM-discriminator demodulation across sample rates, noise tolerance, the eight-phase timing search, and the streaming buffer/tail logic. What they do not prove is on-air interoperability — every test both generates and consumes with the same code, so any shared convention error would pass silently.

7.2 The on-air bit-order caveat

The module is explicit about the one unverified assumption (aisdecode.py docstring):

the framing/CRC/armor here round-trips self-consistently … which validates the GMSK+HDLC mechanics. The exact on-air bit order (HDLC LSB-first octets vs AIS MSB-first fields) must be confirmed against a real capture on hardware before it is guaranteed interoperable; the one place that matters is _HDLC_LSB_FIRST.

_HDLC_LSB_FIRST = True (aisdecode.py) is declared but not referenced anywhere in the code — a placeholder marking the exact spot where the octet bit-order switch will go once a real capture pins it down. HDLC transmits octets LSB-first; AIS numeric fields are defined MSB-first; whether the armour/deframe path needs a per-octet reversal to line those up is the open question. Consequently the app ships with AIS_BACKEND = "catcher" as the default and the native decoder off until validated (ais.py).

7.3 Cross-decoder validation path

The offline harness is built to close that loop against reference decoders. Running the module directly (aisdecode.py):

python -m app.radio.aisdecode <capture.iq> --fs <Hz> [--fmt cf32|cu8|ci16] \
       [--center 162.0e6] [--single]

decodes an IQ capture to !AIVDM sentences (both channels via decode_capture, aisdecode.py, or one baseband channel with --single). Its argparse help states the intent outright: "compare against AIS-catcher to validate interop" (aisdecode.py). The validation procedure is: feed the same capture to AIS-catcher (or gpsd's gpsdecode) and to this harness, and diff the emitted AIVDM sentences. Once they match on real traffic, _HDLC_LSB_FIRST is confirmed and AIS_BACKEND=native can become a supported default.

Downstream, the field-level decode is already interoperable by construction because it is delegated to pyais (ais.py) — the same widely-used library that decodes AIS-catcher and remote-collector feeds. So the only native-vs-reference gap that can exist is at the physical/link layer, which is precisely what §7.2 flags.

7.4 The three feed backends (all converge on pyais)

backend how it produces AIVDM selector
AIS-catcher subprocess AIS-catcher -gu SOAPYSDR: … -u 127.0.0.1 <port> → UDP → listener default (ais.py)
Remote collector Raspberry Pi + RTL-SDR runs AIS-catcher, forwards NMEA over UDP any UDP source (ais.py)
Native in-repo aisdecode.run_native_ais off a low-level SDR (ctypes, no SoapySDR) AIS_BACKEND=native (ais.py)

All three call the identical _on_nmeapyais_ingest path, so a track on the map is indistinguishable regardless of origin — the design goal that makes the native decoder a drop-in.


8. Limitations

Honest boundaries of the current native implementation:

  • On-air bit order unproven (the big one). As §7.2: the octet bit-order versus AIS field order has not been confirmed against a real capture, so the native backend is disabled by default. Everything self-consistent works; over-the-air interop is pending hardware validation at _HDLC_LSB_FIRST.
  • No message-content parsing in the native core. aisdecode.py stops at the 6-bit-armoured AIVDM sentence; all field decode (types 1/2/3/5/18/19/24/27, coordinate scaling, text charset) is delegated to pyais (ais.py). If pyais is absent, sentences are produced but no ship tracks appear (ais.py).
  • Single-fragment transmit only. build_aivdm hard-codes 1,1 (aisdecode.py); a long payload (e.g. type 5's 424 bits) is emitted as one over-length AIVDM sentence rather than the standard two fragments. Inbound multipart reassembly is supported (ais.py), but the native path never creates multi-fragment sentences, which some strict consumers may reject.
  • Brute-force, non-adaptive timing. Symbol timing is chosen by trying all eight sub-symbol phases and keeping whatever passes CRC (aisdecode.py) — fine for short bursts with a stable clock, but there is no tracking loop, no per-symbol timing correction, and no soft-decision/Viterbi equalisation of the GMSK ISI. Weak or frequency-offset bursts that a dedicated GMSK receiver (with CFO estimation and matched filtering) would recover may be missed.
  • No CFO / Doppler correction. The discriminator tolerates modest offset, and the 7 kHz LPF passes a de-tuned signal, but there is no explicit carrier-frequency estimation; large tuning error or satellite-AIS Doppler degrades slicing.
  • No SOTDMA / slot awareness. The receiver is purely burst-opportunistic; it does not model slots, collisions, or the reservation map, so it cannot report channel loading or resolve two colliding bursts.
  • Coarse per-block NCO/decimation. AisDualChannel uses convolve(..., "same") and integer decimation with a phase accumulator; it is adequate but not a polyphase resampler, and the 63-tap LPF is a fixed compromise.
  • SDR capture is out of scope here. The low-level SDR source (LimeSuite / librtlsdr via ctypes, sdr_sources.py) that feeds IQ to the native decoder is a separate subsystem, deliberately not specified in this document.

None of these are architectural dead-ends; the first (bit-order confirmation) is the only one gating the native backend from being turned on by default.


9. Implementation & reproduction

file role
app/radio/aisdecode.py native physical/link layer — GMSK demod, NRZI, HDLC/CRC, 6-bit armour, AIVDM
app/radio/ais.py transport/application — UDP listener, multipart reassembly, pyais decode, MMSI aggregation
test_ais.py self-consistent DSP/framing test suite

Runtime API (native path):

from app.radio import aisdecode as A

# offline: decode a wideband IQ capture (both channels) → AIVDM sentences
sentences = A.decode_capture("cap.cf32", fs=240000, fmt="cf32", center=162.0e6)

# streaming: feed IQ for ONE channel at `sps` samples/symbol
d = A.AisNativeDecoder(on_nmea=print, sps=5.0, channel="A")
d.feed_iq(iq_block)                                  # emits !AIVDM via callback

# dual-channel from wideband IQ at sample-rate fs
rx = A.AisDualChannel(on_nmea=print, fs=240000, center=162.0e6)
rx.feed_iq(wideband_iq)

The physical layer is pure NumPy (SciPy only optionally, for a nicer LPF); the application layer needs pyais for field decode. Turning the native backend on is a single environment flag — AIS_BACKEND=native — once §7.2 is closed.


10. References

  1. ITU-R M.1371Technical characteristics for an automatic identification system using time division multiple access in the VHF maritime mobile band. The authoritative AIS air-interface spec: GMSK parameters, SOTDMA slotting, the two channels, HDLC framing, and every message-type bit-field (§5).
  2. IEC 61993-2Maritime navigation and radiocommunication equipment and systems — AIS — Part 2: Class A shipborne equipment. Conformance/operational requirements for Class-A transponders. (IEC 62287 covers Class B.)
  3. ISO/IEC 13239 — HDLC procedures: 0x7E flag, five-ones bit stuffing, and the 16-bit CRC-16/X.25 FCS used as the AIS link-layer frame check (§4.4–§4.5).
  4. IEC 61162 / NMEA 0183 — the !AIVDM/!AIVDO sentence format, fragment count/index, fill-bits, and the XOR checksum (§5.2).
  5. The AIVDM/AIVDO protocol decoding (E. S. Raymond, gpsd project) — the widely-used community reference for the 6-bit armour table (§6.1), the message layouts, and coordinate scaling; the practical cross-check target alongside gpsdecode.
  6. pyais — the Python AIS decoder Rafe delegates field-level parsing to (ais.py).
  7. AIS-catcher (jvde-github) — the reference SDR AIS receiver Rafe's default backend shells out to, and the interop benchmark for the native decoder (§7.3).

Source files

  • app/radio/aisdecode.py — native physical/link layer (GMSK demod, NRZI, HDLC/CRC, 6-bit armour, AIVDM).
  • app/radio/ais.py — transport/application layer (UDP listener, multipart reassembly, pyais decode, MMSI aggregation).
  • test_ais.py — self-consistent DSP/framing test suite.