From 4be47675f9fdea93a0d34f45f6c7b73bcd6d47d2 Mon Sep 17 00:00:00 2001 From: Rory Yorke Date: Sat, 18 Oct 2025 14:08:02 +0200 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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/5] 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'