"""Index of the KiCad Visaton symbol library, and the resolver. The library's own value strings are irregular: `MKP 1.0μF` sits next to `MKP 10μF`, and `SP-Spule 1.0mH (0.6)` next to `LR-Spule 10mH`. Formatting a number into a name would therefore break on some series, so the library is parsed into `(series, value, gauge)` triples and matched numerically instead. """ from __future__ import annotations import os import re from dataclasses import dataclass from pathlib import Path from .catalog import DEFAULT_SERIES, SERIES_TO_SYMBOL, Catalog from .model import Part, PartType #: Symbols that carry no value and are addressed by name. AMP_SYMBOL = "Amplifier" DRIVER_SYMBOL = "Speaker" #: Unit suffix per component type as the library writes it. UNIT_SUFFIX = { PartType.RESISTOR: "Ω", PartType.CAPACITOR: "μF", PartType.INDUCTOR: "mH", } #: Scale from SI to the unit the library names use. NAME_SCALE = { PartType.RESISTOR: 1.0, PartType.CAPACITOR: 1e6, PartType.INDUCTOR: 1e3, } _SP_RE = re.compile(r"^SP-Spule\s+([\d.]+)mH\s+\(([\d.]+)\)$") _PLAIN_RE = re.compile(r"^(.*?)\s+([\d.]+)(mH|μF|Ω)$") _SYMBOL_RE = re.compile(r'^\s*\(symbol\s+"([^"]+)"', re.MULTILINE) _UNIT_SUFFIX_RE = re.compile(r"_\d+_\d+$") _EXTENDS_RE = re.compile(r'\(extends\s+"([^"]+)"\)') _LIB_VERSION_RE = re.compile(r"\(version\s+(\d+)\)") #: Oldest symbol library format this tool will read. KiCad 6 era libraries use #: `(id N)` properties and bare `hide` tokens, which cannot be embedded into a #: current schematic verbatim. `kicad-cli sym upgrade` converts them. MIN_LIB_VERSION = 20211014 class SymbolError(ValueError): pass @dataclass(frozen=True) class LibrarySymbol: name: str series: str | None # library-side series, e.g. "SP-Spule" value: float | None # SI gauge: str | None # "0.6" etc, SP inductors only @dataclass class Resolution: """The outcome of resolving one Boxsim part to a library symbol.""" part: Part symbol: str value_text: str series: str | None = None inferred_series: bool = False dcr_note: str | None = None @property def notes(self) -> list[str]: out = [] if self.inferred_series: out.append(f"series inferred as {self.series!r} (Boxsim left _bem empty)") if self.dcr_note: out.append(self.dcr_note) return out def _parse_symbol_name(name: str) -> LibrarySymbol: m = _SP_RE.match(name) if m: return LibrarySymbol(name, "SP-Spule", float(m.group(1)) * 1e-3, m.group(2)) m = _PLAIN_RE.match(name) if m: series, number, unit = m.group(1), float(m.group(2)), m.group(3) scale = {"mH": 1e-3, "μF": 1e-6, "Ω": 1.0}[unit] return LibrarySymbol(name, series, number * scale, None) return LibrarySymbol(name, None, None, None) class SymbolLibrary: """Parsed `Visaton.kicad_sym`.""" def __init__(self, path: Path, names: list[str], source: str): self.path = path self.source = source self.symbols = [_parse_symbol_name(n) for n in names] self._by_name = {s.name: s for s in self.symbols} self._index: dict[tuple[str, str | None], dict[int, LibrarySymbol]] = {} for s in self.symbols: if s.series is None or s.value is None: continue self._index.setdefault((s.series, s.gauge), {})[round(s.value, 12)] = s def __len__(self) -> int: return len(self.symbols) def __contains__(self, name: str) -> bool: return name in self._by_name @property def names(self) -> list[str]: return [s.name for s in self.symbols] def lookup( self, series: str, value: float, gauge: str | None = None, rtol: float = 1e-6 ) -> LibrarySymbol | None: bucket = self._index.get((series, gauge)) if not bucket: return None exact = bucket.get(round(value, 12)) if exact is not None: return exact best, best_err = None, None for v, sym in bucket.items(): if v == 0: continue err = abs(v - value) / v if err <= rtol and (best_err is None or err < best_err): best, best_err = sym, err return best def raw_definition(self, name: str) -> str: """The s-expression block for one symbol, exactly as stored.""" needle = f'(symbol "{name}"' start = self.source.find(needle) if start < 0: raise SymbolError(f"symbol {name!r} not found in {self.path}") # Walk balanced parentheses, ignoring those inside strings. Symbol names # such as `SP-Spule 0.82mH (0.6)` contain parentheses of their own. depth, i, in_str, escaped = 0, start, False, False while i < len(self.source): ch = self.source[i] if in_str: if escaped: escaped = False elif ch == "\\": escaped = True elif ch == '"': in_str = False elif ch == '"': in_str = True elif ch == "(": depth += 1 elif ch == ")": depth -= 1 if depth == 0: return self.source[start : i + 1] i += 1 raise SymbolError(f"unbalanced s-expression for symbol {name!r}") def base_of(self, name: str) -> str | None: """The symbol this one `extends`, if any.""" m = _EXTENDS_RE.search(self.raw_definition(name)) return m.group(1) if m else None def geometry_source(self, name: str) -> str: """The definition that actually carries pins and graphics.""" seen = set() current = name while True: base = self.base_of(current) if base is None: return self.raw_definition(current) if base in seen: raise SymbolError(f"circular extends chain at {base!r}") seen.add(base) current = base def definition(self, name: str) -> str: """A self-contained symbol block suitable for a schematic's lib_symbols. A derived symbol is flattened: its own properties are kept and the base symbol's graphical sub-units are copied in under the derived name. KiCad never writes `extends` into a schematic cache, so neither do we. """ own = self.raw_definition(name) base = self.base_of(name) if base is None: return own body = _EXTENDS_RE.sub("", own, count=1) closing = body.rstrip() if not closing.endswith(")"): raise SymbolError(f"malformed symbol block for {name!r}") body = closing[:-1].rstrip() base_block = self.geometry_source(base) units = [] for unit_name in _SYMBOL_RE.findall(base_block): if not _UNIT_SUFFIX_RE.search(unit_name): continue suffix = unit_name[len(base) :] if unit_name.startswith(base) else "" unit_block = self._sub_block(base_block, unit_name) units.append( unit_block.replace( f'(symbol "{unit_name}"', f'(symbol "{name}{suffix}"', 1 ) ) if not units: raise SymbolError(f"base symbol {base!r} of {name!r} has no graphics") indented = "\n".join("\t\t" + u.replace("\n", "\n\t\t") for u in units) return f"{body}\n{indented}\n\t)" @staticmethod def _sub_block(text: str, name: str) -> str: needle = f'(symbol "{name}"' start = text.find(needle) if start < 0: raise SymbolError(f"sub-symbol {name!r} not found") depth, i, in_str, escaped = 0, start, False, False while i < len(text): ch = text[i] if in_str: if escaped: escaped = False elif ch == "\\": escaped = True elif ch == '"': in_str = False elif ch == '"': in_str = True elif ch == "(": depth += 1 elif ch == ")": depth -= 1 if depth == 0: return text[start : i + 1] i += 1 raise SymbolError(f"unbalanced sub-symbol {name!r}") def default_path() -> Path: env = os.environ.get("BOXSIM_KICAD_LIB") if env: p = Path(env) return p if p.suffix == ".kicad_sym" else p / "symbols" / "Visaton.kicad_sym" bundled = Path(__file__).resolve().parent / "data" / "Visaton.kicad_sym" if bundled.exists(): return bundled return Path("/home/magdev/workspaces/other/kicad-visaton-lib/symbols/Visaton.kicad_sym") def load(path: str | os.PathLike[str] | None = None) -> SymbolLibrary: p = Path(path) if path else default_path() if not p.exists(): raise SymbolError( f"KiCad symbol library not found at {p}; " "set BOXSIM_KICAD_LIB to the kicad-visaton-lib checkout" ) source = p.read_text(encoding="utf-8") m = _LIB_VERSION_RE.search(source[:400]) if m is None: raise SymbolError(f"{p} has no (version ...) token; is it a symbol library?") version = int(m.group(1)) if version < MIN_LIB_VERSION: raise SymbolError( f"{p} is in the legacy format (version {version}, need " f"{MIN_LIB_VERSION} or newer). Convert it with:\n" f" kicad-cli sym upgrade --force {p}" ) names = [n for n in _SYMBOL_RE.findall(source) if not _UNIT_SUFFIX_RE.search(n)] if not names: raise SymbolError(f"no symbols found in {p}") return SymbolLibrary(p, names, source) def cross_check(catalog: Catalog, library: SymbolLibrary) -> list[str]: """Every catalogue part must exist in the library. Returns extra symbols.""" missing = [] matched = set() for part in catalog.parts: sym = library.lookup(part.symbol_series, part.value, part.gauge) if sym is None: missing.append(f"{part.series} {part.raw_value:g}") else: matched.add(sym.name) if missing: raise SymbolError( f"{len(missing)} catalogue part(s) have no library symbol: " + ", ".join(missing[:10]) ) return sorted( s.name for s in library.symbols if s.name not in matched and s.name not in (AMP_SYMBOL, DRIVER_SYMBOL) ) def _format_value(typ: PartType, value: float) -> str: scaled = value * NAME_SCALE[typ] text = f"{scaled:g}" return f"{text}{UNIT_SUFFIX[typ]}" class Resolver: """Maps Boxsim parts onto library symbols using the Visaton catalogue.""" def __init__(self, catalog: Catalog, library: SymbolLibrary): self.catalog = catalog self.library = library def _series_for(self, part: Part, siblings: list[Part]) -> tuple[str, bool]: """Catalogue series for a part, inferring one when `_bem` is empty.""" if part.label: if part.label in SERIES_TO_SYMBOL: return part.label, False # Tolerate stray whitespace or case differences before giving up. for known in SERIES_TO_SYMBOL: if known.lower() == part.label.lower(): return known, False raise SymbolError( f"Part{part.slot}: series {part.label!r} is not in the Visaton " f"catalogue (known: {', '.join(sorted(SERIES_TO_SYMBOL))})" ) # Empty _bem: prefer a sibling of the same type and value. for other in siblings: if ( other is not part and other.typ == part.typ and other.label and other.value is not None and part.value is not None and abs(other.value - part.value) <= abs(part.value) * 1e-9 ): return other.label, True fallback = DEFAULT_SERIES.get(part.typ) if fallback is None: raise SymbolError( f"Part{part.slot}: no series given and no sibling or default for " f"{part.typ.name.lower()}" ) return fallback, True def resolve(self, part: Part, siblings: list[Part] | None = None) -> Resolution: siblings = siblings or [] if part.typ == PartType.AMP_INPUT: return Resolution(part=part, symbol=AMP_SYMBOL, value_text="Amp") if part.typ == PartType.DRIVER: return Resolution(part=part, symbol=DRIVER_SYMBOL, value_text="") if part.value is None: raise SymbolError(f"Part{part.slot}: {part.typ.name.lower()} has no value") series, inferred = self._series_for(part, siblings) entry = self.catalog.find(series, part.value) if entry is None: near = self.catalog.nearest(series, part.value) offered = ", ".join(f"{p.raw_value:g}" for p in near) or "none" raise SymbolError( f"Part{part.slot}: {series} " f"{_format_value(part.typ, part.value)} is not stocked by Visaton; " f"nearest available: {offered}" ) sym = self.library.lookup(entry.symbol_series, entry.value, entry.gauge) if sym is None: raise SymbolError( f"Part{part.slot}: catalogue part {series} {entry.raw_value:g} " f"has no symbol in {self.library.path.name}" ) dcr_note = None if ( part.dcr is not None and entry.dcr is not None and abs(part.dcr - entry.dcr) > 1e-9 ): dcr_note = ( f"DCR {part.dcr:g} Ohm differs from catalogue {entry.dcr:g} Ohm " "(edited in Boxsim)" ) return Resolution( part=part, symbol=sym.name, value_text=_format_value(part.typ, part.value), series=series, inferred_series=inferred, dcr_note=dcr_note, ) def resolve_all(self, parts: list[Part]) -> list[Resolution]: return [self.resolve(p, parts) for p in parts]