You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

444 lines
15 KiB

"""
SpiceRunner interface and implementations for pyTesla integration.
Provides a common interface for running SPICE simulations with different
backends (embedded ngspice, subprocess ngspice, etc.). pyTesla uses
this interface to swap between LTspice and ngspice seamlessly.
Usage:
from pyngspice import NgspiceRunner
runner = NgspiceRunner(working_directory="./output")
raw_file, log_file = runner.run("circuit.net")
# Or with auto-detection:
from pyngspice.runner import get_runner
runner = get_runner("./output")
"""
import os
import shutil
import subprocess
import sys
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Optional, Tuple
from .netlist import preprocess_netlist
def _build_probe_mapping(processed_netlist_path: str) -> dict:
"""Build a mapping from probe trace names to original capacitor trace names.
Reads the processed netlist to find V_probe_* lines and builds:
{'i(v_probe_c_mmc)': 'i(c_mmc)', ...}
All names are lowercased to match ngspice's raw file convention.
Args:
processed_netlist_path: Path to the preprocessed netlist file
Returns:
Dict mapping probe trace names to original capacitor trace names
"""
mapping = {}
try:
with open(processed_netlist_path, 'r') as f:
for line in f:
tokens = line.strip().split()
if not tokens:
continue
name = tokens[0]
if name.upper().startswith('V_PROBE_'):
# V_probe_C_mmc -> C_mmc
cap_name = name[8:] # strip 'V_probe_'
old_trace = f'i({name.lower()})'
new_trace = f'i({cap_name.lower()})'
mapping[old_trace] = new_trace
except (OSError, IOError):
pass
return mapping
def _postprocess_raw_file(raw_file: str, processed_netlist_path: str) -> None:
"""Rename capacitor probe traces in raw file header.
After simulation, the raw file contains traces like i(v_probe_c_mmc).
This function renames them back to i(c_mmc) so pyTesla sees the
expected capacitor current names.
Only modifies the ASCII header; the binary data section is untouched.
Args:
raw_file: Path to the .raw file to post-process
processed_netlist_path: Path to the preprocessed netlist (to find probes)
"""
mapping = _build_probe_mapping(processed_netlist_path)
if not mapping:
return
try:
with open(raw_file, 'rb') as f:
content = f.read()
except (OSError, IOError):
return
# Find header/data boundary
header_end = -1
for marker in [b'Binary:\n', b'Binary:\r\n', b'Values:\n', b'Values:\r\n']:
pos = content.find(marker)
if pos >= 0:
header_end = pos + len(marker)
break
if header_end < 0:
return # Can't find boundary, skip
header = content[:header_end].decode('ascii', errors='replace')
data = content[header_end:]
# Apply renames in header
for old_name, new_name in mapping.items():
header = header.replace(old_name, new_name)
with open(raw_file, 'wb') as f:
f.write(header.encode('ascii'))
f.write(data)
class SimulationError(Exception):
"""Raised when a SPICE simulation fails."""
def __init__(self, message: str, log_content: str = None):
super().__init__(message)
self.log_content = log_content
class SpiceRunner(ABC):
"""Abstract base class for SPICE simulator runners.
This interface is used by pyTesla to run simulations with different
SPICE backends (LTspice, ngspice embedded, ngspice subprocess).
All implementations produce .raw and .log files that can be parsed
with PyLTSpice's RawRead or pyngspice's RawRead.
"""
def __init__(self, working_directory: str):
"""Initialize with working directory for netlist and output files.
Args:
working_directory: Directory where output files will be written.
Created if it doesn't exist.
"""
self.working_directory = os.path.abspath(working_directory)
os.makedirs(self.working_directory, exist_ok=True)
@abstractmethod
def run(self, netlist_path: str, timeout: int = None) -> Tuple[str, str]:
"""Execute SPICE simulation.
Args:
netlist_path: Absolute or relative path to .net/.cir file
timeout: Optional timeout in seconds
Returns:
(raw_file_path, log_file_path) - absolute paths to output files
Raises:
SimulationError: If simulation fails or times out
FileNotFoundError: If netlist file doesn't exist
"""
...
@staticmethod
@abstractmethod
def detect() -> bool:
"""Return True if this SPICE engine is available."""
...
@staticmethod
@abstractmethod
def get_executable_path() -> Optional[str]:
"""Auto-detect and return path to executable, or None."""
...
def _preprocess_netlist(self, netlist_path: str) -> str:
"""Pre-process netlist for ngspice compatibility.
If the netlist needs translation (Rser= on inductors, etc.),
writes a processed copy to the working directory and returns
its path. Otherwise returns the original path.
Args:
netlist_path: Path to the original netlist
Returns:
Path to the netlist to actually simulate (may be original or processed copy)
"""
netlist_path = os.path.abspath(netlist_path)
if not os.path.isfile(netlist_path):
raise FileNotFoundError(f"Netlist not found: {netlist_path}")
with open(netlist_path, 'r') as f:
original = f.read()
processed = preprocess_netlist(original)
if processed == original:
return netlist_path
# Write processed netlist to working directory
stem = Path(netlist_path).stem
out_path = os.path.join(self.working_directory, f"{stem}_ngspice.net")
with open(out_path, 'w') as f:
f.write(processed)
return out_path
class NgspiceRunner(SpiceRunner):
"""NgspiceRunner using embedded ngspice via pybind11 C++ extension.
This is the primary runner. It uses the statically-linked ngspice
library through pybind11 bindings — no external ngspice installation
is needed.
The embedded approach is faster than subprocess invocation since there's
no process spawn overhead and the simulator is already loaded in memory.
"""
def __init__(self, working_directory: str = "."):
super().__init__(working_directory)
from . import _cpp_available
if not _cpp_available:
from . import _cpp_error
raise ImportError(
f"pyngspice C++ extension not available: {_cpp_error}\n"
f"Use SubprocessRunner as a fallback, or rebuild with: pip install -e ."
)
from ._pyngspice import SimRunner as _CppSimRunner
self._runner = _CppSimRunner(output_folder=self.working_directory)
def run(self, netlist_path: str, timeout: int = None) -> Tuple[str, str]:
"""Run simulation using embedded ngspice.
Args:
netlist_path: Path to .net/.cir netlist file
timeout: Optional timeout in seconds (not yet implemented for embedded mode)
Returns:
(raw_file_path, log_file_path) as absolute paths
Raises:
SimulationError: If simulation fails
"""
processed_path = self._preprocess_netlist(netlist_path)
try:
raw_file, log_file = self._runner.run_now(processed_path)
except Exception as e:
raise SimulationError(f"Simulation failed: {e}") from e
# Normalize to absolute paths
raw_file = os.path.abspath(raw_file)
log_file = os.path.abspath(log_file)
if not os.path.isfile(raw_file):
raise SimulationError(
f"Simulation completed but raw file not found: {raw_file}"
)
# Post-process: rename capacitor probe traces in raw file header
_postprocess_raw_file(raw_file, processed_path)
return raw_file, log_file
@staticmethod
def detect() -> bool:
"""Return True if the embedded C++ extension is available."""
try:
from . import _cpp_available
return _cpp_available
except ImportError:
return False
@staticmethod
def get_executable_path() -> Optional[str]:
"""Return None — embedded mode has no separate executable."""
return None
class SubprocessRunner(SpiceRunner):
"""Fallback runner using ngspice as a subprocess.
Invokes ngspice in batch mode via the command line. Requires ngspice
to be installed and available on PATH (or specified explicitly).
This is useful when:
- The C++ extension is not compiled
- You need to use a specific ngspice build
- Debugging simulation issues with ngspice's own output
"""
def __init__(self, working_directory: str = ".", executable: str = None):
"""Initialize with optional explicit path to ngspice executable.
Args:
working_directory: Directory for output files
executable: Path to ngspice executable. If None, auto-detects.
"""
super().__init__(working_directory)
self._executable = executable or self._find_ngspice()
if self._executable is None:
raise FileNotFoundError(
"ngspice executable not found. Install ngspice or specify "
"the path explicitly via the 'executable' parameter."
)
def run(self, netlist_path: str, timeout: int = None) -> Tuple[str, str]:
"""Run simulation using ngspice subprocess.
Invokes: ngspice -b -r <output.raw> -o <output.log> <netlist>
Args:
netlist_path: Path to .net/.cir netlist file
timeout: Optional timeout in seconds
Returns:
(raw_file_path, log_file_path) as absolute paths
Raises:
SimulationError: If simulation fails or times out
"""
processed_path = self._preprocess_netlist(netlist_path)
stem = Path(processed_path).stem
raw_file = os.path.join(self.working_directory, f"{stem}.raw")
log_file = os.path.join(self.working_directory, f"{stem}.log")
cmd = [
self._executable,
'-b', # Batch mode (no GUI)
'-r', raw_file, # Raw output file
'-o', log_file, # Log output file
processed_path, # Input netlist
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
cwd=self.working_directory,
)
except subprocess.TimeoutExpired:
raise SimulationError(
f"Simulation timed out after {timeout} seconds"
)
except FileNotFoundError:
raise SimulationError(
f"ngspice executable not found: {self._executable}"
)
# Check for errors
if result.returncode != 0:
log_content = None
if os.path.isfile(log_file):
with open(log_file, 'r') as f:
log_content = f.read()
raise SimulationError(
f"ngspice exited with code {result.returncode}: {result.stderr}",
log_content=log_content,
)
if not os.path.isfile(raw_file):
raise SimulationError(
f"Simulation completed but raw file not found: {raw_file}\n"
f"stderr: {result.stderr}"
)
return os.path.abspath(raw_file), os.path.abspath(log_file)
@staticmethod
def detect() -> bool:
"""Return True if ngspice executable is found on PATH."""
return SubprocessRunner._find_ngspice() is not None
@staticmethod
def get_executable_path() -> Optional[str]:
"""Return path to ngspice executable, or None if not found."""
return SubprocessRunner._find_ngspice()
@staticmethod
def _find_ngspice() -> Optional[str]:
"""Search for ngspice executable.
Checks:
1. PATH (via shutil.which)
2. Common install locations on each platform
"""
# Check PATH first
found = shutil.which("ngspice")
if found:
return found
# Check common install locations
if sys.platform == 'win32':
candidates = [
r"C:\Program Files\ngspice\bin\ngspice.exe",
r"C:\Program Files (x86)\ngspice\bin\ngspice.exe",
os.path.expanduser(r"~\ngspice\bin\ngspice.exe"),
]
elif sys.platform == 'darwin':
candidates = [
"/usr/local/bin/ngspice",
"/opt/homebrew/bin/ngspice",
]
else: # Linux
candidates = [
"/usr/bin/ngspice",
"/usr/local/bin/ngspice",
"/snap/bin/ngspice",
]
for path in candidates:
if os.path.isfile(path):
return path
return None
def get_runner(working_directory: str = ".",
backend: str = "auto") -> SpiceRunner:
"""Factory function to get the best available SpiceRunner.
Args:
working_directory: Directory for simulation output files
backend: One of "auto", "embedded", "subprocess".
- "auto": try embedded first, fall back to subprocess
- "embedded": use NgspiceRunner (requires C++ extension)
- "subprocess": use SubprocessRunner (requires ngspice on PATH)
Returns:
A SpiceRunner instance
Raises:
RuntimeError: If no suitable backend is found
"""
if backend == "embedded":
return NgspiceRunner(working_directory)
elif backend == "subprocess":
return SubprocessRunner(working_directory)
elif backend == "auto":
# Try embedded first (faster, no external dependency)
if NgspiceRunner.detect():
return NgspiceRunner(working_directory)
# Fall back to subprocess
if SubprocessRunner.detect():
return SubprocessRunner(working_directory)
raise RuntimeError(
"No ngspice backend available. Either:\n"
" 1. Build the C++ extension: pip install -e .\n"
" 2. Install ngspice and add it to PATH"
)
else:
raise ValueError(f"Unknown backend: {backend!r}. Use 'auto', 'embedded', or 'subprocess'.")