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}