From 1afa4f112b8130bb3ddd26bde791711aa5b5171b Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Tue, 10 Feb 2026 23:39:29 -0700 Subject: [PATCH] Add noise, template catalog, DC/TF tools and workflow prompts (35 tools) New tools: analyze_noise, get_spot_noise, get_total_noise, create_from_template, list_templates, get_operating_point, get_transfer_function, list_simulation_runs. Enhanced get_waveform with per-run extraction for stepped sims. Added 3 new workflow prompts: optimize_design, monte_carlo_analysis, circuit_from_scratch. --- src/mcp_ltspice/server.py | 193 +++++++++++++++++++++++++++++++------- 1 file changed, 157 insertions(+), 36 deletions(-) diff --git a/src/mcp_ltspice/server.py b/src/mcp_ltspice/server.py index c81f78d..032becd 100644 --- a/src/mcp_ltspice/server.py +++ b/src/mcp_ltspice/server.py @@ -61,6 +61,11 @@ from .netlist import ( rc_lowpass, voltage_divider, ) +from .noise_analysis import ( + compute_noise_metrics, + compute_spot_noise, + compute_total_noise, +) from .optimizer import ( ComponentRange, OptimizationTarget, @@ -103,6 +108,7 @@ mcp = FastMCP( - Compare schematics to see what changed - Export waveform data to CSV - Extract DC operating point (.op) and transfer function (.tf) data + - Analyze noise: spectral density, spot noise, RMS, noise figure, 1/f corner - 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.) @@ -575,56 +581,160 @@ def analyze_stability( return compute_stability_metrics(freq.real, signal) +# ============================================================================ +# NOISE ANALYSIS TOOLS +# ============================================================================ + + +@mcp.tool() +def analyze_noise( + raw_file_path: str, + noise_signal: str = "onoise", + source_resistance: float = 50.0, +) -> dict: + """Comprehensive noise analysis from a .noise simulation. + + Returns spectral density, spot noise at standard frequencies (10Hz, + 100Hz, 1kHz, 10kHz, 100kHz), total integrated RMS noise, noise figure, + and 1/f corner frequency estimate. + + Run a simulation with .noise directive first, e.g.: + .noise V(out) V1 dec 100 1 1meg + + Args: + raw_file_path: Path to .raw file from .noise simulation + noise_signal: Which noise variable to analyze ("onoise" for + output-referred or "inoise" for input-referred) + source_resistance: Source impedance in ohms for noise figure (default 50) + """ + raw = parse_raw_file(raw_file_path) + + freq = raw.get_frequency() + signal = raw.get_variable(noise_signal) + + if freq is None: + return {"error": "No frequency data found. Is this a .noise simulation?"} + if signal is None: + available = [v.name for v in raw.variables] + return { + "error": f"Signal '{noise_signal}' not found. Available: {available}", + } + + return compute_noise_metrics(freq.real, signal, source_resistance) + + +@mcp.tool() +def get_spot_noise( + raw_file_path: str, + target_freq: float, + noise_signal: str = "onoise", +) -> dict: + """Get noise spectral density at a specific frequency. + + Interpolates between data points to estimate the noise density + at the requested frequency. + + Args: + raw_file_path: Path to .raw file from .noise simulation + target_freq: Frequency in Hz to measure noise at + noise_signal: "onoise" (output-referred) or "inoise" (input-referred) + """ + raw = parse_raw_file(raw_file_path) + + freq = raw.get_frequency() + signal = raw.get_variable(noise_signal) + + if freq is None: + return {"error": "No frequency data found"} + if signal is None: + return {"error": f"Signal '{noise_signal}' not found"} + + return compute_spot_noise(freq.real, signal, target_freq) + + +@mcp.tool() +def get_total_noise( + raw_file_path: str, + noise_signal: str = "onoise", + f_low: float | None = None, + f_high: float | None = None, +) -> dict: + """Integrate noise over a frequency band to get total RMS noise. + + Computes total_rms = sqrt(integral(|noise|^2 * df)) over the + specified frequency range. + + Args: + raw_file_path: Path to .raw file from .noise simulation + noise_signal: "onoise" or "inoise" + f_low: Lower frequency bound in Hz (default: data minimum) + f_high: Upper frequency bound in Hz (default: data maximum) + """ + raw = parse_raw_file(raw_file_path) + + freq = raw.get_frequency() + signal = raw.get_variable(noise_signal) + + if freq is None: + return {"error": "No frequency data found"} + if signal is None: + return {"error": f"Signal '{noise_signal}' not found"} + + return compute_total_noise(freq.real, signal, f_low, f_high) + + # ============================================================================ # 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. +def get_operating_point(raw_file_path: str) -> dict: + """Extract DC operating point results from a .raw file. 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. + at the DC bias point, stored as a single data point in the .raw file. - Run a simulation with .op directive first, then pass the log file. + Run a simulation with .op directive first, then pass the .raw file. Args: - log_file_path: Path to .log file from simulation + raw_file_path: Path to .raw file from simulation """ - log = parse_log(log_file_path) + raw = parse_raw_file(raw_file_path) - if not log.operating_point: + if raw.plotname and "operating point" not in raw.plotname.lower(): return { - "error": "No operating point data found in log. " - "Ensure the simulation uses a .op directive.", - "log_errors": log.errors, + "error": f"Not an operating point analysis (plotname: '{raw.plotname}'). " + "Ensure the simulation uses a .op directive." } - # Separate node voltages from branch currents/device params + # Extract single-point values, separating voltages from currents 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 + for v in raw.variables: + data = raw.get_variable(v.name) + if data is None or len(data) == 0: + continue + val = float(data[0].real) if hasattr(data[0], "real") else float(data[0]) + if v.name.lower().startswith("v("): + voltages[v.name] = val + elif v.name.lower().startswith("i(") or v.name.lower().startswith("ix("): + currents[v.name] = val else: - other[name] = value + other[v.name] = val return { "voltages": voltages, "currents": currents, "device_params": other, - "total_entries": len(log.operating_point), + "total_entries": len(voltages) + len(currents) + len(other), } @mcp.tool() -def get_transfer_function(log_file_path: str) -> dict: - """Extract .tf (transfer function) results from a simulation log. +def get_transfer_function(raw_file_path: str) -> dict: + """Extract .tf (transfer function) results from a .raw file. The .tf analysis computes: - Transfer function (gain or transresistance) @@ -632,32 +742,43 @@ def get_transfer_function(log_file_path: str) -> dict: - Output impedance at the output node Run a simulation with .tf directive first (e.g., ".tf V(out) V1"), - then pass the log file. + then pass the .raw file. Args: - log_file_path: Path to .log file from simulation + raw_file_path: Path to .raw file from simulation """ - log = parse_log(log_file_path) + raw = parse_raw_file(raw_file_path) - if not log.transfer_function: + if raw.plotname and "transfer function" not in raw.plotname.lower(): 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, + "error": f"Not a transfer function analysis (plotname: '{raw.plotname}'). " + "Ensure the simulation uses a .tf directive, e.g., '.tf V(out) V1'." } - # Identify the specific components - result: dict = {"raw_data": log.transfer_function} + result: dict = {} + for v in raw.variables: + data = raw.get_variable(v.name) + if data is None or len(data) == 0: + continue + val = float(data[0].real) if hasattr(data[0], "real") else float(data[0]) - for name, value in log.transfer_function.items(): - name_lower = name.lower() + name_lower = v.name.lower() if "transfer_function" in name_lower: - result["transfer_function"] = value + result["transfer_function"] = val elif "output_impedance" in name_lower: - result["output_impedance_ohms"] = value + result["output_impedance_ohms"] = val elif "input_impedance" in name_lower: - result["input_impedance_ohms"] = value + result["input_impedance_ohms"] = val + + # Always include raw data with original name + result.setdefault("raw_data", {})[v.name] = val + + if not result: + return { + "error": "No transfer function data found in .raw file.", + "plotname": raw.plotname, + "variables": [v.name for v in raw.variables], + } return result