Skip to main content

mfsk_core/q65/
protocol.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2//! Q65 protocol markers + trait wiring.
3//!
4//! Each Q65 sub-mode is exposed as its own zero-sized type. Sub-modes
5//! vary along two orthogonal axes (per `q65params.f90`):
6//!
7//! - **T/R period** (15 / 30 / 60 / 120 / 300 s) — controls `NSPS`.
8//! - **Tone-spacing letter** A / B / C / D / E — controls
9//!   `TONE_SPACING_HZ` via `baud × 2^(letter − 1)` multipliers
10//!   (×1, ×2, ×4, ×8, ×16).
11//!
12//! All sub-modes share the same FEC, sync layout, message format and
13//! tone numbering — only NSPS and tone spacing change. The wired
14//! sub-modes:
15//!
16//! | ZST          | T/R   | spacing      | typical use                     |
17//! |--------------|-------|--------------|---------------------------------|
18//! | [`Q65a30`]   | 30 s  | 3.333 Hz     | terrestrial HF/VHF, ionoscatter |
19//! | [`Q65a60`]   | 60 s  | 1.667 Hz     | 6 m EME                         |
20//! | [`Q65b60`]   | 60 s  | 3.333 Hz     | 70 cm – 23 cm EME               |
21//! | [`Q65c60`]   | 60 s  | 6.667 Hz     | microwave EME                   |
22//! | [`Q65d60`]   | 60 s  | 13.33 Hz     | 5.7 / 10 GHz EME                |
23//! | [`Q65e60`]   | 60 s  | 26.67 Hz     | extreme Doppler / wide spread   |
24//!
25//! Adding a new sub-mode is a one-line invocation of the
26//! `q65_submode!` macro defined further down in this file.
27
28use crate::core::{
29    FecCodec, FecOpts, FecResult, FrameLayout, ModulationParams, Protocol, ProtocolId, SyncMode,
30};
31use crate::fec::qra::Q65Codec;
32use crate::fec::qra15_65_64::QRA15_65_64_IRR_E23;
33use crate::msg::Q65Message;
34
35use super::sync_pattern::Q65_SYNC_BLOCKS;
36
37const IDENTITY_65: [u8; 65] = {
38    let mut m = [0u8; 65];
39    let mut i = 0usize;
40    while i < 65 {
41        m[i] = i as u8;
42        i += 1;
43    }
44    m
45};
46
47/// Define a Q65 sub-mode ZST with its `ModulationParams`,
48/// `FrameLayout` and `Protocol` impls. All sub-modes share NTONES,
49/// BITS_PER_SYMBOL, sync pattern, FEC, message codec, and the 22
50/// sync / 63 data symbol layout — only NSPS (driven by T/R period)
51/// and TONE_SPACING_HZ (driven by sub-mode letter) differ.
52macro_rules! q65_submode {
53    (
54        $(#[$attr:meta])*
55        $name:ident,
56        nsps = $nsps:literal,
57        spacing_mult = $mult:literal,
58        tr_period_s = $period:literal,
59    ) => {
60        $(#[$attr])*
61        #[derive(Copy, Clone, Debug, Default)]
62        pub struct $name;
63
64        impl ModulationParams for $name {
65            const NTONES: u32 = 65;
66            const BITS_PER_SYMBOL: u32 = 6;
67            const NSPS: u32 = $nsps;
68            const SYMBOL_DT: f32 = ($nsps as f32) / 12_000.0;
69            /// Tone spacing = baud × multiplier where baud = 12000 / NSPS.
70            const TONE_SPACING_HZ: f32 = (12_000.0 / ($nsps as f32)) * ($mult as f32);
71            const GRAY_MAP: &'static [u8] = &IDENTITY_65;
72            /// Plain FSK — Q65 does not Gaussian-shape its tones.
73            const GFSK_BT: f32 = 0.0;
74            const GFSK_HMOD: f32 = 1.0;
75            const NFFT_PER_SYMBOL_FACTOR: u32 = 2;
76            const NSTEP_PER_SYMBOL: u32 = 2;
77            /// 4 kHz baseband (12000 / 3) — wider than every Q65
78            /// sub-mode's worst-case occupancy of 65 × 26.67 Hz =
79            /// 1733 Hz (Q65-?E).
80            const NDOWN: u32 = 3;
81        }
82
83        impl FrameLayout for $name {
84            const N_DATA: u32 = 63;
85            const N_SYNC: u32 = 22;
86            const N_SYMBOLS: u32 = 85;
87            const N_RAMP: u32 = 0;
88            const SYNC_MODE: SyncMode = SyncMode::Block(&Q65_SYNC_BLOCKS);
89            const T_SLOT_S: f32 = $period as f32;
90            /// 1.0 s start offset matches WSJT-X's Q65 slot timing.
91            const TX_START_OFFSET_S: f32 = 1.0;
92        }
93
94        impl Protocol for $name {
95            type Fec = Q65Fec;
96            type Msg = Q65Message;
97            const ID: ProtocolId = ProtocolId::Q65;
98        }
99    };
100}
101
102q65_submode! {
103    /// Q65-30A: 30 s T/R period, sub-mode A (tone spacing = baud
104    /// × 1 = 3.333 Hz). The most common terrestrial Q65 mode; suits
105    /// HF and VHF ionoscatter / weak-signal QSOs.
106    Q65a30,
107    nsps = 3600,
108    spacing_mult = 1,
109    tr_period_s = 30,
110}
111
112q65_submode! {
113    /// Q65-60A: 60 s T/R period, sub-mode A (tone spacing = baud
114    /// × 1 = 1.667 Hz). Typical for **6 m EME** — narrow spacing
115    /// keeps the signal inside the residual Doppler at low VHF.
116    Q65a60,
117    nsps = 7200,
118    spacing_mult = 1,
119    tr_period_s = 60,
120}
121
122q65_submode! {
123    /// Q65-60B: 60 s T/R period, sub-mode B (tone spacing = baud
124    /// × 2 = 3.333 Hz). Typical for **70 cm and 23 cm EME** where
125    /// Doppler is wider than at 6 m but still moderate.
126    Q65b60,
127    nsps = 7200,
128    spacing_mult = 2,
129    tr_period_s = 60,
130}
131
132q65_submode! {
133    /// Q65-60C: 60 s T/R period, sub-mode C (tone spacing = baud
134    /// × 4 = 6.667 Hz). Microwave EME (~3 GHz) where Doppler
135    /// spread starts to dominate.
136    Q65c60,
137    nsps = 7200,
138    spacing_mult = 4,
139    tr_period_s = 60,
140}
141
142q65_submode! {
143    /// Q65-60D: 60 s T/R period, sub-mode D (tone spacing = baud
144    /// × 8 = 13.33 Hz). Used for **5.7 / 10 GHz EME** where lunar
145    /// libration spreads tones by tens of Hz.
146    Q65d60,
147    nsps = 7200,
148    spacing_mult = 8,
149    tr_period_s = 60,
150}
151
152q65_submode! {
153    /// Q65-60E: 60 s T/R period, sub-mode E (tone spacing = baud
154    /// × 16 = 26.67 Hz). Extreme-Doppler / wide-spread channels;
155    /// useful at 24 GHz and above or for fast aircraft scatter.
156    Q65e60,
157    nsps = 7200,
158    spacing_mult = 16,
159    tr_period_s = 60,
160}
161
162/// FecCodec stub for Q65 — present so [`Q65a30`] can satisfy the
163/// `Protocol::Fec: FecCodec` bound; the real soft-decision decode
164/// path lives in [`Q65Codec`] and is invoked from
165/// [`crate::q65::rx`].
166///
167/// `decode_soft` always returns `None` because the QRA decoder
168/// consumes per-symbol probability distributions over GF(64), not
169/// bit-level LLRs. `encode` is implemented faithfully (bit-level in,
170/// bit-level out) by routing through a transient [`Q65Codec`], so
171/// callers that want a quick reference encoding via the generic
172/// trait still get the right answer.
173#[derive(Copy, Clone, Debug, Default)]
174pub struct Q65Fec;
175
176impl FecCodec for Q65Fec {
177    /// 63 transmitted channel symbols × 6 bits.
178    const N: usize = 63 * 6;
179    /// 13 user info symbols × 6 bits = 78 bits (77-bit Wsjt77 +
180    /// 1-bit zero padding handled at the message-codec boundary).
181    const K: usize = 13 * 6;
182
183    fn encode(&self, info: &[u8], codeword: &mut [u8]) {
184        assert_eq!(info.len(), Self::K, "encode: info.len() != K");
185        assert_eq!(codeword.len(), Self::N, "encode: codeword.len() != N");
186
187        // bits → 13 GF(64) symbols (MSB-first within each 6-bit group).
188        let mut info_syms = [0_i32; 13];
189        for (i, slot) in info_syms.iter_mut().enumerate() {
190            let mut s = 0_i32;
191            for b in 0..6 {
192                s = (s << 1) | (info[6 * i + b] & 1) as i32;
193            }
194            *slot = s;
195        }
196
197        let mut codec = Q65Codec::new(&QRA15_65_64_IRR_E23);
198        let mut channel = [0_i32; 63];
199        codec.encode(&info_syms, &mut channel);
200
201        // 63 GF(64) symbols → bits (MSB-first within each symbol).
202        for (i, &sym) in channel.iter().enumerate() {
203            for b in 0..6 {
204                codeword[6 * i + b] = ((sym >> (5 - b)) & 1) as u8;
205            }
206        }
207    }
208
209    fn decode_soft(&self, _llr: &[f32], _opts: &FecOpts) -> Option<FecResult> {
210        // Bit-LLR soft decoding is not the natural API for Q65's
211        // GF(64) belief propagation. Use `Q65Codec::decode` (or the
212        // protocol-level helpers in `crate::q65::rx`) instead.
213        None
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn modulation_constants_match_spec() {
223        assert_eq!(<Q65a30 as ModulationParams>::NTONES, 65);
224        assert_eq!(<Q65a30 as ModulationParams>::BITS_PER_SYMBOL, 6);
225        assert_eq!(<Q65a30 as ModulationParams>::NSPS, 3600);
226        assert!(
227            (<Q65a30 as ModulationParams>::SYMBOL_DT - 0.3).abs() < 1e-6,
228            "SYMBOL_DT must be 0.3 s for the 30-s T/R period"
229        );
230        let spacing = <Q65a30 as ModulationParams>::TONE_SPACING_HZ;
231        assert!(
232            (spacing - 12_000.0 / 3600.0).abs() < 1e-3,
233            "Q65-30A tone spacing must be 12000/3600 ≈ 3.333 Hz, got {spacing}"
234        );
235    }
236
237    #[test]
238    fn eme_submode_constants_match_q65params_f90() {
239        // q65params.f90 maps T/R → NSPS and letter → spacing
240        // multiplier 2^(letter − 1). Spot-check each EME sub-mode's
241        // NSPS = 7200 and tone spacing = (12000/7200) × multiplier.
242        let baud_60 = 12_000.0 / 7200.0; // 1.667 Hz
243        for (name, spacing, mult) in [
244            ("Q65-60A", <Q65a60 as ModulationParams>::TONE_SPACING_HZ, 1),
245            ("Q65-60B", <Q65b60 as ModulationParams>::TONE_SPACING_HZ, 2),
246            ("Q65-60C", <Q65c60 as ModulationParams>::TONE_SPACING_HZ, 4),
247            ("Q65-60D", <Q65d60 as ModulationParams>::TONE_SPACING_HZ, 8),
248            ("Q65-60E", <Q65e60 as ModulationParams>::TONE_SPACING_HZ, 16),
249        ] {
250            let expected = baud_60 * mult as f32;
251            assert!(
252                (spacing - expected).abs() < 1e-3,
253                "{name} spacing {spacing} != expected {expected}"
254            );
255        }
256        // All 60 s sub-modes share NSPS = 7200.
257        assert_eq!(<Q65a60 as ModulationParams>::NSPS, 7200);
258        assert_eq!(<Q65b60 as ModulationParams>::NSPS, 7200);
259        assert_eq!(<Q65c60 as ModulationParams>::NSPS, 7200);
260        assert_eq!(<Q65d60 as ModulationParams>::NSPS, 7200);
261        assert_eq!(<Q65e60 as ModulationParams>::NSPS, 7200);
262        // …and a 60-second slot.
263        assert_eq!(<Q65a60 as FrameLayout>::T_SLOT_S, 60.0);
264        assert_eq!(<Q65e60 as FrameLayout>::T_SLOT_S, 60.0);
265    }
266
267    #[test]
268    fn all_q65_submodes_share_frame_layout() {
269        // Every Q65 sub-mode MUST share the same frame structure so
270        // that the generic tx/rx code can switch between them on the
271        // type parameter alone (only timing / spacing changes).
272        for (name, n_data, n_sync, n_symbols) in [
273            (
274                "Q65a30",
275                <Q65a30 as FrameLayout>::N_DATA,
276                <Q65a30 as FrameLayout>::N_SYNC,
277                <Q65a30 as FrameLayout>::N_SYMBOLS,
278            ),
279            (
280                "Q65a60",
281                <Q65a60 as FrameLayout>::N_DATA,
282                <Q65a60 as FrameLayout>::N_SYNC,
283                <Q65a60 as FrameLayout>::N_SYMBOLS,
284            ),
285            (
286                "Q65b60",
287                <Q65b60 as FrameLayout>::N_DATA,
288                <Q65b60 as FrameLayout>::N_SYNC,
289                <Q65b60 as FrameLayout>::N_SYMBOLS,
290            ),
291            (
292                "Q65c60",
293                <Q65c60 as FrameLayout>::N_DATA,
294                <Q65c60 as FrameLayout>::N_SYNC,
295                <Q65c60 as FrameLayout>::N_SYMBOLS,
296            ),
297            (
298                "Q65d60",
299                <Q65d60 as FrameLayout>::N_DATA,
300                <Q65d60 as FrameLayout>::N_SYNC,
301                <Q65d60 as FrameLayout>::N_SYMBOLS,
302            ),
303            (
304                "Q65e60",
305                <Q65e60 as FrameLayout>::N_DATA,
306                <Q65e60 as FrameLayout>::N_SYNC,
307                <Q65e60 as FrameLayout>::N_SYMBOLS,
308            ),
309        ] {
310            assert_eq!(n_data, 63, "{name} N_DATA");
311            assert_eq!(n_sync, 22, "{name} N_SYNC");
312            assert_eq!(n_symbols, 85, "{name} N_SYMBOLS");
313        }
314    }
315
316    #[test]
317    fn frame_layout_constants_match_spec() {
318        assert_eq!(<Q65a30 as FrameLayout>::N_DATA, 63);
319        assert_eq!(<Q65a30 as FrameLayout>::N_SYNC, 22);
320        assert_eq!(<Q65a30 as FrameLayout>::N_SYMBOLS, 85);
321        assert_eq!(<Q65a30 as FrameLayout>::N_RAMP, 0);
322        assert_eq!(<Q65a30 as FrameLayout>::T_SLOT_S, 30.0);
323        match <Q65a30 as FrameLayout>::SYNC_MODE {
324            SyncMode::Block(blocks) => {
325                assert_eq!(blocks.len(), 22, "Q65 has 22 distributed sync symbols");
326                for b in blocks {
327                    assert_eq!(b.pattern, &[0u8], "every Q65 sync symbol is tone 0");
328                }
329            }
330            SyncMode::Interleaved { .. } => {
331                panic!("Q65 must use Block sync, not Interleaved")
332            }
333        }
334    }
335
336    #[test]
337    fn protocol_id_is_q65() {
338        assert_eq!(<Q65a30 as Protocol>::ID, ProtocolId::Q65);
339    }
340
341    #[test]
342    fn q65fec_encode_matches_q65codec_direct() {
343        // The bit-level FecCodec stub must agree with calling
344        // `Q65Codec::encode` directly on the same payload.
345        let fec = Q65Fec;
346        // Pseudo-random 78-bit info pattern.
347        let info: Vec<u8> = (0..Q65Fec::K)
348            .map(|i| ((i.wrapping_mul(13) ^ 0x55) & 1) as u8)
349            .collect();
350        let mut codeword = vec![0u8; Q65Fec::N];
351        fec.encode(&info, &mut codeword);
352
353        // Compute the ground truth via Q65Codec on the same 13-symbol
354        // info vector.
355        let mut info_syms = [0_i32; 13];
356        for (i, slot) in info_syms.iter_mut().enumerate() {
357            let mut s = 0_i32;
358            for b in 0..6 {
359                s = (s << 1) | (info[6 * i + b] & 1) as i32;
360            }
361            *slot = s;
362        }
363        let mut codec = Q65Codec::new(&QRA15_65_64_IRR_E23);
364        let mut expected_channel = [0_i32; 63];
365        codec.encode(&info_syms, &mut expected_channel);
366
367        // Reconstruct the bit-level codeword from expected_channel
368        // and compare.
369        let mut expected_bits = vec![0u8; Q65Fec::N];
370        for (i, &sym) in expected_channel.iter().enumerate() {
371            for b in 0..6 {
372                expected_bits[6 * i + b] = ((sym >> (5 - b)) & 1) as u8;
373            }
374        }
375        assert_eq!(codeword, expected_bits);
376    }
377
378    #[test]
379    fn decode_soft_is_a_stub() {
380        let fec = Q65Fec;
381        let llr = vec![0.0_f32; Q65Fec::N];
382        assert!(fec.decode_soft(&llr, &FecOpts::default()).is_none());
383    }
384}