Skip to main content

dune_core/
measure.rs

1//! `.measure`: scalar measurements over a computed plot — a pure
2//! post-processing layer (data in, scalars out) that never touches
3//! the simulation. The CLI prints it in ngspice's format; the
4//! Python, R and JS bindings hand the same [`Measurement`]s back.
5//!
6//! The contract is ngspice-47's `frontend/com_measure2.c` (the 3f5
7//! has no measurements): the same crossing state machine
8//! (`com_measure_when`), linear interpolation (`measure_at`),
9//! trapezoidal average without window interpolation
10//! (`measure_minMaxAvg`) and the Simpson 3/8 – 1/3 – trapezoid
11//! integral with interpolated window edges (`measure_rms_integral`),
12//! with the analysis-specific windows (`TD`/`FROM`/`TO` defaults;
13//! `.dc` windows are inclusive and may run backwards). Deliberate
14//! departures, each in `corpus/KNOWN_DIFFS.md`:
15//!
16//! * a crossing inside the first segment of the window counts and is
17//!   interpolated there (ngspice counts it during its state
18//!   initialisation but never reports it, or extrapolates it from the
19//!   next segment);
20//! * `DERIV` is implemented (ngspice-47: "currently not supported");
21//! * in `WHEN v(a)=v(b)` each side keeps its own part (`vdb`, `vp`…;
22//!   ngspice applies the first side's part to both).
23//!
24//! Notes on the design are in `docs/dune-measure.md`.
25
26use dune_netlist::ir::{
27    MeasCount, MeasCross, MeasEdge, MeasFunc, MeasLevel, MeasPoint, MeasStat, MeasTrig, MeasWindow,
28    Measure, OutVar,
29};
30
31use crate::select::{ColData, OutExpr};
32use crate::solution::Solution;
33
34/// The ngspice "unset" `AT` and the `.dc` window bounds.
35const DC_FAR: f64 = 1e99;
36
37/// What a successful measurement produced. `value` is the measure's
38/// result (what ngspice prints first and stores as the parameter);
39/// `detail` carries the extra numbers ngspice reports beside it.
40#[derive(Clone, Copy, Debug, PartialEq)]
41pub struct Measurement {
42    pub value: f64,
43    pub detail: MeasDetail,
44}
45
46/// The extras ngspice prints after the value, per function.
47#[derive(Clone, Copy, Debug, PartialEq)]
48pub enum MeasDetail {
49    /// `WHEN`, `FIND`, `DERIV`: nothing more.
50    None,
51    /// `TRIG/TARG`: `value = targ − trig`.
52    Delay { trig: f64, targ: f64 },
53    /// `MIN`/`MAX`: the scale value of the extremum (`at=`).
54    At { at: f64 },
55    /// `MIN_AT`/`MAX_AT`: `value` is the scale value, `with` the
56    /// extremum (`with=`).
57    With { with: f64 },
58    /// `AVG`, `RMS`, `INTEG`, `PP`: the window ngspice reports. For
59    /// `RMS`/`INTEG` it is the integrated interval (edges
60    /// interpolated); for `AVG` `from` is the `FROM=` given (or its
61    /// default) and `to` the last scale point read; for `PP` both
62    /// are as given (`0` = unset in `.tran`/`.ac`) — all as ngspice
63    /// prints them.
64    Window { from: f64, to: f64 },
65}
66
67impl MeasDetail {
68    /// The extras as `(label, value)` pairs, labelled as ngspice
69    /// prints them (`trig`, `targ`, `at`, `with`, `from`, `to`) —
70    /// the flat form the bindings hand out.
71    pub fn pairs(&self) -> Vec<(&'static str, f64)> {
72        match *self {
73            MeasDetail::None => Vec::new(),
74            MeasDetail::Delay { trig, targ } => vec![("trig", trig), ("targ", targ)],
75            MeasDetail::At { at } => vec![("at", at)],
76            MeasDetail::With { with } => vec![("with", with)],
77            MeasDetail::Window { from, to } => vec![("from", from), ("to", to)],
78        }
79    }
80}
81
82/// Which clause of the card a failure belongs to (ngspice's
83/// `measure  NAME  FUNC(CLAUSE)` message).
84#[derive(Clone, Copy, Debug, PartialEq, Eq)]
85pub enum MeasClause {
86    Trig,
87    Targ,
88    When,
89    At,
90}
91
92impl MeasClause {
93    /// The upper-case tag ngspice prints.
94    pub fn tag(self) -> &'static str {
95        match self {
96            MeasClause::Trig => "TRIG",
97            MeasClause::Targ => "TARG",
98            MeasClause::When => "WHEN",
99            MeasClause::At => "AT",
100        }
101    }
102}
103
104/// Why a measurement has no value.
105#[derive(Clone, Debug, PartialEq, thiserror::Error)]
106pub enum MeasureError {
107    /// The event never happens in the plot (or the window is empty):
108    /// ngspice's "out of interval".
109    #[error("out of interval")]
110    OutOfInterval { clause: MeasClause },
111    /// The output variable is not in the plot.
112    #[error("no such vector as `{0}`")]
113    UnknownVector(String),
114    /// Measurements need a `.tran`, `.ac`, `.dc` or `.sp` plot.
115    #[error("measure limited to tran, dc, sp, or ac analysis (plot `{0}`)")]
116    UnsupportedPlot(String),
117}
118
119impl MeasureError {
120    /// The message with its clause, for one-line reports
121    /// (`out of interval (WHEN)`).
122    pub fn describe(&self) -> String {
123        match self {
124            MeasureError::OutOfInterval { clause } => {
125                format!("out of interval ({})", clause.tag())
126            }
127            other => other.to_string(),
128        }
129    }
130}
131
132/// The analysis family of a plot, which decides the window rules.
133#[derive(Clone, Copy, Debug, PartialEq, Eq)]
134enum Kind {
135    Tran,
136    /// `.ac` and `.sp`.
137    Ac,
138    Dc,
139}
140
141fn plot_kind(sol: &Solution) -> Result<Kind, MeasureError> {
142    let kind = match sol.plotname.as_str() {
143        "Transient Analysis" => Kind::Tran,
144        "AC Analysis" | "SP Analysis" => Kind::Ac,
145        "DC transfer characteristic" => Kind::Dc,
146        other => return Err(MeasureError::UnsupportedPlot(other.to_string())),
147    };
148    if sol.scale.is_none() {
149        return Err(MeasureError::UnsupportedPlot(sol.plotname.clone()));
150    }
151    Ok(kind)
152}
153
154/// Does a `.measure` card of `analysis` apply to this plot?
155pub fn applies_to(analysis: dune_netlist::ir::MeasAnalysis, sol: &Solution) -> bool {
156    use dune_netlist::ir::MeasAnalysis as A;
157    matches!(
158        (analysis, sol.plotname.as_str()),
159        (A::Tran, "Transient Analysis")
160            | (A::Ac, "AC Analysis")
161            | (A::Sp, "SP Analysis")
162            | (A::Dc, "DC transfer characteristic")
163    )
164}
165
166/// The plot's data, resolved once per measurement.
167struct Plot<'a> {
168    sol: &'a Solution,
169    kind: Kind,
170    x: Vec<f64>,
171}
172
173impl<'a> Plot<'a> {
174    fn new(sol: &'a Solution) -> Result<Self, MeasureError> {
175        let kind = plot_kind(sol)?;
176        let sel = sol
177            .select(&[])
178            .map_err(|e| MeasureError::UnknownVector(e.to_string()))?;
179        let x = match sel.scale.map(|c| c.data) {
180            Some(ColData::Real(v)) => v,
181            Some(ColData::Complex(v)) => v.iter().map(|z| z.re).collect(),
182            None => return Err(MeasureError::UnsupportedPlot(sol.plotname.clone())),
183        };
184        Ok(Plot { sol, kind, x })
185    }
186
187    /// One real trace: complex vectors without a part read their
188    /// real part (`get_value`'s default).
189    fn trace(&self, var: &OutVar) -> Result<Vec<f64>, MeasureError> {
190        let expr = OutExpr::from_ir(var);
191        let label = expr.label();
192        let sel = self
193            .sol
194            .select(std::slice::from_ref(&expr))
195            .map_err(|_| MeasureError::UnknownVector(label.clone()))?;
196        let Some(col) = sel.columns.into_iter().next() else {
197            return Err(MeasureError::UnknownVector(label));
198        };
199        Ok(match col.data {
200            ColData::Real(v) => v,
201            ColData::Complex(v) => v.iter().map(|z| z.re).collect(),
202        })
203    }
204}
205
206impl Solution {
207    /// Evaluate one measurement on this plot (pure: the solution is
208    /// only read). `func` comes from a `.measure` card
209    /// ([`dune_netlist::ir::Measure::func`]) or from
210    /// [`dune_netlist::parse_measure_body`].
211    pub fn measure(&self, func: &MeasFunc) -> Result<Measurement, MeasureError> {
212        let p = Plot::new(self)?;
213        match func {
214            MeasFunc::Delay { trig, targ } => delay(&p, trig, targ),
215            MeasFunc::When(c) => {
216                let t = when(&p, c)?.ok_or(MeasureError::OutOfInterval {
217                    clause: MeasClause::When,
218                })?;
219                Ok(Measurement {
220                    value: t,
221                    detail: MeasDetail::None,
222                })
223            }
224            MeasFunc::Find { var, at } => {
225                let y = p.trace(var)?;
226                let x = point(&p, at)?;
227                let v = value_at(&p, &y, x).ok_or(MeasureError::OutOfInterval {
228                    clause: MeasClause::At,
229                })?;
230                Ok(Measurement {
231                    value: v,
232                    detail: MeasDetail::None,
233                })
234            }
235            MeasFunc::Deriv { var, at } => {
236                let y = p.trace(var)?;
237                let x = point(&p, at)?;
238                let d = derivative(&p.x, &y);
239                let v = value_at(&p, &d, x).filter(|v| !v.is_nan()).ok_or(
240                    MeasureError::OutOfInterval {
241                        clause: MeasClause::At,
242                    },
243                )?;
244                Ok(Measurement {
245                    value: v,
246                    detail: MeasDetail::None,
247                })
248            }
249            MeasFunc::Stat { stat, var, window } => {
250                let y = p.trace(var)?;
251                stat_measure(&p, *stat, &y, window)
252            }
253        }
254    }
255}
256
257/// One `.measure` card evaluated on one plot.
258#[derive(Clone, Debug)]
259pub struct MeasureOutcome {
260    /// Index of the card in [`dune_netlist::ir::Netlist::measures`].
261    pub card: usize,
262    /// Index of the plot it was evaluated on.
263    pub plot: usize,
264    pub result: Result<Measurement, MeasureError>,
265}
266
267/// Evaluate a deck's `.measure` cards: each against every plot of
268/// its analysis, in plot order then card order (what `dune -b`
269/// prints and what the bindings return).
270pub fn measure_all(measures: &[Measure], solutions: &[Solution]) -> Vec<MeasureOutcome> {
271    let mut out = Vec::new();
272    for (pi, sol) in solutions.iter().enumerate() {
273        for (ci, m) in measures.iter().enumerate() {
274            if applies_to(m.analysis, sol) {
275                out.push(MeasureOutcome {
276                    card: ci,
277                    plot: pi,
278                    result: sol.measure(&m.func),
279                });
280            }
281        }
282    }
283    out
284}
285
286/// `TRIG … TARG …` (the `AT_DELAY` case of `get_measure2`).
287fn delay(p: &Plot, trig: &MeasTrig, targ: &MeasTrig) -> Result<Measurement, MeasureError> {
288    // "If there was a FROM propagate trig<->targ."
289    let mut trig = trig.clone();
290    let mut targ = targ.clone();
291    if let (MeasTrig::Cross(a), MeasTrig::Cross(b)) = (&mut trig, &mut targ) {
292        match (a.window.from, b.window.from) {
293            (Some(f), None) => b.window.from = Some(f),
294            (None, Some(f)) => a.window.from = Some(f),
295            _ => {}
296        }
297    }
298    let side = |t: &MeasTrig, clause| -> Result<f64, MeasureError> {
299        match t {
300            MeasTrig::At(x) => Ok(*x),
301            MeasTrig::Cross(c) => when(p, c)?.ok_or(MeasureError::OutOfInterval { clause }),
302        }
303    };
304    let t0 = side(&trig, MeasClause::Trig)?;
305    let t1 = side(&targ, MeasClause::Targ)?;
306    Ok(Measurement {
307        value: t1 - t0,
308        detail: MeasDetail::Delay { trig: t0, targ: t1 },
309    })
310}
311
312/// The scale value `FIND`/`DERIV` read at.
313fn point(p: &Plot, at: &MeasPoint) -> Result<f64, MeasureError> {
314    match at {
315        MeasPoint::At(x) => Ok(*x),
316        MeasPoint::When(c) => when(p, c)?.ok_or(MeasureError::OutOfInterval {
317            clause: MeasClause::At,
318        }),
319    }
320}
321
322/// The resolved window (ngspice's per-analysis sentinels: `TD = 0`,
323/// `FROM = 0`, `TO = 0` meaning "no end" for `.tran`/`.ac`;
324/// `FROM = −1e99`, `TO = 1e99`, swapped into order, for `.dc`).
325#[derive(Clone, Copy)]
326struct Win {
327    td: f64,
328    from: f64,
329    to: f64,
330}
331
332fn window(kind: Kind, w: &MeasWindow) -> Win {
333    match kind {
334        Kind::Dc => {
335            let mut from = w.from.unwrap_or(-DC_FAR);
336            let mut to = w.to.unwrap_or(DC_FAR);
337            if to < from {
338                std::mem::swap(&mut from, &mut to);
339            }
340            Win { td: 0.0, from, to }
341        }
342        _ => Win {
343            td: w.td.unwrap_or(0.0),
344            from: w.from.unwrap_or(0.0),
345            to: w.to.unwrap_or(0.0),
346        },
347    }
348}
349
350/// Where a scale point falls relative to the window.
351enum Gate {
352    Skip,
353    Take,
354    Stop,
355}
356
357/// The window test of `measure_minMaxAvg`/`com_measure_when`.
358fn gate(kind: Kind, w: &Win, x: f64) -> Gate {
359    match kind {
360        Kind::Dc => {
361            if x < w.from || x > w.to {
362                Gate::Skip
363            } else {
364                Gate::Take
365            }
366        }
367        _ => {
368            if x < w.from {
369                Gate::Skip
370            } else if w.to != 0.0 && x > w.to {
371                Gate::Stop
372            } else {
373                Gate::Take
374            }
375        }
376    }
377}
378
379#[derive(Clone, Copy, PartialEq, Eq)]
380enum Side {
381    Above,
382    Below,
383}
384
385/// `com_measure_when`: the scale value at which `var` crosses its
386/// level, by the requested edge; `None` when it never does in the
387/// window.
388fn when(p: &Plot, c: &MeasCross) -> Result<Option<f64>, MeasureError> {
389    let y = p.trace(&c.var)?;
390    let y2 = match &c.level {
391        MeasLevel::Value(_) => None,
392        MeasLevel::Var(v) => Some(p.trace(v)?),
393    };
394    let level = match c.level {
395        MeasLevel::Value(v) => v,
396        MeasLevel::Var(_) => f64::NAN,
397    };
398    let w = window(p.kind, &c.window);
399    // ngspice's counters: MEASURE_DEFAULT (-1) when unused.
400    let (want_rise, want_fall, want_cross) = match c.edge {
401        MeasEdge::Any => (None, None, None),
402        MeasEdge::Rise(n) => (Some(n), None, None),
403        MeasEdge::Fall(n) => (None, Some(n), None),
404        MeasEdge::Cross(n) => (None, None, Some(n)),
405    };
406    let last_rise = want_rise == Some(MeasCount::Last);
407    let last_fall = want_fall == Some(MeasCount::Last);
408    let any_last = matches!(
409        c.edge,
410        MeasEdge::Rise(MeasCount::Last)
411            | MeasEdge::Fall(MeasCount::Last)
412            | MeasEdge::Cross(MeasCount::Last)
413    );
414    let nth = |want: Option<MeasCount>, n: u32| want == Some(MeasCount::Nth(n));
415
416    let origin = p.x.first().copied().unwrap_or(0.0);
417    let (mut rise, mut fall, mut cross) = (0u32, 0u32, 0u32);
418    let mut section = Side::Above;
419    let mut started = false;
420    let (mut px, mut pv, mut pv2) = (0.0, 0.0, 0.0);
421    let mut last: Option<f64> = None;
422
423    for i in 0..y.len().min(p.x.len()) {
424        let x = p.x[i];
425        let v = y[i];
426        let v2 = y2.as_ref().map_or(level, |y2| y2[i]);
427        match p.kind {
428            Kind::Tran if x < w.td => continue,
429            Kind::Ac if x < 0.0 => continue,
430            _ => {}
431        }
432        match gate(p.kind, &w, x) {
433            Gate::Skip => continue,
434            Gate::Stop => break,
435            Gate::Take => {}
436        }
437        // `.dc`: a nested sweep jumping back to its origin starts a
438        // new trace (ngspice resets the cross counter only).
439        if started && p.kind == Kind::Dc && x == origin {
440            started = false;
441            cross = 0;
442        }
443        if !started {
444            section = if v < v2 { Side::Below } else { Side::Above };
445            started = true;
446            (px, pv, pv2) = (x, v, v2);
447            continue;
448        }
449        let mut pending = false;
450        if section == Side::Below && v >= v2 {
451            section = Side::Above;
452            cross += 1;
453            rise += 1;
454            pending = !last_fall;
455        } else if section == Side::Above && v <= v2 {
456            section = Side::Below;
457            cross += 1;
458            fall += 1;
459            pending = !last_rise;
460        }
461        let interp = || match &y2 {
462            None => px + (level - pv) * (x - px) / (v - pv),
463            Some(_) => px + (pv2 - pv) * (x - px) / (v - pv - v2 + pv2),
464        };
465        // The C checks the exact count at every point; counts only
466        // move on transitions, so it fires on the one that reached it.
467        if nth(want_cross, cross) || nth(want_rise, rise) || nth(want_fall, fall) {
468            return Ok(finite(interp()));
469        }
470        if pending {
471            if c.edge == MeasEdge::Any {
472                return Ok(finite(interp()));
473            }
474            if any_last {
475                last = Some(interp());
476            }
477        }
478        (px, pv, pv2) = (x, v, v2);
479    }
480    Ok(last.and_then(finite))
481}
482
483/// A crossing interpolated across a flat stretch (`v == level` on
484/// both ends: the C's `<=`/`>=` flip-flop on equality) is 0/0;
485/// ngspice reports it as "out of interval", and so do we.
486fn finite(x: f64) -> Option<f64> {
487    (!x.is_nan()).then_some(x)
488}
489
490/// `measure_at`: linear interpolation of `y` at scale value `at`
491/// (a `.dc` sweep may run backwards).
492fn value_at(p: &Plot, y: &[f64], at: f64) -> Option<f64> {
493    let n = y.len().min(p.x.len());
494    for i in 1..n {
495        let (px, x) = (p.x[i - 1], p.x[i]);
496        let forward = px <= at && x >= at;
497        let backward = p.kind == Kind::Dc && px >= at && x <= at;
498        if forward || backward {
499            let (pv, v) = (y[i - 1], y[i]);
500            if x == px {
501                return Some(v);
502            }
503            return Some(pv + (at - px) * (v - pv) / (x - px));
504        }
505    }
506    None
507}
508
509/// Pointwise d(y)/d(x): the three-point formula on the (possibly
510/// non-uniform) grid inside, one-sided differences at the ends — the
511/// derivative of the parabola through each sample and its
512/// neighbours, second-order accurate. `DERIV` interpolates it
513/// linearly like `FIND` interpolates values.
514fn derivative(x: &[f64], y: &[f64]) -> Vec<f64> {
515    let n = x.len().min(y.len());
516    let slope = |a: usize, b: usize| {
517        let h = x[b] - x[a];
518        if h == 0.0 {
519            f64::NAN
520        } else {
521            (y[b] - y[a]) / h
522        }
523    };
524    (0..n)
525        .map(|i| {
526            if n < 2 {
527                return f64::NAN;
528            }
529            if i == 0 {
530                return slope(0, 1);
531            }
532            if i == n - 1 {
533                return slope(n - 2, n - 1);
534            }
535            let h0 = x[i] - x[i - 1];
536            let h1 = x[i + 1] - x[i];
537            if h0 == 0.0 {
538                return slope(i, i + 1);
539            }
540            if h1 == 0.0 || h0 + h1 == 0.0 {
541                return slope(i - 1, i);
542            }
543            (h0 * h0 * y[i + 1] - h1 * h1 * y[i - 1] + (h1 * h1 - h0 * h0) * y[i])
544                / (h0 * h1 * (h0 + h1))
545        })
546        .collect()
547}
548
549fn stat_measure(
550    p: &Plot,
551    stat: MeasStat,
552    y: &[f64],
553    w: &MeasWindow,
554) -> Result<Measurement, MeasureError> {
555    let win = window(p.kind, w);
556    let oob = MeasureError::OutOfInterval {
557        clause: MeasClause::Trig,
558    };
559    match stat {
560        MeasStat::Min | MeasStat::Max | MeasStat::MinAt | MeasStat::MaxAt => {
561            let max = matches!(stat, MeasStat::Max | MeasStat::MaxAt);
562            let (m, at) = extremum(p, y, &win, max).ok_or(oob)?;
563            Ok(match stat {
564                MeasStat::Min | MeasStat::Max => Measurement {
565                    value: m,
566                    detail: MeasDetail::At { at },
567                },
568                _ => Measurement {
569                    value: at,
570                    detail: MeasDetail::With { with: m },
571                },
572            })
573        }
574        MeasStat::Pp => {
575            let (lo, _) = extremum(p, y, &win, false).ok_or(oob.clone())?;
576            let (hi, _) = extremum(p, y, &win, true).ok_or(oob)?;
577            Ok(Measurement {
578                value: hi - lo,
579                detail: MeasDetail::Window {
580                    from: win.from,
581                    to: win.to,
582                },
583            })
584        }
585        MeasStat::Avg => {
586            let (avg, to) = average(p, y, &win);
587            let avg = avg.ok_or(oob)?;
588            Ok(Measurement {
589                value: avg,
590                detail: MeasDetail::Window { from: win.from, to },
591            })
592        }
593        MeasStat::Rms | MeasStat::Integ => {
594            let (v, from, to) = rms_integral(p, y, &win, stat == MeasStat::Rms);
595            if v.is_nan() {
596                return Err(oob);
597            }
598            Ok(Measurement {
599                value: v,
600                detail: MeasDetail::Window { from, to },
601            })
602        }
603    }
604}
605
606/// `measure_minMaxAvg` for MIN/MAX: the last extremum wins ties
607/// (`<=`/`>=`), as in the C. `(value, scale)`.
608fn extremum(p: &Plot, y: &[f64], w: &Win, max: bool) -> Option<(f64, f64)> {
609    let mut best: Option<(f64, f64)> = None;
610    for (&x, &v) in p.x.iter().zip(y) {
611        match gate(p.kind, w, x) {
612            Gate::Skip => continue,
613            Gate::Stop => break,
614            Gate::Take => {}
615        }
616        best = match best {
617            None => Some((v, x)),
618            Some((m, _)) if (max && v >= m) || (!max && v <= m) => Some((v, x)),
619            keep => keep,
620        };
621    }
622    best
623}
624
625/// `measure_minMaxAvg` for AVG: trapezoids over the window points
626/// (edges not interpolated), divided by their span. Also returns the
627/// last scale value read — what ngspice prints as `to=`.
628fn average(p: &Plot, y: &[f64], w: &Win) -> (Option<f64>, f64) {
629    let mut sum = 0.0;
630    let mut tsum = 0.0;
631    let mut prev: Option<(f64, f64)> = None;
632    let mut last_x = 0.0;
633    for (&x, &v) in p.x.iter().zip(y) {
634        last_x = x;
635        match gate(p.kind, w, x) {
636            Gate::Skip => continue,
637            Gate::Stop => break,
638            Gate::Take => {}
639        }
640        if let Some((px, pv)) = prev {
641            sum += 0.5 * (v + pv) * (x - px);
642            tsum += x - px;
643        }
644        prev = Some((x, v));
645    }
646    match prev {
647        // A single point: 0/0, NaN, "out of interval" in ngspice.
648        Some(_) => {
649            let v = sum / tsum;
650            (Some(v).filter(|v| !v.is_nan()), last_x)
651        }
652        None => (None, last_x),
653    }
654}
655
656/// ngspice's `AlmostEqualUlps` (`maths/misc/equality.c`).
657pub(crate) fn almost_equal_ulps(a: f64, b: f64, max_ulps: i64) -> bool {
658    if a == b {
659        return true;
660    }
661    let ord = |x: f64| {
662        let i = x.to_bits() as i64;
663        if i < 0 { i64::MIN.wrapping_sub(i) } else { i }
664    };
665    ord(a).wrapping_sub(ord(b)).unsigned_abs() <= max_ulps as u64
666}
667
668/// `measure_rms_integral`: the window's samples, edges interpolated
669/// onto `FROM`/`TO`, integrated by Simpson 3/8 where three equal
670/// widths follow, Simpson 1/3 where two do, trapezoids elsewhere.
671/// `(value, from, to)`; `value` NaN when the window is empty.
672fn rms_integral(p: &Plot, y: &[f64], w: &Win, rms: bool) -> (f64, f64, f64) {
673    let n = y.len().min(p.x.len());
674    let sq = |v: f64| if rms { v * v } else { v };
675    let lerp = |i: usize, at: f64| {
676        let slope = (y[i] - y[i - 1]) / (p.x[i] - p.x[i - 1]);
677        let yint = y[i - 1] - slope * p.x[i - 1];
678        slope * at + yint
679    };
680    let mut xs = Vec::new();
681    let mut ys = Vec::new();
682    let mut from_at = f64::NAN;
683    let mut to_val: Option<f64> = None;
684    for (i, (&x0, &v0)) in p.x.iter().zip(y).enumerate() {
685        let (mut x, mut v) = (x0, v0);
686        if x < w.from {
687            continue;
688        }
689        if w.to != 0.0 && x > w.to {
690            if i > 0 && !almost_equal_ulps(x, w.to, 100) {
691                v = lerp(i, w.to);
692                x = w.to;
693            }
694            xs.push(x);
695            ys.push(sq(v));
696            to_val = Some(x);
697            break;
698        }
699        if xs.is_empty() {
700            if w.from != 0.0 && i > 0 && !almost_equal_ulps(x, w.from, 100) {
701                v = lerp(i, w.from);
702                x = w.from;
703            }
704            from_at = x;
705        }
706        xs.push(x);
707        ys.push(sq(v));
708    }
709    let m = xs.len();
710    // Widths, padded with two zeros like the C.
711    let mut width: Vec<f64> = (0..m.saturating_sub(1))
712        .map(|i| xs[i + 1] - xs[i])
713        .collect();
714    width.extend([0.0, 0.0]);
715    let (mut s1, mut s2, mut s3) = (0.0, 0.0, 0.0);
716    let mut i = 0;
717    while i + 1 < m {
718        if i + 3 < m
719            && almost_equal_ulps(width[i], width[i + 1], 100)
720            && almost_equal_ulps(width[i], width[i + 2], 100)
721        {
722            s1 += 3.0 * width[i] * (ys[i] + 3.0 * (ys[i + 1] + ys[i + 2]) + ys[i + 3]) / 8.0;
723            i += 3;
724        } else if i + 2 < m && almost_equal_ulps(width[i], width[i + 1], 100) {
725            s2 += width[i] * (ys[i] + 4.0 * ys[i + 1] + ys[i + 2]) / 3.0;
726            i += 2;
727        } else {
728            s3 += width[i] * (ys[i] + ys[i + 1]) / 2.0;
729            i += 1;
730        }
731    }
732    // ngspice re-reads the last scale value when no `TO` cut the
733    // window (its `toVal < 0` test).
734    let to = match to_val {
735        Some(t) if t >= 0.0 => t,
736        _ => p.x.get(n.wrapping_sub(1)).copied().unwrap_or(f64::NAN),
737    };
738    if m == 0 {
739        return (f64::NAN, from_at, to);
740    }
741    let sum = s1 + s2 + s3;
742    let v = if rms {
743        (sum / (to - from_at)).sqrt()
744    } else {
745        sum
746    };
747    (v, from_at, to)
748}
749
750#[cfg(test)]
751mod tests {
752    use super::*;
753    use dune_netlist::parse_measure_body;
754    use dune_raw::{Data, VarType};
755
756    fn tran(t: Vec<f64>, y: Vec<f64>) -> Solution {
757        Solution {
758            plotname: "Transient Analysis".into(),
759            variables: vec![
760                ("time".into(), VarType::Time),
761                ("v(1)".into(), VarType::Voltage),
762            ],
763            data: Data::Real(vec![t, y]),
764            scale: Some(0),
765        }
766    }
767
768    fn meas(s: &Solution, body: &str) -> Result<Measurement, MeasureError> {
769        let (f, _) = parse_measure_body(body).unwrap();
770        s.measure(&f)
771    }
772
773    fn val(s: &Solution, body: &str) -> f64 {
774        meas(s, body)
775            .unwrap_or_else(|e| panic!("{body}: {e}"))
776            .value
777    }
778
779    /// A triangle 0→1→0→1→0 over t = 0..4.
780    fn tri() -> Solution {
781        let t: Vec<f64> = (0..=40).map(|i| f64::from(i) * 0.1).collect();
782        let y = t
783            .iter()
784            .map(|&t| {
785                let f = t - t.floor();
786                if (t.floor() as i64) % 2 == 0 {
787                    f
788                } else {
789                    1.0 - f
790                }
791            })
792            .collect();
793        tran(t, y)
794    }
795
796    fn close(a: f64, b: f64) {
797        assert!((a - b).abs() <= 1e-12 * b.abs().max(1.0), "{a} vs {b}");
798    }
799
800    #[test]
801    fn crossings_by_edge_and_count() {
802        let s = tri();
803        close(val(&s, "when v(1)=0.25"), 0.25);
804        close(val(&s, "when v(1)=0.25 rise=2"), 2.25);
805        close(val(&s, "when v(1)=0.25 fall=1"), 1.75);
806        close(val(&s, "when v(1)=0.25 cross=3"), 2.25);
807        close(val(&s, "when v(1)=0.25 cross=last"), 3.75);
808        close(val(&s, "when v(1)=0.25 rise=last"), 2.25);
809        close(val(&s, "when v(1)=0.25 last"), 3.75);
810        close(val(&s, "when v(1)=0.25 td=1"), 1.75);
811        close(val(&s, "when v(1)=0.75 from=1 to=1.5"), 1.25);
812        assert!(meas(&s, "when v(1)=0.25 from=1 to=1.5").is_err());
813    }
814
815    #[test]
816    fn out_of_interval_and_unknown_vectors() {
817        let s = tri();
818        assert_eq!(
819            meas(&s, "when v(1)=5"),
820            Err(MeasureError::OutOfInterval {
821                clause: MeasClause::When
822            })
823        );
824        assert!(matches!(
825            meas(&s, "max v(9)"),
826            Err(MeasureError::UnknownVector(_))
827        ));
828        assert_eq!(
829            meas(&s, "trig v(1) val=0.5 rise=1 targ v(1) val=0.5 rise=9"),
830            Err(MeasureError::OutOfInterval {
831                clause: MeasClause::Targ
832            })
833        );
834        let op = Solution {
835            plotname: "Operating Point".into(),
836            variables: vec![("v(1)".into(), VarType::Voltage)],
837            data: Data::Real(vec![vec![1.0]]),
838            scale: None,
839        };
840        assert!(matches!(
841            meas(&op, "max v(1)"),
842            Err(MeasureError::UnsupportedPlot(_))
843        ));
844    }
845
846    #[test]
847    fn first_segment_crossing_counts() {
848        // ngspice misses this one (KNOWN_DIFFS); the crossing is in
849        // [t0, t1].
850        let s = tri();
851        close(val(&s, "when v(1)=0.05"), 0.05);
852        close(val(&s, "when v(1)=0.05 rise=1"), 0.05);
853    }
854
855    #[test]
856    fn delay_find_and_stats() {
857        let s = tri();
858        let m = meas(&s, "trig v(1) val=0.5 rise=1 targ v(1) val=0.5 fall=1").unwrap();
859        close(m.value, 1.0);
860        assert!(matches!(m.detail, MeasDetail::Delay { .. }));
861        close(val(&s, "trig at=0.2 targ v(1) val=0.5 rise=2"), 2.3);
862        close(val(&s, "find v(1) at=1.25"), 0.75);
863        close(val(&s, "find v(1) when v(1)=0.5 cross=2"), 0.5);
864        close(val(&s, "deriv v(1) at=0.55"), 1.0);
865        close(val(&s, "deriv v(1) at=1.55"), -1.0);
866        close(val(&s, "max v(1)"), 1.0);
867        close(val(&s, "min v(1) from=0.5 to=1.5"), 0.5);
868        close(val(&s, "pp v(1)"), 1.0);
869        close(val(&s, "avg v(1)"), 0.5);
870        let m = meas(&s, "max_at v(1) to=2").unwrap();
871        close(m.value, 1.0);
872        assert_eq!(m.detail, MeasDetail::With { with: 1.0 });
873    }
874
875    #[test]
876    fn simpson_is_exact_on_cubics() {
877        // Simpson 3/8 and 1/3 integrate cubics exactly, so on
878        // uniform samples of t³ and t the window integrals are
879        // closed-form.
880        // 12 widths: four Simpson 3/8 panels (a trapezoid would
881        // close an odd remainder, as in the C).
882        let t: Vec<f64> = (0..=12).map(|i| f64::from(i) / 10.0).collect();
883        let s = Solution {
884            plotname: "Transient Analysis".into(),
885            variables: vec![
886                ("time".into(), VarType::Time),
887                ("v(1)".into(), VarType::Voltage),
888                ("v(2)".into(), VarType::Voltage),
889            ],
890            data: Data::Real(vec![
891                t.clone(),
892                t.clone(),
893                t.iter().map(|t| t * t * t).collect(),
894            ]),
895            scale: Some(0),
896        };
897        close(val(&s, "integ v(2)"), 1.2f64.powi(4) / 4.0);
898        close(val(&s, "rms v(1)"), (1.44f64 / 3.0).sqrt());
899        // Interpolated window edges.
900        let m = meas(&s, "integ v(1) from=0.05 to=0.95").unwrap();
901        close(m.value, 0.5 * (0.95 * 0.95 - 0.05 * 0.05));
902        let MeasDetail::Window { from, to } = m.detail else {
903            panic!()
904        };
905        close(from, 0.05);
906        close(to, 0.95);
907    }
908
909    #[test]
910    fn ulps_like_ngspice() {
911        assert!(almost_equal_ulps(1.0, 1.0 + 50.0 * f64::EPSILON, 100));
912        assert!(!almost_equal_ulps(1.0, 1.0 + 1e-10, 100));
913        assert!(almost_equal_ulps(0.0, -0.0, 100));
914        assert!(!almost_equal_ulps(1.0, -1.0, 100));
915    }
916
917    #[test]
918    fn derivative_is_exact_on_parabolas() {
919        let x = [0.0, 0.1, 0.3, 0.35, 0.8];
920        let y: Vec<f64> = x.iter().map(|x| 3.0 * x * x - x).collect();
921        let d = derivative(&x, &y);
922        for i in 1..4 {
923            close(d[i], 6.0 * x[i] - 1.0);
924        }
925    }
926}