"""Visaton parts catalogue, as shipped with Boxsim. `Weichenbauteile/Visatonteile.txt` is tab separated text: \t\t[\t] 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"))