comnum_waveform/cpm/detection.rs
1//! The receivers of CPM: the maximum-likelihood sequence detector, the quadrature
2//! detector of MSK and its precoder, the differential detector, and the minimum
3//! distance.
4
5use super::{Cpm, FrequencyPulse};
6use comnum_core::{Error, Result, ensure, ensure_shape};
7use comnum_dsp::filter::{LowPass, fir_filter, low_pass_fir};
8use comnum_math::{C64, cis};
9use std::f64::consts::{PI, TAU};
10
11/// The survivor path of the maximum-likelihood sequence detector.
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct MlPath {
14 /// The symbol indices $i_{n}$ ($a_{n} = 2 i_{n} - (M - 1)$).
15 pub symbols: Vec<usize>,
16 /// The terminal phase states $\theta_{n}$ in units of $\pi/q$, modulo $2q$, at the
17 /// start of every interval and after the last one (`symbols.len() + 1` values).
18 pub phase_states: Vec<usize>,
19}
20
21impl Cpm {
22 /// The largest number of difference sequences [`Cpm::min_distance2`] visits.
23 pub const MAX_DIFFERENCE_SEQUENCES: usize = 1 << 20;
24
25 /// The number of whole symbols of a received signal.
26 fn n_symbols(&self, r: &[C64]) -> Result<usize> {
27 let sps = self.cfg.sps;
28 ensure_shape(r.len() >= sps && r.len() % sps == 0, || {
29 format!("{} samples is not a positive multiple of {sps} samples per symbol", r.len())
30 })?;
31 Ok(r.len() / sps)
32 }
33
34 /// The conjugated partial-response waveforms of every combination of a window
35 /// (`combo = i_new + M · past`).
36 fn branch_waveforms(&self) -> Vec<Vec<C64>> {
37 let (m, l, sps) = (self.cfg.order, self.cfg.length, self.cfg.sps);
38 let mut window = vec![0usize; l];
39 (0..m * self.past_combos)
40 .map(|combo| {
41 let mut c = combo;
42 for w in &mut window {
43 *w = c % m;
44 c /= m;
45 }
46 (0..sps).map(|t| cis(-self.partial_phase(&window, t))).collect()
47 })
48 .collect()
49 }
50
51 /// The coherent maximum-likelihood sequence detector (Viterbi algorithm over the
52 /// phase trellis): the symbol indices of the most likely sequence, starting from the
53 /// initial state of the modulator and ending in any state.
54 ///
55 /// # Errors
56 /// [`comnum_core::Error::Shape`] unless `r` holds a positive whole number of symbols.
57 ///
58 /// # Examples
59 /// ```
60 /// use comnum_waveform::cpm::{CpmConfig, FrequencyPulse};
61 /// let cpm = CpmConfig { order: 4, h_num: 1, h_den: 4, pulse: FrequencyPulse::RaisedCosine, length: 2, sps: 4 }.build()?;
62 /// let a = vec![0, 3, 1, 2, 2, 0, 1, 3, 3, 0];
63 /// assert_eq!(cpm.ml_detect(&cpm.modulate(&a)?)?, a);
64 /// # Ok::<(), comnum_core::Error>(())
65 /// ```
66 pub fn ml_detect(&self, r: &[C64]) -> Result<Vec<usize>> {
67 Ok(self.ml_detect_path(r)?.symbols)
68 }
69
70 /// The maximum-likelihood sequence with its terminal phase states (see [`MlPath`]).
71 ///
72 /// # Errors
73 /// As [`Cpm::ml_detect`].
74 pub fn ml_detect_path(&self, r: &[C64]) -> Result<MlPath> {
75 let n_sym = self.n_symbols(r)?;
76 let (m, l, sps) = (self.cfg.order, self.cfg.length, self.cfg.sps);
77 let n_phase = self.n_phase_states();
78 let n_states = self.n_states();
79 let pc = self.past_combos;
80 let waves = self.branch_waveforms();
81 let rot: Vec<C64> = (0..n_phase).map(|s| cis(-PI * s as f64 / f64::from(self.cfg.h_den))).collect();
82 // the initial state: phase 0, past symbols +1
83 let past0 = (0..l.saturating_sub(1)).fold(0usize, |acc, _| acc * m + self.one());
84 let mut metric = vec![f64::NEG_INFINITY; n_states];
85 metric[past0] = 0.0;
86 let mut next = vec![f64::NEG_INFINITY; n_states];
87 let mut prev = vec![0u32; n_sym * n_states];
88 let mut sym = vec![0u8; n_sym * n_states];
89 let mut corr = vec![C64::new(0.0, 0.0); waves.len()];
90 for n in 0..n_sym {
91 let seg = &r[n * sps..(n + 1) * sps];
92 for (c, w) in corr.iter_mut().zip(&waves) {
93 *c = seg.iter().zip(w).map(|(a, b)| a * b).sum();
94 }
95 next.fill(f64::NEG_INFINITY);
96 for state in 0..n_states {
97 let acc = metric[state];
98 if acc == f64::NEG_INFINITY {
99 continue;
100 }
101 let (s, past) = (state / pc, state % pc);
102 for i in 0..m {
103 let combo = i + m * past;
104 let branch = acc + (corr[combo] * rot[s]).re;
105 let leaving = if l == 1 { i } else { past / m.pow((l - 2) as u32) };
106 let s_next = self.next_phase(s, leaving);
107 let past_next = if l == 1 { 0 } else { (i + m * past) % pc };
108 let to = s_next * pc + past_next;
109 if branch > next[to] {
110 next[to] = branch;
111 prev[n * n_states + to] = state as u32;
112 sym[n * n_states + to] = i as u8;
113 }
114 }
115 }
116 // keep the metrics bounded over long sequences
117 let best = next.iter().copied().fold(f64::NEG_INFINITY, f64::max);
118 for v in &mut next {
119 *v -= best;
120 }
121 std::mem::swap(&mut metric, &mut next);
122 }
123 let mut state = (0..n_states).max_by(|&a, &b| metric[a].total_cmp(&metric[b])).unwrap_or(0);
124 let mut symbols = vec![0usize; n_sym];
125 let mut phase_states = vec![0usize; n_sym + 1];
126 phase_states[n_sym] = state / pc;
127 for n in (0..n_sym).rev() {
128 symbols[n] = sym[n * n_states + state] as usize;
129 state = prev[n * n_states + state] as usize;
130 phase_states[n] = state / pc;
131 }
132 Ok(MlPath { symbols, phase_states })
133 }
134
135 /// Whether the signal is MSK (binary 1REC with $h = 1/2$).
136 fn ensure_msk(&self) -> Result<()> {
137 let c = &self.cfg;
138 ensure(
139 c.order == 2 && c.length == 1 && c.pulse == FrequencyPulse::Rectangular && c.h_num == 1 && c.h_den == 2,
140 || "the quadrature detector needs MSK (binary 1REC, h = 1/2)".into(),
141 )
142 }
143
144 /// The quadrature detector of MSK: the boundary bits $d_{n}$, $n = 1 \dots N$
145 /// ($d_{n} = +1$ is the bit 1), decided from
146 /// $\operatorname{Re}\left[(-j)^{n} \int_{(n-1)T}^{(n+1)T} r(t) \cos \frac{\pi (t - nT)}{2T} \\, dt\right]$
147 /// (the last window is truncated to the signal). Over AWGN, each decision has the
148 /// error probability $Q(\sqrt{2 E_{b}/N_{0}})$ of antipodal signalling.
149 ///
150 /// # Errors
151 /// [`comnum_core::Error::Config`] unless the signal is MSK, [`comnum_core::Error::Shape`]
152 /// unless it holds a whole number of symbols.
153 pub fn msk_detect(&self, r: &[C64]) -> Result<Vec<u8>> {
154 self.ensure_msk()?;
155 let n_sym = self.n_symbols(r)?;
156 let sps = self.cfg.sps;
157 let (mut rotate, step) = (C64::new(1.0, 0.0), C64::new(0.0, -1.0));
158 let mut bits = Vec::with_capacity(n_sym);
159 for n in 1..=n_sym {
160 rotate *= step;
161 let centre = n * sps;
162 let lo = centre - sps;
163 let hi = (centre + sps).min(r.len());
164 let z: C64 = (lo..hi).map(|t| r[t] * (PI * (t as f64 - centre as f64) / (2.0 * sps as f64)).cos()).sum();
165 bits.push(u8::from((z * rotate).re > 0.0));
166 }
167 Ok(bits)
168 }
169
170 /// Whether the signal is binary (the differential detector).
171 fn ensure_binary(&self) -> Result<()> {
172 ensure(self.cfg.order == 2, || "the differential detector needs a binary signal".into())
173 }
174
175 /// The one-symbol differential phase detector of binary CPM: after an optional
176 /// low-pass predetection filter of normalised 3 dB bandwidth `rx_bt` (`None` for no
177 /// filter; the band-pass bandwidth $B_{i}$ of Simon & Wang, 1984, is twice this
178 /// value, and about $0.5/T$ is the best choice for MSK), the
179 /// signal is sampled every symbol at $t_{n} = (n + (L + 1)/2) T$, where the phase
180 /// increment $\varphi(t_{n}) - \varphi(t_{n-1})$ depends most on $a_{n}$ (for an even
181 /// $L$ and an odd `sps` the instant is rounded down to a sample), and
182 /// $\hat{a}_{n} = \operatorname{sign} \operatorname{Im} (r_{n} r_{n-1}^{\ast})$. A
183 /// symbol whose instant falls after the end of the signal uses its last sample.
184 ///
185 /// For MSK without filter the increment is exactly $\pi a_{n}/2$; the partial
186 /// response of GMSK closes the eye, so the detector degrades with $BT$.
187 ///
188 /// # Errors
189 /// [`comnum_core::Error::Config`] unless the signal is binary or if `rx_bt` is not
190 /// positive, [`comnum_core::Error::Shape`] unless it holds a whole number of symbols.
191 pub fn differential_detect(&self, r: &[C64], rx_bt: Option<f64>) -> Result<Vec<usize>> {
192 self.ensure_binary()?;
193 let n_sym = self.n_symbols(r)?;
194 let (l, sps) = (self.cfg.length, self.cfg.sps);
195 let filtered;
196 let r = match rx_bt {
197 Some(bt) => {
198 ensure(bt > 0.0 && bt.is_finite(), || format!("the receive bandwidth must be positive, got {bt}"))?;
199 let taps = low_pass_fir(bt, sps as f64, 4 * sps + 1, LowPass::Gauss);
200 filtered = fir_filter(&taps, r);
201 &filtered[..]
202 }
203 None => r,
204 };
205 let offset = (l + 1) * sps / 2;
206 let at = |k: usize| r[k.min(r.len() - 1)];
207 let sample = |n: usize| at(n * sps + offset);
208 let mut prev = at(offset - sps);
209 let mut out = Vec::with_capacity(n_sym);
210 for n in 0..n_sym {
211 let cur = sample(n);
212 out.push(usize::from((cur * prev.conj()).im > 0.0));
213 prev = cur;
214 }
215 Ok(out)
216 }
217
218 /// The normalised minimum squared Euclidean distance over the pairs of sequences
219 /// that diverge at $t = 0$ and merge again within `max_len` symbols (the upper bound
220 /// $d_{B}^{2}$ of Anderson, Aulin & Sundberg, ch. 3, which is the true $d_{\min}^{2}$
221 /// when the shortest merge is the closest one, as for MSK):
222 ///
223 /// $$
224 /// d^{2} = \frac{\log_{2} M}{T} \int_{0}^{(N + L - 1) T} \left[1 - \cos \Delta \varphi(t)\right] dt,
225 /// \qquad \Delta \varphi(t) = 2 \pi h \sum_{k=0}^{N-1} \gamma_{k} \\, q(t - kT),
226 /// $$
227 ///
228 /// over the difference sequences $\gamma_{k} = a_{k} - a_{k}^{\prime} \in \\{0, \pm 2,
229 /// \dots, \pm 2(M-1)\\}$ with $\gamma_{0} \ne 0$ and $\pi h \sum_{k} \gamma_{k}
230 /// \equiv 0 \pmod{2 \pi}$ (the merge). The integral is evaluated by Simpson's rule
231 /// with 256 intervals per symbol (within $10^{-9}$ of the closed form $2(1 -
232 /// \operatorname{sinc} 2h)$ of binary CPFSK); it is infinite if no sequence merges
233 /// within `max_len` symbols. With this normalisation antipodal signalling has
234 /// $d^{2} = 2$. The search visits $(2M - 1)^{N}$ difference sequences for `max_len`
235 /// $= N$, at most [`Cpm::MAX_DIFFERENCE_SEQUENCES`]; with `max_len` $= 0$ it visits
236 /// none and returns infinity.
237 ///
238 /// # Errors
239 /// [`Error::Config`] if $(2M - 1)^{N}$ exceeds [`Cpm::MAX_DIFFERENCE_SEQUENCES`] (at
240 /// `max_len` = 20, binary CPM would visit $3.5 \cdot 10^{9}$ sequences).
241 ///
242 /// # Examples
243 /// ```
244 /// use comnum_waveform::cpm::CpmConfig;
245 /// let msk = CpmConfig::msk(4).build()?;
246 /// assert!((msk.min_distance2(3)? - 2.0).abs() < 1e-3);
247 /// assert!(msk.min_distance2(20).is_err());
248 /// # Ok::<(), comnum_core::Error>(())
249 /// ```
250 pub fn min_distance2(&self, max_len: usize) -> Result<f64> {
251 let (m, l) = (self.cfg.order, self.cfg.length);
252 let n_diff = 2 * m - 1;
253 let res = 256usize;
254 let h = self.h();
255 let unit = i64::from(self.cfg.h_den) * 2;
256 let mut best = f64::INFINITY;
257 if max_len == 0 {
258 return Ok(best);
259 }
260 let n_codes = u32::try_from(max_len)
261 .ok()
262 .and_then(|e| n_diff.checked_pow(e))
263 .filter(|&n| n <= Cpm::MAX_DIFFERENCE_SEQUENCES)
264 .ok_or_else(|| {
265 Error::Config(format!(
266 "{n_diff}^{max_len} difference sequences exceed the {} of the search",
267 Cpm::MAX_DIFFERENCE_SEQUENCES
268 ))
269 })?;
270 let mut gamma = vec![0i64; max_len];
271 for code in 0..n_codes {
272 let mut c = code;
273 for g in &mut gamma {
274 *g = (c % n_diff) as i64 - (m as i64 - 1);
275 c /= n_diff;
276 }
277 if gamma[0] == 0 {
278 continue;
279 }
280 // the merge: p Σ γ ≡ 0 (mod 2q), γ in units of 2 (the differences are even)
281 let total: i64 = gamma.iter().sum();
282 if (i64::from(self.cfg.h_num) * 2 * total).rem_euclid(unit) != 0 {
283 continue;
284 }
285 let span = max_len + l - 1;
286 let integral = comnum_math::simpson(
287 |t| {
288 let dphi: f64 = gamma
289 .iter()
290 .enumerate()
291 .map(|(i, &g)| 2.0 * g as f64 * self.cfg.pulse.phase_response(t - i as f64, l))
292 .sum::<f64>()
293 * TAU
294 * h;
295 1.0 - dphi.cos()
296 },
297 0.0,
298 span as f64,
299 span * res,
300 );
301 best = best.min((m as f64).log2() * integral);
302 }
303 Ok(best)
304 }
305}
306
307/// The differential precoder of MSK: the information bits $b_{1} \dots b_{N}$ give the
308/// boundary signs $d_{n} = 2 b_{n} - 1$ with $d_{0} = +1$, and the CPM symbols
309/// $a_{n} = d_{n} d_{n+1}$ (as indices, $i_{n} = (a_{n} + 1)/2$), so that the signal
310/// equals OQPSK with half-sinusoid pulses carrying $d_{n}$ on the in-phase component at
311/// even $n$ and on the quadrature component at odd $n$ (Proakis & Salehi, §3.3-2).
312///
313/// # Examples
314/// ```
315/// use comnum_waveform::cpm::msk_precode;
316/// assert_eq!(msk_precode(&[1, 1, 0, 0, 1]), vec![1, 1, 0, 1, 0]);
317/// ```
318pub fn msk_precode(bits: &[u8]) -> Vec<usize> {
319 let mut d_prev = 1i8;
320 bits.iter()
321 .map(|&b| {
322 let d = if b & 1 == 1 { 1i8 } else { -1 };
323 let a = d_prev * d;
324 d_prev = d;
325 usize::from(a > 0)
326 })
327 .collect()
328}
329
330/// The information bits of MSK from the terminal phase states of a survivor path
331/// ([`MlPath::phase_states`], in units of $\pi/2$): $d_{n} = \operatorname{Re}
332/// (e^{j \theta_{n}} (-j)^{n})$ for $n = 1 \dots N$, the inverse of [`msk_precode`].
333///
334/// # Examples
335/// ```
336/// use comnum_waveform::cpm::{CpmConfig, msk_bits, msk_precode};
337/// let msk = CpmConfig::msk(4).build()?;
338/// let bits = [1u8, 0, 1, 1, 0, 0, 0, 1];
339/// let path = msk.ml_detect_path(&msk.modulate(&msk_precode(&bits))?)?;
340/// assert_eq!(msk_bits(&path.phase_states), bits);
341/// # Ok::<(), comnum_core::Error>(())
342/// ```
343pub fn msk_bits(phase_states: &[usize]) -> Vec<u8> {
344 phase_states.iter().enumerate().skip(1).map(|(n, &s)| u8::from((s + 4 - n % 4) % 4 == 0)).collect()
345}