Skip to main content

mfsk_core/fec/ldpc240_101/
mod.rs

1//! LDPC(240, 101) codec with CRC-24 for the WSJT FST4 / FST4W family.
2//!
3//! Phase 0c-B unified the implementation: this module no longer carries
4//! its own BP / OSD / encode bodies — those live in
5//! [`crate::fec::ldpc`] and are parameterised by [`Ldpc240_101Params`].
6//! All that remains here are the protocol-specific tables (in [`tables`])
7//! and the CRC-24 helpers ([`crc24`], [`check_crc24`]).
8//!
9//! The public surface is preserved as a type alias:
10//! `pub type Ldpc240_101 = LdpcCodec<Ldpc240_101Params>` lives here so
11//! callers continue to write `mfsk_core::fec::Ldpc240_101`.
12
13pub mod tables;
14
15use alloc::vec;
16
17use crate::core::{FecCodec, FecOpts, FecResult};
18use crate::fec::ldpc::bp::bp_decode_generic_kind;
19use crate::fec::ldpc::osd::{ldpc_encode_generic, osd_decode_generic};
20use crate::fec::ldpc::params::Ldpc240_101Params;
21
22pub const LDPC_N: usize = 240;
23pub const LDPC_K: usize = 101;
24pub const LDPC_M: usize = LDPC_N - LDPC_K; // 139
25
26// ────────────────────────────────────────────────────────────────────
27// CRC-24
28
29/// CRC-24Q as used by WSJT-X FST4: polynomial 0x100065B, applied bit-
30/// serially over the message padded with 24 zeros.
31///
32/// Matches the `get_crc24` subroutine in WSJT-X `lib/fst4/get_crc24.f90`.
33pub fn crc24(bits: &[u8]) -> u32 {
34    let mut r = [0u8; 25];
35    for (i, slot) in r.iter_mut().enumerate() {
36        *slot = if i < bits.len() { bits[i] & 1 } else { 0 };
37    }
38    const POLY: [u8; 25] = [
39        1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1,
40    ];
41    let n = bits.len().saturating_sub(25);
42    for i in 0..=n {
43        if i + 25 <= bits.len() {
44            r[24] = bits[i + 24] & 1;
45        } else {
46            r[24] = 0;
47        }
48        let top = r[0];
49        if top != 0 {
50            for (rv, pv) in r.iter_mut().zip(POLY.iter()) {
51                *rv ^= *pv;
52            }
53        }
54        let first = r[0];
55        for k in 0..24 {
56            r[k] = r[k + 1];
57        }
58        r[24] = first;
59    }
60    let mut v = 0u32;
61    for &b in &r[..24] {
62        v = (v << 1) | (b as u32);
63    }
64    v
65}
66
67/// Verify CRC-24 for a 101-bit decoded word (77 msg + 24 CRC).
68///
69/// Accepts any `&[u8]` slice; lengths other than [`LDPC_K`] (= 101) are
70/// rejected so the function is suitable as a `MessageCodec::verify_info`
71/// implementation passed through `FecOpts::verify_info`.
72pub fn check_crc24(decoded: &[u8]) -> bool {
73    if decoded.len() != LDPC_K {
74        return false;
75    }
76    let mut with_zero = [0u8; LDPC_K];
77    with_zero[..77].copy_from_slice(&decoded[..77]);
78    let expected = crc24(&with_zero);
79
80    let mut got = 0u32;
81    for &b in &decoded[77..101] {
82        got = (got << 1) | (b as u32 & 1);
83    }
84    expected == got
85}
86
87// ────────────────────────────────────────────────────────────────────
88// FecCodec impl
89
90/// Zero-sized LDPC(240, 101) codec — a thin wrapper that pins the
91/// generic [`crate::fec::ldpc::params::LdpcParams`]-based
92/// implementation to [`Ldpc240_101Params`].
93#[derive(Copy, Clone, Debug, Default)]
94pub struct Ldpc240_101;
95
96impl FecCodec for Ldpc240_101 {
97    const N: usize = LDPC_N;
98    const K: usize = LDPC_K;
99
100    fn encode(&self, info: &[u8], codeword: &mut [u8]) {
101        assert_eq!(info.len(), LDPC_K, "info must be {} bits", LDPC_K);
102        assert_eq!(codeword.len(), LDPC_N, "codeword must be {} bits", LDPC_N);
103        ldpc_encode_generic::<Ldpc240_101Params>(info, codeword);
104    }
105
106    fn decode_soft(&self, llr: &[f32], opts: &FecOpts<'_>) -> Option<FecResult> {
107        assert_eq!(llr.len(), LDPC_N, "llr must be {} values", LDPC_N);
108        let mut llr_arr = vec![0f32; LDPC_N];
109        llr_arr.copy_from_slice(llr);
110
111        // AP hint injection (same convention as Ldpc174_91): clamp the
112        // masked bits to ±apmag where apmag dominates any channel
113        // observation, then build a parallel bool mask the BP loop
114        // consults to skip variable-node updates on those bits.
115        let ap_storage_holder;
116        let ap_slice: Option<&[bool]> = match opts.ap_mask {
117            Some((mask, values)) => {
118                assert_eq!(mask.len(), LDPC_N, "ap mask must be {} bits", LDPC_N);
119                assert_eq!(values.len(), LDPC_N, "ap values must be {} bits", LDPC_N);
120                let apmag = llr_arr.iter().map(|x| x.abs()).fold(0.0f32, f32::max) * 1.01;
121                let mut a = vec![false; LDPC_N];
122                for i in 0..LDPC_N {
123                    if mask[i] != 0 {
124                        a[i] = true;
125                        llr_arr[i] = if values[i] != 0 { apmag } else { -apmag };
126                    }
127                }
128                ap_storage_holder = a;
129                Some(ap_storage_holder.as_slice())
130            }
131            None => None,
132        };
133
134        if let Some(r) = bp_decode_generic_kind::<Ldpc240_101Params>(
135            &llr_arr,
136            ap_slice,
137            opts.bp_max_iter,
138            opts.verify_info,
139            opts.bp_kind,
140        ) {
141            return Some(FecResult {
142                info: r.info,
143                hard_errors: r.hard_errors,
144                iterations: r.iterations,
145            });
146        }
147
148        if opts.osd_depth == 0 {
149            return None;
150        }
151
152        if let Some(r) = osd_decode_generic::<Ldpc240_101Params>(
153            &llr_arr,
154            opts.osd_depth.min(3) as u8,
155            LDPC_K,
156            opts.verify_info,
157        ) {
158            return Some(FecResult {
159                info: r.info,
160                hard_errors: r.hard_errors,
161                iterations: 0,
162            });
163        }
164
165        // Issue #146: WSJT-X's `decode240_101` never feeds OSD the raw
166        // channel LLR when BP fails — it feeds the running sum of BP's
167        // variable-node soft estimate across the first two iterations
168        // (`zsave` in `lib/fst4/decode240_101.f90:51-63`, `maxosd=2`).
169        // Diagnostic measurement (`fst4_diag_zsum_osd` in
170        // `tests/fst4_sweep.rs`) on FST4-120 near-threshold AWGN trials —
171        // the sub-mode with the largest residual gap vs WSJT-X — found
172        // this recovers real additional trials (35 of 106 OSD-relevant
173        // trials, raw-LLR-OSD recall 37→62 when tried *in addition to*
174        // the existing raw-LLR attempt) at the cost of 3 trials where
175        // raw succeeds and zsum alone would not — hence "try both", not
176        // "replace": only reached when the raw-LLR OSD attempt above
177        // already failed, so it can only add successes, never remove
178        // any. Skipped under AP hints (`ap_slice.is_some()`) — FST4
179        // doesn't wire AP decoding yet (issue #143), and
180        // `bp_llr_zsum` doesn't clamp AP-locked bits the way the main
181        // BP loop does, so running it under an AP mask would drift
182        // those bits away from their hinted value.
183        if ap_slice.is_none() {
184            let zsum = crate::fec::ldpc::bp::bp_llr_zsum::<Ldpc240_101Params>(&llr_arr, 2);
185            if let Some(r) = osd_decode_generic::<Ldpc240_101Params>(
186                &zsum,
187                opts.osd_depth.min(3) as u8,
188                LDPC_K,
189                opts.verify_info,
190            ) {
191                return Some(FecResult {
192                    info: r.info,
193                    hard_errors: r.hard_errors,
194                    iterations: 0,
195                });
196            }
197        }
198
199        None
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use crate::fec::ldpc::bp::bp_decode_generic;
207    use crate::fec::ldpc::osd::ldpc_encode_generic;
208
209    /// Round-trip: encode a 101-bit info word, feed perfect LLRs,
210    /// decoder should recover the original info. Exercises the full
211    /// BP path plus the generator sub-matrix via the generic helpers.
212    #[test]
213    fn roundtrip_perfect_llr() {
214        let mut info = [0u8; LDPC_K];
215        for i in 0..77 {
216            info[i] = ((i * 7 + 3) & 1) as u8;
217        }
218        let crc = crc24(&info); // upper 24 bits still zero
219        for i in 0..24 {
220            info[77 + i] = ((crc >> (23 - i)) & 1) as u8;
221        }
222
223        let mut cw = [0u8; LDPC_N];
224        ldpc_encode_generic::<Ldpc240_101Params>(&info, &mut cw);
225        // Sanity: systematic encode keeps info bits in positions 0..K.
226        assert_eq!(&cw[..LDPC_K], &info[..]);
227
228        // Perfect LLR: ±8 per bit, sign follows the bit.
229        let mut llr = vec![0f32; LDPC_N];
230        for i in 0..LDPC_N {
231            llr[i] = if cw[i] == 1 { 8.0 } else { -8.0 };
232        }
233        let r = bp_decode_generic::<Ldpc240_101Params>(&llr, None, 30, Some(check_crc24))
234            .expect("BP converges on perfect LLR");
235        assert_eq!(&r.info[..77], &info[..77]);
236    }
237}