Skip to main content

comnum_waveform/
papr.rs

1//! Peak-to-average power ratio (PAPR) of OFDM: its distribution, and the classical
2//! reduction techniques.
3//!
4//! The PAPR of a block $x$ is $\max_{n} |x\[n\]|^{2} / \mathbb{E}|x\[n\]|^{2}$, measured on
5//! the oversampled signal ([`crate::ofdm::Ofdm`] with $L \ge 4$). Its complementary
6//! cumulative distribution (CCDF) $\Pr(\mathrm{PAPR} > \gamma)$ is the standard figure of
7//! merit: an OFDM signal is nearly Gaussian, and a peak is rare but large.
8//!
9//! # Distribution
10//!
11//! - Nyquist-rate samples of $N$ independent Rayleigh amplitudes (van Nee & Prasad):
12//!   [`ccdf_nyquist`], $1 - (1 - e^{-\gamma})^{N}$;
13//! - the continuous (oversampled) signal, for large $N$ (Ochiai & Imai 2001):
14//!   [`ccdf_oversampled`], $1 - \exp\left(-N \sqrt{\pi \gamma / 3}\\, e^{-\gamma}\right)$;
15//! - SLM with $U$ independent candidates: [`ccdf_slm`] ([`ccdf_slm_nyquist`] at the
16//!   Nyquist rate), the CCDF raised to the power $U$.
17//!
18//! # Clipping
19//!
20//! An envelope limiter acting on a complex Gaussian signal is, by Bussgang's theorem, a
21//! gain plus an uncorrelated distortion, whose closed form
22//! ([`comnum_dsp::nonlinear::clipping_bussgang`]) gives the signal-to-distortion ratio, the
23//! error floor of clipping.
24//!
25//! # Reduction techniques
26//!
27//! | Technique | Principle | Distortion | Side information | Reference |
28//! |---|---|---|---|---|
29//! | [`Clip`] | envelope limiter | in band and out of band | none | Li & Cimini 1998 |
30//! | [`ClipFilter`] | repeated clipping and out-of-band filtering | in band | none | Armstrong 2002 |
31//! | [`Slm`] | best of $U$ phase-rotated copies | none | $\log_{2} U$ bits | Bäuml, Fischer & Huber 1996 |
32//! | [`Pts`] | best phase combination of $V$ subblocks | none | $(V - 1)\log_{2} W$ bits | Müller & Huber 1997 |
33//! | [`ToneReservation`] | peak-cancelling signal on reserved tones | none (rate loss) | none | Tellado 2000 |
34//! | [`Ace`] | outer constellation points pushed outwards | none (power increase) | none | Krongold & Jones 2003 |
35//! | [`Companding`] | μ-law or exponential compression of the envelope, expansion at the receiver | yes | none | Wang, Tjhung & Ng 1999; Jiang, Yang & Song 2005 |
36//!
37//! DFT-spread OFDM ([`crate::ofdm::Ofdm::dft_spread`], Myung, Lim & Goodman 2006)
38//! removes most of the PAPR at the source.
39//!
40//! # Examples
41//! ```
42//! use comnum_waveform::ofdm::Ofdm;
43//! use comnum_waveform::papr::{ccdf_oversampled, Slm};
44//! use comnum_math::{papr_db, C64};
45//! let ofdm = Ofdm::new(256, 256)?;
46//! let qpsk: Vec<C64> = (0..256).map(|i| C64::new(if i % 3 == 0 { 1.0 } else { -1.0 }, if i % 5 < 2 { 1.0 } else { -1.0 }) / 2f64.sqrt()).collect();
47//! let (x, side) = ofdm.modulate_with(&qpsk, &Slm { candidates: 16, seed: 1 })?;
48//! assert!(papr_db(&x) <= papr_db(&ofdm.modulate(&qpsk)?));
49//! assert_eq!(ofdm.demodulate_with(&x, &Slm { candidates: 16, seed: 1 }, &side)?.len(), 256);
50//! assert!((ccdf_oversampled(256, 10.0) - 0.0369).abs() < 1e-3);
51//! # Ok::<(), comnum_core::Error>(())
52//! ```
53//!
54//! # References
55//!
56//! 1. R. van Nee and R. Prasad, *OFDM for Wireless Multimedia Communications*, Artech
57//!    House, 2000, ch. 6.
58//! 2. H. Ochiai and H. Imai, "On the distribution of the peak-to-average power ratio in
59//!    OFDM signals," *IEEE Trans. Commun.*, 49(2), 282–289, 2001.
60//! 3. H. Ochiai and H. Imai, "Performance analysis of deliberately clipped OFDM signals,"
61//!    *IEEE Trans. Commun.*, 50(1), 89–101, 2002.
62//! 4. D. Dardari, V. Tralli and A. Vaccari, "A theoretical characterization of nonlinear
63//!    distortion effects in OFDM systems," *IEEE Trans. Commun.*, 48(10), 1755–1764, 2000.
64//! 5. J. J. Bussgang, "Crosscorrelation functions of amplitude-distorted Gaussian
65//!    signals," MIT RLE Tech. Rep. 216, 1952.
66//! 6. S. H. Han and J. H. Lee, "An overview of peak-to-average power ratio reduction
67//!    techniques for multicarrier transmission," *IEEE Wireless Commun.*, 12(2), 56–65,
68//!    2005.
69//! 7. X. Li and L. J. Cimini, "Effects of clipping and filtering on the performance of
70//!    OFDM," *IEEE Commun. Lett.*, 2(5), 131–133, 1998.
71//! 8. J. Armstrong, "Peak-to-average power reduction for OFDM by repeated clipping and
72//!    frequency domain filtering," *Electron. Lett.*, 38(5), 246–247, 2002.
73//! 9. R. W. Bäuml, R. F. H. Fischer and J. B. Huber, "Reducing the peak-to-average power
74//!    ratio of multicarrier modulation by selected mapping," *Electron. Lett.*, 32(22),
75//!    2056–2057, 1996.
76//! 10. S. H. Müller and J. B. Huber, "OFDM with reduced peak-to-average power ratio by
77//!     optimum combination of partial transmit sequences," *Electron. Lett.*, 33(5),
78//!     368–369, 1997.
79//! 11. J. Tellado, *Multicarrier Modulation with Low PAR: Applications to DSL and
80//!     Wireless*, Kluwer, 2000.
81//! 12. B. S. Krongold and D. L. Jones, "PAR reduction in OFDM via active constellation
82//!     extension," *IEEE Trans. Broadcast.*, 49(3), 258–268, 2003.
83//! 13. X. Wang, T. T. Tjhung and C. S. Ng, "Reduction of peak-to-average power ratio of
84//!     OFDM system using a companding technique," *IEEE Trans. Broadcast.*, 45(3),
85//!     303–307, 1999.
86//! 14. T. Jiang, Y. Yang and Y.-H. Song, "Exponential companding technique for PAPR
87//!     reduction in OFDM systems," *IEEE Trans. Broadcast.*, 51(2), 244–248, 2005.
88//! 15. H. G. Myung, J. Lim and D. J. Goodman, "Single carrier FDMA for uplink wireless
89//!     transmission," *IEEE Veh. Technol. Mag.*, 1(3), 30–38, 2006.
90
91use crate::ofdm::{Ofdm, PaprReduction};
92use comnum_core::rng::Rng;
93use comnum_core::{Error, Result, ensure};
94use comnum_dsp::nonlinear::limit_envelope;
95use comnum_dsp::signal::map_envelope;
96use comnum_mapping::constellation::Constellation;
97use comnum_math::{C64, cis, db2amp, db2lin, papr_db};
98use std::f64::consts::PI;
99
100// ------------------------------------------------------------------ distribution
101
102/// $\Pr(\mathrm{PAPR} > \gamma)$ of $n$ independent Rayleigh samples (Nyquist rate),
103/// $1 - (1 - e^{-\gamma})^{n}$ (van Nee & Prasad 2000; Ochiai & Imai 2001, eq. (11)).
104pub fn ccdf_nyquist(n: usize, papr_db: f64) -> f64 {
105    let g = db2lin(papr_db);
106    1.0 - (1.0 - (-g).exp()).powi(n as i32)
107}
108
109/// $\Pr(\mathrm{PAPR} > \gamma)$ of the continuous OFDM signal of $n$ subcarriers,
110/// $1 - \exp(-n \sqrt{\pi\gamma/3}\\, e^{-\gamma})$ (Ochiai & Imai 2001, eq. (27), the
111/// asymptotic form of the level-crossing analysis, within the resolution of the
112/// figures of $n \geq 100$).
113pub fn ccdf_oversampled(n: usize, papr_db: f64) -> f64 {
114    let g = db2lin(papr_db);
115    (1.0 - (-(n as f64) * (PI * g / 3.0).sqrt() * (-g).exp()).exp()).clamp(0.0, 1.0)
116}
117
118/// The CCDF after SLM with `candidates` independent candidates: that of one candidate
119/// raised to the power $U$ (oversampled signal).
120pub fn ccdf_slm(n: usize, candidates: usize, papr_db: f64) -> f64 {
121    ccdf_oversampled(n, papr_db).powi(candidates as i32)
122}
123
124/// The CCDF after SLM with `candidates` independent candidates at the Nyquist rate:
125/// [`ccdf_nyquist`] raised to the power $U$.
126///
127/// # Examples
128/// ```
129/// use comnum_waveform::papr::{ccdf_nyquist, ccdf_slm_nyquist};
130/// assert_eq!(ccdf_slm_nyquist(64, 1, 8.0), ccdf_nyquist(64, 8.0));
131/// assert_eq!(ccdf_slm_nyquist(64, 4, 8.0), ccdf_nyquist(64, 8.0).powi(4));
132/// ```
133pub fn ccdf_slm_nyquist(n: usize, candidates: usize, papr_db: f64) -> f64 {
134    ccdf_nyquist(n, papr_db).powi(candidates as i32)
135}
136
137// ------------------------------------------------------------------ clipping
138
139/// Clipping of the envelope at `ratio_db` above the (unit) average power
140/// (Li & Cimini 1998): in-band distortion and out-of-band regrowth.
141#[derive(Clone, Copy, Debug, PartialEq)]
142pub struct Clip {
143    /// Clipping ratio (dB).
144    pub ratio_db: f64,
145}
146
147impl PaprReduction for Clip {
148    fn reduce(&self, ofdm: &Ofdm, data: &[C64]) -> Result<(Vec<C64>, usize)> {
149        let mut x = ofdm.to_time(&ofdm.spread(data))?;
150        limit_envelope(&mut x, db2amp(self.ratio_db));
151        Ok((x, 0))
152    }
153}
154
155/// Repeated clipping and frequency-domain filtering (Armstrong 2002): every iteration
156/// clips the envelope and removes the out-of-band components, which regrows some
157/// peaks; a few iterations reach the target PAPR without any out-of-band emission.
158#[derive(Clone, Copy, Debug, PartialEq)]
159pub struct ClipFilter {
160    /// Clipping ratio (dB).
161    pub ratio_db: f64,
162    /// Clipping-and-filtering iterations.
163    pub iterations: usize,
164}
165
166impl PaprReduction for ClipFilter {
167    fn reduce(&self, ofdm: &Ofdm, data: &[C64]) -> Result<(Vec<C64>, usize)> {
168        let mut x = ofdm.to_time(&ofdm.spread(data))?;
169        let a = db2amp(self.ratio_db);
170        for _ in 0..self.iterations {
171            limit_envelope(&mut x, a);
172            x = ofdm.filter(&x)?;
173        }
174        Ok((x, 0))
175    }
176}
177
178// ------------------------------------------------------------------ selected mapping
179
180/// Selected mapping (Bäuml, Fischer & Huber 1996): $U$ copies of the block rotated by
181/// known phase sequences (the first one is the original), the one of lowest PAPR is
182/// sent and its index is the side information.
183#[derive(Clone, Copy, Debug, PartialEq, Eq)]
184pub struct Slm {
185    /// Number of candidates $U$.
186    pub candidates: usize,
187    /// Seed of the phase sequences (shared by transmitter and receiver).
188    pub seed: u64,
189}
190
191impl Slm {
192    /// The phase sequence of candidate `u` ($\pm 1, \pm j$; all ones for `u = 0`).
193    pub fn phases(&self, u: usize, k: usize) -> Vec<C64> {
194        if u == 0 {
195            return vec![C64::new(1.0, 0.0); k];
196        }
197        let mut r = comnum_core::rng::member(self.seed, u as u64);
198        (0..k).map(|_| cis(PI / 2.0 * r.below(4) as f64)).collect()
199    }
200}
201
202impl PaprReduction for Slm {
203    fn reduce(&self, ofdm: &Ofdm, data: &[C64]) -> Result<(Vec<C64>, usize)> {
204        ensure(self.candidates >= 1, || "SLM needs at least one candidate".into())?;
205        let active = ofdm.spread(data);
206        let mut best = (f64::INFINITY, Vec::new(), 0);
207        for u in 0..self.candidates {
208            let p = self.phases(u, active.len());
209            let x = ofdm.to_time(&active.iter().zip(&p).map(|(a, b)| a * b).collect::<Vec<_>>())?;
210            let v = papr_db(&x);
211            if v < best.0 {
212                best = (v, x, u);
213            }
214        }
215        Ok((best.1, best.2))
216    }
217
218    fn recover(&self, ofdm: &Ofdm, active: Vec<C64>, side: usize) -> Result<Vec<C64>> {
219        ensure(side < self.candidates, || format!("SLM side information {side} out of {}", self.candidates))?;
220        let p = self.phases(side, active.len());
221        Ok(ofdm.despread(&active.iter().zip(&p).map(|(a, b)| a * b.conj()).collect::<Vec<_>>()))
222    }
223}
224
225// ------------------------------------------------------------------ partial transmit sequences
226
227/// How the subcarriers are split into the subblocks of [`Pts`].
228#[non_exhaustive]
229#[derive(Clone, Copy, Debug, PartialEq, Eq)]
230pub enum Partition {
231    /// Contiguous subblocks.
232    Adjacent,
233    /// Subcarrier $i$ in subblock $i \bmod V$.
234    Interleaved,
235    /// A balanced random assignment (seeded): the best PAPR reduction.
236    Pseudorandom(u64),
237}
238
239impl Partition {
240    /// The partition of a name: `adjacent`, `interleaved` or `pseudorandom` (drawn with
241    /// `seed`), case-insensitive.
242    ///
243    /// # Errors
244    /// [`Error::Config`] for another name.
245    ///
246    /// # Examples
247    /// ```
248    /// use comnum_waveform::papr::Partition;
249    /// assert_eq!(Partition::from_name("pseudorandom", 3)?, Partition::Pseudorandom(3));
250    /// assert!(Partition::from_name("random", 3).is_err());
251    /// # Ok::<(), comnum_core::Error>(())
252    /// ```
253    pub fn from_name(name: &str, seed: u64) -> Result<Partition> {
254        match name.to_ascii_lowercase().as_str() {
255            "adjacent" => Ok(Partition::Adjacent),
256            "interleaved" => Ok(Partition::Interleaved),
257            "pseudorandom" => Ok(Partition::Pseudorandom(seed)),
258            other => Err(Error::Config(format!("unknown partition {other:?}: adjacent, interleaved or pseudorandom"))),
259        }
260    }
261}
262
263/// Partial transmit sequences (Müller & Huber 1997): the subcarriers are split into $V$
264/// subblocks whose time signals are combined with phase factors from
265/// $\\{e^{j 2\pi w / W}\\}$, the first fixed to one; the combination of lowest PAPR is
266/// sent (exhaustive search over $W^{V-1}$ combinations) and its index is the side
267/// information.
268#[derive(Clone, Copy, Debug, PartialEq, Eq)]
269pub struct Pts {
270    /// Number of subblocks $V$.
271    pub subblocks: usize,
272    /// Number of phase factors $W$ (2: $\pm 1$, 4: $\pm 1, \pm j$).
273    pub phases: usize,
274    /// The partition of the subcarriers.
275    pub partition: Partition,
276}
277
278impl Pts {
279    /// The subblock of every active subcarrier.
280    pub fn assignment(&self, k: usize) -> Vec<usize> {
281        let v = self.subblocks.max(1);
282        match self.partition {
283            Partition::Adjacent => (0..k).map(|i| i * v / k).collect(),
284            Partition::Interleaved => (0..k).map(|i| i % v).collect(),
285            Partition::Pseudorandom(seed) => {
286                let mut a: Vec<usize> = (0..k).map(|i| i % v).collect();
287                Rng::new(seed).shuffle(&mut a);
288                a
289            }
290        }
291    }
292
293    /// The phase factor of every subblock for the combination `index`.
294    pub fn factors(&self, index: usize) -> Vec<C64> {
295        let mut f = vec![C64::new(1.0, 0.0)];
296        let mut rest = index;
297        for _ in 1..self.subblocks {
298            f.push(cis(2.0 * PI * (rest % self.phases) as f64 / self.phases as f64));
299            rest /= self.phases;
300        }
301        f
302    }
303
304    fn combinations(&self) -> Result<usize> {
305        ensure(self.subblocks >= 1 && self.phases >= 1, || "PTS needs subblocks and phases".into())?;
306        let n = (1..self.subblocks).try_fold(1usize, |acc, _| acc.checked_mul(self.phases));
307        n.filter(|n| *n <= 1 << 20).ok_or_else(|| Error::Config("too many PTS combinations".into()))
308    }
309}
310
311impl PaprReduction for Pts {
312    fn reduce(&self, ofdm: &Ofdm, data: &[C64]) -> Result<(Vec<C64>, usize)> {
313        let combinations = self.combinations()?;
314        let active = ofdm.spread(data);
315        let assign = self.assignment(active.len());
316        let parts = (0..self.subblocks)
317            .map(|v| {
318                let masked: Vec<C64> =
319                    active.iter().zip(&assign).map(|(a, &b)| if b == v { *a } else { C64::new(0.0, 0.0) }).collect();
320                ofdm.to_time(&masked)
321            })
322            .collect::<Result<Vec<_>>>()?;
323        let mut best = (f64::INFINITY, 0usize);
324        let mut x = vec![C64::new(0.0, 0.0); ofdm.fft_len()];
325        for index in 0..combinations {
326            let f = self.factors(index);
327            for (n, v) in x.iter_mut().enumerate() {
328                *v = parts.iter().zip(&f).map(|(p, b)| p[n] * b).sum();
329            }
330            let p = papr_db(&x);
331            if p < best.0 {
332                best = (p, index);
333            }
334        }
335        let f = self.factors(best.1);
336        let x = (0..ofdm.fft_len()).map(|n| parts.iter().zip(&f).map(|(p, b)| p[n] * b).sum()).collect();
337        Ok((x, best.1))
338    }
339
340    fn recover(&self, ofdm: &Ofdm, active: Vec<C64>, side: usize) -> Result<Vec<C64>> {
341        ensure(side < self.combinations()?, || format!("PTS side information {side} out of range"))?;
342        let f = self.factors(side);
343        let assign = self.assignment(active.len());
344        Ok(ofdm.despread(&active.iter().zip(&assign).map(|(a, &v)| a * f[v].conj()).collect::<Vec<_>>()))
345    }
346}
347
348// ------------------------------------------------------------------ tone reservation
349
350/// Tone reservation (Tellado 2000): a few subcarriers carry no data but a
351/// peak-cancelling signal. Every iteration projects the excess of the peaks above the
352/// target amplitude (the clipping noise) onto the reserved tones, by least squares, and
353/// subtracts it: a gradient descent on the peak excess that never touches the data
354/// tones.
355#[derive(Clone, Debug, PartialEq)]
356pub struct ToneReservation {
357    /// Indices of the reserved tones among the active subcarriers.
358    pub reserved: Vec<usize>,
359    /// Target amplitude, in dB above the average power.
360    pub ratio_db: f64,
361    /// Iterations.
362    pub iterations: usize,
363    /// Step of every iteration (1: the full projection).
364    pub step: f64,
365}
366
367impl ToneReservation {
368    /// `count` reserved tones drawn at random (seeded) among the `n_active` subcarriers,
369    /// the usual choice (a random set gives a peaky cancelling kernel).
370    pub fn random(n_active: usize, count: usize, seed: u64, ratio_db: f64) -> ToneReservation {
371        let mut idx: Vec<usize> = (0..n_active).collect();
372        Rng::new(seed).shuffle(&mut idx);
373        let mut reserved: Vec<usize> = idx.into_iter().take(count.min(n_active)).collect();
374        reserved.sort_unstable();
375        ToneReservation { reserved, ratio_db, iterations: 20, step: 1.0 }
376    }
377
378    fn mask(&self, ofdm: &Ofdm) -> Result<Vec<bool>> {
379        ensure(!ofdm.dft_spread, || "tone reservation needs plain OFDM".into())?;
380        let mut m = vec![false; ofdm.n_active];
381        for &i in &self.reserved {
382            ensure(i < ofdm.n_active, || format!("reserved tone {i} is not an active subcarrier"))?;
383            m[i] = true;
384        }
385        Ok(m)
386    }
387}
388
389impl PaprReduction for ToneReservation {
390    fn data_len(&self, ofdm: &Ofdm) -> usize {
391        ofdm.n_active.saturating_sub(self.reserved.len())
392    }
393
394    fn reduce(&self, ofdm: &Ofdm, data: &[C64]) -> Result<(Vec<C64>, usize)> {
395        let mask = self.mask(ofdm)?;
396        let mut it = data.iter();
397        let active: Vec<C64> = mask
398            .iter()
399            .map(|&r| if r { C64::new(0.0, 0.0) } else { *it.next().unwrap_or(&C64::new(0.0, 0.0)) })
400            .collect();
401        let mut x = ofdm.to_time(&active)?;
402        let a = db2amp(self.ratio_db);
403        for _ in 0..self.iterations {
404            let mut clipped = x.clone();
405            limit_envelope(&mut clipped, a);
406            if clipped.iter().zip(&x).all(|(c, v)| c == v) {
407                break;
408            }
409            let noise: Vec<C64> = clipped.iter().zip(&x).map(|(c, v)| c - v).collect();
410            let projected: Vec<C64> = ofdm
411                .to_active(&noise)?
412                .into_iter()
413                .zip(&mask)
414                .map(|(v, &r)| if r { v } else { C64::new(0.0, 0.0) })
415                .collect();
416            for (v, c) in x.iter_mut().zip(ofdm.to_time(&projected)?) {
417                *v += c * self.step;
418            }
419        }
420        Ok((x, 0))
421    }
422
423    fn recover(&self, ofdm: &Ofdm, active: Vec<C64>, _side: usize) -> Result<Vec<C64>> {
424        let mask = self.mask(ofdm)?;
425        Ok(active.into_iter().zip(&mask).filter(|(_, r)| !**r).map(|(v, _)| v).collect())
426    }
427}
428
429// ------------------------------------------------------------------ active constellation extension
430
431/// Active constellation extension (Krongold & Jones 2003), clipping-based: the peaks
432/// are clipped, and the resulting change of every subcarrier is kept only where it
433/// moves an outer constellation point further out (which can only increase its
434/// distance to the decision boundaries); a few iterations converge. For square QAM.
435#[derive(Clone, Debug, PartialEq)]
436pub struct Ace {
437    /// The constellation of the data (square QAM or QPSK).
438    pub constellation: Constellation,
439    /// Clipping ratio of every iteration (dB).
440    pub ratio_db: f64,
441    /// Iterations.
442    pub iterations: usize,
443    /// Gain applied to the allowed extension at every iteration.
444    pub step: f64,
445}
446
447impl PaprReduction for Ace {
448    fn reduce(&self, ofdm: &Ofdm, data: &[C64]) -> Result<(Vec<C64>, usize)> {
449        ensure(!ofdm.dft_spread, || "active constellation extension needs plain OFDM".into())?;
450        let pts = self.constellation.points();
451        let max_re = pts.iter().map(|p| p.re.abs()).fold(0.0, f64::max);
452        let max_im = pts.iter().map(|p| p.im.abs()).fold(0.0, f64::max);
453        let tol = 1e-9 * max_re.max(max_im).max(1e-300);
454        let original: Vec<C64> = data.iter().map(|d| pts[self.constellation.nearest(*d)]).collect();
455        let mut active = data.to_vec();
456        let mut x = ofdm.to_time(&active)?;
457        let a = db2amp(self.ratio_db);
458        for _ in 0..self.iterations {
459            let mut clipped = x.clone();
460            limit_envelope(&mut clipped, a);
461            let noise: Vec<C64> = clipped.iter().zip(&x).map(|(c, v)| c - v).collect();
462            if noise.iter().all(|v| v.norm_sqr() == 0.0) {
463                break;
464            }
465            for ((v, c), p) in active.iter_mut().zip(ofdm.to_active(&noise)?).zip(&original) {
466                let re = if p.re.abs() >= max_re - tol && c.re * p.re > 0.0 { c.re } else { 0.0 };
467                let im = if p.im.abs() >= max_im - tol && c.im * p.im > 0.0 { c.im } else { 0.0 };
468                *v += C64::new(re, im) * self.step;
469            }
470            x = ofdm.to_time(&active)?;
471        }
472        Ok((x, 0))
473    }
474}
475
476// ------------------------------------------------------------------ companding
477
478/// Companding of the envelope at the transmitter and expansion at the receiver; the
479/// compressed signal keeps the unit average power.
480///
481/// The expansion is exact without noise but amplifies the noise where the law is flat:
482/// the exponential law of degree 2 squeezes every amplitude above about twice the RMS
483/// into a narrow range below its maximum, and expanding them multiplies the noise by
484/// 20 to 150 — an error floor. A receiver can instead skip the expansion and treat the
485/// compression as a distortion (Bussgang gain plus noise), as for clipping.
486#[non_exhaustive]
487#[derive(Clone, Copy, Debug, PartialEq)]
488pub enum Companding {
489    /// μ-law (Wang, Tjhung & Ng 1999): $c(r) = V \ln(1 + \mu r/V) / \ln(1 + \mu)$, with
490    /// $V$ at `peak_db` above the average power, times the gain that keeps the unit
491    /// average power of a Rayleigh envelope. The gain is computed once per block, by a
492    /// 64-point Gauss–Legendre rule within $10^{-13}$ (relative) of its integral for
493    /// $\mu/V \le 10^{5}$.
494    MuLaw {
495        /// Compression parameter $\mu$.
496        mu: f64,
497        /// Reference amplitude $V$ (dB above the average power).
498        peak_db: f64,
499    },
500    /// Exponential companding (Jiang, Yang & Song 2005):
501    /// $c(r) = \left[\alpha (1 - e^{-r^{2}})\right]^{1/d}$ with
502    /// $\alpha = ((d + 2)/d)^{d/2}$, which turns the Rayleigh envelope into one of fixed
503    /// distribution (uniform power for $d = 2$, a PAPR of 3 dB).
504    Exponential {
505        /// Degree $d$.
506        degree: f64,
507    },
508}
509
510/// The 64 nodes and weights of Gauss–Legendre on $[-1, 1]$ of the μ-law normalisation,
511/// computed once.
512fn mu_law_nodes() -> &'static (Vec<f64>, Vec<f64>) {
513    static NODES: std::sync::OnceLock<(Vec<f64>, Vec<f64>)> = std::sync::OnceLock::new();
514    NODES.get_or_init(|| comnum_math::gauss_legendre(64))
515}
516
517/// A companding law with the constants of its parameters computed once (the
518/// normalisation of the μ-law is an integral): [`PaprReduction::reduce`] and
519/// [`PaprReduction::restore_time`] build it once per block.
520#[derive(Clone, Copy, Debug)]
521enum Law {
522    MuLaw { mu: f64, v: f64, gain: f64 },
523    Exponential { degree: f64, alpha: f64 },
524}
525
526impl Law {
527    fn compress(&self, r: f64) -> f64 {
528        match *self {
529            Law::MuLaw { mu, v, gain } => gain * v * (1.0 + mu * r / v).ln() / (1.0 + mu).ln(),
530            Law::Exponential { degree: d, alpha } => (alpha * (1.0 - (-r * r).exp())).powf(1.0 / d),
531        }
532    }
533
534    fn expand(&self, r: f64) -> f64 {
535        match *self {
536            Law::MuLaw { mu, v, gain } => {
537                let c = r / gain;
538                v / mu * ((1.0 + mu).powf(c / v) - 1.0)
539            }
540            Law::Exponential { degree: d, alpha } => {
541                let u = (1.0 - r.powf(d) / alpha).max(1e-16);
542                (-u.ln()).sqrt()
543            }
544        }
545    }
546}
547
548impl Companding {
549    /// The gain $g = 1/\sqrt{E\\{c(R)^{2}\\}}$ of the μ-law that keeps the unit average power,
550    /// for a Rayleigh envelope $R$ of unit power ($R^{2} \sim \mathrm{Exp}(1)$) and $V$ =
551    /// `v`. With $a = \mu/V$ and the change of variable $s = \ln(1 + a r)$,
552    ///
553    /// $$
554    /// E\\{c(R)^{2}\\} = \frac{V^{2}}{\ln^{2}(1 + \mu)} \int_{0}^{\infty} 2 r e^{-r^{2}}
555    /// \ln^{2}(1 + a r)\\, dr = \frac{V^{2}}{\ln^{2}(1 + \mu)} \int_{0}^{\ln(1 + a r_{\max})}
556    /// \frac{2 r e^{-r^{2}} s^{2} e^{s}}{a}\\, ds, \qquad r = \frac{e^{s} - 1}{a},
557    /// $$
558    ///
559    /// an entire integrand in $s$, integrated by the 64-point Gauss–Legendre rule up to
560    /// $r_{\max} = 7$ (the tail beyond holds $e^{-49}$ of the power): within $10^{-13}$
561    /// (relative) of the integral for $a \le 10^{5}$.
562    fn mu_law_gain(mu: f64, v: f64) -> f64 {
563        let a = mu / v;
564        let half = (a * 7.0).ln_1p() / 2.0;
565        let (x, w) = mu_law_nodes();
566        let integral = x
567            .iter()
568            .zip(w)
569            .map(|(x, w)| {
570                let s = (x + 1.0) * half;
571                let r = s.exp_m1() / a;
572                w * 2.0 * r * (-r * r).exp() * s * s * s.exp() / a
573            })
574            .sum::<f64>()
575            * half;
576        let k = v / (1.0 + mu).ln();
577        1.0 / (k * k * integral).sqrt()
578    }
579
580    /// The law with the constants of its parameters.
581    fn law(&self) -> Law {
582        match *self {
583            Companding::MuLaw { mu, peak_db } => {
584                let v = db2amp(peak_db);
585                Law::MuLaw { mu, v, gain: Companding::mu_law_gain(mu, v) }
586            }
587            Companding::Exponential { degree } => {
588                Law::Exponential { degree, alpha: ((degree + 2.0) / degree).powf(degree / 2.0) }
589            }
590        }
591    }
592
593    /// Compress an envelope. Each call computes the normalisation of the μ-law (64
594    /// points); [`PaprReduction::reduce`] computes it once per block.
595    pub fn compress(&self, r: f64) -> f64 {
596        self.law().compress(r)
597    }
598
599    /// Expand an envelope (the inverse of [`Companding::compress`]).
600    pub fn expand(&self, r: f64) -> f64 {
601        self.law().expand(r)
602    }
603
604    fn apply(x: &mut [C64], f: impl Fn(f64) -> f64) {
605        map_envelope(x, |r| C64::new(f(r) / r, 0.0));
606    }
607}
608
609impl PaprReduction for Companding {
610    fn reduce(&self, ofdm: &Ofdm, data: &[C64]) -> Result<(Vec<C64>, usize)> {
611        if let Companding::MuLaw { mu, .. } = self {
612            ensure(*mu > 0.0, || "μ must be positive".into())?;
613        }
614        if let Companding::Exponential { degree } = self {
615            ensure(*degree > 0.0, || "the degree must be positive".into())?;
616        }
617        let mut x = ofdm.to_time(&ofdm.spread(data))?;
618        let law = self.law();
619        Companding::apply(&mut x, |r| law.compress(r));
620        Ok((x, 0))
621    }
622
623    fn restore_time(&self, block: &mut [C64]) {
624        let law = self.law();
625        Companding::apply(block, |r| law.expand(r));
626    }
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632    use comnum_math::power;
633
634    fn qam_data(c: &Constellation, n: usize, seed: u64) -> Vec<C64> {
635        let mut r = Rng::new(seed);
636        (0..n).map(|_| c.points()[r.below(c.order())]).collect()
637    }
638
639    /// The PAPR exceeded by 5 % of the blocks (the tail is what the techniques reduce).
640    fn papr_95(ofdm: &Ofdm, red: &dyn PaprReduction, data: &[C64]) -> f64 {
641        let k = red.data_len(ofdm);
642        let blocks = data.len() / k;
643        let (x, _) = ofdm.modulate_with(&data[..blocks * k], red).unwrap();
644        let mut p: Vec<f64> = x.chunks(ofdm.block_len()).map(papr_db).collect();
645        p.sort_by(f64::total_cmp);
646        p[(0.95 * p.len() as f64) as usize]
647    }
648
649    #[test]
650    fn the_ccdf_of_ofdm_follows_ochiai_and_imai() {
651        let ofdm = Ofdm::new(256, 256).unwrap();
652        let c = Constellation::qam(4).unwrap().normalized();
653        let blocks = 4000;
654        let x = ofdm.modulate(&qam_data(&c, 256 * blocks, 1)).unwrap();
655        let p: Vec<f64> = x.chunks(ofdm.block_len()).map(papr_db).collect();
656        for g in [8.0, 9.0, 10.0] {
657            let emp = p.iter().filter(|v| **v > g).count() as f64 / p.len() as f64;
658            let th = ccdf_oversampled(256, g);
659            assert!((emp / th - 1.0).abs() < 0.25, "{g} dB: {emp} vs {th}");
660            // the Nyquist-rate formula underestimates the peaks of the continuous signal
661            assert!(ccdf_nyquist(256, g) < th);
662        }
663    }
664
665    #[test]
666    fn bussgang_matches_a_monte_carlo() {
667        let mut r = Rng::new(2);
668        let x: Vec<C64> = (0..400_000).map(|_| r.complex_gaussian()).collect();
669        for cr in [0.0, 3.0, 6.0] {
670            let mut y = x.clone();
671            limit_envelope(&mut y, db2amp(cr));
672            let alpha: C64 = y.iter().zip(&x).map(|(a, b)| a * b.conj()).sum::<C64>() / power(&x) / x.len() as f64;
673            let th = comnum_dsp::nonlinear::clipping_bussgang(cr);
674            assert!((alpha - th.gain).norm() < 3e-3, "{cr} dB: {alpha} vs {}", th.gain);
675            assert!((power(&y) - th.output_power).abs() < 3e-3);
676            let d: f64 = y.iter().zip(&x).map(|(a, b)| (a - b * th.gain).norm_sqr()).sum::<f64>() / x.len() as f64;
677            assert!((d / th.distortion - 1.0).abs() < 0.03);
678        }
679    }
680
681    #[test]
682    fn every_technique_reduces_the_papr_and_lossless_ones_recover_the_data() {
683        let ofdm = Ofdm::new(128, 128).unwrap();
684        let c = Constellation::qam(16).unwrap().normalized();
685        let data = qam_data(&c, 128 * 200, 3);
686        let plain = papr_95(&ofdm, &crate::ofdm::NoReduction, &data);
687        let techniques: Vec<(&str, Box<dyn PaprReduction>, bool)> = vec![
688            ("clip", Box::new(Clip { ratio_db: 4.0 }), false),
689            ("icf", Box::new(ClipFilter { ratio_db: 4.0, iterations: 4 }), false),
690            ("slm", Box::new(Slm { candidates: 8, seed: 4 }), true),
691            ("pts", Box::new(Pts { subblocks: 4, phases: 4, partition: Partition::Pseudorandom(5) }), true),
692            ("tr", Box::new(ToneReservation::random(128, 12, 6, 4.0)), true),
693            ("ace", Box::new(Ace { constellation: c.clone(), ratio_db: 4.0, iterations: 10, step: 1.0 }), false),
694            ("exp", Box::new(Companding::Exponential { degree: 2.0 }), true),
695            ("mu", Box::new(Companding::MuLaw { mu: 4.0, peak_db: 9.0 }), true),
696        ];
697        for (name, t, lossless) in &techniques {
698            let k = t.data_len(&ofdm);
699            let d = &data[..(data.len() / k) * k];
700            let reduced = papr_95(&ofdm, t.as_ref(), d);
701            assert!(reduced < plain - 1.0, "{name}: {reduced} vs {plain}");
702            let (x, side) = ofdm.modulate_with(d, t.as_ref()).unwrap();
703            let back = ofdm.demodulate_with(&x, t.as_ref(), &side).unwrap();
704            if *lossless {
705                assert!(back.iter().zip(d).all(|(a, b)| (a - b).norm() < 1e-6), "{name}");
706            } else {
707                // ACE moves outer points outwards only: the decisions are unchanged
708                assert!(
709                    back.iter().zip(d).filter(|(a, b)| c.nearest(**a) != c.nearest(**b)).count() < d.len() / 50,
710                    "{name}"
711                );
712            }
713        }
714        // the exponential companding of degree 2 caps the PAPR at 3 dB
715        let (x, _) = ofdm.modulate_with(&data, &Companding::Exponential { degree: 2.0 }).unwrap();
716        assert!(papr_db(&x) <= comnum_math::lin2db(2.0) + 0.3);
717    }
718
719    /// The μ-law as it was computed before: the gain by Simpson's rule on 6000 intervals
720    /// of $t = r^{2}$ for every sample.
721    fn mu_law_compress_simpson(mu: f64, peak_db: f64, r: f64) -> f64 {
722        let v = db2amp(peak_db);
723        let c = |t: f64| v * (1.0 + mu * t.sqrt() / v).ln() / (1.0 + mu).ln();
724        let gain = 1.0 / comnum_math::simpson(|t| c(t).powi(2) * (-t).exp(), 0.0, 60.0, 6000).sqrt();
725        gain * v * (1.0 + mu * r / v).ln() / (1.0 + mu).ln()
726    }
727
728    #[test]
729    fn the_mu_law_gain_is_the_integral_and_follows_the_former_simpson_rule() {
730        // E{c(R)²} by mpmath.quad at 30 digits (mpmath 1.3), and the error of the former
731        // 6000-point Simpson rule on the gain
732        for (mu, peak_db, power, simpson_error) in [
733            (4.0, 9.0, 2.089_224_506_453_305, 3.5e-7),
734            (255.0, 0.0, 0.912_706_126_294_248_1, 2.6e-4),
735            (255.0, 12.0, 8.026_021_096_858_328, 1.1e-4),
736            (100.0, 3.0, 1.531_119_965_389_956, 1.2e-4),
737            (10.0, 6.0, 1.910_143_981_626_004_6, 3e-6),
738        ] {
739            // the gain g normalises E{(g c(R))²} to 1
740            let gain = Companding::mu_law_gain(mu, db2amp(peak_db));
741            let e = 1.0 / (gain * gain);
742            assert!((e / power - 1.0).abs() < 1e-13, "μ {mu}, {peak_db} dB: {e} vs {power}");
743            let law = Companding::MuLaw { mu, peak_db };
744            for r in [0.01, 0.3, 1.0, 2.0, 4.5] {
745                let (new, old) = (law.compress(r), mu_law_compress_simpson(mu, peak_db, r));
746                assert!((new / old - 1.0).abs() < simpson_error, "μ {mu}, {peak_db} dB, r {r}: {new} vs {old}");
747                assert!((law.expand(new) / r - 1.0).abs() < 1e-12);
748            }
749        }
750        // the unit average power of a Rayleigh envelope, by Monte Carlo
751        let law = Companding::MuLaw { mu: 255.0, peak_db: 0.0 }.law();
752        let mut rng = Rng::new(11);
753        let n = 400_000;
754        let p = (0..n).map(|_| law.compress(rng.complex_gaussian().norm()).powi(2)).sum::<f64>() / f64::from(n);
755        // the power of the compressed envelope has a standard deviation below 1 / sqrt(n)
756        assert!((p - 1.0).abs() < 5e-3, "{p}");
757    }
758
759    #[test]
760    fn ace_never_moves_a_point_across_a_decision_boundary() {
761        let ofdm = Ofdm::new(64, 64).unwrap();
762        let c = Constellation::qam(16).unwrap().normalized();
763        let data = qam_data(&c, 64 * 50, 7);
764        let ace = Ace { constellation: c.clone(), ratio_db: 3.0, iterations: 20, step: 1.0 };
765        let (x, side) = ofdm.modulate_with(&data, &ace).unwrap();
766        let back = ofdm.demodulate_with(&x, &ace, &side).unwrap();
767        assert_eq!(c.decide(&back), c.decide(&data));
768    }
769
770    #[test]
771    fn dft_spread_ofdm_has_a_lower_papr() {
772        let c = Constellation::qam(4).unwrap().normalized();
773        let data = qam_data(&c, 64 * 300, 8);
774        let ofdm = Ofdm::new(256, 64).unwrap();
775        let sc = Ofdm { dft_spread: true, ..ofdm.clone() };
776        let p0 = papr_95(&ofdm, &crate::ofdm::NoReduction, &data);
777        let p1 = papr_95(&sc, &crate::ofdm::NoReduction, &data);
778        assert!(p1 < p0 - 2.0, "{p1} vs {p0}");
779    }
780}
781
782/// Checks against the published closed forms (`references/`).
783#[cfg(test)]
784mod literature {
785    use super::*;
786
787    /// H. Ochiai and H. Imai, "On the distribution of the peak-to-average power ratio in
788    /// OFDM signals," IEEE Trans. Commun. 49(2), 2001: eq. (11) (p. 284), the Nyquist-rate
789    /// distribution `1 - (1 - e^{-λ})^N`, and eq. (27) (p. 287), the asymptotic crest
790    /// factor distribution `exp(-√(π/3) N r e^{-r²})` with `r² = λ`; the text (p. 287)
791    /// notes that even for `N = 100 000` the PAPR exceeds 15 dB with a probability of
792    /// about `1e-8`.
793    #[test]
794    fn ochiai_2001_eqs_11_and_27() {
795        let lambda = db2lin(10.0);
796        // a relative tolerance: powi rounds differently when the compiler folds it
797        // (found under the instrumentation of code coverage)
798        let nyquist = 1.0 - (1.0 - (-lambda).exp()).powi(256);
799        assert!((ccdf_nyquist(256, 10.0) / nyquist - 1.0).abs() < 1e-12);
800        let r = lambda.sqrt();
801        let crest = (-(PI / 3.0).sqrt() * 256.0 * r * (-r * r).exp()).exp();
802        assert!((ccdf_oversampled(256, 10.0) / (1.0 - crest) - 1.0).abs() < 1e-12);
803        let p15 = ccdf_oversampled(100_000, 15.0);
804        assert!(p15 > 3e-9 && p15 < 3e-8, "{p15:e}");
805    }
806}