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,211 @@
|
||||
"""Visaton parts catalogue, as shipped with Boxsim.
|
||||
|
||||
`Weichenbauteile/Visatonteile.txt` is tab separated text:
|
||||
|
||||
<type>\t<series>\t<value>[\t<dcr>]
|
||||
|
||||
Type is R, C or L. Series strings are exactly the `_bem` values Boxsim writes
|
||||
into a project, which makes this the authoritative mapping from project data to
|
||||
a real part. Resistors are in Ohm, capacitors in microfarad, inductors in
|
||||
millihenry with the fourth column giving the DC resistance in Ohm.
|
||||
|
||||
Beware: the file has no trailing newline, so `wc -l` undercounts it by one and
|
||||
the final part is easy to lose.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .model import PartType
|
||||
|
||||
#: Catalogue series name -> library symbol series. Inductor gauges are carried
|
||||
#: separately because the library encodes them as a `(0.6)` style suffix.
|
||||
SERIES_TO_SYMBOL = {
|
||||
"MOX 10W": "MOX-Widerstand",
|
||||
"Keramik 10W": "Keramikwiderstand",
|
||||
"Elko rau": "Elko",
|
||||
"MKT-A": "MKT-A",
|
||||
"MKP": "MKP",
|
||||
"FC-Spule": "FC-Spule",
|
||||
"LR-Spule": "LR-Spule",
|
||||
"KN-Spule": "KN-Spule",
|
||||
"SP 0,6mm": "SP-Spule",
|
||||
"SP 1,0mm": "SP-Spule",
|
||||
"SP 1,3mm": "SP-Spule",
|
||||
}
|
||||
|
||||
#: Wire gauge per SP series, matching the library's symbol name suffix.
|
||||
SERIES_GAUGE = {"SP 0,6mm": "0.6", "SP 1,0mm": "1.0", "SP 1,3mm": "1.3"}
|
||||
|
||||
TYPE_OF = {"R": PartType.RESISTOR, "C": PartType.CAPACITOR, "L": PartType.INDUCTOR}
|
||||
|
||||
#: Catalogue units to SI.
|
||||
SI_SCALE = {
|
||||
PartType.RESISTOR: 1.0,
|
||||
PartType.CAPACITOR: 1e-6,
|
||||
PartType.INDUCTOR: 1e-3,
|
||||
}
|
||||
|
||||
#: Fallback series when a project leaves `_bem` empty and no sibling settles it.
|
||||
#: The widest series for the type, so the substitution is at least stockable.
|
||||
DEFAULT_SERIES = {
|
||||
PartType.CAPACITOR: "MKT-A",
|
||||
PartType.RESISTOR: "Keramik 10W",
|
||||
}
|
||||
|
||||
|
||||
#: Shape of the catalogue shipped with Boxsim 2.1 DE (12.2025), verified
|
||||
#: 2026-09-29. Asserted when bundling the catalogue into an image, because
|
||||
#: `cross_check` only proves the catalogue is a subset of the library: a
|
||||
#: truncated catalogue would pass that and then reject valid projects with
|
||||
#: "not stocked by Visaton". Bump these deliberately when Visaton's list changes.
|
||||
EXPECTED_PART_COUNT = 95
|
||||
EXPECTED_SERIES_COUNT = 11
|
||||
|
||||
|
||||
class CatalogError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CatalogPart:
|
||||
typ: PartType
|
||||
series: str
|
||||
value: float # SI: Ohm, Farad, Henry
|
||||
dcr: float | None # Ohm, inductors only
|
||||
raw_value: float # as printed in the catalogue
|
||||
|
||||
@property
|
||||
def gauge(self) -> str | None:
|
||||
return SERIES_GAUGE.get(self.series)
|
||||
|
||||
@property
|
||||
def symbol_series(self) -> str:
|
||||
return SERIES_TO_SYMBOL[self.series]
|
||||
|
||||
|
||||
class Catalog:
|
||||
"""All parts Boxsim will let a designer pick."""
|
||||
|
||||
def __init__(self, parts: list[CatalogPart]):
|
||||
self.parts = parts
|
||||
self._by_series: dict[str, list[CatalogPart]] = {}
|
||||
for p in parts:
|
||||
self._by_series.setdefault(p.series, []).append(p)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.parts)
|
||||
|
||||
@property
|
||||
def series_names(self) -> list[str]:
|
||||
return sorted(self._by_series)
|
||||
|
||||
def of_series(self, series: str) -> list[CatalogPart]:
|
||||
return self._by_series.get(series, [])
|
||||
|
||||
def find(self, series: str, value: float, rtol: float = 1e-4) -> CatalogPart | None:
|
||||
"""Nearest catalogue part in a series, within a relative tolerance."""
|
||||
best: CatalogPart | None = None
|
||||
best_err = None
|
||||
for p in self.of_series(series):
|
||||
if p.value == 0:
|
||||
continue
|
||||
err = abs(p.value - value) / p.value
|
||||
if err <= rtol and (best_err is None or err < best_err):
|
||||
best, best_err = p, err
|
||||
return best
|
||||
|
||||
def nearest(self, series: str, value: float, count: int = 3) -> list[CatalogPart]:
|
||||
"""Closest stocked values, for error messages."""
|
||||
return sorted(
|
||||
self.of_series(series), key=lambda p: abs(p.value - value)
|
||||
)[:count]
|
||||
|
||||
|
||||
def parse(text: str) -> Catalog:
|
||||
parts: list[CatalogPart] = []
|
||||
lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||
for line in lines[1:]: # first line is the "Visaton-Weichenbauteile" banner
|
||||
fields = line.split("\t")
|
||||
if len(fields) < 3:
|
||||
continue
|
||||
kind = fields[0].strip()
|
||||
series = fields[1].strip()
|
||||
if kind not in TYPE_OF or not series or "---" in series:
|
||||
continue
|
||||
raw = fields[2].strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
raw_value = float(raw.replace(",", "."))
|
||||
except ValueError:
|
||||
continue
|
||||
typ = TYPE_OF[kind]
|
||||
dcr = None
|
||||
if len(fields) > 3 and fields[3].strip():
|
||||
try:
|
||||
dcr = float(fields[3].strip().replace(",", "."))
|
||||
except ValueError:
|
||||
dcr = None
|
||||
if series not in SERIES_TO_SYMBOL:
|
||||
raise CatalogError(
|
||||
f"catalogue series {series!r} has no library mapping; "
|
||||
"add it to SERIES_TO_SYMBOL"
|
||||
)
|
||||
parts.append(
|
||||
CatalogPart(
|
||||
typ=typ,
|
||||
series=series,
|
||||
value=raw_value * SI_SCALE[typ],
|
||||
dcr=dcr,
|
||||
raw_value=raw_value,
|
||||
)
|
||||
)
|
||||
if not parts:
|
||||
raise CatalogError("catalogue contained no parts")
|
||||
return Catalog(parts)
|
||||
|
||||
|
||||
def check_shape(catalog: Catalog) -> None:
|
||||
"""Assert the catalogue is complete, not merely self-consistent."""
|
||||
if len(catalog) != EXPECTED_PART_COUNT or (
|
||||
len(catalog.series_names) != EXPECTED_SERIES_COUNT
|
||||
):
|
||||
raise CatalogError(
|
||||
f"catalogue has {len(catalog)} parts across "
|
||||
f"{len(catalog.series_names)} series, expected "
|
||||
f"{EXPECTED_PART_COUNT} across {EXPECTED_SERIES_COUNT}. "
|
||||
"A short catalogue is usually a truncated file (note it has no "
|
||||
"trailing newline). If Visaton really changed their range, update "
|
||||
"EXPECTED_PART_COUNT and EXPECTED_SERIES_COUNT."
|
||||
)
|
||||
missing = sorted(set(SERIES_TO_SYMBOL) - set(catalog.series_names))
|
||||
if missing:
|
||||
raise CatalogError(f"catalogue is missing series: {', '.join(missing)}")
|
||||
|
||||
|
||||
def default_path() -> Path:
|
||||
"""Where the catalogue lives, overridable for a relocated distribution."""
|
||||
env = os.environ.get("BOXSIM_CATALOG")
|
||||
if env:
|
||||
return Path(env)
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
return (
|
||||
root
|
||||
/ "boxsim_dl"
|
||||
/ "BoxSim 2.1 DE (Stand 12.2025)"
|
||||
/ "Weichenbauteile"
|
||||
/ "Visatonteile.txt"
|
||||
)
|
||||
|
||||
|
||||
def load(path: str | os.PathLike[str] | None = None) -> Catalog:
|
||||
p = Path(path) if path else default_path()
|
||||
if not p.exists():
|
||||
raise CatalogError(
|
||||
f"Visaton catalogue not found at {p}; set BOXSIM_CATALOG to its location"
|
||||
)
|
||||
return parse(p.read_text(encoding="latin1"))
|
||||
Reference in new issue
Block a user