Skip to main content

comnum_mimo/
precoding.rs

1//! Linear precoding: eigenmode transmission with water-filling, and the zero-forcing
2//! precoders of the multi-user downlink.
3//!
4//! # Eigenmodes
5//!
6//! With the SVD $H = U \Sigma V^{H}$, sending $x = V P^{1/2} s$ and filtering with
7//! $U^{H}$ turns the channel into parallel scalar channels of gains $\sigma_{i}$:
8//! $\tilde y_{i} = \sigma_{i} \sqrt{p_{i}}\\, s_{i} + \tilde w_{i}$. Water-filling chooses
9//! the powers that maximise $\sum_{i} \log_{2}(1 + p_{i} g_{i})$ under
10//! $\sum_{i} p_{i} = P$, with $g_{i} = \sigma_{i}^{2} / \sigma^{2}$:
11//!
12//! $$
13//! p_{i} = \left(\mu - \frac{1}{g_{i}}\right)^{+}
14//! $$
15//!
16//! # Multi-user downlink
17//!
18//! The rows of $H$ are the channels of single-antenna users. Zero forcing
19//! $W \propto H^{H} (H H^{H})^{-1}$ removes the interference between users; the
20//! regularised version $W \propto H^{H} (H H^{H} + \alpha I)^{-1}$, with
21//! $\alpha = K \sigma^{2} / P$ for $K$ users, maximises the signal-to-leakage ratio. Both
22//! are scaled to the total power $\lVert W \rVert_{F}^{2} = P$.
23//!
24//! # Reconfigurable intelligent surface
25//!
26//! A RIS of $N$ elements reflects $\sum_{i} g_{i} e^{j\phi_{i}} h_{i}$ (`comnum_channel::ris`):
27//! [`ris_co_phasing`] gives the phases $\phi_{i} = -\arg(h_{i} g_{i})$ that align every
28//! product, which maximise the SNR to $(\sum_{i} |h_{i}| |g_{i}|)^{2} E_{s}/N_{0}$
29//! (Basar et al. 2019, eqs. (11)–(13), as maximal-ratio combining).
30//!
31//! # Examples
32//! ```
33//! use comnum_mimo::precoding::water_filling_powers;
34//! let p = water_filling_powers(&[4.0, 1.0, 0.1], 1.0)?;
35//! assert!((p.iter().sum::<f64>() - 1.0).abs() < 1e-12);
36//! assert_eq!(p[2], 0.0);                       // the weakest mode stays off
37//! # Ok::<(), comnum_core::Error>(())
38//! ```
39//!
40//! # References
41//!
42//! 1. E. Telatar, "Capacity of multi-antenna Gaussian channels," *Eur. Trans.
43//!    Telecommun.*, 10(6), 1999.
44//! 2. C. B. Peel, B. M. Hochwald and A. L. Swindlehurst, "A vector-perturbation
45//!    technique for near-capacity multiantenna multiuser communication — Part I:
46//!    channel inversion and regularization," *IEEE Trans. Commun.*, 53(1), 2005.
47//! 3. E. Basar, M. Di Renzo, J. de Rosny, M. Debbah, M.-S. Alouini and R. Zhang,
48//!    "Wireless communications through reconfigurable intelligent surfaces," *IEEE
49//!    Access*, 2019 (arXiv 1906.09490).
50
51use crate::detection::FLOOR;
52use comnum_core::signal::Symbols;
53use comnum_core::traits::Precode;
54use comnum_core::{Error, Result, ensure};
55use comnum_math::C64;
56use comnum_math::linalg::{CMat, hermitian, identity, inverse_hpd, svd};
57use ndarray::{Array2, ArrayView2};
58
59/// The water-filling powers of parallel channels of gains `gains` (SNR per unit
60/// power) under the total power `total_power`.
61///
62/// # Errors
63/// [`Error::Config`] for a negative gain or a non-positive power.
64pub fn water_filling_powers(gains: &[f64], total_power: f64) -> Result<Vec<f64>> {
65    ensure(total_power > 0.0, || format!("the total power must be positive, got {total_power}"))?;
66    ensure(gains.iter().all(|g| *g >= 0.0 && g.is_finite()), || "the gains must be non-negative".into())?;
67    let mut order: Vec<usize> = (0..gains.len()).filter(|&i| gains[i] > 0.0).collect();
68    order.sort_by(|a, b| gains[*b].total_cmp(&gains[*a]));
69    let mut p = vec![0.0; gains.len()];
70    // the largest set of active modes whose water level stays above their floors
71    for active in (1..=order.len()).rev() {
72        let inv: f64 = order[..active].iter().map(|&i| 1.0 / gains[i]).sum();
73        let mu = (total_power + inv) / active as f64;
74        if mu > 1.0 / gains[order[active - 1]] {
75            for &i in &order[..active] {
76                p[i] = mu - 1.0 / gains[i];
77            }
78            break;
79        }
80    }
81    Ok(p)
82}
83
84/// Eigenmode (SVD) transmission: precoder $V P^{1/2}$ and receive filter $U^{H}$.
85#[derive(Clone, Debug, PartialEq)]
86pub struct Eigenmode {
87    /// Precoder, $n_{t} \times$ active streams.
88    pub precoder: CMat,
89    /// Receive filter, active streams $\times n_{r}$.
90    pub combiner: CMat,
91    /// Singular values of the active modes.
92    pub gains: Vec<f64>,
93    /// Power of every active mode.
94    pub powers: Vec<f64>,
95}
96
97impl Eigenmode {
98    /// The modes of `h` with water-filling of `total_power` against the receiver noise
99    /// variance `noise_var` (equal power on every non-zero mode if `water_filling` is
100    /// false); modes without power are dropped.
101    ///
102    /// # Errors
103    /// As [`water_filling_powers`], and [`Error::Config`] for a non-positive noise variance or a
104    /// zero channel.
105    pub fn new(h: &ArrayView2<'_, C64>, total_power: f64, noise_var: f64, water_filling: bool) -> Result<Eigenmode> {
106        ensure(noise_var > 0.0, || format!("the noise variance must be positive, got {noise_var}"))?;
107        let (u, s, v) = svd(h);
108        let nonzero: Vec<usize> = (0..s.len()).filter(|&i| s[i] > 1e-12 * s[0].max(FLOOR)).collect();
109        ensure(!nonzero.is_empty(), || "the channel is zero".into())?;
110        let powers_all = if water_filling {
111            water_filling_powers(&s.iter().map(|x| x * x / noise_var).collect::<Vec<_>>(), total_power)?
112        } else {
113            let p = total_power / nonzero.len() as f64;
114            (0..s.len()).map(|i| if nonzero.contains(&i) { p } else { 0.0 }).collect()
115        };
116        let active: Vec<usize> = (0..s.len()).filter(|&i| powers_all[i] > 0.0).collect();
117        let precoder =
118            Array2::from_shape_fn((h.ncols(), active.len()), |(r, c)| v[[r, active[c]]] * powers_all[active[c]].sqrt());
119        let combiner = Array2::from_shape_fn((active.len(), h.nrows()), |(r, c)| u[[c, active[r]]].conj());
120        Ok(Eigenmode {
121            precoder,
122            combiner,
123            gains: active.iter().map(|&i| s[i]).collect(),
124            powers: active.iter().map(|&i| powers_all[i]).collect(),
125        })
126    }
127
128    /// Number of active streams.
129    pub fn n_streams(&self) -> usize {
130        self.powers.len()
131    }
132}
133
134impl Precode for Eigenmode {
135    fn precode_into(&self, x: &Symbols, out: &mut Symbols) -> Result<()> {
136        precode_with(&self.precoder, x, out)
137    }
138}
139
140/// $x = W s$ for every column of the streams `x` into `out`.
141fn precode_with(w: &CMat, x: &Symbols, out: &mut Symbols) -> Result<()> {
142    if x.n_streams() != w.ncols() {
143        return Err(Error::Shape(format!("{} streams for a precoder of {}", x.n_streams(), w.ncols())));
144    }
145    out.assign(&w.dot(x.as_array()));
146    Ok(())
147}
148
149/// A linear precoder $x = W s$ (one column of the streams at a time).
150#[derive(Clone, Debug, PartialEq)]
151pub struct LinearPrecoder {
152    /// The precoding matrix, $n_{t} \times$ streams.
153    pub w: CMat,
154}
155
156impl LinearPrecoder {
157    /// Zero forcing for the users of the rows of `h`, scaled to `total_power`.
158    ///
159    /// # Errors
160    /// [`Error::Config`] if there are more users than transmit antennas,
161    /// [`Error::Numerical`] for a rank-deficient channel.
162    pub fn zero_forcing(h: &ArrayView2<'_, C64>, total_power: f64) -> Result<LinearPrecoder> {
163        ensure(h.nrows() <= h.ncols(), || {
164            format!("zero forcing serves at most {} users, not {}", h.ncols(), h.nrows())
165        })?;
166        LinearPrecoder::regularized(h, 0.0, total_power)
167    }
168
169    /// Regularised zero forcing $H^{H}(H H^{H} + \alpha I)^{-1}$, scaled to
170    /// `total_power` (use $\alpha = K \sigma^{2} / P$).
171    ///
172    /// # Errors
173    /// [`Error::Config`] for a negative `alpha` or a non-positive power,
174    /// [`Error::Numerical`] for a singular system.
175    pub fn regularized(h: &ArrayView2<'_, C64>, alpha: f64, total_power: f64) -> Result<LinearPrecoder> {
176        ensure(alpha >= 0.0 && total_power > 0.0, || "alpha ≥ 0 and a positive power".into())?;
177        let hh = hermitian(h);
178        let g = h.dot(&hh) + identity(h.nrows()).mapv(|x| x * alpha);
179        let inv = inverse_hpd(&g.view()).map_err(|_| Error::Numerical("the channel is rank deficient".into()))?;
180        let w = hh.dot(&inv);
181        let norm2: f64 = w.iter().map(C64::norm_sqr).sum();
182        Ok(LinearPrecoder { w: w.mapv(|x| x * (total_power / norm2).sqrt()) })
183    }
184}
185
186impl Precode for LinearPrecoder {
187    fn precode_into(&self, x: &Symbols, out: &mut Symbols) -> Result<()> {
188        precode_with(&self.w, x, out)
189    }
190}
191
192/// The phases $\phi_{i} = -\arg(h_{i} g_{i})$ (rad) of the elements of a RIS that align
193/// the products of its hops `h` (source to element) and `g` (element to destination):
194/// the gain $\sum_{i} g_{i} e^{j\phi_{i}} h_{i}$ is then $\sum_{i} |h_{i}| |g_{i}|$.
195///
196/// # Errors
197/// [`comnum_core::Error::Shape`] for hops of different lengths.
198pub fn ris_co_phasing(h: &[C64], g: &[C64]) -> Result<Vec<f64>> {
199    comnum_core::ensure_shape(h.len() == g.len(), || format!("hops of {} and {} elements", h.len(), g.len()))?;
200    Ok(h.iter().zip(g).map(|(a, b)| -(a * b).arg()).collect())
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use comnum_core::rng::Rng;
207
208    #[test]
209    fn co_phased_elements_add_in_amplitude() {
210        let mut r = Rng::new(3);
211        let (h, g): (Vec<C64>, Vec<C64>) = (0..32).map(|_| (r.complex_gaussian(), r.complex_gaussian())).unzip();
212        let phases = ris_co_phasing(&h, &g).unwrap();
213        let gain: C64 = h.iter().zip(&g).zip(&phases).map(|((a, b), p)| b * C64::from_polar(1.0, *p) * a).sum();
214        let amplitude: f64 = h.iter().zip(&g).map(|(a, b)| a.norm() * b.norm()).sum();
215        assert!((gain - amplitude).norm() < 1e-12 * amplitude);
216        assert!(ris_co_phasing(&h, &g[1..]).is_err());
217    }
218
219    fn channel(nr: usize, nt: usize, seed: u64) -> CMat {
220        let mut r = Rng::new(seed);
221        Array2::from_shape_fn((nr, nt), |_| r.complex_gaussian())
222    }
223
224    #[test]
225    fn water_filling_satisfies_its_conditions() {
226        let g = [3.0, 1.5, 0.4, 0.05];
227        for p in [0.1, 1.0, 10.0, 100.0] {
228            let w = water_filling_powers(&g, p).unwrap();
229            assert!((w.iter().sum::<f64>() - p).abs() < 1e-12);
230            // a common water level on the active modes, below the floor of the others
231            let levels: Vec<f64> = (0..4).filter(|&i| w[i] > 0.0).map(|i| w[i] + 1.0 / g[i]).collect();
232            assert!(levels.windows(2).all(|l| (l[0] - l[1]).abs() < 1e-12));
233            for i in (0..4).filter(|&i| w[i] == 0.0) {
234                assert!(1.0 / g[i] >= levels[0] - 1e-12);
235            }
236        }
237        assert_eq!(water_filling_powers(&[1.0, 1.0], 2.0).unwrap(), vec![1.0, 1.0]);
238        assert!(water_filling_powers(&[1.0], 0.0).is_err());
239    }
240
241    #[test]
242    fn eigenmodes_diagonalise_the_channel() {
243        let h = channel(4, 3, 1);
244        let e = Eigenmode::new(&h.view(), 3.0, 0.1, false).unwrap();
245        let d = e.combiner.dot(&h).dot(&e.precoder);
246        for i in 0..e.n_streams() {
247            for j in 0..e.n_streams() {
248                let want = if i == j { e.gains[i] * e.powers[i].sqrt() } else { 0.0 };
249                assert!((d[[i, j]] - C64::new(want, 0.0)).norm() < 1e-10);
250            }
251        }
252        // at low SNR water-filling keeps the strongest mode only
253        assert_eq!(Eigenmode::new(&h.view(), 1e-3, 1.0, true).unwrap().n_streams(), 1);
254    }
255
256    #[test]
257    fn zero_forcing_removes_the_interference_between_users() {
258        let h = channel(3, 4, 2);
259        let p = LinearPrecoder::zero_forcing(&h.view(), 2.0).unwrap();
260        let e = h.dot(&p.w);
261        for i in 0..3 {
262            for j in 0..3 {
263                if i != j {
264                    assert!(e[[i, j]].norm() < 1e-10);
265                }
266            }
267        }
268        assert!((p.w.iter().map(C64::norm_sqr).sum::<f64>() - 2.0).abs() < 1e-12);
269        assert!(LinearPrecoder::zero_forcing(&channel(5, 4, 3).view(), 1.0).is_err());
270        let x = Symbols::new(Array2::from_elem((3, 5), C64::new(1.0, 0.0)));
271        let mut out = Symbols::default();
272        p.precode_into(&x, &mut out).unwrap();
273        assert_eq!((out.n_streams(), out.len()), (4, 5));
274    }
275}