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