Files
magdevandClaude Fable 5 4737a5bae8 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>
2026-09-29 11:10:43 +02:00

370 lines
11 KiB
Python

"""Typed model of a Boxsim project, including the crossover netlist.
The crossover editor is an 11 x 18 grid of nodes. A part occupies a slot
between two adjacent nodes, and the slot number encodes both position and
orientation:
1..180 horizontal, 10 per row, joins node(r,c) to node(r,c+1)
181..367 vertical, 11 per row, joins node(r,c) to node(r+1,c)
368..375 free link, endpoints given explicitly by _nod1 and _nod2
Nodes are numbered row-major and zero based: node = row * 11 + col.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import IntEnum
from . import ini
from .ini import IniDocument
COLS = 11
ROWS = 18
H_SLOTS = (COLS - 1) * ROWS # 180
V_BASE = H_SLOTS + 1 # 181
V_SLOTS = COLS * (ROWS - 1) # 187
LINK_BASE = V_BASE + V_SLOTS # 368
LINK_SLOTS = 8
TOTAL_SLOTS = LINK_BASE + LINK_SLOTS - 1 # 375
class PartType(IntEnum):
EMPTY = 0
WIRE = 1
RESISTOR = 2
CAPACITOR = 3
INDUCTOR = 4
DRIVER = 5
AMP_INPUT = 6
#: SI unit per component type, for reporting.
UNIT = {
PartType.RESISTOR: "Ohm",
PartType.CAPACITOR: "F",
PartType.INDUCTOR: "H",
}
class Orientation(IntEnum):
HORIZONTAL = 0
VERTICAL = 1
LINK = 2
class ModelError(ValueError):
pass
def slot_geometry(slot: int) -> tuple[Orientation, int, int]:
"""Return (orientation, row, col) for a grid slot. Links have no cell."""
if 1 <= slot <= H_SLOTS:
idx = slot - 1
return Orientation.HORIZONTAL, idx // (COLS - 1), idx % (COLS - 1)
if V_BASE <= slot < V_BASE + V_SLOTS:
idx = slot - V_BASE
return Orientation.VERTICAL, idx // COLS, idx % COLS
if LINK_BASE <= slot <= TOTAL_SLOTS:
return Orientation.LINK, -1, -1
raise ModelError(f"slot {slot} is outside 1..{TOTAL_SLOTS}")
def node_at(row: int, col: int) -> int:
return row * COLS + col
@dataclass
class Part:
"""One populated slot of the crossover grid."""
slot: int
typ: PartType
orientation: Orientation
row: int
col: int
node_a: int
node_b: int
value: float | None = None
label: str = "" # Boxsim `_bem`, the Visaton series name
dcr: float | None = None # `_Rvalue`, inductor series resistance in Ohm
inverted: bool = False # `_verpolt`
chassis_index: int | None = None # `_chassisnr`
optimise: bool = False # `_bOpti`
@property
def is_component(self) -> bool:
"""True for anything that becomes a schematic symbol."""
return self.typ not in (PartType.EMPTY, PartType.WIRE)
def __str__(self) -> str:
name = self.typ.name.lower()
if self.value is not None:
return f"Part{self.slot} {name} {self.value:g}"
return f"Part{self.slot} {name}"
@dataclass
class Chassis:
"""A driver, from a `#Chassis<n>` section."""
index: int
name: str
comment: str = ""
re: float | None = None
fs: float | None = None
vas: float | None = None
qes: float | None = None
qms: float | None = None
@dataclass
class Enclosure:
"""A `#Einbau<n>` section. Type 1 is closed, 2+ are vented variants."""
index: int
typ: int
vb: float | None = None # net volume in m^3
fb: float | None = None # tuning frequency in Hz
@dataclass
class Baffle:
"""A `#Schallwand<n>` section. All lengths in metres."""
index: int
width: float | None = None
height: float | None = None
shared: bool = False
@dataclass
class Netlist:
"""Electrical netlist: components joined through wires and links."""
nets: dict[int, int] # grid node -> canonical net id
components: list[Part]
def net_of(self, node: int) -> int:
return self.nets[node]
@property
def net_count(self) -> int:
return len({self.nets[n] for p in self.components for n in (p.node_a, p.node_b)})
def dangling(self) -> list[int]:
"""Net ids with fewer than two component terminals attached."""
counts: dict[int, int] = {}
for p in self.components:
for node in (p.node_a, p.node_b):
net = self.nets[node]
counts[net] = counts.get(net, 0) + 1
return sorted(net for net, n in counts.items() if n < 2)
@dataclass
class Crossover:
"""One `#Weiche<n>` section: the populated grid."""
index: int
parts: list[Part] = field(default_factory=list)
@property
def components(self) -> list[Part]:
return [p for p in self.parts if p.is_component]
@property
def wires(self) -> list[Part]:
return [p for p in self.parts if p.typ == PartType.WIRE]
def build_netlist(self) -> Netlist:
"""Union-find over wires and free links to collapse nodes into nets."""
parent: dict[int, int] = {}
def find(x: int) -> int:
parent.setdefault(x, x)
root = x
while parent[root] != root:
root = parent[root]
while parent[x] != root: # path compression
parent[x], x = root, parent[x]
return root
def union(a: int, b: int) -> None:
ra, rb = find(a), find(b)
if ra != rb:
parent[ra] = rb
for p in self.parts:
find(p.node_a)
find(p.node_b)
if p.typ == PartType.WIRE:
union(p.node_a, p.node_b)
nets = {node: find(node) for node in list(parent)}
return Netlist(nets=nets, components=self.components)
def occupied_cells(self) -> list[tuple[int, int]]:
return [
(p.row, p.col) for p in self.parts if p.orientation != Orientation.LINK
]
def bounding_box(self) -> tuple[int, int, int, int]:
"""(min_row, min_col, max_row, max_col) over occupied cells."""
cells = self.occupied_cells()
if not cells:
raise ModelError("crossover has no populated slots")
rows = [r for r, _ in cells]
cols = [c for _, c in cells]
return min(rows), min(cols), max(rows), max(cols)
@dataclass
class Project:
"""A whole Boxsim project."""
version: int = 0
amp_count: int = 1
chassis_count: int = 0
comments: list[str] = field(default_factory=list)
crossovers: list[Crossover] = field(default_factory=list)
chassis: list[Chassis] = field(default_factory=list)
enclosures: list[Enclosure] = field(default_factory=list)
baffles: list[Baffle] = field(default_factory=list)
def chassis_name(self, index: int) -> str:
for c in self.chassis:
if c.index == index:
return c.name
return f"Chassis{index + 1}"
def _opt_number(section: dict, key: str) -> float | None:
raw = section.get(key)
if raw is None or not raw.strip():
return None
try:
return ini.number(raw)
except ValueError:
return None
def _parse_crossover(section: dict, index: int) -> Crossover:
"""Collect `_Part<N>_<field>` keys into Part objects."""
fields: dict[int, dict[str, str]] = {}
for key, value in section.items():
if not key.startswith("_Part"):
continue
rest = key[len("_Part") :]
slot_text, sep, attr = rest.partition("_")
if not sep or not slot_text.isdigit():
continue
fields.setdefault(int(slot_text), {})[attr] = value
parts: list[Part] = []
for slot in sorted(fields):
attrs = fields[slot]
typ_raw = attrs.get("typ")
if typ_raw is None:
continue
typ = PartType(ini.integer(typ_raw))
if typ == PartType.EMPTY:
continue
orientation, row, col = slot_geometry(slot)
if orientation == Orientation.LINK:
node_a = ini.integer(attrs.get("nod1", "0"))
node_b = ini.integer(attrs.get("nod2", "0"))
if node_a == node_b:
continue # unused link slot
elif orientation == Orientation.HORIZONTAL:
node_a, node_b = node_at(row, col), node_at(row, col + 1)
else:
node_a, node_b = node_at(row, col), node_at(row + 1, col)
parts.append(
Part(
slot=slot,
typ=typ,
orientation=orientation,
row=row,
col=col,
node_a=node_a,
node_b=node_b,
value=_opt_number(attrs, "value"),
label=attrs.get("bem", "").strip(),
dcr=_opt_number(attrs, "Rvalue"),
inverted=attrs.get("verpolt", "1").strip().startswith("-"),
chassis_index=(
ini.integer(attrs["chassisnr"]) if "chassisnr" in attrs else None
),
optimise=attrs.get("bOpti", "0").strip() not in ("", "0"),
)
)
return Crossover(index=index, parts=parts)
def from_ini(doc: IniDocument) -> Project:
"""Build a Project from a parsed INI document."""
header = doc.first("Header") or {}
general = doc.require("General")
project = Project(
version=ini.integer(header.get("Version", "0")),
amp_count=ini.integer(general.get("nAmps", "1")),
chassis_count=ini.integer(general.get("nChassis", "0")),
comments=[
general.get(k, "") for k in ("Comment1", "Comment2") if general.get(k, "")
],
)
for n in range(1, project.amp_count + 1):
section = doc.first(f"Weiche{n}")
if section is not None:
project.crossovers.append(_parse_crossover(section, n))
for n in range(1, project.chassis_count + 1):
section = doc.first(f"Chassis{n}")
if section is None:
continue
project.chassis.append(
Chassis(
index=n - 1,
name=section.get("Name", "").strip(),
comment=section.get("Kommentar", "").strip(),
re=_opt_number(section, "Re"),
fs=_opt_number(section, "fs"),
vas=_opt_number(section, "Vas"),
qes=_opt_number(section, "Qes"),
qms=_opt_number(section, "Qms"),
)
)
enc = doc.first(f"Einbau{n}")
if enc is not None:
project.enclosures.append(
Enclosure(
index=n - 1,
typ=ini.integer(enc.get("Typ", "0")),
vb=_opt_number(enc, "Vb"),
fb=_opt_number(enc, "fb"),
)
)
baf = doc.first(f"Schallwand{n}")
if baf is not None:
project.baffles.append(
Baffle(
index=n - 1,
width=_opt_number(baf, "b"),
height=_opt_number(baf, "a"),
shared=baf.get("usegemgeh", "0").strip() not in ("", "0"),
)
)
return project
def load(raw: bytes) -> Project:
"""Decode a `.bpj` container and build the project model."""
from .container import decode_text
return from_ini(ini.parse(decode_text(raw)))