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
281 lines
8.3 KiB
Python
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
|