Skip to main content

comnum_sim/
scenario.rs

1//! Scenarios and the metrics of a trial.
2//!
3//! A [`Scenario`] is what one Monte Carlo trial does, the counterpart of a
4//! `LightningModule`:
5//!
6//! - [`Scenario::setup`] runs once per condition, before its trials, and returns a
7//!   **state** shared (read-only) by all of them: a constellation, a filter, a designed
8//!   or trained receiver, a fibre plan… (`setup` hook of Lightning);
9//! - [`Scenario::trial`] runs one trial with its own random generator and records its
10//!   metrics in a [`Record`] (`training_step` and `self.log`).
11//!
12//! Trials run in parallel, so the scenario and its state are `Sync` and a trial must
13//! not mutate shared data. [`from_fn`] makes a scenario from a closure when no state
14//! is needed.
15//!
16//! A [`Record`] maps metric names to [`Estimate`]s: [`Record::errors`] adds error counts
17//! (an error rate with a Wilson interval), [`Record::value`] adds a real value (a mean
18//! with a normal interval). The runner merges the records of all the trials of a
19//! condition.
20//!
21//! A value that cannot enter its metric is **dropped and counted**, never folded into
22//! the estimate: a non-finite value (the SNR of an error-free trial, `+∞` dB), or an
23//! addition to a metric of the other kind (`errors` on a mean, `value` on a rate).
24//! [`Record::dropped`] gives the count of a metric; the runner keeps the counts of the
25//! merged record and the JSON report lists them (`dropped`), so that a mean that
26//! leaves values out shows it.
27//!
28//! # Examples
29//! ```
30//! use comnum_sim::prelude::*;
31//! use comnum_mapping::{constellation::{Constellation, ConstellationKind}, source as sources};
32//! use comnum_channel::awgn as channels;
33//! use comnum_metrics as metrics;
34//!
35//! /// SER of M-PSK over AWGN; the constellation is built once per condition.
36//! struct Psk;
37//! impl Scenario for Psk {
38//!     type State = Constellation;
39//!     fn name(&self) -> &str { "psk-ser" }
40//!     fn setup(&self, cond: &Condition) -> Result<Constellation> {
41//!         Constellation::psk(cond.usize("m")?)
42//!     }
43//!     fn trial(&self, c: &Constellation, cond: &Condition, rng: &mut Rng, rec: &mut Record)
44//!         -> Result<()> {
45//!         let (idx, x) = sources::symbols(c, 1000, None, rng)?;
46//!         let b = c.bits_per_symbol();
47//!         let y = channels::awgn_ebn0(&x, cond.f64("ebn0_db")?, b as f64, 1.0, rng);
48//!         let dec = c.decide(&y);
49//!         rec.errors("ser", metrics::symbol_errors(&dec, &idx)? as u64, idx.len() as u64);
50//!         rec.value("snr_db", metrics::snr_db(&y, &x)?);
51//!         Ok(())
52//!     }
53//! }
54//! let grid = Grid::new().axis("m", [8usize]).axis("ebn0_db", [10.0]);
55//! let r = Runner::new().batch(10).stop(Stop::max_trials(20)).run(&Psk, &grid).unwrap();
56//! assert_eq!(r[0].trials, 20);   // the rule is checked after every batch of 10 trials
57//! assert!((r[0].metric("snr_db").unwrap().value() - (10.0 + comnum_math::lin2db(3.0))).abs() < 0.1);
58//! ```
59
60use crate::grid::Condition;
61use crate::stats::{ErrorRate, Estimate, Mean};
62use comnum_core::Result;
63use comnum_core::rng::Rng;
64use std::collections::BTreeMap;
65
66/// The metrics reported by one trial, or merged over many.
67#[derive(Clone, Debug, Default, PartialEq)]
68pub struct Record {
69    metrics: BTreeMap<String, Estimate>,
70    dropped: BTreeMap<String, u64>,
71    trial: u64,
72}
73
74impl Record {
75    /// An empty record.
76    pub fn new() -> Record {
77        Record::default()
78    }
79
80    /// An empty record of trial `trial` of its condition (the runner numbers the
81    /// trials; blocks that save or label a trial read it).
82    pub fn for_trial(trial: u64) -> Record {
83        Record { trial, ..Record::default() }
84    }
85
86    /// The index of the trial of the condition this record belongs to (0 when merged).
87    pub fn trial(&self) -> u64 {
88        self.trial
89    }
90
91    /// Add `errors` errors among `total` events to the error rate `name`.
92    ///
93    /// If `name` is already a mean, the addition is dropped and counted
94    /// ([`Record::dropped`]).
95    pub fn errors(&mut self, name: &str, errors: u64, total: u64) {
96        let e = self.metrics.entry(name.to_string()).or_insert(Estimate::Rate(ErrorRate::default()));
97        if let Estimate::Rate(r) = e {
98            r.add(errors, total);
99        } else {
100            self.drop_values(name, 1);
101        }
102    }
103
104    /// Add a value to the mean `name`.
105    ///
106    /// A non-finite value, or a value for a metric that is already an error rate, is
107    /// dropped and counted ([`Record::dropped`]).
108    pub fn value(&mut self, name: &str, v: f64) {
109        if !v.is_finite() {
110            self.drop_values(name, 1);
111            return;
112        }
113        let e = self.metrics.entry(name.to_string()).or_insert(Estimate::Mean(Mean::default()));
114        if let Estimate::Mean(m) = e {
115            m.add(v);
116        } else {
117            self.drop_values(name, 1);
118        }
119    }
120
121    /// Add several values to the mean `name` (see [`Record::value`]).
122    pub fn values(&mut self, name: &str, vs: impl IntoIterator<Item = f64>) {
123        for v in vs {
124            self.value(name, v);
125        }
126    }
127
128    /// Merge another record: metrics of the same name and kind are combined, the drop
129    /// counts are added. A metric of the other kind is dropped and counted: as many
130    /// values as it holds for a mean, one for a rate.
131    pub fn merge(&mut self, o: &Record) {
132        for (k, v) in &o.metrics {
133            match self.metrics.get_mut(k) {
134                Some(e) => {
135                    if !e.merge(v) {
136                        let n = match v {
137                            Estimate::Mean(m) => m.count().max(1),
138                            Estimate::Rate(_) => 1,
139                        };
140                        self.drop_values(k, n);
141                    }
142                }
143                None => {
144                    self.metrics.insert(k.clone(), *v);
145                }
146            }
147        }
148        for (k, n) in &o.dropped {
149            self.drop_values(k, *n);
150        }
151    }
152
153    fn drop_values(&mut self, name: &str, n: u64) {
154        *self.dropped.entry(name.to_string()).or_insert(0) += n;
155    }
156
157    /// The number of values of `name` dropped: non-finite, or of the other kind of
158    /// metric (0 if none).
159    ///
160    /// # Examples
161    /// ```
162    /// use comnum_sim::scenario::Record;
163    /// let mut rec = Record::new();
164    /// rec.value("snr_db", 12.0);
165    /// rec.value("snr_db", f64::INFINITY);    // an error-free trial
166    /// rec.errors("snr_db", 1, 10);           // the wrong kind
167    /// assert_eq!(rec.get("snr_db").unwrap().count(), 1);
168    /// assert_eq!(rec.dropped("snr_db"), 2);
169    /// ```
170    pub fn dropped(&self, name: &str) -> u64 {
171        self.dropped.get(name).copied().unwrap_or(0)
172    }
173
174    /// The metrics with dropped values and their counts, sorted by name.
175    pub fn dropped_iter(&self) -> impl Iterator<Item = (&String, u64)> {
176        self.dropped.iter().map(|(k, n)| (k, *n))
177    }
178
179    /// Whether `name` was recorded, as an estimate or only as dropped values.
180    pub fn knows(&self, name: &str) -> bool {
181        self.metrics.contains_key(name) || self.dropped.contains_key(name)
182    }
183
184    /// The estimate of `name`.
185    pub fn get(&self, name: &str) -> Option<&Estimate> {
186        self.metrics.get(name)
187    }
188
189    /// The metrics, sorted by name.
190    pub fn iter(&self) -> impl Iterator<Item = (&String, &Estimate)> {
191        self.metrics.iter()
192    }
193
194    /// Whether the record holds no metric.
195    pub fn is_empty(&self) -> bool {
196        self.metrics.is_empty()
197    }
198}
199
200/// One random trial of a link, evaluated under many conditions.
201pub trait Scenario: Sync {
202    /// The per-condition state built by [`Scenario::setup`] and shared by the trials.
203    type State: Sync;
204
205    /// The name of the scenario (it enters the seeds of the trials and the reports).
206    fn name(&self) -> &str;
207
208    /// Build the state of a condition (run once per condition, before its trials).
209    ///
210    /// # Errors
211    /// Any error of the blocks built for the condition; the run stops with it.
212    fn setup(&self, condition: &Condition) -> Result<Self::State>;
213
214    /// Run one trial: draw from `rng` only, record the metrics in `record`.
215    ///
216    /// # Errors
217    /// Any error of the blocks of the trial; the run stops with it.
218    fn trial(&self, state: &Self::State, condition: &Condition, rng: &mut Rng, record: &mut Record) -> Result<()>;
219}
220
221/// A scenario made of a closure (no per-condition state); see [`from_fn`].
222pub struct FnScenario<F> {
223    name: String,
224    f: F,
225}
226
227/// A scenario from a name and a closure `|condition, rng, record| -> Result<()>`.
228pub fn from_fn<F>(name: &str, f: F) -> FnScenario<F>
229where
230    F: Fn(&Condition, &mut Rng, &mut Record) -> Result<()> + Sync,
231{
232    FnScenario { name: name.to_string(), f }
233}
234
235impl<F> Scenario for FnScenario<F>
236where
237    F: Fn(&Condition, &mut Rng, &mut Record) -> Result<()> + Sync,
238{
239    type State = ();
240
241    fn name(&self) -> &str {
242        &self.name
243    }
244
245    fn setup(&self, _: &Condition) -> Result<()> {
246        Ok(())
247    }
248
249    fn trial(&self, (): &(), condition: &Condition, rng: &mut Rng, record: &mut Record) -> Result<()> {
250        (self.f)(condition, rng, record)
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn non_finite_values_and_wrong_kinds_are_dropped_and_counted() {
260        let mut rec = Record::new();
261        rec.value("snr_db", 10.0);
262        rec.values("snr_db", [f64::INFINITY, f64::NAN, 12.0]);
263        rec.errors("snr_db", 3, 100);
264        rec.errors("ber", 1, 10);
265        rec.value("ber", 0.5);
266        rec.value("only_inf", f64::INFINITY);
267        assert_eq!(rec.get("snr_db").unwrap().count(), 2);
268        assert!((rec.get("snr_db").unwrap().value() - 11.0).abs() < 1e-12);
269        assert_eq!(rec.get("ber").unwrap().total(), 10);
270        assert_eq!((rec.dropped("snr_db"), rec.dropped("ber"), rec.dropped("only_inf")), (3, 1, 1));
271        assert!(rec.get("only_inf").is_none() && rec.knows("only_inf") && !rec.knows("evm"));
272        // merging adds the counts, and drops a metric of the other kind
273        let mut other = Record::new();
274        other.errors("snr_db", 0, 4);
275        other.values("ber", [0.1, 0.2]);
276        other.value("only_inf", f64::NEG_INFINITY);
277        rec.merge(&other);
278        assert_eq!((rec.dropped("snr_db"), rec.dropped("ber"), rec.dropped("only_inf")), (4, 3, 2));
279        assert_eq!(rec.dropped("evm"), 0);
280        let all: Vec<(&String, u64)> = rec.dropped_iter().collect();
281        assert_eq!(all.len(), 3);
282    }
283}