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.
This commit is contained in:
Ryan Malloy 2026-02-10 23:39:29 -07:00
parent cfcd0ae221
commit 1afa4f112b

View File

@ -61,6 +61,11 @@ from .netlist import (
rc_lowpass, rc_lowpass,
voltage_divider, voltage_divider,
) )
from .noise_analysis import (
compute_noise_metrics,
compute_spot_noise,
compute_total_noise,
)
from .optimizer import ( from .optimizer import (
ComponentRange, ComponentRange,
OptimizationTarget, OptimizationTarget,
@ -103,6 +108,7 @@ mcp = FastMCP(
- Compare schematics to see what changed - Compare schematics to see what changed
- Export waveform data to CSV - Export waveform data to CSV
- Extract DC operating point (.op) and transfer function (.tf) data - 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) - Measure stability (gain/phase margins from AC loop gain)
- Compute power and efficiency from voltage/current waveforms - Compute power and efficiency from voltage/current waveforms
- Evaluate waveform math expressions (V*I, gain, dB, etc.) - Evaluate waveform math expressions (V*I, gain, dB, etc.)
@ -575,56 +581,160 @@ def analyze_stability(
return compute_stability_metrics(freq.real, signal) 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 # DC OPERATING POINT & TRANSFER FUNCTION TOOLS
# ============================================================================ # ============================================================================
@mcp.tool() @mcp.tool()
def get_operating_point(log_file_path: str) -> dict: def get_operating_point(raw_file_path: str) -> dict:
"""Extract DC operating point results from a simulation log. """Extract DC operating point results from a .raw file.
The .op analysis computes all node voltages and branch currents The .op analysis computes all node voltages and branch currents
at the DC bias point. Results include device operating points at the DC bias point, stored as a single data point in the .raw file.
(transistor Gm, Id, Vgs, etc.) when available.
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: 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 { return {
"error": "No operating point data found in log. " "error": f"Not an operating point analysis (plotname: '{raw.plotname}'). "
"Ensure the simulation uses a .op directive.", "Ensure the simulation uses a .op directive."
"log_errors": log.errors,
} }
# Separate node voltages from branch currents/device params # Extract single-point values, separating voltages from currents
voltages = {} voltages = {}
currents = {} currents = {}
other = {} other = {}
for name, value in log.operating_point.items(): for v in raw.variables:
if name.startswith("V(") or name.startswith("v("): data = raw.get_variable(v.name)
voltages[name] = value if data is None or len(data) == 0:
elif name.startswith("I(") or name.startswith("i(") or name.startswith("Ix("): continue
currents[name] = value 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: else:
other[name] = value other[v.name] = val
return { return {
"voltages": voltages, "voltages": voltages,
"currents": currents, "currents": currents,
"device_params": other, "device_params": other,
"total_entries": len(log.operating_point), "total_entries": len(voltages) + len(currents) + len(other),
} }
@mcp.tool() @mcp.tool()
def get_transfer_function(log_file_path: str) -> dict: def get_transfer_function(raw_file_path: str) -> dict:
"""Extract .tf (transfer function) results from a simulation log. """Extract .tf (transfer function) results from a .raw file.
The .tf analysis computes: The .tf analysis computes:
- Transfer function (gain or transresistance) - Transfer function (gain or transresistance)
@ -632,32 +742,43 @@ def get_transfer_function(log_file_path: str) -> dict:
- Output impedance at the output node - Output impedance at the output node
Run a simulation with .tf directive first (e.g., ".tf V(out) V1"), Run a simulation with .tf directive first (e.g., ".tf V(out) V1"),
then pass the log file. then pass the .raw file.
Args: 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 { return {
"error": "No transfer function data found in log. " "error": f"Not a transfer function analysis (plotname: '{raw.plotname}'). "
"Ensure the simulation uses a .tf directive, " "Ensure the simulation uses a .tf directive, e.g., '.tf V(out) V1'."
"e.g., '.tf V(out) V1'.",
"log_errors": log.errors,
} }
# Identify the specific components result: dict = {}
result: dict = {"raw_data": log.transfer_function} 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 = v.name.lower()
name_lower = name.lower()
if "transfer_function" in name_lower: if "transfer_function" in name_lower:
result["transfer_function"] = value result["transfer_function"] = val
elif "output_impedance" in name_lower: elif "output_impedance" in name_lower:
result["output_impedance_ohms"] = value result["output_impedance_ohms"] = val
elif "input_impedance" in name_lower: 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 return result