Skip to main content

comnum_waveform/fbmc/
mod.rs

1//! Filter-bank multicarrier with offset QAM (FBMC/OQAM, staggered multitone).
2//!
3//! # Model
4//!
5//! $M$ subcarriers spaced by $1/T$ ($T = M$ samples) carry real symbols $a_{m,n}$ every
6//! $T/2$, each on a time and frequency shift of a real, symmetric prototype filter $h$
7//! of $L = KM - 1$ taps ($K$ the overlapping factor; Bouhadda et al. 2014, eqs.
8//! (3)–(4); Nissel & Rupp 2017, eq. (2)):
9//!
10//! $$
11//! s\[k\] = \sum_{n} \sum_{m=0}^{M-1} a_{m,n}\, g_{m,n}\[k\], \qquad
12//! g_{m,n}\[k\] = h\[k - nM/2\]\, e^{j 2\pi m (k - nM/2 - D/2)/M}\, e^{j \frac{\pi}{2}(m + n)},
13//! $$
14//!
15//! with $D = L - 1$, so that the exponential is centred on the filter. The pulses are
16//! orthogonal in the real sense only, $\Re\langle g_{m,n}, g_{m',n'} \rangle =
17//! \delta_{mm'}\delta_{nn'}$ (nearly, for PHYDYAS: below $5 \cdot 10^{-4}$ per
18//! neighbour for $K = 4$, $5 \cdot 10^{-3}$ for $K = 3$, $0.05$ for $K = 2$):
19//! the receiver projects on $g_{m,n}$ and keeps the real part,
20//! the imaginary part being the intrinsic interference of the neighbours
21//! ([`Fbmc::interference`]). A complex QAM symbol is split into two real ones on
22//! consecutive half-symbols ([`oqam_stagger`]), the real part first on the even
23//! subcarriers and the imaginary part first on the odd ones, so that FBMC/OQAM carries
24//! as many complex symbols as OFDM without a cyclic prefix ($TF = 1$).
25//!
26//! The pulses have unit energy: with QAM symbols of unit energy (real symbols of
27//! variance 1/2) on all subcarriers the signal has unit power, and complex noise of
28//! variance $\sigma^{2}$ per sample leaves a variance $\sigma^{2}/2$ on every real
29//! estimate, the SNR of OFDM per dimension (Nissel & Rupp 2017, eq. (18)).
30//!
31//! The modulator and the demodulator are polyphase: one FFT of size $M$ per
32//! half-symbol and $L$ products.
33//!
34//! As [`Modulate`] and [`Demodulate`] blocks, [`Fbmc`] takes complex QAM symbols and
35//! returns them: the OQAM staggering ([`oqam_stagger`]) and its inverse on the real parts
36//! of the projections ([`oqam_destagger`]) are inside the traits, at
37//! [`Fbmc::sample_rate`].
38//!
39//! [`Prototype::Phydyas`] is the filter of the PHYDYAS project (Bellanger 2010),
40//! sampled from its frequency response $H_{0} = 1$, $H_{1}, \dots, H_{K-1}$:
41//! $h\[k\] = H_{0} + 2\sum_{i=1}^{K-1} (-1)^{i} H_{i} \cos\left(\frac{2\pi i (k+1)}{KM}\right)$,
42//! $k = 0, \dots, KM - 2$, normalised to unit energy; for $K = 4$, $H_{1} = 0.97195983$,
43//! $H_{2} = 1/\sqrt{2}$, $H_{3} = 0.23514695$ (the transmultiplexer response of Table 1
44//! of Bouhadda et al. 2014 to three decimals, checked by [`Fbmc::interference`]).
45//!
46//! # Examples
47//! ```
48//! use comnum_waveform::fbmc::{Fbmc, oqam_stagger, oqam_destagger};
49//! use comnum_math::C64;
50//! let f = Fbmc::new(16, 4)?;
51//! // 6 complex symbols per subcarrier: 12 real half-symbols
52//! let c: Vec<C64> = (0..16 * 6).map(|i| C64::new(if i % 3 == 0 { 1.0 } else { -1.0 }, if i % 5 == 0 { -1.0 } else { 1.0 })).collect();
53//! let a = oqam_stagger(&c, 16)?;
54//! let s = f.modulate(&a)?;
55//! assert_eq!(s.len(), f.n_samples(12));
56//! let y = f.demodulate(&s)?;
57//! let back = oqam_destagger(&y.iter().map(|v| v.re).collect::<Vec<f64>>(), 16)?;
58//! // PHYDYAS is nearly perfect reconstruction: a residual of a few 1e-3
59//! assert!(back.iter().zip(&c).all(|(a, b)| (a - b).norm() < 1e-2));
60//! # Ok::<(), comnum_core::Error>(())
61//! ```
62//!
63//! # References
64//!
65//! 1. B. Farhang-Boroujeny, "OFDM versus filter bank multicarrier," *IEEE Signal
66//!    Process. Mag.*, 28(3), 92–112, 2011.
67//! 2. M. Bellanger, "FBMC physical layer: a primer," PHYDYAS project (FP7-ICT
68//!    211887), 2010.
69//! 3. H. Bouhadda, H. Shaiek, D. Roviras, R. Zayani, Y. Medjahdi and R. Bouallegue,
70//!    "Theoretical analysis of BER performance of nonlinearly amplified FBMC/OQAM and
71//!    OFDM signals," *EURASIP J. Adv. Signal Process.*, 2014:60.
72//! 4. R. Nissel and M. Rupp, "OFDM and FBMC-OQAM in doubly-selective channels:
73//!    calculating the bit error probability," *IEEE Commun. Lett.*, 21(6), 1297–1300,
74//!    2017.
75
76use crate::{check_input, check_rate};
77use comnum_core::signal::{Signal, Symbols};
78use comnum_core::traits::{Demodulate, Modulate};
79use comnum_core::units::Hertz;
80use comnum_core::{Error, Result, ensure, ensure_shape};
81use comnum_dsp::filter::normalize_energy;
82use comnum_math::{C64, cis, fft};
83
84/// The OQAM phase $j^{q} = e^{j \pi q / 2}$ of the pulse of index $q = m + n$.
85fn oqam_phase(q: usize) -> C64 {
86    cis(std::f64::consts::FRAC_PI_2 * (q % 4) as f64)
87}
88
89/// The prototype filter of an [`Fbmc`].
90#[non_exhaustive]
91#[derive(Clone, Copy, Debug, PartialEq, Eq)]
92pub enum Prototype {
93    /// The PHYDYAS filter of Bellanger (overlapping factors 2, 3 and 4).
94    Phydyas,
95}
96
97impl Prototype {
98    /// The $KM - 1$ taps of unit energy for `subcarriers` $M$ and overlapping factor
99    /// `overlap` $K$.
100    ///
101    /// # Errors
102    /// [`Error::Config`] for an overlapping factor the prototype does not define.
103    pub fn taps(self, subcarriers: usize, overlap: usize) -> Result<Vec<f64>> {
104        let freq: &[f64] = match (self, overlap) {
105            (Prototype::Phydyas, 2) => &[1.0, std::f64::consts::FRAC_1_SQRT_2],
106            (Prototype::Phydyas, 3) => &[1.0, 0.911_437_83, 0.411_437_83],
107            (Prototype::Phydyas, 4) => &[1.0, 0.971_959_83, std::f64::consts::FRAC_1_SQRT_2, 0.235_146_95],
108            _ => {
109                return Err(Error::Config(format!(
110                    "no PHYDYAS filter for an overlapping factor of {overlap}: 2, 3 or 4"
111                )));
112            }
113        };
114        let km = (overlap * subcarriers) as f64;
115        let mut h: Vec<f64> = (0..overlap * subcarriers - 1)
116            .map(|k| {
117                let tail: f64 = freq
118                    .iter()
119                    .enumerate()
120                    .skip(1)
121                    .map(|(i, hi)| {
122                        let sign = if i % 2 == 1 { -1.0 } else { 1.0 };
123                        2.0 * sign * hi * (std::f64::consts::TAU * i as f64 * (k + 1) as f64 / km).cos()
124                    })
125                    .sum();
126                freq[0] + tail
127            })
128            .collect();
129        normalize_energy(&mut h);
130        Ok(h)
131    }
132}
133
134/// An FBMC/OQAM modem (see the module).
135#[derive(Clone, Debug, PartialEq)]
136pub struct Fbmc {
137    n_fft: usize,
138    overlap: usize,
139    taps: Vec<f64>,
140    sample_rate: Hertz,
141}
142
143impl Fbmc {
144    /// `n_fft` subcarriers $M$ (even) with the PHYDYAS prototype of overlapping factor
145    /// `overlap`, at the sample rate 1 Hz ([`Fbmc::with_sample_rate`]). The prototype is
146    /// sampled once here: the fields are read through getters.
147    ///
148    /// # Errors
149    /// [`Error::Config`] for an odd or zero number of subcarriers or an unknown
150    /// overlapping factor.
151    pub fn new(n_fft: usize, overlap: usize) -> Result<Fbmc> {
152        Fbmc::with_prototype(n_fft, overlap, Prototype::Phydyas)
153    }
154
155    /// [`Fbmc::new`] with another prototype.
156    ///
157    /// # Errors
158    /// As [`Fbmc::new`].
159    pub fn with_prototype(n_fft: usize, overlap: usize, prototype: Prototype) -> Result<Fbmc> {
160        ensure(n_fft >= 2 && n_fft % 2 == 0, || format!("FBMC/OQAM needs an even number of subcarriers, got {n_fft}"))?;
161        Ok(Fbmc { n_fft, overlap, taps: prototype.taps(n_fft, overlap)?, sample_rate: Hertz(1.0) })
162    }
163
164    /// The modem at the sample rate `sample_rate` ($M \Delta f$), the rate of the
165    /// [`Signal`] of [`Modulate`] and the rate [`Demodulate`] expects.
166    ///
167    /// # Errors
168    /// [`Error::Config`] for a rate that is not positive and finite.
169    ///
170    /// # Examples
171    /// ```
172    /// use comnum_waveform::fbmc::Fbmc;
173    /// use comnum_core::units::Hertz;
174    /// let f = Fbmc::new(64, 4)?.with_sample_rate(Hertz(64.0 * 15e3))?;
175    /// assert_eq!(f.sample_rate(), Hertz(960e3));
176    /// # Ok::<(), comnum_core::Error>(())
177    /// ```
178    pub fn with_sample_rate(self, sample_rate: Hertz) -> Result<Fbmc> {
179        check_rate(sample_rate)?;
180        Ok(Fbmc { sample_rate, ..self })
181    }
182
183    /// The sample rate of the modem.
184    pub fn sample_rate(&self) -> Hertz {
185        self.sample_rate
186    }
187
188    /// The number of subcarriers $M$.
189    pub fn n_fft(&self) -> usize {
190        self.n_fft
191    }
192
193    /// The overlapping factor $K$.
194    pub fn overlap(&self) -> usize {
195        self.overlap
196    }
197
198    /// The prototype filter (unit energy, $KM - 1$ taps).
199    pub fn taps(&self) -> &[f64] {
200        &self.taps
201    }
202
203    /// The number of samples of `half_symbols` real symbols per subcarrier:
204    /// $(n - 1) M/2 + L$.
205    pub fn n_samples(&self, half_symbols: usize) -> usize {
206        if half_symbols == 0 { 0 } else { (half_symbols - 1) * self.n_fft / 2 + self.taps.len() }
207    }
208
209    /// The number of half-symbols of a signal of `n_samples` samples, the inverse of
210    /// [`Fbmc::n_samples`].
211    ///
212    /// # Errors
213    /// [`Error::Shape`] if no whole number of half-symbols has this length.
214    pub fn half_symbols(&self, n_samples: usize) -> Result<usize> {
215        let (half, l) = (self.n_fft / 2, self.taps.len());
216        if n_samples == 0 {
217            return Ok(0);
218        }
219        ensure_shape(n_samples >= l && (n_samples - l) % half == 0, || {
220            format!("{n_samples} samples are not a whole number of half-symbols ((n - 1) {half} + {l})")
221        })?;
222        Ok((n_samples - l) / half + 1)
223    }
224
225    /// The pulse $g_{m,n}$ on its support: `(first sample, values)`.
226    pub fn pulse(&self, m: usize, n: usize) -> (usize, Vec<C64>) {
227        let mm = self.n_fft as f64;
228        let centre = (self.taps.len() - 1) as f64 / 2.0;
229        let phase = oqam_phase(m + n);
230        let values = self
231            .taps
232            .iter()
233            .enumerate()
234            .map(|(i, h)| cis(std::f64::consts::TAU * m as f64 * (i as f64 - centre) / mm) * phase * *h)
235            .collect();
236        (n * self.n_fft / 2, values)
237    }
238
239    /// The intrinsic interference $\langle g_{m+\Delta m, n+\Delta n}, g_{m,n} \rangle =
240    /// \sum_{k} g_{m+\Delta m,n+\Delta n}\[k\]\, g_{m,n}^{*}\[k\]$ that the symbol
241    /// $(m + \Delta m, n + \Delta n)$ leaves on the estimate of $(m, n)$: 1 for
242    /// $\Delta m = \Delta n = 0$, purely imaginary otherwise (Bouhadda et al. 2014, eq. (7)
243    /// and Table 1). The subcarrier index is taken modulo $M$; pulses $|\Delta n| \ge 2K$
244    /// half-symbols apart do not overlap and give 0.
245    pub fn interference(&self, m: usize, n: usize, dm: isize, dn: isize) -> C64 {
246        if dn.unsigned_abs() >= 2 * self.overlap {
247            return C64::new(0.0, 0.0);
248        }
249        let mm = self.n_fft as isize;
250        let m2 = (m as isize + dm).rem_euclid(mm) as usize;
251        // a common time origin far enough for negative shifts
252        let base = 4 * self.overlap;
253        let (n1, n2) = (n + base, (n as isize + base as isize + dn) as usize);
254        let (s1, g1) = self.pulse(m, n1);
255        let (s2, g2) = self.pulse(m2, n2);
256        let (start, end) = (s1.max(s2), (s1 + g1.len()).min(s2 + g2.len()));
257        // the phase of the pulses depends on n modulo 4: shifting both by `base` (a
258        // multiple of 4) changes nothing
259        (start..end).map(|k| g2[k - s2] * g1[k - s1].conj()).sum()
260    }
261
262    /// Modulate real symbols `a` laid out half-symbol after half-symbol,
263    /// `a[n * M + m]`.
264    ///
265    /// # Errors
266    /// [`Error::Shape`] unless `a` holds whole half-symbols.
267    pub fn modulate(&self, a: &[f64]) -> Result<Vec<C64>> {
268        let m = self.n_fft;
269        ensure_shape(a.len() % m == 0, || format!("{} real symbols for {m} subcarriers", a.len()))?;
270        let half_symbols = a.len() / m;
271        let shift = (self.taps.len() - 1) / 2;
272        let plan = fft::plan(m);
273        let mut s = vec![C64::new(0.0, 0.0); self.n_samples(half_symbols)];
274        let mut v = vec![C64::new(0.0, 0.0); m];
275        for (n, row) in a.chunks_exact(m).enumerate() {
276            for (k, (vk, ak)) in v.iter_mut().zip(row).enumerate() {
277                *vk = oqam_phase(k + n) * *ak;
278            }
279            // Σ_m c_m e^{j2πmj/M}: the inverse FFT divides by M
280            plan.inverse(&mut v);
281            let offset = n * m / 2;
282            for (i, h) in self.taps.iter().enumerate() {
283                s[offset + i] += v[(i + m * self.overlap - shift) % m] * (h * m as f64);
284            }
285        }
286        Ok(s)
287    }
288
289    /// Project `r`, the [`Fbmc::n_samples`] of a whole number of half-symbols, on every
290    /// pulse: $y_{m,n} = \langle r, g_{m,n} \rangle$, laid out `y[n * M + m]`; the real
291    /// parts estimate the symbols, the imaginary parts hold the intrinsic interference.
292    ///
293    /// # Errors
294    /// As [`Fbmc::half_symbols`] of the length of `r`.
295    pub fn demodulate(&self, r: &[C64]) -> Result<Vec<C64>> {
296        let m = self.n_fft;
297        let half_symbols = self.half_symbols(r.len())?;
298        let shift = (self.taps.len() - 1) / 2;
299        let plan = fft::plan(m);
300        let mut y = Vec::with_capacity(half_symbols * m);
301        let mut w = vec![C64::new(0.0, 0.0); m];
302        for n in 0..half_symbols {
303            w.fill(C64::new(0.0, 0.0));
304            let offset = n * m / 2;
305            for (i, h) in self.taps.iter().enumerate() {
306                w[(i + m * self.overlap - shift) % m] += r[offset + i] * *h;
307            }
308            plan.forward(&mut w);
309            for (k, wk) in w.iter().enumerate() {
310                y.push(wk * oqam_phase(k + n).conj());
311            }
312        }
313        Ok(y)
314    }
315}
316
317/// FBMC/OQAM as a [`Modulate`] block: one stream of complex symbols, `M` per symbol
318/// ($c\[k M + m\]$), staggered into real symbols ([`oqam_stagger`]) and modulated at
319/// [`Fbmc::sample_rate`].
320impl Modulate for Fbmc {
321    fn modulate_into(&self, x: &Symbols, out: &mut Signal) -> Result<()> {
322        let a = oqam_stagger(crate::one_stream(x, "FBMC")?, self.n_fft)?;
323        out.set_stream(&Fbmc::modulate(self, &a)?, self.sample_rate);
324        Ok(())
325    }
326}
327
328/// FBMC/OQAM as a [`Demodulate`] block: the complex symbols of the real parts of the
329/// projections ([`oqam_destagger`]), the intrinsic interference dropped.
330impl Demodulate for Fbmc {
331    /// # Errors
332    /// [`Error::Config`] for several streams or a signal at another sample rate; the
333    /// errors of [`Fbmc::demodulate`] and of [`oqam_destagger`] (an odd number of
334    /// half-symbols).
335    fn demodulate_into(&mut self, y: &Signal, out: &mut Symbols) -> Result<()> {
336        check_input(y, self.sample_rate, "FBMC")?;
337        let re: Vec<f64> = Fbmc::demodulate(self, y.stream(0))?.iter().map(|v| v.re).collect();
338        out.set_stream(&oqam_destagger(&re, self.n_fft)?);
339        Ok(())
340    }
341}
342
343/// Split complex symbols `c[k * M + m]` ($k$ the complex symbol, $m$ the subcarrier) into
344/// real symbols on twice as many half-symbols, `a[n * M + m]`: the real part on the even
345/// half-symbol and the imaginary part on the odd one for the even subcarriers, the
346/// reverse for the odd subcarriers.
347///
348/// # Errors
349/// [`Error::Shape`] if `c` does not hold whole symbols of `m` subcarriers.
350pub fn oqam_stagger(c: &[C64], m: usize) -> Result<Vec<f64>> {
351    ensure_shape(m > 0 && c.len() % m == 0, || {
352        format!("{} symbols are not whole symbols of {m} subcarriers", c.len())
353    })?;
354    let mut a = vec![0.0; 2 * c.len()];
355    for (k, row) in c.chunks_exact(m).enumerate() {
356        for (sub, v) in row.iter().enumerate() {
357            let (first, second) = if sub % 2 == 0 { (v.re, v.im) } else { (v.im, v.re) };
358            a[2 * k * m + sub] = first;
359            a[(2 * k + 1) * m + sub] = second;
360        }
361    }
362    Ok(a)
363}
364
365/// The inverse of [`oqam_stagger`]: complex symbols from the real estimates
366/// `a[n * M + m]` of an even number of half-symbols.
367///
368/// # Errors
369/// [`Error::Shape`] if `a` does not hold an even number of half-symbols of `m`
370/// subcarriers.
371pub fn oqam_destagger(a: &[f64], m: usize) -> Result<Vec<C64>> {
372    ensure_shape(m > 0 && a.len() % (2 * m) == 0, || {
373        format!("{} real symbols are not an even number of half-symbols of {m} subcarriers", a.len())
374    })?;
375    let mut c = Vec::with_capacity(a.len() / 2);
376    for pair in a.chunks_exact(2 * m) {
377        let (first, second) = pair.split_at(m);
378        for sub in 0..m {
379            c.push(if sub % 2 == 0 { C64::new(first[sub], second[sub]) } else { C64::new(second[sub], first[sub]) });
380        }
381    }
382    Ok(c)
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use comnum_core::rng::Rng;
389
390    /// The OQAM phases were written three times with `cis(±π/2 · q)` and the taps
391    /// normalised by hand before `oqam_phase` and `comnum_dsp::filter::normalize_energy`:
392    /// the taps, the samples and the projections are bit-identical.
393    #[test]
394    fn the_shared_phase_and_normalisation_are_the_former_ones() {
395        let f = Fbmc::new(16, 4).unwrap();
396        let mut r = Rng::new(6);
397        let a: Vec<f64> = (0..16 * 10).map(|_| r.gaussian()).collect();
398        let (m, shift) = (16usize, (f.taps().len() - 1) / 2);
399        let plan = fft::plan(m);
400        let mut s = vec![C64::new(0.0, 0.0); f.n_samples(10)];
401        for (n, row) in a.chunks_exact(m).enumerate() {
402            let mut v: Vec<C64> = row
403                .iter()
404                .enumerate()
405                .map(|(k, ak)| cis(std::f64::consts::FRAC_PI_2 * ((k + n) % 4) as f64) * *ak)
406                .collect();
407            plan.inverse(&mut v);
408            for (i, h) in f.taps().iter().enumerate() {
409                s[n * m / 2 + i] += v[(i + m * 4 - shift) % m] * (h * m as f64);
410            }
411        }
412        assert_eq!(f.modulate(&a).unwrap(), s);
413        let y = f.demodulate(&s).unwrap();
414        for n in 0..10 {
415            let mut w = vec![C64::new(0.0, 0.0); m];
416            for (i, h) in f.taps().iter().enumerate() {
417                w[(i + m * 4 - shift) % m] += s[n * m / 2 + i] * *h;
418            }
419            plan.forward(&mut w);
420            for (k, wk) in w.iter().enumerate() {
421                assert_eq!(y[n * m + k], wk * cis(-std::f64::consts::FRAC_PI_2 * ((k + n) % 4) as f64));
422            }
423        }
424        let raw = Prototype::Phydyas.taps(16, 4).unwrap();
425        let norm = raw.iter().map(|v| v * v).sum::<f64>().sqrt();
426        assert!((norm - 1.0).abs() < 1e-15);
427    }
428
429    #[test]
430    fn the_traits_stagger_inside() {
431        let f = Fbmc::new(16, 4).unwrap().with_sample_rate(Hertz(1e6)).unwrap();
432        let c: Vec<C64> = (0..16 * 6)
433            .map(|i| C64::new(if i % 3 == 0 { 1.0 } else { -1.0 }, if i % 5 == 0 { -1.0 } else { 1.0 }))
434            .collect();
435        let x = Modulate::modulate(&f, &Symbols::from_stream(c.clone()).unwrap()).unwrap();
436        assert_eq!((x.len(), x.sample_rate()), (f.n_samples(12), Hertz(1e6)));
437        assert_eq!(x.stream(0), &f.modulate(&oqam_stagger(&c, 16).unwrap()).unwrap()[..]);
438        let mut g = f.clone();
439        let back = Demodulate::demodulate(&mut g, &x).unwrap();
440        assert!(back.as_slice().iter().zip(&c).all(|(a, b)| (a - b).norm() < 1e-2));
441        let other = Signal::from_stream(x.stream(0).to_vec(), Hertz(2e6)).unwrap();
442        assert!(Demodulate::demodulate(&mut g, &other).is_err() && f.clone().with_sample_rate(Hertz(-1.0)).is_err());
443    }
444
445    #[test]
446    fn distant_pulses_do_not_interfere() {
447        let f = Fbmc::new(8, 4).unwrap();
448        assert_eq!(f.interference(0, 0, 1, -1000), C64::new(0.0, 0.0));
449        assert_eq!(f.interference(0, 0, 1, isize::MAX), C64::new(0.0, 0.0));
450        assert!(f.interference(0, 0, 1, 7).norm() > 0.0);
451    }
452
453    #[test]
454    fn the_pulses_are_real_orthogonal_and_of_unit_energy() {
455        for (m, k) in [(8usize, 2usize), (16, 3), (32, 4)] {
456            let f = Fbmc::new(m, k).unwrap();
457            assert!((f.taps().iter().map(|v| v * v).sum::<f64>() - 1.0).abs() < 1e-12);
458            for m0 in [0usize, 1, 5] {
459                for n0 in [0usize, 1, 2, 3] {
460                    for dm in -2isize..=2 {
461                        for dn in -(2 * k as isize)..=(2 * k as isize) {
462                            let v = f.interference(m0, n0, dm, dn);
463                            let want = if dm == 0 && dn == 0 { 1.0 } else { 0.0 };
464                            // PHYDYAS is only nearly perfect reconstruction, the closer the
465                            // longer the filter
466                            let tol = match k {
467                                2 => 0.05,
468                                3 => 5e-3,
469                                _ => 5e-4,
470                            };
471                            assert!((v.re - want).abs() < tol, "M {m} K {k} ({m0},{n0}) + ({dm},{dn}): {v}");
472                        }
473                    }
474                }
475            }
476        }
477    }
478
479    #[test]
480    fn modulation_is_the_sum_of_the_pulses_and_demodulation_their_projection() {
481        let f = Fbmc::new(8, 4).unwrap();
482        let mut r = Rng::new(3);
483        let n = 5;
484        let a: Vec<f64> = (0..8 * n).map(|_| r.gaussian()).collect();
485        let s = f.modulate(&a).unwrap();
486        let mut direct = vec![C64::new(0.0, 0.0); f.n_samples(n)];
487        for nn in 0..n {
488            for m in 0..8 {
489                let (start, g) = f.pulse(m, nn);
490                for (i, v) in g.iter().enumerate() {
491                    direct[start + i] += v * a[nn * 8 + m];
492                }
493            }
494        }
495        assert!(s.iter().zip(&direct).all(|(x, y)| (x - y).norm() < 1e-12));
496        let y = f.demodulate(&s).unwrap();
497        for nn in 0..n {
498            for m in 0..8 {
499                let (start, g) = f.pulse(m, nn);
500                let proj: C64 = g.iter().enumerate().map(|(i, v)| s[start + i] * v.conj()).sum();
501                assert!((y[nn * 8 + m] - proj).norm() < 1e-12);
502            }
503        }
504    }
505
506    #[test]
507    fn unit_power_and_half_the_noise_on_each_real_estimate() {
508        let (m, n) = (64usize, 400usize);
509        let f = Fbmc::new(m, 4).unwrap();
510        let mut r = Rng::new(9);
511        let a: Vec<f64> = (0..m * n).map(|_| if r.below(2) == 0 { 0.5f64.sqrt() } else { -0.5f64.sqrt() }).collect();
512        let s = f.modulate(&a).unwrap();
513        // the steady part, away from the ramps
514        let mid = &s[f.taps().len()..s.len() - f.taps().len()];
515        let p = mid.iter().map(C64::norm_sqr).sum::<f64>() / mid.len() as f64;
516        assert!((p - 1.0).abs() < 0.02, "{p}");
517        let sigma2: f64 = 0.1;
518        let noisy: Vec<C64> = s.iter().map(|v| v + r.complex_gaussian() * sigma2.sqrt()).collect();
519        let y = f.demodulate(&noisy).unwrap();
520        let err: Vec<f64> = y.iter().zip(&a).map(|(v, a)| v.re - a).collect();
521        let var = err.iter().map(|e| e * e).sum::<f64>() / err.len() as f64;
522        assert!((var / (sigma2 / 2.0) - 1.0).abs() < 0.03, "{var}");
523    }
524
525    #[test]
526    fn the_spectrum_is_confined_where_that_of_ofdm_is_not() {
527        // Farhang-Boroujeny 2011, Sec. "FBMC": the subcarrier filters of FBMC leave
528        // almost nothing beyond the neighbouring subcarriers, the sinc of OFDM decays as
529        // 1/f. One subcarrier, 16 half-symbols, 8 subcarriers away (half-way between
530        // subcarriers): more than 50 dB below its peak for PHYDYAS, about 25 dB for OFDM.
531        let (m, n) = (32usize, 16usize);
532        let f = Fbmc::new(m, 4).unwrap();
533        let mut a = vec![0.0; m * n];
534        let mut r = Rng::new(4);
535        let mut p = vec![0.0; 16 * m];
536        for _ in 0..50 {
537            for nn in 0..n {
538                a[nn * m] = if r.below(2) == 0 { 1.0 } else { -1.0 };
539            }
540            let mut s = f.modulate(&a).unwrap();
541            s.resize(16 * m * 8, C64::new(0.0, 0.0));
542            let spec = fft::fft(&s);
543            // bins of the zero-padded transform: subcarrier k at 8 k (the grid is 8 times finer)
544            for (i, v) in p.iter_mut().enumerate() {
545                *v += spec[i * 8].norm_sqr();
546            }
547        }
548        let at = |k: f64| p[((k * 16.0).round() as isize).rem_euclid(16 * m as isize) as usize];
549        let fbmc_db = comnum_math::lin2db(at(8.5) / at(0.0));
550        // OFDM: a rectangular symbol of M samples, |sinc(8.5)|²
551        let ofdm_db = 20.0 * comnum_math::sinc(8.5).abs().log10();
552        assert!(fbmc_db < -50.0 && ofdm_db > -30.0, "FBMC {fbmc_db} dB, OFDM {ofdm_db} dB");
553    }
554
555    #[test]
556    fn staggering_round_trips() {
557        let c: Vec<C64> = (0..12).map(|i| C64::new(i as f64, -(i as f64) - 0.5)).collect();
558        let a = oqam_stagger(&c, 4).unwrap();
559        assert_eq!(a.len(), 24);
560        assert_eq!((a[0], a[4], a[1], a[5]), (0.0, -0.5, -1.5, 1.0));
561        assert_eq!(oqam_destagger(&a, 4).unwrap(), c);
562        assert!(oqam_stagger(&c[..5], 4).is_err() && oqam_destagger(&a[..20], 4).is_err());
563    }
564}
565
566#[cfg(test)]
567mod literature {
568    use super::*;
569
570    /// H. Bouhadda et al., EURASIP J. Adv. Signal Process. 2014:60, Table 1: the
571    /// transmultiplexer response of the PHYDYAS filter ($K = 4$) around a symbol
572    /// $(m_{0}, n_{0})$, to the three decimals printed. With the phases of the module
573    /// (those of Nissel & Rupp, $\frac{\pi}{2}(m + n)$, with $m_{0}$ and $n_{0}$ even), the
574    /// rows $m_{0} - 1$ and $m_{0} + 1$ are those of the table exchanged: the table counts
575    /// the subcarriers the other way.
576    #[test]
577    fn bouhadda_2014_table_1() {
578        let f = Fbmc::new(64, 4).unwrap();
579        let rows: [(isize, [f64; 7]); 3] = [
580            (1, [0.043, 0.125, 0.206, 0.239, 0.206, 0.125, 0.043]),
581            (0, [-0.067, 0.0, -0.564, 0.0, 0.564, 0.0, 0.067]),
582            (-1, [0.043, -0.125, 0.206, -0.239, 0.206, -0.125, 0.043]),
583        ];
584        for (dm, row) in rows {
585            for (j, want) in row.iter().enumerate() {
586                let dn = j as isize - 3;
587                let v = f.interference(10, 10, dm, dn);
588                if dm == 0 && dn == 0 {
589                    assert!((v - 1.0).norm() < 1e-3);
590                } else {
591                    assert!(v.re.abs() < 5e-4 && (v.im - want).abs() < 5e-4, "({dm}, {dn}): {v} against {want}j");
592                }
593            }
594        }
595    }
596}