Skip to main content

comnum_waveform/ofdm/
estimation.rs

1//! Channel estimation on the OFDM resource grid.
2//!
3//! The receiver knows the pilots: LS estimates $\hat H = Y / X$ at the pilot REs, then an
4//! interpolation over the grid — first across the subcarriers of every pilot-carrying
5//! symbol, then across the symbols of every subcarrier (linear in time, constant beyond
6//! the first and last pilot symbols).
7//!
8//! - [`FrequencyInterpolation::Linear`]: linear between pilot subcarriers;
9//! - [`FrequencyInterpolation::SecondOrder`], [`FrequencyInterpolation::Spline`],
10//!   [`FrequencyInterpolation::LowPass`] and [`FrequencyInterpolation::TimeDomain`]: the
11//!   interpolations compared by Coleri et al. (2002), the quadratic through three
12//!   pilots, the not-a-knot cubic spline, the band-limited interpolator of MATLAB
13//!   `interp` and the zero padding of the IDFT of the pilot estimates (a comb of
14//!   pilots on subcarriers $0, L, \dots$ of a grid of a multiple of $L$ subcarriers);
15//! - [`FrequencyInterpolation::Lmmse`]: the linear MMSE estimator of
16//!   `comnum_dsp::estimation` with the frequency correlation of a power-delay profile,
17//!   $R(\Delta k) = \sum_{l} p_{l}\, e^{-j 2\pi \Delta k\, d_{l} / N}$ — the true profile, or the
18//!   uniform profile over the cyclic prefix of the robust design (Edfors et al. 1998).
19//!
20//! With pilots on every subcarrier (block pattern) LMMSE still smooths the LS estimates
21//! (van de Beek et al. 1995); with a comb it interpolates them.
22//!
23//! The LMMSE estimator and the band-limited interpolator are built once per set of pilot
24//! subcarriers (`super::wiener::Wiener2d` on the pilots of the symbol, `comnum_dsp::interpolation::LowPassInterpolator`) and
25//! applied to every pilot symbol that shares it.
26//!
27//! # Examples
28//! ```
29//! use comnum_waveform::ofdm::estimation::{estimate, FrequencyInterpolation};
30//! use comnum_waveform::ofdm::grid::{PilotPattern, ResourceGrid};
31//! use comnum_math::C64;
32//! let g = ResourceGrid { n_subcarriers: 16, n_symbols: 4, pattern: PilotPattern::Comb { spacing: 4 }, seed: 3 };
33//! // a flat channel 0.5 + 0.5j, no noise: the estimate is exact everywhere
34//! let h = C64::new(0.5, 0.5);
35//! let tx = g.map(&vec![C64::new(1.0, 0.0); g.n_data()])?;
36//! let rx: Vec<C64> = tx.iter().map(|x| x * h).collect();
37//! let est = estimate(&g, &rx, 1e-6, &FrequencyInterpolation::Linear)?;
38//! assert!(est.iter().all(|e| (e - h).norm() < 1e-9));
39//! # Ok::<(), comnum_core::Error>(())
40//! ```
41//!
42//! # References
43//!
44//! 1. J.-J. van de Beek, O. Edfors, M. Sandell, S. K. Wilson and P. O. Börjesson, "On
45//!    channel estimation in OFDM systems," *Proc. IEEE VTC*, 815–819, 1995.
46//! 2. O. Edfors, M. Sandell, J.-J. van de Beek, S. K. Wilson and P. O. Börjesson, "OFDM
47//!    channel estimation by singular value decomposition," *IEEE Trans. Commun.*, 46(7),
48//!    931–939, 1998.
49//! 3. S. Coleri, M. Ergen, A. Puri and A. Bahai, "Channel estimation techniques based on
50//!    pilot arrangement in OFDM systems," *IEEE Trans. Broadcast.*, 48(3), 223–229, 2002.
51
52use super::grid::ResourceGrid;
53use super::wiener::{Correlation2d, DelaySpectrum, DopplerSpectrum, Wiener2d, WienerPlan};
54use comnum_core::{Error, Result, ensure, ensure_shape};
55use comnum_dsp::estimation::frequency_correlation;
56use comnum_dsp::interpolation::{self, LowPassInterpolator};
57use comnum_dsp::resample::resample_fft;
58use comnum_math::C64;
59use ndarray::Array2;
60
61/// A power-delay profile on the sampling grid of the OFDM symbol: checked when it is
62/// built, its fields read through getters.
63#[derive(Clone, Debug, PartialEq)]
64pub struct PowerDelayProfile {
65    delays: Vec<f64>,
66    powers: Vec<f64>,
67    n_fft: usize,
68}
69
70impl PowerDelayProfile {
71    /// A profile from delays (samples of the IFFT of size `n_fft`) and linear powers,
72    /// normalised to a unit sum.
73    ///
74    /// # Errors
75    /// [`Error::Shape`] for as many delays as powers; [`Error::Config`] for no tap, a
76    /// zero FFT size, a negative or non-finite delay or power, or powers of zero sum.
77    ///
78    /// # Examples
79    /// ```
80    /// use comnum_waveform::ofdm::estimation::PowerDelayProfile;
81    /// let p = PowerDelayProfile::new(vec![0.0, 3.0], &[3.0, 1.0], 64)?;
82    /// assert_eq!(p.powers(), &[0.75, 0.25]);
83    /// assert!(PowerDelayProfile::new(vec![0.0], &[1.0, 1.0], 64).is_err());
84    /// assert!(PowerDelayProfile::new(vec![0.0], &[0.0], 64).is_err());
85    /// # Ok::<(), comnum_core::Error>(())
86    /// ```
87    pub fn new(delays: Vec<f64>, powers: &[f64], n_fft: usize) -> Result<PowerDelayProfile> {
88        comnum_core::same_len(powers.len(), delays.len(), "powers and delays of the profile")?;
89        ensure(!delays.is_empty() && n_fft >= 1, || "a power-delay profile needs a tap and an FFT size".into())?;
90        ensure(delays.iter().all(|d| *d >= 0.0 && d.is_finite()), || {
91            format!("negative or non-finite delays {delays:?}")
92        })?;
93        ensure(powers.iter().all(|p| *p >= 0.0 && p.is_finite()), || {
94            format!("negative or non-finite powers {powers:?}")
95        })?;
96        let t: f64 = powers.iter().sum();
97        ensure(t > 0.0, || "the powers of the profile sum to zero".into())?;
98        Ok(PowerDelayProfile { delays, powers: powers.iter().map(|p| p / t).collect(), n_fft })
99    }
100
101    /// The uniform profile over `max_delay` samples (the robust design: the cyclic
102    /// prefix; one tap at least).
103    ///
104    /// # Errors
105    /// [`Error::Config`] for a zero FFT size.
106    pub fn uniform(max_delay: usize, n_fft: usize) -> Result<PowerDelayProfile> {
107        let n = max_delay.max(1);
108        PowerDelayProfile::new((0..n).map(|d| d as f64).collect(), &vec![1.0; n], n_fft)
109    }
110
111    /// The delays (samples of the IFFT of size [`PowerDelayProfile::n_fft`]).
112    pub fn delays(&self) -> &[f64] {
113        &self.delays
114    }
115
116    /// The powers (linear, of unit sum).
117    pub fn powers(&self) -> &[f64] {
118        &self.powers
119    }
120
121    /// The IFFT size $N$ the delays refer to.
122    pub fn n_fft(&self) -> usize {
123        self.n_fft
124    }
125
126    /// $R(\Delta k)$ between subcarriers $\Delta k$ apart
127    /// (`comnum_dsp::estimation::frequency_correlation`).
128    pub fn correlation(&self, delta: f64) -> C64 {
129        frequency_correlation(&self.delays, &self.powers, delta, self.n_fft)
130    }
131}
132
133/// A channel of `paths` impulses whose delays are independent and uniform over
134/// $[0, L]$ samples of the IFFT (`max_delay` = $L$), not on the sampling grid, of
135/// power $e^{-\tau/\tau_{\mathrm{rms}}}$ (or all equal), the first one at delay 0 when
136/// `first_at_zero`: the "synchronized" (exponential, first at 0) and "uniform" models of
137/// Edfors et al. (1998, Sec. II). Its correlation averaged over the delays is, for one
138/// random delay of power $w(\tau)$,
139///
140/// $$
141/// E\left[w(\tau)\, e^{-j 2\pi \Delta k \tau / N}\right] = \frac{1 - e^{-aL}}{aL}, \qquad
142/// a = \frac{1}{\tau_{\mathrm{rms}}} + j\frac{2\pi \Delta k}{N},
143/// $$
144///
145/// normalised to a unit power: [`RandomDelayProfile::correlation`] and the $N \times N$
146/// matrix of $N$ adjacent subcarriers, [`RandomDelayProfile::correlation_matrix`].
147///
148/// # Examples
149/// ```
150/// use comnum_waveform::ofdm::estimation::RandomDelayProfile;
151/// // equal powers over a prefix of 4 samples: the correlation of a uniform delay
152/// let p = RandomDelayProfile { paths: 3, max_delay: 4.0, tau_rms: None, first_at_zero: false, n_fft: 64 };
153/// assert!((p.correlation(0.0).re - 1.0).abs() < 1e-15);
154/// // 8 subcarriers apart, half a turn over the prefix: (1 - e^{-j pi}) / (j pi)
155/// assert!((p.correlation(8.0).norm() - (2.0 / std::f64::consts::PI)).abs() < 1e-12);
156/// ```
157#[derive(Clone, Copy, Debug, PartialEq)]
158pub struct RandomDelayProfile {
159    /// Impulses $M$.
160    pub paths: usize,
161    /// The largest delay $L$ (samples of the IFFT), the cyclic prefix.
162    pub max_delay: f64,
163    /// The decay $\tau_{\mathrm{rms}}$ (samples) of the powers, `None` for equal powers.
164    pub tau_rms: Option<f64>,
165    /// The first impulse at delay 0, the others random.
166    pub first_at_zero: bool,
167    /// The IFFT size $N$ the delays refer to.
168    pub n_fft: usize,
169}
170
171impl RandomDelayProfile {
172    /// $E[w(\tau) e^{-j2\pi\Delta k \tau/N}]$ for one delay uniform over $[0, L]$.
173    fn random_term(&self, delta: f64) -> C64 {
174        let a = C64::new(self.tau_rms.map_or(0.0, |t| 1.0 / t), 2.0 * std::f64::consts::PI * delta / self.n_fft as f64);
175        let al = a * self.max_delay;
176        if al.norm() < 1e-12 { C64::new(1.0, 0.0) } else { (C64::new(1.0, 0.0) - (-al).exp()) / al }
177    }
178
179    /// The correlation $R(\Delta k)$ between subcarriers $\Delta k$ apart, averaged over
180    /// the delays, of unit power.
181    pub fn correlation(&self, delta: f64) -> C64 {
182        let random = if self.first_at_zero { self.paths.saturating_sub(1) } else { self.paths } as f64;
183        let fixed = if self.first_at_zero { 1.0 } else { 0.0 };
184        let total = fixed + random * self.random_term(0.0).re;
185        (C64::new(fixed, 0.0) + self.random_term(delta) * random) / total
186    }
187
188    /// The correlation matrix of `n` adjacent subcarriers, $R_{ij} = R(i - j)$.
189    pub fn correlation_matrix(&self, n: usize) -> Array2<C64> {
190        Array2::from_shape_fn((n, n), |(i, j)| self.correlation(i as f64 - j as f64))
191    }
192}
193
194/// How the pilot estimates are interpolated across the subcarriers.
195#[non_exhaustive]
196#[derive(Clone, Debug, PartialEq)]
197pub enum FrequencyInterpolation {
198    /// Linear between pilot subcarriers.
199    Linear,
200    /// Linear MMSE with the frequency correlation of a power-delay profile.
201    Lmmse(PowerDelayProfile),
202    /// The parabola through the three nearest pilots (Coleri et al. 2002, eq. (19)).
203    SecondOrder,
204    /// The cubic spline through the pilots, not-a-knot ends (MATLAB `spline`).
205    Spline,
206    /// The band-limited interpolator of MATLAB `interp` over `2 * half` pilots, for a
207    /// spectrum over the fraction `alpha` of the band of the pilots (`interp`: 4, 0.5).
208    LowPass {
209        /// Half the number of pilots combined.
210        half: usize,
211        /// The fraction of the band of the pilots the channel occupies.
212        alpha: f64,
213    },
214    /// Zero padding of the IDFT of the pilot estimates ("time-domain" interpolation,
215    /// Coleri et al. 2002, eqs. (20)–(22)): pilots on the subcarriers $0, L, \dots$ of a
216    /// grid of $N_{p} L$ subcarriers.
217    TimeDomain,
218}
219
220impl FrequencyInterpolation {
221    /// The interpolation of a name: `linear`, `lmmse` (with the profile `pdp`),
222    /// `second_order`, `spline`, `lowpass` (MATLAB `interp`: `half` = 4, `alpha` = 0.5)
223    /// or `time_domain`.
224    ///
225    /// # Errors
226    /// [`Error::Config`] for another name, or for `lmmse` without a profile.
227    ///
228    /// # Examples
229    /// ```
230    /// use comnum_waveform::ofdm::estimation::FrequencyInterpolation;
231    /// assert_eq!(FrequencyInterpolation::from_name("spline", None)?, FrequencyInterpolation::Spline);
232    /// assert!(FrequencyInterpolation::from_name("lmmse", None).is_err());
233    /// # Ok::<(), comnum_core::Error>(())
234    /// ```
235    pub fn from_name(name: &str, pdp: Option<PowerDelayProfile>) -> Result<FrequencyInterpolation> {
236        Ok(match name.to_ascii_lowercase().as_str() {
237            "linear" => FrequencyInterpolation::Linear,
238            "lmmse" => FrequencyInterpolation::Lmmse(
239                pdp.ok_or_else(|| Error::Config("lmmse interpolation needs a power-delay profile".into()))?,
240            ),
241            "second_order" => FrequencyInterpolation::SecondOrder,
242            "spline" => FrequencyInterpolation::Spline,
243            "lowpass" => FrequencyInterpolation::LowPass { half: 4, alpha: 0.5 },
244            "time_domain" => FrequencyInterpolation::TimeDomain,
245            other => {
246                return Err(Error::Config(format!(
247                    "unknown interpolation {other:?}: linear, lmmse, second_order, spline, lowpass, time_domain"
248                )));
249            }
250        })
251    }
252}
253
254/// The channel at every RE of the grid (symbol after symbol) from the received grid
255/// `rx` (the FFT outputs of the active subcarriers), with the noise variance per RE
256/// `noise_var` (used by LMMSE).
257///
258/// # Errors
259/// [`Error::Shape`] if `rx` is not a whole grid, [`Error::Config`] without pilots.
260pub fn estimate(grid: &ResourceGrid, rx: &[C64], noise_var: f64, method: &FrequencyInterpolation) -> Result<Vec<C64>> {
261    estimate_port(grid, rx, &PortEstimation { method: method.clone(), noise_var, ..PortEstimation::default() })
262}
263
264/// The settings of [`estimate_port`]: the interpolation, the noise variance per RE (used
265/// by LMMSE) and the transmit port among the ports of the grid.
266#[derive(Clone, Debug, PartialEq)]
267pub struct PortEstimation {
268    /// The interpolation in frequency.
269    pub method: FrequencyInterpolation,
270    /// The noise variance per RE.
271    pub noise_var: f64,
272    /// The transmit port whose channel is estimated.
273    pub port: usize,
274    /// The ports of the grid ([`ResourceGrid::port_pilots`]).
275    pub n_ports: usize,
276}
277
278impl Default for PortEstimation {
279    /// Linear interpolation, no noise, the one port of a grid.
280    fn default() -> PortEstimation {
281        PortEstimation { method: FrequencyInterpolation::Linear, noise_var: 0.0, port: 0, n_ports: 1 }
282    }
283}
284
285impl PortEstimation {
286    /// Check the settings.
287    ///
288    /// # Errors
289    /// [`Error::Config`] for a noise variance that is negative or not finite, or a port
290    /// outside the ports of the grid.
291    pub fn check(&self) -> Result<()> {
292        let (v, p, n) = (self.noise_var, self.port, self.n_ports);
293        ensure(v >= 0.0 && v.is_finite(), || format!("the noise variance must be finite and non-negative, got {v}"))?;
294        ensure(p < n, || format!("no port {p} out of {n}"))
295    }
296}
297
298/// [`estimate`] for the channel of the port of `settings`, from its pilots
299/// ([`ResourceGrid::port_pilots`]): with one receive antenna's grid `rx`, the channel
300/// between that antenna and the transmit port.
301///
302/// # Errors
303/// As [`estimate`]; the errors of [`PortEstimation::check`] and of
304/// [`ResourceGrid::check`]; [`Error::Config`] if a pilot symbol has no pilot of the port.
305pub fn estimate_port(grid: &ResourceGrid, rx: &[C64], settings: &PortEstimation) -> Result<Vec<C64>> {
306    settings.check()?;
307    let PortEstimation { method, noise_var, port, n_ports } = settings;
308    let (noise_var, port, n_ports) = (*noise_var, *port, *n_ports);
309    grid.check()?;
310    let (k_n, l_n) = (grid.n_subcarriers, grid.n_symbols);
311    ensure_shape(rx.len() == k_n * l_n, || format!("{} REs for a grid of {k_n} × {l_n}", rx.len()))?;
312    let symbols = grid.pilot_symbols();
313    if symbols.is_empty() {
314        return Err(Error::Config("the grid has no pilots".into()));
315    }
316    // the channel of every subcarrier at every pilot-carrying symbol; the LMMSE and
317    // band-limited interpolators are built once per set of pilots (and pilot energy)
318    let mut per_symbol = Vec::with_capacity(symbols.len());
319    let mut lowpass_plans: Vec<(Vec<usize>, LowPassInterpolator)> = Vec::new();
320    let mut lmmse_plans: Vec<((Vec<usize>, u64), WienerPlan)> = Vec::new();
321    for &l in &symbols {
322        let ks = grid.port_pilots(l, port, n_ports);
323        if ks.is_empty() {
324            return Err(Error::Config(format!("OFDM symbol {l} has no pilot of port {port} out of {n_ports}")));
325        }
326        let pilots: Vec<C64> = ks.iter().map(|&k| grid.pilot(k, l)).collect();
327        let hp: Vec<C64> = ks.iter().zip(&pilots).map(|(&k, p)| rx[l * k_n + k] / p).collect();
328        let full = match method {
329            FrequencyInterpolation::Linear => interpolation::linear(&ks, &hp, k_n)?,
330            FrequencyInterpolation::SecondOrder => interpolation::quadratic(&ks, &hp, k_n)?,
331            FrequencyInterpolation::Spline => interpolation::spline(&ks, &hp, k_n)?,
332            FrequencyInterpolation::LowPass { half, alpha } => {
333                cached(&mut lowpass_plans, &ks, || LowPassInterpolator::new(&ks, k_n, *half, *alpha))?.apply(&hp)?
334            }
335            FrequencyInterpolation::TimeDomain => {
336                let spacing = k_n / ks.len();
337                if ks.iter().enumerate().any(|(i, &k)| k != i * spacing) || spacing * ks.len() != k_n {
338                    return Err(Error::Config(format!(
339                        "time-domain interpolation needs pilots on 0, L, 2L, … of the {k_n} subcarriers"
340                    )));
341                }
342                resample_fft(&hp, k_n)
343            }
344            FrequencyInterpolation::Lmmse(pdp) => {
345                // the LS estimate Y / X of a pilot of energy |X|² has the noise variance σ² / |X|²
346                let energy = pilots.iter().map(C64::norm_sqr).sum::<f64>() / pilots.len() as f64;
347                // the 1-D Wiener filter of every subcarrier on all the pilots of the symbol
348                let plan = cached(&mut lmmse_plans, &(ks.clone(), energy.to_bits()), || {
349                    let corr = Correlation2d {
350                        fd_ts: 0.0,
351                        doppler: DopplerSpectrum::Uniform,
352                        delay: DelaySpectrum::Profile(pdp.clone()),
353                    };
354                    let pilots: Vec<(usize, usize)> = ks.iter().map(|&k| (0, k)).collect();
355                    let targets: Vec<(usize, usize)> = (0..k_n).map(|q| (0, q)).collect();
356                    Wiener2d::new(pilots, corr, noise_var / energy, ks.len())?.plan(&targets)
357                })?;
358                plan.apply(&hp)?
359            }
360        };
361        per_symbol.push(full);
362    }
363    // linear in time
364    let mut out = vec![C64::new(0.0, 0.0); k_n * l_n];
365    for k in 0..k_n {
366        let vals: Vec<C64> = per_symbol.iter().map(|v| v[k]).collect();
367        for (l, v) in interpolation::linear(&symbols, &vals, l_n)?.into_iter().enumerate() {
368            out[l * k_n + k] = v;
369        }
370    }
371    Ok(out)
372}
373
374/// The plan of `key` in `cache`, built by `build` the first time.
375fn cached<'a, K: PartialEq + Clone, P>(
376    cache: &'a mut Vec<(K, P)>,
377    key: &K,
378    build: impl FnOnce() -> Result<P>,
379) -> Result<&'a P> {
380    let i = if let Some(i) = cache.iter().position(|(k, _)| k == key) {
381        i
382    } else {
383        cache.push((key.clone(), build()?));
384        cache.len() - 1
385    };
386    Ok(&cache[i].1)
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use crate::ofdm::grid::PilotPattern;
393    use comnum_core::rng::Rng;
394    use comnum_math::cis;
395
396    /// A random multipath channel on the grid (static), and the received grid.
397    fn channel(grid: &ResourceGrid, pdp: &PowerDelayProfile, s2: f64, rng: &mut Rng) -> (Vec<C64>, Vec<C64>) {
398        let taps: Vec<C64> = pdp.powers.iter().map(|p| rng.complex_gaussian() * p.sqrt()).collect();
399        let hk: Vec<C64> = (0..grid.n_subcarriers)
400            .map(|k| {
401                pdp.delays
402                    .iter()
403                    .zip(&taps)
404                    .map(|(d, t)| t * cis(-2.0 * std::f64::consts::PI * k as f64 * d / pdp.n_fft as f64))
405                    .sum()
406            })
407            .collect();
408        let tx = grid.map(&vec![C64::new(1.0, 0.0); grid.n_data()]).unwrap();
409        let h: Vec<C64> = (0..tx.len()).map(|i| hk[i % grid.n_subcarriers]).collect();
410        let rx = tx.iter().zip(&h).map(|(x, g)| x * g + rng.complex_gaussian() * s2.sqrt()).collect();
411        (h, rx)
412    }
413
414    #[test]
415    fn lmmse_beats_linear_interpolation_and_ls_has_the_noise_variance() {
416        let grid =
417            ResourceGrid { n_subcarriers: 64, n_symbols: 2, pattern: PilotPattern::Block { period: 1 }, seed: 1 };
418        let pdp = PowerDelayProfile::new(vec![0.0, 2.0, 5.0], &[1.0, 0.5, 0.25], 64).unwrap();
419        let mut rng = Rng::new(2);
420        let s2 = 0.1;
421        let (mut e_ls, mut e_mm) = (0.0, 0.0);
422        for _ in 0..200 {
423            let (h, rx) = channel(&grid, &pdp, s2, &mut rng);
424            let a = estimate(&grid, &rx, s2, &FrequencyInterpolation::Linear).unwrap();
425            let b = estimate(&grid, &rx, s2, &FrequencyInterpolation::Lmmse(pdp.clone())).unwrap();
426            e_ls += a.iter().zip(&h).map(|(x, y)| (x - y).norm_sqr()).sum::<f64>() / a.len() as f64;
427            e_mm += b.iter().zip(&h).map(|(x, y)| (x - y).norm_sqr()).sum::<f64>() / b.len() as f64;
428        }
429        // pilots everywhere: linear interpolation is LS, of MSE σ²
430        assert!((e_ls / 200.0 / s2 - 1.0).abs() < 0.05);
431        // three taps: LMMSE keeps about 3/64 of the noise
432        assert!(e_mm / 200.0 < 0.1 * s2, "{}", e_mm / 200.0);
433    }
434
435    #[test]
436    fn the_interpolators_built_once_give_the_former_estimates() {
437        // the former computation: the LMMSE system and the band-limited interpolation
438        // built and solved again for every pilot symbol
439        let former = |grid: &ResourceGrid, rx: &[C64], s2: f64, method: &FrequencyInterpolation| {
440            let k_n = grid.n_subcarriers;
441            let symbols = grid.pilot_symbols();
442            let per_symbol: Vec<Vec<C64>> = symbols
443                .iter()
444                .map(|&l| {
445                    let ks = grid.port_pilots(l, 0, 1);
446                    let pilots: Vec<C64> = ks.iter().map(|&k| grid.pilot(k, l)).collect();
447                    let hp: Vec<C64> = ks.iter().zip(&pilots).map(|(&k, p)| rx[l * k_n + k] / p).collect();
448                    match method {
449                        FrequencyInterpolation::LowPass { half, alpha } => {
450                            interpolation::lowpass(&ks, &hp, k_n, *half, *alpha).unwrap()
451                        }
452                        FrequencyInterpolation::Lmmse(pdp) => {
453                            let r_pp = Array2::from_shape_fn((ks.len(), ks.len()), |(i, j)| {
454                                pdp.correlation(ks[i] as f64 - ks[j] as f64)
455                            });
456                            let r_qp = Array2::from_shape_fn((k_n, ks.len()), |(q, j)| {
457                                pdp.correlation(q as f64 - ks[j] as f64)
458                            });
459                            let energy = pilots.iter().map(C64::norm_sqr).sum::<f64>() / pilots.len() as f64;
460                            comnum_dsp::estimation::lmmse(&r_qp, &r_pp, s2 / energy, &hp).unwrap()
461                        }
462                        _ => unreachable!(),
463                    }
464                })
465                .collect();
466            let mut out = vec![C64::new(0.0, 0.0); k_n * grid.n_symbols];
467            for k in 0..k_n {
468                let vals: Vec<C64> = per_symbol.iter().map(|v| v[k]).collect();
469                for (l, v) in interpolation::linear(&symbols, &vals, grid.n_symbols).unwrap().into_iter().enumerate() {
470                    out[l * k_n + k] = v;
471                }
472            }
473            out
474        };
475        let pdp = PowerDelayProfile::new(vec![0.0, 2.0, 5.0], &[1.0, 0.5, 0.25], 64).unwrap();
476        let mut rng = Rng::new(6);
477        for pattern in [PilotPattern::Comb { spacing: 4 }, PilotPattern::Lattice { freq: 3, time: 2 }] {
478            let grid = ResourceGrid { n_subcarriers: 48, n_symbols: 6, pattern, seed: 2 };
479            let (_, rx) = channel(&grid, &pdp, 0.05, &mut rng);
480            for method in
481                [FrequencyInterpolation::Lmmse(pdp.clone()), FrequencyInterpolation::LowPass { half: 4, alpha: 0.5 }]
482            {
483                assert_eq!(
484                    estimate(&grid, &rx, 0.05, &method).unwrap(),
485                    former(&grid, &rx, 0.05, &method),
486                    "{method:?}"
487                );
488            }
489        }
490    }
491
492    #[test]
493    fn boosted_pilots_lower_the_noise_of_the_estimates() {
494        use crate::ofdm::grid::PilotTable;
495        // pilots on every RE, of amplitude 2: the LS estimate Y / X has the variance σ² / 4
496        let boosted = |a: f64| {
497            let t = PilotTable::new(64, 2, (0..2).flat_map(|l| (0..64).map(move |k| ((k, l), C64::new(a, 0.0)))));
498            ResourceGrid::from_table(t.unwrap())
499        };
500        let pdp = PowerDelayProfile::new(vec![0.0, 2.0, 5.0], &[1.0, 0.5, 0.25], 64).unwrap();
501        let mut rng = Rng::new(3);
502        let s2 = 0.1;
503        let mut mse = [[0.0; 2]; 2];
504        for (i, a) in [1.0, 2.0].into_iter().enumerate() {
505            let grid = boosted(a);
506            for _ in 0..200 {
507                let (h, rx) = channel(&grid, &pdp, s2, &mut rng);
508                let ls = estimate(&grid, &rx, s2, &FrequencyInterpolation::Linear).unwrap();
509                let mm = estimate(&grid, &rx, s2, &FrequencyInterpolation::Lmmse(pdp.clone())).unwrap();
510                mse[i][0] += ls.iter().zip(&h).map(|(x, y)| (x - y).norm_sqr()).sum::<f64>() / ls.len() as f64 / 200.0;
511                mse[i][1] += mm.iter().zip(&h).map(|(x, y)| (x - y).norm_sqr()).sum::<f64>() / mm.len() as f64 / 200.0;
512            }
513        }
514        assert!((mse[0][0] / s2 - 1.0).abs() < 0.05, "{mse:?}");
515        assert!((mse[1][0] / (s2 / 4.0) - 1.0).abs() < 0.05, "{mse:?}");
516        // LMMSE weighs the boosted estimates with their own noise: about 4 times less error
517        assert!((mse[0][1] / mse[1][1] / 4.0 - 1.0).abs() < 0.15, "{mse:?}");
518    }
519}