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 <netlist>' 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).
This commit is contained in:
Ryan Malloy 2026-07-03 15:46:26 -06:00
parent 37caaf0310
commit 47beacc9e1
3 changed files with 62 additions and 0 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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.