Skip to main content

comnum_waveform/cpm/
mod.rs

1//! Continuous-phase modulation (CPM): CPFSK, MSK and GMSK, their coherent
2//! maximum-likelihood sequence detector, two simple detectors for MSK and GMSK, and the
3//! closed forms of the MSK spectrum and of the Gaussian pulse.
4//!
5//! # Model
6//!
7//! An $M$-ary symbol sequence $a_{k} \in \\{\pm 1, \pm 3, \dots, \pm (M - 1)\\}$ is
8//! carried by the phase of a constant-envelope signal,
9//!
10//! $$
11//! s(t) = e^{j \varphi(t)}, \qquad
12//! \varphi(t) = 2 \pi h \sum_{k} a_{k} \\, q(t - kT),
13//! $$
14//!
15//! where $h = p/q$ is the **modulation index** (a rational number in lowest terms) and
16//! $q(t) = \int_{-\infty}^{t} g(\tau) \\, d\tau$ the **phase response**, the integral of a
17//! frequency pulse $g$ of duration $LT$ and area $q(LT) = 1/2$ (Proakis & Salehi, ch. 3,
18//! §3.3-3). The pulses of [`FrequencyPulse`] (Proakis & Salehi, Table 3.3-1):
19//!
20//! | Pulse | $g(t)$ on $0 \le t \le LT$ |
21//! |---|---|
22//! | $L$REC (rectangular; 1REC is CPFSK, MSK is 1REC with $h = 1/2$) | $1/(2LT)$ |
23//! | $L$RC (raised cosine) | $\frac{1}{2LT}\left(1 - \cos \frac{2 \pi t}{LT}\right)$ |
24//! | GMSK (Gaussian, $BT$ the normalised bandwidth of the Gaussian filter) | $\frac{1}{2T}\left[Q\left(2 \pi B \frac{t - LT/2 - T/2}{\sqrt{\ln 2}}\right) - Q\left(2 \pi B \frac{t - LT/2 + T/2}{\sqrt{\ln 2}}\right)\right]$, truncated to $L$ symbols and scaled to the area $1/2$ |
25//!
26//! The Gaussian pulse is the response of the Gaussian low-pass filter of Murota & Hirade
27//! (1981, §II) to a rectangular pulse of one symbol ([`gmsk_pulse`]).
28//!
29//! # Phase state
30//!
31//! During the interval $nT \le t < (n + 1)T$ the phase splits into a **terminal phase**,
32//! which the symbols that have left the pulse window fix, and the partial response of the
33//! last $L$ symbols:
34//!
35//! $$
36//! \varphi(t) = \underbrace{\pi h \sum_{k \le n - L} a_{k}}_{\theta_{n}} +
37//! 2 \pi h \sum_{k = n - L + 1}^{n} a_{k} \\, q(t - kT).
38//! $$
39//!
40//! The modulator keeps $\theta_{n}$ as an **integer multiple of $\pi / q$ modulo $2 \pi$**,
41//! so a signal of millions of symbols has no numerical drift of its phase. The state of
42//! the trellis is $(\theta_{n}, a_{n-1}, \dots, a_{n-L+1})$: $2q$ terminal phases
43//! (only $q$ of them are visited when $p$ is even) times $M^{L-1}$ past symbols. The
44//! signal starts in the state $\theta_{0} = 0$ with the past symbols equal to $+1$.
45//!
46//! # Receivers
47//!
48//! - [`Cpm::ml_detect`]: the coherent maximum-likelihood sequence detector, the Viterbi
49//!   algorithm over the trellis above with the correlation
50//!   $\operatorname{Re} \int_{nT}^{(n+1)T} r(t) \\, s^{\ast}(t; \text{branch}) \\, dt$ as
51//!   branch metric (a constant envelope makes it equivalent to the Euclidean distance;
52//!   Proakis & Salehi, ch. 4, "Optimum receiver for CPM signals");
53//! - [`Cpm::msk_detect`]: the quadrature (OQPSK-type) detector of MSK, which decides the
54//!   in-phase component at even symbol boundaries and the quadrature component at odd ones
55//!   after a half-sinusoid matched filter (Proakis & Salehi, §3.3-2, MSK as OQPSK with
56//!   sinusoidal pulses). It decides the **boundary signs** $d_{n}$ with
57//!   $e^{j \theta_{n}} = j^{n} d_{n}$, which a differential precoder ([`msk_precode`])
58//!   maps to the CPM symbols, $a_{n} = d_{n} d_{n+1}$; the bit error rate of the precoded
59//!   bits is that of antipodal signalling, $Q(\sqrt{2 E_{b}/N_{0}})$, that of the raw CPM
60//!   symbols twice as much;
61//! - [`Cpm::differential_detect`]: the one-symbol differential phase detector of binary
62//!   CPM (Simon & Wang, 1984), $\hat{a}_{n} = \operatorname{sign} \operatorname{Im}
63//!   (r_{n} r_{n-1}^{\ast})$ on samples spaced by $T$, after an optional Gaussian
64//!   predetection filter.
65//!
66//! # Closed forms
67//!
68//! - [`msk_psd`]: the power spectral density of MSK (Proakis & Salehi, §3.4-3),
69//!   $S(f) = \frac{16 T}{\pi^{2}} \left[\frac{\cos 2 \pi f T}{1 - 16 f^{2} T^{2}}\right]^{2}$,
70//!   whose first nulls are at $f T = 3/4$ and whose two-sided 3 dB bandwidth is $0.59/T$;
71//! - [`gmsk_pulse`] and [`gmsk_phase`]: the Gaussian frequency pulse and its integral in
72//!   closed form, $\int Q(x) \\, dx = x Q(x) - \phi(x)$ with $\phi$ the normal density;
73//! - [`Cpm::min_distance2`]: the normalised minimum squared Euclidean distance over the
74//!   merges of a few symbols, $d^{2} = \frac{\log_{2} M}{T} \int [1 - \cos \Delta
75//!   \varphi(t)] \\, dt$ (Anderson, Aulin & Sundberg, 1986, ch. 3), 2 for MSK.
76//!
77//! # Verb traits
78//!
79//! [`Cpm`] implements `Modulate<Labels>` ([`Cpm::modulate`]) and `Demodulate<Labels>`
80//! (the coherent maximum-likelihood sequence detector [`Cpm::ml_detect`]) of `comnum-core`
81//! on one stream of symbol indices, at its [`Cpm::sample_rate`] (1 Hz by default, time
82//! in samples; [`Cpm::with_sample_rate`]), the demodulator refusing an input at another
83//! rate. The other detectors and the phase states of the path stay methods.
84//!
85//! # Examples
86//! ```
87//! use comnum_waveform::cpm::{Cpm, CpmConfig, FrequencyPulse, msk_precode};
88//! let msk = CpmConfig::msk(8).build()?;
89//! let bits = [1u8, 0, 0, 1, 1, 1, 0, 1, 0, 0];
90//! let a = msk_precode(&bits);
91//! let s = msk.modulate(&a)?;
92//! assert_eq!(s.len(), 80);
93//! assert!(s.iter().all(|v| (v.norm() - 1.0).abs() < 1e-12));        // constant envelope
94//! assert_eq!(msk.ml_detect(&s)?, a);                                 // coherent ML
95//! assert_eq!(msk.msk_detect(&s)?, bits);                             // quadrature detector
96//! assert_eq!(msk.differential_detect(&s, None)?, a);                 // differential detector
97//! let gmsk = CpmConfig::gmsk(0.3, 3, 8).build()?;
98//! assert_eq!(gmsk.config().pulse, FrequencyPulse::Gaussian { bt: 0.3 });
99//! assert_eq!(gmsk.ml_detect(&gmsk.modulate(&a)?)?, a);
100//! # Ok::<(), comnum_core::Error>(())
101//! ```
102//!
103//! # References
104//!
105//! 1. J. G. Proakis and M. Salehi, *Digital Communications*, 5th ed., McGraw-Hill, 2008,
106//!    §3.3-2 (CPFSK, MSK), §3.3-3 (CPM, Table 3.3-1), §3.4-3 (spectrum of MSK) and ch. 4
107//!    (optimum receiver for CPM, minimum distance).
108//! 2. K. Murota and K. Hirade, "GMSK modulation for digital mobile radio telephony,"
109//!    *IEEE Trans. Commun.*, 29(7), 1044–1050, 1981.
110//! 3. J. B. Anderson, T. Aulin and C.-E. Sundberg, *Digital Phase Modulation*, Plenum,
111//!    1986, ch. 2–3.
112//! 4. T. Aulin and C.-E. Sundberg, "Continuous phase modulation—Part I: Full response
113//!    signaling," *IEEE Trans. Commun.*, 29(3), 196–209, 1981.
114//! 5. M. K. Simon and C. C. Wang, "Differential detection of Gaussian MSK in a mobile
115//!    radio environment," *IEEE Trans. Veh. Technol.*, 33(4), 307–320, 1984.
116
117mod detection;
118mod pulse;
119
120pub use detection::{MlPath, msk_bits, msk_precode};
121pub use pulse::{FrequencyPulse, gmsk_phase, gmsk_pulse, msk_psd};
122
123use crate::{check_input, check_rate};
124use comnum_core::signal::{Labels, Signal};
125use comnum_core::traits::{Demodulate, Modulate};
126use comnum_core::units::Hertz;
127use comnum_core::{Error, Result, ensure};
128use comnum_math::{C64, cis};
129use std::f64::consts::{PI, TAU};
130
131/// A rational modulation index $h = p/q$ ([`CpmConfig::h_num`], [`CpmConfig::h_den`]),
132/// read from the fraction `"p/q"`.
133///
134/// # Examples
135/// ```
136/// use comnum_waveform::cpm::ModulationIndex;
137/// let h: ModulationIndex = " 1 / 2".parse()?;
138/// assert_eq!((h.num, h.den), (1, 2));
139/// # Ok::<(), comnum_core::Error>(())
140/// ```
141#[derive(Clone, Copy, Debug, PartialEq, Eq)]
142pub struct ModulationIndex {
143    /// The numerator $p$.
144    pub num: u32,
145    /// The denominator $q$.
146    pub den: u32,
147}
148
149impl std::str::FromStr for ModulationIndex {
150    type Err = Error;
151
152    /// `"p/q"`, two non-negative integers (spaces around them are ignored); the values
153    /// are checked by [`CpmConfig::build`].
154    ///
155    /// # Errors
156    /// [`Error::Config`] for another text.
157    fn from_str(s: &str) -> Result<ModulationIndex> {
158        s.split_once('/')
159            .and_then(|(p, q)| Some(ModulationIndex { num: p.trim().parse().ok()?, den: q.trim().parse().ok()? }))
160            .ok_or_else(|| Error::Config(format!("h must be a fraction p/q, got {s:?}")))
161    }
162}
163
164/// The parameters of a CPM signal, validated by [`CpmConfig::build`].
165#[derive(Clone, Copy, Debug, PartialEq)]
166pub struct CpmConfig {
167    /// The alphabet size $M$ (2 or 4).
168    pub order: usize,
169    /// The numerator $p$ of the modulation index $h = p/q$.
170    pub h_num: u32,
171    /// The denominator $q$ of the modulation index.
172    pub h_den: u32,
173    /// The frequency pulse.
174    pub pulse: FrequencyPulse,
175    /// The pulse length $L$ in symbols.
176    pub length: usize,
177    /// Samples per symbol.
178    pub sps: usize,
179}
180
181impl CpmConfig {
182    /// MSK: binary 1REC with $h = 1/2$.
183    pub fn msk(sps: usize) -> CpmConfig {
184        CpmConfig { order: 2, h_num: 1, h_den: 2, pulse: FrequencyPulse::Rectangular, length: 1, sps }
185    }
186
187    /// GMSK: binary Gaussian pulse of bandwidth `bt` over `length` symbols, $h = 1/2$.
188    pub fn gmsk(bt: f64, length: usize, sps: usize) -> CpmConfig {
189        CpmConfig { order: 2, h_num: 1, h_den: 2, pulse: FrequencyPulse::Gaussian { bt }, length, sps }
190    }
191
192    /// Build the modulator: tabulate the phase response and the trellis.
193    ///
194    /// # Errors
195    /// [`comnum_core::Error::Config`] unless the order is 2 or 4, $0 < h < 1$ with $q \le 64$, the
196    /// length is between 1 and 8, `sps` is at least 2, and $BT > 0$ for a Gaussian pulse.
197    pub fn build(self) -> Result<Cpm> {
198        ensure(self.order == 2 || self.order == 4, || format!("the order must be 2 or 4, got {}", self.order))?;
199        ensure(self.h_num >= 1 && self.h_den >= 2 && self.h_num < self.h_den && self.h_den <= 64, || {
200            format!("the modulation index must be p/q with 0 < p < q ≤ 64, got {}/{}", self.h_num, self.h_den)
201        })?;
202        ensure((1..=8).contains(&self.length), || {
203            format!("the pulse length must be 1 to 8 symbols, got {}", self.length)
204        })?;
205        ensure(self.sps >= 2, || format!("at least 2 samples per symbol, got {}", self.sps))?;
206        if let FrequencyPulse::Gaussian { bt } = self.pulse {
207            ensure(bt > 0.0 && bt.is_finite(), || format!("BT must be positive, got {bt}"))?;
208        }
209        let g = comnum_math::gcd(self.h_num as usize, self.h_den as usize) as u32;
210        let cfg = CpmConfig { h_num: self.h_num / g, h_den: self.h_den / g, ..self };
211        let (l, sps) = (cfg.length, cfg.sps);
212        // q(t) at every sample of the L symbol intervals: index j·sps + i for the symbol
213        // j intervals in the past at the sample i
214        let q_table: Vec<f64> = (0..l * sps).map(|k| cfg.pulse.phase_response(k as f64 / sps as f64, l)).collect();
215        let past_combos = cfg.order.pow((l - 1) as u32);
216        Ok(Cpm { cfg, q_table, past_combos, sample_rate: Hertz(1.0) })
217    }
218}
219
220/// A CPM modulator and its detectors (see the module documentation).
221#[derive(Clone, Debug, PartialEq)]
222pub struct Cpm {
223    cfg: CpmConfig,
224    /// $q(t)$ at the sample `j·sps + i`: the response of the symbol `j` intervals in the
225    /// past at the sample `i` of the current interval.
226    q_table: Vec<f64>,
227    /// $M^{L-1}$, the number of combinations of the past symbols of a state.
228    past_combos: usize,
229    /// The sample rate of the signal of the verb traits.
230    sample_rate: Hertz,
231}
232
233impl Cpm {
234    /// The validated parameters (the modulation index in lowest terms).
235    pub fn config(&self) -> &CpmConfig {
236        &self.cfg
237    }
238
239    /// The same modulator at the sample rate `sample_rate` (`sps` samples per symbol) of
240    /// the verb traits.
241    ///
242    /// # Errors
243    /// [`comnum_core::Error::Config`] for a sample rate that is not positive and finite.
244    ///
245    /// # Examples
246    /// ```
247    /// use comnum_core::prelude::*;
248    /// use comnum_waveform::cpm::CpmConfig;
249    /// let mut msk = CpmConfig::msk(8).build()?.with_sample_rate(Hertz(8e3))?;
250    /// let a = Labels::from_stream(vec![1, 0, 0, 1, 1])?;
251    /// let s = Modulate::modulate(&msk, &a)?;
252    /// assert_eq!((s.len(), s.sample_rate()), (40, Hertz(8e3)));
253    /// assert_eq!(Demodulate::<Labels>::demodulate(&mut msk, &s)?, a);
254    /// # Ok::<(), comnum_core::Error>(())
255    /// ```
256    pub fn with_sample_rate(self, sample_rate: Hertz) -> Result<Cpm> {
257        check_rate(sample_rate)?;
258        Ok(Cpm { sample_rate, ..self })
259    }
260
261    /// The sample rate of the signal of the verb traits (1 Hz unless set by
262    /// [`Cpm::with_sample_rate`]).
263    pub fn sample_rate(&self) -> Hertz {
264        self.sample_rate
265    }
266
267    /// The modulation index $h = p/q$.
268    pub fn h(&self) -> f64 {
269        f64::from(self.cfg.h_num) / f64::from(self.cfg.h_den)
270    }
271
272    /// The number of terminal phases, $2q$ (the trellis visits $q$ of them when $p$ is
273    /// even).
274    pub fn n_phase_states(&self) -> usize {
275        2 * self.cfg.h_den as usize
276    }
277
278    /// The number of trellis states, $2q M^{L-1}$.
279    pub fn n_states(&self) -> usize {
280        self.n_phase_states() * self.past_combos
281    }
282
283    /// The symbol value $a = 2i - (M - 1)$ of the index `i`.
284    pub fn symbol_value(&self, index: usize) -> f64 {
285        2.0 * index as f64 - (self.cfg.order as f64 - 1.0)
286    }
287
288    /// The tabulated phase response, $q(k T / \mathrm{sps})$ for $k < L \cdot \mathrm{sps}$.
289    pub fn phase_response(&self) -> &[f64] {
290        &self.q_table
291    }
292
293    /// The index of the symbol $+1$ (the past symbols of the initial state).
294    fn one(&self) -> usize {
295        self.cfg.order / 2
296    }
297
298    /// The terminal phase after the symbol of index `i` leaves the window.
299    fn next_phase(&self, s: usize, i: usize) -> usize {
300        let n = self.n_phase_states() as i64;
301        let inc = i64::from(self.cfg.h_num) * (2 * i as i64 - (self.cfg.order as i64 - 1));
302        ((s as i64 + inc).rem_euclid(n)) as usize
303    }
304
305    /// The partial response $2 \pi h \sum_{j=0}^{L-1} a_{n-j} q(t - (n - j)T)$ at the
306    /// sample `t` of the interval, for the window `window` (`window[j]` is the index of
307    /// the symbol `j` intervals in the past, `window[0]` the current one).
308    fn partial_phase(&self, window: &[usize], t: usize) -> f64 {
309        let sps = self.cfg.sps;
310        let sum: f64 = window.iter().enumerate().map(|(j, &i)| self.symbol_value(i) * self.q_table[j * sps + t]).sum();
311        TAU * self.h() * sum
312    }
313
314    /// The phase of the signal at every sample, with the terminal phase reduced modulo
315    /// $2 \pi$ (the phase is continuous across the intervals up to these $2 \pi$ jumps).
316    ///
317    /// # Errors
318    /// [`comnum_core::Error::Config`] if a symbol index is not below $M$.
319    pub fn phase(&self, symbols: &[usize]) -> Result<Vec<f64>> {
320        let m = self.cfg.order;
321        ensure(symbols.iter().all(|&i| i < m), || format!("symbol indices must be below {m}"))?;
322        let (l, sps) = (self.cfg.length, self.cfg.sps);
323        let mut window = vec![self.one(); l];
324        let mut state = 0usize;
325        let unit = PI / f64::from(self.cfg.h_den);
326        let mut out = Vec::with_capacity(symbols.len() * sps);
327        for &i in symbols {
328            window.rotate_right(1);
329            window[0] = i;
330            let theta = state as f64 * unit;
331            for t in 0..sps {
332                out.push(theta + self.partial_phase(&window, t));
333            }
334            state = self.next_phase(state, window[l - 1]);
335        }
336        Ok(out)
337    }
338
339    /// The complex baseband signal $e^{j \varphi(t)}$ at `sps` samples per symbol.
340    ///
341    /// # Errors
342    /// [`comnum_core::Error::Config`] if a symbol index is not below $M$.
343    pub fn modulate(&self, symbols: &[usize]) -> Result<Vec<C64>> {
344        Ok(self.phase(symbols)?.into_iter().map(cis).collect())
345    }
346}
347
348/// CPM as a [`Modulate`] block: one stream of symbol indices into one stream of samples
349/// at [`Cpm::sample_rate`], by [`Cpm::modulate`].
350impl Modulate<Labels> for Cpm {
351    /// # Errors
352    /// [`comnum_core::Error::Config`] for several streams, and the errors of
353    /// [`Cpm::modulate`].
354    fn modulate_into(&self, x: &Labels, out: &mut Signal) -> Result<()> {
355        ensure(x.n_streams() == 1, || format!("CPM modulates one stream, not {}", x.n_streams()))?;
356        out.set_stream(&Cpm::modulate(self, x.stream(0))?, self.sample_rate);
357        Ok(())
358    }
359}
360
361/// CPM as a [`Demodulate`] block: the coherent maximum-likelihood sequence detector
362/// [`Cpm::ml_detect`] on one stream of samples at [`Cpm::sample_rate`].
363impl Demodulate<Labels> for Cpm {
364    /// # Errors
365    /// [`comnum_core::Error::Config`] for several streams or a signal at another sample rate,
366    /// and the errors of [`Cpm::ml_detect`].
367    fn demodulate_into(&mut self, y: &Signal, out: &mut Labels) -> Result<()> {
368        check_input(y, self.sample_rate, "CPM")?;
369        out.set_stream(&self.ml_detect(y.stream(0))?);
370        Ok(())
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    /// The verb traits give the samples of `modulate` and the decisions of `ml_detect`,
379    /// bit for bit, at the sample rate of the modulator; an input at another rate or with
380    /// two streams is refused.
381    #[test]
382    fn the_verb_traits_are_the_slice_methods() {
383        let mut rng = comnum_core::rng::Rng::new(11);
384        for cfg in [
385            CpmConfig::msk(4),
386            CpmConfig::gmsk(0.3, 3, 8),
387            CpmConfig { order: 4, h_num: 1, h_den: 4, pulse: FrequencyPulse::RaisedCosine, length: 2, sps: 4 },
388        ] {
389            let mut cpm = cfg.build().unwrap().with_sample_rate(Hertz(1e6)).unwrap();
390            let a: Vec<usize> = (0..60).map(|_| rng.below(cfg.order)).collect();
391            let s = cpm.modulate(&a).unwrap();
392            let mut x = Signal::zeros(0, 0, Hertz(1.0));
393            cpm.modulate_into(&Labels::from_stream(a.clone()).unwrap(), &mut x).unwrap();
394            assert_eq!((x.stream(0), x.sample_rate()), (&s[..], Hertz(1e6)));
395            let noisy: Vec<C64> = s.iter().map(|v| v + C64::new(rng.gaussian(), rng.gaussian()) * 0.6).collect();
396            let y = Signal::from_stream(noisy.clone(), Hertz(1e6)).unwrap();
397            let mut out = Labels::default();
398            cpm.demodulate_into(&y, &mut out).unwrap();
399            assert_eq!(out.stream(0), cpm.ml_detect(&noisy).unwrap());
400            assert!(cpm.demodulate_into(&Signal::from_stream(noisy, Hertz(1.0)).unwrap(), &mut out).is_err());
401            assert!(Modulate::modulate(&cpm, &Labels::zeros(2, 4)).is_err());
402        }
403        assert!(CpmConfig::msk(4).build().unwrap().with_sample_rate(Hertz(f64::NAN)).is_err());
404    }
405    use comnum_core::rng::Rng;
406
407    #[test]
408    fn the_modulation_index_is_read_from_a_fraction() {
409        assert_eq!("3/8".parse::<ModulationIndex>().unwrap(), ModulationIndex { num: 3, den: 8 });
410        for bad in ["0.5", "1/", "a/2", "-1/2", "1/2/3"] {
411            assert!(bad.parse::<ModulationIndex>().is_err(), "{bad}");
412        }
413    }
414
415    fn random_symbols(m: usize, n: usize, rng: &mut Rng) -> Vec<usize> {
416        (0..n).map(|_| rng.below(m)).collect()
417    }
418
419    fn count_errors<T: PartialEq>(a: &[T], b: &[T]) -> usize {
420        assert_eq!(a.len(), b.len());
421        a.iter().zip(b).filter(|(x, y)| x != y).count()
422    }
423
424    #[test]
425    fn config_is_validated_and_reduced() {
426        assert!(CpmConfig { order: 8, ..CpmConfig::msk(4) }.build().is_err());
427        assert!(CpmConfig { h_num: 2, h_den: 2, ..CpmConfig::msk(4) }.build().is_err());
428        assert!(CpmConfig { sps: 1, ..CpmConfig::msk(4) }.build().is_err());
429        assert!(CpmConfig { length: 0, ..CpmConfig::msk(4) }.build().is_err());
430        assert!(CpmConfig::gmsk(0.0, 3, 4).build().is_err());
431        let c = CpmConfig { h_num: 2, h_den: 8, ..CpmConfig::msk(4) }.build().unwrap();
432        assert_eq!((c.config().h_num, c.config().h_den), (1, 4));
433        assert_eq!(c.n_phase_states(), 8);
434        let g = CpmConfig { order: 4, length: 3, ..CpmConfig::gmsk(0.3, 3, 4) }.build().unwrap();
435        assert_eq!(g.n_states(), 4 * 16);
436        assert!(g.ml_detect(&[C64::new(1.0, 0.0); 5]).is_err());
437        assert!(g.msk_detect(&[C64::new(1.0, 0.0); 8]).is_err());
438        assert!(g.differential_detect(&[C64::new(1.0, 0.0); 8], None).is_err());
439        assert!(c.modulate(&[2]).is_err());
440    }
441
442    #[test]
443    fn edge_cases_return_instead_of_panicking() {
444        // one symbol with a pulse of three symbols: the reference sample is the last one
445        let g = CpmConfig::gmsk(0.3, 3, 4).build().unwrap();
446        assert_eq!(g.differential_detect(&[C64::new(1.0, 0.0); 4], None).unwrap().len(), 1);
447        assert_eq!(CpmConfig::msk(4).build().unwrap().min_distance2(0).unwrap(), f64::INFINITY);
448    }
449
450    #[test]
451    fn constant_envelope_and_continuous_phase() {
452        // the instantaneous frequency is 2πh Σ_k a_k g(t - kT), and the shifted pulses
453        // sum to Σ_j g(t + jT) = 1/(2T) (LREC and LRC exactly, the truncated Gaussian
454        // pulse up to the scaling of its missing tails, below 0.1 % at BT = 0.3 and
455        // L = 4), so the phase increment per sample is at most πh (M-1) / sps
456        let mut rng = Rng::new(3);
457        let sps = 16;
458        for cfg in [
459            CpmConfig::msk(sps),
460            CpmConfig { order: 4, h_num: 1, h_den: 4, pulse: FrequencyPulse::RaisedCosine, length: 3, sps },
461            CpmConfig { order: 4, h_num: 3, h_den: 5, pulse: FrequencyPulse::Rectangular, length: 2, sps },
462            CpmConfig::gmsk(0.3, 4, sps),
463        ] {
464            let cpm = cfg.build().unwrap();
465            let a = random_symbols(cfg.order, 400, &mut rng);
466            let s = cpm.modulate(&a).unwrap();
467            assert!(s.iter().all(|v| (v.norm() - 1.0).abs() < 1e-12));
468            let bound = PI * cpm.h() * (cfg.order as f64 - 1.0) / sps as f64;
469            let max_jump = s.windows(2).map(|w| (w[1] * w[0].conj()).arg().abs()).fold(0.0, f64::max);
470            assert!(max_jump <= bound * (1.0 + 1e-3), "{cfg:?}: jump {max_jump} above {bound}");
471        }
472    }
473
474    #[test]
475    fn the_terminal_phase_never_drifts() {
476        // one million MSK symbols: the terminal phase is an integer state, so the phase
477        // at every boundary is exactly a multiple of π/2
478        let msk = CpmConfig::msk(2).build().unwrap();
479        let a = random_symbols(2, 1_000_000, &mut Rng::new(5));
480        let phi = msk.phase(&a).unwrap();
481        let worst =
482            phi.iter().step_by(2).map(|p| (p / (PI / 2.0) - (p / (PI / 2.0)).round()).abs()).fold(0.0, f64::max);
483        assert!(worst < 1e-12, "{worst}");
484        assert!(phi.iter().all(|p| *p > -PI && *p < TAU + PI));
485    }
486
487    #[test]
488    fn ml_detector_recovers_noiseless_sequences() {
489        let mut rng = Rng::new(11);
490        for cfg in [
491            CpmConfig::msk(4),
492            CpmConfig { order: 2, h_num: 1, h_den: 4, pulse: FrequencyPulse::RaisedCosine, length: 2, sps: 4 },
493            CpmConfig { order: 4, h_num: 1, h_den: 2, pulse: FrequencyPulse::Rectangular, length: 1, sps: 4 },
494            CpmConfig { order: 4, h_num: 2, h_den: 3, pulse: FrequencyPulse::RaisedCosine, length: 3, sps: 4 },
495            CpmConfig::gmsk(0.25, 4, 4),
496        ] {
497            let cpm = cfg.build().unwrap();
498            let a = random_symbols(cfg.order, 300, &mut rng);
499            let s = cpm.modulate(&a).unwrap();
500            let path = cpm.ml_detect_path(&s).unwrap();
501            assert_eq!(path.symbols, a, "{cfg:?}");
502            // the phase states of the path are those of the modulator
503            let unit = PI / f64::from(cpm.config().h_den);
504            let phi = cpm.phase(&a).unwrap();
505            for n in 0..a.len() {
506                let theta = phi[n * cfg.sps] - cpm.partial_phase(&window_at(&cpm, &a, n), 0);
507                let want = ((theta / unit).round() as i64).rem_euclid(cpm.n_phase_states() as i64) as usize;
508                assert_eq!(path.phase_states[n], want, "{cfg:?} at {n}");
509            }
510        }
511    }
512
513    /// The window of the modulator at the interval `n` (`[a_n, a_{n-1}, …]`, +1 before
514    /// the start).
515    fn window_at(cpm: &Cpm, a: &[usize], n: usize) -> Vec<usize> {
516        (0..cpm.config().length).map(|j| if j > n { cpm.one() } else { a[n - j] }).collect()
517    }
518
519    #[test]
520    fn msk_precoder_and_quadrature_detector_round_trip() {
521        let msk = CpmConfig::msk(8).build().unwrap();
522        let bits: Vec<u8> = (0..500).map(|_| 0).chain(comnum_mapping::source::bits(500, &mut Rng::new(2))).collect();
523        let a = msk_precode(&bits);
524        let s = msk.modulate(&a).unwrap();
525        assert_eq!(msk.msk_detect(&s).unwrap(), bits);
526        assert_eq!(msk_bits(&msk.ml_detect_path(&s).unwrap().phase_states), bits);
527        // a constant phase rotation of a quarter turn swaps the components: the detector
528        // is coherent (sanity of the test, not a property of MSK)
529        let rotated: Vec<C64> = s.iter().map(|v| v * C64::new(0.0, 1.0)).collect();
530        assert!(count_errors(&msk.msk_detect(&rotated).unwrap(), &bits) > 100);
531        // the differential detector ignores the rotation
532        assert_eq!(msk.differential_detect(&rotated, None).unwrap(), a);
533    }
534
535    #[test]
536    fn gaussian_phase_response_is_normalised() {
537        for bt in [0.2, 0.3, 0.5] {
538            for l in [2usize, 3, 4, 5] {
539                let p = FrequencyPulse::Gaussian { bt };
540                assert_eq!(p.phase_response(l as f64, l), 0.5);
541                assert_eq!(p.phase_response(0.0, l), 0.0);
542                assert!((p.phase_response(l as f64 / 2.0, l) - 0.25).abs() < 1e-12);
543                // monotone
544                let v: Vec<f64> = (0..=100).map(|k| p.phase_response(l as f64 * k as f64 / 100.0, l)).collect();
545                assert!(v.windows(2).all(|w| w[1] >= w[0]));
546            }
547        }
548        // the closed-form integral of Q matches a numerical one
549        let bt = 0.3;
550        let (lo, hi) = (-3.0, 1.2);
551        let num = comnum_math::simpson(|t| gmsk_pulse(t, bt), lo, hi, 2000);
552        assert!((num - (gmsk_phase(hi, bt) - gmsk_phase(lo, bt))).abs() < 1e-10);
553    }
554
555    #[test]
556    fn parse_pulses() {
557        assert_eq!(FrequencyPulse::from_name("REC", 0.0).unwrap(), FrequencyPulse::Rectangular);
558        assert_eq!(FrequencyPulse::from_name("rc", 0.0).unwrap(), FrequencyPulse::RaisedCosine);
559        assert_eq!(FrequencyPulse::from_name("gmsk", 0.5).unwrap(), FrequencyPulse::Gaussian { bt: 0.5 });
560        assert!(FrequencyPulse::from_name("sinc", 0.5).is_err());
561    }
562
563    /// Averaged Hann-windowed periodogram of `x` over non-overlapping segments of `nfft`
564    /// samples sampled at `sps` per symbol ([`comnum_dsp::spectrum::welch`]): frequencies
565    /// `fT` (centred) and the density per unit `fT`, normalised to a unit total power.
566    pub(super) fn welch(x: &[C64], sps: usize, nfft: usize) -> (Vec<f64>, Vec<f64>) {
567        let hann = comnum_dsp::window::hann(nfft);
568        let (f, d) = comnum_dsp::spectrum::welch(x, sps as f64, nfft, Some(&hann), nfft).unwrap();
569        let total: f64 = d.iter().sum::<f64>() * (f[1] - f[0]);
570        (f, d.into_iter().map(|v| v / total).collect())
571    }
572
573    /// The two-sided bandwidth holding the `fraction` of the power of a centred density.
574    pub(super) fn power_bandwidth(freqs: &[f64], dens: &[f64], fraction: f64) -> f64 {
575        let df = freqs[1] - freqs[0];
576        // the bins by distance to the carrier, added as the band widens by `df`
577        let mut bins: Vec<(f64, f64)> = freqs.iter().zip(dens).map(|(f, d)| (f.abs(), d * df)).collect();
578        bins.sort_by(|a, b| a.0.total_cmp(&b.0));
579        let (mut inside, mut next, mut b) = (0.0, 0, 0.0);
580        while inside < fraction && next < bins.len() {
581            b += df;
582            while next < bins.len() && bins[next].0 <= b / 2.0 {
583                inside += bins[next].1;
584                next += 1;
585            }
586        }
587        b
588    }
589}
590
591#[cfg(test)]
592mod literature {
593    use super::tests::{power_bandwidth, welch};
594    use super::*;
595    use comnum_core::rng::Rng;
596    use comnum_math::{db2lin, qfunc};
597
598    fn noisy(x: &[C64], sps: usize, ebn0_db: f64, rng: &mut Rng) -> Vec<C64> {
599        let sd = (sps as f64 / db2lin(ebn0_db)).sqrt();
600        x.iter().map(|v| v + rng.complex_gaussian() * sd).collect()
601    }
602
603    /// J. G. Proakis and M. Salehi, *Digital Communications*, 5th ed., 2008, §3.3-2:
604    /// MSK is OQPSK with half-sinusoid pulses, so coherent detection of the precoded
605    /// (OQPSK) bits has the error probability `Q(√(2 E_b/N_0))` of antipodal signalling,
606    /// with the quadrature detector as with the maximum-likelihood sequence detector; the
607    /// raw CPM symbols `a_n = d_n d_{n+1}` are then wrong with probability `2p(1 - p)`.
608    ///
609    /// 200 000 bits: at 4 dB (`p = 1.25e-2`) about 2 500 errors are expected, at 6 dB
610    /// (`p = 2.39e-3`) about 477, so the relative standard errors are 2 % and 4.6 %; the
611    /// test accepts three standard errors.
612    #[test]
613    fn proakis_msk_as_oqpsk_coherent_ber() {
614        let sps = 8;
615        let msk = CpmConfig::msk(sps).build().unwrap();
616        let mut rng = Rng::new(2024);
617        let n_bits = 200_000;
618        for ebn0_db in [4.0, 6.0] {
619            let p = qfunc((2.0 * db2lin(ebn0_db)).sqrt());
620            let bits = comnum_mapping::source::bits(n_bits, &mut rng);
621            let a = msk_precode(&bits);
622            let r = noisy(&msk.modulate(&a).unwrap(), sps, ebn0_db, &mut rng);
623            let tol = 3.0 / (p * n_bits as f64).sqrt();
624            let quad = msk.msk_detect(&r).unwrap();
625            let ber_quad = quad.iter().zip(&bits).filter(|(x, y)| x != y).count() as f64 / n_bits as f64;
626            assert!((ber_quad / p - 1.0).abs() < tol, "quadrature at {ebn0_db} dB: {ber_quad:e} vs {p:e}");
627            let path = msk.ml_detect_path(&r).unwrap();
628            let ml = msk_bits(&path.phase_states);
629            let ber_ml = ml.iter().zip(&bits).filter(|(x, y)| x != y).count() as f64 / n_bits as f64;
630            assert!((ber_ml / p - 1.0).abs() < tol, "ML at {ebn0_db} dB: {ber_ml:e} vs {p:e}");
631            let raw = path.symbols.iter().zip(&a).filter(|(x, y)| x != y).count() as f64 / n_bits as f64;
632            let want = 2.0 * p * (1.0 - p);
633            assert!((raw / want - 1.0).abs() < tol, "raw symbols at {ebn0_db} dB: {raw:e} vs {want:e}");
634        }
635    }
636
637    /// Proakis & Salehi, 5th ed., §3.4-3 (the power spectrum of MSK): the closed form
638    /// `S(f) = (16T/π²)[cos 2πfT / (1 - 16 f² T²)]²` has its first nulls at `fT = 3/4`
639    /// and its two-sided 3 dB bandwidth at `0.59/T` (`S(0.295)/S(0) = 0.50`). The
640    /// averaged periodogram of a simulated signal (Hann window, segments of 64 symbols,
641    /// 400 segments, so about 5 % of standard deviation per bin, 0.2 dB) matches the
642    /// closed form within 1 dB on every bin and 0.3 dB in RMS down to -20 dB, its minimum
643    /// near `fT = 3/4` is 25 dB below the peak (the window fills the null), and the
644    /// 3 dB bandwidth interpolated between the bins is `0.59/T ± 0.03/T` (the slope of
645    /// the spectrum there turns 5 % of density into 0.017/T of bandwidth).
646    #[test]
647    fn proakis_msk_power_spectrum() {
648        let sps = 8;
649        let msk = CpmConfig::msk(sps).build().unwrap();
650        let mut rng = Rng::new(77);
651        let n_seg = 400;
652        let nfft = 64 * sps;
653        let a: Vec<usize> = (0..n_seg * 64).map(|_| rng.below(2)).collect();
654        let s = msk.modulate(&a).unwrap();
655        let (f, d) = welch(&s, sps, nfft);
656        // the closed form integrates to one over all frequencies
657        let n = 400_000;
658        let integral: f64 = (0..n).map(|i| msk_psd(-20.0 + 40.0 * i as f64 / n as f64) * 40.0 / n as f64).sum();
659        assert!((integral - 1.0).abs() < 1e-3, "{integral}");
660        let peak = d[nfft / 2];
661        let (mut sq, mut count) = (0.0, 0usize);
662        for (fi, di) in f.iter().zip(&d) {
663            let want = msk_psd(*fi);
664            if want > 1e-2 * msk_psd(0.0) {
665                let err_db = comnum_math::lin2db(di / want);
666                assert!(err_db.abs() < 1.0, "fT = {fi}: {err_db} dB");
667                sq += err_db * err_db;
668                count += 1;
669            }
670        }
671        assert!((sq / count as f64).sqrt() < 0.3, "RMS error {} dB", (sq / count as f64).sqrt());
672        let (null_f, null_d) = f
673            .iter()
674            .zip(&d)
675            .filter(|(fi, _)| **fi > 0.6 && **fi < 0.9)
676            .min_by(|a, b| a.1.total_cmp(b.1))
677            .map(|(fi, di)| (*fi, *di))
678            .unwrap();
679        assert!((null_f - 0.75).abs() < 0.02, "null at {null_f}");
680        assert!(comnum_math::lin2db(null_d / peak) < -25.0, "null depth {}", comnum_math::lin2db(null_d / peak));
681        let i0 = nfft / 2;
682        let k = (i0..nfft - 1).find(|&i| d[i + 1] < peak / 2.0).unwrap();
683        let half = f[k] + (f[k + 1] - f[k]) * (d[k] - peak / 2.0) / (d[k] - d[k + 1]);
684        assert!((2.0 * half - 0.59).abs() < 0.03, "3 dB bandwidth {}", 2.0 * half);
685        let x = 0.295;
686        assert!((msk_psd(x) / msk_psd(0.0) - 0.5).abs() < 0.01);
687    }
688
689    /// K. Murota and K. Hirade, "GMSK modulation for digital mobile radio telephony,"
690    /// IEEE Trans. Commun. 29(7), 1981: §II gives the Gaussian frequency pulse (area
691    /// 1/2); Table I gives the two-sided bandwidths (`× 1/T`) holding 90, 99, 99.9 and
692    /// 99.99 % of the power, for `BT = 0.2`, 0.25, 0.5 and MSK. The spectra of GMSK are
693    /// estimated (pulse truncated to 5 symbols, Hann window, segments of 128 symbols, 400
694    /// segments): the whole table within 3 %. MSK uses its closed form [`msk_psd`]: 0.777,
695    /// 1.182 and 2.735 against 0.78, 1.20 and 2.76 (within 2 %); its 99.99 % bandwidth is
696    /// 5.67, where the table prints 6.00 (6 % more; the estimated spectrum cannot settle
697    /// it, its window leaks into the tails in `1/f^4`). `BT = 0.3`, absent from the
698    /// table, falls between `BT = 0.25` and `BT = 0.5`.
699    #[test]
700    fn murota_hirade_1981_gmsk_bandwidths() {
701        let sps = 8;
702        let nfft = 128 * sps;
703        let n_seg = 400;
704        let fractions = [0.90, 0.99, 0.999, 0.9999];
705        let bandwidths = |cfg: CpmConfig, seed: u64| {
706            let cpm = cfg.build().unwrap();
707            let mut rng = Rng::new(seed);
708            let a: Vec<usize> = (0..n_seg * 128).map(|_| rng.below(2)).collect();
709            let (f, d) = welch(&cpm.modulate(&a).unwrap(), sps, nfft);
710            fractions.map(|q| power_bandwidth(&f, &d, q))
711        };
712        // Table I: BT, then 90, 99, 99.9 and 99.99 %
713        let table_1 =
714            [(0.2, [0.52, 0.79, 0.99, 1.22]), (0.25, [0.57, 0.86, 1.09, 1.37]), (0.5, [0.69, 1.04, 1.33, 2.08])];
715        let mut b99 = Vec::new();
716        for (seed, (bt, want)) in table_1.into_iter().enumerate() {
717            let got = bandwidths(CpmConfig::gmsk(bt, 5, sps), seed as u64 + 1);
718            for ((q, g), w) in fractions.iter().zip(got).zip(want) {
719                assert!((g / w - 1.0).abs() < 0.03, "BT = {bt}, {q}: {g} vs {w}");
720            }
721            b99.push(got[1]);
722        }
723        let b03 = bandwidths(CpmConfig::gmsk(0.3, 5, sps), 9)[1];
724        assert!(b03 > b99[1] && b03 < b99[2], "BT = 0.3: {b03} between {} and {}", b99[1], b99[2]);
725        // MSK, from its closed form
726        let df = 1e-4;
727        let f: Vec<f64> = (-400_000..400_000).map(|i| i as f64 * df).collect();
728        let d: Vec<f64> = f.iter().map(|v| msk_psd(*v)).collect();
729        for (q, w) in [(0.90, 0.78), (0.99, 1.20), (0.999, 2.76)] {
730            let g = power_bandwidth(&f, &d, q);
731            assert!((g / w - 1.0).abs() < 0.02, "MSK, {q}: {g} vs {w}");
732        }
733        let g = power_bandwidth(&f, &d, 0.9999);
734        assert!((g - 5.67).abs() < 0.03, "MSK, 99.99 %: {g} (printed 6.00)");
735    }
736
737    /// K. Murota and K. Hirade (1981), Fig. 5: the minimum distance of GMSK (normalised
738    /// so that MSK has 2) against `BT`, read on the figure within 0.02: 1.53 at
739    /// `BT = 0.2`, 1.89 at 0.4, 1.96 at 0.6 and 1.99 at 0.8.
740    #[test]
741    fn murota_hirade_1981_fig_5_minimum_distance() {
742        for (bt, want) in [(0.2, 1.53), (0.4, 1.89), (0.6, 1.96), (0.8, 1.99)] {
743            let got = CpmConfig::gmsk(bt, 5, 8).build().unwrap().min_distance2(3).unwrap();
744            assert!((got - want).abs() < 0.02, "BT = {bt}: {got} vs {want}");
745        }
746    }
747
748    /// M. K. Simon and C. C. Wang, "Differential detection of Gaussian MSK in a mobile
749    /// radio environment," IEEE Trans. Veh. Technol. 33(4), 1984, Section II and III:
750    /// one-symbol differential detection decides on the phase increment over a symbol,
751    /// which the partial response of GMSK reduces for some patterns (with `BT = 0.3`
752    /// the worst increment is 0.48 of the `π/2` of MSK), so the error rate over AWGN
753    /// degrades as `BT` decreases, without an error floor, and the predetection filter
754    /// trades noise against its own intersymbol interference. The test checks the
755    /// ordering at `E_b/N_0 = 10 dB` with a Gaussian low-pass of 3 dB bandwidth `0.5/T`
756    /// (the best found for MSK among 0.25 to 1; a band-pass bandwidth of `1/T` in the
757    /// convention of the paper), and plausible ranges: MSK above the coherent
758    /// `Q(√(2 E_b/N_0)) = 3.9e-6` and below 1e-2 (measured 1.3e-3: the filter is not a
759    /// matched filter and its ISI closes the eye, against the ideal noncoherent DBPSK
760    /// rate `½ e^{-E_b/N_0} = 2.3e-5`); GMSK `BT = 0.5` above MSK (measured 7.7e-3),
761    /// `BT = 0.3` above `BT = 0.5` and below 0.1 (measured 4.1e-2). The published curves
762    /// of the paper are not reproduced here: they use a different filter.
763    ///
764    /// 200 000 bits: about 260 errors are expected for MSK, so the ordering is not a
765    /// fluctuation (the standard error of its rate is 6 %).
766    #[test]
767    fn simon_wang_1984_differential_detection_degrades_with_bt() {
768        let sps = 8;
769        let n_bits = 200_000;
770        let ebn0_db = 10.0;
771        let ber = |cfg: CpmConfig, seed: u64| {
772            let cpm = cfg.build().unwrap();
773            let mut rng = Rng::new(seed);
774            let a: Vec<usize> = (0..n_bits).map(|_| rng.below(2)).collect();
775            let r = noisy(&cpm.modulate(&a).unwrap(), sps, ebn0_db, &mut rng);
776            let hat = cpm.differential_detect(&r, Some(0.5)).unwrap();
777            hat.iter().zip(&a).filter(|(x, y)| x != y).count() as f64 / n_bits as f64
778        };
779        let msk = ber(CpmConfig::msk(sps), 10);
780        let gmsk05 = ber(CpmConfig::gmsk(0.5, 4, sps), 11);
781        let gmsk03 = ber(CpmConfig::gmsk(0.3, 4, sps), 12);
782        let coherent = qfunc((2.0 * db2lin(ebn0_db)).sqrt());
783        assert!(msk > coherent && msk < 1e-2, "MSK differential: {msk:e}");
784        assert!(
785            gmsk05 > msk && gmsk03 > gmsk05 && gmsk03 < 0.1,
786            "GMSK: {gmsk03:e} (0.3), {gmsk05:e} (0.5), MSK {msk:e}"
787        );
788    }
789
790    /// T. Aulin and C.-E. W. Sundberg, "Continuous phase modulation—Part I: Full response
791    /// signaling," IEEE Trans. Commun. 29(3), 196–209, 1981, Table I: the upper bound
792    /// $d_{B}^{2}$ of M-ary CPFSK reaches its maximum 2.434 at `h = 0.715` for `M = 2` and
793    /// 4.232 at `h = 0.914` for `M = 4` (here at `h = 5/7` and `32/35`, within 1e-3); with
794    /// `h = 1/2` binary CPFSK is MSK, `d² = 2`, and the gain of `h = 0.715` is 0.85 dB.
795    #[test]
796    fn aulin_sundberg_1981_table_i_optimum_cpfsk() {
797        let bound = |order: usize, h_num: u32, h_den: u32| {
798            CpmConfig { order, h_num, h_den, ..CpmConfig::msk(8) }.build().unwrap().min_distance2(2).unwrap()
799        };
800        let (b2, b4) = (bound(2, 5, 7), bound(4, 32, 35));
801        assert!((b2 - 2.434).abs() < 1e-3 && (b4 - 4.232).abs() < 1e-3, "{b2} and {b4}");
802        assert!((comnum_math::lin2db(b2 / bound(2, 1, 2)) - 0.85).abs() < 0.01);
803    }
804
805    /// C.-E. Sundberg, "Continuous phase modulation," IEEE Commun. Mag. 24(4), 25–38,
806    /// 1986, Fig. 5: the upper bound $d_{B}^{2}$ of binary LRC against $h$, read on the
807    /// figure (within 0.05): at `h = 1/2`, 2 for 1RC (the MSK point, where 2RC lies just
808    /// below) and the dots 1.78, 1.53, 1.32 and 1.15 of 3RC to 6RC; the peaks 2.11 of 1RC
809    /// near `h = 0.6`, 2.66 of 2RC near `h = 0.8` and 3.52 of 3RC near `h = 0.96`.
810    #[test]
811    fn sundberg_1986_fig_5_distance_of_binary_lrc() {
812        let d2 = |length: usize, h_num: u32, h_den: u32| {
813            let cfg = CpmConfig { h_num, h_den, pulse: FrequencyPulse::RaisedCosine, length, ..CpmConfig::msk(8) };
814            cfg.build().unwrap().min_distance2(3).unwrap()
815        };
816        let read = [
817            (1, 1, 2, 2.0),
818            (3, 1, 2, 1.78),
819            (4, 1, 2, 1.53),
820            (5, 1, 2, 1.32),
821            (6, 1, 2, 1.15),
822            (1, 3, 5, 2.11),
823            (2, 4, 5, 2.66),
824            (3, 19, 20, 3.52),
825        ];
826        for (l, p, q, want) in read {
827            let got = d2(l, p, q);
828            assert!((got - want).abs() < 0.05, "{l}RC, h = {p}/{q}: {got} vs {want}");
829        }
830        let two = d2(2, 1, 2);
831        assert!(two < 2.0 && two > 1.9, "2RC, h = 1/2: {two}");
832    }
833
834    /// J. B. Anderson, T. Aulin and C.-E. Sundberg, *Digital Phase Modulation*, Plenum,
835    /// 1986, ch. 3 (also Aulin & Sundberg, IEEE Trans. Commun. 29(3), 1981, Section III):
836    /// the minimum normalised squared Euclidean distance of binary 1REC CPM with
837    /// `h = 1/2` (MSK) is `d²_min = 2`, that of antipodal signalling, reached by the
838    /// merge `γ = (+2, -2)`; the upper bound of binary CPFSK from that merge,
839    /// `d²_B(h) = 2(1 - sin(2πh)/(2πh))`, gives 0.727 at `h = 1/4` and reaches its
840    /// maximum 2.43 near `h = 0.715` (Proakis & Salehi, 5th ed., ch. 4, the distance
841    /// bound of CPFSK; the test uses `h = 5/7`, where the bound is 2.434).
842    #[test]
843    fn anderson_aulin_sundberg_minimum_distance() {
844        let msk = CpmConfig::msk(4).build().unwrap();
845        assert!((msk.min_distance2(4).unwrap() - 2.0).abs() < 1e-3, "{}", msk.min_distance2(4).unwrap());
846        for (p, q) in [(1u32, 4u32), (5, 7)] {
847            let h = f64::from(p) / f64::from(q);
848            let want = 2.0 * (1.0 - comnum_math::sinc(2.0 * h));
849            let cpm = CpmConfig { h_num: p, h_den: q, ..CpmConfig::msk(4) }.build().unwrap();
850            let got = cpm.min_distance2(2).unwrap();
851            // Simpson on 256 intervals per symbol: within 1e-9 of the closed form
852            assert!((got - want).abs() < 1e-9, "h = {p}/{q}: {got} vs {want}");
853        }
854        // the ML detector of 4-ary 1REC with h = 1/4 decodes a noiseless sequence
855        let cpm = CpmConfig { order: 4, h_num: 1, h_den: 4, ..CpmConfig::msk(4) }.build().unwrap();
856        assert_eq!(cpm.n_states(), 8);
857        let mut rng = Rng::new(9);
858        let a: Vec<usize> = (0..2000).map(|_| rng.below(4)).collect();
859        assert_eq!(cpm.ml_detect(&cpm.modulate(&a).unwrap()).unwrap(), a);
860    }
861}