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
|
# 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
|
## Quick Start
|
||||||
|
|
||||||
@ -17,8 +19,8 @@ claude mcp add mcltspice -- uv run --directory /path/to/mcltspice mcltspice
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- **Linux** with Wine installed
|
- **Linux** with Wine installed, OR **macOS** (Apple Silicon or Intel) with LTspice.app
|
||||||
- **LTspice** extracted from the Windows installer (see [Setup](#ltspice-setup))
|
- **LTspice** — on Linux, extracted from the Windows installer; on macOS, the native app (see [Setup](#ltspice-setup))
|
||||||
- **Python 3.11+**
|
- **Python 3.11+**
|
||||||
|
|
||||||
## Tools
|
## 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 |
|
| `get_symbol_info` | Pin details and attributes for a component symbol |
|
||||||
| `search_spice_models` | Search `.model` definitions in the library |
|
| `search_spice_models` | Search `.model` definitions in the library |
|
||||||
| `search_spice_subcircuits` | Search `.subckt` definitions (op-amps, ICs, etc.) |
|
| `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
|
## Resources
|
||||||
|
|
||||||
@ -119,6 +121,8 @@ claude mcp add mcltspice -- uv run --directory /path/to/mcltspice mcltspice
|
|||||||
|
|
||||||
## LTspice Setup
|
## LTspice Setup
|
||||||
|
|
||||||
|
### Linux (Wine)
|
||||||
|
|
||||||
Extract LTspice from the Windows MSI installer:
|
Extract LTspice from the Windows MSI installer:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -136,6 +140,20 @@ wineboot --init
|
|||||||
|
|
||||||
Set `LTSPICE_DIR` to point at your extracted directory, or use the default `~/claude/ltspice/extracted/ltspice`.
|
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
|
## Repository
|
||||||
|
|
||||||
[git.supported.systems/MCP/mcltspice](https://git.supported.systems/MCP/mcltspice)
|
[git.supported.systems/MCP/mcltspice](https://git.supported.systems/MCP/mcltspice)
|
||||||
|
|||||||
@ -64,7 +64,7 @@ export default defineConfig({
|
|||||||
label: 'Concepts',
|
label: 'Concepts',
|
||||||
collapsed: true,
|
collapsed: true,
|
||||||
items: [
|
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' },
|
{ label: 'Simulation Types', slug: 'concepts/simulation-types' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
title: LTspice on Linux
|
title: How mcltspice drives LTspice
|
||||||
description: How mcltspice drives a Windows application via Wine.
|
description: How mcltspice runs LTspice in batch mode — via Wine on Linux, natively on macOS.
|
||||||
---
|
---
|
||||||
|
|
||||||
import { Aside } from '@astrojs/starlight/components';
|
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
|
## 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.
|
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>
|
</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
|
## 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.
|
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()
|
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**
|
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")
|
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:
|
In five tool calls, you:
|
||||||
|
|
||||||
- Generated a complete SPICE netlist from a template
|
- 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
|
- Extracted complex-valued frequency-domain data from the binary `.raw` file
|
||||||
- Measured the -3dB bandwidth automatically
|
- Measured the -3dB bandwidth automatically
|
||||||
- Produced a publication-ready Bode plot
|
- 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';
|
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
|
## 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**
|
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/)
|
[Prerequisites details](/getting-started/prerequisites/)
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,19 @@
|
|||||||
---
|
---
|
||||||
title: Prerequisites
|
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';
|
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.
|
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
|
## 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.
|
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>
|
</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
|
## 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()
|
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
|
- Wine availability and version
|
||||||
- LTspice binary presence
|
- LTspice binary presence
|
||||||
- Library and symbol directories
|
- Library and symbol directories
|
||||||
- Write permissions for output files
|
- 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.
|
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/)
|
[Browse tutorials](/tutorials/)
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="Concepts" icon="list-format">
|
<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/)
|
[Read concepts](/concepts/ltspice-on-linux/)
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="Reference" icon="setting">
|
<Card title="Reference" icon="setting">
|
||||||
|
|||||||
@ -15,6 +15,8 @@ classifiers = [
|
|||||||
"Intended Audience :: Science/Research",
|
"Intended Audience :: Science/Research",
|
||||||
"Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)",
|
"Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)",
|
||||||
"License :: OSI Approved :: MIT License",
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Operating System :: POSIX :: Linux",
|
||||||
|
"Operating System :: MacOS",
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
"Programming Language :: Python :: 3.13",
|
"Programming Language :: Python :: 3.13",
|
||||||
|
|||||||
@ -1,16 +1,57 @@
|
|||||||
"""Configuration for mcltspice."""
|
"""Configuration for mcltspice."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
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(
|
LTSPICE_DIR = Path(
|
||||||
os.environ.get("LTSPICE_DIR", Path.home() / "claude" / "ltspice" / "extracted" / "ltspice")
|
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_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 configuration
|
||||||
WINE_PREFIX = LTSPICE_DIR / ".wine"
|
WINE_PREFIX = LTSPICE_DIR / ".wine"
|
||||||
@ -47,12 +88,70 @@ def get_wine_env() -> dict[str, str]:
|
|||||||
return env
|
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]:
|
def validate_installation() -> tuple[bool, str]:
|
||||||
"""Check if LTspice is properly installed."""
|
"""Check if LTspice is properly installed."""
|
||||||
if not LTSPICE_DIR.exists():
|
if not IS_DARWIN:
|
||||||
return False, f"LTspice directory not found: {LTSPICE_DIR}"
|
if not LTSPICE_DIR.exists():
|
||||||
if not LTSPICE_EXE.exists():
|
return False, f"LTspice directory not found: {LTSPICE_DIR}"
|
||||||
return False, f"LTspice executable not found: {LTSPICE_EXE}"
|
if not LTSPICE_EXE.exists():
|
||||||
if not WINE_PREFIX.exists():
|
return False, f"LTspice executable not found: {LTSPICE_EXE}"
|
||||||
return False, f"Wine prefix not found: {WINE_PREFIX}"
|
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"
|
return True, "LTspice installation OK"
|
||||||
|
|||||||
@ -11,10 +11,13 @@ from pathlib import Path
|
|||||||
|
|
||||||
from ._app import mcp
|
from ._app import mcp
|
||||||
from .config import (
|
from .config import (
|
||||||
|
IS_DARWIN,
|
||||||
|
LTSPICE_BIN,
|
||||||
LTSPICE_DIR,
|
LTSPICE_DIR,
|
||||||
LTSPICE_EXAMPLES,
|
LTSPICE_EXAMPLES,
|
||||||
LTSPICE_EXE,
|
LTSPICE_EXE,
|
||||||
LTSPICE_LIB,
|
LTSPICE_LIB,
|
||||||
|
LTSPICE_LIB_ROOT,
|
||||||
WINE_PREFIX,
|
WINE_PREFIX,
|
||||||
validate_installation,
|
validate_installation,
|
||||||
)
|
)
|
||||||
@ -79,12 +82,89 @@ def list_examples_impl(
|
|||||||
return {"examples": examples[:limit], "total_count": total, "returned_count": min(limit, total)}
|
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:
|
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()
|
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 {
|
return {
|
||||||
"valid": ok,
|
"valid": ok,
|
||||||
"message": msg,
|
"message": msg,
|
||||||
|
"platform": "linux",
|
||||||
"paths": {
|
"paths": {
|
||||||
"ltspice_dir": str(LTSPICE_DIR),
|
"ltspice_dir": str(LTSPICE_DIR),
|
||||||
"ltspice_exe": str(LTSPICE_EXE),
|
"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 (
|
from .config import (
|
||||||
DEFAULT_TIMEOUT,
|
DEFAULT_TIMEOUT,
|
||||||
LTSPICE_EXE,
|
|
||||||
MAX_RAW_FILE_SIZE,
|
MAX_RAW_FILE_SIZE,
|
||||||
get_wine_env,
|
get_launch,
|
||||||
validate_installation,
|
validate_installation,
|
||||||
)
|
)
|
||||||
from .raw_parser import RawFile, parse_raw_file
|
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:
|
def _safe_copy(src: Path, dst: Path) -> None:
|
||||||
"""Copy src to dst, skipping when they are the same file.
|
"""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}"):
|
for f in src_dir.glob(f"*{ext}"):
|
||||||
_safe_copy(f, work_dir / f.name)
|
_safe_copy(f, work_dir / f.name)
|
||||||
|
|
||||||
# Convert path to Windows format for Wine
|
# Resolve the platform launch spec (Wine on Linux, native on macOS).
|
||||||
# Wine maps Z: to root filesystem
|
launch = get_launch()
|
||||||
win_path = "Z:" + str(work_schematic).replace("/", "\\")
|
|
||||||
|
|
||||||
# Build command
|
# macOS has no CLI netlister: "LTspice -b file.asc" fails with
|
||||||
cmd = [
|
# 'Multiple instances of "Flag"'. Convert the schematic to a netlist
|
||||||
"wine",
|
# ourselves and run -b on that. On Linux, Wine's internal netlister
|
||||||
str(LTSPICE_EXE),
|
# handles the .asc directly, so run_target stays the .asc unchanged.
|
||||||
"-b", # Batch mode
|
run_target = work_schematic
|
||||||
win_path,
|
if launch.is_darwin:
|
||||||
]
|
from .netlister import asc_to_netlist # lazy, darwin-only
|
||||||
|
|
||||||
# Run simulation
|
net_text = asc_to_netlist(work_schematic.read_text(errors="replace"))
|
||||||
env = get_wine_env()
|
run_target = work_schematic.with_suffix(".net")
|
||||||
|
run_target.write_text(net_text)
|
||||||
|
|
||||||
process = await asyncio.create_subprocess_exec(
|
# Build command (plain -b, never -Run: -Run hangs on macOS).
|
||||||
*cmd,
|
cmd = [*launch.argv_prefix, "-b", launch.translate_path(run_target)]
|
||||||
stdout=asyncio.subprocess.PIPE,
|
env = launch.env
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
env=env,
|
# Output files derive from the run target's stem.
|
||||||
cwd=str(work_dir),
|
raw_file = run_target.with_suffix(".raw")
|
||||||
)
|
log_file = run_target.with_suffix(".log")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
outcome = await _run_and_wait(
|
||||||
process.communicate(), timeout=timeout
|
cmd, env, work_dir, raw_file, log_file, timeout, launch.is_darwin
|
||||||
)
|
)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
process.kill()
|
|
||||||
await process.wait()
|
|
||||||
return SimulationResult(
|
return SimulationResult(
|
||||||
success=False,
|
success=False,
|
||||||
raw_file=None,
|
raw_file=None,
|
||||||
@ -151,15 +251,31 @@ async def run_simulation(
|
|||||||
elapsed_seconds=time.monotonic() - start_time,
|
elapsed_seconds=time.monotonic() - start_time,
|
||||||
)
|
)
|
||||||
|
|
||||||
stdout = stdout_bytes.decode("utf-8", errors="replace")
|
if isinstance(outcome, _MacTimeout):
|
||||||
stderr = stderr_bytes.decode("utf-8", errors="replace")
|
# 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
|
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():
|
if not raw_file.exists():
|
||||||
# Check for error in log
|
# Check for error in log
|
||||||
error_msg = "Simulation failed - no .raw file produced"
|
error_msg = "Simulation failed - no .raw file produced"
|
||||||
@ -287,26 +403,20 @@ async def run_netlist(
|
|||||||
for f in src_dir.glob(f"*{ext}"):
|
for f in src_dir.glob(f"*{ext}"):
|
||||||
_safe_copy(f, work_dir / f.name)
|
_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]
|
raw_file = work_netlist.with_suffix(".raw")
|
||||||
env = get_wine_env()
|
log_file = work_netlist.with_suffix(".log")
|
||||||
|
|
||||||
process = await asyncio.create_subprocess_exec(
|
|
||||||
*cmd,
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
env=env,
|
|
||||||
cwd=str(work_dir),
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
outcome = await _run_and_wait(
|
||||||
process.communicate(), timeout=timeout
|
cmd, env, work_dir, raw_file, log_file, timeout, launch.is_darwin
|
||||||
)
|
)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
process.kill()
|
|
||||||
await process.wait()
|
|
||||||
return SimulationResult(
|
return SimulationResult(
|
||||||
success=False,
|
success=False,
|
||||||
raw_file=None,
|
raw_file=None,
|
||||||
@ -318,12 +428,28 @@ async def run_netlist(
|
|||||||
elapsed_seconds=time.monotonic() - start_time,
|
elapsed_seconds=time.monotonic() - start_time,
|
||||||
)
|
)
|
||||||
|
|
||||||
stdout = stdout_bytes.decode("utf-8", errors="replace")
|
if isinstance(outcome, _MacTimeout):
|
||||||
stderr = stderr_bytes.decode("utf-8", errors="replace")
|
if not raw_file.exists() and not log_file.exists():
|
||||||
elapsed = time.monotonic() - start_time
|
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")
|
stdout, stderr = outcome
|
||||||
log_file = work_netlist.with_suffix(".log")
|
elapsed = time.monotonic() - start_time
|
||||||
|
|
||||||
if not raw_file.exists():
|
if not raw_file.exists():
|
||||||
error_msg = "Simulation failed - no .raw file produced"
|
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