"""Verify a generated project against the Boxsim data it came from. The gate that matters is not ERC: a schematic can be perfectly well formed and still wired to the wrong cells. Instead the netlist KiCad extracts from the generated sheet is compared against the netlist derived independently from the Boxsim grid. Two derivations that agree is real evidence; ERC alone is not. """ from __future__ import annotations import re import shutil import subprocess from dataclasses import dataclass, field from pathlib import Path from .model import Crossover from .schematic import Placement MIN_KICAD_MAJOR = 10 _NET_RE = re.compile(r"\(net\b(.*?)(?=\n\t\t\(net\b|\n\t\)\s*$)", re.S) _NAME_RE = re.compile(r'\(name\s+"([^"]*)"\)') _NODE_RE = re.compile(r'\(node\s*\(ref\s+"([^"]+)"\)\s*\(pin\s+"([^"]+)"\)', re.S) class VerifyError(RuntimeError): pass @dataclass class GateResult: erc_ok: bool erc_violations: int netlist_matches: bool kicad_version: str reference_nets: list[frozenset[str]] = field(default_factory=list) kicad_nets: list[frozenset[str]] = field(default_factory=list) messages: list[str] = field(default_factory=list) @property def ok(self) -> bool: return self.erc_ok and self.netlist_matches def kicad_cli() -> str: exe = shutil.which("kicad-cli") if exe is None: raise VerifyError( "kicad-cli not found. KiCad 10 is a hard dependency of the " "verification gate; install it or run `boxsim build` without --verify." ) return exe def kicad_version() -> str: out = subprocess.run( [kicad_cli(), "version"], capture_output=True, text=True, check=True ).stdout.strip() major = out.split(".")[0] if not major.isdigit() or int(major) != MIN_KICAD_MAJOR: raise VerifyError( f"kicad-cli reports version {out!r}, but this tool targets " f"KiCad {MIN_KICAD_MAJOR}.x schematics" ) return out def reference_partition( crossover: Crossover, placements: list[Placement] ) -> list[frozenset[str]]: """Group `REFDES.PIN` terminals by the net they sit on in the Boxsim grid.""" netlist = crossover.build_netlist() groups: dict[int, set[str]] = {} for p in placements: for pin, node in p.pin_nodes.items(): net = netlist.net_of(node) groups.setdefault(net, set()).add(f"{p.refdes}.{pin}") return sorted((frozenset(v) for v in groups.values()), key=_net_key) def parse_netlist(text: str) -> list[frozenset[str]]: """Read the `(nets ...)` block of a KiCad sexpr netlist into a partition.""" start = text.find("\t(nets") if start < 0: raise VerifyError("exported netlist has no (nets ...) section") body = text[start:] out = [] for chunk in body.split("\t\t(net")[1:]: terminals = {f"{ref}.{pin}" for ref, pin in _NODE_RE.findall(chunk)} if terminals: out.append(frozenset(terminals)) if not out: raise VerifyError("exported netlist contained no nets") return sorted(out, key=_net_key) def _net_key(net: frozenset[str]) -> tuple: return (len(net), tuple(sorted(net))) def run_gate( schematic: Path, crossover: Crossover, placements: list[Placement], *, workdir: Path | None = None, ) -> GateResult: version = kicad_version() workdir = workdir or schematic.parent erc_report = workdir / f"{schematic.stem}-erc.rpt" netlist_path = workdir / f"{schematic.stem}.net" erc = subprocess.run( [ kicad_cli(), "sch", "erc", "--severity-error", "--exit-code-violations", "-o", str(erc_report), str(schematic), ], capture_output=True, text=True, ) violations = 0 if erc_report.exists(): m = re.search(r"Errors (\d+)", erc_report.read_text(encoding="utf-8")) if m: violations = int(m.group(1)) erc_ok = erc.returncode == 0 and violations == 0 export = subprocess.run( [ kicad_cli(), "sch", "export", "netlist", "--format", "kicadsexpr", "-o", str(netlist_path), str(schematic), ], capture_output=True, text=True, ) if export.returncode != 0: raise VerifyError(f"netlist export failed: {export.stderr.strip()}") reference = reference_partition(crossover, placements) extracted = parse_netlist(netlist_path.read_text(encoding="utf-8")) messages = [] if not erc_ok: messages.append(f"ERC reported {violations} error(s); see {erc_report.name}") matches = reference == extracted if not matches: only_ref = [sorted(n) for n in reference if n not in extracted] only_kicad = [sorted(n) for n in extracted if n not in reference] messages.append( f"netlist mismatch: {len(reference)} reference nets vs " f"{len(extracted)} extracted" ) for net in only_ref[:8]: messages.append(f" only in Boxsim reference: {net}") for net in only_kicad[:8]: messages.append(f" only in KiCad netlist: {net}") return GateResult( erc_ok=erc_ok, erc_violations=violations, netlist_matches=matches, kicad_version=version, reference_nets=reference, kicad_nets=extracted, messages=messages, )