From 63ce37d983c3d0298ecb0ef6c05d0dfb4d4c0e5e Mon Sep 17 00:00:00 2001 From: Rory Yorke Date: Sat, 8 Nov 2025 11:23:57 +0200 Subject: [PATCH 1/2] pytest-parametrize and slycot-mark tests in mateqn_test.py Test solvers dependent on Slycot when "slycot" test marker is specified. These tests are now parametrized by method. --- control/tests/mateqn_test.py | 153 ++++++++++++++++++++++------------- 1 file changed, 95 insertions(+), 58 deletions(-) diff --git a/control/tests/mateqn_test.py b/control/tests/mateqn_test.py index d12a7f6ef..77bf553bf 100644 --- a/control/tests/mateqn_test.py +++ b/control/tests/mateqn_test.py @@ -39,53 +39,56 @@ import pytest from scipy.linalg import eigvals, solve -import control as ct from control.mateqn import lyap, dlyap, care, dare -from control.exception import ControlArgument, ControlDimension, slycot_check +from control.exception import ControlArgument, ControlDimension class TestMatrixEquations: """These are tests for the matrix equation solvers in mateqn.py""" - def test_lyap(self): + @pytest.mark.parametrize('method', + ['scipy', + pytest.param('slycot', marks=pytest.mark.slycot)]) + def test_lyap(self, method): A = array([[-1, 1], [-1, 0]]) Q = array([[1, 0], [0, 1]]) - X = lyap(A, Q) + X = lyap(A, Q, method=method) # print("The solution obtained is ", X) assert_array_almost_equal(A @ X + X @ A.T + Q, zeros((2,2))) A = array([[1, 2], [-3, -4]]) Q = array([[3, 1], [1, 1]]) - X = lyap(A,Q) + X = lyap(A,Q, method=method) # print("The solution obtained is ", X) assert_array_almost_equal(A @ X + X @ A.T + Q, zeros((2,2))) # Compare methods - if slycot_check(): + if method == 'slycot': X_scipy = lyap(A, Q, method='scipy') - X_slycot = lyap(A, Q, method='slycot') - assert_array_almost_equal(X_scipy, X_slycot) + assert_array_almost_equal(X_scipy, X) - def test_lyap_sylvester(self): + @pytest.mark.parametrize('method', + ['scipy', + pytest.param('slycot', marks=pytest.mark.slycot)]) + def test_lyap_sylvester(self, method): A = 5 B = array([[4, 3], [4, 3]]) C = array([2, 1]) - X = lyap(A, B, C) + X = lyap(A, B, C, method=method) # print("The solution obtained is ", X) assert_array_almost_equal(A * X + X @ B + C, zeros((1,2))) A = array([[2, 1], [1, 2]]) B = array([[1, 2], [0.5, 0.1]]) C = array([[1, 0], [0, 1]]) - X = lyap(A, B, C) + X = lyap(A, B, C, method=method) # print("The solution obtained is ", X) assert_array_almost_equal(A @ X + X @ B + C, zeros((2,2))) # Compare methods - if slycot_check(): + if method=='slycot': X_scipy = lyap(A, B, C, method='scipy') - X_slycot = lyap(A, B, C, method='slycot') - assert_array_almost_equal(X_scipy, X_slycot) + assert_array_almost_equal(X_scipy, X) @pytest.mark.slycot def test_lyap_g(self): @@ -101,19 +104,27 @@ def test_lyap_g(self): with pytest.raises(ControlArgument, match="'scipy' not valid"): X = lyap(A, Q, None, E, method='scipy') - def test_dlyap(self): + @pytest.mark.parametrize('method', + ['scipy', + pytest.param('slycot', marks=pytest.mark.slycot)]) + def test_dlyap(self, method): A = array([[-0.6, 0],[-0.1, -0.4]]) Q = array([[1,0],[0,1]]) - X = dlyap(A,Q) + X = dlyap(A,Q,method=method) # print("The solution obtained is ", X) assert_array_almost_equal(A @ X @ A.T - X + Q, zeros((2,2))) A = array([[-0.6, 0],[-0.1, -0.4]]) Q = array([[3, 1],[1, 1]]) - X = dlyap(A,Q) + X = dlyap(A,Q,method=method) # print("The solution obtained is ", X) assert_array_almost_equal(A @ X @ A.T - X + Q, zeros((2,2))) + # Compare methods + if method=='slycot': + X_scipy = dlyap(A,Q, method='scipy') + assert_array_almost_equal(X_scipy, X) + @pytest.mark.slycot def test_dlyap_g(self): A = array([[-0.6, 0],[-0.1, -0.4]]) @@ -148,12 +159,15 @@ def test_dlyap_sylvester(self): with pytest.raises(ControlArgument, match="'scipy' not valid"): X = dlyap(A, B, C, method='scipy') - def test_care(self): + @pytest.mark.parametrize('method', + ['scipy', + pytest.param('slycot', marks=pytest.mark.slycot)]) + def test_care(self, method): A = array([[-2, -1],[-1, -1]]) Q = array([[0, 0],[0, 1]]) B = array([[1, 0],[0, 4]]) - X, L, G = care(A, B, Q) + X, L, G = care(A, B, Q, method=method) # print("The solution obtained is", X) M = A.T @ X + X @ A - X @ B @ B.T @ X + Q assert_array_almost_equal(M, @@ -161,14 +175,16 @@ def test_care(self): assert_array_almost_equal(B.T @ X, G) # Compare methods - if slycot_check(): + if method == 'slycot': X_scipy, L_scipy, G_scipy = care(A, B, Q, method='scipy') - X_slycot, L_slycot, G_slycot = care(A, B, Q, method='slycot') - assert_array_almost_equal(X_scipy, X_slycot) - assert_array_almost_equal(np.sort(L_scipy), np.sort(L_slycot)) - assert_array_almost_equal(G_scipy, G_slycot) - - def test_care_g(self): + assert_array_almost_equal(X_scipy, X) + assert_array_almost_equal(np.sort(L_scipy), np.sort(L)) + assert_array_almost_equal(G_scipy, G) + + @pytest.mark.parametrize('method', + ['scipy', + pytest.param('slycot', marks=pytest.mark.slycot)]) + def test_care_g(self, method): A = array([[-2, -1],[-1, -1]]) Q = array([[0, 0],[0, 1]]) B = array([[1, 0],[0, 4]]) @@ -176,7 +192,7 @@ def test_care_g(self): S = array([[0, 0],[0, 0]]) E = array([[2, 1],[1, 2]]) - X,L,G = care(A,B,Q,R,S,E) + X,L,G = care(A,B,Q,R,S,E,method=method) # print("The solution obtained is", X) Gref = solve(R, B.T @ X @ E + S.T) assert_array_almost_equal(Gref, G) @@ -186,16 +202,17 @@ def test_care_g(self): zeros((2,2))) # Compare methods - if slycot_check(): + if method=='slycot': X_scipy, L_scipy, G_scipy = care( A, B, Q, R, S, E, method='scipy') - X_slycot, L_slycot, G_slycot = care( - A, B, Q, R, S, E, method='slycot') - assert_array_almost_equal(X_scipy, X_slycot) - assert_array_almost_equal(np.sort(L_scipy), np.sort(L_slycot)) - assert_array_almost_equal(G_scipy, G_slycot) - - def test_care_g2(self): + assert_array_almost_equal(X_scipy, X) + assert_array_almost_equal(np.sort(L_scipy), np.sort(L)) + assert_array_almost_equal(G_scipy, G) + + @pytest.mark.parametrize('method', + ['scipy', + pytest.param('slycot', marks=pytest.mark.slycot)]) + def test_care_g2(self, method): A = array([[-2, -1],[-1, -1]]) Q = array([[0, 0],[0, 1]]) B = array([[1],[0]]) @@ -203,7 +220,7 @@ def test_care_g2(self): S = array([[1],[0]]) E = array([[2, 1],[1, 2]]) - X,L,G = care(A,B,Q,R,S,E) + X,L,G = care(A,B,Q,R,S,E,method=method) # print("The solution obtained is", X) Gref = 1/R * (B.T @ X @ E + S.T) assert_array_almost_equal( @@ -213,22 +230,23 @@ def test_care_g2(self): assert_array_almost_equal(Gref , G) # Compare methods - if slycot_check(): + if method=='slycot': X_scipy, L_scipy, G_scipy = care( A, B, Q, R, S, E, method='scipy') - X_slycot, L_slycot, G_slycot = care( - A, B, Q, R, S, E, method='slycot') - assert_array_almost_equal(X_scipy, X_slycot) - assert_array_almost_equal(L_scipy, L_slycot) - assert_array_almost_equal(G_scipy, G_slycot) - - def test_dare(self): + assert_array_almost_equal(X_scipy, X) + assert_array_almost_equal(L_scipy, L) + assert_array_almost_equal(G_scipy, G) + + @pytest.mark.parametrize('method', + ['scipy', + pytest.param('slycot', marks=pytest.mark.slycot)]) + def test_dare(self, method): A = array([[-0.6, 0],[-0.1, -0.4]]) Q = array([[2, 1],[1, 0]]) B = array([[2, 1],[0, 1]]) R = array([[1, 0],[0, 1]]) - X, L, G = dare(A, B, Q, R) + X, L, G = dare(A, B, Q, R, method=method) # print("The solution obtained is", X) Gref = solve(B.T @ X @ B + R, B.T @ X @ A) assert_array_almost_equal(Gref, G) @@ -243,7 +261,7 @@ def test_dare(self): B = array([[1],[0]]) R = 2 - X, L, G = dare(A, B, Q, R) + X, L, G = dare(A, B, Q, R, method=method) # print("The solution obtained is", X) AtXA = A.T @ X @ A AtXB = A.T @ X @ B @@ -256,6 +274,7 @@ def test_dare(self): lam = eigvals(A - B @ G) assert_array_less(abs(lam), 1.0) + @pytest.mark.slycot def test_dare_compare(self): A = np.array([[-0.6, 0], [-0.1, -0.4]]) Q = np.array([[2, 1], [1, 0]]) @@ -267,15 +286,16 @@ def test_dare_compare(self): # Solve via scipy X_scipy, L_scipy, G_scipy = dare(A, B, Q, R, method='scipy') - # Solve via slycot - if ct.slycot_check(): - X_slicot, L_slicot, G_slicot = dare( - A, B, Q, R, S, E, method='scipy') - np.testing.assert_almost_equal(X_scipy, X_slicot) - np.testing.assert_almost_equal(L_scipy, L_slicot) - np.testing.assert_almost_equal(G_scipy, G_slicot) + X_slicot, L_slicot, G_slicot = dare( + A, B, Q, R, S, E, method='scipy') + np.testing.assert_almost_equal(X_scipy, X_slicot) + np.testing.assert_almost_equal(L_scipy, L_slicot) + np.testing.assert_almost_equal(G_scipy, G_slicot) - def test_dare_g(self): + @pytest.mark.parametrize('method', + ['scipy', + pytest.param('slycot', marks=pytest.mark.slycot)]) + def test_dare_g(self, method): A = array([[-0.6, 0],[-0.1, -0.4]]) Q = array([[2, 1],[1, 3]]) B = array([[1, 5],[2, 4]]) @@ -283,7 +303,7 @@ def test_dare_g(self): S = array([[1, 0],[2, 0]]) E = array([[2, 1],[1, 2]]) - X, L, G = dare(A, B, Q, R, S, E) + X, L, G = dare(A, B, Q, R, S, E, method=method) # print("The solution obtained is", X) Gref = solve(B.T @ X @ B + R, B.T @ X @ A + S.T) assert_array_almost_equal(Gref, G) @@ -293,8 +313,18 @@ def test_dare_g(self): # check for stable closed loop lam = eigvals(A - B @ G, E) assert_array_less(abs(lam), 1.0) - - def test_dare_g2(self): + # Compare methods + if method=='slycot': + X_scipy, L_scipy, G_scipy = dare( + A, B, Q, R, S, E, method='scipy') + assert_array_almost_equal(X_scipy, X) + assert_array_almost_equal(L_scipy, L) + assert_array_almost_equal(G_scipy, G) + + @pytest.mark.parametrize('method', + ['scipy', + pytest.param('slycot', marks=pytest.mark.slycot)]) + def test_dare_g2(self, method): A = array([[-0.6, 0], [-0.1, -0.4]]) Q = array([[2, 1], [1, 3]]) B = array([[1], [2]]) @@ -302,7 +332,7 @@ def test_dare_g2(self): S = array([[1], [2]]) E = array([[2, 1], [1, 2]]) - X, L, G = dare(A, B, Q, R, S, E) + X, L, G = dare(A, B, Q, R, S, E, method=method) # print("The solution obtained is", X) AtXA = A.T @ X @ A AtXB = A.T @ X @ B @@ -316,6 +346,13 @@ def test_dare_g2(self): lam = eigvals(A - B @ G, E) assert_array_less(abs(lam), 1.0) + if method=='slycot': + X_scipy, L_scipy, G_scipy = dare( + A, B, Q, R, S, E, method='scipy') + assert_array_almost_equal(X_scipy, X) + assert_array_almost_equal(L_scipy, L) + assert_array_almost_equal(G_scipy, G) + def test_raise(self): """ Test exception raise for invalid inputs """ From faaa40eae0b96bacc7c3dde9109bac3e24f16379 Mon Sep 17 00:00:00 2001 From: Rory Yorke Date: Sat, 8 Nov 2025 15:17:46 +0200 Subject: [PATCH 2/2] Add test marker noslycot & mark more tests with slycot Use custom pytest mark `noslycot` for tests where slycot must not be installed to pass. Extend use of pyest mark `slycot` to other tests using Slycot functions. --- control/tests/conftest.py | 12 ++-- control/tests/convert_test.py | 22 +++++--- control/tests/interconnect_test.py | 6 +- control/tests/lti_test.py | 48 ++++++++-------- control/tests/margin_test.py | 91 ++++++++++++++++-------------- control/tests/namedio_test.py | 39 +++++++------ control/tests/statefbk_test.py | 52 ++++++++++------- control/tests/statesp_test.py | 7 ++- control/tests/stochsys_test.py | 16 +++--- control/tests/timeresp_test.py | 53 ++++++++++------- pyproject.toml | 1 + 11 files changed, 194 insertions(+), 153 deletions(-) diff --git a/control/tests/conftest.py b/control/tests/conftest.py index 0ad8afeaa..d055690d1 100644 --- a/control/tests/conftest.py +++ b/control/tests/conftest.py @@ -7,10 +7,14 @@ import control def pytest_runtest_setup(item): - if (not control.exception.slycot_check() - and any(mark.name == 'slycot' - for mark in item.iter_markers())): - pytest.skip("slycot not installed") + if not control.exception.slycot_check(): + if any(mark.name == 'slycot' + for mark in item.iter_markers()): + pytest.skip("slycot not installed") + elif any(mark.name == 'noslycot' + for mark in item.iter_markers()): + # used, e.g., for tests checking ControlSlycot + pytest.skip("slycot installed") if (not control.exception.cvxopt_check() and any(mark.name == 'cvxopt' diff --git a/control/tests/convert_test.py b/control/tests/convert_test.py index b3784e0f2..9cdabbe6c 100644 --- a/control/tests/convert_test.py +++ b/control/tests/convert_test.py @@ -21,16 +21,13 @@ from control import rss, ss, ss2tf, tf, tf2ss from control.statefbk import ctrb, obsv from control.freqplot import bode -from control.exception import slycot_check, ControlMIMONotImplemented +from control.exception import ControlMIMONotImplemented # Set to True to print systems to the output. verbose = False # Maximum number of states to test + 1 maxStates = 4 -# Maximum number of inputs and outputs to test + 1 -# If slycot is not installed, just check SISO -maxIO = 5 if slycot_check() else 2 @pytest.fixture @@ -49,8 +46,13 @@ def printSys(self, sys, ind): @pytest.mark.usefixtures("legacy_plot_signature") @pytest.mark.parametrize("states", range(1, maxStates)) - @pytest.mark.parametrize("inputs", range(1, maxIO)) - @pytest.mark.parametrize("outputs", range(1, maxIO)) + # If slycot is not installed, just check SISO + @pytest.mark.parametrize("inputs", + [1] + [pytest.param(i, marks=pytest.mark.slycot) + for i in range(2, 5)]) + @pytest.mark.parametrize("outputs", + [1] + [pytest.param(i, marks=pytest.mark.slycot) + for i in range(2, 5)]) def testConvert(self, fixedseed, states, inputs, outputs): """Test state space to transfer function conversion. @@ -147,7 +149,11 @@ def testConvert(self, fixedseed, states, inputs, outputs): np.testing.assert_array_almost_equal( ssorig_imag, tfxfrm_imag, decimal=5) - def testConvertMIMO(self): + + @pytest.mark.parametrize('have_slycot', + [pytest.param(True, marks=pytest.mark.slycot), + pytest.param(False, marks=pytest.mark.noslycot)]) + def testConvertMIMO(self, have_slycot): """Test state space to transfer function conversion. Do a MIMO conversion and make sure that it is processed @@ -165,7 +171,7 @@ def testConvertMIMO(self): [0.008, 1.39, 48.78]]]) # Convert to state space and look for an error - if (not slycot_check()): + if not have_slycot: with pytest.raises(ControlMIMONotImplemented): tf2ss(tsys) else: diff --git a/control/tests/interconnect_test.py b/control/tests/interconnect_test.py index aea3cbbc6..ccce76f34 100644 --- a/control/tests/interconnect_test.py +++ b/control/tests/interconnect_test.py @@ -56,14 +56,12 @@ def test_summation_exceptions(): ct.summing_junction('u', 'y', dimension=False) -@pytest.mark.parametrize("dim", [1, 3]) +@pytest.mark.parametrize("dim", + [1, pytest.param(3, marks=pytest.mark.slycot)]) def test_interconnect_implicit(dim): """Test the use of implicit connections in interconnect()""" import random - if dim != 1 and not ct.slycot_check(): - pytest.xfail("slycot not installed") - # System definition P = ct.rss(2, dim, dim, strictly_proper=True, name='P') diff --git a/control/tests/lti_test.py b/control/tests/lti_test.py index 45c75f964..dd95f3505 100644 --- a/control/tests/lti_test.py +++ b/control/tests/lti_test.py @@ -8,7 +8,6 @@ import control as ct from control import NonlinearIOSystem, c2d, common_timebase, isctime, \ isdtime, issiso, ss, tf, tf2ss -from control.exception import slycot_check from control.lti import LTI, bandwidth, damp, dcgain, evalfr, poles, zeros @@ -189,6 +188,10 @@ def test_isdtime(self, objfun, arg, dt, ref, strictref): assert isctime(obj) == ref assert isctime(obj, strict=True) == strictref + def p(*args): + # convenience for parametrize below + return pytest.param(*args, marks=pytest.mark.slycot) + @pytest.mark.usefixtures("editsdefaults") @pytest.mark.parametrize("fcn", [ct.ss, ct.tf, ct.frd]) @pytest.mark.parametrize("nstate, nout, ninp, omega, squeeze, shape", [ @@ -201,26 +204,26 @@ def test_isdtime(self, objfun, arg, dt, ref, strictref): [3, 1, 1, 0.1, False, (1, 1)], [3, 1, 1, [0.1], False, (1, 1, 1)], [3, 1, 1, [0.1, 1, 10], False, (1, 1, 3)], - [1, 2, 1, 0.1, None, (2, 1)], # SIMO - [1, 2, 1, [0.1], None, (2, 1, 1)], - [1, 2, 1, [0.1, 1, 10], None, (2, 1, 3)], - [2, 2, 1, 0.1, True, (2,)], - [2, 2, 1, [0.1], True, (2,)], - [3, 2, 1, 0.1, False, (2, 1)], - [3, 2, 1, [0.1], False, (2, 1, 1)], - [3, 2, 1, [0.1, 1, 10], False, (2, 1, 3)], - [1, 1, 2, [0.1, 1, 10], None, (1, 2, 3)], # MISO - [2, 1, 2, [0.1, 1, 10], True, (2, 3)], - [3, 1, 2, [0.1, 1, 10], False, (1, 2, 3)], - [1, 1, 2, 0.1, None, (1, 2)], - [1, 1, 2, 0.1, True, (2,)], - [1, 1, 2, 0.1, False, (1, 2)], - [1, 2, 2, [0.1, 1, 10], None, (2, 2, 3)], # MIMO - [2, 2, 2, [0.1, 1, 10], True, (2, 2, 3)], - [3, 2, 2, [0.1, 1, 10], False, (2, 2, 3)], - [1, 2, 2, 0.1, None, (2, 2)], - [2, 2, 2, 0.1, True, (2, 2)], - [3, 2, 2, 0.1, False, (2, 2)], + p(1, 2, 1, 0.1, None, (2, 1)), + p(1, 2, 1, [0.1], None, (2, 1, 1)), + p(1, 2, 1, [0.1, 1, 10], None, (2, 1, 3)), + p(2, 2, 1, 0.1, True, (2,)), + p(2, 2, 1, [0.1], True, (2,)), + p(3, 2, 1, 0.1, False, (2, 1)), + p(3, 2, 1, [0.1], False, (2, 1, 1)), + p(3, 2, 1, [0.1, 1, 10], False, (2, 1, 3)), + p(1, 1, 2, [0.1, 1, 10], None, (1, 2, 3)), # MISO + p(2, 1, 2, [0.1, 1, 10], True, (2, 3)), + p(3, 1, 2, [0.1, 1, 10], False, (1, 2, 3)), + p(1, 1, 2, 0.1, None, (1, 2)), + p(1, 1, 2, 0.1, True, (2,)), + p(1, 1, 2, 0.1, False, (1, 2)), + p(1, 2, 2, [0.1, 1, 10], None, (2, 2, 3)), # MIMO + p(2, 2, 2, [0.1, 1, 10], True, (2, 2, 3)), + p(3, 2, 2, [0.1, 1, 10], False, (2, 2, 3)), + p(1, 2, 2, 0.1, None, (2, 2)), + p(2, 2, 2, 0.1, True, (2, 2)), + p(3, 2, 2, 0.1, False, (2, 2)), ]) @pytest.mark.parametrize("omega_type", ["numpy", "native"]) def test_squeeze(self, fcn, nstate, nout, ninp, omega, squeeze, shape, @@ -229,9 +232,6 @@ def test_squeeze(self, fcn, nstate, nout, ninp, omega, squeeze, shape, # Create the system to be tested if fcn == ct.frd: sys = fcn(ct.rss(nstate, nout, ninp), [1e-2, 1e-1, 1, 1e1, 1e2]) - elif fcn == ct.tf and (nout > 1 or ninp > 1) and not slycot_check(): - pytest.skip("Conversion of MIMO systems to transfer functions " - "requires slycot.") else: sys = fcn(ct.rss(nstate, nout, ninp)) diff --git a/control/tests/margin_test.py b/control/tests/margin_test.py index 679c1c685..c8be4ee6c 100644 --- a/control/tests/margin_test.py +++ b/control/tests/margin_test.py @@ -15,7 +15,6 @@ from control import ControlMIMONotImplemented, FrequencyResponseData, \ StateSpace, TransferFunction, margin, phase_crossover_frequencies, \ stability_margins, disk_margins, tf, ss -from control.exception import slycot_check s = TransferFunction.s @@ -394,6 +393,7 @@ def test_siso_disk_margin(): DM = disk_margins(L, omega, skew=1.0)[0] assert_allclose([DM], [SM], atol=0.01) +@pytest.mark.slycot def test_mimo_disk_margin(): # Frequencies of interest omega = np.logspace(-1, 3, 1001) @@ -404,23 +404,32 @@ def test_mimo_disk_margin(): Lo = P * K # loop transfer function, broken at plant output Li = K * P # loop transfer function, broken at plant input - if slycot_check(): - # Balanced (S - T) disk-based stability margins at plant output + # Balanced (S - T) disk-based stability margins at plant output + DMo, DGMo, DPMo = disk_margins(Lo, omega, skew=0.0) + assert_allclose([DMo], [0.3754], atol=0.1) # disk margin of 0.3754 + assert_allclose([DGMo], [3.3], atol=0.1) # disk-based gain margin of 3.3 dB + assert_allclose([DPMo], [21.26], atol=0.1) # disk-based phase margin of 21.26 deg + + # Balanced (S - T) disk-based stability margins at plant input + DMi, DGMi, DPMi = disk_margins(Li, omega, skew=0.0) + assert_allclose([DMi], [0.3754], atol=0.1) # disk margin of 0.3754 + assert_allclose([DGMi], [3.3], atol=0.1) # disk-based gain margin of 3.3 dB + assert_allclose([DPMi], [21.26], atol=0.1) # disk-based phase margin of 21.26 deg + + +@pytest.mark.noslycot +def test_mimo_disk_margin_exception(): + # Slycot not installed. Should throw exception. + # Frequencies of interest + omega = np.logspace(-1, 3, 1001) + + # Loop transfer gain + P = ss([[0, 10], [-10, 0]], np.eye(2), [[1, 10], [-10, 1]], 0) # plant + K = ss([], [], [], [[1, -2], [0, 1]]) # controller + Lo = P * K # loop transfer function, broken at plant output + with pytest.raises(ControlMIMONotImplemented,\ + match="Need slycot to compute MIMO disk_margins"): DMo, DGMo, DPMo = disk_margins(Lo, omega, skew=0.0) - assert_allclose([DMo], [0.3754], atol=0.1) # disk margin of 0.3754 - assert_allclose([DGMo], [3.3], atol=0.1) # disk-based gain margin of 3.3 dB - assert_allclose([DPMo], [21.26], atol=0.1) # disk-based phase margin of 21.26 deg - - # Balanced (S - T) disk-based stability margins at plant input - DMi, DGMi, DPMi = disk_margins(Li, omega, skew=0.0) - assert_allclose([DMi], [0.3754], atol=0.1) # disk margin of 0.3754 - assert_allclose([DGMi], [3.3], atol=0.1) # disk-based gain margin of 3.3 dB - assert_allclose([DPMi], [21.26], atol=0.1) # disk-based phase margin of 21.26 deg - else: - # Slycot not installed. Should throw exception. - with pytest.raises(ControlMIMONotImplemented,\ - match="Need slycot to compute MIMO disk_margins"): - DMo, DGMo, DPMo = disk_margins(Lo, omega, skew=0.0) def test_siso_disk_margin_return_all(): # Frequencies of interest @@ -439,6 +448,8 @@ def test_siso_disk_margin_return_all(): assert_allclose([DPM[np.argmin(DM)]], [25.8],\ atol=0.1) # disk-based phase margin of 25.8 deg + +@pytest.mark.slycot def test_mimo_disk_margin_return_all(): # Frequencies of interest omega = np.logspace(-1, 3, 1001) @@ -450,29 +461,23 @@ def test_mimo_disk_margin_return_all(): Lo = P * K # loop transfer function, broken at plant output Li = K * P # loop transfer function, broken at plant input - if slycot_check(): - # Balanced (S - T) disk-based stability margins at plant output - DMo, DGMo, DPMo = disk_margins(Lo, omega, skew=0.0, returnall=True) - assert_allclose([omega[np.argmin(DMo)]], [omega[0]],\ - atol=0.01) # sensitivity peak at 0 rad/s (or smallest provided) - assert_allclose([min(DMo)], [0.3754], atol=0.1) # disk margin of 0.3754 - assert_allclose([DGMo[np.argmin(DMo)]], [3.3],\ - atol=0.1) # disk-based gain margin of 3.3 dB - assert_allclose([DPMo[np.argmin(DMo)]], [21.26],\ - atol=0.1) # disk-based phase margin of 21.26 deg - - # Balanced (S - T) disk-based stability margins at plant input - DMi, DGMi, DPMi = disk_margins(Li, omega, skew=0.0, returnall=True) - assert_allclose([omega[np.argmin(DMi)]], [omega[0]],\ - atol=0.01) # sensitivity peak at 0 rad/s (or smallest provided) - assert_allclose([min(DMi)], [0.3754],\ - atol=0.1) # disk margin of 0.3754 - assert_allclose([DGMi[np.argmin(DMi)]], [3.3],\ - atol=0.1) # disk-based gain margin of 3.3 dB - assert_allclose([DPMi[np.argmin(DMi)]], [21.26],\ - atol=0.1) # disk-based phase margin of 21.26 deg - else: - # Slycot not installed. Should throw exception. - with pytest.raises(ControlMIMONotImplemented,\ - match="Need slycot to compute MIMO disk_margins"): - DMo, DGMo, DPMo = disk_margins(Lo, omega, skew=0.0, returnall=True) + # Balanced (S - T) disk-based stability margins at plant output + DMo, DGMo, DPMo = disk_margins(Lo, omega, skew=0.0, returnall=True) + assert_allclose([omega[np.argmin(DMo)]], [omega[0]],\ + atol=0.01) # sensitivity peak at 0 rad/s (or smallest provided) + assert_allclose([min(DMo)], [0.3754], atol=0.1) # disk margin of 0.3754 + assert_allclose([DGMo[np.argmin(DMo)]], [3.3],\ + atol=0.1) # disk-based gain margin of 3.3 dB + assert_allclose([DPMo[np.argmin(DMo)]], [21.26],\ + atol=0.1) # disk-based phase margin of 21.26 deg + + # Balanced (S - T) disk-based stability margins at plant input + DMi, DGMi, DPMi = disk_margins(Li, omega, skew=0.0, returnall=True) + assert_allclose([omega[np.argmin(DMi)]], [omega[0]],\ + atol=0.01) # sensitivity peak at 0 rad/s (or smallest provided) + assert_allclose([min(DMi)], [0.3754],\ + atol=0.1) # disk margin of 0.3754 + assert_allclose([DGMi[np.argmin(DMi)]], [3.3],\ + atol=0.1) # disk-based gain margin of 3.3 dB + assert_allclose([DPMi[np.argmin(DMi)]], [21.26],\ + atol=0.1) # disk-based phase margin of 21.26 deg diff --git a/control/tests/namedio_test.py b/control/tests/namedio_test.py index ad74d27ba..8c44f5980 100644 --- a/control/tests/namedio_test.py +++ b/control/tests/namedio_test.py @@ -79,21 +79,26 @@ def test_named_ss(): } +def p(*args): + # convenience for parametrize below + return pytest.param(*args, marks=pytest.mark.slycot) + + @pytest.mark.parametrize("fun, args, kwargs", [ - [ct.rss, (4, 1, 1), {}], - [ct.rss, (3, 2, 1), {}], - [ct.drss, (4, 1, 1), {}], - [ct.drss, (3, 2, 1), {}], + p(ct.rss, (4, 1, 1), {}), + p(ct.rss, (3, 2, 1), {}), + p(ct.drss, (4, 1, 1), {}), + p(ct.drss, (3, 2, 1), {}), [ct.FRD, ([1, 2, 3,], [1, 2, 3]), {}], [ct.NonlinearIOSystem, (lambda t, x, u, params: -x, None), {'inputs': 2, 'outputs':2, 'states':2}], - [ct.ss, ([[1, 2], [3, 4]], [[0], [1]], [[1, 0]], 0), {}], - [ct.ss, ([], [], [], 3), {}], # static system - [ct.StateSpace, ([[1, 2], [3, 4]], [[0], [1]], [[1, 0]], 0), {}], - [ct.tf, ([1, 2], [3, 4, 5]), {}], - [ct.tf, (2, 3), {}], # static system - [ct.TransferFunction, ([1, 2], [3, 4, 5]), {}], + p(ct.ss, ([[1, 2], [3, 4]], [[0], [1]], [[1, 0]], 0), {}), + p(ct.ss, ([], [], [], 3), {}), # static system + p(ct.StateSpace, ([[1, 2], [3, 4]], [[0], [1]], [[1, 0]], 0), {}), + p(ct.tf, ([1, 2], [3, 4, 5]), {}), + p(ct.tf, (2, 3), {}), # static system + p(ct.TransferFunction, ([1, 2], [3, 4, 5]), {}), ]) def test_io_naming(fun, args, kwargs): # Reset the ID counter to get uniform generic names @@ -164,8 +169,8 @@ def test_io_naming(fun, args, kwargs): # # Convert the system to state space and make sure labels transfer # - if ct.slycot_check() and not isinstance( - sys_r, (ct.FrequencyResponseData, ct.NonlinearIOSystem)): + if not isinstance(sys_r, + (ct.FrequencyResponseData, ct.NonlinearIOSystem)): sys_ss = ct.ss(sys_r) assert sys_ss != sys_r assert sys_ss.input_labels == input_labels @@ -184,9 +189,8 @@ def test_io_naming(fun, args, kwargs): # # Convert the system to a transfer function and make sure labels transfer # - if not isinstance( - sys_r, (ct.FrequencyResponseData, ct.NonlinearIOSystem)) and \ - ct.slycot_check(): + if not isinstance(sys_r, + (ct.FrequencyResponseData, ct.NonlinearIOSystem)): sys_tf = ct.tf(sys_r) assert sys_tf != sys_r assert sys_tf.input_labels == input_labels @@ -202,9 +206,8 @@ def test_io_naming(fun, args, kwargs): # # Convert the system to a StateSpace and make sure labels transfer # - if not isinstance( - sys_r, (ct.FrequencyResponseData, ct.NonlinearIOSystem)) and \ - ct.slycot_check(): + if not isinstance(sys_r, + (ct.FrequencyResponseData, ct.NonlinearIOSystem)): sys_lio = ct.ss(sys_r) assert sys_lio != sys_r assert sys_lio.input_labels == input_labels diff --git a/control/tests/statefbk_test.py b/control/tests/statefbk_test.py index b34150018..97cf7be68 100644 --- a/control/tests/statefbk_test.py +++ b/control/tests/statefbk_test.py @@ -12,7 +12,7 @@ import control as ct from control import poles, rss, ss, tf from control.exception import ControlDimension, ControlSlycot, \ - ControlArgument, slycot_check + ControlArgument from control.mateqn import care, dare from control.statefbk import (ctrb, obsv, place, place_varga, lqr, dlqr, gram, place_acker) @@ -411,27 +411,30 @@ def check_DLQR(self, K, S, poles, Q, R): np.testing.assert_array_almost_equal(K, K_expected) np.testing.assert_array_almost_equal(poles, poles_expected) - @pytest.mark.parametrize("method", [None, 'slycot', 'scipy']) + @pytest.mark.parametrize("method", + [None, + pytest.param('slycot', marks=pytest.mark.slycot), + 'scipy']) def test_LQR_integrator(self, method): - if method == 'slycot' and not slycot_check(): - return A, B, Q, R = (np.array([[X]]) for X in [0., 1., 10., 2.]) K, S, poles = lqr(A, B, Q, R, method=method) self.check_LQR(K, S, poles, Q, R) - @pytest.mark.parametrize("method", [None, 'slycot', 'scipy']) + @pytest.mark.parametrize("method", + [None, + pytest.param('slycot', marks=pytest.mark.slycot), + 'scipy']) def test_LQR_3args(self, method): - if method == 'slycot' and not slycot_check(): - return sys = ss(0., 1., 1., 0.) Q, R = (np.array([[X]]) for X in [10., 2.]) K, S, poles = lqr(sys, Q, R, method=method) self.check_LQR(K, S, poles, Q, R) - @pytest.mark.parametrize("method", [None, 'slycot', 'scipy']) + @pytest.mark.parametrize("method", + [None, + pytest.param('slycot', marks=pytest.mark.slycot), + 'scipy']) def test_DLQR_3args(self, method): - if method == 'slycot' and not slycot_check(): - return dsys = ss(0., 1., 1., 0., .1) Q, R = (np.array([[X]]) for X in [10., 2.]) K, S, poles = dlqr(dsys, Q, R, method=method) @@ -448,12 +451,12 @@ def test_lqr_badmethod(self, cdlqr): with pytest.raises(ControlArgument, match="Unknown method"): K, S, poles = cdlqr(A, B, Q, R, method='nosuchmethod') + @pytest.mark.noslycot @pytest.mark.parametrize("cdlqr", [lqr, dlqr]) def test_lqr_slycot_not_installed(self, cdlqr): A, B, Q, R = 0, 1, 10, 2 - if not slycot_check(): - with pytest.raises(ControlSlycot, match="Can't find slycot"): - K, S, poles = cdlqr(A, B, Q, R, method='slycot') + with pytest.raises(ControlSlycot, match="Can't find slycot"): + K, S, poles = cdlqr(A, B, Q, R, method='slycot') @pytest.mark.xfail(reason="warning not implemented") def testLQR_warning(self): @@ -537,7 +540,13 @@ def testDLQR_warning(self): with pytest.warns(UserWarning): (K, S, E) = dlqr(A, B, Q, R, N) - def test_care(self): + @pytest.mark.parametrize('have_slycot', + [pytest.param(True, marks=pytest.mark.slycot), + pytest.param(False, marks=pytest.mark.noslycot)]) + @pytest.mark.parametrize("method", + [pytest.param('slycot', marks=pytest.mark.slycot), + 'scipy']) + def test_care(self, have_slycot, method): """Test stabilizing and anti-stabilizing feedback, continuous""" A = np.diag([1, -1]) B = np.identity(2) @@ -546,15 +555,15 @@ def test_care(self): S = np.zeros((2, 2)) E = np.identity(2) - X, L, G = care(A, B, Q, R, S, E, stabilizing=True) + X, L, G = care(A, B, Q, R, S, E, stabilizing=True, method=method) assert np.all(np.real(L) < 0) - if slycot_check(): - X, L, G = care(A, B, Q, R, S, E, stabilizing=False) + if have_slycot and method=='slycot': + X, L, G = care(A, B, Q, R, S, E, stabilizing=False, method=method) assert np.all(np.real(L) > 0) else: with pytest.raises(ControlArgument, match="'scipy' not valid"): - X, L, G = care(A, B, Q, R, S, E, stabilizing=False) + X, L, G = care(A, B, Q, R, S, E, stabilizing=False, method=method) @pytest.mark.parametrize( "stabilizing", @@ -781,7 +790,10 @@ def test_statefbk_iosys_unused(self): np.testing.assert_allclose(clsys0_lin.A, clsys2_lin.A) - def test_lqr_integral_continuous(self): + @pytest.mark.parametrize('have_slycot', + [pytest.param(True, marks=pytest.mark.slycot), + pytest.param(False, marks=pytest.mark.noslycot)]) + def test_lqr_integral_continuous(self, have_slycot): # Generate a continuous-time system for testing sys = ct.rss(4, 4, 2, strictly_proper=True) sys.C = np.eye(4) # reset output to be full state @@ -843,7 +855,7 @@ def test_lqr_integral_continuous(self): assert all(np.real(clsys.poles()) < 0) # Make sure controller infinite zero frequency gain - if slycot_check(): + if have_slycot: ctrl_tf = tf(ctrl) assert abs(ctrl_tf(1e-9)[0][0]) > 1e6 assert abs(ctrl_tf(1e-9)[1][1]) > 1e6 diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index 1f6d4a6bb..9b3c677fe 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -1602,10 +1602,13 @@ def test_tf2ss_unstable(method): np.testing.assert_allclose(tf_poles, ss_poles, rtol=1e-4) -def test_tf2ss_mimo(): +@pytest.mark.parametrize('have_slycot', + [pytest.param(True, marks=pytest.mark.slycot), + pytest.param(False, marks=pytest.mark.noslycot)]) +def test_tf2ss_mimo(have_slycot): sys_tf = ct.tf([[[1], [1, 1, 1]]], [[[1, 1, 1], [1, 2, 1]]]) - if ct.slycot_check(): + if have_slycot: sys_ss = ct.ss(sys_tf) np.testing.assert_allclose( np.sort(sys_tf.poles()), np.sort(sys_ss.poles())) diff --git a/control/tests/stochsys_test.py b/control/tests/stochsys_test.py index 6fc87461b..20e799643 100644 --- a/control/tests/stochsys_test.py +++ b/control/tests/stochsys_test.py @@ -6,7 +6,7 @@ import control as ct import control.optimal as opt -from control import lqe, dlqe, rss, tf, ControlArgument, slycot_check +from control import lqe, dlqe, rss, tf, ControlArgument from math import log, pi # Utility function to check LQE answer @@ -27,11 +27,10 @@ def check_DLQE(L, P, poles, G, QN, RN): np.testing.assert_almost_equal(L, L_expected) np.testing.assert_almost_equal(poles, poles_expected) -@pytest.mark.parametrize("method", [None, 'slycot', 'scipy']) +@pytest.mark.parametrize("method", [None, + pytest.param('slycot', marks=pytest.mark.slycot), + 'scipy']) def test_LQE(method): - if method == 'slycot' and not slycot_check(): - return - A, G, C, QN, RN = (np.array([[X]]) for X in [0., .1, 1., 10., 2.]) L, P, poles = lqe(A, G, C, QN, RN, method=method) check_LQE(L, P, poles, G, QN, RN) @@ -78,11 +77,10 @@ def test_lqe_call_format(cdlqe): with pytest.raises(ct.ControlArgument, match="LTI system must be"): L, P, E = cdlqe(sys_tf, Q, R) -@pytest.mark.parametrize("method", [None, 'slycot', 'scipy']) +@pytest.mark.parametrize("method", [None, + pytest.param('slycot', marks=pytest.mark.slycot), + 'scipy']) def test_DLQE(method): - if method == 'slycot' and not slycot_check(): - return - A, G, C, QN, RN = (np.array([[X]]) for X in [0., .1, 1., 10., 2.]) L, P, poles = dlqe(A, G, C, QN, RN, method=method) check_DLQE(L, P, poles, G, QN, RN) diff --git a/control/tests/timeresp_test.py b/control/tests/timeresp_test.py index fdb47fd53..16ee01a3d 100644 --- a/control/tests/timeresp_test.py +++ b/control/tests/timeresp_test.py @@ -8,7 +8,7 @@ import control as ct from control import StateSpace, TransferFunction, c2d, isctime, ss2tf, tf2ss -from control.exception import pandas_check, slycot_check +from control.exception import pandas_check from control.timeresp import _default_time_vector, _ideal_tfinal_and_dt, \ forced_response, impulse_response, initial_response, step_info, \ step_response @@ -1032,30 +1032,41 @@ def test_time_series_data_convention_2D(self, tsystem): assert y.ndim == 1 # SISO returns "scalar" output assert t.shape == y.shape # Allows direct plotting of output + def p(*args): + # convenience for parametrize below + return pytest.param(*args, marks=pytest.mark.slycot) + @pytest.mark.usefixtures("editsdefaults") - @pytest.mark.parametrize("fcn", [ct.ss, ct.tf]) - @pytest.mark.parametrize("nstate, nout, ninp, squeeze, shape1, shape2", [ - # state out in squeeze in/out out-only - [1, 1, 1, None, (8,), (8,)], - [2, 1, 1, True, (8,), (8,)], - [3, 1, 1, False, (1, 1, 8), (1, 8)], - [3, 2, 1, None, (2, 1, 8), (2, 8)], - [4, 2, 1, True, (2, 8), (2, 8)], - [5, 2, 1, False, (2, 1, 8), (2, 8)], - [3, 1, 2, None, (1, 2, 8), (1, 8)], - [4, 1, 2, True, (2, 8), (8,)], - [5, 1, 2, False, (1, 2, 8), (1, 8)], - [4, 2, 2, None, (2, 2, 8), (2, 8)], - [5, 2, 2, True, (2, 2, 8), (2, 8)], - [6, 2, 2, False, (2, 2, 8), (2, 8)], + @pytest.mark.parametrize("fcn, nstate, nout, ninp, squeeze, shape1, shape2", [ + # fcn, state out in squeeze in/out out-only + [ct.ss, 1, 1, 1, None, (8,), (8,)], + [ct.ss, 2, 1, 1, True, (8,), (8,)], + [ct.ss, 3, 1, 1, False, (1, 1, 8), (1, 8)], + [ct.ss, 3, 2, 1, None, (2, 1, 8), (2, 8)], + [ct.ss, 4, 2, 1, True, (2, 8), (2, 8)], + [ct.ss, 5, 2, 1, False, (2, 1, 8), (2, 8)], + [ct.ss, 3, 1, 2, None, (1, 2, 8), (1, 8)], + [ct.ss, 4, 1, 2, True, (2, 8), (8,)], + [ct.ss, 5, 1, 2, False, (1, 2, 8), (1, 8)], + [ct.ss, 4, 2, 2, None, (2, 2, 8), (2, 8)], + [ct.ss, 5, 2, 2, True, (2, 2, 8), (2, 8)], + [ct.ss, 6, 2, 2, False, (2, 2, 8), (2, 8)], + [ct.tf, 1, 1, 1, None, (8,), (8,)], + [ct.tf, 2, 1, 1, True, (8,), (8,)], + [ct.tf, 3, 1, 1, False, (1, 1, 8), (1, 8)], + p(ct.tf, 3, 2, 1, None, (2, 1, 8), (2, 8)), + p(ct.tf, 4, 2, 1, True, (2, 8), (2, 8)), + p(ct.tf, 5, 2, 1, False, (2, 1, 8), (2, 8)), + p(ct.tf, 3, 1, 2, None, (1, 2, 8), (1, 8)), + p(ct.tf, 4, 1, 2, True, (2, 8), (8,)), + p(ct.tf, 5, 1, 2, False, (1, 2, 8), (1, 8)), + p(ct.tf, 4, 2, 2, None, (2, 2, 8), (2, 8)), + p(ct.tf, 5, 2, 2, True, (2, 2, 8), (2, 8)), + p(ct.tf, 6, 2, 2, False, (2, 2, 8), (2, 8)), ]) def test_squeeze(self, fcn, nstate, nout, ninp, squeeze, shape1, shape2): # Define the system - if fcn == ct.tf and (nout > 1 or ninp > 1) and not slycot_check(): - pytest.skip("Conversion of MIMO systems to transfer functions " - "requires slycot.") - else: - sys = fcn(ct.rss(nstate, nout, ninp, strictly_proper=True)) + sys = fcn(ct.rss(nstate, nout, ninp, strictly_proper=True)) # Generate the time and input vectors tvec = np.linspace(0, 1, 8) diff --git a/pyproject.toml b/pyproject.toml index 494aafe69..b76a3731f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ filterwarnings = [ ] markers = [ "slycot: tests needing slycot", + "noslycot: test needing slycot absent", "cvxopt: tests needing cvxopt", "pandas: tests needing pandas", ]