Skip to main content

dune_core/
sweep.rs

1//! Parametric sweeps and Monte Carlo over a parsed deck (issue #10,
2//! D-0028 track 1).
3//!
4//! SPICE3 has no `.step`: varying a value means re-running the deck
5//! by hand. [`sweep`] does exactly that, for a set of *concrete*
6//! assignments the caller supplies — one row of values per run. The
7//! engine holds no RNG and builds no grids: a grid is the caller's
8//! Cartesian product, a Monte Carlo is the caller's random draws, so
9//! distributions stay idiomatic to each host language and every run
10//! is reproducible from the caller's seed.
11//!
12//! Each run is a fresh, independent simulation: the assignments are
13//! written into a copy of the netlist IR, the circuit is elaborated
14//! again with [`Circuit::from_netlist`] and every analysis card runs
15//! through [`Circuit::run`] — the same path a single `dune -b` run
16//! takes, so a sweep reproduces N individual runs bit for bit. No
17//! state (operating point, factorization) carries between runs; that
18//! is issue #12's job (re-solve), which can reuse [`SweepParam`] to
19//! address what it alters.
20//!
21//! Parameter names follow the ngspice `@device[param]` syntax, plus
22//! two shorthands (see [`SweepParam::parse`]).
23
24use dune_netlist::ir::{AcSpec, Device, Netlist, Param, ParamValue};
25
26use crate::circuit::Circuit;
27use crate::consts::CTOK;
28use crate::error::Error;
29use crate::options::Options;
30use crate::solution::Solution;
31
32/// One swept quantity.
33///
34/// Built with [`SweepParam::parse`] from the forms below (case
35/// insensitive, like the deck); names are the elaborated ones, so an
36/// element inside a subcircuit is addressed by its expanded name
37/// (`r.x1.r1`, as in the rawfile).
38///
39/// | text | quantity |
40/// |---|---|
41/// | `temp` | circuit temperature `TEMP`, in °C (as `.options temp=`) |
42/// | `r1`, `c1`, `l1` | the element value (Ω, F, H) |
43/// | `vin`, `iin` | the source's DC level |
44/// | `@r1[resistance]`, `@r1[r]` | resistor value |
45/// | `@c1[capacitance]`, `@c1[c]` | capacitor value |
46/// | `@l1[inductance]`, `@l1[l]` | inductor value |
47/// | `@vin[dc]`, `@vin[acmag]`, `@vin[acphase]` | V/I source DC level, AC magnitude, AC phase (degrees) |
48/// | `@m1[w]`, `@m1[l]` | MOSFET drawn width and length (m) |
49///
50/// Model parameters are not sweepable yet (v2 of issue #10).
51#[derive(Clone, Debug, PartialEq, Eq, Hash)]
52pub enum SweepParam {
53    /// Circuit temperature (`TEMP`), in °C.
54    Temp,
55    /// A parameter of one element: `param` is the canonical ngspice
56    /// name (`resistance`, `capacitance`, `inductance`, `dc`,
57    /// `acmag`, `acphase`, `w`, `l`); `None` is the element's primary
58    /// value (R/C/L value, V/I DC level).
59    Instance { name: String, param: Option<String> },
60}
61
62impl SweepParam {
63    /// Parse `temp`, `name` or `@name[param]` (see the type's table).
64    /// Whether the element exists and takes the parameter is checked
65    /// against a netlist by [`sweep`].
66    pub fn parse(text: &str) -> Result<Self, Error> {
67        let t = text.trim().to_ascii_lowercase();
68        let bad = || {
69            Error::Build(format!(
70                "sweep parameter `{text}`: expected `temp`, an element name or `@name[param]`"
71            ))
72        };
73        if t == "temp" {
74            return Ok(SweepParam::Temp);
75        }
76        if let Some(rest) = t.strip_prefix('@') {
77            let (name, param) = rest
78                .strip_suffix(']')
79                .and_then(|r| r.split_once('['))
80                .ok_or_else(bad)?;
81            let (name, param) = (name.trim(), param.trim());
82            if name.is_empty() || param.is_empty() || !is_word(name) || !is_word(param) {
83                return Err(bad());
84            }
85            let param = match param {
86                "r" => "resistance",
87                "c" => "capacitance",
88                other => other,
89            };
90            return Ok(SweepParam::Instance {
91                name: name.to_string(),
92                param: Some(param.to_string()),
93            });
94        }
95        if t.is_empty() || !is_word(&t) {
96            return Err(bad());
97        }
98        Ok(SweepParam::Instance {
99            name: t,
100            param: None,
101        })
102    }
103
104    /// The canonical text form (`temp`, `r1`, `@m1[w]`), which
105    /// [`parse`](Self::parse) maps back to `self`.
106    pub fn key(&self) -> String {
107        match self {
108            SweepParam::Temp => "temp".into(),
109            SweepParam::Instance { name, param: None } => name.clone(),
110            SweepParam::Instance {
111                name,
112                param: Some(p),
113            } => format!("@{name}[{p}]"),
114        }
115    }
116}
117
118impl std::fmt::Display for SweepParam {
119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        f.write_str(&self.key())
121    }
122}
123
124impl std::str::FromStr for SweepParam {
125    type Err = Error;
126    fn from_str(s: &str) -> Result<Self, Error> {
127        SweepParam::parse(s)
128    }
129}
130
131/// No brackets, `@`, `=` or blanks inside a name or a parameter.
132fn is_word(s: &str) -> bool {
133    !s.chars()
134        .any(|c| c.is_whitespace() || matches!(c, '[' | ']' | '@' | '=' | '(' | ')' | ','))
135}
136
137/// What one run produced: the plots of every analysis card, in deck
138/// order (as [`Circuit::run`] gives them), and the notes of the
139/// elaboration and of each analysis.
140#[derive(Debug)]
141pub struct SweepOutput {
142    pub solutions: Vec<Solution>,
143    pub notes: Vec<String>,
144}
145
146/// One run of a sweep: the values it assigned (in the order of the
147/// sweep's parameters) and its outcome. A failing run (no
148/// convergence, say — routine in a Monte Carlo tail) does not stop
149/// the others.
150#[derive(Debug)]
151pub struct SweepRun {
152    pub values: Vec<f64>,
153    pub outcome: Result<SweepOutput, Error>,
154}
155
156/// How a resolved parameter writes into the IR.
157#[derive(Clone, Copy, Debug, PartialEq)]
158enum Slot {
159    Temp,
160    /// R/C/L `value`.
161    Value(usize),
162    SrcDc(usize),
163    SrcAcMag(usize),
164    SrcAcPhase(usize),
165    /// MOSFET keyword parameter (`w` or `l`).
166    MosGeom(usize, &'static str),
167}
168
169/// Resolve every parameter against the netlist, refusing unknown
170/// elements, parameters the element does not take and duplicates.
171fn resolve(nl: &Netlist, params: &[SweepParam]) -> Result<Vec<Slot>, Error> {
172    let mut slots = Vec::with_capacity(params.len());
173    for p in params {
174        let SweepParam::Instance { name, param } = p else {
175            if slots.contains(&Slot::Temp) {
176                return Err(Error::Build("sweep parameter `temp` given twice".into()));
177            }
178            slots.push(Slot::Temp);
179            continue;
180        };
181        let Some(idx) = nl.elements.iter().position(|e| &e.name == name) else {
182            return Err(Error::Build(format!(
183                "sweep parameter `{p}`: no element `{name}` in the circuit"
184            )));
185        };
186        let el = &nl.elements[idx];
187        let param = param.as_deref();
188        let slot = match (&el.device, param) {
189            (Device::Resistor { .. }, None | Some("resistance"))
190            | (Device::Capacitor { .. }, None | Some("capacitance"))
191            | (Device::Inductor { .. }, None | Some("inductance")) => Slot::Value(idx),
192            // `@l1[l]`: the inductor's own letter, as ngspice allows.
193            (Device::Inductor { .. }, Some("l")) => Slot::Value(idx),
194            (Device::Source { .. }, None | Some("dc")) => Slot::SrcDc(idx),
195            (Device::Source { .. }, Some("acmag")) => Slot::SrcAcMag(idx),
196            (Device::Source { .. }, Some("acphase")) => Slot::SrcAcPhase(idx),
197            (Device::Mosfet { .. }, Some("w")) => Slot::MosGeom(idx, "w"),
198            (Device::Mosfet { .. }, Some("l")) => Slot::MosGeom(idx, "l"),
199            (_, None) => {
200                return Err(Error::Build(format!(
201                    "sweep parameter `{p}`: `{name}` has no primary value to sweep \
202                     (R, C, L values and V/I DC levels do); name a parameter as `@{name}[...]`"
203                )));
204            }
205            (_, Some(other)) => {
206                return Err(Error::Build(format!(
207                    "sweep parameter `{p}`: `{name}` has no sweepable parameter `{other}` \
208                     (sweepable: R/C/L value, V/I dc/acmag/acphase, MOSFET w/l, temp)"
209                )));
210            }
211        };
212        if let Some(j) = slots.iter().position(|s| *s == slot) {
213            return Err(Error::Build(format!(
214                "sweep parameter `{p}` given twice (also as `{}`)",
215                params[j]
216            )));
217        }
218        slots.push(slot);
219    }
220    Ok(slots)
221}
222
223/// Write one run's values into a copy of the netlist and options.
224fn assign(nl: &mut Netlist, opts: &mut Options, slots: &[Slot], values: &[f64]) {
225    for (slot, &v) in slots.iter().zip(values) {
226        match *slot {
227            // °C, like the `.options temp=` card (`Options::apply`).
228            Slot::Temp => opts.temp = v + CTOK,
229            Slot::Value(i) => match &mut nl.elements[i].device {
230                Device::Resistor { value, .. }
231                | Device::Capacitor { value, .. }
232                | Device::Inductor { value, .. } => *value = Some(v),
233                _ => unreachable!("resolved as an R/C/L value"),
234            },
235            Slot::SrcDc(i) => {
236                if let Device::Source { dc, .. } = &mut nl.elements[i].device {
237                    *dc = Some(v);
238                }
239            }
240            Slot::SrcAcMag(i) | Slot::SrcAcPhase(i) => {
241                if let Device::Source { ac, .. } = &mut nl.elements[i].device {
242                    // A source without an AC spec gains one with the
243                    // other half zero (a zero-magnitude source excites
244                    // nothing, as no spec at all).
245                    let spec = ac.get_or_insert(AcSpec {
246                        mag: 0.0,
247                        phase: 0.0,
248                    });
249                    if matches!(slot, Slot::SrcAcMag(_)) {
250                        spec.mag = v;
251                    } else {
252                        spec.phase = v;
253                    }
254                }
255            }
256            Slot::MosGeom(i, key) => {
257                let span = nl.elements[i].span;
258                if let Device::Mosfet { params, .. } = &mut nl.elements[i].device {
259                    // The elaborator takes the last `w=`/`l=`: rewrite
260                    // every occurrence so no stale one remains.
261                    let mut found = false;
262                    for p in params.iter_mut().filter(|p| p.name == key) {
263                        p.value = ParamValue::Num(v);
264                        found = true;
265                    }
266                    if !found {
267                        params.push(Param {
268                            name: key.to_string(),
269                            value: ParamValue::Num(v),
270                            span,
271                        });
272                    }
273                }
274            }
275        }
276    }
277}
278
279/// One run: assign, elaborate, run every analysis card.
280fn run_one(nl: &Netlist, opts: &Options, slots: &[Slot], values: &[f64]) -> SweepRun {
281    let mut nl = nl.clone();
282    let mut opts = opts.clone();
283    assign(&mut nl, &mut opts, slots, values);
284    let outcome = (|| {
285        let (circuit, mut notes) = Circuit::from_netlist(&nl)?;
286        let mut solutions = Vec::new();
287        for analysis in &nl.analyses {
288            let (sols, n) = circuit.run(analysis, &opts)?;
289            notes.extend(n);
290            solutions.extend(sols);
291        }
292        Ok(SweepOutput { solutions, notes })
293    })();
294    SweepRun {
295        values: values.to_vec(),
296        outcome,
297    }
298}
299
300/// Check the shape and values of the runs and resolve the
301/// parameters.
302fn prepare(nl: &Netlist, params: &[SweepParam], runs: &[Vec<f64>]) -> Result<Vec<Slot>, Error> {
303    let slots = resolve(nl, params)?;
304    for (r, row) in runs.iter().enumerate() {
305        if row.len() != params.len() {
306            return Err(Error::Build(format!(
307                "sweep run {r}: {} values for {} parameters",
308                row.len(),
309                params.len()
310            )));
311        }
312        if let Some((p, v)) = params.iter().zip(row).find(|(_, v)| !v.is_finite()) {
313            return Err(Error::Build(format!(
314                "sweep run {r}: `{p}` = {v} is not finite"
315            )));
316        }
317    }
318    Ok(slots)
319}
320
321/// Run the deck once per row of `runs`, each row assigning
322/// `runs[r][k]` to `params[k]`, sequentially and in order.
323///
324/// `opts` are the run's options (the deck's `.options` already
325/// applied, plus any caller overrides); a swept `temp` replaces
326/// `opts.temp` per run. Every run is independent and identical to
327/// simulating the deck with those values written into it — bit for
328/// bit, the same path as [`Circuit::from_netlist`] + [`Circuit::run`].
329///
330/// The outer error is for the sweep itself (an unknown element or
331/// parameter, a duplicate, a row of the wrong length, a non-finite
332/// value), checked before any run; each run's own failure is in its
333/// [`SweepRun::outcome`].
334pub fn sweep(
335    netlist: &Netlist,
336    opts: &Options,
337    params: &[SweepParam],
338    runs: &[Vec<f64>],
339) -> Result<Vec<SweepRun>, Error> {
340    let slots = prepare(netlist, params, runs)?;
341    Ok(runs
342        .iter()
343        .map(|row| run_one(netlist, opts, &slots, row))
344        .collect())
345}
346
347/// [`sweep`] with the runs spread over `threads` worker threads
348/// (`0`: one per core, rayon's default). Results come back in run
349/// order and are bit-identical to [`sweep`]'s: each run is its own
350/// circuit, with no reduction across runs.
351///
352/// Native only (feature `parallel`); `wasm32` builds use [`sweep`].
353#[cfg(feature = "parallel")]
354pub fn sweep_parallel(
355    netlist: &Netlist,
356    opts: &Options,
357    params: &[SweepParam],
358    runs: &[Vec<f64>],
359    threads: usize,
360) -> Result<Vec<SweepRun>, Error> {
361    use rayon::prelude::*;
362    let slots = prepare(netlist, params, runs)?;
363    let pool = rayon::ThreadPoolBuilder::new()
364        .num_threads(threads)
365        .build()
366        .map_err(|e| Error::Build(format!("sweep thread pool: {e}")))?;
367    Ok(pool.install(|| {
368        runs.par_iter()
369            .map(|row| run_one(netlist, opts, &slots, row))
370            .collect()
371    }))
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn parse_forms() {
380        assert_eq!(SweepParam::parse("TEMP").unwrap(), SweepParam::Temp);
381        assert_eq!(
382            SweepParam::parse("R1").unwrap(),
383            SweepParam::Instance {
384                name: "r1".into(),
385                param: None
386            }
387        );
388        let w = SweepParam::parse("@M1[W]").unwrap();
389        assert_eq!(w.key(), "@m1[w]");
390        assert_eq!(SweepParam::parse(&w.key()).unwrap(), w);
391        assert_eq!(
392            SweepParam::parse("@r.x1.r1[r]").unwrap().key(),
393            "@r.x1.r1[resistance]"
394        );
395        for bad in ["", "@", "@r1", "@r1[]", "@[w]", "r 1", "@m1[w", "a=b"] {
396            assert!(SweepParam::parse(bad).is_err(), "{bad:?}");
397        }
398    }
399}