Add boxsim CLI: pure Python codec, netlist extraction, KiCad project generation
Replaces the bpj2ini.py prototype with a packaged tool. The A2F codec is now reimplemented in Python, so neither Boxsim.exe nor a CPU emulator is needed to decode a container; the emulator survives as a development-only oracle that the codec is differentially tested against. Verified: - 134 containers decode byte-identically to Boxsim's own decoder, 270 pre-2.0 files rejected by both implementations (tools/difftest.py --self-test) - Default.BPJ: 22 components, 14 nets, no dangling terminal - 22 of 22 parts resolve to library symbols; one series inferred from a sibling, two DC resistance deviations reported - ERC clean and the netlist KiCad extracts from the generated sheet is an identical partition to the one derived from the Boxsim grid - container build byte-identical to the host build - 32 tests passing, ruff clean Decode time went 69 s -> 14.5 s (binding emulator hooks to trap addresses) -> 1.6 s (pure Python). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
eb81ca61cb
commit
4737a5bae8
28 files changed
+4459
-122
No files matched your search
@@ -0,0 +1,167 @@
|
||||
"""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,
|
||||
)
|
||||
Reference in new issue
Block a user