Skip to main content

comnum_waveform/ce_ofdm/
mod.rs

1//! Constant-envelope OFDM (CE-OFDM): the modulator, the phase demodulator and the
2//! error rate of its linear regime, after S. C. Thompson, A. U. Ahmed, J. G. Proakis,
3//! J. R. Zeidler and M. J. Geile, "Constant envelope OFDM," *IEEE Trans. Commun.*
4//! 56(8), 2008.
5//!
6//! # Model (matrix form)
7//!
8//! A block carries $K$ data symbols $\mathbf{d} \in \mathbb{C}^{K}$ (unit mean energy)
9//! on the subcarriers $1..K$ of a DFT of size $N = 2J(K + 1)$, with $J \ge 1$ the
10//! **oversampling factor** ($J = 1$ is the smallest DFT that keeps the bins $0$ and
11//! $K + 1$ empty). With $\mathbf{F} \in \mathbb{C}^{N \times K}$,
12//! $F_{nk} = e^{j2\pi kn/N}$, the real OFDM signal and the transmitted samples are
13//!
14//! $$\mathbf{x} = \frac{\mathbf{F}\mathbf{d} + \mathbf{F}^{*}\mathbf{d}^{*}}{\sqrt{2K}}\,,
15//! \qquad s_{n} = e^{j 2\pi h x_{n}}\,,$$
16//!
17//! so that $\mathbb{E}[\mathbf{x}\mathbf{x}^{T}]$ is circulant with a unit diagonal and
18//! the phase $\varphi = 2\pi h x$ has the standard deviation $2\pi h$, $h$ being the
19//! **modulation index** (Sec. II of the paper). An optional cyclic prefix of
20//! $N_{\mathrm{cp}}$ samples is prepended. The channel adds
21//! $\mathbf{w} \sim \mathcal{CN}(0, \sigma^{2}\mathbf{I})$.
22//!
23//! The receiver (Sec. III, Fig. 1 of the paper) takes the phase $\theta = \arg r$,
24//! optionally unwraps it along the stream ([`Unwrap::Stream`], the phase unwrapper of
25//! the paper), removes the prefix and projects on the data bins:
26//!
27//! $$\hat{\mathbf{d}} = \frac{\mathbf{G}\boldsymbol{\psi}}{2\pi h}\,, \qquad
28//! \mathbf{G} = \frac{\sqrt{2K}}{N}\mathbf{F}^{H}$$
29//!
30//! ($\mathbf{G}\mathbf{x} = \mathbf{d}$ because $\mathbf{F}^{H}\mathbf{F} = N\mathbf{I}$
31//! and $\mathbf{F}^{H}\mathbf{F}^{*} = \mathbf{0}$).
32//!
33//! # SNR convention
34//!
35//! The SNR is $E_{s}/N_{0}$ **per data symbol**: a block of energy $N + N_{\mathrm{cp}}$
36//! (unit envelope) carries $K$ symbols, so
37//! $\sigma^{2} = (N + N_{\mathrm{cp}})/(K \cdot E_{s}/N_{0})$. The results of the linear
38//! regime are then independent of the oversampling factor.
39//!
40//! # Theory of the linear regime
41//!
42//! Above the threshold of the phase demodulator (a carrier-to-noise ratio of about
43//! 10 dB, Sec. III-B of the paper), the phase error
44//! $\varepsilon = \operatorname{Im}(w e^{-j\varphi}) \sim \mathcal{N}(0, \sigma^{2}/2)$
45//! and $\hat{\mathbf{d}} = \mathbf{d} + \mathbf{G}\boldsymbol{\varepsilon}/(2\pi h)$, so
46//! every data subcarrier sees the SNR
47//!
48//! $$\mathrm{SNR} = (2\pi h)^{2}\,\frac{E_{s}}{N_{0}}\,\frac{N}{N + N_{\mathrm{cp}}}\,:$$
49//!
50//! the error rates are those of the constellation over AWGN with the SNR multiplied by
51//! $(2\pi h)^{2}$ ([`theory::snr`], [`theory::ser_esn0`], [`theory::ber_esn0`]). For $M$-PAM data
52//! on every real subcarrier this is eq. (15) of the paper, which keeps the
53//! nearest-neighbour term of the Gray BER; the library uses the exact Gray BER of the
54//! square QAM whose real and imaginary parts are the two PAM symbols (checked against
55//! eq. (15) by the test `literature`). Below the threshold the demodulator fails and
56//! the error rate stays above this theory, as in Fig. 2 of the paper.
57//!
58//! # Receivers
59//!
60//! [`CeOfdm::demodulate_with_receiver`] takes a [`Receiver`]: the arctangent phase demodulator
61//! above, or the low-complexity receivers of Ahmed and Zeidler (2015), which replace
62//! $\arg r$ by $\operatorname{Im} r$ (basic linear receiver) or by
63//! $\operatorname{Im} r \\, (2 - \operatorname{Re} r)$ (enhanced receiver) and avoid the
64//! threshold and the wraps of the phase at the price of a distortion. [`gamp::Gamp`] is
65//! the near-optimum receiver of López Morales et al. (2022), by message passing, which
66//! decodes beyond $2\pi h \approx 0.7$ where the phase leaves $(-\pi, \pi]$.
67//! [`theory`] gives the error rates of the receivers, the spectrum, the optimum
68//! asymptotic gain and the effect of a cycle slip.
69//!
70//! # Examples
71//! ```
72//! use comnum_waveform::ce_ofdm::{CeOfdm, Receiver, Unwrap};
73//! use comnum_mapping::constellation::Constellation;
74//! use comnum_core::rng::Rng;
75//! let ce = CeOfdm::new(16, 2, 0.1).unwrap();
76//! let c = Constellation::qam(16).unwrap().normalized();
77//! let (_, d) = comnum_mapping::source::symbols(&c, 64, None, &mut Rng::new(1)).unwrap();
78//! let s = ce.modulate(&d).unwrap();
79//! assert_eq!(s.len(), 4 * ce.n_fft());
80//! assert!(s.iter().all(|v| (v.norm() - 1.0).abs() < 1e-12));          // constant envelope
81//! let e = ce.demodulate_with_receiver(&s, Receiver::Arctangent(Unwrap::None)).unwrap();
82//! assert!(e.iter().zip(&d).all(|(a, b)| (a - b).norm() < 1e-9));
83//! ```
84
85use crate::{check_input, check_rate};
86use comnum_core::signal::{Signal, Symbols};
87use comnum_core::traits::{Demodulate, Modulate};
88use comnum_core::units::Hertz;
89use comnum_core::{Result, ensure, ensure_shape};
90use comnum_dsp::signal::{extend_with_prefix, strip_prefix};
91use comnum_dsp::sync::carrier;
92use comnum_math::fft;
93use comnum_math::{C64, cis, db2lin};
94use std::f64::consts::TAU;
95
96/// The phase processing of the receiver before the DFT.
97#[non_exhaustive]
98#[derive(Clone, Copy, Debug, PartialEq, Eq)]
99pub enum Unwrap {
100    /// The principal phase `arg r ∈ (-π, π]` (phase wraps are errors).
101    None,
102    /// The phase unwrapped along the whole stream, the phase unwrapper of Thompson et
103    /// al. (jumps of the increments above `π` are errors).
104    Stream,
105}
106
107impl std::str::FromStr for Unwrap {
108    type Err = comnum_core::Error;
109
110    /// `none` or `stream` (case-insensitive).
111    ///
112    /// # Errors
113    /// [`comnum_core::Error::Config`] for another name.
114    fn from_str(s: &str) -> Result<Unwrap> {
115        match s.to_ascii_lowercase().as_str() {
116            "none" => Ok(Unwrap::None),
117            "stream" => Ok(Unwrap::Stream),
118            _ => Err(comnum_core::Error::Config(format!("unwrap must be none or stream, got {s:?}"))),
119        }
120    }
121}
122
123/// The receiver: how the samples become a proxy of the phase before the DFT.
124#[non_exhaustive]
125#[derive(Clone, Copy, Debug, PartialEq, Eq)]
126pub enum Receiver {
127    /// The arctangent phase demodulator, $\arg r$, with the given unwrapping (Thompson et
128    /// al. 2008, Fig. 1; Ahmed et al. 2007, Fig. 6).
129    Arctangent(Unwrap),
130    /// The basic linear receiver of A. U. Ahmed and J. R. Zeidler, "Novel low-complexity
131    /// receivers for constant envelope OFDM," *IEEE Trans. Signal Process.* 63(17), 2015,
132    /// Fig. 4: the quadrature component $\operatorname{Im} r = \sin\varphi + \ldots$,
133    /// the first term of its Taylor series in $\varphi$.
134    Linear,
135    /// The enhanced receiver of Ahmed and Zeidler (2015), Fig. 5 and eq. (8):
136    /// $\operatorname{Im} r \\, (2 - \operatorname{Re} r) = \varphi + \varphi^{3}/3 + \ldots$,
137    /// whose cubic term adds to the signal.
138    Enhanced,
139}
140
141impl Default for Receiver {
142    /// The arctangent demodulator of the principal phase.
143    fn default() -> Receiver {
144        Receiver::Arctangent(Unwrap::None)
145    }
146}
147
148impl std::str::FromStr for Receiver {
149    type Err = comnum_core::Error;
150
151    /// `arctangent` (with the principal phase), `linear` or `enhanced`
152    /// (case-insensitive).
153    ///
154    /// # Errors
155    /// [`comnum_core::Error::Config`] for another name.
156    fn from_str(s: &str) -> Result<Receiver> {
157        match s.to_ascii_lowercase().as_str() {
158            "arctangent" | "atan" => Ok(Receiver::Arctangent(Unwrap::None)),
159            "linear" => Ok(Receiver::Linear),
160            "enhanced" => Ok(Receiver::Enhanced),
161            _ => Err(comnum_core::Error::Config(format!(
162                "the receiver must be arctangent, linear or enhanced, got {s:?}"
163            ))),
164        }
165    }
166}
167
168/// A CE-OFDM link (see the module documentation).
169#[derive(Clone, Debug, PartialEq)]
170pub struct CeOfdm {
171    /// Data subcarriers `K` per block.
172    pub n_subcarriers: usize,
173    /// Oversampling factor `J`: the DFT has `N = 2J(K + 1)` points.
174    pub oversampling: usize,
175    /// Modulation index `h` (phase standard deviation `2πh`).
176    pub h: f64,
177    /// Cyclic prefix, in samples of the DFT of $N$ points (the oversampling is already in
178    /// $N$, unlike [`crate::ofdm::Ofdm::n_cp`], which counts Nyquist-rate samples).
179    pub n_cp: usize,
180    /// The receiver of [`Demodulate`] ([`CeOfdm::demodulate_with_receiver`]); the arctangent
181    /// demodulator of the principal phase by default.
182    pub receiver: Receiver,
183    /// The rate of the samples (one per point of the DFT of $N$): the rate of the
184    /// [`Signal`] of [`Modulate`] and the rate [`Demodulate`] expects; 1 Hz by default
185    /// (time in samples).
186    pub sample_rate: Hertz,
187}
188
189impl CeOfdm {
190    /// The DFT size `N = 2J(K + 1)`.
191    pub fn n_fft(&self) -> usize {
192        2 * self.oversampling * (self.n_subcarriers + 1)
193    }
194
195    /// Samples per block, `N + N_cp`.
196    pub fn block_len(&self) -> usize {
197        self.n_fft() + self.n_cp
198    }
199
200    /// Samples per data symbol, `(N + N_cp)/K`.
201    pub fn sps(&self) -> f64 {
202        self.block_len() as f64 / self.n_subcarriers as f64
203    }
204
205    /// Noise variance per sample for an `E_s/N_0` (dB) per data symbol and a unit
206    /// envelope: `σ² = (N + N_cp)/(K E_s/N_0)`.
207    pub fn noise_variance(&self, esn0_db: f64) -> f64 {
208        self.sps() / db2lin(esn0_db)
209    }
210
211    /// `K` data subcarriers, oversampling `J`, modulation index `h`, no prefix, the default
212    /// receiver, at the sample rate 1 Hz.
213    ///
214    /// # Errors
215    /// [`comnum_core::Error::Config`] for invalid parameters (see [`CeOfdm::check`]).
216    ///
217    /// # Examples
218    /// ```
219    /// use comnum_waveform::ce_ofdm::CeOfdm;
220    /// let ce = CeOfdm { n_cp: 8, ..CeOfdm::new(16, 2, 0.1)? };
221    /// assert_eq!(ce.block_len(), 2 * 2 * 17 + 8);
222    /// # Ok::<(), comnum_core::Error>(())
223    /// ```
224    pub fn new(n_subcarriers: usize, oversampling: usize, h: f64) -> Result<CeOfdm> {
225        let ce =
226            CeOfdm { n_subcarriers, oversampling, h, n_cp: 0, receiver: Receiver::default(), sample_rate: Hertz(1.0) };
227        ce.check()?;
228        Ok(ce)
229    }
230
231    /// Validate the configuration.
232    ///
233    /// # Errors
234    /// [`comnum_core::Error::Config`] unless `K ≥ 1`, `J ≥ 1`, `h > 0`, the prefix is at
235    /// most one block of `N` samples and the sample rate is positive and finite.
236    pub fn check(&self) -> Result<()> {
237        ensure(self.n_subcarriers >= 1 && self.oversampling >= 1, || "CE-OFDM needs K ≥ 1 and J ≥ 1".into())?;
238        ensure(self.h > 0.0 && self.h.is_finite(), || {
239            format!("the modulation index must be positive, got {}", self.h)
240        })?;
241        ensure(self.n_cp <= self.n_fft(), || {
242            format!("a prefix of {} samples is longer than the block of {}", self.n_cp, self.n_fft())
243        })?;
244        check_rate(self.sample_rate)
245    }
246
247    /// The real OFDM signal `x = (F d + F* d*)/√(2K)` of every block (no prefix).
248    ///
249    /// # Errors
250    /// [`comnum_core::Error::Config`] for invalid parameters, [`comnum_core::Error::Shape`] for
251    /// a number of symbols that is not a multiple of `K`.
252    pub fn ofdm_signal(&self, d: &[C64]) -> Result<Vec<f64>> {
253        self.check()?;
254        let (k, n) = (self.n_subcarriers, self.n_fft());
255        ensure_shape(d.len() % k == 0, || format!("{} symbols is not a multiple of K = {k}", d.len()))?;
256        let mut out = Vec::with_capacity(d.len() / k * n);
257        for block in d.chunks(k) {
258            out.extend(self.synthesize(block));
259        }
260        Ok(out)
261    }
262
263    /// The real OFDM signal `x = (F d + F* d*)/√(2K)` of one block of `K` symbols.
264    fn synthesize(&self, d: &[C64]) -> Vec<f64> {
265        let (k, n) = (self.n_subcarriers, self.n_fft());
266        let mut spec = vec![C64::new(0.0, 0.0); n];
267        for (i, &v) in d.iter().enumerate() {
268            spec[i + 1] = v;
269            spec[n - 1 - i] = v.conj();
270        }
271        // ifft divides by N: x = N/√(2K) · ifft
272        let scale = n as f64 / (2.0 * k as f64).sqrt();
273        fft::ifft(&spec).into_iter().map(|v| v.re * scale).collect()
274    }
275
276    /// The DFT bins `1 … K` of one block of `N` real samples: `F^H x` on the data
277    /// subcarriers.
278    fn data_bins(&self, x: &[f64]) -> Vec<C64> {
279        let spec = fft::fft(&x.iter().map(|&v| C64::new(v, 0.0)).collect::<Vec<_>>());
280        spec[1..=self.n_subcarriers].to_vec()
281    }
282
283    /// The transmitted samples `exp(j2πh x)`, with the cyclic prefix of every block.
284    ///
285    /// # Errors
286    /// As [`CeOfdm::ofdm_signal`].
287    pub fn modulate(&self, d: &[C64]) -> Result<Vec<C64>> {
288        let x = self.ofdm_signal(d)?;
289        let n = self.n_fft();
290        let g = TAU * self.h;
291        let mut out = Vec::with_capacity(x.len() / n * self.block_len());
292        for block in x.chunks(n) {
293            let s: Vec<C64> = block.iter().map(|&v| cis(g * v)).collect();
294            extend_with_prefix(&mut out, &s, self.n_cp)?;
295        }
296        Ok(out)
297    }
298
299    /// The symbol estimates of every block with the receiver: the projection of its proxy
300    /// of the phase on the data bins, divided by $2\pi h$ and by Bussgang's gain of the
301    /// receiver ([`theory::receiver_gain`]), so that the estimates are unbiased. With
302    /// [`Receiver::Arctangent`], the estimates `d̂ = G ψ/(2πh)` of the arctangent
303    /// demodulator with its unwrapping.
304    ///
305    /// # Errors
306    /// [`comnum_core::Error::Config`] for invalid parameters, [`comnum_core::Error::Shape`] for
307    /// a number of samples that is not a multiple of `N + N_cp`.
308    ///
309    /// # Examples
310    /// ```
311    /// use comnum_waveform::ce_ofdm::{CeOfdm, Receiver};
312    /// use comnum_mapping::constellation::Constellation;
313    /// use comnum_core::rng::Rng;
314    /// let ce = CeOfdm::new(64, 2, 0.02).unwrap();
315    /// let c = Constellation::qam(16).unwrap().normalized();
316    /// let (_, d) = comnum_mapping::source::symbols(&c, 64, None, &mut Rng::new(1)).unwrap();
317    /// // without noise the linear receivers err only by their distortion, small at a small h
318    /// let e = ce.demodulate_with_receiver(&ce.modulate(&d).unwrap(), Receiver::Enhanced).unwrap();
319    /// assert_eq!(c.decide(&e), c.decide(&d));
320    /// ```
321    pub fn demodulate_with_receiver(&self, r: &[C64], receiver: Receiver) -> Result<Vec<C64>> {
322        self.check_block(r.len())?;
323        let (proxy, gain): (Vec<f64>, f64) = match receiver {
324            Receiver::Arctangent(unwrap) => (self.received_phase(r, unwrap), 1.0),
325            Receiver::Linear => (r.iter().map(|v| v.im).collect(), theory::receiver_gain(self, receiver)),
326            Receiver::Enhanced => {
327                (r.iter().map(|v| v.im * (2.0 - v.re)).collect(), theory::receiver_gain(self, receiver))
328            }
329        };
330        self.project_checked(&proxy, gain)
331    }
332
333    /// `Ok` for a valid configuration and `len` samples of whole blocks.
334    fn check_block(&self, len: usize) -> Result<()> {
335        self.check()?;
336        let l = self.block_len();
337        ensure_shape(len % l == 0, || format!("{len} samples is not a multiple of N + N_cp = {l}"))
338    }
339
340    /// The projection `G ψ/(2πh g)` of the per-sample proxy `ψ` of every block.
341    #[cfg(test)]
342    fn project(&self, psi: &[f64], gain: f64) -> Result<Vec<C64>> {
343        self.check_block(psi.len())?;
344        self.project_checked(psi, gain)
345    }
346
347    /// [`CeOfdm::project`] of a configuration and a length already checked.
348    fn project_checked(&self, psi: &[f64], gain: f64) -> Result<Vec<C64>> {
349        let (k, n, l) = (self.n_subcarriers, self.n_fft(), self.block_len());
350        let scale = (2.0 * k as f64).sqrt() / (n as f64 * TAU * self.h * gain);
351        let mut out = Vec::with_capacity(psi.len() / l * k);
352        for block in psi.chunks(l) {
353            out.extend(self.data_bins(strip_prefix(block, self.n_cp)?).into_iter().map(|v| v * scale));
354        }
355        Ok(out)
356    }
357
358    /// The phase of the receiver before the DFT, prefix included: the principal phase
359    /// `arg r` or the phase unwrapped along the stream.
360    pub fn received_phase(&self, r: &[C64], unwrap: Unwrap) -> Vec<f64> {
361        let theta: Vec<f64> = r.iter().map(|v| v.arg()).collect();
362        match unwrap {
363            Unwrap::None => theta,
364            Unwrap::Stream => carrier::unwrap(&theta, TAU),
365        }
366    }
367}
368
369/// CE-OFDM as a [`Modulate`] block: one stream of data symbols into one stream of
370/// samples at [`CeOfdm::sample_rate`].
371impl Modulate for CeOfdm {
372    fn modulate_into(&self, x: &Symbols, out: &mut Signal) -> Result<()> {
373        out.set_stream(&CeOfdm::modulate(self, crate::one_stream(x, "CE-OFDM")?)?, self.sample_rate);
374        Ok(())
375    }
376}
377
378/// CE-OFDM as a [`Demodulate`] block: one stream of samples at [`CeOfdm::sample_rate`],
379/// demodulated by [`CeOfdm::receiver`].
380impl Demodulate for CeOfdm {
381    /// # Errors
382    /// [`comnum_core::Error::Config`] for several streams or a signal at another sample rate,
383    /// and the errors of [`CeOfdm::demodulate_with_receiver`].
384    fn demodulate_into(&mut self, y: &Signal, out: &mut Symbols) -> Result<()> {
385        check_input(y, self.sample_rate, "CE-OFDM")?;
386        out.set_stream(&self.demodulate_with_receiver(y.stream(0), self.receiver)?);
387        Ok(())
388    }
389}
390
391pub mod gamp;
392pub mod theory;
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use comnum_core::rng::Rng;
398    use comnum_mapping::constellation::{Constellation, ConstellationKind};
399
400    #[test]
401    fn the_traits_use_the_receiver_and_the_rate_of_the_configuration() {
402        let c = Constellation::qam(4).unwrap().normalized();
403        let (_, d) = comnum_mapping::source::symbols(&c, 32 * 4, None, &mut Rng::new(3)).unwrap();
404        let mut ce = CeOfdm {
405            n_cp: 4,
406            receiver: Receiver::Enhanced,
407            sample_rate: Hertz(2e6),
408            ..CeOfdm::new(32, 2, 0.02).unwrap()
409        };
410        let x = Modulate::modulate(&ce, &Symbols::from_stream(d.clone()).unwrap()).unwrap();
411        assert_eq!((x.stream(0), x.sample_rate()), (&ce.modulate(&d).unwrap()[..], Hertz(2e6)));
412        let back = Demodulate::demodulate(&mut ce, &x).unwrap();
413        assert_eq!(back.as_slice(), &ce.demodulate_with_receiver(x.stream(0), Receiver::Enhanced).unwrap()[..]);
414        let slow = Signal::from_stream(x.stream(0).to_vec(), Hertz(1e6)).unwrap();
415        assert!(Demodulate::demodulate(&mut ce, &slow).is_err());
416    }
417
418    #[test]
419    fn the_projection_inverts_the_ofdm_signal_and_the_phase_variance_is_2pi_h() {
420        // h = 0.6: the phase (standard deviation 3.8 rad) wraps often, but at J = 16 its
421        // increments stay far below π
422        let ce = CeOfdm { n_cp: 5, ..CeOfdm::new(32, 16, 0.6).unwrap() };
423        let c = Constellation::qam(16).unwrap().normalized();
424        let (_, d) = comnum_mapping::source::symbols(&c, 32 * 200, None, &mut Rng::new(2)).unwrap();
425        let x = ce.ofdm_signal(&d).unwrap();
426        let var = x.iter().map(|v| v * v).sum::<f64>() / x.len() as f64;
427        assert!((var - 1.0).abs() < 0.03, "{var}");
428        let s = ce.modulate(&d).unwrap();
429        assert_eq!(s.len(), 200 * ce.block_len());
430        // the prefix repeats the end of the block
431        assert!((s[0] - s[ce.n_fft()]).norm() < 1e-12);
432        // without noise, the unwrapped receiver recovers the symbols despite the wraps,
433        // which the wrapped receiver does not
434        let e = ce.demodulate_with_receiver(&s, Receiver::Arctangent(Unwrap::Stream)).unwrap();
435        assert!(e.iter().zip(&d).all(|(a, b)| (a - b).norm() < 1e-8));
436        let w = ce.demodulate_with_receiver(&s, Receiver::Arctangent(Unwrap::None)).unwrap();
437        assert!(w.iter().zip(&d).map(|(a, b)| (a - b).norm()).fold(0.0, f64::max) > 0.1);
438    }
439
440    #[test]
441    fn the_linear_theory_is_proakis_with_the_snr_scaled_by_2pi_h_squared() {
442        let ce = CeOfdm::new(32, 4, 0.05).unwrap();
443        let g = (TAU * 0.05).powi(2) * 100.0;
444        let s = theory::snr(&ce, 20.0).unwrap();
445        assert!((s - g).abs() < 1e-9 * g);
446        let ser_ce = theory::ser_esn0(&ce, ConstellationKind::Qam, 16, 20.0).unwrap();
447        let proakis = comnum_mapping::theory::ser(ConstellationKind::Qam, 16, comnum_math::lin2db(g / 4.0)).unwrap();
448        assert!((ser_ce - proakis).abs() < 1e-12);
449        // the cyclic prefix costs its share of the energy
450        let cp = CeOfdm { n_cp: ce.n_fft() / 4, ..ce.clone() };
451        assert!((theory::snr(&cp, 20.0).unwrap() / s - 0.8).abs() < 1e-12);
452        // a simulated link at high CNR follows the theory within the sampling error
453        let c = Constellation::qam(16).unwrap().normalized();
454        let (_, d) = comnum_mapping::source::symbols(&c, 32 * 2000, None, &mut Rng::new(5)).unwrap();
455        let mut rng = Rng::new(6);
456        // J = 2 keeps the CNR per sample 14 dB, above the threshold of the demodulator;
457        // the SNR per subcarrier is 16 dB, a SER of 4e-3 for 16-QAM (about 250 errors)
458        let ce = CeOfdm::new(32, 2, 0.1).unwrap();
459        let esn0_db = 20.0;
460        let sigma = ce.noise_variance(esn0_db).sqrt();
461        let r: Vec<C64> = ce.modulate(&d).unwrap().into_iter().map(|v| v + rng.complex_gaussian() * sigma).collect();
462        let e = ce.demodulate_with_receiver(&r, Receiver::Arctangent(Unwrap::None)).unwrap();
463        let errors = c.decide(&e).iter().zip(c.decide(&d)).filter(|(a, b)| *a != b).count();
464        let ser = errors as f64 / d.len() as f64;
465        let want = theory::ser_esn0(&ce, ConstellationKind::Qam, 16, esn0_db).unwrap();
466        assert!((ser / want - 1.0).abs() < 0.15, "{ser} vs {want}");
467    }
468
469    #[test]
470    fn a_cycle_slip_adds_the_response_of_the_theory() {
471        let ce = CeOfdm { n_cp: 6, ..CeOfdm::new(31, 2, 0.8 / TAU).unwrap() };
472        let c = Constellation::qam(4).unwrap().normalized();
473        let (_, d) = comnum_mapping::source::symbols(&c, 31, None, &mut Rng::new(4)).unwrap();
474        let s = ce.modulate(&d).unwrap();
475        let clean = ce.demodulate_with_receiver(&s, Receiver::Arctangent(Unwrap::Stream)).unwrap();
476        for start in [0usize, 1, 40, 127] {
477            // the phase gains 2π from `start`: the unwrapped phase of the samples rotated by a
478            // full turn in small steps over one sample would do the same; add it to the phase
479            let mut psi = ce.received_phase(&s, Unwrap::Stream);
480            for v in &mut psi[ce.n_cp + start..] {
481                *v += TAU;
482            }
483            let slipped = ce.project(&psi, 1.0).unwrap();
484            let want = theory::cycle_slip_response(&ce, start).unwrap();
485            for ((a, b), w) in slipped.iter().zip(&clean).zip(&want) {
486                assert!((a - b - w).norm() < 1e-9, "{start}: {} vs {w}", a - b);
487            }
488        }
489        assert!(theory::cycle_slip_response(&ce, ce.n_fft()).is_err());
490    }
491
492    #[test]
493    fn the_linear_receivers_follow_the_bussgang_analysis() {
494        // QPSK, K = 32 and J = 2 (the 2X sampling of Ahmed and Zeidler 2015)
495        let c = Constellation::qam(4).unwrap().normalized();
496        let mut rng = Rng::new(9);
497        let (_, d) = comnum_mapping::source::symbols(&c, 32 * 3000, None, &mut rng).unwrap();
498        for (two_pi_h, ebn0_db) in [(0.3, 15.0), (0.5, 10.5), (0.7, 8.0)] {
499            let ce = CeOfdm::new(32, 2, two_pi_h / TAU).unwrap();
500            let esn0_db = ebn0_db + comnum_math::lin2db(2.0);
501            let sigma = ce.noise_variance(esn0_db).sqrt();
502            let r: Vec<C64> =
503                ce.modulate(&d).unwrap().into_iter().map(|v| v + rng.complex_gaussian() * sigma).collect();
504            for receiver in [Receiver::Linear, Receiver::Enhanced] {
505                let e = ce.demodulate_with_receiver(&r, receiver).unwrap();
506                let bits: usize =
507                    c.decide(&e).iter().zip(c.decide(&d)).map(|(a, b)| (a ^ b).count_ones() as usize).sum();
508                let ber = bits as f64 / (2.0 * d.len() as f64);
509                let want = theory::ber_receiver(&CeOfdm { receiver, ..ce.clone() }, ConstellationKind::Qam, 4, esn0_db)
510                    .unwrap();
511                // BER about 1e-2, 2000 errors: a 10 % band for the sampling error and the
512                // Gaussian model of the distortion (simulation and theory agree to 4 %)
513                assert!((ber / want - 1.0).abs() < 0.1, "2πh = {two_pi_h}, {receiver:?}: {ber:e} vs {want:e}");
514            }
515        }
516        // at a small index the linear receiver is the arctangent one (eq. (20) of the paper)
517        let ce = CeOfdm::new(32, 2, 0.01 / TAU).unwrap();
518        let lin = theory::subcarrier_snr(&CeOfdm { receiver: Receiver::Linear, ..ce.clone() }, 30.0).unwrap();
519        let flat = theory::snr(&ce, 30.0).unwrap();
520        assert!(lin.iter().all(|s| (s / flat - 1.0).abs() < 1e-3), "{lin:?} vs {flat}");
521    }
522}
523
524#[cfg(test)]
525mod literature {
526    use super::{CeOfdm, theory};
527    use comnum_mapping::constellation::ConstellationKind;
528    use comnum_math::{db2lin, qfunc};
529
530    /// S. C. Thompson, A. U. Ahmed, J. G. Proakis, J. R. Zeidler and M. J. Geile,
531    /// "Constant envelope OFDM," IEEE Trans. Commun. 56(8), 2008, eq. (15) (p. 1303):
532    /// the BER of the linear regime, `2 (M-1)/(M log2 M) Q(2πh √(6 log2 M E_b/N_0 /
533    /// (M²-1)))` with M-PAM per real subcarrier, which the paper calls a lower bound (it
534    /// keeps the nearest-neighbour term of the Gray BER). The library's theory is the
535    /// exact Gray BER of the QAM of order M² at `E_s/N_0 = 2 log2 M · E_b/N_0`: never
536    /// below eq. (15), and within 1 % of it once the BER is under 1e-2.
537    #[test]
538    fn thompson_2008_eq_15_linear_ber() {
539        for (m_pam, m_qam) in [(4usize, 16usize), (8, 64)] {
540            let log2m = (m_pam as f64).log2();
541            for h in [0.05, 0.1, 0.3] {
542                for ebn0_db in [6.0, 10.0, 14.0] {
543                    let ce = CeOfdm::new(64, 4, h).unwrap();
544                    let esn0_db = ebn0_db + comnum_math::lin2db(2.0 * log2m);
545                    let lib = theory::ber_esn0(&ce, ConstellationKind::Qam, m_qam, esn0_db).unwrap();
546                    let arg = std::f64::consts::TAU
547                        * h
548                        * (6.0 * log2m / ((m_pam * m_pam - 1) as f64) * db2lin(ebn0_db)).sqrt();
549                    let paper = 2.0 * (m_pam - 1) as f64 / (m_pam as f64 * log2m) * qfunc(arg);
550                    assert!(lib >= paper * (1.0 - 1e-12), "M = {m_pam}, h = {h}: {lib} below the bound {paper}");
551                    if paper < 1e-2 {
552                        assert!((lib / paper - 1.0).abs() < 0.01, "M = {m_pam}, h = {h}: {lib} vs {paper}");
553                    }
554                }
555            }
556        }
557    }
558}