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
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()
|