use comnum_core::{Error, Result, ensure, same_len};
use comnum_math::bits::bits_to_indices;
use comnum_math::nearest::Slicer;
use comnum_math::{C64, cis, gray, index_to_bits};
use std::str::FromStr;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ConstellationKind {
Pam,
Qam,
Psk,
Ook,
Custom,
}
impl FromStr for ConstellationKind {
type Err = Error;
fn from_str(s: &str) -> Result<ConstellationKind> {
match s.to_ascii_lowercase().as_str() {
"pam" => Ok(ConstellationKind::Pam),
"qam" => Ok(ConstellationKind::Qam),
"psk" => Ok(ConstellationKind::Psk),
"ook" => Ok(ConstellationKind::Ook),
other => Err(Error::Config(format!("unknown constellation type {other:?}: pam, qam, psk or ook"))),
}
}
}
impl ConstellationKind {
pub fn name(self) -> &'static str {
match self {
ConstellationKind::Pam => "pam",
ConstellationKind::Qam => "qam",
ConstellationKind::Psk => "psk",
ConstellationKind::Ook => "ook",
ConstellationKind::Custom => "custom",
}
}
pub fn is_real(self) -> bool {
matches!(self, ConstellationKind::Pam | ConstellationKind::Ook)
}
}
pub const MAX_GRAY_BITS: u32 = 24;
pub fn gray_code(n_bits: u32) -> Result<Vec<usize>> {
ensure(n_bits <= MAX_GRAY_BITS, || format!("n_bits must be at most {MAX_GRAY_BITS}, got {n_bits}"))?;
Ok((0..1usize << n_bits).map(gray).collect())
}
pub fn indices_to_bits(indices: &[usize], bits_per_symbol: usize) -> Vec<u8> {
let mut out = Vec::with_capacity(indices.len() * bits_per_symbol);
for &i in indices {
out.extend(index_to_bits(i, bits_per_symbol));
}
out
}
pub fn bpsk(bits: &[u8]) -> Vec<f64> {
bits.iter().map(|b| 1.0 - 2.0 * f64::from(*b)).collect()
}
#[derive(Clone, Debug, PartialEq)]
pub struct Constellation {
kind: ConstellationKind,
points: Vec<C64>,
bits: usize,
slicer: Slicer,
}
impl Constellation {
pub fn new(kind: ConstellationKind, m: usize) -> Result<Constellation> {
ensure(m >= 2 && m.is_power_of_two(), || format!("the order must be a power of two ≥ 2, got {m}"))?;
let natural: Vec<C64> = match kind {
ConstellationKind::Pam => pam_levels(m).into_iter().map(|v| C64::new(v, 0.0)).collect(),
ConstellationKind::Qam => {
let l = qam_side(m).ok_or_else(|| Error::Config(format!("QAM needs a square order, got {m}")))?;
let lv = pam_levels(l);
let mut pts = Vec::with_capacity(m);
for r in 0..l {
let im = lv[l - 1 - r];
let row: Vec<C64> = lv.iter().map(|&re| C64::new(re, im)).collect();
if r % 2 == 1 {
pts.extend(row.into_iter().rev());
} else {
pts.extend(row);
}
}
pts
}
ConstellationKind::Psk => (0..m).map(|k| cis(2.0 * std::f64::consts::PI * k as f64 / m as f64)).collect(),
ConstellationKind::Ook => {
ensure(m == 2, || format!("OOK has two points, got {m}"))?;
vec![C64::new(0.0, 0.0), C64::new(1.0, 0.0)]
}
ConstellationKind::Custom => {
return Err(Error::Config("use Constellation::from_points for a custom constellation".into()));
}
};
let bits = m.trailing_zeros() as usize;
let mut points = vec![C64::new(0.0, 0.0); m];
for (i, p) in natural.into_iter().enumerate() {
points[gray(i)] = p;
}
Ok(Constellation::assemble(kind, points, bits))
}
fn assemble(kind: ConstellationKind, points: Vec<C64>, bits: usize) -> Constellation {
let slicer = Slicer::new(&points);
Constellation { kind, points, bits, slicer }
}
pub fn qam(m: usize) -> Result<Constellation> {
Constellation::new(ConstellationKind::Qam, m)
}
pub fn psk(m: usize) -> Result<Constellation> {
Constellation::new(ConstellationKind::Psk, m)
}
pub fn pam(m: usize) -> Result<Constellation> {
Constellation::new(ConstellationKind::Pam, m)
}
pub fn ook() -> Constellation {
Constellation::new(ConstellationKind::Ook, 2).unwrap_or_else(|_| unreachable!("OOK of order 2 is valid"))
}
pub fn from_name(name: &str) -> Result<Constellation> {
let t = name.to_ascii_lowercase().replace(['-', '_', ' '], "");
let (kind, m) = match t.as_str() {
"qpsk" => (ConstellationKind::Qam, 4),
"bpsk" => (ConstellationKind::Psk, 2),
"ook" => (ConstellationKind::Ook, 2),
_ => {
let digits: String = t.chars().filter(char::is_ascii_digit).collect();
let letters: String = t.chars().filter(|c| !c.is_ascii_digit()).collect();
let m = digits
.parse::<usize>()
.map_err(|_| Error::Config(format!("cannot read the constellation {name:?}")))?;
(letters.parse()?, m)
}
};
Constellation::new(kind, m)
}
pub fn from_points(points: Vec<C64>) -> Result<Constellation> {
let m = points.len();
ensure(m >= 2 && m.is_power_of_two(), || format!("the number of points must be a power of two ≥ 2, got {m}"))?;
Ok(Constellation::assemble(ConstellationKind::Custom, points, m.trailing_zeros() as usize))
}
pub fn set_partitioning(kind: ConstellationKind, m: usize) -> Result<Constellation> {
let points = match (kind, m) {
(ConstellationKind::Psk, _) => {
ensure(m >= 2 && m.is_power_of_two(), || format!("the order must be a power of two ≥ 2, got {m}"))?;
(0..m).map(|k| cis(2.0 * std::f64::consts::PI * k as f64 / m as f64)).collect()
}
(ConstellationKind::Qam, 16) => {
let rows: [[usize; 4]; 4] = [
[0b1001, 0b1100, 0b1101, 0b1000],
[0b1110, 0b1011, 0b1010, 0b1111],
[0b0101, 0b0000, 0b0001, 0b0100],
[0b0010, 0b0111, 0b0110, 0b0011],
];
let mut pts = vec![C64::new(0.0, 0.0); 16];
for (r, row) in rows.iter().enumerate() {
for (c, &label) in row.iter().enumerate() {
pts[label] = C64::new(2.0 * c as f64 - 3.0, 3.0 - 2.0 * r as f64);
}
}
pts
}
_ => return Err(Error::Config(format!("no set-partitioning labelling for {}-{}", m, kind.name()))),
};
Constellation::from_points(points)
}
pub fn kind(&self) -> ConstellationKind {
self.kind
}
pub fn order(&self) -> usize {
self.points.len()
}
pub fn bits_per_symbol(&self) -> usize {
self.bits
}
pub fn points(&self) -> &[C64] {
&self.points
}
pub fn energy(&self) -> f64 {
comnum_math::power(&self.points)
}
pub fn energy_with(&self, px: &[f64]) -> Result<f64> {
same_len(px.len(), self.points.len(), "energy_with: probabilities and points")?;
Ok(self.points.iter().zip(px).map(|(p, w)| w * p.norm_sqr()).sum())
}
pub fn priors(&self, px: Option<&[f64]>) -> Result<Vec<f64>> {
let m = self.order();
Ok(match px {
None => vec![1.0 / m as f64; m],
Some(p) => {
same_len(p.len(), m, "probabilities and constellation points")?;
let total: f64 = p.iter().sum();
ensure(total > 0.0 && p.iter().all(|v| *v >= 0.0), || "the probabilities must be non-negative".into())?;
p.iter().map(|v| v / total).collect()
}
})
}
pub fn normalized_under(&self, px: &[f64]) -> Result<Constellation> {
let es = self.energy_with(&self.priors(Some(px))?)?;
ensure(es > 0.0, || "the constellation has no energy".into())?;
Ok(self.scaled(1.0 / es.sqrt()))
}
pub fn normalized(&self) -> Constellation {
self.scaled(1.0 / self.energy().sqrt())
}
pub fn scaled(&self, s: f64) -> Constellation {
Constellation::assemble(self.kind, self.points.iter().map(|p| p * s).collect(), self.bits)
}
pub fn indices(&self, bits: &[u8]) -> Result<Vec<usize>> {
bits_to_indices(bits, self.bits)
}
pub fn map_bits(&self, bits: &[u8]) -> Result<Vec<C64>> {
Ok(self.indices(bits)?.into_iter().map(|i| self.points[i]).collect())
}
pub fn symbols(&self, indices: &[usize]) -> Vec<C64> {
indices.iter().map(|&i| self.points[i]).collect()
}
pub fn decide(&self, z: &[C64]) -> Vec<usize> {
z.iter().map(|&v| self.nearest(v)).collect()
}
#[inline]
pub fn nearest(&self, z: C64) -> usize {
self.slicer.nearest(z)
}
pub fn project(&self, z: &[C64]) -> Vec<C64> {
z.iter().map(|&v| self.points[self.nearest(v)]).collect()
}
pub fn demap_bits(&self, z: &[C64]) -> Vec<u8> {
indices_to_bits(&self.decide(z), self.bits)
}
}
fn pam_levels(m: usize) -> Vec<f64> {
(0..m).map(|i| 2.0 * i as f64 - (m as f64 - 1.0)).collect()
}
pub const PRIOR_FLOOR: f64 = 1e-300;
pub fn log_priors(px: &[f64]) -> Vec<f64> {
px.iter().map(|v| v.max(PRIOR_FLOOR).ln()).collect()
}
pub fn qam_side(m: usize) -> Option<usize> {
let side = (m as f64).sqrt().round() as usize;
(side * side == m).then_some(side)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn energy_with_rejects_too_few_probabilities_instead_of_truncating() {
assert!(matches!(Constellation::qam(4).unwrap().energy_with(&[0.5, 0.5]), Err(Error::Shape(_))));
}
#[test]
fn normalized_under_treats_unnormalised_priors_as_their_normalisation() {
let c = Constellation::qam(16).unwrap();
let p: Vec<f64> = c.points().iter().map(|x| (-0.1 * x.norm_sqr()).exp()).collect();
let total: f64 = p.iter().sum();
let unit: Vec<f64> = p.iter().map(|v| v / total).collect();
let a = c.normalized_under(&p).unwrap();
let b = c.normalized_under(&unit).unwrap();
assert!(a.points().iter().zip(b.points()).all(|(x, y)| (x - y).norm() < 1e-14));
assert!((a.energy_with(&unit).unwrap() - 1.0).abs() < 1e-14);
let triple: Vec<f64> = unit.iter().map(|v| 3.0 * v).collect();
assert!((c.normalized_under(&triple).unwrap().energy_with(&unit).unwrap() - 1.0).abs() < 1e-14);
assert!(matches!(c.normalized_under(&unit[..15]), Err(Error::Shape(_))));
assert!(matches!(c.normalized_under(&[-1.0; 16]), Err(Error::Config(_))));
}
#[test]
fn the_grid_slicer_agrees_with_the_exhaustive_search() {
let mut rng = comnum_core::rng::Rng::new(11);
let cs = [
Constellation::qam(4).unwrap(),
Constellation::qam(16).unwrap().normalized(),
Constellation::qam(256).unwrap().scaled(0.37),
Constellation::pam(8).unwrap(),
Constellation::psk(8).unwrap(),
Constellation::from_points(vec![
C64::new(0.0, 0.0),
C64::new(1.0, 0.2),
C64::new(-0.4, 1.0),
C64::new(0.3, -0.9),
])
.unwrap(),
];
let grids = [true, true, true, true, false, false];
for (c, grid) in cs.iter().zip(grids) {
assert_eq!(c.slicer.is_grid(), grid, "{:?} {}", c.kind(), c.order());
let brute = Slicer::exhaustive(c.points());
let scale = c.energy().sqrt() * 1.5;
for _ in 0..2000 {
let z = rng.complex_gaussian() * scale;
assert_eq!(c.nearest(z), brute.nearest(z), "{:?} {} at {z}", c.kind(), c.order());
}
}
}
#[test]
fn energies_and_labels() {
assert!((Constellation::pam(4).unwrap().energy() - 5.0).abs() < 1e-12);
assert!((Constellation::qam(64).unwrap().energy() - 42.0).abs() < 1e-12);
assert!((Constellation::psk(8).unwrap().energy() - 1.0).abs() < 1e-12);
assert!(Constellation::qam(32).is_err());
let c = Constellation::psk(4).unwrap();
assert!((c.points()[0b01] - C64::new(0.0, 1.0)).norm() < 1e-12);
assert!((c.points()[0b11] - C64::new(-1.0, 0.0)).norm() < 1e-12);
}
}