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
+293
@@ -0,0 +1,293 @@
|
||||
"""Command line interface.
|
||||
|
||||
boxsim decode file.bpj raw INI payload
|
||||
boxsim extract file.bpj structured JSON model
|
||||
boxsim netlist file.bpj crossover netlist
|
||||
boxsim build file.bpj outdir/ a KiCad 10 project
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from . import catalog as catalog_mod
|
||||
from . import container, ini, model
|
||||
from . import project as project_mod
|
||||
from . import symbols as symbols_mod
|
||||
from .model import Project
|
||||
|
||||
|
||||
def _add_library_args(p: argparse.ArgumentParser) -> None:
|
||||
p.add_argument(
|
||||
"--library",
|
||||
help="path to the kicad-visaton-lib checkout or its Visaton.kicad_sym "
|
||||
"(default: $BOXSIM_KICAD_LIB, else the copy bundled with this tool)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--catalog",
|
||||
help="path to Visatonteile.txt (default: $BOXSIM_CATALOG, else the "
|
||||
"copy bundled with this tool)",
|
||||
)
|
||||
|
||||
|
||||
def _read(path: str) -> bytes:
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
raise SystemExit(f"boxsim: {path}: no such file")
|
||||
return p.read_bytes()
|
||||
|
||||
|
||||
def _load_project(path: str) -> tuple[Project, str]:
|
||||
raw = _read(path)
|
||||
doc = ini.parse(container.decode_text(raw))
|
||||
return model.from_ini(doc), Path(path).stem
|
||||
|
||||
|
||||
def cmd_decode(args: argparse.Namespace) -> int:
|
||||
raw = _read(args.file)
|
||||
if args.oracle:
|
||||
from . import _oracle
|
||||
|
||||
payload = _oracle.decode_bpj(raw)
|
||||
else:
|
||||
payload = container.decode(raw)
|
||||
if args.output:
|
||||
Path(args.output).write_bytes(payload)
|
||||
print(f"{args.file}: {len(payload)} bytes -> {args.output}")
|
||||
else:
|
||||
sys.stdout.buffer.write(payload)
|
||||
return 0
|
||||
|
||||
|
||||
def _model_to_dict(project: Project, resolutions=None) -> dict:
|
||||
by_slot = {r.part.slot: r for r in (resolutions or [])}
|
||||
out: dict = {
|
||||
"version": project.version,
|
||||
"amp_count": project.amp_count,
|
||||
"chassis_count": project.chassis_count,
|
||||
"comments": project.comments,
|
||||
"chassis": [
|
||||
{
|
||||
"index": c.index,
|
||||
"name": c.name,
|
||||
"comment": c.comment,
|
||||
"re_ohm": c.re,
|
||||
"fs_hz": c.fs,
|
||||
"vas_m3": c.vas,
|
||||
"qes": c.qes,
|
||||
"qms": c.qms,
|
||||
}
|
||||
for c in project.chassis
|
||||
],
|
||||
"enclosures": [
|
||||
{"index": e.index, "type": e.typ, "vb_m3": e.vb, "fb_hz": e.fb}
|
||||
for e in project.enclosures
|
||||
],
|
||||
"baffles": [
|
||||
{"index": b.index, "width_m": b.width, "height_m": b.height,
|
||||
"shared_enclosure": b.shared}
|
||||
for b in project.baffles
|
||||
],
|
||||
"crossovers": [],
|
||||
}
|
||||
for xo in project.crossovers:
|
||||
netlist = xo.build_netlist()
|
||||
parts = []
|
||||
for p in xo.parts:
|
||||
entry = {
|
||||
"slot": p.slot,
|
||||
"type": p.typ.name.lower(),
|
||||
"orientation": p.orientation.name.lower(),
|
||||
"row": p.row,
|
||||
"col": p.col,
|
||||
"nodes": [p.node_a, p.node_b],
|
||||
"nets": [netlist.net_of(p.node_a), netlist.net_of(p.node_b)],
|
||||
}
|
||||
if p.value is not None:
|
||||
entry["value_si"] = p.value
|
||||
if p.label:
|
||||
entry["series"] = p.label
|
||||
if p.dcr is not None:
|
||||
entry["dcr_ohm"] = p.dcr
|
||||
if p.chassis_index is not None:
|
||||
entry["chassis_index"] = p.chassis_index
|
||||
if p.inverted:
|
||||
entry["inverted"] = True
|
||||
res = by_slot.get(p.slot)
|
||||
if res is not None:
|
||||
entry["symbol"] = res.symbol
|
||||
entry["value_text"] = res.value_text
|
||||
if res.notes:
|
||||
entry["notes"] = res.notes
|
||||
parts.append(entry)
|
||||
out["crossovers"].append(
|
||||
{
|
||||
"index": xo.index,
|
||||
"component_count": len(xo.components),
|
||||
"wire_count": len(xo.wires),
|
||||
"net_count": netlist.net_count,
|
||||
"dangling_nets": netlist.dangling(),
|
||||
"parts": parts,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def cmd_extract(args: argparse.Namespace) -> int:
|
||||
project, _ = _load_project(args.file)
|
||||
resolutions = None
|
||||
if not args.no_symbols:
|
||||
try:
|
||||
cat, lib = project_mod.load_parts(args.catalog, args.library)
|
||||
resolver = symbols_mod.Resolver(cat, lib)
|
||||
resolutions = [
|
||||
r
|
||||
for xo in project.crossovers
|
||||
for r in resolver.resolve_all(xo.components)
|
||||
]
|
||||
except (symbols_mod.SymbolError, catalog_mod.CatalogError) as exc:
|
||||
print(f"boxsim: symbol resolution skipped: {exc}", file=sys.stderr)
|
||||
payload = json.dumps(_model_to_dict(project, resolutions), indent=2, sort_keys=False)
|
||||
if args.output:
|
||||
Path(args.output).write_text(payload + "\n", encoding="utf-8")
|
||||
print(f"{args.file} -> {args.output}")
|
||||
else:
|
||||
print(payload)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_netlist(args: argparse.Namespace) -> int:
|
||||
project, _ = _load_project(args.file)
|
||||
for xo in project.crossovers:
|
||||
netlist = xo.build_netlist()
|
||||
print(f"# crossover {xo.index}: {len(xo.components)} components, "
|
||||
f"{netlist.net_count} nets")
|
||||
groups: dict[int, list[str]] = {}
|
||||
for p in xo.components:
|
||||
label = f"Part{p.slot}({p.typ.name.lower()}"
|
||||
label += f" {p.value:g})" if p.value is not None else ")"
|
||||
for node in (p.node_a, p.node_b):
|
||||
groups.setdefault(netlist.net_of(node), []).append(label)
|
||||
for net in sorted(groups):
|
||||
print(f" net {net:3d}: {', '.join(sorted(groups[net]))}")
|
||||
dangling = netlist.dangling()
|
||||
if dangling:
|
||||
print(f" WARNING dangling nets: {dangling}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_build(args: argparse.Namespace) -> int:
|
||||
project, name = _load_project(args.file)
|
||||
try:
|
||||
cat, lib = project_mod.load_parts(args.catalog, args.library)
|
||||
except (symbols_mod.SymbolError, catalog_mod.CatalogError) as exc:
|
||||
raise SystemExit(f"boxsim: {exc}") from exc
|
||||
|
||||
extras = symbols_mod.cross_check(cat, lib)
|
||||
if extras and args.verbose:
|
||||
print(f"note: library has {len(extras)} symbol(s) Boxsim cannot select: "
|
||||
f"{', '.join(extras)}")
|
||||
|
||||
outdir = Path(args.outdir)
|
||||
try:
|
||||
result = project_mod.build(
|
||||
project, args.name or name, outdir, cat, lib, pitch=args.pitch
|
||||
)
|
||||
except (symbols_mod.SymbolError, NotImplementedError) as exc:
|
||||
raise SystemExit(f"boxsim: {exc}") from exc
|
||||
|
||||
print(f"{result.schematic.name}: {len(result.sheet.placements)} symbols, "
|
||||
f"{len(result.sheet.wires)} wires, {len(result.sheet.junctions)} junctions, "
|
||||
f"paper {result.sheet.paper}")
|
||||
for note in result.notes:
|
||||
print(f" note: {note}")
|
||||
|
||||
if args.verify:
|
||||
from . import verify as verify_mod
|
||||
|
||||
try:
|
||||
gate = verify_mod.run_gate(
|
||||
result.schematic, project.crossovers[0], result.sheet.placements
|
||||
)
|
||||
except verify_mod.VerifyError as exc:
|
||||
raise SystemExit(f"boxsim: {exc}") from exc
|
||||
for m in gate.messages:
|
||||
print(f" {m}")
|
||||
status = "PASS" if gate.ok else "FAIL"
|
||||
print(f" gate {status}: KiCad {gate.kicad_version}, ERC "
|
||||
f"{'clean' if gate.erc_ok else 'failed'}, netlist "
|
||||
f"{'matches' if gate.netlist_matches else 'differs'} "
|
||||
f"({len(gate.reference_nets)} nets)")
|
||||
if not gate.ok:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
ap = argparse.ArgumentParser(
|
||||
prog="boxsim",
|
||||
description="Read Boxsim loudspeaker projects and turn their crossovers "
|
||||
"into KiCad projects.",
|
||||
)
|
||||
sub = ap.add_subparsers(dest="command", required=True)
|
||||
|
||||
p = sub.add_parser("decode", help="write the raw INI payload of a container")
|
||||
p.add_argument("file")
|
||||
p.add_argument("-o", "--output")
|
||||
p.add_argument(
|
||||
"--oracle",
|
||||
action="store_true",
|
||||
help="decode by emulating Boxsim.exe instead (development only)",
|
||||
)
|
||||
p.set_defaults(func=cmd_decode)
|
||||
|
||||
p = sub.add_parser("extract", help="write the structured project model as JSON")
|
||||
p.add_argument("file")
|
||||
p.add_argument("-o", "--output")
|
||||
p.add_argument(
|
||||
"--no-symbols", action="store_true", help="skip KiCad symbol resolution"
|
||||
)
|
||||
_add_library_args(p)
|
||||
p.set_defaults(func=cmd_extract)
|
||||
|
||||
p = sub.add_parser("netlist", help="print the crossover netlist")
|
||||
p.add_argument("file")
|
||||
p.set_defaults(func=cmd_netlist)
|
||||
|
||||
p = sub.add_parser("build", help="write a KiCad project")
|
||||
p.add_argument("file")
|
||||
p.add_argument("outdir")
|
||||
p.add_argument("--name", help="project name (default: input file stem)")
|
||||
p.add_argument(
|
||||
"--pitch", type=float, default=None,
|
||||
help="grid pitch in mm (default 25.4, must exceed 22.86)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--verify", action="store_true",
|
||||
help="run ERC and compare the extracted netlist against Boxsim "
|
||||
"(requires kicad-cli 10)",
|
||||
)
|
||||
p.add_argument("-v", "--verbose", action="store_true")
|
||||
_add_library_args(p)
|
||||
p.set_defaults(func=cmd_build)
|
||||
return ap
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = build_parser()
|
||||
args = ap.parse_args(argv)
|
||||
if getattr(args, "pitch", None) is None and args.command == "build":
|
||||
from .schematic import DEFAULT_PITCH_MM
|
||||
|
||||
args.pitch = DEFAULT_PITCH_MM
|
||||
try:
|
||||
return args.func(args)
|
||||
except (container.ContainerError, model.ModelError) as exc:
|
||||
raise SystemExit(f"boxsim: {exc}") from exc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user