Skip to main content

comnum_waveform/ofdm/
sync.rs

1//! Timing and carrier-frequency synchronisation of OFDM (Schmidl & Cox 1997).
2//!
3//! The preamble is an OFDM symbol whose odd subcarriers are empty: its useful part is
4//! two identical halves of $L = LN/2$ samples. With the received samples $r$, the
5//! correlation and the energy of the second half,
6//!
7//! $$
8//! P(d) = \sum_{m=0}^{L-1} r^{*}[d + m]\, r[d + m + L], \qquad
9//! R(d) = \sum_{m=0}^{L-1} |r[d + m + L]|^{2}, \qquad
10//! M(d) = \frac{|P(d)|^{2}}{R(d)^{2}},
11//! $$
12//!
13//! the timing metric $M(d)$ reaches a plateau of the length of the cyclic prefix at the
14//! start of the preamble; the phase of $P$ there gives the fractional carrier frequency
15//! offset, in units of the subcarrier spacing, $\hat\varepsilon = \angle P(\hat d) / \pi$
16//! (an offset of $\varepsilon$ rotates the second half by $\pi\varepsilon$).
17//!
18//! [`schmidl_cox`] returns the metric; [`synchronize`] the timing (the middle of the
19//! points above [`PLATEAU_FRACTION`] of the peak, which lies in the cyclic prefix when
20//! data follow the preamble) and the offset, the phase of the correlation $P$ that the
21//! metric was computed from.
22//!
23//! # A repeated symbol (Moose 1994)
24//!
25//! When a block of $N$ = [`Ofdm::fft_len`] samples is sent twice in a row
26//! ([`repeated_preamble`]), the DFTs $Y_{1k}$, $Y_{2k}$ of the two received blocks differ by
27//! the phase $2\pi\varepsilon$ of the offset alone, and the maximum-likelihood estimate is
28//! (Moose, eq. (21))
29//!
30//! $$
31//! \hat\varepsilon = \frac{1}{2\pi} \angle \sum_{k} Y_{2k} Y_{1k}^{*}, \qquad |\varepsilon| < \tfrac{1}{2},
32//! $$
33//!
34//! over the active subcarriers ([`moose`]); for small errors it is unbiased, of variance
35//! $1/((2\pi)^{2} E_{s}/N_{0})$ with $E_{s}$ the energy of one block (eq. (26),
36//! [`moose_std`]). An offset leaves on subcarrier $k$ the fraction
37//! $\operatorname{diric}(2\pi\varepsilon/N, N)^{2}$ of its power and adds the interference
38//! of the others (eq. (10), [`ici_power`]):
39//!
40//! $$
41//! \frac{E|I_{k}|^{2}}{|X|^{2}|H|^{2}} = \sum_{l \ne k}
42//! \left(\frac{\sin \pi\varepsilon}{N \sin(\pi(l - k + \varepsilon)/N)}\right)^{2}
43//! \le 0.5947 \sin^{2}\pi\varepsilon \quad (|\varepsilon| \le \tfrac{1}{2}),
44//! $$
45//!
46//! hence the lower bound of the SNR after the DFT (eq. (15), [`cfo_snr_bound`]),
47//! $\mathrm{SNR}(\varepsilon) \ge \mathrm{SNR}\, (\sin\pi\varepsilon/\pi\varepsilon)^{2} /
48//! (1 + 0.5947\, \mathrm{SNR} \sin^{2}\pi\varepsilon)$.
49//!
50//! # The cyclic prefix (van de Beek, Sandell & Börjesson 1997)
51//!
52//! Without a preamble, the $L$ samples of the prefix and their copies $N$ samples later
53//! carry the timing and the offset. Over a window of $2N + L$ samples that contains one
54//! whole symbol, the joint maximum-likelihood estimates are (their eqs. (6)–(8),
55//! (12)–(13))
56//!
57//! $$
58//! \hat\theta = \arg\max_{\theta} \left\{ |\gamma(\theta)| - \rho\, \Phi(\theta) \right\},
59//! \qquad \hat\varepsilon = -\frac{1}{2\pi} \angle \gamma(\hat\theta),
60//! $$
61//!
62//! with $\gamma(\theta) = \sum_{k=\theta}^{\theta+L-1} r(k)\, r^{*}(k + N)$, the energy
63//! $\Phi(\theta) = \frac{1}{2} \sum_{k=\theta}^{\theta+L-1} |r(k)|^{2} + |r(k+N)|^{2}$ and
64//! $\rho = \mathrm{SNR}/(\mathrm{SNR} + 1)$ ([`cp_ml`]: $\hat\theta$ is the first
65//! sample of the prefix).
66//!
67//! # Examples
68//! ```
69//! use comnum_waveform::ofdm::Ofdm;
70//! use comnum_waveform::ofdm::sync::{preamble, synchronize};
71//! use comnum_core::rng::Rng;
72//! use comnum_math::{cis, C64};
73//! let ofdm = Ofdm { n_cp: 16, ..Ofdm::new(64, 52)? };
74//! let p = preamble(&ofdm, 1)?;                               // with its prefix
75//! // silence, the preamble, then a data symbol (random QPSK)
76//! let mut r = Rng::new(4);
77//! let data: Vec<C64> = (0..52).map(|_| cis(std::f64::consts::FRAC_PI_2 * r.below(4) as f64)).collect();
78//! let mut rx = vec![C64::new(0.0, 0.0); 100];
79//! rx.extend(&p);
80//! rx.extend(ofdm.modulate(&data)?);
81//! // an offset of 0.2 subcarrier spacing
82//! let mut rx = rx;
83//! comnum_dsp::signal::rotate(&mut rx, std::f64::consts::TAU * 0.2 / ofdm.fft_len() as f64, 0.0);
84//! let e = synchronize(&rx, &ofdm)?;
85//! assert!((e.cfo - 0.2).abs() < 1e-6);
86//! assert!(e.timing >= 100 && e.timing <= 100 + 4 * 16);       // within the prefix
87//! # Ok::<(), comnum_core::Error>(())
88//! ```
89//!
90//! # References
91//!
92//! 1. T. M. Schmidl and D. C. Cox, "Robust frequency and timing synchronization for
93//!    OFDM," *IEEE Trans. Commun.*, 45(12), 1613–1621, 1997.
94//! 2. P. H. Moose, "A technique for orthogonal frequency division multiplexing frequency
95//!    offset correction," *IEEE Trans. Commun.*, 42(10), 2908–2914, 1994.
96//! 3. J.-J. van de Beek, M. Sandell and P. O. Börjesson, "ML estimation of time and
97//!    frequency offset in OFDM systems," *IEEE Trans. Signal Process.*, 45(7),
98//!    1800–1805, 1997.
99//! 4. J. Armstrong, "Analysis of new and existing methods of reducing intercarrier
100//!    interference due to carrier frequency offset in OFDM," *IEEE Trans. Commun.*,
101//!    47(3), 365–369, 1999.
102
103use super::Ofdm;
104use comnum_core::rng::Rng;
105use comnum_core::{Result, ensure, ensure_shape};
106use comnum_dsp::sync::frame::{delayed_correlation, windowed_energy};
107use comnum_mapping::source::qpsk;
108use comnum_math::{C64, cis, diric, sinc};
109use std::f64::consts::TAU;
110
111/// The preamble of Schmidl & Cox (with its cyclic prefix): QPSK on the even active
112/// subcarriers (scaled by $\sqrt{2}$ to keep the unit power), nothing on the odd ones.
113///
114/// # Errors
115/// [`comnum_core::Error::Shape`] if the prefix is longer than the block.
116pub fn preamble(ofdm: &Ofdm, seed: u64) -> Result<Vec<C64>> {
117    let mut r = Rng::new(seed);
118    let active: Vec<C64> = (0..ofdm.n_active)
119        .map(|i| {
120            let s = qpsk(&mut r) * std::f64::consts::SQRT_2;
121            if ofdm.subcarrier(i).rem_euclid(2) == 0 { s } else { C64::new(0.0, 0.0) }
122        })
123        .collect();
124    ofdm.with_prefix(&ofdm.to_time(&active)?)
125}
126
127/// The timing metric $M(d)$ for every start $d$ (halves of `half` samples), on
128/// [`delayed_correlation`] and [`windowed_energy`].
129pub fn schmidl_cox(r: &[C64], half: usize) -> Vec<f64> {
130    metric(r, half).0
131}
132
133/// The metric $M(d)$ and the correlation $P(d)$ it is computed from.
134fn metric(r: &[C64], half: usize) -> (Vec<f64>, Vec<C64>) {
135    let p = delayed_correlation(r, half, half);
136    let e = windowed_energy(r, half);
137    let m = p
138        .iter()
139        .enumerate()
140        .map(|(d, p)| {
141            let e = e[d + half];
142            if e > 0.0 { p.norm_sqr() / (e * e) } else { 0.0 }
143        })
144        .collect();
145    (m, p)
146}
147
148/// The fraction of the peak of the metric above which [`synchronize`] counts the points
149/// of its plateau (Schmidl & Cox 1997 average the two times, on either side of the
150/// maximum, at 90 % of it).
151pub const PLATEAU_FRACTION: f64 = 0.9;
152
153/// A synchronisation estimate.
154#[derive(Clone, Copy, Debug, PartialEq)]
155pub struct Estimate {
156    /// The timing (sample index): for [`synchronize`], the middle of the plateau of the
157    /// metric, inside the cyclic prefix of the preamble; for [`cp_ml`], the first sample
158    /// of the cyclic prefix.
159    pub timing: usize,
160    /// Fractional carrier frequency offset, in subcarrier spacings ([`synchronize`]:
161    /// $|\varepsilon| < 1$; [`cp_ml`]: $|\varepsilon| < 1/2$).
162    pub cfo: f64,
163    /// Peak of the timing metric (for [`cp_ml`], the log-likelihood $|\gamma| - \rho\Phi$).
164    pub peak: f64,
165}
166
167/// Estimate the timing and the frequency offset of the preamble of `ofdm` in `r`.
168///
169/// # Errors
170/// [`comnum_core::Error::Shape`] if `r` is shorter than the preamble.
171pub fn synchronize(r: &[C64], ofdm: &Ofdm) -> Result<Estimate> {
172    let half = ofdm.fft_len() / 2;
173    ensure_shape(r.len() >= 2 * half, || {
174        format!("the received block of {} samples is shorter than the preamble of {}", r.len(), 2 * half)
175    })?;
176    let (m, p) = metric(r, half);
177    let (imax, peak) = comnum_math::argmax(m.iter().copied()).unwrap_or((0, 0.0));
178    // the plateau: the points above PLATEAU_FRACTION of the peak around it
179    let thr = PLATEAU_FRACTION * peak;
180    let mut a = imax;
181    while a > 0 && m[a - 1] >= thr {
182        a -= 1;
183    }
184    let mut b = imax;
185    while b + 1 < m.len() && m[b + 1] >= thr {
186        b += 1;
187    }
188    let d = (a + b) / 2;
189    Ok(Estimate { timing: d, cfo: p[d].arg() / std::f64::consts::PI, peak })
190}
191
192/// The constant of Moose's bound of the interference, eq. (13): $E|I_{k}|^{2} \le 0.5947
193/// |X|^{2}|H|^{2} \sin^{2}\pi\varepsilon$ for $|\varepsilon| \le 1/2$.
194pub const MOOSE_ICI_BOUND: f64 = 0.5947;
195
196/// The preamble of Moose (with its cyclic prefix): one block of QPSK on every active
197/// subcarrier, sent twice in a row, $L(N_{\mathrm{cp}} + 2N)$ samples.
198///
199/// # Errors
200/// [`comnum_core::Error::Shape`] if the prefix is longer than the block.
201pub fn repeated_preamble(ofdm: &Ofdm, seed: u64) -> Result<Vec<C64>> {
202    let mut r = Rng::new(seed);
203    let active: Vec<C64> = (0..ofdm.n_active).map(|_| qpsk(&mut r)).collect();
204    let block = ofdm.to_time(&active)?;
205    let mut out = ofdm.with_prefix(&block)?;
206    out.extend_from_slice(&block);
207    Ok(out)
208}
209
210/// The frequency offset (in subcarrier spacings, $|\varepsilon| < 1/2$) of a block
211/// repeated at `start` and `start + N` in `r`, Moose's eq. (21) over the active
212/// subcarriers ([`Ofdm::to_active`]).
213///
214/// # Errors
215/// [`comnum_core::Error::Shape`] if `r` ends before the second block.
216pub fn moose(r: &[C64], start: usize, ofdm: &Ofdm) -> Result<f64> {
217    let n = ofdm.fft_len();
218    ensure_shape(start + 2 * n <= r.len(), || {
219        format!("the two blocks of {n} samples from {start} end after the {} received samples", r.len())
220    })?;
221    let y1 = ofdm.to_active(&r[start..start + n])?;
222    let y2 = ofdm.to_active(&r[start + n..start + 2 * n])?;
223    let s: C64 = y2.iter().zip(&y1).map(|(b, a)| b * a.conj()).sum();
224    Ok(s.arg() / TAU)
225}
226
227/// The standard deviation of Moose's estimate for small errors,
228/// $1/(2\pi\sqrt{E_{s}/N_{0}})$, with $E_{s}/N_{0}$ (linear) the energy of one block over
229/// the noise density (eq. (26)).
230pub fn moose_std(es_n0_lin: f64) -> f64 {
231    1.0 / (TAU * es_n0_lin.sqrt())
232}
233
234/// The weight $c_{d}$ with which, under a frequency offset `eps` (in subcarrier
235/// spacings), the symbol of the subcarrier $d$ positions away reaches a subcarrier
236/// after the DFT of $N$ = `n` points (Armstrong 1999, eq. (8); Moose 1994, eq. (8)):
237///
238/// $$
239/// c_{d} = \frac{\sin \pi(d + \varepsilon)}{N \sin(\pi(d + \varepsilon)/N)}\,
240/// e^{j\pi(N - 1)(d + \varepsilon)/N},
241/// $$
242///
243/// [`comnum_math::diric`] times its phase; $c_{0}$ is the attenuation and rotation of the
244/// wanted symbol, and the others the interference.
245pub fn cfo_coefficient(n: usize, d: isize, eps: f64) -> C64 {
246    let x = d as f64 + eps;
247    let nf = n as f64;
248    cis(std::f64::consts::PI * (nf - 1.0) * x / nf) * diric(TAU * x / nf, n)
249}
250
251/// The power of the interference that a frequency offset `eps` (in subcarrier
252/// spacings) brings onto the active subcarrier `i` from the other active subcarriers,
253/// relative to the power of one subcarrier: Moose's eq. (10), the sum of
254/// $|c_{l-k}|^{2}$ ([`cfo_coefficient`]) over the other active subcarriers $l$.
255pub fn ici_power(ofdm: &Ofdm, i: usize, eps: f64) -> f64 {
256    let n = ofdm.fft_len();
257    let k = ofdm.subcarrier(i);
258    (0..ofdm.n_active).filter(|&l| l != i).map(|l| cfo_coefficient(n, ofdm.subcarrier(l) - k, eps).norm_sqr()).sum()
259}
260
261/// Moose's lower bound of the SNR of a subcarrier after the DFT under a frequency offset
262/// `eps` (in subcarrier spacings), for an SNR `snr_lin` (linear, $E_{c}/N_{0}$; infinite for
263/// the interference alone), eq. (15):
264/// $\mathrm{SNR}\,(\sin\pi\varepsilon/\pi\varepsilon)^{2} / (1 + 0.5947\, \mathrm{SNR}
265/// \sin^{2}\pi\varepsilon)$ (linear).
266pub fn cfo_snr_bound(snr_lin: f64, eps: f64) -> f64 {
267    let s = (std::f64::consts::PI * eps).sin();
268    sinc(eps).powi(2) / (1.0 / snr_lin + MOOSE_ICI_BOUND * s * s)
269}
270
271/// The joint maximum-likelihood timing and frequency offset of van de Beek, Sandell &
272/// Börjesson (1997) from the cyclic prefix, for an SNR `snr_lin` (linear) per sample: over
273/// every start $\theta$ such that the prefix and its copy lie in `r` (a window of
274/// $2N + L$ samples that contains one whole symbol), the maximum of $|\gamma(\theta)| -
275/// \rho\Phi(\theta)$ ([`delayed_correlation`] and [`windowed_energy`]); the timing is the
276/// first sample of the prefix.
277///
278/// # Errors
279/// [`comnum_core::Error::Config`] without a cyclic prefix or for a non-positive SNR,
280/// [`comnum_core::Error::Shape`] if `r` is shorter than one symbol.
281pub fn cp_ml(r: &[C64], ofdm: &Ofdm, snr_lin: f64) -> Result<Estimate> {
282    let (n, l) = (ofdm.fft_len(), ofdm.oversampling * ofdm.n_cp);
283    ensure(l > 0, || "the estimator needs a cyclic prefix".into())?;
284    ensure(snr_lin > 0.0, || format!("the SNR must be positive, got {snr_lin}"))?;
285    ensure_shape(r.len() >= n + l, || format!("{} samples are shorter than one symbol of {}", r.len(), n + l))?;
286    let rho = snr_lin / (snr_lin + 1.0);
287    // P(θ) = Σ r*(k) r(k + N) = γ(θ)*
288    let p = delayed_correlation(r, n, l);
289    let e = windowed_energy(r, l);
290    let lambda = p.iter().enumerate().map(|(t, pt)| pt.norm() - rho * 0.5 * (e[t] + e[t + n]));
291    let (theta, peak) = comnum_math::argmax(lambda).unwrap_or((0, f64::NEG_INFINITY));
292    Ok(Estimate { timing: theta, cfo: p[theta].arg() / TAU, peak })
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    /// `r` rotated by an offset of `eps` subcarrier spacings on a grid of `n`.
300    fn offset(r: &[C64], eps: f64, n: usize) -> Vec<C64> {
301        let mut y = r.to_vec();
302        comnum_dsp::signal::rotate(&mut y, TAU * eps / n as f64, 0.0);
303        y
304    }
305
306    /// The offset was the phase of a correlation summed again after the metric, and the
307    /// peaks were found by folds, before the correlation of the metric and
308    /// `comnum_math::argmax`: the same timing and peak to the last bit, and the same offset
309    /// to the rounding of the windowed sums (computed in O(n) by block sums).
310    #[test]
311    fn synchronize_reuses_the_correlation_of_the_metric() {
312        let ofdm = Ofdm { n_cp: 16, ..Ofdm::new(128, 100).unwrap() };
313        let half = ofdm.fft_len() / 2;
314        let mut rng = Rng::new(12);
315        let mut rx: Vec<C64> = (0..300).map(|_| rng.complex_gaussian() * 0.3).collect();
316        rx.extend(preamble(&ofdm, 5).unwrap().iter().map(|v| v + rng.complex_gaussian() * 0.3));
317        rx.extend((0..ofdm.block_len()).map(|_| rng.complex_gaussian()));
318        let rx = offset(&rx, 0.17, ofdm.fft_len());
319        let e = synchronize(&rx, &ofdm).unwrap();
320        let m = schmidl_cox(&rx, half);
321        let (imax, peak) = m.iter().enumerate().fold((0, 0.0), |acc, (i, v)| if *v > acc.1 { (i, *v) } else { acc });
322        let (mut a, mut b) = (imax, imax);
323        while a > 0 && m[a - 1] >= 0.9 * peak {
324            a -= 1;
325        }
326        while b + 1 < m.len() && m[b + 1] >= 0.9 * peak {
327            b += 1;
328        }
329        let d = (a + b) / 2;
330        let p: C64 = (0..half).map(|k| rx[d + k].conj() * rx[d + k + half]).sum();
331        assert_eq!((e.timing, e.peak), (d, peak));
332        let cfo = p.arg() / std::f64::consts::PI;
333        assert!((e.cfo - cfo).abs() <= 1e-13 * cfo.abs(), "{} against {cfo}", e.cfo);
334    }
335
336    /// The direct sums of the former metric: the windowed sums of the shared kernel are
337    /// computed in O(n) by block prefix and suffix sums (`comnum_dsp::sync::frame`), the same
338    /// values to the rounding of the sums (bit for bit where a window starts a block).
339    #[test]
340    fn schmidl_cox_on_the_shared_kernel_is_the_former_metric() {
341        let mut rng = Rng::new(9);
342        let r: Vec<C64> = (0..300).map(|_| rng.complex_gaussian()).collect();
343        let former: Vec<f64> = (0..=r.len() - 2 * 64)
344            .map(|d| {
345                let p: C64 = (0..64).map(|m| r[d + m].conj() * r[d + m + 64]).sum();
346                let e: f64 = (0..64).map(|m| r[d + m + 64].norm_sqr()).sum();
347                if e > 0.0 { p.norm_sqr() / (e * e) } else { 0.0 }
348            })
349            .collect();
350        let m = schmidl_cox(&r, 64);
351        assert_eq!(m.len(), former.len());
352        for (d, (a, b)) in m.iter().zip(&former).enumerate() {
353            assert!((a - b).abs() <= 1e-13 * b, "d = {d}: {a} against {b}");
354            if d % 64 == 0 {
355                assert_eq!(a, b, "d = {d}");
356            }
357        }
358    }
359
360    #[test]
361    fn moose_and_the_cyclic_prefix_are_exact_without_noise() {
362        let ofdm = Ofdm { n_cp: 8, oversampling: 1, ..Ofdm::new(64, 52).unwrap() };
363        let n = ofdm.fft_len();
364        let p = repeated_preamble(&ofdm, 3).unwrap();
365        assert_eq!(p.len(), 8 + 2 * n);
366        for eps in [-0.45, -0.1, 0.0, 0.23, 0.49] {
367            assert!((moose(&offset(&p, eps, n), 8, &ofdm).unwrap() - eps).abs() < 1e-12, "{eps}");
368        }
369        // a symbol inside random samples: the prefix is found exactly
370        let mut rng = Rng::new(5);
371        let data: Vec<C64> = (0..52).map(|_| qpsk(&mut rng)).collect();
372        let mut r: Vec<C64> = (0..30).map(|_| rng.complex_gaussian() * 0.01).collect();
373        r.extend(ofdm.modulate(&data).unwrap());
374        r.extend((0..n + 8 - 30).map(|_| rng.complex_gaussian() * 0.01));
375        let e = cp_ml(&offset(&r, 0.2, n), &ofdm, 1e4).unwrap();
376        assert_eq!(e.timing, 30);
377        assert!((e.cfo - 0.2).abs() < 1e-3, "{}", e.cfo);
378        assert!(cp_ml(&r, &Ofdm { n_cp: 0, ..ofdm }, 10.0).is_err() && cp_ml(&r[..60], &ofdm, 10.0).is_err());
379        assert!(moose(&p, 9, &ofdm).is_err());
380    }
381
382    #[test]
383    fn the_interference_stays_below_moose_bound() {
384        // N = 256, 2K + 1 = 193 carriers (Moose, Sec. II)
385        let ofdm = Ofdm { oversampling: 1, ..Ofdm::new(256, 193).unwrap() };
386        for eps in [0.05, 0.25, 0.5] {
387            let s2 = (std::f64::consts::PI * eps).sin().powi(2);
388            let worst = (0..193).map(|i| ici_power(&ofdm, i, eps)).fold(0.0, f64::max);
389            assert!(worst <= MOOSE_ICI_BOUND * s2 && worst > 0.0, "{eps}: {worst}");
390        }
391        assert!(ici_power(&ofdm, 7, 0.0) < 1e-25); // zero up to the rounding of sin(πp)
392        assert!((cfo_snr_bound(100.0, 0.0) - 100.0).abs() < 1e-12);
393        assert!(cfo_snr_bound(f64::INFINITY, 0.1).is_finite());
394    }
395
396    #[test]
397    fn the_preamble_has_two_identical_halves_and_noise_hurts_gracefully() {
398        let ofdm = Ofdm { n_cp: 16, ..Ofdm::new(128, 100).unwrap() };
399        let p = preamble(&ofdm, 2).unwrap();
400        let cp = 4 * 16;
401        let half = ofdm.fft_len() / 2;
402        for i in 0..half {
403            assert!((p[cp + i] - p[cp + half + i]).norm() < 1e-12);
404        }
405        // at 10 dB the offset error is small
406        let mut rng = Rng::new(3);
407        let mut err = 0.0;
408        for t in 0..100 {
409            let mut rx: Vec<C64> = (0..200).map(|_| rng.complex_gaussian() * 0.1f64.sqrt()).collect();
410            let eps = -0.3 + 0.006 * f64::from(t);
411            rx.extend(p.iter().map(|v| v + rng.complex_gaussian() * 0.1f64.sqrt()));
412            // a data symbol follows the preamble
413            rx.extend((0..ofdm.block_len()).map(|_| rng.complex_gaussian() * 1.1f64.sqrt()));
414            let rx = offset(&rx, eps, ofdm.fft_len());
415            let e = synchronize(&rx, &ofdm).unwrap();
416            assert!(e.timing >= 200 && e.timing <= 200 + cp + 8, "timing {}", e.timing);
417            err += (e.cfo - eps).powi(2);
418        }
419        assert!((err / 100.0).sqrt() < 0.01);
420    }
421}