Skip to main content

leodos_protocols/physical/modulator/
oqpsk.rs

1//! Offset Quadrature Phase Shift Keying (OQPSK)
2//!
3//! OQPSK staggers the I and Q channels by half a symbol period,
4//! ensuring they never transition simultaneously. This reduces
5//! the maximum phase change per transition from 180° (QPSK) to
6//! 90°, yielding a more constant envelope. Used in Proximity-1
7//! links (CCSDS 211.0).
8//!
9//! The modulator outputs at 2× the QPSK symbol rate to properly
10//! represent the I/Q offset. The demodulator re-aligns the
11//! channels before computing LLRs.
12
13use super::clamp_i16;
14
15/// Modulates packed bits to OQPSK at 2× the symbol rate.
16///
17/// Maps consecutive bit pairs to I/Q values (±1/√2), then
18/// staggers them: I transitions at even sample indices, Q at
19/// odd indices.
20///
21/// `n_bits` must be even. Writes `n_bits` samples each to
22/// `symbols_i` and `symbols_q`.
23pub fn modulate_oqpsk(
24    bits: &[u8],
25    n_bits: usize,
26    symbols_i: &mut [f32],
27    symbols_q: &mut [f32],
28) {
29    assert!(n_bits % 2 == 0);
30    let n_sym = n_bits / 2;
31    assert!(bits.len() * 8 >= n_bits);
32    assert!(symbols_i.len() >= n_bits);
33    assert!(symbols_q.len() >= n_bits);
34
35    let s = core::f32::consts::FRAC_1_SQRT_2;
36
37    fn get_bit(bits: &[u8], idx: usize) -> f32 {
38        ((bits[idx / 8] >> (7 - (idx % 8))) & 1) as f32
39    }
40
41    for k in 0..n_sym {
42        let i_val = s * (1.0 - 2.0 * get_bit(bits, 2 * k));
43        let q_val = s * (1.0 - 2.0 * get_bit(bits, 2 * k + 1));
44
45        // I transitions at even sample indices
46        symbols_i[2 * k] = i_val;
47        symbols_i[2 * k + 1] = i_val;
48
49        // Q transitions at odd sample indices (half-symbol later)
50        if k > 0 {
51            // Q[2k] still holds the previous symbol's Q value
52            // (already written as symbols_q[2k-1] in the previous iteration)
53        } else {
54            symbols_q[0] = 0.0;
55        }
56        symbols_q[2 * k + 1] = q_val;
57        if 2 * k + 2 < n_bits {
58            symbols_q[2 * k + 2] = q_val;
59        }
60    }
61}
62
63/// Demodulates OQPSK symbols to soft-decision i16 LLRs.
64///
65/// Re-aligns the staggered I/Q channels by sampling I at even
66/// indices and Q at odd indices, then computes LLRs as for QPSK.
67///
68/// Produces `n_bits` LLRs: even indices from I, odd from Q.
69pub fn demodulate_oqpsk(
70    symbols_i: &[f32],
71    symbols_q: &[f32],
72    n_bits: usize,
73    noise_var: f32,
74    scale: f32,
75    llr: &mut [i16],
76) {
77    assert!(n_bits % 2 == 0);
78    let n_sym = n_bits / 2;
79    assert!(symbols_i.len() >= n_bits);
80    assert!(symbols_q.len() >= n_bits);
81    assert!(llr.len() >= n_bits);
82
83    let s = core::f32::consts::FRAC_1_SQRT_2;
84    let factor = scale * 2.0 / noise_var * s;
85
86    for k in 0..n_sym {
87        // I bit sampled at even index (center of I pulse)
88        llr[2 * k] = clamp_i16(symbols_i[2 * k] * factor);
89        // Q bit sampled at odd index (center of Q pulse)
90        llr[2 * k + 1] = clamp_i16(symbols_q[2 * k + 1] * factor);
91    }
92}
93
94/// OQPSK modulator/demodulator with configurable noise parameters.
95pub struct Oqpsk {
96    noise_var: f32,
97    scale: f32,
98}
99
100impl Oqpsk {
101    /// Creates an OQPSK modem with the given noise variance and
102    /// LLR scale factor.
103    pub fn new(noise_var: f32, scale: f32) -> Self {
104        Self { noise_var, scale }
105    }
106}
107
108impl super::Modulator for Oqpsk {
109    fn modulate(
110        &self,
111        bits: &[u8],
112        n_bits: usize,
113        symbols: &mut [f32],
114    ) -> usize {
115        let (si, sq) = symbols.split_at_mut(n_bits);
116        modulate_oqpsk(bits, n_bits, si, sq);
117        n_bits * 2
118    }
119}
120
121impl super::Demodulator for Oqpsk {
122    fn demodulate_soft(
123        &self,
124        symbols: &[f32],
125        n_bits: usize,
126        llr: &mut [i16],
127    ) {
128        let (si, sq) = symbols.split_at(n_bits);
129        demodulate_oqpsk(
130            si, sq, n_bits, self.noise_var, self.scale, llr,
131        );
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn oqpsk_modulate_all_zeros() {
141        let bits = [0x00u8];
142        let mut si = [0f32; 8];
143        let mut sq = [0f32; 8];
144        modulate_oqpsk(&bits, 8, &mut si, &mut sq);
145
146        let s = core::f32::consts::FRAC_1_SQRT_2;
147        // All I values should be +1/√2
148        for k in 0..4 {
149            assert!((si[2 * k] - s).abs() < 1e-6);
150            assert!((si[2 * k + 1] - s).abs() < 1e-6);
151        }
152    }
153
154    #[test]
155    fn oqpsk_i_q_never_simultaneous() {
156        let bits = [0xFFu8];
157        let mut si = [0f32; 8];
158        let mut sq = [0f32; 8];
159        modulate_oqpsk(&bits, 8, &mut si, &mut sq);
160
161        // Between consecutive samples, at most one of I/Q changes
162        for n in 1..8 {
163            let i_changed = (si[n] - si[n - 1]).abs() > 1e-6;
164            let q_changed = (sq[n] - sq[n - 1]).abs() > 1e-6;
165            assert!(
166                !(i_changed && q_changed),
167                "I and Q both changed at sample {n}"
168            );
169        }
170    }
171
172    #[test]
173    fn oqpsk_roundtrip() {
174        let bits = [0xC3, 0x5A];
175        let mut si = [0f32; 16];
176        let mut sq = [0f32; 16];
177        modulate_oqpsk(&bits, 16, &mut si, &mut sq);
178
179        let mut llr = [0i16; 16];
180        demodulate_oqpsk(&si, &sq, 16, 0.5, 100.0, &mut llr);
181
182        let mut recovered = [0u8; 2];
183        for i in 0..16 {
184            if llr[i] < 0 {
185                recovered[i / 8] |= 1 << (7 - (i % 8));
186            }
187        }
188        assert_eq!(recovered, bits);
189    }
190
191    #[test]
192    fn oqpsk_unit_energy() {
193        let bits = [0xA5u8];
194        let mut si = [0f32; 8];
195        let mut sq = [0f32; 8];
196        modulate_oqpsk(&bits, 8, &mut si, &mut sq);
197
198        // At odd sample indices (where both I and Q are valid),
199        // I² + Q² should be approximately 1.
200        for k in 0..4 {
201            let n = 2 * k + 1;
202            let energy = si[n] * si[n] + sq[n] * sq[n];
203            assert!(
204                (energy - 1.0).abs() < 1e-5,
205                "energy at sample {n} = {energy}"
206            );
207        }
208    }
209}