Skip to main content

comnum_waveform/ce_ofdm/
gamp.rs

1//! A near-optimum receiver of CE-OFDM by generalised approximate message passing
2//! (GAMP), after M. J. López Morales, R. Dinis and A. García Armada, "Near-optimal
3//! detection of CE-OFDM signals with high power efficiency via GAMP-based receivers,"
4//! *Proc. IEEE Globecom Workshops*, 2022, Algorithm 1.
5//!
6//! # Model
7//!
8//! The real and imaginary parts of the $K$ data symbols of a block form
9//! $\mathbf{u} \in \mathbb{R}^{2K}$, and the OFDM signal is $\mathbf{x} = \mathbf{A}
10//! \mathbf{u}$ with $A_{n,k} = \frac{2}{\sqrt{2K}} \cos\frac{2\pi k n}{N}$ on the real
11//! parts and $-\frac{2}{\sqrt{2K}} \sin\frac{2\pi k n}{N}$ on the imaginary ones (the
12//! modulator of [`super::CeOfdm`]); the receiver sees $r_{n} = e^{j2\pi h x_{n}} + w_{n}$,
13//! $w_{n} \sim \mathcal{CN}(0, \sigma^{2})$. Every row of $|\mathbf{A}|^{2}$ sums to 2
14//! and every column to $N/K$, so scalar variances are exact on average.
15//!
16//! # Algorithm
17//!
18//! Damped sum-product GAMP (S. Rangan, P. Schniter, A. K. Fletcher and S. Sarkar, "On the
19//! convergence of approximate message passing with arbitrary matrices," *IEEE Trans.
20//! Inf. Theory* 65(9), 2019): the output step computes the mean and variance of
21//! $x_{n}$ under the Gaussian message $\mathcal{N}(\hat{p}_{n}, \tau_{p})$ and the
22//! likelihood $e^{-|r_{n} - e^{j2\pi h x}|^{2}/\sigma^{2}} \propto
23//! e^{c_{n}\cos(2\pi h x - \arg r_{n})}$, $c_{n} = 2|r_{n}|/\sigma^{2}$, by the midpoint
24//! rule (eq. (12) of the paper) or, when the likelihood is sharp ($c_{n} \ge 25$), by its
25//! Laplace approximation, a mixture of Gaussians over the branches $\arg r_{n} + 2\pi m$;
26//! the input step is the posterior of the levels of the constellation. The linear steps
27//! are FFTs.
28//!
29//! # Differences with the paper
30//!
31//! The paper estimates the $N M$ bins of a Hermitian-symmetric spectrum as independent
32//! complex symbols; here the unknowns are the $2K$ real coordinates, so the symmetry is
33//! exact and the priors are the per-axis levels of a square QAM (or PAM, BPSK). The
34//! damping is a constant (the paper ties it to $E_{b}/N_{0}$).
35//!
36//! # Size of the system
37//!
38//! GAMP assumes a large, random system. At $2\pi h = 1.4$ and $E_{b}/N_{0} = 4$ dB (QPSK,
39//! $J = 4$), its BER is $1.6 \cdot 10^{-2}$, $4.5 \cdot 10^{-3}$, $1.0 \cdot 10^{-3}$ and
40//! $8.1 \cdot 10^{-4}$ for $K = 63$, 127, 255 and 511 data subcarriers, against
41//! $8.5 \cdot 10^{-4}$ for the optimum asymptotic BER; the paper uses $N = 1024$
42//! ($K = 511$). Structured data, whose blocks have high peaks, can trap it.
43
44use super::{CeOfdm, TAU};
45use comnum_core::{Result, ensure, ensure_shape};
46use comnum_mapping::constellation::Constellation;
47use comnum_math::C64;
48use comnum_mimo::message_passing::{Domain, Prior};
49
50/// The GAMP receiver (see the module).
51#[derive(Clone, Debug, PartialEq)]
52pub struct Gamp {
53    /// Iterations per block.
54    pub iterations: usize,
55    /// Damping factor $\beta \in (0, 1]$ (1: no damping).
56    pub damping: f64,
57    /// Points of the midpoint rule of the output step.
58    pub points: usize,
59}
60
61impl Default for Gamp {
62    /// 50 iterations (as the paper), a damping of 0.5 and 64 points.
63    fn default() -> Gamp {
64        Gamp { iterations: 50, damping: 0.5, points: 64 }
65    }
66}
67
68/// The equally likely levels of the real and imaginary parts of a constellation that is
69/// their Cartesian product, as the real priors of message passing.
70fn axis_levels(c: &Constellation) -> Result<(Prior, Prior)> {
71    let unique = |values: Vec<f64>| {
72        let mut v = values;
73        v.sort_by(f64::total_cmp);
74        v.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
75        v
76    };
77    let re = unique(c.points().iter().map(|p| p.re).collect());
78    let im = unique(c.points().iter().map(|p| p.im).collect());
79    let near = |levels: &[f64], v: f64| levels.iter().any(|l| (l - v).abs() < 1e-9);
80    let product = re.len() * im.len() == c.order() && c.points().iter().all(|p| near(&re, p.re) && near(&im, p.im));
81    ensure(product, || {
82        "GAMP needs a constellation whose real and imaginary parts are independent (square QAM, PAM, BPSK)".into()
83    })?;
84    let prior = |levels: Vec<f64>| {
85        let m = levels.len();
86        Prior::Discrete {
87            points: levels.into_iter().map(|l| C64::new(l, 0.0)).collect(),
88            probs: vec![1.0 / m as f64; m],
89        }
90    };
91    Ok((prior(re), prior(im)))
92}
93
94/// The mean and variance of a real level of `prior` under the Gaussian message
95/// `N(r, tr)` ([`Prior::denoise`] of a real model).
96fn input_moments(prior: &Prior, r: f64, tr: f64) -> (f64, f64) {
97    let (m, v) = prior.denoise(C64::new(r, 0.0), tr, Domain::Real);
98    (m.re, v)
99}
100
101/// The concentration $c$ of the likelihood from which its branches are Gaussian
102/// (Laplace's approximation) instead of integrated numerically.
103const LAPLACE_CONCENTRATION: f64 = 25.0;
104
105/// The half-width, in standard deviations of the message and the branch, of the window
106/// of branches summed by Laplace's approximation.
107const LAPLACE_SPREAD: f64 = 8.0;
108
109/// The half-width, in standard deviations of the message, of the midpoint rule (the
110/// Gaussian beyond holds $2Q(7) = 2.6 \cdot 10^{-12}$ of it).
111const QUADRATURE_HALF_WIDTH: f64 = 7.0;
112
113/// The points of the midpoint rule per width of a branch of the likelihood.
114const POINTS_PER_BRANCH: f64 = 4.0;
115
116/// The largest number of points of the midpoint rule.
117const MAX_QUADRATURE_POINTS: usize = 4096;
118
119/// The mean and variance of `x` under the message `N(p, tp)` and the likelihood
120/// `exp(c cos(κx - ψ))` (see the module).
121fn output_moments(p: f64, tp: f64, c: f64, psi: f64, kappa: f64, points: usize) -> (f64, f64) {
122    let sd = tp.sqrt();
123    if c <= 0.0 {
124        return (p, tp);
125    }
126    // width of one branch of the likelihood
127    let wl = 1.0 / (kappa * c.sqrt());
128    if c >= LAPLACE_CONCENTRATION {
129        // Laplace: a Gaussian of variance v around every branch (ψ + 2πm)/κ
130        let v = wl * wl;
131        let spread = LAPLACE_SPREAD * (tp + v).sqrt();
132        let m_lo = ((kappa * (p - spread) - psi) / TAU).floor() as i64;
133        let m_hi = ((kappa * (p + spread) - psi) / TAU).ceil() as i64;
134        let (mut s0, mut s1, mut s2) = (0.0, 0.0, 0.0);
135        let mut terms = Vec::with_capacity((m_hi - m_lo + 1) as usize);
136        for m in m_lo..=m_hi {
137            let mu = (psi + TAU * m as f64) / kappa;
138            terms.push((mu, -(mu - p).powi(2) / (2.0 * (tp + v))));
139        }
140        let top = terms.iter().map(|t| t.1).fold(f64::NEG_INFINITY, f64::max);
141        let var = tp * v / (tp + v);
142        for (mu, lw) in terms {
143            let w = (lw - top).exp();
144            let mean = (mu * tp + p * v) / (tp + v);
145            s0 += w;
146            s1 += w * mean;
147            s2 += w * (var + mean * mean);
148        }
149        let mean = s1 / s0;
150        return (mean, (s2 / s0 - mean * mean).max(0.0));
151    }
152    // the midpoint rule over ±7 standard deviations of the message, fine enough for the
153    // likelihood
154    let span = 2.0 * QUADRATURE_HALF_WIDTH * sd;
155    let step = (span / points as f64).min(wl / POINTS_PER_BRANCH);
156    let n = ((span / step).ceil() as usize).clamp(points, MAX_QUADRATURE_POINTS);
157    let h = span / n as f64;
158    let z0 = p - QUADRATURE_HALF_WIDTH * sd;
159    let logw = |z: f64| -(z - p).powi(2) / (2.0 * tp) + c * (kappa * z - psi).cos();
160    let top = (0..n).map(|i| logw(z0 + (i as f64 + 0.5) * h)).fold(f64::NEG_INFINITY, f64::max);
161    let (mut s0, mut s1, mut s2) = (0.0, 0.0, 0.0);
162    for i in 0..n {
163        let z = z0 + (i as f64 + 0.5) * h;
164        let w = (logw(z) - top).exp();
165        s0 += w;
166        s1 += w * z;
167        s2 += w * z * z;
168    }
169    let mean = s1 / s0;
170    (mean, (s2 / s0 - mean * mean).max(0.0))
171}
172
173impl Gamp {
174    /// The posterior means of the data symbols of every block (prefix removed) of the
175    /// received samples `r`, for symbols of the constellation `c` (as transmitted) and the
176    /// noise variance per sample `noise_var` ([`CeOfdm::noise_variance`]).
177    ///
178    /// # Errors
179    /// [`comnum_core::Error::Config`] for invalid parameters or a constellation whose real
180    /// and imaginary parts are not independent, [`comnum_core::Error::Shape`] for a number
181    /// of samples that is not a multiple of `N + N_cp`.
182    ///
183    /// # Examples
184    /// ```
185    /// use comnum_waveform::ce_ofdm::{CeOfdm, gamp::Gamp};
186    /// use comnum_mapping::constellation::Constellation;
187    /// use comnum_core::rng::Rng;
188    /// // 2πh = 1.4: the phase leaves (-π, π] and the arctangent receiver fails
189    /// let ce = CeOfdm::new(63, 4, 1.4 / std::f64::consts::TAU).unwrap();
190    /// let c = Constellation::qam(4).unwrap().normalized();
191    /// let (_, d) = comnum_mapping::source::symbols(&c, 63, None, &mut Rng::new(1)).unwrap();
192    /// let r = ce.modulate(&d).unwrap();
193    /// let e = Gamp::default().detect(&ce, &r, &c, 1e-4).unwrap();
194    /// assert_eq!(c.decide(&e), c.decide(&d));
195    /// ```
196    pub fn detect(&self, ce: &CeOfdm, r: &[C64], c: &Constellation, noise_var: f64) -> Result<Vec<C64>> {
197        ce.check()?;
198        ensure(self.iterations >= 1 && self.points >= 2, || "GAMP needs one iteration and two points".into())?;
199        ensure(self.damping > 0.0 && self.damping <= 1.0, || {
200            format!("the damping must lie in (0, 1], got {}", self.damping)
201        })?;
202        ensure(noise_var > 0.0, || format!("the noise variance must be positive, got {noise_var}"))?;
203        let (k, l) = (ce.n_subcarriers, ce.block_len());
204        ensure_shape(r.len() % l == 0, || format!("{} samples is not a multiple of N + N_cp = {l}", r.len()))?;
205        let (re, im) = axis_levels(c)?;
206        let mut out = Vec::with_capacity(r.len() / l * k);
207        for block in r.chunks(l) {
208            out.extend(self.block(ce, comnum_dsp::signal::strip_prefix(block, ce.n_cp)?, &re, &im, noise_var));
209        }
210        Ok(out)
211    }
212
213    fn block(&self, ce: &CeOfdm, r: &[C64], re: &Prior, im: &Prior, noise_var: f64) -> Vec<C64> {
214        let (k, n) = (ce.n_subcarriers, ce.n_fft());
215        let kappa = TAU * ce.h;
216        let a = 2.0 / (2.0 * k as f64).sqrt();
217        let beta = self.damping;
218        // A u is the modulator's OFDM signal; Aᵀ y the data bins of the DFT, scaled
219        let forward = |u: &[C64]| ce.synthesize(u);
220        let adjoint = |y: &[f64]| -> Vec<C64> { ce.data_bins(y).into_iter().map(|v| v * a).collect() };
221        let mut u_hat = vec![C64::new(re.mean().re, im.mean().re); k];
222        let mut u_bar = u_hat.clone();
223        let prior_var = re.variance() + im.variance();
224        let mut tu = prior_var / 2.0;
225        let mut s_hat = vec![0.0; n];
226        let mut ts = 0.0;
227        let likelihood: Vec<(f64, f64)> = r.iter().map(|v| (2.0 * v.norm() / noise_var, v.arg())).collect();
228        for t in 0..self.iterations {
229            // output linear and nonlinear steps
230            let tp = 2.0 * tu;
231            let au = forward(&u_hat);
232            let mut ts_new = 0.0;
233            for (i, (&(cn, psi), x)) in likelihood.iter().zip(&au).enumerate() {
234                let p = x - tp * s_hat[i];
235                let (z, tz) = output_moments(p, tp, cn, psi, kappa, self.points);
236                let s = (z - p) / tp;
237                s_hat[i] = if t == 0 { s } else { (1.0 - beta) * s_hat[i] + beta * s };
238                // a posterior variance above the prior one is a rounding error
239                ts_new += (1.0 - tz / tp).max(0.0) / tp;
240            }
241            ts_new /= n as f64;
242            ts = if t == 0 { ts_new } else { (1.0 - beta) * ts + beta * ts_new };
243            // input linear and nonlinear steps
244            for (b, h) in u_bar.iter_mut().zip(&u_hat) {
245                *b = if t == 0 { *h } else { (1.0 - beta) * *b + beta * h };
246            }
247            let tr = k as f64 / (n as f64 * ts.max(1e-300));
248            let back = adjoint(&s_hat);
249            let mut next = Vec::with_capacity(k);
250            let mut tu_new = 0.0;
251            for (b, g) in u_bar.iter().zip(&back) {
252                let rv = b + g * tr;
253                let (mr, vr) = input_moments(re, rv.re, tr);
254                let (mi, vi) = input_moments(im, rv.im, tr);
255                next.push(C64::new(mr, mi));
256                tu_new += vr + vi;
257            }
258            if next.iter().any(|v| !v.is_finite()) {
259                break;
260            }
261            u_hat = next;
262            tu = tu_new / (2 * k) as f64;
263            // the decisions are firm: the variances would vanish and the steps divide by them
264            if tu < 1e-9 * prior_var {
265                break;
266            }
267        }
268        u_hat
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use crate::ce_ofdm::{Receiver, Unwrap};
276    use comnum_core::rng::Rng;
277
278    #[test]
279    fn the_output_step_matches_the_quadrature_and_the_input_step_the_levels() {
280        // Laplace (sharp likelihood) against a fine midpoint rule
281        for psi in [0.5, 2.0, 3.0, -3.1] {
282            let (m1, v1) = output_moments(0.3, 0.8, 30.0, psi, 1.4, 64);
283            let (sd, n) = (0.8f64.sqrt(), 400_000);
284            let h = 16.0 * sd / n as f64;
285            let (mut s0, mut s1, mut s2) = (0.0, 0.0, 0.0);
286            for i in 0..n {
287                let z = 0.3 - 8.0 * sd + (i as f64 + 0.5) * h;
288                let w = (-(z - 0.3).powi(2) / 1.6 + 30.0 * ((1.4 * z - psi).cos() - 1.0)).exp();
289                (s0, s1, s2) = (s0 + w, s1 + w * z, s2 + w * z * z);
290            }
291            let (m2, v2) = (s1 / s0, s2 / s0 - (s1 / s0).powi(2));
292            // the Laplace approximation errs by O(1/c)
293            assert!((m1 - m2).abs() < 0.05 && (v1 - v2).abs() < 0.05 * v2.max(0.01), "{psi}: {m1} {v1} vs {m2} {v2}");
294        }
295        // a flat likelihood leaves the message
296        assert_eq!(output_moments(0.2, 0.5, 0.0, 1.0, 1.0, 64), (0.2, 0.5));
297        let (bpsk, _) = axis_levels(&Constellation::psk(2).unwrap()).unwrap();
298        assert!(bpsk.mean().norm() < 1e-15 && (bpsk.variance() - 1.0).abs() < 1e-15);
299        let (m, v) = input_moments(&bpsk, 0.9, 0.01);
300        assert!((m - 1.0).abs() < 1e-6 && v < 1e-6);
301    }
302
303    #[test]
304    fn gamp_detects_where_the_phase_demodulator_fails() {
305        // 2πh = 1.4 (López Morales et al. 2022, Fig. 3): the phase wraps, the arctangent
306        // receiver floors at a SER of 0.1 and more, GAMP decodes
307        let c = Constellation::qam(4).unwrap().normalized();
308        let ce = CeOfdm::new(63, 4, 1.4 / TAU).unwrap();
309        let mut rng = Rng::new(3);
310        let (_, d) = comnum_mapping::source::symbols(&c, 63 * 8, None, &mut rng).unwrap();
311        for ebn0 in [8.0, 30.0] {
312            let sigma2 = ce.noise_variance(ebn0 + comnum_math::lin2db(2.0));
313            let r: Vec<C64> =
314                ce.modulate(&d).unwrap().into_iter().map(|v| v + rng.complex_gaussian() * sigma2.sqrt()).collect();
315            let ser = |e: &[C64]| {
316                c.decide(e).iter().zip(c.decide(&d)).filter(|(a, b)| *a != b).count() as f64 / d.len() as f64
317            };
318            let gamp = Gamp::default().detect(&ce, &r, &c, sigma2).unwrap();
319            let atan = ce.demodulate_with_receiver(&r, Receiver::Arctangent(Unwrap::None)).unwrap();
320            assert!(ser(&gamp) == 0.0 && ser(&atan) > 0.1, "{ebn0} dB: GAMP {} arctangent {}", ser(&gamp), ser(&atan));
321        }
322        // a constellation that is not a product of its axes is refused
323        let psk8 = Constellation::psk(8).unwrap();
324        assert!(Gamp::default().detect(&ce, &vec![C64::new(1.0, 0.0); ce.block_len()], &psk8, 0.1).is_err());
325    }
326}