mcltspice/tests/test_stability.py
2026-02-10 23:35:53 -07:00

109 lines
4.0 KiB
Python

"""Tests for stability module: gain margin, phase margin from loop gain data."""
import numpy as np
import pytest
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