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

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,
)