comnum_mapping/constellation.rs
1//! Constellations, Gray mapping and hard decisions (OptiCommPy `optic.comm.modulation`).
2//!
3//! # Constellations
4//!
5//! A [`Constellation`] stores its `M = 2^b` points **indexed by their bit label**: the
6//! point `points()[i]` carries the bits of `i` written on `b` bits, most significant
7//! bit first. The points are those of OptiCommPy, not normalised:
8//!
9//! - PAM: `{-(M-1), …, -1, 1, …, M-1}` (minimum distance 2, `E_s = (M²-1)/3`);
10//! - square QAM: the Cartesian product of two `√M`-PAM (`E_s = 2(M-1)/3`);
11//! - PSK: `e^{j2πk/M}`, `k = 0..M-1` (`E_s = 1`);
12//! - OOK: `{0, 1}`.
13//!
14//! The Gray labels are assigned as in OptiCommPy: the points are listed in their
15//! construction order (PAM and PSK in increasing order; QAM row by row from the top,
16//! every other row reversed, a "snake" that makes neighbouring points adjacent in the
17//! list), and the `i`-th point of the list gets the Gray code `i ⊕ (i >> 1)`. Two
18//! nearest neighbours then differ by one bit, and the bits of a symbol stream are those
19//! of OptiCommPy's `modulateGray`.
20//!
21//! Use [`Constellation::normalized`] for unit average energy (`E_s = 1`), the usual
22//! convention of the simulations.
23//!
24//! # Decisions
25//!
26//! [`Constellation::decide`] returns the nearest point (the maximum-likelihood decision
27//! over AWGN); the soft demapper is in [`crate::demapper`].
28//!
29//! # Examples
30//! ```
31//! use comnum_mapping::constellation::{Constellation, ConstellationKind};
32//! use comnum_math::C64;
33//! let c = Constellation::qam(16).unwrap();
34//! assert_eq!(c.points()[0], C64::new(-3.0, 3.0)); // label 0000
35//! assert!((c.energy() - 10.0).abs() < 1e-12); // 2(M-1)/3
36//! let x = c.map_bits(&[0, 0, 0, 1, 1, 0, 1, 1]).unwrap();
37//! assert_eq!(x, vec![c.points()[1], c.points()[11]]);
38//! // Gray mapping: nearest neighbours differ by one bit
39//! for i in 0..16 {
40//! for j in 0..16 {
41//! if (c.points()[i] - c.points()[j]).norm() == 2.0 {
42//! assert_eq!((i ^ j).count_ones(), 1);
43//! }
44//! }
45//! }
46//! assert_eq!(Constellation::new(ConstellationKind::Psk, 8).unwrap().order(), 8);
47//! ```
48
49use comnum_core::{Error, Result, ensure, same_len};
50use comnum_math::bits::bits_to_indices;
51use comnum_math::nearest::Slicer;
52use comnum_math::{C64, cis, gray, index_to_bits};
53use std::str::FromStr;
54
55/// The family of a constellation.
56#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
57#[non_exhaustive]
58pub enum ConstellationKind {
59 /// Pulse amplitude modulation (real).
60 Pam,
61 /// Square quadrature amplitude modulation.
62 Qam,
63 /// Phase shift keying.
64 Psk,
65 /// On-off keying `{0, 1}` (real).
66 Ook,
67 /// Points given by the user ([`Constellation::from_points`]).
68 Custom,
69}
70
71impl FromStr for ConstellationKind {
72 type Err = Error;
73
74 /// Parse `"pam"`, `"qam"`, `"psk"` or `"ook"` (the `constType` of OptiCommPy),
75 /// case-insensitive.
76 ///
77 /// # Errors
78 /// [`Error::Config`] for an unknown name.
79 fn from_str(s: &str) -> Result<ConstellationKind> {
80 match s.to_ascii_lowercase().as_str() {
81 "pam" => Ok(ConstellationKind::Pam),
82 "qam" => Ok(ConstellationKind::Qam),
83 "psk" => Ok(ConstellationKind::Psk),
84 "ook" => Ok(ConstellationKind::Ook),
85 other => Err(Error::Config(format!("unknown constellation type {other:?}: pam, qam, psk or ook"))),
86 }
87 }
88}
89
90impl ConstellationKind {
91 /// The name of the family: `"pam"`, `"qam"`, `"psk"`, `"ook"` or `"custom"` (the
92 /// names that [`ConstellationKind::from_str`] reads, but the last).
93 ///
94 /// # Examples
95 /// ```
96 /// use comnum_mapping::constellation::ConstellationKind;
97 /// let k: ConstellationKind = "QAM".parse()?;
98 /// assert_eq!(k.name(), "qam");
99 /// # Ok::<(), comnum_core::Error>(())
100 /// ```
101 pub fn name(self) -> &'static str {
102 match self {
103 ConstellationKind::Pam => "pam",
104 ConstellationKind::Qam => "qam",
105 ConstellationKind::Psk => "psk",
106 ConstellationKind::Ook => "ook",
107 ConstellationKind::Custom => "custom",
108 }
109 }
110
111 /// Whether the constellation is real (one real dimension per symbol).
112 pub fn is_real(self) -> bool {
113 matches!(self, ConstellationKind::Pam | ConstellationKind::Ook)
114 }
115}
116
117/// The largest number of bits of [`gray_code`] (16 777 216 code words; the $2^{n}$ words
118/// are allocated at once).
119pub const MAX_GRAY_BITS: u32 = 24;
120
121/// The Gray code of every `n`-bit integer: `code[i] = i ⊕ (i >> 1)` ([`comnum_math::gray`]).
122///
123/// # Errors
124/// [`Error::Config`] for more than [`MAX_GRAY_BITS`] bits.
125pub fn gray_code(n_bits: u32) -> Result<Vec<usize>> {
126 ensure(n_bits <= MAX_GRAY_BITS, || format!("n_bits must be at most {MAX_GRAY_BITS}, got {n_bits}"))?;
127 Ok((0..1usize << n_bits).map(gray).collect())
128}
129
130/// The bits of every index, `bits_per_symbol` bits each, most significant bit first
131/// ([`comnum_math::index_to_bits`]).
132pub fn indices_to_bits(indices: &[usize], bits_per_symbol: usize) -> Vec<u8> {
133 let mut out = Vec::with_capacity(indices.len() * bits_per_symbol);
134 for &i in indices {
135 out.extend(index_to_bits(i, bits_per_symbol));
136 }
137 out
138}
139
140/// The real BPSK symbols $1 - 2b$ of bits ($0 \mapsto +1$, $1 \mapsto -1$), the
141/// modulation of the code tests over a real channel ([`crate::demapper::bpsk_llr`] is
142/// its demapper).
143///
144/// # Examples
145/// ```
146/// use comnum_mapping::constellation::bpsk;
147/// assert_eq!(bpsk(&[0, 1, 1]), [1.0, -1.0, -1.0]);
148/// ```
149pub fn bpsk(bits: &[u8]) -> Vec<f64> {
150 bits.iter().map(|b| 1.0 - 2.0 * f64::from(*b)).collect()
151}
152
153/// A labelled constellation: `points()[i]` carries the bits of `i`.
154#[derive(Clone, Debug, PartialEq)]
155pub struct Constellation {
156 kind: ConstellationKind,
157 points: Vec<C64>,
158 bits: usize,
159 slicer: Slicer,
160}
161
162impl Constellation {
163 /// The constellation of a family and an order `m` (power of two; a square for QAM;
164 /// 2 for OOK).
165 ///
166 /// # Errors
167 /// [`Error::Config`] unless `m` is a power of two ≥ 2 (a square for QAM).
168 pub fn new(kind: ConstellationKind, m: usize) -> Result<Constellation> {
169 ensure(m >= 2 && m.is_power_of_two(), || format!("the order must be a power of two ≥ 2, got {m}"))?;
170 let natural: Vec<C64> = match kind {
171 ConstellationKind::Pam => pam_levels(m).into_iter().map(|v| C64::new(v, 0.0)).collect(),
172 ConstellationKind::Qam => {
173 let l = qam_side(m).ok_or_else(|| Error::Config(format!("QAM needs a square order, got {m}")))?;
174 let lv = pam_levels(l);
175 let mut pts = Vec::with_capacity(m);
176 for r in 0..l {
177 let im = lv[l - 1 - r];
178 let row: Vec<C64> = lv.iter().map(|&re| C64::new(re, im)).collect();
179 if r % 2 == 1 {
180 pts.extend(row.into_iter().rev());
181 } else {
182 pts.extend(row);
183 }
184 }
185 pts
186 }
187 ConstellationKind::Psk => (0..m).map(|k| cis(2.0 * std::f64::consts::PI * k as f64 / m as f64)).collect(),
188 ConstellationKind::Ook => {
189 ensure(m == 2, || format!("OOK has two points, got {m}"))?;
190 vec![C64::new(0.0, 0.0), C64::new(1.0, 0.0)]
191 }
192 ConstellationKind::Custom => {
193 return Err(Error::Config("use Constellation::from_points for a custom constellation".into()));
194 }
195 };
196 let bits = m.trailing_zeros() as usize;
197 let mut points = vec![C64::new(0.0, 0.0); m];
198 for (i, p) in natural.into_iter().enumerate() {
199 points[gray(i)] = p;
200 }
201 Ok(Constellation::assemble(kind, points, bits))
202 }
203
204 fn assemble(kind: ConstellationKind, points: Vec<C64>, bits: usize) -> Constellation {
205 let slicer = Slicer::new(&points);
206 Constellation { kind, points, bits, slicer }
207 }
208
209 /// Square `m`-QAM (`m` = 4, 16, 64, 256, …).
210 ///
211 /// # Errors
212 /// [`Error::Config`] unless `m` is an even power of two ≥ 4.
213 pub fn qam(m: usize) -> Result<Constellation> {
214 Constellation::new(ConstellationKind::Qam, m)
215 }
216
217 /// `m`-PSK.
218 ///
219 /// # Errors
220 /// [`Error::Config`] unless `m` is a power of two ≥ 2.
221 pub fn psk(m: usize) -> Result<Constellation> {
222 Constellation::new(ConstellationKind::Psk, m)
223 }
224
225 /// `m`-PAM.
226 ///
227 /// # Errors
228 /// [`Error::Config`] unless `m` is a power of two ≥ 2.
229 pub fn pam(m: usize) -> Result<Constellation> {
230 Constellation::new(ConstellationKind::Pam, m)
231 }
232
233 /// On-off keying.
234 pub fn ook() -> Constellation {
235 Constellation::new(ConstellationKind::Ook, 2).unwrap_or_else(|_| unreachable!("OOK of order 2 is valid"))
236 }
237
238 /// The constellation of a name: `16-QAM`, `qam16`, `QPSK` (4-QAM), `BPSK` (2-PSK),
239 /// `8-PSK`, `4-PAM`, `OOK` (case-insensitive; `-`, `_` and spaces are ignored), not
240 /// normalised (call [`Constellation::normalized`] for unit energy).
241 ///
242 /// # Errors
243 /// [`Error::Config`] for an unknown family, a missing order or an invalid one.
244 ///
245 /// # Examples
246 /// ```
247 /// use comnum_mapping::constellation::{Constellation, ConstellationKind};
248 /// let c = Constellation::from_name("16-QAM")?;
249 /// assert_eq!((c.kind(), c.order()), (ConstellationKind::Qam, 16));
250 /// assert_eq!(Constellation::from_name("qpsk")?, Constellation::qam(4)?);
251 /// assert!(Constellation::from_name("sixteen").is_err());
252 /// # Ok::<(), comnum_core::Error>(())
253 /// ```
254 pub fn from_name(name: &str) -> Result<Constellation> {
255 let t = name.to_ascii_lowercase().replace(['-', '_', ' '], "");
256 let (kind, m) = match t.as_str() {
257 "qpsk" => (ConstellationKind::Qam, 4),
258 "bpsk" => (ConstellationKind::Psk, 2),
259 "ook" => (ConstellationKind::Ook, 2),
260 _ => {
261 let digits: String = t.chars().filter(char::is_ascii_digit).collect();
262 let letters: String = t.chars().filter(|c| !c.is_ascii_digit()).collect();
263 let m = digits
264 .parse::<usize>()
265 .map_err(|_| Error::Config(format!("cannot read the constellation {name:?}")))?;
266 (letters.parse()?, m)
267 }
268 };
269 Constellation::new(kind, m)
270 }
271
272 /// A constellation from its points, `points[i]` labelled by the bits of `i`.
273 ///
274 /// # Errors
275 /// [`Error::Config`] unless the number of points is a power of two ≥ 2.
276 pub fn from_points(points: Vec<C64>) -> Result<Constellation> {
277 let m = points.len();
278 ensure(m >= 2 && m.is_power_of_two(), || format!("the number of points must be a power of two ≥ 2, got {m}"))?;
279 Ok(Constellation::assemble(ConstellationKind::Custom, points, m.trailing_zeros() as usize))
280 }
281
282 /// The same points as [`Constellation::new`] with a set-partitioning labelling
283 /// (Ungerboeck 1982): fixing the last bits of the label leaves subsets whose minimum
284 /// distance grows. PSK gets the natural labelling, the point $e^{j2\pi k/M}$ carrying
285 /// the bits of $k$; 16-QAM the labelling of Caire, Taricco & Biglieri (*IEEE Trans.
286 /// Inf. Theory* 44(3), 1998, Fig. 2(a)). The family of the result is
287 /// [`ConstellationKind::Custom`].
288 ///
289 /// # Errors
290 /// [`Error::Config`] for another family or order.
291 ///
292 /// # Examples
293 /// ```
294 /// use comnum_mapping::constellation::{Constellation, ConstellationKind};
295 /// use comnum_math::C64;
296 /// let c = Constellation::set_partitioning(ConstellationKind::Qam, 16)?;
297 /// assert_eq!(c.points()[0b1001], C64::new(-3.0, 3.0));
298 /// // the points ending in 00 form a 4-QAM of side 4
299 /// let sub: Vec<C64> = (0..16).filter(|i| i & 3 == 0).map(|i| c.points()[i]).collect();
300 /// assert!(sub.iter().all(|a| sub.iter().all(|b| a == b || (a - b).norm() >= 4.0)));
301 /// # Ok::<(), comnum_core::Error>(())
302 /// ```
303 pub fn set_partitioning(kind: ConstellationKind, m: usize) -> Result<Constellation> {
304 let points = match (kind, m) {
305 (ConstellationKind::Psk, _) => {
306 ensure(m >= 2 && m.is_power_of_two(), || format!("the order must be a power of two ≥ 2, got {m}"))?;
307 (0..m).map(|k| cis(2.0 * std::f64::consts::PI * k as f64 / m as f64)).collect()
308 }
309 (ConstellationKind::Qam, 16) => {
310 // Caire et al. 1998, Fig. 2(a), row by row from the top, left to right
311 let rows: [[usize; 4]; 4] = [
312 [0b1001, 0b1100, 0b1101, 0b1000],
313 [0b1110, 0b1011, 0b1010, 0b1111],
314 [0b0101, 0b0000, 0b0001, 0b0100],
315 [0b0010, 0b0111, 0b0110, 0b0011],
316 ];
317 let mut pts = vec![C64::new(0.0, 0.0); 16];
318 for (r, row) in rows.iter().enumerate() {
319 for (c, &label) in row.iter().enumerate() {
320 pts[label] = C64::new(2.0 * c as f64 - 3.0, 3.0 - 2.0 * r as f64);
321 }
322 }
323 pts
324 }
325 _ => return Err(Error::Config(format!("no set-partitioning labelling for {}-{}", m, kind.name()))),
326 };
327 Constellation::from_points(points)
328 }
329
330 /// The family.
331 pub fn kind(&self) -> ConstellationKind {
332 self.kind
333 }
334
335 /// The number of points `M`.
336 pub fn order(&self) -> usize {
337 self.points.len()
338 }
339
340 /// Bits per symbol `log2 M`.
341 pub fn bits_per_symbol(&self) -> usize {
342 self.bits
343 }
344
345 /// The points, indexed by their label.
346 pub fn points(&self) -> &[C64] {
347 &self.points
348 }
349
350 /// Average energy `Σ |x_i|² / M` (uniform probabilities).
351 pub fn energy(&self) -> f64 {
352 comnum_math::power(&self.points)
353 }
354
355 /// Average energy `Σ p_i |x_i|²` under the probabilities `px`.
356 ///
357 /// # Errors
358 /// [`Error::Shape`] unless `px` holds one probability per point.
359 pub fn energy_with(&self, px: &[f64]) -> Result<f64> {
360 same_len(px.len(), self.points.len(), "energy_with: probabilities and points")?;
361 Ok(self.points.iter().zip(px).map(|(p, w)| w * p.norm_sqr()).sum())
362 }
363
364 /// The prior probabilities of the points: `px` checked (one non-negative value per
365 /// point, not all zero) and normalised to a sum of 1, or uniform if `None`.
366 ///
367 /// # Errors
368 /// [`Error::Shape`] for another number of values, [`Error::Config`] for negative or
369 /// all-zero values.
370 pub fn priors(&self, px: Option<&[f64]>) -> Result<Vec<f64>> {
371 let m = self.order();
372 Ok(match px {
373 None => vec![1.0 / m as f64; m],
374 Some(p) => {
375 same_len(p.len(), m, "probabilities and constellation points")?;
376 let total: f64 = p.iter().sum();
377 ensure(total > 0.0 && p.iter().all(|v| *v >= 0.0), || "the probabilities must be non-negative".into())?;
378 p.iter().map(|v| v / total).collect()
379 }
380 })
381 }
382
383 /// The same constellation scaled to unit average energy under the probabilities
384 /// `px` (probabilistic shaping), normalised to a sum of 1 first as by
385 /// [`Constellation::priors`].
386 ///
387 /// # Errors
388 /// As [`Constellation::priors`]; [`Error::Config`] if the energy under `px` is zero.
389 pub fn normalized_under(&self, px: &[f64]) -> Result<Constellation> {
390 let es = self.energy_with(&self.priors(Some(px))?)?;
391 ensure(es > 0.0, || "the constellation has no energy".into())?;
392 Ok(self.scaled(1.0 / es.sqrt()))
393 }
394
395 /// The same constellation scaled to unit average energy.
396 pub fn normalized(&self) -> Constellation {
397 self.scaled(1.0 / self.energy().sqrt())
398 }
399
400 /// The same constellation with every point multiplied by `s`.
401 pub fn scaled(&self, s: f64) -> Constellation {
402 Constellation::assemble(self.kind, self.points.iter().map(|p| p * s).collect(), self.bits)
403 }
404
405 /// The label (index) of every symbol of a bit stream (most significant bit first).
406 ///
407 /// # Errors
408 /// [`Error::Shape`] if the number of bits is not a multiple of `log2 M`.
409 pub fn indices(&self, bits: &[u8]) -> Result<Vec<usize>> {
410 bits_to_indices(bits, self.bits)
411 }
412
413 /// Map a bit stream to symbols (OptiCommPy `modulateGray`).
414 ///
415 /// # Errors
416 /// [`Error::Shape`] if the number of bits is not a multiple of `log2 M`.
417 pub fn map_bits(&self, bits: &[u8]) -> Result<Vec<C64>> {
418 Ok(self.indices(bits)?.into_iter().map(|i| self.points[i]).collect())
419 }
420
421 /// The symbols of a sequence of labels.
422 pub fn symbols(&self, indices: &[usize]) -> Vec<C64> {
423 indices.iter().map(|&i| self.points[i]).collect()
424 }
425
426 /// The label of the nearest point of every sample (minimum Euclidean distance).
427 pub fn decide(&self, z: &[C64]) -> Vec<usize> {
428 z.iter().map(|&v| self.nearest(v)).collect()
429 }
430
431 /// The label of the point nearest to `z`.
432 #[inline]
433 pub fn nearest(&self, z: C64) -> usize {
434 self.slicer.nearest(z)
435 }
436
437 /// The nearest point of every sample.
438 pub fn project(&self, z: &[C64]) -> Vec<C64> {
439 z.iter().map(|&v| self.points[self.nearest(v)]).collect()
440 }
441
442 /// Hard-decision bits of every sample (OptiCommPy `demodulateGray`).
443 pub fn demap_bits(&self, z: &[C64]) -> Vec<u8> {
444 indices_to_bits(&self.decide(z), self.bits)
445 }
446}
447
448/// `{-(m-1), …, -1, 1, …, m-1}`.
449fn pam_levels(m: usize) -> Vec<f64> {
450 (0..m).map(|i| 2.0 * i as f64 - (m as f64 - 1.0)).collect()
451}
452
453/// The floor of a prior probability before its logarithm: $\ln 10^{-300} \approx -691$.
454pub const PRIOR_FLOOR: f64 = 1e-300;
455
456/// The log of prior probabilities, floored at [`PRIOR_FLOOR`] so that a zero
457/// probability gives a finite log (the metric of an impossible point).
458pub fn log_priors(px: &[f64]) -> Vec<f64> {
459 px.iter().map(|v| v.max(PRIOR_FLOOR).ln()).collect()
460}
461
462/// The side of a square QAM of order `m` (`None` unless `m` is a perfect square).
463pub fn qam_side(m: usize) -> Option<usize> {
464 let side = (m as f64).sqrt().round() as usize;
465 (side * side == m).then_some(side)
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471
472 #[test]
473 fn energy_with_rejects_too_few_probabilities_instead_of_truncating() {
474 assert!(matches!(Constellation::qam(4).unwrap().energy_with(&[0.5, 0.5]), Err(Error::Shape(_))));
475 }
476
477 #[test]
478 fn normalized_under_treats_unnormalised_priors_as_their_normalisation() {
479 let c = Constellation::qam(16).unwrap();
480 let p: Vec<f64> = c.points().iter().map(|x| (-0.1 * x.norm_sqr()).exp()).collect();
481 let total: f64 = p.iter().sum();
482 let unit: Vec<f64> = p.iter().map(|v| v / total).collect();
483 let a = c.normalized_under(&p).unwrap();
484 let b = c.normalized_under(&unit).unwrap();
485 assert!(a.points().iter().zip(b.points()).all(|(x, y)| (x - y).norm() < 1e-14));
486 assert!((a.energy_with(&unit).unwrap() - 1.0).abs() < 1e-14);
487 // three times the priors: the same constellation, not one of energy 1/3
488 let triple: Vec<f64> = unit.iter().map(|v| 3.0 * v).collect();
489 assert!((c.normalized_under(&triple).unwrap().energy_with(&unit).unwrap() - 1.0).abs() < 1e-14);
490 assert!(matches!(c.normalized_under(&unit[..15]), Err(Error::Shape(_))));
491 assert!(matches!(c.normalized_under(&[-1.0; 16]), Err(Error::Config(_))));
492 }
493
494 #[test]
495 fn the_grid_slicer_agrees_with_the_exhaustive_search() {
496 let mut rng = comnum_core::rng::Rng::new(11);
497 let cs = [
498 Constellation::qam(4).unwrap(),
499 Constellation::qam(16).unwrap().normalized(),
500 Constellation::qam(256).unwrap().scaled(0.37),
501 Constellation::pam(8).unwrap(),
502 Constellation::psk(8).unwrap(),
503 Constellation::from_points(vec![
504 C64::new(0.0, 0.0),
505 C64::new(1.0, 0.2),
506 C64::new(-0.4, 1.0),
507 C64::new(0.3, -0.9),
508 ])
509 .unwrap(),
510 ];
511 let grids = [true, true, true, true, false, false];
512 for (c, grid) in cs.iter().zip(grids) {
513 assert_eq!(c.slicer.is_grid(), grid, "{:?} {}", c.kind(), c.order());
514 let brute = Slicer::exhaustive(c.points());
515 let scale = c.energy().sqrt() * 1.5;
516 for _ in 0..2000 {
517 let z = rng.complex_gaussian() * scale;
518 assert_eq!(c.nearest(z), brute.nearest(z), "{:?} {} at {z}", c.kind(), c.order());
519 }
520 }
521 }
522
523 #[test]
524 fn energies_and_labels() {
525 assert!((Constellation::pam(4).unwrap().energy() - 5.0).abs() < 1e-12);
526 assert!((Constellation::qam(64).unwrap().energy() - 42.0).abs() < 1e-12);
527 assert!((Constellation::psk(8).unwrap().energy() - 1.0).abs() < 1e-12);
528 assert!(Constellation::qam(32).is_err());
529 let c = Constellation::psk(4).unwrap();
530 // natural order 1, j, -1, -j gets the Gray codes 00, 01, 11, 10
531 assert!((c.points()[0b01] - C64::new(0.0, 1.0)).norm() < 1e-12);
532 assert!((c.points()[0b11] - C64::new(-1.0, 0.0)).norm() < 1e-12);
533 }
534}