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 resamplesfs → bwfirst).gr-lora-sdr— a faithful Python port of the EPFL gr-lora_sdrframe_syncblock, run at the native ratefs(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_bwinsoftlora/utils.py): zero-phase IIR decimation viascipy.signal.decimate. Needs the whole signal up front. - Streaming (
_StatefulDecimatorinsoftlora/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
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
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
The averaged preamble upchirp peak ŝ_up and the averaged SFD downchirp peak
ŝ_down jointly resolve the integer offsets:
- integer CFO
L_CFOfrom(ŝ_up + ŝ_down) mod N - integer STO
L_STOfromŝ_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
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
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:
- Preamble detection — consecutive dechirped symbol windows must peak in
nearly the same FFT bin; a
k_hatmajority vote picks the reference bin. - Fractional CFO (Bernier) — the per-symbol phase drift of the preamble
upchirps, used to build a
CFO_frac_correcphase ramp. - Fractional STO (RCTSL) — a zero-padded-FFT estimator on the CFO-corrected preamble.
- Net-id validation — the two network-identifier upchirps after the
preamble are checked against
sync_word(default0x12, mapped to two net-id symbols); wrong sync words are rejected, and off-by-one alignments are recovered by re-reading the stream. - Integer CFO — extracted from the first SFD downchirp as
floor(down_val/2)(orfloor((down_val − N)/2)), the same trick gr-lora_sdr'sfft_demoduses. - SFO correction — a clock-ratio
sfo_hatis applied to the preamble symbols. By default it is derived from the residual CFO againstfc(valid when the whole frequency error comes from a shared clock); a fixed value can be supplied withsfo_ppm, or swept whensfo_ppm='auto'. - 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.
2.4 The sfo_ppm search
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_headerextracts 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:
- Gray decode —
symbol − 1, thenx ^ (x >> 1)(data frames). - Deinterleave — undo the symbol interleaver (block width
cr + 4). - Hamming decode — correct single-bit errors per codeword.
- Dewhitening — XOR the payload nibbles with the whitening sequence.
- CRC-16 — the two CRC bytes are compared against the recomputed
calc_lora_crc16of 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 |