1use 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#[derive(Debug, Clone)]
60pub struct SpiceConfig {
61 pub sample_rate: u32,
63 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 pub fn internal_rate(&self) -> f64 {
79 (self.sample_rate * self.oversample) as f64
80 }
81
82 pub fn timestep(&self) -> f64 {
84 1.0 / self.internal_rate()
85 }
86}
87
88pub struct SpiceRunner {
90 config: SpiceConfig,
91}
92
93impl SpiceRunner {
94 pub fn new(config: SpiceConfig) -> Self {
96 Self { config }
97 }
98
99 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 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 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 let tmpdir = TempDir::new()?;
140 let netlist_path = tmpdir.path().join("circuit.spice");
141 let output_path = tmpdir.path().join("output.txt");
142
143 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 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 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 if !output_path.exists() {
171 return Err(SpiceError::ExecutionFailed(
172 "ngspice did not produce output file".to_string(),
173 ));
174 }
175
176 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 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 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 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 let circuit_body = Self::strip_standalone_elements(&raw_body);
246 let timestep = self.config.timestep();
247
248 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 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 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 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 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 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 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 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 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 pub fn config(&self) -> &SpiceConfig {
427 &self.config
428 }
429
430 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 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 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
522pub struct SpiceDeviceOp {
523 pub device: String,
525 pub model: String,
527 pub vbe: f64,
529 pub vce: f64,
531 pub ic: f64,
533 pub ib: f64,
535 pub gm: f64,
537}
538
539#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
544pub struct SpiceOpSnapshot {
545 pub circuit: String,
547 pub devices: Vec<SpiceDeviceOp>,
549 pub nodes: std::collections::BTreeMap<String, f64>,
551}
552
553impl SpiceRunner {
554 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 let bjt_nodes = parse_bjt_terminal_nodes(&raw_body);
624 parse_op_output(&stdout, stem, &bjt_nodes)
625 }
626}
627
628fn 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 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
664fn parse_op_output(
671 out: &str,
672 circuit: String,
673 bjt_nodes: &std::collections::HashMap<String, (String, String, String)>,
674) -> Result<SpiceOpSnapshot, SpiceError> {
675 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 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 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 if parts.len() == 3 && parts[1] == "=" {
720 if let Ok(v) = parts[2].parse::<f64>() {
721 if !parts[0].contains("#branch") {
723 nodes.insert(parts[0].to_string(), v);
724 }
725 continue;
726 }
727 }
728
729 if parts[0] == "device" {
731 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 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 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 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
822pub 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}