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.
This commit is contained in:
parent
9c252606f7
commit
37caaf0310
26
README.md
26
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)
|
||||
|
||||
@ -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' },
|
||||
],
|
||||
},
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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/).
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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/)
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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),
|
||||
|
||||
406
src/mcltspice/netlister.py
Normal file
406
src/mcltspice/netlister.py
Normal file
@ -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 ``* <path>`` convention
|
||||
loosely -- the exact title does not affect simulation."""
|
||||
return "LTspice Simulation (netlisted from .asc)"
|
||||
@ -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"
|
||||
|
||||
365
tests/test_netlister.py
Normal file
365
tests/test_netlister.py
Normal file
@ -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}"
|
||||
)
|
||||
222
tests/test_platform_config.py
Normal file
222
tests/test_platform_config.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user