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}