Ryan Malloy ba649d2a6e Add stability, power, optimization, batch, and schematic generation tools
Phase 3 features bringing the server to 27 tools:
- Stepped/multi-run .raw file parsing (.step, .mc, .temp)
- Stability analysis (gain/phase margin from AC loop gain)
- Power analysis (average, RMS, efficiency, power factor)
- Safe waveform expression evaluator (recursive-descent parser)
- Component value optimizer (binary search + coordinate descent)
- Batch simulation: parameter sweep, temperature sweep, Monte Carlo
- .asc schematic generation from templates (RC filter, divider, inverting amp)
- Touchstone .s1p/.s2p/.snp S-parameter file parsing
- 7 new netlist templates (diff amp, common emitter, buck, LDO, oscillator, H-bridge)
- Full ruff lint and format compliance across all modules
2026-02-10 23:05:35 -07:00

1502 lines
45 KiB
Python

"""FastMCP server for LTspice circuit simulation automation.
This server provides tools for:
- Running SPICE simulations on schematics and netlists
- Extracting and analyzing waveform data
- Creating circuits programmatically
- Modifying schematic components
- Browsing component libraries, models, and examples
- Design rule checks and circuit comparison
"""
import csv
import io
import json
import math
import tempfile
from pathlib import Path
import numpy as np
from fastmcp import FastMCP
from . import __version__
from .asc_generator import (
generate_inverting_amp,
)
from .asc_generator import (
generate_rc_lowpass as generate_rc_lowpass_asc,
)
from .asc_generator import (
generate_voltage_divider as generate_voltage_divider_asc,
)
from .batch import (
run_monte_carlo,
run_parameter_sweep,
run_temperature_sweep,
)
from .config import (
LTSPICE_EXAMPLES,
LTSPICE_LIB,
validate_installation,
)
from .diff import diff_schematics as _diff_schematics
from .drc import run_drc as _run_drc
from .log_parser import parse_log
from .models import (
search_models as _search_models,
)
from .models import (
search_subcircuits as _search_subcircuits,
)
from .netlist import Netlist
from .optimizer import (
ComponentRange,
OptimizationTarget,
format_engineering,
optimize_component_values,
)
from .power_analysis import compute_efficiency, compute_power_metrics
from .raw_parser import parse_raw_file
from .runner import run_netlist, run_simulation
from .schematic import modify_component_value, parse_schematic
from .stability import compute_stability_metrics
from .touchstone import parse_touchstone, s_param_to_db
from .waveform_expr import WaveformCalculator
from .waveform_math import (
compute_bandwidth,
compute_fft,
compute_peak_to_peak,
compute_rise_time,
compute_rms,
compute_settling_time,
compute_thd,
)
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
- Analyze signals: FFT, THD, RMS, bandwidth, settling time
- Create circuits from scratch using the netlist builder
- Modify component values in schematics programmatically
- Browse LTspice's component library (6500+ symbols)
- Search 2800+ SPICE models and subcircuits
- Access example circuits (4000+ examples)
- Run design rule checks before simulation
- Compare schematics to see what changed
- Export waveform data to CSV
- Measure stability (gain/phase margins from AC loop gain)
- Compute power and efficiency from voltage/current waveforms
- Evaluate waveform math expressions (V*I, gain, dB, etc.)
- Optimize component values to hit target specs automatically
- Generate .asc schematic files (graphical format)
- Run parameter sweeps, temperature sweeps, and Monte Carlo analysis
- Parse Touchstone (.s2p) S-parameter files
- Use circuit templates: buck converter, LDO, diff amp, oscillator, H-bridge
LTspice runs via Wine on Linux. Simulations execute in batch mode
and results are parsed from binary .raw files.
""",
)
# ============================================================================
# SIMULATION TOOLS
# ============================================================================
@mcp.tool()
async def simulate(
schematic_path: str,
timeout_seconds: float = 300,
) -> dict:
"""Run an LTspice simulation on a schematic file.
Executes any simulation directives (.tran, .ac, .dc, .op, etc.)
found in the schematic. Returns available signal names and
the path to the .raw file for waveform extraction.
Args:
schematic_path: Absolute path to .asc schematic file
timeout_seconds: Maximum time to wait for simulation (default 5 min)
"""
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
if result.log_file and result.log_file.exists():
log = parse_log(result.log_file)
if log.measurements:
response["measurements"] = log.get_all_measurements()
if log.errors:
response["log_errors"] = log.errors
return response
@mcp.tool()
async def simulate_netlist(
netlist_path: str,
timeout_seconds: float = 300,
) -> dict:
"""Run an LTspice simulation on a netlist file (.cir or .net).
Args:
netlist_path: Absolute path to .cir or .net netlist file
timeout_seconds: Maximum time to wait for simulation
"""
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
if result.log_file and result.log_file.exists():
log = parse_log(result.log_file)
if log.measurements:
response["measurements"] = log.get_all_measurements()
if log.errors:
response["log_errors"] = log.errors
return response
# ============================================================================
# WAVEFORM & ANALYSIS TOOLS
# ============================================================================
@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.
For transient analysis, returns time + voltage/current values.
For AC analysis, returns frequency + magnitude(dB)/phase(degrees).
Args:
raw_file_path: Path to .raw file from simulation
signal_names: Signal names to extract, e.g. ["V(out)", "I(R1)"]
max_points: Maximum data points (downsampled if needed)
"""
raw = parse_raw_file(raw_file_path)
x_axis = raw.get_time()
x_name = "time"
if x_axis is None:
x_axis = raw.get_frequency()
x_name = "frequency"
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]
if np.iscomplexobj(sampled):
result["x_axis_data"] = sampled.real.tolist()
else:
result["x_axis_data"] = sampled.tolist()
result["returned_points"] = len(result["x_axis_data"])
for name in signal_names:
data = raw.get_variable(name)
if data is not None:
sampled = data[::step]
if np.iscomplexobj(sampled):
result["signals"][name] = {
"magnitude_db": [
20 * math.log10(abs(x)) if abs(x) > 0 else -200 for x in sampled
],
"phase_degrees": [math.degrees(math.atan2(x.imag, x.real)) for x in sampled],
}
else:
result["signals"][name] = {"values": sampled.tolist()}
return result
@mcp.tool()
def analyze_waveform(
raw_file_path: str,
signal_name: str,
analyses: list[str],
settling_tolerance_pct: float = 2.0,
settling_final_value: float | None = None,
rise_low_pct: float = 10.0,
rise_high_pct: float = 90.0,
fft_max_harmonics: int = 50,
thd_n_harmonics: int = 10,
) -> dict:
"""Analyze a signal from simulation results.
Run one or more analyses on a waveform. Available analyses:
- "rms": Root mean square value
- "peak_to_peak": Min, max, peak-to-peak swing, mean
- "settling_time": Time to settle within tolerance of final value
- "rise_time": 10%-90% rise time (configurable)
- "fft": Frequency spectrum via FFT
- "thd": Total Harmonic Distortion
Args:
raw_file_path: Path to .raw file
signal_name: Signal to analyze, e.g. "V(out)"
analyses: List of analysis types to run
settling_tolerance_pct: Tolerance for settling time (default 2%)
settling_final_value: Target value (None = use last sample)
rise_low_pct: Low threshold for rise time (default 10%)
rise_high_pct: High threshold for rise time (default 90%)
fft_max_harmonics: Max harmonics to return in FFT
thd_n_harmonics: Number of harmonics for THD calculation
"""
raw = parse_raw_file(raw_file_path)
time = raw.get_time()
signal = raw.get_variable(signal_name)
if signal is None:
return {
"error": f"Signal '{signal_name}' not found. Available: "
f"{[v.name for v in raw.variables]}"
}
# Use real parts for time-domain analysis
if np.iscomplexobj(time):
time = time.real
if np.iscomplexobj(signal):
signal = np.abs(signal)
results = {"signal": signal_name}
for analysis in analyses:
if analysis == "rms":
results["rms"] = compute_rms(signal)
elif analysis == "peak_to_peak":
results["peak_to_peak"] = compute_peak_to_peak(signal)
elif analysis == "settling_time":
if time is not None:
results["settling_time"] = compute_settling_time(
time,
signal,
final_value=settling_final_value,
tolerance_percent=settling_tolerance_pct,
)
elif analysis == "rise_time":
if time is not None:
results["rise_time"] = compute_rise_time(
time,
signal,
low_pct=rise_low_pct,
high_pct=rise_high_pct,
)
elif analysis == "fft":
if time is not None:
results["fft"] = compute_fft(
time,
signal,
max_harmonics=fft_max_harmonics,
)
elif analysis == "thd":
if time is not None:
results["thd"] = compute_thd(
time,
signal,
n_harmonics=thd_n_harmonics,
)
return results
@mcp.tool()
def measure_bandwidth(
raw_file_path: str,
signal_name: str,
ref_db: float | None = None,
) -> dict:
"""Measure -3dB bandwidth from an AC analysis result.
Computes the frequency range where the signal is within 3dB
of its peak (or a specified reference level).
Args:
raw_file_path: Path to .raw file from AC simulation
signal_name: Signal to measure, e.g. "V(out)"
ref_db: Reference level in dB (None = use peak)
"""
raw = parse_raw_file(raw_file_path)
freq = raw.get_frequency()
signal = raw.get_variable(signal_name)
if freq is None:
return {"error": "Not an AC analysis - no frequency data found"}
if signal is None:
return {"error": f"Signal '{signal_name}' not found"}
# Convert complex signal to magnitude in dB
mag_db = np.array([20 * math.log10(abs(x)) if abs(x) > 0 else -200 for x in signal])
return compute_bandwidth(freq.real, mag_db, ref_db=ref_db)
@mcp.tool()
def export_csv(
raw_file_path: str,
signal_names: list[str] | None = None,
output_path: str | None = None,
max_points: int = 10000,
) -> dict:
"""Export simulation waveform data to CSV format.
Args:
raw_file_path: Path to .raw file
signal_names: Signals to export (None = all)
output_path: Where to save CSV (None = auto-generate in /tmp)
max_points: Maximum rows to export
"""
raw = parse_raw_file(raw_file_path)
# Determine x-axis
x_axis = raw.get_time()
x_name = "time"
if x_axis is None:
x_axis = raw.get_frequency()
x_name = "frequency"
# Select signals
if signal_names is None:
signal_names = [
v.name for v in raw.variables if v.name not in (x_name, "time", "frequency")
]
# Downsample
total = raw.points
step = max(1, total // max_points)
# Build CSV
buf = io.StringIO()
writer = csv.writer(buf)
# Header
if x_axis is not None and np.iscomplexobj(x_axis):
headers = [x_name]
else:
headers = [x_name]
for name in signal_names:
data = raw.get_variable(name)
if data is not None:
if np.iscomplexobj(data):
headers.extend([f"{name}_magnitude_db", f"{name}_phase_deg"])
else:
headers.append(name)
writer.writerow(headers)
# Data rows
indices = range(0, total, step)
for i in indices:
row = []
if x_axis is not None:
row.append(x_axis[i].real if np.iscomplexobj(x_axis) else x_axis[i])
for name in signal_names:
data = raw.get_variable(name)
if data is not None:
if np.iscomplexobj(data):
val = data[i]
row.append(20 * math.log10(abs(val)) if abs(val) > 0 else -200)
row.append(math.degrees(math.atan2(val.imag, val.real)))
else:
row.append(data[i])
writer.writerow(row)
csv_content = buf.getvalue()
# Save to file
if output_path is None:
raw_name = Path(raw_file_path).stem
output_path = str(Path(tempfile.gettempdir()) / f"{raw_name}.csv")
Path(output_path).write_text(csv_content)
return {
"output_path": output_path,
"rows": len(indices),
"columns": headers,
}
# ============================================================================
# STABILITY ANALYSIS TOOLS
# ============================================================================
@mcp.tool()
def analyze_stability(
raw_file_path: str,
signal_name: str,
) -> dict:
"""Measure gain margin and phase margin from AC loop gain data.
Computes Bode plot (magnitude + phase) and finds the crossover
frequencies where gain = 0 dB and phase = -180 degrees.
Args:
raw_file_path: Path to .raw file from AC simulation
signal_name: Loop gain signal, e.g. "V(out)" or "V(loop_gain)"
"""
raw = parse_raw_file(raw_file_path)
freq = raw.get_frequency()
signal = raw.get_variable(signal_name)
if freq is None:
return {"error": "Not an AC analysis - no frequency data found"}
if signal is None:
return {
"error": f"Signal '{signal_name}' not found. Available: "
f"{[v.name for v in raw.variables]}"
}
return compute_stability_metrics(freq.real, signal)
# ============================================================================
# POWER ANALYSIS TOOLS
# ============================================================================
@mcp.tool()
def analyze_power(
raw_file_path: str,
voltage_signal: str,
current_signal: str,
) -> dict:
"""Compute power metrics from voltage and current waveforms.
Returns average power, RMS power, peak power, and power factor.
Args:
raw_file_path: Path to .raw file from transient simulation
voltage_signal: Voltage signal name, e.g. "V(out)"
current_signal: Current signal name, e.g. "I(R1)"
"""
raw = parse_raw_file(raw_file_path)
time = raw.get_time()
voltage = raw.get_variable(voltage_signal)
current = raw.get_variable(current_signal)
if time is None:
return {"error": "Not a transient analysis - no time data found"}
if voltage is None:
return {"error": f"Voltage signal '{voltage_signal}' not found"}
if current is None:
return {"error": f"Current signal '{current_signal}' not found"}
return compute_power_metrics(time, voltage, current)
@mcp.tool()
def compute_efficiency_tool(
raw_file_path: str,
input_voltage_signal: str,
input_current_signal: str,
output_voltage_signal: str,
output_current_signal: str,
) -> dict:
"""Compute power conversion efficiency.
Compares input power to output power for regulators, converters, etc.
Args:
raw_file_path: Path to .raw file from transient simulation
input_voltage_signal: Input voltage, e.g. "V(vin)"
input_current_signal: Input current, e.g. "I(Vin)"
output_voltage_signal: Output voltage, e.g. "V(out)"
output_current_signal: Output current, e.g. "I(Rload)"
"""
raw = parse_raw_file(raw_file_path)
time = raw.get_time()
if time is None:
return {"error": "Not a transient analysis"}
v_in = raw.get_variable(input_voltage_signal)
i_in = raw.get_variable(input_current_signal)
v_out = raw.get_variable(output_voltage_signal)
i_out = raw.get_variable(output_current_signal)
for name, sig in [
(input_voltage_signal, v_in),
(input_current_signal, i_in),
(output_voltage_signal, v_out),
(output_current_signal, i_out),
]:
if sig is None:
return {"error": f"Signal '{name}' not found"}
return compute_efficiency(time, v_in, i_in, v_out, i_out)
# ============================================================================
# WAVEFORM EXPRESSION TOOLS
# ============================================================================
@mcp.tool()
def evaluate_waveform_expression(
raw_file_path: str,
expression: str,
max_points: int = 1000,
) -> dict:
"""Evaluate a math expression on simulation waveforms.
Supports: +, -, *, /, abs(), sqrt(), log10(), dB()
Signal names reference variables from the .raw file.
Examples:
"V(out) * I(R1)" - instantaneous power
"V(out) / V(in)" - voltage gain
"dB(V(out))" - magnitude in dB
Args:
raw_file_path: Path to .raw file
expression: Math expression using signal names
max_points: Maximum data points to return
"""
raw = parse_raw_file(raw_file_path)
calc = WaveformCalculator(raw)
try:
result = calc.calc(expression)
except ValueError as e:
return {"error": str(e), "available_signals": calc.available_signals()}
# Get x-axis
x_axis = raw.get_time()
x_name = "time"
if x_axis is None:
x_axis = raw.get_frequency()
x_name = "frequency"
total = len(result)
step = max(1, total // max_points)
response = {
"expression": expression,
"total_points": total,
"returned_points": len(result[::step]),
}
if x_axis is not None:
sampled_x = x_axis[::step]
response["x_axis_name"] = x_name
response["x_axis_data"] = (
sampled_x.real.tolist() if np.iscomplexobj(sampled_x) else sampled_x.tolist()
)
response["values"] = result[::step].tolist()
response["available_signals"] = calc.available_signals()
return response
# ============================================================================
# OPTIMIZER TOOLS
# ============================================================================
@mcp.tool()
async def optimize_circuit(
netlist_template: str,
targets: list[dict],
component_ranges: list[dict],
max_iterations: int = 20,
) -> dict:
"""Automatically optimize component values to hit target specifications.
Runs real LTspice simulations in a loop, adjusting component values
using binary search (single component) or coordinate descent (multiple).
Args:
netlist_template: Netlist text with {ComponentName} placeholders
(e.g., {R1}, {C1}) that get substituted each iteration.
targets: List of target specs, each with:
- signal_name: Signal to measure (e.g., "V(out)")
- metric: One of "bandwidth_hz", "rms", "peak_to_peak",
"settling_time", "gain_db", "phase_margin_deg"
- target_value: Desired value
- weight: Importance weight (default 1.0)
component_ranges: List of tunable components, each with:
- component_name: Name matching {placeholder} (e.g., "R1")
- min_value: Minimum value in base units
- max_value: Maximum value in base units
- preferred_series: Optional "E12", "E24", or "E96" for snapping
max_iterations: Max simulation iterations (default 20)
"""
opt_targets = [
OptimizationTarget(
signal_name=t["signal_name"],
metric=t["metric"],
target_value=t["target_value"],
weight=t.get("weight", 1.0),
)
for t in targets
]
opt_ranges = [
ComponentRange(
component_name=r["component_name"],
min_value=r["min_value"],
max_value=r["max_value"],
preferred_series=r.get("preferred_series"),
)
for r in component_ranges
]
result = await optimize_component_values(
netlist_template,
opt_targets,
opt_ranges,
max_iterations,
)
return {
"best_values": {k: format_engineering(v) for k, v in result.best_values.items()},
"best_values_raw": result.best_values,
"best_cost": result.best_cost,
"iterations": result.iterations,
"targets_met": result.targets_met,
"final_metrics": result.final_metrics,
"history_length": len(result.history),
}
# ============================================================================
# BATCH SIMULATION TOOLS
# ============================================================================
@mcp.tool()
async def parameter_sweep(
netlist_text: str,
param_name: str,
start: float,
stop: float,
num_points: int = 10,
timeout_seconds: float = 300,
) -> dict:
"""Sweep a parameter across a range of values.
Runs multiple simulations, substituting the parameter value each time.
The netlist should contain a .param directive for the parameter.
Args:
netlist_text: Netlist with .param directive
param_name: Parameter to sweep (e.g., "Rval")
start: Start value
stop: Stop value
num_points: Number of sweep points
timeout_seconds: Per-simulation timeout
"""
values = np.linspace(start, stop, num_points).tolist()
result = await run_parameter_sweep(
netlist_text,
param_name,
values,
timeout=timeout_seconds,
)
return {
"success_count": result.success_count,
"failure_count": result.failure_count,
"total_elapsed": result.total_elapsed,
"parameter_values": result.parameter_values,
"raw_files": [str(r.raw_file) if r.raw_file else None for r in result.results],
}
@mcp.tool()
async def temperature_sweep(
netlist_text: str,
temperatures: list[float],
timeout_seconds: float = 300,
) -> dict:
"""Run simulations at different temperatures.
Args:
netlist_text: Netlist text
temperatures: List of temperatures in degrees C
timeout_seconds: Per-simulation timeout
"""
result = await run_temperature_sweep(
netlist_text,
temperatures,
timeout=timeout_seconds,
)
return {
"success_count": result.success_count,
"failure_count": result.failure_count,
"total_elapsed": result.total_elapsed,
"parameter_values": result.parameter_values,
"raw_files": [str(r.raw_file) if r.raw_file else None for r in result.results],
}
@mcp.tool()
async def monte_carlo(
netlist_text: str,
n_runs: int,
tolerances: dict[str, float],
timeout_seconds: float = 300,
seed: int | None = None,
) -> dict:
"""Run Monte Carlo analysis with component tolerances.
Randomly varies component values within tolerance using a normal
distribution, then runs simulations for each variant.
Args:
netlist_text: Netlist text
n_runs: Number of Monte Carlo iterations
tolerances: Component tolerances, e.g. {"R1": 0.05} for 5%
timeout_seconds: Per-simulation timeout
seed: Optional RNG seed for reproducibility
"""
result = await run_monte_carlo(
netlist_text,
n_runs,
tolerances,
timeout=timeout_seconds,
seed=seed,
)
return {
"success_count": result.success_count,
"failure_count": result.failure_count,
"total_elapsed": result.total_elapsed,
"parameter_values": result.parameter_values,
"raw_files": [str(r.raw_file) if r.raw_file else None for r in result.results],
}
# ============================================================================
# SCHEMATIC GENERATION TOOLS
# ============================================================================
@mcp.tool()
def generate_schematic(
template: str,
output_path: str | None = None,
r: str | None = None,
c: str | None = None,
r1: str | None = None,
r2: str | None = None,
vin: str | None = None,
rin: str | None = None,
rf: str | None = None,
opamp_model: str | None = None,
) -> dict:
"""Generate an LTspice .asc schematic file from a template.
Available templates and their parameters:
- "rc_lowpass": r (resistor, default "1k"), c (capacitor, default "100n")
- "voltage_divider": r1 (top, default "10k"), r2 (bottom, default "10k"),
vin (input voltage, default "5")
- "inverting_amp": rin (input R, default "10k"), rf (feedback R,
default "100k"), opamp_model (default "UniversalOpamp2")
Args:
template: Template name
output_path: Where to save (None = auto in /tmp)
r: Resistor value (rc_lowpass)
c: Capacitor value (rc_lowpass)
r1: Top resistor (voltage_divider)
r2: Bottom resistor (voltage_divider)
vin: Input voltage (voltage_divider)
rin: Input resistor (inverting_amp)
rf: Feedback resistor (inverting_amp)
opamp_model: Op-amp model name (inverting_amp)
"""
if template == "rc_lowpass":
params: dict[str, str] = {}
if r is not None:
params["r"] = r
if c is not None:
params["c"] = c
sch = generate_rc_lowpass_asc(**params)
elif template == "voltage_divider":
params = {}
if r1 is not None:
params["r1"] = r1
if r2 is not None:
params["r2"] = r2
if vin is not None:
params["vin"] = vin
sch = generate_voltage_divider_asc(**params)
elif template == "inverting_amp":
params = {}
if rin is not None:
params["rin"] = rin
if rf is not None:
params["rf"] = rf
if opamp_model is not None:
params["opamp_model"] = opamp_model
sch = generate_inverting_amp(**params)
else:
return {
"error": f"Unknown template '{template}'. "
f"Available: rc_lowpass, voltage_divider, inverting_amp"
}
if output_path is None:
output_path = str(Path(tempfile.gettempdir()) / f"{template}.asc")
saved = sch.save(output_path)
return {
"success": True,
"output_path": str(saved),
"schematic_preview": sch.render()[:500],
}
# ============================================================================
# TOUCHSTONE / S-PARAMETER TOOLS
# ============================================================================
@mcp.tool()
def read_touchstone(file_path: str) -> dict:
"""Parse a Touchstone (.s1p, .s2p, .snp) S-parameter file.
Returns S-parameter data, frequency points, and port information.
Args:
file_path: Path to Touchstone file
"""
try:
data = parse_touchstone(file_path)
except (ValueError, FileNotFoundError) as e:
return {"error": str(e)}
# Convert S-parameter data to a more digestible format
s_params = {}
for i in range(data.n_ports):
for j in range(data.n_ports):
key = f"S{i + 1}{j + 1}"
s_data = data.data[:, i, j]
s_params[key] = {
"magnitude_db": s_param_to_db(s_data).tolist(),
}
return {
"filename": data.filename,
"n_ports": data.n_ports,
"n_frequencies": len(data.frequencies),
"freq_range_hz": [float(data.frequencies[0]), float(data.frequencies[-1])],
"reference_impedance": data.reference_impedance,
"s_parameters": s_params,
"comments": data.comments[:5], # First 5 comment lines
}
# ============================================================================
# SCHEMATIC TOOLS
# ============================================================================
@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
"""
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.
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 (None = overwrite original)
"""
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 diff_schematics(
schematic_a: str,
schematic_b: str,
) -> dict:
"""Compare two schematics and show what changed.
Reports component additions, removals, value changes,
directive changes, and wire/net topology differences.
Args:
schematic_a: Path to "before" .asc file
schematic_b: Path to "after" .asc file
"""
diff = _diff_schematics(schematic_a, schematic_b)
return diff.to_dict()
@mcp.tool()
def run_drc(schematic_path: str) -> dict:
"""Run design rule checks on a schematic.
Checks for common issues:
- Missing ground connection
- Floating nodes
- Missing simulation directive
- Voltage source loops
- Missing component values
- Duplicate component names
- Unconnected components
Args:
schematic_path: Path to .asc schematic file
"""
result = _run_drc(schematic_path)
return result.to_dict()
# ============================================================================
# NETLIST BUILDER TOOLS
# ============================================================================
@mcp.tool()
def create_netlist(
title: str,
components: list[dict],
directives: list[str],
output_path: str | None = None,
) -> dict:
"""Create a SPICE netlist programmatically and save to a .cir file.
Build circuits from scratch without needing a graphical schematic.
The created .cir file can be simulated with simulate_netlist.
Args:
title: Circuit title/description
components: List of component dicts, each with:
- name: Component name (R1, C1, V1, M1, X1, etc.)
- nodes: List of node names (use "0" for ground)
- value: Value or model name
- params: Optional extra parameters string
directives: List of SPICE directives, e.g.:
[".tran 10m", ".ac dec 100 1 1meg",
".meas tran vmax MAX V(out)"]
output_path: Where to save .cir file (None = auto in /tmp)
Example components:
[
{"name": "V1", "nodes": ["in", "0"], "value": "AC 1"},
{"name": "R1", "nodes": ["in", "out"], "value": "10k"},
{"name": "C1", "nodes": ["out", "0"], "value": "100n"}
]
"""
nl = Netlist(title=title)
for comp in components:
nl.add_component(
name=comp["name"],
nodes=comp["nodes"],
value=comp["value"],
params=comp.get("params", ""),
)
for directive in directives:
nl.add_directive(directive)
# Determine output path
if output_path is None:
safe_title = "".join(c if c.isalnum() else "_" for c in title)[:30]
output_path = str(Path(tempfile.gettempdir()) / f"{safe_title}.cir")
saved = nl.save(output_path)
return {
"success": True,
"output_path": str(saved),
"netlist_preview": nl.render(),
"component_count": len(nl.components),
}
# ============================================================================
# LIBRARY & MODEL TOOLS
# ============================================================================
@mcp.tool()
def list_symbols(
category: str | None = None,
search: str | None = None,
limit: int = 50,
) -> dict:
"""List available component symbols from LTspice library.
Args:
category: Filter by category (e.g., "Opamps", "Comparators")
search: Search term for symbol name (case-insensitive)
limit: Maximum results to return
"""
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
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)})
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.
Args:
category: Filter by category folder
search: Search term for example name
limit: Maximum results to return
"""
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
"""
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 search_spice_models(
search: str | None = None,
model_type: str | None = None,
limit: int = 50,
) -> dict:
"""Search for SPICE .model definitions in the library.
Finds transistors, diodes, and other discrete devices.
Args:
search: Search term for model name (case-insensitive)
model_type: Filter by type: NPN, PNP, NMOS, PMOS, D, NJF, PJF
limit: Maximum results
"""
models = _search_models(search=search, model_type=model_type, limit=limit)
return {
"models": [
{
"name": m.name,
"type": m.type,
"source_file": m.source_file,
"parameters": m.parameters,
}
for m in models
],
"total_count": len(models),
}
@mcp.tool()
def search_spice_subcircuits(
search: str | None = None,
limit: int = 50,
) -> dict:
"""Search for SPICE .subckt definitions (op-amps, ICs, etc.).
Args:
search: Search term for subcircuit name
limit: Maximum results
"""
subs = _search_subcircuits(search=search, limit=limit)
return {
"subcircuits": [
{
"name": s.name,
"pins": s.pins,
"pin_names": s.pin_names,
"description": s.description,
"source_file": s.source_file,
"n_components": s.n_components,
}
for s in subs
],
"total_count": len(subs),
}
@mcp.tool()
def check_installation() -> dict:
"""Verify LTspice and Wine are properly installed."""
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:
"""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:
"""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)
# ============================================================================
# PROMPTS
# ============================================================================
@mcp.prompt()
def design_filter(
filter_type: str = "lowpass",
topology: str = "rc",
cutoff_freq: str = "1kHz",
) -> str:
"""Guide through designing and simulating a filter circuit.
Args:
filter_type: lowpass, highpass, bandpass, or notch
topology: rc (1st order), rlc (2nd order), or sallen-key (active)
cutoff_freq: Target cutoff frequency with units
"""
return f"""Design a {filter_type} filter with these requirements:
- Topology: {topology}
- Cutoff frequency: {cutoff_freq}
Workflow:
1. Use create_netlist to build the circuit
2. Add .ac analysis directive for frequency sweep
3. Add .meas directive for -3dB bandwidth
4. Simulate with simulate_netlist
5. Use measure_bandwidth to verify cutoff frequency
6. Use get_waveform to inspect the frequency response
7. Adjust component values with create_netlist if needed
Tips:
- For RC lowpass: f_c = 1/(2*pi*R*C)
- For 2nd order: Q controls peaking, Butterworth Q=0.707
- Use search_spice_models to find op-amp models for active filters
"""
@mcp.prompt()
def analyze_power_supply(schematic_path: str = "") -> str:
"""Guide through analyzing a power supply circuit.
Args:
schematic_path: Path to the power supply schematic
"""
path_instruction = (
f"The schematic is at: {schematic_path}"
if schematic_path
else "First, identify or create the power supply schematic."
)
return f"""Analyze a power supply circuit for key performance metrics.
{path_instruction}
Workflow:
1. Use read_schematic to understand the circuit topology
2. Use run_drc to check for design issues
3. Simulate with .tran analysis (include load step if applicable)
4. Use analyze_waveform with these analyses:
- "peak_to_peak" on output for ripple measurement
- "settling_time" for transient response
- "fft" on output to identify noise frequencies
5. If AC analysis available, use measure_bandwidth for loop gain
Key metrics to extract:
- Output voltage regulation (DC accuracy)
- Ripple voltage (peak-to-peak on output)
- Load transient response (settling time after step)
- Efficiency (input power vs output power)
"""
@mcp.prompt()
def debug_circuit(schematic_path: str = "") -> str:
"""Guide through debugging a circuit that isn't working.
Args:
schematic_path: Path to the problematic schematic
"""
path_instruction = (
f"The schematic is at: {schematic_path}"
if schematic_path
else "First, identify the schematic file."
)
return f"""Systematic approach to debugging a circuit.
{path_instruction}
Step 1 - Validate the schematic:
- Use run_drc to catch obvious issues (missing ground, floating nodes)
- Use read_schematic to review component values and connections
Step 2 - Check simulation setup:
- Verify simulation directives are correct
- Check that models/subcircuits are available (search_spice_models)
Step 3 - Run and analyze:
- Simulate the circuit
- Use get_waveform to inspect key node voltages
- Compare expected vs actual values at each stage
Step 4 - Isolate the problem:
- Use edit_component to simplify (replace active devices with ideal)
- Use diff_schematics to track what changes fixed the issue
- Re-simulate after each change
Common issues:
- Wrong node connections (check wire endpoints)
- Missing bias voltages or ground
- Component values off by orders of magnitude
- Wrong model (check with search_spice_models)
"""
# ============================================================================
# ENTRY POINT
# ============================================================================
def main():
"""Run the MCP server."""
print(f"\U0001f50c mcp-ltspice v{__version__}")
print(" LTspice circuit simulation automation")
ok, msg = validate_installation()
if ok:
print(f" \u2713 {msg}")
else:
print(f" \u26a0 {msg}")
mcp.run()
if __name__ == "__main__":
main()