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}