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.
 
 
 
 
 
 

518 lines
17 KiB

"""
Netlist pre-processor for ngspice compatibility.
Transforms LTspice-style netlist constructs into ngspice-compatible
equivalents. The primary transformation is converting Rser= parameters
on inductor lines into separate series resistor elements.
Transformations applied:
- Inductor Rser=: L1 p1 0 8.5u Rser=0.012 -> L1 + R_L1_ser
- Strip .backanno directives (LTspice-specific, benign)
Usage:
from pyngspice.netlist import preprocess_netlist
with open("circuit.net") as f:
original = f.read()
processed = preprocess_netlist(original)
"""
import re
from typing import List, Tuple
def preprocess_netlist(content: str) -> str:
"""Pre-process a SPICE netlist for ngspice compatibility.
Applies all necessary transformations to convert an LTspice-style
netlist into one that ngspice can parse correctly.
Args:
content: The raw netlist string (LTspice format)
Returns:
Processed netlist string ready for ngspice
"""
lines = content.splitlines()
output_lines = []
extra_components = [] # Series resistors to insert before .end
has_savecurrents = False
targeted_cap_names = set() # Capacitor names from .save i(C_*) directives
for line in lines:
stripped = line.strip()
# Skip empty lines (preserve them)
if not stripped:
output_lines.append(line)
continue
# Strip .backanno directives (LTspice-specific, ignored by ngspice)
if stripped.lower().startswith('.backanno'):
continue
# Detect and strip .options savecurrents (doesn't work in embedded mode)
if _is_savecurrents_option(stripped):
has_savecurrents = True
continue
# Handle continuation lines (start with +) — pass through
if stripped.startswith('+'):
output_lines.append(line)
continue
# Detect .save i(C_*) directives for targeted capacitor probing
if stripped.lower().startswith('.save'):
cap_names = _parse_save_cap_currents(stripped)
if cap_names:
targeted_cap_names.update(name.upper() for name in cap_names)
output_lines.append(line)
continue
# Handle inductor lines with Rser= parameter
if stripped[0].upper() == 'L' and 'rser' in stripped.lower():
processed_line, extras = _process_inductor_rser(stripped)
if extras:
output_lines.append(processed_line)
extra_components.extend(extras)
continue
output_lines.append(line)
# Insert extra components (series resistors) just before .end
if extra_components:
result = []
for line in output_lines:
if line.strip().lower() == '.end':
result.append('* --- pyngspice: inductor series resistance expansion ---')
result.extend(extra_components)
result.append(line)
output_lines = result
# Insert 0V voltage source probes for capacitor current measurement
if has_savecurrents:
output_lines = _insert_capacitor_probes(output_lines)
# Expand .options savecurrents into explicit .save directives
if has_savecurrents:
output_lines = _expand_savecurrents(output_lines)
# Targeted capacitor probing from .save i(C_*) directives
# Only active when savecurrents is NOT present (savecurrents probes everything already)
if targeted_cap_names and not has_savecurrents:
output_lines = _insert_targeted_capacitor_probes(output_lines, targeted_cap_names)
output_lines = _rewrite_save_directives(output_lines, targeted_cap_names)
return '\n'.join(output_lines)
# Pattern for inductor lines with Rser= parameter
# Matches: L<name> <node+> <node-> <value> Rser=<value> [other params...]
# Value can be: number, number with suffix (8.5u, 100n, 1.2k), parameter ref ({Lpri}),
# or scientific notation (1.5e-6)
_INDUCTOR_RSER_PATTERN = re.compile(
r'^(L\w+)' # Group 1: Inductor name (L1, Lprimary, etc.)
r'\s+'
r'(\S+)' # Group 2: Positive node
r'\s+'
r'(\S+)' # Group 3: Negative node
r'\s+'
r'(\S+)' # Group 4: Inductance value
r'\s+'
r'Rser\s*=\s*' # Rser= keyword
r'(\S+)' # Group 5: Series resistance value
r'(.*)', # Group 6: Remaining parameters (Rpar=, Cpar=, etc.)
re.IGNORECASE
)
def _process_inductor_rser(line: str) -> Tuple[str, List[str]]:
"""Process Rser= parameter on an inductor line.
Transforms:
L1 p1 0 8.5u Rser=0.012
Into:
L1 p1 _rser_L1 8.5u
Plus extra line:
R_L1_ser _rser_L1 0 0.012
The intermediate node name uses _rser_<name> prefix to avoid
collisions with user-defined node names.
Args:
line: The inductor line to process
Returns:
Tuple of (modified_line, list_of_extra_lines).
If no Rser= found, returns (original_line, []).
"""
match = _INDUCTOR_RSER_PATTERN.match(line.strip())
if not match:
return line, []
name = match.group(1) # e.g., L1
node_p = match.group(2) # e.g., p1
node_n = match.group(3) # e.g., 0
inductance = match.group(4) # e.g., 8.5u
rser_val = match.group(5) # e.g., 0.012
remaining = match.group(6).strip() # e.g., Cpar=10p
# Create unique intermediate node name
int_node = f"_rser_{name}"
# Build modified inductor line (inductor connects to intermediate node)
new_inductor = f"{name} {node_p} {int_node} {inductance}"
# Preserve any remaining parameters (Rpar=, Cpar=, etc.)
# Strip any additional Rser-like params that ngspice doesn't support
if remaining:
# Remove Rpar= and Cpar= as well if present (ngspice doesn't support these either)
cleaned = _strip_ltspice_inductor_params(remaining)
if cleaned:
new_inductor += f" {cleaned}"
# Build series resistor line
resistor = f"R_{name}_ser {int_node} {node_n} {rser_val}"
return new_inductor, [resistor]
# SPICE component prefixes whose current can be saved with i(name)
# K (coupling) is excluded — it has no "through" current
_COMPONENT_PREFIXES = set('RCLVIDEFJMQBXGHrclvidefjmqbxgh')
def _is_savecurrents_option(line: str) -> bool:
"""Check if a line is .options savecurrents (any case, any spacing)."""
lowered = line.strip().lower()
if not lowered.startswith('.options') and not lowered.startswith('.option'):
return False
return 'savecurrents' in lowered
# Pattern to extract i(name) references from .save directives
_SAVE_CURRENT_PATTERN = re.compile(r'i\((\w+)\)', re.IGNORECASE)
def _parse_save_cap_currents(line: str) -> list:
"""Extract capacitor names from .save i(C_name) directives.
Parses a .save directive line and returns names of capacitors
whose currents are being saved. Non-capacitor names (V1, R1, L1)
are ignored.
Args:
line: A .save directive line (e.g., ".save i(C_mmc) i(V1)")
Returns:
List of capacitor names found (e.g., ["C_mmc"])
"""
if not line.strip().lower().startswith('.save'):
return []
return [m.group(1) for m in _SAVE_CURRENT_PATTERN.finditer(line)
if m.group(1)[0].upper() == 'C']
def _expand_savecurrents(lines: List[str]) -> List[str]:
"""Replace .options savecurrents with explicit .save directives.
Scans the netlist for all component names and generates:
.save all
.save i(V1) i(C1) i(R1) ...
This is needed because .options savecurrents doesn't work reliably
in ngspice's embedded (shared library) mode — the write command
silently fails to produce a .raw file.
Args:
lines: Processed netlist lines (Rser already expanded, savecurrents already stripped)
Returns:
Modified lines with .save directives inserted before .end
"""
component_names = _collect_component_names(lines)
if not component_names:
return lines
# Build .save directives
save_lines = [
'* --- pyngspice: explicit current saves (expanded from .options directive) ---',
'.save all',
]
# Group i(name) saves into lines of reasonable length
current_saves = [f'i({name})' for name in component_names]
# Put them all on one .save line (ngspice handles long lines fine)
save_lines.append('.save ' + ' '.join(current_saves))
# Insert before .end
result = []
for line in lines:
if line.strip().lower() == '.end':
result.extend(save_lines)
result.append(line)
return result
def _collect_component_names(lines: List[str]) -> List[str]:
"""Extract component names from netlist lines.
Returns names of components whose current can be saved with i(name).
Skips: comments, directives, continuations, K elements, blank lines.
Args:
lines: Netlist lines to scan
Returns:
List of component names in order of appearance
"""
names = []
for line in lines:
stripped = line.strip()
if not stripped:
continue
first_char = stripped[0]
# Skip comments, directives, continuations
if first_char in ('*', '.', '+'):
continue
# Skip K elements (coupling coefficients)
if first_char in ('K', 'k'):
continue
# Check if it's a component line
if first_char in _COMPONENT_PREFIXES:
# Component name is the first token
tokens = stripped.split()
if tokens:
names.append(tokens[0])
return names
def _insert_capacitor_probes(lines: List[str]) -> List[str]:
"""Insert 0V voltage source probes in series with capacitors.
ngspice embedded mode silently ignores .save i(capacitor) directives.
The workaround is to insert a 0V voltage source in series with each
capacitor — V-source currents are always saved. The trace is later
renamed from i(v_probe_Cname) back to i(Cname) in raw file post-processing.
Only processes top-level capacitors (skips those inside .subckt blocks).
Transforms:
C_mmc vin p1 0.03u
Into:
C_mmc _probe_C_mmc p1 0.03u
Plus extra line before .end:
V_probe_C_mmc vin _probe_C_mmc 0
Args:
lines: Processed netlist lines (Rser already expanded, savecurrents stripped)
Returns:
Modified lines with capacitor probes inserted
"""
modified_lines = []
probe_lines = []
subckt_depth = 0
for line in lines:
stripped = line.strip()
# Track .subckt nesting
if stripped.lower().startswith('.subckt'):
subckt_depth += 1
modified_lines.append(line)
continue
elif stripped.lower().startswith('.ends'):
subckt_depth -= 1
modified_lines.append(line)
continue
# Only process top-level capacitors
if subckt_depth == 0 and stripped and stripped[0].upper() == 'C':
mod_line, probe = _process_capacitor_probe(stripped)
if probe:
modified_lines.append(mod_line)
probe_lines.append(probe)
continue
modified_lines.append(line)
# Insert probe V sources before .end
if probe_lines:
result = []
for line in modified_lines:
if line.strip().lower() == '.end':
result.append('* --- pyngspice: capacitor current probes ---')
result.extend(probe_lines)
result.append(line)
return result
return modified_lines
def _insert_targeted_capacitor_probes(lines: List[str], target_caps: set) -> List[str]:
"""Insert 0V voltage source probes for specific capacitors only.
Like _insert_capacitor_probes(), but only processes capacitors whose
names (case-insensitive) are in target_caps. This avoids the performance
penalty of probing all capacitors when only a few currents are needed.
Args:
lines: Processed netlist lines
target_caps: Set of capacitor names to probe (uppercase for comparison)
Returns:
Modified lines with targeted capacitor probes inserted
"""
modified_lines = []
probe_lines = []
subckt_depth = 0
for line in lines:
stripped = line.strip()
# Track .subckt nesting
if stripped.lower().startswith('.subckt'):
subckt_depth += 1
modified_lines.append(line)
continue
elif stripped.lower().startswith('.ends'):
subckt_depth -= 1
modified_lines.append(line)
continue
# Only process top-level capacitors that are in the target set
if subckt_depth == 0 and stripped and stripped[0].upper() == 'C':
tokens = stripped.split()
if tokens and tokens[0].upper() in target_caps:
mod_line, probe = _process_capacitor_probe(stripped)
if probe:
modified_lines.append(mod_line)
probe_lines.append(probe)
continue
modified_lines.append(line)
# Insert probe V sources before .end
if probe_lines:
result = []
for line in modified_lines:
if line.strip().lower() == '.end':
result.append('* --- pyngspice: targeted capacitor current probes ---')
result.extend(probe_lines)
result.append(line)
return result
return modified_lines
def _process_capacitor_probe(line: str) -> Tuple[str, str]:
"""Insert a 0V voltage source probe in series with a capacitor.
Transforms:
C_mmc vin p1 0.03u
Into modified line:
C_mmc _probe_C_mmc p1 0.03u
And probe line:
V_probe_C_mmc vin _probe_C_mmc 0
Args:
line: A capacitor line (must start with C)
Returns:
(modified_cap_line, probe_v_source_line).
If parsing fails, returns (original_line, "").
"""
tokens = line.strip().split()
if len(tokens) < 4:
return line, ""
name = tokens[0] # C_mmc
node1 = tokens[1] # vin (positive node)
node2 = tokens[2] # p1 (negative node)
rest = ' '.join(tokens[3:]) # 0.03u [params...]
int_node = f"_probe_{name}"
modified_cap = f"{name} {int_node} {node2} {rest}"
probe_source = f"V_probe_{name} {node1} {int_node} 0"
return modified_cap, probe_source
def _rewrite_save_directives(lines: List[str], probed_caps: set) -> List[str]:
"""Rewrite .save directives to reference probe V-sources instead of capacitors.
When targeted capacitor probing is active:
- .save i(C_mmc) -> .save i(V_probe_C_mmc)
- .save i(V1) i(C_mmc) -> .save i(V1) i(V_probe_C_mmc)
- .save v(out) -> .save v(out) (voltages unchanged)
- Ensures .save all is present (needed for voltage traces)
Args:
lines: Netlist lines (after probe insertion)
probed_caps: Set of capacitor names that were probed (uppercase for matching)
Returns:
Modified lines with .save directives rewritten
"""
has_save_all = False
result = []
for line in lines:
stripped = line.strip()
if stripped.lower().startswith('.save'):
# Check if this is .save all
if stripped.lower().split() == ['.save', 'all']:
has_save_all = True
result.append(line)
continue
# Rewrite i(C_name) references to i(V_probe_C_name)
def rewrite_cap_current(match):
name = match.group(1)
if name.upper() in probed_caps:
return f'i(V_probe_{name})'
return match.group(0)
rewritten = _SAVE_CURRENT_PATTERN.sub(rewrite_cap_current, stripped)
result.append(rewritten)
else:
result.append(line)
# Ensure .save all is present (user may have only .save i(C_mmc), but
# we still need voltage traces)
if not has_save_all:
final = []
for line in result:
if line.strip().lower() == '.end':
final.append('* --- pyngspice: auto-inserted .save all for voltage traces ---')
final.append('.save all')
final.append(line)
return final
return result
def _strip_ltspice_inductor_params(params: str) -> str:
"""Remove LTspice-specific inductor parameters that ngspice doesn't support.
LTspice supports Rser=, Rpar=, Cpar= on inductors. NGspice does not.
These need to be removed or converted to separate components.
For now, we just strip them. In the future, Rpar= and Cpar= could
be expanded into parallel R and C elements.
Args:
params: Remaining parameter string after the inductance value
Returns:
Cleaned parameter string with LTspice-specific params removed
"""
# Remove Rpar=<value> and Cpar=<value>
cleaned = re.sub(r'Rpar\s*=\s*\S+', '', params, flags=re.IGNORECASE)
cleaned = re.sub(r'Cpar\s*=\s*\S+', '', cleaned, flags=re.IGNORECASE)
return cleaned.strip()