Skip to main content

comnum_spec/blocks/
analysis.rs

1//! Measurements in the middle of a link: probes, recordings of the signal (`dump`) and
2//! the declared posterior of a classic receiver.
3
4use super::{Block, BuildContext, Extension, Frame};
5use crate::io::Datatype;
6use crate::metrics::{Metric, Metrics, MetricsWindow};
7use comnum_core::Error;
8use comnum_core::Result;
9use comnum_core::ensure;
10use comnum_core::rng::Rng;
11use std::path::PathBuf;
12
13/// The blocks of this module: their documentation and their builders.
14pub(super) fn blocks() -> Vec<Extension> {
15    vec![
16        builtin!(
17            "probe",
18            "Measure metrics at this point of the link; reported as <label>.<metric> (label: probe by default).",
19            ProbeSpec,
20            needs [Samples],
21            gives [],
22            |q, label, _| Ok(Box::new(Probe::new(q, label)?)),
23        ),
24        builtin!(
25            "dump",
26            "Save the samples at this point of the link for the first trials (by index) of every condition, as SigMF recordings <dir>/<campaign>/<condition>/<label>_<trial>.sigmf-meta and -data (one per stream, suffixed _s<stream> after the first).",
27            DumpSpec,
28            needs [Samples],
29            gives [],
30            |q, label, ctx| Ok(Box::new(Dump::new(&q, label, ctx)?)),
31        ),
32        builtin!(
33            "noise_estimate",
34            "Declare the receiver's posterior: the noise variance of the aligned samples; records sigma2.",
35            NoiseEstimateSpec,
36            needs [Samples, Symbols, Constellation],
37            gives [Noise],
38            |q, label, _| Ok(Box::new(NoiseEstimate::new(&q, label)?)),
39        ),
40        builtin!(
41            "classify",
42            "Blind modulation classification at one sample per symbol, by fourth-order cumulants or by likelihood; records class (the index of the decided candidate), correct (1 if it is the transmitted alphabet) and snr_m2m4_db (M2M4 for the decided alphabet).",
43            ClassifySpec,
44            needs [Samples],
45            gives [],
46            |q, label, _| Ok(Box::new(Classify::new(&q, label)?)),
47        ),
48    ]
49}
50
51// ------------------------------------------------------------------------- probes
52
53block_params! {
54    struct ProbeSpec {
55        metrics: Vec<String> [["snr_db"] => "[snr_db]"],
56            "the metrics of the specifications (on symbols aligned at 1 sample per symbol), and power_dbm and papr_db anywhere";
57        window: MetricsWindow [flatten], "the window and the scaling, as for the metrics of the specification";
58    }
59}
60
61/// The metrics that a probe measures on the samples alone, aligned or not.
62const SIGNAL_METRICS: [&str; 2] = ["power_dbm", "papr_db"];
63
64/// Metrics measured in the middle of a link (`probe`).
65struct Probe {
66    label: String,
67    signal: Vec<&'static str>,
68    symbols: Option<Metrics>,
69}
70
71impl Probe {
72    fn new(p: ProbeSpec, label: String) -> Result<Probe> {
73        let mut signal = Vec::new();
74        let mut symbols = Vec::new();
75        for m in &p.metrics {
76            match SIGNAL_METRICS.iter().find(|s| s.eq_ignore_ascii_case(m)) {
77                Some(s) => signal.push(*s),
78                None => symbols.push(m.parse::<Metric>().map_err(|e| e.context("probe"))?),
79            }
80        }
81        let symbols = (!symbols.is_empty()).then(|| Metrics::with_window(symbols, p.window));
82        Ok(Probe { label, signal, symbols })
83    }
84}
85
86impl Block for Probe {
87    fn apply(&self, s: &mut Frame, _: &mut Rng) -> Result<()> {
88        let mut rec = comnum_sim::scenario::Record::new();
89        for &m in &self.signal {
90            if m == "power_dbm" {
91                rec.value(m, comnum_math::w2dbm(s.mean_power()));
92            } else {
93                let papr: Vec<f64> = s.streams().map(|x| comnum_math::papr_db(x)).collect();
94                rec.value(m, papr.iter().sum::<f64>() / papr.len() as f64);
95            }
96        }
97        if let Some(metrics) = &self.symbols {
98            metrics.record(s, &mut rec)?;
99        }
100        for (k, e) in rec.iter() {
101            let name = format!("{}.{k}", self.label);
102            match e {
103                comnum_sim::stats::Estimate::Rate(r) => s.probes.errors(&name, r.errors, r.total),
104                comnum_sim::stats::Estimate::Mean(m) => s.probes.value(&name, m.mean()),
105            }
106        }
107        Ok(())
108    }
109}
110
111// ------------------------------------------------------------------------- dumps
112
113block_params! {
114    struct DumpSpec {
115        dir: String ["results/signals"], "directory of the recordings (relative to the working directory)";
116        trials: usize [1] >= 1, "trials saved per condition: those of index 0 to trials - 1, whatever the order the threads run them in";
117        datatype: String ["cf64"] one_of ["cf32", "cf64", "ci16"], "sample format of the SigMF data file";
118    }
119}
120
121/// The samples at a point of the link, saved for the first trials (`dump`).
122///
123/// Every stream of the frame goes to its own recording, with the samples per symbol,
124/// the symbol rate, the block and the trial in the global metadata (`comnum:sps`,
125/// `comnum:symbol_rate`, `comnum:block`, `comnum:trial`, `comnum:stream`), and
126/// `core:sample_rate` is the symbol rate times the samples per symbol. The trials are
127/// chosen by their index ([`Frame::trial`], set by the runner), so trial `t` of a
128/// condition is the frame that the stream `(seed, name, condition, t)` gives, whatever
129/// the order the threads run the trials in.
130struct Dump {
131    base: PathBuf,
132    label: String,
133    trials: u64,
134    datatype: Datatype,
135}
136
137impl Dump {
138    fn new(p: &DumpSpec, label: String, ctx: &BuildContext) -> Result<Dump> {
139        let mut base = PathBuf::from(&p.dir);
140        if let Some(name) = &ctx.name {
141            base.push(name);
142        }
143        if let Some(cond) = &ctx.condition {
144            if !cond.is_empty() {
145                base.push(cond.replace(", ", "_").replace(' ', ""));
146            }
147        }
148        Ok(Dump { base, label, trials: p.trials as u64, datatype: Datatype::parse(&p.datatype)? })
149    }
150}
151
152impl Block for Dump {
153    fn apply(&self, s: &mut Frame, _: &mut Rng) -> Result<()> {
154        let trial = s.trial;
155        if trial >= self.trials {
156            return Ok(());
157        }
158        std::fs::create_dir_all(&self.base)?;
159        for (i, x) in s.streams().enumerate() {
160            let name = if i == 0 { format!("{}_{trial}", self.label) } else { format!("{}_{trial}_s{i}", self.label) };
161            let global = serde_json::json!({
162                "comnum:block": self.label,
163                "comnum:trial": trial,
164                "comnum:stream": i,
165                "comnum:sps": s.sps,
166                "comnum:symbol_rate": s.symbol_rate,
167            });
168            crate::io::write_sigmf(self.base.join(name), x, self.datatype, s.symbol_rate * s.sps, Some(&global))?;
169        }
170        Ok(())
171    }
172}
173
174block_params! {
175    struct NoiseEstimateSpec {
176        mode: String ["decisions"] one_of ["decisions", "known"],
177            "decisions (blind, around the nearest points) or known (around the transmitted symbols)";
178    }
179}
180
181/// The posterior of a classic receiver: `z` as it is and the mean squared distance to
182/// the decided (or transmitted) points.
183struct NoiseEstimate {
184    known: bool,
185    label: String,
186}
187
188impl NoiseEstimate {
189    fn new(p: &NoiseEstimateSpec, label: String) -> Result<NoiseEstimate> {
190        let known = match p.mode.as_str() {
191            "decisions" => false,
192            "known" => true,
193            other => {
194                return Err(Error::Config(format!("noise_estimate: mode must be decisions or known, got {other:?}")));
195            }
196        };
197        Ok(NoiseEstimate { known, label })
198    }
199}
200
201impl Block for NoiseEstimate {
202    fn apply(&self, s: &mut Frame, _: &mut Rng) -> Result<()> {
203        s.require_symbols("noise_estimate")?;
204        let c = s.constellation("noise_estimate")?;
205        let mut e = 0.0;
206        let mut n = 0usize;
207        for i in (0..s.samples.len()).filter(|&i| !s.excluded[i]) {
208            let z = s.samples[i];
209            let x = if self.known { s.symbols[i] } else { c.points()[c.nearest(z)] };
210            e += (z - x).norm_sqr();
211            n += 1;
212        }
213        ensure(n > 0, || "noise_estimate: every symbol is excluded".into())?;
214        let sigma2 = (e / n as f64).max(f64::MIN_POSITIVE);
215        s.receiver.noise_var = Some(sigma2);
216        s.record(&self.label, "sigma2", sigma2);
217        Ok(())
218    }
219}
220
221// ---------------------------------------------------------------------- classify
222
223block_params! {
224    struct ClassifySpec {
225        candidates: Vec<String> [["BPSK", "QPSK", "8-PSK", "16-QAM", "64-QAM"] => "[BPSK, QPSK, 8-PSK, 16-QAM, 64-QAM]"],
226            "the candidate alphabets";
227        method: String ["ml"] one_of ["cumulants", "ml"],
228            "cumulants (Swami & Sadler 2000, normalised by the received power: for high SNR) or ml (likelihood, the scale and the noise estimated by M2M4 under every candidate)";
229    }
230}
231
232struct Classify {
233    candidates: Vec<comnum_mapping::constellation::Constellation>,
234    ml: bool,
235    label: String,
236}
237
238impl Classify {
239    fn new(p: &ClassifySpec, label: String) -> Result<Classify> {
240        ensure(!p.candidates.is_empty(), || "classify: give at least one candidate".into())?;
241        let candidates = p
242            .candidates
243            .iter()
244            .map(|n| comnum_mapping::constellation::Constellation::from_name(n).map(|c| c.normalized()))
245            .collect::<Result<Vec<_>>>()
246            .map_err(|e| e.context("classify"))?;
247        Ok(Classify { candidates, ml: p.method == "ml", label })
248    }
249}
250
251impl Block for Classify {
252    fn apply(&self, s: &mut Frame, _: &mut Rng) -> Result<()> {
253        s.require_one_sps("classify")?;
254        let r: Vec<_> =
255            (0..s.samples.len()).filter(|&i| s.excluded.get(i).is_none_or(|e| !e)).map(|i| s.samples[i]).collect();
256        let d = if self.ml {
257            comnum_metrics::blind::classify_ml_blind(&r, &self.candidates)
258        } else {
259            comnum_metrics::blind::classify_cumulants(&r, &self.candidates, None)
260        }
261        .map_err(|e| e.context("classify"))?;
262        let decided = &self.candidates[d.index];
263        let snr = comnum_metrics::blind::snr_m2m4(&r, decided).map_err(|e| e.context("classify"))?;
264        s.record(&self.label, "class", d.index as f64);
265        if let Some(c) = &s.constellation {
266            let correct = c.kind() == decided.kind() && c.order() == decided.order();
267            s.record(&self.label, "correct", f64::from(u8::from(correct)));
268        }
269        s.record(&self.label, "snr_m2m4_db", comnum_math::lin2db(snr));
270        Ok(())
271    }
272}