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>
370 lines
11 KiB
Python
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)))
|