Skip to main content

dune_core/
select.rs

1//! Output selection: resolve `.print`/`.plot`/`.save`-style output
2//! expressions against a [`Solution`] and hand back plain named
3//! columns.
4//!
5//! This is the one layer every consumer shares (D-0008): the CLI's
6//! spice3-format `.print` renders a [`Selection`]; the Python, R and
7//! WASM bindings turn the same [`Selection`] into polars/numpy,
8//! tibbles and JSON column arrays. No consumer ever parses formatted
9//! text, and all of them see identical numbers.
10
11use dune_raw::{Data, VarType};
12use num_complex::Complex64;
13
14use crate::error::Error;
15use crate::solution::Solution;
16
17/// The derived part of a vector (`VR VI VM VP VDB` in `spice.txt`
18/// §.PRINT; `cx_*` in the 3f5 front end).
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum Part {
21    Real,
22    Imag,
23    Mag,
24    /// Phase in radians (the 3f5 default; `set units=degrees` is a
25    /// front-end nicety that can live in the consumers).
26    Phase,
27    /// `20·log10(mag)` (`cx_db`).
28    Db,
29}
30
31/// One output expression: what a `.print` card, a Python
32/// `result["v(2)"]` or an R `dune_run(...)$nodes` selection asks for.
33#[derive(Clone, Debug, PartialEq)]
34pub enum OutExpr {
35    /// `V(node)` / `V(a,b)` with an optional part (`VM(out)`, …).
36    Voltage {
37        node: String,
38        reference: Option<String>,
39        part: Option<Part>,
40    },
41    /// `I(vname)` with an optional part.
42    Current { source: String, part: Option<Part> },
43    /// A vector by its exact name (`time`, `v(v-sweep)`, …).
44    Name(String),
45    /// Every vector of the solution.
46    All,
47}
48
49impl Part {
50    fn prefix(self) -> &'static str {
51        match self {
52            Part::Real => "r",
53            Part::Imag => "i",
54            Part::Mag => "m",
55            Part::Phase => "p",
56            Part::Db => "db",
57        }
58    }
59
60    fn parse(s: &str) -> Option<Option<Part>> {
61        Some(match s {
62            "" => None,
63            "r" => Some(Part::Real),
64            "i" => Some(Part::Imag),
65            "m" => Some(Part::Mag),
66            "p" => Some(Part::Phase),
67            "db" => Some(Part::Db),
68            _ => return None,
69        })
70    }
71
72    /// Apply to one complex sample (`cx_real`, `cx_imag`, `cx_mag`,
73    /// `cx_ph`, `cx_db`); real data is the `im == 0` case.
74    fn apply(self, z: Complex64) -> f64 {
75        match self {
76            Part::Real => z.re,
77            Part::Imag => z.im,
78            Part::Mag => z.norm(),
79            Part::Phase => z.im.atan2(z.re),
80            Part::Db => 20.0 * z.norm().log10(),
81        }
82    }
83}
84
85impl OutExpr {
86    /// Parse `v(2)`, `v(a,b)`, `vm(out)`, `i(vin)`, `idb(v1)`,
87    /// `all`, or a bare vector name. Case-insensitive, like the 3f5
88    /// front end.
89    pub fn parse(text: &str) -> Result<OutExpr, Error> {
90        let t = text.trim().to_ascii_lowercase();
91        if t == "all" {
92            return Ok(OutExpr::All);
93        }
94        let Some(open) = t.find('(') else {
95            if t.is_empty() {
96                return Err(Error::Build("empty output expression".into()));
97            }
98            return Ok(OutExpr::Name(t));
99        };
100        let Some(inner) = t[open..]
101            .strip_prefix('(')
102            .and_then(|s| s.strip_suffix(')'))
103        else {
104            return Err(Error::Build(format!(
105                "malformed output expression `{text}`"
106            )));
107        };
108        let head = &t[..open];
109        let (kind, part_text) = match head.as_bytes().first() {
110            Some(b'v') => ('v', &head[1..]),
111            Some(b'i') => ('i', &head[1..]),
112            _ => return Ok(OutExpr::Name(t.clone())),
113        };
114        let Some(part) = Part::parse(part_text) else {
115            return Ok(OutExpr::Name(t.clone()));
116        };
117        let args: Vec<&str> = inner.split(',').map(str::trim).collect();
118        match (kind, args.as_slice()) {
119            ('v', [node]) if !node.is_empty() => Ok(OutExpr::Voltage {
120                node: node.to_string(),
121                reference: None,
122                part,
123            }),
124            ('v', [node, reference]) if !node.is_empty() && !reference.is_empty() => {
125                Ok(OutExpr::Voltage {
126                    node: node.to_string(),
127                    reference: Some(reference.to_string()),
128                    part,
129                })
130            }
131            ('i', [source]) if !source.is_empty() => Ok(OutExpr::Current {
132                source: source.to_string(),
133                part,
134            }),
135            _ => Err(Error::Build(format!(
136                "malformed output expression `{text}`"
137            ))),
138        }
139    }
140
141    /// Convert a parsed `.print`/`.save` output variable.
142    pub fn from_ir(ov: &dune_netlist::ir::OutVar) -> OutExpr {
143        use dune_netlist::ir::{AcSuffix, OutVar};
144        let part = |s: &Option<AcSuffix>| {
145            s.as_ref().map(|x| match x {
146                AcSuffix::Real => Part::Real,
147                AcSuffix::Imag => Part::Imag,
148                AcSuffix::Mag => Part::Mag,
149                AcSuffix::Phase => Part::Phase,
150                AcSuffix::Db => Part::Db,
151            })
152        };
153        match ov {
154            OutVar::Voltage {
155                node,
156                reference,
157                suffix,
158            } => OutExpr::Voltage {
159                node: node.clone(),
160                reference: reference.clone(),
161                part: part(suffix),
162            },
163            OutVar::Current { vsrc, suffix } => OutExpr::Current {
164                source: vsrc.clone(),
165                part: part(suffix),
166            },
167            OutVar::Raw(s) if s == "all" => OutExpr::All,
168            OutVar::Raw(s) => OutExpr::Name(s.clone()),
169        }
170    }
171
172    /// The canonical label (`vm(2)`, `v(a,b)`, `i(vin)`).
173    pub fn label(&self) -> String {
174        match self {
175            OutExpr::Voltage {
176                node,
177                reference,
178                part,
179            } => {
180                let p = part.map(Part::prefix).unwrap_or("");
181                match reference {
182                    Some(r) => format!("v{p}({node},{r})"),
183                    None => format!("v{p}({node})"),
184                }
185            }
186            OutExpr::Current { source, part } => {
187                let p = part.map(Part::prefix).unwrap_or("");
188                format!("i{p}({source})")
189            }
190            OutExpr::Name(n) => n.clone(),
191            OutExpr::All => "all".into(),
192        }
193    }
194}
195
196/// Column data: real for `.op`/`.dc`/`.tran`, complex for `.ac`
197/// vectors selected without a part.
198#[derive(Clone, Debug, PartialEq)]
199pub enum ColData {
200    Real(Vec<f64>),
201    Complex(Vec<Complex64>),
202}
203
204impl ColData {
205    pub fn len(&self) -> usize {
206        match self {
207            ColData::Real(v) => v.len(),
208            ColData::Complex(v) => v.len(),
209        }
210    }
211
212    pub fn is_empty(&self) -> bool {
213        self.len() == 0
214    }
215
216    /// The real view, when real.
217    pub fn real(&self) -> Option<&[f64]> {
218        match self {
219            ColData::Real(v) => Some(v),
220            ColData::Complex(_) => None,
221        }
222    }
223
224    fn as_complex(&self) -> Vec<Complex64> {
225        match self {
226            ColData::Real(v) => v.iter().map(|x| Complex64::new(*x, 0.0)).collect(),
227            ColData::Complex(v) => v.clone(),
228        }
229    }
230
231    fn sub(&self, other: &ColData) -> ColData {
232        match (self, other) {
233            (ColData::Real(a), ColData::Real(b)) => {
234                ColData::Real(a.iter().zip(b).map(|(x, y)| x - y).collect())
235            }
236            _ => {
237                let a = self.as_complex();
238                let b = other.as_complex();
239                ColData::Complex(a.iter().zip(&b).map(|(x, y)| x - y).collect())
240            }
241        }
242    }
243
244    fn part(&self, p: Part) -> ColData {
245        match self {
246            ColData::Real(v) => {
247                ColData::Real(v.iter().map(|x| p.apply(Complex64::new(*x, 0.0))).collect())
248            }
249            ColData::Complex(v) => ColData::Real(v.iter().map(|z| p.apply(*z)).collect()),
250        }
251    }
252}
253
254/// One selected column: pure data, ready for a numpy array, an R
255/// vector, a JSON column or the CLI formatter.
256#[derive(Clone, Debug)]
257pub struct OutColumn {
258    pub label: String,
259    pub unit: VarType,
260    pub data: ColData,
261}
262
263/// A resolved selection. `scale` is the plot's abscissa (`time`,
264/// `frequency`, `v(v-sweep)`) when the analysis has one, whether or
265/// not it was asked for — consumers building tables want it
266/// alongside. The scale is real by construction, also in complex
267/// plots (the 3f5's `pl_scale` is a real vector).
268#[derive(Clone, Debug)]
269pub struct Selection {
270    pub scale: Option<OutColumn>,
271    pub columns: Vec<OutColumn>,
272}
273
274impl Solution {
275    /// Resolve output expressions into columns.
276    pub fn select(&self, exprs: &[OutExpr]) -> Result<Selection, Error> {
277        let mut columns = Vec::new();
278        for e in exprs {
279            match e {
280                OutExpr::All => {
281                    for (i, (name, unit)) in self.variables.iter().enumerate() {
282                        columns.push(OutColumn {
283                            label: name.clone(),
284                            unit: unit.clone(),
285                            data: self.col(i),
286                        });
287                    }
288                }
289                OutExpr::Name(n) => {
290                    let i = self.index_or_err(n)?;
291                    columns.push(OutColumn {
292                        label: self.variables[i].0.clone(),
293                        unit: self.variables[i].1.clone(),
294                        data: self.col(i),
295                    });
296                }
297                OutExpr::Voltage {
298                    node,
299                    reference,
300                    part,
301                } => {
302                    let a = self.voltage_col(node)?;
303                    let mut data = match reference {
304                        None => a,
305                        Some(r) if r == "0" => a,
306                        Some(r) => a.sub(&self.voltage_col(r)?),
307                    };
308                    if let Some(p) = part {
309                        data = data.part(*p);
310                    }
311                    columns.push(OutColumn {
312                        label: e.label(),
313                        unit: VarType::Voltage,
314                        data,
315                    });
316                }
317                OutExpr::Current { source, part } => {
318                    let i = self.index_or_err(&format!("i({source})"))?;
319                    let mut data = self.col(i);
320                    if let Some(p) = part {
321                        data = data.part(*p);
322                    }
323                    columns.push(OutColumn {
324                        label: e.label(),
325                        unit: VarType::Current,
326                        data,
327                    });
328                }
329            }
330        }
331        let scale = self.scale.map(|i| OutColumn {
332            label: self.variables[i].0.clone(),
333            unit: self.variables[i].1.clone(),
334            // The abscissa is real by construction; strip the zero
335            // imaginary part complex plots carry.
336            data: match self.col(i) {
337                ColData::Complex(v) => ColData::Real(v.iter().map(|z| z.re).collect()),
338                real => real,
339            },
340        });
341        Ok(Selection { scale, columns })
342    }
343
344    fn col(&self, i: usize) -> ColData {
345        match &self.data {
346            Data::Real(cols) => ColData::Real(cols[i].clone()),
347            Data::Complex(cols) => ColData::Complex(cols[i].clone()),
348        }
349    }
350
351    fn index_or_err(&self, name: &str) -> Result<usize, Error> {
352        self.index(name)
353            .ok_or_else(|| Error::UnknownVector(name.to_string()))
354    }
355
356    /// `v(<node>)` by node name, with `v(v-sweep)`-style names also
357    /// reachable as plain vectors.
358    fn voltage_col(&self, node: &str) -> Result<ColData, Error> {
359        let wrapped = format!("v({node})");
360        if let Some(i) = self.index(&wrapped) {
361            return Ok(self.col(i));
362        }
363        let i = self.index_or_err(node)?;
364        Ok(self.col(i))
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    fn sol() -> Solution {
373        Solution {
374            plotname: "Transient Analysis".into(),
375            variables: vec![
376                ("time".into(), VarType::Time),
377                ("v(1)".into(), VarType::Voltage),
378                ("v(2)".into(), VarType::Voltage),
379                ("i(vin)".into(), VarType::Current),
380            ],
381            data: Data::Real(vec![
382                vec![0.0, 1.0, 2.0],
383                vec![1.0, 1.0, 1.0],
384                vec![0.25, 0.5, -1.0],
385                vec![-1e-3, -2e-3, -3e-3],
386            ]),
387            scale: Some(0),
388        }
389    }
390
391    fn sol_ac() -> Solution {
392        Solution {
393            plotname: "AC Analysis".into(),
394            variables: vec![
395                ("frequency".into(), VarType::Frequency),
396                ("v(2)".into(), VarType::Voltage),
397            ],
398            data: Data::Complex(vec![
399                vec![Complex64::new(1.0, 0.0), Complex64::new(10.0, 0.0)],
400                vec![Complex64::new(1.0, 0.0), Complex64::new(0.0, -0.5)],
401            ]),
402            scale: Some(0),
403        }
404    }
405
406    #[test]
407    fn parse_forms() {
408        assert_eq!(
409            OutExpr::parse("V(2)").unwrap(),
410            OutExpr::Voltage {
411                node: "2".into(),
412                reference: None,
413                part: None
414            }
415        );
416        assert_eq!(
417            OutExpr::parse("vdb(out,ref)").unwrap().label(),
418            "vdb(out,ref)"
419        );
420        assert_eq!(
421            OutExpr::parse("I(Vin)").unwrap(),
422            OutExpr::Current {
423                source: "vin".into(),
424                part: None
425            }
426        );
427        assert_eq!(OutExpr::parse("all").unwrap(), OutExpr::All);
428        assert_eq!(
429            OutExpr::parse("time").unwrap(),
430            OutExpr::Name("time".into())
431        );
432        assert!(OutExpr::parse("v(").is_err());
433        assert!(OutExpr::parse("").is_err());
434        // Unknown head letters fall back to a plain name.
435        assert_eq!(
436            OutExpr::parse("par(x)").unwrap(),
437            OutExpr::Name("par(x)".into())
438        );
439    }
440
441    #[test]
442    fn select_simple_and_differential() {
443        let s = sol();
444        let sel = s
445            .select(&[
446                OutExpr::parse("v(2)").unwrap(),
447                OutExpr::parse("v(1,2)").unwrap(),
448                OutExpr::parse("i(vin)").unwrap(),
449            ])
450            .unwrap();
451        assert_eq!(sel.columns[0].data.real().unwrap(), &[0.25, 0.5, -1.0]);
452        assert_eq!(sel.columns[1].label, "v(1,2)");
453        assert_eq!(sel.columns[1].data.real().unwrap(), &[0.75, 0.5, 2.0]);
454        assert_eq!(sel.columns[2].unit, VarType::Current);
455        // The scale rides along without being asked for.
456        assert_eq!(sel.scale.as_ref().unwrap().label, "time");
457    }
458
459    #[test]
460    fn parts_on_real_data() {
461        let s = sol();
462        let sel = s
463            .select(&[
464                OutExpr::parse("vm(2)").unwrap(),
465                OutExpr::parse("vdb(1)").unwrap(),
466                OutExpr::parse("vp(2)").unwrap(),
467            ])
468            .unwrap();
469        assert_eq!(sel.columns[0].data.real().unwrap(), &[0.25, 0.5, 1.0]);
470        assert_eq!(sel.columns[1].data.real().unwrap(), &[0.0, 0.0, 0.0]);
471        assert_eq!(sel.columns[2].data.real().unwrap()[2], std::f64::consts::PI);
472    }
473
474    #[test]
475    fn parts_on_complex_data() {
476        let s = sol_ac();
477        let sel = s
478            .select(&[
479                OutExpr::parse("v(2)").unwrap(),
480                OutExpr::parse("vm(2)").unwrap(),
481                OutExpr::parse("vdb(2)").unwrap(),
482                OutExpr::parse("vp(2)").unwrap(),
483            ])
484            .unwrap();
485        // No part: the complex column comes through complex.
486        assert!(matches!(sel.columns[0].data, ColData::Complex(_)));
487        let m = sel.columns[1].data.real().unwrap();
488        assert_eq!(m, &[1.0, 0.5]);
489        let db = sel.columns[2].data.real().unwrap();
490        assert!((db[1] - 20.0 * 0.5f64.log10()).abs() < 1e-12);
491        let ph = sel.columns[3].data.real().unwrap();
492        assert!((ph[1] + std::f64::consts::FRAC_PI_2).abs() < 1e-12);
493        // The frequency scale is delivered real.
494        assert_eq!(
495            sel.scale.as_ref().unwrap().data.real().unwrap(),
496            &[1.0, 10.0]
497        );
498    }
499
500    #[test]
501    fn all_and_errors() {
502        let s = sol();
503        let sel = s.select(&[OutExpr::All]).unwrap();
504        assert_eq!(sel.columns.len(), 4);
505        let err = s.select(&[OutExpr::parse("v(99)").unwrap()]).unwrap_err();
506        assert!(matches!(err, Error::UnknownVector(_)), "{err}");
507    }
508
509    #[test]
510    fn v_zero_reference_is_identity() {
511        let s = sol();
512        let sel = s.select(&[OutExpr::parse("v(2,0)").unwrap()]).unwrap();
513        assert_eq!(sel.columns[0].data.real().unwrap(), &[0.25, 0.5, -1.0]);
514    }
515}