Skip to main content

comnum_waveform/css/
theory.rs

1//! The theory of chirp spread spectrum: error rates, cross-correlation, spectrum and
2//! same-SF interference.
3//!
4//! # Error rates over AWGN
5//!
6//! After dechirping and the DFT, the bin of the transmitted symbol has the magnitude
7//! $M$ and every bin the noise $\mathcal{CN}(0, M/\rho)$, $\rho$ the SNR per sample.
8//! Normalised, the $M - 1$ other bins are Rayleigh and the bin of the symbol Rice with
9//! the location $v = \sqrt{2M\rho}$, so the noncoherent decision errs with the
10//! probability (O. Afisiadis, M. Cotting, A. Burg and A. Balatsoukas-Stimming, *IEEE
11//! Trans. Wireless Commun.* 19(2), 2020, eq. (16))
12//!
13//! $$P_{s} = \int_{0}^{\infty} f_{\mathrm{Ri}}(y; v, 1)\\,\left[1 - \left(1 -
14//! e^{-y^{2}/2}\right)^{M-1}\right] dy$$
15//!
16//! ([`ser`]), that of $M$ orthogonal signals detected noncoherently; every wrong
17//! symbol being equally likely, the BER is $P_{s} M/(2(M - 1))$ ([`ber`]). [`ser_rice`]
18//! and [`ser_gumbel`] are the approximations of their eqs. (17)–(18), after T.
19//! Elshabrawy and J. Robert, *IEEE Commun. Lett.* 22(9), 2018.
20//!
21//! # Waveforms and spectrum
22//!
23//! [`cross_correlation`] is the correlation of two continuous-time LoRa waveforms, eq.
24//! (7) of M. Chiani and A. Elzanaty, *IEEE Internet Things J.* 6(5), 2019: they are
25//! orthogonal only at one sample per chip. [`continuous_spectrum`] and
26//! [`spectral_lines`] are the two parts of the power spectrum of LoRa modulated by
27//! random symbols, their eqs. (20)–(25) in Fresnel functions; the lines hold exactly
28//! $1/M$ of the power (their Property 4) and [`occupied_bandwidth`] is the 99 % band of
29//! their Table I.
30//!
31//! # Same-SF interference
32//!
33//! An interferer of power $P_{I}$ offset by $\tau$ chips leaves in bin $k$ the pattern
34//! $R_{k}$ ([`interference_pattern`], the DFT of eq. (25) of Afisiadis et al.), of the
35//! magnitude of their eqs. (38)–(43) ([`interference_magnitudes`]). [`ser_interference`]
36//! and [`fer_interference`] are their approximations (61)–(63) and (64)–(67).
37
38use super::Css;
39use comnum_core::Result;
40use comnum_math::{C64, cis, db2lin, fft, fresnel, qfunc};
41use std::f64::consts::{PI, TAU};
42
43/// The number of chirps $M = 2^{\mathrm{SF}}$ of the spreading factor `sf`.
44///
45/// # Errors
46/// As [`Css::check`]: the spreading factor lies in 1..=16.
47fn chips(sf: u32) -> Result<usize> {
48    Css::lora(sf)?;
49    Ok(1 << sf)
50}
51
52/// The exact SER of the noncoherent receiver over AWGN at the SNR per sample `snr_db`
53/// (dB), spreading factor `sf` (see the module): the SER of $M$ orthogonal signals
54/// ([`comnum_mapping::theory::ser_orthogonal_noncoherent`]) at $E_{s}/N_{0} = M\rho$.
55///
56/// # Errors
57/// [`comnum_core::Error::Config`] unless the spreading factor lies in 1..=16.
58///
59/// # Examples
60/// ```
61/// use comnum_waveform::css::theory::ser;
62/// // SF 7: 1.6e-3 at -8 dB and 1.4e-4 at -7 dB (Fig. 2 of Afisiadis et al. 2020)
63/// assert!((ser(7, -8.0)? / 1.61e-3 - 1.0).abs() < 0.01);
64/// assert!((ser(7, -7.0)? / 1.43e-4 - 1.0).abs() < 0.01);
65/// # Ok::<(), comnum_core::Error>(())
66/// ```
67pub fn ser(sf: u32, snr_db: f64) -> Result<f64> {
68    let m = chips(sf)?;
69    comnum_mapping::theory::ser_orthogonal_noncoherent(m, snr_db + comnum_math::lin2db(m as f64))
70}
71
72/// The BER of the noncoherent receiver over AWGN at the SNR per sample `snr_db` (dB):
73/// [`ser`]` M/(2(M - 1))`, every wrong symbol being equally likely
74/// ([`comnum_mapping::theory::ber_orthogonal_noncoherent`] at $E_{s}/N_{0} = M\rho$).
75///
76/// # Errors
77/// As [`ser`].
78pub fn ber(sf: u32, snr_db: f64) -> Result<f64> {
79    let m = chips(sf)?;
80    comnum_mapping::theory::ber_orthogonal_noncoherent(m, snr_db + comnum_math::lin2db(m as f64))
81}
82
83/// The harmonic number `H_n`.
84fn harmonic(n: usize) -> f64 {
85    (1..=n).map(|k| 1.0 / k as f64).sum()
86}
87
88/// The approximation of the SER of eq. (17) of Afisiadis et al. (2020), after Elshabrawy
89/// and Robert (2018), the magnitudes taken as Gaussian, at the SNR per sample `snr_db`.
90/// As printed, the formula reads with the SNR per symbol $M\rho$ in place of its
91/// "SNR"; with it, it follows the figure, whose abscissa is the SNR per sample.
92///
93/// # Errors
94/// As [`ser`].
95pub fn ser_rice(sf: u32, snr_db: f64) -> Result<f64> {
96    let m = chips(sf)?;
97    let h = harmonic(m - 1);
98    let d = (h * h - PI * PI / 12.0).sqrt();
99    let gamma = m as f64 * db2lin(snr_db);
100    Ok(qfunc((gamma.sqrt() - d.sqrt()) / (h - d + 0.5).sqrt()))
101}
102
103/// The concise approximation of eq. (18) of Afisiadis et al. (2020),
104/// $Q(\sqrt{2M\rho} - \sqrt{2(\ln 2 \cdot \mathrm{SF} + \gamma_{\mathrm{EM}})})$, at the SNR
105/// per sample $\rho$ (`snr_db`), $\gamma_{\mathrm{EM}}$ Euler's constant.
106///
107/// # Errors
108/// As [`ser`].
109pub fn ser_gumbel(sf: u32, snr_db: f64) -> Result<f64> {
110    const EULER: f64 = 0.577_215_664_901_532_9;
111    let gamma = chips(sf)? as f64 * db2lin(snr_db);
112    Ok(qfunc((2.0 * gamma).sqrt() - (2.0 * (std::f64::consts::LN_2 * f64::from(sf) + EULER)).sqrt()))
113}
114
115/// The cross-correlation $C_{\ell, m} = \frac{1}{T_{s}}\int_{0}^{T_{s}} x(t; \ell)\\, x^{*}(t;
116/// m)\\, dt$ of two continuous-time LoRa waveforms (Chiani and Elzanaty 2019, eq. (7)):
117/// $1$ for $\ell = m$, else
118/// $M \frac{e^{j2\pi\ell(m - \ell)/M} - e^{j2\pi m(m - \ell)/M}}{j2\pi(M - |m - \ell|)\\,|m - \ell|}$.
119///
120/// # Errors
121/// As [`ser`], and [`comnum_core::Error::Config`] unless both symbols lie below $M$.
122pub fn cross_correlation(sf: u32, l: usize, m_sym: usize) -> Result<C64> {
123    let m = chips(sf)?;
124    comnum_core::ensure(l < m && m_sym < m, || format!("the symbols {l} and {m_sym} must lie below M = {m}"))?;
125    Ok(correlation(m as f64, l, m_sym))
126}
127
128/// [`cross_correlation`] for $M$ chips (`m` as a float) and valid symbols.
129fn correlation(m: f64, l: usize, m_sym: usize) -> C64 {
130    if l == m_sym {
131        return C64::new(1.0, 0.0);
132    }
133    let (l, q) = (l as f64, m_sym as f64);
134    let d = (q - l).abs();
135    let num = cis(TAU * l * (q - l) / m) - cis(TAU * q * (q - l) / m);
136    num * m / (C64::new(0.0, TAU) * (m - d) * d)
137}
138
139/// The largest real cross-correlation $\max_{\ell \ne m} \Re C_{\ell, m}$ of the
140/// continuous-time waveforms (Chiani and Elzanaty 2019, Table I), which costs the
141/// coherent receiver at most $-10 \log_{10}(1 - \max \Re C)$ dB against orthogonal
142/// signals.
143///
144/// # Errors
145/// As [`ser`].
146pub fn max_cross_correlation(sf: u32) -> Result<f64> {
147    let m = chips(sf)?;
148    // C_{l,q} depends on l through e^{j2πl(q-l)/M}: scan every pair
149    let mut best = f64::NEG_INFINITY;
150    for l in 0..m {
151        for q in 0..m {
152            if l != q {
153                best = best.max(correlation(m as f64, l, q).re);
154            }
155        }
156    }
157    Ok(best)
158}
159
160/// $K(x) = C(x) + j S(x)$, the Fresnel integrals.
161fn fresnel_k(x: f64) -> C64 {
162    let (c, s) = fresnel(x);
163    C64::new(c, s)
164}
165
166/// The parts of the Fourier transform $X(f; \ell)$ of the waveform of symbol $\ell$
167/// (Chiani and Elzanaty 2019, eq. (25)) at one frequency `f` (units of $B$, time in
168/// chips) that every symbol shares. Each of the two chirp segments of eq. (25) is
169/// $\int e^{j2\pi(a t + b t^{2})} dt$ in Fresnel functions (their eq. (24)), $b = 1/(2M)$;
170/// two of their four arguments do not depend on $\ell$ and the third is common to both:
171///
172/// $$
173/// X(f; \ell) = \sqrt{\frac{M}{2}}\\, e^{-j\pi(\ell - c)^{2}/M} \left[K(\beta_{+}) - w
174/// K(\beta_{-}) + (w - 1) K\left(\sqrt{2/M}\\,(\ell - c)\right)\right],
175/// $$
176///
177/// with $c = M/2 + fM$, $\beta_{\pm} = \sqrt{2/M}\\,(\pm M/2 - fM)$ and
178/// $w = e^{-j 2\pi f M}$: one Fresnel evaluation per symbol instead of four.
179struct Shared {
180    /// $M$.
181    m: f64,
182    /// $c = M/2 + fM$.
183    c: f64,
184    /// $K(\beta_{+}) - w K(\beta_{-})$.
185    d: C64,
186    /// $w - 1$.
187    w1: C64,
188    /// $\sqrt{M/2}$.
189    root: f64,
190}
191
192/// $e^{-j\pi x^{2}/M}$, the phase of $X(f; \ell)$ at $x = \ell - c$.
193fn chirp_phase(m: usize, x: f64) -> C64 {
194    cis(-PI * x * x / m as f64)
195}
196
197impl Shared {
198    fn new(m: usize, f: f64) -> Shared {
199        let m = m as f64;
200        let s = (2.0 / m).sqrt();
201        let w = cis(-TAU * f * m);
202        let d = fresnel_k(s * (m / 2.0 - f * m)) - w * fresnel_k(s * (-m / 2.0 - f * m));
203        Shared { m, c: m / 2.0 + f * m, d, w1: w - 1.0, root: (m / 2.0).sqrt() }
204    }
205
206    /// $X(f; \ell)$ from the phase $e^{-j\pi x^{2}/M}$ ([`chirp_phase`]) and
207    /// $K(\sqrt{2/M}\\, x)$ of $x = \ell - c$.
208    fn spectrum(&self, phase: C64, k: C64) -> C64 {
209        phase * (self.d + self.w1 * k) * self.root
210    }
211
212    /// $G^{c}(f) B = \frac{1}{T_{s} M} (\sum_{\ell} |X(f; \ell)|^{2} - |\sum_{\ell} X(f;
213    /// \ell)|^{2}/M)$ (eq. (20)) from the terms $X(f; \ell)$, with $T_{s} = M$ chips and
214    /// $B = 1$.
215    fn density(&self, terms: impl Iterator<Item = C64>) -> f64 {
216        let (energy, sum) = terms.fold((0.0, C64::new(0.0, 0.0)), |(e, s), x| (e + x.norm_sqr(), s + x));
217        (energy - sum.norm_sqr() / self.m) / (self.m * self.m)
218    }
219}
220
221/// The continuous part of the power spectrum of LoRa modulated by independent uniform
222/// symbols, normalised as $G^{c}(f) B$, at the frequencies `f` in units of $B$ (Chiani
223/// and Elzanaty 2019, eqs. (20) and (25)); its integral is $1 - 1/M$.
224///
225/// Eq. (20) sums over the $M$ symbols: each frequency costs $M + 2$ Fresnel evaluations
226/// (two of the four arguments of eq. (25) are shared by every symbol), $O(M)$.
227///
228/// # Errors
229/// As [`ser`].
230pub fn continuous_spectrum(sf: u32, f: &[f64]) -> Result<Vec<f64>> {
231    let m = chips(sf)?;
232    let s = (2.0 / m as f64).sqrt();
233    Ok(f.iter()
234        .map(|&fk| {
235            let shared = Shared::new(m, fk);
236            shared.density((0..m).map(|l| {
237                let x = l as f64 - shared.c;
238                shared.spectrum(chirp_phase(m, x), fresnel_k(s * x))
239            }))
240        })
241        .collect())
242}
243
244/// The continuous spectrum at the midpoints $f_{i} = (i + 1/2)/(8M)$, $i < n$, of the
245/// grid of [`occupied_bandwidth`]: there $\ell - c = (8\ell - 4M - i - 1/2)/8$ depends on
246/// $8\ell - i$ only, so the Fresnel function and the phase of eq. (25) are tabulated
247/// once on the $8(M - 1) + n$ values of $8\ell - i$, and each of the $nM$ terms costs
248/// three complex products.
249fn continuous_spectrum_grid(m: usize, n: usize) -> Vec<f64> {
250    let s = (2.0 / m as f64).sqrt();
251    let mf = m as f64;
252    // index j = 8ℓ - i + n - 1 ∈ [0, 8(M - 1) + n - 1]
253    let table: Vec<(C64, C64)> = (0..8 * (m - 1) + n)
254        .map(|j| {
255            let x = (j as f64 - (n - 1) as f64 - 4.0 * mf - 0.5) / 8.0;
256            (chirp_phase(m, x), fresnel_k(s * x))
257        })
258        .collect();
259    (0..n)
260        .map(|i| {
261            let shared = Shared::new(m, (i as f64 + 0.5) / (8.0 * mf));
262            shared.density((0..m).map(|l| {
263                let (phase, k) = table[8 * l + n - 1 - i];
264                shared.spectrum(phase, k)
265            }))
266        })
267        .collect()
268}
269
270/// The lines of the power spectrum, `(f, P)` at $f = nB/M$ for $|n| \le$ `n_max` (in
271/// units of $B$), $P = |\sum_{\ell} X(nB/M; \ell)|^{2}/(T_{s}^{2} M^{2})$ (Chiani and
272/// Elzanaty 2019, eq. (21)); they hold $1/M$ of the power (Property 4).
273///
274/// At $f = n/M$, $w = 1$ and the sum over the symbols is the quadratic Gauss sum
275/// $\sum_{\ell = 0}^{M - 1} e^{-j\pi(\ell - c)^{2}/M} = \sqrt{M} e^{-j\pi/4}$ ($c$ an
276/// integer, $M$ even), so that
277///
278/// $$
279/// P_{n} = \frac{1}{2M^{2}} \left|K\left(\sqrt{2/M}\\,(M/2 - n)\right) +
280/// K\left(\sqrt{2/M}\\,(M/2 + n)\right)\right|^{2},
281/// $$
282///
283/// two Fresnel evaluations per line.
284///
285/// # Errors
286/// As [`ser`].
287pub fn spectral_lines(sf: u32, n_max: usize) -> Result<Vec<(f64, f64)>> {
288    let m = chips(sf)?;
289    let mf = m as f64;
290    let s = (2.0 / mf).sqrt();
291    let n_max = n_max as i64;
292    Ok((-n_max..=n_max)
293        .map(|n| {
294            let n = n as f64;
295            let d = fresnel_k(s * (mf / 2.0 - n)) + fresnel_k(s * (mf / 2.0 + n));
296            (n / mf, d.norm_sqr() / (2.0 * mf * mf))
297        })
298        .collect())
299}
300
301/// The band $[-z, z]$ (in units of $B$, returned as $2z$) of the complex envelope that
302/// holds the fraction `fraction` of the power, lines included (Chiani and Elzanaty 2019,
303/// Table I, $B_{99}$ for 0.99), from the spectrum on a grid of $B/(8M)$ up to
304/// `span` $B$ on each side.
305///
306/// The $8M \cdot$ `span` points of the grid cost $O(M^{2}\\,\mathrm{span})$ products
307/// and $O(M\\,\mathrm{span})$ Fresnel evaluations (0.13 s at SF 12 and a span of $0.6B$).
308///
309/// # Errors
310/// As [`ser`], and [`comnum_core::Error::Config`] unless `fraction` lies in (0, 1) and
311/// `span` is positive.
312pub fn occupied_bandwidth(sf: u32, fraction: f64, span: f64) -> Result<f64> {
313    let m = chips(sf)?;
314    comnum_core::ensure(fraction > 0.0 && fraction < 1.0 && span > 0.0, || {
315        format!("a fraction in (0, 1) and a positive span, got {fraction} and {span}")
316    })?;
317    let step = 1.0 / (8.0 * m as f64);
318    let n = (span / step).ceil() as usize;
319    // the spectrum is even: integrate 0 ≤ f ≤ span by the midpoint rule
320    let g = continuous_spectrum_grid(m, n);
321    let mut lines = spectral_lines(sf, (span * m as f64).ceil() as usize)?;
322    // P(|f| ≤ z) on the grid of the cells
323    let mut inside = lines.iter().filter(|(fl, _)| *fl == 0.0).map(|l| l.1).sum::<f64>();
324    // the lines of the cell (z - step, z]: a window of the lines sorted by |f|
325    lines.sort_by(|a, b| a.0.abs().total_cmp(&b.0.abs()));
326    let (mut lo, mut hi) = (0, 0);
327    let mut prev = (0.0, inside);
328    for (i, gi) in g.iter().enumerate() {
329        let z = (i + 1) as f64 * step;
330        inside += 2.0 * gi * step;
331        while lo < lines.len() && lines[lo].0.abs() <= z - step {
332            lo += 1;
333        }
334        hi = hi.max(lo);
335        while hi < lines.len() && lines[hi].0.abs() <= z {
336            hi += 1;
337        }
338        inside += lines[lo..hi].iter().map(|l| l.1).sum::<f64>();
339        if inside >= fraction {
340            let (z0, p0) = prev;
341            return Ok(2.0 * (z0 + (z - z0) * (fraction - p0) / (inside - p0)));
342        }
343        prev = (z, inside);
344    }
345    Ok(2.0 * span)
346}
347
348/// The pattern $R_{k} = \mathrm{DFT}(x_{I} \odot x_{\mathrm{ref}}^{*})\[k\]$, $k = 0 \dots M -
349/// 1$, that an interferer of symbols `s1`, `s2` offset by `tau` chips leaves after the
350/// dechirping (Afisiadis et al. 2020, eqs. (21) and (25)).
351///
352/// # Errors
353/// As [`Css::interferer`].
354pub fn interference_pattern(sf: u32, s1: usize, s2: usize, tau: f64) -> Result<Vec<C64>> {
355    let css = Css::lora(sf)?;
356    let x = css.interferer(s1, s2, tau)?;
357    let reference = css.symbol(0)?;
358    Ok(fft::fft(&x.iter().zip(&reference).map(|(a, b)| a * b.conj()).collect::<Vec<_>>()))
359}
360
361/// The magnitudes $|R_{k}|$ of the interference pattern in closed form, eqs. (39)–(43)
362/// of Afisiadis et al. (2020): $A_{k,1}$, $A_{k,2}$ (with their limits $\lceil\tau\rceil$
363/// and $M - \lceil\tau\rceil$ when the sine of the denominator vanishes) and the phases
364/// $\theta_{k,1}$, $\theta_{k,2}$ as printed.
365///
366/// # Errors
367/// As [`Css::interferer`].
368pub fn interference_magnitudes(sf: u32, s1: usize, s2: usize, tau: f64) -> Result<Vec<f64>> {
369    let m = chips(sf)?;
370    comnum_core::ensure(s1 < m && s2 < m, || format!("the symbols must lie below M = {m}"))?;
371    comnum_core::ensure((0.0..m as f64).contains(&tau), || format!("the offset must lie in [0, {m}), got {tau}"))?;
372    let mf = m as f64;
373    let c = tau.ceil();
374    let (l_int, lambda) = (tau.floor(), tau - tau.floor());
375    let ratio = |x: f64, len: f64| sine_ratio(x, len, mf);
376    let (s1f, s2f) = (s1 as f64, s2 as f64);
377    Ok((0..m)
378        .map(|k| {
379            let k = k as f64;
380            let a1 = ratio(s1f - k - tau, c);
381            let a2 = ratio(s2f - k - tau, mf - c);
382            let t1 = PI / mf
383                * (-tau * tau + (lambda - l_int) * mf + s1f * (2.0 * tau - c + 1.0) + k * (c - 1.0) + tau * (c - 1.0));
384            let t2 = PI / mf * (-tau * tau + s2f * (2.0 * tau - c + 1.0 - mf) + k * (c - 1.0 + mf) + tau * (c - 1.0));
385            (a1 * a1 + a2 * a2 + 2.0 * a1 * a2 * (t1 - t2).cos()).max(0.0).sqrt()
386        })
387        .collect())
388}
389
390/// $\sin(\pi x L / M) / \sin(\pi x / M)$ for a whole number of chips $L$ = `len` (0 for
391/// $L = 0$): $L \operatorname{diric}(2\pi x / M, L)$, the amplitudes $A_{k,1}$ and
392/// $A_{k,2}$ of Afisiadis et al. (2020), eqs. (39)–(40).
393fn sine_ratio(x: f64, len: f64, m: f64) -> f64 {
394    if len == 0.0 { 0.0 } else { len * comnum_math::diric(TAU * x / m, len as usize) }
395}
396
397/// The magnitude $|A_{k,1} + A_{k,2}|$ of the strongest interference bin for `s2 = 0`
398/// and `s1 = si` (Afisiadis et al. 2020, eqs. (58)–(59)), at $k = [-\lfloor\tau\rceil]_{M}$:
399/// the dechirped interferer delayed by $\tau$ is a tone at $-\tau$, where $A_{k,2}$ of
400/// their eq. (40) peaks; eq. (58) prints $\lfloor\tau\rceil$, where it nearly vanishes.
401fn strongest_bin(m: usize, si: usize, tau: f64) -> f64 {
402    let mf = m as f64;
403    let c = tau.ceil();
404    let k = -tau.round();
405    (sine_ratio(si as f64 - k - tau, c, mf) + sine_ratio(-k - tau, mf - c, mf)).abs()
406}
407
408/// The probability that the strongest interference bin beats the bin of the symbol,
409/// $Q((M - \sqrt{P_{I}}|R_{k_{\max}}|)/\sqrt{M/\rho})$, for one interfering symbol `si` and
410/// offset `tau` (Afisiadis et al. 2020, eqs. (60)–(61), in the normalisation of the
411/// module: the difference of two magnitudes of noise variance $M/(2\rho)$ each).
412fn interference_error(m: usize, si: usize, tau: f64, rho: f64, pi_lin: f64) -> f64 {
413    let mf = m as f64;
414    qfunc((mf - pi_lin.sqrt() * strongest_bin(m, si, tau)) / (mf / rho).sqrt())
415}
416
417/// The step of the offsets of the paper, 1/5 chip, taken for a non-finite `eps`.
418const PAPER_OFFSET_STEP: f64 = 0.2;
419
420/// The smallest step of the offsets (chips): a finer `eps` is raised to it.
421const MIN_OFFSET_STEP: f64 = 1e-3;
422
423/// The step (`eps` floored at [`MIN_OFFSET_STEP`], [`PAPER_OFFSET_STEP`] for a
424/// non-finite `eps`) and the offsets `0, step, 2 step, …` below `half`.
425fn offsets(half: f64, eps: f64) -> (f64, Vec<f64>) {
426    let eps = if eps.is_finite() { eps.max(MIN_OFFSET_STEP) } else { PAPER_OFFSET_STEP };
427    let n = (half / eps).ceil().max(0.0) as usize;
428    (eps, (0..n).map(|i| i as f64 * eps).filter(|t| *t < half).collect())
429}
430
431/// The approximate SER under AWGN and a same-SF interferer of power `pi_db` (dB, relative
432/// to the signal), at the SNR per sample `snr_db`, the offset sampled every `eps` chips
433/// over $[0, (M - 1)/2)$ (Afisiadis et al. 2020, eqs. (62)–(63); the paper takes
434/// `eps` = 1/5): a step below 10⁻³ chip is raised to it, a non-finite one is 1/5.
435///
436/// # Errors
437/// As [`ser`].
438pub fn ser_interference(sf: u32, snr_db: f64, pi_db: f64, eps: f64) -> Result<f64> {
439    let m = chips(sf)?;
440    let (r, pi) = (db2lin(snr_db), comnum_math::db2lin(pi_db));
441    let half = (m as f64 - 1.0) / 2.0;
442    let (eps, taus) = offsets(half, eps);
443    let mut acc = 0.0;
444    for si in 0..m {
445        for &tau in &taus {
446            acc += interference_error(m, si, tau, r, pi);
447        }
448    }
449    let p_int = acc * eps / (m as f64 * (m as f64 / 2.0));
450    let p_noise = ser(sf, snr_db)?;
451    Ok(p_noise + (1.0 - p_noise) * p_int)
452}
453
454/// The approximate frame error rate of `frame` uncoded symbols under AWGN and a same-SF
455/// interferer of power `pi_db` (dB, relative to the signal) whose frame of the same
456/// length overlaps a uniform number of symbols (Afisiadis et al. 2020, eqs. (64)–(67)),
457/// at the SNR per sample `snr_db`, the offset sampled every `eps` chips (as
458/// [`ser_interference`]).
459///
460/// # Errors
461/// As [`ser`].
462pub fn fer_interference(sf: u32, snr_db: f64, pi_db: f64, frame: usize, eps: f64) -> Result<f64> {
463    let m = chips(sf)?;
464    let (r, pi) = (db2lin(snr_db), comnum_math::db2lin(pi_db));
465    let half = (m as f64 - 1.0) / 2.0;
466    let (eps, taus) = offsets(half, eps);
467    // eq. (65): the SER of an interfered symbol at the offset τ
468    let p_tau: Vec<f64> = taus
469        .iter()
470        .map(|&tau| (0..m).map(|si| interference_error(m, si, tau, r, pi)).sum::<f64>() / m as f64)
471        .collect();
472    let p_noise = ser(sf, snr_db)?;
473    let mut total = 0.0;
474    for fi in 1..=frame {
475        // eqs. (64) and (66)
476        let ok: f64 = p_tau.iter().map(|p| (1.0 - p).powi(fi as i32) * (1.0 - p_noise).powi((frame - fi) as i32)).sum();
477        total += 2.0 / m as f64 * eps * (taus.len() as f64 - ok);
478    }
479    Ok((total / frame as f64).clamp(0.0, 1.0))
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485    use crate::css::Waveform;
486    use comnum_core::rng::Rng;
487
488    /// The former evaluation of eq. (25): both segments in Fresnel functions, four per
489    /// symbol, $\int_{t_{1}}^{t_{2}} e^{j2\pi(a t + b t^{2})} dt$ by eq. (24).
490    fn former_waveform_spectrum(m: usize, l: usize, f: f64) -> C64 {
491        let segment = |a: f64, b: f64, t1: f64, t2: f64| {
492            let r = b.sqrt();
493            let shift = a / (2.0 * b);
494            cis(-TAU * a * a / (4.0 * b)) / (2.0 * r)
495                * (fresnel_k(2.0 * r * (t2 + shift)) - fresnel_k(2.0 * r * (t1 + shift)))
496        };
497        let (mf, lf) = (m as f64, l as f64);
498        let b = 1.0 / (2.0 * mf);
499        let fold = mf - lf;
500        segment(lf / mf - 0.5 - f, b, 0.0, fold) + segment(lf / mf - 1.5 - f, b, fold, mf)
501    }
502
503    fn former_continuous_spectrum(m: usize, f: f64) -> f64 {
504        let mf = m as f64;
505        let x: Vec<C64> = (0..m).map(|l| former_waveform_spectrum(m, l, f)).collect();
506        let sum: C64 = x.iter().sum();
507        (x.iter().map(C64::norm_sqr).sum::<f64>() - sum.norm_sqr() / mf) / (mf * mf)
508    }
509
510    /// The former [`occupied_bandwidth`]: the spectrum by [`former_continuous_spectrum`],
511    /// the lines by the sum over the symbols, every line tested in every cell.
512    fn former_occupied_bandwidth(sf: u32, fraction: f64, span: f64) -> f64 {
513        let m = 1usize << sf;
514        let mf = m as f64;
515        let step = 1.0 / (8.0 * mf);
516        let n = (span / step).ceil() as usize;
517        let g: Vec<f64> = (0..n).map(|i| former_continuous_spectrum(m, (i as f64 + 0.5) * step)).collect();
518        let n_max = (span * mf).ceil() as i64;
519        let lines: Vec<(f64, f64)> = (-n_max..=n_max)
520            .map(|k| {
521                let f = k as f64 / mf;
522                let sum: C64 = (0..m).map(|l| former_waveform_spectrum(m, l, f)).sum();
523                (f, sum.norm_sqr() / (mf * mf * mf * mf))
524            })
525            .collect();
526        let mut inside = lines.iter().filter(|(fl, _)| *fl == 0.0).map(|l| l.1).sum::<f64>();
527        let mut prev = (0.0, inside);
528        for (i, gi) in g.iter().enumerate() {
529            let z = (i + 1) as f64 * step;
530            inside += 2.0 * gi * step;
531            inside += lines.iter().filter(|(fl, _)| fl.abs() > z - step && fl.abs() <= z).map(|l| l.1).sum::<f64>();
532            if inside >= fraction {
533                let (z0, p0) = prev;
534                return 2.0 * (z0 + (z - z0) * (fraction - p0) / (inside - p0));
535            }
536            prev = (z, inside);
537        }
538        2.0 * span
539    }
540
541    #[test]
542    fn the_spectrum_is_the_former_evaluation_of_eq_25() {
543        let mut rng = Rng::new(9);
544        for sf in [1u32, 3, 5, 8] {
545            let m = 1usize << sf;
546            // generic frequencies, the midpoints of the grid and the lines
547            let f: Vec<f64> = (0..40).map(|_| 3.0 * rng.uniform() - 1.5).collect();
548            let peak = former_continuous_spectrum(m, 0.0).max(1e-3);
549            for (fk, g) in f.iter().zip(continuous_spectrum(sf, &f).unwrap()) {
550                let want = former_continuous_spectrum(m, *fk);
551                assert!((g - want).abs() < 1e-12 * peak, "SF {sf}, f {fk}: {g} vs {want}");
552            }
553            let n = 8 * m + 5;
554            let mid: Vec<f64> = (0..n).map(|i| (i as f64 + 0.5) / (8.0 * m as f64)).collect();
555            let direct = continuous_spectrum(sf, &mid).unwrap();
556            for (a, b) in continuous_spectrum_grid(m, n).iter().zip(&direct) {
557                assert!((a - b).abs() < 1e-12 * peak, "SF {sf}: {a} vs {b}");
558            }
559            for (fl, p) in spectral_lines(sf, 3 * m).unwrap() {
560                let sum: C64 = (0..m).map(|l| former_waveform_spectrum(m, l, fl)).sum();
561                let want = sum.norm_sqr() / (m as f64).powi(4);
562                assert!((p - want).abs() < 1e-12 / m as f64, "SF {sf}, line {fl}: {p} vs {want}");
563            }
564        }
565        for (sf, span) in [(3u32, 3.0), (5, 3.0), (7, 0.6)] {
566            let (b, want) = (occupied_bandwidth(sf, 0.99, span).unwrap(), former_occupied_bandwidth(sf, 0.99, span));
567            assert!((b - want).abs() < 1e-10, "SF {sf}: {b} vs {want}");
568        }
569    }
570
571    #[test]
572    fn the_exact_ser_is_that_of_a_simulated_receiver() {
573        // SF 7 at -9 dB: SER 1e-2, about 300 errors
574        let css = Css::lora(7).unwrap();
575        let mut rng = Rng::new(1);
576        let n = 30_000;
577        let s: Vec<usize> = (0..n).map(|_| rng.below(128)).collect();
578        let sigma = (1.0 / comnum_math::db2lin(-9.0)).sqrt();
579        let y: Vec<C64> =
580            css.modulate(&s).unwrap().into_iter().map(|v| v * cis(1.3) + rng.complex_gaussian() * sigma).collect();
581        let d = css.demodulate(&y).unwrap();
582        let sim = d.iter().zip(&s).filter(|(a, b)| a != b).count() as f64 / n as f64;
583        let want = ser(7, -9.0).unwrap();
584        assert!((sim / want - 1.0).abs() < 0.15, "{sim} vs {want}");
585        assert!((ber(7, -9.0).unwrap() / want - 128.0 / 254.0).abs() < 1e-12);
586    }
587
588    #[test]
589    fn the_cross_correlation_is_that_of_the_sampled_waveforms() {
590        // Chiani and Elzanaty 2019, eq. (7), against the inner product of the waveforms
591        // sampled 64 times per chip (an O(1/J) error)
592        let css =
593            Css { sf: 4, oversampling: 64, waveform: Waveform::LoRa, sample_rate: comnum_core::units::Hertz(1.0) };
594        let x: Vec<Vec<C64>> = (0..16).map(|a| css.symbol(a).unwrap()).collect();
595        let n = x[0].len() as f64;
596        for (l, q) in [(0usize, 1usize), (3, 7), (15, 2), (5, 13), (6, 6)] {
597            let c: C64 = x[l].iter().zip(&x[q]).map(|(a, b)| a * b.conj()).sum::<C64>() / n;
598            let want = cross_correlation(4, l, q).unwrap();
599            assert!((c - want).norm() < 0.02, "C({l}, {q}) = {c} vs {want}");
600        }
601    }
602
603    #[test]
604    fn the_spectrum_holds_the_power_and_the_lines_hold_one_over_m() {
605        // Chiani and Elzanaty 2019, Property 4 and eq. (20): SF 5
606        let lines: f64 = spectral_lines(5, 40 << 5).unwrap().iter().map(|l| l.1).sum();
607        assert!((lines - 1.0 / 32.0).abs() < 1e-9, "{lines}");
608        let step = 1.0 / 256.0;
609        let f: Vec<f64> = (0..(4.0 / step) as usize).map(|i| -2.0 + (i as f64 + 0.5) * step).collect();
610        let cont: f64 = continuous_spectrum(5, &f).unwrap().iter().sum::<f64>() * step;
611        assert!((cont - (1.0 - 1.0 / 32.0)).abs() < 2e-3, "{cont}");
612    }
613
614    #[test]
615    fn the_closed_form_interference_is_the_dft_and_has_its_symmetries() {
616        // Afisiadis et al. 2020, eqs. (38)-(43) and Propositions 1-2
617        let sorted = |mut v: Vec<f64>| {
618            v.sort_by(f64::total_cmp);
619            v
620        };
621        let mags = |s1: usize, s2: usize, tau: f64| {
622            interference_pattern(6, s1, s2, tau).unwrap().iter().map(|v| v.norm()).collect::<Vec<_>>()
623        };
624        for (s1, s2, tau) in [(5usize, 0usize, 3.0), (40, 10, 10.4), (63, 2, 30.7), (0, 0, 0.5), (17, 17, 62.9)] {
625            let dft = mags(s1, s2, tau);
626            let closed = interference_magnitudes(6, s1, s2, tau).unwrap();
627            let err = dft.iter().zip(&closed).map(|(a, b)| (a - b).abs()).fold(0.0, f64::max);
628            assert!(err < 1e-9, "({s1}, {s2}, {tau}): {err}");
629        }
630        // Proposition 1: shifting both symbols by δ keeps the set of magnitudes while
631        // s1 ≥ s2 stays true
632        let base = sorted(mags(20, 5, 7.3));
633        for delta in [1usize, 10, 43] {
634            let shifted = sorted(mags(20 + delta, 5 + delta, 7.3));
635            assert!(base.iter().zip(&shifted).all(|(a, b)| (a - b).abs() < 1e-9), "δ = {delta}");
636        }
637        // Proposition 2: τ and (M - 1) - τ give the same set
638        let (a, b) = (sorted(mags(20, 5, 7.3)), sorted(mags(20, 5, 63.0 - 7.3)));
639        assert!(a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-9));
640    }
641}
642
643#[cfg(test)]
644mod literature {
645    use super::*;
646
647    /// M. Chiani and A. Elzanaty, "On the LoRa modulation for IoT: waveform properties and
648    /// spectral analysis," IEEE Internet Things J. 6(5), 2019, Table I: the maximum real
649    /// cross-correlation (0.212, 0.091, 0.045, 0.015 for M = 2^3, 2^5, 2^7, 2^10), the SNR
650    /// penalty (1.04, 0.41, 0.20, 0.07 dB), the power of the lines (1/M) and the 99 %
651    /// band (1.500 B, 1.185 B, 1.045 B for M = 2^3, 2^5, 2^7). SF 10 and 12 of the band
652    /// are checked by `validation/tests/modulation/css.rs`.
653    #[test]
654    fn chiani_2019_table_1() {
655        for (sf, corr, penalty) in [(3u32, 0.212, 1.04), (5, 0.091, 0.41), (7, 0.045, 0.20), (10, 0.015, 0.07)] {
656            let c = max_cross_correlation(sf).unwrap();
657            assert!((c - corr).abs() < 5e-4, "SF {sf}: {c}");
658            let p = -comnum_math::lin2db(1.0 - c);
659            assert!((p - penalty).abs() < 0.005, "SF {sf}: {p} dB");
660        }
661        for (sf, b99) in [(3u32, 1.500), (5, 1.185), (7, 1.045)] {
662            let b = occupied_bandwidth(sf, 0.99, 3.0).unwrap();
663            assert!((b / b99 - 1.0).abs() < 0.005, "SF {sf}: {b}");
664            // the lines beyond 40 B hold less than 1e-4 of their power
665            let lines: f64 = spectral_lines(sf, 40 << sf).unwrap().iter().map(|l| l.1).sum();
666            assert!((lines * (1 << sf) as f64 - 1.0).abs() < 1e-4, "SF {sf}: {lines}");
667        }
668    }
669
670    /// O. Afisiadis, M. Cotting, A. Burg and A. Balatsoukas-Stimming, "On the error rate of
671    /// the LoRa modulation with interference," IEEE Trans. Wireless Commun. 19(2), 2020,
672    /// Fig. 2 (SF 7, read 1.8e-3 at -8 dB and 1.4e-4 at -7 dB) against the exact SER of eq.
673    /// (16); eq. (17) follows it within 30 % from 1e-2 to 1e-6 when its "SNR" is the SNR
674    /// per symbol $M\rho$ (the abscissa is the SNR per sample $\rho$, while eqs. (3)–(4)
675    /// define the SNR as $1/N_{0}$ with $\sigma^{2} = N_{0}/(2N)$, which would make the
676    /// SNR per sample $2N$ times larger); eq. (18) is optimistic, by a factor 1.05 to 5.2
677    /// over that range (the most at SF 7 and low rates).
678    #[test]
679    fn afisiadis_2020_fig_2_and_eqs_16_to_18() {
680        assert!((ser(7, -8.0).unwrap() / 1.8e-3 - 1.0).abs() < 0.15);
681        assert!((ser(7, -7.0).unwrap() / 1.4e-4 - 1.0).abs() < 0.1);
682        for sf in 7u32..=12 {
683            for snr in (-30..=0).map(f64::from) {
684                let exact = ser(sf, snr).unwrap();
685                if (1e-6..1e-2).contains(&exact) {
686                    let (r, g) = (ser_rice(sf, snr).unwrap(), ser_gumbel(sf, snr).unwrap());
687                    assert!((r / exact - 1.0).abs() < 0.3, "SF {sf} {snr} dB: {r} vs {exact}");
688                    assert!(g < exact && g > exact / 6.0, "SF {sf} {snr} dB: {g} vs {exact}");
689                }
690            }
691        }
692    }
693
694    /// L. Vangelista, "Frequency shift chirp modulation: the LoRa modulation," IEEE Signal
695    /// Process. Lett. 24(12), 2017, Fig. 1, flat channel: the BER of FSCM and FSK with
696    /// SF 7, read 2e-2, 4.7e-3, 8e-4 and 7.5e-5 at -10, -9, -8 and -7 dB, against the BER
697    /// of 128 orthogonal signals detected noncoherently.
698    #[test]
699    fn vangelista_2017_fig_1_awgn() {
700        for (snr, read) in [(-10.0, 2e-2), (-9.0, 4.7e-3), (-8.0, 8e-4), (-7.0, 7.5e-5)] {
701            let b = ber(7, snr).unwrap();
702            assert!((b / read - 1.0).abs() < 0.15, "{snr} dB: {b} vs {read}");
703        }
704    }
705
706    /// Afisiadis et al. 2020, eq. (58): the strongest bin of an interferer offset by
707    /// $\tau$ is $[-\lfloor\tau\rceil]_{M}$, where $A_{k,2}$ of their eq. (40) peaks; the
708    /// printed $\lfloor\tau\rceil$ is a bin of almost no energy.
709    #[test]
710    fn afisiadis_2020_eq_58_strongest_bin() {
711        let m = 128usize;
712        for (si, tau) in [(0usize, 3.0), (40, 10.4), (100, 30.7), (5, 50.2)] {
713            let r = interference_pattern(7, si, 0, tau).unwrap();
714            let kmax = comnum_math::argmax(r.iter().map(|v| v.norm())).unwrap().0;
715            assert_eq!(kmax, (m - tau.round() as usize) % m, "τ = {tau}");
716            assert!(r[tau.round() as usize].norm() < 0.05 * r[kmax].norm());
717            // eq. (59) drops the phase term of eq. (43): within 0.5 %
718            assert!((strongest_bin(m, si, tau) / r[kmax].norm() - 1.0).abs() < 5e-3);
719        }
720    }
721}