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}