From 4be47675f9fdea93a0d34f45f6c7b73bcd6d47d2 Mon Sep 17 00:00:00 2001 From: Rory Yorke Date: Sat, 18 Oct 2025 14:08:02 +0200 Subject: [PATCH 1/7] Replace test decorator slycotonly with pytest mark slycot The pytest marker slycot is equivalent to the slycotonly decorator, and also allows slycot tests to be included or excluded at the pytest command-line. --- control/tests/canonical_test.py | 16 +++++++--------- control/tests/conftest.py | 11 ++++++----- control/tests/convert_test.py | 5 ++--- control/tests/frd_test.py | 5 ++--- control/tests/freqresp_test.py | 5 ++--- control/tests/lti_test.py | 3 +-- control/tests/mateqn_test.py | 7 +++---- control/tests/matlab_test.py | 13 ++++++------- control/tests/minreal_test.py | 3 +-- control/tests/modelsimp_test.py | 7 +++---- control/tests/optimal_test.py | 3 +-- control/tests/robust_test.py | 25 ++++++++++++------------- control/tests/slycot_convert_test.py | 3 +-- control/tests/statefbk_test.py | 23 +++++++++++------------ control/tests/statesp_test.py | 26 +++++++++++++------------- control/tests/timeplot_test.py | 5 ++--- control/tests/timeresp_test.py | 9 ++++----- control/tests/xferfcn_test.py | 4 ++-- pytest.ini | 3 +++ 19 files changed, 82 insertions(+), 94 deletions(-) create mode 100644 pytest.ini diff --git a/control/tests/canonical_test.py b/control/tests/canonical_test.py index ecdaa04cb..63afd51c3 100644 --- a/control/tests/canonical_test.py +++ b/control/tests/canonical_test.py @@ -4,8 +4,6 @@ import pytest import scipy.linalg -from control.tests.conftest import slycotonly - from control import ss, tf, tf2ss from control.canonical import canonical_form, reachable_form, \ observable_form, modal_form, similarity_transform, bdschur @@ -244,7 +242,7 @@ def block_diag_from_eig(eigvals): return scipy.linalg.block_diag(*blocks) -@slycotonly +@pytest.mark.slycot @pytest.mark.parametrize( "eigvals, condmax, blksizes", [ @@ -269,7 +267,7 @@ def test_bdschur_ref(eigvals, condmax, blksizes): np.testing.assert_array_almost_equal(solve(t, a) @ t, b) -@slycotonly +@pytest.mark.slycot @pytest.mark.parametrize( "eigvals, sorted_blk_eigvals, sort", [ @@ -300,7 +298,7 @@ def test_bdschur_sort(eigvals, sorted_blk_eigvals, sort): blk_eigval.imag) -@slycotonly +@pytest.mark.slycot def test_bdschur_defective(): # the eigenvalues of this simple defective matrix cannot be separated # a previous version of the bdschur would fail on this @@ -323,14 +321,14 @@ def test_bdschur_condmax_lt_1(): bdschur(1, condmax=np.nextafter(1, 0)) -@slycotonly +@pytest.mark.slycot def test_bdschur_invalid_sort(): # sort must be in ('continuous', 'discrete') with pytest.raises(ValueError): bdschur(1, sort='no-such-sort') -@slycotonly +@pytest.mark.slycot @pytest.mark.parametrize( "A_true, B_true, C_true, D_true", [(np.diag([4.0, 3.0, 2.0, 1.0]), # order from largest to smallest @@ -390,7 +388,7 @@ def test_modal_form(A_true, B_true, C_true, D_true): C @ np.linalg.matrix_power(A, i) @ B) -@slycotonly +@pytest.mark.slycot @pytest.mark.parametrize( "condmax, len_blksizes", [(1.1, 1), @@ -409,7 +407,7 @@ def test_modal_form_condmax(condmax, len_blksizes): np.testing.assert_array_almost_equal(zsys.D, xsys.D) -@slycotonly +@pytest.mark.slycot @pytest.mark.parametrize( "sys_type", ['continuous', diff --git a/control/tests/conftest.py b/control/tests/conftest.py index c10dcc225..1b3f9755c 100644 --- a/control/tests/conftest.py +++ b/control/tests/conftest.py @@ -6,14 +6,15 @@ import control - -# some common pytest marks. These can be used as test decorators or in -# pytest.param(marks=) -slycotonly = pytest.mark.skipif( - not control.exception.slycot_check(), reason="slycot not installed") cvxoptonly = pytest.mark.skipif( not control.exception.cvxopt_check(), reason="cvxopt not installed") +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") + @pytest.fixture(scope="session", autouse=True) def control_defaults(): diff --git a/control/tests/convert_test.py b/control/tests/convert_test.py index 7975bbe5a..b3784e0f2 100644 --- a/control/tests/convert_test.py +++ b/control/tests/convert_test.py @@ -22,7 +22,6 @@ from control.statefbk import ctrb, obsv from control.freqplot import bode from control.exception import slycot_check, ControlMIMONotImplemented -from control.tests.conftest import slycotonly # Set to True to print systems to the output. @@ -214,7 +213,7 @@ def testSs2tfStaticMimo(self): np.testing.assert_allclose(numref, np.array(gtf.num) / np.array(gtf.den)) - @slycotonly + @pytest.mark.slycot def testTf2SsDuplicatePoles(self): """Tests for 'too few poles for MIMO tf gh-111'""" num = [[[1], [0]], @@ -225,7 +224,7 @@ def testTf2SsDuplicatePoles(self): s = ss(g) np.testing.assert_allclose(g.poles(), s.poles()) - @slycotonly + @pytest.mark.slycot def test_tf2ss_robustness(self): """Unit test to make sure that tf2ss is working correctly. gh-240""" num = [ [[0], [1]], [[1], [0]] ] diff --git a/control/tests/frd_test.py b/control/tests/frd_test.py index 1b370c629..b2d2a207a 100644 --- a/control/tests/frd_test.py +++ b/control/tests/frd_test.py @@ -12,7 +12,6 @@ from control.xferfcn import TransferFunction from control.frdata import frd, _convert_to_frd, FrequencyResponseData from control import bdalg, freqplot -from control.tests.conftest import slycotonly from control.exception import pandas_check @@ -567,7 +566,7 @@ def test_mul_mimo_siso(self, left, right, expected): np.testing.assert_array_almost_equal(expected_frd.omega, result.omega) np.testing.assert_array_almost_equal(expected_frd.frdata, result.frdata) - @slycotonly + @pytest.mark.slycot def test_truediv_mimo_siso(self): omega = np.logspace(-1, 1, 10) tf_mimo = TransferFunction([1], [1, 0]) * np.eye(2) @@ -592,7 +591,7 @@ def test_truediv_mimo_siso(self): np.testing.assert_array_almost_equal(expected.omega, result.omega) np.testing.assert_array_almost_equal(expected.frdata, result.frdata) - @slycotonly + @pytest.mark.slycot def test_rtruediv_mimo_siso(self): omega = np.logspace(-1, 1, 10) tf_mimo = TransferFunction([1], [1, 0]) * np.eye(2) diff --git a/control/tests/freqresp_test.py b/control/tests/freqresp_test.py index a268d38eb..5112a99e9 100644 --- a/control/tests/freqresp_test.py +++ b/control/tests/freqresp_test.py @@ -19,7 +19,6 @@ singular_values_plot, singular_values_response) from control.matlab import bode, rss, ss, tf from control.statesp import StateSpace -from control.tests.conftest import slycotonly from control.xferfcn import TransferFunction pytestmark = pytest.mark.usefixtures("mplcleanup") @@ -61,7 +60,7 @@ def test_freqresp_siso(ss_siso): @pytest.mark.filterwarnings(r"ignore:freqresp\(\) is deprecated") -@slycotonly +@pytest.mark.slycot def test_freqresp_mimo_legacy(ss_mimo): """Test MIMO frequency response calls""" omega = np.linspace(10e-2, 10e2, 1000) @@ -70,7 +69,7 @@ def test_freqresp_mimo_legacy(ss_mimo): ctrl.freqresp(tf_mimo, omega) -@slycotonly +@pytest.mark.slycot def test_freqresp_mimo(ss_mimo): """Test MIMO frequency response calls""" omega = np.linspace(10e-2, 10e2, 1000) diff --git a/control/tests/lti_test.py b/control/tests/lti_test.py index 17dc7796e..45c75f964 100644 --- a/control/tests/lti_test.py +++ b/control/tests/lti_test.py @@ -10,7 +10,6 @@ isdtime, issiso, ss, tf, tf2ss from control.exception import slycot_check from control.lti import LTI, bandwidth, damp, dcgain, evalfr, poles, zeros -from control.tests.conftest import slycotonly class TestLTI: @@ -59,7 +58,7 @@ def test_issiso(self): assert issiso(sys) assert issiso(sys, strict=True) - @slycotonly + @pytest.mark.slycot def test_issiso_mimo(self): # MIMO transfer function sys = tf([[[-1, 41], [1]], [[1, 2], [3, 4]]], diff --git a/control/tests/mateqn_test.py b/control/tests/mateqn_test.py index 0ae5a7db2..d12a7f6ef 100644 --- a/control/tests/mateqn_test.py +++ b/control/tests/mateqn_test.py @@ -42,7 +42,6 @@ import control as ct from control.mateqn import lyap, dlyap, care, dare from control.exception import ControlArgument, ControlDimension, slycot_check -from control.tests.conftest import slycotonly class TestMatrixEquations: @@ -88,7 +87,7 @@ def test_lyap_sylvester(self): X_slycot = lyap(A, B, C, method='slycot') assert_array_almost_equal(X_scipy, X_slycot) - @slycotonly + @pytest.mark.slycot def test_lyap_g(self): A = array([[-1, 2], [-3, -4]]) Q = array([[3, 1], [1, 1]]) @@ -115,7 +114,7 @@ def test_dlyap(self): # print("The solution obtained is ", X) assert_array_almost_equal(A @ X @ A.T - X + Q, zeros((2,2))) - @slycotonly + @pytest.mark.slycot def test_dlyap_g(self): A = array([[-0.6, 0],[-0.1, -0.4]]) Q = array([[3, 1],[1, 1]]) @@ -129,7 +128,7 @@ def test_dlyap_g(self): with pytest.raises(ControlArgument, match="'scipy' not valid"): X = dlyap(A, Q, None, E, method='scipy') - @slycotonly + @pytest.mark.slycot def test_dlyap_sylvester(self): A = 5 B = array([[4, 3], [4, 3]]) diff --git a/control/tests/matlab_test.py b/control/tests/matlab_test.py index c6a45e2a2..d1a71bce3 100644 --- a/control/tests/matlab_test.py +++ b/control/tests/matlab_test.py @@ -30,7 +30,6 @@ from control.exception import ControlArgument from control.frdata import FRD -from control.tests.conftest import slycotonly # for running these through Matlab or Octave ''' @@ -487,21 +486,21 @@ def testEvalfr_mimo(self, mimo): ref = np.array([[44.8 - 21.4j, 0.], [0., 44.8 - 21.4j]]) np.testing.assert_array_almost_equal(fr, ref) - @slycotonly + @pytest.mark.slycot def testHsvd(self, siso): """Call hsvd()""" hsvd(siso.ss1) hsvd(siso.ss2) hsvd(siso.ss3) - @slycotonly + @pytest.mark.slycot def testBalred(self, siso): """Call balred()""" balred(siso.ss1, 1) balred(siso.ss2, 2) balred(siso.ss3, [2, 2]) - @slycotonly + @pytest.mark.slycot def testModred(self, siso): """Call modred()""" modred(siso.ss1, [1]) @@ -509,7 +508,7 @@ def testModred(self, siso): modred(siso.ss1, [1], 'matchdc') modred(siso.ss1, [1], 'truncate') - @slycotonly + @pytest.mark.slycot def testPlace_varga(self, siso): """Call place_varga()""" place_varga(siso.ss1.A, siso.ss1.B, [-2, -2]) @@ -552,7 +551,7 @@ def testObsv(self, siso): obsv(siso.ss1.A, siso.ss1.C) obsv(siso.ss2.A, siso.ss2.C) - @slycotonly + @pytest.mark.slycot def testGram(self, siso): """Call gram()""" gram(siso.ss1, 'c') @@ -696,7 +695,7 @@ def testFRD(self): frd2 = frd(frd1.frdata[0, 0, :], omega) assert isinstance(frd2, FRD) - @slycotonly + @pytest.mark.slycot def testMinreal(self, verbose=False): """Test a minreal model reduction""" # A = [-2, 0.5, 0; 0.5, -0.3, 0; 0, 0, -0.1] diff --git a/control/tests/minreal_test.py b/control/tests/minreal_test.py index 10c56d4ca..e8223184c 100644 --- a/control/tests/minreal_test.py +++ b/control/tests/minreal_test.py @@ -11,7 +11,6 @@ from control.statesp import StateSpace from control.xferfcn import TransferFunction from itertools import permutations -from control.tests.conftest import slycotonly @pytest.fixture @@ -19,7 +18,7 @@ def fixedseed(scope="class"): np.random.seed(5) -@slycotonly +@pytest.mark.slycot @pytest.mark.usefixtures("fixedseed") class TestMinreal: """Tests for the StateSpace class.""" diff --git a/control/tests/modelsimp_test.py b/control/tests/modelsimp_test.py index e09446073..c2773231b 100644 --- a/control/tests/modelsimp_test.py +++ b/control/tests/modelsimp_test.py @@ -14,13 +14,12 @@ from control.exception import ControlArgument, ControlDimension from control.modelsimp import balred, eigensys_realization, hsvd, markov, \ modred -from control.tests.conftest import slycotonly class TestModelsimp: """Test model reduction functions""" - @slycotonly + @pytest.mark.slycot def testHSVD(self): A = np.array([[1., -2.], [3., -4.]]) B = np.array([[5.], [7.]]) @@ -390,7 +389,7 @@ def testModredTruncate(self): np.testing.assert_array_almost_equal(rsys.D, Drtrue) - @slycotonly + @pytest.mark.slycot def testBalredTruncate(self): # controlable canonical realization computed in matlab for the transfer # function: @@ -431,7 +430,7 @@ def testBalredTruncate(self): np.testing.assert_array_almost_equal(Cr, Crtrue, decimal=4) np.testing.assert_array_almost_equal(Dr, Drtrue, decimal=4) - @slycotonly + @pytest.mark.slycot def testBalredMatchDC(self): # controlable canonical realization computed in matlab for the transfer # function: diff --git a/control/tests/optimal_test.py b/control/tests/optimal_test.py index fa8fcb941..fb3f4e716 100644 --- a/control/tests/optimal_test.py +++ b/control/tests/optimal_test.py @@ -12,7 +12,6 @@ import control as ct import control.optimal as opt import control.flatsys as flat -from control.tests.conftest import slycotonly from numpy.lib import NumpyVersion @@ -103,7 +102,7 @@ def test_finite_horizon_simple(method): # optimal control problem with terminal cost set to LQR "cost to go" # gives the same answer as LQR. # -@slycotonly +@pytest.mark.slycot def test_discrete_lqr(): # oscillator model defined in 2D # Source: https://www.mpt3.org/UI/RegulationProblem diff --git a/control/tests/robust_test.py b/control/tests/robust_test.py index fc9c9570d..8434ea6cd 100644 --- a/control/tests/robust_test.py +++ b/control/tests/robust_test.py @@ -5,12 +5,11 @@ from control import append, minreal, ss, tf from control.robust import augw, h2syn, hinfsyn, mixsyn -from control.tests.conftest import slycotonly class TestHinf: - @slycotonly + @pytest.mark.slycot def testHinfsyn(self): """Test hinfsyn""" p = ss(-1, [[1, 1]], [[1], [1]], [[0, 1], [1, 0]]) @@ -32,7 +31,7 @@ def testHinfsyn(self): class TestH2: - @slycotonly + @pytest.mark.slycot def testH2syn(self): """Test h2syn""" p = ss(-1, [[1, 1]], [[1], [1]], [[0, 1], [1, 0]]) @@ -71,7 +70,7 @@ def siso_almost_equal(self, g, h): "sys 2:\n" "{}".format(maxnum, g, h)) - @slycotonly + @pytest.mark.slycot def testSisoW1(self): """SISO plant with S weighting""" g = ss([-1.], [1.], [1.], [1.]) @@ -88,7 +87,7 @@ def testSisoW1(self): # u->v should be -g self.siso_almost_equal(-g, p[1, 1]) - @slycotonly + @pytest.mark.slycot def testSisoW2(self): """SISO plant with KS weighting""" g = ss([-1.], [1.], [1.], [1.]) @@ -105,7 +104,7 @@ def testSisoW2(self): # u->v should be -g self.siso_almost_equal(-g, p[1, 1]) - @slycotonly + @pytest.mark.slycot def testSisoW3(self): """SISO plant with T weighting""" g = ss([-1.], [1.], [1.], [1.]) @@ -122,7 +121,7 @@ def testSisoW3(self): # u->v should be -g self.siso_almost_equal(-g, p[1, 1]) - @slycotonly + @pytest.mark.slycot def testSisoW123(self): """SISO plant with all weights""" g = ss([-1.], [1.], [1.], [1.]) @@ -149,7 +148,7 @@ def testSisoW123(self): # u->v should be -g self.siso_almost_equal(-g, p[3, 1]) - @slycotonly + @pytest.mark.slycot def testMimoW1(self): """MIMO plant with S weighting""" g = ss([[-1., -2], [-3, -4]], @@ -181,7 +180,7 @@ def testMimoW1(self): self.siso_almost_equal(-g[1, 0], p[3, 2]) self.siso_almost_equal(-g[1, 1], p[3, 3]) - @slycotonly + @pytest.mark.slycot def testMimoW2(self): """MIMO plant with KS weighting""" g = ss([[-1., -2], [-3, -4]], @@ -213,7 +212,7 @@ def testMimoW2(self): self.siso_almost_equal(-g[1, 0], p[3, 2]) self.siso_almost_equal(-g[1, 1], p[3, 3]) - @slycotonly + @pytest.mark.slycot def testMimoW3(self): """MIMO plant with T weighting""" g = ss([[-1., -2], [-3, -4]], @@ -245,7 +244,7 @@ def testMimoW3(self): self.siso_almost_equal(-g[1, 0], p[3, 2]) self.siso_almost_equal(-g[1, 1], p[3, 3]) - @slycotonly + @pytest.mark.slycot def testMimoW123(self): """MIMO plant with all weights""" g = ss([[-1., -2], [-3, -4]], @@ -307,7 +306,7 @@ def testMimoW123(self): self.siso_almost_equal(-g[1, 0], p[7, 2]) self.siso_almost_equal(-g[1, 1], p[7, 3]) - @slycotonly + @pytest.mark.slycot def testErrors(self): """Error cases handled""" from control import augw, ss @@ -330,7 +329,7 @@ class TestMixsyn: """Test control.robust.mixsyn""" # it's a relatively simple wrapper; compare results with augw, hinfsyn - @slycotonly + @pytest.mark.slycot def testSiso(self): """mixsyn with SISO system""" # Skogestad+Postlethwaite, Multivariable Feedback Control, 1st Ed., Example 2.11 diff --git a/control/tests/slycot_convert_test.py b/control/tests/slycot_convert_test.py index 25beeb908..2739a4cf1 100644 --- a/control/tests/slycot_convert_test.py +++ b/control/tests/slycot_convert_test.py @@ -7,7 +7,6 @@ import pytest from control import bode, rss, ss, tf -from control.tests.conftest import slycotonly numTests = 5 maxStates = 10 @@ -21,7 +20,7 @@ def fixedseed(): np.random.seed(0) -@slycotonly +@pytest.mark.slycot @pytest.mark.usefixtures("fixedseed") class TestSlycot: """Test Slycot system conversion diff --git a/control/tests/statefbk_test.py b/control/tests/statefbk_test.py index 3f4b4849a..b34150018 100644 --- a/control/tests/statefbk_test.py +++ b/control/tests/statefbk_test.py @@ -16,7 +16,6 @@ from control.mateqn import care, dare from control.statefbk import (ctrb, obsv, place, place_varga, lqr, dlqr, gram, place_acker) -from control.tests.conftest import slycotonly @pytest.fixture @@ -128,7 +127,7 @@ def testCtrbObsvDuality(self): Wo = np.transpose(obsv(A, C)) np.testing.assert_array_almost_equal(Wc,Wo) - @slycotonly + @pytest.mark.slycot def testGramWc(self): A = np.array([[1., -2.], [3., -4.]]) B = np.array([[5., 6.], [7., 8.]]) @@ -144,7 +143,7 @@ def testGramWc(self): Wc = gram(sysd, 'c') np.testing.assert_array_almost_equal(Wc, Wctrue) - @slycotonly + @pytest.mark.slycot def testGramWc2(self): A = np.array([[1., -2.], [3., -4.]]) B = np.array([[5.], [7.]]) @@ -161,7 +160,7 @@ def testGramWc2(self): Wc = gram(sysd, 'c') np.testing.assert_array_almost_equal(Wc, Wctrue) - @slycotonly + @pytest.mark.slycot def testGramRc(self): A = np.array([[1., -2.], [3., -4.]]) B = np.array([[5., 6.], [7., 8.]]) @@ -177,7 +176,7 @@ def testGramRc(self): Rc = gram(sysd, 'cf') np.testing.assert_array_almost_equal(Rc, Rctrue) - @slycotonly + @pytest.mark.slycot def testGramWo(self): A = np.array([[1., -2.], [3., -4.]]) B = np.array([[5., 6.], [7., 8.]]) @@ -193,7 +192,7 @@ def testGramWo(self): Wo = gram(sysd, 'o') np.testing.assert_array_almost_equal(Wo, Wotrue) - @slycotonly + @pytest.mark.slycot def testGramWo2(self): A = np.array([[1., -2.], [3., -4.]]) B = np.array([[5.], [7.]]) @@ -209,7 +208,7 @@ def testGramWo2(self): Wo = gram(sysd, 'o') np.testing.assert_array_almost_equal(Wo, Wotrue) - @slycotonly + @pytest.mark.slycot def testGramRo(self): A = np.array([[1., -2.], [3., -4.]]) B = np.array([[5., 6.], [7., 8.]]) @@ -318,7 +317,7 @@ def testPlace(self): with pytest.raises(ValueError): place(A, B, P_repeated) - @slycotonly + @pytest.mark.slycot def testPlace_varga_continuous(self): """ Check that we can place eigenvalues for dtime=False @@ -345,7 +344,7 @@ def testPlace_varga_continuous(self): self.checkPlaced(P, P_placed) - @slycotonly + @pytest.mark.slycot def testPlace_varga_continuous_partial_eigs(self): """ Check that we are able to use the alpha parameter to only place @@ -365,7 +364,7 @@ def testPlace_varga_continuous_partial_eigs(self): # No guarantee of the ordering, so sort them self.checkPlaced(P_expected, P_placed) - @slycotonly + @pytest.mark.slycot def testPlace_varga_discrete(self): """ Check that we can place poles using dtime=True (discrete time) @@ -379,7 +378,7 @@ def testPlace_varga_discrete(self): # No guarantee of the ordering, so sort them self.checkPlaced(P, P_placed) - @slycotonly + @pytest.mark.slycot def testPlace_varga_discrete_partial_eigs(self): """" Check that we can only assign a single eigenvalue in the discrete @@ -559,7 +558,7 @@ def test_care(self): @pytest.mark.parametrize( "stabilizing", - [True, pytest.param(False, marks=slycotonly)]) + [True, pytest.param(False, marks=pytest.mark.slycot)]) def test_dare(self, stabilizing): """Test stabilizing and anti-stabilizing feedback, discrete""" A = np.diag([0.5, 2]) diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index 3c1411f04..1f6d4a6bb 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -22,7 +22,7 @@ from control.statesp import StateSpace, _convert_to_statespace, \ _rss_generate, _statesp_defaults, drss, linfnorm, rss, ss, tf2ss from control.xferfcn import TransferFunction, ss2tf -from .conftest import assert_tf_close_coeff, slycotonly +from .conftest import assert_tf_close_coeff class TestStateSpace: """Tests for the StateSpace class.""" @@ -229,7 +229,7 @@ def test_zero_empty(self): sys = _convert_to_statespace(TransferFunction([1], [1, 2, 1])) np.testing.assert_array_equal(sys.zeros(), np.array([])) - @slycotonly + @pytest.mark.slycot def test_zero_siso(self, sys222): """Evaluate the zeros of a SISO system.""" # extract only first input / first output system of sys222. This system is denoted sys111 @@ -259,7 +259,7 @@ def test_zero_mimo_sys222_square(self, sys222): true_z = np.sort([-10.568501, 3.368501]) np.testing.assert_array_almost_equal(z, true_z) - @slycotonly + @pytest.mark.slycot def test_zero_mimo_sys623_non_square(self, sys623): """Evaluate the zeros of a non square MIMO system.""" @@ -406,7 +406,7 @@ def test_add_sub_mimo_siso(self): ss2tf(result).minreal(), ) - @slycotonly + @pytest.mark.slycot @pytest.mark.parametrize( "left, right, expected", [ @@ -481,7 +481,7 @@ def test_mul_mimo_siso(self, left, right, expected): ss2tf(result).minreal(), ) - @slycotonly + @pytest.mark.slycot @pytest.mark.parametrize( "left, right, expected", [ @@ -556,7 +556,7 @@ def test_rmul_mimo_siso(self, left, right, expected): ss2tf(result).minreal(), ) - @slycotonly + @pytest.mark.slycot @pytest.mark.parametrize("power", [0, 1, 3, -3]) @pytest.mark.parametrize("sysname", ["sys222", "sys322"]) def test_pow(self, request, sysname, power): @@ -575,7 +575,7 @@ def test_pow(self, request, sysname, power): np.testing.assert_allclose(expected.C, result.C) np.testing.assert_allclose(expected.D, result.D) - @slycotonly + @pytest.mark.slycot @pytest.mark.parametrize("order", ["left", "right"]) @pytest.mark.parametrize("sysname", ["sys121", "sys222", "sys322"]) def test_pow_inv(self, request, sysname, order): @@ -599,7 +599,7 @@ def test_pow_inv(self, request, sysname, order): # Check that the output is the same as the input np.testing.assert_allclose(R.outputs, U) - @slycotonly + @pytest.mark.slycot def test_truediv(self, sys222, sys322): """Test state space truediv""" for sys in [sys222, sys322]: @@ -618,7 +618,7 @@ def test_truediv(self, sys222, sys322): ss2tf(result).minreal(), ) - @slycotonly + @pytest.mark.slycot def test_rtruediv(self, sys222, sys322): """Test state space rtruediv""" for sys in [sys222, sys322]: @@ -719,7 +719,7 @@ def test_freq_resp(self): mag, phase, omega = sys.freqresp(true_omega) np.testing.assert_almost_equal(mag, true_mag) - @slycotonly + @pytest.mark.slycot def test_minreal(self): """Test a minreal model reduction.""" # A = [-2, 0.5, 0; 0.5, -0.3, 0; 0, 0, -0.1] @@ -1514,7 +1514,7 @@ def dt_siso(self, request): name, systype, sysargs, dt, refgpeak, reffpeak = request.param return ct.c2d(systype(*sysargs), dt), refgpeak, reffpeak - @slycotonly + @pytest.mark.slycot @pytest.mark.usefixtures('ignore_future_warning') def test_linfnorm_ct_siso(self, ct_siso): sys, refgpeak, reffpeak = ct_siso @@ -1522,7 +1522,7 @@ def test_linfnorm_ct_siso(self, ct_siso): np.testing.assert_allclose(gpeak, refgpeak) np.testing.assert_allclose(fpeak, reffpeak) - @slycotonly + @pytest.mark.slycot @pytest.mark.usefixtures('ignore_future_warning') def test_linfnorm_dt_siso(self, dt_siso): sys, refgpeak, reffpeak = dt_siso @@ -1531,7 +1531,7 @@ def test_linfnorm_dt_siso(self, dt_siso): np.testing.assert_allclose(gpeak, refgpeak) np.testing.assert_allclose(fpeak, reffpeak) - @slycotonly + @pytest.mark.slycot @pytest.mark.usefixtures('ignore_future_warning') def test_linfnorm_ct_mimo(self, ct_siso): siso, refgpeak, reffpeak = ct_siso diff --git a/control/tests/timeplot_test.py b/control/tests/timeplot_test.py index 888ff9080..ea0a290c9 100644 --- a/control/tests/timeplot_test.py +++ b/control/tests/timeplot_test.py @@ -7,7 +7,6 @@ import pytest import control as ct -from control.tests.conftest import slycotonly # Detailed test of (almost) all functionality # @@ -237,7 +236,7 @@ def test_axes_setup(): sys_3x1 = ct.rss(4, 3, 1) -@slycotonly +@pytest.mark.slycot @pytest.mark.usefixtures('mplcleanup') def test_legend_map(): sys_mimo = ct.tf2ss( @@ -371,7 +370,7 @@ def test_list_responses(resp_fcn): assert cplt.lines[row, col][1].get_color() == 'tab:orange' -@slycotonly +@pytest.mark.slycot @pytest.mark.usefixtures('mplcleanup') def test_linestyles(): # Check to make sure we can change line styles diff --git a/control/tests/timeresp_test.py b/control/tests/timeresp_test.py index 8bbd27d73..cf04b697b 100644 --- a/control/tests/timeresp_test.py +++ b/control/tests/timeresp_test.py @@ -9,7 +9,6 @@ import control as ct from control import StateSpace, TransferFunction, c2d, isctime, ss2tf, tf2ss from control.exception import pandas_check, slycot_check -from control.tests.conftest import slycotonly from control.timeresp import _default_time_vector, _ideal_tfinal_and_dt, \ forced_response, impulse_response, initial_response, step_info, \ step_response @@ -454,7 +453,7 @@ def test_step_info(self, tsystem, systype, time_2d, yfinal): @pytest.mark.parametrize( "tsystem", ['mimo_ss_step_matlab', - pytest.param('mimo_tf_step_info', marks=slycotonly)], + pytest.param('mimo_tf_step_info', marks=pytest.mark.slycot)], indirect=["tsystem"]) def test_step_info_mimo(self, tsystem, systype, yfinal): """Test step info for MIMO systems.""" @@ -799,7 +798,7 @@ def test_lsim_double_integrator(self, u, x0, xtrue): np.testing.assert_array_almost_equal(yout, ytrue, decimal=6) - @slycotonly + @pytest.mark.slycot def test_step_robustness(self): "Test robustness os step_response against denomiantors: gh-240" # Create 2 input, 2 output system @@ -902,9 +901,9 @@ def test_default_timevector_functions_d(self, fun, dt): "siso_dtf2", "siso_ss2_dtnone", # undetermined timebase "mimo_ss2", # MIMO - pytest.param("mimo_tf2", marks=slycotonly), + pytest.param("mimo_tf2", marks=pytest.mark.slycot), "mimo_dss1", - pytest.param("mimo_dtf1", marks=slycotonly), + pytest.param("mimo_dtf1", marks=pytest.mark.slycot), ], indirect=True) @pytest.mark.parametrize("fun", [step_response, diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py index d3db08ef6..a9be040ab 100644 --- a/control/tests/xferfcn_test.py +++ b/control/tests/xferfcn_test.py @@ -14,7 +14,7 @@ isdtime, reset_defaults, rss, sample_system, set_defaults, ss, ss2tf, tf, tf2ss, zpk) from control.statesp import _convert_to_statespace -from control.tests.conftest import assert_tf_close_coeff, slycotonly +from control.tests.conftest import assert_tf_close_coeff from control.xferfcn import _convert_to_transfer_function @@ -997,7 +997,7 @@ def test_minreal_4(self): np.testing.assert_allclose(hm.num[0][0], hr.num[0][0]) np.testing.assert_allclose(hr.dt, hm.dt) - @slycotonly + @pytest.mark.slycot def test_state_space_conversion_mimo(self): """Test conversion of a single input, two-output state-space system against the same TF""" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..dfe7e760e --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +markers = + slycot: tests needing slycot From eb2b492c2bf347ff7bf79aa453ce4a9bf9544858 Mon Sep 17 00:00:00 2001 From: Rory Yorke Date: Sat, 18 Oct 2025 14:14:27 +0200 Subject: [PATCH 2/7] Replace test decorator cvxoptonly with pytest mark cvxopt The pytest marker cvxopt is equivalent to the cvxoptonly decorator, and also allows cvxopt tests to be included or excluded at the pytest command-line. --- control/tests/conftest.py | 5 +++++ control/tests/passivity_test.py | 3 +-- pytest.ini | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/control/tests/conftest.py b/control/tests/conftest.py index 1b3f9755c..848dfc9b0 100644 --- a/control/tests/conftest.py +++ b/control/tests/conftest.py @@ -15,6 +15,11 @@ def pytest_runtest_setup(item): for mark in item.iter_markers())): pytest.skip("slycot not installed") + if (not control.exception.cvxopt_check() + and any(mark.name == 'cvxopt' + for mark in item.iter_markers())): + pytest.skip("cvxopt not installed") + @pytest.fixture(scope="session", autouse=True) def control_defaults(): diff --git a/control/tests/passivity_test.py b/control/tests/passivity_test.py index 4d7c8e6eb..22b73e0da 100644 --- a/control/tests/passivity_test.py +++ b/control/tests/passivity_test.py @@ -5,10 +5,9 @@ import pytest import numpy from control import ss, passivity, tf, sample_system, parallel, feedback -from control.tests.conftest import cvxoptonly from control.exception import ControlArgument, ControlDimension -pytestmark = cvxoptonly +pytestmark = pytest.mark.cvxopt def test_ispassive_ctime(): diff --git a/pytest.ini b/pytest.ini index dfe7e760e..75d2e3d71 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,4 @@ [pytest] markers = slycot: tests needing slycot + cvxopt: tests needing cvxopt From 5ee1a56552556188b8d332ed72f839f0bb15652b Mon Sep 17 00:00:00 2001 From: Rory Yorke Date: Sat, 18 Oct 2025 14:17:47 +0200 Subject: [PATCH 3/7] Move pytest marker config to pyproject.toml --- pyproject.toml | 7 ++++++- pytest.ini | 4 ---- 2 files changed, 6 insertions(+), 5 deletions(-) delete mode 100644 pytest.ini diff --git a/pyproject.toml b/pyproject.toml index b47f7462c..a534e115d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,10 +51,15 @@ source = "https://github.com/python-control/python-control" write_to = "control/_version.py" [tool.pytest.ini_options] -addopts = "-ra" +addopts = "-ra --strict-markers" filterwarnings = [ "error:.*matrix subclass:PendingDeprecationWarning", ] +markers = [ + "slycot: tests needing slycot", + "cvxopt: tests needing cvxopt", +] + [tool.ruff] diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 75d2e3d71..000000000 --- a/pytest.ini +++ /dev/null @@ -1,4 +0,0 @@ -[pytest] -markers = - slycot: tests needing slycot - cvxopt: tests needing cvxopt From 6454b043229a013337dfe164c47c427eff403674 Mon Sep 17 00:00:00 2001 From: Rory Yorke Date: Sat, 18 Oct 2025 15:09:37 +0200 Subject: [PATCH 4/7] Add pytest marker pandas for tests requiring pandas --- control/tests/conftest.py | 6 ++++++ control/tests/frd_test.py | 3 +-- control/tests/timeresp_test.py | 2 +- pyproject.toml | 1 + 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/control/tests/conftest.py b/control/tests/conftest.py index 848dfc9b0..a3ce3eceb 100644 --- a/control/tests/conftest.py +++ b/control/tests/conftest.py @@ -20,6 +20,12 @@ def pytest_runtest_setup(item): for mark in item.iter_markers())): pytest.skip("cvxopt not installed") + if (not control.exception.pandas_check() + and any(mark.name == 'pandas' + for mark in item.iter_markers())): + pytest.skip("pandas not installed") + + @pytest.fixture(scope="session", autouse=True) def control_defaults(): diff --git a/control/tests/frd_test.py b/control/tests/frd_test.py index b2d2a207a..ab8ce3be6 100644 --- a/control/tests/frd_test.py +++ b/control/tests/frd_test.py @@ -12,7 +12,6 @@ from control.xferfcn import TransferFunction from control.frdata import frd, _convert_to_frd, FrequencyResponseData from control import bdalg, freqplot -from control.exception import pandas_check class TestFRD: @@ -820,7 +819,7 @@ def test_named_signals(): assert f1.output_labels == ['y0'] -@pytest.mark.skipif(not pandas_check(), reason="pandas not installed") +@pytest.mark.pandas def test_to_pandas(): # Create a SISO frequency response h1 = TransferFunction([1], [1, 2, 2]) diff --git a/control/tests/timeresp_test.py b/control/tests/timeresp_test.py index cf04b697b..fdb47fd53 100644 --- a/control/tests/timeresp_test.py +++ b/control/tests/timeresp_test.py @@ -1235,7 +1235,7 @@ def test_response_transpose( assert x.shape == (T.size, sys.nstates) -@pytest.mark.skipif(not pandas_check(), reason="pandas not installed") +@pytest.mark.pandas def test_to_pandas(): # Create a SISO time response sys = ct.rss(2, 1, 1) diff --git a/pyproject.toml b/pyproject.toml index a534e115d..494aafe69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ filterwarnings = [ markers = [ "slycot: tests needing slycot", "cvxopt: tests needing cvxopt", + "pandas: tests needing pandas", ] From 01d76fde9bda5052de0ecc405f16cef45670c7db Mon Sep 17 00:00:00 2001 From: Rory Yorke Date: Sun, 2 Nov 2025 17:54:37 +0200 Subject: [PATCH 5/7] Remove unnecessary `cvxoptonly` marker in control/tests/conftest.py --- control/tests/conftest.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/control/tests/conftest.py b/control/tests/conftest.py index a3ce3eceb..0ad8afeaa 100644 --- a/control/tests/conftest.py +++ b/control/tests/conftest.py @@ -6,9 +6,6 @@ import control -cvxoptonly = pytest.mark.skipif( - not control.exception.cvxopt_check(), reason="cvxopt not installed") - def pytest_runtest_setup(item): if (not control.exception.slycot_check() and any(mark.name == 'slycot' From 63ce37d983c3d0298ecb0ef6c05d0dfb4d4c0e5e Mon Sep 17 00:00:00 2001 From: Rory Yorke Date: Sat, 8 Nov 2025 11:23:57 +0200 Subject: [PATCH 6/7] 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 7/7] 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", ]