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
425 lines
15 KiB
Python
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),
|
|
)
|
|
)
|