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

93 lines
3.3 KiB
Python

from __future__ import annotations
import pytest
from boxsim import model
from boxsim.model import COLS, LINK_BASE, ROWS, TOTAL_SLOTS, Orientation, PartType
def test_slot_partition_is_exact():
"""The three slot ranges must tile 1..375 with no gap or overlap."""
seen = {}
for slot in range(1, TOTAL_SLOTS + 1):
orientation, row, col = model.slot_geometry(slot)
if orientation == Orientation.LINK:
continue
key = (orientation, row, col)
assert key not in seen, f"slot {slot} collides with {seen[key]}"
seen[key] = slot
horizontal = sum(1 for k in seen if k[0] == Orientation.HORIZONTAL)
vertical = sum(1 for k in seen if k[0] == Orientation.VERTICAL)
assert horizontal == (COLS - 1) * ROWS == 180
assert vertical == COLS * (ROWS - 1) == 187
assert LINK_BASE == 368 and TOTAL_SLOTS == 375
def test_slot_geometry_bounds():
with pytest.raises(model.ModelError):
model.slot_geometry(0)
with pytest.raises(model.ModelError):
model.slot_geometry(TOTAL_SLOTS + 1)
def test_slot_endpoints_are_adjacent_nodes():
orientation, row, col = model.slot_geometry(1)
assert (orientation, row, col) == (Orientation.HORIZONTAL, 0, 0)
orientation, row, col = model.slot_geometry(181)
assert (orientation, row, col) == (Orientation.VERTICAL, 0, 0)
# The last vertical slot must sit on the bottom-but-one row.
orientation, row, col = model.slot_geometry(367)
assert (orientation, row, col) == (Orientation.VERTICAL, ROWS - 2, COLS - 1)
def test_default_project_shape(default_project):
p = default_project
assert p.version == 2000
assert p.amp_count == 1
assert p.chassis_count == 3
assert [c.name for c in p.chassis] == [
"GF 200 - 8 Ohm",
"B 100 - 6 Ohm",
"G 20 SC - 8 Ohm",
]
def test_default_netlist_is_closed(default_project):
"""The published Default project must have no floating terminal."""
xo = default_project.crossovers[0]
netlist = xo.build_netlist()
assert len(xo.components) == 22
assert netlist.net_count == 14
assert netlist.dangling() == []
def test_drivers_and_amp_present(default_project):
xo = default_project.crossovers[0]
drivers = [p for p in xo.components if p.typ == PartType.DRIVER]
amps = [p for p in xo.components if p.typ == PartType.AMP_INPUT]
assert len(amps) == 1
assert sorted(d.chassis_index for d in drivers) == [0, 1, 2]
def test_bounding_box(default_project):
assert default_project.crossovers[0].bounding_box() == (0, 0, 8, 9)
def test_crossover_corner_frequencies(default_project):
"""Sanity-check the reconstruction against loudspeaker physics.
A wrong slot-to-node mapping would still produce a netlist, but not one
whose series/shunt pairs land on sensible crossover points.
"""
import math
xo = default_project.crossovers[0]
by_slot = {p.slot: p for p in xo.components}
# Woofer low-pass: 4.7 mH series with the 15 uF shunt.
fc = 1 / (2 * math.pi * math.sqrt(by_slot[5].value * by_slot[198].value))
assert 550 < fc < 650
# Tweeter high-pass: two series caps into the 0.22 mH shunt.
cser = 1 / (1 / by_slot[65].value + 1 / by_slot[67].value)
fc = 1 / (2 * math.pi * math.sqrt(by_slot[252].value * cser))
assert 5000 < fc < 6500