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>
This commit is contained in:
1 parent
eb81ca61cb
commit
4737a5bae8
28 files changed
+4459
-122
No files matched your search
@@ -0,0 +1,401 @@
|
||||
"""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]
|
||||
Reference in new issue
Block a user