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>
402 lines
14 KiB
Python
402 lines
14 KiB
Python
"""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]
|