Merge macos-port: native macOS support (no Wine), hardware-validated

Port validated end-to-end on real Apple Silicon (macOS 26.5.2, LTspice XVII
17.2.4): full suite 534 passed / 8 skipped on native Mac; Linux unchanged
(524 unit + 18 integration via Wine). Platform-branches execution so Linux
behaves identically. See commits for details.
This commit is contained in:
Ryan Malloy 2026-07-03 17:30:38 -06:00
commit 1debe608d4
15 changed files with 1550 additions and 78 deletions

View File

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

View File

@ -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' },
],
},

View File

@ -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.
</Aside>
## 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.
<Aside type="note">
Native macOS support is newer than the Linux/Wine path and is being validated. The `.raw`/`.log`/`.net` files LTspice produces are byte-compatible across Windows, Wine, and native macOS, so everything downstream of the run — the parser and the entire analysis layer — is identical on both platforms.
</Aside>
## 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.

View File

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

View File

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

View File

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

View File

@ -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).
<Aside type="note">
Native macOS support is new and under active validation. The Linux/Wine path is the mature one. If you hit a rough edge on macOS, the [check_installation](#verify-the-setup) tool is the fastest way to see what the server detected.
</Aside>
<Tabs>
<TabItem label="Linux (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.
</Aside>
</TabItem>
<TabItem label="macOS">
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.
<Steps>
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
```
</Steps>
## 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.
<Aside type="caution">
**GUI-session requirement.** Mac LTspice is an Aqua GUI application. It runs batch simulations, but it needs an active login/GUI session — it will **not** run over a bare SSH connection with no logged-in desktop. This is the opposite of the Linux/Wine path, which runs fully headless. Run mcltspice from a Mac that has a user logged in at the desktop.
</Aside>
</TabItem>
</Tabs>
## 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.

View File

@ -27,7 +27,7 @@ import { Card, CardGrid } from '@astrojs/starlight/components';
[Browse tutorials](/tutorials/)
</Card>
<Card title="Concepts" icon="list-format">
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/)
</Card>
<Card title="Reference" icon="setting">

View File

@ -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",

View File

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

View File

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

425
src/mcltspice/netlister.py Normal file
View File

@ -0,0 +1,425 @@
"""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"
# 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:
"""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)
)
# 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)
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 ``* <path>`` convention
loosely -- the exact title does not affect simulation."""
return "LTspice Simulation (netlisted from .asc)"

View File

@ -8,13 +8,152 @@ 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 _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.
@ -110,36 +249,35 @@ 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"))
net_text = _resolve_lib_paths(net_text)
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 +289,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 +441,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 +466,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"

370
tests/test_netlister.py Normal file
View File

@ -0,0 +1,370 @@
"""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 shutil
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
@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.
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}"
)

View File

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