""" 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 = [] found_end = False for line in output_lines: if line.strip().lower() == '.end': found_end = True result.append('* --- pyngspice: inductor series resistance expansion ---') result.extend(extra_components) result.append(line) if not found_end: # No .end found - append at end to avoid dangling nodes result.append('* --- pyngspice: inductor series resistance expansion ---') result.extend(extra_components) 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 Rser= [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_ 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 = [] found_end = False for line in lines: if line.strip().lower() == '.end': found_end = True result.extend(save_lines) result.append(line) if not found_end: result.extend(save_lines) 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 = [] found_end = False for line in modified_lines: if line.strip().lower() == '.end': found_end = True result.append('* --- pyngspice: capacitor current probes ---') result.extend(probe_lines) result.append(line) if not found_end: result.append('* --- pyngspice: capacitor current probes ---') result.extend(probe_lines) 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 = [] found_end = False for line in modified_lines: if line.strip().lower() == '.end': found_end = True result.append('* --- pyngspice: targeted capacitor current probes ---') result.extend(probe_lines) result.append(line) if not found_end: result.append('* --- pyngspice: targeted capacitor current probes ---') result.extend(probe_lines) 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= and Cpar= 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()