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