Skip to main content

pedalkernel_validate/
spice.rs

1//! ngspice integration for golden reference generation.
2//!
3//! This module provides functions to run ngspice simulations and extract
4//! output for comparison with WDF results.
5//!
6//! # Requirements
7//!
8//! ngspice must be installed and available in PATH:
9//! - macOS: `brew install ngspice`
10//! - Ubuntu/Debian: `apt install ngspice`
11//! - Windows: Download from ngspice.sourceforge.io
12//!
13//! # Example
14//!
15//! ```rust,ignore
16//! use pedalkernel_validate::spice::{SpiceRunner, SpiceConfig};
17//! use pedalkernel_validate::signals;
18//!
19//! let config = SpiceConfig {
20//!     sample_rate: 96000,
21//!     oversample: 4,
22//! };
23//!
24//! let runner = SpiceRunner::new(config);
25//!
26//! // Generate input signal
27//! let input = signals::sine(config.internal_rate(), 1000.0, 0.1, 1.0);
28//!
29//! // Run SPICE simulation
30//! let output = runner.simulate(
31//!     "circuits/nonlinear/diode_clipper.spice",
32//!     &input,
33//!     "v_out"
34//! ).unwrap();
35//! ```
36
37use std::path::Path;
38use std::process::Command;
39use tempfile::TempDir;
40use thiserror::Error;
41
42#[derive(Error, Debug)]
43pub enum SpiceError {
44    #[error("ngspice not found in PATH. Install with: brew install ngspice (macOS) or apt install ngspice (Linux)")]
45    NgspiceNotFound,
46    #[error("ngspice execution failed: {0}")]
47    ExecutionFailed(String),
48    #[error("Failed to parse ngspice output: {0}")]
49    ParseError(String),
50    #[error("Circuit file not found: {0}")]
51    CircuitNotFound(String),
52    #[error("IO error: {0}")]
53    IoError(#[from] std::io::Error),
54    #[error("Simulation did not converge")]
55    ConvergenceFailed,
56}
57
58/// Configuration for SPICE simulations.
59#[derive(Debug, Clone)]
60pub struct SpiceConfig {
61    /// Output sample rate in Hz.
62    pub sample_rate: u32,
63    /// Oversampling factor for internal simulation.
64    pub oversample: u32,
65}
66
67impl Default for SpiceConfig {
68    fn default() -> Self {
69        Self {
70            sample_rate: 96000,
71            oversample: 4,
72        }
73    }
74}
75
76impl SpiceConfig {
77    /// Internal simulation rate (sample_rate × oversample).
78    pub fn internal_rate(&self) -> f64 {
79        (self.sample_rate * self.oversample) as f64
80    }
81
82    /// Time step for simulation.
83    pub fn timestep(&self) -> f64 {
84        1.0 / self.internal_rate()
85    }
86}
87
88/// Runner for ngspice simulations.
89pub struct SpiceRunner {
90    config: SpiceConfig,
91}
92
93impl SpiceRunner {
94    /// Create a new SPICE runner with the given configuration.
95    pub fn new(config: SpiceConfig) -> Self {
96        Self { config }
97    }
98
99    /// Check if ngspice is available.
100    pub fn check_ngspice() -> Result<String, SpiceError> {
101        let output = Command::new("ngspice")
102            .arg("--version")
103            .output()
104            .map_err(|_| SpiceError::NgspiceNotFound)?;
105
106        if output.status.success() {
107            let version = String::from_utf8_lossy(&output.stdout);
108            // Extract version number from output
109            let version_line = version.lines().next().unwrap_or("unknown");
110            Ok(version_line.to_string())
111        } else {
112            Err(SpiceError::NgspiceNotFound)
113        }
114    }
115
116    /// Run a SPICE simulation with the given input signal.
117    ///
118    /// # Arguments
119    /// * `circuit_path` - Path to the .spice circuit file
120    /// * `input` - Input signal samples at internal_rate
121    /// * `output_node` - Node name to measure (e.g., "v_out")
122    ///
123    /// # Returns
124    /// Output signal resampled to `sample_rate`
125    pub fn simulate(
126        &self,
127        circuit_path: impl AsRef<Path>,
128        input: &[f64],
129        output_node: &str,
130    ) -> Result<Vec<f64>, SpiceError> {
131        let circuit_path = circuit_path.as_ref();
132        if !circuit_path.exists() {
133            return Err(SpiceError::CircuitNotFound(
134                circuit_path.display().to_string(),
135            ));
136        }
137
138        // Create temp directory for simulation files
139        let tmpdir = TempDir::new()?;
140        let netlist_path = tmpdir.path().join("circuit.spice");
141        let output_path = tmpdir.path().join("output.txt");
142
143        // Generate complete netlist with inline PWL
144        let duration = input.len() as f64 / self.config.internal_rate();
145        let netlist =
146            self.generate_netlist(circuit_path, input, duration, output_node, &output_path)?;
147        let sample_time_offset = if single_sample_impulse(input).is_some() {
148            0.5 * self.config.timestep()
149        } else {
150            0.0
151        };
152
153        std::fs::write(&netlist_path, &netlist)?;
154
155        // Run ngspice
156        let result = Command::new("ngspice")
157            .args(["-b", netlist_path.to_str().unwrap()])
158            .output()?;
159
160        if !result.status.success() {
161            let stderr = String::from_utf8_lossy(&result.stderr);
162            // Look for convergence errors
163            if stderr.contains("no convergence") || stderr.contains("timestep too small") {
164                return Err(SpiceError::ConvergenceFailed);
165            }
166            return Err(SpiceError::ExecutionFailed(stderr.to_string()));
167        }
168
169        // Check if output file was created
170        if !output_path.exists() {
171            return Err(SpiceError::ExecutionFailed(
172                "ngspice did not produce output file".to_string(),
173            ));
174        }
175
176        // Parse output and resample
177        let raw_output = self.parse_wrdata_output(&output_path)?;
178        let resampled = self.resample_and_decimate(&raw_output, duration, sample_time_offset);
179
180        Ok(resampled)
181    }
182
183    /// Strip standalone-deck elements that conflict with harness injection.
184    ///
185    /// New SPICE decks are authored as self-contained files (they can be run
186    /// with `ngspice deck.spice` for standalone verification) but the harness
187    /// composes them into a fresh netlist with its own VIN source, .TRAN, and
188    /// .CONTROL block. Strip the standalone-only lines so the composition
189    /// doesn't produce duplicate element definitions.
190    ///
191    /// Stripped:
192    /// - Lines beginning with `VIN ` or `VIN\t` (the standalone AC source)
193    /// - `.TRAN` lines
194    /// - `.OP` lines
195    /// - `.MEAS` lines
196    /// - `.FOUR` lines
197    /// - `.CONTROL` … `.ENDC` blocks (including contents)
198    /// - Bare `.END` lines (not inside a subcircuit `.ENDS`)
199    fn strip_standalone_elements(body: &str) -> String {
200        let mut out = Vec::with_capacity(body.len());
201        let mut in_control = false;
202        for line in body.lines() {
203            let upper = line.trim().to_uppercase();
204            if upper == ".CONTROL" {
205                in_control = true;
206                continue;
207            }
208            if in_control {
209                if upper == ".ENDC" {
210                    in_control = false;
211                }
212                continue;
213            }
214            // Skip standalone-only directives. .OPTIONS is stripped so the
215            // harness can set its own consistent convergence knobs; per-deck
216            // convergence tweaks belong as .IC initial-condition statements
217            // instead (those survive and help UIC startup).
218            if upper.starts_with("VIN ")
219                || upper.starts_with("VIN\t")
220                || upper.starts_with(".TRAN")
221                || upper.starts_with(".OP")
222                || upper.starts_with(".MEAS")
223                || upper.starts_with(".FOUR")
224                || upper.starts_with(".OPTIONS")
225                || upper == ".END"
226            {
227                continue;
228            }
229            out.push(line);
230        }
231        out.join("\n")
232    }
233
234    /// Generate complete ngspice netlist from circuit template.
235    fn generate_netlist(
236        &self,
237        circuit_path: &Path,
238        input_signal: &[f64],
239        duration: f64,
240        output_node: &str,
241        output_file: &Path,
242    ) -> Result<String, SpiceError> {
243        let raw_body = std::fs::read_to_string(circuit_path)?;
244        // Strip standalone-deck elements before embedding in the harness netlist
245        let circuit_body = Self::strip_standalone_elements(&raw_body);
246        let timestep = self.config.timestep();
247
248        // Generate inline PWL data (more portable than file= syntax)
249        let pwl_data = self.generate_pwl_inline(input_signal);
250
251        let netlist = format!(
252            r#"* PedalKernel Golden Reference Generation
253* Circuit: {circuit_name}
254* Internal rate: {rate} Hz (timestep: {timestep:.12e} s)
255* Duration: {duration} s
256
257.TITLE Golden Reference — {circuit_stem}
258
259{circuit_body}
260
261* Input source — inline PWL data
262VIN v_in 0 PWL({pwl_data})
263
264* Simulation control
265* RELTOL=5e-3 / ABSTOL=1e-9: balances accuracy vs convergence for stiff
266* nonlinear circuits (diodes, BJTs, triodes, pentodes). Tighter tolerances
267* can cause non-convergence in high-current tube stages.
268.OPTIONS RELTOL=5e-3 ABSTOL=1e-9 VNTOL=1e-6
269.OPTIONS METHOD=GEAR MAXORD=2
270.OPTIONS ITL1=500 ITL2=200 ITL4=200 DELMAX=50u
271
272.TRAN {timestep:.12e} {duration:.12e} 0 {timestep:.12e} UIC
273
274.CONTROL
275  set filetype=ascii
276  run
277  wrdata {output_file} {output_node}
278  quit
279.ENDC
280
281.END
282"#,
283            circuit_name = circuit_path.file_name().unwrap().to_string_lossy(),
284            circuit_stem = circuit_path.file_stem().unwrap().to_string_lossy(),
285            rate = self.config.internal_rate(),
286            timestep = timestep,
287            duration = duration,
288            circuit_body = circuit_body,
289            pwl_data = pwl_data,
290            output_file = output_file.display(),
291            output_node = output_node,
292        );
293
294        Ok(netlist)
295    }
296
297    /// Generate inline PWL string (decimated to reduce netlist size).
298    fn generate_pwl_inline(&self, signal: &[f64]) -> String {
299        let dt = self.config.timestep();
300
301        if let Some(amplitude) = single_sample_impulse(signal) {
302            return generate_sample_hold_impulse_pwl(amplitude, signal.len(), dt);
303        }
304
305        // Keep shorter validation signals sample-exact. Very long signals are
306        // decimated to keep generated netlists practical, but their tail stays
307        // exact so high-frequency sweep endings do not pick up PWL interpolation
308        // residuals.
309        let max_pwl_points = 200_000;
310        let exact_tail_samples = 65_536;
311        let decimate_factor = if signal.len() <= max_pwl_points {
312            1
313        } else {
314            signal.len().div_ceil(max_pwl_points)
315        };
316
317        let mut pwl_points = Vec::new();
318        let exact_tail_start = if decimate_factor > 1 {
319            signal.len().saturating_sub(exact_tail_samples)
320        } else {
321            signal.len()
322        };
323
324        for i in (0..exact_tail_start).step_by(decimate_factor) {
325            let t = i as f64 * dt;
326            pwl_points.push(format!("{:.9e} {:.9e}", t, signal[i]));
327        }
328
329        if decimate_factor > 1 {
330            for (i, &sample) in signal.iter().enumerate().skip(exact_tail_start) {
331                let t = i as f64 * dt;
332                pwl_points.push(format!("{:.9e} {:.9e}", t, sample));
333            }
334        }
335
336        pwl_points.join(" ")
337    }
338
339    /// Parse wrdata output file (time, value columns).
340    fn parse_wrdata_output(&self, path: &Path) -> Result<Vec<(f64, f64)>, SpiceError> {
341        let contents = std::fs::read_to_string(path)?;
342        let mut data = Vec::new();
343
344        for line in contents.lines() {
345            let line = line.trim();
346            if line.is_empty() || line.starts_with('*') || line.starts_with('#') {
347                continue;
348            }
349
350            let parts: Vec<&str> = line.split_whitespace().collect();
351            if parts.len() >= 2 {
352                let t: f64 = parts[0]
353                    .parse()
354                    .map_err(|e| SpiceError::ParseError(format!("Invalid time value: {}", e)))?;
355                let v: f64 = parts[1]
356                    .parse()
357                    .map_err(|e| SpiceError::ParseError(format!("Invalid voltage value: {}", e)))?;
358                data.push((t, v));
359            }
360        }
361
362        if data.is_empty() {
363            return Err(SpiceError::ParseError("No data points found".to_string()));
364        }
365
366        Ok(data)
367    }
368
369    /// Resample SPICE output to uniform grid at internal rate.
370    ///
371    /// Returns samples at the full internal rate (sample_rate × oversample)
372    /// to match the WDF runner's output rate for sample-by-sample comparison.
373    fn resample_and_decimate(
374        &self,
375        raw_data: &[(f64, f64)],
376        duration: f64,
377        time_offset: f64,
378    ) -> Vec<f64> {
379        let internal_rate = self.config.internal_rate();
380        let n_internal = (duration * internal_rate) as usize;
381
382        // Create uniform time grid at internal rate
383        let mut uniform_output = Vec::with_capacity(n_internal);
384
385        for i in 0..n_internal {
386            let t = i as f64 / internal_rate + time_offset;
387            let v = self.interpolate(raw_data, t);
388            uniform_output.push(v);
389        }
390
391        uniform_output
392    }
393
394    /// Linear interpolation at time t.
395    fn interpolate(&self, data: &[(f64, f64)], t: f64) -> f64 {
396        if data.is_empty() {
397            return 0.0;
398        }
399        if t <= data[0].0 {
400            return data[0].1;
401        }
402        if t >= data.last().unwrap().0 {
403            return data.last().unwrap().1;
404        }
405
406        // Binary search for bracket
407        let mut lo = 0;
408        let mut hi = data.len() - 1;
409        while hi - lo > 1 {
410            let mid = (lo + hi) / 2;
411            if data[mid].0 <= t {
412                lo = mid;
413            } else {
414                hi = mid;
415            }
416        }
417
418        // Linear interpolation
419        let (t0, v0) = data[lo];
420        let (t1, v1) = data[hi];
421        let alpha = (t - t0) / (t1 - t0);
422        v0 + alpha * (v1 - v0)
423    }
424
425    /// Get the configuration.
426    pub fn config(&self) -> &SpiceConfig {
427        &self.config
428    }
429
430    /// Decimate an internal-rate signal (`sample_rate × oversample`) down to the
431    /// base `sample_rate` by taking every `oversample`-th sample.
432    ///
433    /// [`simulate`] returns at the INTERNAL rate (to match the oversampled main
434    /// runner).  The standalone `*_spice.rs` tests instead compare against a WDF
435    /// processor compiled at the BASE `sample_rate`, so their golden must be at
436    /// the base rate too — otherwise a 1 kHz golden tone carries `oversample×`
437    /// the samples-per-period of the WDF tone and the 1:1 comparison lines up two
438    /// signals at different rates (the bug this fixes).
439    pub fn decimate_to_base(&self, internal: &[f64]) -> Vec<f64> {
440        let k = self.config.oversample.max(1) as usize;
441        internal.iter().step_by(k).copied().collect()
442    }
443
444    /// Run a SETTLED steady-state simulation and return the MEASUREMENT window at
445    /// the base `sample_rate`.
446    ///
447    /// `input_internal` is the full stimulus at the internal rate covering
448    /// `settle_s + measure_s` seconds.  The circuit is simulated over that whole
449    /// span so coupling caps / bias networks reach steady state; the first
450    /// `settle_s` is then discarded and the remaining tail is decimated to the
451    /// base `sample_rate`.  The settle boundary is floored to a multiple of
452    /// `oversample` so the decimated grid stays phase-aligned with a WDF
453    /// processor warmed up by the same `settle_s`.
454    ///
455    /// The returned golden is at the base `sample_rate`: `len ≈ measure_s ×
456    /// sample_rate`, and a tone of frequency `f` has `sample_rate / f`
457    /// samples per period.
458    pub fn simulate_settled_window(
459        &self,
460        circuit_path: impl AsRef<Path>,
461        input_internal: &[f64],
462        output_node: &str,
463        settle_s: f64,
464    ) -> Result<Vec<f64>, SpiceError> {
465        let internal = self.simulate(circuit_path, input_internal, output_node)?;
466        let k = self.config.oversample.max(1) as usize;
467        // Floor the settle boundary to a multiple of the oversample factor so the
468        // decimation phase is identical to a base-rate WDF warmup.
469        let mut settle_internal = (settle_s * self.config.internal_rate()).round() as usize;
470        settle_internal -= settle_internal % k;
471        let tail: &[f64] = if settle_internal < internal.len() {
472            &internal[settle_internal..]
473        } else {
474            &internal[..]
475        };
476        Ok(self.decimate_to_base(tail))
477    }
478}
479
480fn single_sample_impulse(signal: &[f64]) -> Option<f64> {
481    let (&first, rest) = signal.split_first()?;
482    if first == 0.0 {
483        return None;
484    }
485
486    if rest.iter().all(|&sample| sample == 0.0) {
487        Some(first)
488    } else {
489        None
490    }
491}
492
493fn generate_sample_hold_impulse_pwl(amplitude: f64, len: usize, dt: f64) -> String {
494    let end_time = len.saturating_sub(1) as f64 * dt;
495    let drop_time = dt;
496
497    let mut pwl_points = vec![
498        format!("{:.9e} {:.9e}", 0.0, amplitude),
499        format!("{:.9e} {:.9e}", drop_time, amplitude),
500        format!("{:.9e} {:.9e}", drop_time, 0.0),
501    ];
502
503    if end_time > drop_time {
504        pwl_points.push(format!("{:.9e} {:.9e}", end_time, 0.0));
505    }
506
507    pwl_points.join(" ")
508}
509
510// ============================================================================
511// DC operating-point (.op) extraction — the bias-accuracy golden source
512// ============================================================================
513
514/// One device's DC operating point as reported by ngspice's `.op` / `show`.
515///
516/// `vce`/`ic` are derived for BJTs from the `show` table:
517/// `vce = vbe − vbc`, `ic` is the collector current row. All voltages in volts,
518/// currents in amps. For PNP devices ngspice reports the junction-voltage
519/// magnitudes (positive `vbe`) with a negative `ic`; the values are stored
520/// verbatim so the caller controls sign convention.
521#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
522pub struct SpiceDeviceOp {
523    /// Device reference, uppercased (e.g. `Q1`, `Q3`).
524    pub device: String,
525    /// Model name (e.g. `QBC184C`, `QAC128`).
526    pub model: String,
527    /// Base–emitter voltage `vbe`, volts.
528    pub vbe: f64,
529    /// Collector–emitter voltage `vce = vbe − vbc`, volts.
530    pub vce: f64,
531    /// Collector current `ic`, amps.
532    pub ic: f64,
533    /// Base current `ib`, amps.
534    pub ib: f64,
535    /// Transconductance `gm`, siemens.
536    pub gm: f64,
537}
538
539/// A full ngspice `.op` snapshot: per-device operating points plus node voltages.
540///
541/// This is the **ngspice-derived golden** for the bias-accuracy dimension. It is
542/// serialized to `golden/op/<circuit>.json` and is never WDF-bootstrapped.
543#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
544pub struct SpiceOpSnapshot {
545    /// Source deck name (file stem).
546    pub circuit: String,
547    /// Per-device operating points (BJTs), keyed by uppercased device ref.
548    pub devices: Vec<SpiceDeviceOp>,
549    /// DC node voltages (node name → volts).
550    pub nodes: std::collections::BTreeMap<String, f64>,
551}
552
553impl SpiceRunner {
554    /// Run ngspice `.op` on a standalone deck and capture the DC operating point.
555    ///
556    /// Composes the deck the same way [`Self::simulate`] does (stripping
557    /// standalone control/analysis directives), injects a quiescent `VIN v_in 0
558    /// DC 0` source, and runs an `.op` followed by `show` (per-device params) and
559    /// `print all` (node voltages). The `show` table is parsed into
560    /// [`SpiceDeviceOp`] records (`vce = vbe − vbc`, `ic` from the row).
561    ///
562    /// This is read-only and ngspice-derived — the resulting snapshot is the
563    /// golden the WDF DC bias is graded against.
564    pub fn operating_point(
565        &self,
566        circuit_path: impl AsRef<Path>,
567    ) -> Result<SpiceOpSnapshot, SpiceError> {
568        let circuit_path = circuit_path.as_ref();
569        if !circuit_path.exists() {
570            return Err(SpiceError::CircuitNotFound(
571                circuit_path.display().to_string(),
572            ));
573        }
574        let raw_body = std::fs::read_to_string(circuit_path)?;
575        let circuit_body = Self::strip_standalone_elements(&raw_body);
576
577        let netlist = format!(
578            r#"* PedalKernel DC operating-point extraction
579.TITLE OP — {stem}
580
581{circuit_body}
582
583* Quiescent input (DC bias point, no signal)
584VIN v_in 0 DC 0
585
586.OPTIONS GMIN=1e-12 RELTOL=1e-4 ABSTOL=1e-12 VNTOL=1e-9
587
588.CONTROL
589  op
590  show
591  print all
592  quit
593.ENDC
594
595.END
596"#,
597            stem = circuit_path.file_stem().unwrap().to_string_lossy(),
598            circuit_body = circuit_body,
599        );
600
601        let tmpdir = TempDir::new()?;
602        let netlist_path = tmpdir.path().join("op.spice");
603        std::fs::write(&netlist_path, &netlist)?;
604
605        let result = Command::new("ngspice")
606            .args(["-b", netlist_path.to_str().unwrap()])
607            .output()?;
608        let stdout = String::from_utf8_lossy(&result.stdout);
609        let stderr = String::from_utf8_lossy(&result.stderr);
610        if stderr.contains("no convergence") || stdout.contains("no convergence") {
611            return Err(SpiceError::ConvergenceFailed);
612        }
613        if !result.status.success() && stdout.trim().is_empty() {
614            return Err(SpiceError::ExecutionFailed(stderr.to_string()));
615        }
616
617        let stem = circuit_path.file_stem().unwrap().to_string_lossy().to_string();
618        // Map each BJT to its (collector, base, emitter) nodes from the netlist so
619        // the parser can report TERMINAL (node-difference) Vbe/Vce instead of the
620        // `show` table's INTRINSIC junction voltages. The two differ by the device
621        // parasitic IR drops (Ib·RB + Ie·RE); the WDF port sees the TERMINAL value,
622        // so that is what the bias-accuracy golden must store.
623        let bjt_nodes = parse_bjt_terminal_nodes(&raw_body);
624        parse_op_output(&stdout, stem, &bjt_nodes)
625    }
626}
627
628/// Parse `Qxxx nc nb ne model …` device lines from a SPICE deck, returning a map
629/// from the **uppercased** device ref to its `(collector, base, emitter)` node
630/// names (lowercased, matching ngspice's `print all` node keys).
631///
632/// Skips comment (`*`) and continuation (`+`) lines. Only the first three node
633/// tokens after the device name are read (BJT topology: C B E [substrate] model).
634fn parse_bjt_terminal_nodes(
635    deck: &str,
636) -> std::collections::HashMap<String, (String, String, String)> {
637    let mut map = std::collections::HashMap::new();
638    for line in deck.lines() {
639        let t = line.trim();
640        if t.is_empty() || t.starts_with('*') || t.starts_with('+') {
641            continue;
642        }
643        let parts: Vec<&str> = t.split_whitespace().collect();
644        // `Qname C B E model` → at least name + 3 nodes + model.
645        if parts.len() < 5 {
646            continue;
647        }
648        let name = parts[0];
649        if !name.starts_with('Q') && !name.starts_with('q') {
650            continue;
651        }
652        map.insert(
653            name.to_uppercase(),
654            (
655                parts[1].to_lowercase(),
656                parts[2].to_lowercase(),
657                parts[3].to_lowercase(),
658            ),
659        );
660    }
661    map
662}
663
664/// Parse the combined `show` + `print all` ngspice `.op` output.
665///
666/// `bjt_nodes` maps each BJT (uppercased ref) to its `(collector, base, emitter)`
667/// node names; when present, the device's reported `vbe`/`vce` are the TERMINAL
668/// node differences `v(base)−v(emitter)` / `v(collector)−v(emitter)` rather than
669/// the `show` table's intrinsic junction voltages.
670fn parse_op_output(
671    out: &str,
672    circuit: String,
673    bjt_nodes: &std::collections::HashMap<String, (String, String, String)>,
674) -> Result<SpiceOpSnapshot, SpiceError> {
675    // ── `show` device table ─────────────────────────────────────────────────
676    // ngspice prints one block per device type. A `device <name> <name>...`
677    // header names the columns; following `param val val ...` rows carry values.
678    // We accumulate per-column maps and keep only BJT-like columns (those that
679    // carry both `vbe` and `vbc`).
680    let mut cur_names: Vec<String> = Vec::new();
681    let mut cur_models: Vec<String> = Vec::new();
682    let mut cols: std::collections::HashMap<String, f64> = std::collections::HashMap::new();
683    // device -> (param -> value)
684    let mut dev_params: Vec<(String, std::collections::HashMap<String, f64>)> = Vec::new();
685
686    let flush = |names: &mut Vec<String>,
687                 models: &mut Vec<String>,
688                 dev_params: &mut Vec<(String, std::collections::HashMap<String, f64>)>,
689                 collected: &mut std::collections::HashMap<String, f64>| {
690        // collected keys are "<col_idx>::<param>"
691        for (i, name) in names.iter().enumerate() {
692            let mut p = std::collections::HashMap::new();
693            for (k, v) in collected.iter() {
694                if let Some(rest) = k.strip_prefix(&format!("{i}::")) {
695                    p.insert(rest.to_string(), *v);
696                }
697            }
698            if let Some(m) = models.get(i) {
699                p.insert("__model_idx".to_string(), i as f64);
700            }
701            dev_params.push((name.clone(), p));
702        }
703        names.clear();
704        models.clear();
705        collected.clear();
706    };
707
708    let mut models_by_dev: std::collections::HashMap<String, String> = std::collections::HashMap::new();
709    let mut nodes: std::collections::BTreeMap<String, f64> = std::collections::BTreeMap::new();
710
711    for line in out.lines() {
712        let t = line.trim();
713        if t.is_empty() {
714            continue;
715        }
716        let parts: Vec<&str> = t.split_whitespace().collect();
717
718        // Node-voltage lines from `print all`: `name = 1.234e+00`
719        if parts.len() == 3 && parts[1] == "=" {
720            if let Ok(v) = parts[2].parse::<f64>() {
721                // skip #branch currents — keep node voltages only
722                if !parts[0].contains("#branch") {
723                    nodes.insert(parts[0].to_string(), v);
724                }
725                continue;
726            }
727        }
728
729        // `device q2 q1 q3` header begins a new device block.
730        if parts[0] == "device" {
731            // flush previous block
732            if !cur_names.is_empty() {
733                flush(&mut cur_names, &mut cur_models, &mut dev_params, &mut cols);
734            }
735            cur_names = parts[1..].iter().map(|s| s.to_uppercase()).collect();
736            continue;
737        }
738        if parts[0] == "model" && !cur_names.is_empty() {
739            cur_models = parts[1..].iter().map(|s| s.to_string()).collect();
740            for (i, n) in cur_names.iter().enumerate() {
741                if let Some(m) = cur_models.get(i) {
742                    models_by_dev.insert(n.clone(), m.clone());
743                }
744            }
745            continue;
746        }
747        // value row: `vbe 0.40 0.53 0.60`
748        if !cur_names.is_empty() && parts.len() == cur_names.len() + 1 {
749            let param = parts[0].to_string();
750            let mut ok = true;
751            let mut vals = Vec::with_capacity(cur_names.len());
752            for raw in &parts[1..] {
753                match raw.parse::<f64>() {
754                    Ok(v) => vals.push(v),
755                    Err(_) => {
756                        ok = false;
757                        break;
758                    }
759                }
760            }
761            if ok {
762                for (i, v) in vals.into_iter().enumerate() {
763                    cols.insert(format!("{i}::{param}"), v);
764                }
765            }
766        }
767    }
768    if !cur_names.is_empty() {
769        flush(&mut cur_names, &mut cur_models, &mut dev_params, &mut cols);
770    }
771
772    let mut devices = Vec::new();
773    for (name, p) in dev_params {
774        // Keep only transistor columns (carry both vbe and vbc).
775        let (vbe_intrinsic, vbc_intrinsic) = match (p.get("vbe"), p.get("vbc")) {
776            (Some(a), Some(b)) => (*a, *b),
777            _ => continue,
778        };
779        let ic = p.get("ic").copied().unwrap_or(0.0);
780        let ib = p.get("ib").copied().unwrap_or(0.0);
781        let gm = p.get("gm").copied().unwrap_or(0.0);
782
783        // Prefer TERMINAL node-difference voltages (what the WDF port sees). The
784        // `show` table's `vbe`/`vbc` are INTRINSIC junction voltages; they differ
785        // from the terminal values by the parasitic drops (Ib·RB + Ie·RE). Ground
786        // (node `0`, absent from `print all`) defaults to 0 V. Fall back to the
787        // intrinsic values when the device's nodes are unknown.
788        let (vbe, vce) = match bjt_nodes.get(&name) {
789            Some((c, b, e)) => {
790                let vc = nodes.get(c).copied().unwrap_or(0.0);
791                let vb = nodes.get(b).copied().unwrap_or(0.0);
792                let ve = nodes.get(e).copied().unwrap_or(0.0);
793                (vb - ve, vc - ve)
794            }
795            None => (vbe_intrinsic, vbe_intrinsic - vbc_intrinsic),
796        };
797        devices.push(SpiceDeviceOp {
798            model: models_by_dev.get(&name).cloned().unwrap_or_default(),
799            device: name,
800            vbe,
801            vce,
802            ic,
803            ib,
804            gm,
805        });
806    }
807    devices.sort_by(|a, b| a.device.cmp(&b.device));
808
809    if devices.is_empty() && nodes.is_empty() {
810        return Err(SpiceError::ParseError(
811            "no devices or nodes parsed from .op output".to_string(),
812        ));
813    }
814
815    Ok(SpiceOpSnapshot {
816        circuit,
817        devices,
818        nodes,
819    })
820}
821
822/// Generate golden references for a test circuit.
823///
824/// This is a convenience function that runs SPICE for multiple signals
825/// and saves the outputs as .npy files.
826pub fn generate_golden(
827    circuit_path: impl AsRef<Path>,
828    signals: &[(&str, Vec<f64>)],
829    output_dir: impl AsRef<Path>,
830    config: SpiceConfig,
831) -> Result<(), SpiceError> {
832    let runner = SpiceRunner::new(config);
833    let output_dir = output_dir.as_ref();
834
835    std::fs::create_dir_all(output_dir)?;
836
837    for (label, input) in signals {
838        let output = runner.simulate(&circuit_path, input, "v_out")?;
839
840        let output_path = output_dir.join(format!("{}.npy", label));
841        crate::npy::write_f64(&output_path, &output).map_err(|e| {
842            SpiceError::IoError(std::io::Error::new(
843                std::io::ErrorKind::Other,
844                e.to_string(),
845            ))
846        })?;
847    }
848
849    Ok(())
850}
851
852#[cfg(test)]
853mod tests {
854    use super::*;
855
856    #[test]
857    fn config_defaults_are_sensible() {
858        let config = SpiceConfig::default();
859        assert_eq!(config.sample_rate, 96000);
860        assert_eq!(config.oversample, 4);
861        assert_eq!(config.internal_rate(), 384000.0);
862    }
863
864    #[test]
865    fn interpolation_works() {
866        let runner = SpiceRunner::new(SpiceConfig::default());
867        let data = vec![(0.0, 0.0), (1.0, 10.0), (2.0, 20.0)];
868
869        assert!((runner.interpolate(&data, 0.5) - 5.0).abs() < 1e-10);
870        assert!((runner.interpolate(&data, 1.5) - 15.0).abs() < 1e-10);
871    }
872
873    #[test]
874    fn pwl_generation_keeps_short_signals_sample_exact() {
875        let runner = SpiceRunner::new(SpiceConfig::default());
876        let signal: Vec<f64> = (0..12).map(|i| i as f64).collect();
877
878        let pwl = runner.generate_pwl_inline(&signal);
879        let pairs = pwl.split_whitespace().collect::<Vec<_>>();
880
881        assert_eq!(pairs.len(), signal.len() * 2);
882        assert_eq!(pairs[1], "0.000000000e0");
883        assert_eq!(pairs[3], "1.000000000e0");
884        assert_eq!(pairs[23], "1.100000000e1");
885    }
886
887    #[test]
888    fn pwl_generation_keeps_long_signal_tail_exact() {
889        let runner = SpiceRunner::new(SpiceConfig::default());
890        let signal: Vec<f64> = (0..800_000).map(|i| i as f64).collect();
891
892        let pwl = runner.generate_pwl_inline(&signal);
893        let pairs = pwl.split_whitespace().collect::<Vec<_>>();
894        let values: Vec<f64> = pairs
895            .chunks_exact(2)
896            .map(|pair| pair[1].parse::<f64>().unwrap())
897            .collect();
898
899        assert!(
900            values.len() < signal.len(),
901            "long signal should still be decimated"
902        );
903        assert!(values.windows(2).any(|w| (w[1] - w[0]).abs() > 1.0));
904        assert_eq!(values[values.len() - 3], 799_997.0);
905        assert_eq!(values[values.len() - 2], 799_998.0);
906        assert_eq!(values[values.len() - 1], 799_999.0);
907    }
908
909    #[test]
910    fn pwl_generation_uses_sample_hold_for_impulses() {
911        let runner = SpiceRunner::new(SpiceConfig::default());
912        let mut signal = vec![0.0; 8];
913        signal[0] = 2.0;
914
915        let pwl = runner.generate_pwl_inline(&signal);
916        let pairs = pwl.split_whitespace().collect::<Vec<_>>();
917
918        assert_eq!(pairs.len(), 8);
919        assert_eq!(pairs[1], "2.000000000e0");
920        assert_eq!(pairs[3], "2.000000000e0");
921        assert_eq!(pairs[5], "0.000000000e0");
922        assert_eq!(pairs[6], "1.822916667e-5");
923        assert_eq!(pairs[7], "0.000000000e0");
924    }
925}