"""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