Initial mcp-ltspice: MCP server for LTspice circuit simulation
Wine batch-mode runner, binary .raw parser (UTF-16 LE + mixed precision float64/float32), .asc schematic parser/editor, and 9 FastMCP tools for simulation automation on Linux.
This commit is contained in:
commit
50953a4dea
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.venv/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
.ruff_cache/
|
||||||
81
README.md
Normal file
81
README.md
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
# mcp-ltspice
|
||||||
|
|
||||||
|
MCP server for LTspice circuit simulation automation on Linux.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Run Simulations** - Execute .asc schematics or .cir netlists in batch mode
|
||||||
|
- **Extract Waveforms** - Parse binary .raw files to get voltage/current data
|
||||||
|
- **Modify Schematics** - Programmatically edit component values
|
||||||
|
- **Browse Libraries** - Access 6500+ symbols and 4000+ example circuits
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **Linux** with Wine installed
|
||||||
|
- **LTspice** extracted (see below)
|
||||||
|
- **Python 3.11+**
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From PyPI (once published)
|
||||||
|
uvx mcp-ltspice
|
||||||
|
|
||||||
|
# From source
|
||||||
|
uv pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
### LTspice Setup
|
||||||
|
|
||||||
|
Extract LTspice from the Windows MSI installer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Download LTspice64.msi from analog.com
|
||||||
|
cd /path/to/downloads
|
||||||
|
7z x LTspice64.msi -oltspice
|
||||||
|
cd ltspice
|
||||||
|
7z x disk1.cab
|
||||||
|
|
||||||
|
# Set up Wine prefix
|
||||||
|
export WINEPREFIX=$PWD/.wine
|
||||||
|
export WINEARCH=win64
|
||||||
|
wineboot --init
|
||||||
|
```
|
||||||
|
|
||||||
|
Set the `LTSPICE_DIR` environment variable or use the default `~/claude/ltspice/extracted/ltspice`.
|
||||||
|
|
||||||
|
## MCP Tools
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `simulate` | Run simulation on .asc schematic |
|
||||||
|
| `simulate_netlist` | Run simulation on .cir netlist |
|
||||||
|
| `get_waveform` | Extract signal data from .raw file |
|
||||||
|
| `read_schematic` | Parse schematic components and nets |
|
||||||
|
| `edit_component` | Modify component values |
|
||||||
|
| `list_symbols` | Browse component symbol library |
|
||||||
|
| `list_examples` | Browse example circuits |
|
||||||
|
| `get_symbol_info` | Get symbol pins and attributes |
|
||||||
|
| `check_installation` | Verify LTspice setup |
|
||||||
|
|
||||||
|
## MCP Resources
|
||||||
|
|
||||||
|
- `ltspice://symbols` - All available component symbols
|
||||||
|
- `ltspice://examples` - All example circuits
|
||||||
|
- `ltspice://status` - Installation status
|
||||||
|
|
||||||
|
## Usage with Claude Code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp add mcp-ltspice -- uvx mcp-ltspice
|
||||||
|
```
|
||||||
|
|
||||||
|
Or for local development:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp add mcp-ltspice -- uv run --directory /path/to/mcp-ltspice mcp-ltspice
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
55
pyproject.toml
Normal file
55
pyproject.toml
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
[project]
|
||||||
|
name = "mcp-ltspice"
|
||||||
|
version = "2026.02.10"
|
||||||
|
description = "MCP server for LTspice circuit simulation automation"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
license = "MIT"
|
||||||
|
authors = [
|
||||||
|
{name = "Ryan Malloy", email = "ryan@supported.systems"}
|
||||||
|
]
|
||||||
|
keywords = ["mcp", "ltspice", "spice", "circuit", "simulation", "electronics"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"Intended Audience :: Science/Research",
|
||||||
|
"Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"fastmcp>=2.0.0",
|
||||||
|
"numpy>=1.24.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"ruff>=0.1.0",
|
||||||
|
"pytest>=7.0.0",
|
||||||
|
]
|
||||||
|
plot = [
|
||||||
|
"matplotlib>=3.7.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
mcp-ltspice = "mcp_ltspice.server:main"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Repository = "https://github.com/ryanmalloy/mcp-ltspice"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/mcp_ltspice"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py311"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "N", "W", "UP"]
|
||||||
|
ignore = ["E501"]
|
||||||
8
src/mcp_ltspice/__init__.py
Normal file
8
src/mcp_ltspice/__init__.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
"""MCP server for LTspice circuit simulation automation."""
|
||||||
|
|
||||||
|
from importlib.metadata import version
|
||||||
|
|
||||||
|
try:
|
||||||
|
__version__ = version("mcp-ltspice")
|
||||||
|
except Exception:
|
||||||
|
__version__ = "0.0.0"
|
||||||
42
src/mcp_ltspice/config.py
Normal file
42
src/mcp_ltspice/config.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
"""Configuration for mcp-ltspice."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# LTspice installation paths
|
||||||
|
LTSPICE_DIR = Path(os.environ.get(
|
||||||
|
"LTSPICE_DIR",
|
||||||
|
Path.home() / "claude" / "ltspice" / "extracted" / "ltspice"
|
||||||
|
))
|
||||||
|
|
||||||
|
LTSPICE_EXE = LTSPICE_DIR / "LTspice.exe"
|
||||||
|
LTSPICE_LIB = LTSPICE_DIR / "lib"
|
||||||
|
LTSPICE_EXAMPLES = LTSPICE_DIR / "examples"
|
||||||
|
|
||||||
|
# Wine configuration
|
||||||
|
WINE_PREFIX = LTSPICE_DIR / ".wine"
|
||||||
|
WINE_DEBUG = os.environ.get("WINE_DEBUG", "-all")
|
||||||
|
|
||||||
|
# Simulation defaults
|
||||||
|
DEFAULT_TIMEOUT = 300 # 5 minutes
|
||||||
|
MAX_RAW_FILE_SIZE = 500 * 1024 * 1024 # 500MB
|
||||||
|
|
||||||
|
|
||||||
|
def get_wine_env() -> dict[str, str]:
|
||||||
|
"""Get environment variables for Wine execution."""
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["WINEPREFIX"] = str(WINE_PREFIX)
|
||||||
|
env["WINEARCH"] = "win64"
|
||||||
|
env["WINEDEBUG"] = WINE_DEBUG
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
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}"
|
||||||
|
return True, "LTspice installation OK"
|
||||||
274
src/mcp_ltspice/raw_parser.py
Normal file
274
src/mcp_ltspice/raw_parser.py
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
"""Parser for LTspice .raw waveform files.
|
||||||
|
|
||||||
|
LTspice binary .raw format:
|
||||||
|
- Header: ASCII text with metadata (title, date, plotname, flags, variables, points)
|
||||||
|
- Data: Binary IEEE float64 (real) or float64 pairs (complex) depending on analysis type
|
||||||
|
"""
|
||||||
|
|
||||||
|
import struct
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Variable:
|
||||||
|
"""A variable (signal) in the raw file."""
|
||||||
|
index: int
|
||||||
|
name: str
|
||||||
|
type: str # e.g., "voltage", "current", "time", "frequency"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RawFile:
|
||||||
|
"""Parsed LTspice .raw file."""
|
||||||
|
title: str
|
||||||
|
date: str
|
||||||
|
plotname: str
|
||||||
|
flags: list[str]
|
||||||
|
variables: list[Variable]
|
||||||
|
points: int
|
||||||
|
data: np.ndarray # Shape: (n_variables, n_points)
|
||||||
|
|
||||||
|
def get_variable(self, name: str) -> np.ndarray | None:
|
||||||
|
"""Get data for a variable by name (case-insensitive partial match)."""
|
||||||
|
name_lower = name.lower()
|
||||||
|
for var in self.variables:
|
||||||
|
if name_lower in var.name.lower():
|
||||||
|
return self.data[var.index]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_time(self) -> np.ndarray | None:
|
||||||
|
"""Get the time axis (for transient analysis)."""
|
||||||
|
return self.get_variable("time")
|
||||||
|
|
||||||
|
def get_frequency(self) -> np.ndarray | None:
|
||||||
|
"""Get the frequency axis (for AC analysis)."""
|
||||||
|
return self.get_variable("frequency")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_raw_file(path: Path | str) -> RawFile:
|
||||||
|
"""Parse an LTspice .raw file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path to the .raw file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RawFile with parsed metadata and waveform data
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If file format is not recognized
|
||||||
|
FileNotFoundError: If file doesn't exist
|
||||||
|
"""
|
||||||
|
path = Path(path)
|
||||||
|
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Detect encoding: UTF-16 LE (Windows) vs ASCII
|
||||||
|
# UTF-16 LE has null bytes between characters
|
||||||
|
is_utf16 = content[1:2] == b'\x00' and content[3:4] == b'\x00'
|
||||||
|
|
||||||
|
if is_utf16:
|
||||||
|
# UTF-16 LE encoding - decode header portion, find Binary marker
|
||||||
|
# "Binary:\n" in UTF-16 LE
|
||||||
|
binary_marker = "Binary:\n".encode("utf-16-le")
|
||||||
|
ascii_marker = "Values:\n".encode("utf-16-le")
|
||||||
|
else:
|
||||||
|
binary_marker = b"Binary:\n"
|
||||||
|
ascii_marker = b"Values:\n"
|
||||||
|
|
||||||
|
binary_offset = content.find(binary_marker)
|
||||||
|
ascii_offset = content.find(ascii_marker)
|
||||||
|
|
||||||
|
if binary_offset != -1:
|
||||||
|
header_end = binary_offset + len(binary_marker)
|
||||||
|
is_binary = True
|
||||||
|
elif ascii_offset != -1:
|
||||||
|
header_end = ascii_offset + len(ascii_marker)
|
||||||
|
is_binary = False
|
||||||
|
else:
|
||||||
|
raise ValueError("Could not find data section marker in .raw file")
|
||||||
|
|
||||||
|
# Parse header (handle encoding)
|
||||||
|
if is_utf16:
|
||||||
|
header = content[:header_end].decode("utf-16-le", errors="replace")
|
||||||
|
else:
|
||||||
|
header = content[:header_end].decode("utf-8", errors="replace")
|
||||||
|
header_lines = header.strip().split("\n")
|
||||||
|
|
||||||
|
title = ""
|
||||||
|
date = ""
|
||||||
|
plotname = ""
|
||||||
|
flags: list[str] = []
|
||||||
|
variables: list[Variable] = []
|
||||||
|
points = 0
|
||||||
|
|
||||||
|
in_variables = False
|
||||||
|
var_count = 0
|
||||||
|
|
||||||
|
for line in header_lines:
|
||||||
|
line = line.strip()
|
||||||
|
|
||||||
|
if line.startswith("Title:"):
|
||||||
|
title = line[6:].strip()
|
||||||
|
elif line.startswith("Date:"):
|
||||||
|
date = line[5:].strip()
|
||||||
|
elif line.startswith("Plotname:"):
|
||||||
|
plotname = line[9:].strip()
|
||||||
|
elif line.startswith("Flags:"):
|
||||||
|
flags = line[6:].strip().split()
|
||||||
|
elif line.startswith("No. Variables:"):
|
||||||
|
var_count = int(line[14:].strip())
|
||||||
|
elif line.startswith("No. Points:"):
|
||||||
|
points = int(line[11:].strip())
|
||||||
|
elif line.startswith("Variables:"):
|
||||||
|
in_variables = True
|
||||||
|
elif in_variables and line and not line.startswith("Binary") and not line.startswith("Values"):
|
||||||
|
# Parse variable line: "index name type"
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 3:
|
||||||
|
idx = int(parts[0])
|
||||||
|
name = parts[1]
|
||||||
|
vtype = parts[2]
|
||||||
|
variables.append(Variable(idx, name, vtype))
|
||||||
|
|
||||||
|
if not variables:
|
||||||
|
raise ValueError("No variables found in .raw file header")
|
||||||
|
|
||||||
|
# Parse data section
|
||||||
|
data_bytes = content[header_end:]
|
||||||
|
n_vars = len(variables)
|
||||||
|
|
||||||
|
is_complex = "complex" in flags
|
||||||
|
is_stepped = "stepped" in flags
|
||||||
|
is_forward = "forward" in flags # FastAccess format
|
||||||
|
|
||||||
|
if is_binary:
|
||||||
|
data = _parse_binary_data(data_bytes, n_vars, points, is_complex)
|
||||||
|
else:
|
||||||
|
data = _parse_ascii_data(data_bytes.decode("utf-8"), n_vars, points, is_complex)
|
||||||
|
|
||||||
|
return RawFile(
|
||||||
|
title=title,
|
||||||
|
date=date,
|
||||||
|
plotname=plotname,
|
||||||
|
flags=flags,
|
||||||
|
variables=variables,
|
||||||
|
points=points,
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_binary_data(
|
||||||
|
data: bytes, n_vars: int, points: int, is_complex: bool
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Parse binary data section.
|
||||||
|
|
||||||
|
LTspice binary formats:
|
||||||
|
- Real transient: time (float64) + other vars (float32)
|
||||||
|
- Real all-double: all variables as float64 (older format)
|
||||||
|
- Complex AC: frequency (float64) + other vars as (float64, float64) pairs
|
||||||
|
"""
|
||||||
|
if is_complex:
|
||||||
|
# Complex data (AC analysis): freq as double + complex values
|
||||||
|
# freq (8 bytes) + (n_vars-1) * (real + imag) = 8 + (n-1)*16 bytes per point
|
||||||
|
bytes_per_point = 8 + (n_vars - 1) * 16
|
||||||
|
expected_bytes = bytes_per_point * points
|
||||||
|
|
||||||
|
if len(data) >= expected_bytes:
|
||||||
|
result = np.zeros((n_vars, points), dtype=np.complex128)
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
for p in range(points):
|
||||||
|
# Frequency as double (real)
|
||||||
|
result[0, p] = struct.unpack("<d", data[offset:offset + 8])[0]
|
||||||
|
offset += 8
|
||||||
|
|
||||||
|
# Rest are complex (real, imag) pairs
|
||||||
|
for v in range(1, n_vars):
|
||||||
|
real = struct.unpack("<d", data[offset:offset + 8])[0]
|
||||||
|
imag = struct.unpack("<d", data[offset + 8:offset + 16])[0]
|
||||||
|
result[v, p] = complex(real, imag)
|
||||||
|
offset += 16
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Real data - detect format from data size
|
||||||
|
# Mixed format: time (8 bytes) + other vars (4 bytes each)
|
||||||
|
bytes_per_point_mixed = 8 + (n_vars - 1) * 4
|
||||||
|
bytes_per_point_double = n_vars * 8
|
||||||
|
|
||||||
|
expected_mixed = bytes_per_point_mixed * points
|
||||||
|
expected_double = bytes_per_point_double * points
|
||||||
|
|
||||||
|
if abs(len(data) - expected_mixed) < abs(len(data) - expected_double):
|
||||||
|
# Mixed precision format (common in newer LTspice)
|
||||||
|
actual_points = len(data) // bytes_per_point_mixed
|
||||||
|
result = np.zeros((n_vars, actual_points), dtype=np.float64)
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
for p in range(actual_points):
|
||||||
|
# Time as double
|
||||||
|
result[0, p] = struct.unpack("<d", data[offset:offset + 8])[0]
|
||||||
|
offset += 8
|
||||||
|
|
||||||
|
# Other variables as float32
|
||||||
|
for v in range(1, n_vars):
|
||||||
|
result[v, p] = struct.unpack("<f", data[offset:offset + 4])[0]
|
||||||
|
offset += 4
|
||||||
|
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
# All double format (older LTspice or specific settings)
|
||||||
|
actual_points = len(data) // bytes_per_point_double
|
||||||
|
|
||||||
|
flat = np.frombuffer(data[:actual_points * bytes_per_point_double], dtype=np.float64)
|
||||||
|
# LTspice stores point-by-point
|
||||||
|
return flat.reshape(actual_points, n_vars).T.copy()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ascii_data(
|
||||||
|
data: str, n_vars: int, points: int, is_complex: bool
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Parse ASCII data section."""
|
||||||
|
lines = data.strip().split("\n")
|
||||||
|
|
||||||
|
if is_complex:
|
||||||
|
result = np.zeros((n_vars, points), dtype=np.complex128)
|
||||||
|
else:
|
||||||
|
result = np.zeros((n_vars, points), dtype=np.float64)
|
||||||
|
|
||||||
|
point_idx = 0
|
||||||
|
var_idx = 0
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 2:
|
||||||
|
# Format: "var_idx value" or "var_idx real,imag"
|
||||||
|
try:
|
||||||
|
idx = int(parts[0])
|
||||||
|
if idx == 0:
|
||||||
|
if var_idx > 0:
|
||||||
|
point_idx += 1
|
||||||
|
var_idx = 0
|
||||||
|
|
||||||
|
if point_idx < points:
|
||||||
|
value_str = parts[1]
|
||||||
|
if "," in value_str:
|
||||||
|
# Complex value
|
||||||
|
real, imag = value_str.split(",")
|
||||||
|
result[var_idx, point_idx] = complex(float(real), float(imag))
|
||||||
|
else:
|
||||||
|
result[var_idx, point_idx] = float(value_str)
|
||||||
|
|
||||||
|
var_idx += 1
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
return result
|
||||||
332
src/mcp_ltspice/runner.py
Normal file
332
src/mcp_ltspice/runner.py
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
"""Run LTspice simulations via Wine batch mode."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .config import (
|
||||||
|
DEFAULT_TIMEOUT,
|
||||||
|
LTSPICE_EXE,
|
||||||
|
MAX_RAW_FILE_SIZE,
|
||||||
|
get_wine_env,
|
||||||
|
validate_installation,
|
||||||
|
)
|
||||||
|
from .raw_parser import RawFile, parse_raw_file
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SimulationResult:
|
||||||
|
"""Result of a simulation run."""
|
||||||
|
success: bool
|
||||||
|
raw_file: Path | None
|
||||||
|
log_file: Path | None
|
||||||
|
raw_data: RawFile | None
|
||||||
|
error: str | None
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
|
elapsed_seconds: float
|
||||||
|
|
||||||
|
|
||||||
|
async def run_simulation(
|
||||||
|
schematic_path: Path | str,
|
||||||
|
timeout: float = DEFAULT_TIMEOUT,
|
||||||
|
work_dir: Path | str | None = None,
|
||||||
|
parse_results: bool = True,
|
||||||
|
) -> SimulationResult:
|
||||||
|
"""Run an LTspice simulation in batch mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schematic_path: Path to .asc schematic file
|
||||||
|
timeout: Maximum simulation time in seconds
|
||||||
|
work_dir: Working directory for output files (temp dir if None)
|
||||||
|
parse_results: Whether to parse the .raw file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SimulationResult with status and data
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
start_time = time.monotonic()
|
||||||
|
|
||||||
|
# Validate installation
|
||||||
|
ok, msg = validate_installation()
|
||||||
|
if not ok:
|
||||||
|
return SimulationResult(
|
||||||
|
success=False,
|
||||||
|
raw_file=None,
|
||||||
|
log_file=None,
|
||||||
|
raw_data=None,
|
||||||
|
error=msg,
|
||||||
|
stdout="",
|
||||||
|
stderr="",
|
||||||
|
elapsed_seconds=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
schematic_path = Path(schematic_path).resolve()
|
||||||
|
if not schematic_path.exists():
|
||||||
|
return SimulationResult(
|
||||||
|
success=False,
|
||||||
|
raw_file=None,
|
||||||
|
log_file=None,
|
||||||
|
raw_data=None,
|
||||||
|
error=f"Schematic not found: {schematic_path}",
|
||||||
|
stdout="",
|
||||||
|
stderr="",
|
||||||
|
elapsed_seconds=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set up working directory
|
||||||
|
temp_dir = None
|
||||||
|
if work_dir:
|
||||||
|
work_dir = Path(work_dir)
|
||||||
|
work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
else:
|
||||||
|
temp_dir = tempfile.mkdtemp(prefix="ltspice_")
|
||||||
|
work_dir = Path(temp_dir)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Copy schematic to work directory
|
||||||
|
work_schematic = work_dir / schematic_path.name
|
||||||
|
shutil.copy2(schematic_path, work_schematic)
|
||||||
|
|
||||||
|
# Copy any .lib, .sub files from the same directory
|
||||||
|
src_dir = schematic_path.parent
|
||||||
|
for ext in [".lib", ".sub", ".inc", ".model"]:
|
||||||
|
for f in src_dir.glob(f"*{ext}"):
|
||||||
|
shutil.copy2(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("/", "\\")
|
||||||
|
|
||||||
|
# Build command
|
||||||
|
cmd = [
|
||||||
|
"wine",
|
||||||
|
str(LTSPICE_EXE),
|
||||||
|
"-b", # Batch mode
|
||||||
|
win_path,
|
||||||
|
]
|
||||||
|
|
||||||
|
# Run simulation
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
||||||
|
process.communicate(),
|
||||||
|
timeout=timeout
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
process.kill()
|
||||||
|
await process.wait()
|
||||||
|
return SimulationResult(
|
||||||
|
success=False,
|
||||||
|
raw_file=None,
|
||||||
|
log_file=None,
|
||||||
|
raw_data=None,
|
||||||
|
error=f"Simulation timed out after {timeout} seconds",
|
||||||
|
stdout="",
|
||||||
|
stderr="",
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
if log_file.exists():
|
||||||
|
log_content = log_file.read_text(errors="replace")
|
||||||
|
if "Error" in log_content or "error" in log_content:
|
||||||
|
error_msg = f"Simulation error: {log_content[:500]}"
|
||||||
|
|
||||||
|
return SimulationResult(
|
||||||
|
success=False,
|
||||||
|
raw_file=None,
|
||||||
|
log_file=log_file if log_file.exists() else None,
|
||||||
|
raw_data=None,
|
||||||
|
error=error_msg,
|
||||||
|
stdout=stdout,
|
||||||
|
stderr=stderr,
|
||||||
|
elapsed_seconds=elapsed,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check file size
|
||||||
|
if raw_file.stat().st_size > MAX_RAW_FILE_SIZE:
|
||||||
|
return SimulationResult(
|
||||||
|
success=True,
|
||||||
|
raw_file=raw_file,
|
||||||
|
log_file=log_file if log_file.exists() else None,
|
||||||
|
raw_data=None,
|
||||||
|
error=f"Raw file too large to parse ({raw_file.stat().st_size} bytes)",
|
||||||
|
stdout=stdout,
|
||||||
|
stderr=stderr,
|
||||||
|
elapsed_seconds=elapsed,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse results
|
||||||
|
raw_data = None
|
||||||
|
parse_error = None
|
||||||
|
if parse_results:
|
||||||
|
try:
|
||||||
|
raw_data = parse_raw_file(raw_file)
|
||||||
|
except Exception as e:
|
||||||
|
parse_error = f"Failed to parse .raw file: {e}"
|
||||||
|
|
||||||
|
return SimulationResult(
|
||||||
|
success=True,
|
||||||
|
raw_file=raw_file,
|
||||||
|
log_file=log_file if log_file.exists() else None,
|
||||||
|
raw_data=raw_data,
|
||||||
|
error=parse_error,
|
||||||
|
stdout=stdout,
|
||||||
|
stderr=stderr,
|
||||||
|
elapsed_seconds=elapsed,
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up temp directory (but not user-specified work_dir)
|
||||||
|
if temp_dir:
|
||||||
|
# Keep files for debugging if simulation failed
|
||||||
|
pass # Let user specify cleanup behavior
|
||||||
|
|
||||||
|
|
||||||
|
async def run_netlist(
|
||||||
|
netlist_path: Path | str,
|
||||||
|
timeout: float = DEFAULT_TIMEOUT,
|
||||||
|
work_dir: Path | str | None = None,
|
||||||
|
parse_results: bool = True,
|
||||||
|
) -> SimulationResult:
|
||||||
|
"""Run an LTspice simulation from a netlist (.cir/.net) file.
|
||||||
|
|
||||||
|
This is similar to run_simulation but for netlist files.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
netlist_path: Path to .cir or .net netlist file
|
||||||
|
timeout: Maximum simulation time in seconds
|
||||||
|
work_dir: Working directory for output files
|
||||||
|
parse_results: Whether to parse the .raw file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SimulationResult with status and data
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
start_time = time.monotonic()
|
||||||
|
|
||||||
|
ok, msg = validate_installation()
|
||||||
|
if not ok:
|
||||||
|
return SimulationResult(
|
||||||
|
success=False, raw_file=None, log_file=None, raw_data=None,
|
||||||
|
error=msg, stdout="", stderr="", elapsed_seconds=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
netlist_path = Path(netlist_path).resolve()
|
||||||
|
if not netlist_path.exists():
|
||||||
|
return SimulationResult(
|
||||||
|
success=False, raw_file=None, log_file=None, raw_data=None,
|
||||||
|
error=f"Netlist not found: {netlist_path}",
|
||||||
|
stdout="", stderr="", elapsed_seconds=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
temp_dir = None
|
||||||
|
if work_dir:
|
||||||
|
work_dir = Path(work_dir)
|
||||||
|
work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
else:
|
||||||
|
temp_dir = tempfile.mkdtemp(prefix="ltspice_")
|
||||||
|
work_dir = Path(temp_dir)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Copy netlist to work directory
|
||||||
|
work_netlist = work_dir / netlist_path.name
|
||||||
|
shutil.copy2(netlist_path, work_netlist)
|
||||||
|
|
||||||
|
# Copy included files
|
||||||
|
src_dir = netlist_path.parent
|
||||||
|
for ext in [".lib", ".sub", ".inc", ".model"]:
|
||||||
|
for f in src_dir.glob(f"*{ext}"):
|
||||||
|
shutil.copy2(f, work_dir / f.name)
|
||||||
|
|
||||||
|
win_path = "Z:" + str(work_netlist).replace("/", "\\")
|
||||||
|
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
||||||
|
process.communicate(),
|
||||||
|
timeout=timeout
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
process.kill()
|
||||||
|
await process.wait()
|
||||||
|
return SimulationResult(
|
||||||
|
success=False, raw_file=None, log_file=None, raw_data=None,
|
||||||
|
error=f"Simulation timed out after {timeout} seconds",
|
||||||
|
stdout="", stderr="",
|
||||||
|
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
|
||||||
|
|
||||||
|
raw_file = work_netlist.with_suffix(".raw")
|
||||||
|
log_file = work_netlist.with_suffix(".log")
|
||||||
|
|
||||||
|
if not raw_file.exists():
|
||||||
|
error_msg = "Simulation failed - no .raw file produced"
|
||||||
|
if log_file.exists():
|
||||||
|
log_content = log_file.read_text(errors="replace")
|
||||||
|
if "error" in log_content.lower():
|
||||||
|
error_msg = f"Simulation error: {log_content[:500]}"
|
||||||
|
|
||||||
|
return SimulationResult(
|
||||||
|
success=False,
|
||||||
|
raw_file=None,
|
||||||
|
log_file=log_file if log_file.exists() else None,
|
||||||
|
raw_data=None, error=error_msg,
|
||||||
|
stdout=stdout, stderr=stderr, elapsed_seconds=elapsed,
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_data = None
|
||||||
|
parse_error = None
|
||||||
|
if parse_results and raw_file.stat().st_size <= MAX_RAW_FILE_SIZE:
|
||||||
|
try:
|
||||||
|
raw_data = parse_raw_file(raw_file)
|
||||||
|
except Exception as e:
|
||||||
|
parse_error = f"Failed to parse .raw file: {e}"
|
||||||
|
|
||||||
|
return SimulationResult(
|
||||||
|
success=True,
|
||||||
|
raw_file=raw_file,
|
||||||
|
log_file=log_file if log_file.exists() else None,
|
||||||
|
raw_data=raw_data,
|
||||||
|
error=parse_error,
|
||||||
|
stdout=stdout, stderr=stderr, elapsed_seconds=elapsed,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
pass # Keep temp files for debugging
|
||||||
286
src/mcp_ltspice/schematic.py
Normal file
286
src/mcp_ltspice/schematic.py
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
"""Parser and editor for LTspice .asc schematic files.
|
||||||
|
|
||||||
|
LTspice .asc format is a simple text format with components, wires, and directives.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Component:
|
||||||
|
"""A component instance in the schematic."""
|
||||||
|
name: str # Instance name (e.g., R1, C1, M1)
|
||||||
|
symbol: str # Symbol name (e.g., res, cap, nmos)
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
rotation: int # 0, 90, 180, 270
|
||||||
|
mirror: bool
|
||||||
|
attributes: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def value(self) -> str | None:
|
||||||
|
"""Get component value (e.g., '10k' for resistor)."""
|
||||||
|
return self.attributes.get("Value") or self.attributes.get("Value2")
|
||||||
|
|
||||||
|
@value.setter
|
||||||
|
def value(self, val: str):
|
||||||
|
"""Set component value."""
|
||||||
|
self.attributes["Value"] = val
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Wire:
|
||||||
|
"""A wire connection."""
|
||||||
|
x1: int
|
||||||
|
y1: int
|
||||||
|
x2: int
|
||||||
|
y2: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Text:
|
||||||
|
"""Text annotation or SPICE directive."""
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
content: str
|
||||||
|
type: str = "comment" # "comment" or "spice"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Flag:
|
||||||
|
"""A net flag/label."""
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
name: str
|
||||||
|
type: str = "0" # Net type
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Schematic:
|
||||||
|
"""A parsed LTspice schematic."""
|
||||||
|
version: int = 4
|
||||||
|
sheet: tuple[int, int, int, int] = (1, 1, 0, 0)
|
||||||
|
components: list[Component] = field(default_factory=list)
|
||||||
|
wires: list[Wire] = field(default_factory=list)
|
||||||
|
texts: list[Text] = field(default_factory=list)
|
||||||
|
flags: list[Flag] = field(default_factory=list)
|
||||||
|
|
||||||
|
def get_component(self, name: str) -> Component | None:
|
||||||
|
"""Get component by instance name (case-insensitive)."""
|
||||||
|
name_lower = name.lower()
|
||||||
|
for comp in self.components:
|
||||||
|
if comp.name.lower() == name_lower:
|
||||||
|
return comp
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_components_by_symbol(self, symbol: str) -> list[Component]:
|
||||||
|
"""Get all components using a specific symbol."""
|
||||||
|
symbol_lower = symbol.lower()
|
||||||
|
return [c for c in self.components if c.symbol.lower() == symbol_lower]
|
||||||
|
|
||||||
|
def get_spice_directives(self) -> list[str]:
|
||||||
|
"""Get all SPICE directives from the schematic."""
|
||||||
|
return [t.content for t in self.texts if t.type == "spice"]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_schematic(path: Path | str) -> Schematic:
|
||||||
|
"""Parse an LTspice .asc schematic file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path to the .asc file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Schematic object with parsed contents
|
||||||
|
"""
|
||||||
|
path = Path(path)
|
||||||
|
content = path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
lines = content.split("\n")
|
||||||
|
|
||||||
|
schematic = Schematic()
|
||||||
|
current_component: Component | None = None
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
while i < len(lines):
|
||||||
|
line = lines[i].strip()
|
||||||
|
|
||||||
|
if line.startswith("Version"):
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 2:
|
||||||
|
schematic.version = int(parts[1])
|
||||||
|
|
||||||
|
elif line.startswith("SHEET"):
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 5:
|
||||||
|
schematic.sheet = (
|
||||||
|
int(parts[1]), int(parts[2]),
|
||||||
|
int(parts[3]), int(parts[4])
|
||||||
|
)
|
||||||
|
|
||||||
|
elif line.startswith("WIRE"):
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 5:
|
||||||
|
schematic.wires.append(Wire(
|
||||||
|
int(parts[1]), int(parts[2]),
|
||||||
|
int(parts[3]), int(parts[4])
|
||||||
|
))
|
||||||
|
|
||||||
|
elif line.startswith("FLAG"):
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 4:
|
||||||
|
schematic.flags.append(Flag(
|
||||||
|
int(parts[1]), int(parts[2]),
|
||||||
|
parts[3],
|
||||||
|
parts[4] if len(parts) > 4 else "0"
|
||||||
|
))
|
||||||
|
|
||||||
|
elif line.startswith("SYMBOL"):
|
||||||
|
# Save previous component before starting a new one
|
||||||
|
if current_component and current_component.name:
|
||||||
|
schematic.components.append(current_component)
|
||||||
|
|
||||||
|
# Start of a new component
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 4:
|
||||||
|
symbol = parts[1]
|
||||||
|
x = int(parts[2])
|
||||||
|
y = int(parts[3])
|
||||||
|
|
||||||
|
# Parse rotation/mirror from remaining parts
|
||||||
|
rotation = 0
|
||||||
|
mirror = False
|
||||||
|
if len(parts) > 4:
|
||||||
|
rot_str = parts[4]
|
||||||
|
if rot_str.startswith("M"):
|
||||||
|
mirror = True
|
||||||
|
rot_str = rot_str[1:]
|
||||||
|
if rot_str.startswith("R"):
|
||||||
|
rotation = int(rot_str[1:])
|
||||||
|
|
||||||
|
current_component = Component(
|
||||||
|
name="",
|
||||||
|
symbol=symbol,
|
||||||
|
x=x, y=y,
|
||||||
|
rotation=rotation,
|
||||||
|
mirror=mirror
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
current_component = None
|
||||||
|
|
||||||
|
elif line.startswith("SYMATTR") and current_component:
|
||||||
|
parts = line.split(None, 2)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
attr_name = parts[1]
|
||||||
|
attr_value = parts[2]
|
||||||
|
|
||||||
|
if attr_name == "InstName":
|
||||||
|
current_component.name = attr_value
|
||||||
|
else:
|
||||||
|
current_component.attributes[attr_name] = attr_value
|
||||||
|
|
||||||
|
elif line.startswith("TEXT"):
|
||||||
|
# Parse text/directive
|
||||||
|
match = re.match(r"TEXT\s+(-?\d+)\s+(-?\d+)\s+(\w+)\s+(\d+)\s*(.*)", line)
|
||||||
|
if match:
|
||||||
|
x, y = int(match.group(1)), int(match.group(2))
|
||||||
|
align = match.group(3)
|
||||||
|
size = int(match.group(4))
|
||||||
|
content = match.group(5) if match.group(5) else ""
|
||||||
|
|
||||||
|
# Check for multi-line text (continuation with \n or actual newlines)
|
||||||
|
text_type = "comment"
|
||||||
|
if content.startswith("!"):
|
||||||
|
text_type = "spice"
|
||||||
|
content = content[1:]
|
||||||
|
elif content.startswith(";"):
|
||||||
|
content = content[1:]
|
||||||
|
|
||||||
|
schematic.texts.append(Text(x, y, content, text_type))
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Save the last component if not already saved
|
||||||
|
if current_component and current_component.name:
|
||||||
|
if current_component not in schematic.components:
|
||||||
|
schematic.components.append(current_component)
|
||||||
|
|
||||||
|
return schematic
|
||||||
|
|
||||||
|
|
||||||
|
def write_schematic(schematic: Schematic, path: Path | str) -> None:
|
||||||
|
"""Write a schematic to an .asc file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schematic: Schematic to write
|
||||||
|
path: Output path
|
||||||
|
"""
|
||||||
|
path = Path(path)
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
lines.append(f"Version {schematic.version}")
|
||||||
|
lines.append(f"SHEET {schematic.sheet[0]} {schematic.sheet[1]} {schematic.sheet[2]} {schematic.sheet[3]}")
|
||||||
|
|
||||||
|
# Write wires
|
||||||
|
for wire in schematic.wires:
|
||||||
|
lines.append(f"WIRE {wire.x1} {wire.y1} {wire.x2} {wire.y2}")
|
||||||
|
|
||||||
|
# Write flags
|
||||||
|
for flag in schematic.flags:
|
||||||
|
lines.append(f"FLAG {flag.x} {flag.y} {flag.name}")
|
||||||
|
|
||||||
|
# Write components
|
||||||
|
for comp in schematic.components:
|
||||||
|
rot_str = f"R{comp.rotation}"
|
||||||
|
if comp.mirror:
|
||||||
|
rot_str = "M" + rot_str
|
||||||
|
|
||||||
|
lines.append(f"SYMBOL {comp.symbol} {comp.x} {comp.y} {rot_str}")
|
||||||
|
lines.append(f"SYMATTR InstName {comp.name}")
|
||||||
|
|
||||||
|
for attr_name, attr_value in comp.attributes.items():
|
||||||
|
if attr_name != "InstName":
|
||||||
|
lines.append(f"SYMATTR {attr_name} {attr_value}")
|
||||||
|
|
||||||
|
# Write text/directives
|
||||||
|
for text in schematic.texts:
|
||||||
|
prefix = "!" if text.type == "spice" else ""
|
||||||
|
lines.append(f"TEXT {text.x} {text.y} Left 2 {prefix}{text.content}")
|
||||||
|
|
||||||
|
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def modify_component_value(
|
||||||
|
path: Path | str,
|
||||||
|
component_name: str,
|
||||||
|
new_value: str,
|
||||||
|
output_path: Path | str | None = None
|
||||||
|
) -> Schematic:
|
||||||
|
"""Modify a component's value in a schematic.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Input schematic path
|
||||||
|
component_name: Name of component to modify (e.g., "R1")
|
||||||
|
new_value: New value to set
|
||||||
|
output_path: Output path (defaults to overwriting input)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Modified schematic
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If component not found
|
||||||
|
"""
|
||||||
|
schematic = parse_schematic(path)
|
||||||
|
|
||||||
|
comp = schematic.get_component(component_name)
|
||||||
|
if not comp:
|
||||||
|
available = [c.name for c in schematic.components]
|
||||||
|
raise ValueError(
|
||||||
|
f"Component '{component_name}' not found. "
|
||||||
|
f"Available components: {', '.join(available)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
comp.value = new_value
|
||||||
|
|
||||||
|
write_schematic(schematic, output_path or path)
|
||||||
|
return schematic
|
||||||
531
src/mcp_ltspice/server.py
Normal file
531
src/mcp_ltspice/server.py
Normal file
@ -0,0 +1,531 @@
|
|||||||
|
"""FastMCP server for LTspice circuit simulation automation.
|
||||||
|
|
||||||
|
This server provides tools for:
|
||||||
|
- Running SPICE simulations
|
||||||
|
- Extracting waveform data
|
||||||
|
- Modifying schematic components
|
||||||
|
- Browsing component libraries and examples
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
|
from . import __version__
|
||||||
|
from .config import (
|
||||||
|
LTSPICE_EXAMPLES,
|
||||||
|
LTSPICE_LIB,
|
||||||
|
validate_installation,
|
||||||
|
)
|
||||||
|
from .raw_parser import parse_raw_file
|
||||||
|
from .runner import run_netlist, run_simulation
|
||||||
|
from .schematic import (
|
||||||
|
modify_component_value,
|
||||||
|
parse_schematic,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize FastMCP server
|
||||||
|
mcp = FastMCP(
|
||||||
|
name="mcp-ltspice",
|
||||||
|
instructions="""
|
||||||
|
LTspice MCP Server - Circuit simulation automation.
|
||||||
|
|
||||||
|
Use this server to:
|
||||||
|
- Run SPICE simulations on .asc schematics or .cir netlists
|
||||||
|
- Extract waveform data (voltages, currents) from simulation results
|
||||||
|
- Modify component values in schematics programmatically
|
||||||
|
- Browse LTspice's component library (6500+ symbols)
|
||||||
|
- Access example circuits (4000+ examples)
|
||||||
|
|
||||||
|
LTspice runs via Wine on Linux. Simulations execute in batch mode
|
||||||
|
and results are parsed from binary .raw files.
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TOOLS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def simulate(
|
||||||
|
schematic_path: str,
|
||||||
|
timeout_seconds: float = 300,
|
||||||
|
) -> dict:
|
||||||
|
"""Run an LTspice simulation on a schematic file.
|
||||||
|
|
||||||
|
This runs LTspice in batch mode, which executes any simulation
|
||||||
|
directives (.tran, .ac, .dc, .op, etc.) in the schematic.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schematic_path: Absolute path to .asc schematic file
|
||||||
|
timeout_seconds: Maximum time to wait for simulation (default 5 min)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with:
|
||||||
|
- success: bool
|
||||||
|
- elapsed_seconds: simulation time
|
||||||
|
- variables: list of signal names available
|
||||||
|
- points: number of data points
|
||||||
|
- error: error message if failed
|
||||||
|
"""
|
||||||
|
result = await run_simulation(
|
||||||
|
schematic_path,
|
||||||
|
timeout=timeout_seconds,
|
||||||
|
parse_results=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = {
|
||||||
|
"success": result.success,
|
||||||
|
"elapsed_seconds": result.elapsed_seconds,
|
||||||
|
"error": result.error,
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.raw_data:
|
||||||
|
response["variables"] = [
|
||||||
|
{"name": v.name, "type": v.type}
|
||||||
|
for v in result.raw_data.variables
|
||||||
|
]
|
||||||
|
response["points"] = result.raw_data.points
|
||||||
|
response["plotname"] = result.raw_data.plotname
|
||||||
|
response["raw_file"] = str(result.raw_file) if result.raw_file else None
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def simulate_netlist(
|
||||||
|
netlist_path: str,
|
||||||
|
timeout_seconds: float = 300,
|
||||||
|
) -> dict:
|
||||||
|
"""Run an LTspice simulation on a netlist file.
|
||||||
|
|
||||||
|
Use this for .cir or .net SPICE netlist files instead of
|
||||||
|
schematic .asc files.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
netlist_path: Absolute path to .cir or .net netlist file
|
||||||
|
timeout_seconds: Maximum time to wait for simulation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with simulation results (same as simulate)
|
||||||
|
"""
|
||||||
|
result = await run_netlist(
|
||||||
|
netlist_path,
|
||||||
|
timeout=timeout_seconds,
|
||||||
|
parse_results=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = {
|
||||||
|
"success": result.success,
|
||||||
|
"elapsed_seconds": result.elapsed_seconds,
|
||||||
|
"error": result.error,
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.raw_data:
|
||||||
|
response["variables"] = [
|
||||||
|
{"name": v.name, "type": v.type}
|
||||||
|
for v in result.raw_data.variables
|
||||||
|
]
|
||||||
|
response["points"] = result.raw_data.points
|
||||||
|
response["raw_file"] = str(result.raw_file) if result.raw_file else None
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_waveform(
|
||||||
|
raw_file_path: str,
|
||||||
|
signal_names: list[str],
|
||||||
|
max_points: int = 1000,
|
||||||
|
) -> dict:
|
||||||
|
"""Extract waveform data from a .raw simulation results file.
|
||||||
|
|
||||||
|
After running a simulation, use this to get the actual data values.
|
||||||
|
For transient analysis, includes time axis. For AC, includes frequency.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_file_path: Path to .raw file from simulation
|
||||||
|
signal_names: List of signal names to extract (partial match OK)
|
||||||
|
e.g., ["V(out)", "I(R1)"] or just ["out", "R1"]
|
||||||
|
max_points: Maximum data points to return (downsampled if needed)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with:
|
||||||
|
- time_or_frequency: the x-axis data
|
||||||
|
- signals: dict mapping signal name to data array
|
||||||
|
- units: dict mapping signal name to unit type
|
||||||
|
"""
|
||||||
|
raw = parse_raw_file(raw_file_path)
|
||||||
|
|
||||||
|
# Get x-axis (time or frequency)
|
||||||
|
x_axis = raw.get_time()
|
||||||
|
x_name = "time"
|
||||||
|
if x_axis is None:
|
||||||
|
x_axis = raw.get_frequency()
|
||||||
|
x_name = "frequency"
|
||||||
|
|
||||||
|
# Downsample if needed
|
||||||
|
total_points = len(x_axis) if x_axis is not None else raw.points
|
||||||
|
step = max(1, total_points // max_points)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"x_axis_name": x_name,
|
||||||
|
"x_axis_data": [],
|
||||||
|
"signals": {},
|
||||||
|
"total_points": total_points,
|
||||||
|
"returned_points": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if x_axis is not None:
|
||||||
|
sampled = x_axis[::step]
|
||||||
|
# Convert complex to magnitude for frequency domain
|
||||||
|
if sampled.dtype == complex:
|
||||||
|
result["x_axis_data"] = [abs(x) for x in sampled]
|
||||||
|
else:
|
||||||
|
result["x_axis_data"] = sampled.tolist()
|
||||||
|
result["returned_points"] = len(result["x_axis_data"])
|
||||||
|
|
||||||
|
# Extract requested signals
|
||||||
|
for name in signal_names:
|
||||||
|
data = raw.get_variable(name)
|
||||||
|
if data is not None:
|
||||||
|
sampled = data[::step]
|
||||||
|
# Handle complex data (AC analysis)
|
||||||
|
if sampled.dtype == complex:
|
||||||
|
result["signals"][name] = {
|
||||||
|
"magnitude": [abs(x) for x in sampled],
|
||||||
|
"phase_degrees": [
|
||||||
|
(180 / 3.14159) * (x.imag / x.real if x.real != 0 else 0)
|
||||||
|
for x in sampled
|
||||||
|
],
|
||||||
|
"real": [x.real for x in sampled],
|
||||||
|
"imag": [x.imag for x in sampled],
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
result["signals"][name] = {"values": sampled.tolist()}
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def read_schematic(schematic_path: str) -> dict:
|
||||||
|
"""Read and parse an LTspice schematic file.
|
||||||
|
|
||||||
|
Returns component list, net names, and SPICE directives.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schematic_path: Path to .asc schematic file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with:
|
||||||
|
- components: list of {name, symbol, value, x, y}
|
||||||
|
- nets: list of net/flag names
|
||||||
|
- directives: list of SPICE directive strings
|
||||||
|
"""
|
||||||
|
sch = parse_schematic(schematic_path)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"version": sch.version,
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"name": c.name,
|
||||||
|
"symbol": c.symbol,
|
||||||
|
"value": c.value,
|
||||||
|
"x": c.x,
|
||||||
|
"y": c.y,
|
||||||
|
"attributes": c.attributes,
|
||||||
|
}
|
||||||
|
for c in sch.components
|
||||||
|
],
|
||||||
|
"nets": [f.name for f in sch.flags],
|
||||||
|
"directives": sch.get_spice_directives(),
|
||||||
|
"wire_count": len(sch.wires),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def edit_component(
|
||||||
|
schematic_path: str,
|
||||||
|
component_name: str,
|
||||||
|
new_value: str,
|
||||||
|
output_path: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Modify a component's value in a schematic.
|
||||||
|
|
||||||
|
Use this to change resistor values, capacitor values, etc.
|
||||||
|
programmatically before running a simulation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schematic_path: Path to .asc schematic file
|
||||||
|
component_name: Instance name like "R1", "C2", "M1"
|
||||||
|
new_value: New value string, e.g., "10k", "100n", "2N7000"
|
||||||
|
output_path: Where to save modified schematic (None = overwrite)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with success status and component details
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sch = modify_component_value(
|
||||||
|
schematic_path,
|
||||||
|
component_name,
|
||||||
|
new_value,
|
||||||
|
output_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
comp = sch.get_component(component_name)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"component": component_name,
|
||||||
|
"new_value": new_value,
|
||||||
|
"output_path": output_path or schematic_path,
|
||||||
|
"symbol": comp.symbol if comp else None,
|
||||||
|
}
|
||||||
|
except ValueError as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def list_symbols(
|
||||||
|
category: str | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> dict:
|
||||||
|
"""List available component symbols from LTspice library.
|
||||||
|
|
||||||
|
Symbols define the graphical representation and pins of components.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
category: Filter by category folder (e.g., "Opamps", "Comparators")
|
||||||
|
search: Search term for symbol name (case-insensitive)
|
||||||
|
limit: Maximum results to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with:
|
||||||
|
- symbols: list of {name, category, path}
|
||||||
|
- total_count: total matching symbols
|
||||||
|
"""
|
||||||
|
symbols = []
|
||||||
|
sym_dir = LTSPICE_LIB / "sym"
|
||||||
|
|
||||||
|
if not sym_dir.exists():
|
||||||
|
return {"error": "Symbol library not found", "symbols": [], "total_count": 0}
|
||||||
|
|
||||||
|
for asy_file in sym_dir.rglob("*.asy"):
|
||||||
|
rel_path = asy_file.relative_to(sym_dir)
|
||||||
|
cat = str(rel_path.parent) if rel_path.parent != Path(".") else "misc"
|
||||||
|
name = asy_file.stem
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
if category and cat.lower() != category.lower():
|
||||||
|
continue
|
||||||
|
if search and search.lower() not in name.lower():
|
||||||
|
continue
|
||||||
|
|
||||||
|
symbols.append({
|
||||||
|
"name": name,
|
||||||
|
"category": cat,
|
||||||
|
"path": str(asy_file),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by name
|
||||||
|
symbols.sort(key=lambda x: x["name"].lower())
|
||||||
|
total = len(symbols)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"symbols": symbols[:limit],
|
||||||
|
"total_count": total,
|
||||||
|
"returned_count": min(limit, total),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def list_examples(
|
||||||
|
category: str | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> dict:
|
||||||
|
"""List example circuits from LTspice examples library.
|
||||||
|
|
||||||
|
Great for learning or as starting points for new designs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
category: Filter by category folder
|
||||||
|
search: Search term for example name
|
||||||
|
limit: Maximum results to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with list of example schematics
|
||||||
|
"""
|
||||||
|
examples = []
|
||||||
|
|
||||||
|
if not LTSPICE_EXAMPLES.exists():
|
||||||
|
return {"error": "Examples not found", "examples": [], "total_count": 0}
|
||||||
|
|
||||||
|
for asc_file in LTSPICE_EXAMPLES.rglob("*.asc"):
|
||||||
|
rel_path = asc_file.relative_to(LTSPICE_EXAMPLES)
|
||||||
|
cat = str(rel_path.parent) if rel_path.parent != Path(".") else "misc"
|
||||||
|
name = asc_file.stem
|
||||||
|
|
||||||
|
if category and cat.lower() != category.lower():
|
||||||
|
continue
|
||||||
|
if search and search.lower() not in name.lower():
|
||||||
|
continue
|
||||||
|
|
||||||
|
examples.append({
|
||||||
|
"name": name,
|
||||||
|
"category": cat,
|
||||||
|
"path": str(asc_file),
|
||||||
|
})
|
||||||
|
|
||||||
|
examples.sort(key=lambda x: x["name"].lower())
|
||||||
|
total = len(examples)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"examples": examples[:limit],
|
||||||
|
"total_count": total,
|
||||||
|
"returned_count": min(limit, total),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_symbol_info(symbol_path: str) -> dict:
|
||||||
|
"""Get detailed information about a component symbol.
|
||||||
|
|
||||||
|
Reads the .asy file to extract pin names, attributes, and description.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
symbol_path: Path to .asy symbol file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with symbol details including pins and default attributes
|
||||||
|
"""
|
||||||
|
path = Path(symbol_path)
|
||||||
|
if not path.exists():
|
||||||
|
return {"error": f"Symbol not found: {symbol_path}"}
|
||||||
|
|
||||||
|
content = path.read_text(errors="replace")
|
||||||
|
lines = content.split("\n")
|
||||||
|
|
||||||
|
info = {
|
||||||
|
"name": path.stem,
|
||||||
|
"pins": [],
|
||||||
|
"attributes": {},
|
||||||
|
"description": "",
|
||||||
|
"prefix": "",
|
||||||
|
"spice_prefix": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
|
||||||
|
if line.startswith("PIN"):
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 5:
|
||||||
|
info["pins"].append({
|
||||||
|
"x": int(parts[1]),
|
||||||
|
"y": int(parts[2]),
|
||||||
|
"justification": parts[3],
|
||||||
|
"rotation": parts[4] if len(parts) > 4 else "0",
|
||||||
|
})
|
||||||
|
|
||||||
|
elif line.startswith("PINATTR PinName"):
|
||||||
|
pin_name = line.split(None, 2)[2] if len(line.split()) > 2 else ""
|
||||||
|
if info["pins"]:
|
||||||
|
info["pins"][-1]["name"] = pin_name
|
||||||
|
|
||||||
|
elif line.startswith("SYMATTR"):
|
||||||
|
parts = line.split(None, 2)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
attr_name = parts[1]
|
||||||
|
attr_value = parts[2]
|
||||||
|
info["attributes"][attr_name] = attr_value
|
||||||
|
|
||||||
|
if attr_name == "Description":
|
||||||
|
info["description"] = attr_value
|
||||||
|
elif attr_name == "Prefix":
|
||||||
|
info["prefix"] = attr_value
|
||||||
|
elif attr_name == "SpiceModel":
|
||||||
|
info["spice_prefix"] = attr_value
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def check_installation() -> dict:
|
||||||
|
"""Verify LTspice and Wine are properly installed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with installation status and paths
|
||||||
|
"""
|
||||||
|
ok, msg = validate_installation()
|
||||||
|
|
||||||
|
from .config import LTSPICE_DIR, LTSPICE_EXE, WINE_PREFIX
|
||||||
|
|
||||||
|
return {
|
||||||
|
"valid": ok,
|
||||||
|
"message": msg,
|
||||||
|
"paths": {
|
||||||
|
"ltspice_dir": str(LTSPICE_DIR),
|
||||||
|
"ltspice_exe": str(LTSPICE_EXE),
|
||||||
|
"wine_prefix": str(WINE_PREFIX),
|
||||||
|
"lib_dir": str(LTSPICE_LIB),
|
||||||
|
"examples_dir": str(LTSPICE_EXAMPLES),
|
||||||
|
},
|
||||||
|
"lib_exists": LTSPICE_LIB.exists(),
|
||||||
|
"examples_exist": LTSPICE_EXAMPLES.exists(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# RESOURCES
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.resource("ltspice://symbols")
|
||||||
|
def resource_symbols() -> str:
|
||||||
|
"""List of all available LTspice symbols organized by category."""
|
||||||
|
result = list_symbols(limit=10000)
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.resource("ltspice://examples")
|
||||||
|
def resource_examples() -> str:
|
||||||
|
"""List of all LTspice example circuits."""
|
||||||
|
result = list_examples(limit=10000)
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.resource("ltspice://status")
|
||||||
|
def resource_status() -> str:
|
||||||
|
"""Current LTspice installation status."""
|
||||||
|
return json.dumps(check_installation(), indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ENTRY POINT
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run the MCP server."""
|
||||||
|
print(f"🔌 mcp-ltspice v{__version__}")
|
||||||
|
print(" LTspice circuit simulation automation")
|
||||||
|
|
||||||
|
# Quick validation
|
||||||
|
ok, msg = validate_installation()
|
||||||
|
if ok:
|
||||||
|
print(f" ✓ {msg}")
|
||||||
|
else:
|
||||||
|
print(f" ⚠ {msg}")
|
||||||
|
|
||||||
|
mcp.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
x
Reference in New Issue
Block a user