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

425 lines
15 KiB
Python

"""Design Rule Checks for LTspice schematics."""
from collections import defaultdict
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from .schematic import Schematic, parse_schematic
class Severity(Enum):
ERROR = "error" # Will likely cause simulation failure
WARNING = "warning" # May cause unexpected results
INFO = "info" # Suggestion for improvement
@dataclass
class DRCViolation:
"""A single design rule violation."""
rule: str # Short rule identifier
severity: Severity
message: str # Human-readable description
component: str | None = None # Related component name
location: tuple[int, int] | None = None # (x, y) if applicable
@dataclass
class DRCResult:
"""Results of a design rule check."""
violations: list[DRCViolation] = field(default_factory=list)
checks_run: int = 0
@property
def errors(self) -> list[DRCViolation]:
return [v for v in self.violations if v.severity == Severity.ERROR]
@property
def warnings(self) -> list[DRCViolation]:
return [v for v in self.violations if v.severity == Severity.WARNING]
@property
def passed(self) -> bool:
return len(self.errors) == 0
def summary(self) -> str:
"""Human-readable summary."""
total = len(self.violations)
err_count = len(self.errors)
warn_count = len(self.warnings)
info_count = total - err_count - warn_count
if total == 0:
return f"DRC passed: {self.checks_run} checks run, no violations found."
parts = []
if err_count:
parts.append(f"{err_count} error{'s' if err_count != 1 else ''}")
if warn_count:
parts.append(f"{warn_count} warning{'s' if warn_count != 1 else ''}")
if info_count:
parts.append(f"{info_count} info")
status = "FAILED" if err_count else "passed with warnings"
return f"DRC {status}: {self.checks_run} checks run, {', '.join(parts)}."
def to_dict(self) -> dict:
"""Convert to JSON-serializable dict."""
return {
"passed": self.passed,
"checks_run": self.checks_run,
"summary": self.summary(),
"error_count": len(self.errors),
"warning_count": len(self.warnings),
"violations": [
{
"rule": v.rule,
"severity": v.severity.value,
"message": v.message,
"component": v.component,
"location": list(v.location) if v.location else None,
}
for v in self.violations
],
}
def run_drc(schematic_path: Path | str) -> DRCResult:
"""Run all design rule checks on a schematic.
Args:
schematic_path: Path to .asc file
Returns:
DRCResult with all violations found
"""
sch = parse_schematic(schematic_path)
result = DRCResult()
_check_ground(sch, result)
_check_floating_nodes(sch, result)
_check_simulation_directive(sch, result)
_check_voltage_source_loops(sch, result)
_check_component_values(sch, result)
_check_duplicate_names(sch, result)
_check_unconnected_components(sch, result)
return result
def _check_ground(sch: Schematic, result: DRCResult) -> None:
"""Check that at least one ground (node '0') exists."""
result.checks_run += 1
has_ground = any(f.name == "0" for f in sch.flags)
if not has_ground:
result.violations.append(
DRCViolation(
rule="NO_GROUND",
severity=Severity.ERROR,
message=(
"No ground node found. Every circuit needs at least one ground (0) connection."
),
)
)
def _check_floating_nodes(sch: Schematic, result: DRCResult) -> None:
"""Check for nodes with only one wire connection (likely floating).
Build a connectivity map from wires and flags.
A node is a unique (x,y) point where wires meet or flags are placed.
If a point only has one wire endpoint and no flag, it might be floating.
This is approximate -- we cannot fully determine connectivity without
knowing component pin locations, but we can flag obvious cases.
"""
result.checks_run += 1
# Count how many wire endpoints touch each point
point_count: dict[tuple[int, int], int] = defaultdict(int)
for wire in sch.wires:
point_count[(wire.x1, wire.y1)] += 1
point_count[(wire.x2, wire.y2)] += 1
# Flags also represent connections at a point
flag_points = {(f.x, f.y) for f in sch.flags}
# Component positions also represent connection points (approximately)
component_points = {(c.x, c.y) for c in sch.components}
for point, count in point_count.items():
if count == 1 and point not in flag_points and point not in component_points:
result.violations.append(
DRCViolation(
rule="FLOATING_NODE",
severity=Severity.WARNING,
message=(
f"Possible floating node at ({point[0]}, {point[1]}). "
f"Wire endpoint has only one connection."
),
location=point,
)
)
def _check_simulation_directive(sch: Schematic, result: DRCResult) -> None:
"""Check that at least one simulation directive exists."""
result.checks_run += 1
directives = sch.get_spice_directives()
sim_types = [".tran", ".ac", ".dc", ".op", ".noise", ".tf"]
has_sim = any(any(d.lower().startswith(s) for s in sim_types) for d in directives)
if not has_sim:
result.violations.append(
DRCViolation(
rule="NO_SIM_DIRECTIVE",
severity=Severity.ERROR,
message=("No simulation directive found. Add .tran, .ac, .dc, .op, etc."),
)
)
def _check_voltage_source_loops(sch: Schematic, result: DRCResult) -> None:
"""Check for voltage sources connected in parallel (short circuit).
This is a simplified check -- look for voltage sources that share both
pin nodes via wire connectivity. Two voltage sources whose positions
connect to the same pair of nets would create a loop or conflict.
"""
result.checks_run += 1
# Build a union-find structure from wire connectivity so we can
# determine which points belong to the same electrical net.
parent: dict[tuple[int, int], tuple[int, int]] = {}
def find(p: tuple[int, int]) -> tuple[int, int]:
if p not in parent:
parent[p] = p
while parent[p] != p:
parent[p] = parent[parent[p]]
p = parent[p]
return p
def union(a: tuple[int, int], b: tuple[int, int]) -> None:
ra, rb = find(a), find(b)
if ra != rb:
parent[ra] = rb
# Each wire merges its two endpoints into the same net
for wire in sch.wires:
union((wire.x1, wire.y1), (wire.x2, wire.y2))
# Also merge flag locations (named nets connect distant points with
# the same name)
flag_groups: dict[str, list[tuple[int, int]]] = defaultdict(list)
for flag in sch.flags:
flag_groups[flag.name].append((flag.x, flag.y))
for pts in flag_groups.values():
for pt in pts[1:]:
union(pts[0], pt)
# Find voltage sources and approximate their pin positions.
# LTspice voltage sources have pins at the component origin and
# offset along the component axis. Standard pin spacing is 64 units
# vertically for a non-rotated voltage source (pin+ at top, pin- at
# bottom relative to the symbol origin).
voltage_sources = [c for c in sch.components if "voltage" in c.symbol.lower()]
if len(voltage_sources) < 2:
return
# Estimate pin positions per voltage source based on rotation.
# Default (R0): positive pin at (x, y-16), negative at (x, y+16)
# We use a coarse offset; the exact value depends on the symbol but
# 16 is a common half-pin-spacing in LTspice grid units.
pin_offset = 16
def _pin_positions(comp):
"""Return approximate (positive_pin, negative_pin) coordinates."""
x, y = comp.x, comp.y
rot = comp.rotation
if rot == 0:
return (x, y - pin_offset), (x, y + pin_offset)
elif rot == 90:
return (x + pin_offset, y), (x - pin_offset, y)
elif rot == 180:
return (x, y + pin_offset), (x, y - pin_offset)
elif rot == 270:
return (x - pin_offset, y), (x + pin_offset, y)
return (x, y - pin_offset), (x, y + pin_offset)
def _nearest_net(pin: tuple[int, int]) -> tuple[int, int]:
"""Find the nearest wire/flag point to a pin and return its net root.
If the pin is directly on a known point, use it. Otherwise search
within a small radius for the closest connected point.
"""
if pin in parent:
return find(pin)
# Search nearby points (LTspice grid snap is typically 16 units)
best = None
best_dist = float("inf")
for pt in parent:
dx = pin[0] - pt[0]
dy = pin[1] - pt[1]
dist = dx * dx + dy * dy
if dist < best_dist:
best_dist = dist
best = pt
if best is not None and best_dist <= 32 * 32:
return find(best)
return pin # isolated -- return pin itself as its own net
# For each pair of voltage sources, check if they share both nets
for i in range(len(voltage_sources)):
pin_a_pos, pin_a_neg = _pin_positions(voltage_sources[i])
net_a_pos = _nearest_net(pin_a_pos)
net_a_neg = _nearest_net(pin_a_neg)
for j in range(i + 1, len(voltage_sources)):
pin_b_pos, pin_b_neg = _pin_positions(voltage_sources[j])
net_b_pos = _nearest_net(pin_b_pos)
net_b_neg = _nearest_net(pin_b_neg)
# Parallel if both nets match (in either polarity)
parallel = (net_a_pos == net_b_pos and net_a_neg == net_b_neg) or (
net_a_pos == net_b_neg and net_a_neg == net_b_pos
)
if parallel:
name_i = voltage_sources[i].name
name_j = voltage_sources[j].name
result.violations.append(
DRCViolation(
rule="VSOURCE_LOOP",
severity=Severity.ERROR,
message=(
f"Voltage sources '{name_i}' and '{name_j}' "
f"appear to be connected in parallel, which "
f"creates a short circuit / voltage conflict."
),
component=name_i,
)
)
def _check_component_values(sch: Schematic, result: DRCResult) -> None:
"""Check that components have values where expected.
Resistors, capacitors, inductors should have values.
Voltage/current sources should have values.
Skip if the value looks like a parameter expression (e.g., "{R1}").
"""
result.checks_run += 1
# Map symbol substrings to human-readable type names
value_required = {
"res": "Resistor",
"cap": "Capacitor",
"ind": "Inductor",
"voltage": "Voltage source",
"current": "Current source",
}
for comp in sch.components:
symbol_lower = comp.symbol.lower()
matched_type = None
for pattern, label in value_required.items():
if pattern in symbol_lower:
matched_type = label
break
if matched_type is None:
continue
val = comp.value
if not val or not val.strip():
result.violations.append(
DRCViolation(
rule="MISSING_VALUE",
severity=Severity.WARNING,
message=(f"{matched_type} '{comp.name}' has no value set."),
component=comp.name,
location=(comp.x, comp.y),
)
)
elif val.strip().startswith("{") and val.strip().endswith("}"):
# Parameter expression -- valid, skip
pass
def _check_duplicate_names(sch: Schematic, result: DRCResult) -> None:
"""Check for duplicate component instance names."""
result.checks_run += 1
seen: dict[str, bool] = {}
for comp in sch.components:
if not comp.name:
continue
if comp.name in seen:
result.violations.append(
DRCViolation(
rule="DUPLICATE_NAME",
severity=Severity.ERROR,
message=f"Duplicate component name '{comp.name}'.",
component=comp.name,
location=(comp.x, comp.y),
)
)
else:
seen[comp.name] = True
def _check_unconnected_components(sch: Schematic, result: DRCResult) -> None:
"""Check for components that don't seem to be connected to anything.
A component at (x, y) should have wire endpoints near its pins.
This is approximate without knowing exact pin positions, so we check
whether any wire endpoint falls within a reasonable distance (16
units -- one LTspice grid step) of the component's origin.
"""
result.checks_run += 1
proximity = 16 # LTspice grid spacing
# Collect all wire endpoints into a set for fast lookup
wire_points: set[tuple[int, int]] = set()
for wire in sch.wires:
wire_points.add((wire.x1, wire.y1))
wire_points.add((wire.x2, wire.y2))
# Also include flag positions (flags connect to nets)
flag_points: set[tuple[int, int]] = set()
for flag in sch.flags:
flag_points.add((flag.x, flag.y))
all_connection_points = wire_points | flag_points
for comp in sch.components:
if not comp.name:
continue
# Check if any connection point is within proximity of the
# component origin. We scan a small bounding box rather than
# iterating all points.
connected = False
for pt in all_connection_points:
dx = abs(pt[0] - comp.x)
dy = abs(pt[1] - comp.y)
if dx <= proximity and dy <= proximity:
connected = True
break
if not connected:
result.violations.append(
DRCViolation(
rule="UNCONNECTED_COMPONENT",
severity=Severity.WARNING,
message=(
f"Component '{comp.name}' ({comp.symbol}) at "
f"({comp.x}, {comp.y}) has no nearby wire "
f"connections."
),
component=comp.name,
location=(comp.x, comp.y),
)
)