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
+369
@@ -0,0 +1,369 @@
|
||||
"""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)))
|
||||
Reference in new issue
Block a user