- SVG waveform plots (svg_plot.py): pure-SVG timeseries, Bode, spectrum
generation with plot_waveform MCP tool — no matplotlib dependency
- Circuit tuning tool (tune_circuit): single-shot simulate → measure →
compare targets → suggest adjustments workflow for iterative design
- 5 new circuit templates: Sallen-Key lowpass, boost converter,
instrumentation amplifier, current mirror, transimpedance amplifier
(both netlist and .asc schematic generators, 15 total templates)
- Fix all 6 prompts to return list[Message] per FastMCP 2.x spec
- Add ltspice://templates and ltspice://template/{name} resources
- Add troubleshoot_simulation prompt
- Integration tests for RC lowpass and non-inverting amp (2/4 pass;
CE amp and Colpitts oscillator have pre-existing schematic bugs)
- 360 unit tests passing, ruff clean
108 lines
4.0 KiB
Python
108 lines
4.0 KiB
Python
"""Tests for stability module: gain margin, phase margin from loop gain data."""
|
|
|
|
import numpy as np
|
|
|
|
from mcp_ltspice.stability import (
|
|
compute_gain_margin,
|
|
compute_phase_margin,
|
|
compute_stability_metrics,
|
|
)
|
|
|
|
|
|
def _second_order_system(freq, wn=1000.0, zeta=0.3):
|
|
"""Create a 2nd-order underdamped system: H(s) = wn^2 / (s^2 + 2*zeta*wn*s + wn^2).
|
|
|
|
Returns complex loop gain at the given frequencies.
|
|
"""
|
|
s = 1j * 2 * np.pi * freq
|
|
return wn**2 / (s**2 + 2 * zeta * wn * s + wn**2)
|
|
|
|
|
|
class TestGainMargin:
|
|
def test_third_order_system(self):
|
|
"""A 3rd-order system crosses -180 phase and has finite gain margin."""
|
|
freq = np.logspace(0, 6, 10000)
|
|
# Three-pole system: K / ((s/w1 + 1) * (s/w2 + 1) * (s/w3 + 1))
|
|
# Phase goes from 0 to -270, so it definitely crosses -180
|
|
w1 = 2 * np.pi * 100
|
|
w2 = 2 * np.pi * 1000
|
|
w3 = 2 * np.pi * 10000
|
|
K = 100.0 # enough gain to have a gain crossover
|
|
s = 1j * 2 * np.pi * freq
|
|
loop_gain = K / ((s / w1 + 1) * (s / w2 + 1) * (s / w3 + 1))
|
|
|
|
result = compute_gain_margin(freq, loop_gain)
|
|
assert result["gain_margin_db"] is not None
|
|
assert result["is_stable"] is True
|
|
assert result["gain_margin_db"] > 0
|
|
assert result["phase_crossover_freq_hz"] is not None
|
|
|
|
def test_no_phase_crossover(self):
|
|
"""A simple first-order system never reaches -180 phase, so GM is infinite."""
|
|
freq = np.logspace(0, 6, 1000)
|
|
s = 1j * 2 * np.pi * freq
|
|
# First-order: 1/(1+s/wn) -- phase goes from 0 to -90
|
|
loop_gain = 1.0 / (1 + s / (2 * np.pi * 1000))
|
|
result = compute_gain_margin(freq, loop_gain)
|
|
assert result["gain_margin_db"] == float("inf")
|
|
assert result["is_stable"] is True
|
|
|
|
def test_short_input(self):
|
|
result = compute_gain_margin(np.array([1.0]), np.array([1.0 + 0j]))
|
|
assert result["gain_margin_db"] is None
|
|
|
|
|
|
class TestPhaseMargin:
|
|
def test_third_order_system(self):
|
|
"""A 3rd-order system with sufficient gain should have measurable phase margin."""
|
|
freq = np.logspace(0, 6, 10000)
|
|
w1 = 2 * np.pi * 100
|
|
w2 = 2 * np.pi * 1000
|
|
w3 = 2 * np.pi * 10000
|
|
K = 100.0
|
|
s = 1j * 2 * np.pi * freq
|
|
loop_gain = K / ((s / w1 + 1) * (s / w2 + 1) * (s / w3 + 1))
|
|
|
|
result = compute_phase_margin(freq, loop_gain)
|
|
assert result["phase_margin_deg"] is not None
|
|
assert result["is_stable"] is True
|
|
assert result["phase_margin_deg"] > 0
|
|
|
|
def test_all_gain_below_0db(self):
|
|
"""If gain is always below 0 dB, phase margin is infinite (system is stable)."""
|
|
freq = np.logspace(0, 6, 1000)
|
|
s = 1j * 2 * np.pi * freq
|
|
# Very low gain system
|
|
loop_gain = 0.001 / (1 + s / (2 * np.pi * 1000))
|
|
result = compute_phase_margin(freq, loop_gain)
|
|
assert result["phase_margin_deg"] == float("inf")
|
|
assert result["is_stable"] is True
|
|
assert result["gain_crossover_freq_hz"] is None
|
|
|
|
def test_short_input(self):
|
|
result = compute_phase_margin(np.array([1.0]), np.array([1.0 + 0j]))
|
|
assert result["phase_margin_deg"] is None
|
|
|
|
|
|
class TestStabilityMetrics:
|
|
def test_comprehensive_output(self):
|
|
"""compute_stability_metrics returns all expected fields."""
|
|
freq = np.logspace(0, 6, 5000)
|
|
w1 = 2 * np.pi * 100
|
|
w2 = 2 * np.pi * 1000
|
|
w3 = 2 * np.pi * 10000
|
|
K = 100.0
|
|
s = 1j * 2 * np.pi * freq
|
|
loop_gain = K / ((s / w1 + 1) * (s / w2 + 1) * (s / w3 + 1))
|
|
|
|
result = compute_stability_metrics(freq, loop_gain)
|
|
assert "gain_margin" in result
|
|
assert "phase_margin" in result
|
|
assert "bode" in result
|
|
assert "is_stable" in result
|
|
assert len(result["bode"]["frequency_hz"]) == len(freq)
|
|
|
|
def test_short_input_structure(self):
|
|
result = compute_stability_metrics(np.array([]), np.array([]))
|
|
assert result["is_stable"] is None
|