Skip to main content

leodos_protocols/physical/modulator/
eight_psk.rs

1//! Gray-coded 8PSK Modulation and Demodulation
2//!
3//! Maps 3-bit groups to one of 8 equally-spaced constellation
4//! points on the unit circle. Gray coding ensures adjacent symbols
5//! differ by exactly one bit, minimizing BER at moderate SNR.
6//!
7//! Used in high-rate downlinks and DVB-S2 based CCSDS links
8//! (CCSDS 131.2-B).
9//!
10//! # Constellation
11//!
12//! ```text
13//!         Q
14//!     011 · 010
15//!    /    |    \
16//!  001    |   110
17//!  ───────┼──────→ I
18//!  000    |   111
19//!    \    |   /
20//!     100 · 101
21//! ```
22
23use super::clamp_i16;
24
25/// Gray code: `GRAY[n]` = Gray-coded value for constellation
26/// point `n` (angle = n·π/4).
27static GRAY: [u8; 8] = [0, 1, 3, 2, 6, 7, 5, 4];
28
29/// Inverse Gray code: `GRAY_INV[data]` = constellation index for
30/// 3-bit data value `data`.
31static GRAY_INV: [u8; 8] = {
32    let mut t = [0u8; 8];
33    let mut i = 0;
34    while i < 8 {
35        t[GRAY[i] as usize] = i as u8;
36        i += 1;
37    }
38    t
39};
40
41/// Precomputed constellation points: `CONSTELLATION[n]` = (I, Q)
42/// for constellation index `n`, with angle = n·π/4.
43static CONSTELLATION: [(f32, f32); 8] = {
44    // Compute at compile time using known values of cos/sin at
45    // multiples of π/4.
46    let s = 0.707_106_77; // 1/√2
47    [
48        (1.0, 0.0),   // 0
49        (s, s),        // π/4
50        (0.0, 1.0),   // π/2
51        (-s, s),       // 3π/4
52        (-1.0, 0.0),   // π
53        (-s, -s),      // 5π/4
54        (0.0, -1.0),   // 3π/2
55        (s, -s),       // 7π/4
56    ]
57};
58
59/// For each of the 3 bits, which constellation indices have that
60/// bit = 0 and which have bit = 1.
61///
62/// `BIT_SETS[bit][0]` = indices where bit is 0 in the Gray code.
63/// `BIT_SETS[bit][1]` = indices where bit is 1 in the Gray code.
64static BIT_SETS: [[[u8; 4]; 2]; 3] = {
65    let mut sets = [[[0u8; 4]; 2]; 3];
66    let mut bit = 0;
67    while bit < 3 {
68        let mut count0 = 0usize;
69        let mut count1 = 0usize;
70        let mut idx = 0;
71        while idx < 8 {
72            if (GRAY[idx] >> (2 - bit)) & 1 == 0 {
73                sets[bit][0][count0] = idx as u8;
74                count0 += 1;
75            } else {
76                sets[bit][1][count1] = idx as u8;
77                count1 += 1;
78            }
79            idx += 1;
80        }
81        bit += 1;
82    }
83    sets
84};
85
86/// Number of bits per 8PSK symbol.
87pub const BITS_PER_SYMBOL: usize = 3;
88
89/// Modulates packed bits to 8PSK I/Q symbols.
90///
91/// Groups `n_bits` into 3-bit chunks (MSB-first), maps each
92/// through the Gray-coded constellation, and writes one I/Q pair
93/// per symbol. `n_bits` must be a multiple of 3.
94///
95/// Writes `n_bits / 3` samples to each of `symbols_i`, `symbols_q`.
96pub fn modulate_8psk(
97    bits: &[u8],
98    n_bits: usize,
99    symbols_i: &mut [f32],
100    symbols_q: &mut [f32],
101) {
102    assert!(n_bits % 3 == 0);
103    let n_sym = n_bits / 3;
104    assert!(bits.len() * 8 >= n_bits);
105    assert!(symbols_i.len() >= n_sym);
106    assert!(symbols_q.len() >= n_sym);
107
108    for k in 0..n_sym {
109        let base = 3 * k;
110        let b0 = ((bits[base / 8] >> (7 - (base % 8))) & 1) as u8;
111        let b1 =
112            ((bits[(base + 1) / 8] >> (7 - ((base + 1) % 8))) & 1) as u8;
113        let b2 =
114            ((bits[(base + 2) / 8] >> (7 - ((base + 2) % 8))) & 1) as u8;
115
116        let data = (b0 << 2) | (b1 << 1) | b2;
117        let idx = GRAY_INV[data as usize] as usize;
118        let (ci, cq) = CONSTELLATION[idx];
119        symbols_i[k] = ci;
120        symbols_q[k] = cq;
121    }
122}
123
124/// Demodulates 8PSK symbols to soft-decision i16 LLRs.
125///
126/// Uses the max-log-MAP approximation: for each of the 3 bits
127/// per symbol, computes the difference between the minimum
128/// squared distance to constellation points where that bit is 0
129/// vs 1.
130///
131/// Produces `n_bits` LLRs (3 per symbol, MSB first).
132pub fn demodulate_8psk(
133    symbols_i: &[f32],
134    symbols_q: &[f32],
135    n_bits: usize,
136    noise_var: f32,
137    scale: f32,
138    llr: &mut [i16],
139) {
140    assert!(n_bits % 3 == 0);
141    let n_sym = n_bits / 3;
142    assert!(symbols_i.len() >= n_sym);
143    assert!(symbols_q.len() >= n_sym);
144    assert!(llr.len() >= n_bits);
145
146    let factor = scale / noise_var;
147
148    for k in 0..n_sym {
149        let yi = symbols_i[k];
150        let yq = symbols_q[k];
151
152        // Squared distances to all 8 constellation points
153        let mut dist2 = [0f32; 8];
154        for p in 0..8 {
155            let (ci, cq) = CONSTELLATION[p];
156            let di = yi - ci;
157            let dq = yq - cq;
158            dist2[p] = di * di + dq * dq;
159        }
160
161        // For each of the 3 bits, compute max-log LLR
162        for bit in 0..3 {
163            let mut min_d0 = f32::MAX;
164            for &idx in &BIT_SETS[bit][0] {
165                let d = dist2[idx as usize];
166                if d < min_d0 {
167                    min_d0 = d;
168                }
169            }
170
171            let mut min_d1 = f32::MAX;
172            for &idx in &BIT_SETS[bit][1] {
173                let d = dist2[idx as usize];
174                if d < min_d1 {
175                    min_d1 = d;
176                }
177            }
178
179            // LLR > 0 means bit 0 more likely (smaller distance)
180            let raw = factor * (min_d1 - min_d0);
181            llr[3 * k + bit] = clamp_i16(raw);
182        }
183    }
184}
185
186/// 8PSK modulator/demodulator with configurable noise parameters.
187pub struct EightPsk {
188    noise_var: f32,
189    scale: f32,
190}
191
192impl EightPsk {
193    /// Creates an 8PSK modem with the given noise variance and
194    /// LLR scale factor.
195    pub fn new(noise_var: f32, scale: f32) -> Self {
196        Self { noise_var, scale }
197    }
198}
199
200impl super::Modulator for EightPsk {
201    fn modulate(
202        &self,
203        bits: &[u8],
204        n_bits: usize,
205        symbols: &mut [f32],
206    ) -> usize {
207        let n_sym = n_bits / BITS_PER_SYMBOL;
208        let (si, sq) = symbols.split_at_mut(n_sym);
209        modulate_8psk(bits, n_bits, si, sq);
210        n_sym * 2
211    }
212}
213
214impl super::Demodulator for EightPsk {
215    fn demodulate_soft(
216        &self,
217        symbols: &[f32],
218        n_bits: usize,
219        llr: &mut [i16],
220    ) {
221        let n_sym = n_bits / BITS_PER_SYMBOL;
222        let (si, sq) = symbols.split_at(n_sym);
223        demodulate_8psk(
224            si, sq, n_bits, self.noise_var, self.scale, llr,
225        );
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn gray_code_adjacency() {
235        // Adjacent constellation points should differ by 1 bit
236        for i in 0..8 {
237            let j = (i + 1) % 8;
238            let diff = GRAY[i] ^ GRAY[j];
239            assert_eq!(
240                diff.count_ones(),
241                1,
242                "points {i} and {j} differ by {} bits",
243                diff.count_ones()
244            );
245        }
246    }
247
248    #[test]
249    fn gray_inverse_roundtrip() {
250        for data in 0..8u8 {
251            let idx = GRAY_INV[data as usize];
252            assert_eq!(GRAY[idx as usize], data);
253        }
254    }
255
256    #[test]
257    fn constellation_unit_energy() {
258        for (i, &(ci, cq)) in CONSTELLATION.iter().enumerate() {
259            let energy = ci * ci + cq * cq;
260            assert!(
261                (energy - 1.0).abs() < 1e-5,
262                "point {i}: energy = {energy}"
263            );
264        }
265    }
266
267    #[test]
268    fn modulate_known_symbols() {
269        // Data 000 → constellation index 0 → angle 0 → (1, 0)
270        let bits = [0b000_000_00u8];
271        let mut si = [0f32; 2];
272        let mut sq = [0f32; 2];
273        modulate_8psk(&bits, 6, &mut si, &mut sq);
274        assert!((si[0] - 1.0).abs() < 1e-6);
275        assert!(sq[0].abs() < 1e-6);
276    }
277
278    #[test]
279    fn roundtrip_no_noise() {
280        // 12 bits = 4 symbols
281        let bits = [0b101_011_00, 0b0_110_0000];
282        let mut si = [0f32; 4];
283        let mut sq = [0f32; 4];
284        modulate_8psk(&bits, 12, &mut si, &mut sq);
285
286        let mut llr = [0i16; 12];
287        demodulate_8psk(&si, &sq, 12, 0.5, 100.0, &mut llr);
288
289        let mut recovered = [0u8; 2];
290        for i in 0..12 {
291            if llr[i] < 0 {
292                recovered[i / 8] |= 1 << (7 - (i % 8));
293            }
294        }
295        // Original bits: 101_011_000_110 = 0b10101100, 0b01100000
296        assert_eq!(recovered[0], 0b10101100);
297        assert_eq!(recovered[1] & 0xF0, 0b01100000);
298    }
299
300    #[test]
301    fn all_symbols_roundtrip() {
302        // Encode all 8 possible 3-bit values
303        // 000 001 010 011 100 101 110 111 = 0x05, 0x39, 0xBF
304        let bits = [0b000_001_01, 0b0_011_100_1, 0b01_110_111];
305        let mut si = [0f32; 8];
306        let mut sq = [0f32; 8];
307        modulate_8psk(&bits, 24, &mut si, &mut sq);
308
309        let mut llr = [0i16; 24];
310        demodulate_8psk(&si, &sq, 24, 0.5, 100.0, &mut llr);
311
312        let mut recovered = [0u8; 3];
313        for i in 0..24 {
314            if llr[i] < 0 {
315                recovered[i / 8] |= 1 << (7 - (i % 8));
316            }
317        }
318        assert_eq!(recovered, bits);
319    }
320
321    #[test]
322    fn bit_sets_partition() {
323        // Each bit's 0-set and 1-set should cover all 8 points
324        for bit in 0..3 {
325            let mut seen = [false; 8];
326            for &idx in &BIT_SETS[bit][0] {
327                seen[idx as usize] = true;
328            }
329            for &idx in &BIT_SETS[bit][1] {
330                seen[idx as usize] = true;
331            }
332            assert!(seen.iter().all(|&s| s), "bit {bit} doesn't cover all");
333        }
334    }
335}