mfsk-core 0.4.0

Pure-Rust library for WSJT-family digital amateur-radio modes (FT8/FT4/FST4/WSPR/JT9/JT65/Q65): protocol traits, DSP, FEC codecs, message codecs, decoders and synthesisers — unified behind a zero-cost generic abstraction.
Documentation
// SPDX-License-Identifier: GPL-3.0-or-later
//! Frame-head preamble catalogue for the new uvpacket modem
//! (post-redesign, 0.4.0).
//!
//! ## Mode-encoded sync
//!
//! Sync and modulation mode are jointly identified by **which of
//! four 127-chip BPSK m-sequences** the receiver finds at a given
//! audio offset/centre. Each [`Mode`] is paired with one preamble
//! variant; the receiver correlates against all four and the
//! winner identifies both the timing offset and the payload mode
//! in a single matched-filter pass. This eliminates the 4-modes ×
//! 32-blocks brute-force LDPC sweep that the prior decoder did to
//! discover layout from a single mode-agnostic preamble.
//!
//! All four variants are 127-chip maximum-length sequences (period
//! 2⁷ − 1) generated by Fibonacci LFSRs from **four distinct**
//! primitive polynomials over GF(2):
//!
//! - `UltraRobust` — `x⁷ + x⁶ + 1`  (header_code 0)
//! - `Robust`     — `x⁷ + x³ + 1`  (header_code 1)
//! - `Standard`   — `x⁷ + x + 1`   (header_code 2)
//! - `Express`    — `x⁷ + x⁴ + x³ + x² + 1`  (header_code 3)
//!
//! Note that UltraRobust transmits its preamble at half the symbol
//! rate (600 baud → 20 samples/chip vs the 10 samples/chip of the
//! other modes). The receiver computes one matched-filter output
//! per `nsps` value and runs the corresponding correlations on it.
//!
//! Different polynomials are used (rather than different seeds for
//! the same polynomial) because seeds of the same polynomial just
//! produce cyclic shifts of the same sequence — their cross-
//! correlation has a peak of N at the shift offset, defeating the
//! "winning preamble identifies the mode" design. Distinct
//! polynomials produce sequences whose cross-correlations are
//! bounded well below the autocorrelation peak.
//!
//! ## NRZ mapping
//!
//! Each `bool true` maps to BPSK `−1`, each `false` to BPSK `+1`
//! (the standard NRZ-mapping consumed by the receiver's
//! correlator).

use crate::core::SyncBlock;

use super::puncture::Mode;

/// Length of every preamble variant in BPSK chips. 127 chips at
/// 1200 baud = ~106 ms of preamble.
pub const PREAMBLE_LEN: usize = 127;

/// Number of distinct preamble variants in the catalogue (= number
/// of [`Mode`] variants).
pub const NUM_PREAMBLES: usize = 4;

/// Run a 7-bit Fibonacci LFSR with the given tap mask
/// (bit `i` set ⇔ tap at `xⁱ`). The mask must include the constant
/// `+1` term (tap at bit 0) for the polynomial to be primitive in
/// the standard form `xⁿ + ... + 1`. Init state must be non-zero.
const fn lfsr_seven(taps: u8, mut state: u8) -> [bool; PREAMBLE_LEN] {
    let mut bits = [false; PREAMBLE_LEN];
    let mut i = 0;
    while i < PREAMBLE_LEN {
        bits[i] = (state & 1) != 0;
        // new_bit = XOR of all tap bits.
        let masked = state & taps;
        let new_bit = (masked.count_ones() & 1) as u8;
        state = (state >> 1) | (new_bit << 6);
        i += 1;
    }
    bits
}

// Primitive polynomial tap masks (bit `i` ⇒ x^i term):

/// `x⁷ + x⁶ + 1` → bits 0 and 6.
const TAPS_X7_X6: u8 = 0b100_0001;
/// `x⁷ + x³ + 1` → bits 0 and 3.
const TAPS_X7_X3: u8 = 0b000_1001;
/// `x⁷ + x + 1` → bits 0 and 1.
const TAPS_X7_X1: u8 = 0b000_0011;
/// `x⁷ + x⁴ + x³ + x² + 1` → bits 0, 2, 3, 4.
const TAPS_X7_X4_X3_X2: u8 = 0b001_1101;

/// Preamble for the [`Mode::UltraRobust`] mode (`x⁷ + x⁶ + 1`).
pub const PREAMBLE_ULTRA_ROBUST: [bool; PREAMBLE_LEN] = lfsr_seven(TAPS_X7_X6, 0b000_0001);

/// Preamble for the [`Mode::Robust`] mode (`x⁷ + x³ + 1`).
pub const PREAMBLE_ROBUST: [bool; PREAMBLE_LEN] = lfsr_seven(TAPS_X7_X3, 0b000_0001);

/// Preamble for the [`Mode::Standard`] mode (`x⁷ + x + 1`).
pub const PREAMBLE_STANDARD: [bool; PREAMBLE_LEN] = lfsr_seven(TAPS_X7_X1, 0b000_0001);

/// Preamble for the [`Mode::Express`] mode (`x⁷ + x⁴ + x³ + x² + 1`).
pub const PREAMBLE_EXPRESS: [bool; PREAMBLE_LEN] = lfsr_seven(TAPS_X7_X4_X3_X2, 0b000_0001);

/// All four preamble variants, indexed by the mode's
/// [`Mode::header_code`].
pub const PREAMBLES: [&[bool; PREAMBLE_LEN]; NUM_PREAMBLES] = [
    &PREAMBLE_ULTRA_ROBUST,
    &PREAMBLE_ROBUST,
    &PREAMBLE_STANDARD,
    &PREAMBLE_EXPRESS,
];

/// Look up the preamble bits for a given mode.
pub const fn preamble_for(mode: Mode) -> &'static [bool; PREAMBLE_LEN] {
    match mode {
        Mode::UltraRobust => &PREAMBLE_ULTRA_ROBUST,
        Mode::Robust => &PREAMBLE_ROBUST,
        Mode::Standard => &PREAMBLE_STANDARD,
        Mode::Express => &PREAMBLE_EXPRESS,
    }
}

/// Inverse of [`preamble_for`]: identify which mode owns a
/// particular index in the catalogue.
pub const fn mode_for_index(idx: usize) -> Option<Mode> {
    match idx {
        0 => Some(Mode::UltraRobust),
        1 => Some(Mode::Robust),
        2 => Some(Mode::Standard),
        3 => Some(Mode::Express),
        _ => None,
    }
}

// ── Trait-level placeholder (unused by uvpacket bespoke pipeline) ──

/// Decorative 4-tone-index pattern kept around so
/// `Protocol::SYNC_MODE = SyncMode::Block(&UVPACKET_SYNC_BLOCKS)`
/// has something non-empty to point at and `protocol_invariants`
/// tests pass. The uvpacket TX / RX paths are bespoke and do not
/// consult this constant — they use [`PREAMBLES`] / [`preamble_for`]
/// directly.
pub const UVPACKET_COSTAS: [u8; 4] = [0, 1, 3, 2];

/// `Protocol::SYNC_MODE` placeholder. See module docs.
pub const UVPACKET_SYNC_BLOCKS: [SyncBlock; 1] = [SyncBlock {
    start_symbol: 0,
    pattern: &UVPACKET_COSTAS,
}];

#[cfg(test)]
mod tests {
    use super::*;

    /// Each preamble must contain exactly 64 ones and 63 zeros (or
    /// vice versa; m-sequences are "almost balanced" with one extra
    /// of one symbol).
    #[test]
    fn each_preamble_is_almost_balanced() {
        for (idx, p) in PREAMBLES.iter().enumerate() {
            let ones = p.iter().filter(|&&b| b).count();
            assert!(
                ones == 63 || ones == 64,
                "variant {idx} has {ones} ones (expect 63 or 64)",
            );
        }
    }

    /// All four preambles must be distinct.
    #[test]
    fn preambles_are_pairwise_distinct() {
        for i in 0..NUM_PREAMBLES {
            for j in (i + 1)..NUM_PREAMBLES {
                assert_ne!(PREAMBLES[i], PREAMBLES[j], "preamble {i} == preamble {j}",);
            }
        }
    }

    /// m-sequence cyclic autocorrelation: lag 0 → +N, lag 1..N-1 → −1.
    /// (Real m-sequences have this property by construction; tests
    /// here protect against LFSR-tap typos like the earlier
    /// `state[6] ^ state[5]` bug that produced an immediately-zero
    /// state.)
    #[test]
    fn each_preamble_has_m_sequence_autocorrelation() {
        for (idx, p) in PREAMBLES.iter().enumerate() {
            let bpsk: Vec<i32> = p.iter().map(|&b| if b { -1 } else { 1 }).collect();
            let n = bpsk.len() as i32;
            let lag0: i32 = bpsk.iter().map(|x| x * x).sum();
            assert_eq!(lag0, n, "variant {idx} lag-0 autocorr {lag0} ≠ {n}");
            for lag in 1..bpsk.len() {
                let sum: i32 = (0..bpsk.len())
                    .map(|i| bpsk[i] * bpsk[(i + lag) % bpsk.len()])
                    .sum();
                assert_eq!(
                    sum, -1,
                    "variant {idx} cyclic lag {lag} autocorr = {sum} ≠ -1",
                );
            }
        }
    }

    /// Cross-correlation between any two distinct preambles must
    /// stay well below the autocorrelation peak so the receiver
    /// can confidently pick the winning variant. The optimal
    /// "preferred-pair" Gold-code bound for length-127 m-sequences
    /// is `2^((m+2)/2) − 1 = 17` for m=7; for arbitrary primitive-
    /// polynomial pairs the cross-correlation peak is empirically
    /// ≤ ~40 % of the autocorr peak. We assert ≤ 50 (≈ 40 % of
    /// 127), which is loose enough to admit our four chosen
    /// polynomials while still guaranteeing the autocorr peak
    /// (127) wins by a comfortable margin in any sync race.
    #[test]
    fn cross_correlation_below_autocorr_peak() {
        let n = PREAMBLE_LEN as i32;
        for i in 0..NUM_PREAMBLES {
            for j in (i + 1)..NUM_PREAMBLES {
                let a: Vec<i32> = PREAMBLES[i]
                    .iter()
                    .map(|&b| if b { -1 } else { 1 })
                    .collect();
                let b: Vec<i32> = PREAMBLES[j]
                    .iter()
                    .map(|&b| if b { -1 } else { 1 })
                    .collect();
                let mut peak_xc: i32 = 0;
                for lag in 0..PREAMBLE_LEN {
                    let s: i32 = (0..PREAMBLE_LEN)
                        .map(|k| a[k] * b[(k + lag) % PREAMBLE_LEN])
                        .sum();
                    if s.abs() > peak_xc {
                        peak_xc = s.abs();
                    }
                }
                assert!(
                    peak_xc <= 50,
                    "variants {i}/{j} cross-corr peak {peak_xc} > 50 (autocorr peak {n})",
                );
            }
        }
    }

    /// `preamble_for` and `mode_for_index` are mutual inverses.
    #[test]
    fn mode_index_roundtrip() {
        for i in 0..NUM_PREAMBLES {
            let m = mode_for_index(i).unwrap();
            let p = preamble_for(m);
            assert_eq!(p, PREAMBLES[i]);
        }
        assert!(mode_for_index(NUM_PREAMBLES).is_none());
    }
}