Skip to content

The decode pipeline

This page walks through every stage a packet travels through, from raw IQ to decoded payload, and where each stage lives in the code.

The receiver has two synchronization front-ends, selected with DecoderSettings(sync_algorithm=...):

  • xhonneux (default) — the Xhonneux et al. 2021 three-stage paper sync, run at the Nyquist rate (the decoder resamples fs → bw first).
  • gr-lora-sdr — a faithful Python port of the EPFL gr-lora_sdr frame_sync block, run at the native rate fs (no resampling).

They differ only in how the frame is synchronized; everything downstream of synchronization (header, demodulation, decoding, CRC, Chase) is shared.

flowchart TB
    A[IQ at fs] --> B{DecoderSettings.sync_algorithm}
    B -->|xhonneux| X1[resample fs→bw]
    X1 --> X2["Stage 1 · preamble + fractional CFO"]
    X2 --> X3["Stage 2 · preliminary fractional STO"]
    X3 --> X4["Stage 3 · integer CFO/STO + definitive STO"]
    X4 --> X5[apply_freq_correction + sub-sample timing]
    B -->|gr-lora-sdr| G1[band-limit to fs/bw ≤ max_os_factor]
    G1 --> G2[GrFrameSync · detect → Bernier CFO → RCTSL STO]
    G2 --> G3[net-id validate + integer CFO from SFD]
    G3 --> G4[SFO correction → synced symbols at native rate]
    X5 --> C[header decode / forced params]
    G4 --> C
    C --> D[demodulate N payload symbols]
    D --> E[gray → deinterleave → Hamming → dewhiten]
    E --> F[CRC-16 check]
    F --> L[Packet]
    D -->|"decode_mode='chase'"| K["Chase soft-decision · max-likelihood first, then flips"]
    K --> L

0. The two synchronization front-ends

xhonneux gr-lora-sdr
Sample rate resamples fs → bw (Nyquist) native fs (band-limited to ≤ max_os_factor·bw)
Algorithm Xhonneux et al. 2021 three-stage paper sync (sync.py) EPFL gr-lora_sdr frame_sync port (gr_frame_sync.py)
CFO estimate fractional from preamble bin products (Eq. 14) + integer from up/SFD peaks (Eq. 17) Bernier fractional CFO + integer CFO from the first SFD downchirp (floor(down_val/2))
STO estimate preliminary + definitive fractional (Eq. 20), integer from Eq. 18 RCTSL fractional STO (zero-padded FFT)
Net-id not validated validated against sync_word
SFO not modelled derived from the residual CFO, or fixed via sfo_ppm
Streaming decode_stream (stateful FIR decimator) decode_file / decode_iq / decode only

See the README's Synchronization algorithms section for the full comparison and when to pick each.

1. Xhonneux sync (default)

This is the paper algorithm as implemented in softlora/sync.py; every estimator carries its equation number from Xhonneux et al. 2021.

1.1 Resampling

The Xhonneux sync runs at the LoRa bandwidth (one sample per N = 2**sf symbol).

  • Offline (resample_to_bw in softlora/utils.py): zero-phase IIR decimation via scipy.signal.decimate. Needs the whole signal up front.
  • Streaming (_StatefulDecimator in softlora/decoder.py): a causal, linear-phase FIR decimator that carries filter state across chunks. Linear phase is essential — an IIR would smear the sweeping chirp in time. The group delay is compensated once at the stream start and the downsampling phase is tracked globally, so the output matches the offline path exactly regardless of chunk boundaries.

When fs == bw, resampling is a no-op.

1.2 Stage 1 — Preamble detection and fractional CFO

stage1_detect_and_cfo

Every N-sample window is dechirped (multiplied by the reference downchirp) and FFT'd. A preamble is declared when N_detect consecutive windows peak in the same bin (within one bin). The fractional CFO λ_CFO is then estimated by averaging the phase of the dechirped bin products over consecutive upchirp pairs in the preamble (Eq. 14 of Xhonneux et al.).

1.3 Stage 2 — Preliminary fractional STO

stage2_preliminary_sto

The N_detect windows after the detected preamble are CFO-corrected and their FFTs averaged (Y_avg). The peak bin s̃_up and its neighbours are used to interpolate a first, fractional estimate of the symbol timing offset λ̃_STO (Sec. 4.3 of the paper).

1.4 Stage 3 — Integer CFO/STO and definitive STO

stage3_final_sync

The averaged preamble upchirp peak ŝ_up and the averaged SFD downchirp peak ŝ_down jointly resolve the integer offsets:

  • integer CFO L_CFO from (ŝ_up + ŝ_down) mod N
  • integer STO L_STO from ŝ_up - L_CFO, wrapped to [-N/2, N/2)
  • the preamble/SFD boundary estimate M̂ = N - L_STO

The definitive fractional STO λ_STO is then interpolated from the averaged spectrum Y_avg.

1.5 Frequency and timing correction

apply_freq_correction applies the combined L_CFO + λ_CFO phase ramp to the whole signal. The data start is computed as

payload_start = preamble_start + (N_preamble_up + N_netid + N_sfd_down)·N − (Γ_N(L_STO) + λ_STO)

with Γ_N unwrapping L_STO from [0, N) into [-N/2, N/2) so a slightly negative STO does not push the payload a full symbol early, and the signal is sliced there. The fractional residual (up to ±0.5 samples) is not removed up front; instead _demod_at samples the corrected signal at a fractional start via a zero-padded FFT phase ramp (_frac_advance), and — when the hard decode of a fully buffered packet fails — the sub-sample timing_search probe tries those alignments before giving up.

1.6 SNR estimation

estimate_snr averages the preamble upchirp spectra and compares the peak-bin power to the median noise floor. The dechirped FFT concentrates the signal into one bin of power Ps·N² while the noise spreads N·Pn per bin, giving

SNR = 10·log10( P_peak / (N · P_noise) )

2. gr-lora-sdr sync

The gr-lora-sdr front-end (_decode_gr / _decode_gr_once in decoder.py) runs the ported frame_sync block at the native rate fs, which must be a clean multiple of bw.

2.1 Band limiting

GrFrameSync decimates by picking every fs/bw-th sample with an STO-dependent phase offset and no anti-alias filter (exactly as gr-lora_sdr does, which expects fs/bw ≤ 8). At high oversampling that would fold noise bands into the LoRa band, so when fs/bw exceeds DecoderSettings.max_os_factor (default 4) and is an exact multiple of it, the input is band-limited and decimated down to max_os_factor with scipy.signal.resample_poly first.

2.2 The GrFrameSync state machine

GrFrameSync (in softlora/gr_frame_sync.py) transcribes the EPFL lib/frame_sync_impl.cc line-by-line. It walks the signal through three states — DETECT, SYNC, SFO_COMPENSATION:

  1. Preamble detection — consecutive dechirped symbol windows must peak in nearly the same FFT bin; a k_hat majority vote picks the reference bin.
  2. Fractional CFO (Bernier) — the per-symbol phase drift of the preamble upchirps, used to build a CFO_frac_correc phase ramp.
  3. Fractional STO (RCTSL) — a zero-padded-FFT estimator on the CFO-corrected preamble.
  4. Net-id validation — the two network-identifier upchirps after the preamble are checked against sync_word (default 0x12, mapped to two net-id symbols); wrong sync words are rejected, and off-by-one alignments are recovered by re-reading the stream.
  5. Integer CFO — extracted from the first SFD downchirp as floor(down_val/2) (or floor((down_val − N)/2)), the same trick gr-lora_sdr's fft_demod uses.
  6. SFO correction — a clock-ratio sfo_hat is applied to the preamble symbols. By default it is derived from the residual CFO against fc (valid when the whole frequency error comes from a shared clock); a fixed value can be supplied with sfo_ppm, or swept when sfo_ppm='auto'.
  7. Payload output — synced symbols are emitted at the native rate, with the residual CFO still in the signal.

2.3 Demodulation and reporting

Because the CFO is left in the output, the payload symbols are demodulated against a reference built at cfo_int (build_upchirp(info['cfo_int'] % N) adjusted by cfo_frac), the same trick gr-lora_sdr's fft_demod uses — this is what makes the sync robust to any downchirp behaviour.

Packet.freq_offset_hz is the total offset (cfo_int + cfo_frac)·bw/N, and the raw estimates (cfo_int, cfo_frac, sto_frac, k_hat) are reported in Packet.sync.

When sfo_ppm='auto' (the default), _decode_gr sweeps DecoderSettings.sfo_ppm_search — the ppm candidates measured across real captures, roughly −30..+15 ppm — and accepts the first frame whose CRC validates. This adds one decode attempt per candidate; for a known-good capture you can pin sfo_ppm=0 to decode faster.

2.5 Streaming

Both sync algorithms stream through decode_stream / flush. The xhonneux path decimates fs -> bw and runs the streaming scan; with sync_algorithm= 'gr-lora-sdr' the native-rate run-detection scanner (_GrFrameStream, used by decode_file / decode_iq and exposed directly by decode_stream) dechirps consecutive symbol windows, buffers a window around each detected preamble run, and decodes it with GrFrameSync as soon as the window is complete.

3. Header

  • Explicit header (standard LoRa): the first 8 symbols are decoded with the 4/5 header code. decode_header extracts the payload length, CRC presence flag and code rate, and checks the header checksum.
  • Implicit header: no header is transmitted; length, CRC and code rate are taken from the forced constructor parameters.

4. Demodulation

calc_payload_sym_num computes how many symbols the declared payload occupies. The synced signal is sliced into N-sample windows, each dechirped and reduced to its argmax bin — that bin index is the LoRa symbol value. (The gr-lora-sdr path dechirps against the cfo_int-adjusted reference instead of the plain downchirp.)

5. Decoding

decode reverses the LoRa TX chain for each coding block:

  1. Gray decodesymbol − 1, then x ^ (x >> 1) (data frames).
  2. Deinterleave — undo the symbol interleaver (block width cr + 4).
  3. Hamming decode — correct single-bit errors per codeword.
  4. Dewhitening — XOR the payload nibbles with the whitening sequence.
  5. CRC-16 — the two CRC bytes are compared against the recomputed calc_lora_crc16 of the payload.

6. Chase mode

With DecoderSettings(decode_mode='chase'), Chase is the payload decoder: the per-symbol FFT power spectra go straight to chase_decode — the max-likelihood symbol vector is tried first, then symbol/bit flips of the least-reliable positions until a candidate's CRC matches. There is no separate hard-decision pass. With the default 'hard' mode Chase never runs. See the Chase guide.

Where this lives

Stage Function Module
Resample (xhonneux) resample_to_bw / _StatefulDecimator utils.py / decoder.py
Band-limit (gr) resample_poly (max_os_factor) decoder.py
Xhonneux stage 1 stage1_detect_and_cfo sync.py
Xhonneux stage 2 stage2_preliminary_sto sync.py
Xhonneux stage 3 stage3_final_sync sync.py
Xhonneux correction apply_freq_correction, _frac_advance sync.py / decoder.py
Xhonneux SNR estimate_snr packet.py
gr-lora-sdr sync _decode_gr_once, GrFrameSync.run decoder.py / gr_frame_sync.py
gr-lora-sdr SNR GrFrameSync.determine_snr gr_frame_sync.py
Header decode_header coding.py
Demodulate demodulate_symbols_from utils.py
Decode decode, decode_symbols, dewhiten coding.py
CRC calc_lora_crc16 coding.py
Chase chase_decode chase.py
Orchestration LoRaDecoder.decode / _decode_xhonneux / _decode_gr decoder.py