"""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` 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` 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` 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` 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_` 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)))