Skip to main content

comnum_spec/
metrics.rs

1//! The metrics of a declarative link.
2//!
3//! The metrics compare the samples at the end of the link with the transmitted
4//! symbols. The samples must be at one sample per symbol and aligned on the symbols
5//! (after decimation and, when the receiver introduces a delay, frame
6//! synchronisation). The comparison uses the symbols $n$ with
7//! $s_{\mathrm{start}} \le n < N - s_{\mathrm{end}}$ (`skip_start`, `skip_end`) that no
8//! block excluded (pilots, preambles).
9//!
10//! ```yaml
11//! metrics:
12//!   report: [ber, ser, snr_db, gmi]   # default [ber, ser, snr_db]
13//!   skip_start: 64                    # edges of the filters
14//!   skip_end: 64
15//!   scale: power                      # power (default), ls or none
16//! ```
17//!
18//! The names of the metrics and of the scalings are read without case. Before the
19//! decisions the samples $z_{n}$ are scaled:
20//!
21//! - `power`: to the power of the transmitted symbols $x_{n}$ on the window (blind);
22//! - `ls`: by the least-squares complex gain
23//!   $\sum_{n} x_{n} z_{n}^{*} / \sum_{n} \lvert z_{n} \rvert^{2}$, which also removes a
24//!   constant phase (genie-aided);
25//! - `none`: as they are.
26//!
27//! | Metric | Kind | Definition |
28//! |---|---|---|
29//! | `ber` | rate | hard-decision bit errors (Gray labels) over the window |
30//! | `ser` | rate | symbol errors |
31//! | `snr_db` | mean | $10 \log_{10} \left( \sum \lvert x \rvert^{2} / \sum \lvert z - x \rvert^{2} \right)$ per trial, after the scaling ([`metrics::snr_db`]; a trial without error gives $+\infty$, which the record drops and counts) |
32//! | `snr_eff_db` | mean | $10 \log_{10} \left( \rho^{2} / (1 - \rho^{2}) \right)$, $\rho^{2} = \lvert \sum z x^{*} \rvert^{2} / \left( \sum \lvert z \rvert^{2} \sum \lvert x \rvert^{2} \right)$ per trial: the effective SNR after the least-squares gain of $x$ onto $z$ (Häger & Pfister; independent of the scaling) |
33//! | `evm` | mean | $\sum \lvert z - x \rvert^{2} / \sum \lvert x \rvert^{2}$ per trial (power ratio, [`metrics::evm_data_aided`]) |
34//! | `mse` | mean | the mean of $\lvert z - x \rvert^{2}$ per trial ([`metrics::mse`]) |
35//! | `gmi` | mean | generalised mutual information (bits/symbol) per trial |
36//! | `ngmi` | mean | GMI per bit of information, $\mathrm{GMI} / H(X)$ |
37//! | `entropy` | mean | entropy $H(X)$ of the source (bits/symbol): $\log_{2} M$, or less with shaping |
38//! | `mi` | mean | symbol-wise mutual information (bits/symbol) per trial |
39//! | `coded_ber` | rate | information bits decoded in error (`fec_decode`) |
40//! | `bler` | rate | coded blocks with at least one information bit in error |
41//! | `air` | mean | rate of a decoder that uses the receiver's own posterior (bits/symbol) |
42//! | `air_bmd` | mean | the same with the bit-wise LLRs of that posterior |
43//! | `air_opt`, `air_bmd_opt` | mean | the same with the best scaling $s$ of the posterior, per block |
44//! | `posterior_calibration` | mean | $\hat{\sigma}^{2}$ over the mean of $\lvert z - x \rvert^{2}$: 1 for a calibrated receiver, below 1 for an over-confident one |
45//!
46//! The `air` metrics need a block that declares the receiver's posterior (a learned
47//! receiver, or `noise_estimate` after a classic receiver) and use the samples as they are, without
48//! scaling (see [`metrics::posterior_rates`]). Compared with `mi` and `gmi` (genie gain
49//! and variance), they show what the receiver loses by its own estimates.
50//!
51//! With a shaped source (`source` with `mb_lambda`, or `pas_encode`), the information
52//! rates, the soft demapper and the posterior use the probabilities of the points, and
53//! `awgn_ebn0` counts the energy per bit of information (the entropy $H(X)$).
54//!
55//! The information rates use the least-squares channel gain of
56//! [`metrics::information_rates`] (consistent at every SNR, independent of the
57//! scaling), not the power normalisation of OptiCommPy's estimators.
58//!
59//! With two polarisations (`source` with `pols: 2`) the metrics pool both: the samples
60//! of x then those of y, against the symbols of x then those of y, the window
61//! (`skip_start`, `skip_end`) applying to each polarisation.
62//!
63//! The coded metrics compare the decoded information bits with the transmitted ones
64//! and need no aligned symbols: a coded link may report them alone.
65//!
66//! The rates are counted over all the trials (Wilson intervals, usable by the
67//! `min_errors` stopping rule); the other metrics are means of per-trial values.
68
69use crate::blocks::schema::ParamType;
70use crate::blocks::{Frame, Params};
71use comnum_core::{Error, Result, ensure_shape};
72use comnum_math::{C64, energy};
73use comnum_metrics as metrics;
74use comnum_sim::scenario::Record;
75use serde::Deserialize;
76use serde_json::Value as Json;
77use std::str::FromStr;
78
79/// The metrics, declared once: the variant, its name, whether it is computed on the
80/// decoded information bits.
81macro_rules! metrics {
82    ($($variant:ident = $name:literal $(, $coded:ident)?;)*) => {
83        /// A metric of a declarative link (see the module documentation).
84        #[derive(Clone, Copy, Debug, PartialEq, Eq)]
85        pub enum Metric {
86            $(
87                #[doc = concat!("`", $name, "`.")]
88                $variant,
89            )*
90        }
91
92        /// The names of the metrics a link can report, in the order of [`Metric::ALL`].
93        pub const METRICS: [&str; [$($name),*].len()] = [$($name),*];
94
95        impl Metric {
96            /// Every metric.
97            pub const ALL: [Metric; METRICS.len()] = [$(Metric::$variant),*];
98
99            /// The name of the metric in a specification and in the results.
100            pub fn name(self) -> &'static str {
101                match self {
102                    $(Metric::$variant => $name,)*
103                }
104            }
105
106            /// Whether the metric is computed on the decoded information bits.
107            pub fn is_coded(self) -> bool {
108                match self {
109                    $(Metric::$variant => metrics!(@coded $($coded)?),)*
110                }
111            }
112        }
113    };
114    (@coded coded) => { true };
115    (@coded) => { false };
116}
117
118metrics! {
119    Ber = "ber";
120    Ser = "ser";
121    SnrDb = "snr_db";
122    SnrEffDb = "snr_eff_db";
123    Evm = "evm";
124    Mse = "mse";
125    Gmi = "gmi";
126    Ngmi = "ngmi";
127    Mi = "mi";
128    Entropy = "entropy";
129    Air = "air";
130    AirBmd = "air_bmd";
131    AirOpt = "air_opt";
132    AirBmdOpt = "air_bmd_opt";
133    PosteriorCalibration = "posterior_calibration";
134    CodedBer = "coded_ber", coded;
135    Bler = "bler", coded;
136}
137
138impl FromStr for Metric {
139    type Err = Error;
140
141    /// The metric of a name, without case.
142    ///
143    /// # Errors
144    /// [`Error::Config`] for an unknown name (the message lists the metrics).
145    ///
146    /// # Examples
147    /// ```
148    /// use comnum_spec::metrics::Metric;
149    /// assert_eq!("BER".parse::<Metric>()?, Metric::Ber);
150    /// assert!("fer".parse::<Metric>().is_err());
151    /// # Ok::<(), comnum_core::Error>(())
152    /// ```
153    fn from_str(s: &str) -> Result<Metric> {
154        Metric::ALL
155            .into_iter()
156            .find(|m| m.name().eq_ignore_ascii_case(s))
157            .ok_or_else(|| crate::unknown("metric", s, &METRICS))
158    }
159}
160
161/// The scaling of the samples before the decisions.
162#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
163#[serde(rename_all = "lowercase")]
164pub(crate) enum Scale {
165    /// To the power of the transmitted symbols.
166    Power,
167    /// By the least-squares complex gain.
168    Ls,
169    /// None.
170    None,
171}
172
173impl ParamType for Scale {
174    fn schema() -> Json {
175        String::schema()
176    }
177}
178
179crate::block_params! {
180    /// The window and the scaling of the metrics, shared by the `metrics` of a
181    /// specification and the `probe` blocks.
182    #[derive(Clone, Debug)]
183    pub(crate) struct MetricsWindow {
184        skip_start: usize [0] "symbols", "symbols left out at the start of every polarisation (the edges of the filters)";
185        skip_end: usize [0] "symbols", "symbols left out at the end of every polarisation";
186        scale: Scale ["power"] one_of ["power", "ls", "none"],
187            "scaling before the decisions: power (blind), ls (least-squares complex gain, genie-aided) or none";
188    }
189}
190
191crate::block_params! {
192    /// The `metrics` mapping of a specification.
193    struct MetricsSpec {
194        report: Vec<String> [["ber", "ser", "snr_db"] => "[ber, ser, snr_db]"] one_of (METRICS), "the metrics reported";
195        window: MetricsWindow [flatten], "the window and the scaling";
196    }
197}
198
199/// The metrics of a link (see the module documentation).
200#[derive(Clone, Debug)]
201pub struct Metrics {
202    report: Vec<Metric>,
203    window: MetricsWindow,
204}
205
206/// The values a trial computes once for several metrics.
207#[derive(Default)]
208struct Shared {
209    info: Option<metrics::InformationRates>,
210    posterior: Option<metrics::PosteriorRates>,
211}
212
213impl Metrics {
214    /// Parse the `metrics` mapping of a specification (`null` gives the defaults).
215    ///
216    /// # Errors
217    /// [`Error::Config`] for an unknown metric, key or scaling.
218    pub fn from_json(v: &Json) -> Result<Metrics> {
219        let q = MetricsSpec::parse("metrics", v.clone())?;
220        let report = q.report.iter().map(|m| m.parse()).collect::<Result<_>>()?;
221        Ok(Metrics { report, window: q.window })
222    }
223
224    /// The metrics `report` on the window and with the scaling `window` (a probe).
225    pub(crate) fn with_window(report: Vec<Metric>, window: MetricsWindow) -> Metrics {
226        Metrics { report, window }
227    }
228
229    /// The reported metrics.
230    pub fn names(&self) -> Vec<&'static str> {
231        self.report.iter().map(|m| m.name()).collect()
232    }
233
234    /// Record what a trial of a link records after its blocks ([`crate::Link::trial`]):
235    /// the metrics of the frame ([`Metrics::record`]), then the values and the probes
236    /// that its blocks recorded. The Python chains record their frames with it.
237    ///
238    /// # Errors
239    /// As [`Metrics::record`].
240    pub fn record_frame(&self, s: &Frame, rec: &mut Record) -> Result<()> {
241        self.record(s, rec)?;
242        for (k, v) in &s.values {
243            rec.value(k, *v);
244        }
245        rec.merge(&s.probes);
246        Ok(())
247    }
248
249    /// Evaluate the metrics of one trial and record them.
250    ///
251    /// # Errors
252    /// [`Error::Config`] for a coded metric without a coded link, or a signal that is
253    /// not at one sample per symbol aligned on the symbols; [`Error::Shape`] if the
254    /// window is empty.
255    pub fn record(&self, s: &Frame, rec: &mut Record) -> Result<()> {
256        if self.report.is_empty() {
257            return Ok(());
258        }
259        // both polarisations of a dual-polarisation channel, each with its own window
260        let segments = if s.layout.polarisations { s.n_streams() } else { 1 };
261        let pooled = s.pooled();
262        let s: &Frame = &pooled;
263        self.record_coded(s, rec)?;
264        if self.report.iter().all(|m| m.is_coded()) {
265            return Ok(());
266        }
267        s.require_symbols("metrics")?;
268        let idx = self.window(s, segments)?;
269        let x: Vec<C64> = idx.iter().map(|&i| s.symbols[i]).collect();
270        let raw: Vec<C64> = idx.iter().map(|&i| s.samples[i]).collect();
271        // none for Gaussian symbols: the metrics that need them then fail on the lengths
272        let tx: Vec<usize> = idx.iter().filter_map(|&i| s.indices.get(i).copied()).collect();
273        let g = self.gain(&raw, &x)?;
274        let z: Vec<C64> = raw.iter().map(|v| v * g).collect();
275        let mut shared = Shared::default();
276        for &m in &self.report {
277            let name = m.name();
278            match m {
279                Metric::Ber => {
280                    let c = s.constellation("metrics: ber")?;
281                    let b = c.bits_per_symbol();
282                    let bits: Vec<u8> = idx.iter().flat_map(|&i| s.bits[i * b..(i + 1) * b].iter().copied()).collect();
283                    rec.errors(name, metrics::bit_errors(&c.demap_bits(&z), &bits)? as u64, bits.len() as u64);
284                }
285                Metric::Ser => {
286                    let c = s.constellation("metrics: ser")?;
287                    rec.errors(name, metrics::symbol_errors(&c.decide(&z), &tx)? as u64, tx.len() as u64);
288                }
289                Metric::SnrDb => rec.value(name, metrics::snr_db(&z, &x)?),
290                // independent of the scaling: on the samples as they are
291                Metric::SnrEffDb => rec.value(name, metrics::effective_snr_db(&raw, &x)?),
292                Metric::Evm => rec.value(name, metrics::evm_data_aided(&z, &x)?),
293                Metric::Mse => rec.value(name, metrics::mse(&z, &x)?),
294                Metric::Gmi | Metric::Ngmi | Metric::Mi | Metric::Entropy => {
295                    let r = match shared.info {
296                        Some(r) => r,
297                        None => *shared.info.insert(metrics::information_rates(
298                            &raw,
299                            &tx,
300                            s.constellation("metrics: information rates")?,
301                            s.pmf.as_deref(),
302                        )?),
303                    };
304                    let v = match m {
305                        Metric::Gmi => r.gmi,
306                        Metric::Ngmi => r.ngmi,
307                        Metric::Entropy => r.entropy,
308                        _ => r.mi,
309                    };
310                    rec.value(name, v);
311                }
312                Metric::Air | Metric::AirBmd | Metric::AirOpt | Metric::AirBmdOpt | Metric::PosteriorCalibration => {
313                    let r = match shared.posterior {
314                        Some(r) => r,
315                        None => *shared.posterior.insert(Self::posterior(s, name, &raw, &tx)?),
316                    };
317                    let v = match m {
318                        Metric::Air => r.air,
319                        Metric::AirBmd => r.air_bmd,
320                        Metric::AirOpt => r.air_opt,
321                        Metric::AirBmdOpt => r.air_bmd_opt,
322                        _ => r.calibration,
323                    };
324                    rec.value(name, v);
325                }
326                // recorded by record_coded
327                Metric::CodedBer | Metric::Bler => {}
328            }
329        }
330        Ok(())
331    }
332
333    /// The coded metrics, on the decoded information bits.
334    fn record_coded(&self, s: &Frame, rec: &mut Record) -> Result<()> {
335        for m in self.report.iter().filter(|m| m.is_coded()) {
336            let c = &s.coding;
337            if c.payload.is_empty() || c.decoded.len() != c.payload.len() {
338                return Err(Error::Config(format!(
339                    "metric {:?} needs a coded link: fec_encode first and fec_decode before the metrics",
340                    m.name()
341                )));
342            }
343            if *m == Metric::CodedBer {
344                let e = metrics::bit_errors(&c.payload, &c.decoded)?;
345                rec.errors(m.name(), e as u64, c.payload.len() as u64);
346            } else {
347                let k = c.payload.len() / c.n_blocks.max(1);
348                let e = metrics::block_errors(&c.decoded, &c.payload, k)?;
349                rec.errors(m.name(), e as u64, c.n_blocks.max(1) as u64);
350            }
351        }
352        Ok(())
353    }
354
355    /// The indices of the symbols of the window: in every one of the `segments`
356    /// polarisations, `skip_start` to `skip_end` from its end, the excluded symbols left
357    /// out.
358    fn window(&self, s: &Frame, segments: usize) -> Result<Vec<usize>> {
359        let n = s.n_symbols();
360        let per = n / segments;
361        let (start, end) = (self.window.skip_start, self.window.skip_end);
362        let idx: Vec<usize> = (0..segments)
363            .flat_map(|p| {
364                let hi = per.saturating_sub(end);
365                (start.min(hi)..hi).map(move |i| p * per + i)
366            })
367            .filter(|&i| !s.excluded.get(i).copied().unwrap_or(false))
368            .collect();
369        ensure_shape(!idx.is_empty(), || format!("the metrics window of the {n} symbols is empty"))?;
370        Ok(idx)
371    }
372
373    /// The gain of the scaling of the samples `z` against the symbols `x`.
374    fn gain(&self, z: &[C64], x: &[C64]) -> Result<C64> {
375        Ok(match self.window.scale {
376            Scale::None => C64::new(1.0, 0.0),
377            Scale::Power => {
378                let (pz, px) = (energy(z), energy(x));
379                C64::new(if pz > 0.0 { (px / pz).sqrt() } else { 1.0 }, 0.0)
380            }
381            Scale::Ls => metrics::ls_gain(z, x)?,
382        })
383    }
384
385    /// The rates of the receiver's declared posterior, on the samples as they are.
386    fn posterior(s: &Frame, name: &str, raw: &[C64], tx: &[usize]) -> Result<metrics::PosteriorRates> {
387        let s2 = s.receiver.noise_var.ok_or_else(|| {
388            Error::Config(format!(
389                "metric {name:?} needs a block that declares the receiver's posterior \
390                 (a learned receiver, or noise_estimate after a classic receiver)"
391            ))
392        })?;
393        metrics::posterior_rates(raw, tx, s.constellation("metrics: air")?, s2, s.pmf.as_deref())
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    #[test]
402    fn the_names_are_read_without_case() {
403        let m = Metrics::from_json(&serde_json::json!({"report": ["BER", "Snr_dB"], "scale": "LS"})).unwrap();
404        assert_eq!(m.names(), ["ber", "snr_db"]);
405        assert_eq!(m.window.scale, Scale::Ls);
406        let e = Metrics::from_json(&serde_json::json!({"report": ["fer"]})).unwrap_err().to_string();
407        assert!(e.contains("report must be one of") && e.contains("coded_ber"), "{e}");
408        assert_eq!(Metrics::from_json(&Json::Null).unwrap().names(), ["ber", "ser", "snr_db"]);
409        assert_eq!(Metric::ALL.map(Metric::name), METRICS);
410    }
411
412    #[test]
413    fn gaussian_symbols_have_an_snr_and_no_symbol_errors() {
414        let spec = crate::Spec::from_yaml(
415            "name: g\nlink: [{type: source, constellation: gaussian, n_symbols: 256}, {type: awgn, snr_db: 10}]\n\
416             metrics: {report: [snr_db]}\nstop: {max_trials: 2}",
417        )
418        .unwrap();
419        let r = crate::run(&spec, &crate::RunOptions::default()).unwrap();
420        assert!((r[0].metric("snr_db").unwrap().value() - 10.0).abs() < 1.0);
421    }
422}