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}