Skip to main content

mfsk_core/fec/conv/
mod.rs

1//! Convolutional + Fano sequential decoder, shared across WSPR / JT9.
2//!
3//! The Fano algorithm (see [`fano`]) runs bit-by-bit on a rate-1/2 K=32
4//! convolutional code. Only the Layland–Lushbaugh generator pair is wired
5//! for now (that's what WSPR uses); JT9 uses the same pair, so adding it
6//! will be a no-op on this module.
7//!
8//! The `ConvFano` type implements [`crate::core::FecCodec`] for the specific
9//! shape WSPR needs: 50 info bits, 31 zero-tail bits, 162 coded bits.
10
11pub mod fano;
12
13use alloc::vec;
14
15#[cfg(not(feature = "std"))]
16use num_traits::Float;
17
18use super::FecCodec;
19use crate::core::{FecOpts, FecResult};
20
21/// WSPR convolutional codec: 50 info bits + 31 zero-tail → 162 coded bits.
22///
23/// The 31-bit tail is an implementation detail of the Fano decoder (it lets
24/// the search terminate in known state); callers see `K = 50` information
25/// bits and `N = 162` channel bits.
26#[derive(Copy, Clone, Debug, Default)]
27pub struct ConvFano;
28
29impl ConvFano {
30    /// Total input bits the Fano decoder runs over (50 message + 31 tail).
31    pub const NBITS: usize = 81;
32    /// Default Fano threshold step. 17 is a pragmatic starting point for
33    /// our `build_branch_metrics` scale (16.0) and closely mirrors WSJT-X's
34    /// 60/10 ≈ 6 ratio when you account for the different quantisation.
35    pub const DEFAULT_DELTA: i32 = 17;
36    /// Default "max cycles per bit" — 10000 matches WSJT-X's wsprd default.
37    pub const DEFAULT_MAX_CYCLES: u64 = 10_000;
38    /// LLR → branch-metric quantisation scale.
39    pub const METRIC_SCALE: f32 = 16.0;
40    /// Fano bias, subtracted from each per-bit metric. WSJT-X
41    /// `wsprd/fano.c` uses `bias = 0.42` on a ±1 soft-symbol scale.
42    /// Our LLR pipeline runs at a higher scale (≈ ±20 clamp), and a
43    /// proportional bias of 4.2 breaks synthetic-low-magnitude tests
44    /// (`tolerates_a_few_errors` runs at LLR magnitude 6). 1.0 is the
45    /// largest value that keeps both synthetic and real-WAV decodes
46    /// healthy; raising further would need an LLR-normalisation pass
47    /// before Fano (deferred — see `wspr_wsjtx_samples.rs`).
48    pub const METRIC_BIAS: f32 = 1.0;
49}
50
51/// Pack the message bits + 31 zero tail into the 11-byte buffer that
52/// [`conv_encode`](fano::conv_encode) consumes.
53fn pack_msg_with_tail(info: &[u8]) -> [u8; 11] {
54    assert_eq!(info.len(), 50, "WSPR info payload must be 50 bits");
55    let mut packed = [0u8; 11];
56    for (i, &b) in info.iter().enumerate() {
57        if b & 1 != 0 {
58            packed[i / 8] |= 1 << (7 - (i % 8));
59        }
60    }
61    // Bits 50..81 are the zero tail; bits 81..88 are padding and ignored.
62    packed
63}
64
65impl FecCodec for ConvFano {
66    const N: usize = 162;
67    const K: usize = 50;
68
69    fn encode(&self, info: &[u8], codeword: &mut [u8]) {
70        assert_eq!(info.len(), Self::K);
71        assert_eq!(codeword.len(), Self::N);
72        let packed = pack_msg_with_tail(info);
73        let mut out = vec![0u8; 2 * Self::NBITS];
74        fano::conv_encode(&packed, Self::NBITS, &mut out);
75        codeword.copy_from_slice(&out);
76    }
77
78    fn decode_soft(&self, llr: &[f32], _opts: &FecOpts) -> Option<FecResult> {
79        assert_eq!(llr.len(), Self::N);
80        let bm = fano::build_branch_metrics(llr, Self::METRIC_BIAS, Self::METRIC_SCALE);
81        let res = fano::fano_decode(
82            &bm,
83            Self::NBITS,
84            Self::DEFAULT_DELTA,
85            Self::DEFAULT_MAX_CYCLES,
86        );
87        if !res.converged {
88            return None;
89        }
90
91        // Recover 50-bit info vector (drop the 31-bit zero tail).
92        let mut info = vec![0u8; Self::K];
93        for i in 0..Self::K {
94            info[i] = (res.data[i / 8] >> (7 - (i % 8))) & 1;
95        }
96
97        // Re-encode to check consistency and count hard errors.
98        let mut reencoded = vec![0u8; Self::N];
99        self.encode(&info, &mut reencoded);
100        let hard_errors = llr
101            .iter()
102            .zip(reencoded.iter())
103            .filter(|&(&l, &c)| (c == 1) != (l < 0.0))
104            .count() as u32;
105
106        Some(FecResult {
107            info,
108            hard_errors,
109            iterations: 0,
110        })
111    }
112}
113
114/// JT9 convolutional codec: 72 info bits + 31 zero-tail → 206 coded bits.
115///
116/// Shares generator polynomials with [`ConvFano`] (the Layland-Lushbaugh
117/// r=½ K=32 pair, POLY1 = 0xf2d0_5351, POLY2 = 0xe461_3c47); only the
118/// code dimensions differ. Naming echoes WSJT-X's `fano232.f90`, which
119/// is the module this one is modelled on.
120#[derive(Copy, Clone, Debug, Default)]
121pub struct ConvFano232;
122
123impl ConvFano232 {
124    /// Total input bits the Fano decoder runs over (72 message + 31 tail).
125    pub const NBITS: usize = 103;
126    /// Fano threshold step — `nint(3.4·50) = 170`, matching
127    /// `lib/jt9fano.f90` `ndelta`. The 50× scale on the WSJT-X metric
128    /// is the `scale` used to quantise the `xx0` LUT below.
129    pub const DEFAULT_DELTA: i32 = 170;
130    /// Max cycles per bit. WSJT-X's jt9_decode varies this with depth
131    /// (5 000–100 000); 10 000 matches the wsprd default and decodes
132    /// reliably for clean / moderate-SNR signals.
133    pub const DEFAULT_MAX_CYCLES: u64 = 10_000;
134}
135
136/// `xx0` log-likelihood lookup table from `lib/jt9fano.f90`. Indexed by
137/// the WSJT-X soft-symbol value `i ∈ 0..=255` (corresponding to signed
138/// `i4 = i − 128 ∈ −128..=127`). For positive `i4` (bit=1 evidence)
139/// the table saturates at strongly-negative values; for negative `i4`
140/// (bit=0 evidence) it saturates at +1.0. This asymmetry is what
141/// gives Fano its WSJT-X-faithful path-search behaviour at low SNR
142/// and is **not** captured by the linear `m = 0.5·l − bias` form used
143/// in [`fano::build_branch_metrics`].
144#[rustfmt::skip]
145const JT9_XX0: [f32; 256] = [
146    1.000, 1.000, 1.000, 1.000, 1.000, 1.000, 1.000, 1.000,
147    1.000, 1.000, 1.000, 1.000, 1.000, 1.000, 1.000, 1.000,
148    1.000, 1.000, 1.000, 1.000, 1.000, 1.000, 1.000, 1.000,
149    1.000, 1.000, 1.000, 1.000, 1.000, 1.000, 1.000, 1.000,
150    1.000, 1.000, 1.000, 1.000, 1.000, 1.000, 1.000, 1.000,
151    1.000, 1.000, 1.000, 1.000, 1.000, 1.000, 1.000, 1.000,
152    0.988, 1.000, 0.991, 0.993, 1.000, 0.995, 1.000, 0.991,
153    1.000, 0.991, 0.992, 0.991, 0.990, 0.990, 0.992, 0.996,
154    0.990, 0.994, 0.993, 0.991, 0.992, 0.989, 0.991, 0.987,
155    0.985, 0.989, 0.984, 0.983, 0.979, 0.977, 0.971, 0.975,
156    0.974, 0.970, 0.970, 0.970, 0.967, 0.962, 0.960, 0.957,
157    0.956, 0.953, 0.942, 0.946, 0.937, 0.933, 0.929, 0.920,
158    0.917, 0.911, 0.903, 0.895, 0.884, 0.877, 0.869, 0.858,
159    0.846, 0.834, 0.821, 0.806, 0.790, 0.775, 0.755, 0.737,
160    0.713, 0.691, 0.667, 0.640, 0.612, 0.581, 0.548, 0.510,
161    0.472, 0.425, 0.378, 0.328, 0.274, 0.212, 0.146, 0.075,
162    0.000,-0.079,-0.163,-0.249,-0.338,-0.425,-0.514,-0.606,
163   -0.706,-0.796,-0.895,-0.987,-1.084,-1.181,-1.280,-1.376,
164   -1.473,-1.587,-1.678,-1.790,-1.882,-1.992,-2.096,-2.201,
165   -2.301,-2.411,-2.531,-2.608,-2.690,-2.829,-2.939,-3.058,
166   -3.164,-3.212,-3.377,-3.463,-3.550,-3.768,-3.677,-3.975,
167   -4.062,-4.098,-4.186,-4.261,-4.472,-4.621,-4.623,-4.608,
168   -4.822,-4.870,-4.652,-4.954,-5.108,-5.377,-5.544,-5.995,
169   -5.632,-5.826,-6.304,-6.002,-6.559,-6.369,-6.658,-7.016,
170   -6.184,-7.332,-6.534,-6.152,-6.113,-6.288,-6.426,-6.313,
171   -9.966,-6.371,-9.966,-7.055,-9.966,-6.629,-6.313,-9.966,
172   -5.858,-9.966,-9.966,-9.966,-9.966,-9.966,-9.966,-9.966,
173   -9.966,-9.966,-9.966,-9.966,-9.966,-9.966,-9.966,-9.966,
174   -9.966,-9.966,-9.966,-9.966,-9.966,-9.966,-9.966,-9.966,
175   -9.966,-9.966,-9.966,-9.966,-9.966,-9.966,-9.966,-9.966,
176   -9.966,-9.966,-9.966,-9.966,-9.966,-9.966,-9.966,-9.966,
177   -9.966,-9.966,-9.966,-9.966,-9.966,-9.966,-9.966,-9.966,
178];
179
180/// Build the 256×2 mettab used by `jt9fano`. Mirrors lines 65–77 of
181/// `lib/jt9fano.f90`:
182///
183/// ```fortran
184/// bias=0.5; scale=50; ndelta=nint(3.4*scale); ib=160; slope=2
185/// do i=0,255
186///   mettab(i-128,0)=nint(scale*(xx0(i)-bias))
187///   if(i.gt.ib) mettab(i-128,0)=mettab(ib-128,0) - slope*(i-ib)
188///   if(i.ge.1)  mettab(128-i,1)=mettab(i-128,0)
189/// enddo
190/// mettab(-128,1)=mettab(-127,1)
191/// ```
192fn build_jt9_mettab() -> [[i32; 2]; 256] {
193    const BIAS: f32 = 0.5;
194    const SCALE: f32 = 50.0;
195    const IB: usize = 160;
196    const SLOPE: i32 = 2;
197    let mut mettab = [[0i32; 2]; 256];
198    // Column 0: m_if_sent_bit_was_0 (raw + linear extension beyond IB).
199    let mut col0 = [0i32; 256];
200    for (i, val) in JT9_XX0.iter().enumerate() {
201        col0[i] = (SCALE * (val - BIAS)).round() as i32;
202    }
203    let pivot = col0[IB];
204    for i in (IB + 1)..=255 {
205        col0[i] = pivot - SLOPE * (i as i32 - IB as i32);
206    }
207    for i in 0..=255 {
208        mettab[i][0] = col0[i];
209    }
210    // Column 1 mirrors column 0 by Fortran rule
211    // `mettab(128−i, 1) = mettab(i−128, 0)` for i ∈ 1..=255 — i.e.
212    // `mettab[256−i][1] = col0[i]`. Then `mettab(−128, 1)` falls back
213    // to `mettab(−127, 1)` because (256 − 0) = 256 is out of range.
214    for i in 1..=255 {
215        mettab[256 - i][1] = col0[i];
216    }
217    mettab[0][1] = mettab[1][1];
218    mettab
219}
220
221/// Convert mfsk-core LLRs (positive ⇒ bit=0, scale ≈ 10) into Fano
222/// branch metrics using the WSJT-X `jt9fano` mettab. Each LLR is
223/// negated to match WSJT-X's "positive ⇒ bit=1" soft-symbol sign,
224/// clipped to `i4 ∈ [−127, 127]`, and looked up in the mettab.
225fn jt9_branch_metrics(llrs: &[f32]) -> alloc::vec::Vec<[i32; 2]> {
226    let mettab = build_jt9_mettab();
227    llrs.iter()
228        .map(|&l| {
229            // mfsk-core LLR sign is opposite of WSJT-X i4: flip.
230            let i4 = (-l).round().clamp(-127.0, 127.0) as i32;
231            let idx = (i4 + 128) as usize;
232            // Fano expects [m_if_0, m_if_1] per coded bit.
233            [mettab[idx][0], mettab[idx][1]]
234        })
235        .collect()
236}
237
238/// Pack 72 message bits + 31-bit zero tail into the 13-byte buffer that
239/// [`conv_encode`](fano::conv_encode) consumes (NBITS = 103 → 13 bytes
240/// with the last 4 bits unused).
241fn pack_msg_with_tail_jt9(info: &[u8]) -> [u8; 13] {
242    assert_eq!(info.len(), 72, "JT9 info payload must be 72 bits");
243    let mut packed = [0u8; 13];
244    for (i, &b) in info.iter().enumerate() {
245        if b & 1 != 0 {
246            packed[i / 8] |= 1 << (7 - (i % 8));
247        }
248    }
249    // Bits 72..103 are the zero tail; bits 103..104 are padding.
250    packed
251}
252
253impl FecCodec for ConvFano232 {
254    const N: usize = 206;
255    const K: usize = 72;
256
257    fn encode(&self, info: &[u8], codeword: &mut [u8]) {
258        assert_eq!(info.len(), Self::K);
259        assert_eq!(codeword.len(), Self::N);
260        let packed = pack_msg_with_tail_jt9(info);
261        let mut out = vec![0u8; 2 * Self::NBITS];
262        fano::conv_encode(&packed, Self::NBITS, &mut out);
263        codeword.copy_from_slice(&out);
264    }
265
266    fn decode_soft(&self, llr: &[f32], opts: &FecOpts) -> Option<FecResult> {
267        assert_eq!(llr.len(), Self::N);
268        // JT9 uses the WSJT-X-calibrated `xx0` mettab rather than the
269        // linear `m = 0.5·l − bias` form — the asymmetric saturation
270        // (heavy penalty for high-confidence disagreement, flat
271        // reward for high-confidence agreement) is what stops Fano
272        // from latching onto plausible-looking neighbour codewords
273        // at marginal SNR.
274        let bm = jt9_branch_metrics(llr);
275        let max_cycles = opts.max_cycles_per_bit.unwrap_or(Self::DEFAULT_MAX_CYCLES);
276        let res = fano::fano_decode(&bm, Self::NBITS, Self::DEFAULT_DELTA, max_cycles);
277        if !res.converged {
278            return None;
279        }
280        let mut info = vec![0u8; Self::K];
281        for i in 0..Self::K {
282            info[i] = (res.data[i / 8] >> (7 - (i % 8))) & 1;
283        }
284        let mut reencoded = vec![0u8; Self::N];
285        self.encode(&info, &mut reencoded);
286        let hard_errors = llr
287            .iter()
288            .zip(reencoded.iter())
289            .filter(|&(&l, &c)| (c == 1) != (l < 0.0))
290            .count() as u32;
291        Some(FecResult {
292            info,
293            hard_errors,
294            iterations: 0,
295        })
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn encode_then_decode_roundtrip() {
305        let codec = ConvFano;
306        // Arbitrary 50-bit info word.
307        let mut info = vec![0u8; 50];
308        for (i, slot) in info.iter_mut().enumerate() {
309            *slot = (((i * 7) ^ 0x2a) & 1) as u8;
310        }
311        let mut cw = vec![0u8; 162];
312        codec.encode(&info, &mut cw);
313
314        // Perfect LLRs.
315        let llr: Vec<f32> = cw
316            .iter()
317            .map(|&b| if b == 0 { 8.0 } else { -8.0 })
318            .collect();
319        let r = codec
320            .decode_soft(&llr, &FecOpts::default())
321            .expect("perfect LLRs must decode");
322        assert_eq!(r.info, info);
323        assert_eq!(r.hard_errors, 0);
324    }
325
326    #[test]
327    fn jt9_encode_decode_roundtrip() {
328        let codec = ConvFano232;
329        let mut info = vec![0u8; 72];
330        for (i, slot) in info.iter_mut().enumerate() {
331            *slot = (((i * 11) ^ 0x55) & 1) as u8;
332        }
333        let mut cw = vec![0u8; 206];
334        codec.encode(&info, &mut cw);
335        let llr: Vec<f32> = cw
336            .iter()
337            .map(|&b| if b == 0 { 8.0 } else { -8.0 })
338            .collect();
339        let r = codec
340            .decode_soft(&llr, &FecOpts::default())
341            .expect("perfect LLRs must decode");
342        assert_eq!(r.info, info);
343        assert_eq!(r.hard_errors, 0);
344    }
345
346    #[test]
347    fn jt9_tolerates_a_few_errors() {
348        let codec = ConvFano232;
349        let info: Vec<u8> = (0..72).map(|i| i as u8 & 1).collect();
350        let mut cw = vec![0u8; 206];
351        codec.encode(&info, &mut cw);
352        // Real-pipeline `symspec2` emits LLRs in ±127 (matching WSJT-X
353        // soft sym scale=10), and the `jt9fano` mettab below is
354        // calibrated for that range. Use a strong magnitude here so
355        // the synthetic LLRs land in the same regime.
356        let mut llr: Vec<f32> = cw
357            .iter()
358            .map(|&b| if b == 0 { 100.0 } else { -100.0 })
359            .collect();
360        for &pos in &[3usize, 17, 42, 91, 155, 199] {
361            llr[pos] = -llr[pos] * 0.3;
362        }
363        let r = codec
364            .decode_soft(&llr, &FecOpts::default())
365            .expect("should correct 6 weak errors");
366        assert_eq!(r.info, info);
367    }
368
369    #[test]
370    fn tolerates_a_few_errors() {
371        let codec = ConvFano;
372        let info: Vec<u8> = (0..50).map(|i| i as u8 & 1).collect();
373        let mut cw = vec![0u8; 162];
374        codec.encode(&info, &mut cw);
375        // Strong LLRs.
376        let mut llr: Vec<f32> = cw
377            .iter()
378            .map(|&b| if b == 0 { 6.0 } else { -6.0 })
379            .collect();
380        // Flip 5 LLRs to the wrong side with lower magnitude — simulates noise
381        // on a handful of coded bits.
382        for &pos in &[3usize, 17, 42, 91, 155] {
383            llr[pos] = -llr[pos] * 0.3;
384        }
385        let r = codec
386            .decode_soft(&llr, &FecOpts::default())
387            .expect("should correct 5 weak errors");
388        assert_eq!(r.info, info);
389    }
390}