Skip to main content

comnum_waveform/css/
mod.rs

1//! Chirp spread spectrum: the frequency-shift chirp modulation of LoRa.
2//!
3//! # Model
4//!
5//! A symbol $a \in \{0, \dots, M - 1\}$, $M = 2^{\mathrm{SF}}$ with the spreading factor
6//! $\mathrm{SF}$, is a chirp of $M$ chips over the bandwidth $B$ whose initial frequency
7//! is shifted by $aB/M$ and folded at $B/2$. Its complex envelope over
8//! $0 \le t < T_{s} = M/B$ is (M. Chiani and A. Elzanaty, *IEEE Internet Things J.*
9//! 6(5), 2019, eq. (5))
10//!
11//! $$x(t; a) = \exp\left\\{j 2\pi B t \left(\frac{a}{M} - \frac{1}{2} + \frac{Bt}{2M} -
12//! u\left(t - \frac{M - a}{B}\right)\right)\right\\},$$
13//!
14//! a memoryless continuous-phase modulation. At one sample per chip the fold vanishes
15//! and $x_{a}\[n\] = e^{j2\pi(n^{2}/(2M) + (a/M - 1/2) n)}$ (their eq. (13); eq. (1) of
16//! Tapparel et al. 2020, eq. (2) of Afisiadis et al. 2020). [`Css`] samples it
17//! `oversampling` times per chip; [`Waveform`] also gives, for comparison, the discrete
18//! chirp of L. Vangelista, *IEEE Signal Process. Lett.* 24(12), 2017, eq. (3),
19//! $e^{j2\pi((a + n) \bmod M) n/M}$, whose quadratic phase lacks the factor $1/2$
20//! (Chiani and Elzanaty, Sec. II-A), and the FSK of the same cardinality of his eq. (17).
21//!
22//! The modulator tabulates the chirp of symbol 0 and the tones $e^{j 2\pi k/(JM)}$ once
23//! per call: the sample $n$ of symbol $a$ is the product of the base chirp, the tone
24//! $(a n) \bmod JM$ and, after the fold, the conjugate of the tone $(M n) \bmod JM$,
25//! within a few units of the last place of the direct evaluation of the phase.
26//!
27//! # Demodulation
28//!
29//! The receiver multiplies every symbol by the conjugate of the chirp of symbol 0 and
30//! takes a DFT (Vangelista, eq. (16)): the dechirped symbol $a$ is a tone in bin $a$
31//! (and, when oversampled, in bin $a - M$ after the fold), so the noncoherent decision
32//! is the bin of largest energy, the maximum-likelihood detection of these orthogonal
33//! signals with an unknown phase. [`theory`] gives its error rates, the spectrum and
34//! the effect of a same-SF interferer.
35//!
36//! # SNR
37//!
38//! The SNR is that of a sample at one sample per chip (the signal power over the noise
39//! power in the band $B$), the abscissa of the figures of Vangelista (2017), Afisiadis
40//! et al. (2020) and Tapparel et al. (2020); the SNR per symbol is $M$ times larger.
41//!
42//! # Trademarks and patents
43//!
44//! LoRa is a trademark of Semtech Corporation, and the modulation is covered by patents
45//! (F. Sforza, US 8,406,275, 2013; O. B. A. Seller and N. Sornin, US 9,252,834, 2016).
46//! This module models the published descriptions above for simulation and research; it
47//! is not the code of any vendor nor a certified implementation.
48//!
49//! # Verb traits
50//!
51//! [`Css`] implements `Modulate<Labels>` and `Demodulate<Labels>` of `comnum-core` on one
52//! stream of symbol indices ([`Labels`]), at its [`Css::sample_rate`] (1 Hz by default,
53//! time in samples), the demodulator refusing an input at another rate: the slice methods
54//! [`Css::modulate`] and [`Css::demodulate`] (the noncoherent decisions) inside. The soft
55//! output, [`Css::energies`], stays a method.
56//!
57//! # Examples
58//! ```
59//! use comnum_core::prelude::*;
60//! use comnum_waveform::css::Css;
61//! let css = Css::lora(7)?;
62//! let s = css.modulate(&[0, 5, 127])?;
63//! assert_eq!(s.len(), 3 * 128);
64//! assert!(s.iter().all(|v| (v.norm() - 1.0).abs() < 1e-12));   // constant envelope
65//! assert_eq!(css.demodulate(&s)?, vec![0, 5, 127]);
66//! // the same through the verb traits
67//! let mut css = Css { sample_rate: Hertz(125e3), ..css };
68//! let x = Modulate::modulate(&css, &Labels::from_stream(vec![0, 5, 127])?)?;
69//! assert_eq!((x.stream(0), x.sample_rate()), (&s[..], Hertz(125e3)));
70//! let a: Labels = Demodulate::demodulate(&mut css, &x)?;
71//! assert_eq!(a.stream(0), [0, 5, 127]);
72//! # Ok::<(), comnum_core::Error>(())
73//! ```
74
75pub mod theory;
76
77use crate::{check_input, check_rate};
78use comnum_core::signal::{Labels, Signal};
79use comnum_core::traits::{Demodulate, Modulate};
80use comnum_core::units::Hertz;
81use comnum_core::{Result, ensure, ensure_shape};
82use comnum_math::{C64, cis, fft};
83use std::f64::consts::TAU;
84
85/// The family of waveforms (see the module).
86#[non_exhaustive]
87#[derive(Clone, Copy, Debug, PartialEq, Eq)]
88pub enum Waveform {
89    /// The chirp of LoRa (Chiani and Elzanaty 2019, eq. (5)), at any oversampling.
90    LoRa,
91    /// The discrete chirp of Vangelista (2017), eq. (3), one sample per chip.
92    Vangelista,
93    /// The FSK of the same cardinality, Vangelista (2017), eq. (17), one sample per chip.
94    Fsk,
95}
96
97impl std::str::FromStr for Waveform {
98    type Err = comnum_core::Error;
99
100    /// `lora`, `vangelista` or `fsk` (case-insensitive).
101    ///
102    /// # Errors
103    /// [`comnum_core::Error::Config`] for another name.
104    fn from_str(s: &str) -> Result<Waveform> {
105        match s.to_ascii_lowercase().as_str() {
106            "lora" => Ok(Waveform::LoRa),
107            "vangelista" => Ok(Waveform::Vangelista),
108            "fsk" => Ok(Waveform::Fsk),
109            _ => Err(comnum_core::Error::Config(format!("the waveform must be lora, vangelista or fsk, got {s:?}"))),
110        }
111    }
112}
113
114/// A chirp spread-spectrum link (see the module): a configuration with public fields,
115/// built by [`Css::lora`] and changed by struct update, checked by [`Css::check`] when it
116/// is used.
117#[derive(Clone, Debug, PartialEq)]
118pub struct Css {
119    /// Spreading factor: `M = 2^SF` chips per symbol.
120    pub sf: u32,
121    /// Samples per chip (1: the chip rate `B`).
122    pub oversampling: usize,
123    /// The family of waveforms.
124    pub waveform: Waveform,
125    /// The sample rate of the signal of the verb traits, `J` times the chip rate `B`
126    /// (1 Hz by default: time in samples).
127    pub sample_rate: Hertz,
128}
129
130impl Css {
131    /// The LoRa waveform at one sample per chip, at 1 Hz.
132    ///
133    /// # Errors
134    /// As [`Css::check`]: the spreading factor lies in 1..=16.
135    ///
136    /// # Examples
137    /// ```
138    /// use comnum_waveform::css::Css;
139    /// let css = Css { oversampling: 4, ..Css::lora(7)? };
140    /// assert_eq!(css.symbol_len(), 4 * 128);
141    /// assert!(Css::lora(17).is_err());
142    /// # Ok::<(), comnum_core::Error>(())
143    /// ```
144    pub fn lora(sf: u32) -> Result<Css> {
145        let css = Css { sf, oversampling: 1, waveform: Waveform::LoRa, sample_rate: Hertz(1.0) };
146        css.check()?;
147        Ok(css)
148    }
149
150    /// The number of symbols (and of chips per symbol) `M = 2^SF`.
151    pub fn m(&self) -> usize {
152        1 << self.sf
153    }
154
155    /// Samples per symbol, `J M`.
156    pub fn symbol_len(&self) -> usize {
157        self.oversampling * self.m()
158    }
159
160    /// Check the configuration.
161    ///
162    /// # Errors
163    /// [`comnum_core::Error::Config`] unless the spreading factor lies in 1..=16, the
164    /// oversampling factor is at least 1 (exactly 1 for the FSK reference) and the sample
165    /// rate is positive and finite.
166    pub fn check(&self) -> Result<()> {
167        ensure((1..=16).contains(&self.sf), || format!("the spreading factor must lie in 1..=16, got {}", self.sf))?;
168        check_rate(self.sample_rate)?;
169        ensure(self.oversampling >= 1, || "the oversampling factor must be at least 1".into())?;
170        ensure(self.waveform == Waveform::LoRa || self.oversampling == 1, || {
171            format!("the {:?} waveform is defined at one sample per chip", self.waveform)
172        })
173    }
174
175    /// The phase (rad) of symbol `a` at the time `t` counted in chips, `0 ≤ t < M`.
176    fn phase(&self, a: usize, t: f64) -> f64 {
177        let m = self.m() as f64;
178        let a = a as f64;
179        match self.waveform {
180            Waveform::LoRa => {
181                let fold = if t >= m - a { 1.0 } else { 0.0 };
182                TAU * t * (a / m - 0.5 + t / (2.0 * m) - fold)
183            }
184            // e^{j2π((a + n) mod M) n/M} = e^{j2π(n² + a n)/M} at the integer n
185            Waveform::Vangelista => TAU * (t * t + a * t) / m,
186            Waveform::Fsk => TAU * a * t / m,
187        }
188    }
189
190    /// The `J M` samples of symbol `a`.
191    ///
192    /// # Errors
193    /// [`comnum_core::Error::Config`] for invalid parameters or `a ≥ M`.
194    pub fn symbol(&self, a: usize) -> Result<Vec<C64>> {
195        self.modulate(&[a])
196    }
197
198    /// The samples of a sequence of symbols (the chirps tabulated once, see the module).
199    ///
200    /// # Errors
201    /// [`comnum_core::Error::Config`] for invalid parameters or a symbol `≥ M`.
202    pub fn modulate(&self, symbols: &[usize]) -> Result<Vec<C64>> {
203        self.check()?;
204        let m = self.m();
205        if let Some(a) = symbols.iter().find(|&&a| a >= m) {
206            return Err(comnum_core::Error::Config(format!("symbol {a} does not lie below M = {m}")));
207        }
208        let chirps = Chirps::new(self);
209        let mut out = Vec::with_capacity(symbols.len() * self.symbol_len());
210        for &a in symbols {
211            chirps.extend_symbol(&mut out, a);
212        }
213        Ok(out)
214    }
215
216    /// The energy of the `M` candidate symbols of every received symbol: the squared
217    /// magnitudes of the DFT of the dechirped samples, bins `a` and, when oversampled,
218    /// `a - M` (after the fold) added.
219    ///
220    /// # Errors
221    /// [`comnum_core::Error::Config`] for invalid parameters, [`comnum_core::Error::Shape`] for
222    /// a number of samples that is not a multiple of `J M`.
223    pub fn energies(&self, r: &[C64]) -> Result<Vec<f64>> {
224        self.check()?;
225        let (m, l) = (self.m(), self.symbol_len());
226        ensure_shape(r.len() % l == 0, || format!("{} samples is not a multiple of J M = {l}", r.len()))?;
227        let down: Vec<C64> = Chirps::new(self).base.iter().map(C64::conj).collect();
228        let mut out = Vec::with_capacity(r.len() / l * m);
229        for block in r.chunks(l) {
230            let y = fft::fft(&block.iter().zip(&down).map(|(a, b)| a * b).collect::<Vec<_>>());
231            if self.oversampling == 1 {
232                out.extend(y.iter().map(C64::norm_sqr));
233            } else {
234                out.extend((0..m).map(|k| y[k].norm_sqr() + y[(k + l - m) % l].norm_sqr()));
235            }
236        }
237        Ok(out)
238    }
239
240    /// The noncoherent decisions: the candidate of largest energy in every symbol.
241    ///
242    /// # Errors
243    /// As [`Css::energies`].
244    pub fn demodulate(&self, r: &[C64]) -> Result<Vec<usize>> {
245        let m = self.m();
246        Ok(self.energies(r)?.chunks(m).map(|e| comnum_math::argmax(e.iter().copied()).map_or(0, |(k, _)| k)).collect())
247    }
248
249    /// One symbol of an interferer of the same spreading factor, not synchronised: the
250    /// end of its symbol `s1` then the start of its symbol `s2`, which begins `tau` chips
251    /// after the symbol of interest, `0 ≤ tau < M` (Afisiadis et al. 2020, eq. (21)), at
252    /// one sample per chip.
253    ///
254    /// # Errors
255    /// [`comnum_core::Error::Config`] for another waveform than LoRa at one sample per chip,
256    /// symbols `≥ M` or `tau` outside `[0, M)`.
257    pub fn interferer(&self, s1: usize, s2: usize, tau: f64) -> Result<Vec<C64>> {
258        self.check()?;
259        let m = self.m();
260        ensure(self.waveform == Waveform::LoRa && self.oversampling == 1, || {
261            "the interferer is a LoRa signal at one sample per chip".into()
262        })?;
263        ensure(s1 < m && s2 < m, || format!("the symbols must lie below M = {m}"))?;
264        ensure((0.0..m as f64).contains(&tau), || format!("the offset must lie in [0, {m}), got {tau}"))?;
265        let mf = m as f64;
266        let x = |t: f64, s: usize| cis(TAU * (t * t / (2.0 * mf) + t * (s as f64 / mf - 0.5)));
267        Ok((0..m)
268            .map(|n| {
269                let n = n as f64;
270                if n < tau.ceil() { x(n + mf - tau, s1) } else { x(n - tau, s2) }
271            })
272            .collect())
273    }
274}
275
276/// CSS as a [`Modulate`] block: one stream of symbol indices into one stream of samples
277/// at [`Css::sample_rate`], by [`Css::modulate`].
278impl Modulate<Labels> for Css {
279    /// # Errors
280    /// [`comnum_core::Error::Config`] for several streams, and the errors of
281    /// [`Css::modulate`].
282    fn modulate_into(&self, x: &Labels, out: &mut Signal) -> Result<()> {
283        ensure(x.n_streams() == 1, || format!("CSS modulates one stream, not {}", x.n_streams()))?;
284        out.set_stream(&Css::modulate(self, x.stream(0))?, self.sample_rate);
285        Ok(())
286    }
287}
288
289/// CSS as a [`Demodulate`] block: the noncoherent decisions of [`Css::demodulate`] on one
290/// stream of samples at [`Css::sample_rate`].
291impl Demodulate<Labels> for Css {
292    /// # Errors
293    /// [`comnum_core::Error::Config`] for several streams or a signal at another sample rate,
294    /// and the errors of [`Css::demodulate`].
295    fn demodulate_into(&mut self, y: &Signal, out: &mut Labels) -> Result<()> {
296        check_input(y, self.sample_rate, "CSS")?;
297        out.set_stream(&Css::demodulate(self, y.stream(0))?);
298        Ok(())
299    }
300}
301
302/// The tables of [`Css::modulate`]: the chirp of symbol 0 and the tones
303/// $e^{j 2\pi k/(JM)}$, $k < JM$.
304struct Chirps {
305    /// The `J M` samples of symbol 0, $e^{j\varphi(0, n/J)}$.
306    base: Vec<C64>,
307    /// $e^{j 2\pi k/(JM)}$.
308    tone: Vec<C64>,
309    m: usize,
310    j: usize,
311    waveform: Waveform,
312}
313
314impl Chirps {
315    /// The tables of a checked configuration.
316    fn new(css: &Css) -> Chirps {
317        let (m, j, l) = (css.m(), css.oversampling, css.symbol_len());
318        let jf = j as f64;
319        let base = (0..l).map(|n| cis(css.phase(0, n as f64 / jf))).collect();
320        let tone = (0..l).map(|k| cis(TAU * k as f64 / l as f64)).collect();
321        Chirps { base, tone, m, j, waveform: css.waveform }
322    }
323
324    /// Append the samples of symbol `a < M`: $x_{a}\[n\] = x_{0}\[n\]\, e^{j 2\pi a n/(JM)}$,
325    /// times $e^{-j 2\pi n / J}$ after the fold of LoRa ($n \ge J(M - a)$); the FSK
326    /// has the base 1.
327    fn extend_symbol(&self, out: &mut Vec<C64>, a: usize) {
328        let l = self.base.len();
329        let fold = self.j * (self.m - a);
330        out.extend(self.base.iter().enumerate().map(|(n, b)| {
331            let v = b * self.tone[(a * n) % l];
332            if self.waveform == Waveform::LoRa && n >= fold { v * self.tone[(self.m * n) % l].conj() } else { v }
333        }));
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    /// The verb traits give the samples and the decisions of the slice methods, bit for
342    /// bit, at the sample rate of the configuration; an input at another rate or with two
343    /// streams is refused.
344    #[test]
345    fn the_verb_traits_are_the_slice_methods() {
346        let mut rng = comnum_core::rng::Rng::new(7);
347        for (sf, oversampling, waveform) in
348            [(7u32, 1usize, Waveform::LoRa), (8, 4, Waveform::LoRa), (6, 1, Waveform::Fsk)]
349        {
350            let mut css = Css { sf, oversampling, waveform, sample_rate: Hertz(250e3) };
351            let a: Vec<usize> = (0..50).map(|_| rng.below(css.m())).collect();
352            let s = css.modulate(&a).unwrap();
353            let mut x = Signal::zeros(0, 0, Hertz(1.0));
354            css.modulate_into(&Labels::from_stream(a.clone()).unwrap(), &mut x).unwrap();
355            assert_eq!((x.stream(0), x.sample_rate()), (&s[..], Hertz(250e3)));
356            // a noisy signal: the decisions of the trait are those of the method
357            let noisy: Vec<C64> = s.iter().map(|v| v + C64::new(rng.gaussian(), rng.gaussian()) * 3.0).collect();
358            let y = Signal::from_stream(noisy.clone(), Hertz(250e3)).unwrap();
359            let mut out = Labels::default();
360            css.demodulate_into(&y, &mut out).unwrap();
361            assert_eq!(out.stream(0), css.demodulate(&noisy).unwrap());
362            assert!(css.demodulate_into(&Signal::from_stream(noisy, Hertz(1.0)).unwrap(), &mut out).is_err());
363            assert!(Modulate::modulate(&css, &Labels::zeros(2, 4)).is_err());
364        }
365        assert!(Css { sample_rate: Hertz(0.0), ..Css::lora(7).unwrap() }.check().is_err());
366    }
367
368    /// The chirps were computed from their phase, sample after sample and symbol after
369    /// symbol, before they were tabulated: the same samples within $10^{-11}$ up to
370    /// SF 12, symbol 0 exactly. Against the phase reduced exactly modulo
371    /// $2\pi$ (an integer numerator over $2M$, LoRa at one sample per chip), the
372    /// tabulated chirps are the closer: the direct evaluation carries the rounding of a
373    /// phase of up to $\pi M$ rad ($4.2 \cdot 10^{-12}$ at SF 12, $6.3 \cdot 10^{-13}$
374    /// tabulated).
375    #[test]
376    fn the_tabulated_chirps_are_the_direct_ones() {
377        for (sf, oversampling, waveform) in [
378            (7u32, 1usize, Waveform::LoRa),
379            (7, 4, Waveform::LoRa),
380            (12, 1, Waveform::LoRa),
381            (9, 8, Waveform::LoRa),
382            (7, 1, Waveform::Vangelista),
383            (10, 1, Waveform::Vangelista),
384            (7, 1, Waveform::Fsk),
385        ] {
386            let css = Css { sf, oversampling, waveform, sample_rate: Hertz(1.0) };
387            let m = css.m();
388            let jf = oversampling as f64;
389            let symbols: Vec<usize> = (0..m).step_by((m / 64).max(1)).chain([m - 1]).collect();
390            let fast = css.modulate(&symbols).unwrap();
391            for (i, &a) in symbols.iter().enumerate() {
392                let direct: Vec<C64> = (0..css.symbol_len()).map(|n| cis(css.phase(a, n as f64 / jf))).collect();
393                let got = &fast[i * css.symbol_len()..(i + 1) * css.symbol_len()];
394                let err = got.iter().zip(&direct).map(|(x, y)| (x - y).norm()).fold(0.0, f64::max);
395                assert!(err < 1e-11, "SF {sf} J {oversampling} {waveform:?} symbol {a}: {err}");
396                assert!(a != 0 || err == 0.0, "{waveform:?} symbol 0: {err}");
397            }
398        }
399        assert!(Css::lora(7).unwrap().modulate(&[3, 128]).is_err());
400        for sf in [7u32, 10, 12] {
401            let css = Css::lora(sf).unwrap();
402            let m = css.m();
403            let (mut direct, mut tabulated) = (0.0f64, 0.0f64);
404            for a in (0..m).step_by(m / 32) {
405                let fast = css.symbol(a).unwrap();
406                for (n, v) in fast.iter().enumerate() {
407                    // 2π (n² + (2a - M) n - 2 M n [n ≥ M - a]) / (2M), the numerator reduced
408                    let fold = usize::from(n >= m - a);
409                    let num = (n * n + 2 * a * n + 4 * m * n - m * n - 2 * m * n * fold) % (2 * m);
410                    let exact = cis(TAU * num as f64 / (2 * m) as f64);
411                    direct = direct.max((cis(css.phase(a, n as f64)) - exact).norm());
412                    tabulated = tabulated.max((v - exact).norm());
413                }
414            }
415            assert!(tabulated < direct, "SF {sf}: tabulated {tabulated:e}, direct {direct:e}");
416        }
417    }
418
419    #[test]
420    fn the_waveforms_are_orthogonal_and_the_fft_receiver_is_the_correlator_bank() {
421        // Vangelista 2017, eqs. (9) and (16); Chiani and Elzanaty 2019, Property 3
422        for waveform in [Waveform::LoRa, Waveform::Vangelista, Waveform::Fsk] {
423            let css = Css { sf: 5, oversampling: 1, waveform, sample_rate: Hertz(1.0) };
424            let m = css.m();
425            let x: Vec<Vec<C64>> = (0..m).map(|a| css.symbol(a).unwrap()).collect();
426            for i in 0..m {
427                for q in 0..m {
428                    let c: C64 = x[i].iter().zip(&x[q]).map(|(a, b)| a * b.conj()).sum::<C64>() / m as f64;
429                    let want = if i == q { 1.0 } else { 0.0 };
430                    assert!((c - want).norm() < 1e-12, "{waveform:?}: <{i}, {q}> = {c}");
431                }
432            }
433            // a received block: the DFT receiver gives the energies of the correlators
434            let r: Vec<C64> = (0..m).map(|n| C64::new((0.3 * n as f64).sin(), (1.7 * n as f64).cos())).collect();
435            let e = css.energies(&r).unwrap();
436            for (q, xq) in x.iter().enumerate() {
437                let c: C64 = r.iter().zip(xq).map(|(a, b)| a * b.conj()).sum();
438                assert!((c.norm_sqr() - e[q]).abs() < 1e-9, "{waveform:?}: bin {q}");
439            }
440        }
441    }
442
443    #[test]
444    fn lora_is_a_continuous_phase_chirp_sampled_at_any_rate() {
445        // Chiani and Elzanaty 2019, Property 1: the phase starts and ends at 0 (mod 2π)
446        let css = Css { sf: 6, oversampling: 8, waveform: Waveform::LoRa, sample_rate: Hertz(1.0) };
447        let m = css.m() as f64;
448        for a in [0usize, 1, 17, 63] {
449            let end = css.phase(a, m - 1e-9).rem_euclid(TAU);
450            assert!(end.min(TAU - end) < 1e-6, "symbol {a}: {end}");
451            // the instantaneous frequency rises by B/M per chip and folds once
452            let x = css.symbol(a).unwrap();
453            let df: Vec<f64> = x.windows(2).map(|w| (w[1] * w[0].conj()).arg() * 8.0 / TAU).collect();
454            assert!((df[0] - (a as f64 / m - 0.5)).abs() < 0.02, "{a}: {}", df[0]);
455        }
456        // oversampled, the receiver still decides right without noise
457        let s = css.modulate(&[3, 0, 63, 40]).unwrap();
458        assert_eq!(css.demodulate(&s).unwrap(), vec![3, 0, 63, 40]);
459        // at one sample per chip it is eq. (13)
460        let one = Css::lora(6).unwrap();
461        let x = one.symbol(9).unwrap();
462        for (n, v) in x.iter().enumerate() {
463            let n = n as f64;
464            let want = cis(TAU * n * (9.0 / 64.0 - 0.5 + n / 128.0));
465            assert!((v - want).norm() < 1e-9);
466        }
467        assert!(Css { sf: 6, oversampling: 2, waveform: Waveform::Fsk, sample_rate: Hertz(1.0) }.symbol(0).is_err());
468        assert!(one.symbol(64).is_err() && Css::lora(0).is_err());
469    }
470
471    #[test]
472    fn the_interferer_is_the_lora_signal_shifted_by_an_integer_offset() {
473        // for an integer τ, eq. (21) is the stream of the two symbols delayed by τ chips
474        let css = Css::lora(5).unwrap();
475        let stream = css.modulate(&[7, 20]).unwrap();
476        for tau in [0usize, 1, 13, 31] {
477            let y = css.interferer(7, 20, tau as f64).unwrap();
478            for (n, v) in y.iter().enumerate() {
479                assert!((v - stream[n + 32 - tau]).norm() < 1e-9, "τ = {tau}, n = {n}");
480            }
481        }
482        assert!(css.interferer(0, 0, 32.0).is_err());
483    }
484}