Skip to content

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:

  1. The per-symbol FFT power spectra of the payload are collected.
  2. 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.
  3. 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).
  4. If no candidate validates, the packet is still emitted as ok=True with crc_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:

\[ p_m(k) \;=\; \frac{|Y_{m,k}|^2}{\displaystyle\sum_{j=0}^{N-1} |Y_{m,j}|^2} \]

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 \;=\; \frac{p_m(1)}{p_m(2)} \]
  • \(\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

\[ c(m, r) \;=\; -\ln \frac{p_m(r)}{p_m(1)} \;=\; \ln p_m(1) - \ln p_m(r) \;\ge\; 0 \]

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-K bins of each symbol are considered (K=5 by 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:

  1. the max-likelihood (argmax) vector — all top-1 bins;
  2. single flips of the least-reliable positions, cheapest first;
  3. 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:

\[ L_{m,b} \;=\; \ln \frac{\displaystyle\sum_{k : \mathrm{bit}_b(k)=0} p_m(k)} {\displaystyle\sum_{k : \mathrm{bit}_b(k)=1} p_m(k)} \]
  • \(\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

\[ s_m \;=\; \sum_{b=0}^{sf-1} \mathrm{bit}_{m,b} \cdot 2^{sf-1-b} \]

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() in softlora/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-K bins) 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

\[ p_m = [\,0.70,\; 0.20,\; 0.05,\; 0.05\,] \]

Reliability. \(p_m(1)=0.70\), \(p_m(2)=0.20\):

\[ \rho_m = \frac{0.70}{0.20} = 3.5 < 15 \]

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\).

\[ L_{m,0} = \ln \frac{p_0+p_1}{p_2+p_3} = \ln \frac{0.90}{0.10} = 2.20 \quad\Rightarrow\quad \mathrm{bit}_0 = 0 \]
\[ L_{m,1} = \ln \frac{p_0+p_2}{p_1+p_3} = \ln \frac{0.75}{0.25} = 1.10 \quad\Rightarrow\quad \mathrm{bit}_1 = 0 \]

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.py for 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.