Chase decoding
Chase is a soft-decision decoder for payload symbols. It is a decoder
mode: with DecoderSettings(decode_mode='chase') Chase
is the payload decoder — the per-symbol FFT spectra go straight into it, and
it returns the first candidate symbol vector whose payload decodes and
verifies. It is a list decoder: instead of committing to one hard decision
per symbol, it builds a short list of the most plausible symbol vectors (ranked
by soft reliability) and checks each one until one validates.
When it runs
decode_mode='chase' makes Chase the decoder from the very start: there is
no separate hard-decision pass and no sub-sample timing probe. On every
decoded frame, for both the xhonneux and gr-lora-sdr sync paths:
- The per-symbol FFT power spectra of the payload are collected.
- The max-likelihood (argmax) symbol vector is decoded first; if its CRC-16 does not validate, single/pair/triple symbol flips and bit flips of the least-reliable positions are tried, ranked by soft reliability.
- A candidate is accepted only when its payload's recomputed CRC-16 matches the received CRC bytes (a packet without a CRC accepts the first clean decode).
- If no candidate validates, the packet is still emitted as
ok=Truewithcrc_valid=False, the payload reported from the argmax symbols as best effort.
Sync must still succeed: Chase only rescues payload-symbol errors, never "no preamble", bad sync, or a broken header.
With the default decode_mode='hard', Chase never runs.
The soft information — spectra to symbol probabilities
For each payload symbol m the decoder dechirps the N = 2**sf-sample window
and takes its FFT; demodulate_spectra_from
keeps the power spectrum \(|Y_{m,k}|^2\), k = 0 … N−1.
LoRa symbols are M-ary orthogonal over the dechirped FFT: a clean symbol
concentrates (almost) all its energy into a single bin, and the others see only
noise. The FFT power spectrum is the non-coherent energy-detector statistic,
so its argmax is the maximum-likelihood symbol decision. Chase keeps the whole
spectrum as soft information by normalizing it to a probability vector over
the N symbol values:
The hard symbol is \(\hat{s}_m = \arg\max_k p_m(k)\). The more the probability mass concentrates on one bin, the more confident that symbol is — an isolated peak gives \(p_m \approx \mathbf{e}_{\hat{s}_m}\) (one-hot), while under noise the mass spreads over several bins and the argmax can land on the wrong value.
Symbol-level Chase
Reliability
For symbol m, keep the top-K bins (K=5 by default) with probabilities
\(p_m(1) \ge p_m(2) \ge \dots\). The reliability is the
peak-to-second-peak ratio:
- \(\rho_m \to \infty\): the peak is isolated, the symbol is reliable;
- \(\rho_m \to 1\): two bins are nearly equally likely, the symbol is ambiguous — a candidate for a flip.
Only symbols with \(\rho_m < \rho_\text{thresh}\) (default 15) may be
flipped, and at most max_flip_pos (default 6) of the least-reliable ones.
Candidate flips and their cost
Replacing symbol m's top choice with its rank-r alternative costs the
log-likelihood distance
A cost near 0 means the alternative is almost as likely as the top bin. The costs are additive, so a two-symbol flip costs \(c(m_1,r_1) + c(m_2,r_2)\) and a three-symbol flip the sum of three terms. Candidates are generated in increasing total cost — i.e. in order of decreasing joint soft likelihood.
Two gates keep the list finite:
- only the top-
Kbins of each symbol are considered (K=5by default); - an alternative bin must hold at least 3 % of the top-bin power (\(p_m(r) \ge 0.03\, p_m(1)\)) to be usable.
The symbol-stage order is therefore:
- the max-likelihood (argmax) vector — all top-1 bins;
- single flips of the least-reliable positions, cheapest first;
- pair flips (two positions), then triple flips (over up to 4 flip positions), all ranked by total cost.
The whole stage is capped at max_attempts (default 120) candidates.
Bit-level Chase
Each symbol value is a bit pattern of sf bits, MSB first. The bit
log-likelihood ratio marginalizes the symbol posterior over all bins where
that bit is 0 vs 1:
- \(\operatorname{sign} L_{m,b}\) is the hard bit decision (\(L < 0 \Rightarrow\) bit = 1);
- \(|L_{m,b}|\) is that bit's reliability — small \(|L|\) means the bit is ambiguous.
The least-reliable bits (smallest \(|L|\)) are flipped one at a time (up to
max_bit_flips, default 40), then in pairs (max_pair_flips, default
15). Each candidate bit vector is rebuilt into symbols via
Bit-level Chase is a finer search than symbol-level: a symbol error moves a
bunch of bits at once, whereas a bit flip changes a single bit of one symbol.
It can recover the truth when the correct bin is outside the symbol stage's
top-K but the correct bits are only one or two flips from the bit-level hard
decision.
Chase as list decoding
This is the classical Chase algorithm (Chase-2) adapted to LoRa. Chase-2
takes the hard decisions, marks the t least-reliable bit positions, and
decodes a list of test patterns that flip subsets of those positions. Here:
- the "code" is the full LoRa TX chain — whitening, Hamming, interleaving,
Gray mapping (i.e.
decode()insoftlora/coding.py); - the "least-reliable positions" are the symbols with \(\rho_m < \rho_\text{thresh}\), and, in the bit stage, the bits with small \(|L_{m,b}|\);
- the "test patterns" are the cost-ordered symbol flips (top-
Kbins) and the \(|L|\)-ordered bit flips; - the acceptance test replaces nearest-codeword distance with CRC-16 verification: a candidate is accepted iff its payload's recomputed CRC-16 equals the received CRC bytes (packets without a CRC accept the first clean decode).
decode_fn is injected, so the Chase engine is agnostic to the coding scheme
— see chase_decode. The
first accepted candidate wins; if none validates, the stage is 'none' and
the decoder reports the argmax symbols as best effort (crc_valid=False).
Worked example
Take a single 4-bin symbol (sf=2, for illustration) with normalized spectrum
Reliability. \(p_m(1)=0.70\), \(p_m(2)=0.20\):
so symbol m is an ambiguous flip position. Its hard value is
\(\hat{s}_m = 0\) (bin 0).
Flip costs. The alternatives (all above the 3 % gate \(0.03 \cdot 0.70 = 0.021\)):
| alternative bin | \(p_m(r)\) | cost \(-\ln(p_m(r)/0.70)\) |
|---|---|---|
| 1 | 0.20 | \(-\ln(0.20/0.70) = 1.25\) |
| 2 | 0.05 | \(-\ln(0.05/0.70) = 2.64\) |
| 3 | 0.05 | \(-\ln(0.05/0.70) = 2.64\) |
Candidates touching this symbol therefore try bin 1 first, then bins 2 or 3; flipping this symbol together with another ambiguous symbol would add their costs.
Bit LLRs. Bins as 2-bit words (MSB first): \(0 \to 00\), \(1 \to 01\), \(2 \to 10\), \(3 \to 11\).
Both bits are 0 → symbol 00 = 0, matching the argmax, and both are fairly
reliable. Had \(p_1\) been close to \(p_0\), \(|L_{m,1}|\) would be small and
bit 1 would be flipped before bit 0 in the bit stage.
Tuning
DecoderSettings.chase_kwargs is forwarded to
chase_decode:
from softlora import DecoderSettings
decoder = LoRaDecoder(
sf=10, bw=125_000, fs=125_000, fc=437e6,
settings=DecoderSettings(decode_mode='chase', chase_kwargs={
'K': 5, # top bins per symbol (flip alphabet)
'reliability_thresh': 15.0, # rho_m below this -> flip position
'max_flip_pos': 6, # how many least-reliable symbols
'max_attempts': 120, # symbol-stage candidate budget
'max_bit_flips': 40, # single-bit flip budget
'max_pair_flips': 15, # bit-pair flip budget
'enable_bit_chase': True, # False: symbol stage only
}),
)
| knob | equation it bounds |
|---|---|
K |
size of the per-symbol flip alphabet (top-K bins) |
reliability_thresh |
which symbols satisfy \(\rho_m < \rho_\text{thresh}\) |
max_flip_pos |
how many of the least-reliable symbols may be flipped |
max_attempts |
how many cost-ordered symbol candidates are tried |
max_bit_flips / max_pair_flips |
how many single / pair bit flips are tried |
enable_bit_chase |
whether the bit-LLR stage runs at all |
Limits
- Chase helps at marginal SNR where most symbols demodulate correctly but a
few are ambiguous — the flip budget is bounded by the
chase_kwargs. - It does not fix bad sync/CFO/timing, heavy damage, or many symbol errors.
- In streaming, a truncated packet is handled by the pending/retry logic, not Chase.
- Chase is roughly 6× slower than hard decoding (see
perf/awgn_chase_vs_hard.pyfor the measured coding gain).
Result fields
Chase runs entirely inside the decoder and leaves no per-packet diagnostics on
Packet: the packet is emitted
normally (ok=True, crc_valid carrying the integrity verdict). On Chase
failure the packet is still emitted as ok=True with
crc_valid=False, the payload reported from the argmax symbols.