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,
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