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