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
418 lines
14 KiB
Python
418 lines
14 KiB
Python
"""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,
|
|
)
|