Skip to main content

mfsk_core/fec/qra/
code.rs

1//! Generic Q-ary Repeat-Accumulate (QRA) code framework.
2//!
3//! Ports `lib/qra/qracodes/qracodes.c` from the WSJT-X distribution
4//! (the qracodes library by Nico Palermo IV3NWV). One [`QraCode`]
5//! instance bundles every constant the encoder + belief-propagation
6//! decoder needs for one specific code; concrete codes such as
7//! `qra15_65_64_irr_e23` (Q65's FEC) live in their own modules and
8//! are exposed as `pub static` [`QraCode`] tables.
9//!
10//! ## API shape
11//!
12//! - [`QraCode::encode`] — systematic encoder, K info → N codeword
13//!   symbols, all over GF(M).
14//! - [`QraCode::mfsk_bessel_metric`] — convert M-FSK squared-amplitude
15//!   observations into intrinsic per-symbol probability distributions.
16//!   Used by the Q65 receiver before BP.
17//! - [`QraCode::extrinsic`] — non-binary belief propagation in the
18//!   probability domain, using Walsh-Hadamard transforms for
19//!   variable-to-check and check-to-variable updates over GF(M).
20//! - [`QraCode::map_decode`] — final maximum-a-posteriori hard
21//!   decision: pointwise multiply intrinsic × extrinsic, then argmax
22//!   each information symbol's distribution.
23//!
24//! ## Naming
25//!
26//! Field names match the C reference (uppercase `K`, `N`, `M`, `NC`,
27//! `V`, `C`, `NMSG`, `MAXVDEG`, `MAXCDEG`) so that `grep`-ing across
28//! the Rust port and the upstream source stays trivial. The
29//! `non_snake_case` lint is silenced for those fields.
30
31use super::{npfwht, pdmath};
32
33/// Code type discriminator, mirroring the C `QRATYPE_*` defines from
34/// `lib/qra/q65/qracodes.h`.
35///
36/// - [`QraCodeType::Normal`] — every codeword symbol carries data.
37/// - [`QraCodeType::Crc`] — the last information symbol is a CRC-6
38///   over the others; transmitted normally.
39/// - [`QraCodeType::CrcPunctured`] — the trailing CRC-6 symbol is
40///   not transmitted.
41/// - [`QraCodeType::CrcPunctured2`] — the trailing **two** CRC-12
42///   symbols are not transmitted (this is the variant used by Q65).
43#[repr(i32)]
44#[derive(Copy, Clone, Debug, Eq, PartialEq)]
45pub enum QraCodeType {
46    Normal = 0,
47    Crc = 1,
48    CrcPunctured = 2,
49    CrcPunctured2 = 3,
50}
51
52/// Static description of a single QRA code: parameters + the precomputed
53/// tables consumed by the encoder and BP decoder.
54#[allow(non_snake_case)]
55pub struct QraCode {
56    /// Number of information symbols.
57    pub K: usize,
58    /// Codeword length in symbols.
59    pub N: usize,
60    /// Bits per symbol (M = 2^m).
61    pub m: u32,
62    /// Symbol alphabet cardinality.
63    pub M: usize,
64    /// Code grouping factor (1 for Q65).
65    pub a: usize,
66    /// Number of check symbols (N − K).
67    pub NC: usize,
68    /// Number of variables in the code graph (= N).
69    pub V: usize,
70    /// Number of factor nodes (= N + NC + 1).
71    pub C: usize,
72    /// Number of edges / messages in the bipartite graph.
73    pub NMSG: usize,
74    /// Maximum variable-node degree in the graph.
75    pub MAXVDEG: usize,
76    /// Maximum factor-node (check) degree in the graph.
77    pub MAXCDEG: usize,
78    /// Code type (Normal / CRC / CRC-punctured).
79    pub code_type: QraCodeType,
80    /// Code rate K / N.
81    pub R: f32,
82    /// Human-readable code name (e.g. `"qra15_65_64_irr_e23"`).
83    pub name: &'static str,
84
85    // ── Encoder tables ──────────────────────────────────────────────
86    /// `acc_input_idx[k]` — index into `x[]` of the systematic input
87    /// symbol entering the accumulator at check `k`. Length
88    /// `NC * a + 1` (the +1 entry is the terminator check used in
89    /// debug builds; it is harmless to have it present).
90    pub acc_input_idx: &'static [i32],
91    /// `acc_input_wlog[k]` — log-α weight (in GF(M)*) applied to that
92    /// input before XOR-accumulation.
93    pub acc_input_wlog: &'static [i32],
94    /// GF(M) discrete log: `gflog[x] = log_α(x)` for `x != 0`.
95    pub gflog: &'static [i32],
96    /// GF(M) discrete exp: `gfexp[i] = α^i` for `0 ≤ i < M − 1`.
97    pub gfexp: &'static [i32],
98
99    // ── Decoder tables ──────────────────────────────────────────────
100    /// `msgw[i]` — log-α weight attached to message edge `i`.
101    pub msgw: &'static [i32],
102    /// `vdeg[v]` — degree of variable node `v`.
103    pub vdeg: &'static [i32],
104    /// `cdeg[c]` — degree of factor node `c`.
105    pub cdeg: &'static [i32],
106    /// `v2cmidx[v * MAXVDEG + k]` — message index for the k-th edge
107    /// leaving variable `v`. Length `V * MAXVDEG`.
108    pub v2cmidx: &'static [i32],
109    /// `c2vmidx[c * MAXCDEG + k]` — message index for the k-th edge
110    /// leaving check `c`. Length `C * MAXCDEG`.
111    pub c2vmidx: &'static [i32],
112    /// `gfpmat[w * M + k]` — multiplication-by-`α^w` permutation
113    /// table over GF(M). Length `(M − 1) * M`.
114    pub gfpmat: &'static [i32],
115}
116
117/// Outcome of [`QraCode::extrinsic`].
118#[derive(Copy, Clone, Debug, Eq, PartialEq)]
119pub enum ExtrinsicResult {
120    /// Iterations converged: every symbol's extrinsic distribution is
121    /// concentrated on a single value (sum of per-symbol max ≈ V).
122    Converged { iterations: u32 },
123    /// `maxiter` was hit without convergence. The output `pex` still
124    /// holds the best-effort distribution and the caller can pass it
125    /// to [`QraCode::map_decode`] for a possibly-correct hard decision
126    /// (often used as the front-end for an outer CRC check).
127    NotConverged,
128    /// A factor node had degree 1 — code-table pathology. Should not
129    /// happen for the codes shipped in this crate.
130    BadCodeTables,
131}
132
133/// Reusable scratch buffers for the BP decoder. Allocated once per
134/// [`QraCode`] and reused across decode calls — these arrays are
135/// sized in `O(NMSG · M)` floats which is non-trivial (~55 kB for
136/// Q65), so caller-side reuse matters.
137pub struct DecoderScratch {
138    v2cmsg: Vec<f32>,
139    c2vmsg: Vec<f32>,
140    msgout: Vec<f32>,
141}
142
143impl DecoderScratch {
144    /// Allocate scratch for the given code. Cheap to keep on a worker
145    /// or thread-local for the lifetime of a decoding session.
146    pub fn for_code(code: &QraCode) -> Self {
147        let edges = code.NMSG * code.M;
148        Self {
149            v2cmsg: vec![0.0; edges],
150            c2vmsg: vec![0.0; edges],
151            msgout: vec![0.0; code.M],
152        }
153    }
154}
155
156impl QraCode {
157    // ────────────────────────────────────────────────────────────────
158    // Encoder
159    // ────────────────────────────────────────────────────────────────
160
161    /// Systematic encode: copy `x` (K info symbols over GF(M)) into the
162    /// first K positions of `y`, then compute `NC` parity symbols by
163    /// running the QRA accumulator chain across `acc_input_idx /
164    /// acc_input_wlog`.
165    ///
166    /// `x.len()` must equal `self.K`; `y.len()` must equal `self.N`.
167    /// All symbol values must be in `0 .. self.M`.
168    pub fn encode(&self, x: &[i32], y: &mut [i32]) {
169        assert_eq!(x.len(), self.K, "encode: x.len() != K");
170        assert_eq!(y.len(), self.N, "encode: y.len() != N");
171
172        // Systematic prefix.
173        y[..self.K].copy_from_slice(x);
174        let modulus = (self.M - 1) as i32;
175
176        let mut chk: i32 = 0;
177        if self.a == 1 {
178            for k in 0..self.NC {
179                let idx = self.acc_input_idx[k] as usize;
180                let t = x[idx];
181                if t != 0 {
182                    let lg = (self.gflog[t as usize] + self.acc_input_wlog[k]).rem_euclid(modulus);
183                    chk ^= self.gfexp[lg as usize];
184                }
185                y[self.K + k] = chk;
186            }
187        } else {
188            // Irregular grouping: a > 1, with -1 entries skipped.
189            for k in 0..self.NC {
190                let kk = self.a * k;
191                for j in 0..self.a {
192                    let jj = kk + j;
193                    let idx = self.acc_input_idx[jj];
194                    if idx < 0 {
195                        continue;
196                    }
197                    let t = x[idx as usize];
198                    if t != 0 {
199                        let lg =
200                            (self.gflog[t as usize] + self.acc_input_wlog[jj]).rem_euclid(modulus);
201                        chk ^= self.gfexp[lg as usize];
202                    }
203                }
204                y[self.K + k] = chk;
205            }
206        }
207    }
208
209    // ────────────────────────────────────────────────────────────────
210    // Front-end: M-FSK Bessel metric
211    // ────────────────────────────────────────────────────────────────
212
213    /// Convert per-tone squared amplitudes into per-symbol intrinsic
214    /// probability distributions, assuming non-coherent M-FSK
215    /// reception in AWGN.
216    ///
217    /// `n_symbols` is the number of observed channel symbols — this
218    /// matches the C reference's `qra_mfskbesselmetric(..., N, ...)`
219    /// call shape, where `N` is passed in rather than read off the
220    /// code, because Q65 punctures 2 symbols off the codeword and so
221    /// observes `N - 2 = 63` symbols, not the underlying code's
222    /// `N = 65`. Both `pix` and `rsq` must have length `M * n_symbols`,
223    /// laid out row-major: symbol-`k` data sits in
224    /// `[M * k .. M * (k+1)]`.
225    ///
226    /// Returns the estimated noise standard deviation `σ̂`.
227    ///
228    /// The metric is calibrated for `Es/No = es_no_metric` (linear,
229    /// not dB). Q65 uses ~2.8 dB linearised, scaled by `m * R`.
230    pub fn mfsk_bessel_metric(
231        &self,
232        pix: &mut [f32],
233        rsq: &[f32],
234        n_symbols: usize,
235        es_no_metric: f32,
236    ) -> f32 {
237        let big_m = self.M;
238        let nsamples = big_m * n_symbols;
239        assert_eq!(pix.len(), nsamples, "mfsk_bessel_metric: pix length");
240        assert_eq!(rsq.len(), nsamples, "mfsk_bessel_metric: rsq length");
241
242        let mut rsum = 0.0_f32;
243        for k in 0..nsamples {
244            rsum += rsq[k];
245            pix[k] = rsq[k].sqrt();
246        }
247        rsum /= nsamples as f32;
248
249        let sigmaest = (rsum / (1.0 + es_no_metric / big_m as f32) / 2.0).sqrt();
250        let cmetric = (2.0 * es_no_metric).sqrt() / sigmaest;
251
252        for k in 0..n_symbols {
253            let row = &mut pix[big_m * k..big_m * (k + 1)];
254            ioapprox(row, cmetric);
255            pdmath::norm(row);
256        }
257
258        sigmaest
259    }
260
261    // ────────────────────────────────────────────────────────────────
262    // Belief-propagation decoder
263    // ────────────────────────────────────────────────────────────────
264
265    /// Iterative non-binary BP: given per-symbol intrinsic probability
266    /// distributions `pix` (length `M·V`), iterate until each
267    /// variable's outgoing extrinsic distribution is concentrated on
268    /// one value or `maxiter` is exhausted.
269    ///
270    /// On return, `pex` (length `M·V`) holds the extrinsic
271    /// distributions — pass them together with `pix` to
272    /// [`Self::map_decode`] for the final hard decision.
273    ///
274    /// `scratch` must come from [`DecoderScratch::for_code`] applied
275    /// to the same code instance.
276    pub fn extrinsic(
277        &self,
278        pex: &mut [f32],
279        pix: &[f32],
280        maxiter: u32,
281        scratch: &mut DecoderScratch,
282    ) -> ExtrinsicResult {
283        let big_m = self.M;
284        let v = self.V;
285        let c = self.C;
286        let max_vdeg = self.MAXVDEG;
287        let max_cdeg = self.MAXCDEG;
288
289        assert_eq!(pex.len(), big_m * v, "extrinsic: pex length");
290        assert_eq!(pix.len(), big_m * v, "extrinsic: pix length");
291        assert_eq!(
292            scratch.v2cmsg.len(),
293            self.NMSG * big_m,
294            "extrinsic: scratch belongs to a different code"
295        );
296
297        // Initial messages: c->v copy of the intrinsics for the
298        // first V check nodes (which are the intrinsic factors), and
299        // the v->c outbound copy of the intrinsic for every non-trivial
300        // variable degree.
301        let init_len = big_m * v;
302        scratch.c2vmsg[..init_len].copy_from_slice(pix);
303
304        for nv in 0..v {
305            let ndeg = self.vdeg[nv] as usize;
306            let msgbase = nv * max_vdeg;
307            let intrinsic_row = &pix[big_m * nv..big_m * (nv + 1)];
308            for k in 1..ndeg {
309                let imsg = self.v2cmidx[msgbase + k] as usize;
310                let dst = &mut scratch.v2cmsg[big_m * imsg..big_m * (imsg + 1)];
311                dst.copy_from_slice(intrinsic_row);
312            }
313        }
314
315        let mut rc = ExtrinsicResult::NotConverged;
316
317        for nit in 0..maxiter {
318            // ── c->v step ──────────────────────────────────────────
319            // Walsh-Hadamard convolution: for parity x1 + x2 + ... = 0
320            // over GF(M), Prob(x1) = WHT^{-1}( ∏ WHT(Prob(xi)) ) for
321            // i != 1. Output is normalised + permuted by α^{-w}.
322            for nc in v..c {
323                let ndeg = self.cdeg[nc] as usize;
324                if ndeg == 1 {
325                    return ExtrinsicResult::BadCodeTables;
326                }
327                let msgbase = nc * max_cdeg;
328
329                // Forward WHT on every input message in place. After
330                // this loop the v2cmsg rows feeding this check are in
331                // the WHT "frequency" domain.
332                for k in 0..ndeg {
333                    let imsg = self.c2vmidx[msgbase + k] as usize;
334                    let row = &mut scratch.v2cmsg[big_m * imsg..big_m * (imsg + 1)];
335                    npfwht::fwht(row);
336                }
337
338                for k in 0..ndeg {
339                    scratch.msgout.copy_from_slice(pdmath::uniform(big_m));
340
341                    for kk in 0..ndeg {
342                        if kk != k {
343                            let imsg = self.c2vmidx[msgbase + kk] as usize;
344                            let src = &scratch.v2cmsg[big_m * imsg..big_m * (imsg + 1)];
345                            pdmath::imul(&mut scratch.msgout, src);
346                        }
347                    }
348
349                    // Anti-underflow bias on the DC term, copied
350                    // verbatim from the reference: keeps the sum of
351                    // post-WHT components strictly positive.
352                    scratch.msgout[0] += 1e-7;
353
354                    npfwht::fwht(&mut scratch.msgout);
355
356                    let imsg = self.c2vmidx[msgbase + k] as usize;
357                    let wmsg = self.msgw[imsg];
358                    let dst = &mut scratch.c2vmsg[big_m * imsg..big_m * (imsg + 1)];
359
360                    if wmsg == 0 {
361                        dst.copy_from_slice(&scratch.msgout);
362                    } else {
363                        let perm = self.gfpmat_row(wmsg as usize);
364                        pdmath::bwdperm(dst, &scratch.msgout, perm);
365                    }
366                }
367            }
368
369            // ── v->c step ──────────────────────────────────────────
370            // Pointwise product of incoming c->v messages, normalise,
371            // then permute by α^{w}.
372            for nv in 0..v {
373                let ndeg = self.vdeg[nv] as usize;
374                let msgbase = nv * max_vdeg;
375
376                for k in 0..ndeg {
377                    scratch.msgout.copy_from_slice(pdmath::uniform(big_m));
378
379                    for kk in 0..ndeg {
380                        if kk != k {
381                            let imsg = self.v2cmidx[msgbase + kk] as usize;
382                            let src = &scratch.c2vmsg[big_m * imsg..big_m * (imsg + 1)];
383                            pdmath::imul(&mut scratch.msgout, src);
384                        }
385                    }
386
387                    pdmath::norm(&mut scratch.msgout);
388
389                    let imsg = self.v2cmidx[msgbase + k] as usize;
390                    let wmsg = self.msgw[imsg];
391                    let dst = &mut scratch.v2cmsg[big_m * imsg..big_m * (imsg + 1)];
392
393                    if wmsg == 0 {
394                        dst.copy_from_slice(&scratch.msgout);
395                    } else {
396                        let perm = self.gfpmat_row(wmsg as usize);
397                        pdmath::fwdperm(dst, &scratch.msgout, perm);
398                    }
399                }
400            }
401
402            // ── Convergence check ──────────────────────────────────
403            // Each variable's first outgoing message (msg index nv) is
404            // the extrinsic distribution towards the rest of the code.
405            // If its max approaches 1 for every variable, the EXIT
406            // chart has reached (1, 1) and we stop.
407            let mut totex = 0.0_f32;
408            for nv in 0..v {
409                let row = &scratch.v2cmsg[big_m * nv..big_m * (nv + 1)];
410                totex += pdmath::max(row);
411            }
412
413            if totex > v as f32 - 0.01 {
414                rc = ExtrinsicResult::Converged { iterations: nit };
415                break;
416            }
417        }
418
419        // Copy the V outgoing extrinsics into the caller's buffer.
420        pex.copy_from_slice(&scratch.v2cmsg[..big_m * v]);
421        rc
422    }
423
424    /// MAP hard decision on the K information symbols.
425    ///
426    /// Multiplies extrinsic × intrinsic for each variable, then
427    /// argmax. Destroys `pex` in place. `xdec.len()` must equal
428    /// `self.K`.
429    pub fn map_decode(&self, pex: &mut [f32], pix: &[f32], xdec: &mut [i32]) {
430        let big_m = self.M;
431        assert_eq!(pex.len(), big_m * self.V, "map_decode: pex length");
432        assert_eq!(pix.len(), big_m * self.V, "map_decode: pix length");
433        assert_eq!(xdec.len(), self.K, "map_decode: xdec length");
434
435        for k in 0..self.K {
436            let row_e = &mut pex[big_m * k..big_m * (k + 1)];
437            let row_i = &pix[big_m * k..big_m * (k + 1)];
438            pdmath::imul(row_e, row_i);
439            // Probability distributions are non-negative; the
440            // all-negative argmax case is unreachable here.
441            let (_, idx) = pdmath::argmax(row_e).expect("MAP decode: empty distribution");
442            xdec[k] = idx as i32;
443        }
444    }
445
446    /// `gfpmat[w * M .. (w+1) * M]` — the permutation that realises
447    /// "multiply distribution by α^w" in the probability domain.
448    fn gfpmat_row(&self, logw: usize) -> &[i32] {
449        let m = self.M;
450        let start = logw * m;
451        &self.gfpmat[start..start + m]
452    }
453}
454
455/// In-place rational approximation of `exp(log I0(C·x))` — the
456/// modified Bessel function of the first kind, order 0, applied to
457/// each element of `buf`. Matches the polynomial in
458/// `qra_ioapprox()` from the C reference.
459fn ioapprox(buf: &mut [f32], c: f32) {
460    for x in buf.iter_mut() {
461        let v = *x * c;
462        let vsq = v * v;
463        let mut v = vsq * (v + 0.039) / (vsq * 0.9931 + v * 2.6936 + 0.5185);
464        if v > 80.0 {
465            v = 80.0;
466        }
467        *x = v.exp();
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    /// A trivial GF(2) repetition code (5, 1) for end-to-end testing
476    /// of the encoder. Won't exercise BP — that needs a real QRA
477    /// table — but it pins the systematic-prefix / accumulator
478    /// behaviour against a hand-computable case.
479    #[test]
480    fn ioapprox_monotonic_increasing() {
481        // I0(C·x) is monotonically increasing in x for x >= 0, so
482        // the rational approximation must respect that ordering.
483        let mut buf = [0.0_f32, 0.5, 1.0, 2.0, 4.0];
484        ioapprox(&mut buf, 1.0);
485        for w in buf.windows(2) {
486            assert!(
487                w[0] <= w[1],
488                "ioapprox not monotonic: {:?} > {:?}",
489                w[0],
490                w[1]
491            );
492        }
493    }
494
495    #[test]
496    fn ioapprox_clamps_at_80() {
497        // Very large C·x must clamp the exponent to 80 to avoid
498        // f32 overflow.
499        let mut buf = [10.0_f32; 4];
500        ioapprox(&mut buf, 100.0);
501        let max_allowed = 80.0_f32.exp() * 1.001;
502        for &v in &buf {
503            assert!(v.is_finite(), "overflowed to {v}");
504            assert!(v <= max_allowed, "value {v} exceeds clamp");
505        }
506    }
507}