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>
212 lines
6.8 KiB
Python
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"))
|