"""Compare two LTspice schematics and produce a structured diff.""" from dataclasses import dataclass, field from pathlib import Path from .schematic import Component, Schematic, parse_schematic @dataclass class ComponentChange: """A change to a component.""" name: str change_type: str # "added", "removed", "modified" symbol: str = "" old_value: str | None = None new_value: str | None = None old_attributes: dict[str, str] = field(default_factory=dict) new_attributes: dict[str, str] = field(default_factory=dict) moved: bool = False # Position changed @dataclass class DirectiveChange: """A change to a SPICE directive.""" change_type: str # "added", "removed", "modified" old_text: str | None = None new_text: str | None = None @dataclass class SchematicDiff: """Complete diff between two schematics.""" component_changes: list[ComponentChange] = field(default_factory=list) directive_changes: list[DirectiveChange] = field(default_factory=list) nets_added: list[str] = field(default_factory=list) nets_removed: list[str] = field(default_factory=list) wires_added: int = 0 wires_removed: int = 0 @property def has_changes(self) -> bool: return bool( self.component_changes or self.directive_changes or self.nets_added or self.nets_removed or self.wires_added or self.wires_removed ) def summary(self) -> str: """Human-readable summary of changes.""" lines: list[str] = [] if not self.has_changes: return "No changes detected." # Group component changes by type added = [c for c in self.component_changes if c.change_type == "added"] removed = [c for c in self.component_changes if c.change_type == "removed"] modified = [c for c in self.component_changes if c.change_type == "modified"] if modified: lines.append(f"{len(modified)} component{'s' if len(modified) != 1 else ''} modified:") for c in modified: parts: list[str] = [] if c.old_value != c.new_value: parts.append(f"{c.old_value} -> {c.new_value}") if c.moved: parts.append("moved") # Check for attribute changes beyond value attr_diff = _attr_diff_summary(c.old_attributes, c.new_attributes) if attr_diff: parts.append(attr_diff) detail = ", ".join(parts) if parts else "attributes changed" lines.append(f" {c.name}: {detail}") if added: for c in added: val = f" = {c.new_value}" if c.new_value else "" lines.append( f"1 component added: {c.name} ({c.symbol}){val}" if len(added) == 1 else f" {c.name} ({c.symbol}){val}" ) if len(added) > 1: lines.insert( len(lines) - len(added), f"{len(added)} components added:", ) if removed: for c in removed: val = f" = {c.old_value}" if c.old_value else "" lines.append( f"1 component removed: {c.name} ({c.symbol}){val}" if len(removed) == 1 else f" {c.name} ({c.symbol}){val}" ) if len(removed) > 1: lines.insert( len(lines) - len(removed), f"{len(removed)} components removed:", ) # Directive changes dir_added = [d for d in self.directive_changes if d.change_type == "added"] dir_removed = [d for d in self.directive_changes if d.change_type == "removed"] dir_modified = [d for d in self.directive_changes if d.change_type == "modified"] for d in dir_modified: lines.append(f"1 directive changed: {d.old_text} -> {d.new_text}") for d in dir_added: lines.append(f"1 directive added: {d.new_text}") for d in dir_removed: lines.append(f"1 directive removed: {d.old_text}") # Net changes if self.nets_added: lines.append( f"{len(self.nets_added)} net{'s' if len(self.nets_added) != 1 else ''} " f"added: {', '.join(self.nets_added)}" ) if self.nets_removed: lines.append( f"{len(self.nets_removed)} net{'s' if len(self.nets_removed) != 1 else ''} " f"removed: {', '.join(self.nets_removed)}" ) # Wire changes wire_parts: list[str] = [] if self.wires_added: wire_parts.append( f"{self.wires_added} wire{'s' if self.wires_added != 1 else ''} added" ) if self.wires_removed: wire_parts.append( f"{self.wires_removed} wire{'s' if self.wires_removed != 1 else ''} removed" ) if wire_parts: lines.append(", ".join(wire_parts)) return "\n".join(lines) def to_dict(self) -> dict: """Convert to JSON-serializable dict.""" return { "has_changes": self.has_changes, "component_changes": [ { "name": c.name, "change_type": c.change_type, "symbol": c.symbol, "old_value": c.old_value, "new_value": c.new_value, "old_attributes": c.old_attributes, "new_attributes": c.new_attributes, "moved": c.moved, } for c in self.component_changes ], "directive_changes": [ { "change_type": d.change_type, "old_text": d.old_text, "new_text": d.new_text, } for d in self.directive_changes ], "nets_added": self.nets_added, "nets_removed": self.nets_removed, "wires_added": self.wires_added, "wires_removed": self.wires_removed, "summary": self.summary(), } def _attr_diff_summary(old: dict[str, str], new: dict[str, str]) -> str: """Summarize attribute differences, excluding Value (handled separately).""" changes: list[str] = [] all_keys = set(old) | set(new) # Skip Value since it's reported on its own all_keys.discard("Value") all_keys.discard("Value2") for key in sorted(all_keys): old_val = old.get(key) new_val = new.get(key) if old_val != new_val: if old_val is None: changes.append(f"+{key}={new_val}") elif new_val is None: changes.append(f"-{key}={old_val}") else: changes.append(f"{key}: {old_val} -> {new_val}") return "; ".join(changes) def _normalize_directive(text: str) -> str: """Normalize whitespace in a SPICE directive for comparison.""" return " ".join(text.split()) def _wire_set(schematic: Schematic) -> set[tuple[int, int, int, int]]: """Convert wires to a set of coordinate tuples for comparison. Each wire is stored in a canonical form (smaller point first) so that reversed wires compare equal. """ result: set[tuple[int, int, int, int]] = set() for w in schematic.wires: # Canonical ordering: sort by (x, y) so direction doesn't matter if (w.x1, w.y1) <= (w.x2, w.y2): result.add((w.x1, w.y1, w.x2, w.y2)) else: result.add((w.x2, w.y2, w.x1, w.y1)) return result def _component_map(schematic: Schematic) -> dict[str, Component]: """Build a map of component instance name -> Component.""" return {comp.name: comp for comp in schematic.components} def _diff_components(schema_a: Schematic, schema_b: Schematic) -> list[ComponentChange]: """Compare components between two schematics.""" map_a = _component_map(schema_a) map_b = _component_map(schema_b) names_a = set(map_a) names_b = set(map_b) changes: list[ComponentChange] = [] # Removed components (in A but not B) for name in sorted(names_a - names_b): comp = map_a[name] changes.append( ComponentChange( name=name, change_type="removed", symbol=comp.symbol, old_value=comp.value, old_attributes=dict(comp.attributes), ) ) # Added components (in B but not A) for name in sorted(names_b - names_a): comp = map_b[name] changes.append( ComponentChange( name=name, change_type="added", symbol=comp.symbol, new_value=comp.value, new_attributes=dict(comp.attributes), ) ) # Potentially modified components (in both) for name in sorted(names_a & names_b): comp_a = map_a[name] comp_b = map_b[name] moved = (comp_a.x, comp_a.y) != (comp_b.x, comp_b.y) value_changed = comp_a.value != comp_b.value attrs_changed = comp_a.attributes != comp_b.attributes rotation_changed = comp_a.rotation != comp_b.rotation or comp_a.mirror != comp_b.mirror if moved or value_changed or attrs_changed or rotation_changed: changes.append( ComponentChange( name=name, change_type="modified", symbol=comp_a.symbol, old_value=comp_a.value, new_value=comp_b.value, old_attributes=dict(comp_a.attributes), new_attributes=dict(comp_b.attributes), moved=moved, ) ) return changes def _diff_directives(schema_a: Schematic, schema_b: Schematic) -> list[DirectiveChange]: """Compare SPICE directives between two schematics.""" directives_a = [t.content for t in schema_a.texts if t.type == "spice"] directives_b = [t.content for t in schema_b.texts if t.type == "spice"] # Normalize for comparison but keep originals for display norm_a = {_normalize_directive(d): d for d in directives_a} norm_b = {_normalize_directive(d): d for d in directives_b} keys_a = set(norm_a) keys_b = set(norm_b) changes: list[DirectiveChange] = [] # Removed directives for key in sorted(keys_a - keys_b): changes.append(DirectiveChange(change_type="removed", old_text=norm_a[key])) # Added directives for key in sorted(keys_b - keys_a): changes.append(DirectiveChange(change_type="added", new_text=norm_b[key])) # For modified detection: directives that share a command keyword but differ. # We match by the first token (e.g., ".tran", ".ac") to detect modifications # vs pure add/remove pairs. removed = [c for c in changes if c.change_type == "removed"] added = [c for c in changes if c.change_type == "added"] matched_removed: set[int] = set() matched_added: set[int] = set() for ri, rc in enumerate(removed): old_cmd = (rc.old_text or "").split()[0].lower() if rc.old_text else "" for ai, ac in enumerate(added): if ai in matched_added: continue new_cmd = (ac.new_text or "").split()[0].lower() if ac.new_text else "" if old_cmd and old_cmd == new_cmd: matched_removed.add(ri) matched_added.add(ai) break # Rebuild the list: unmatched stay as-is, matched pairs become "modified" final_changes: list[DirectiveChange] = [] for ri, rc in enumerate(removed): if ri not in matched_removed: final_changes.append(rc) for ai, ac in enumerate(added): if ai not in matched_added: final_changes.append(ac) for ri in sorted(matched_removed): rc = removed[ri] # Find its matched added entry old_cmd = (rc.old_text or "").split()[0].lower() for ai, ac in enumerate(added): new_cmd = (ac.new_text or "").split()[0].lower() if ac.new_text else "" if ai in matched_added and old_cmd == new_cmd: final_changes.append( DirectiveChange( change_type="modified", old_text=rc.old_text, new_text=ac.new_text, ) ) break return final_changes def _diff_nets(schema_a: Schematic, schema_b: Schematic) -> tuple[list[str], list[str]]: """Compare net flags between two schematics. Returns: (nets_added, nets_removed) """ names_a = {f.name for f in schema_a.flags} names_b = {f.name for f in schema_b.flags} added = sorted(names_b - names_a) removed = sorted(names_a - names_b) return added, removed def _diff_wires(schema_a: Schematic, schema_b: Schematic) -> tuple[int, int]: """Compare wires between two schematics using set operations. Returns: (wires_added, wires_removed) """ set_a = _wire_set(schema_a) set_b = _wire_set(schema_b) added = len(set_b - set_a) removed = len(set_a - set_b) return added, removed def diff_schematics( path_a: Path | str, path_b: Path | str, ) -> SchematicDiff: """Compare two schematics and return differences. Args: path_a: Path to the "before" schematic path_b: Path to the "after" schematic Returns: SchematicDiff with all changes """ schema_a = parse_schematic(path_a) schema_b = parse_schematic(path_b) component_changes = _diff_components(schema_a, schema_b) directive_changes = _diff_directives(schema_a, schema_b) nets_added, nets_removed = _diff_nets(schema_a, schema_b) wires_added, wires_removed = _diff_wires(schema_a, schema_b) return SchematicDiff( component_changes=component_changes, directive_changes=directive_changes, nets_added=nets_added, nets_removed=nets_removed, wires_added=wires_added, wires_removed=wires_removed, )