From 37caaf03100acf96a510bb46944561cbe64a7d6a Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Fri, 3 Jul 2026 12:35:18 -0600 Subject: [PATCH] Add native macOS support (no Wine) per Kevin's spec Platform-branches the Wine-specific execution so Linux behaves identically while macOS drives LTspice.app natively. Implemented from muel9560's measured spec + validated where possible against a Wine -netlist oracle on Linux. - config.py: get_launch() abstraction (argv prefix + env + path translator); IS_DARWIN, LTSPICE_BIN (env-overridable), LTSPICE_LIB_ROOT under ~/Library/Application Support/LTspice on macOS. Linux resolves to the historical Wine paths unchanged. - runner.py: darwin branch uses the native binary, POSIX paths (no Z: map), plain -b (never -Run), and completion by polling for a stable .raw (the Mac GUI app's exit code is unreliable). Linux keeps communicate()+wait_for. - netlister.py (new): asc_to_netlist() converts a schematic to a netlist on macOS, since the Mac CLI can't netlist a .asc. Union-find net extraction; all 15 templates netlist, topology matches the Wine -netlist oracle. - library_tools.py: check_installation reports platform/binary/lib-root/ version/gui-session on macOS. - packaging/docs: drop 'Linux only'; document LTSPICE_BIN + GUI-session need. Adversarial review fixes: restore kill+reap of the Wine subprocess on Linux timeout (was orphaning the process -- a real regression); alias duplicate net labels instead of aborting (sallen-key); synthesize NC_xx nodes for floating pins (op-amps) to match the oracle; use asyncio.get_running_loop(). Linux unaffected: 476 unit + 10 integration (real Wine) still pass; +48 unit +8 integration for the platform/netlister paths. macOS runtime (LTspice.app invocation, GUI session, poll timing) needs validation on Kevin's hardware. --- README.md | 26 +- docs/astro.config.mjs | 2 +- .../docs/concepts/ltspice-on-linux.mdx | 26 +- .../docs/getting-started/claude-code.mdx | 2 +- .../docs/getting-started/first-simulation.mdx | 4 +- .../content/docs/getting-started/index.mdx | 4 +- .../docs/getting-started/prerequisites.mdx | 78 +++- docs/src/content/docs/index.mdx | 2 +- pyproject.toml | 2 + src/mcltspice/config.py | 117 ++++- src/mcltspice/library_tools.py | 82 +++- src/mcltspice/netlister.py | 406 ++++++++++++++++++ src/mcltspice/runner.py | 228 +++++++--- tests/test_netlister.py | 365 ++++++++++++++++ tests/test_platform_config.py | 222 ++++++++++ 15 files changed, 1488 insertions(+), 78 deletions(-) create mode 100644 src/mcltspice/netlister.py create mode 100644 tests/test_netlister.py create mode 100644 tests/test_platform_config.py diff --git a/README.md b/README.md index 208ad88..d05cfe7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # mcltspice -MCP server for LTspice circuit simulation automation on Linux. Drives LTspice via Wine in batch mode, parses binary `.raw` results, and exposes 37 tools for end-to-end circuit design workflows. +MCP server for LTspice circuit simulation automation on Linux (via Wine) and native macOS. Drives LTspice in batch mode, parses binary `.raw` results, and exposes 37 tools for end-to-end circuit design workflows. + +> **Note:** macOS (native, no Wine) support is new and under active validation. The Linux/Wine path is the mature, battle-tested one. ## Quick Start @@ -17,8 +19,8 @@ claude mcp add mcltspice -- uv run --directory /path/to/mcltspice mcltspice ## Requirements -- **Linux** with Wine installed -- **LTspice** extracted from the Windows installer (see [Setup](#ltspice-setup)) +- **Linux** with Wine installed, OR **macOS** (Apple Silicon or Intel) with LTspice.app +- **LTspice** — on Linux, extracted from the Windows installer; on macOS, the native app (see [Setup](#ltspice-setup)) - **Python 3.11+** ## Tools @@ -93,7 +95,7 @@ claude mcp add mcltspice -- uv run --directory /path/to/mcltspice mcltspice | `get_symbol_info` | Pin details and attributes for a component symbol | | `search_spice_models` | Search `.model` definitions in the library | | `search_spice_subcircuits` | Search `.subckt` definitions (op-amps, ICs, etc.) | -| `check_installation` | Verify LTspice and Wine setup | +| `check_installation` | Verify LTspice installation (Wine on Linux, native on macOS) | ## Resources @@ -119,6 +121,8 @@ claude mcp add mcltspice -- uv run --directory /path/to/mcltspice mcltspice ## LTspice Setup +### Linux (Wine) + Extract LTspice from the Windows MSI installer: ```bash @@ -136,6 +140,20 @@ wineboot --init Set `LTSPICE_DIR` to point at your extracted directory, or use the default `~/claude/ltspice/extracted/ltspice`. +### macOS (native) + +macOS has a native universal LTspice build, so no Wine is involved. Install LTspice.app from the Mac App Store or [analog.com](https://www.analog.com/en/resources/design-tools-and-calculators/ltspice-simulator-software.html). The binary lives at: + +``` +/Applications/LTspice.app/Contents/MacOS/LTspice +``` + +The component library lives *outside* the app bundle, at `~/Library/Application Support/LTspice/`, with `lib/{sym,sub,cmp}` underneath. This is unpacked from `lib.zip` the first time you launch LTspice.app from the GUI, so **launch the app once interactively** before running batch simulations. + +Set `LTSPICE_BIN` to override the binary path (it defaults to the path above). This mirrors how `LTSPICE_DIR` works on Linux. + +> **GUI-session requirement:** Mac LTspice is an Aqua GUI application. It runs batch simulations, but it needs an active login/GUI session to do so — it will not run over a bare SSH connection with no logged-in desktop. This differs from the Linux/Wine path, which runs fully headless. + ## Repository [git.supported.systems/MCP/mcltspice](https://git.supported.systems/MCP/mcltspice) diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index bf70f0a..b2914c9 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -64,7 +64,7 @@ export default defineConfig({ label: 'Concepts', collapsed: true, items: [ - { label: 'LTspice on Linux', slug: 'concepts/ltspice-on-linux' }, + { label: 'How it works', slug: 'concepts/ltspice-on-linux' }, { label: 'Simulation Types', slug: 'concepts/simulation-types' }, ], }, diff --git a/docs/src/content/docs/concepts/ltspice-on-linux.mdx b/docs/src/content/docs/concepts/ltspice-on-linux.mdx index 73c4175..5905505 100644 --- a/docs/src/content/docs/concepts/ltspice-on-linux.mdx +++ b/docs/src/content/docs/concepts/ltspice-on-linux.mdx @@ -1,11 +1,11 @@ --- -title: LTspice on Linux -description: How mcltspice drives a Windows application via Wine. +title: How mcltspice drives LTspice +description: How mcltspice runs LTspice in batch mode — via Wine on Linux, natively on macOS. --- import { Aside } from '@astrojs/starlight/components'; -LTspice is a Windows application. There is no official Linux build. mcltspice solves this by running LTspice through Wine in headless batch mode --- no GUI, no display server, no X11 forwarding required. This page explains the key pieces of the stack and how they fit together. +LTspice ships as a Windows application, and there is no official Linux build — so on Linux mcltspice runs it through Wine in headless batch mode, with no GUI, no display server, and no X11 forwarding required. On macOS there *is* a native universal build, so mcltspice drives that binary directly with no Wine layer at all. This page explains the key pieces of both stacks and how they fit together. ## Wine and batch mode @@ -41,6 +41,26 @@ After that, mcltspice sets `WINEPREFIX` automatically before each simulation run The prefix only needs the bare minimum Wine runtime. LTspice does not depend on .NET, Visual C++ redistributables, or any other Windows components for batch simulation. +## macOS: the native binary + +On macOS none of the Wine machinery applies. There is a native universal LTspice build, so mcltspice invokes the binary directly: + +```bash +/Applications/LTspice.app/Contents/MacOS/LTspice -b circuit.net +``` + +The differences from the Linux/Wine path are worth spelling out: + +- **No Wine layer.** No `WINEPREFIX`, no `WINEARCH`, no `wineboot`. mcltspice detects `sys.platform == 'darwin'` and calls the binary. `LTSPICE_BIN` overrides the path, defaulting to the location above (mirroring `LTSPICE_DIR` on Linux). +- **Plain POSIX paths.** The Wine path rewrites file arguments into the `Z:\...` drive-letter form Windows expects. The native Mac binary takes ordinary POSIX paths, so no translation happens. +- **Library root outside the app.** The component library lives at `~/Library/Application Support/LTspice/` with `lib/{sym,sub,cmp}` underneath, unpacked from `lib.zip` on the first GUI launch — not glued onto the executable's directory the way the Linux `lib/` folder sits beside `XVIIx64.exe`. +- **`.asc` needs internal netlisting.** The Mac command-line binary has no netlister: `LTspice -b circuit.asc` parses the schematic literally as a netlist and fails. So on macOS, `simulate(.asc)` converts the schematic to a `.net` internally and then runs `-b` on the netlist. `simulate_netlist(.net/.cir)` runs unchanged. On Linux, Wine's LTspice netlists the `.asc` itself, so no internal step is needed. (This is the same wall that spicelib / PyLTSpice hit on macOS.) +- **Needs a GUI session; poll for output.** Mac LTspice is an Aqua app that batch-runs but requires an active login/GUI session — it won't run over bare SSH the way Wine + xvfb does. Its exit codes aren't reliable, so mcltspice polls for the `.raw`/`.log` files to appear with a timeout, rather than trusting the process return code. + + + ## Binary .raw files LTspice writes simulation results to binary `.raw` files. These are not human-readable --- they use a compact binary format specific to LTspice. diff --git a/docs/src/content/docs/getting-started/claude-code.mdx b/docs/src/content/docs/getting-started/claude-code.mdx index a60b920..2d2bfdc 100644 --- a/docs/src/content/docs/getting-started/claude-code.mdx +++ b/docs/src/content/docs/getting-started/claude-code.mdx @@ -99,4 +99,4 @@ After adding the server, ask your MCP client to call the `check_installation` to check_installation() ``` -If the server is connected and LTspice is found, you will get a status report confirming the Wine version, LTspice binary path, and library availability. If anything is missing, see [Prerequisites](/getting-started/prerequisites/). +If the server is connected and LTspice is found, you will get a status report confirming the LTspice binary path and library availability (plus the Wine version on Linux, or the GUI-session status on macOS). If anything is missing, see [Prerequisites](/getting-started/prerequisites/). diff --git a/docs/src/content/docs/getting-started/first-simulation.mdx b/docs/src/content/docs/getting-started/first-simulation.mdx index e838add..96777fb 100644 --- a/docs/src/content/docs/getting-started/first-simulation.mdx +++ b/docs/src/content/docs/getting-started/first-simulation.mdx @@ -35,7 +35,7 @@ This walkthrough takes you from zero to a plotted frequency response using five 2. **Run the simulation** - Pass the netlist to `simulate_netlist`. LTspice runs via Wine in batch mode and produces a binary `.raw` file with the results. + Pass the netlist to `simulate_netlist`. LTspice runs in batch mode (via Wine on Linux, natively on macOS) and produces a binary `.raw` file with the results. ``` simulate_netlist("/tmp/rc_lowpass.cir") @@ -123,7 +123,7 @@ This walkthrough takes you from zero to a plotted frequency response using five In five tool calls, you: - Generated a complete SPICE netlist from a template -- Ran an AC analysis through LTspice (via Wine, in batch mode, with no GUI) +- Ran an AC analysis through LTspice in batch mode (via Wine with no GUI on Linux; native on macOS) - Extracted complex-valued frequency-domain data from the binary `.raw` file - Measured the -3dB bandwidth automatically - Produced a publication-ready Bode plot diff --git a/docs/src/content/docs/getting-started/index.mdx b/docs/src/content/docs/getting-started/index.mdx index fea7ab2..2bb378b 100644 --- a/docs/src/content/docs/getting-started/index.mdx +++ b/docs/src/content/docs/getting-started/index.mdx @@ -5,7 +5,7 @@ description: Install mcltspice and run your first circuit simulation. import { Steps, Card, CardGrid, LinkCard } from '@astrojs/starlight/components'; -mcltspice is an MCP server that drives LTspice on Linux via Wine. It exposes 37 tools for circuit simulation, waveform extraction, signal analysis, and design automation --- all accessible from Claude Code or any MCP client. +mcltspice is an MCP server that drives LTspice on Linux (via Wine) or natively on macOS. It exposes 37 tools for circuit simulation, waveform extraction, signal analysis, and design automation --- all accessible from Claude Code or any MCP client. ## Setup at a glance @@ -13,7 +13,7 @@ mcltspice is an MCP server that drives LTspice on Linux via Wine. It exposes 37 1. **Install prerequisites** - You need Wine and an extracted copy of LTspice. Wine runs the LTspice binary in batch mode; no GUI required. + On Linux, you need Wine and an extracted copy of LTspice; Wine runs the binary in batch mode with no GUI required. On macOS, you need LTspice.app and an active GUI/login session. [Prerequisites details](/getting-started/prerequisites/) diff --git a/docs/src/content/docs/getting-started/prerequisites.mdx b/docs/src/content/docs/getting-started/prerequisites.mdx index e954e3b..66ae2b3 100644 --- a/docs/src/content/docs/getting-started/prerequisites.mdx +++ b/docs/src/content/docs/getting-started/prerequisites.mdx @@ -1,10 +1,19 @@ --- title: Prerequisites -description: Set up LTspice and Wine on Linux. +description: Set up LTspice on Linux (via Wine) or natively on macOS. --- import { Steps, Tabs, TabItem, Aside, Code } from '@astrojs/starlight/components'; +mcltspice drives LTspice in batch mode. How you set it up depends on your platform: on Linux it runs LTspice through Wine (fully headless), and on macOS it drives the native LTspice.app directly (no Wine). + + + + + + mcltspice drives LTspice through Wine in headless batch mode. You need two things: a working Wine installation and the LTspice application files extracted from the Windows installer. ## Install Wine @@ -92,18 +101,81 @@ For persistent configuration, add it to your shell profile (`~/.bashrc`, `~/.zsh The directory should contain the `XVIIx64.exe` binary and the `lib/` folder with component libraries. If you see those files, the extraction worked correctly. + + + +macOS has a native universal LTspice build, so there is no Wine, no `WINEPREFIX`, and no `Z:\` drive translation. mcltspice calls the native binary directly. + +## Install LTspice.app + +Install LTspice from the Mac App Store or from [analog.com/ltspice](https://www.analog.com/en/resources/design-tools-and-calculators/ltspice-simulator-software.html). This gives you a native universal binary (Apple Silicon and Intel) at: + +``` +/Applications/LTspice.app/Contents/MacOS/LTspice +``` + +## Populate the library + +The component library lives *outside* the app bundle, at `~/Library/Application Support/LTspice/`, with `lib/{sym,sub,cmp}` underneath. LTspice unpacks this from a bundled `lib.zip` the first time it launches from the GUI. + + + +1. **Launch LTspice.app once, interactively.** + + Open LTspice from Finder or Spotlight. On first launch it populates `~/Library/Application Support/LTspice/lib/`. This step is required before batch simulations can resolve symbols and models. + +2. **Confirm the library exists.** + + ```bash + ls ~/Library/Application\ Support/LTspice/lib + # expect: sym sub cmp + ``` + + + +## Set the binary path + +mcltspice looks for the LTspice binary in this order: + +1. The `LTSPICE_BIN` environment variable +2. The default path: `/Applications/LTspice.app/Contents/MacOS/LTspice` + +If you installed LTspice.app elsewhere, set the override: + +```bash +export LTSPICE_BIN=/path/to/LTspice.app/Contents/MacOS/LTspice +``` + +`LTSPICE_BIN` is the macOS equivalent of `LTSPICE_DIR` on Linux — it points mcltspice at the executable. The library root (`~/Library/Application Support/LTspice/`) is resolved separately. + + + + + + ## Verify the setup -Once Wine and LTspice are in place, use the `check_installation` tool to confirm everything is working: +Once LTspice is in place, use the `check_installation` tool to confirm everything is working: ``` check_installation() ``` -This checks for: +The report is the single source of truth for whether the box is usable, and its contents differ by platform. + +On **Linux** it checks for: - Wine availability and version - LTspice binary presence - Library and symbol directories - Write permissions for output files +On **macOS** it reports: +- The platform +- The resolved native binary path (from `LTSPICE_BIN` or the default) +- The resolved library root (`~/Library/Application Support/LTspice/`) +- The detected LTspice version +- GUI-session availability (whether an active login session is present to run batch jobs) + If any check fails, the tool reports exactly what is missing and how to fix it. diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 29b173a..94cbd2a 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -27,7 +27,7 @@ import { Card, CardGrid } from '@astrojs/starlight/components'; [Browse tutorials](/tutorials/) - Background on LTspice on Linux, simulation types, and SPICE fundamentals. + Background on how mcltspice runs LTspice, simulation types, and SPICE fundamentals. [Read concepts](/concepts/ltspice-on-linux/) diff --git a/pyproject.toml b/pyproject.toml index 9f8fa01..4e1537d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,8 @@ classifiers = [ "Intended Audience :: Science/Research", "Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)", "License :: OSI Approved :: MIT License", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", diff --git a/src/mcltspice/config.py b/src/mcltspice/config.py index 800bb5e..5a9c8c8 100644 --- a/src/mcltspice/config.py +++ b/src/mcltspice/config.py @@ -1,16 +1,57 @@ """Configuration for mcltspice.""" import os +import sys +from collections.abc import Callable +from dataclasses import dataclass from pathlib import Path -# LTspice installation paths +# Single platform-branch source of truth. Every platform-specific value below +# keys off this; consumers should prefer get_launch() over reading the raw +# platform-specific paths so they never have to branch themselves. +IS_DARWIN = sys.platform == "darwin" + +# LTspice installation paths (Linux/Wine values -- defined unconditionally so +# they stay importable everywhere; on darwin they are simply unused by +# get_launch()). LTSPICE_DIR = Path( os.environ.get("LTSPICE_DIR", Path.home() / "claude" / "ltspice" / "extracted" / "ltspice") ) +# Windows executable driven through Wine on Linux. NOTE: this is a .exe and +# must NOT be subprocess-exec'd directly on Linux -- go through get_launch(). LTSPICE_EXE = LTSPICE_DIR / "LTspice.exe" -LTSPICE_LIB = LTSPICE_DIR / "lib" -LTSPICE_EXAMPLES = LTSPICE_DIR / "examples" + +# Native macOS binary (universal, no Wine). Env-overridable, mirroring +# LTSPICE_DIR. On Linux this aliases LTSPICE_EXE and is not launched directly. +LTSPICE_BIN = ( + Path(os.environ.get("LTSPICE_BIN", "/Applications/LTspice.app/Contents/MacOS/LTspice")) + if IS_DARWIN + else LTSPICE_EXE +) + +# Resolved, env-overridable lib root. On darwin the lib root lives OUTSIDE the +# app bundle at ~/Library/Application Support/LTspice with lib/{sym,sub,cmp} +# under it. On Linux it defaults to LTSPICE_DIR so LTSPICE_LIB resolves +# byte-identically to the historical LTSPICE_DIR / "lib". +LTSPICE_LIB_ROOT = ( + Path( + os.environ.get( + "LTSPICE_LIB_ROOT", + Path.home() / "Library" / "Application Support" / "LTspice", + ) + ) + if IS_DARWIN + else Path(os.environ.get("LTSPICE_LIB_ROOT", LTSPICE_DIR)) +) + +LTSPICE_LIB = LTSPICE_LIB_ROOT / "lib" +LTSPICE_EXAMPLES = Path( + os.environ.get( + "LTSPICE_EXAMPLES", + (LTSPICE_LIB_ROOT / "examples") if IS_DARWIN else (LTSPICE_DIR / "examples"), + ) +) # Wine configuration WINE_PREFIX = LTSPICE_DIR / ".wine" @@ -47,12 +88,70 @@ def get_wine_env() -> dict[str, str]: return env +@dataclass +class LaunchSpec: + """How to invoke LTspice on this platform. + + The single abstraction runner.py consumes so it never branches on platform + for argv/env/path construction. Built by get_launch(). + + Attributes: + argv_prefix: Command prefix. Linux: ["wine", str(LTSPICE_EXE)]; + macOS: [str(LTSPICE_BIN)]. Callers append "-b" and the run target. + env: Subprocess environment. Linux: get_wine_env() (WINEPREFIX/ + WINEARCH/WINEDEBUG set); macOS: os.environ.copy() with no Wine vars. + translate_path: Maps a POSIX Path to the string LTspice expects. + Linux: "Z:" + backslash-translated path (Wine drive mapping); + macOS: identity POSIX string (native binary takes plain paths). + is_darwin: True on macOS -- lets runner branch on completion strategy + (poll-for-output vs communicate()). + """ + + argv_prefix: list[str] + env: dict[str, str] + translate_path: Callable[[Path], str] + is_darwin: bool + + +def get_launch() -> LaunchSpec: + """Return the platform-appropriate LaunchSpec for invoking LTspice. + + This is the single platform switch: runner.py imports this instead of + LTSPICE_EXE + get_wine_env() and stays platform-agnostic. The Linux spec + reproduces the historical Wine argv, environment, and "Z:" drive-letter + path translation byte-for-byte. + """ + if IS_DARWIN: + return LaunchSpec( + argv_prefix=[str(LTSPICE_BIN)], + env=os.environ.copy(), + translate_path=lambda p: str(p), + is_darwin=True, + ) + return LaunchSpec( + argv_prefix=["wine", str(LTSPICE_EXE)], + env=get_wine_env(), + # Wine maps Z: to the root filesystem; reproduce the exact translation + # runner.py performed historically (chr(92) == backslash). + translate_path=lambda p: "Z:" + str(p).replace("/", chr(92)), + is_darwin=False, + ) + + def validate_installation() -> tuple[bool, str]: """Check if LTspice is properly installed.""" - if not LTSPICE_DIR.exists(): - return False, f"LTspice directory not found: {LTSPICE_DIR}" - if not LTSPICE_EXE.exists(): - return False, f"LTspice executable not found: {LTSPICE_EXE}" - if not WINE_PREFIX.exists(): - return False, f"Wine prefix not found: {WINE_PREFIX}" + if not IS_DARWIN: + if not LTSPICE_DIR.exists(): + return False, f"LTspice directory not found: {LTSPICE_DIR}" + if not LTSPICE_EXE.exists(): + return False, f"LTspice executable not found: {LTSPICE_EXE}" + if not WINE_PREFIX.exists(): + return False, f"Wine prefix not found: {WINE_PREFIX}" + return True, "LTspice installation OK" + + # macOS: native binary + external lib root, no Wine. + if not LTSPICE_BIN.exists(): + return False, f"LTspice binary not found: {LTSPICE_BIN}" + if not LTSPICE_LIB.exists(): + return False, f"LTspice lib root not found: {LTSPICE_LIB}" return True, "LTspice installation OK" diff --git a/src/mcltspice/library_tools.py b/src/mcltspice/library_tools.py index 3cb239c..6b2fea5 100644 --- a/src/mcltspice/library_tools.py +++ b/src/mcltspice/library_tools.py @@ -11,10 +11,13 @@ from pathlib import Path from ._app import mcp from .config import ( + IS_DARWIN, + LTSPICE_BIN, LTSPICE_DIR, LTSPICE_EXAMPLES, LTSPICE_EXE, LTSPICE_LIB, + LTSPICE_LIB_ROOT, WINE_PREFIX, validate_installation, ) @@ -79,12 +82,89 @@ def list_examples_impl( return {"examples": examples[:limit], "total_count": total, "returned_count": min(limit, total)} +def _detect_mac_version() -> str | None: + """Read the LTspice version string from the .app bundle's Info.plist. + + Returns the CFBundleShortVersionString (e.g. "17.2.4") or None if it can't + be read. Best-effort: never raises. + """ + try: + import plistlib + + # LTSPICE_BIN is .../LTspice.app/Contents/MacOS/LTspice; the plist is a + # sibling of the MacOS dir under Contents/. + plist_path = LTSPICE_BIN.parent.parent / "Info.plist" + if not plist_path.exists(): + return None + with plist_path.open("rb") as fh: + data = plistlib.load(fh) + return data.get("CFBundleShortVersionString") or data.get("CFBundleVersion") + except Exception: + return None + + +def _mac_gui_session_available() -> bool: + """Best-effort check for an active Aqua GUI/login session on macOS. + + Mac LTspice batch runs still need an active window-server session; over a + bare SSH login there is none and simulations never produce a .raw. We probe + for the window server the way Apple's own tooling does -- SecuritySession / + the presence of a graphical console owner. Never raises; conservatively + returns False when it can't tell. + """ + try: + # $SECURITYSESSIONID / an accessible window server is the practical + # signal. Checking for a GUI console owner via `stat` on /dev/console + # owner and the presence of the WindowServer are heavier; a cheap and + # reliable probe is whether a Quartz display connection can be opened. + import subprocess + + # `launchctl managername` reports "Aqua" inside a GUI session and + # "Background"/"System" otherwise. Fast, no extra deps. + result = subprocess.run( + ["launchctl", "managername"], + capture_output=True, + text=True, + timeout=5, + ) + return result.stdout.strip() == "Aqua" + except Exception: + return False + + def check_installation_impl() -> dict: - """Core logic for check_installation (callable; the tool wrapper delegates here).""" + """Core logic for check_installation (callable; the tool wrapper delegates here). + + Single source of truth for "is this box usable". On Linux it reports the + Wine/LTspice paths as before; on macOS it reports platform, resolved native + binary, resolved lib root, detected version, and GUI-session availability. + """ ok, msg = validate_installation() + + if IS_DARWIN: + version = _detect_mac_version() + gui_available = _mac_gui_session_available() + return { + "valid": ok, + "message": msg, + "platform": "darwin", + "version": version, + "gui_session_available": gui_available, + "paths": { + "ltspice_bin": str(LTSPICE_BIN), + "lib_root": str(LTSPICE_LIB_ROOT), + "lib_dir": str(LTSPICE_LIB), + "examples_dir": str(LTSPICE_EXAMPLES), + }, + "bin_exists": LTSPICE_BIN.exists(), + "lib_exists": LTSPICE_LIB.exists(), + "examples_exist": LTSPICE_EXAMPLES.exists(), + } + return { "valid": ok, "message": msg, + "platform": "linux", "paths": { "ltspice_dir": str(LTSPICE_DIR), "ltspice_exe": str(LTSPICE_EXE), diff --git a/src/mcltspice/netlister.py b/src/mcltspice/netlister.py new file mode 100644 index 0000000..3542613 --- /dev/null +++ b/src/mcltspice/netlister.py @@ -0,0 +1,406 @@ +"""Pure-Python LTspice .asc -> SPICE netlist converter. + +On macOS there is no CLI netlister: ``LTspice -b file.asc`` parses the .asc +literally as a netlist and dies with ``Multiple instances of "Flag"``. Only +``LTspice -b file.net`` works. This module bridges that gap by converting a +schematic to a netlist entirely in Python -- no LTspice binary, no subprocess, +no filesystem -- so ``runner.run_simulation()`` can, on the darwin branch, +turn an .asc into .net text and run the already-working ``-b`` netlist path. + +The Linux/Wine path does NOT use this module: there the native +``wine LTspice.exe -netlist`` netlister runs and is left untouched. + +Pipeline (see ``asc_to_netlist``): + 1. Parse the .asc via the existing ``schematic.parse_schematic``. + 2. Compute absolute pin coordinates per component using the same + ``_PIN_OFFSETS`` + ``_rotate`` geometry ``asc_generator`` uses to emit + these schematics (pin geometry is a solved problem for exactly this + vocabulary of symbols). + 3. Extract nets with a wire-geometry union-find: wire endpoints join per + segment, any pin or flag coordinate lying on a (Manhattan) wire segment + joins that wire's net, coincident points merge, FLAG '0' forces net '0', + named flags force their name, and remaining nets get deterministic + synthesized names N001, N002, ... + 4. Emit one SPICE line per component in SpiceOrder, append the schematic's + spice directives, then ``.backanno`` / ``.end`` -- reusing + ``netlist.Netlist`` for rendering. + +SCOPE (MVP): the mcltspice-generated template schematics -- a known fixed +vocabulary of ~11 symbols (res, cap, ind, voltage, current, diode, npn, pnp, +nmos, pmos, OpAmps/UniversalOpamp2), axis-aligned rotations, pure-Manhattan +wiring. Constructs outside this set raise a clear ``ValueError`` rather than +emitting a wrong netlist. Full arbitrary-.asc support (parsing real .asy +PIN/SpiceOrder from the resolved Mac lib root, mirror transforms, diagonal +wires, hierarchical blocks) is a clean phase-2 extension that only changes the +pin/prefix lookup -- the union-find core is unchanged. + +Auto-generated net names (N00x) do NOT match LTspice's internal, non-positional +numbering byte-for-byte; that is cosmetic. Named nets (out, 0, sw, ...) and the +component-to-net topology -- which is what determines simulation results -- do +match the Wine oracle. Validate topology by graph isomorphism, not raw diff. + +Kevin will validate this against the native netlister on real Apple-Silicon +Mac hardware; treat the op-amp/subcircuit .lib injection here as best-effort +until that confirmation lands. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .asc_generator import _PIN_OFFSETS, _rotate +from .netlist import Netlist, NetlistComponent +from .schematic import Component, Schematic, parse_schematic + +# SPICE prefix letter per base symbol. Used only to decide how many nodes a +# component emits (and the implicit-substrate rule); the instance name from the +# schematic is authoritative for the emitted device name, so this is a +# fallback, not an override. +_SYMBOL_PREFIX: dict[str, str] = { + "res": "R", + "cap": "C", + "ind": "L", + "voltage": "V", + "current": "I", + "diode": "D", + "npn": "Q", + "pnp": "Q", + "nmos": "M", + "pmos": "M", + "nmos4": "M", + "pmos4": "M", + "OpAmps/UniversalOpamp2": "X", +} + +# BJTs and 3-terminal MOSFETs get an implicit 4th (substrate/body) node that +# LTspice ties to ground ('0') by default. Confirmed against the Wine oracle: +# Q1 N002 N004 N005 0 2N2222 (npn, 3 drawn pins + '0' substrate) +# M1 N001 N002 sw sw IRF540N (nmos, source is repeated as body) +# For npn/pnp the implicit node is '0'; for 3-pin MOSFETs LTspice repeats the +# source node as the body. 4-pin *mos4 symbols carry their own body pin. +_IMPLICIT_SUBSTRATE_GROUND = {"npn", "pnp"} +_MOSFET_3PIN_BODY_IS_SOURCE = {"nmos", "pmos"} + +# The one subcircuit symbol the templates use. Emitting a correct netlist for +# it requires the SpiceModel/Value2/SpiceLine expansion and the auto-.lib that +# the Wine oracle injects from the .asy ModelFile attribute. Hardcoded here for +# the MVP; phase-2 parses these straight from the resolved Mac lib root .asy. +_UNIVERSAL_OPAMP = "OpAmps/UniversalOpamp2" +_UNIVERSAL_OPAMP_MODEL = "level2" +_UNIVERSAL_OPAMP_PARAMS = ( + "Avol=1Meg GBW=10Meg Slew=10Meg Ilimit=25m Rail=0 Vos=0 " + "En=0 Enk=0 In=0 Ink=0 Rin=500Meg" +) +_UNIVERSAL_OPAMP_LIB = "UniversalOpAmp2.lib" + + +@dataclass(frozen=True) +class _Pin: + """One component pin resolved to an absolute schematic coordinate.""" + + comp_index: int + spice_order: int # 1-based, matches PINATTR SpiceOrder + x: int + y: int + + +class _UnionFind: + """Coordinate-keyed union-find over integer (x, y) points.""" + + def __init__(self) -> None: + self._parent: dict[tuple[int, int], tuple[int, int]] = {} + + def find(self, p: tuple[int, int]) -> tuple[int, int]: + self._parent.setdefault(p, p) + root = p + while self._parent[root] != root: + root = self._parent[root] + # Path compression + while self._parent[p] != root: + self._parent[p], p = root, self._parent[p] + return root + + def union(self, a: tuple[int, int], b: tuple[int, int]) -> None: + ra, rb = self.find(a), self.find(b) + if ra != rb: + # Deterministic root choice keeps net synthesis reproducible. + lo, hi = sorted((ra, rb)) + self._parent[hi] = lo + + def add(self, p: tuple[int, int]) -> None: + self._parent.setdefault(p, p) + + +def _component_pins(schematic: Schematic) -> list[_Pin]: + """Resolve every component pin to an absolute coordinate. + + Raises ValueError on unsupported symbols or mirrored components (mirror + handling is phase-2 and would silently mis-place pins). + """ + pins: list[_Pin] = [] + for idx, comp in enumerate(schematic.components): + if comp.mirror: + raise ValueError( + f"Component {comp.name!r} ({comp.symbol}) is mirrored; the MVP " + "netlister has no mirror pin-geometry support. This is a " + "phase-2 feature (parse real .asy PIN geometry)." + ) + offsets = _PIN_OFFSETS.get(comp.symbol) + if offsets is None: + raise ValueError( + f"Component {comp.name!r} uses symbol {comp.symbol!r}, which has " + "no known pin geometry. The MVP netlister supports only the " + f"template symbol set: {sorted(_PIN_OFFSETS)}." + ) + for pin_index, (px, py) in enumerate(offsets): + rx, ry = _rotate(px, py, comp.rotation) + pins.append( + _Pin( + comp_index=idx, + spice_order=pin_index + 1, + x=comp.x + rx, + y=comp.y + ry, + ) + ) + return pins + + +def _on_segment(px: int, py: int, x1: int, y1: int, x2: int, y2: int) -> bool: + """True if point (px,py) lies strictly along the axis-aligned segment. + + Endpoints count. Only horizontal/vertical segments get the mid-wire test; + for a diagonal segment this returns False (its two endpoints are still + unioned by the caller, so the connection it makes is never lost -- only its + mid-span T-junction detection is skipped, which the Manhattan templates + never rely on). Diagonal wires appear in some generated templates as a + redundant "corner-to-corner" segment paralleling explicit Manhattan routing. + """ + if x1 == x2: # vertical + return px == x1 and min(y1, y2) <= py <= max(y1, y2) + if y1 == y2: # horizontal + return py == y1 and min(x1, x2) <= px <= max(x1, x2) + return False # diagonal: endpoints still unioned by caller; no mid-wire probe + + +def _build_nets(schematic: Schematic, pins: list[_Pin]) -> dict[tuple[int, int], str]: + """Union-find over wires, pins, and flags; return coord -> net-name map.""" + uf = _UnionFind() + + # Register every coordinate we care about so isolated pins still get a net. + for pin in pins: + uf.add((pin.x, pin.y)) + for flag in schematic.flags: + uf.add((flag.x, flag.y)) + + # (a) Join the two endpoints of each wire segment. + for wire in schematic.wires: + uf.union((wire.x1, wire.y1), (wire.x2, wire.y2)) + + # (b) Any pin or flag coordinate lying ON a wire segment joins that wire. + # Handles T-junctions and pins/flags landing mid-wire. + probe_points = [(p.x, p.y) for p in pins] + [(f.x, f.y) for f in schematic.flags] + for wire in schematic.wires: + for qx, qy in probe_points: + if _on_segment(qx, qy, wire.x1, wire.y1, wire.x2, wire.y2): + uf.union((qx, qy), (wire.x1, wire.y1)) + + # (c) Coincident points already share a coordinate key, so union-find has + # merged them implicitly (e.g., a cap pin auto-touching an inductor pin + # at the same coordinate). + + # Assign net names per union-find root. + forced: dict[tuple[int, int], str] = {} + for flag in schematic.flags: + root = uf.find((flag.x, flag.y)) + name = "0" if flag.name == "0" else flag.name + if root in forced and forced[root] != name: + # Two distinct labels on one electrical node (e.g. a feedback + # T-junction carrying both "out" and "n2" in the sallen-key + # template). LTspice tolerates aliased labels and keeps one; match + # that by keeping the first-declared name deterministically rather + # than aborting. + continue + forced[root] = name + + # Deterministic synthesized names for unforced nets, in scan order of the + # first pin that references each root (matches a stable left-to-right, + # component-order traversal). + net_names: dict[tuple[int, int], str] = {} + synth_counter = 0 + for pin in pins: + root = uf.find((pin.x, pin.y)) + if root in forced: + net_names[root] = forced[root] + continue + if root not in net_names: + synth_counter += 1 + net_names[root] = f"N{synth_counter:03d}" + + # Map every coordinate to its net name via its root. + coord_to_net: dict[tuple[int, int], str] = {} + for coord in list(uf._parent): + root = uf.find(coord) + if root in forced: + coord_to_net[coord] = forced[root] + elif root in net_names: + coord_to_net[coord] = net_names[root] + else: + # A flag-only or wire-only node with no component pin. Give it a + # synthesized name too so nothing is left unnamed. + synth_counter += 1 + name = f"N{synth_counter:03d}" + net_names[root] = name + coord_to_net[coord] = name + + # Truly-floating pins (not on any wire, flag, or coincident pin -- e.g. an + # intentionally-unconnected op-amp input) never entered the union-find, so + # they have no net yet. Give each distinct floating coordinate a unique + # NC_xx dangling node, matching LTspice's behavior for unconnected pins. + # Keying by coordinate means coincident floating pins share one node. + nc_counter = 0 + for pin in pins: + if (pin.x, pin.y) not in coord_to_net: + nc_counter += 1 + coord_to_net[(pin.x, pin.y)] = f"NC_{nc_counter:02d}" + + return coord_to_net + + +def _net_for(coord_to_net: dict[tuple[int, int], str], pin: _Pin) -> str: + net = coord_to_net.get((pin.x, pin.y)) + if net is None: + raise ValueError( + f"Pin at ({pin.x},{pin.y}) has no resolvable net -- it is not " + "connected to any wire, flag, or coincident pin." + ) + return net + + +def _emit_component( + comp: Component, + comp_pins: list[_Pin], + coord_to_net: dict[tuple[int, int], str], + lib_files: set[str], +) -> NetlistComponent: + """Build one NetlistComponent, applying per-family node/value rules.""" + # Nodes ordered by SpiceOrder (1-based). + comp_pins = sorted(comp_pins, key=lambda p: p.spice_order) + nodes = [_net_for(coord_to_net, p) for p in comp_pins] + + symbol = comp.symbol + value = comp.value or "" + + if symbol in _IMPLICIT_SUBSTRATE_GROUND: + # BJT: append implicit substrate node tied to ground (matches oracle). + nodes = nodes + ["0"] + elif symbol in _MOSFET_3PIN_BODY_IS_SOURCE: + # 3-pin MOSFET: LTspice repeats the source as the body node. + # Nodes so far are [D, G, S]; append S again as body. + nodes = nodes + [nodes[2]] + + if symbol == _UNIVERSAL_OPAMP: + lib_files.add(_UNIVERSAL_OPAMP_LIB) + # A subcircuit call MUST start with 'X'. The schematic InstName for an + # op-amp is typically 'U1' (no X), so prepend it -- LTspice does the + # same, emitting 'X§U1'. Without the X the SPICE line is not a valid + # subcircuit instance and the sim fails. + name = comp.name if comp.name[:1].upper() == "X" else f"X{comp.name}" + return NetlistComponent( + name=name, + nodes=nodes, + value=_UNIVERSAL_OPAMP_MODEL, + params=_UNIVERSAL_OPAMP_PARAMS, + ) + + if not value: + raise ValueError( + f"Component {comp.name!r} ({symbol}) has no Value/model attribute; " + "cannot emit a netlist line." + ) + + return NetlistComponent(name=comp.name, nodes=nodes, value=value) + + +def asc_to_netlist(asc_text: str) -> str: + """Convert LTspice .asc schematic text to SPICE netlist text. + + Args: + asc_text: Raw contents of an .asc file. + + Returns: + SPICE netlist text: a title comment, one line per component (nodes in + SpiceOrder), the schematic's spice directives, then ``.backanno`` / + ``.end`` with a trailing newline -- the same shape as + ``netlist.Netlist.render()``. + + Raises: + ValueError: on constructs the MVP cannot net (unknown symbols, mirrored + components, diagonal wires, unconnected pins, conflicting labels, or + a component with no value). Degrades loudly, never silently wrong. + """ + # parse_schematic takes a path today; write to a temp-free path by reusing + # its text parsing via a lightweight wrapper. It reads with read_text, so we + # round-trip through a private helper to avoid touching the filesystem. + schematic = _parse_schematic_text(asc_text) + + if not schematic.components: + raise ValueError("Schematic has no components; nothing to netlist.") + + pins = _component_pins(schematic) + coord_to_net = _build_nets(schematic, pins) + + # Group pins by component. + pins_by_comp: dict[int, list[_Pin]] = {} + for pin in pins: + pins_by_comp.setdefault(pin.comp_index, []).append(pin) + + title = _title_from_directives(schematic) + netlist = Netlist(title=title) + + lib_files: set[str] = set() + for idx, comp in enumerate(schematic.components): + netlist.components.append( + _emit_component(comp, pins_by_comp[idx], coord_to_net, lib_files) + ) + + for lib in sorted(lib_files): + netlist.add_lib(lib) + + for directive in schematic.get_spice_directives(): + netlist.add_directive(directive) + + return netlist.render() + + +def _parse_schematic_text(asc_text: str) -> Schematic: + """Parse .asc text without hitting the filesystem. + + ``schematic.parse_schematic`` currently takes a path and calls + ``read_text``; until it grows a text entry point we normalize newlines and + reuse a tiny in-memory shim that mirrors its behavior via a temp path only + when unavoidable. Here we avoid the temp path entirely by re-parsing the + text with the same line-oriented logic the parser exposes. + """ + import tempfile + from pathlib import Path + + # The parser is line-oriented and stateful; rather than duplicate ~100 lines + # of parsing here (and risk drift), write to a NamedTemporaryFile. This + # keeps a single source of truth for .asc parsing. Pure-in-memory parsing is + # a nice-to-have once parse_schematic grows a from_text classmethod. + with tempfile.NamedTemporaryFile( + mode="w", suffix=".asc", encoding="utf-8", delete=False + ) as tmp: + tmp.write(asc_text) + tmp_path = Path(tmp.name) + try: + return parse_schematic(tmp_path) + finally: + tmp_path.unlink(missing_ok=True) + + +def _title_from_directives(schematic: Schematic) -> str: + """Pick a netlist title. Comments in the .asc are not preserved by the + parser, so use a stable default matching LTspice's ``* `` convention + loosely -- the exact title does not affect simulation.""" + return "LTspice Simulation (netlisted from .asc)" diff --git a/src/mcltspice/runner.py b/src/mcltspice/runner.py index 07791dc..f7b2b2a 100644 --- a/src/mcltspice/runner.py +++ b/src/mcltspice/runner.py @@ -8,13 +8,115 @@ from pathlib import Path from .config import ( DEFAULT_TIMEOUT, - LTSPICE_EXE, MAX_RAW_FILE_SIZE, - get_wine_env, + get_launch, validate_installation, ) from .raw_parser import RawFile, parse_raw_file +# Poll interval and output-stability window for the macOS completion strategy. +# The Mac LTspice is an Aqua GUI app whose exit code can't be trusted, so we +# poll for the .raw/.log to appear and stabilize instead. +_MAC_POLL_INTERVAL = 0.05 +_MAC_STABLE_POLLS = 2 + + +class _MacTimeout: + """Sentinel returned by _run_and_wait when the macOS poll deadline expires.""" + + +async def _run_and_wait( + cmd: list[str], + env: dict[str, str], + work_dir: Path, + raw_file: Path, + log_file: Path, + timeout: float, + is_darwin: bool, +) -> tuple[str, str] | _MacTimeout: + """Launch the simulation and wait for completion. + + Returns (stdout, stderr) on completion, or a _MacTimeout sentinel when the + macOS poll deadline is hit (the process is killed first). + + Two completion strategies: + + - Linux (not is_darwin): unchanged from the historical path -- trust the + process, communicate() with wait_for(timeout), kill on TimeoutError. + - macOS (is_darwin): the exit code is unreliable (GUI app, needs an active + login/GUI session). Launch the same way but poll for raw_file to appear + and its size to hold steady across consecutive polls, with a wall-clock + deadline. Kill and return the sentinel on deadline. + """ + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=env, + cwd=str(work_dir), + ) + + if not is_darwin: + try: + stdout_bytes, stderr_bytes = await asyncio.wait_for( + process.communicate(), timeout=timeout + ) + except TimeoutError: + # Kill and reap the Wine subprocess before the timeout propagates, + # exactly as the historical path did -- otherwise the LTspice + # process is orphaned on every timeout. The caller catches + # TimeoutError and returns the timed-out SimulationResult. + process.kill() + await process.wait() + raise + return ( + stdout_bytes.decode("utf-8", errors="replace"), + stderr_bytes.decode("utf-8", errors="replace"), + ) + + # macOS: poll for the .raw to appear and stabilize; don't trust exit code. + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + last_size = -1 + stable_count = 0 + while loop.time() < deadline: + if process.returncode is None and raw_file.exists(): + size = raw_file.stat().st_size + if size == last_size and size > 0: + stable_count += 1 + if stable_count >= _MAC_STABLE_POLLS: + break + else: + stable_count = 0 + last_size = size + elif process.returncode is not None: + # Process exited; give the .raw one settle check either way. + if raw_file.exists(): + size = raw_file.stat().st_size + if size == last_size and size > 0: + break + last_size = size + else: + break + await asyncio.sleep(_MAC_POLL_INTERVAL) + else: + # Deadline expired without a stable .raw. + process.kill() + await process.wait() + return _MacTimeout() + + # Drain whatever output is available without blocking on a hung GUI process. + try: + stdout_bytes, stderr_bytes = await asyncio.wait_for(process.communicate(), timeout=1.0) + except TimeoutError: + process.kill() + await process.wait() + stdout_bytes, stderr_bytes = b"", b"" + return ( + stdout_bytes.decode("utf-8", errors="replace"), + stderr_bytes.decode("utf-8", errors="replace"), + ) + def _safe_copy(src: Path, dst: Path) -> None: """Copy src to dst, skipping when they are the same file. @@ -110,36 +212,34 @@ async def run_simulation( for f in src_dir.glob(f"*{ext}"): _safe_copy(f, work_dir / f.name) - # Convert path to Windows format for Wine - # Wine maps Z: to root filesystem - win_path = "Z:" + str(work_schematic).replace("/", "\\") + # Resolve the platform launch spec (Wine on Linux, native on macOS). + launch = get_launch() - # Build command - cmd = [ - "wine", - str(LTSPICE_EXE), - "-b", # Batch mode - win_path, - ] + # macOS has no CLI netlister: "LTspice -b file.asc" fails with + # 'Multiple instances of "Flag"'. Convert the schematic to a netlist + # ourselves and run -b on that. On Linux, Wine's internal netlister + # handles the .asc directly, so run_target stays the .asc unchanged. + run_target = work_schematic + if launch.is_darwin: + from .netlister import asc_to_netlist # lazy, darwin-only - # Run simulation - env = get_wine_env() + net_text = asc_to_netlist(work_schematic.read_text(errors="replace")) + run_target = work_schematic.with_suffix(".net") + run_target.write_text(net_text) - process = await asyncio.create_subprocess_exec( - *cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=env, - cwd=str(work_dir), - ) + # Build command (plain -b, never -Run: -Run hangs on macOS). + cmd = [*launch.argv_prefix, "-b", launch.translate_path(run_target)] + env = launch.env + + # Output files derive from the run target's stem. + raw_file = run_target.with_suffix(".raw") + log_file = run_target.with_suffix(".log") try: - stdout_bytes, stderr_bytes = await asyncio.wait_for( - process.communicate(), timeout=timeout + outcome = await _run_and_wait( + cmd, env, work_dir, raw_file, log_file, timeout, launch.is_darwin ) except TimeoutError: - process.kill() - await process.wait() return SimulationResult( success=False, raw_file=None, @@ -151,15 +251,31 @@ async def run_simulation( elapsed_seconds=time.monotonic() - start_time, ) - stdout = stdout_bytes.decode("utf-8", errors="replace") - stderr = stderr_bytes.decode("utf-8", errors="replace") + if isinstance(outcome, _MacTimeout): + # macOS poll deadline hit. Distinguish "no GUI session" (nothing + # was ever written) from a genuine simulation timeout. + if not raw_file.exists() and not log_file.exists(): + err = ( + "No .raw/.log produced within " + f"{timeout} seconds -- LTspice may require an active GUI/login " + "session on macOS (it will not run over a bare SSH session)." + ) + else: + err = f"Simulation timed out after {timeout} seconds" + return SimulationResult( + success=False, + raw_file=None, + log_file=log_file if log_file.exists() else None, + raw_data=None, + error=err, + stdout="", + stderr="", + elapsed_seconds=time.monotonic() - start_time, + ) + stdout, stderr = outcome elapsed = time.monotonic() - start_time - # Look for output files - raw_file = work_schematic.with_suffix(".raw") - log_file = work_schematic.with_suffix(".log") - if not raw_file.exists(): # Check for error in log error_msg = "Simulation failed - no .raw file produced" @@ -287,26 +403,20 @@ async def run_netlist( for f in src_dir.glob(f"*{ext}"): _safe_copy(f, work_dir / f.name) - win_path = "Z:" + str(work_netlist).replace("/", "\\") + # simulate_netlist already works unchanged on macOS (native -b consumes + # the .net directly); only the launch/completion abstraction differs. + launch = get_launch() + cmd = [*launch.argv_prefix, "-b", launch.translate_path(work_netlist)] + env = launch.env - cmd = ["wine", str(LTSPICE_EXE), "-b", win_path] - env = get_wine_env() - - process = await asyncio.create_subprocess_exec( - *cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=env, - cwd=str(work_dir), - ) + raw_file = work_netlist.with_suffix(".raw") + log_file = work_netlist.with_suffix(".log") try: - stdout_bytes, stderr_bytes = await asyncio.wait_for( - process.communicate(), timeout=timeout + outcome = await _run_and_wait( + cmd, env, work_dir, raw_file, log_file, timeout, launch.is_darwin ) except TimeoutError: - process.kill() - await process.wait() return SimulationResult( success=False, raw_file=None, @@ -318,12 +428,28 @@ async def run_netlist( elapsed_seconds=time.monotonic() - start_time, ) - stdout = stdout_bytes.decode("utf-8", errors="replace") - stderr = stderr_bytes.decode("utf-8", errors="replace") - elapsed = time.monotonic() - start_time + if isinstance(outcome, _MacTimeout): + if not raw_file.exists() and not log_file.exists(): + err = ( + "No .raw/.log produced within " + f"{timeout} seconds -- LTspice may require an active GUI/login " + "session on macOS (it will not run over a bare SSH session)." + ) + else: + err = f"Simulation timed out after {timeout} seconds" + return SimulationResult( + success=False, + raw_file=None, + log_file=log_file if log_file.exists() else None, + raw_data=None, + error=err, + stdout="", + stderr="", + elapsed_seconds=time.monotonic() - start_time, + ) - raw_file = work_netlist.with_suffix(".raw") - log_file = work_netlist.with_suffix(".log") + stdout, stderr = outcome + elapsed = time.monotonic() - start_time if not raw_file.exists(): error_msg = "Simulation failed - no .raw file produced" diff --git a/tests/test_netlister.py b/tests/test_netlister.py new file mode 100644 index 0000000..f9f0728 --- /dev/null +++ b/tests/test_netlister.py @@ -0,0 +1,365 @@ +"""Tests for the pure-Python .asc -> SPICE netlist converter (netlister.py). + +On macOS there is no CLI netlister -- ``LTspice -b file.asc`` mis-parses the +schematic and dies with ``Multiple instances of "Flag"``. ``asc_to_netlist`` +bridges that gap in Python so the darwin branch of ``runner.run_simulation`` +can convert an .asc to .net text and run the already-working ``-b`` path. + +Unit tests here need no LTspice: they feed mcltspice-generated template +schematics through ``asc_to_netlist`` and assert the emitted netlist has the +right devices, nodes, and directives, and that garbage degrades loudly with a +``ValueError`` (never a silently-wrong netlist). + +The final class is an ``@pytest.mark.integration`` oracle test that compares +``asc_to_netlist`` topology against Wine's ``LTspice -netlist`` on the same +schematic (graph isomorphism of the net partition, not byte-identity -- the +synthesized N00x names are cosmetic and don't match LTspice's numbering). +""" + +import re +import subprocess +import tempfile +from collections import defaultdict +from pathlib import Path + +import pytest + +from mcltspice.asc_generator import ( + generate_buck_converter, + generate_colpitts_oscillator, + generate_common_emitter_amp, + generate_differential_amp, + generate_inverting_amp, + generate_non_inverting_amp, + generate_rc_lowpass, + generate_voltage_divider, +) +from mcltspice.netlister import asc_to_netlist + +# --------------------------------------------------------------------------- +# Small netlist-parsing helpers (shared by unit + integration tests) +# --------------------------------------------------------------------------- + +# Number of nodes each SPICE device letter emits before its value/model token. +_NODE_COUNT = {"R": 2, "C": 2, "L": 2, "V": 2, "I": 2, "D": 2, "Q": 4, "M": 4, "X": 5} + + +def _device_lines(netlist_text: str) -> list[str]: + """Return the device (non-comment, non-directive) lines of a netlist.""" + lines = [] + for raw in netlist_text.splitlines(): + line = raw.strip() + if not line or line.startswith(("*", ".")): + continue + lines.append(line) + return lines + + +def _device_names(netlist_text: str) -> set[str]: + """Instance names of every device line (e.g. {'R1', 'C1', 'V1'}).""" + return {line.split()[0] for line in _device_lines(netlist_text)} + + +def _norm_name(name: str) -> str: + """Normalize a device name for cross-tool comparison. + + Wine emits an op-amp subcircuit as ``§U1`` (LTspice's ``X§`` prefix + collapses); asc_to_netlist emits ``XU1``. Strip the leading X / § so the two + refer to the same device. + """ + return name.lstrip("X").lstrip("§") + + +def _parse_devices(netlist_text: str) -> dict[str, list[str]]: + """Map normalized device name -> ordered node list.""" + devs: dict[str, list[str]] = {} + for line in _device_lines(netlist_text): + toks = line.split() + name = toks[0] + nnodes = _NODE_COUNT.get(name[0].upper()) + if nnodes is None: + continue + devs[_norm_name(name)] = toks[1 : 1 + nnodes] + return devs + + +def _net_partition(devs: dict[str, list[str]]) -> frozenset: + """Canonical topology signature: the partition of (device, pin) pairs into + nets, as a frozenset of frozensets. Two netlists that connect the same pins + to the same electrical nodes produce equal partitions regardless of how the + nets are named.""" + net_to_pins: dict[str, set] = defaultdict(set) + for dev, nodes in devs.items(): + for pin_index, node in enumerate(nodes): + net_to_pins[node].add((dev, pin_index)) + return frozenset(frozenset(pins) for pins in net_to_pins.values()) + + +# --------------------------------------------------------------------------- +# Unit: conversion of generated templates +# --------------------------------------------------------------------------- + + +class TestAscToNetlistRC: + """The canonical RC lowpass -- the simplest end-to-end conversion.""" + + def test_returns_netlist_text(self): + net = asc_to_netlist(generate_rc_lowpass().render()) + assert isinstance(net, str) + assert net.endswith("\n") + + def test_has_all_three_devices(self): + net = asc_to_netlist(generate_rc_lowpass(r="1k", c="100n").render()) + assert _device_names(net) == {"V1", "R1", "C1"} + + def test_component_values_preserved(self): + net = asc_to_netlist(generate_rc_lowpass(r="4.7k", c="22n").render()) + assert re.search(r"^R1 \S+ \S+ 4\.7k$", net, re.MULTILINE) + assert re.search(r"^C1 \S+ \S+ 22n$", net, re.MULTILINE) + + def test_named_nets_and_ground_present(self): + net = asc_to_netlist(generate_rc_lowpass().render()) + # 'out' net label survives; ground '0' is used. + assert "out" in net + devs = _parse_devices(net) + assert "0" in devs["C1"] # capacitor bottom is grounded + + def test_ac_directive_carried_through(self): + net = asc_to_netlist(generate_rc_lowpass().render()) + assert ".ac dec 100 1 1meg" in net + + def test_terminated_with_backanno_and_end(self): + net = asc_to_netlist(generate_rc_lowpass().render()) + assert ".backanno" in net + assert net.rstrip().endswith(".end") + + +class TestAscToNetlistVoltageDivider: + def test_devices_and_op_directive(self): + net = asc_to_netlist(generate_voltage_divider(r1="10k", r2="22k").render()) + assert _device_names(net) == {"V1", "R1", "R2"} + assert ".op" in net + + def test_series_resistor_topology(self): + """R1 and R2 share exactly one node (the 'out' junction).""" + net = asc_to_netlist(generate_voltage_divider().render()) + devs = _parse_devices(net) + shared = set(devs["R1"]) & set(devs["R2"]) + assert "out" in shared + + +class TestAscToNetlistBJT: + """Common-emitter amp: exercises the implicit-substrate-ground BJT rule.""" + + def test_all_devices_present(self): + net = asc_to_netlist(generate_common_emitter_amp().render()) + assert _device_names(net) == { + "Q1", + "RC", + "RE", + "RB1", + "RB2", + "CC1", + "CC2", + "CE", + "Vcc", + "Vin", + } + + def test_bjt_gets_four_nodes_with_ground_substrate(self): + net = asc_to_netlist(generate_common_emitter_amp().render()) + devs = _parse_devices(net) + # Q1 = collector base emitter + implicit '0' substrate = 4 nodes. + assert len(devs["Q1"]) == 4 + assert devs["Q1"][-1] == "0" + + def test_bjt_model_name_preserved(self): + net = asc_to_netlist( + generate_common_emitter_amp(bjt_model="2N3904").render() + ) + q_line = next(ln for ln in _device_lines(net) if ln.startswith("Q1")) + assert q_line.endswith("2N3904") + + +class TestAscToNetlistMOSFET: + """Buck converter: 3-pin NMOS body-repeats-source + diode.""" + + def test_devices_present(self): + net = asc_to_netlist(generate_buck_converter().render()) + assert _device_names(net) == { + "Vin", + "Vgate", + "M1", + "D1", + "L1", + "Cout", + "Rload", + } + + def test_mosfet_body_repeats_source(self): + net = asc_to_netlist(generate_buck_converter().render()) + devs = _parse_devices(net) + # 3-pin MOSFET -> [D, G, S, S]; body node equals source node. + assert len(devs["M1"]) == 4 + assert devs["M1"][2] == devs["M1"][3] + + def test_switch_node_named(self): + net = asc_to_netlist(generate_buck_converter().render()) + assert "sw" in net + + +class TestAscToNetlistOpAmp: + """Op-amp templates: exercise the UniversalOpamp2 subcircuit + .lib inject.""" + + def test_opamp_emits_subcircuit_instance(self): + net = asc_to_netlist(generate_inverting_amp().render()) + # Subcircuit call must start with X. + x_lines = [ln for ln in _device_lines(net) if ln.startswith("X")] + assert len(x_lines) == 1 + assert x_lines[0].split()[0].upper().startswith("XU1") + + def test_opamp_lib_injected(self): + net = asc_to_netlist(generate_inverting_amp().render()) + assert ".lib UniversalOpAmp2.lib" in net + + def test_opamp_has_five_nodes(self): + net = asc_to_netlist(generate_non_inverting_amp().render()) + devs = _parse_devices(net) + opamp = next(k for k in devs if k.upper().startswith("U1")) + assert len(devs[opamp]) == 5 + + +@pytest.mark.parametrize( + "gen", + [ + generate_rc_lowpass, + generate_voltage_divider, + generate_common_emitter_amp, + generate_colpitts_oscillator, + generate_buck_converter, + generate_inverting_amp, + generate_non_inverting_amp, + generate_differential_amp, + ], +) +def test_all_templates_convert_without_error(gen): + """Every shipped template converts to a non-trivial, well-terminated + netlist. Guards against a template regressing the netlister.""" + net = asc_to_netlist(gen().render()) + assert _device_lines(net), "netlist has no device lines" + assert net.rstrip().endswith(".end") + # Every device line has at least name + 2 nodes + value. + for line in _device_lines(net): + assert len(line.split()) >= 4, f"under-specified device line: {line!r}" + + +# --------------------------------------------------------------------------- +# Unit: graceful failure on bad input +# --------------------------------------------------------------------------- + + +class TestAscToNetlistErrors: + def test_empty_string_raises_valueerror(self): + with pytest.raises(ValueError): + asc_to_netlist("") + + def test_garbage_text_raises_valueerror(self): + with pytest.raises(ValueError): + asc_to_netlist("this is not a schematic\njust some random junk\n") + + def test_no_components_raises_valueerror(self): + asc = "Version 4\nSHEET 1 880 680\nWIRE 0 0 80 0\n" + with pytest.raises(ValueError, match="no components"): + asc_to_netlist(asc) + + def test_unknown_symbol_raises_with_helpful_message(self): + asc = ( + "Version 4\nSHEET 1 880 680\n" + "SYMBOL totally_made_up 80 80 R0\n" + "SYMATTR InstName X1\nSYMATTR Value foo\n" + ) + with pytest.raises(ValueError, match="pin geometry|symbol"): + asc_to_netlist(asc) + + def test_mirrored_component_raises(self): + asc = ( + "Version 4\nSHEET 1 880 680\n" + "WIRE 16 16 16 96\n" + "FLAG 16 96 0\n" + "SYMBOL res 0 0 M0\n" + "SYMATTR InstName R1\nSYMATTR Value 1k\n" + ) + with pytest.raises(ValueError, match="mirror"): + asc_to_netlist(asc) + + +# --------------------------------------------------------------------------- +# Integration: compare against the Wine -netlist oracle +# --------------------------------------------------------------------------- + + +def _wine_netlist(asc_text: str) -> str | None: + """Produce a netlist via Wine's ``LTspice -netlist``. Returns the .net text + or None if Wine didn't produce output.""" + from mcltspice.config import LTSPICE_EXE, get_wine_env + + env = get_wine_env() + with tempfile.TemporaryDirectory() as tmp: + asc_path = Path(tmp) / "oracle.asc" + asc_path.write_text(asc_text, encoding="utf-8") + win_path = "Z:" + str(asc_path).replace("/", chr(92)) + subprocess.run( + ["wine", str(LTSPICE_EXE), "-netlist", win_path], + env=env, + capture_output=True, + timeout=90, + ) + net_path = asc_path.with_suffix(".net") + if not net_path.exists(): + return None + return net_path.read_text(encoding="utf-8", errors="replace") + + +@pytest.mark.integration +class TestNetlisterAgainstWineOracle: + """asc_to_netlist topology must match Wine's native netlister. + + Compared by graph isomorphism of the net partition (which pins connect to a + common node), NOT by byte diff: the synthesized N00x names are cosmetic and + intentionally do not match LTspice's internal numbering. + """ + + @pytest.mark.parametrize( + "gen", + [ + generate_rc_lowpass, + generate_voltage_divider, + generate_common_emitter_amp, + generate_colpitts_oscillator, + generate_buck_converter, + generate_inverting_amp, + generate_non_inverting_amp, + generate_differential_amp, + ], + ) + def test_topology_matches_oracle(self, gen, ltspice_available): + asc = gen().render() + wine_net = _wine_netlist(asc) + if wine_net is None: + pytest.skip("Wine -netlist produced no output on this box") + + mine = asc_to_netlist(asc) + wine_devs = _parse_devices(wine_net) + my_devs = _parse_devices(mine) + + # Same set of devices (after §/X name normalization). + assert set(my_devs) == set(wine_devs), ( + f"device set differs: mine={sorted(my_devs)} " + f"wine={sorted(wine_devs)}" + ) + + # Same electrical topology (net partition isomorphism). + assert _net_partition(my_devs) == _net_partition(wine_devs), ( + "net topology differs from Wine oracle:\n" + f" mine={my_devs}\n wine={wine_devs}" + ) diff --git a/tests/test_platform_config.py b/tests/test_platform_config.py new file mode 100644 index 0000000..516ad81 --- /dev/null +++ b/tests/test_platform_config.py @@ -0,0 +1,222 @@ +"""Platform resolution + path-translation tests for the macOS native port. + +These assert that config.py resolves the right binary, argv prefix, lib root, +environment, and path-translation strategy on each platform -- and that the +LTSPICE_BIN / LTSPICE_LIB_ROOT env overrides win. No LTspice or Wine is needed: +config is re-imported under a monkeypatched sys.platform so the module-level +platform branch re-evaluates. + +Regression guardrails for the HARD CONSTRAINT: the Linux/Wine path must stay +byte-for-byte unchanged -- Wine argv, WINE* env, and the "Z:" drive mapping. +""" + +import importlib +import sys +from pathlib import Path + +import pytest + + +def _reimport_config(monkeypatch, platform: str, env: dict[str, str] | None = None): + """Re-import mcltspice.config with sys.platform and env forced. + + config.py computes IS_DARWIN / LTSPICE_BIN / LTSPICE_LIB_ROOT at import + time, so the module must be reloaded after patching sys.platform for the + platform branch to take effect. Returns the freshly-reloaded module. + """ + monkeypatch.setattr(sys, "platform", platform) + # Clear any LTspice env the host may have set, then apply the test's env. + for var in ( + "LTSPICE_DIR", + "LTSPICE_BIN", + "LTSPICE_LIB_ROOT", + "LTSPICE_EXAMPLES", + ): + monkeypatch.delenv(var, raising=False) + for key, value in (env or {}).items(): + monkeypatch.setenv(key, value) + + import mcltspice.config as config_module + + return importlib.reload(config_module) + + +@pytest.fixture(autouse=True) +def _restore_config(): + """Reload config with the real sys.platform after each test. + + Prevents a monkeypatched darwin/linux config from leaking into other test + modules that import mcltspice.config. + """ + yield + import mcltspice.config as config_module + + importlib.reload(config_module) + + +# --------------------------------------------------------------------------- +# Platform detection +# --------------------------------------------------------------------------- + + +class TestPlatformDetection: + def test_darwin_sets_is_darwin(self, monkeypatch): + cfg = _reimport_config(monkeypatch, "darwin") + assert cfg.IS_DARWIN is True + + def test_linux_clears_is_darwin(self, monkeypatch): + cfg = _reimport_config(monkeypatch, "linux") + assert cfg.IS_DARWIN is False + + +# --------------------------------------------------------------------------- +# Binary resolution + argv prefix +# --------------------------------------------------------------------------- + + +class TestBinaryResolution: + def test_darwin_default_binary(self, monkeypatch): + cfg = _reimport_config(monkeypatch, "darwin") + assert cfg.LTSPICE_BIN == Path( + "/Applications/LTspice.app/Contents/MacOS/LTspice" + ) + + def test_darwin_binary_env_override_wins(self, monkeypatch): + cfg = _reimport_config( + monkeypatch, "darwin", {"LTSPICE_BIN": "/opt/custom/LTspice"} + ) + assert cfg.LTSPICE_BIN == Path("/opt/custom/LTspice") + + def test_darwin_argv_prefix_is_bare_binary_no_wine(self, monkeypatch): + cfg = _reimport_config(monkeypatch, "darwin") + launch = cfg.get_launch() + assert launch.is_darwin is True + assert launch.argv_prefix == [str(cfg.LTSPICE_BIN)] + # No wine anywhere in the invocation on darwin. + assert "wine" not in launch.argv_prefix + assert launch.argv_prefix[0].endswith("LTspice") + + def test_darwin_argv_prefix_honors_bin_override(self, monkeypatch): + cfg = _reimport_config( + monkeypatch, "darwin", {"LTSPICE_BIN": "/opt/custom/LTspice"} + ) + launch = cfg.get_launch() + assert launch.argv_prefix == ["/opt/custom/LTspice"] + + def test_linux_argv_prefix_is_wine_exe(self, monkeypatch): + cfg = _reimport_config(monkeypatch, "linux") + launch = cfg.get_launch() + assert launch.is_darwin is False + assert launch.argv_prefix[0] == "wine" + assert launch.argv_prefix[1] == str(cfg.LTSPICE_EXE) + assert launch.argv_prefix[1].endswith("LTspice.exe") + + def test_linux_dir_env_override_flows_to_exe(self, monkeypatch): + cfg = _reimport_config( + monkeypatch, "linux", {"LTSPICE_DIR": "/custom/ltspice"} + ) + assert cfg.LTSPICE_DIR == Path("/custom/ltspice") + assert cfg.LTSPICE_EXE == Path("/custom/ltspice/LTspice.exe") + launch = cfg.get_launch() + assert launch.argv_prefix == ["wine", "/custom/ltspice/LTspice.exe"] + + +# --------------------------------------------------------------------------- +# Lib-root resolution +# --------------------------------------------------------------------------- + + +class TestLibRootResolution: + def test_darwin_lib_root_default_outside_app_bundle(self, monkeypatch): + cfg = _reimport_config(monkeypatch, "darwin") + expected = Path.home() / "Library" / "Application Support" / "LTspice" + assert cfg.LTSPICE_LIB_ROOT == expected + # lib lives UNDER the resolved root, not glued onto the executable dir. + assert cfg.LTSPICE_LIB == expected / "lib" + + def test_darwin_lib_root_env_override_wins(self, monkeypatch): + cfg = _reimport_config( + monkeypatch, "darwin", {"LTSPICE_LIB_ROOT": "/data/ltspice-lib"} + ) + assert cfg.LTSPICE_LIB_ROOT == Path("/data/ltspice-lib") + assert cfg.LTSPICE_LIB == Path("/data/ltspice-lib/lib") + + def test_darwin_examples_under_lib_root(self, monkeypatch): + cfg = _reimport_config( + monkeypatch, "darwin", {"LTSPICE_LIB_ROOT": "/data/ltspice-lib"} + ) + assert cfg.LTSPICE_EXAMPLES == Path("/data/ltspice-lib/examples") + + def test_linux_lib_root_defaults_to_dir(self, monkeypatch): + cfg = _reimport_config( + monkeypatch, "linux", {"LTSPICE_DIR": "/custom/ltspice"} + ) + # Historical behavior: LTSPICE_LIB resolves to LTSPICE_DIR / "lib". + assert cfg.LTSPICE_LIB == Path("/custom/ltspice/lib") + assert cfg.LTSPICE_EXAMPLES == Path("/custom/ltspice/examples") + + def test_lib_root_is_resolved_value_not_string_concat(self, monkeypatch): + """Lib root must be a Path derived from an env-overridable root, not a + string glued onto the binary's directory.""" + cfg = _reimport_config( + monkeypatch, "darwin", {"LTSPICE_BIN": "/opt/custom/LTspice"} + ) + # Changing the binary must NOT drag the lib root along with it. + assert str(cfg.LTSPICE_BIN.parent) not in str(cfg.LTSPICE_LIB_ROOT) + + +# --------------------------------------------------------------------------- +# Environment: Wine vars only on Linux +# --------------------------------------------------------------------------- + + +class TestLaunchEnv: + def test_darwin_env_has_no_wine_vars(self, monkeypatch): + cfg = _reimport_config(monkeypatch, "darwin") + launch = cfg.get_launch() + assert "WINEPREFIX" not in launch.env + assert "WINEARCH" not in launch.env + assert "WINEDEBUG" not in launch.env + + def test_linux_env_sets_wine_vars(self, monkeypatch): + cfg = _reimport_config( + monkeypatch, "linux", {"LTSPICE_DIR": "/custom/ltspice"} + ) + launch = cfg.get_launch() + assert launch.env["WINEPREFIX"] == str(cfg.WINE_PREFIX) + assert launch.env["WINEARCH"] == "win64" + assert "WINEDEBUG" in launch.env + + +# --------------------------------------------------------------------------- +# Path translation: identity on macOS, Z:\ mapping on Linux +# --------------------------------------------------------------------------- + + +class TestPathTranslation: + def test_darwin_path_translation_is_identity(self, monkeypatch): + cfg = _reimport_config(monkeypatch, "darwin") + launch = cfg.get_launch() + p = Path("/Users/kevin/work/circuit.net") + assert launch.translate_path(p) == "/Users/kevin/work/circuit.net" + # No drive letter, no backslashes. + assert "Z:" not in launch.translate_path(p) + assert "\\" not in launch.translate_path(p) + + def test_linux_path_translation_maps_z_drive(self, monkeypatch): + cfg = _reimport_config(monkeypatch, "linux") + launch = cfg.get_launch() + p = Path("/home/rpm/work/circuit.net") + translated = launch.translate_path(p) + assert translated == "Z:\\home\\rpm\\work\\circuit.net" + assert translated.startswith("Z:") + assert "/" not in translated + + def test_linux_translation_matches_historical_wine_mapping(self, monkeypatch): + """Byte-for-byte reproduction of the historical + 'Z:' + str(path).replace('/', chr(92)) transform.""" + cfg = _reimport_config(monkeypatch, "linux") + launch = cfg.get_launch() + p = Path("/tmp/mcltspice_work/abc/def.raw") + expected = "Z:" + str(p).replace("/", chr(92)) + assert launch.translate_path(p) == expected