1963 lines
60 KiB
Python
1963 lines
60 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,
|
|
buck_converter,
|
|
colpitts_oscillator,
|
|
common_emitter_amplifier,
|
|
differential_amplifier,
|
|
h_bridge,
|
|
inverting_amplifier,
|
|
ldo_regulator,
|
|
non_inverting_amplifier,
|
|
rc_lowpass,
|
|
voltage_divider,
|
|
)
|
|
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
|
|
- Create circuits from 10 pre-built templates (list_templates)
|
|
- 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
|
|
- Extract DC operating point (.op) and transfer function (.tf) data
|
|
- 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
|
|
- Handle stepped simulations: list runs, extract per-run data
|
|
- Parse Touchstone (.s2p) S-parameter files
|
|
|
|
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,
|
|
run: int | None = None,
|
|
) -> 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).
|
|
|
|
For stepped simulations (.step, .mc, .temp), specify `run` (1-based)
|
|
to extract a single run's data. Omit `run` to get all data combined.
|
|
|
|
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)
|
|
run: Run number (1-based) for stepped simulations (None = all data)
|
|
"""
|
|
raw = parse_raw_file(raw_file_path)
|
|
|
|
# Extract specific run if requested
|
|
if run is not None:
|
|
if not raw.is_stepped:
|
|
return {"error": "Not a stepped simulation - no multiple runs available"}
|
|
if run < 1 or run > raw.n_runs:
|
|
return {"error": f"Run {run} out of range (1..{raw.n_runs})"}
|
|
raw = raw.get_run_data(run)
|
|
|
|
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,
|
|
"is_stepped": raw.is_stepped,
|
|
"n_runs": raw.n_runs,
|
|
}
|
|
|
|
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 list_simulation_runs(raw_file_path: str) -> dict:
|
|
"""List runs in a stepped simulation (.step, .mc, .temp).
|
|
|
|
Returns run count and boundary information for multi-run .raw files.
|
|
|
|
Args:
|
|
raw_file_path: Path to .raw file from simulation
|
|
"""
|
|
raw = parse_raw_file(raw_file_path)
|
|
|
|
result = {
|
|
"is_stepped": raw.is_stepped,
|
|
"n_runs": raw.n_runs,
|
|
"total_points": raw.points,
|
|
"plotname": raw.plotname,
|
|
"variables": [{"name": v.name, "type": v.type} for v in raw.variables],
|
|
}
|
|
|
|
if raw.is_stepped and raw.run_boundaries:
|
|
runs = []
|
|
for i in range(raw.n_runs):
|
|
start, end = raw._run_slice(i + 1)
|
|
runs.append(
|
|
{
|
|
"run": i + 1,
|
|
"start_index": start,
|
|
"end_index": end,
|
|
"points": end - start,
|
|
}
|
|
)
|
|
result["runs"] = runs
|
|
|
|
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)
|
|
|
|
|
|
# ============================================================================
|
|
# DC OPERATING POINT & TRANSFER FUNCTION TOOLS
|
|
# ============================================================================
|
|
|
|
|
|
@mcp.tool()
|
|
def get_operating_point(log_file_path: str) -> dict:
|
|
"""Extract DC operating point results from a simulation log.
|
|
|
|
The .op analysis computes all node voltages and branch currents
|
|
at the DC bias point. Results include device operating points
|
|
(transistor Gm, Id, Vgs, etc.) when available.
|
|
|
|
Run a simulation with .op directive first, then pass the log file.
|
|
|
|
Args:
|
|
log_file_path: Path to .log file from simulation
|
|
"""
|
|
log = parse_log(log_file_path)
|
|
|
|
if not log.operating_point:
|
|
return {
|
|
"error": "No operating point data found in log. "
|
|
"Ensure the simulation uses a .op directive.",
|
|
"log_errors": log.errors,
|
|
}
|
|
|
|
# Separate node voltages from branch currents/device params
|
|
voltages = {}
|
|
currents = {}
|
|
other = {}
|
|
for name, value in log.operating_point.items():
|
|
if name.startswith("V(") or name.startswith("v("):
|
|
voltages[name] = value
|
|
elif name.startswith("I(") or name.startswith("i(") or name.startswith("Ix("):
|
|
currents[name] = value
|
|
else:
|
|
other[name] = value
|
|
|
|
return {
|
|
"voltages": voltages,
|
|
"currents": currents,
|
|
"device_params": other,
|
|
"total_entries": len(log.operating_point),
|
|
}
|
|
|
|
|
|
@mcp.tool()
|
|
def get_transfer_function(log_file_path: str) -> dict:
|
|
"""Extract .tf (transfer function) results from a simulation log.
|
|
|
|
The .tf analysis computes:
|
|
- Transfer function (gain or transresistance)
|
|
- Input impedance at the source
|
|
- Output impedance at the output node
|
|
|
|
Run a simulation with .tf directive first (e.g., ".tf V(out) V1"),
|
|
then pass the log file.
|
|
|
|
Args:
|
|
log_file_path: Path to .log file from simulation
|
|
"""
|
|
log = parse_log(log_file_path)
|
|
|
|
if not log.transfer_function:
|
|
return {
|
|
"error": "No transfer function data found in log. "
|
|
"Ensure the simulation uses a .tf directive, "
|
|
"e.g., '.tf V(out) V1'.",
|
|
"log_errors": log.errors,
|
|
}
|
|
|
|
# Identify the specific components
|
|
result: dict = {"raw_data": log.transfer_function}
|
|
|
|
for name, value in log.transfer_function.items():
|
|
name_lower = name.lower()
|
|
if "transfer_function" in name_lower:
|
|
result["transfer_function"] = value
|
|
elif "output_impedance" in name_lower:
|
|
result["output_impedance_ohms"] = value
|
|
elif "input_impedance" in name_lower:
|
|
result["input_impedance_ohms"] = value
|
|
|
|
return result
|
|
|
|
|
|
# ============================================================================
|
|
# 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),
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# CIRCUIT TEMPLATE TOOLS
|
|
# ============================================================================
|
|
|
|
# Registry of netlist templates with parameter metadata
|
|
_TEMPLATES: dict[str, dict] = {
|
|
"voltage_divider": {
|
|
"func": voltage_divider,
|
|
"description": "Resistive voltage divider with .op or custom analysis",
|
|
"params": {"v_in": "5", "r1": "10k", "r2": "10k", "sim_type": "op"},
|
|
},
|
|
"rc_lowpass": {
|
|
"func": rc_lowpass,
|
|
"description": "RC lowpass filter with AC sweep",
|
|
"params": {"r": "1k", "c": "100n", "f_start": "1", "f_stop": "1meg"},
|
|
},
|
|
"inverting_amplifier": {
|
|
"func": inverting_amplifier,
|
|
"description": "Inverting op-amp (gain = -Rf/Rin), +/-15V supply",
|
|
"params": {"r_in": "10k", "r_f": "100k", "opamp_model": "LT1001"},
|
|
},
|
|
"non_inverting_amplifier": {
|
|
"func": non_inverting_amplifier,
|
|
"description": "Non-inverting op-amp (gain = 1 + Rf/Rin), +/-15V supply",
|
|
"params": {"r_in": "10k", "r_f": "100k", "opamp_model": "LT1001"},
|
|
},
|
|
"differential_amplifier": {
|
|
"func": differential_amplifier,
|
|
"description": "Diff amp: Vout = (R2/R1)*(V2-V1), +/-15V supply",
|
|
"params": {
|
|
"r1": "10k",
|
|
"r2": "10k",
|
|
"r3": "10k",
|
|
"r4": "10k",
|
|
"opamp_model": "LT1001",
|
|
},
|
|
},
|
|
"common_emitter_amplifier": {
|
|
"func": common_emitter_amplifier,
|
|
"description": "BJT common-emitter with voltage divider bias",
|
|
"params": {
|
|
"rc": "2.2k",
|
|
"rb1": "56k",
|
|
"rb2": "12k",
|
|
"re": "1k",
|
|
"cc1": "10u",
|
|
"cc2": "10u",
|
|
"ce": "47u",
|
|
"vcc": "12",
|
|
"bjt_model": "2N2222",
|
|
},
|
|
},
|
|
"buck_converter": {
|
|
"func": buck_converter,
|
|
"description": "Step-down DC-DC converter with MOSFET switch",
|
|
"params": {
|
|
"ind": "10u",
|
|
"c_out": "100u",
|
|
"r_load": "10",
|
|
"v_in": "12",
|
|
"duty_cycle": "0.5",
|
|
"freq": "100k",
|
|
"mosfet_model": "IRF540N",
|
|
"diode_model": "1N5819",
|
|
},
|
|
},
|
|
"ldo_regulator": {
|
|
"func": ldo_regulator,
|
|
"description": "LDO regulator: Vout = Vref * (1 + R1/R2)",
|
|
"params": {
|
|
"opamp_model": "LT1001",
|
|
"r1": "10k",
|
|
"r2": "10k",
|
|
"pass_transistor": "IRF9540N",
|
|
"v_in": "8",
|
|
"v_ref": "2.5",
|
|
},
|
|
},
|
|
"colpitts_oscillator": {
|
|
"func": colpitts_oscillator,
|
|
"description": "LC oscillator: f ~ 1/(2pi*sqrt(L*Cseries))",
|
|
"params": {
|
|
"ind": "1u",
|
|
"c1": "100p",
|
|
"c2": "100p",
|
|
"rb": "47k",
|
|
"rc": "1k",
|
|
"re": "470",
|
|
"vcc": "12",
|
|
"bjt_model": "2N2222",
|
|
},
|
|
},
|
|
"h_bridge": {
|
|
"func": h_bridge,
|
|
"description": "4-MOSFET H-bridge motor driver with dead time",
|
|
"params": {
|
|
"v_supply": "12",
|
|
"r_load": "10",
|
|
"mosfet_model": "IRF540N",
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
@mcp.tool()
|
|
def create_from_template(
|
|
template_name: str,
|
|
params: dict[str, str] | None = None,
|
|
output_path: str | None = None,
|
|
) -> dict:
|
|
"""Create a circuit netlist from a pre-built template.
|
|
|
|
Available templates:
|
|
- voltage_divider: params {v_in, r1, r2, sim_type}
|
|
- rc_lowpass: params {r, c, f_start, f_stop}
|
|
- inverting_amplifier: params {r_in, r_f, opamp_model}
|
|
- non_inverting_amplifier: params {r_in, r_f, opamp_model}
|
|
- differential_amplifier: params {r1, r2, r3, r4, opamp_model}
|
|
- common_emitter_amplifier: params {rc, rb1, rb2, re, cc1, cc2, ce, vcc, bjt_model}
|
|
- buck_converter: params {ind, c_out, r_load, v_in, duty_cycle, freq, mosfet_model, diode_model}
|
|
- ldo_regulator: params {opamp_model, r1, r2, pass_transistor, v_in, v_ref}
|
|
- colpitts_oscillator: params {ind, c1, c2, rb, rc, re, vcc, bjt_model}
|
|
- h_bridge: params {v_supply, r_load, mosfet_model}
|
|
|
|
All parameter values are optional -- defaults are used if omitted.
|
|
|
|
Args:
|
|
template_name: Template name from the list above
|
|
params: Optional dict of parameter overrides (all values as strings)
|
|
output_path: Where to save .cir file (None = auto in /tmp)
|
|
"""
|
|
template = _TEMPLATES.get(template_name)
|
|
if template is None:
|
|
return {
|
|
"error": f"Unknown template '{template_name}'",
|
|
"available_templates": [
|
|
{"name": k, "description": v["description"], "params": v["params"]}
|
|
for k, v in _TEMPLATES.items()
|
|
],
|
|
}
|
|
|
|
# Build kwargs from params, converting duty_cycle to float for buck_converter
|
|
kwargs: dict = {}
|
|
if params:
|
|
for k, v in params.items():
|
|
if k not in template["params"]:
|
|
return {
|
|
"error": f"Unknown parameter '{k}' for template '{template_name}'",
|
|
"valid_params": template["params"],
|
|
}
|
|
# duty_cycle needs to be float, not string
|
|
if k == "duty_cycle":
|
|
kwargs[k] = float(v)
|
|
else:
|
|
kwargs[k] = v
|
|
|
|
nl = template["func"](**kwargs)
|
|
|
|
if output_path is None:
|
|
output_path = str(Path(tempfile.gettempdir()) / f"{template_name}.cir")
|
|
|
|
saved = nl.save(output_path)
|
|
|
|
return {
|
|
"success": True,
|
|
"template": template_name,
|
|
"description": template["description"],
|
|
"output_path": str(saved),
|
|
"netlist_preview": nl.render(),
|
|
"component_count": len(nl.components),
|
|
"params_used": {**template["params"], **(params or {})},
|
|
}
|
|
|
|
|
|
@mcp.tool()
|
|
def list_templates() -> dict:
|
|
"""List all available circuit templates with their parameters and defaults.
|
|
|
|
Returns template names, descriptions, and the parameters each accepts
|
|
with their default values.
|
|
"""
|
|
return {
|
|
"templates": [
|
|
{
|
|
"name": name,
|
|
"description": info["description"],
|
|
"params": info["params"],
|
|
}
|
|
for name, info in _TEMPLATES.items()
|
|
],
|
|
"total_count": len(_TEMPLATES),
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# 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)
|
|
"""
|
|
|
|
|
|
@mcp.prompt()
|
|
def optimize_design(
|
|
circuit_type: str = "filter",
|
|
target_spec: str = "1kHz bandwidth",
|
|
) -> str:
|
|
"""Guide through optimizing a circuit to meet target specifications.
|
|
|
|
Args:
|
|
circuit_type: Type of circuit (filter, amplifier, regulator, oscillator)
|
|
target_spec: Target specification to achieve
|
|
"""
|
|
return f"""Optimize a {circuit_type} circuit to achieve: {target_spec}
|
|
|
|
Workflow:
|
|
1. Start with a template: use list_templates to see available circuits
|
|
2. Create the initial circuit with create_from_template
|
|
3. Simulate and measure the current performance
|
|
4. Use optimize_circuit to automatically tune component values:
|
|
- Define target metrics (bandwidth, gain, settling time, etc.)
|
|
- Specify component ranges with preferred E-series values
|
|
- Let the optimizer iterate (typically 10-20 simulations)
|
|
5. Verify the optimized design with a full simulation
|
|
6. Run Monte Carlo (monte_carlo tool) to check yield with tolerances
|
|
|
|
Tips:
|
|
- Start with reasonable initial values from the template
|
|
- Use E24 or E96 series for resistors/capacitors
|
|
- For filters: target bandwidth_hz metric
|
|
- For amplifiers: target gain_db and phase_margin_deg
|
|
- For regulators: target settling_time and peak_to_peak (ripple)
|
|
"""
|
|
|
|
|
|
@mcp.prompt()
|
|
def monte_carlo_analysis(
|
|
circuit_description: str = "RC filter",
|
|
n_runs: str = "100",
|
|
) -> str:
|
|
"""Guide through Monte Carlo tolerance analysis.
|
|
|
|
Args:
|
|
circuit_description: What circuit to analyze
|
|
n_runs: Number of Monte Carlo iterations
|
|
"""
|
|
return f"""Run Monte Carlo tolerance analysis on: {circuit_description}
|
|
Number of runs: {n_runs}
|
|
|
|
Workflow:
|
|
1. Create or identify the netlist for your circuit
|
|
2. Use monte_carlo tool with component tolerances:
|
|
- Resistors: typically 1% (0.01) or 5% (0.05)
|
|
- Capacitors: typically 10% (0.1) or 20% (0.2)
|
|
- Inductors: typically 10% (0.1)
|
|
3. For each completed run, extract key metrics:
|
|
- Use get_waveform on each raw file
|
|
- Use analyze_waveform for RMS, peak-to-peak, etc.
|
|
- Use measure_bandwidth for filter circuits
|
|
4. Compute statistics across all runs:
|
|
- Mean and standard deviation of each metric
|
|
- Min/max (worst case)
|
|
- Yield: what percentage meet spec?
|
|
|
|
Tips:
|
|
- Use list_simulation_runs to understand stepped data
|
|
- For stepped simulations, use get_waveform with run parameter
|
|
- Start with fewer runs (10-20) to verify setup, then scale up
|
|
- Set seed for reproducible results during development
|
|
- Typical component tolerances:
|
|
- Metal film resistors: 1%
|
|
- Ceramic capacitors: 10-20%
|
|
- Electrolytic capacitors: 20%
|
|
- Inductors: 10-20%
|
|
"""
|
|
|
|
|
|
@mcp.prompt()
|
|
def circuit_from_scratch(
|
|
description: str = "audio amplifier",
|
|
) -> str:
|
|
"""Guide through creating a complete circuit from scratch.
|
|
|
|
Args:
|
|
description: What circuit to build
|
|
"""
|
|
return f"""Build a complete circuit from scratch: {description}
|
|
|
|
Approach 1 - Use a template (recommended for common circuits):
|
|
1. Use list_templates to see available circuit templates
|
|
2. Use create_from_template with custom parameters
|
|
3. Simulate with simulate_netlist
|
|
4. Analyze results with get_waveform and analyze_waveform
|
|
|
|
Approach 2 - Build from components:
|
|
1. Use create_netlist to define components and connections
|
|
2. Use search_spice_models to find transistor/diode models
|
|
3. Use search_spice_subcircuits to find op-amp/IC models
|
|
4. Add simulation directives (.tran, .ac, .dc, .op, .tf)
|
|
5. Simulate and analyze
|
|
|
|
Approach 3 - Graphical schematic:
|
|
1. Use generate_schematic for supported topologies (rc_lowpass,
|
|
voltage_divider, inverting_amp)
|
|
2. The .asc file can be opened in LTspice GUI for editing
|
|
3. Simulate with the simulate tool
|
|
|
|
Verification workflow:
|
|
1. Run run_drc to check for design issues before simulating
|
|
2. Start with .op analysis to verify DC bias point
|
|
3. Run .tf analysis for gain and impedance
|
|
4. Run .ac analysis for frequency response
|
|
5. Run .tran analysis for time-domain behavior
|
|
6. Use diff_schematics to compare design iterations
|
|
"""
|
|
|
|
|
|
# ============================================================================
|
|
# 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()
|