Patrick Lidstone
Self-hosted

ADS-B / Mode S (1090 MHz): a native pulse-position demodulator and extended-squitter decoder

A clean-room, dependency-free implementation of the 1090 MHz Mode S air interface — from magnitude samples through the 8 µs PPM preamble and bit slicer to CRC-24-validated 56/112-bit frames, and from those frames to ICAO address, callsign, CPR position, altitude and velocity — that replaces the decode role of dump1090.

Rafe project · app/radio/adsb.py · validated against the canonical mode-s.org example frames


Abstract

adsb.py is a self-contained decoder for the two coupled layers of the Secondary Surveillance Radar Mode S / ADS-B downlink on 1090 MHz. The PHY layer takes magnitude samples |I + jQ| at ≥ 2 MS/s from any 1090 MHz receiver (an RTL-SDR or LimeSDR — the IC-705 cannot tune 1090 MHz), detects the 8 µs pulse-position preamble, and slices each 1 µs data bit into a 0 or 1, yielding raw 56-bit or 112-bit frames whose integrity is checked by the Mode S CRC-24 (generator 0xFFF409). The message layer dispatches on the 5-bit Downlink Format and, for the Downlink Format 17/18 extended squitter, reads the 24-bit ICAO address in the clear and decodes the 56-bit ME field by type code: aircraft identification (callsign), airborne position (Compact Position Reporting latitude/longitude plus barometric altitude), and airborne velocity.

The whole thing is pure Python with one NumPy dependency in the PHY (the DSP is a threshold correlation and a half-bit energy comparison — no FFT). It is validated interop-correct: it decodes the four canonical mode-s.org example frames to their published fields — callsign KLM1023, position 52.2572° N, 3.9194° E, ground speed 159.2 kt on heading 182.9° descending 832 ft/min — and a synthetic PPM round-trip through the module's own synth()/demodulate() recovers the frames bit-for-bit under additive noise at both 2 MS/s and 4 MS/s. The value for Rafe is a Mode S / 1090ES stack owned end-to-end: no dump1090, no librtlsdr decode chain, driven directly from SDR magnitude samples.


1. Scope and module map

The module (app/radio/adsb.py) is organised as two stacked layers, mirroring the module docstring:

  IQ / magnitude samples (>=2 MS/s)
        │  PHY
        ▼
  demodulate() ── preamble detect ─▶ PPM bit slice ─▶ CRC-24 gate ─▶ [56/112-bit frames]
        │  MESSAGE
        ▼
  decode() ── DF dispatch ─▶ typecode ─▶ {callsign | position+altitude | velocity}

The public surface:

function role source
crc24(data) / frame_ok(msg) Mode S CRC-24; validity gate adsb.py
df(msg) / typecode(msg) / icao(msg) header field extractors adsb.py
callsign / altitude / velocity ME-field decoders adsb.py
position(even, odd, latest) global CPR lat/lon decode adsb.py
decode(msg) one frame → field dict adsb.py
mag_from_iq(iq) IQ (or interleaved int16) → magnitude adsb.py
demodulate(mag, sps) magnitude → list of CRC-valid frames adsb.py
synth(msg, sps, gap) frame → magnitude samples (test only) adsb.py

Everything below documents the real implementation; every constant is inlined here from the source. Where the code omits a documented Mode S feature (surface position, Gillham altitude, address-overlay recovery, airspeed velocity subtypes) that is called out explicitly in §8, not glossed.


2. Background

2.1 Mode S and ADS-B on 1090 MHz

Secondary Surveillance Radar (SSR) works by interrogation and reply: a ground radar transmits on 1030 MHz, and aircraft transponders reply on 1090 MHz. Mode S ("Select") is the addressable evolution of SSR — every aircraft carries a globally unique 24-bit ICAO address, so a reply can be directed to, or attributed to, one specific airframe. ADS-B (Automatic Dependent Surveillance – Broadcast) rides the same 1090 MHz Mode S physical layer but is unsolicited: a compliant aircraft periodically broadcasts ("squitters") its own GPS-derived position, velocity and identity with no interrogation at all. These broadcasts use the 112-bit extended squitter in Downlink Format 17 (and DF18 for non-transponder emitters). That is what this decoder is built to receive: a passive listener on 1090 MHz needs no transmit capability, only a receiver, an antenna, and the two decode layers below.

2.2 Pulse-position modulation (PPM)

Mode S does not use a carrier-phase or frequency modulation for data; it uses pulse-position modulation of the 1090 MHz carrier. The line rate is 1 Mbit/s — one data bit per microsecond. Each 1 µs bit is divided into two 0.5 µs chips, and exactly one of the two chips carries an RF pulse:

 bit = 1 :  ██▁▁      pulse in the FIRST half-microsecond
 bit = 0 :  ▁▁██      pulse in the SECOND half-microsecond
            └┬┘└┬┘
          0.5µs 0.5µs

So although each bit is 1 µs, the pulse can sit in either of two 0.5 µs positions — the modulation resolves to a 2 Mchip/s pulse grid, which is why the receiver samples at 2 MS/s (one sample per chip). This is a form of Manchester-like on-off keying: the presence of energy is constant per bit, only its position within the bit carries information, which makes the demodulator a simple per-bit energy comparison rather than a threshold slicer sensitive to absolute amplitude (§4.2). It also means bit and preamble timing can be recovered purely from the pulse edges.

2.3 Why the CRC doubles as the address ("parity/address overlay")

Mode S appends a 24-bit parity field to every frame, computed with the CRC-24 generator polynomial of §6.1. What makes Mode S unusual is that this parity field is overlaid (XOR-ed) with an address before transmission — the field on the wire is AP = CRC(message) ⊕ address. This serves two jobs with one set of 24 bits:

  • On an interrogation reply (DF 4, 5, 20, 21, and the surveillance formats), the overlaid value is the aircraft's own 24-bit ICAO address. A receiver that knows which aircraft it interrogated computes CRC(message) ⊕ AP and expects its address back — a mismatch means a corrupt frame or a reply from a different aircraft. The address is never sent in the clear; it is recovered by the XOR. The CRC and the address are the same 24 bits.
  • On an all-call reply (DF 11) the overlay is the interrogator identifier (II/SI), usually 0.
  • On the extended squitter (DF 17/18) — the ADS-B case this decoder targets — there is no address overlay on the ME payload's parity: the 24-bit ICAO address is carried in the clear in message bits 9–32, and the CRC is applied so that the remainder over the entire 112-bit frame is zero for a valid frame. A passive listener therefore validates a squitter by computing the CRC over all 14 bytes and checking for a zero remainder, and reads the address directly.

This decoder implements the DF17/18 case exactly (frame_ok = zero-remainder gate, icao = clear-text bits 9–32). It does not perform address-overlay recovery for the interrogation-reply formats (see §8) — those require a candidate address list, which a pure passive squitter receiver does not have.


3. Signal structure — exact numbers

Everything the demodulator relies on is fixed by the Mode S air interface:

quantity value source
Carrier 1090 MHz §2.1
Line (bit) rate 1 Mbit/s = 1 µs/bit adsb.py
Chip rate (pulse grid) 2 Mchip/s (two 0.5 µs chips/bit) §2.2
Receiver sample rate ≥ 2 MS/s (sps samples/µs, default 2) adsb.py
Preamble length 8 µs (16 chips) adsb.py
Preamble pulse times 0.0, 1.0, 3.5, 4.5 µs adsb.py
Short frame 56 bits = 7 bytes adsb.py
Long frame (DF17/18) 112 bits = 14 bytes adsb.py
Parity CRC-24, generator 0xFFF409 adsb.py

3.1 The 8 µs preamble

Every Mode S reply is prefixed by a fixed 8 µs preamble of four 0.5 µs pulses at 0.0, 1.0, 3.5 and 4.5 µs. At the internal 2 samples/µs the module stores it as a 16-element magnitude template:

_PREAMBLE = np.array([1, 0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0], float)
              index:   0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15
              µs:    0.0 .5 1.0 .. .. .. .. 3.5 .. 4.5 ..    ..    ..  7.5

The 1s at samples 0, 2, 7, 9 correspond exactly to 0.0, 1.0, 3.5, 4.5 µs — the canonical preamble pulse positions (verified by the module comment in adsb.py). The pulse pair spacing (1.0 µs then 3.5/4.5 µs) is deliberately non-uniform so the preamble autocorrelates sharply and is unlikely to be mimicked by random data-bit energy.

3.2 Frame layout

After the preamble the 56 or 112 data bits follow immediately, one per µs. A long (112-bit / 14-byte) frame is laid out as:

 bit  1        6 9              32 33                    88 89          112
     ┌─────────┬──────────────────┬────────────────────────┬─────────────┐
     │  DF (5) │  ICAO address /   │      ME field (56)      │  parity(24) │
     │  CA (3) │  overlaid AP hdr  │  (DF17/18: type + data) │  CRC / AP   │
     └─────────┴──────────────────┴────────────────────────┴─────────────┘
      byte 0        bytes 1..3           bytes 4..10            bytes 11..13

The module addresses fields by byte (all in adsb.py):

  • df(msg) = msg[0] >> 3 — the top 5 bits of byte 0.
  • icao(msg) = (msg[1]<<16)|(msg[2]<<8)|msg[3] — bits 9–32.
  • The ME field is msg[4:11] — 56 bits, bytes 4–10.
  • typecode(msg) = msg[4] >> 3 — the top 5 bits of the ME field.
  • The parity is bytes 11–13, consumed implicitly by the CRC (a valid frame has a zero remainder over all 14 bytes, so the code never extracts it separately).

4. Demodulation step-by-step (demodulate, adsb.py)

Input: a 1-D array of magnitude samples at sps samples/µs (default 2). Output: a list of CRC-valid frames as bytes.

4.1 From IQ to magnitude (mag_from_iq, adsb.py)

Mode S is amplitude/energy modulation, so the demodulator works on magnitude, not complex IQ. mag_from_iq accepts either a complex array or an interleaved little-endian int16 I/Q byte buffer (the RTL-SDR/rtl_sdr native format), and returns |I + jQ| at the same rate:

x = np.frombuffer(iq, dtype="<i2").astype(float)   # int16 I,Q,I,Q,...
iq = x[0::2] + 1j*x[1::2]
return np.abs(np.asarray(iq))

All timing and slicing below happen on this real-valued magnitude envelope. No carrier recovery is needed — PPM carries data in pulse position, not phase.

4.2 Preamble correlation and window setup

The demodulator precomputes a magnitude template at the working rate by up-sampling _PREAMBLE — each 0.5 µs chip becomes sps//2 samples:

plen = len(_PREAMBLE) * (sps // 2)        # 16 for sps=2, 32 for sps=4
tmpl = np.repeat(_PREAMBLE, sps // 2)
hi   = tmpl > 0                            # boolean mask of the 4 pulse chips
need = plen + 112 * sps                    # samples needed for a full long frame

It then slides a plen-sample window across the magnitude stream. Detection is a pulse-versus-gap contrast test, not a full matched filter:

win = mag[i:i+plen]
if win[hi].mean() > 3 * (win[~hi].mean() + 1e-9):   # pulse chips >> gap chips
    ...decode...

i.e. the mean magnitude of the samples that should be pulses (the four preamble positions) must exceed three times the mean of the samples that should be gaps. This is amplitude-agnostic (it compares two means from the same window, so gain and DC drift cancel) and cheap. The +1e-9 guards against a divide-by-zero on a silent window. When it fires, i is a candidate frame start (first sample after the window is bit 0).

4.3 PPM bit slicing

The 112 data bits are read from the 112*sps samples immediately after the preamble, reshaped so each row is one 1 µs bit:

body = mag[i+plen : i+plen + 112*sps].reshape(112, sps)
bits = (body[:, :sps//2].mean(1) > body[:, sps//2:].mean(1)).astype(int)

For each bit the mean energy of the first half (sps//2 samples) is compared to the mean energy of the second half. First-half-brighter ⇒ 1; second-half-brighter ⇒ 0 — exactly the PPM convention of §2.2. This relative comparison is the "confidence" mechanism: a genuine PPM bit has one bright half and one dark half, so the comparison is decisive; a noise-only bit has two comparably-dim halves and slices to an arbitrary value that the CRC then rejects (§4.4). There is no separate soft-confidence metric or error-correction — validity is binary and enforced entirely by the CRC gate.

4.4 CRC gate, frame-length disambiguation, and advance

The 112 sliced bits are packed MSB-first into 14 bytes and validated:

b = bytes(int("".join(map(str, bits[k:k+8])), 2) for k in range(0, 112, 8))
if frame_ok(b):            # 14-byte long frame, CRC remainder 0
    frames.append(b);      i += plen + 112*sps;  continue
if frame_ok(b[:7]):        # first 7 bytes are a valid short frame
    frames.append(b[:7]);  i += plen + 56*sps;   continue
i += 1                     # no valid frame here; slide one sample and retry

Because a short (56-bit) reply and a long (112-bit) reply share the same preamble, the demodulator disambiguates by CRC: it always slices a full 112-bit body, tries the long frame first, and if that fails tries the first 56 bits as a short frame. Whichever passes CRC is accepted and the read cursor jumps past the whole frame; on a valid long frame it advances plen + 112*sps, on a short frame plen + 56*sps, so a short reply does not swallow samples belonging to the next message. If neither passes, the candidate was a false preamble hit and the search advances a single sample. The CRC is thus doing double duty: integrity check and frame-length classifier.

Worked PHY round-trip. synth(msg, sps=2) builds the magnitude stream a transmitter would produce — a leading gap, _PREAMBLE up-sampled by sps//2, then [1,1,0,0]/[0,0,1,1] chip pairs per bit (adsb.py) — and the test suite feeds three concatenated frames plus Gaussian noise (σ = 0.05) back through demodulate, recovering all three hex strings exactly (test_adsb.py). A second test confirms framing survives 4 samples/µs oversampling (sps=4, test_adsb.py).


5. Message decode step-by-step (decode, adsb.py)

decode(msg) takes one CRC-valid frame and returns a field dict. It always reports the Downlink Format and the (clear-text) ICAO address, then dispatches on type code for the extended-squitter formats:

out = {"df": df(msg), "icao": "%06X" % icao(msg)}
if out["df"] in (17, 18):              # extended squitter
    tc = typecode(msg)
    out["tc"] = tc
    if 1 <= tc <= 4:
        out["callsign"] = callsign(msg)
    elif 9 <= tc <= 18 or 20 <= tc <= 22:
        out["altitude"] = altitude(msg)
        out["cpr"] = _cpr_bits(msg)[0]          # 0 even / 1 odd
    elif tc == 19:
        v = velocity(msg)
        if v:
            out["speed"], out["heading"], out["vert_rate"] = v
return out

The type-code → content mapping is:

TC ADS-B content handled?
1–4 Aircraft identification (callsign + category) callsign
5–8 Surface position ❌ (see §8)
9–18 Airborne position, barometric altitude ✅ altitude + CPR flag
19 Airborne velocity velocity
20–22 Airborne position, GNSS height ✅ altitude + CPR flag
others status/operational not decoded

5.1 CRC-24 syndrome and validity (crc24, frame_ok)

The syndrome is a straight bit-serial CRC-24 over the message bytes (adsb.py):

reg = 0
for b in data:
    reg ^= b << 16
    for _ in range(8):
        reg <<= 1
        if reg & 0x1000000:        # bit 24 set
            reg ^= 0x1FFF409       # feed back the 25-bit generator
return reg & 0xFFFFFF

This is the classic MSB-first shift register: each message byte is XOR-ed into the top of a 24-bit register, then 8 shifts feed back the generator 0x1FFF409 (= x²⁴ + the 24-bit polynomial 0xFFF409) whenever the shifted-out bit is 1. For a DF17/18 frame whose 24 parity bits were computed by the transmitter, the remainder over all 14 bytes is 0; frame_ok accepts a frame only if it is 7 or 14 bytes long and the remainder is 0 (return len(msg) in (7, 14) and crc24(msg) == 0). Corrupting a single bit (the test flips the LSB of the last byte) makes the remainder non-zero and the frame is rejected (test_adsb.py).

Address recovery note: for the address-overlaid formats of §2.3 the syndrome crc24(msg) is not zero — it equals the overlaid address (or interrogator ID). Recovering the address is then crc24(msg) ⊕ AP, but this module does not implement that path; it reads the DF17/18 clear-text address only (§8).

5.2 Aircraft identification — callsign (callsign, adsb.py)

For TC 1–4 the 56-bit ME field carries TC(5) + category(3) + 8 characters × 6 bits. The eight 6-bit codes are the low 48 bits; the decoder walks them MSB-first and maps each through the ICAO 6-bit character table (_CS, see §6.2):

me = int.from_bytes(msg[4:11], "big")                 # 56-bit ME
cs = "".join(_CS[(me >> (42 - 6*i)) & 0x3F] for i in range(8))
return cs.replace("#", "").rstrip()

i=0 reads bits 42–47 (first character), i=7 reads bits 0–5 (last); the top 8 ME bits (TC + category) are skipped by starting the shift at 42. Invalid codes map to # and are stripped, and trailing pad spaces are removed.

Worked example — IDENT = 8D4840D6202CC371C32CE0576098. DF = 17, TC = 4, ICAO = 4840D6. The ME field's eight 6-bit codes are [11, 12, 13, 49, 48, 50, 51, 32] → via _CSK L M 1 0 2 3 ␠ → after stripping → KLM1023 (test_adsb.py).

5.3 Airborne position — CPR (position, _cpr_bits, _nl)

Compact Position Reporting (CPR) is the clever, and fiddly, part. To fit a global position into 17+17 bits per message, ADS-B does not send absolute lat/lon; it sends the fractional position within a local grid cell, and alternates between two grids — an even grid of 60 latitude zones and an odd grid of 59 — flagged by one bit. Two consecutive frames of opposite parity are enough to resolve which cell you are in globally and unambiguously; a single frame plus a known nearby reference resolves it locally. This module implements the global even/odd decode.

_cpr_bits extracts the three CPR fields from the ME (adsb.py):

me  = int.from_bytes(msg[4:11], "big")
f   = (me >> 34) & 1          # 0 = even frame, 1 = odd frame
lat = (me >> 17) & 0x1FFFF    # 17-bit CPR latitude  (0 .. 131071)
lon =  me        & 0x1FFFF    # 17-bit CPR longitude

altitude reads the 12-bit barometric field that precedes the CPR bits (adsb.py): alt = ((msg[5]<<4)|(msg[6]>>4)) & 0xFFF; if the Q bit ((alt>>4)&1) is set, the altitude is on the 25-ft scale — n = ((alt & 0xFE0)>>1) | (alt & 0xF) removes the Q bit and the result is n*25 − 1000 feet. Q = 0 (100-ft Gillham/Gray coding) returns None (§8), as does an all-zero altitude field.

The global decode (position(even_msg, odd_msg, latest), adsb.py) proceeds in the canonical CPR order — latitude first (it is separable), then longitude using the resolved latitude's zone count:

cprlat_e, cprlon_e = lat_e/131072.0, lon_e/131072.0     # normalise to [0,1)
cprlat_o, cprlon_o = lat_o/131072.0, lon_o/131072.0
j = floor(59*cprlat_e - 60*cprlat_o + 0.5)              # latitude zone index
rlat_e = (360/60)*((j % 60) + cprlat_e)                 # dLat_even = 6.000°
rlat_o = (360/59)*((j % 59) + cprlat_o)                 # dLat_odd  = 6.101°
if rlat_e >= 270: rlat_e -= 360                         # southern-hemisphere wrap
if rlat_o >= 270: rlat_o -= 360
if _nl(rlat_e) != _nl(rlat_o): return None              # even/odd in different zones

Then longitude, from whichever frame is latest (default "even"):

nl = _nl(rlat_e)                 # (even branch)
ni = max(nl, 1)                  # longitude zones for even frame
m  = floor(cprlon_e*(nl-1) - cprlon_o*nl + 0.5)         # longitude zone index
lon = (360/ni)*((m % ni) + cprlon_e)                    # dLon_even = 360/ni
lat = rlat_e
if lon >= 180: lon -= 360                               # normalise to [-180,180)

The odd branch mirrors this with nl = _nl(rlat_o), ni = max(nl-1, 1) and cprlon_o / rlat_o (adsb.py). The NL (number of longitude zones) function _nl(lat) (adsb.py) is the standard transcendental:

if abs(lat) >= 87.0: return 1
if lat == 0:         return 59
a = 1 - cos(pi/(2*_NZ))                    # _NZ = 15
b = cos(pi/180*abs(lat))**2
return int(2*pi / acos(1 - a/b))

with the number of geographic latitude zones _NZ = 15 (adsb.py), giving 4·NZ = 60 even zones and 59 odd zones — the origin of the 60/59 constants above. The even/odd NL-equality check is the global-decode safeguard: if the two frames fall in different longitude-zone bands the pair cannot be resolved and the function returns None.

Worked example — POS_E/POS_O (ICAO 40621D, mode-s.org). POS_E = 8D40621D58C382D690C8AC2863A7 (even, TC 11), POS_O = 8D40621D58C386435CC412692AD6 (odd). altitude(POS_E)38000 ft (12-bit field 0xC38, Q = 1, n = 1560, 25·1560 − 1000). CPR bits: even (f=0, lat=93000, lon=51372), odd (f=1, lat=74158, lon=50194). Normalised: cprlat_e = 0.709534, cprlat_o = 0.565781, cprlon_e = 0.391937, cprlon_o = 0.382950. Latitude index j = floor(59·0.709534 − 60·0.565781 + 0.5) = 8. rlat_e = 6·(8 + 0.709534) = 52.257202°, rlat_o = 52.265780°, and NL(rlat_e) = NL(rlat_o) = 36 — same zone, so the pair is valid. Longitude (even, latest): nl = 36, m = floor(0.391937·35 − 0.382950·36 + 0.5) = 0, lon = (360/36)·(0 + 0.391937) = 3.919373°. Result (52.257202, 3.919373) — matching the published 52.2572° N, 3.9194° E (test_adsb.py).

5.4 Airborne velocity (velocity, adsb.py)

TC 19 carries ground track and vertical rate. The decoder handles the two ground-speed subtypes (1 and 2); the airspeed subtypes (3, 4) return None (§8). The ME field is unpacked by the ICAO bit positions (comments in adsb.py):

me      = int.from_bytes(msg[4:11], "big")
subtype = (me >> 48) & 0x7        # ME bits 6-8
vsign   = (me >> 19) & 1          # vertical-rate sign (ME bit 37); 1 = descending
vrate   = (me >> 10) & 0x1FF      # 9-bit vertical rate (ME bits 38-46)
vr = None if vrate == 0 else (vrate-1) * 64 * (-1 if vsign else 1)   # 64 ft/min/unit
if subtype in (1, 2):             # ground speed
    ew_s = (me >> 42) & 1;  ew_v = (me >> 32) & 0x3FF   # E-W sign + 10-bit velocity
    ns_s = (me >> 31) & 1;  ns_v = (me >> 21) & 0x3FF   # N-S sign + 10-bit velocity
    vx = (ew_v-1) * (-1 if ew_s else 1)     # value 0 = "no data"; subtract 1
    vy = (ns_v-1) * (-1 if ns_s else 1)
    spd = hypot(vx, vy)                       # knots
    hdg = degrees(atan2(vx, vy)) % 360        # 0° = North, clockwise
    return round(spd,1), round(hdg,1), vr

The east–west and north–south velocity components are each a signed 10-bit field with a −1 offset (raw 0 means "no velocity data", raw 1 means zero speed). Speed is their Euclidean magnitude; heading is atan2(vₑ_w, vₙ_s) measured clockwise from north; vertical rate is a 9-bit field in 64 ft/min units, likewise −1-offset and signed.

Worked example — VEL = 8D485020994409940838175B284F (ICAO 485020). subtype 1; ew_s = 1 (west), ew_v = 9vx = −8; ns_s = 1 (south), ns_v = 160vy = −159; speed hypot(8,159) = 159.2 kt; heading atan2(−8,−159) mod 360 = 182.9°; vsign = 1, vrate = 14(14−1)·64·(−1) = −832 ft/min. Result speed 159.2 kt, heading 182.9°, vertical rate −832 ft/min (test_adsb.py).


6. Constants & tables

6.1 The CRC-24 generator polynomial

The feedback constant used in crc24 is 0x1FFF409 (adsb.py); its low 24 bits 0xFFF409 are the Mode S generator polynomial (adsb.py). In binary 0x1FFF409 = 1 1111 1111 1111 0100 0000 1001, i.e.

G(x) = x²⁴ + x²³ + x²² + x²¹ + x²⁰ + x¹⁹ + x¹⁸ + x¹⁷ + x¹⁶
            + x¹⁵ + x¹⁴ + x¹³ + x¹² + x¹⁰ + x³ + 1

This is the ICAO Annex 10 / DO-260 Mode S CRC-24. It is applied MSB-first with no bit-reversal and no final XOR (crc24, adsb.py); a correctly-parity'd DF17/18 frame yields remainder 0.

6.2 The 6-bit callsign character set

_CS (adsb.py) is the 64-entry ICAO identification alphabet (a subset of IA-5), indexed 0–63:

_CS = "#ABCDEFGHIJKLMNOPQRSTUVWXYZ##### ###############0123456789######"
code range characters
0 # (unused/NUL placeholder)
1–26 AZ
27–31 # (reserved/invalid)
32 space (word separator / pad)
33–47 # (reserved/invalid)
48–57 09
58–63 # (reserved/invalid)

All # entries are removed by callsign's .replace("#",""), so out-of-range or reserved codes simply drop out; trailing spaces are stripped.

6.3 CPR constants

constant value source meaning
_NZ 15 adsb.py geographic latitude zones equator→pole
even latitude zones 60 (= 4·NZ) adsb.py dLat_even = 360/60 = 6.000°
odd latitude zones 59 adsb.py dLat_odd = 360/59 ≈ 6.1017°
CPR field scale 131072 = 2¹⁷ adsb.py 17-bit fraction → [0,1)
southern wrap threshold 270° adsb.py maps [270,360) → [−90,0)
longitude normalise 180° adsb.py maps [180,360) → [−180,0)
NL(|lat|≥87°) 1 adsb.py polar cap: single longitude zone
NL(0°) 59 adsb.py equatorial special case

6.4 DF and type-code dispatch tables

downlink format df handled by decode
17 (ADS-B extended squitter) ✅ full ME decode
18 (TIS-B / non-transponder ES) ✅ full ME decode
all others (0,4,5,11,20,21,…) reports df + clear-text bits 9–32 only

Type-code table as implemented — see §5 for the mapping and §8 for the gaps. The velocity decoder additionally gates on typecode(msg) != 19 returning None (adsb.py), and honours only ground-speed subtypes 1–2 (adsb.py).


7. Interoperability & validation

The module is validated two ways (test_adsb.py):

  1. Canonical real frames. The four mode-s.org worked-example frames are decoded and checked field-by-field:

    • CRC: all four pass frame_ok; a single-bit flip fails it.
    • IDENT → ICAO 4840D6, callsign KLM1023.
    • VEL → ICAO 485020, speed 159.2 kt, heading 182.9°, vertical rate −832 ft/min.
    • POS_E → barometric altitude 38000 ft; position(POS_E, POS_O)(52.2572, 3.91937) within 10⁻³.

    These are the same reference vectors used throughout the Mode S literature (the mode-s.org tutorial and the pyModeS test corpus), so agreement is a genuine interoperability check against the published Mode S / 1090ES definition — the CRC polynomial, ME bit layout, CPR arithmetic and callsign alphabet must all match bit-for-bit to reproduce these outputs.

  2. Synthetic PHY round-trip. synth renders frames to magnitude samples using the same preamble and PPM conventions the demodulator expects; demodulate recovers them under Gaussian noise (σ = 0.05) at 2 MS/s and under 4 MS/s oversampling (test_adsb.py). This exercises preamble detection, half-bit slicing, byte packing, the CRC gate and the short/long frame-length disambiguation end-to-end.

The module docstring frames the intent as replacing "the decode role of dump1090" (adsb.py): given raw SDR magnitude samples it produces the same address/position/velocity fields dump1090 would, without the external binary. (Note that Rafe's live app path in digimodes.py still polls a running dump1090/readsb aircraft.json over HTTP for its map display — the native adsb.py decoder is the clean-room replacement for that decode stage, driven from an SDR, and is what this document specifies.)


8. Limitations

Documented honestly against a full Mode S / 1090ES receiver. None are silent — each corresponds to a code path that returns None or is simply not present:

  • Passive squitter only; no address-overlay recovery. Only DF17/18, where the ICAO address is in the clear and the CRC remainder is 0, are decoded to fields (adsb.py). The interrogation-reply formats (DF 4/5/20/21) whose parity is overlaid with the address (§2.3) are neither validated nor address-recovered — frame_ok rejects them (their remainder is the address, non-zero) unless the overlay happens to be 0 (e.g. DF11 with II = 0). decode on any non-17/18 frame still emits an "icao" string, but for those formats bits 9–32 are not the address, so that value is not meaningful.
  • No error correction. Real dump1090 attempts single/two-bit CRC correction by trying error-syndrome patterns. Here the CRC is a pure accept/reject gate; a frame with any bit error is dropped.
  • Preamble detector is a contrast threshold, not a matched filter. The >3× pulse/gap-mean test (adsb.py) is cheap and gain-agnostic but weaker than a normalised cross-correlation; in dense traffic overlapping replies ("garbling") are not separated, and there is no adaptive noise floor.
  • Surface position (TC 5–8) not decoded — only airborne position (TC 9–18, 20–22) is dispatched (adsb.py). Surface CPR uses a different scale (4·10⁶ and a reference-based decode) and is out of scope.
  • Only 25-ft (Q = 1) altitude. The 100-ft Gillham/Gray-coded altitude (Q = 0) returns None (adsb.py); so does an all-zero altitude field.
  • Only ground-speed velocity subtypes (1, 2). Airspeed/heading subtypes (3, 4) return None (adsb.py); the aircraft-identification category (the 3 bits after TC) and the surveillance-status/NIC/NUC quality fields are not surfaced.
  • CPR is global-pair only. position needs a matched even+odd pair in the same latitude/longitude zone band; there is no single-frame local decode against a receiver reference position, and no zone-consistency time-window check between the two frames beyond the NL equality test.
  • NumPy required for the PHY. The message layer is pure Python, but demodulate/synth/mag_from_iq use NumPy (adsb.py).

None of these are architectural; each is a bounded addition on a working base.


9. References

  1. ICAO Annex 10, Volume IVAeronautical Telecommunications: Surveillance and Collision Avoidance Systems. The normative definition of Mode S: the PPM waveform, the 8 µs preamble, the CRC-24 generator polynomial, and the downlink-format frame structure.
  2. RTCA DO-260B / EUROCAE ED-102AMinimum Operational Performance Standards for 1090 MHz Extended Squitter ADS-B. Defines the DF17/18 ME field type codes, the CPR encoding, the velocity and identification message formats.
  3. J. Sun, The 1090 Megahertz Riddle (mode-s.org) — the open ADS-B decoding tutorial and reference; source of the callsign alphabet, CPR global/local algorithms, the NL(lat) table and the worked example frames this module is validated against.
  4. pyModeS (J. Sun) — the reference open-source Python Mode S decoder whose bit layouts and CPR arithmetic this clean-room implementation reproduces.
  5. dump1090 (M. Robb / S. Sanfilippo, and the readsb fork) — the reference C demodulator whose decode role adsb.py replaces; the preamble-correlation and half-bit-slicing PHY approach follows its design.
  6. In-repo: app/radio/adsb.py (implementation), test_adsb.py (validation vectors), and the gold-standard companion spec ../rvqvoice.md.

Source files

  • app/radio/adsb.py — native Mode S PHY (PPM demod, CRC-24) and message decode (DF17/18 ME fields).
  • test_adsb.py — canonical mode-s.org validation vectors and synthetic PHY round-trip.