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