Skip to main content
gr-adsb processes a raw IQ stream through three sequential GNU Radio blocks: FramerDemodDecoder. Each block performs one stage of the processing chain. This page explains the signal characteristics and the algorithms used in each stage.

RF signal characteristics

ParameterValue
Center frequency1090 MHz
ModulationPulse Position Modulation (PPM)
Symbol rate1 Msym/s (SYMBOL_RATE = 1e6)
Minimum sample rate2 Msps (2 samples per symbol)
Short message duration~64 µs (56 bits)
Long message duration~120 µs (112 bits)
The sample rate must be an integer multiple of twice the symbol rate (2 Msps). Both the Framer and Demod blocks enforce this:
# framer.py / demod.py
assert self.fs % SYMBOL_RATE == 0, \
    "ADS-B Framer is designed to operate on an integer number of samples per symbol, not %f sps" \
    % (self.fs / SYMBOL_RATE)
self.sps = int(fs // SYMBOL_RATE)
Common valid sample rates are 2 Msps, 4 Msps, 6 Msps, etc.

Preamble detection (Framer block)

Before any data bits can be demodulated, the Framer must locate the start of each ADS-B burst within the continuous sample stream.

Preamble structure

Every ADS-B message is preceded by an 8-symbol (16 half-symbol pulse) preamble. At 1 Msym/s, the preamble occupies 8 µs. The expected preamble pattern in half-symbol units is:
# framer.py
self.preamble_pulses = [1, 0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0]
A 1 indicates a pulse above half the amplitude of the first detected pulse; a 0 indicates noise or silence.

Detection algorithm

1

Threshold detection

The input power samples (float32, representing I² + Q²) are compared against a user-configurable threshold. Samples above the threshold are marked 1; samples below are marked 0, producing a binary pulse array.
# framer.py
in0_pulses = np.zeros(N + 1, dtype=int)
in0_pulses[np.insert(in0[0:N], 0, self.prev_in0) >= self.threshold] = 1
2

Edge detection

Subtracting the pulse array from a one-sample-delayed copy yields transition indicators: +1 for rising edges and -1 for falling edges.
in0_transitions = in0_pulses[1:] - in0_pulses[:-1]
in0_rise_edge_idxs = np.nonzero(in0_transitions == 1)[0]
in0_fall_edge_idxs = np.nonzero(in0_transitions == -1)[0]
3

Pulse center calculation

The center index of each detected pulse is computed as the mean of the corresponding rising and falling edge indices.
pulse_idxs = np.mean((in0_fall_edge_idxs, in0_rise_edge_idxs), axis=0, dtype=int)
4

Preamble correlation

For each candidate pulse center, the amplitudes at each half-symbol offset are sampled and compared against half the amplitude of the candidate pulse. The resulting binary pattern is compared against the expected preamble. A burst is declared only when all 16 half-symbol positions match exactly.
amps = in0[pulse_idx : pulse_idx + NUM_PREAMBLE_BITS*self.sps : self.sps // 2]
pulses = np.zeros(NUM_PREAMBLE_PULSES, dtype=int)
pulses[amps > in0[pulse_idx] / 2] = 1

corr_matches = np.sum(pulses == self.preamble_pulses)
if corr_matches == NUM_PREAMBLE_PULSES:
    # Preamble found
5

SNR estimation and stream tagging

Once a preamble is confirmed, the burst SNR is estimated and the start of the burst is tagged on the sample stream. The SNR uses the detected pulse amplitude as signal power and the median of the preceding 100 samples as noise power. A +1.6 dB correction accounts for the fact that the median of a Rayleigh-distributed variable is 1.6 dB below its mean.
snr = 10.0 * np.log10(in0[pulse_idx] / np.median(in0[(pulse_idx - NUM_NOISE_SAMPLES):pulse_idx])) + 1.6
The tag is written to the sample stream with key "burst" and value ("SOB", snr) at the burst start offset.
To avoid redundant preamble searches within an active burst, the Framer tracks the expected end-of-burst index (prev_eob_idx) and skips any pulse candidate that falls within an ongoing packet.

Bit demodulation (Demod block)

The Demod block reads the stream tags produced by the Framer and extracts the bit values from each burst using PPM decoding.

PPM decoding principle

In Pulse Position Modulation, each symbol period is divided into two equal half-periods:
  • A pulse in the first half = bit 1
  • A pulse in the second half = bit 0
At 1 Msym/s with 1 sample per symbol (minimum), each symbol occupies 1 sample. At 2 Msps, each symbol occupies 2 samples — one for each half-symbol period.

Bit extraction

After skipping the 8-symbol preamble, the Demod block samples the amplitude at the expected bit-1 and bit-0 positions for all 112 bit slots, then assigns each bit based on which half had the higher amplitude:
# demod.py
# Grab the amplitudes where the "bit 1 pulse" should be
bit1_idxs = range(sob_idx, sob_idx + self.sps * MAX_NUM_BITS, self.sps)
bit1_amps  = in0[bit1_idxs]

# Grab the amplitudes where the "bit 0 pulse" should be
bit0_idxs = range(sob_idx + self.sps // 2, sob_idx + self.sps // 2 + self.sps * MAX_NUM_BITS, self.sps)
bit0_amps  = in0[bit0_idxs]

self.bits = np.zeros(MAX_NUM_BITS, dtype=np.uint8)
self.bits[bit1_amps > bit0_amps] = 1

Bit confidence metric

A log-likelihood ratio is computed for each bit position. Values near zero indicate ambiguous bits; large positive values indicate confident 1s; large negative values indicate confident 0s. This metric is stored internally but is not currently forwarded to the Decoder.
# demod.py
with np.errstate(divide='ignore'):
    self.bit_confidence = 10.0 * (np.log10(bit1_amps) - np.log10(bit0_amps))

PDU output

Each decoded burst is emitted as a GNU Radio PDU (Protocol Data Unit) on the demodulated message port:
# demod.py
meta = pmt.to_pmt({
    "timestamp": self.start_timestamp + tag.offset / self.fs,
    "snr": snr,
})
vector = pmt.to_pmt(self.bits)   # uint8 array, 112 elements
pdu = pmt.cons(meta, vector)
self.message_port_pub(pmt.to_pmt("demodulated"), pdu)
The metadata dictionary carries two fields: timestamp (UTC seconds since epoch, derived from the sample offset) and snr (dB, from the Framer tag).

CRC verification (Decoder block)

The Decoder receives PDUs from the Demod block and first checks whether the message’s parity field is valid before attempting to decode the contents.

CRC polynomial

The CRC-24 polynomial used is 0xFFFA048, represented as a coefficient array:
# decoder.py
self.crc_poly = np.array([1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,0,0,0,0,0,0,1,0,0,1])
# = 1 + x + x² + x³ + x⁴ + x⁵ + x⁶ + x⁷ + x⁸ + x⁹ + x¹⁰ + x¹¹ + x¹² + x¹⁴ + x²¹ + x²⁴

CRC computation

The CRC is computed using standard binary polynomial long division over GF(2). The data bits (excluding the final 24 parity bits) are shifted by 24 positions and then XOR’d with the polynomial at each 1 bit:
# decoder.py
def compute_crc(self, data, poly):
    num_data_bits = len(data)
    num_crc_bits  = len(poly) - 1
    data = np.append(data, np.zeros(num_crc_bits, dtype=int))
    for ii in range(0, num_data_bits):
        if data[ii] == 1:
            data[ii : ii + num_crc_bits + 1] ^= poly
    return data[num_data_bits : num_data_bits + num_crc_bits]

Parity check rules by DF

The PI (Parity/Interrogator ID) field at bits 88–111 must equal the CRC computed over bits 0–87:
pi  = self.bin2dec(self.bits[88:88+24])
crc = self.bin2dec(self.compute_crc(self.bits[0:88], self.crc_poly))
parity_passed = (pi == crc)
This is a self-contained check; no prior knowledge of the aircraft is required.
DF 0/4/5/16/20/21 messages can only be verified if the aircraft’s ICAO address is already in the plane dictionary. If the receiver has not yet seen a DF 11 or DF 17 message from that aircraft, these messages will fail the parity check and be discarded.

Error correction

When a message fails its initial CRC check, gr-adsb can optionally attempt to correct it. Three modes are available, selected at block construction time via the error_corr parameter.
No correction is attempted. Only messages that pass the CRC check unmodified are forwarded to the decode stage.
# decoder.py
if self.error_corr == "None":
    return 0
Attempts to correct contiguous burst errors of 1 or 2 bits using a pre-computed CRC syndrome lookup table. At startup, the Decoder precomputes the syndrome for every possible single- and double-bit contiguous burst error position across both 56-bit and 112-bit message lengths.
# decoder.py — syndrome table construction
for burst in range(1, 3):
    self.compute_crc_syndromes_for_contiguous_bursts(56,  burst)
    self.compute_crc_syndromes_for_contiguous_bursts(112, burst)
At decode time, the CRC residual of the failed message is used as a lookup key. If a matching syndrome is found, the corresponding bit positions are flipped and the CRC is re-checked:
# decoder.py — correction attempt
def correct_burst_errors(self):
    crc_bits = self.compute_crc_2(self.bits[0:self.payload_length], self.crc_poly)
    crc_lookup = self.bin2dec(crc_bits)
    if self.payload_length in self.crc_fix_lookup:
        if crc_lookup in self.crc_fix_lookup[self.payload_length]:
            bits_to_change = self.crc_fix_lookup[self.payload_length][crc_lookup]
            for bt in bits_to_change:
                self.bits[bt] = self.bits[bt] ^ 1
            # Re-check CRC after correction
            crc_bits = self.compute_crc_2(self.bits[0:self.payload_length], self.crc_poly)
            return self.bin2dec(crc_bits) == 0
    return 0
Intended to try a wider range of error patterns beyond contiguous bursts. The mode is recognized by the Decoder but currently logs a critical message and returns without any correction:
# decoder.py
elif self.error_corr == "Brute Force":
    self.log("critical", "FEC", "Brute Force error correction to be implemented")
    return 0

Build docs developers (and LLMs) love