Skip to main content

mfsk_core/uvpacket/
sync_pattern.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2//! Frame-head preamble for uvpacket's coherent QPSK modem.
3//!
4//! Replaces the 4-FSK Costas-4 head pattern that the Phase 1 design
5//! used (and that Phase 2 found could not survive the modulation
6//! pivot to coherent QPSK — Costas arrays are FSK-tone-index
7//! sequences, not constellation-point sequences).
8//!
9//! The new sync is a **31-bit maximum-length sequence** (PRBS,
10//! generator polynomial `x⁵ + x² + 1`) mapped to BPSK at the
11//! channel-symbol rate. 31 chips × 1 sym/chip = 26 ms preamble at
12//! 1200 baud. Autocorrelation sidelobes are bounded by `1/31` ≈
13//! −15 dB amplitude, giving a clean correlator peak that the
14//! receiver uses for symbol-timing acquisition, frequency-offset
15//! estimation, and initial carrier-phase lock.
16//!
17//! After the preamble, the receiver maintains phase via
18//! decision-directed PLL with periodic [`PILOT_SYMBOL_INTERVAL`]
19//! known-QPSK pilot symbols (one per 32 transmitted symbols ≈ 3.1 %
20//! overhead). The pilot's constellation point is `+1 + 0j` —
21//! the QPSK symbol mapped from bit pair `[0, 0]`.
22//!
23//! ## Trait-level placeholder
24//!
25//! [`UVPACKET_SYNC_BLOCKS`] is kept as a `[Costas-4 at symbol 0]`
26//! placeholder so that `Protocol::SYNC_MODE = SyncMode::Block(...)`
27//! has something non-empty to point at and `protocol_invariants`
28//! tests pass. The uvpacket TX / RX paths are bespoke and do **not**
29//! consult this constant — they use [`UVPACKET_PREAMBLE_BPSK_BITS`]
30//! and [`PILOT_SYMBOL_INTERVAL`] directly.
31
32use crate::core::SyncBlock;
33
34/// Length of the m-sequence preamble in BPSK chips (= QPSK
35/// transmitted symbols since the preamble is BPSK-mapped onto the
36/// QPSK constellation's I axis).
37pub const PREAMBLE_LEN: usize = 31;
38
39/// 31-bit maximum-length sequence from a Fibonacci LFSR with
40/// polynomial `x⁵ + x² + 1` and initial state `[0, 0, 0, 0, 1]`.
41/// Bits run in TX order. Reproducible: any LFSR walker with the
42/// same polynomial / initial state regenerates this exact sequence.
43///
44/// Each `true` maps to BPSK `−1`, each `false` maps to `+1` (the
45/// standard NRZ-mapping used by the receiver's correlator).
46pub const UVPACKET_PREAMBLE_BPSK_BITS: [bool; PREAMBLE_LEN] = {
47    let mut bits = [false; PREAMBLE_LEN];
48    let mut state: u8 = 0b0_0001;
49    let mut i = 0;
50    while i < PREAMBLE_LEN {
51        // Output the rightmost bit (b1) → bit `i` of the sequence.
52        bits[i] = (state & 1) != 0;
53        // Fibonacci LFSR: new MSB = state[2] XOR state[0]
54        // (polynomial x⁵ + x² + 1).
55        let new_bit = ((state >> 2) & 1) ^ (state & 1);
56        state = (state >> 1) | (new_bit << 4);
57        i += 1;
58    }
59    bits
60};
61
62/// Pilot interval: every `PILOT_SYMBOL_INTERVAL`th transmitted
63/// symbol after the preamble is a known pilot, the rest are data.
64/// 32 means 1 pilot per 31 data → ~3.1 % overhead, comfortable
65/// margin against 10 Hz Doppler at 1200 baud (coherence time ≈
66/// 100 ms = 120 symbols, so a pilot every 32 symbols is well
67/// inside the coherence interval).
68pub const PILOT_SYMBOL_INTERVAL: usize = 32;
69
70/// QPSK pilot constellation point. Chosen as constellation index
71/// 0 (= bit pair `[0, 0]`) so it maps to `+1 + 0j` — receiver-side
72/// phase reference is straightforward (the pilot's expected angle
73/// is the carrier reference angle).
74pub const PILOT_QPSK_POINT: u8 = 0;
75
76// ── Trait-level placeholder (unused by uvpacket bespoke pipeline) ──
77
78/// Decorative 4-FSK Costas pattern kept around so `Protocol::
79/// SYNC_MODE = SyncMode::Block(&UVPACKET_SYNC_BLOCKS)` has
80/// something to point at and `protocol_invariants` checks pass.
81/// Not consulted by [`crate::uvpacket::tx::encode`] /
82/// [`crate::uvpacket::rx::decode_known_layout`] / [`crate::uvpacket::rx::decode`].
83///
84/// (Kept under the legacy `UVPACKET_COSTAS` name through the
85/// modulation pivot; existing TX / RX modules — which are about
86/// to be rewritten — still import this symbol. The real frame
87/// sync after the pivot is the m-sequence preamble above.)
88pub const UVPACKET_COSTAS: [u8; 4] = [0, 1, 3, 2];
89
90/// `Protocol::SYNC_MODE` placeholder. See module docs.
91pub const UVPACKET_SYNC_BLOCKS: [SyncBlock; 1] = [SyncBlock {
92    start_symbol: 0,
93    pattern: &UVPACKET_COSTAS,
94}];
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    /// 31-bit m-sequence has 16 ones and 15 zeros — standard
101    /// "almost balanced" property of maximum-length sequences.
102    #[test]
103    fn preamble_has_balanced_one_count() {
104        let ones = UVPACKET_PREAMBLE_BPSK_BITS.iter().filter(|&&b| b).count();
105        assert_eq!(ones, 16);
106        assert_eq!(PREAMBLE_LEN - ones, 15);
107    }
108
109    /// Autocorrelation of an m-sequence is N at lag 0 and -1 at
110    /// every other lag (sidelobe / mainlobe ratio = 1 / N).
111    /// Equivalently, `Σ (±1)·(±1)` of shifted vs unshifted is N
112    /// at lag 0 and -1 at lag 1..N-1.
113    #[test]
114    fn preamble_autocorrelation_sidelobes_minimal() {
115        let bpsk: Vec<i32> = UVPACKET_PREAMBLE_BPSK_BITS
116            .iter()
117            .map(|&b| if b { -1 } else { 1 })
118            .collect();
119        let n = bpsk.len() as i32;
120        // Lag 0 — perfect correlation.
121        let lag0: i32 = bpsk.iter().map(|x| x * x).sum();
122        assert_eq!(lag0, n);
123        // Cyclic lags 1..N-1 — each must be -1.
124        for lag in 1..(bpsk.len()) {
125            let sum: i32 = (0..bpsk.len())
126                .map(|i| bpsk[i] * bpsk[(i + lag) % bpsk.len()])
127                .sum();
128            assert_eq!(sum, -1, "cyclic lag {lag} autocorr = {sum} ≠ -1");
129        }
130    }
131
132    #[test]
133    fn pilot_interval_is_reasonable() {
134        // ~3 % overhead, well inside coherence time at 10 Hz Doppler.
135        assert!((16..=64).contains(&PILOT_SYMBOL_INTERVAL));
136    }
137}