Skip to main content

comnum_mimo/
capacity.rs

1//! Capacity of a MIMO channel realisation.
2//!
3//! With the total transmit power $P$, the receiver noise variance $\sigma^{2}$ and
4//! $\rho = P / \sigma^{2}$:
5//!
6//! - without channel knowledge at the transmitter, equal power on every antenna:
7//!
8//! $$
9//! C = \log_{2} \det\left(I_{n_{r}} + \frac{\rho}{n_{t}} H H^{H}\right);
10//! $$
11//!
12//! - with channel knowledge, water-filling on the eigenmodes:
13//!   $C = \sum_{i} \log_{2}(1 + p_{i} \sigma_{i}^{2})$ (see [`crate::precoding`]).
14//!
15//! The ergodic capacity of a fading channel is the mean of these over the channel
16//! realisations. Over the i.i.d. Rayleigh channel (entries $\mathcal{CN}(0, 1)$) it has
17//! the closed form of Telatar (1999, Theorem 2), [`ergodic_rayleigh`]: with
18//! $m = \min(n_{r}, n_{t})$, $n = \max(n_{r}, n_{t})$ and the associated Laguerre
19//! polynomials $L_{k}^{n-m}$,
20//!
21//! $$
22//! C = \int_{0}^{\infty} \log_{2}\left(1 + \frac{\rho}{n_{t}} \lambda\right)
23//! \sum_{k=0}^{m-1} \frac{k!}{(k+n-m)!} \left[L_{k}^{n-m}(\lambda)\right]^{2}
24//! \lambda^{n-m} e^{-\lambda} \, d\lambda ,
25//! $$
26//!
27//! the sum being $m$ times the density of an unordered eigenvalue of the Wishart matrix
28//! $H H^{H}$ or $H^{H} H$ ([`wishart_eigenvalue_density`]). [`ergodic_rayleigh_limit`]
29//! is its slope for $n_{r} = n_{t} \to \infty$ (eq. (13)). When $H$ is drawn once and
30//! held, the rate $R$ is in outage with the probability [`outage_rayleigh`]
31//! (eq. (16) and Example 7, for one antenna on one side), and [`outage_capacity_rayleigh`]
32//! is the largest rate of outage probability $\varepsilon$ (eqs. (17), (18)).
33//!
34//! # Examples
35//! ```
36//! use comnum_mimo::capacity::{equal_power, water_filling};
37//! use comnum_math::linalg::identity;
38//! // two parallel unit channels at ρ = 10: 2 log2(1 + 5)
39//! let h = identity(2);
40//! assert!((equal_power(&h.view(), 10.0)? - 2.0 * 6f64.log2()).abs() < 1e-12);
41//! assert!((water_filling(&h.view(), 10.0)? - 2.0 * 6f64.log2()).abs() < 1e-12);
42//! # Ok::<(), comnum_core::Error>(())
43//! ```
44//!
45//! # References
46//!
47//! 1. E. Telatar, "Capacity of multi-antenna Gaussian channels," *Eur. Trans.
48//!    Telecommun.*, 10(6), 1999.
49//! 2. G. J. Foschini and M. J. Gans, "On limits of wireless communications in a fading
50//!    environment when using multiple antennas," *Wireless Pers. Commun.*, 6(3), 1998.
51
52use crate::precoding;
53use comnum_core::{Result, ensure};
54use comnum_math::linalg::{hermitian, identity, log2_det_hpd, svd};
55use comnum_math::{C64, bisect, gamma_p, ln_gamma, simpson};
56use ndarray::ArrayView2;
57
58/// Capacity with equal power on the $n_{t}$ antennas (bits per channel use).
59///
60/// # Errors
61/// [`comnum_core::Error::Config`] for a negative SNR.
62pub fn equal_power(h: &ArrayView2<'_, C64>, snr_lin: f64) -> Result<f64> {
63    ensure(snr_lin >= 0.0, || format!("the SNR must be non-negative, got {snr_lin}"))?;
64    let a = identity(h.nrows()) + h.dot(&hermitian(h)).mapv(|x| x * (snr_lin / h.ncols() as f64));
65    log2_det_hpd(&a.view())
66}
67
68/// Capacity with water-filling on the eigenmodes (bits per channel use).
69///
70/// # Errors
71/// [`comnum_core::Error::Config`] for a non-positive SNR.
72pub fn water_filling(h: &ArrayView2<'_, C64>, snr_lin: f64) -> Result<f64> {
73    let (_, s, _) = svd(h);
74    let g: Vec<f64> = s.iter().map(|x| x * x).collect();
75    let p = precoding::water_filling_powers(&g, snr_lin)?;
76    Ok(g.iter().zip(&p).map(|(g, p)| (1.0 + g * p).log2()).sum())
77}
78
79/// The density of one unordered eigenvalue of the complex Wishart matrix $H H^{H}$,
80/// $H$ of size $m \times n$ ($m \le n$) with i.i.d. $\mathcal{CN}(0, 1)$ entries
81/// (Telatar 1999, eq. preceding (8)):
82///
83/// $$
84/// p(\lambda) = \frac{1}{m} \sum_{k=0}^{m-1} \frac{k!}{(k+n-m)!}
85/// \left[L_{k}^{n-m}(\lambda)\right]^{2} \lambda^{n-m} e^{-\lambda} .
86/// $$
87///
88/// The normalized Laguerre functions
89/// $\ell_{k} = \sqrt{k!/(k+\alpha)!}\, L_{k}^{\alpha}(\lambda)\, \lambda^{\alpha/2}
90/// e^{-\lambda/2}$, $\alpha = n - m$, are computed by the three-term recurrence
91/// $\sqrt{(k+1)(k+1+\alpha)}\, \ell_{k+1} = (2k+1+\alpha-\lambda)\, \ell_{k} -
92/// \sqrt{k(k+\alpha)}\, \ell_{k-1}$, which stays in range where the polynomials
93/// themselves overflow. `m` and `n` are swapped if `m > n`.
94///
95/// # Examples
96/// ```
97/// use comnum_mimo::capacity::wishart_eigenvalue_density;
98/// // one antenna on each side: |h|² is exponential
99/// assert!((wishart_eigenvalue_density(1, 1, 2.0) - (-2f64).exp()).abs() < 1e-15);
100/// ```
101pub fn wishart_eigenvalue_density(m: usize, n: usize, lambda: f64) -> f64 {
102    let (m, n) = (m.min(n), m.max(n));
103    if m == 0 || lambda < 0.0 {
104        return 0.0;
105    }
106    let alpha = (n - m) as f64;
107    let power = if alpha == 0.0 { 0.0 } else { 0.5 * alpha * lambda.ln() };
108    let mut prev = 0.0;
109    let mut cur = (power - 0.5 * lambda - 0.5 * ln_gamma(alpha + 1.0)).exp();
110    let mut sum = cur * cur;
111    for k in 0..m - 1 {
112        let k = k as f64;
113        let next = ((2.0 * k + 1.0 + alpha - lambda) * cur - (k * (k + alpha)).sqrt() * prev)
114            / ((k + 1.0) * (k + 1.0 + alpha)).sqrt();
115        (prev, cur) = (cur, next);
116        sum += cur * cur;
117    }
118    sum / m as f64
119}
120
121/// Ergodic capacity (bits per channel use) of the i.i.d. Rayleigh channel with `n_rx`
122/// receive and `n_tx` transmit antennas, the total SNR `snr_lin` (linear) spread equally
123/// on the transmit antennas, the channel known at the receiver only: Telatar (1999),
124/// Theorem 2, eq. (8), integrated by Simpson's rule in $\lambda$ up to well beyond the
125/// largest eigenvalue after the change of variable $\lambda = u^{2}$ (the Laguerre
126/// functions oscillate with a nearly constant period in $u$), to about $10^{-10}$
127/// relative. It is the mean of
128/// [`equal_power`] over the realisations of $H$.
129///
130/// # Errors
131/// [`comnum_core::Error::Config`] for no antenna or a negative SNR.
132///
133/// # Examples
134/// ```
135/// use comnum_mimo::capacity::ergodic_rayleigh;
136/// use comnum_math::expint_e1;
137/// // one antenna: e^{1/ρ} E₁(1/ρ) nats
138/// let rho = 10.0;
139/// let c = ergodic_rayleigh(1, 1, rho)?;
140/// assert!((c * 2f64.ln() - (1.0 / rho).exp() * expint_e1(1.0 / rho)).abs() < 1e-9);
141/// // four antennas on each side: about four times as much at high SNR
142/// assert!(ergodic_rayleigh(4, 4, 1e3)? > 3.7 * ergodic_rayleigh(1, 1, 1e3)?);
143/// # Ok::<(), comnum_core::Error>(())
144/// ```
145pub fn ergodic_rayleigh(n_rx: usize, n_tx: usize, snr_lin: f64) -> Result<f64> {
146    ensure(n_rx >= 1 && n_tx >= 1, || format!("need at least one antenna on each side, got {n_rx} × {n_tx}"))?;
147    ensure(snr_lin >= 0.0, || format!("the SNR must be non-negative, got {snr_lin}"))?;
148    let (m, n) = (n_rx.min(n_tx), n_rx.max(n_tx));
149    // the largest eigenvalue concentrates at (√n + √m)², with fluctuations of order
150    // √n + √m: the density is below e^{-40} beyond the upper limit
151    let edge = (n as f64).sqrt() + (m as f64).sqrt();
152    let upper = edge * edge + 10.0 * edge + 60.0;
153    // λ = u²: the Laguerre functions oscillate with a nearly constant period in u
154    let intervals = 400 + (120.0 * (upper * (m + n) as f64).sqrt()) as usize;
155    let g = snr_lin / n_tx as f64;
156    let c = simpson(
157        |u| 2.0 * u * (g * u * u).ln_1p() * wishart_eigenvalue_density(m, n, u * u),
158        0.0,
159        upper.sqrt(),
160        intervals,
161    );
162    Ok(m as f64 * c / std::f64::consts::LN_2)
163}
164
165/// The limit of $C / n$ (bits per channel use and per antenna) of
166/// [`ergodic_rayleigh`] with $n$ antennas on each side, $n \to \infty$, at the total
167/// SNR $\rho$ (Telatar 1999, eq. (13), from the quarter-circle law of the eigenvalues
168/// of $H H^{H} / n$):
169///
170/// $$
171/// \lim_{n \to \infty} \frac{C}{n} = \left[\ln \rho - 1 + \frac{\sqrt{1 + 4\rho} - 1}{2\rho} +
172/// 2 \tanh^{-1} \frac{1}{\sqrt{1 + 4\rho}}\right] \frac{1}{\ln 2} .
173/// $$
174///
175/// # Examples
176/// ```
177/// use comnum_mimo::capacity::{ergodic_rayleigh, ergodic_rayleigh_limit};
178/// // twenty antennas are already within 0.5 % of the limit at 20 dB
179/// let c = ergodic_rayleigh(20, 20, 100.0)? / 20.0;
180/// assert!((c / ergodic_rayleigh_limit(100.0) - 1.0).abs() < 5e-3);
181/// # Ok::<(), comnum_core::Error>(())
182/// ```
183pub fn ergodic_rayleigh_limit(snr_lin: f64) -> f64 {
184    let s = (1.0 + 4.0 * snr_lin).sqrt();
185    (snr_lin.ln() - 1.0 + (s - 1.0) / (2.0 * snr_lin) + 2.0 * (1.0 / s).atanh()) / std::f64::consts::LN_2
186}
187
188fn ensure_single_side(n_rx: usize, n_tx: usize) -> Result<()> {
189    ensure(n_rx >= 1 && n_tx >= 1 && n_rx.min(n_tx) == 1, || {
190        format!("the closed form needs one antenna on one side, got {n_rx} × {n_tx}")
191    })
192}
193
194/// The outage probability $P(\log_{2} \det(I + \frac{\rho}{n_{t}} H H^{H}) < R)$ of a
195/// rate `rate` (bits per channel use) over a Rayleigh channel drawn once, with equal
196/// power on the transmit antennas and one antenna on one side. $\lVert h \rVert^{2}$ is
197/// then a gamma variable of shape $k = \max(n_{r}, n_{t})$, and
198///
199/// $$
200/// P_{\mathrm{out}} = P\left(k, \frac{n_{t} (2^{R} - 1)}{\rho}\right),
201/// $$
202///
203/// the regularized incomplete gamma function [`comnum_math::gamma_p`] (Telatar 1999, eq.
204/// (16) for $n_{t} = 1$ and Example 7 for $n_{r} = 1$, there in nats).
205///
206/// # Errors
207/// [`comnum_core::Error::Config`] unless one side has a single antenna, or for a
208/// non-positive SNR.
209///
210/// # Examples
211/// ```
212/// use comnum_mimo::capacity::outage_rayleigh;
213/// // one antenna: 1 - e^{-(2^R - 1)/ρ}
214/// let p = outage_rayleigh(1, 1, 10.0, 1.0)?;
215/// assert!((p - (1.0 - (-0.1f64).exp())).abs() < 1e-15);
216/// # Ok::<(), comnum_core::Error>(())
217/// ```
218pub fn outage_rayleigh(n_rx: usize, n_tx: usize, snr_lin: f64, rate: f64) -> Result<f64> {
219    ensure_single_side(n_rx, n_tx)?;
220    ensure(snr_lin > 0.0, || format!("the SNR must be positive, got {snr_lin}"))?;
221    let k = n_rx.max(n_tx) as f64;
222    Ok(gamma_p(k, n_tx as f64 * (rate * std::f64::consts::LN_2).exp_m1() / snr_lin))
223}
224
225/// The $\varepsilon$-capacity (bits per channel use) of [`outage_rayleigh`]: the rate
226/// $R$ whose outage probability is `eps` (Telatar 1999, eqs. (17) and (18), Figs. 3 and
227/// 5), $R = \log_{2}(1 + \rho x / n_{t})$ with $P(k, x) = \varepsilon$ solved by
228/// bisection.
229///
230/// # Errors
231/// [`comnum_core::Error::Config`] unless one side has a single antenna, for a
232/// non-positive SNR or for `eps` outside $(0, 1)$.
233///
234/// # Examples
235/// ```
236/// use comnum_mimo::capacity::{outage_capacity_rayleigh, outage_rayleigh};
237/// let r = outage_capacity_rayleigh(4, 1, 100.0, 1e-3)?;
238/// assert!((outage_rayleigh(4, 1, 100.0, r)? / 1e-3 - 1.0).abs() < 1e-9);
239/// # Ok::<(), comnum_core::Error>(())
240/// ```
241pub fn outage_capacity_rayleigh(n_rx: usize, n_tx: usize, snr_lin: f64, eps: f64) -> Result<f64> {
242    ensure_single_side(n_rx, n_tx)?;
243    ensure(snr_lin > 0.0, || format!("the SNR must be positive, got {snr_lin}"))?;
244    ensure(eps > 0.0 && eps < 1.0, || format!("the outage probability must be in (0, 1), got {eps}"))?;
245    let k = n_rx.max(n_tx) as f64;
246    // P(k, x) < ε on the left of the quantile; search ln x for the dynamic range
247    let ln_x = bisect(|u| gamma_p(k, u.exp()) < eps, -800.0, 10.0 + 2.0 * k.ln() + k.sqrt(), 200);
248    Ok((snr_lin * ln_x.exp() / n_tx as f64).ln_1p() / std::f64::consts::LN_2)
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use comnum_core::rng::Rng;
255    use ndarray::Array2;
256
257    #[test]
258    fn water_filling_never_loses_and_matches_at_high_snr() {
259        let mut r = Rng::new(1);
260        for _ in 0..50 {
261            let h = Array2::from_shape_fn((3, 3), |_| r.complex_gaussian());
262            for snr in [0.1, 1.0, 100.0] {
263                assert!(water_filling(&h.view(), snr).unwrap() >= equal_power(&h.view(), snr).unwrap() - 1e-9);
264            }
265            let hi = 1e6;
266            let gap = water_filling(&h.view(), hi).unwrap() - equal_power(&h.view(), hi).unwrap();
267            assert!(gap < 0.01, "{gap}");
268        }
269    }
270
271    #[test]
272    fn the_wishart_density_is_normalized_with_the_mean_of_the_trace() {
273        // ∫ p = 1 and ∫ λ p = n (E tr W = m n)
274        for (m, n) in [(1, 1), (1, 4), (3, 3), (2, 7), (8, 8)] {
275            let up = 4.0 * (m + n) as f64 + 80.0;
276            let mass = comnum_math::simpson(|l| wishart_eigenvalue_density(m, n, l), 0.0, up, 20_000);
277            let mean = comnum_math::simpson(|l| l * wishart_eigenvalue_density(m, n, l), 0.0, up, 20_000);
278            assert!((mass - 1.0).abs() < 1e-8, "{m} × {n}: {mass}");
279            assert!((mean / n as f64 - 1.0).abs() < 1e-8, "{m} × {n}: {mean}");
280        }
281    }
282
283    #[test]
284    fn the_ergodic_integral_has_converged() {
285        // against the same integral on four times as many intervals
286        for (nr, nt, snr) in [(1usize, 1usize, 1e-2), (8, 8, 100.0), (3, 20, 1e4), (32, 32, 10.0)] {
287            let (m, n) = (nr.min(nt), nr.max(nt));
288            let up = ((n as f64).sqrt() + (m as f64).sqrt()).powi(2) * 2.0 + 200.0;
289            let g = snr / nt as f64;
290            let fine = comnum_math::simpson(
291                |u| 2.0 * u * (g * u * u).ln_1p() * wishart_eigenvalue_density(m, n, u * u),
292                0.0,
293                up.sqrt(),
294                100_000,
295            ) * m as f64
296                / std::f64::consts::LN_2;
297            let c = ergodic_rayleigh(nr, nt, snr).unwrap();
298            assert!((c / fine - 1.0).abs() < 1e-10, "{nr} × {nt}: {c} vs {fine}");
299        }
300    }
301
302    #[test]
303    fn the_ergodic_capacity_is_the_mean_of_the_realisations() {
304        let mut r = Rng::new(7);
305        for (nr, nt, snr) in [(2usize, 2usize, 10.0), (4, 2, 3.0), (2, 4, 100.0), (1, 3, 10.0)] {
306            let n = 20_000;
307            let v: Vec<f64> = (0..n)
308                .map(|_| {
309                    let h = Array2::from_shape_fn((nr, nt), |_| r.complex_gaussian());
310                    equal_power(&h.view(), snr).unwrap()
311                })
312                .collect();
313            let mean = v.iter().sum::<f64>() / n as f64;
314            let sd = (v.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n * (n - 1)) as f64).sqrt();
315            let c = ergodic_rayleigh(nr, nt, snr).unwrap();
316            assert!((c - mean).abs() < 4.0 * sd, "{nr} × {nt}: {c} vs {mean} ± {sd}");
317        }
318    }
319
320    #[test]
321    fn the_outage_probability_is_that_of_the_realisations() {
322        let mut r = Rng::new(3);
323        for (nr, nt, snr, rate) in [(1usize, 1usize, 10.0, 2.0), (4, 1, 3.0, 2.5), (1, 3, 30.0, 4.0)] {
324            let n = 50_000;
325            let hits = (0..n)
326                .filter(|_| {
327                    let h = Array2::from_shape_fn((nr, nt), |_| r.complex_gaussian());
328                    equal_power(&h.view(), snr).unwrap() < rate
329                })
330                .count();
331            let p = outage_rayleigh(nr, nt, snr, rate).unwrap();
332            let sd = (p * (1.0 - p) / n as f64).sqrt();
333            assert!((hits as f64 / n as f64 - p).abs() < 4.0 * sd, "{nr} × {nt}: {hits} vs {p}");
334        }
335        assert!(outage_rayleigh(2, 2, 10.0, 1.0).is_err());
336        assert!(outage_capacity_rayleigh(1, 2, 10.0, 1.0).is_err());
337    }
338
339    #[test]
340    fn the_capacity_of_a_rank_one_channel_is_one_mode() {
341        // H = a b^H: one mode of gain ‖a‖² ‖b‖²
342        let a = ndarray::arr2(&[[C64::new(1.0, 0.0)], [C64::new(0.0, 1.0)]]);
343        let b = ndarray::arr2(&[[C64::new(1.0, 0.0), C64::new(1.0, 0.0)]]);
344        let h = a.dot(&b);
345        let c = water_filling(&h.view(), 5.0).unwrap();
346        assert!((c - (1.0 + 5.0 * 4.0f64).log2()).abs() < 1e-12);
347    }
348}
349
350#[cfg(test)]
351mod literature {
352    use super::*;
353
354    const NATS: f64 = std::f64::consts::LN_2;
355
356    /// E. Telatar, "Capacity of multi-antenna Gaussian channels," Eur. Trans. Telecommun.
357    /// 10(6), 1999, Remark 3 (the reciprocity $C(a, b, Pb) = C(b, a, Pa)$, while
358    /// $C(a, b, P) \ne C(b, a, P)$) and Examples 3 and 4: with one transmit antenna the
359    /// capacity follows $\ln(1 + Pr)$ as $r$ grows, with one receive antenna it tends to
360    /// $\ln(1 + P)$ as $t$ grows.
361    #[test]
362    fn telatar_1999_remark_3_and_examples_3_4() {
363        for (a, b, p) in [(1usize, 4usize, 10.0), (2, 5, 3.0), (3, 6, 100.0)] {
364            let left = ergodic_rayleigh(a, b, p * b as f64).unwrap();
365            let right = ergodic_rayleigh(b, a, p * a as f64).unwrap();
366            assert!((left - right).abs() < 1e-9 * left, "{a}, {b}: {left} vs {right}");
367            assert!((ergodic_rayleigh(a, b, p).unwrap() - ergodic_rayleigh(b, a, p).unwrap()).abs() > 0.1);
368        }
369        let p = 10.0;
370        let gap = |r: usize| ergodic_rayleigh(r, 1, p).unwrap() * NATS - (p * r as f64).ln_1p();
371        assert!(gap(4).abs() > gap(16).abs() && gap(16).abs() > gap(64).abs() && gap(64).abs() < 0.01, "{}", gap(64));
372        let lim = ergodic_rayleigh(1, 200, p).unwrap() * NATS;
373        assert!((lim - p.ln_1p()).abs() < 0.01, "{lim}");
374    }
375
376    /// Telatar 1999, Fig. 1 ($1 \le r, t \le 20$, $P = 20$ dB): its five contours are
377    /// labelled 12.7, 25.3, 38, 50.7 and 63.4 nats, the multiples $k\, C_{\max} / 6$ of
378    /// the largest value, $C(20, 20) = 76.0$ nats by eq. (8); Fig. 2 and eq. (13): with
379    /// $r = t$ the capacity is nearly linear in $r$, $C(20, 20) / 20$ within 0.5 % of the
380    /// limit (13) at every $P$ from 0 to 35 dB.
381    #[test]
382    fn telatar_1999_figs_1_2_and_eq_13() {
383        let p = 100.0;
384        let cmax = ergodic_rayleigh(20, 20, p).unwrap() * NATS;
385        for (k, label) in [(1.0, 12.7), (2.0, 25.3), (3.0, 38.0), (4.0, 50.7), (5.0, 63.4)] {
386            assert!((k * cmax / 6.0 - label).abs() < 0.05, "{k}: {}", k * cmax / 6.0);
387        }
388        for t in [1usize, 7, 13, 19] {
389            assert!(ergodic_rayleigh(20, t, p).unwrap() * NATS < cmax);
390        }
391        for p_db in [0.0, 10.0, 20.0, 35.0] {
392            let p = comnum_math::db2lin(p_db);
393            let c = ergodic_rayleigh(20, 20, p).unwrap() / 20.0;
394            assert!((c / ergodic_rayleigh_limit(p) - 1.0).abs() < 5e-3, "{p_db} dB: {c}");
395        }
396    }
397
398    /// Telatar 1999, Fig. 3 (the $\varepsilon$-capacity of eq. (17) for $t = 1$): the
399    /// ends of the curves at $r = 10$ read on panel (c), $P = 30$ dB, 8.85 nats for
400    /// $\varepsilon = 10^{-1}$ and 7.75 nats for $10^{-4}$ (eq. (17) gives 8.74 and
401    /// 7.70); Fig. 4 (one receive antenna): at $P = 15$ dB the curves of
402    /// $t = 1, \dots, 10$ cross near $P(\Psi \le R) \approx 0.6$ at
403    /// $R \approx 3.5$ nats $= \ln(1 + P)$, more transmit antennas being better below the
404    /// crossing.
405    #[test]
406    fn telatar_1999_figs_3_4_outage() {
407        let p = 1e3;
408        let hi = outage_capacity_rayleigh(10, 1, p, 0.1).unwrap() * NATS;
409        let lo = outage_capacity_rayleigh(10, 1, p, 1e-4).unwrap() * NATS;
410        assert!((hi - 8.85).abs() < 0.15 && (lo - 7.75).abs() < 0.1, "{hi}, {lo}");
411        let p = comnum_math::db2lin(15.0);
412        let rate = p.ln_1p() / NATS;
413        let probs: Vec<f64> = (1..=10).map(|t| outage_rayleigh(1, t, p, rate).unwrap()).collect();
414        for w in probs.windows(2) {
415            assert!((w[1] - w[0]).abs() < 0.05, "{probs:?}");
416        }
417        assert!((probs[9] - 0.6).abs() < 0.1, "{probs:?}");
418        let low = 2.0 / NATS;
419        assert!(outage_rayleigh(1, 10, p, low).unwrap() < outage_rayleigh(1, 2, p, low).unwrap());
420    }
421}