Rename package from mcp-ltspice/mcp_ltspice to mcltspice throughout: source directory, imports, pyproject.toml, tests, and README. Remove startup banner prints from main() since FastMCP handles its own banner and stdout is the MCP JSON-RPC transport. Point repo URL at git.supported.systems/MCP/mcltspice.
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 mcltspice.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
|