Skip to main content

comnum_spec/blocks/
dsp.rs

1//! Receiver DSP: shaping and matched filtering, resampling and decimation, clock recovery, adaptive equalisation, carrier recovery, frame synchronisation.
2
3use super::schema::ParamType;
4use super::{Block, Extension, Frame, NumberOrList, part, per_pol};
5use comnum_core::Error;
6use comnum_core::Result;
7use comnum_core::ensure;
8use comnum_core::rng::Rng;
9use comnum_dsp::adaptive::UpdateRule;
10use comnum_dsp::equalizer::Algorithm;
11use comnum_dsp::equalizer::Singularity;
12use comnum_dsp::equalizer::{AdaptiveEqualizer, EqualizerOutput};
13use comnum_dsp::filter;
14use comnum_dsp::filter::PulseShape;
15use comnum_dsp::resample;
16use comnum_dsp::signal;
17use comnum_dsp::sync::carrier;
18use comnum_dsp::sync::frame;
19use comnum_dsp::sync::timing::GardnerLoop;
20use comnum_math::C64;
21use comnum_math::dbm2w;
22use serde::Deserialize;
23use serde_json::Value as Json;
24use std::f64::consts::{FRAC_PI_2, PI};
25
26/// The blocks of this module: their documentation and their builders.
27pub(super) fn blocks() -> Vec<Extension> {
28    vec![
29        builtin!(
30            per_polarization "pulse_shape",
31            "Upsample and filter with a transmit pulse, then normalise to unit power.",
32            PulseSpec,
33            needs [Samples],
34            gives [],
35            |q, _, _| Ok(Box::new(PulseShaping::new(&q)?)),
36        ),
37        builtin!(
38            "normalize", "Scale the samples to a mean power.", Normalize, needs [Samples], gives [],
39            |q, _, _| Ok(Box::new(q)),
40        ),
41        builtin!(
42            per_polarization "resample",
43            "Band-limited resampling of the block (FFT) to a number of samples per symbol.",
44            Resample,
45            needs [Samples],
46            gives [],
47            |q, _, _| Ok(Box::new(q)),
48        ),
49        builtin!(
50            per_polarization "matched_filter",
51            "Filter with a receive pulse at the current samples per symbol.",
52            MatchedSpec,
53            needs [Samples],
54            gives [],
55            |q, _, _| Ok(Box::new(MatchedFilter::new(q)?)),
56        ),
57        builtin!(
58            per_polarization "decimate", "Reduce the samples per symbol.", DecimateSpec, needs [Samples], gives [],
59            |q, _, _| Ok(Box::new(Decimate::new(&q))),
60        ),
61        builtin!(
62            per_polarization "gardner", "Gardner clock recovery at 2 samples per symbol.", Gardner, needs [Samples], gives [],
63            |q, _, _| Ok(Box::new(q)),
64        ),
65        builtin!(
66            "adaptive_equalizer",
67            "Adaptive FIR equaliser, output at 1 sample per symbol; the n × n butterfly over the polarisations or antennas.",
68            EqualizerSpec,
69            needs [Samples, Constellation],
70            gives [],
71            |q, label, _| Ok(Box::new(Equalizer::new(&q, label)?)),
72        ),
73        builtin!(
74            per_polarization "foe",
75            "Frequency-offset estimation by the M-th power and correction; records foe_hz.",
76            FoeSpec,
77            needs [Samples],
78            gives [],
79            |q, label, _| Ok(Box::new(Foe { p: q, label })),
80        ),
81        builtin!(
82            per_polarization "bps",
83            "Blind phase search, unwrapped with a period of π/2, and correction.",
84            Bps,
85            needs [Samples, Constellation],
86            gives [],
87            |q, _, _| Ok(Box::new(q)),
88        ),
89        builtin!(
90            per_polarization "viterbi", "Viterbi–Viterbi phase estimation and correction.", Viterbi, needs [Samples], gives [],
91            |q, _, _| Ok(Box::new(q)),
92        ),
93        builtin!(
94            per_polarization "ddpll", "Decision-directed phase-locked loop.", Ddpll, needs [Samples, Constellation], gives [],
95            |q, _, _| Ok(Box::new(q)),
96        ),
97        builtin!(
98            "frame_sync",
99            "Delay and rotation on known symbols, output aligned on the transmitted symbols; records delay and quality.",
100            FrameSyncSpec,
101            needs [Samples, Symbols],
102            gives [],
103            |q, label, _| Ok(Box::new(FrameSync { p: q, label })),
104        ),
105    ]
106}
107
108block_params! {
109    /// A pulse: the parameters shared by `pulse_shape`, `matched_filter` and `wdm_tx`.
110    #[derive(Clone, Debug)]
111    pub(super) struct PulseSettings {
112        kind: String ["rrc"] one_of ["rect", "nrz", "rc", "rrc", "rz"], "the pulse";
113        rolloff: f64 [0.1] >= 0 <= 1, "roll-off of rc and rrc";
114        n_taps: usize [1025] >= 1, "taps of rc and rrc (odd: on the sampling instants)";
115    }
116}
117
118impl PulseSettings {
119    /// The taps of the pulse at `sps` samples per symbol.
120    pub(super) fn taps(&self, sps: usize) -> Result<Vec<f64>> {
121        ensure(sps >= 1, || "sps must be positive".into())?;
122        filter::pulse_shape(PulseShape::from_name(&self.kind, self.rolloff)?, sps, self.n_taps)
123    }
124}
125
126block_params! {
127    struct PulseSpec {
128        pulse: PulseSettings [flatten], "the transmit pulse";
129        sps: usize [2] >= 1, "samples per symbol";
130    }
131}
132
133struct PulseShaping {
134    taps: Vec<f64>,
135    sps: usize,
136}
137
138impl PulseShaping {
139    fn new(p: &PulseSpec) -> Result<PulseShaping> {
140        Ok(PulseShaping { taps: p.pulse.taps(p.sps)?, sps: p.sps })
141    }
142}
143
144impl Block for PulseShaping {
145    fn apply(&self, s: &mut Frame, _: &mut Rng) -> Result<()> {
146        let k = s.int_sps("pulse_shape")?;
147        ensure(k == 1, || "pulse_shape expects symbols (1 sample per symbol)".into())?;
148        s.samples = signal::pnorm(&filter::upfirdn(&self.taps, &s.samples, self.sps, 1));
149        s.sps = self.sps as f64;
150        Ok(())
151    }
152}
153
154block_params! {
155    struct Normalize {
156        power_dbm: Option<f64> [absent "unit power"] "dBm", "mean power";
157    }
158}
159
160impl Block for Normalize {
161    /// Every sample divided by the RMS value over all the streams (as
162    /// `comnum_dsp::signal::pnorm` for one stream), then multiplied by the amplitude of
163    /// the power.
164    fn apply(&self, s: &mut Frame, _: &mut Rng) -> Result<()> {
165        let a = self.power_dbm.map_or(1.0, |p| dbm2w(p).sqrt());
166        let rms = s.mean_power().sqrt();
167        s.streams_mut().flatten().for_each(|v| *v = if rms == 0.0 { *v * a } else { *v / rms * a });
168        Ok(())
169    }
170}
171
172block_params! {
173    struct MatchedSpec {
174        pulse: PulseSettings [flatten], "the receive pulse";
175    }
176}
177
178/// The matched filter: its taps at the samples per symbol of the signal.
179struct MatchedFilter(PulseSettings);
180
181impl MatchedFilter {
182    fn new(p: MatchedSpec) -> Result<MatchedFilter> {
183        p.pulse.taps(2)?;
184        Ok(MatchedFilter(p.pulse))
185    }
186}
187
188impl Block for MatchedFilter {
189    fn apply(&self, s: &mut Frame, _: &mut Rng) -> Result<()> {
190        let sps = s.int_sps("matched_filter")?;
191        let h = self.0.taps(sps)?;
192        s.samples = filter::fir_filter(&h, &s.samples);
193        Ok(())
194    }
195}
196
197block_params! {
198    struct DecimateSpec {
199        sps_out: usize [1] >= 1, "output samples per symbol";
200        method: DecimationMethod ["max_variance"] one_of ["max_variance", "offset"],
201            "max_variance (OptiCommPy decimate) or offset";
202        offset: usize [0], "sampling phase of the offset method";
203    }
204}
205
206/// The sampling phase of `decimate`.
207#[derive(Clone, Copy, PartialEq, Eq, Deserialize)]
208#[serde(rename_all = "snake_case")]
209enum DecimationMethod {
210    /// The phase of maximum variance (OptiCommPy `decimate`).
211    MaxVariance,
212    /// A given phase.
213    Offset,
214}
215
216impl ParamType for DecimationMethod {
217    fn schema() -> Json {
218        String::schema()
219    }
220}
221
222struct Decimate {
223    sps_out: usize,
224    offset: Option<usize>,
225}
226
227impl Decimate {
228    fn new(p: &DecimateSpec) -> Decimate {
229        let offset = match p.method {
230            DecimationMethod::MaxVariance => None,
231            DecimationMethod::Offset => Some(p.offset),
232        };
233        Decimate { sps_out: p.sps_out, offset }
234    }
235}
236
237impl Block for Decimate {
238    fn apply(&self, s: &mut Frame, _: &mut Rng) -> Result<()> {
239        let sps = s.int_sps("decimate")?;
240        ensure(sps % self.sps_out == 0, || format!("decimate: {} does not divide {sps}", self.sps_out))?;
241        let n = s.samples.len() / sps * sps;
242        s.samples.truncate(n);
243        s.samples = match self.offset {
244            None => resample::decimate(&s.samples, sps, self.sps_out)?,
245            Some(o) => resample::downsample(&s.samples, sps / self.sps_out, o),
246        };
247        s.sps = self.sps_out as f64;
248        Ok(())
249    }
250}
251
252block_params! {
253    struct Gardner {
254        kp: f64 [1e-3], "proportional gain";
255        ki: f64 [1e-6], "integral gain";
256        nyquist: bool [true], "detector for Nyquist pulses";
257    }
258}
259
260impl Block for Gardner {
261    fn apply(&self, s: &mut Frame, _: &mut Rng) -> Result<()> {
262        ensure(s.int_sps("gardner")? == 2, || "gardner needs 2 samples per symbol".into())?;
263        let g = GardnerLoop { kp: self.kp, ki: self.ki, nyquist: self.nyquist, max_ppm: 500.0 };
264        let mut y = g.run(&signal::pnorm(&s.samples)).signal;
265        y.truncate(y.len() / 2 * 2);
266        s.samples = y;
267        Ok(())
268    }
269}
270
271block_params! {
272    struct EqualizerSpec {
273        algorithm: Schedule ["cma"], "cma, rde, nlms, dd-lms, or a schedule [[algorithm, symbols], …]";
274        n_taps: usize [15] >= 1, "taps";
275        mu: NumberOrList [1e-3] > 0,
276            "step size, or one per stage of the schedule (the last one holds for the following stages)";
277        passes: usize [1] >= 1, "passes over the block";
278        update_rule: Option<String> [absent] one_of ["lms", "nlms", "sign"],
279            "the update rule of every stage instead of that of its algorithm: sign for the sign-error update";
280        kikuchi: Option<usize> [absent],
281            "2 × 2: Kikuchi's initialisation against the singularity, output 0 alone during this number of symbols, then output 1 as its unitary complement; records correlation and singular";
282    }
283}
284
285json_param! {
286    /// The algorithm of `adaptive_equalizer`: a name, or a schedule of stages.
287    Schedule,
288    serde_json::json!({"anyOf": [
289        {"type": "string"},
290        {"type": "array", "items": {"type": "array", "prefixItems": [{"type": "string"}, {"type": "integer", "minimum": 0}], "minItems": 2, "maxItems": 2}}
291    ]})
292}
293
294struct Equalizer {
295    schedule: Vec<(Algorithm, usize)>,
296    n_taps: usize,
297    mu: Vec<f64>,
298    passes: usize,
299    singularity: Singularity,
300    update_rule: Option<UpdateRule>,
301    label: String,
302}
303
304impl Equalizer {
305    fn new(p: &EqualizerSpec, label: String) -> Result<Equalizer> {
306        let schedule = match &p.algorithm.0 {
307            Json::String(a) => vec![(a.parse()?, usize::MAX)],
308            Json::Array(steps) => steps
309                .iter()
310                .map(|st| match st {
311                    Json::Array(pair) if pair.len() == 2 => match (pair[0].as_str(), pair[1].as_u64()) {
312                        (Some(a), Some(n)) => Ok((a.parse()?, n as usize)),
313                        _ => Err(Error::Config(format!("a schedule step is [algorithm, symbols], got {st}"))),
314                    },
315                    _ => Err(Error::Config(format!("a schedule step is [algorithm, symbols], got {st}"))),
316                })
317                .collect::<Result<_>>()?,
318            other => return Err(Error::Config(format!("algorithm must be a name or a schedule, got {other}"))),
319        };
320        let singularity = p.kikuchi.map_or(Singularity::None, Singularity::Kikuchi);
321        let mu: Vec<f64> = match &p.mu.0 {
322            Json::Array(v) => v
323                .iter()
324                .map(|x| x.as_f64().ok_or_else(|| Error::Config("adaptive_equalizer: mu must be numbers".into())))
325                .collect::<Result<_>>()?,
326            v => vec![v.as_f64().ok_or_else(|| Error::Config("adaptive_equalizer: mu must be a number".into()))?],
327        };
328        ensure(!mu.is_empty() && mu.iter().all(|m| *m > 0.0), || {
329            "adaptive_equalizer: mu must be positive step sizes".into()
330        })?;
331        let update_rule = p.update_rule.as_deref().map(str::parse).transpose()?;
332        Ok(Equalizer { schedule, n_taps: p.n_taps, mu, passes: p.passes, singularity, update_rule, label })
333    }
334}
335
336impl Block for Equalizer {
337    fn apply(&self, s: &mut Frame, _: &mut Rng) -> Result<()> {
338        let sps = s.int_sps("adaptive_equalizer")?;
339        let eq = AdaptiveEqualizer {
340            n_taps: self.n_taps,
341            sps,
342            mu: self.mu[0],
343            stage_mu: self.mu.clone(),
344            schedule: self.schedule.clone(),
345            passes: self.passes,
346            singularity: self.singularity,
347            update_rule: self.update_rule,
348        };
349        let c = s.constellation("adaptive_equalizer")?.clone();
350        // every polarisation (or antenna) is a mode of the butterfly
351        let n = s.n_streams();
352        let needs_ref = self.schedule.iter().any(|(a, _)| *a == Algorithm::Nlms);
353        let reference: Vec<Vec<C64>> = if needs_ref {
354            (0..n).map(|i| part(&s.symbols, i, n, "symbols")).collect::<Result<_>>()?
355        } else {
356            Vec::new()
357        };
358        let x: Vec<Vec<C64>> = s.streams().map(|v| signal::pnorm(v)).collect();
359        let out = eq.run(&x, if needs_ref { Some(&reference) } else { None }, c.points())?;
360        if let Some(rho) = out.output_correlation() {
361            s.record(&self.label, "correlation", rho);
362            s.record(&self.label, "singular", f64::from(u8::from(rho > EqualizerOutput::SINGULAR_CORRELATION)));
363        }
364        s.set_streams(out.symbols);
365        s.sps = 1.0;
366        Ok(())
367    }
368}
369
370block_params! {
371    struct FoeSpec {
372        m: u32 [4] >= 1, "rotational symmetry";
373        refined: bool [true], "refine the periodogram peak between the bins";
374    }
375}
376
377struct Foe {
378    p: FoeSpec,
379    label: String,
380}
381
382impl Block for Foe {
383    fn apply(&self, s: &mut Frame, _: &mut Rng) -> Result<()> {
384        let fs = s.fs();
385        let fo = if self.p.refined {
386            carrier::fourth_power_foe_refined(&s.samples, fs, self.p.m)?
387        } else {
388            carrier::fourth_power_foe(&s.samples, fs, self.p.m)?
389        };
390        s.samples = signal::freq_shift(&s.samples, -fo, fs);
391        s.record(&self.label, "foe_hz", fo);
392        Ok(())
393    }
394}
395
396block_params! {
397    struct Bps {
398        n: usize [35] "symbols", "half window";
399        b: usize [64] >= 1, "test phases";
400    }
401}
402
403impl Block for Bps {
404    fn apply(&self, s: &mut Frame, _: &mut Rng) -> Result<()> {
405        let pts = s.constellation("bps")?.points().to_vec();
406        let z = signal::pnorm(&s.samples);
407        let theta = carrier::unwrap(&carrier::bps(&z, self.n, &pts, self.b)?, FRAC_PI_2);
408        s.samples = carrier::derotate(&z, &theta, 1.0)?;
409        Ok(())
410    }
411}
412
413block_params! {
414    struct Viterbi {
415        n: usize [35] "symbols", "window";
416        m: u32 [4] >= 1, "power";
417    }
418}
419
420impl Block for Viterbi {
421    fn apply(&self, s: &mut Frame, _: &mut Rng) -> Result<()> {
422        let theta = carrier::viterbi_viterbi(&s.samples, self.n, self.m)?;
423        s.samples = carrier::derotate(&s.samples, &theta, 1.0)?;
424        Ok(())
425    }
426}
427
428block_params! {
429    struct Ddpll {
430        kv: f64 [0.1], "VCO gain";
431        tau1_s: f64 [1.0 / (2.0 * PI * 10e6) => "1/(2π 10 MHz)"] "s" > 0, "loop-filter time constant";
432        tau2_s: f64 [1.0 / (2.0 * PI * 10e6) => "1/(2π 10 MHz)"] "s" > 0, "loop-filter time constant";
433    }
434}
435
436impl Block for Ddpll {
437    fn apply(&self, s: &mut Frame, _: &mut Rng) -> Result<()> {
438        let pts = s.constellation("ddpll")?.points().to_vec();
439        let z = signal::pnorm(&s.samples);
440        let theta = carrier::ddpll(&z, 1.0 / s.symbol_rate, self.kv, self.tau1_s, self.tau2_s, &pts, None)?;
441        s.samples = carrier::derotate(&z, &theta, 1.0)?;
442        Ok(())
443    }
444}
445
446block_params! {
447    struct FrameSyncSpec {
448        start: usize [0], "first known symbol";
449        length: usize [512] >= 1, "number of known symbols";
450        max_delay: usize [64] "symbols", "largest delay searched";
451        symmetry: u32 [4], "rotational symmetry of the constellation; 0 removes the exact phase";
452        exclude: bool [true], "exclude the known symbols from the metrics";
453    }
454}
455
456struct FrameSync {
457    p: FrameSyncSpec,
458    label: String,
459}
460
461impl Block for FrameSync {
462    fn apply(&self, s: &mut Frame, rng: &mut Rng) -> Result<()> {
463        if !s.layout.polarisations || s.n_streams() != 2 {
464            return self.single(s);
465        }
466        // the order of the outputs of a blind 2 × 2 equaliser is arbitrary: keep the
467        // order whose known symbols correlate best, then synchronise each polarisation
468        s.require_one_sps("frame_sync")?;
469        let p = &self.p;
470        let n = s.symbols.len() / 2;
471        let symmetry = if p.symmetry == 0 { None } else { Some(p.symmetry) };
472        let q = |z: &[C64], pol: usize| {
473            frame::frame_sync(z, &s.symbols[pol * n..(pol + 1) * n], p.start..p.start + p.length, p.max_delay, symmetry)
474                .map_or(0.0, |r| r.quality)
475        };
476        let direct = q(&s.samples, 0) + q(&s.antennas[0], 1);
477        let swapped = q(&s.samples, 1) + q(&s.antennas[0], 0);
478        let swap = swapped > direct;
479        if swap {
480            std::mem::swap(&mut s.samples, &mut s.antennas[0]);
481        }
482        s.record(&self.label, "pol_swap", f64::from(u8::from(swap)));
483        per_pol(s, rng, &|sub, _| self.single(sub))
484    }
485}
486
487impl FrameSync {
488    fn single(&self, s: &mut Frame) -> Result<()> {
489        s.require_one_sps("frame_sync")?;
490        let p = &self.p;
491        let range = p.start..p.start + p.length;
492        let symmetry = if p.symmetry == 0 { None } else { Some(p.symmetry) };
493        let r = frame::frame_sync(&s.samples, &s.symbols, range.clone(), p.max_delay, symmetry)?;
494        let len_in = s.samples.len() as isize;
495        s.samples = frame::align(&s.samples, r.delay, r.rotation, s.symbols.len());
496        // the symbols whose received sample falls outside the block are excluded
497        for (i, e) in s.excluded.iter_mut().enumerate() {
498            let k = i as isize + r.delay;
499            if k < 0 || k >= len_in {
500                *e = true;
501            }
502        }
503        if p.exclude {
504            let n = s.excluded.len();
505            for i in range.filter(|&i| i < n) {
506                s.excluded[i] = true;
507            }
508        }
509        s.record(&self.label, "delay", r.delay as f64);
510        s.record(&self.label, "quality", r.quality);
511        Ok(())
512    }
513}
514
515block_params! {
516    struct Resample {
517        sps: f64 [2.0] > 0, "output samples per symbol";
518    }
519}
520
521impl Block for Resample {
522    fn apply(&self, s: &mut Frame, _: &mut Rng) -> Result<()> {
523        let n_out = (s.samples.len() as f64 * self.sps / s.sps).round() as usize;
524        s.samples = resample::resample_fft(&s.samples, n_out);
525        s.sps = self.sps;
526        Ok(())
527    }
528}