Files

293 lines
9.7 KiB
Python
Raw Permalink Normal View History

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