From 47beacc9e1914416f0d2052a7a5d121f397dc5a5 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Fri, 3 Jul 2026 15:46:26 -0600 Subject: [PATCH] macOS: resolve device-model libs (validated on real Apple Silicon) Hardware testing on a native Apple Silicon Mac (LTspice XVII 17.2.4) surfaced that model/subcircuit-bearing circuits failed with 'Unable to find definition of model' / 'Could not open library file': native Mac LTspice in batch mode does NOT auto-include the standard device libs or search the lib dir the way Wine does (Wine netlists the .asc itself on Linux, so this never showed there). Two fixes: - netlister.py: emit .lib standard.{bjt,mos,dio,jft} for each semiconductor family present, since Mac LTspice won't auto-include them in batch mode. - runner.py: _resolve_lib_paths() rewrites bare-name .lib/.include refs to absolute paths under the Mac lib root before running -b (Mac LTspice can't find them by name). darwin-only; Linux path untouched. - test_netlister.py: skip the Wine-oracle comparison class when wine is absent (it's a Linux-only oracle). Validated on hardware: full suite 534 passed / 8 skipped on native macOS (all 10 integration circuits simulate, incl. op-amp + BJT); Linux unchanged (524 unit + 18 integration). Also answered Kevin's open questions on hardware: #2 -- 'LTspice -b ' DOES run over bare SSH (Background session), so mcltspice is fully headless on Mac since it always netlists itself; #3 -- examples are not pre-extracted on Mac (examples_exist reports false). --- src/mcltspice/netlister.py | 19 +++++++++++++++++++ src/mcltspice/runner.py | 38 ++++++++++++++++++++++++++++++++++++++ tests/test_netlister.py | 5 +++++ 3 files changed, 62 insertions(+) diff --git a/src/mcltspice/netlister.py b/src/mcltspice/netlister.py index 3542613..162ca61 100644 --- a/src/mcltspice/netlister.py +++ b/src/mcltspice/netlister.py @@ -93,6 +93,15 @@ _UNIVERSAL_OPAMP_PARAMS = ( ) _UNIVERSAL_OPAMP_LIB = "UniversalOpAmp2.lib" +# Standard device-model library per SPICE prefix letter. Mac LTspice does not +# auto-include these in batch mode the way Wine does, so the netlister emits them. +_STD_DEVICE_LIB: dict[str, str] = { + "Q": "standard.bjt", + "M": "standard.mos", + "D": "standard.dio", + "J": "standard.jft", +} + @dataclass(frozen=True) class _Pin: @@ -363,6 +372,16 @@ def asc_to_netlist(asc_text: str) -> str: _emit_component(comp, pins_by_comp[idx], coord_to_net, lib_files) ) + # macOS LTspice (unlike Wine) does not auto-include the standard device + # model libraries in batch mode, so emit a .lib for each semiconductor + # family present (2N2222 lives in standard.bjt, etc.). The netlister is + # macOS-only -- on Linux, Wine netlists the .asc itself -- and LTspice + # tolerates an unused .lib, so this is harmless. + for nc in netlist.components: + lib = _STD_DEVICE_LIB.get(nc.name[:1].upper()) + if lib: + lib_files.add(lib) + for lib in sorted(lib_files): netlist.add_lib(lib) diff --git a/src/mcltspice/runner.py b/src/mcltspice/runner.py index f7b2b2a..43e16a4 100644 --- a/src/mcltspice/runner.py +++ b/src/mcltspice/runner.py @@ -118,6 +118,43 @@ async def _run_and_wait( ) +def _resolve_lib_paths(net_text: str) -> str: + """Rewrite bare-name .lib/.include references to absolute paths (macOS). + + Wine LTspice resolves an unqualified ``.lib standard.bjt`` via its configured + search path; native Mac LTspice in batch mode does not, so an unqualified + reference fails with "Could not open library file". We resolve each bare + filename to its absolute location under the lib tree (first rglob match). + Already-absolute paths and references with a path separator are left as-is. + """ + from .config import LTSPICE_LIB + + if not LTSPICE_LIB.exists(): + return net_text + + cache: dict[str, str | None] = {} + + def _find(name: str) -> str | None: + if name not in cache: + matches = sorted(LTSPICE_LIB.rglob(name)) + cache[name] = str(matches[0]) if matches else None + return cache[name] + + out: list[str] = [] + for line in net_text.splitlines(): + stripped = line.strip() + if stripped.lower().startswith((".lib ", ".include ", ".inc ")): + kw, _, arg = stripped.partition(" ") + arg = arg.strip().strip('"') + if arg and "/" not in arg and not Path(arg).is_absolute(): + abs_path = _find(arg) + if abs_path: + out.append(f'{kw} "{abs_path}"') + continue + out.append(line) + return "\n".join(out) + "\n" + + def _safe_copy(src: Path, dst: Path) -> None: """Copy src to dst, skipping when they are the same file. @@ -224,6 +261,7 @@ async def run_simulation( from .netlister import asc_to_netlist # lazy, darwin-only net_text = asc_to_netlist(work_schematic.read_text(errors="replace")) + net_text = _resolve_lib_paths(net_text) run_target = work_schematic.with_suffix(".net") run_target.write_text(net_text) diff --git a/tests/test_netlister.py b/tests/test_netlister.py index f9f0728..b4eef5f 100644 --- a/tests/test_netlister.py +++ b/tests/test_netlister.py @@ -17,6 +17,7 @@ synthesized N00x names are cosmetic and don't match LTspice's numbering). """ import re +import shutil import subprocess import tempfile from collections import defaultdict @@ -321,6 +322,10 @@ def _wine_netlist(asc_text: str) -> str | None: @pytest.mark.integration +@pytest.mark.skipif( + shutil.which("wine") is None, + reason="Wine -netlist oracle is Linux-only; not available on native macOS", +) class TestNetlisterAgainstWineOracle: """asc_to_netlist topology must match Wine's native netlister.