Patrick Lidstone
Self-hosted

VDL Mode 2: native aviation VHF datalink (D8PSK · RS · AVLC · ACARS)

A self-contained, clean-room receiver and transmitter for VDL Mode 2 (ICAO Annex 10 Vol III): differential 8-PSK at 10 500 sym/s (31 500 bit/s), Gray-coded so a one-step phase slip costs a single bit; a Reed–Solomon RS(255,249) block FEC that corrects three byte errors per codeword; a 15-bit additive scrambler; and the AVLC link layer — an HDLC variant with 24-bit aircraft/ground addresses and a CRC-16/X.25 frame-check — that carries an ACARS message in its information field.

Rafe project · app/radio/vdl2.py · in-repo NumPy


Abstract

VDL Mode 2 (VHF Data Link Mode 2) is the digital replacement for the classic 2400 bit/s AM-MSK ACARS radio: instead of tone-keying characters onto an AM carrier, it sends a differentially-encoded 8-PSK waveform at 10 500 symbols/s — three bits per symbol, 31 500 bit/s gross — in a 25 kHz VHF aviation channel. Above the modem sits a rigid stack: a Reed–Solomon block code for forward error correction, a bit-scrambler for spectral flatness, a header carrying the transmission length, and AVLC (Aviation VHF Link Control) — a close cousin of ISO HDLC — providing addressed, CRC-checked frames between aircraft and ground stations. The user payload that rides inside an AVLC information field is, for the "ACARS-over-AVLC" service, an ordinary ACARS message: the same SOH … text … BCS … DEL byte layout that the VHF and satellite ACARS paths use.

This document specifies Rafe's native VDL2 implementation in full — enough to reimplement it from scratch. It is a clean-room transcription from ICAO Annex 10 Vol III. The modem (modulate / demodulate) is a Gray-coded differential 8-PSK with a root-raised-cosine pulse shared with the TETRA π/4-DQPSK front end (widened here to 8 samples per symbol). The FEC is a self-contained RS(255,249) codec — Berlekamp–Massey / Chien / Forney — ported from the CCSDS RS decoder that backs the LRPT satellite path. The link layer reuses the HDLC bit-stuffing and CRC-16/X.25 frame-check from the native AIS decoder, and the header length word is protected by the Golay(24,12,8) code from the P25 FEC library.

Honesty note (carried from the module docstring). Spec-accurate against Annex 10 are: the D8PSK Gray mapping, the RS dimensions, the LFSR polynomial and the AVLC frame shape. The exact training symbols, the scrambler seed, the header bit layout and the RS symbol ordering are self-consistent transcriptions — they round-trip against themselves and against injected noise, but they are the named on-air interop checks, not verified bit-exact against a real VDL2 signal. Where the code makes such a choice, this document says so.


1. Motivation and scope

Rafe already decodes the ACARS message layer natively — app/radio/acars.py builds and parses the SOH…BCS…DEL byte format and drives two physical layers: the 2400 bit/s VHF AM-MSK modem and the Inmarsat AERO satellite framing (see ACARS). What it lacked was the VDL Mode 2 physical and link layers — the modern, higher-rate VHF datalink that most airline ACARS traffic now uses. app/radio/vdl2.py fills that gap: it is the D8PSK modem, the RS(255,249) FEC, the scrambler, the length header and the AVLC link layer, with the ACARS message layer plugged in as the payload.

The design deliberately reuses proven in-repo building blocks rather than re-deriving them (module header, vdl2.py):

                    NATIVE (app/radio/vdl2.py)
  IQ ─▶ D8PSK demod ─▶ sync search ─▶ Golay length ─▶ descramble ─▶ RS(255,249)
        (this module)   (this module)   (p25.fec)      (this module)  (this module)
                                                                          │
                                                                          ▼
                                        AVLC deframe ─▶ strip marker ─▶ acars.parse
                                        (aisdecode HDLC)  (this module)   (acars.py)

The reused pieces: the RRC pulse (rrc_taps from radio/tetra/dsp.py, widened to 8 samples/symbol); the HDLC bit-stuffing and CRC-16/X.25 frame-check (hdlc_frame/hdlc_deframe/_fcs from radio/aisdecode.py); the Golay(24,12,8) header code (golay24_encode/golay24_decode from radio/p25/fec.py); the ACARS message layer (radio/acars.py). The RS decoder structure (Berlekamp–Massey / Chien / Forney) is ported from radio/sat/rs.py, but VDL2's RS is self-contained in vdl2.py with its own field polynomial, parity count and first-consecutive-root.

Scope caveat. VDL2 is a new module: a standalone encoder/decoder plus a test suite (test_vdl2.py). It is not yet wired into the app's live mode managervdl2 is imported only by its test, not by radio/manager.py or any *_mode.py. There is no streaming receiver class, no SDR source binding and no UI surface today; the entry points are encode_burst / decode_burst on in-memory IQ. This spec documents the codec as it stands.


2. Background — real VDL Mode 2

VDL Mode 2 operates in the aeronautical VHF band (the common global signalling channel is 136.975 MHz) with 25 kHz channelisation. It is a shared, CSMA medium: stations contend for the channel, transmit short bursts, and the link layer sorts out addressing and acknowledgement. The modulation is differential 8-PSK — chosen so the receiver never needs an absolute phase reference (only phase changes carry data) — at 10 500 symbols/s. Three bits per symbol give the headline 31 500 bit/s, roughly 13× the old 2400 bit/s AM-MSK ACARS.

The link layer is AVLC (Aviation VHF Link Control), defined in Annex 10 as a variant of ISO HDLC. Like all HDLC descendants it delimits frames with the 0x7E flag byte, escapes accidental flags by bit-stuffing (a 0 after any run of five 1s), and seals each frame with a CRC — here the CRC-16/X.25 FCS (reflected 0x1021 / 0x8408, init 0xFFFF, final XOR 0xFFFF), the same polynomial dressing AIS and APRS/AX.25 use. An AVLC frame carries 24-bit addresses — a destination and a source, each an ICAO 24-bit identifier (the same address space as a Mode-S/ADS-B aircraft address) — plus a control field and an information field.

2.3 ACARS-over-AVLC versus ATN

Two things ride inside an AVLC information field in the real world:

  • ACARS-over-AVLC — the legacy character-oriented ACARS message wrapped for transport over VDL2. This is what most airline operational traffic uses and what this module implements: the AVLC info field begins with a fixed marker, then the ACARS bytes.
  • ATN/OSI — the aeronautical telecommunications network, a full OSI stack (CLNP/CM/CPDLC) for controller-pilot datalink. Out of scope here.

2.4 The address model

Every VDL2 frame names a destination and a source by 24-bit address. Aircraft addresses are ICAO 24-bit identifiers; ground stations have addresses in the same space. build_avlc(dst_icao, src_icao, …) and parse_avlc in this module carry both as big-endian 3-byte fields (vdl2.py).


3. Signal and symbol structure — exact numbers

Every value below is a constant in app/radio/vdl2.py or directly derivable from one; the symbol is cited.

Symbol layer

  • Symbol (baud) rate: SYMRATE = 10500 sym/s (vdl2.py).
  • Bits per symbol: 3 (one tribit). Gross bit rate 10 500 × 3 = 31 500 bit/s.
  • Phase alphabet: 8 phases, k · 2π/8 for step k ∈ {0…7}; _TWO_PI_8 = 2π/8 (vdl2.py).
  • Differential: each symbol's phase is the accumulated sum of per-symbol steps (modulate: phase += step * _TWO_PI_8), so data lives in phase changes, not absolute phase.
  • Gray mapping: step → tribit is _step_to_tribit(s) = s ^ (s >> 1) (vdl2.py); the inverse table is _TRIBIT_TO_STEP (vdl2.py).
  • Samples per symbol (default): sps = 8 (modulate / demodulate default).
  • Pulse shape: root-raised-cosine, roll-off α = 0.35, rrc_taps(sps=8, α=0.35) from radio/tetra/dsp.py — the same RRC as TETRA, unit-energy normalised, span 8 symbols each side → 2·8·8 + 1 = 129 taps.

Sync layer

  • Unique word: SYNC_SYMS = [0,4,2,6,1,5,3,7, 0,4,2,6,1,5,3,7]16 D8PSK step indices (vdl2.py), i.e. two repeats of the 8-permutation 0,4,2,6,1,5,3,7.
  • Matched on demodulated step indices with a tolerance of ≤ 2 mismatches over the 16 symbols (decode_burst); the search scans the first 64 step positions.

Header layer

  • Transmission byte-length carried under Golay(24,12,8): header_encode(n) = golay24_encode(n & 0xFFF)24 bits = 8 D8PSK symbols (vdl2.py).
  • 12 information bits ⇒ max encodable length 4095 bytes.

FEC layer

  • Code: RS(255,249) over GF(2⁸), primitive polynomial 0x11D (x⁸+x⁴+x³+x²+1), first-consecutive-root fcr = 0, _NROOTS = 6 parity bytes ⇒ corrects up to t = 3 byte errors per codeword (vdl2.py).
  • Systematic and shortened: encode_burst RS-codes the data in 249-byte chunks, so the last block of a burst may be a shortened codeword (len(chunk)+6 bytes, chunk < 249).

Scrambler layer

  • 15-bit LFSR, polynomial x¹⁵ + x¹⁴ + 1 (feedback XOR of register bits 14 and 13), seed 0x7FFF, additive so the routine is its own inverse (_scramble, vdl2.py).

Burst layout. encode_burst assembles, and modulate shapes, this symbol sequence (a phase-0 reference symbol is prepended by modulate so the first data symbol is differentially recoverable):

   ref     sync (16 sym)       header (8 sym)      data (N sym)
   1 sym   SYNC_SYMS           Golay(24,12)        scrambled RS(255,249) blocks
  ┌─────┬───────────────────┬──────────────────┬───────────────────────────────┐
  │ φ=0 │ 0 4 2 6 1 5 3 7 … │  24-bit length   │  b0 b1 … (3 bits / symbol)     │
  └─────┴───────────────────┴──────────────────┴───────────────────────────────┘
   pref    unique word         nbytes coded        3 bits/sym, D8PSK, RRC-shaped

On air the whole thing is one contiguous D8PSK burst: 1 + 16 + 8 + N symbols, upsampled ×8 and RRC-convolved into complex baseband IQ.


4. The D8PSK modem — differential 8-PSK with a Gray code

Input to demodulate: complex baseband IQ. Output: a bit stream, three bits per symbol. modulate is the exact inverse for test-signal generation.

4.1 Why differential, and why 8-PSK

Ordinary 8-PSK assigns each of eight absolute carrier phases a 3-bit symbol. That needs a coherent phase reference at the receiver — hard on a fading, Doppler-shifted VHF aviation link. Differential 8-PSK instead encodes each symbol as a phase step relative to the previous symbol: the transmitter accumulates phase (modulate),

phase = 0
syms  = [1 + 0j]                       # a phase-0 reference symbol, prepended
for each tribit (3 bits):
    step   = _TRIBIT_TO_STEP[value]    # 0..7
    phase += step * 2π/8               # accumulate
    syms.append( exp(j·phase) )

and the receiver recovers the step from the phase difference between adjacent symbols (_demod_steps):

dphi  = angle( sym[k] · conj(sym[k-1]) )      # phase change since last symbol
step  = round( dphi / (2π/8) )  mod 8

Any constant phase offset (unknown carrier phase, slow rotation) cancels in the sym[k]·conj(sym[k-1]) product, so no absolute reference is needed. The prepended reference symbol at phase 0 gives the very first data symbol something to differ from — it is consumed by the first difference and carries no data.

4.2 The Gray mapping — why a one-step slip is a single-bit error

The eight phase steps 0…7 are mapped to tribits by the Gray code tribit = step ^ (step >> 1) (_step_to_tribit). Walking the eight phases in order of increasing angle gives:

step  angle   tribit
  0     0°     000
  1    45°     001
  2    90°     011
  3   135°     010
  4   180°     110
  5   225°     111
  6   270°     101
  7   315°     100          (…and 100 → 000 wraps: still one bit)

Each neighbour differs from the next in exactly one bit — that is the defining property of a Gray code. It matters because the dominant demod error on a PSK link is a one-step phase slip: noise rotates a symbol just far enough that it rounds to the adjacent phase. With a Gray map, a ±1 step slip flips a single bit in the tribit, not two or three. The test asserts this directly: for every tribit, both neighbours (±1 step) differ in exactly one bit (test_d8psk_roundtrip_and_gray).

The D8PSK constellation, phases labelled with their Gray tribits:

                       011 (90°)
                        |
          010 (135°)    |    001 (45°)
                   \    |    /
                    \   |   /
       110 (180°) ────  +  ──── 000 (0°)
                    /   |   \
                   /    |    \
          111 (225°)    |    100 (315°)
                        |
                       101 (270°)

4.3 Pulse shaping (RRC)

modulate upsamples the symbol stream by sps (zero-stuffing: up[::sps] = syms) and convolves with rrc_taps(sps, alpha) — the same root-raised-cosine filter as the TETRA modem, here with sps = 8, α = 0.35. RRC at both ends makes the cascade a full raised-cosine (a Nyquist pulse: zero inter-symbol interference at the symbol instants) and splits the matched-filter gain evenly between transmit and receive.

4.4 Timing and energy pick (_demod_steps)

The receiver convolves the incoming IQ with the same RRC (the matched filter), then must choose one of sps sampling phases. It picks the phase whose sampled points carry the most energy — the offset that lands nearest the pulse peaks:

z        = convolve(iq, rrc_taps(sps, α), "same")
energies = [ mean(|z[p::sps]|²) for p in 0..sps-1 ]
syms     = z[ argmax(energies) :: sps ]          # the chosen symbol samples
dphi     = angle( syms[1:] · conj(syms[:-1]) )   # differential detection
steps    = round(dphi / (2π/8)) mod 8

There is no tracking loop or fractional interpolation — one energy-max pick over the whole burst, which is adequate for the short, clock-stable bursts VDL2 sends (the same one-shot approach the AIS and P25 demods use). demodulate then expands each recovered step through the Gray map into 3 bits: bits += _tobits(_step_to_tribit(s), 3).


5. Decode — step by step (decode_burst)

Input: complex baseband IQ of one burst. Output: a list of AVLC frames ({dst, src, control, info}), RS-corrected and descrambled.

5.1 Demodulate to step indices

steps = _demod_steps(iq, sps) — the differential detector of §4.4 returns the list of D8PSK step indices (0–7), one per symbol after the reference. Sync search and everything below work on these steps, not yet on bits.

5.2 Sync search on step indices

The 16-symbol unique word SYNC_SYMS is matched directly against the step stream. The search slides over the first 64 positions and accepts the first offset whose Hamming-style mismatch count over the 16 sync symbols is ≤ 2 (decode_burst):

for i in range(min(len(steps) - 16, 64)):
    if sum(steps[i+k] != SYNC_SYMS[k] for k in 0..15) <= 2:
        off = i;  break

Exact match is not required — a couple of slipped sync symbols are tolerated, just as the AIS and P25 sync searches tolerate a few flipped bits. If no offset matches, decode_burst returns [].

5.3 Expand the data steps to bits

The symbols after the 16-symbol sync word are the header + data. Each is Gray-expanded to 3 bits (decode_burst):

bits = []
for s in steps[off + 16:]:
    bits += _tobits(_step_to_tribit(s), 3)

5.4 Golay length header

The first 24 bits are the Golay(24,12,8)-protected transmission length. header_decode(bits[:24]) calls p25.fec.golay24_decode, which corrects up to 3 bit errors in the 24-bit word and returns the 12-bit byte count nbytes (the number of coded bytes — RS data + parity — that follow), or None if the Golay word is uncorrectable, in which case decode aborts.

5.5 Descramble

The next nbytes · 8 bits are the scrambled RS-coded payload. _scramble is additive and involutive — the same routine that scrambled on transmit descrambles on receive (XOR of a 15-bit LFSR stream):

body = _bits_to_bytes( _scramble( bits[24 : 24 + nbytes*8] ) )

The LFSR (seed 0x7FFF, taps 14 and 13, x¹⁵+x¹⁴+1) is re-seeded identically each call, so scramble-then-scramble is the identity — asserted by test_scrambler_involutive.

5.6 RS(255,249) block decode, including the shortened last block

body is a concatenation of RS codewords. The decoder walks it in 255-byte strides; each block is RS-decoded and its (up to) 249 data bytes appended (decode_burst):

data = bytearray()
for i in range(0, len(body), 255):
    blk = body[i : i+255]
    if len(blk) < _NROOTS + 1:      # < 7 bytes: no room for a codeword, stop
        break
    d, _ = rs_decode(blk)           # corrects <=3 byte errors
    data += d

The last block is usually shortened: encode_burst RS-codes the data in 249-byte chunks (rs_encode(data[i:i+249])), so a final chunk shorter than 249 bytes yields a codeword shorter than 255. rs_decode is length-agnostic — it takes n = len(cw) and computes error positions as n − 1 − i — so a shortened final block decodes correctly with no special case (§7). RS decode's ok flag is ignored here: whatever the block decodes to is passed on, and the AVLC FCS is the final integrity gate.

5.7 AVLC deframe (parse_avlc)

The recovered data bytes are turned back to bits and handed to hdlc_deframe (from radio/aisdecode.py), which finds 0x7E-flag-delimited frames, removes bit-stuffing, checks the CRC-16/X.25 FCS, and yields only CRC-valid payloads. parse_avlc slices each payload into the AVLC fields (vdl2.py):

dst     = bytes 0..2   (24-bit, big-endian)
src     = bytes 3..5   (24-bit, big-endian)
control = byte  6
info    = bytes 7..                       # the information field

Payloads shorter than 7 bytes (no room for two addresses + control) are dropped.

5.8 ACARS extraction

For the ACARS-over-AVLC service the information field begins with the marker INFO_ACARS = FF FF 01 (vdl2.py). The caller checks info.startswith(INFO_ACARS), strips the 3-byte marker, and passes the remainder to acars.parse (test_acars_over_avlc_end_to_end):

if info.startswith(INFO_ACARS):
    msg = acars.parse(info[len(INFO_ACARS):])   # -> {reg, flight, label, text, …}

build_acars_avlc(dst, src, acars_bytes) is the exact inverse of this wrapping on the transmit side: build_avlc(dst, src, INFO_ACARS + acars_bytes).


6. Constants and tables

All values are quoted verbatim from app/radio/vdl2.py.

6.1 Modem

SYMRATE   = 10500                        # symbols/s  -> 31500 bit/s (×3)
_TWO_PI_8 = 2π/8                         # phase-step quantum
sps       = 8   (default)               # samples per symbol
alpha     = 0.35 (default)              # RRC roll-off (shared with tetra/dsp.rrc_taps)
_step_to_tribit(s) = s ^ (s >> 1)       # Gray code (step -> tribit)
_TRIBIT_TO_STEP    = { (s ^ (s>>1)) : s for s in 0..7 }   # inverse

Step ↔︎ tribit (Gray) table, in step order:

step   0    1    2    3    4    5    6    7
tribit 000  001  011  010  110  111  101  100

6.2 Sync unique word

SYNC_SYMS = [0, 4, 2, 6, 1, 5, 3, 7,  0, 4, 2, 6, 1, 5, 3, 7]   # 16 D8PSK steps

Matched on demodulated step indices, ≤ 2 mismatches, over the first 64 positions.

6.3 Header

header_encode(n) = _tobits( golay24_encode(n & 0xFFF), 24 )     # 24-bit Golay(24,12,8)
header_decode(b) = golay24_decode( _frombits(b[:24]) )          # corrects <=3 bit errors

The Golay(24,12,8) codec is described in P25 §6.4; it is a perfect Golay(23,12) plus an overall even-parity bit, decoded by a full syndrome→error table.

6.4 Scrambler

_scramble(bits, seed=0x7FFF):
    reg = seed & 0x7FFF                          # 15-bit register
    for b in bits:
        fb  = ((reg >> 14) ^ (reg >> 13)) & 1    # taps at bits 14,13 : x^15 + x^14 + 1
        out.append( b ^ (reg & 1) )              # additive: XOR the LSB
        reg = ((reg >> 1) | (fb << 14)) & 0x7FFF # shift right, feedback into bit 14

Additive ⇒ involutive: _scramble(_scramble(x)) == x.

6.5 AVLC frame and ACARS marker

INFO_ACARS = bytes([0xFF, 0xFF, 0x01])           # AVLC info-field ACARS marker

build_avlc(dst, src, info, control=0x13):
    payload = dst(3) + src(3) + [control] + info      # big-endian 24-bit addresses
    return hdlc_frame( bits(payload) )                # flag + stuff + FCS + flag

parse_avlc(bits): dst=b0..2, src=b3..5, control=b6, info=b7..  (per CRC-valid frame)

Default control field 0x13 (build_avlc). Framing (flag 0x7E, five-ones bit-stuffing, CRC-16/X.25 FCS) is hdlc_frame / hdlc_deframe / _fcs from radio/aisdecode.py — see AIS.

6.6 RS(255,249) parameters

GF(2^8)  primitive polynomial 0x11D  (x^8 + x^4 + x^3 + x^2 + 1),  alpha = 2
_NROOTS  = 6         # parity bytes  -> corrects t = 3 byte errors
_FCR     = 0         # first consecutive root: alpha^0 .. alpha^5
n <= 255, k = n - 6  # systematic, shortened when a burst's last chunk < 249 bytes

7. The RS(255,249) codec (vdl2.py)

VDL2's Reed–Solomon codec is a self-contained port of the proven Berlekamp–Massey / Chien / Forney decoder in radio/sat/rs.py (the CCSDS RS(255,223) that backs the LRPT satellite path). The structure is identical; only the parameters differ:

Parameter sat/rs.py (CCSDS) vdl2.py (VDL2)
Field polynomial 0x187 0x11D
Parity symbols NROOTS 32 (t = 16) 6 (t = 3)
First consecutive root FCR 112 0
Dual-basis transform yes (CCSDS) no (conventional basis only)

7.1 The field GF(2⁸)

_EXP / _LOG are built by stepping the primitive element α = 2 for 255 steps, reducing modulo 0x11D whenever bit 8 sets (vdl2.py):

g = 1
for i in 0..254:
    _EXP[i] = g ;  _LOG[g] = i
    g <<= 1
    if g & 0x100:  g ^= 0x11D
_EXP[255..511] = _EXP[0..256]        # doubled so log sums never wrap

_mul(a,b) = _EXP[_LOG[a]+_LOG[b]] (0 if either is 0); _gpow(a,n) = α^((log a · n) mod 255); _ginv(a) = _EXP[255 − _LOG[a]]; _peval is Horner evaluation over the field.

7.2 Generator polynomial

_rs_gen forms g(x) = ∏_{i=0}^{5} (x − α^(FCR+i)) = ∏_{i=0}^{5}(x − α^i) (fcr = 0), returned low-order-first (vdl2.py). Six roots ⇒ degree-6 generator ⇒ 6 parity symbols.

7.3 Systematic encode (rs_encode)

Standard systematic RS: append _NROOTS zero bytes, then reduce modulo g(x) by polynomial long division, leaving data untouched and the remainder as parity (vdl2.py):

out = data + [0]*6
for i in 0..len(data)-1:
    c = out[i]
    if c:  for j in 1..6:  out[i+j] ^= _mul(gen[j], c)
return data + out[len(data):]        # data (<=249) + 6 parity bytes

For a data chunk shorter than 249 bytes this produces a shortened codeword (len(chunk)+6 bytes) — exactly what a burst's final block is.

7.4 Decode — syndromes / BM / Chien / Forney (rs_decode)

rs_decode(cw) takes a codeword of length n = len(cw) (so shortened blocks just work) and returns (data, ok):

  1. Syndromes. S_j = r(α^(j+FCR)) for j = 0…5 (_syndromes). All-zero ⇒ no errors, return the first n−6 bytes with ok = True.
  2. Berlekamp–Massey. Build the error-locator polynomial Λ(x) iteratively from the discrepancy δ = S_k + Σ Λ_j·S_{k−j}, growing/updating Λ and the auxiliary old polynomial (vdl2.py). A shift = len(synd) − _NROOTS indexes the syndrome array (padded with a leading 0).
  3. Chien search. The number of errors is deg Λ. Error positions are the i for which Λ(α^i) = 0, taken as pos = n − 1 − i (vdl2.py). If the root count ≠ deg Λ, decoding fails (ok = False) — but the raw first n−6 bytes are still returned.
  4. Forney. For each located position, the error magnitude is Y = Λ'(X⁻¹)-scaled evaluation of the error-evaluator Ω: ev = (S(x)·eLoc(x)) truncated, then y = _peval(ev, X⁻¹), corrected by the formal-derivative product prime = ∏_{j≠i}(1 − X_i⁻¹·X_j) and the fcr twist _gpow(X_i, 1−FCR) (vdl2.py). The byte at pos[i] is XORed with y · prime⁻¹.
  5. Verify. Recompute syndromes; ok = (max syndrome == 0).

Because every position index is computed relative to n = len(cw), the same code decodes a full 255-byte codeword and a shortened last block without a special case — which is exactly how decode_burst uses it (§5.6). The test drives a shortened codeword directly: 60 data bytes → 66-byte codeword, three byte errors injected and corrected (test_rs_corrects_three_errors), and four errors (beyond t = 3) correctly fail (test_rs_fails_beyond_capacity).


8. Interoperability and validation

8.1 The test suite (test_vdl2.py)

Eight tests exercise every layer; all use fixed RNG seeds and are self-consistent (encode → optionally corrupt → decode):

Test What it proves Key assertions
test_d8psk_roundtrip_and_gray modem round-trip + Gray adjacency demodulate(modulate(bits)) recovers 300 random bits; every ±1 step slip flips exactly one tribit bit
test_rs_corrects_three_errors RS shortened-codeword correction 60-byte data → 66-byte codeword; 3 byte errors at positions 3/30/61 corrected exactly
test_rs_fails_beyond_capacity RS failure past t = 3 4 byte errors → decode fails or returns wrong data (never a silent wrong "ok")
test_scrambler_involutive additive LFSR is its own inverse _scramble(_scramble(bits)) == bits; and _scramble(bits) != bits
test_avlc_hdlc_roundtrip AVLC build/parse + bit-stuffing info padded with 7e 7e ff (stresses stuffing); dst 0xABCDEF, src 0x123456, info recovered exactly
test_acars_over_avlc_end_to_end full stack, clean IQ ACARS → build_acars_avlcencode_burstdecode_burst; marker present; acars.parse recovers reg G-EUUU, text POS 5030N 00400W
test_burst_through_noise full stack, 30 dB AWGN same chain with complex noise at `√(mean
test_header_golay Golay length header header_encode(198) is 24 bits, decodes to 198; survives 3 injected bit flips

The end-to-end noise test is the strongest evidence the chain hangs together: it modulates a real ACARS message through AVLC + RS + scrambler + D8PSK, adds complex Gaussian noise at ~30 dB SNR, and still recovers the exact registration and text — the sync tolerance, RS correction and CRC gate all doing their jobs.

8.2 On-air reference

The self-consistency tests validate the transcription against itself. The final interoperability check — decoding a real off-air VDL2 signal — is not yet done; there is no capture harness or SDR binding in this module today. When it is, the natural reference is an external VDL2 decoder such as dumpvdl2 (comparable to how the P25 path is checked against dsd-fme and AIS against AIS-catcher). The named on-air unknowns are the four items in §9.


9. Limitations and interop caveats

Stated honestly, roughly in order of impact:

  • Four transcription unknowns (the module's own caveat). Spec-accurate are the D8PSK Gray mapping, the RS dimensions, the LFSR polynomial and the AVLC frame shape. Still to be confirmed against a real signal are: the training / sync symbols (SYNC_SYMS is a self-consistent unique word, not verified to be the Annex 10 preamble), the scrambler seed (0x7FFF), the header bit layout (see next point), and the RS symbol ordering (byte order within and across codewords). Each round-trips against itself but is a named on-air interop step, not a bit-exact match.
  • Simplified header. The module encodes only a 12-bit byte-length under Golay(24,12,8). The module docstring notes the real VDL2 header is a wider "17-bit reserved/length word under a block code"; the reserved bits and the real header FEC are not modelled — the Golay length word is a stand-in that is sufficient to size the payload for the decoder.
  • Prepended reference symbol. modulate prepends an explicit phase-0 reference symbol so the first differential detection has an anchor. Real VDL2 derives its phase reference from the training sequence; the explicit reference is a transcription convenience, not the on-air preamble.
  • Not wired into the app. VDL2 is a standalone codec + test. There is no streaming decoder class, no mode-manager entry, no SDR source and no UI — unlike the AIS/P25/TETRA paths, which have live receivers. encode_burst / decode_burst operate on in-memory IQ only.
  • ACARS-over-AVLC only. The ATN/OSI stack (CLNP/CPDLC) that VDL2 also carries is out of scope; only the ACARS information-field service is wrapped/unwrapped.
  • Block-at-a-time timing, no tracking. Symbol timing is a one-shot max-energy pick over the burst (_demod_steps), with no fractional interpolation or tracking loop — fine for short clean bursts, weaker on drifting or very weak signals (the same trade-off as the AIS/P25 demods).
  • No media-access / link-management layer. Real VDL2 is a CSMA multi-station link with acknowledgements, XID/link-establishment frames and a data-link service sublayer; this module handles a single information frame in isolation — no acknowledgement, retransmission or connection state.

None of these change the architecture; they are the natural next increments on a working native modem + FEC + link layer.


10. References

  1. ICAO Annex 10, Volume IIIAeronautical Telecommunications, Part I, the VHF Data Link (VDL Mode 2) SARPs: the D8PSK waveform, the symbol/bit rates, AVLC and the transmission structure (cited vdl2.py).
  2. ICAO Doc 9776Manual on VHF Digital Link (VDL) Mode 2 — the detailed physical-layer, AVLC and sub-network protocol description.
  3. ARINC-618 / ARINC-620 — the ACARS message layer carried in the AVLC information field (the payload; see ACARS).
  4. In-repo implementation: app/radio/vdl2.py; tests in test_vdl2.py.
  5. Reused building blocks: app/radio/tetra/dsp.py (rrc_taps), app/radio/aisdecode.py (hdlc_frame/hdlc_deframe/_fcs), app/radio/p25/fec.py (golay24_encode/golay24_decode), app/radio/sat/rs.py (the RS decoder structure), app/radio/acars.py (the message layer).

Related: ACARS — the message layer VDL2 carries in its AVLC information field; AIS — the shared HDLC bit-stuffing + CRC-16/X.25 link layer; TETRA — the shared root-raised-cosine pulse (widened here to 8 samples/symbol); P25 — the Golay(24,12,8) header code and the RS Berlekamp–Massey/Chien/Forney lineage; LRPT — the CCSDS RS(255,223) this codec was ported from.