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

212 lines
6.8 KiB
Python

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