mcltspice/src/mcp_ltspice/schematic.py
Ryan Malloy ba649d2a6e Add stability, power, optimization, batch, and schematic generation tools
Phase 3 features bringing the server to 27 tools:
- Stepped/multi-run .raw file parsing (.step, .mc, .temp)
- Stability analysis (gain/phase margin from AC loop gain)
- Power analysis (average, RMS, efficiency, power factor)
- Safe waveform expression evaluator (recursive-descent parser)
- Component value optimizer (binary search + coordinate descent)
- Batch simulation: parameter sweep, temperature sweep, Monte Carlo
- .asc schematic generation from templates (RC filter, divider, inverting amp)
- Touchstone .s1p/.s2p/.snp S-parameter file parsing
- 7 new netlist templates (diff amp, common emitter, buck, LDO, oscillator, H-bridge)
- Full ruff lint and format compliance across all modules
2026-02-10 23:05:35 -07:00

281 lines
8.3 KiB
Python

"""Parser and editor for LTspice .asc schematic files.
LTspice .asc format is a simple text format with components, wires, and directives.
"""
import re
from dataclasses import dataclass, field
from pathlib import Path
@dataclass
class Component:
"""A component instance in the schematic."""
name: str # Instance name (e.g., R1, C1, M1)
symbol: str # Symbol name (e.g., res, cap, nmos)
x: int
y: int
rotation: int # 0, 90, 180, 270
mirror: bool
attributes: dict[str, str] = field(default_factory=dict)
@property
def value(self) -> str | None:
"""Get component value (e.g., '10k' for resistor)."""
return self.attributes.get("Value") or self.attributes.get("Value2")
@value.setter
def value(self, val: str):
"""Set component value."""
self.attributes["Value"] = val
@dataclass
class Wire:
"""A wire connection."""
x1: int
y1: int
x2: int
y2: int
@dataclass
class Text:
"""Text annotation or SPICE directive."""
x: int
y: int
content: str
type: str = "comment" # "comment" or "spice"
@dataclass
class Flag:
"""A net flag/label."""
x: int
y: int
name: str
type: str = "0" # Net type
@dataclass
class Schematic:
"""A parsed LTspice schematic."""
version: int = 4
sheet: tuple[int, int, int, int] = (1, 1, 0, 0)
components: list[Component] = field(default_factory=list)
wires: list[Wire] = field(default_factory=list)
texts: list[Text] = field(default_factory=list)
flags: list[Flag] = field(default_factory=list)
def get_component(self, name: str) -> Component | None:
"""Get component by instance name (case-insensitive)."""
name_lower = name.lower()
for comp in self.components:
if comp.name.lower() == name_lower:
return comp
return None
def get_components_by_symbol(self, symbol: str) -> list[Component]:
"""Get all components using a specific symbol."""
symbol_lower = symbol.lower()
return [c for c in self.components if c.symbol.lower() == symbol_lower]
def get_spice_directives(self) -> list[str]:
"""Get all SPICE directives from the schematic."""
return [t.content for t in self.texts if t.type == "spice"]
def parse_schematic(path: Path | str) -> Schematic:
"""Parse an LTspice .asc schematic file.
Args:
path: Path to the .asc file
Returns:
Schematic object with parsed contents
"""
path = Path(path)
content = path.read_text(encoding="utf-8", errors="replace")
lines = content.split("\n")
schematic = Schematic()
current_component: Component | None = None
i = 0
while i < len(lines):
line = lines[i].strip()
if line.startswith("Version"):
parts = line.split()
if len(parts) >= 2:
schematic.version = int(parts[1])
elif line.startswith("SHEET"):
parts = line.split()
if len(parts) >= 5:
schematic.sheet = (int(parts[1]), int(parts[2]), int(parts[3]), int(parts[4]))
elif line.startswith("WIRE"):
parts = line.split()
if len(parts) >= 5:
schematic.wires.append(
Wire(int(parts[1]), int(parts[2]), int(parts[3]), int(parts[4]))
)
elif line.startswith("FLAG"):
parts = line.split()
if len(parts) >= 4:
schematic.flags.append(
Flag(
int(parts[1]), int(parts[2]), parts[3], parts[4] if len(parts) > 4 else "0"
)
)
elif line.startswith("SYMBOL"):
# Save previous component before starting a new one
if current_component and current_component.name:
schematic.components.append(current_component)
# Start of a new component
parts = line.split()
if len(parts) >= 4:
symbol = parts[1]
x = int(parts[2])
y = int(parts[3])
# Parse rotation/mirror from remaining parts
rotation = 0
mirror = False
if len(parts) > 4:
rot_str = parts[4]
if rot_str.startswith("M"):
mirror = True
rot_str = rot_str[1:]
if rot_str.startswith("R"):
rotation = int(rot_str[1:])
current_component = Component(
name="", symbol=symbol, x=x, y=y, rotation=rotation, mirror=mirror
)
else:
current_component = None
elif line.startswith("SYMATTR") and current_component:
parts = line.split(None, 2)
if len(parts) >= 3:
attr_name = parts[1]
attr_value = parts[2]
if attr_name == "InstName":
current_component.name = attr_value
else:
current_component.attributes[attr_name] = attr_value
elif line.startswith("TEXT"):
# Parse text/directive
match = re.match(r"TEXT\s+(-?\d+)\s+(-?\d+)\s+(\w+)\s+(\d+)\s*(.*)", line)
if match:
x, y = int(match.group(1)), int(match.group(2))
# groups 3 (align) and 4 (size) are parsed but not stored
content = match.group(5) if match.group(5) else ""
# Check for multi-line text (continuation with \n or actual newlines)
text_type = "comment"
if content.startswith("!"):
text_type = "spice"
content = content[1:]
elif content.startswith(";"):
content = content[1:]
schematic.texts.append(Text(x, y, content, text_type))
i += 1
# Save the last component if not already saved
if current_component and current_component.name:
if current_component not in schematic.components:
schematic.components.append(current_component)
return schematic
def write_schematic(schematic: Schematic, path: Path | str) -> None:
"""Write a schematic to an .asc file.
Args:
schematic: Schematic to write
path: Output path
"""
path = Path(path)
lines = []
lines.append(f"Version {schematic.version}")
lines.append(
f"SHEET {schematic.sheet[0]} {schematic.sheet[1]} {schematic.sheet[2]} {schematic.sheet[3]}"
)
# Write wires
for wire in schematic.wires:
lines.append(f"WIRE {wire.x1} {wire.y1} {wire.x2} {wire.y2}")
# Write flags
for flag in schematic.flags:
lines.append(f"FLAG {flag.x} {flag.y} {flag.name}")
# Write components
for comp in schematic.components:
rot_str = f"R{comp.rotation}"
if comp.mirror:
rot_str = "M" + rot_str
lines.append(f"SYMBOL {comp.symbol} {comp.x} {comp.y} {rot_str}")
lines.append(f"SYMATTR InstName {comp.name}")
for attr_name, attr_value in comp.attributes.items():
if attr_name != "InstName":
lines.append(f"SYMATTR {attr_name} {attr_value}")
# Write text/directives
for text in schematic.texts:
prefix = "!" if text.type == "spice" else ""
lines.append(f"TEXT {text.x} {text.y} Left 2 {prefix}{text.content}")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def modify_component_value(
path: Path | str, component_name: str, new_value: str, output_path: Path | str | None = None
) -> Schematic:
"""Modify a component's value in a schematic.
Args:
path: Input schematic path
component_name: Name of component to modify (e.g., "R1")
new_value: New value to set
output_path: Output path (defaults to overwriting input)
Returns:
Modified schematic
Raises:
ValueError: If component not found
"""
schematic = parse_schematic(path)
comp = schematic.get_component(component_name)
if not comp:
available = [c.name for c in schematic.components]
raise ValueError(
f"Component '{component_name}' not found. Available components: {', '.join(available)}"
)
comp.value = new_value
write_schematic(schematic, output_path or path)
return schematic