Skip to main content

comnum_spec/
run.rs

1//! Running a specification ([`run`]) and the profile of its blocks ([`profile`]).
2
3use crate::{Spec, SpecScenario};
4use comnum_core::Result;
5use comnum_sim::callbacks::ProgressBar;
6use comnum_sim::loggers::{CsvLogger, JsonLogger};
7use comnum_sim::runner::{ConditionResult, Runner};
8use std::path::PathBuf;
9
10/// Options of [`run`] that override the specification.
11#[derive(Clone, Debug, Default)]
12pub struct RunOptions {
13    /// Worker threads.
14    pub threads: Option<usize>,
15    /// Trials per batch.
16    pub batch: Option<u64>,
17    /// One batch per condition.
18    pub fast_dev_run: bool,
19    /// Print one line per condition.
20    pub progress: Option<bool>,
21    /// Time every block (the metrics `<label>.time_ms`, see [`profile`]).
22    pub profile: Option<bool>,
23    /// CSV output.
24    pub csv: Option<PathBuf>,
25    /// JSON output.
26    pub json: Option<PathBuf>,
27}
28
29impl Spec {
30    /// The runner of the specification without its callbacks and loggers: its seed,
31    /// batch, threads, stopping rule, confidence and source (the configuration of the
32    /// JSON report), the options overriding the batch and the threads, and
33    /// `fast_dev_run` (what [`search`](crate::search::search) evaluates every point with).
34    pub(crate) fn bare_runner(&self, opts: &RunOptions) -> Runner {
35        Runner::new()
36            .seed(self.seed)
37            .batch(opts.batch.unwrap_or(self.batch))
38            .threads(opts.threads.unwrap_or(self.threads))
39            .stop(self.stop.clone())
40            .confidence(self.confidence)
41            .config(self.source.to_string())
42            .fast_dev_run(opts.fast_dev_run)
43    }
44
45    /// The runner of the specification: its seed, batch, threads, stopping rule,
46    /// confidence and source, its callbacks (the progress lines, `skip_below`) and its
47    /// loggers (the CSV and JSON reports), each overridden by `opts` when it gives one.
48    /// [`run`] runs it on the scenario of the specification; a front end that runs the
49    /// trials otherwise (the Python chains with Python blocks) runs it on its own
50    /// scenario.
51    ///
52    /// # Examples
53    /// ```
54    /// use comnum_spec::{RunOptions, Spec, SpecScenario};
55    /// let spec = Spec::from_yaml("name: t\nthreads: 2\nlink: [source]\nstop: {max_trials: 3}")?;
56    /// let opts = RunOptions { threads: Some(1), ..RunOptions::default() };
57    /// let r = spec.runner(&opts).run(&SpecScenario::new(&spec), &spec.grid)?;
58    /// assert_eq!(r[0].trials, 3);
59    /// # Ok::<(), comnum_core::Error>(())
60    /// ```
61    pub fn runner(&self, opts: &RunOptions) -> Runner {
62        let mut r = self.bare_runner(opts);
63        if opts.progress.unwrap_or(self.progress) {
64            let mut bar = ProgressBar::default();
65            bar.confidence = self.confidence;
66            r = r.callback(bar);
67        }
68        if let Some(skip) = &self.skip_below {
69            r = r.callback(skip.clone());
70        }
71        if let Some(p) = opts.csv.as_ref().or(self.csv.as_ref()) {
72            r = r.logger(CsvLogger::new(p));
73        }
74        if let Some(p) = opts.json.as_ref().or(self.json.as_ref()) {
75            r = r.logger(JsonLogger::new(p));
76        }
77        r
78    }
79}
80
81/// Run a specification: its runner ([`Spec::runner`]) on its scenario
82/// ([`SpecScenario`]).
83///
84/// # Errors
85/// The errors of the blocks, of the stopping rule (an unbounded rule) and of the
86/// loggers.
87pub fn run(spec: &Spec, opts: &RunOptions) -> Result<Vec<ConditionResult>> {
88    spec.runner(opts).run(&SpecScenario::new(spec).profile(opts.profile.unwrap_or(spec.profile)), &spec.grid)
89}
90
91/// One block of a [`profile`]: its mean time per trial and its share of the link.
92#[derive(Clone, Debug, PartialEq)]
93pub struct ProfileRow {
94    /// The label of the block (`link` for the whole link).
95    pub label: String,
96    /// Mean time per trial (ms), averaged over the conditions that report it.
97    pub time_ms: f64,
98    /// Share of the time of the link, in `[0, 1]`.
99    pub share: f64,
100}
101
102/// Where the time goes: the blocks of a campaign run with `profile`, in the order of
103/// the link, from the metrics `<label>.time_ms` of the results (an empty list when the
104/// campaign was not profiled). `labels` gives the order, [`Link::labels`](crate::Link::labels); the last row
105/// is the whole link.
106pub fn profile(results: &[ConditionResult], labels: &[String]) -> Vec<ProfileRow> {
107    let mean = |name: &str| {
108        let v: Vec<f64> =
109            results.iter().filter_map(|r| r.record.get(name).map(comnum_sim::stats::Estimate::value)).collect();
110        (!v.is_empty()).then(|| v.iter().sum::<f64>() / v.len() as f64)
111    };
112    let Some(total) = mean("link.time_ms") else { return Vec::new() };
113    let mut rows: Vec<ProfileRow> = labels
114        .iter()
115        .filter_map(|l| {
116            let time_ms = mean(&format!("{l}.time_ms"))?;
117            Some(ProfileRow { label: l.clone(), time_ms, share: if total > 0.0 { time_ms / total } else { 0.0 } })
118        })
119        .collect();
120    rows.push(ProfileRow { label: "link".into(), time_ms: total, share: 1.0 });
121    rows
122}
123
124/// The profile of a specification's results: the labels of its blocks come from its
125/// first condition ([`link_labels`](crate::link_labels), without building the link).
126///
127/// # Errors
128/// [`comnum_core::Error::Config`] for a variable that the condition does not define or a
129/// block that is neither a name nor a mapping with a type.
130pub fn profile_of(spec: &Spec, results: &[ConditionResult]) -> Result<Vec<ProfileRow>> {
131    let Some(cond) = spec.conditions().into_iter().next() else { return Ok(Vec::new()) };
132    let link = spec.link.iter().map(|b| crate::substitute(b, &cond)).collect::<Result<Vec<_>>>()?;
133    Ok(profile(results, &crate::link_labels(&link)?))
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::blocks::Frame;
140    use crate::io;
141    use comnum_core::rng::stream;
142
143    #[test]
144    fn profile_times_every_block_in_the_order_of_the_link() {
145        let spec = Spec::from_yaml(
146            "name: prof\ngrid: {ebn0_db: [4, 8]}\nlink: [{type: source, constellation: QPSK, n_symbols: 512}, {type: pulse_shape, sps: 2}, {type: awgn_ebn0, ebn0_db: $ebn0_db}, {type: matched_filter}, {type: decimate}]\nmetrics: {report: [ber], skip_start: 16, skip_end: 16}\nstop: {max_trials: 4}\nprofile: true",
147        )
148        .unwrap();
149        let results = run(&spec, &RunOptions::default()).unwrap();
150        let names: Vec<&String> =
151            results[0].record.iter().map(|(k, _)| k).filter(|k| k.ends_with(".time_ms")).collect();
152        assert_eq!(names.len(), 6, "{names:?}");
153        let rows = profile_of(&spec, &results).unwrap();
154        let labels: Vec<&str> = rows.iter().map(|r| r.label.as_str()).collect();
155        assert_eq!(labels, ["source", "pulse_shape", "awgn_ebn0", "matched_filter", "decimate", "link"]);
156        assert!(rows.iter().all(|r| r.time_ms >= 0.0 && r.share >= 0.0 && r.share <= 1.0));
157        assert!((rows.last().unwrap().share - 1.0).abs() < 1e-12);
158        // not profiled: no timing metric, an empty table
159        let spec =
160            Spec::from_yaml("name: plain\nlink: [{type: source, n_symbols: 64}]\nstop: {max_trials: 1}").unwrap();
161        let results = run(&spec, &RunOptions::default()).unwrap();
162        assert!(results[0].record.iter().all(|(k, _)| !k.ends_with(".time_ms")));
163        assert_eq!(profile_of(&spec, &results).unwrap(), Vec::<ProfileRow>::new());
164        // the option overrides the specification
165        let results = run(&spec, &RunOptions { profile: Some(true), ..RunOptions::default() }).unwrap();
166        assert!(results[0].record.iter().any(|(k, _)| k == "link.time_ms"));
167    }
168
169    #[test]
170    fn dump_saves_the_first_trials_of_every_condition() {
171        let dir = std::env::temp_dir().join(format!("comnum-dump-{}", std::process::id()));
172        let _ = std::fs::remove_dir_all(&dir);
173        let spec = Spec::from_yaml(&format!(
174            "name: dumps\ngrid: {{snr_db: [5, 10]}}\nbatch: 8\nlink: [{{type: source, constellation: QPSK, n_symbols: 128, symbol_rate: 1e6}}, {{type: pulse_shape, sps: 4}}, {{type: dump, dir: {}, trials: 2, label: tap}}, {{type: awgn, snr_db: $snr_db}}, {{type: matched_filter}}, {{type: decimate}}]\nstop: {{max_trials: 8}}",
175            dir.display()
176        ))
177        .unwrap();
178        run(&spec, &RunOptions::default()).unwrap();
179        let cond = dir.join("dumps").join("snr_db=10");
180        let mut files: Vec<String> =
181            std::fs::read_dir(&cond).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
182        files.sort();
183        assert_eq!(files, ["tap_0.sigmf-data", "tap_0.sigmf-meta", "tap_1.sigmf-data", "tap_1.sigmf-meta"]);
184        let rec = io::read_sigmf(cond.join("tap_0.sigmf-meta")).unwrap();
185        assert_eq!(rec.samples.len(), 512);
186        assert_eq!(rec.sample_rate, Some(4e6));
187        assert_eq!(rec.meta["global"]["comnum:sps"], 4.0);
188        assert_eq!(rec.meta["global"]["comnum:block"], "tap");
189        // trial 0 (by index, whatever the order the threads ran them in) is
190        // reproducible from its stream
191        let link = spec.link_for(&spec.conditions()[1]).unwrap();
192        let mut rng = stream(spec.seed, &spec.name, 1, 0);
193        let mut s = Frame::default();
194        for b in &link.blocks[..2] {
195            b.apply(&mut s, &mut rng).unwrap();
196        }
197        assert_eq!(s.samples, rec.samples);
198        std::fs::remove_dir_all(&dir).unwrap();
199    }
200}