Skip to main content

comnum_mapping/
theory.rs

1//! Closed-form error rates and information rates of constellations over AWGN.
2//!
3//! - [`ber`] (Gray-mapped QAM, PSK and PAM, OptiCommPy `theoryBER`) and [`ser`] give the
4//!   error rates as functions of $E_{b}/N_{0}$, and [`ebn0_for_ber`], [`ebn0_for_ser`]
5//!   their inverses;
6//! - [`ber_rayleigh_mrc`] gives the BER of BPSK and Gray QPSK with maximal-ratio
7//!   combining of independent Rayleigh branches;
8//! - [`mi`] and [`gmi`] give the symbol-wise and bit-wise mutual information by
9//!   Gauss–Hermite quadrature (OptiCommPy `theoryMI`, `theoryGMI`), and
10//!   [`constellation_mi`], [`constellation_gmi`] the same for any constellation and
11//!   labelling, over AWGN or Rayleigh fading ([`RateChannel`]): the capacities of coded
12//!   modulation and BICM;
13//! - [`ber_dpsk`], [`ber_dbpsk_coherent`] and [`differential_phase_tail`] give the error
14//!   rates of differential PSK ([`crate::differential`]);
15//! - [`ber_gray_pam`], [`ber_gray_qam`] and [`ber_gray_rect_qam`] give the exact BER of
16//!   Gray-mapped PAM, square and rectangular QAM (Cho and Yoon 2002), [`ber_exact`] the
17//!   exact BER of a family where it exists, and [`phase_pdf`]
18//!   the density of the phase of a carrier in Gaussian noise;
19//! - [`ser_orthogonal_noncoherent`] and [`ber_orthogonal_noncoherent`] give the SER and
20//!   the BER of $M$ orthogonal signals detected noncoherently (M-FSK, chirp spread
21//!   spectrum).
22//!
23//! The closed forms are functions of $E_{b}/N_{0}$ (`ebn0_db`) unless their name or
24//! their argument says otherwise: [`ser_esn0`] and the orthogonal signals take
25//! $E_{s}/N_{0}$ (`esn0_db`).
26//!
27//! They are properties of a constellation and its labelling, so they live with the
28//! constellations; `comnum-metrics` re-exports them as `theory_ber`, `theory_ser`,
29//! `theory_mi` and `theory_gmi`. The functions of a family of constellations take the
30//! family and the order first: `(kind, m, …)`.
31//!
32//! # Model
33//!
34//! For BPSK over AWGN,
35//!
36//! $$
37//! P_{b} = Q\left(\sqrt{2 E_{b} / N_{0}}\right)
38//! $$
39//!
40//! # Examples
41//! ```
42//! use comnum_mapping::{theory, constellation::ConstellationKind};
43//! let pb = theory::ber(ConstellationKind::Qam, 4, 6.0).unwrap();
44//! assert!((pb - 2.388e-3).abs() < 1e-6);
45//! ```
46//!
47//! # References
48//!
49//! 1. J. G. Proakis and M. Salehi, *Digital Communications*, 5th ed., McGraw-Hill, 2008.
50//! 2. R. F. Pawula, S. O. Rice and J. H. Roberts, "Distribution of the phase angle
51//!    between two vectors perturbed by Gaussian noise," *IEEE Trans. Commun.*, 30(8),
52//!    1828–1841, 1982.
53
54use crate::constellation::{Constellation, ConstellationKind, log_priors, qam_side};
55use crate::demapper::LabelHalves;
56use comnum_core::{Error, Result, ensure};
57use comnum_math::{C64, Jacobian, bessel_i0e, bisect, db2lin, entropy, log_sum_exp, qfunc};
58
59/// The orders of the closed forms of [`ber`] and [`ser`]: a power of two of at least 2,
60/// a square for QAM.
61fn ensure_order(kind: ConstellationKind, m: usize) -> Result<()> {
62    ensure(m >= 2 && m.is_power_of_two(), || format!("the order must be a power of two ≥ 2, got {m}"))?;
63    ensure(kind != ConstellationKind::Qam || qam_side(m).is_some(), || {
64        format!("the closed forms of QAM need a square order, got {m}")
65    })
66}
67
68/// Theoretical bit error rate over AWGN with Gray mapping (OptiCommPy `theoryBER`).
69///
70/// - QAM (`L = √M`): `P_b = 2(1 - 1/L)/log2 L · Q(√(3 log2 L/(L²-1) · 2 E_b/N_0))`;
71/// - PSK: `P_b = 2 Q(√(2 log2 M · E_b/N_0) sin(π/M)) / log2 M`, and the exact
72///   `Q(√(2 E_b/N_0))` for `M = 2` (OptiCommPy applies the general formula, which counts
73///   two nearest neighbours and doubles the BER of BPSK);
74/// - PAM: `P_b = 2(M-1)/M · Q(√(6 log2 M/(M²-1) · E_b/N_0)) / log2 M`.
75///
76/// # Errors
77/// [`Error::Config`] for another family, an order that is not a power of two (at least
78/// 2), or a QAM that is not square.
79pub fn ber(kind: ConstellationKind, m: usize, ebn0_db: f64) -> Result<f64> {
80    ensure_order(kind, m)?;
81    let g = db2lin(ebn0_db);
82    let mf = m as f64;
83    let k = mf.log2();
84    match kind {
85        ConstellationKind::Qam => {
86            let l = mf.sqrt();
87            Ok(2.0 * (1.0 - 1.0 / l) / l.log2() * qfunc((3.0 * l.log2() / (l * l - 1.0) * 2.0 * g).sqrt()))
88        }
89        ConstellationKind::Psk if m == 2 => Ok(qfunc((2.0 * g).sqrt())),
90        ConstellationKind::Psk => Ok(2.0 * qfunc((2.0 * k * g).sqrt() * (std::f64::consts::PI / mf).sin()) / k),
91        ConstellationKind::Pam => Ok(2.0 * (mf - 1.0) / mf * qfunc((6.0 * k / (mf * mf - 1.0) * g).sqrt()) / k),
92        other => Err(Error::Config(format!("no theoretical BER for {other:?}"))),
93    }
94}
95
96/// Theoretical symbol error rate over AWGN as a function of `E_b/N_0`.
97///
98/// - square QAM: exact, `P_s = 1 - (1 - p)²` with `p = 2(1 - 1/L) Q(√(3 E_s/N_0/(M-1)))`;
99/// - PAM: exact, `P_s = 2(M-1)/M · Q(√(6 E_s/N_0/(M²-1)))`;
100/// - PSK: the nearest-neighbour bound `2 Q(√(2 E_s/N_0) sin(π/M))` (exact for `M = 2`
101///   when halved, tight for `M ≥ 4`).
102///
103/// # Errors
104/// As [`ber`].
105pub fn ser(kind: ConstellationKind, m: usize, ebn0_db: f64) -> Result<f64> {
106    ensure_order(kind, m)?;
107    ser_at(kind, m, db2lin(ebn0_db) * (m as f64).log2())
108}
109
110/// [`ser`] as a function of $E_{s}/N_{0}$ (dB), the SNR per symbol: the entry point of
111/// the closed forms whose SNR is per symbol (CE-OFDM, a symbol per subcarrier).
112///
113/// # Errors
114/// As [`ber`].
115///
116/// # Examples
117/// ```
118/// use comnum_mapping::{constellation::ConstellationKind, theory};
119/// // QPSK carries 2 bits: Es/N0 = Eb/N0 + 3.01 dB
120/// let (a, b) = (theory::ser_esn0(ConstellationKind::Qam, 4, 10.0 + 10.0 * 2f64.log10())?, theory::ser(ConstellationKind::Qam, 4, 10.0)?);
121/// assert!((a / b - 1.0).abs() < 1e-12);
122/// # Ok::<(), comnum_core::Error>(())
123/// ```
124pub fn ser_esn0(kind: ConstellationKind, m: usize, esn0_db: f64) -> Result<f64> {
125    ensure_order(kind, m)?;
126    ser_at(kind, m, db2lin(esn0_db))
127}
128
129/// The SER of [`ser`] at the linear $E_{s}/N_{0}$ `es`.
130fn ser_at(kind: ConstellationKind, m: usize, es: f64) -> Result<f64> {
131    let mf = m as f64;
132    match kind {
133        ConstellationKind::Qam => {
134            let l = mf.sqrt();
135            let p = 2.0 * (1.0 - 1.0 / l) * qfunc((3.0 * es / (mf - 1.0)).sqrt());
136            Ok(1.0 - (1.0 - p) * (1.0 - p))
137        }
138        ConstellationKind::Pam => Ok(2.0 * (mf - 1.0) / mf * qfunc((6.0 * es / (mf * mf - 1.0)).sqrt())),
139        ConstellationKind::Psk => {
140            let p = 2.0 * qfunc((2.0 * es).sqrt() * (std::f64::consts::PI / mf).sin());
141            Ok(if m == 2 { p / 2.0 } else { p })
142        }
143        other => Err(Error::Config(format!("no theoretical SER for {other:?}"))),
144    }
145}
146
147/// The average SER of `m`-PSK or square `m`-QAM over a fading channel whose SNR per
148/// symbol $\gamma$ has the moment generating function `mgf` $(s) = \mathbb{E}\,
149/// e^{s\gamma}$, $s \le 0$, by the MGF approach with Craig's form of the $Q$ function
150/// (E. Basar et al., "Wireless communications through reconfigurable intelligent
151/// surfaces," *IEEE Access*, 2019, eqs. (16) and (21)): with
152/// $g = \sin^{2}(\pi/M)$,
153///
154/// $$
155/// P_{e}^{\mathrm{PSK}} = \frac{1}{\pi} \int_{0}^{(M-1)\pi/M} M_{\gamma}\Bigl(-\frac{g}{\sin^{2}\eta}\Bigr) d\eta ,
156/// $$
157///
158/// and with $c = 3/(2(M-1))$ and $q = 1 - 1/\sqrt{M}$,
159///
160/// $$
161/// P_{e}^{\mathrm{QAM}} = \frac{4q}{\pi} \int_{0}^{\pi/2} M_{\gamma}\Bigl(-\frac{c}{\sin^{2}\eta}\Bigr) d\eta -
162///   \frac{4q^{2}}{\pi} \int_{0}^{\pi/4} M_{\gamma}\Bigl(-\frac{c}{\sin^{2}\eta}\Bigr) d\eta ,
163/// $$
164///
165/// by Gauss–Legendre quadrature of 96 nodes on every interval. With the MGF
166/// $e^{s\gamma_{0}}$ of a constant SNR, these are the SERs of [`ser_esn0`] over AWGN.
167///
168/// # Errors
169/// [`Error::Config`] for another family or an order that is not a power of two (and,
170/// for QAM, not a square).
171///
172/// # Examples
173/// ```
174/// use comnum_mapping::{theory, constellation::ConstellationKind};
175/// // BPSK over Rayleigh fading of mean SNR 10: M(s) = 1/(1 - 10 s)
176/// let p = theory::ser_mgf(ConstellationKind::Psk, 2, |s| 1.0 / (1.0 - 10.0 * s))?;
177/// assert!((p - 0.5 * (1.0 - (10.0f64 / 11.0).sqrt())).abs() < 1e-12);
178/// # Ok::<(), comnum_core::Error>(())
179/// ```
180pub fn ser_mgf(kind: ConstellationKind, m: usize, mgf: impl Fn(f64) -> f64) -> Result<f64> {
181    ensure_order(kind, m)?;
182    let mf = m as f64;
183    let (x, w) = comnum_math::gauss_legendre(96);
184    // ∫_0^b M(-c / sin² η) dη
185    let integral = |c: f64, b: f64| -> f64 {
186        x.iter().zip(&w).map(|(t, wt)| wt * mgf(-c / (b / 2.0 * (t + 1.0)).sin().powi(2))).sum::<f64>() * b / 2.0
187    };
188    match kind {
189        ConstellationKind::Psk => {
190            let g = (std::f64::consts::PI / mf).sin().powi(2);
191            Ok(integral(g, (mf - 1.0) * std::f64::consts::PI / mf) / std::f64::consts::PI)
192        }
193        ConstellationKind::Qam => {
194            let (c, q) = (1.5 / (mf - 1.0), 1.0 - 1.0 / mf.sqrt());
195            let pi = std::f64::consts::PI;
196            Ok(4.0 * q / pi * integral(c, pi / 2.0) - 4.0 * q * q / pi * integral(c, pi / 4.0))
197        }
198        other => Err(Error::Config(format!("no SER by the MGF for {other:?}"))),
199    }
200}
201
202/// The $E_{b}/N_{0}$ (dB) at which the BER of [`ber`] equals `target`: the bisection
203/// (200 steps) of $[-10, 60]$ dB, where the BER decreases.
204///
205/// # Errors
206/// [`Error::Config`] for a family without a closed form, or a target that no
207/// $E_{b}/N_{0}$ of $[-10, 60]$ dB reaches.
208///
209/// # Examples
210/// ```
211/// use comnum_mapping::{theory, constellation::ConstellationKind};
212/// // QPSK: Q(√(2 Eb/N0)) = 1e-3 at 6.79 dB
213/// let e = theory::ebn0_for_ber(ConstellationKind::Qam, 4, 1e-3).unwrap();
214/// assert!((e - 6.79).abs() < 0.01);
215/// ```
216pub fn ebn0_for_ber(kind: ConstellationKind, m: usize, target: f64) -> Result<f64> {
217    ebn0_for(|e| ber(kind, m, e), "BER", target)
218}
219
220/// The $E_{b}/N_{0}$ (dB) at which the SER of [`ser`] equals `target`, as
221/// [`ebn0_for_ber`].
222///
223/// # Errors
224/// As [`ebn0_for_ber`].
225///
226/// # Examples
227/// ```
228/// use comnum_mapping::{theory, constellation::ConstellationKind};
229/// let e = theory::ebn0_for_ser(ConstellationKind::Qam, 16, 1e-4).unwrap();
230/// let s = theory::ser(ConstellationKind::Qam, 16, e).unwrap();
231/// assert!((s / 1e-4 - 1.0).abs() < 1e-9);
232/// ```
233pub fn ebn0_for_ser(kind: ConstellationKind, m: usize, target: f64) -> Result<f64> {
234    ebn0_for(|e| ser(kind, m, e), "SER", target)
235}
236
237/// The root of `rate(e) = target` on `[-10, 60]` dB for a decreasing `rate`.
238fn ebn0_for(rate: impl Fn(f64) -> Result<f64>, what: &str, target: f64) -> Result<f64> {
239    let (a, b) = (-10.0, 60.0);
240    ensure(rate(a)? > target && target > rate(b)?, || format!("no Eb/N0 in [-10, 60] dB gives a {what} of {target}"))?;
241    // the family has a closed form (checked above): `rate` cannot fail on the bracket
242    Ok(bisect(|e| rate(e).is_ok_and(|v| v > target), a, b, 200))
243}
244
245/// The bit error rate of BPSK (or Gray QPSK, per bit) with maximal-ratio combining of
246/// `branches` independent Rayleigh-faded branches of mean SNR per bit `mean_ebn0`
247/// ($\bar\gamma$, linear) each (Proakis & Salehi, *Digital Communications*, 5th ed.,
248/// eq. (13.4-15)):
249///
250/// $$
251/// P_{b} = \left(\frac{1 - \mu}{2}\right)^{L} \sum_{k=0}^{L-1} \binom{L - 1 + k}{k}
252/// \left(\frac{1 + \mu}{2}\right)^{k}, \qquad \mu = \sqrt{\frac{\bar\gamma}{1 + \bar\gamma}},
253/// $$
254///
255/// with $\bar\gamma$ the mean $E_{b}/N_{0}$ per branch (linear) and $L$ the number of
256/// branches; $L = 1$ is the flat Rayleigh channel, $P_{b} = \frac{1}{2}(1 - \mu)$.
257///
258/// # Errors
259/// [`Error::Config`] for no branch.
260///
261/// # Examples
262/// ```
263/// use comnum_mapping::theory::ber_rayleigh_mrc;
264/// let g = comnum_math::db2lin(10.0);
265/// assert!((ber_rayleigh_mrc(g, 1)? - 0.5 * (1.0 - (g / (1.0 + g)).sqrt())).abs() < 1e-15);
266/// // diversity: four branches at 10 dB each
267/// assert!(ber_rayleigh_mrc(g, 4)? < 1e-5);
268/// # Ok::<(), comnum_core::Error>(())
269/// ```
270pub fn ber_rayleigh_mrc(mean_ebn0_lin: f64, branches: usize) -> Result<f64> {
271    ensure(branches >= 1, || "maximal-ratio combining needs at least one branch".into())?;
272    let mu = (mean_ebn0_lin / (1.0 + mean_ebn0_lin)).sqrt();
273    let l = branches;
274    let mut binom = 1.0;
275    let mut sum = 0.0;
276    for k in 0..l {
277        if k > 0 {
278            binom *= (l - 1 + k) as f64 / k as f64;
279        }
280        sum += binom * ((1.0 + mu) / 2.0).powi(k as i32);
281    }
282    Ok(((1.0 - mu) / 2.0).powi(l as i32) * sum)
283}
284
285/// The normalised points, their probabilities, `N_0` and the quadrature nodes of a
286/// theory function.
287pub(crate) struct TheorySetup {
288    /// The points, at unit energy under `probs`; point `i` carries the bits of `i`.
289    pub(crate) points: Vec<C64>,
290    /// The probabilities of the points.
291    pub(crate) probs: Vec<f64>,
292    /// The noise of the metrics, $N_{0}$.
293    pub(crate) n0: f64,
294    /// The quadrature nodes `(w, weight)` of the noise.
295    pub(crate) nodes: Vec<(C64, f64)>,
296}
297
298/// The normalised points, the probabilities and the quadrature of a theory function of
299/// a constellation, its labels included (`points()[i]` carries the bits of `i`).
300pub(crate) fn setup(c: &Constellation, px: Option<&[f64]>, snr_db: f64, points: usize) -> Result<TheorySetup> {
301    let p = c.priors(px)?;
302    let pts = c.normalized_under(&p)?.points().to_vec();
303    // OptiCommPy: a real constellation sees a real channel of variance 1/SNR, a complex
304    // one a complex channel of variance 1/SNR (1/(2 SNR) per dimension)
305    let real = c.kind().is_real();
306    let sigma2 = db2lin(-snr_db);
307    let n0 = if real { 2.0 * sigma2 } else { sigma2 };
308    Ok(TheorySetup { points: pts, probs: p, n0, nodes: comnum_math::gaussian_nodes(sigma2, !real, points) })
309}
310
311/// The channel of [`constellation_mi`], [`constellation_gmi`] and of the cutoff rates of
312/// [`crate::bicm`].
313#[derive(Clone, Copy, Debug, PartialEq, Eq)]
314pub enum RateChannel {
315    /// No fading: $g = 1$.
316    Awgn,
317    /// Flat Rayleigh fading, $g \sim \mathcal{CN}(0, 1)$ independent from symbol to
318    /// symbol (ideal interleaving), known at the receiver.
319    Rayleigh,
320}
321
322impl std::str::FromStr for RateChannel {
323    type Err = Error;
324
325    /// `"awgn"` or `"rayleigh"`.
326    fn from_str(name: &str) -> Result<RateChannel> {
327        match name.to_ascii_lowercase().as_str() {
328            "awgn" => Ok(RateChannel::Awgn),
329            "rayleigh" => Ok(RateChannel::Rayleigh),
330            other => Err(Error::Config(format!("unknown fading {other:?}: awgn, rayleigh"))),
331        }
332    }
333}
334
335/// The mean of `f(snr_db)` over the channel: `f(snr_db)` itself without fading, and
336/// $\mathbb{E}\, f(\mathrm{snr} + 10 \log_{10} |g|^{2})$ over Rayleigh fading, the
337/// integral over $u = \ln |g|^{2}$, of density $e^{u - e^{u}}$, by Simpson's rule with a
338/// step of 1/8.
339pub(crate) fn over_fading(f: impl Fn(f64) -> Result<f64>, snr_db: f64, fading: RateChannel) -> Result<f64> {
340    if fading == RateChannel::Awgn {
341        return f(snr_db);
342    }
343    // below e^{lo} the integrand is f(0) to first order, with the mass 1 - e^{-e^{lo}}
344    let lo = (1e-4 / db2lin(snr_db).max(1.0)).ln();
345    let hi = 40f64.ln();
346    let n = (8.0 * (hi - lo)).ceil() as usize;
347    let at = |u: f64| -> Result<f64> { Ok((u - u.exp()).exp() * f(snr_db + 10.0 * u / std::f64::consts::LN_10)?) };
348    let tail = -(-lo.exp()).exp_m1() * f(snr_db + 10.0 * lo / std::f64::consts::LN_10)?;
349    Ok(comnum_math::try_simpson(at, lo, hi, n)? + tail)
350}
351
352/// The Gauss–Hermite nodes per dimension of the information rates when the caller does
353/// not choose ([`constellation_mi`], [`constellation_gmi`], the BICM cutoff rate): the
354/// default of the `theory` checks of the specifications and of the Python closed forms.
355pub const DEFAULT_QUADRATURE_POINTS: usize = 40;
356
357/// The mutual information (bits/symbol) of the constellation `c` with the probabilities
358/// `px` (uniform if `None`) over the channel `fading`: [`mi`] of any constellation,
359/// averaged over the fading. With uniform inputs it is the capacity of coded modulation
360/// (Caire, Taricco & Biglieri 1998, eq. (10)).
361///
362/// # Errors
363/// [`Error::Config`] for invalid probabilities.
364pub fn constellation_mi(
365    c: &Constellation,
366    snr_db: f64,
367    px: Option<&[f64]>,
368    fading: RateChannel,
369    points: usize,
370) -> Result<f64> {
371    over_fading(|s| Ok(mi_of(&setup(c, px, s, points)?)), snr_db, fading)
372}
373
374/// The generalized mutual information (bits/symbol) of the bit-wise decoder of the
375/// labelled constellation `c` (point `i` carries the bits of `i`) with the probabilities
376/// `px` over the channel `fading`: [`gmi`] of any labelling, averaged over the fading.
377/// With uniform inputs it is the capacity of BICM (Caire, Taricco & Biglieri 1998,
378/// eq. (14)).
379///
380/// # Errors
381/// [`Error::Config`] for invalid probabilities.
382pub fn constellation_gmi(
383    c: &Constellation,
384    snr_db: f64,
385    px: Option<&[f64]>,
386    fading: RateChannel,
387    points: usize,
388) -> Result<f64> {
389    over_fading(|s| Ok(gmi_of(&setup(c, px, s, points)?)), snr_db, fading)
390}
391
392/// Mutual information (bits/symbol) of a constellation over AWGN, by Gauss–Hermite
393/// quadrature (OptiCommPy `theoryMI`).
394///
395/// `snr_db` is `E_s/σ²` with `E_s` the mean energy under `px` (uniform if `None`);
396/// a real constellation (PAM, OOK) sees a real channel, a complex one a circular
397/// complex channel, as in OptiCommPy. With `D_ij(w) = (|x_i - x_j + w|² - |w|²)/N_0`,
398///
399/// `MI = -Σ_i p_i E_w log2 Σ_j p_j e^{-D_ij(w)}`,
400///
401/// evaluated with `points` nodes per dimension (40 give 6 digits for 64-QAM).
402///
403/// # Errors
404/// [`Error::Config`] for an invalid constellation or probabilities.
405///
406/// # Examples
407/// ```
408/// use comnum_mapping::{theory, constellation::ConstellationKind};
409/// // the MI of 16-QAM approaches 4 bits at high SNR and stays below the capacity
410/// let mi = theory::mi(ConstellationKind::Qam, 16, 10.0, None, 40).unwrap();
411/// assert!(mi < (1.0 + 10.0f64).log2() && mi > 2.5);
412/// let hi = theory::mi(ConstellationKind::Qam, 16, 30.0, None, 40).unwrap();
413/// assert!((hi - 4.0).abs() < 1e-6);
414/// ```
415pub fn mi(kind: ConstellationKind, m: usize, snr_db: f64, px: Option<&[f64]>, points: usize) -> Result<f64> {
416    constellation_mi(&Constellation::new(kind, m)?, snr_db, px, RateChannel::Awgn, points)
417}
418
419/// The kernel of [`mi`].
420pub(crate) fn mi_of(s: &TheorySetup) -> f64 {
421    let TheorySetup { points: x, probs: p, n0, nodes } = s;
422    let (n0, lp) = (*n0, log_priors(p));
423    let mut acc = 0.0;
424    let mut terms = vec![0.0; x.len()];
425    for (i, xi) in x.iter().enumerate() {
426        if p[i] == 0.0 {
427            continue;
428        }
429        let mut e = 0.0;
430        for (w, a) in nodes {
431            for (j, xj) in x.iter().enumerate() {
432                terms[j] = lp[j] - ((xi - xj + w).norm_sqr() - w.norm_sqr()) / n0;
433            }
434            e += a * log_sum_exp(&terms);
435        }
436        acc += p[i] * e;
437    }
438    -acc / std::f64::consts::LN_2
439}
440
441/// Generalised mutual information (bits/symbol) of the bit-wise decoder of a Gray-labelled
442/// constellation over AWGN, by Gauss–Hermite quadrature (OptiCommPy `theoryGMI`).
443///
444/// `GMI = H(X) - Σ_k H(B_k | Y)` with the labels of the points (point `i` carries the
445/// bits of `i`); same channel conventions and parameters as [`mi`]. For uniform symbols
446/// it is `Σ_k I(B_k; Y)`, OptiCommPy's formula; with shaping the bits are dependent and
447/// `Σ_k I(B_k; Y)` can exceed the entropy of the source, while `H(X) - Σ_k H(B_k | Y)`
448/// is the rate achievable by bit-metric decoding (Böcherer 2014) and the limit of the
449/// Monte Carlo estimators (OptiCommPy's `monteCarloGMI` included).
450///
451/// G. Böcherer, "Achievable rates for shaped bit-metric decoding," arXiv:1410.8075,
452/// 2014; A. Alvarado, T. Fehenberger, B. Chen and F. M. J. Willems, *J. Lightw.
453/// Technol.*, 36(2), 424–439, 2018.
454///
455/// # Errors
456/// [`Error::Config`] for an invalid constellation or probabilities.
457pub fn gmi(kind: ConstellationKind, m: usize, snr_db: f64, px: Option<&[f64]>, points: usize) -> Result<f64> {
458    constellation_gmi(&Constellation::new(kind, m)?, snr_db, px, RateChannel::Awgn, points)
459}
460
461/// The kernel of [`gmi`].
462pub(crate) fn gmi_of(s: &TheorySetup) -> f64 {
463    // GMI = H(X) - Σ_k H(B_k | Y): with shaped (dependent) bits, the sum of the
464    // bit-wise mutual informations Σ_k I(B_k; Y) can exceed H(X) and is not achievable
465    let mut gmi = entropy(&s.probs);
466    // -log2 P(b_k = b_k(i) | y)
467    for h_cond in bit_expectations(s, |all, same| log_sum_exp(all) - same) {
468        gmi -= h_cond / std::f64::consts::LN_2;
469    }
470    gmi
471}
472
473/// For every bit position `k`, $\sum_{i} p_{i} E_{w} f(\ell, \ell_{k})$ with
474/// `ℓ[j] = ln p_j - (|x_i - x_j + w|² - |w|²)/N_0` (the log-metrics of $y = x_{i} + w$,
475/// up to a common term) and $\ell_{k}$ their log-sum over the points whose bit `k`
476/// equals that of `i`; the metrics of a noise sample are computed once for all bits.
477pub(crate) fn bit_expectations(s: &TheorySetup, f: impl Fn(&[f64], f64) -> f64) -> Vec<f64> {
478    let TheorySetup { points: x, probs: p, n0, nodes } = s;
479    let m = x.len();
480    let b = m.trailing_zeros() as usize;
481    let lp = log_priors(p);
482    let mut all = vec![0.0; m];
483    let mut out = vec![0.0; b];
484    let mut e = vec![0.0; b];
485    for (i, xi) in x.iter().enumerate() {
486        if p[i] == 0.0 {
487            continue;
488        }
489        e.fill(0.0);
490        for (w, a) in nodes {
491            for (j, xj) in x.iter().enumerate() {
492                all[j] = lp[j] - ((xi - xj + w).norm_sqr() - w.norm_sqr()) / n0;
493            }
494            for (k, ek) in e.iter_mut().enumerate() {
495                let shift = b - 1 - k;
496                let bi = (i >> shift) & 1;
497                let same = LabelHalves::new(&all, shift, Jacobian::Exact).log_sum(bi);
498                *ek += a * f(&all, same);
499            }
500        }
501        for (o, ek) in out.iter_mut().zip(&e) {
502            *o += p[i] * ek;
503        }
504    }
505    out
506}
507
508/// The probability that the phase angle between two vectors of the same SNR `esn0_lin`
509/// (linear, $E_{s}/N_{0}$ each) perturbed by independent Gaussian noise exceeds `psi`
510/// in absolute value, $0 \le \psi \le \pi$ (Pawula, Rice & Roberts, 1982, the integral
511/// form for equal SNRs):
512///
513/// $$
514/// P(|\Delta\theta| > \psi) = \frac{\sin \psi}{2 \pi} \int_{-\pi/2}^{\pi/2}
515/// \frac{\exp\left[-\gamma_{s} (1 - \cos \psi \cos t)\right]}{1 - \cos \psi \cos t} \\, dt,
516/// $$
517///
518/// evaluated by Simpson's rule with 2 000 intervals (the integrand is smooth). It is
519/// the symbol error rate of M-DPSK for $\psi = \pi/M$, and $\frac{1}{2} e^{-\gamma_{s}}$
520/// for $\psi = \pi/2$ (DBPSK).
521///
522/// # Examples
523/// ```
524/// use comnum_mapping::theory::differential_phase_tail;
525/// let g = 5.0;
526/// assert!((differential_phase_tail(std::f64::consts::FRAC_PI_2, g) - 0.5 * (-g).exp()).abs() < 1e-9);
527/// assert!((differential_phase_tail(0.0, g) - 1.0).abs() < 1e-9);
528/// assert!(differential_phase_tail(std::f64::consts::PI, g).abs() < 1e-15);
529/// ```
530pub fn differential_phase_tail(psi: f64, esn0_lin: f64) -> f64 {
531    let psi = psi.clamp(0.0, std::f64::consts::PI);
532    if psi == 0.0 {
533        return 1.0;
534    }
535    let (s, c) = psi.sin_cos();
536    let f = |t: f64| {
537        let d = 1.0 - c * t.cos();
538        (-esn0_lin * d).exp() / d
539    };
540    let half = std::f64::consts::FRAC_PI_2;
541    s / (2.0 * std::f64::consts::PI) * comnum_math::simpson(f, -half, half, 2000)
542}
543
544/// The terms of eq. (9) of Cho and Yoon for the bit `k` (1 = most significant) of
545/// Gray-mapped `side`-PAM: the pairs $(X, Y)$ of Table I, so that
546/// $P_{b}(k) = \frac{1}{I} \sum X \\, \mathrm{erfc}(Y \cdot \ldots)$, with
547/// $X = (-1)^{\lfloor i 2^{k-1}/I \rfloor} (2^{k-1} - \lfloor i 2^{k-1}/I + \frac{1}{2} \rfloor)$
548/// and $Y = 2i + 1$ for $i = 0 \dots (1 - 2^{-k}) I - 1$.
549fn cho_yoon_terms(side: usize, k: usize) -> impl Iterator<Item = (f64, usize)> {
550    let step = 1usize << (k - 1);
551    (0..side - side / (1 << k)).map(move |i| {
552        let sign = if (i * step / side) % 2 == 0 { 1.0 } else { -1.0 };
553        let weight = step as f64 - ((i * step) as f64 / side as f64 + 0.5).floor();
554        (sign * weight, 2 * i + 1)
555    })
556}
557
558/// $\sum_{k} P_{b}(k)$ over the $\log_{2} I$ bits of Gray-mapped $I$-PAM, eq. (9) of Cho
559/// and Yoon with $\mathrm{erfc}(Y a) = 2Q(Y x)$: `x` is the half distance between
560/// neighbouring levels over the noise standard deviation.
561fn gray_pam_bit_errors(side: usize, x: f64) -> f64 {
562    let levels = side.trailing_zeros() as usize;
563    (1..=levels)
564        .map(|k| cho_yoon_terms(side, k).map(|(w, y)| w * 2.0 * qfunc(y as f64 * x)).sum::<f64>() / side as f64)
565        .sum()
566}
567
568fn ensure_pam_side(side: usize, what: &str) -> Result<()> {
569    ensure(side >= 2 && side.is_power_of_two(), || {
570        format!("the exact Gray BER needs {what} a power of two ≥ 2, got {side}")
571    })
572}
573
574/// Exact BER of Gray-mapped `m`-PAM over AWGN (K. Cho and D. Yoon, "On the general BER
575/// expression of one- and two-dimensional amplitude modulations," *IEEE Trans. Commun.*
576/// 50(7), 1074–1080, 2002, eqs. (9)–(10)), as a function of $E_{b}/N_{0}$ in dB:
577///
578/// $$P_{b} = \frac{1}{\log_{2} I} \sum_{k=1}^{\log_{2} I} P_{b}(k)\\,, \qquad
579/// P_{b}(k) = \frac{1}{I} \sum_{i} X_{i,k} \\, \mathrm{erfc}\left((2i + 1)
580/// \sqrt{\frac{3 \log_{2} I \cdot E_{b}/N_{0}}{I^{2} - 1}}\right),$$
581///
582/// with the coefficients $X_{i,k}$ of Table I of the paper. Unlike the
583/// nearest-neighbour approximation of [`ber`], it tends to 1/2 at low SNR.
584///
585/// # Errors
586/// [`Error::Config`] unless `m` is a power of two ≥ 2.
587///
588/// # Examples
589/// ```
590/// use comnum_mapping::theory::ber_gray_pam;
591/// use comnum_math::{db2lin, qfunc};
592/// // 4-PAM: (3Q(x) + 2Q(3x) - Q(5x))/4 with x = √(4/5 · E_b/N_0)
593/// let x = (0.8 * db2lin(10.0)).sqrt();
594/// let want = (3.0 * qfunc(x) + 2.0 * qfunc(3.0 * x) - qfunc(5.0 * x)) / 4.0;
595/// assert!((ber_gray_pam(4, 10.0).unwrap() - want).abs() < 1e-15);
596/// ```
597pub fn ber_gray_pam(m: usize, ebn0_db: f64) -> Result<f64> {
598    ensure_pam_side(m, "an order")?;
599    let log2m = f64::from(m.trailing_zeros());
600    let x = (6.0 * log2m * db2lin(ebn0_db) / ((m * m) as f64 - 1.0)).sqrt();
601    Ok(gray_pam_bit_errors(m, x) / log2m)
602}
603
604/// Exact BER of Gray-mapped `mi × mj` rectangular QAM over AWGN, two Gray-mapped PAM
605/// of `mi` and `mj` levels in quadrature (Cho and Yoon 2002, eqs. (19)–(22)):
606///
607/// $$P_{b} = \frac{1}{\log_{2}(IJ)} \left(\sum_{k=1}^{\log_{2} I} P_{I}(k) +
608/// \sum_{l=1}^{\log_{2} J} P_{J}(l)\right),$$
609///
610/// the terms of [`ber_gray_pam`] with the argument
611/// $(2i + 1)\sqrt{3 \log_{2}(IJ) \\, E_{b}/N_{0} / (I^{2} + J^{2} - 2)}$.
612///
613/// # Errors
614/// [`Error::Config`] unless `mi` and `mj` are powers of two ≥ 2.
615///
616/// # Examples
617/// ```
618/// use comnum_mapping::theory::{ber_gray_qam, ber_gray_rect_qam};
619/// // a square QAM is the case I = J
620/// let (a, b) = (ber_gray_rect_qam(4, 4, 8.0).unwrap(), ber_gray_qam(16, 8.0).unwrap());
621/// assert!((a - b).abs() < 1e-15);
622/// // 32-QAM as 8 × 4 lies between 16- and 64-QAM
623/// let p32 = ber_gray_rect_qam(8, 4, 12.0).unwrap();
624/// assert!(p32 > ber_gray_qam(16, 12.0).unwrap() && p32 < ber_gray_qam(64, 12.0).unwrap());
625/// ```
626pub fn ber_gray_rect_qam(mi: usize, mj: usize, ebn0_db: f64) -> Result<f64> {
627    ensure_pam_side(mi, "both sides")?;
628    ensure_pam_side(mj, "both sides")?;
629    let log2m = f64::from(mi.trailing_zeros() + mj.trailing_zeros());
630    let x = (6.0 * log2m * db2lin(ebn0_db) / ((mi * mi + mj * mj) as f64 - 2.0)).sqrt();
631    Ok((gray_pam_bit_errors(mi, x) + gray_pam_bit_errors(mj, x)) / log2m)
632}
633
634/// Exact BER of Gray-mapped square `m`-QAM over AWGN (Cho and Yoon 2002, eq. (16)), as
635/// a function of `E_b/N_0` in dB: [`ber_gray_rect_qam`] with `I = J = √m`. Unlike the
636/// nearest-neighbour approximation of [`ber`], it tends to 1/2 at low SNR, the BER of
637/// random decisions; for 16-QAM, `P_b = (3Q(x) + 2Q(3x) - Q(5x))/4` with
638/// `x = √(E_s/(5N_0))`.
639///
640/// # Errors
641/// [`Error::Config`] for a non-square order.
642///
643/// # Examples
644/// ```
645/// use comnum_mapping::theory::{ber, ber_gray_qam};
646/// use comnum_mapping::constellation::ConstellationKind;
647/// let exact = ber_gray_qam(16, 10.0).unwrap();
648/// let approx = ber(ConstellationKind::Qam, 16, 10.0).unwrap();
649/// assert!(exact > approx && (exact / approx - 1.0).abs() < 0.03);
650/// ```
651pub fn ber_gray_qam(m: usize, ebn0_db: f64) -> Result<f64> {
652    let side = qam_side(m).unwrap_or(0);
653    ensure(side >= 2 && side.is_power_of_two(), || format!("the exact Gray BER needs a square QAM, got {m}"))?;
654    ber_gray_rect_qam(side, side, ebn0_db)
655}
656
657/// Probability density of the phase error $\theta = \arg r - \varphi$ of a carrier
658/// $r = A e^{j\varphi} + w$ in circular Gaussian noise, at the SNR
659/// $\rho = A^{2}/\mathbb{E}|w|^{2}$ (linear) (S. O. Rice, *Bell Syst. Tech. J.* 27(1),
660/// 1948; as eq. (8) of A. U. Ahmed, S. C. Thompson and J. R. Zeidler, "Threshold
661/// extending receiver structures for CE-OFDM," *Proc. IEEE MILCOM*, 2007):
662///
663/// $$p(\theta) = \frac{e^{-\rho}}{2\pi}\left[1 + \sqrt{4\pi\rho}\\,\cos\theta\\,
664/// e^{\rho\cos^{2}\theta}\\,\Phi\left(\sqrt{2\rho}\cos\theta\right)\right],
665/// \qquad \theta \in (-\pi, \pi],$$
666///
667/// $\Phi$ the standard normal distribution function. It is uniform at $\rho = 0$ and
668/// tends to $\mathcal{N}(0, 1/(2\rho))$ at high SNR; integrated over the decision
669/// sectors it gives the error rates of PSK.
670///
671/// # Examples
672/// ```
673/// use comnum_mapping::theory::phase_pdf;
674/// // uniform without signal, Gaussian of variance 1/(2ρ) at high SNR
675/// assert!((phase_pdf(1.0, 0.0) - 1.0 / std::f64::consts::TAU).abs() < 1e-15);
676/// let rho = 400.0;
677/// let gauss = (rho / std::f64::consts::PI).sqrt();
678/// assert!((phase_pdf(0.0, rho) / gauss - 1.0).abs() < 1e-12);
679/// ```
680pub fn phase_pdf(theta: f64, snr_lin: f64) -> f64 {
681    let c = theta.cos();
682    let s2 = theta.sin().powi(2);
683    // e^{-ρ} √(4πρ) cos θ e^{ρ cos²θ} = √(4πρ) cos θ e^{-ρ sin²θ}, which does not overflow
684    (-snr_lin).exp() / std::f64::consts::TAU
685        + (snr_lin / std::f64::consts::PI).sqrt()
686            * c
687            * (-snr_lin * s2).exp()
688            * (1.0 - qfunc((2.0 * snr_lin).sqrt() * c))
689}
690
691/// The exact bit error rate of a Gray-labelled constellation over AWGN, as a function of
692/// $E_{b}/N_{0}$ (dB), where a closed form exists: [`ber_gray_pam`] for PAM,
693/// [`ber_gray_qam`] for square QAM (Cho and Yoon 2002), and [`ber`] for BPSK and QPSK,
694/// whose nearest-neighbour expression is exact.
695///
696/// # Errors
697/// [`Error::Config`] for another constellation (8-PSK and beyond, OOK, rectangular QAM:
698/// see [`ber`] and [`ber_gray_rect_qam`]).
699///
700/// # Examples
701/// ```
702/// use comnum_mapping::{theory, constellation::ConstellationKind};
703/// let qam = theory::ber_exact(ConstellationKind::Qam, 16, 10.0).unwrap();
704/// assert!((qam - theory::ber_gray_qam(16, 10.0).unwrap()).abs() < 1e-15);
705/// assert!(theory::ber_exact(ConstellationKind::Psk, 8, 10.0).is_err());
706/// ```
707pub fn ber_exact(kind: ConstellationKind, m: usize, ebn0_db: f64) -> Result<f64> {
708    match kind {
709        ConstellationKind::Pam => ber_gray_pam(m, ebn0_db),
710        ConstellationKind::Qam => ber_gray_qam(m, ebn0_db),
711        ConstellationKind::Psk if m <= 4 => ber(kind, m, ebn0_db),
712        _ => Err(Error::Config(format!("no exact Gray BER of {kind:?} of order {m}"))),
713    }
714}
715
716/// Bit error rate of Gray-labelled M-DPSK with noncoherent detection over AWGN, as a
717/// function of $E_{b}/N_{0}$ (dB): $\frac{1}{2} e^{-E_{b}/N_{0}}$ for DBPSK (Proakis &
718/// Salehi, §4.5), and for $M \ge 4$ the average number of wrong bits over the decision
719/// sectors of the differential phase, whose probabilities come from
720/// [`differential_phase_tail`] at $\gamma_{s} = E_{b}/N_{0} \log_{2} M$:
721///
722/// $$
723/// P_{b} = \frac{1}{\log_{2} M} \sum_{k=1}^{M-1} w_{k} \\,
724/// P\left(\Delta\theta \in \left[\frac{(2k - 1)\pi}{M}, \frac{(2k + 1)\pi}{M}\right)\right),
725/// $$
726///
727/// $w_{k}$ the Hamming weight of the Gray label of the transition $k$ (for DQPSK,
728/// $P_{b} = \frac{1}{2}[P(|\Delta\theta| > \pi/4) + P(|\Delta\theta| > 3\pi/4)]$).
729///
730/// # Errors
731/// [`Error::Config`] unless `m` is a power of two ≥ 2.
732///
733/// # Examples
734/// ```
735/// use comnum_mapping::theory::ber_dpsk;
736/// let g = comnum_math::db2lin(8.0);
737/// assert!((ber_dpsk(2, 8.0).unwrap() - 0.5 * (-g).exp()).abs() < 1e-12);
738/// // DQPSK is worse than DBPSK at the same E_b/N_0, and better than D8PSK
739/// assert!(ber_dpsk(4, 8.0).unwrap() > ber_dpsk(2, 8.0).unwrap());
740/// assert!(ber_dpsk(8, 8.0).unwrap() > ber_dpsk(4, 8.0).unwrap());
741/// ```
742pub fn ber_dpsk(m: usize, ebn0_db: f64) -> Result<f64> {
743    comnum_core::ensure(m >= 2 && m.is_power_of_two(), || format!("the order must be a power of two ≥ 2, got {m}"))?;
744    let gb = db2lin(ebn0_db);
745    if m == 2 {
746        return Ok(0.5 * (-gb).exp());
747    }
748    let bits = m.trailing_zeros();
749    let gs = gb * f64::from(bits);
750    let pi = std::f64::consts::PI;
751    // P(Δθ < ψ) on (-π, π]
752    let cdf = |psi: f64| {
753        if psi < 0.0 { 0.5 * differential_phase_tail(-psi, gs) } else { 1.0 - 0.5 * differential_phase_tail(psi, gs) }
754    };
755    let gray = crate::constellation::gray_code(bits)?;
756    let mut errors = 0.0;
757    for k in 1..m {
758        let mut theta = 2.0 * pi * k as f64 / m as f64;
759        if theta > pi {
760            theta -= 2.0 * pi;
761        }
762        let (lo, hi) = (theta - pi / m as f64, theta + pi / m as f64);
763        let p = if hi > pi { 1.0 - cdf(lo) + cdf(hi - 2.0 * pi) } else { cdf(hi) - cdf(lo) };
764        errors += gray[k].count_ones() as f64 * p;
765    }
766    Ok(errors / f64::from(bits))
767}
768
769/// Bit error rate of DBPSK with coherent detection and differential decoding over AWGN
770/// (Proakis & Salehi, §3.2-2): an isolated symbol error spoils two differences, so
771/// $P_{b} = 2p(1 - p)$ with $p = Q(\sqrt{2 E_{b}/N_{0}})$.
772///
773/// # Examples
774/// ```
775/// use comnum_mapping::theory::ber_dbpsk_coherent;
776/// let p = comnum_math::qfunc((2.0 * comnum_math::db2lin(6.0)).sqrt());
777/// assert!((ber_dbpsk_coherent(6.0) - 2.0 * p * (1.0 - p)).abs() < 1e-15);
778/// ```
779pub fn ber_dbpsk_coherent(ebn0_db: f64) -> f64 {
780    let p = qfunc((2.0 * db2lin(ebn0_db)).sqrt());
781    2.0 * p * (1.0 - p)
782}
783
784/// Bit error rate of $M$-DPSK over AWGN with the detection `detection`: [`ber_dpsk`]
785/// for the noncoherent detection, [`ber_dbpsk_coherent`] for the coherent detection with
786/// differential decoding (defined for $M = 2$).
787///
788/// # Errors
789/// [`Error::Config`] for an order that is not a power of two ≥ 2, or the coherent
790/// detection with $M \ne 2$.
791///
792/// # Examples
793/// ```
794/// use comnum_mapping::differential::Detection;
795/// use comnum_mapping::theory::{ber_dbpsk_coherent, ber_differential, ber_dpsk};
796/// assert_eq!(ber_differential(4, 8.0, Detection::Noncoherent)?, ber_dpsk(4, 8.0)?);
797/// assert_eq!(ber_differential(2, 6.0, Detection::Coherent)?, ber_dbpsk_coherent(6.0));
798/// assert!(ber_differential(4, 6.0, Detection::Coherent).is_err());
799/// # Ok::<(), comnum_core::Error>(())
800/// ```
801pub fn ber_differential(m: usize, ebn0_db: f64, detection: crate::differential::Detection) -> Result<f64> {
802    use crate::differential::Detection;
803    match detection {
804        Detection::Noncoherent => ber_dpsk(m, ebn0_db),
805        Detection::Coherent if m == 2 => Ok(ber_dbpsk_coherent(ebn0_db)),
806        Detection::Coherent => Err(Error::Config(format!(
807            "coherent detection with differential decoding is defined for m = 2, got m = {m}"
808        ))),
809    }
810}
811
812/// Symbol error rate of $M$ orthogonal signals of equal energy detected noncoherently
813/// over AWGN (noncoherent M-FSK, chirp spread spectrum), as a function of $E_{s}/N_{0}$
814/// (dB): the magnitude of the matched filter of the sent signal is Rice of location
815/// $v = \sqrt{2 E_{s}/N_{0}}$, the $M - 1$ others Rayleigh, all of unit scale, and the
816/// largest wins (Proakis & Salehi, §4.5; Afisiadis et al. 2020, eq. (16)):
817///
818/// $$
819/// P_{s} = \int_{0}^{\infty} y\, e^{-(y^{2} + v^{2})/2} I_{0}(v y)
820/// \left[1 - \left(1 - e^{-y^{2}/2}\right)^{M - 1}\right] dy,
821/// $$
822///
823/// by Simpson's rule on $v \pm 12$, which avoids the cancellation of the alternating
824/// binomial sum for large $M$.
825///
826/// # Errors
827/// [`Error::Config`] if `m` is below 2.
828///
829/// # Examples
830/// ```
831/// use comnum_mapping::theory::ser_orthogonal_noncoherent;
832/// // binary: e^{-Es/(2 N0)} / 2
833/// let g = comnum_math::db2lin(9.0);
834/// assert!((ser_orthogonal_noncoherent(2, 9.0)? - 0.5 * (-g / 2.0).exp()).abs() < 1e-9);
835/// # Ok::<(), comnum_core::Error>(())
836/// ```
837pub fn ser_orthogonal_noncoherent(m: usize, esn0_db: f64) -> Result<f64> {
838    ensure(m >= 2, || format!("M orthogonal signals need M ≥ 2, got {m}"))?;
839    // 1 - (1 - e^{-y²/2})^n without cancellation
840    let one_minus_power = |y: f64, n: f64| {
841        let p = (-y * y / 2.0).exp();
842        if p >= 1.0 { 1.0 } else { -((n * (-p).ln_1p()).exp_m1()) }
843    };
844    let v = (2.0 * db2lin(esn0_db)).sqrt();
845    let rice = |y: f64| y * (-(y - v) * (y - v) / 2.0).exp() * bessel_i0e(y * v);
846    let (lo, hi) = ((v - 12.0).max(0.0), v + 12.0);
847    let others = (m - 1) as f64;
848    Ok(comnum_math::simpson(|y| rice(y) * one_minus_power(y, others), lo, hi, 4800).clamp(0.0, 1.0))
849}
850
851/// Bit error rate of $M$ orthogonal signals detected noncoherently over AWGN, as a
852/// function of $E_{s}/N_{0}$ (dB): $P_{b} = P_{s} M / (2(M - 1))$, every wrong symbol
853/// being equally likely ([`ser_orthogonal_noncoherent`]; Proakis & Salehi, §4.5).
854///
855/// # Errors
856/// As [`ser_orthogonal_noncoherent`].
857pub fn ber_orthogonal_noncoherent(m: usize, esn0_db: f64) -> Result<f64> {
858    let mf = m as f64;
859    Ok(ser_orthogonal_noncoherent(m, esn0_db)? * mf / (2.0 * (mf - 1.0)))
860}
861
862#[cfg(test)]
863mod tests {
864    use super::*;
865
866    #[test]
867    fn the_ser_of_a_constant_snr_is_that_of_awgn() {
868        // the SERs of BPSK and square QAM are exact (below 10⁻¹² the 1 - (1 - p)² of
869        // ser_esn0 loses digits)
870        for (kind, m) in [(ConstellationKind::Psk, 2), (ConstellationKind::Qam, 4), (ConstellationKind::Qam, 64)] {
871            for es_db in [0.0, 6.0, 12.0] {
872                let g = db2lin(es_db);
873                let by_mgf = ser_mgf(kind, m, |s| (s * g).exp()).unwrap();
874                let awgn = ser_esn0(kind, m, es_db).unwrap();
875                assert!((by_mgf / awgn - 1.0).abs() < 1e-9, "{kind:?} {m} at {es_db} dB: {by_mgf} against {awgn}");
876            }
877        }
878        // that of 8-PSK is the bound 2 Q(·), tight at high SNR
879        for (es_db, rel) in [(0.0, 0.05), (18.0, 1e-6)] {
880            let by_mgf = ser_mgf(ConstellationKind::Psk, 8, |s| (s * db2lin(es_db)).exp()).unwrap();
881            let bound = ser_esn0(ConstellationKind::Psk, 8, es_db).unwrap();
882            assert!(
883                by_mgf <= bound * (1.0 + 1e-12) && bound / by_mgf - 1.0 < rel,
884                "8-PSK at {es_db} dB: {by_mgf} against {bound}"
885            );
886        }
887        assert!(ser_mgf(ConstellationKind::Pam, 4, f64::exp).is_err());
888    }
889
890    /// The bitwise expectations as written before the shared halves: the log-sum of the
891    /// labels sharing the bit of `i`, by `log_sum_exp_iter`.
892    fn former_bit_expectations(s: &TheorySetup, f: impl Fn(&[f64], f64) -> f64) -> Vec<f64> {
893        let TheorySetup { points: x, probs: p, n0, nodes } = s;
894        let m = x.len();
895        let b = m.trailing_zeros() as usize;
896        let lp = log_priors(p);
897        let (mut all, mut out, mut e) = (vec![0.0; m], vec![0.0; b], vec![0.0; b]);
898        for (i, xi) in x.iter().enumerate() {
899            if p[i] == 0.0 {
900                continue;
901            }
902            e.fill(0.0);
903            for (w, a) in nodes {
904                for (j, xj) in x.iter().enumerate() {
905                    all[j] = lp[j] - ((xi - xj + w).norm_sqr() - w.norm_sqr()) / n0;
906                }
907                for (k, ek) in e.iter_mut().enumerate() {
908                    let shift = b - 1 - k;
909                    let bi = (i >> shift) & 1;
910                    let same =
911                        comnum_math::log_sum_exp_iter((0..m).filter(|&j| (j >> shift) & 1 == bi).map(|j| all[j]));
912                    *ek += a * f(&all, same);
913                }
914            }
915            for (o, ek) in out.iter_mut().zip(&e) {
916                *o += p[i] * ek;
917            }
918        }
919        out
920    }
921
922    #[test]
923    fn the_shared_halves_give_the_former_bitwise_expectations() {
924        let shaped = [0.4, 0.1, 0.3, 0.2, 0.05, 0.25, 0.15, 0.35];
925        for (c, px) in [
926            (Constellation::qam(16).unwrap(), None),
927            (Constellation::psk(8).unwrap(), Some(&shaped[..])),
928            (Constellation::pam(4).unwrap(), None),
929        ] {
930            for snr in [-5.0, 3.0, 15.0, 40.0] {
931                let s = setup(&c, px, snr, 12).unwrap();
932                let f = |all: &[f64], same: f64| log_sum_exp(all) - same;
933                let (new, old) = (bit_expectations(&s, f), former_bit_expectations(&s, f));
934                assert!(new.iter().zip(&old).all(|(a, b)| a.to_bits() == b.to_bits()), "{snr} dB");
935            }
936        }
937    }
938
939    #[test]
940    fn the_default_quadrature_has_converged() {
941        let c = Constellation::qam(16).unwrap();
942        for fading in [RateChannel::Awgn, RateChannel::Rayleigh] {
943            let at = |n| constellation_mi(&c, 10.0, None, fading, n).unwrap();
944            assert!((at(DEFAULT_QUADRATURE_POINTS) - at(80)).abs() < 1e-6, "{fading:?}");
945        }
946    }
947
948    #[test]
949    fn bpsk_theory_is_q_of_sqrt_2ebn0() {
950        for eb in [0.0, 4.0, 8.0] {
951            let g = db2lin(eb);
952            let q = qfunc((2.0 * g).sqrt());
953            assert!((ber(ConstellationKind::Psk, 2, eb).unwrap() / q - 1.0).abs() < 1e-9);
954            assert!((ser(ConstellationKind::Psk, 2, eb).unwrap() / q - 1.0).abs() < 1e-9);
955            assert!((ber(ConstellationKind::Qam, 4, eb).unwrap() / q - 1.0).abs() < 1e-9);
956        }
957    }
958
959    #[test]
960    fn closed_forms_reject_orders_they_do_not_cover() {
961        for (kind, m) in [
962            (ConstellationKind::Qam, 8),
963            (ConstellationKind::Qam, 32),
964            (ConstellationKind::Pam, 6),
965            (ConstellationKind::Psk, 1),
966        ] {
967            assert!(ber(kind, m, 10.0).is_err() && ser(kind, m, 10.0).is_err(), "{kind:?} {m}");
968        }
969    }
970
971    #[test]
972    fn dpsk_closed_forms() {
973        // the integral reproduces the closed form of DBPSK at every SNR
974        for g in [0.1f64, 1.0, 5.0, 20.0] {
975            let want = 0.5 * (-g).exp();
976            assert!((differential_phase_tail(std::f64::consts::FRAC_PI_2, g) / want - 1.0).abs() < 1e-8, "{g}");
977        }
978        // DQPSK: the sectors sum to one and the rate lies between QPSK and its double at 3 dB
979        // more (the classical loss of differential detection)
980        for eb in [4.0, 8.0, 12.0] {
981            let gs = 2.0 * db2lin(eb);
982            let t = |psi: f64| differential_phase_tail(psi, gs);
983            let want = 0.5 * (t(std::f64::consts::FRAC_PI_4) + t(3.0 * std::f64::consts::FRAC_PI_4));
984            // the sector sums cancel 1 - ½T, hence the rounding at 1e-12 of a 1e-5 rate
985            let got = ber_dpsk(4, eb).unwrap();
986            assert!((got / want - 1.0).abs() < 1e-9, "{eb}: {got:e} vs {want:e}");
987            let qpsk = ber(ConstellationKind::Psk, 4, eb).unwrap();
988            let qpsk_3db = ber(ConstellationKind::Psk, 4, eb - 3.0).unwrap();
989            assert!(want > qpsk && want < 2.0 * qpsk_3db, "{eb}: {want} against {qpsk} and {qpsk_3db}");
990        }
991        assert!(ber_dpsk(3, 1.0).is_err());
992    }
993
994    /// Exact error count of Gray-mapped `side`-PAM by enumeration of the decision
995    /// regions, with the labels of [`comnum_math::gray`]: `x` is the half distance between
996    /// levels over the noise standard deviation.
997    fn pam_bit_errors_by_enumeration(side: usize, x: f64) -> f64 {
998        let mut total = 0.0;
999        for j in 0..side {
1000            for r in 0..side {
1001                let d = r as f64 - j as f64;
1002                let lo = if r == 0 { 1.0 } else { qfunc((2.0 * d - 1.0) * x) };
1003                let hi = if r == side - 1 { 0.0 } else { qfunc((2.0 * d + 1.0) * x) };
1004                let wrong = (comnum_math::gray(j) ^ comnum_math::gray(r)).count_ones();
1005                total += f64::from(wrong) * (lo - hi);
1006            }
1007        }
1008        total / side as f64
1009    }
1010
1011    #[test]
1012    fn the_cho_yoon_sums_count_the_errors_of_the_gray_labels() {
1013        for side in [2usize, 4, 8, 16, 32, 64] {
1014            for x in [0.05, 0.3, 1.0, 2.5] {
1015                let (a, b) = (gray_pam_bit_errors(side, x), pam_bit_errors_by_enumeration(side, x));
1016                assert!((a - b).abs() < 1e-13, "{side}-PAM at x = {x}: {a} vs {b}");
1017            }
1018        }
1019        // rectangular QAM: two PAM in quadrature at the same distance
1020        for (mi, mj) in [(4usize, 2usize), (8, 4), (16, 8), (32, 16)] {
1021            let eb = 9.0;
1022            let log2m = f64::from(mi.trailing_zeros() + mj.trailing_zeros());
1023            let x = (6.0 * log2m * db2lin(eb) / ((mi * mi + mj * mj) as f64 - 2.0)).sqrt();
1024            let want = (pam_bit_errors_by_enumeration(mi, x) + pam_bit_errors_by_enumeration(mj, x)) / log2m;
1025            assert!((ber_gray_rect_qam(mi, mj, eb).unwrap() - want).abs() < 1e-14, "{mi} × {mj}");
1026        }
1027        // random decisions at low SNR, BPSK and QPSK
1028        assert!((ber_gray_pam(16, -60.0).unwrap() - 0.5).abs() < 1e-3);
1029        let q = qfunc((2.0 * db2lin(5.0)).sqrt());
1030        assert!((ber_gray_pam(2, 5.0).unwrap() - q).abs() < 1e-15);
1031        assert!((ber_gray_qam(4, 5.0).unwrap() - q).abs() < 1e-15);
1032        assert!(ber_gray_pam(6, 5.0).is_err() && ber_gray_rect_qam(8, 1, 5.0).is_err());
1033    }
1034
1035    #[test]
1036    fn the_phase_density_integrates_to_the_psk_error_rates() {
1037        let integral = |a: f64, b: f64, rho: f64| {
1038            let n = 20_000;
1039            let h = (b - a) / n as f64;
1040            (0..n).map(|i| phase_pdf(a + (i as f64 + 0.5) * h, rho)).sum::<f64>() * h
1041        };
1042        let pi = std::f64::consts::PI;
1043        for rho in [0.1, 1.0, 4.0, 20.0] {
1044            // to the accuracy of the midpoint rule
1045            assert!((integral(-pi, pi, rho) - 1.0).abs() < 1e-6, "{rho}: {}", integral(-pi, pi, rho));
1046            // BPSK: an error is Re r < 0, Q(√(2ρ)); QPSK: 2Q(√ρ) - Q(√ρ)²
1047            let bpsk = 2.0 * integral(pi / 2.0, pi, rho);
1048            assert!((bpsk - qfunc((2.0 * rho).sqrt())).abs() < 1e-6, "{rho}: {bpsk}");
1049            let q = qfunc(rho.sqrt());
1050            let qpsk = 2.0 * integral(pi / 4.0, pi, rho);
1051            assert!((qpsk - (2.0 * q - q * q)).abs() < 1e-6, "{rho}: {qpsk}");
1052        }
1053    }
1054}
1055
1056#[cfg(test)]
1057mod literature {
1058    use super::*;
1059
1060    /// K. Cho and D. Yoon, "On the general BER expression of one- and two-dimensional
1061    /// amplitude modulations," IEEE Trans. Commun. 50(7), 2002, Table I (p. 1076): the
1062    /// coefficients $[X_{I}(k)]$ and $[Y_{I}(k)]$ of eq. (9) for $I$ = 2, 4, 8 and 16.
1063    #[test]
1064    fn cho_2002_table_1() {
1065        let table: [(usize, usize, &[f64]); 10] = [
1066            (2, 1, &[1.0]),
1067            (4, 1, &[1.0, 1.0]),
1068            (4, 2, &[2.0, 1.0, -1.0]),
1069            (8, 1, &[1.0; 4]),
1070            (8, 2, &[2.0, 2.0, 1.0, 1.0, -1.0, -1.0]),
1071            (8, 3, &[4.0, 3.0, -3.0, -2.0, 2.0, 1.0, -1.0]),
1072            (16, 1, &[1.0; 8]),
1073            (16, 2, &[2.0, 2.0, 2.0, 2.0, 1.0, 1.0, 1.0, 1.0, -1.0, -1.0, -1.0, -1.0]),
1074            (16, 3, &[4.0, 4.0, 3.0, 3.0, -3.0, -3.0, -2.0, -2.0, 2.0, 2.0, 1.0, 1.0, -1.0, -1.0]),
1075            (16, 4, &[8.0, 7.0, -7.0, -6.0, 6.0, 5.0, -5.0, -4.0, 4.0, 3.0, -3.0, -2.0, 2.0, 1.0, -1.0]),
1076        ];
1077        for (side, k, xs) in table {
1078            let (x, y): (Vec<f64>, Vec<usize>) = cho_yoon_terms(side, k).unzip();
1079            assert_eq!(x, xs, "X_{side}({k})");
1080            // Y is 1, 3, 5, …, 2[(1 - 2^-k) I - 1] + 1
1081            assert_eq!(y, (0..xs.len()).map(|i| 2 * i + 1).collect::<Vec<_>>(), "Y_{side}({k})");
1082        }
1083    }
1084
1085    /// Cho and Yoon 2002, Sec. II-C and Fig. 2: "an additional 4–5 dB of SNR is
1086    /// required to transmit an extra bit to maintain an average BER of 1e-3". The exact
1087    /// BER gives 4.25, 4.62 and 4.88 dB from 4- to 32-PAM; the step is 3.73 dB from 2- to
1088    /// 4-PAM and grows to 5.06 and 5.20 dB beyond (64- and 128-PAM).
1089    #[test]
1090    fn cho_2002_fig_2_cost_of_an_extra_bit() {
1091        let required = |m: usize| bisect(|e| ber_gray_pam(m, e).unwrap() > 1e-3, -5.0, 60.0, 100);
1092        let r: Vec<f64> = (1..=7).map(|b| required(1 << b)).collect();
1093        assert!((r[0] - 6.79).abs() < 0.01, "BPSK at 1e-3: {}", r[0]);
1094        let steps: Vec<f64> = r.windows(2).map(|w| w[1] - w[0]).collect();
1095        for (i, s) in steps.iter().enumerate() {
1096            assert!((3.6..5.3).contains(s), "step {i}: {s}");
1097        }
1098        assert!(steps[1..4].iter().all(|s| (4.0..5.0).contains(s)), "{steps:?}");
1099    }
1100}