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 super::FecCodec;
14use crate::core::{FecOpts, FecResult};
15
16/// WSPR convolutional codec: 50 info bits + 31 zero-tail → 162 coded bits.
17///
18/// The 31-bit tail is an implementation detail of the Fano decoder (it lets
19/// the search terminate in known state); callers see `K = 50` information
20/// bits and `N = 162` channel bits.
21#[derive(Copy, Clone, Debug, Default)]
22pub struct ConvFano;
23
24impl ConvFano {
25    /// Total input bits the Fano decoder runs over (50 message + 31 tail).
26    pub const NBITS: usize = 81;
27    /// Default Fano threshold step. 17 is a pragmatic starting point for
28    /// our `build_branch_metrics` scale (16.0) and closely mirrors WSJT-X's
29    /// 60/10 ≈ 6 ratio when you account for the different quantisation.
30    pub const DEFAULT_DELTA: i32 = 17;
31    /// Default "max cycles per bit" — 10000 matches WSJT-X's wsprd default.
32    pub const DEFAULT_MAX_CYCLES: u64 = 10_000;
33    /// LLR → branch-metric quantisation scale.
34    pub const METRIC_SCALE: f32 = 16.0;
35    /// Fano bias, subtracted from each per-bit metric.
36    pub const METRIC_BIAS: f32 = 0.0;
37}
38
39/// Pack the message bits + 31 zero tail into the 11-byte buffer that
40/// [`conv_encode`](fano::conv_encode) consumes.
41fn pack_msg_with_tail(info: &[u8]) -> [u8; 11] {
42    assert_eq!(info.len(), 50, "WSPR info payload must be 50 bits");
43    let mut packed = [0u8; 11];
44    for (i, &b) in info.iter().enumerate() {
45        if b & 1 != 0 {
46            packed[i / 8] |= 1 << (7 - (i % 8));
47        }
48    }
49    // Bits 50..81 are the zero tail; bits 81..88 are padding and ignored.
50    packed
51}
52
53impl FecCodec for ConvFano {
54    const N: usize = 162;
55    const K: usize = 50;
56
57    fn encode(&self, info: &[u8], codeword: &mut [u8]) {
58        assert_eq!(info.len(), Self::K);
59        assert_eq!(codeword.len(), Self::N);
60        let packed = pack_msg_with_tail(info);
61        let mut out = vec![0u8; 2 * Self::NBITS];
62        fano::conv_encode(&packed, Self::NBITS, &mut out);
63        codeword.copy_from_slice(&out);
64    }
65
66    fn decode_soft(&self, llr: &[f32], _opts: &FecOpts) -> Option<FecResult> {
67        assert_eq!(llr.len(), Self::N);
68        let bm = fano::build_branch_metrics(llr, Self::METRIC_BIAS, Self::METRIC_SCALE);
69        let res = fano::fano_decode(
70            &bm,
71            Self::NBITS,
72            Self::DEFAULT_DELTA,
73            Self::DEFAULT_MAX_CYCLES,
74        );
75        if !res.converged {
76            return None;
77        }
78
79        // Recover 50-bit info vector (drop the 31-bit zero tail).
80        let mut info = vec![0u8; Self::K];
81        for i in 0..Self::K {
82            info[i] = (res.data[i / 8] >> (7 - (i % 8))) & 1;
83        }
84
85        // Re-encode to check consistency and count hard errors.
86        let mut reencoded = vec![0u8; Self::N];
87        self.encode(&info, &mut reencoded);
88        let hard_errors = llr
89            .iter()
90            .zip(reencoded.iter())
91            .filter(|&(&l, &c)| (c == 1) != (l < 0.0))
92            .count() as u32;
93
94        Some(FecResult {
95            info,
96            hard_errors,
97            iterations: 0,
98        })
99    }
100}
101
102/// JT9 convolutional codec: 72 info bits + 31 zero-tail → 206 coded bits.
103///
104/// Shares generator polynomials with [`ConvFano`] (the Layland-Lushbaugh
105/// r=½ K=32 pair, POLY1 = 0xf2d0_5351, POLY2 = 0xe461_3c47); only the
106/// code dimensions differ. Naming echoes WSJT-X's `fano232.f90`, which
107/// is the module this one is modelled on.
108#[derive(Copy, Clone, Debug, Default)]
109pub struct ConvFano232;
110
111impl ConvFano232 {
112    /// Total input bits the Fano decoder runs over (72 message + 31 tail).
113    pub const NBITS: usize = 103;
114    /// Fano threshold step — same scale as `ConvFano` since the metric
115    /// computation hasn't changed.
116    pub const DEFAULT_DELTA: i32 = 17;
117    /// Max cycles per bit. WSJT-X's jt9_decode varies this with depth
118    /// (5 000–100 000); 10 000 matches the wsprd default and decodes
119    /// reliably for clean / moderate-SNR signals.
120    pub const DEFAULT_MAX_CYCLES: u64 = 10_000;
121    pub const METRIC_SCALE: f32 = 16.0;
122    pub const METRIC_BIAS: f32 = 0.0;
123}
124
125/// Pack 72 message bits + 31-bit zero tail into the 13-byte buffer that
126/// [`conv_encode`](fano::conv_encode) consumes (NBITS = 103 → 13 bytes
127/// with the last 4 bits unused).
128fn pack_msg_with_tail_jt9(info: &[u8]) -> [u8; 13] {
129    assert_eq!(info.len(), 72, "JT9 info payload must be 72 bits");
130    let mut packed = [0u8; 13];
131    for (i, &b) in info.iter().enumerate() {
132        if b & 1 != 0 {
133            packed[i / 8] |= 1 << (7 - (i % 8));
134        }
135    }
136    // Bits 72..103 are the zero tail; bits 103..104 are padding.
137    packed
138}
139
140impl FecCodec for ConvFano232 {
141    const N: usize = 206;
142    const K: usize = 72;
143
144    fn encode(&self, info: &[u8], codeword: &mut [u8]) {
145        assert_eq!(info.len(), Self::K);
146        assert_eq!(codeword.len(), Self::N);
147        let packed = pack_msg_with_tail_jt9(info);
148        let mut out = vec![0u8; 2 * Self::NBITS];
149        fano::conv_encode(&packed, Self::NBITS, &mut out);
150        codeword.copy_from_slice(&out);
151    }
152
153    fn decode_soft(&self, llr: &[f32], _opts: &FecOpts) -> Option<FecResult> {
154        assert_eq!(llr.len(), Self::N);
155        let bm = fano::build_branch_metrics(llr, Self::METRIC_BIAS, Self::METRIC_SCALE);
156        let res = fano::fano_decode(
157            &bm,
158            Self::NBITS,
159            Self::DEFAULT_DELTA,
160            Self::DEFAULT_MAX_CYCLES,
161        );
162        if !res.converged {
163            return None;
164        }
165        let mut info = vec![0u8; Self::K];
166        for i in 0..Self::K {
167            info[i] = (res.data[i / 8] >> (7 - (i % 8))) & 1;
168        }
169        let mut reencoded = vec![0u8; Self::N];
170        self.encode(&info, &mut reencoded);
171        let hard_errors = llr
172            .iter()
173            .zip(reencoded.iter())
174            .filter(|&(&l, &c)| (c == 1) != (l < 0.0))
175            .count() as u32;
176        Some(FecResult {
177            info,
178            hard_errors,
179            iterations: 0,
180        })
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn encode_then_decode_roundtrip() {
190        let codec = ConvFano;
191        // Arbitrary 50-bit info word.
192        let mut info = vec![0u8; 50];
193        for (i, slot) in info.iter_mut().enumerate() {
194            *slot = (((i * 7) ^ 0x2a) & 1) as u8;
195        }
196        let mut cw = vec![0u8; 162];
197        codec.encode(&info, &mut cw);
198
199        // Perfect LLRs.
200        let llr: Vec<f32> = cw
201            .iter()
202            .map(|&b| if b == 0 { 8.0 } else { -8.0 })
203            .collect();
204        let r = codec
205            .decode_soft(&llr, &FecOpts::default())
206            .expect("perfect LLRs must decode");
207        assert_eq!(r.info, info);
208        assert_eq!(r.hard_errors, 0);
209    }
210
211    #[test]
212    fn jt9_encode_decode_roundtrip() {
213        let codec = ConvFano232;
214        let mut info = vec![0u8; 72];
215        for (i, slot) in info.iter_mut().enumerate() {
216            *slot = (((i * 11) ^ 0x55) & 1) as u8;
217        }
218        let mut cw = vec![0u8; 206];
219        codec.encode(&info, &mut cw);
220        let llr: Vec<f32> = cw
221            .iter()
222            .map(|&b| if b == 0 { 8.0 } else { -8.0 })
223            .collect();
224        let r = codec
225            .decode_soft(&llr, &FecOpts::default())
226            .expect("perfect LLRs must decode");
227        assert_eq!(r.info, info);
228        assert_eq!(r.hard_errors, 0);
229    }
230
231    #[test]
232    fn jt9_tolerates_a_few_errors() {
233        let codec = ConvFano232;
234        let info: Vec<u8> = (0..72).map(|i| i as u8 & 1).collect();
235        let mut cw = vec![0u8; 206];
236        codec.encode(&info, &mut cw);
237        let mut llr: Vec<f32> = cw
238            .iter()
239            .map(|&b| if b == 0 { 6.0 } else { -6.0 })
240            .collect();
241        for &pos in &[3usize, 17, 42, 91, 155, 199] {
242            llr[pos] = -llr[pos] * 0.3;
243        }
244        let r = codec
245            .decode_soft(&llr, &FecOpts::default())
246            .expect("should correct 6 weak errors");
247        assert_eq!(r.info, info);
248    }
249
250    #[test]
251    fn tolerates_a_few_errors() {
252        let codec = ConvFano;
253        let info: Vec<u8> = (0..50).map(|i| i as u8 & 1).collect();
254        let mut cw = vec![0u8; 162];
255        codec.encode(&info, &mut cw);
256        // Strong LLRs.
257        let mut llr: Vec<f32> = cw
258            .iter()
259            .map(|&b| if b == 0 { 6.0 } else { -6.0 })
260            .collect();
261        // Flip 5 LLRs to the wrong side with lower magnitude — simulates noise
262        // on a handful of coded bits.
263        for &pos in &[3usize, 17, 42, 91, 155] {
264            llr[pos] = -llr[pos] * 0.3;
265        }
266        let r = codec
267            .decode_soft(&llr, &FecOpts::default())
268            .expect("should correct 5 weak errors");
269        assert_eq!(r.info, info);
270    }
271}