Skip to main content

comnum_mimo/
noma.rs

1//! Downlink non-orthogonal multiple access (NOMA) with users dropped at random in a disc
2//! (Ding, Yang, Fan & Poor 2014): superposition coding at the base station, successive
3//! interference cancellation (SIC) at the users, outage probabilities in closed form.
4//!
5//! $M$ users lie uniformly in a disc of radius $\mathcal{R}_{D}$ around the base station;
6//! the channel of a user at the distance $d$ is $h = g/\sqrt{1 + d^{\alpha}}$ with
7//! $g \sim \mathcal{CN}(0, 1)$. The unordered gain $|h|^{2}$ has the CDF (eq. (3))
8//! $$F(y) = \frac{2}{\mathcal{R}_{D}^{2}}\int_{0}^{\mathcal{R}_{D}}\left(1 - e^{-(1 + z^{\alpha})y}\right) z\, dz$$
9//! ([`RandomDisc::gain_cdf`], by Gauss–Legendre with 256 nodes), approximated in the
10//! paper by Gauss–Chebyshev with $N$ nodes (eq. (4), [`RandomDisc::gain_cdf_chebyshev`]).
11//! With the gains ordered $|h_{1}|^{2} \le \dots \le |h_{M}|^{2}$, the power coefficients
12//! $a_{1} \ge \dots \ge a_{M}$ and the target rates $\tilde R_{m}$, $\phi_{j} = 2^{\tilde R_{j}} - 1$,
13//! user $m$ decodes the messages $1..m$ when $|h_{m}|^{2} \ge \psi_{m}^{*} = \max_{j \le m}\psi_{j}$,
14//! $\psi_{j} = \phi_{j}/(\rho(a_{j} - \phi_{j}\sum_{i > j} a_{i}))$ and $\psi_{M} = \phi_{M}/(\rho a_{M})$
15//! (eqs. (7)–(8); no outage threshold when $a_{j} \le \phi_{j}\sum_{i > j}a_{i}$: outage 1).
16//! The outage of user $m$ is that of the $m$-th order statistic (eq. (9)),
17//! $P_{m} = I_{F(\psi_{m}^{*})}(m, M - m + 1)$ ([`NomaDownlink::outage`]), and at high SNR
18//! (eq. (11)) $P_{m} \approx \frac{\tau_{m}}{m}\eta^{m}(\psi_{m}^{*})^{m}$, $\tau_{m} = \frac{M!}{(m-1)!(M-m)!}$,
19//! $\eta = \frac{1}{\mathcal{R}_{D}}\sum_{n}\beta_{n}$ ([`NomaDownlink::outage_high_snr`]):
20//! user $m$ has the diversity order $m$.
21//!
22//! # Examples
23//! ```
24//! use comnum_mimo::noma::{NomaDownlink, RandomDisc};
25//! // Fig. 3 of Ding et al., case 1: 80 m, alpha = 3, rates 0.1 and 0.5, rho = 80 dB
26//! let disc = RandomDisc { radius: 80.0, alpha: 3.0 };
27//! let noma = NomaDownlink::new(vec![0.8, 0.2], vec![0.1, 0.5])?;
28//! let p = noma.outage_high_snr(&disc, 1e8, 10)?;
29//! assert!((p[0] / 3.781e-4 - 1.0).abs() < 1e-3 && (p[1] / 0.1837e-4 - 1.0).abs() < 1e-3, "{p:?}");
30//! # Ok::<(), comnum_core::Error>(())
31//! ```
32//!
33//! # References
34//! 1. Z. Ding, Z. Yang, P. Fan, H. V. Poor, "On the performance of non-orthogonal
35//!    multiple access in 5G systems with randomly deployed users," *IEEE Signal Process.
36//!    Lett.* 21(12), 1501–1505, 2014 (arXiv:1406.1516): eqs. (3)–(11), Figs. 1, 3.
37
38use comnum_core::{Error, Result};
39use comnum_math::{beta_inc, gauss_chebyshev, gauss_legendre};
40
41/// Users dropped uniformly in a disc around the base station, path loss $1 + d^{\alpha}$,
42/// Rayleigh fading.
43#[derive(Clone, Copy, Debug, PartialEq)]
44pub struct RandomDisc {
45    /// Radius $\mathcal{R}_{D}$ of the disc (m).
46    pub radius: f64,
47    /// Path-loss exponent $\alpha$.
48    pub alpha: f64,
49}
50
51impl RandomDisc {
52    fn check(&self) -> Result<()> {
53        if self.radius > 0.0 && self.alpha > 0.0 && self.radius.is_finite() && self.alpha.is_finite() {
54            Ok(())
55        } else {
56            Err(Error::Config(format!("a disc needs a positive radius and exponent, got {self:?}")))
57        }
58    }
59
60    /// The CDF of the gain $|h|^{2}$ of a user dropped at random, eq. (3) (Gauss–Legendre,
61    /// 256 nodes on $[0, \mathcal{R}_{D}]$).
62    ///
63    /// # Errors
64    /// [`Error::Config`] for a radius or an exponent that is not positive and finite.
65    pub fn gain_cdf(&self, y: f64) -> Result<f64> {
66        self.check()?;
67        let (t, w) = gauss_legendre(256);
68        let r = self.radius;
69        let s: f64 = t
70            .iter()
71            .zip(&w)
72            .map(|(&t, &w)| {
73                let z = r / 2.0 * (t + 1.0);
74                w * (-(-(1.0 + z.powf(self.alpha)) * y).exp_m1()) * z
75            })
76            .sum();
77        Ok(s * r / 2.0 * 2.0 / (r * r))
78    }
79
80    /// The Gauss–Chebyshev nodes $c_{n}$ and coefficients $\beta_{n}$ of eqs. (4)–(5).
81    fn chebyshev(&self, n: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
82        let (theta, w) = gauss_chebyshev(n);
83        let r = self.radius;
84        let z: Vec<f64> = theta.iter().map(|&t| r / 2.0 * t + r / 2.0).collect();
85        let c: Vec<f64> = z.iter().map(|&z| 1.0 + z.powf(self.alpha)).collect();
86        // the factor of 1 - e^{-c_n y} in eq. (4) and beta_n of eq. (5)
87        let a: Vec<f64> = theta.iter().zip(&w).zip(&z).map(|((&t, &w), &z)| w * (1.0 - t * t).sqrt() * z).collect();
88        let beta = a.iter().zip(&c).map(|(a, c)| a * c).collect();
89        (c, a, beta)
90    }
91
92    /// The Gauss–Chebyshev approximation (4) of [`RandomDisc::gain_cdf`] with `n` nodes.
93    ///
94    /// # Errors
95    /// As [`RandomDisc::gain_cdf`], and [`Error::Config`] for `n` = 0.
96    pub fn gain_cdf_chebyshev(&self, y: f64, n: usize) -> Result<f64> {
97        self.check()?;
98        if n == 0 {
99            return Err(Error::Config("Gauss-Chebyshev needs at least one node".into()));
100        }
101        let (c, a, _) = self.chebyshev(n);
102        Ok(c.iter().zip(&a).map(|(c, a)| a * (1.0 - (-c * y).exp())).sum::<f64>() / self.radius)
103    }
104
105    /// The slope $\eta = \frac{1}{\mathcal{R}_{D}}\sum_{n}\beta_{n}$ of the approximate CDF at
106    /// zero (eq. (10)) with `n` Gauss–Chebyshev nodes.
107    ///
108    /// # Errors
109    /// As [`RandomDisc::gain_cdf_chebyshev`].
110    pub fn gain_slope(&self, n: usize) -> Result<f64> {
111        self.check()?;
112        if n == 0 {
113            return Err(Error::Config("Gauss-Chebyshev needs at least one node".into()));
114        }
115        Ok(self.chebyshev(n).2.iter().sum::<f64>() / self.radius)
116    }
117}
118
119/// A downlink NOMA policy: the power coefficients $a_{1} \ge \dots \ge a_{M}$ of the
120/// users ordered by increasing gain and their target rates $\tilde R_{m}$ (bit per
121/// channel use).
122#[derive(Clone, Debug, PartialEq)]
123pub struct NomaDownlink {
124    /// Power coefficients $a_{m}$ (summing to 1).
125    pub powers: Vec<f64>,
126    /// Target rates $\tilde R_{m}$ (bit per channel use).
127    pub rates: Vec<f64>,
128}
129
130impl NomaDownlink {
131    /// The policy of the power coefficients `powers` and the target rates `rates`.
132    ///
133    /// # Errors
134    /// [`Error::Config`] for no user, lengths that differ, negative values or powers that
135    /// do not sum to 1.
136    pub fn new(powers: Vec<f64>, rates: Vec<f64>) -> Result<NomaDownlink> {
137        if powers.is_empty() || powers.len() != rates.len() {
138            return Err(Error::Shape(format!("{} power coefficients for {} rates", powers.len(), rates.len())));
139        }
140        if powers.iter().chain(&rates).any(|v| !(v.is_finite() && *v >= 0.0)) {
141            return Err(Error::Config("power coefficients and rates must be non-negative".into()));
142        }
143        if (powers.iter().sum::<f64>() - 1.0).abs() > 1e-9 {
144            return Err(Error::Config(format!("the power coefficients must sum to 1, got {powers:?}")));
145        }
146        Ok(NomaDownlink { powers, rates })
147    }
148
149    /// The thresholds $\psi_{m}^{*}$ of the users at the transmit SNR `snr` (linear): user
150    /// $m$ decodes its message when $|h_{m}|^{2} \ge \psi_{m}^{*}$ (infinite when condition
151    /// (8) fails for one of the messages it decodes).
152    pub fn thresholds(&self, snr: f64) -> Vec<f64> {
153        let m = self.powers.len();
154        let psi: Vec<f64> = (0..m)
155            .map(|j| {
156                let phi = 2f64.powf(self.rates[j]) - 1.0;
157                let rest: f64 = self.powers[j + 1..].iter().sum();
158                let margin = self.powers[j] - phi * rest;
159                if margin > 0.0 { phi / (snr * margin) } else { f64::INFINITY }
160            })
161            .collect();
162        psi.iter()
163            .scan(0.0f64, |acc, &p| {
164                *acc = acc.max(p);
165                Some(*acc)
166            })
167            .collect()
168    }
169
170    /// The outage probability of every user at the transmit SNR `snr` (linear), eq. (9):
171    /// $I_{F(\psi_{m}^{*})}(m, M - m + 1)$, $F$ the exact CDF (3).
172    ///
173    /// # Errors
174    /// As [`RandomDisc::gain_cdf`].
175    pub fn outage(&self, disc: &RandomDisc, snr: f64) -> Result<Vec<f64>> {
176        let m = self.powers.len();
177        self.thresholds(snr)
178            .iter()
179            .enumerate()
180            .map(|(i, &psi)| {
181                if psi.is_infinite() {
182                    return Ok(1.0);
183                }
184                let f = disc.gain_cdf(psi)?;
185                Ok(beta_inc((i + 1) as f64, (m - i) as f64, f))
186            })
187            .collect()
188    }
189
190    /// The high-SNR approximation (11) of the outage of every user with `n`
191    /// Gauss–Chebyshev nodes: $\frac{\tau_{m}}{m}\eta^{m}(\psi_{m}^{*})^{m}$, clipped at 1.
192    ///
193    /// # Errors
194    /// As [`RandomDisc::gain_slope`].
195    pub fn outage_high_snr(&self, disc: &RandomDisc, snr: f64, n: usize) -> Result<Vec<f64>> {
196        let eta = disc.gain_slope(n)?;
197        let m = self.powers.len();
198        Ok(self
199            .thresholds(snr)
200            .iter()
201            .enumerate()
202            .map(|(i, &psi)| {
203                let k = i + 1;
204                // tau_m / m = M! / (m! (M - m)!), the binomial coefficient
205                let binom = (0..k).fold(1.0, |c, j| c * (m - j) as f64 / (j + 1) as f64);
206                (binom * (eta * psi).powi(k as i32)).min(1.0)
207            })
208            .collect())
209    }
210}
211
212/// The outage probability of a user scheduled at random on an orthogonal resource at the
213/// transmit SNR `snr` (linear) and the rate `rate` (bit per channel use): $F((2^{R} - 1)/\rho)$
214/// (the "conventional MA" of Ding et al. 2014, Figs. 1 and 3).
215///
216/// # Errors
217/// As [`RandomDisc::gain_cdf`].
218pub fn orthogonal_outage(disc: &RandomDisc, snr: f64, rate: f64) -> Result<f64> {
219    disc.gain_cdf((2f64.powf(rate) - 1.0) / snr)
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    /// The exact CDF is 0 at 0 and tends to 1; for alpha = 2 it has the closed form
227    /// $1 - e^{-y}(1 - e^{-R^{2}y})/(R^{2}y)$; Gauss–Chebyshev approaches it.
228    #[test]
229    fn the_gain_cdf_has_its_closed_form_for_alpha_2() {
230        let d = RandomDisc { radius: 3.0, alpha: 2.0 };
231        for y in [1e-3f64, 0.05, 0.5, 3.0] {
232            let r2 = 9.0;
233            let exact = 1.0 - (-y).exp() * (1.0 - (-r2 * y).exp()) / (r2 * y);
234            assert!((d.gain_cdf(y).unwrap() - exact).abs() < 1e-12, "{y}");
235            assert!((d.gain_cdf_chebyshev(y, 200).unwrap() - exact).abs() < 1e-4, "{y}");
236        }
237        assert!(d.gain_cdf(0.0).unwrap().abs() < 1e-15);
238        assert!(RandomDisc { radius: -1.0, alpha: 2.0 }.gain_cdf(1.0).is_err());
239    }
240
241    /// Condition (8) fails for $a_{1} = 0.6$ at the rate 2 ($\phi = 3 > 0.6/0.4$): every user is
242    /// in outage; the outage falls with the SNR, faster for the stronger user.
243    #[test]
244    fn the_outage_follows_condition_8_and_the_diversity_order() {
245        let d = RandomDisc { radius: 5.0, alpha: 3.0 };
246        let bad = NomaDownlink::new(vec![0.6, 0.4], vec![2.0, 1.0]).unwrap();
247        assert_eq!(bad.outage(&d, 1e6).unwrap(), vec![1.0, 1.0]);
248        let noma = NomaDownlink::new(vec![0.8, 0.2], vec![0.1, 0.5]).unwrap();
249        let (p3, p4) = (noma.outage(&d, 1e3).unwrap(), noma.outage(&d, 1e4).unwrap());
250        // one decade per 10 dB for user 1, two for user 2
251        assert!(((p3[0] / p4[0]).log10() - 1.0).abs() < 0.1 && ((p3[1] / p4[1]).log10() - 2.0).abs() < 0.15);
252        assert!(NomaDownlink::new(vec![0.5, 0.4], vec![0.1, 0.1]).is_err());
253        assert!(NomaDownlink::new(vec![1.0], vec![0.1, 0.1]).is_err());
254    }
255}