Skip to main content

comnum_mimo/
estimation.rs

1//! Training-based estimation of a flat MIMO channel: the least-squares, scaled
2//! least-squares, LMMSE and relaxed MMSE estimators, their mean square errors, the
3//! optimal training matrices and the combination of several training periods
4//! (Biguesh & Gershman 2006).
5//!
6//! # Model
7//!
8//! An $r \times t$ channel $H$ is probed by $N \ge t$ training vectors, the columns of
9//! the $t \times N$ matrix $P$ of power $\lVert P \rVert_{F}^{2} = \mathcal{P}$:
10//!
11//! $$
12//! S = H P + V,
13//! $$
14//!
15//! $V$ white of variance $\sigma_{n}^{2}$ per entry (eq. (2)). The prior of $H$ is its
16//! correlation $R_{H} = E\\{H^{H} H\\}$ ($t \times t$, footnote 2: not that of
17//! $\mathrm{vec}(H)$), the rows of $H$ uncorrelated. Every estimator is linear,
18//! $\hat{H} = S A$ with an $N \times t$ matrix $A$ ([`ChannelEstimator`]):
19//!
20//! | Estimator | $A$ | Needs | Eq. |
21//! |---|---|---|---|
22//! | LS | $P^{\dagger} = P^{H} (P P^{H})^{-1}$ | — | (4) |
23//! | scaled LS | $\gamma_{o} P^{\dagger}$, $\gamma_{o} = \frac{\mathrm{tr}\\{R_{H}\\}}{J_{\mathrm{LS}} + \mathrm{tr}\\{R_{H}\\}}$ | $\mathrm{tr}\\{R_{H}\\}$, $\sigma_{n}^{2}$ | (14), (16) |
24//! | LMMSE | $(P^{H} R_{H} P + \sigma_{n}^{2} r I)^{-1} P^{H} R_{H}$ | $R_{H}$, $\sigma_{n}^{2}$ | (25), (26) |
25//! | relaxed MMSE | the LMMSE of $R_{H} = \frac{\mathrm{tr}\\{R_{H}\\}}{t} I$ | $\mathrm{tr}\\{R_{H}\\}$, $\sigma_{n}^{2}$ | (47), (48) |
26//!
27//! Scaled LS and relaxed MMSE take $\mathrm{tr}\\{R_{H}\\}$ either known or estimated
28//! by $\mathrm{tr}\\{\hat{H}_{\mathrm{LS}}^{H} \hat{H}_{\mathrm{LS}}\\}$ (eq. (17), the
29//! LS-SLS and LS-RMMSE estimators; [`TracePrior`]).
30//!
31//! # Mean square error
32//!
33//! With $B = I - P A$, the error $H - \hat{H} = H B - V A$ has
34//!
35//! $$
36//! J = E\\{\lVert H - \hat{H} \rVert_{F}^{2}\\} = \mathrm{tr}\\{B^{H} R_{H} B\\} +
37//!   \sigma_{n}^{2} r \\, \mathrm{tr}\\{A^{H} A\\},
38//! $$
39//!
40//! the closed form [`Training::mse`] for every estimator whose prior is known, also
41//! when the prior of the estimator is not the true $R_{H}$ (a mismatch). It gives
42//! $J_{\mathrm{LS}} = \sigma_{n}^{2} r \\, \mathrm{tr}\\{(P P^{H})^{-1}\\}$ (7),
43//! $J_{\mathrm{SLS}} = \frac{J_{\mathrm{LS}} \mathrm{tr}\\{R_{H}\\}}{J_{\mathrm{LS}} +
44//! \mathrm{tr}\\{R_{H}\\}}$ (15) and
45//! $J_{\mathrm{MMSE}} = \mathrm{tr}\\{(R_{H}^{-1} + \frac{1}{\sigma_{n}^{2} r} P P^{H})^{-1}\\}$
46//! (28). LS-SLS and LS-RMMSE have no closed form (a Monte Carlo).
47//!
48//! # Training
49//!
50//! Orthogonal rows, $P P^{H} = \frac{\mathcal{P}}{t} I$ (9), minimise the MSE of LS,
51//! scaled LS and relaxed MMSE; [`Training::orthogonal`] is the DFT matrix of eq. (10),
52//! of entries of equal magnitude. For the LMMSE, the optimal training
53//! [`Training::mmse_optimal`] water-fills the power on the eigenvectors of
54//! $R_{H} = Q \Lambda Q^{H}$: $P = \sqrt{\sigma_{n}^{2} r}\\, Q \\,[\mathrm{diag}(p_{i})^{1/2}, 0]$,
55//! $p_{i} = (\mu_{o} - 1/\lambda_{i})^{+}$, $\sum_{i} p_{i} = \mathcal{P}/(\sigma_{n}^{2} r)$
56//! (eqs. (41)–(44), the unitary $U$ of (44) the identity).
57//!
58//! # Several training periods
59//!
60//! The LS estimates $\hat{H}_{k}$ of $K$ periods of training $P_{k}$, the channel
61//! unchanged, are combined by the best linear unbiased estimator [`blue_combine`] of
62//! weights [`blue_weights`] (Lemma 4, eq. (54)):
63//!
64//! $$
65//! \alpha_{k} = \frac{1 / \kappa_{k}}{\sum_{l} 1 / \kappa_{l}}, \qquad
66//! \kappa_{k} = \mathrm{tr}\\{(P_{k} P_{k}^{H})^{-1}\\},
67//! $$
68//!
69//! $\alpha_{k} = \mathcal{P}_{k}/\mathcal{P}_{\mathrm{tot}}$ for orthogonal training
70//! (58), of MSE [`blue_mse`] $\sigma_{n}^{2} r / \sum_{l} 1/\kappa_{l}$ (eqs. (59), (61)).
71//!
72//! # Examples
73//! ```
74//! use comnum_mimo::estimation::{ChannelEstimator, Training};
75//! use comnum_math::linalg::{identity, matmul};
76//! let (t, r, power, noise) = (4, 2, 10.0, 0.5);
77//! let p = Training::orthogonal(t, t, power)?;
78//! let r_h = identity(t) * comnum_math::C64::from(r as f64);
79//! // LS: sigma^2 t^2 r / P, eq. (12)
80//! let j = p.mse(&ChannelEstimator::Ls, &r_h.view(), noise, r)?;
81//! assert!((j - noise * (t * t * r) as f64 / power).abs() < 1e-12);
82//! // a noiseless observation is estimated exactly by LS
83//! let h = comnum_math::linalg::CMat::from_elem((r, t), comnum_math::C64::new(0.3, -1.0));
84//! let s = matmul(&h.view(), &p.symbols().view());
85//! let h_ls = p.estimate(&s.view(), &ChannelEstimator::Ls, noise)?;
86//! assert!((&h_ls - &h).iter().all(|e| e.norm() < 1e-12));
87//! # Ok::<(), comnum_core::Error>(())
88//! ```
89//!
90//! # References
91//!
92//! 1. M. Biguesh and A. B. Gershman, "Training-based MIMO channel estimation: a study
93//!    of estimator tradeoffs and optimal training signals," *IEEE Trans. Signal
94//!    Process.*, 54(3), 884–893, 2006.
95
96use crate::precoding::water_filling_powers;
97use comnum_core::{Error, Result, ensure, ensure_shape};
98use comnum_math::C64;
99use comnum_math::linalg::{CMat, hermitian, identity, inverse_hpd, matmul, solve_hpd, svd};
100use ndarray::ArrayView2;
101use std::f64::consts::PI;
102
103/// The trace $\mathrm{tr}\\{R_{H}\\}$ used by the scaled LS and relaxed MMSE estimators.
104#[derive(Clone, Copy, Debug, PartialEq)]
105pub enum TracePrior {
106    /// Known (SLS, eq. (16); RMMSE, eq. (48)).
107    Known(f64),
108    /// Estimated from the LS estimate, $\mathrm{tr}\\{\hat{H}_{\mathrm{LS}}^{H}
109    /// \hat{H}_{\mathrm{LS}}\\}$ (eq. (17): LS-SLS; with (48): LS-RMMSE, eq. (51)).
110    FromLs,
111}
112
113/// A linear estimator $\hat{H} = S A$ of the channel (see the module).
114#[derive(Clone, Debug, PartialEq)]
115pub enum ChannelEstimator {
116    /// Least squares, $A = P^{\dagger}$ (eq. (4)).
117    Ls,
118    /// Scaled least squares, $A = \gamma_{o} P^{\dagger}$ (eqs. (14), (16)).
119    ScaledLs(TracePrior),
120    /// LMMSE of the correlation $R_{H} = E\\{H^{H} H\\}$ ($t \times t$, eq. (26)).
121    Mmse(CMat),
122    /// The LMMSE of $R_{H} = \frac{\mathrm{tr}\\{R_{H}\\}}{t} I$ (eq. (48)).
123    RelaxedMmse(TracePrior),
124}
125
126/// A training matrix $P$ ($t \times N$, see the module).
127#[derive(Clone, Debug, PartialEq)]
128pub struct Training {
129    p: CMat,
130    /// $(P P^{H})^{-1}$ when $P$ has rank $t$ (needed by LS).
131    gram_inv: Option<CMat>,
132}
133
134impl Training {
135    /// The training of the $t \times N$ matrix `p`, $N \ge t$.
136    ///
137    /// # Errors
138    /// [`Error::Config`] for an empty matrix, fewer columns than rows or a non-finite
139    /// entry. A matrix of rank below $t$ is accepted (the LMMSE estimator and the
140    /// water-filled training of a singular correlation); LS and scaled LS refuse it.
141    pub fn new(p: CMat) -> Result<Training> {
142        let (t, n) = p.dim();
143        ensure(t >= 1 && n >= t, || format!("the training must be t x N with N >= t >= 1, got {t} x {n}"))?;
144        ensure(p.iter().all(|z| z.re.is_finite() && z.im.is_finite()), || "the training must be finite".into())?;
145        let gram = matmul(&p.view(), &hermitian(&p.view()).view());
146        let gram_inv = inverse_hpd(&gram.view()).ok();
147        Ok(Training { p, gram_inv })
148    }
149
150    /// The DFT training of eq. (10): $\[P\]_{k,n} = \sqrt{\mathcal{P}/(N t)}\\,
151    /// e^{j 2\pi k n / N}$, of orthogonal rows, $P P^{H} = \frac{\mathcal{P}}{t} I$ (9).
152    ///
153    /// # Errors
154    /// [`Error::Config`] for `n < n_tx`, no antenna or a power that is not positive.
155    pub fn orthogonal(n_tx: usize, n: usize, total_power: f64) -> Result<Training> {
156        ensure(n_tx >= 1 && n >= n_tx, || format!("the training needs N >= t >= 1, got t = {n_tx}, N = {n}"))?;
157        ensure(total_power > 0.0 && total_power.is_finite(), || {
158            format!("the training power must be positive, got {total_power}")
159        })?;
160        let a = (total_power / (n * n_tx) as f64).sqrt();
161        let p = CMat::from_shape_fn((n_tx, n), |(k, m)| C64::from_polar(a, 2.0 * PI * ((k * m) % n) as f64 / n as f64));
162        Training::new(p)
163    }
164
165    /// The training that minimises the MSE of the LMMSE estimator of the correlation
166    /// `r_h` (eqs. (41)–(44)): the power water-filled on the eigenvectors of $R_{H}$, the
167    /// last $N - t$ columns zero.
168    ///
169    /// # Errors
170    /// [`Error::Config`] for a correlation that is not $t \times t$ with $t \ge 1$,
171    /// `n < t`, no receive antenna, a noise variance or a power that is not positive.
172    pub fn mmse_optimal(
173        r_h: &ArrayView2<'_, C64>,
174        noise_var: f64,
175        n_rx: usize,
176        total_power: f64,
177        n: usize,
178    ) -> Result<Training> {
179        let t = check_correlation(r_h)?;
180        ensure(n >= t, || format!("the training needs N >= t, got t = {t}, N = {n}"))?;
181        ensure(n_rx >= 1, || "at least one receive antenna".into())?;
182        ensure(noise_var > 0.0 && noise_var.is_finite(), || {
183            format!("the noise variance must be positive, got {noise_var}")
184        })?;
185        let scale = noise_var * n_rx as f64;
186        let (q, lambda, _) = svd(r_h);
187        let powers = water_filling_powers(&lambda, total_power / scale)?;
188        let mut p = CMat::zeros((t, n));
189        for (i, pw) in powers.iter().enumerate() {
190            let g = (scale * pw).sqrt();
191            for k in 0..t {
192                p[(k, i)] = q[(k, i)] * g;
193            }
194        }
195        Training::new(p)
196    }
197
198    /// The training symbols $P$ ($t \times N$).
199    pub fn symbols(&self) -> &CMat {
200        &self.p
201    }
202
203    /// Transmit antennas $t$.
204    pub fn n_tx(&self) -> usize {
205        self.p.nrows()
206    }
207
208    /// Training symbols $N$.
209    pub fn n_symbols(&self) -> usize {
210        self.p.ncols()
211    }
212
213    /// The power $\lVert P \rVert_{F}^{2} = \mathcal{P}$ (eq. (5)).
214    pub fn total_power(&self) -> f64 {
215        self.p.iter().map(C64::norm_sqr).sum()
216    }
217
218    /// The estimate $\hat{H} = S A$ ($r \times t$) of the channel from the received
219    /// training `s` ($r \times N$), the noise variance per entry `noise_var`
220    /// ($\sigma_{n}^{2}$, unused by LS).
221    ///
222    /// # Errors
223    /// [`Error::Shape`] when `s` does not have $N$ columns or the correlation of
224    /// [`ChannelEstimator::Mmse`] is not $t \times t$; [`Error::Config`] for a negative
225    /// noise variance, a trace that is not positive, LS or scaled LS on a training of
226    /// rank below $t$; [`Error::Numerical`] for a singular LMMSE system (no noise and a
227    /// rank-deficient $P^{H} R_{H} P$).
228    pub fn estimate(&self, s: &ArrayView2<'_, C64>, estimator: &ChannelEstimator, noise_var: f64) -> Result<CMat> {
229        ensure_shape(s.ncols() == self.n_symbols(), || {
230            format!("the received training has {} columns, the training {}", s.ncols(), self.n_symbols())
231        })?;
232        ensure(s.nrows() >= 1, || "at least one receive antenna".into())?;
233        let from_ls = || -> Result<f64> {
234            let h_ls = matmul(s, &self.ls_filter()?.view());
235            Ok(h_ls.iter().map(C64::norm_sqr).sum())
236        };
237        let a = self.filter(estimator, noise_var, s.nrows(), from_ls)?;
238        Ok(matmul(s, &a.view()))
239    }
240
241    /// The MSE $E\\{\lVert H - \hat{H} \rVert_{F}^{2}\\}$ of `estimator` on a channel of
242    /// true correlation `r_h` ($E\\{H^{H} H\\}$, $t \times t$) with `n_rx` receive
243    /// antennas and the noise variance `noise_var` (see the module): eqs. (7), (13),
244    /// (28) and (46) for any training, (12), (20), (32) and (49) for the optimal ones.
245    ///
246    /// # Errors
247    /// [`Error::Config`] for LS-SLS and LS-RMMSE ([`TracePrior::FromLs`]: no closed
248    /// form) and the errors of [`Training::estimate`]; [`Error::Shape`] for a
249    /// correlation that is not $t \times t$.
250    pub fn mse(
251        &self,
252        estimator: &ChannelEstimator,
253        r_h: &ArrayView2<'_, C64>,
254        noise_var: f64,
255        n_rx: usize,
256    ) -> Result<f64> {
257        ensure_shape(r_h.dim() == (self.n_tx(), self.n_tx()), || {
258            format!("the correlation is {:?}, the training has {} rows", r_h.dim(), self.n_tx())
259        })?;
260        ensure(n_rx >= 1, || "at least one receive antenna".into())?;
261        let a = self.filter(estimator, noise_var, n_rx, || {
262            Err(Error::Config("LS-SLS and LS-RMMSE have no closed-form MSE (estimate it by Monte Carlo)".into()))
263        })?;
264        let b = identity(self.n_tx()) - matmul(&self.p.view(), &a.view());
265        let bias = matmul(&hermitian(&b.view()).view(), &matmul(r_h, &b.view()).view());
266        let bias: f64 = (0..self.n_tx()).map(|i| bias[(i, i)].re).sum();
267        let noise: f64 = a.iter().map(C64::norm_sqr).sum();
268        Ok(bias + noise_var * n_rx as f64 * noise)
269    }
270
271    /// $P^{\dagger} = P^{H} (P P^{H})^{-1}$.
272    fn ls_filter(&self) -> Result<CMat> {
273        let g = self.gram_inv.as_ref().ok_or_else(|| {
274            Error::Config(format!("LS needs a training of rank t = {} (P P^H invertible)", self.n_tx()))
275        })?;
276        Ok(matmul(&hermitian(&self.p.view()).view(), &g.view()))
277    }
278
279    /// The LMMSE filter $(P^{H} R P + \sigma_{n}^{2} r I)^{-1} P^{H} R$ (eq. (25)).
280    fn lmmse_filter(&self, r: &ArrayView2<'_, C64>, noise_var: f64, n_rx: usize) -> Result<CMat> {
281        let ph_r = matmul(&hermitian(&self.p.view()).view(), r);
282        let m = matmul(&ph_r.view(), &self.p.view()) + identity(self.n_symbols()) * C64::from(noise_var * n_rx as f64);
283        solve_hpd(&m.view(), &ph_r.view())
284    }
285
286    /// The matrix $A$ of `estimator`; `from_ls` gives the estimated trace of
287    /// [`TracePrior::FromLs`].
288    fn filter(
289        &self,
290        estimator: &ChannelEstimator,
291        noise_var: f64,
292        n_rx: usize,
293        from_ls: impl FnOnce() -> Result<f64>,
294    ) -> Result<CMat> {
295        ensure(noise_var >= 0.0 && noise_var.is_finite(), || {
296            format!("the noise variance must be non-negative, got {noise_var}")
297        })?;
298        let trace = |prior: &TracePrior| -> Result<f64> {
299            let tr = match prior {
300                TracePrior::Known(tr) => *tr,
301                TracePrior::FromLs => from_ls()?,
302            };
303            ensure(tr > 0.0 && tr.is_finite(), || format!("the trace of R_H must be positive, got {tr}"))?;
304            Ok(tr)
305        };
306        match estimator {
307            ChannelEstimator::Ls => self.ls_filter(),
308            ChannelEstimator::ScaledLs(prior) => {
309                let a = self.ls_filter()?;
310                let g = self.gram_inv.as_ref().map_or(0.0, |g| (0..self.n_tx()).map(|i| g[(i, i)].re).sum());
311                let tr = trace(prior)?;
312                let j_ls = noise_var * n_rx as f64 * g;
313                Ok(a * C64::from(tr / (j_ls + tr)))
314            }
315            ChannelEstimator::Mmse(r) => {
316                check_correlation(&r.view())?;
317                ensure_shape(r.nrows() == self.n_tx(), || {
318                    format!("the correlation is {:?}, the training has {} rows", r.dim(), self.n_tx())
319                })?;
320                self.lmmse_filter(&r.view(), noise_var, n_rx)
321            }
322            ChannelEstimator::RelaxedMmse(prior) => {
323                let alpha = trace(prior)? / self.n_tx() as f64;
324                self.lmmse_filter(&(identity(self.n_tx()) * C64::from(alpha)).view(), noise_var, n_rx)
325            }
326        }
327    }
328}
329
330/// The size $t$ of a correlation, square and finite.
331fn check_correlation(r: &ArrayView2<'_, C64>) -> Result<usize> {
332    let (t, c) = r.dim();
333    ensure(t >= 1 && t == c, || format!("the correlation must be square and non-empty, got {t} x {c}"))?;
334    ensure(r.iter().all(|z| z.re.is_finite() && z.im.is_finite()), || "the correlation must be finite".into())?;
335    Ok(t)
336}
337
338/// $1/\kappa_{k} = 1/\mathrm{tr}\\{(P_{k} P_{k}^{H})^{-1}\\}$ of every period.
339fn inverse_kappas(trainings: &[Training]) -> Result<Vec<f64>> {
340    ensure(!trainings.is_empty(), || "at least one training period".into())?;
341    let t = trainings[0].n_tx();
342    trainings
343        .iter()
344        .map(|tr| {
345            ensure_shape(tr.n_tx() == t, || "every period must train the same transmit antennas".into())?;
346            let g = tr.gram_inv.as_ref().ok_or_else(|| Error::Config("BLUE needs trainings of rank t".into()))?;
347            Ok(1.0 / (0..t).map(|i| g[(i, i)].re).sum::<f64>())
348        })
349        .collect()
350}
351
352/// The weights $\alpha_{k}$ of the best linear unbiased combination of the LS estimates
353/// of the training periods `trainings` (Lemma 4, eq. (54)); $\mathcal{P}_{k} /
354/// \mathcal{P}_{\mathrm{tot}}$ for orthogonal trainings (58).
355///
356/// # Errors
357/// [`Error::Config`] for no period or a training of rank below $t$, [`Error::Shape`]
358/// for periods of different $t$.
359pub fn blue_weights(trainings: &[Training]) -> Result<Vec<f64>> {
360    let inv = inverse_kappas(trainings)?;
361    let sum: f64 = inv.iter().sum();
362    Ok(inv.iter().map(|k| k / sum).collect())
363}
364
365/// The combination $\sum_{k} \alpha_{k} \hat{H}_{k}$ of the estimates `estimates` with
366/// the weights `weights` (eq. (52)).
367///
368/// # Errors
369/// [`Error::Shape`] for no estimate, estimates of different shapes or as many weights
370/// as estimates.
371pub fn blue_combine(estimates: &[CMat], weights: &[f64]) -> Result<CMat> {
372    ensure_shape(!estimates.is_empty() && estimates.len() == weights.len(), || {
373        format!("{} estimates and {} weights", estimates.len(), weights.len())
374    })?;
375    let dim = estimates[0].dim();
376    ensure_shape(estimates.iter().all(|h| h.dim() == dim), || "the estimates must have the same shape".into())?;
377    let mut out = CMat::zeros(dim);
378    for (h, w) in estimates.iter().zip(weights) {
379        out.scaled_add(C64::from(*w), h);
380    }
381    Ok(out)
382}
383
384/// The MSE $\sigma_{n}^{2} r \sum_{k} \alpha_{k}^{2} \kappa_{k} = \sigma_{n}^{2} r /
385/// \sum_{k} 1/\kappa_{k}$ of the BLUE combination of the LS estimates of `trainings`
386/// (eqs. (59), (61)); $\sigma_{n}^{2} t^{2} r / \mathcal{P}_{\mathrm{tot}}$ for
387/// orthogonal trainings.
388///
389/// # Errors
390/// Those of [`blue_weights`], and [`Error::Config`] for a negative noise variance or no
391/// receive antenna.
392pub fn blue_mse(trainings: &[Training], noise_var: f64, n_rx: usize) -> Result<f64> {
393    ensure(noise_var >= 0.0 && n_rx >= 1, || {
394        format!("a non-negative noise variance and a receive antenna, got {noise_var} and {n_rx}")
395    })?;
396    let inv = inverse_kappas(trainings)?;
397    Ok(noise_var * n_rx as f64 / inv.iter().sum::<f64>())
398}
399
400#[cfg(test)]
401mod tests {
402    #![allow(clippy::unwrap_used, reason = "test code: a failure is a test failure")]
403    use super::*;
404
405    fn exponential(t: usize, eps: f64, r: usize) -> CMat {
406        CMat::from_shape_fn((t, t), |(i, j)| C64::from(r as f64 * eps.powi(i.abs_diff(j) as i32)))
407    }
408
409    fn close(a: f64, b: f64) -> bool {
410        (a - b).abs() <= 1e-10 * b.abs().max(1.0)
411    }
412
413    #[test]
414    fn the_dft_training_has_orthogonal_rows_of_the_power() {
415        let p = Training::orthogonal(3, 5, 6.0).unwrap();
416        let g = matmul(&p.symbols().view(), &hermitian(&p.symbols().view()).view());
417        for i in 0..3 {
418            for j in 0..3 {
419                let want = if i == j { 2.0 } else { 0.0 };
420                assert!((g[(i, j)] - C64::from(want)).norm() < 1e-12);
421            }
422        }
423        assert!(close(p.total_power(), 6.0));
424    }
425
426    #[test]
427    fn the_mse_of_orthogonal_training_follows_eqs_12_20_49() {
428        let (t, r, power, noise) = (4, 3, 20.0, 0.7);
429        let p = Training::orthogonal(t, t, power).unwrap();
430        let rh = exponential(t, 0.6, r);
431        let tr = (r * t) as f64;
432        let j_ls = noise * (t * t * r) as f64 / power;
433        assert!(close(p.mse(&ChannelEstimator::Ls, &rh.view(), noise, r).unwrap(), j_ls));
434        let j_sls = noise * (t * t * r) as f64 * tr / (noise * (t * t * r) as f64 + power * tr);
435        let sls = ChannelEstimator::ScaledLs(TracePrior::Known(tr));
436        let rmmse = ChannelEstimator::RelaxedMmse(TracePrior::Known(tr));
437        assert!(close(p.mse(&sls, &rh.view(), noise, r).unwrap(), j_sls));
438        assert!(close(p.mse(&rmmse, &rh.view(), noise, r).unwrap(), j_sls));
439        assert!(j_sls < j_ls);
440    }
441
442    #[test]
443    fn the_lmmse_mse_is_eq_28_and_eq_32_at_its_optimal_training() {
444        let (t, r, noise) = (3, 2, 0.5);
445        let rh = exponential(t, 0.8, r);
446        let mmse = ChannelEstimator::Mmse(rh.clone());
447        // (28) for the orthogonal training
448        let p = Training::orthogonal(t, t, 9.0).unwrap();
449        let inner = inverse_hpd(&rh.view()).unwrap()
450            + matmul(&p.symbols().view(), &hermitian(&p.symbols().view()).view()) * C64::from(1.0 / (noise * r as f64));
451        let e = inverse_hpd(&inner.view()).unwrap();
452        let j28: f64 = (0..t).map(|i| e[(i, i)].re).sum();
453        assert!(close(p.mse(&mmse, &rh.view(), noise, r).unwrap(), j28));
454        // (32) at high SNR (every mode active), and it beats the orthogonal training
455        let power = 200.0;
456        let opt = Training::mmse_optimal(&rh.view(), noise, r, power, t).unwrap();
457        assert!(close(opt.total_power(), power));
458        let tr_inv: f64 = {
459            let i = inverse_hpd(&rh.view()).unwrap();
460            (0..t).map(|k| i[(k, k)].re).sum()
461        };
462        let j32 = (t * t) as f64 / (tr_inv + power / (noise * r as f64));
463        assert!(close(opt.mse(&mmse, &rh.view(), noise, r).unwrap(), j32));
464        let orth = Training::orthogonal(t, t, power).unwrap();
465        assert!(opt.mse(&mmse, &rh.view(), noise, r).unwrap() < orth.mse(&mmse, &rh.view(), noise, r).unwrap());
466    }
467
468    #[test]
469    fn the_water_filled_mse_is_active_modes_over_the_level_plus_the_inactive_eigenvalues() {
470        // low power: some modes off, J = k / mu + sum of the inactive eigenvalues
471        let (t, r, noise, power) = (4, 2, 1.0, 0.4);
472        let rh = exponential(t, 0.9, r);
473        let opt = Training::mmse_optimal(&rh.view(), noise, r, power, t).unwrap();
474        let (_, lambda, _) = svd(&rh.view());
475        let pw = water_filling_powers(&lambda, power / (noise * r as f64)).unwrap();
476        let active: Vec<usize> = (0..t).filter(|&i| pw[i] > 0.0).collect();
477        assert!(active.len() < t);
478        let mu = pw[active[0]] + 1.0 / lambda[active[0]];
479        let want = active.len() as f64 / mu + (0..t).filter(|i| pw[*i] == 0.0).map(|i| lambda[i]).sum::<f64>();
480        let got = opt.mse(&ChannelEstimator::Mmse(rh.clone()), &rh.view(), noise, r).unwrap();
481        assert!(close(got, want), "{got} {want}");
482        // LS refuses the rank-deficient training
483        assert!(opt.mse(&ChannelEstimator::Ls, &rh.view(), noise, r).is_err());
484    }
485
486    #[test]
487    fn the_estimators_are_exact_without_noise_and_scaled_ls_shrinks_ls() {
488        let (t, r) = (2, 3);
489        let p = Training::orthogonal(t, 4, 8.0).unwrap();
490        let h = CMat::from_shape_fn((r, t), |(i, j)| C64::new(i as f64 - 1.0, j as f64 + 0.5));
491        let s = matmul(&h.view(), &p.symbols().view());
492        for est in [ChannelEstimator::Ls, ChannelEstimator::ScaledLs(TracePrior::FromLs)] {
493            let e = p.estimate(&s.view(), &est, 0.0).unwrap();
494            assert!((&e - &h).iter().all(|z| z.norm() < 1e-12), "{est:?}");
495        }
496        let ls = p.estimate(&s.view(), &ChannelEstimator::Ls, 1.0).unwrap();
497        let sls = p.estimate(&s.view(), &ChannelEstimator::ScaledLs(TracePrior::Known(6.0)), 1.0).unwrap();
498        // gamma = tr / (sigma^2 r tr((PP^H)^-1) + tr) = 6 / (3 * 2 * 2 / 8 + 6)
499        let gamma = 6.0 / (3.0 * 2.0 * 2.0 / 8.0 + 6.0);
500        assert!((&sls - &(ls * C64::from(gamma))).iter().all(|z| z.norm() < 1e-12));
501    }
502
503    #[test]
504    fn the_blue_weights_are_the_power_shares_for_orthogonal_trainings() {
505        let trainings: Vec<Training> =
506            [1.0, 3.0, 4.0].iter().map(|&pw| Training::orthogonal(2, 2, pw).unwrap()).collect();
507        let w = blue_weights(&trainings).unwrap();
508        for (wk, pk) in w.iter().zip([1.0, 3.0, 4.0]) {
509            assert!(close(*wk, pk / 8.0));
510        }
511        // (59): sigma^2 t^2 r / P_tot
512        assert!(close(blue_mse(&trainings, 0.5, 3).unwrap(), 0.5 * 4.0 * 3.0 / 8.0));
513        let hs = vec![
514            CMat::from_elem((1, 2), C64::from(1.0)),
515            CMat::from_elem((1, 2), C64::from(3.0)),
516            CMat::from_elem((1, 2), C64::from(2.0)),
517        ];
518        let c = blue_combine(&hs, &w).unwrap();
519        assert!((c[(0, 1)] - C64::from((1.0 + 9.0 + 8.0) / 8.0)).norm() < 1e-12);
520    }
521
522    #[test]
523    fn invalid_inputs_are_errors() {
524        assert!(Training::orthogonal(3, 2, 1.0).is_err());
525        assert!(Training::orthogonal(2, 2, 0.0).is_err());
526        let p = Training::orthogonal(2, 2, 1.0).unwrap();
527        let s = CMat::zeros((2, 3));
528        assert!(matches!(p.estimate(&s.view(), &ChannelEstimator::Ls, 0.1), Err(Error::Shape(_))));
529        let rh = identity(2);
530        assert!(p.mse(&ChannelEstimator::ScaledLs(TracePrior::FromLs), &rh.view(), 0.1, 1).is_err());
531        assert!(p.mse(&ChannelEstimator::ScaledLs(TracePrior::Known(-1.0)), &rh.view(), 0.1, 1).is_err());
532        assert!(p.mse(&ChannelEstimator::Mmse(identity(3)), &rh.view(), 0.1, 1).is_err());
533        assert!(blue_weights(&[]).is_err());
534        assert!(blue_combine(&[CMat::zeros((1, 2))], &[0.5, 0.5]).is_err());
535    }
536}