From 2d7becbcfde4d48c104cb24a332819c776e56fa6 Mon Sep 17 00:00:00 2001 From: Andrii Oriekhov Date: Sat, 5 Mar 2022 15:43:58 +0200 Subject: [PATCH 001/157] add GitHub URL for PyPi --- setup.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/setup.py b/setup.py index f5e766ebb..a8609148e 100644 --- a/setup.py +++ b/setup.py @@ -34,6 +34,9 @@ author='Python Control Developers', author_email='python-control-developers@lists.sourceforge.net', url='http://python-control.org', + project_urls={ + 'Source': 'https://github.com/python-control/python-control', + }, description='Python Control Systems Library', long_description=long_description, packages=find_packages(exclude=['benchmarks']), From e8e87a47e045739c08aea6c7e0dc91eb1ee24b7e Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 30 May 2022 22:44:56 -0500 Subject: [PATCH 002/157] Add passivity module, is_passive function, and passivity_test. --- control/passivity.py | 54 +++++++++++++++++++++++++++++++++ control/tests/passivity_test.py | 25 +++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 control/passivity.py create mode 100644 control/tests/passivity_test.py diff --git a/control/passivity.py b/control/passivity.py new file mode 100644 index 000000000..f33f7ff09 --- /dev/null +++ b/control/passivity.py @@ -0,0 +1,54 @@ +''' +Author: Mark Yeatman +Date: May 15, 2022 +''' + +from . import statesp as ss +from sympy import symbols, Matrix, symarray +from lmi_sdp import LMI_NSD, to_cvxopt +from cvxopt import solvers + +import numpy as np + + +def is_passive(sys): + ''' + Indicates if a linear time invarient system is passive + + Constructs a linear matrix inequality and a feasibility optimization + such that is a solution exists, the system is passive. + + The source for the algorithm is: + McCourt, Michael J., and Panos J. Antsaklis. "Demonstrating passivity and dissipativity using computational methods." ISIS 8 (2013). + ''' + + A = sys.A + B = sys.B + C = sys.C + D = sys.D + + P = Matrix(symarray('p', A.shape)) + + # enforce symmetry in P + size = A.shape[0] + for i in range(0, size): + for j in range(0, size): + P[i, j] = P[j, i] + + # construct matrix for storage function x'*V*x + V = Matrix.vstack( + Matrix.hstack(A.T * P + P*A, P*B - C.T), + Matrix.hstack(B.T*P - C, Matrix(-D - D.T)) + ) + + # construct LMI, convert to form for feasibility solver + LMI_passivty = LMI_NSD(V, 0*V) + min_obj = 0 * symbols("x") + variables = V.free_symbols + solvers.options['show_progress'] = False + c, Gs, hs = to_cvxopt(min_obj, LMI_passivty, variables) + + # crunch feasibility solution + sol = solvers.sdp(c, Gs=Gs, hs=hs) + + return (sol["x"] is not None) diff --git a/control/tests/passivity_test.py b/control/tests/passivity_test.py new file mode 100644 index 000000000..5041c4695 --- /dev/null +++ b/control/tests/passivity_test.py @@ -0,0 +1,25 @@ +''' +Author: Mark Yeatman +Date: May 30, 2022 +''' + +import pytest +import numpy +from control import ss, passivity +from sympy import Matrix + + +def test_is_passive(): + A = numpy.array([[0, 1], [-2, -2]]) + B = numpy.array([[0], [1]]) + C = numpy.array([[-1, 2]]) + D = numpy.array([[1.5]]) + sys = ss(A, B, C, D) + + assert(passivity.is_passive(sys)) + + D = -D + sys = ss(A, B, C, D) + + assert(not passivity.is_passive(sys)) + From 2eef6612a046b2982327a36ffe03beb4a0aa54f3 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 6 Jun 2022 14:32:02 -0500 Subject: [PATCH 003/157] Remove dependancies on lmi-sdp and sympy for is_passive. --- control/passivity.py | 53 +++++++++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/control/passivity.py b/control/passivity.py index f33f7ff09..12c2c8ad7 100644 --- a/control/passivity.py +++ b/control/passivity.py @@ -4,11 +4,8 @@ ''' from . import statesp as ss -from sympy import symbols, Matrix, symarray -from lmi_sdp import LMI_NSD, to_cvxopt -from cvxopt import solvers - import numpy as np +import cvxopt as cvx def is_passive(sys): @@ -27,28 +24,38 @@ def is_passive(sys): C = sys.C D = sys.D - P = Matrix(symarray('p', A.shape)) - - # enforce symmetry in P - size = A.shape[0] - for i in range(0, size): - for j in range(0, size): - P[i, j] = P[j, i] - - # construct matrix for storage function x'*V*x - V = Matrix.vstack( - Matrix.hstack(A.T * P + P*A, P*B - C.T), - Matrix.hstack(B.T*P - C, Matrix(-D - D.T)) + def make_LMI_matrix(P): + V = np.vstack(( + np.hstack((A.T @ P + P@A, P@B)), + np.hstack((B.T@P, np.zeros_like(D)))) + ) + return V + + P = np.zeros_like(A) + matrix_list = [] + state_space_size = A.shape[0] + for i in range(0, state_space_size): + for j in range(0, state_space_size): + if j <= i: + P = P*0.0 + P[i, j] = 1.0 + P[j, i] = 1.0 + matrix_list.append(make_LMI_matrix(P).flatten()) + + coefficents = np.vstack(matrix_list).T + + constants = -np.vstack(( + np.hstack((np.zeros_like(A), - C.T)), + np.hstack((- C, -D - D.T))) ) - # construct LMI, convert to form for feasibility solver - LMI_passivty = LMI_NSD(V, 0*V) - min_obj = 0 * symbols("x") - variables = V.free_symbols - solvers.options['show_progress'] = False - c, Gs, hs = to_cvxopt(min_obj, LMI_passivty, variables) + number_of_opt_vars = int( + (state_space_size**2-state_space_size)/2 + state_space_size) + c = cvx.matrix(0.0, (number_of_opt_vars, 1)) # crunch feasibility solution - sol = solvers.sdp(c, Gs=Gs, hs=hs) + sol = cvx.solvers.sdp(c, + Gs=[cvx.matrix(coefficents)], + hs=[cvx.matrix(constants)]) return (sol["x"] is not None) From 147a24ea0ba9da03b3774b7993e20e785776e027 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 6 Jun 2022 14:37:53 -0500 Subject: [PATCH 004/157] Use sys.nstates in stead of using A.shape[0] --- control/passivity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control/passivity.py b/control/passivity.py index 12c2c8ad7..2bc00f958 100644 --- a/control/passivity.py +++ b/control/passivity.py @@ -33,7 +33,7 @@ def make_LMI_matrix(P): P = np.zeros_like(A) matrix_list = [] - state_space_size = A.shape[0] + state_space_size = sys.nstates for i in range(0, state_space_size): for j in range(0, state_space_size): if j <= i: From fdb2d4ad6bf15d7565347528a69a99ce4e121fda Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 6 Jun 2022 14:58:51 -0500 Subject: [PATCH 005/157] Attempt to setup cvxopt similar to slycot. --- .github/workflows/python-package-conda.yml | 4 ++++ control/exception.py | 12 ++++++++++++ control/tests/conftest.py | 2 ++ control/tests/passivity_test.py | 4 ++-- setup.py | 3 ++- 5 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-package-conda.yml b/.github/workflows/python-package-conda.yml index 3f1910697..e4b1aea42 100644 --- a/.github/workflows/python-package-conda.yml +++ b/.github/workflows/python-package-conda.yml @@ -13,6 +13,7 @@ jobs: python-version: [3.7, 3.9] slycot: ["", "conda"] pandas: [""] + cvxopt: ["conda"] array-and-matrix: [0] include: - python-version: 3.9 @@ -46,6 +47,9 @@ jobs: if [[ '${{matrix.pandas}}' == 'conda' ]]; then conda install -c conda-forge pandas fi + if [[ '${{matrix.cvxopt}}' == 'conda' ]]; then + conda install -c conda-forge cvxopt + fi - name: Test with pytest env: diff --git a/control/exception.py b/control/exception.py index f66eb7f30..d14ff87e6 100644 --- a/control/exception.py +++ b/control/exception.py @@ -84,3 +84,15 @@ def pandas_check(): except: pandas_installed = False return pandas_installed + +# Utility function to see if cvxopt is installed +cvxopt_installed = None +def cvxopt_check(): + global cvxopt_installed + if cvxopt_installed is None: + try: + import cvxopt + cvxopt_installed = True + except: + cvxopt_installed = False + return cvxopt_installed \ No newline at end of file diff --git a/control/tests/conftest.py b/control/tests/conftest.py index b67ef3674..853c1dd61 100644 --- a/control/tests/conftest.py +++ b/control/tests/conftest.py @@ -18,6 +18,8 @@ # 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") noscipy0 = pytest.mark.skipif(StrictVersion(sp.__version__) < "1.0", reason="requires SciPy 1.0 or greater") nopython2 = pytest.mark.skipif(sys.version_info < (3, 0), diff --git a/control/tests/passivity_test.py b/control/tests/passivity_test.py index 5041c4695..182e3923f 100644 --- a/control/tests/passivity_test.py +++ b/control/tests/passivity_test.py @@ -3,12 +3,12 @@ Date: May 30, 2022 ''' -import pytest import numpy from control import ss, passivity from sympy import Matrix +from control.tests.conftest import cvxoptonly - +@cvxoptonly def test_is_passive(): A = numpy.array([[0, 1], [-2, -2]]) B = numpy.array([[0], [1]]) diff --git a/setup.py b/setup.py index f5e766ebb..58e4d11cf 100644 --- a/setup.py +++ b/setup.py @@ -43,6 +43,7 @@ 'matplotlib'], extras_require={ 'test': ['pytest', 'pytest-timeout'], - 'slycot': [ 'slycot>=0.4.0' ] + 'slycot': [ 'slycot>=0.4.0' ], + 'cvxopt': [ 'cvxopt>=1.2.0' ] } ) From 04ef02dfec7a4b449f71712bc50cafe633d0f6ba Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Mon, 6 Jun 2022 21:48:39 -0700 Subject: [PATCH 006/157] handle t_eval for static systems in input_output_response (addresses #742) --- control/iosys.py | 13 +------------ control/tests/iosys_test.py | 31 ++++++++++++++++++++++--------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index e3719614b..6008cf43d 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -1762,19 +1762,8 @@ def input_output_response( warn("initial state too short; padding with zeros") X0 = np.hstack([X0, np.zeros(sys.nstates - X0.size)]) - # Check to make sure this is not a static function + # Compute the number of states nstates = _find_size(sys.nstates, X0) - if nstates == 0: - # No states => map input to output - u = U[0] if len(U.shape) == 1 else U[:, 0] - y = np.zeros((np.shape(sys._out(T[0], X0, u))[0], len(T))) - for i in range(len(T)): - u = U[i] if len(U.shape) == 1 else U[:, i] - y[:, i] = sys._out(T[i], [], u) - return TimeResponseData( - T, y, None, U, issiso=sys.issiso(), - output_labels=sys.output_index, input_labels=sys.input_index, - transpose=transpose, return_x=return_x, squeeze=squeeze) # create X0 if not given, test if X0 has correct shape X0 = _check_convert_array(X0, [(nstates,), (nstates, 1)], diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index ecb30c316..825cec5c5 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -1763,27 +1763,40 @@ def test_input_output_broadcasting(): resp_bad = ct.input_output_response( sys, T, (U[0, :], U[:2, :-1]), [X0, P0]) - -def test_nonuniform_timepts(): +@pytest.mark.parametrize("nstates, ninputs, noutputs", [ + [2, 1, 1], + [4, 2, 3], + [0, 1, 1], # static function + [0, 3, 2], # static function +]) +def test_nonuniform_timepts(nstates, noutputs, ninputs): """Test non-uniform time points for simulations""" - sys = ct.LinearIOSystem(ct.rss(2, 1, 1)) + if nstates: + sys = ct.rss(nstates, noutputs, ninputs) + else: + sys = ct.ss( + [], np.zeros((0, ninputs)), np.zeros((noutputs, 0)), + np.random.rand(noutputs, ninputs)) # Start with a uniform set of times unifpts = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] - uniform = [1, 2, 3, 2, 1, -1, -3, -5, -7, -3, 1] - t_unif, y_unif = ct.input_output_response(sys, unifpts, uniform) + uniform = np.outer( + np.ones(ninputs), [1, 2, 3, 2, 1, -1, -3, -5, -7, -3, 1]) + t_unif, y_unif = ct.input_output_response( + sys, unifpts, uniform, squeeze=False) # Create a non-uniform set of inputs noufpts = [0, 2, 4, 8, 10] - nonunif = [1, 3, 1, -7, 1] - t_nouf, y_nouf = ct.input_output_response(sys, noufpts, nonunif) + nonunif = np.outer(np.ones(ninputs), [1, 3, 1, -7, 1]) + t_nouf, y_nouf = ct.input_output_response( + sys, noufpts, nonunif, squeeze=False) # Make sure the outputs agree at common times - np.testing.assert_almost_equal(y_unif[noufpts], y_nouf, decimal=6) + np.testing.assert_almost_equal(y_unif[:, noufpts], y_nouf, decimal=6) # Resimulate using a new set of evaluation points t_even, y_even = ct.input_output_response( - sys, noufpts, nonunif, t_eval=unifpts) + sys, noufpts, nonunif, t_eval=unifpts, squeeze=False) np.testing.assert_almost_equal(y_unif, y_even, decimal=6) From dc46f3cf2c70a884661a1dbd2fa274ee99083c87 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 7 Jun 2022 11:48:36 -0400 Subject: [PATCH 007/157] Remove unused import. --- control/passivity.py | 1 - 1 file changed, 1 deletion(-) diff --git a/control/passivity.py b/control/passivity.py index 2bc00f958..f37443722 100644 --- a/control/passivity.py +++ b/control/passivity.py @@ -3,7 +3,6 @@ Date: May 15, 2022 ''' -from . import statesp as ss import numpy as np import cvxopt as cvx From 0ecc1354765cc7753d8a980ea2e70c9be79186b6 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 7 Jun 2022 11:49:47 -0400 Subject: [PATCH 008/157] Apply suggestions from code review Co-authored-by: Ben Greiner --- .github/workflows/python-package-conda.yml | 2 +- control/exception.py | 2 +- control/tests/passivity_test.py | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-package-conda.yml b/.github/workflows/python-package-conda.yml index e4b1aea42..c0e720ea8 100644 --- a/.github/workflows/python-package-conda.yml +++ b/.github/workflows/python-package-conda.yml @@ -13,7 +13,7 @@ jobs: python-version: [3.7, 3.9] slycot: ["", "conda"] pandas: [""] - cvxopt: ["conda"] + cvxopt: ["", "conda"] array-and-matrix: [0] include: - python-version: 3.9 diff --git a/control/exception.py b/control/exception.py index d14ff87e6..575c78c0a 100644 --- a/control/exception.py +++ b/control/exception.py @@ -95,4 +95,4 @@ def cvxopt_check(): cvxopt_installed = True except: cvxopt_installed = False - return cvxopt_installed \ No newline at end of file + return cvxopt_installed diff --git a/control/tests/passivity_test.py b/control/tests/passivity_test.py index 182e3923f..947b5729c 100644 --- a/control/tests/passivity_test.py +++ b/control/tests/passivity_test.py @@ -5,7 +5,6 @@ import numpy from control import ss, passivity -from sympy import Matrix from control.tests.conftest import cvxoptonly @cvxoptonly From 649b21ee7e915fc8758413da04f4ec411ba58d61 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 7 Jun 2022 12:07:45 -0400 Subject: [PATCH 009/157] Update passivity.py --- control/passivity.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/control/passivity.py b/control/passivity.py index f37443722..17a64120d 100644 --- a/control/passivity.py +++ b/control/passivity.py @@ -4,8 +4,11 @@ ''' import numpy as np -import cvxopt as cvx +try: + import cvxopt as cvx +except ImportError as e: + cvx = None def is_passive(sys): ''' @@ -17,7 +20,9 @@ def is_passive(sys): The source for the algorithm is: McCourt, Michael J., and Panos J. Antsaklis. "Demonstrating passivity and dissipativity using computational methods." ISIS 8 (2013). ''' - + if cvx is None: + raise ModuleNotFoundError + A = sys.A B = sys.B C = sys.C From b73e6fe0eac04fc541056d3ee9430c767b6d126b Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 13 Jun 2022 14:19:36 -0500 Subject: [PATCH 010/157] Address some review comments. --- .github/workflows/python-package-conda.yml | 2 +- control/passivity.py | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/python-package-conda.yml b/.github/workflows/python-package-conda.yml index c0e720ea8..d8f810104 100644 --- a/.github/workflows/python-package-conda.yml +++ b/.github/workflows/python-package-conda.yml @@ -4,7 +4,7 @@ on: [push, pull_request] jobs: test-linux: - name: Python ${{ matrix.python-version }}${{ matrix.slycot && format(' with Slycot from {0}', matrix.slycot) || ' without Slycot' }}${{ matrix.pandas && ', with pandas' || '' }}${{ matrix.array-and-matrix == 1 && ', array and matrix' || '' }} + name: Python ${{ matrix.python-version }}${{ matrix.slycot && format(' with Slycot from {0}', matrix.slycot) || ' without Slycot' }}${{ matrix.pandas && ', with pandas' || '' }}${{ matrix.array-and-matrix == 1 && ', array and matrix' || '' }}${{ matrix.cvxopt && format(' with cvxopt from {0}', matrix.cvxopt) || ' without cvxopt' }} runs-on: ubuntu-latest strategy: diff --git a/control/passivity.py b/control/passivity.py index 17a64120d..109a75356 100644 --- a/control/passivity.py +++ b/control/passivity.py @@ -10,6 +10,7 @@ except ImportError as e: cvx = None + def is_passive(sys): ''' Indicates if a linear time invarient system is passive @@ -18,11 +19,13 @@ def is_passive(sys): such that is a solution exists, the system is passive. The source for the algorithm is: - McCourt, Michael J., and Panos J. Antsaklis. "Demonstrating passivity and dissipativity using computational methods." ISIS 8 (2013). + McCourt, Michael J., and Panos J. Antsaklis. + "Demonstrating passivity and dissipativity using computational methods." ISIS 8 (2013). ''' if cvx is None: + print("cvxopt required for passivity module") raise ModuleNotFoundError - + A = sys.A B = sys.B C = sys.C @@ -35,13 +38,12 @@ def make_LMI_matrix(P): ) return V - P = np.zeros_like(A) matrix_list = [] state_space_size = sys.nstates for i in range(0, state_space_size): for j in range(0, state_space_size): if j <= i: - P = P*0.0 + P = np.zeros_like(A) P[i, j] = 1.0 P[j, i] = 1.0 matrix_list.append(make_LMI_matrix(P).flatten()) From cd7ec0fce4cc73954d2c3769adff0373b9ef62b2 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 13 Jun 2022 14:56:35 -0500 Subject: [PATCH 011/157] Update control/passivity.py Co-authored-by: Ben Greiner --- control/passivity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control/passivity.py b/control/passivity.py index 109a75356..3d376f392 100644 --- a/control/passivity.py +++ b/control/passivity.py @@ -24,7 +24,7 @@ def is_passive(sys): ''' if cvx is None: print("cvxopt required for passivity module") - raise ModuleNotFoundError + raise ModuleNotFoundError("cvxopt required for passivity module") A = sys.A B = sys.B From d65f1d230baa84cf3c74378a1505f6eea5093439 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 13 Jun 2022 15:01:52 -0500 Subject: [PATCH 012/157] Remove duplicate "print" statement. --- control/passivity.py | 1 - 1 file changed, 1 deletion(-) diff --git a/control/passivity.py b/control/passivity.py index 3d376f392..a87e07730 100644 --- a/control/passivity.py +++ b/control/passivity.py @@ -23,7 +23,6 @@ def is_passive(sys): "Demonstrating passivity and dissipativity using computational methods." ISIS 8 (2013). ''' if cvx is None: - print("cvxopt required for passivity module") raise ModuleNotFoundError("cvxopt required for passivity module") A = sys.A From 7e79c822d069ba13c77c210c4a9975168bea3b3b Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 13 Jun 2022 15:36:56 -0500 Subject: [PATCH 013/157] Fix grammar in doc string. --- control/passivity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control/passivity.py b/control/passivity.py index a87e07730..df99ee20c 100644 --- a/control/passivity.py +++ b/control/passivity.py @@ -16,7 +16,7 @@ def is_passive(sys): Indicates if a linear time invarient system is passive Constructs a linear matrix inequality and a feasibility optimization - such that is a solution exists, the system is passive. + such that if a solution exists, the system is passive. The source for the algorithm is: McCourt, Michael J., and Panos J. Antsaklis. From c57b928528df7573d47651abc8db9043a2e26d5c Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 13 Jun 2022 17:08:48 -0500 Subject: [PATCH 014/157] Another grammar in doc string fix. --- control/passivity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control/passivity.py b/control/passivity.py index df99ee20c..ad032d60b 100644 --- a/control/passivity.py +++ b/control/passivity.py @@ -13,7 +13,7 @@ def is_passive(sys): ''' - Indicates if a linear time invarient system is passive + Indicates if a linear time invariant system is passive Constructs a linear matrix inequality and a feasibility optimization such that if a solution exists, the system is passive. From 27487a9707d0ea28e41358f36e815eff792a2990 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 13 Jun 2022 19:39:29 -0500 Subject: [PATCH 015/157] Address edge case of stricly proper systems. --- control/passivity.py | 6 ++++++ control/tests/passivity_test.py | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/control/passivity.py b/control/passivity.py index ad032d60b..1d981c9de 100644 --- a/control/passivity.py +++ b/control/passivity.py @@ -10,6 +10,7 @@ except ImportError as e: cvx = None +lmi_epsilon = 1e-12 def is_passive(sys): ''' @@ -30,6 +31,10 @@ def is_passive(sys): C = sys.C D = sys.D + #account for strictly proper systems + [n,m] = D.shape + D = D + np.nextafter(0,1)*np.eye(n,m) + def make_LMI_matrix(P): V = np.vstack(( np.hstack((A.T @ P + P@A, P@B)), @@ -59,6 +64,7 @@ def make_LMI_matrix(P): c = cvx.matrix(0.0, (number_of_opt_vars, 1)) # crunch feasibility solution + cvx.solvers.options['show_progress'] = False sol = cvx.solvers.sdp(c, Gs=[cvx.matrix(coefficents)], hs=[cvx.matrix(constants)]) diff --git a/control/tests/passivity_test.py b/control/tests/passivity_test.py index 947b5729c..79197866f 100644 --- a/control/tests/passivity_test.py +++ b/control/tests/passivity_test.py @@ -15,10 +15,22 @@ def test_is_passive(): D = numpy.array([[1.5]]) sys = ss(A, B, C, D) + # happy path is passive assert(passivity.is_passive(sys)) + # happy path not passive D = -D sys = ss(A, B, C, D) assert(not passivity.is_passive(sys)) + #edge cases of D=0 boundary condition + B *= 0 + C *= 0 + D *= 0 + sys = ss(A, B, C, D) + assert(passivity.is_passive(sys)) + + A = A*1e12 + sys = ss(A, B, C, D) + assert(passivity.is_passive(sys)) \ No newline at end of file From d6916c661a7799e5998f84f5e2d34368ace528a8 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 14 Jun 2022 06:46:56 -0500 Subject: [PATCH 016/157] Expand unit tests, add info to doc string for parameters and returns, rename is_passive to ispassive for naming convention consistency. Autoformat to pep8. --- control/passivity.py | 21 +++++++++++----- control/tests/passivity_test.py | 43 +++++++++++++++++++++++++++------ 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/control/passivity.py b/control/passivity.py index 1d981c9de..b00cc6b65 100644 --- a/control/passivity.py +++ b/control/passivity.py @@ -10,11 +10,10 @@ except ImportError as e: cvx = None -lmi_epsilon = 1e-12 -def is_passive(sys): +def ispassive(sys): ''' - Indicates if a linear time invariant system is passive + Indicates if a linear time invariant (LTI) system is passive Constructs a linear matrix inequality and a feasibility optimization such that if a solution exists, the system is passive. @@ -22,6 +21,16 @@ def is_passive(sys): The source for the algorithm is: McCourt, Michael J., and Panos J. Antsaklis. "Demonstrating passivity and dissipativity using computational methods." ISIS 8 (2013). + + Parameters + ---------- + sys: A continuous LTI system + System to be checked. + + Returns + ------- + bool: + The input system passive. ''' if cvx is None: raise ModuleNotFoundError("cvxopt required for passivity module") @@ -31,9 +40,9 @@ def is_passive(sys): C = sys.C D = sys.D - #account for strictly proper systems - [n,m] = D.shape - D = D + np.nextafter(0,1)*np.eye(n,m) + # account for strictly proper systems + [n, m] = D.shape + D = D + np.nextafter(0, 1)*np.eye(n, m) def make_LMI_matrix(P): V = np.vstack(( diff --git a/control/tests/passivity_test.py b/control/tests/passivity_test.py index 79197866f..171d3c542 100644 --- a/control/tests/passivity_test.py +++ b/control/tests/passivity_test.py @@ -7,8 +7,9 @@ from control import ss, passivity from control.tests.conftest import cvxoptonly + @cvxoptonly -def test_is_passive(): +def test_ispassive(): A = numpy.array([[0, 1], [-2, -2]]) B = numpy.array([[0], [1]]) C = numpy.array([[-1, 2]]) @@ -16,21 +17,47 @@ def test_is_passive(): sys = ss(A, B, C, D) # happy path is passive - assert(passivity.is_passive(sys)) + assert(passivity.ispassive(sys)) # happy path not passive D = -D sys = ss(A, B, C, D) - assert(not passivity.is_passive(sys)) + assert(not passivity.ispassive(sys)) + + +@cvxoptonly +def test_ispassive_edge_cases(): + A = numpy.array([[0, 1], [-2, -2]]) + B = numpy.array([[0], [1]]) + C = numpy.array([[-1, 2]]) + D = numpy.array([[1.5]]) - #edge cases of D=0 boundary condition - B *= 0 - C *= 0 D *= 0 + + # strictly proper sys = ss(A, B, C, D) - assert(passivity.is_passive(sys)) + assert(passivity.ispassive(sys)) + # ill conditioned A = A*1e12 sys = ss(A, B, C, D) - assert(passivity.is_passive(sys)) \ No newline at end of file + assert(passivity.ispassive(sys)) + + # different combinations of zero A,B,C,D are 0 + B *= 0 + C *= 0 + assert(passivity.ispassive(sys)) + + A *= 0 + B = numpy.array([[0], [1]]) + C = numpy.array([[-1, 2]]) + D = numpy.array([[1.5]]) + assert(passivity.ispassive(sys)) + + B *= 0 + C *= 0 + assert(passivity.ispassive(sys)) + + A *= 0 + assert(passivity.ispassive(sys)) From bb16be01ff70919971d990a3228b6ff96b9b8182 Mon Sep 17 00:00:00 2001 From: mark-yeatman Date: Tue, 14 Jun 2022 11:26:30 -0400 Subject: [PATCH 017/157] Parameterize unit tests. Catch edge case of A=0. --- control/passivity.py | 7 +++-- control/tests/passivity_test.py | 53 +++++++++++++-------------------- 2 files changed, 25 insertions(+), 35 deletions(-) diff --git a/control/passivity.py b/control/passivity.py index b00cc6b65..b2cf5a09e 100644 --- a/control/passivity.py +++ b/control/passivity.py @@ -44,6 +44,9 @@ def ispassive(sys): [n, m] = D.shape D = D + np.nextafter(0, 1)*np.eye(n, m) + [n, _] = A.shape + A = A - np.nextafter(0, 1)*np.eye(n) + def make_LMI_matrix(P): V = np.vstack(( np.hstack((A.T @ P + P@A, P@B)), @@ -75,7 +78,7 @@ def make_LMI_matrix(P): # crunch feasibility solution cvx.solvers.options['show_progress'] = False sol = cvx.solvers.sdp(c, - Gs=[cvx.matrix(coefficents)], - hs=[cvx.matrix(constants)]) + Gs=[cvx.matrix(coefficents)], + hs=[cvx.matrix(constants)]) return (sol["x"] is not None) diff --git a/control/tests/passivity_test.py b/control/tests/passivity_test.py index 171d3c542..d413f6fa5 100644 --- a/control/tests/passivity_test.py +++ b/control/tests/passivity_test.py @@ -2,7 +2,7 @@ Author: Mark Yeatman Date: May 30, 2022 ''' - +import pytest import numpy from control import ss, passivity from control.tests.conftest import cvxoptonly @@ -25,39 +25,26 @@ def test_ispassive(): assert(not passivity.ispassive(sys)) - +A_d = numpy.array([[-2, 0], [0, 0]]) +A = numpy.array([[-3, 0], [0, -2]]) +B = numpy.array([[0], [1]]) +C = numpy.array([[-1, 2]]) +D = numpy.array([[1.5]]) @cvxoptonly -def test_ispassive_edge_cases(): - A = numpy.array([[0, 1], [-2, -2]]) - B = numpy.array([[0], [1]]) - C = numpy.array([[-1, 2]]) - D = numpy.array([[1.5]]) - - D *= 0 +@pytest.mark.parametrize( + "test_input,expected", + [((A,B,C,D*0.0), True), + ((A_d,B,C,D), True), + ((A*1e12,B,C,D*0), True), + ((A,B*0,C*0,D), True), + ((A*0,B,C,D), True), + ((A*0,B*0,C*0,D*0), True)]) +def test_ispassive_edge_cases(test_input, expected): # strictly proper + A = test_input[0] + B = test_input[1] + C = test_input[2] + D = test_input[3] sys = ss(A, B, C, D) - assert(passivity.ispassive(sys)) - - # ill conditioned - A = A*1e12 - sys = ss(A, B, C, D) - assert(passivity.ispassive(sys)) - - # different combinations of zero A,B,C,D are 0 - B *= 0 - C *= 0 - assert(passivity.ispassive(sys)) - - A *= 0 - B = numpy.array([[0], [1]]) - C = numpy.array([[-1, 2]]) - D = numpy.array([[1.5]]) - assert(passivity.ispassive(sys)) - - B *= 0 - C *= 0 - assert(passivity.ispassive(sys)) - - A *= 0 - assert(passivity.ispassive(sys)) + assert(passivity.ispassive(sys)==expected) From cf0eac302a71897424ba8eeffd6b591cfbe1cde0 Mon Sep 17 00:00:00 2001 From: mark-yeatman Date: Tue, 14 Jun 2022 11:35:13 -0400 Subject: [PATCH 018/157] Run autopep8. --- control/passivity.py | 4 ++-- control/tests/passivity_test.py | 19 +++++++++++-------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/control/passivity.py b/control/passivity.py index b2cf5a09e..60b081826 100644 --- a/control/passivity.py +++ b/control/passivity.py @@ -78,7 +78,7 @@ def make_LMI_matrix(P): # crunch feasibility solution cvx.solvers.options['show_progress'] = False sol = cvx.solvers.sdp(c, - Gs=[cvx.matrix(coefficents)], - hs=[cvx.matrix(constants)]) + Gs=[cvx.matrix(coefficents)], + hs=[cvx.matrix(constants)]) return (sol["x"] is not None) diff --git a/control/tests/passivity_test.py b/control/tests/passivity_test.py index d413f6fa5..681d2f527 100644 --- a/control/tests/passivity_test.py +++ b/control/tests/passivity_test.py @@ -25,20 +25,23 @@ def test_ispassive(): assert(not passivity.ispassive(sys)) + A_d = numpy.array([[-2, 0], [0, 0]]) A = numpy.array([[-3, 0], [0, -2]]) B = numpy.array([[0], [1]]) C = numpy.array([[-1, 2]]) D = numpy.array([[1.5]]) + + @cvxoptonly @pytest.mark.parametrize( - "test_input,expected", - [((A,B,C,D*0.0), True), - ((A_d,B,C,D), True), - ((A*1e12,B,C,D*0), True), - ((A,B*0,C*0,D), True), - ((A*0,B,C,D), True), - ((A*0,B*0,C*0,D*0), True)]) + "test_input,expected", + [((A, B, C, D*0.0), True), + ((A_d, B, C, D), True), + ((A*1e12, B, C, D*0), True), + ((A, B*0, C*0, D), True), + ((A*0, B, C, D), True), + ((A*0, B*0, C*0, D*0), True)]) def test_ispassive_edge_cases(test_input, expected): # strictly proper @@ -47,4 +50,4 @@ def test_ispassive_edge_cases(test_input, expected): C = test_input[2] D = test_input[3] sys = ss(A, B, C, D) - assert(passivity.ispassive(sys)==expected) + assert(passivity.ispassive(sys) == expected) From 7e47d8060c49dd89b52fc62e849da7c521a51119 Mon Sep 17 00:00:00 2001 From: mark-yeatman Date: Wed, 15 Jun 2022 21:22:54 -0400 Subject: [PATCH 019/157] Add wrapper like functionality for ispassive(), so that it can be called in an object oriented style as a LTI class member. Added unit tests for transfer function and oo style calls. Ran autopep8 on lti.py. --- control/lti.py | 18 +++++++++++++----- control/passivity.py | 3 +++ control/tests/passivity_test.py | 22 +++++++++++++++++++++- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/control/lti.py b/control/lti.py index fdb4946cd..4f6624748 100644 --- a/control/lti.py +++ b/control/lti.py @@ -13,6 +13,7 @@ """ import numpy as np + from numpy import absolute, real, angle, abs from warnings import warn from . import config @@ -21,6 +22,7 @@ __all__ = ['poles', 'zeros', 'damp', 'evalfr', 'frequency_response', 'freqresp', 'dcgain', 'pole', 'zero'] + class LTI(NamedIOSystem): """LTI is a parent class to linear time-invariant (LTI) system objects. @@ -44,6 +46,7 @@ class LTI(NamedIOSystem): Note: dt processing has been moved to the NamedIOSystem class. """ + def __init__(self, inputs=1, outputs=1, states=None, name=None, **kwargs): """Assign the LTI object's numbers of inputs and ouputs.""" super().__init__( @@ -71,8 +74,7 @@ def _set_inputs(self, value): #: Deprecated inputs = property( - _get_inputs, _set_inputs, doc= - """ + _get_inputs, _set_inputs, doc=""" Deprecated attribute; use :attr:`ninputs` instead. The ``inputs`` attribute was used to store the number of system @@ -94,8 +96,7 @@ def _set_outputs(self, value): #: Deprecated outputs = property( - _get_outputs, _set_outputs, doc= - """ + _get_outputs, _set_outputs, doc=""" Deprecated attribute; use :attr:`noutputs` instead. The ``outputs`` attribute was used to store the number of system @@ -201,6 +202,11 @@ def _dcgain(self, warn_infinite): else: return zeroresp + def ispassive(self): + # importing here prevents circular dependancy + from control.passivity import ispassive + ispassive(self) + # # Deprecated functions # @@ -321,7 +327,7 @@ def damp(sys, doprint=True): wn, damping, poles = sys.damp() if doprint: print('_____Eigenvalue______ Damping___ Frequency_') - for p, d, w in zip(poles, damping, wn) : + for p, d, w in zip(poles, damping, wn): if abs(p.imag) < 1e-12: print("%10.4g %10.4g %10.4g" % (p.real, 1.0, -p.real)) @@ -330,6 +336,7 @@ def damp(sys, doprint=True): (p.real, p.imag, d, w)) return wn, damping, poles + def evalfr(sys, x, squeeze=None): """Evaluate the transfer function of an LTI system for complex frequency x. @@ -388,6 +395,7 @@ def evalfr(sys, x, squeeze=None): """ return sys.__call__(x, squeeze=squeeze) + def frequency_response(sys, omega, squeeze=None): """Frequency response of an LTI system at multiple angular frequencies. diff --git a/control/passivity.py b/control/passivity.py index 60b081826..e833fbf96 100644 --- a/control/passivity.py +++ b/control/passivity.py @@ -4,6 +4,7 @@ ''' import numpy as np +from control import statesp as ss try: import cvxopt as cvx @@ -35,6 +36,8 @@ def ispassive(sys): if cvx is None: raise ModuleNotFoundError("cvxopt required for passivity module") + sys = ss._convert_to_statespace(sys) + A = sys.A B = sys.B C = sys.C diff --git a/control/tests/passivity_test.py b/control/tests/passivity_test.py index 681d2f527..09ef42b4a 100644 --- a/control/tests/passivity_test.py +++ b/control/tests/passivity_test.py @@ -4,7 +4,7 @@ ''' import pytest import numpy -from control import ss, passivity +from control import ss, passivity, tf from control.tests.conftest import cvxoptonly @@ -51,3 +51,23 @@ def test_ispassive_edge_cases(test_input, expected): D = test_input[3] sys = ss(A, B, C, D) assert(passivity.ispassive(sys) == expected) + + +def test_transfer_function(): + sys = tf([1], [1, -2]) + assert(passivity.ispassive(sys)) + + sys = tf([1], [1, 2]) + assert(not passivity.ispassive(sys)) + + +def test_oo_style(): + A = numpy.array([[0, 1], [-2, -2]]) + B = numpy.array([[0], [1]]) + C = numpy.array([[-1, 2]]) + D = numpy.array([[1.5]]) + sys = ss(A, B, C, D) + assert(sys.ispassive()) + + sys = tf([1], [1, -2]) + assert(sys.ispassive()) From d4ee3be8f1ff8d655bf40cbe3e70ca2a53302cec Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Thu, 16 Jun 2022 11:37:16 +0200 Subject: [PATCH 020/157] clean from old scipyy<1.3 and python2 code --- control/tests/conftest.py | 13 ++++--------- control/tests/flatsys_test.py | 11 ++++------- control/tests/iosys_test.py | 19 +------------------ control/tests/timeresp_test.py | 10 ++-------- control/tests/xferfcn_test.py | 6 ++---- doc/intro.rst | 2 +- setup.py | 2 +- 7 files changed, 15 insertions(+), 48 deletions(-) diff --git a/control/tests/conftest.py b/control/tests/conftest.py index b67ef3674..ca878337e 100644 --- a/control/tests/conftest.py +++ b/control/tests/conftest.py @@ -1,7 +1,6 @@ """conftest.py - pytest local plugins and fixtures""" from contextlib import contextmanager -from distutils.version import StrictVersion import os import sys @@ -18,10 +17,6 @@ # pytest.param(marks=) slycotonly = pytest.mark.skipif(not control.exception.slycot_check(), reason="slycot not installed") -noscipy0 = pytest.mark.skipif(StrictVersion(sp.__version__) < "1.0", - reason="requires SciPy 1.0 or greater") -nopython2 = pytest.mark.skipif(sys.version_info < (3, 0), - reason="requires Python 3+") matrixfilter = pytest.mark.filterwarnings("ignore:.*matrix subclass:" "PendingDeprecationWarning") matrixerrorfilter = pytest.mark.filterwarnings("error:.*matrix subclass:" @@ -110,10 +105,10 @@ def editsdefaults(): @pytest.fixture(scope="function") def mplcleanup(): - """Workaround for python2 - - python 2 does not like to mix the original mpl decorator with pytest - fixtures. So we roll our own. + """Clean up any plots and changes a test may have made to matplotlib. + + compare matplotlib.testing.decorators.cleanup() but as a fixture instead + of a decorator. """ save = mpl.units.registry.copy() try: diff --git a/control/tests/flatsys_test.py b/control/tests/flatsys_test.py index a12852759..e3584d459 100644 --- a/control/tests/flatsys_test.py +++ b/control/tests/flatsys_test.py @@ -8,8 +8,6 @@ created for that purpose. """ -from distutils.version import StrictVersion - import numpy as np import pytest import scipy as sp @@ -118,11 +116,10 @@ def test_kinematic_car(self, vehicle_flat, poly): resp = ct.input_output_response(vehicle_flat, T, ud, x0) np.testing.assert_array_almost_equal(resp.states, xd, decimal=2) - # For SciPy 1.0+, integrate equations and compare to desired - if StrictVersion(sp.__version__) >= "1.0": - t, y, x = ct.input_output_response( - vehicle_flat, T, ud, x0, return_x=True) - np.testing.assert_allclose(x, xd, atol=0.01, rtol=0.01) + # integrate equations and compare to desired + t, y, x = ct.input_output_response( + vehicle_flat, T, ud, x0, return_x=True) + np.testing.assert_allclose(x, xd, atol=0.01, rtol=0.01) def test_flat_default_output(self, vehicle_flat): # Construct a flat system with the default outputs diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index 825cec5c5..db683e1a1 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -15,7 +15,7 @@ import control as ct from control import iosys as ios -from control.tests.conftest import noscipy0, matrixfilter +from control.tests.conftest import matrixfilter class TestIOSys: @@ -46,7 +46,6 @@ class TSys: return T - @noscipy0 def test_linear_iosys(self, tsys): # Create an input/output system from the linear system linsys = tsys.siso_linsys @@ -65,7 +64,6 @@ def test_linear_iosys(self, tsys): np.testing.assert_array_almost_equal(lti_t, ios_t) np.testing.assert_allclose(lti_y, ios_y, atol=0.002, rtol=0.) - @noscipy0 def test_tf2io(self, tsys): # Create a transfer function from the state space system linsys = tsys.siso_linsys @@ -129,7 +127,6 @@ def test_iosys_print(self, tsys, capsys): ios_linearized = ios.linearize(ios_unspecified, [0, 0], [0]) print(ios_linearized) - @noscipy0 @pytest.mark.parametrize("ss", [ios.NonlinearIOSystem, ct.ss]) def test_nonlinear_iosys(self, tsys, ss): # Create a simple nonlinear I/O system @@ -236,7 +233,6 @@ def test_linearize_named_signals(self, kincar): assert lin_nocopy.find_output('x') is None assert lin_nocopy.find_state('x') is None - @noscipy0 def test_connect(self, tsys): # Define a couple of (linear) systems to interconnection linsys1 = tsys.siso_linsys @@ -294,7 +290,6 @@ def test_connect(self, tsys): np.testing.assert_array_almost_equal(lti_t, ios_t) np.testing.assert_allclose(lti_y, ios_y,atol=0.002,rtol=0.) - @noscipy0 @pytest.mark.parametrize( "connections, inplist, outlist", [pytest.param([[(1, 0), (0, 0, 1)]], [[(0, 0, 1)]], [[(1, 0, 1)]], @@ -338,7 +333,6 @@ def test_connect_spec_variants(self, tsys, connections, inplist, outlist): np.testing.assert_array_almost_equal(lti_t, ios_t) np.testing.assert_allclose(lti_y, ios_y, atol=0.002, rtol=0.) - @noscipy0 @pytest.mark.parametrize( "connections, inplist, outlist", [pytest.param([['sys2.u[0]', 'sys1.y[0]']], @@ -375,7 +369,6 @@ def test_connect_spec_warnings(self, tsys, connections, inplist, outlist): np.testing.assert_array_almost_equal(lti_t, ios_t) np.testing.assert_allclose(lti_y, ios_y, atol=0.002, rtol=0.) - @noscipy0 def test_static_nonlinearity(self, tsys): # Linear dynamical system linsys = tsys.siso_linsys @@ -400,7 +393,6 @@ def test_static_nonlinearity(self, tsys): np.testing.assert_array_almost_equal(lti_y, ios_y, decimal=2) - @noscipy0 @pytest.mark.filterwarnings("ignore:Duplicate name::control.iosys") def test_algebraic_loop(self, tsys): # Create some linear and nonlinear systems to play with @@ -470,7 +462,6 @@ def test_algebraic_loop(self, tsys): with pytest.raises(RuntimeError): ios.input_output_response(*args) - @noscipy0 def test_summer(self, tsys): # Construct a MIMO system for testing linsys = tsys.mimo_linsys1 @@ -489,7 +480,6 @@ def test_summer(self, tsys): ios_t, ios_y = ios.input_output_response(iosys_parallel, T, U, X0) np.testing.assert_allclose(ios_y, lin_y,atol=0.002,rtol=0.) - @noscipy0 def test_rmul(self, tsys): # Test right multiplication # TODO: replace with better tests when conversions are implemented @@ -510,7 +500,6 @@ def test_rmul(self, tsys): lti_t, lti_y = ct.forced_response(ioslin, T, U*U, X0) np.testing.assert_array_almost_equal(ios_y, lti_y*lti_y, decimal=3) - @noscipy0 def test_neg(self, tsys): """Test negation of a system""" @@ -533,7 +522,6 @@ def test_neg(self, tsys): lti_t, lti_y = ct.forced_response(ioslin, T, U*U, X0) np.testing.assert_array_almost_equal(ios_y, -lti_y, decimal=3) - @noscipy0 def test_feedback(self, tsys): # Set up parameters for simulation T, U, X0 = tsys.T, tsys.U, tsys.X0 @@ -549,7 +537,6 @@ def test_feedback(self, tsys): lti_t, lti_y = ct.forced_response(linsys, T, U, X0) np.testing.assert_allclose(ios_y, lti_y,atol=0.002,rtol=0.) - @noscipy0 def test_bdalg_functions(self, tsys): """Test block diagram functions algebra on I/O systems""" # Set up parameters for simulation @@ -596,7 +583,6 @@ def test_bdalg_functions(self, tsys): ios_t, ios_y = ios.input_output_response(iosys_feedback, T, U, X0) np.testing.assert_allclose(ios_y, lin_y,atol=0.002,rtol=0.) - @noscipy0 def test_algebraic_functions(self, tsys): """Test algebraic operations on I/O systems""" # Set up parameters for simulation @@ -648,7 +634,6 @@ def test_algebraic_functions(self, tsys): ios_t, ios_y = ios.input_output_response(iosys_negate, T, U, X0) np.testing.assert_allclose(ios_y, lin_y,atol=0.002,rtol=0.) - @noscipy0 def test_nonsquare_bdalg(self, tsys): # Set up parameters for simulation T = tsys.T @@ -699,7 +684,6 @@ def test_nonsquare_bdalg(self, tsys): with pytest.raises(ValueError): ct.series(*args) - @noscipy0 def test_discrete(self, tsys): """Test discrete time functionality""" # Create some linear and nonlinear systems to play with @@ -868,7 +852,6 @@ def test_find_eqpts(self, tsys): assert xeq is None assert ueq is None - @noscipy0 def test_params(self, tsys): # Start with the default set of parameters ios_secord_default = ios.NonlinearIOSystem( diff --git a/control/tests/timeresp_test.py b/control/tests/timeresp_test.py index fe73ab4a9..ff712f849 100644 --- a/control/tests/timeresp_test.py +++ b/control/tests/timeresp_test.py @@ -1,7 +1,6 @@ """timeresp_test.py - test time response functions""" from copy import copy -from distutils.version import StrictVersion import numpy as np import pytest @@ -521,8 +520,6 @@ def test_impulse_response_mimo(self, tsystem): _t, yy = impulse_response(sys, T=t, input=0) np.testing.assert_array_almost_equal(yy[:,0,:], yref_notrim, decimal=4) - @pytest.mark.skipif(StrictVersion(sp.__version__) < "1.3", - reason="requires SciPy 1.3 or greater") @pytest.mark.parametrize("tsystem", ["siso_tf1"], indirect=True) def test_discrete_time_impulse(self, tsystem): # discrete time impulse sampled version should match cont time @@ -998,9 +995,6 @@ def test_time_series_data_convention_2D(self, tsystem): [6, 2, 2, False, (2, 2, 8), (2, 8)], ]) def test_squeeze(self, fcn, nstate, nout, ninp, squeeze, shape1, shape2): - # Figure out if we have SciPy 1+ - scipy0 = StrictVersion(sp.__version__) < '1.0' - # 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 " @@ -1077,7 +1071,7 @@ def test_squeeze(self, fcn, nstate, nout, ninp, squeeze, shape1, shape2): assert yvec.shape == (8, ) # For InputOutputSystems, also test input/output response - if isinstance(sys, ct.InputOutputSystem) and not scipy0: + if isinstance(sys, ct.InputOutputSystem): _, yvec = ct.input_output_response(sys, tvec, uvec, squeeze=squeeze) assert yvec.shape == shape2 @@ -1108,7 +1102,7 @@ def test_squeeze(self, fcn, nstate, nout, ninp, squeeze, shape1, shape2): assert yvec.shape == (sys.noutputs, 8) # For InputOutputSystems, also test input_output_response - if isinstance(sys, ct.InputOutputSystem) and not scipy0: + if isinstance(sys, ct.InputOutputSystem): _, yvec = ct.input_output_response(sys, tvec, uvec) if squeeze is not True or sys.noutputs > 1: assert yvec.shape == (sys.noutputs, 8) diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py index 79273f31b..894da5594 100644 --- a/control/tests/xferfcn_test.py +++ b/control/tests/xferfcn_test.py @@ -12,7 +12,7 @@ from control import isctime, isdtime, sample_system, defaults from control.statesp import _convert_to_statespace from control.xferfcn import _convert_to_transfer_function -from control.tests.conftest import slycotonly, nopython2, matrixfilter +from control.tests.conftest import slycotonly, matrixfilter class TestXferFcn: @@ -448,7 +448,6 @@ def test_call_siso(self, dt, omega, resp): np.testing.assert_allclose(sys.evalfr(omega), resp, atol=1e-3) - @nopython2 def test_call_dtime(self): sys = TransferFunction([1., 3., 5], [1., 6., 2., -1], 0.1) np.testing.assert_array_almost_equal(sys(1j), -0.5 - 0.5j) @@ -741,8 +740,7 @@ def test_indexing(self): "matarrayin", [pytest.param(np.array, id="arrayin", - marks=[nopython2, - pytest.mark.skip(".__matmul__ not implemented")]), + marks=[pytest.mark.skip(".__matmul__ not implemented")]), pytest.param(np.matrix, id="matrixin", marks=matrixfilter)], diff --git a/doc/intro.rst b/doc/intro.rst index 01fe81bd0..ce01aca15 100644 --- a/doc/intro.rst +++ b/doc/intro.rst @@ -36,7 +36,7 @@ Installation ============ The `python-control` package can be installed using pip, conda or the -standard distutils/setuptools mechanisms. The package requires `numpy`_ and +standard setuptools mechanisms. The package requires `numpy`_ and `scipy`_, and the plotting routines require `matplotlib `_. In addition, some routines require the `slycot `_ library in order to implement diff --git a/setup.py b/setup.py index a8609148e..df3f8519d 100644 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ packages=find_packages(exclude=['benchmarks']), classifiers=[f for f in CLASSIFIERS.split('\n') if f], install_requires=['numpy', - 'scipy', + 'scipy>=1.3', 'matplotlib'], extras_require={ 'test': ['pytest', 'pytest-timeout'], From f0393b73921c4a688490f1ca17519c37a2c6bbd5 Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Thu, 16 Jun 2022 11:42:30 +0200 Subject: [PATCH 021/157] isort and autopep8 conftest.py --- control/tests/conftest.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/control/tests/conftest.py b/control/tests/conftest.py index ca878337e..ac35748f3 100644 --- a/control/tests/conftest.py +++ b/control/tests/conftest.py @@ -1,13 +1,13 @@ """conftest.py - pytest local plugins and fixtures""" -from contextlib import contextmanager import os import sys +from contextlib import contextmanager import matplotlib as mpl import numpy as np -import scipy as sp import pytest +import scipy as sp import control @@ -38,6 +38,7 @@ def control_defaults(): # assert that nothing changed it without reverting assert control.config.defaults == the_defaults + @pytest.fixture(scope="function", autouse=TEST_MATRIX_AND_ARRAY, params=[pytest.param("arrayout", marks=matrixerrorfilter), pytest.param("matrixout", marks=matrixfilter)]) @@ -106,7 +107,7 @@ def editsdefaults(): @pytest.fixture(scope="function") def mplcleanup(): """Clean up any plots and changes a test may have made to matplotlib. - + compare matplotlib.testing.decorators.cleanup() but as a fixture instead of a decorator. """ From 0cd3ac8ee0b1f250183c4c7492e67b90c7e36470 Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Thu, 16 Jun 2022 11:57:45 +0200 Subject: [PATCH 022/157] move away from deprecated scipy namespace --- control/tests/optimal_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control/tests/optimal_test.py b/control/tests/optimal_test.py index 1aa307b60..027b55c75 100644 --- a/control/tests/optimal_test.py +++ b/control/tests/optimal_test.py @@ -366,7 +366,7 @@ def test_optimal_logging(capsys): x0 = [-1, 1] # Solve it, with logging turned on (with warning due to mixed constraints) - with pytest.warns(sp.optimize.optimize.OptimizeWarning, + with pytest.warns(sp.optimize.OptimizeWarning, match="Equality and inequality .* same element"): res = opt.solve_ocp( sys, time, x0, cost, input_constraint, terminal_cost=cost, From 7f883f67b7f0e2782a1fa8e965b9e9bd7dbd691d Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Thu, 16 Jun 2022 12:19:12 +0200 Subject: [PATCH 023/157] Avoid PytestRemovedIn8Warning for pytest.warns(None) Details: https://docs.pytest.org/en/latest/how-to/capture-warnings.html#additional-use-cases-of-warnings-in-tests --- control/tests/iosys_test.py | 39 ++++++++++++++--------------------- control/tests/namedio_test.py | 8 +++++-- control/tests/nyquist_test.py | 7 +++++-- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index db683e1a1..693be979e 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -9,6 +9,7 @@ """ import re +import warnings import numpy as np import pytest @@ -17,6 +18,7 @@ from control import iosys as ios from control.tests.conftest import matrixfilter + class TestIOSys: @pytest.fixture @@ -1416,11 +1418,10 @@ def test_duplicates(self, tsys): nlios2 = ios.NonlinearIOSystem(None, lambda t, x, u, params: u * u, inputs=1, outputs=1, name="nlios2") - with pytest.warns(None) as record: + with warnings.catch_warnings(): + warnings.simplefilter("error") ct.InterconnectedSystem([nlios1, iosys_siso, nlios2], inputs=0, outputs=0, states=0) - if record: - pytest.fail("Warning not expected: " + record[0].message) def test_linear_interconnection(): @@ -1510,7 +1511,7 @@ def predprey(t, x, u, params={}): def pvtol(t, x, u, params={}): """Reduced planar vertical takeoff and landing dynamics""" - from math import sin, cos + from math import cos, sin m = params.get('m', 4.) # kg, system mass J = params.get('J', 0.0475) # kg m^2, system inertia r = params.get('r', 0.25) # m, thrust offset @@ -1526,7 +1527,7 @@ def pvtol(t, x, u, params={}): def pvtol_full(t, x, u, params={}): - from math import sin, cos + from math import cos, sin m = params.get('m', 4.) # kg, system mass J = params.get('J', 0.0475) # kg m^2, system inertia r = params.get('r', 0.25) # m, thrust offset @@ -1579,8 +1580,12 @@ def test_interconnect_unused_input(): inputs=['r'], outputs=['y']) - with pytest.warns(None) as record: + with warnings.catch_warnings(): # no warning if output explicitly ignored, various argument forms + warnings.simplefilter("error") + # strip out matrix warnings + warnings.filterwarnings("ignore", "the matrix subclass", + category=PendingDeprecationWarning) h = ct.interconnect([g,s,k], inputs=['r'], outputs=['y'], @@ -1595,14 +1600,6 @@ def test_interconnect_unused_input(): h = ct.interconnect([g,s,k], connections=False) - #https://docs.pytest.org/en/6.2.x/warnings.html#recwarn - for r in record: - # strip out matrix warnings - if re.match(r'.*matrix subclass', str(r.message)): - continue - print(r.message) - pytest.fail(f'Unexpected warning: {r.message}') - # warn if explicity ignored input in fact used with pytest.warns( @@ -1657,7 +1654,11 @@ def test_interconnect_unused_output(): # no warning if output explicitly ignored - with pytest.warns(None) as record: + with warnings.catch_warnings(): + warnings.simplefilter("error") + # strip out matrix warnings + warnings.filterwarnings("ignore", "the matrix subclass", + category=PendingDeprecationWarning) h = ct.interconnect([g,s,k], inputs=['r'], outputs=['y'], @@ -1672,14 +1673,6 @@ def test_interconnect_unused_output(): h = ct.interconnect([g,s,k], connections=False) - #https://docs.pytest.org/en/6.2.x/warnings.html#recwarn - for r in record: - # strip out matrix warnings - if re.match(r'.*matrix subclass', str(r.message)): - continue - print(r.message) - pytest.fail(f'Unexpected warning: {r.message}') - # warn if explicity ignored output in fact used with pytest.warns( UserWarning, diff --git a/control/tests/namedio_test.py b/control/tests/namedio_test.py index 3a96203a8..4925e9790 100644 --- a/control/tests/namedio_test.py +++ b/control/tests/namedio_test.py @@ -10,6 +10,7 @@ import re from copy import copy +import warnings import numpy as np import control as ct @@ -243,9 +244,12 @@ def test_duplicate_sysname(): sys = ct.rss(4, 1, 1) # No warnings should be generated if we reuse an an unnamed system - with pytest.warns(None) as record: + with warnings.catch_warnings(): + warnings.simplefilter("error") + # strip out matrix warnings + warnings.filterwarnings("ignore", "the matrix subclass", + category=PendingDeprecationWarning) res = sys * sys - assert not any([type(msg) == UserWarning for msg in record]) # Generate a warning if the system is named sys = ct.rss(4, 1, 1, name='sys') diff --git a/control/tests/nyquist_test.py b/control/tests/nyquist_test.py index b1aa00577..ddc69e7bb 100644 --- a/control/tests/nyquist_test.py +++ b/control/tests/nyquist_test.py @@ -8,6 +8,8 @@ """ +import warnings + import pytest import numpy as np import matplotlib.pyplot as plt @@ -225,10 +227,11 @@ def test_nyquist_encirclements(): sys = 1 / (s**2 + s + 1) with pytest.warns(UserWarning, match="encirclements was a non-integer"): count = ct.nyquist_plot(sys, omega_limits=[0.5, 1e3]) - with pytest.warns(None) as records: + with warnings.catch_warnings(): + warnings.simplefilter("error") + # strip out matrix warnings count = ct.nyquist_plot( sys, omega_limits=[0.5, 1e3], encirclement_threshold=0.2) - assert len(records) == 0 plt.title("Non-integer number of encirclements [%g]" % count) From ce11d0be328af73179307d4b63071968175fd537 Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Thu, 16 Jun 2022 13:28:32 +0200 Subject: [PATCH 024/157] mark the whole passivity_test module as skippable --- control/tests/passivity_test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/control/tests/passivity_test.py b/control/tests/passivity_test.py index 09ef42b4a..2a12f10df 100644 --- a/control/tests/passivity_test.py +++ b/control/tests/passivity_test.py @@ -8,7 +8,9 @@ from control.tests.conftest import cvxoptonly -@cvxoptonly +pytestmark = cvxoptonly + + def test_ispassive(): A = numpy.array([[0, 1], [-2, -2]]) B = numpy.array([[0], [1]]) @@ -33,7 +35,6 @@ def test_ispassive(): D = numpy.array([[1.5]]) -@cvxoptonly @pytest.mark.parametrize( "test_input,expected", [((A, B, C, D*0.0), True), From f7d74b2a52e4653d602512664f6ac00111f897c6 Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 16 Jun 2022 09:14:24 -0500 Subject: [PATCH 025/157] Fix bug in tests and lti.py. --- control/lti.py | 2 +- control/tests/passivity_test.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/control/lti.py b/control/lti.py index 4f6624748..b87944cd0 100644 --- a/control/lti.py +++ b/control/lti.py @@ -205,7 +205,7 @@ def _dcgain(self, warn_infinite): def ispassive(self): # importing here prevents circular dependancy from control.passivity import ispassive - ispassive(self) + return ispassive(self) # # Deprecated functions diff --git a/control/tests/passivity_test.py b/control/tests/passivity_test.py index 2a12f10df..791d70b6c 100644 --- a/control/tests/passivity_test.py +++ b/control/tests/passivity_test.py @@ -55,10 +55,10 @@ def test_ispassive_edge_cases(test_input, expected): def test_transfer_function(): - sys = tf([1], [1, -2]) + sys = tf([1], [1, 2]) assert(passivity.ispassive(sys)) - sys = tf([1], [1, 2]) + sys = tf([1], [1, -2]) assert(not passivity.ispassive(sys)) @@ -70,5 +70,5 @@ def test_oo_style(): sys = ss(A, B, C, D) assert(sys.ispassive()) - sys = tf([1], [1, -2]) + sys = tf([1], [1, 2]) assert(sys.ispassive()) From c2255b0b087696e09717cc1591c1038b357f4ae7 Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 16 Jun 2022 09:45:50 -0500 Subject: [PATCH 026/157] Fix merge issue. --- control/tests/conftest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/control/tests/conftest.py b/control/tests/conftest.py index ac35748f3..1201b8746 100644 --- a/control/tests/conftest.py +++ b/control/tests/conftest.py @@ -17,6 +17,8 @@ # 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") matrixfilter = pytest.mark.filterwarnings("ignore:.*matrix subclass:" "PendingDeprecationWarning") matrixerrorfilter = pytest.mark.filterwarnings("error:.*matrix subclass:" From 0ff91914fd7a1cd699e7376576f5242ca22da14e Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Mon, 18 Jul 2022 10:49:00 +0200 Subject: [PATCH 027/157] Slycot source uses setuptools_scm --- .github/workflows/control-slycot-src.yml | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/control-slycot-src.yml b/.github/workflows/control-slycot-src.yml index 13a66e426..ffbeca3f9 100644 --- a/.github/workflows/control-slycot-src.yml +++ b/.github/workflows/control-slycot-src.yml @@ -7,7 +7,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - name: Checkout python-control + uses: actions/checkout@v3 + with: + path: python-control - name: Set up Python uses: actions/setup-python@v2 - name: Install Python dependencies @@ -24,18 +27,27 @@ jobs: # Install python-control dependencies conda install numpy matplotlib scipy + - name: Checkout Slycot + uses: actions/checkout@v3 + with: + repository: python-control/Slycot + submodules: recursive + fetch-depth: 0 + path: slycot - name: Install slycot from source + env: + BLA_VENDOR: Generic + CMAKE_GENERATOR: Unix Makefiles + working-directory: slycot run: | # Install compilers, libraries, and development environment sudo apt-get -y install gfortran cmake --fix-missing sudo apt-get -y install libblas-dev liblapack-dev - conda install -c conda-forge scikit-build; + conda install -c conda-forge scikit-build setuptools-scm # Compile and install slycot - git clone https://github.com/python-control/Slycot.git slycot - cd slycot - git submodule update --init - python setup.py build_ext install -DBLA_VENDOR=Generic + pip install -v --no-build-isolation --no-deps . - name: Test with pytest + working-directory: python-control run: xvfb-run --auto-servernum pytest control/tests From a19c501927327039ddc7bed181393574c347adb5 Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 30 Jul 2022 17:11:32 -0400 Subject: [PATCH 028/157] Passivity indices and support for discrete time systems. (#750) This adds support for input and output passivity indices. This implementation ignores frequency bands, relative passivity index, combined I/O passivity, and directional passivity index. This also adds support for computing indices and "is passive or not" for discrete time systems. Authored-by: Mark --- control/__init__.py | 1 + control/passivity.py | 307 ++++++++++++++++++++++++++------ control/tests/passivity_test.py | 82 ++++++++- doc/control.rst | 5 + 4 files changed, 339 insertions(+), 56 deletions(-) diff --git a/control/__init__.py b/control/__init__.py index ad2685273..e0edc96a2 100644 --- a/control/__init__.py +++ b/control/__init__.py @@ -72,6 +72,7 @@ from .config import * from .sisotool import * from .iosys import * +from .passivity import * # Exceptions from .exception import * diff --git a/control/passivity.py b/control/passivity.py index e833fbf96..2ec1a7683 100644 --- a/control/passivity.py +++ b/control/passivity.py @@ -1,42 +1,72 @@ -''' -Author: Mark Yeatman -Date: May 15, 2022 -''' +""" +Functions for passive control. + +Author: Mark Yeatman +Date: July 17, 2022 +""" import numpy as np -from control import statesp as ss +from control import statesp +from control.exception import ControlArgument, ControlDimension try: import cvxopt as cvx -except ImportError as e: +except ImportError: cvx = None +eps = np.nextafter(0, 1) + +__all__ = ["get_output_fb_index", "get_input_ff_index", "ispassive", + "solve_passivity_LMI"] + + +def solve_passivity_LMI(sys, rho=None, nu=None): + """Compute passivity indices and/or solves feasiblity via a LMI. -def ispassive(sys): - ''' - Indicates if a linear time invariant (LTI) system is passive + Constructs a linear matrix inequality (LMI) such that if a solution exists + and the last element of the solution is positive, the system `sys` is + passive. Inputs of None for `rho` or `nu` indicate that the function should + solve for that index (they are mutually exclusive, they can't both be + None, otherwise you're trying to solve a nonconvex bilinear matrix + inequality.) The last element of `solution` is either the output or input + passivity index, for `rho` = None and `nu` = None respectively. - Constructs a linear matrix inequality and a feasibility optimization - such that if a solution exists, the system is passive. + The sources for the algorithm are: - The source for the algorithm is: - McCourt, Michael J., and Panos J. Antsaklis. - "Demonstrating passivity and dissipativity using computational methods." ISIS 8 (2013). + McCourt, Michael J., and Panos J. Antsaklis + "Demonstrating passivity and dissipativity using computational + methods." + + Nicholas Kottenstette and Panos J. Antsaklis + "Relationships Between Positive Real, Passive Dissipative, & Positive + Systems", equation 36. Parameters ---------- - sys: A continuous LTI system - System to be checked. + sys : LTI + System to be checked + rho : float or None + Output feedback passivity index + nu : float or None + Input feedforward passivity index Returns ------- - bool: - The input system passive. - ''' + solution : ndarray + The LMI solution + """ if cvx is None: raise ModuleNotFoundError("cvxopt required for passivity module") - sys = ss._convert_to_statespace(sys) + if sys.ninputs != sys.noutputs: + raise ControlDimension( + "The number of system inputs must be the same as the number of " + "system outputs.") + + if rho is None and nu is None: + raise ControlArgument("rho or nu must be given a numerical value.") + + sys = statesp._convert_to_statespace(sys) A = sys.A B = sys.B @@ -45,43 +75,218 @@ def ispassive(sys): # account for strictly proper systems [n, m] = D.shape - D = D + np.nextafter(0, 1)*np.eye(n, m) + D = D + eps * np.eye(n, m) [n, _] = A.shape - A = A - np.nextafter(0, 1)*np.eye(n) - - def make_LMI_matrix(P): - V = np.vstack(( - np.hstack((A.T @ P + P@A, P@B)), - np.hstack((B.T@P, np.zeros_like(D)))) - ) - return V - - matrix_list = [] - state_space_size = sys.nstates - for i in range(0, state_space_size): - for j in range(0, state_space_size): - if j <= i: - P = np.zeros_like(A) - P[i, j] = 1.0 - P[j, i] = 1.0 - matrix_list.append(make_LMI_matrix(P).flatten()) - - coefficents = np.vstack(matrix_list).T - - constants = -np.vstack(( - np.hstack((np.zeros_like(A), - C.T)), - np.hstack((- C, -D - D.T))) - ) + A = A - eps*np.eye(n) + + def make_LMI_matrix(P, rho, nu, one): + q = sys.noutputs + Q = -rho*np.eye(q, q) + S = 1.0/2.0*(one+rho*nu)*np.eye(q) + R = -nu*np.eye(m) + if sys.isctime(): + off_diag = P@B - (C.T@S + C.T@Q@D) + return np.vstack(( + np.hstack((A.T @ P + P@A - C.T@Q@C, off_diag)), + np.hstack((off_diag.T, -(D.T@Q@D + D.T@S + S.T@D + R))) + )) + else: + off_diag = A.T@P@B - (C.T@S + C.T@Q@D) + return np.vstack(( + np.hstack((A.T @ P @ A - P - C.T@Q@C, off_diag)), + np.hstack((off_diag.T, B.T@P@B-(D.T@Q@D + D.T@S + S.T@D + R))) + )) + + def make_P_basis_matrices(n, rho, nu): + """Make list of matrix constraints for passivity LMI. + + Utility function to make basis matrices for a LMI from a + symmetric matrix P of size n by n representing a parametrized symbolic + matrix + """ + matrix_list = [] + for i in range(0, n): + for j in range(0, n): + if j <= i: + P = np.zeros((n, n)) + P[i, j] = 1 + P[j, i] = 1 + matrix_list.append(make_LMI_matrix(P, 0, 0, 0).flatten()) + zeros = eps*np.eye(n) + if rho is None: + matrix_list.append(make_LMI_matrix(zeros, 1, 0, 0).flatten()) + elif nu is None: + matrix_list.append(make_LMI_matrix(zeros, 0, 1, 0).flatten()) + return matrix_list + + + def P_pos_def_constraint(n): + """Make a list of matrix constraints for P >= 0. + + Utility function to make basis matrices for a LMI that ensures + parametrized symbolic matrix of size n by n is positive definite + """ + matrix_list = [] + for i in range(0, n): + for j in range(0, n): + if j <= i: + P = np.zeros((n, n)) + P[i, j] = -1 + P[j, i] = -1 + matrix_list.append(P.flatten()) + if rho is None or nu is None: + matrix_list.append(np.zeros((n, n)).flatten()) + return matrix_list + + n = sys.nstates + + # coefficents for passivity indices and feasibility matrix + sys_matrix_list = make_P_basis_matrices(n, rho, nu) + # get constants for numerical values of rho and nu + sys_constants = list() + if rho is not None and nu is not None: + sys_constants = -make_LMI_matrix(np.zeros_like(A), rho, nu, 1.0) + elif rho is not None: + sys_constants = -make_LMI_matrix(np.zeros_like(A), rho, eps, 1.0) + elif nu is not None: + sys_constants = -make_LMI_matrix(np.zeros_like(A), eps, nu, 1.0) + + sys_coefficents = np.vstack(sys_matrix_list).T + + # LMI to ensure P is positive definite + P_matrix_list = P_pos_def_constraint(n) + P_coefficents = np.vstack(P_matrix_list).T + P_constants = np.zeros((n, n)) + + # cost function number_of_opt_vars = int( - (state_space_size**2-state_space_size)/2 + state_space_size) + (n**2-n)/2 + n) c = cvx.matrix(0.0, (number_of_opt_vars, 1)) + #we're maximizing a passivity index, include it in the cost function + if rho is None or nu is None: + c = cvx.matrix(np.append(np.array(c), -1.0)) + + Gs = [cvx.matrix(sys_coefficents)] + [cvx.matrix(P_coefficents)] + hs = [cvx.matrix(sys_constants)] + [cvx.matrix(P_constants)] + # crunch feasibility solution cvx.solvers.options['show_progress'] = False - sol = cvx.solvers.sdp(c, - Gs=[cvx.matrix(coefficents)], - hs=[cvx.matrix(constants)]) + sol = cvx.solvers.sdp(c, Gs=Gs, hs=hs) + return sol["x"] + + +def get_output_fb_index(sys): + """Return the output feedback passivity (OFP) index for the system. + + The OFP is the largest gain that can be placed in positive feedback + with a system such that the new interconnected system is passive. + + Parameters + ---------- + sys : LTI + System to be checked + + Returns + ------- + float + The OFP index + """ + sol = solve_passivity_LMI(sys, nu=eps) + if sol is None: + raise RuntimeError("LMI passivity problem is infeasible") + else: + return sol[-1] + + +def get_input_ff_index(sys): + """Return the input feedforward passivity (IFP) index for the system. - return (sol["x"] is not None) + The IFP is the largest gain that can be placed in negative parallel + interconnection with a system such that the new interconnected system is + passive. + + Parameters + ---------- + sys : LTI + System to be checked. + + Returns + ------- + float + The IFP index + """ + sol = solve_passivity_LMI(sys, rho=eps) + if sol is None: + raise RuntimeError("LMI passivity problem is infeasible") + else: + return sol[-1] + + +def get_relative_index(sys): + """Return the relative passivity index for the system. + + (not implemented yet) + """ + raise NotImplementedError("Relative passivity index not implemented") + + +def get_combined_io_index(sys): + """Return the combined I/O passivity index for the system. + + (not implemented yet) + """ + raise NotImplementedError("Combined I/O passivity index not implemented") + + +def get_directional_index(sys): + """Return the directional passivity index for the system. + + (not implemented yet) + """ + raise NotImplementedError("Directional passivity index not implemented") + + +def ispassive(sys, ofp_index=0, ifp_index=0): + r"""Indicate if a linear time invariant (LTI) system is passive. + + Checks if system is passive with the given output feedback (OFP) and input + feedforward (IFP) passivity indices. + + Parameters + ---------- + sys : LTI + System to be checked + ofp_index : float + Output feedback passivity index + ifp_index : float + Input feedforward passivity index + + Returns + ------- + bool + The system is passive. + + Notes + ----- + Querying if the system is passive in the sense of + + .. math:: V(x) >= 0 \land \dot{V}(x) <= y^T u + + is equivalent to the default case of `ofp_index` = 0 and `ifp_index` = 0. + Note that computing the `ofp_index` and `ifp_index` for a system, then + using both values simultaneously as inputs to this function is not + guaranteed to have an output of True (the system might not be passive with + both indices at the same time). + + For more details, see [1]. + + References + ---------- + .. [1] McCourt, Michael J., and Panos J. Antsaklis + "Demonstrating passivity and dissipativity using computational + methods." + """ + return solve_passivity_LMI(sys, rho=ofp_index, nu=ifp_index) is not None diff --git a/control/tests/passivity_test.py b/control/tests/passivity_test.py index 791d70b6c..4c95c96b9 100644 --- a/control/tests/passivity_test.py +++ b/control/tests/passivity_test.py @@ -4,14 +4,14 @@ ''' import pytest import numpy -from control import ss, passivity, tf +from control import ss, passivity, tf, sample_system, parallel, feedback from control.tests.conftest import cvxoptonly - +from control.exception import ControlArgument, ControlDimension pytestmark = cvxoptonly -def test_ispassive(): +def test_ispassive_ctime(): A = numpy.array([[0, 1], [-2, -2]]) B = numpy.array([[0], [1]]) C = numpy.array([[-1, 2]]) @@ -28,6 +28,69 @@ def test_ispassive(): assert(not passivity.ispassive(sys)) +def test_ispassive_dtime(): + A = numpy.array([[0, 1], [-2, -2]]) + B = numpy.array([[0], [1]]) + C = numpy.array([[-1, 2]]) + D = numpy.array([[1.5]]) + sys = ss(A, B, C, D) + sys = sample_system(sys, 1, method='bilinear') + assert(passivity.ispassive(sys)) + + +def test_passivity_indices_ctime(): + sys = tf([1, 1, 5, 0.1], [1, 2, 3, 4]) + + iff_index = passivity.get_input_ff_index(sys) + ofb_index = passivity.get_output_fb_index(sys) + + assert(isinstance(ofb_index, float)) + + sys_ff = parallel(sys, -iff_index) + sys_fb = feedback(sys, ofb_index, sign=1) + + assert(sys_ff.ispassive()) + assert(sys_fb.ispassive()) + + sys_ff = parallel(sys, -iff_index-1e-6) + sys_fb = feedback(sys, ofb_index+1e-6, sign=1) + + assert(not sys_ff.ispassive()) + assert(not sys_fb.ispassive()) + + +def test_passivity_indices_dtime(): + sys = tf([1, 1, 5, 0.1], [1, 2, 3, 4]) + sys = sample_system(sys, Ts=0.1) + iff_index = passivity.get_input_ff_index(sys) + ofb_index = passivity.get_output_fb_index(sys) + + assert(isinstance(iff_index, float)) + + sys_ff = parallel(sys, -iff_index) + sys_fb = feedback(sys, ofb_index, sign=1) + + assert(sys_ff.ispassive()) + assert(sys_fb.ispassive()) + + sys_ff = parallel(sys, -iff_index-1e-2) + sys_fb = feedback(sys, ofb_index+1e-2, sign=1) + + assert(not sys_ff.ispassive()) + assert(not sys_fb.ispassive()) + + +def test_system_dimension(): + A = numpy.array([[0, 1], [-2, -2]]) + B = numpy.array([[0], [1]]) + C = numpy.array([[-1, 2], [0, 1]]) + D = numpy.array([[1.5], [1]]) + sys = ss(A, B, C, D) + + with pytest.raises(ControlDimension): + passivity.ispassive(sys) + + A_d = numpy.array([[-2, 0], [0, 0]]) A = numpy.array([[-3, 0], [0, -2]]) B = numpy.array([[0], [1]]) @@ -44,8 +107,6 @@ def test_ispassive(): ((A*0, B, C, D), True), ((A*0, B*0, C*0, D*0), True)]) def test_ispassive_edge_cases(test_input, expected): - - # strictly proper A = test_input[0] B = test_input[1] C = test_input[2] @@ -54,6 +115,17 @@ def test_ispassive_edge_cases(test_input, expected): assert(passivity.ispassive(sys) == expected) +def test_rho_and_nu_are_none(): + A = numpy.array([[0]]) + B = numpy.array([[0]]) + C = numpy.array([[0]]) + D = numpy.array([[0]]) + sys = ss(A, B, C, D) + + with pytest.raises(ControlArgument): + passivity.solve_passivity_LMI(sys) + + def test_transfer_function(): sys = tf([1], [1, 2]) assert(passivity.ispassive(sys)) diff --git a/doc/control.rst b/doc/control.rst index fc6618d24..172790f83 100644 --- a/doc/control.rst +++ b/doc/control.rst @@ -80,6 +80,9 @@ Control system analysis describing_function evalfr freqresp + get_input_ff_index + get_output_fb_index + ispassive margin stability_margins phase_crossover_frequencies @@ -89,6 +92,8 @@ Control system analysis root_locus sisotool + + Matrix computations =================== .. autosummary:: From 813572fb18d6f0e26c2f0bb67ab82c9cb4330e36 Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Fri, 5 Aug 2022 23:55:32 +0200 Subject: [PATCH 029/157] New job name format --- .github/workflows/python-package-conda.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-package-conda.yml b/.github/workflows/python-package-conda.yml index 87cece16b..5db9239a3 100644 --- a/.github/workflows/python-package-conda.yml +++ b/.github/workflows/python-package-conda.yml @@ -4,7 +4,12 @@ on: [push, pull_request] jobs: test-linux: - name: Python ${{ matrix.python-version }}${{ matrix.slycot && format(' with Slycot from {0}', matrix.slycot) || ' without Slycot' }}${{ matrix.pandas && ', with pandas' || '' }}${{ matrix.array-and-matrix == 1 && ', array and matrix' || '' }}${{ matrix.cvxopt && format(' with cvxopt from {0}', matrix.cvxopt) || ' without cvxopt' }} + name: > + Python ${{ matrix.python-version }}; + ${{ matrix.slycot || 'without' }} Slycot; + ${{ matrix.pandas || 'without' }} Pandas; + ${{ matrix.cvxopt || 'without' }} CVXOPT; + ${{ matrix.array-and-matrix == 1 && '; array and matrix' || '' }} runs-on: ubuntu-latest strategy: From c617b33ded2c6ab5282a1f3bbf8370cdb2d7bd28 Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Sat, 6 Aug 2022 00:15:36 +0200 Subject: [PATCH 030/157] switch to mambaforge setup with strict conda-forge channel --- .github/conda-env/test-env.yml | 11 +++++ .github/workflows/python-package-conda.yml | 52 +++++++++++----------- 2 files changed, 36 insertions(+), 27 deletions(-) create mode 100644 .github/conda-env/test-env.yml diff --git a/.github/conda-env/test-env.yml b/.github/conda-env/test-env.yml new file mode 100644 index 000000000..cc91a1ade --- /dev/null +++ b/.github/conda-env/test-env.yml @@ -0,0 +1,11 @@ +name: test-env +dependencies: + - pip + - coverage + - coveralls + - pytest + - pytest-cov + - pytest-timeout + - numpy + - matplotlib + - scipy \ No newline at end of file diff --git a/.github/workflows/python-package-conda.yml b/.github/workflows/python-package-conda.yml index 5db9239a3..0953f40c3 100644 --- a/.github/workflows/python-package-conda.yml +++ b/.github/workflows/python-package-conda.yml @@ -5,10 +5,10 @@ on: [push, pull_request] jobs: test-linux: name: > - Python ${{ matrix.python-version }}; - ${{ matrix.slycot || 'without' }} Slycot; - ${{ matrix.pandas || 'without' }} Pandas; - ${{ matrix.cvxopt || 'without' }} CVXOPT; + Py${{ matrix.python-version }}; + ${{ matrix.slycot || 'no' }} Slycot; + ${{ matrix.pandas || 'no' }} Pandas; + ${{ matrix.cvxopt || 'no' }} CVXOPT; ${{ matrix.array-and-matrix == 1 && '; array and matrix' || '' }} runs-on: ubuntu-latest @@ -27,43 +27,41 @@ jobs: array-and-matrix: 1 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - - name: Install dependencies - run: | - # Set up conda - echo $CONDA/bin >> $GITHUB_PATH - conda create -q -n test-environment python=${{matrix.python-version}} - source $CONDA/bin/activate test-environment - - # Set up (virtual) X11 - sudo apt install -y xvfb + - name: Set up (virtual) X11 + run: sudo apt install -y xvfb - # Install test tools - conda install pip coverage pytest pytest-timeout - pip install coveralls + - name: Setup Conda + uses: conda-incubator/setup-miniconda@v2 + with: + python-version: ${{ matrix.python-version }} + activate-environment: test-env + environment-file: .github/conda-env/test-env.yml + miniforge-version: latest + miniforge-variant: Mambaforge + channels: conda-forge + channel-priority: strict + auto-update-conda: false + auto-activate-base: false - # Install python-control dependencies - conda install numpy matplotlib scipy + - name: Install optional dependencies + run: | if [[ '${{matrix.slycot}}' == 'conda' ]]; then - conda install -c conda-forge slycot + mamba install slycot fi if [[ '${{matrix.pandas}}' == 'conda' ]]; then - conda install pandas + mamba install pandas fi if [[ '${{matrix.cvxopt}}' == 'conda' ]]; then - conda install -c conda-forge cvxopt + mamba install cvxopt fi - name: Test with pytest env: PYTHON_CONTROL_ARRAY_AND_MATRIX: ${{ matrix.array-and-matrix }} run: | - source $CONDA/bin/activate test-environment - # Use xvfb-run instead of pytest-xvfb to get proper mpl backend - # Use coverage instead of pytest-cov to get .coverage file - # See https://github.com/python-control/python-control/pull/504 - xvfb-run --auto-servernum coverage run -m pytest control/tests + xvfb-run --auto-servernum pytest --cov=control --cov-config=.coveragerc control/tests - name: Coveralls parallel # https://github.com/coverallsapp/github-action From afb1ce08161782b3f50a563048d4775d191c793d Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Sat, 6 Aug 2022 00:38:16 +0200 Subject: [PATCH 031/157] fix conda enviroment shell --- .github/workflows/python-package-conda.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/python-package-conda.yml b/.github/workflows/python-package-conda.yml index 0953f40c3..4e287b45a 100644 --- a/.github/workflows/python-package-conda.yml +++ b/.github/workflows/python-package-conda.yml @@ -46,6 +46,7 @@ jobs: auto-activate-base: false - name: Install optional dependencies + shell: bash -l {0} run: | if [[ '${{matrix.slycot}}' == 'conda' ]]; then mamba install slycot @@ -58,6 +59,7 @@ jobs: fi - name: Test with pytest + shell: bash -l {0} env: PYTHON_CONTROL_ARRAY_AND_MATRIX: ${{ matrix.array-and-matrix }} run: | From f17c9194456b659c390c0190d14d2c38f5bc932c Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Wed, 3 Aug 2022 21:34:27 -0700 Subject: [PATCH 032/157] fix timebase bug in InterconnectedSystem (issue #754) --- control/iosys.py | 7 +++++-- control/tests/timebase_test.py | 37 ++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 control/tests/timebase_test.py diff --git a/control/iosys.py b/control/iosys.py index 6008cf43d..ce717c0fb 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -871,9 +871,12 @@ def __init__(self, syslist, connections=[], inplist=[], outlist=[], if not isinstance(outlist, (list, tuple)): outlist = [outlist] - # Process keyword arguments + # Check if dt argument was given; if not, pull from systems + dt = kwargs.pop('dt', None) + + # Process keyword arguments (except dt) defaults = {'inputs': len(inplist), 'outputs': len(outlist)} - name, inputs, outputs, states, dt = _process_namedio_keywords( + name, inputs, outputs, states, _ = _process_namedio_keywords( kwargs, defaults, end=True) # Initialize the system list and index diff --git a/control/tests/timebase_test.py b/control/tests/timebase_test.py new file mode 100644 index 000000000..50d69fb88 --- /dev/null +++ b/control/tests/timebase_test.py @@ -0,0 +1,37 @@ +import pytest +import inspect +import numpy as np +import control as ct + +@pytest.mark.parametrize( + "dt1, dt2, dt3", [ + (0, 0, 0), + (0, 0.1, ValueError), + (0, None, 0), + (0.1, 0, ValueError), + (0.1, 0.1, 0.1), + (0.1, None, 0.1), + (None, 0, 0), + (None, 0.1, 0.1), + (None, None, None), + (0.2, None, 0.2), + (0.2, 0.1, ValueError), + ]) +@pytest.mark.parametrize("op", [ct.series, ct.parallel, ct.feedback]) +@pytest.mark.parametrize("type", [ct.StateSpace, ct.ss, ct.tf]) +def test_composition(dt1, dt2, dt3, op, type): + # Define the system + A, B, C, D = [[1, 1], [0, 1]], [[0], [1]], [[1, 0]], 0 + sys1 = ct.StateSpace(A, B, C, D, dt1) + sys2 = ct.StateSpace(A, B, C, D, dt2) + + # Convert to the desired form + sys1 = type(sys1) + sys2 = type(sys2) + + if inspect.isclass(dt3) and issubclass(dt3, Exception): + with pytest.raises(dt3, match="incompatible timebases"): + sys3 = op(sys1, sys2) + else: + sys3 = op(sys1, sys2) + assert sys3.dt == dt3 From 3c13f2c58eb17f37a95551a5171dc7c9ad45bd6b Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Wed, 3 Aug 2022 21:54:06 -0700 Subject: [PATCH 033/157] add additional checks for overriding timebase --- control/tests/timebase_test.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/control/tests/timebase_test.py b/control/tests/timebase_test.py index 50d69fb88..f245f625f 100644 --- a/control/tests/timebase_test.py +++ b/control/tests/timebase_test.py @@ -35,3 +35,28 @@ def test_composition(dt1, dt2, dt3, op, type): else: sys3 = op(sys1, sys2) assert sys3.dt == dt3 + + +@pytest.mark.parametrize("dt", [None, 0, 0.1]) +def test_composition_override(dt): + # Define the system + A, B, C, D = [[1, 1], [0, 1]], [[0], [1]], [[1, 0]], 0 + sys1 = ct.ss(A, B, C, D, None, inputs='u1', outputs='y1') + sys2 = ct.ss(A, B, C, D, None, inputs='y1', outputs='y2') + + # Show that we can override the type + sys3 = ct.interconnect([sys1, sys2], inputs='u1', outputs='y2', dt=dt) + assert sys3.dt == dt + + # Overriding the type with an inconsistent type generates an error + sys1 = ct.StateSpace(A, B, C, D, 0.1, inputs='u1', outputs='y1') + if dt != 0.1 and dt is not None: + with pytest.raises(ValueError, match="incompatible timebases"): + sys3 = ct.interconnect( + [sys1, sys2], inputs='u1', outputs='y2', dt=dt) + + sys1 = ct.StateSpace(A, B, C, D, 0, inputs='u1', outputs='y1') + if dt != 0 and dt is not None: + with pytest.raises(ValueError, match="incompatible timebases"): + sys3 = ct.interconnect( + [sys1, sys2], inputs='u1', outputs='y2', dt=dt) From cf304794745baf4aa246df30feccdc3b0c43a490 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Wed, 3 Aug 2022 22:56:47 -0700 Subject: [PATCH 034/157] added tests for dt=True case per @sawyerbfuller suggestion --- control/tests/timebase_test.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/control/tests/timebase_test.py b/control/tests/timebase_test.py index f245f625f..a391d2fe7 100644 --- a/control/tests/timebase_test.py +++ b/control/tests/timebase_test.py @@ -8,12 +8,19 @@ (0, 0, 0), (0, 0.1, ValueError), (0, None, 0), + (0, True, ValueError), (0.1, 0, ValueError), (0.1, 0.1, 0.1), (0.1, None, 0.1), + (0.1, True, 0.1), (None, 0, 0), (None, 0.1, 0.1), (None, None, None), + (None, True, True), + (True, 0, ValueError), + (True, 0.1, 0.1), + (True, None, True), + (True, True, True), (0.2, None, 0.2), (0.2, 0.1, ValueError), ]) From c54097022899eaf51d8feb29637b26e663de88d1 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 19 Aug 2022 17:52:13 -0700 Subject: [PATCH 035/157] fix issue with slycot balred change in state --- control/tests/modelsimp_test.py | 48 +++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/control/tests/modelsimp_test.py b/control/tests/modelsimp_test.py index 70e94dd91..0746e3fe2 100644 --- a/control/tests/modelsimp_test.py +++ b/control/tests/modelsimp_test.py @@ -182,17 +182,33 @@ def testBalredTruncate(self, matarrayin): B = matarrayin([[2.], [0.], [0.], [0.]]) C = matarrayin([[0.5, 0.6875, 0.7031, 0.5]]) D = matarrayin([[0.]]) + sys = StateSpace(A, B, C, D) orders = 2 rsys = balred(sys, orders, method='truncate') + Ar, Br, Cr, Dr = rsys.A, rsys.B, rsys.C, rsys.D + + # Result from MATLAB Artrue = np.array([[-1.958, -1.194], [-1.194, -0.8344]]) Brtrue = np.array([[0.9057], [0.4068]]) Crtrue = np.array([[0.9057, 0.4068]]) Drtrue = np.array([[0.]]) - np.testing.assert_array_almost_equal(rsys.A, Artrue, decimal=2) - np.testing.assert_array_almost_equal(rsys.B, Brtrue, decimal=4) - np.testing.assert_array_almost_equal(rsys.C, Crtrue, decimal=4) - np.testing.assert_array_almost_equal(rsys.D, Drtrue, decimal=4) + + # Look for possible changes in state in slycot + T1 = np.array([[1, 0], [0, -1]]) + T2 = np.array([[-1, 0], [0, 1]]) + T3 = np.array([[0, 1], [1, 0]]) + for T in (T1, T2, T3): + if np.allclose(T @ Ar @ T, Artrue, atol=1e-2, rtol=1e-2): + # Apply a similarity transformation + Ar, Br, Cr = T @ Ar @ T, T @ Br, Cr @ T + break + + # Make sure we got the correct answer + np.testing.assert_array_almost_equal(Ar, Artrue, decimal=2) + np.testing.assert_array_almost_equal(Br, Brtrue, decimal=4) + np.testing.assert_array_almost_equal(Cr, Crtrue, decimal=4) + np.testing.assert_array_almost_equal(Dr, Drtrue, decimal=4) @slycotonly def testBalredMatchDC(self, matarrayin): @@ -207,16 +223,32 @@ def testBalredMatchDC(self, matarrayin): B = matarrayin([[2.], [0.], [0.], [0.]]) C = matarrayin([[0.5, 0.6875, 0.7031, 0.5]]) D = matarrayin([[0.]]) + sys = StateSpace(A, B, C, D) orders = 2 rsys = balred(sys,orders,method='matchdc') + Ar, Br, Cr, Dr = rsys.A, rsys.B, rsys.C, rsys.D + + # Result from MATLAB Artrue = np.array( [[-4.43094773, -4.55232904], [-4.55232904, -5.36195206]]) Brtrue = np.array([[1.36235673], [1.03114388]]) Crtrue = np.array([[1.36235673, 1.03114388]]) Drtrue = np.array([[-0.08383902]]) - np.testing.assert_array_almost_equal(rsys.A, Artrue, decimal=2) - np.testing.assert_array_almost_equal(rsys.B, Brtrue, decimal=4) - np.testing.assert_array_almost_equal(rsys.C, Crtrue, decimal=4) - np.testing.assert_array_almost_equal(rsys.D, Drtrue, decimal=4) + + # Look for possible changes in state in slycot + T1 = np.array([[1, 0], [0, -1]]) + T2 = np.array([[-1, 0], [0, 1]]) + T3 = np.array([[0, 1], [1, 0]]) + for T in (T1, T2, T3): + if np.allclose(T @ Ar @ T, Artrue, atol=1e-2, rtol=1e-2): + # Apply a similarity transformation + Ar, Br, Cr = T @ Ar @ T, T @ Br, Cr @ T + break + + # Make sure we got the correct answer + np.testing.assert_array_almost_equal(Ar, Artrue, decimal=2) + np.testing.assert_array_almost_equal(Br, Brtrue, decimal=4) + np.testing.assert_array_almost_equal(Cr, Crtrue, decimal=4) + np.testing.assert_array_almost_equal(Dr, Drtrue, decimal=4) From 9dbfa18df42276ccf45eecb51680dc97430220ce Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Sat, 6 Aug 2022 10:13:48 +0200 Subject: [PATCH 036/157] pip install, run examples and notebooks in conda-forge base environment --- .github/workflows/install_examples.yml | 29 ++++++++++++-------------- setup.py | 2 +- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/.github/workflows/install_examples.yml b/.github/workflows/install_examples.yml index b36ff3e7f..b1e7adb3c 100644 --- a/.github/workflows/install_examples.yml +++ b/.github/workflows/install_examples.yml @@ -1,4 +1,4 @@ -name: setup.py, examples +name: Setup, Examples, Notebooks on: [push, pull_request] @@ -7,26 +7,23 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 - - name: Install Python dependencies + - uses: actions/checkout@v3 + - name: Install Python dependencies from conda-forge run: | - # Set up conda + # Set up conda using the preinstalled GHA conda environment echo $CONDA/bin >> $GITHUB_PATH + conda config --add channels conda-forge + conda config --set channel_priority strict - # Set up (virtual) X11 - sudo apt install -y xvfb + # Install build tools + conda install pip setuptools setuptools-scm - # Install test tools - conda install pip pytest + # Install python-control dependencies and extras + conda install numpy matplotlib scipy + conda install slycot pmw jupyter - # Install python-control dependencies - conda install numpy matplotlib scipy jupyter - conda install -c conda-forge slycot pmw - - - name: Install with setup.py - run: python setup.py install + - name: Install from source + run: pip install . - name: Run examples run: | diff --git a/setup.py b/setup.py index 2021d5eb9..45db2341b 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ exec(fd.read(), ver) version = ver.get('__version__', 'dev') except IOError: - version = 'dev' + version = '0.0.0dev' with open('README.rst') as fp: long_description = fp.read() From 3aeac6189227e4b97a868da33220ff28ab1a965c Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Sat, 6 Aug 2022 10:12:06 +0200 Subject: [PATCH 037/157] Build Slycot from source using PyPI wheels --- .github/workflows/control-slycot-src.yml | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/.github/workflows/control-slycot-src.yml b/.github/workflows/control-slycot-src.yml index ffbeca3f9..f42de8802 100644 --- a/.github/workflows/control-slycot-src.yml +++ b/.github/workflows/control-slycot-src.yml @@ -15,17 +15,14 @@ jobs: uses: actions/setup-python@v2 - name: Install Python dependencies run: | - # Set up conda - echo $CONDA/bin >> $GITHUB_PATH - # Set up (virtual) X11 sudo apt install -y xvfb # Install test tools - conda install pip pytest pytest-timeout + pip install pytest pytest-timeout # Install python-control dependencies - conda install numpy matplotlib scipy + pip install numpy matplotlib scipy - name: Checkout Slycot uses: actions/checkout@v3 @@ -43,10 +40,9 @@ jobs: # Install compilers, libraries, and development environment sudo apt-get -y install gfortran cmake --fix-missing sudo apt-get -y install libblas-dev liblapack-dev - conda install -c conda-forge scikit-build setuptools-scm # Compile and install slycot - pip install -v --no-build-isolation --no-deps . + pip install -v . - name: Test with pytest working-directory: python-control From 8c963bb306d0039a04c7f130bdc4c1ae73e703cd Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Sat, 6 Aug 2022 14:05:18 +0200 Subject: [PATCH 038/157] Modernize build system with PEP517 --- README.rst | 15 +++++-------- make_version.py | 58 ------------------------------------------------- pyproject.toml | 58 +++++++++++++++++++++++++++++++++++++++++++++++++ setup.cfg | 7 ------ setup.py | 52 -------------------------------------------- 5 files changed, 64 insertions(+), 126 deletions(-) delete mode 100644 make_version.py create mode 100644 pyproject.toml delete mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/README.rst b/README.rst index f1feda7c5..7e2058293 100644 --- a/README.rst +++ b/README.rst @@ -97,17 +97,14 @@ To install using pip:: If you install Slycot using pip you'll need a development environment (e.g., Python development files, C and Fortran compilers). -Distutils ---------- +Installing from source +---------------------- -To install in your home directory, use:: +To install from source, get the source code of the desired branch or release +from the github repository or archive, unpack, and run from within the +toplevel `python-control` directory:: - python setup.py install --user - -To install for all users (on Linux or Mac OS):: - - python setup.py build - sudo python setup.py install + pip install . Development diff --git a/make_version.py b/make_version.py deleted file mode 100644 index 356f4d747..000000000 --- a/make_version.py +++ /dev/null @@ -1,58 +0,0 @@ -# make_version.py - generate version information -# -# Author: Clancy Rowley -# Date: 2 Apr 2015 -# Modified: Richard M. Murray, 28 Dec 2017 -# -# This script is used to create the version information for the python- -# control package. The version information is now generated directly from -# tags in the git repository. Now, *before* running setup.py, one runs -# -# python make_version.py -# -# and this generates a file with the version information. This is copied -# from binstar (https://github.com/Binstar/binstar) and seems to work well. -# -# The original version of this script also created version information for -# conda, but this stopped working when conda v3 was released. Instead, we -# now use jinja templates in conda-recipe to create the conda information. -# The current version information is used in setup.py, control/__init__.py, -# and doc/conf.py (for sphinx). - -from subprocess import check_output -import os - -def main(): - cmd = 'git describe --always --long' - # describe --long usually outputs "tag-numberofcommits-commitname" - output = check_output(cmd.split()).decode('utf-8').strip().rsplit('-',2) - if len(output) == 3: - version, build, commit = output - else: - # If the clone is shallow, describe's output won't have tag and - # number of commits. This is a particular issue on Travis-CI, - # which by default clones with a depth of 50. - # This behaviour isn't well documented in git-describe docs, - # but see, e.g., https://stackoverflow.com/a/36389573/1008142 - # and https://github.com/travis-ci/travis-ci/issues/3412 - version = 'unknown' - build = 'unknown' - # we don't ever expect just one dash from describe --long, but - # just in case: - commit = '-'.join(output) - - print("Version: %s" % version) - print("Build: %s" % build) - print("Commit: %s\n" % commit) - - filename = "control/_version.py" - print("Writing %s" % filename) - with open(filename, 'w') as fd: - if build == '0': - fd.write('__version__ = "%s"\n' % (version)) - else: - fd.write('__version__ = "%s.post%s"\n' % (version, build)) - fd.write('__commit__ = "%s"\n' % (commit)) - -if __name__ == '__main__': - main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..89690ac8d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,58 @@ +[build-system] +requires = [ + "setuptools", + "setuptools-scm", + "wheel" +] +build-backend = "setuptools.build_meta" + +[project] +name = "control" +description = "Python Control Systems Library" +authors = [{name = "Python Control Developers", email = "python-control-developers@lists.sourceforge.net"}] +license = {text = "BSD-3-Clause"} +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Topic :: Software Development", + "Topic :: Scientific/Engineering", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Operating System :: Unix", + "Operating System :: MacOS", +] +requires-python = ">=3.7" +dependencies = [ + "numpy", + "scipy>=1.3", + "matplotlib", +] +dynamic = ["version"] + +[tool.setuptools] +packages = ["control"] + +[project.optional-dependencies] +test = ["pytest", "pytest-timeout"] +slycot = [ "slycot>=0.4.0" ] +cvxopt = [ "cvxopt>=1.2.0" ] + +[project.urls] +homepage = "https//python-control.org" +source = "https://github.com/python-control/python-control" + +[tool.setuptools_scm] +write_to = "control/_version.py" + +[tool.pytest.ini_options] +addopts = "-ra" +filterwarnings = [ + "error:.*matrix subclass:PendingDeprecationWarning", +] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 5b1ce28a7..000000000 --- a/setup.cfg +++ /dev/null @@ -1,7 +0,0 @@ -[bdist_wheel] -universal=1 - -[tool:pytest] -addopts = -ra -filterwarnings = - error:.*matrix subclass:PendingDeprecationWarning diff --git a/setup.py b/setup.py deleted file mode 100644 index 45db2341b..000000000 --- a/setup.py +++ /dev/null @@ -1,52 +0,0 @@ -from setuptools import setup, find_packages - -ver = {} -try: - with open('control/_version.py') as fd: - exec(fd.read(), ver) - version = ver.get('__version__', 'dev') -except IOError: - version = '0.0.0dev' - -with open('README.rst') as fp: - long_description = fp.read() - -CLASSIFIERS = """ -Development Status :: 3 - Alpha -Intended Audience :: Science/Research -Intended Audience :: Developers -License :: OSI Approved :: BSD License -Programming Language :: Python :: 3 -Programming Language :: Python :: 3.7 -Programming Language :: Python :: 3.8 -Programming Language :: Python :: 3.9 -Topic :: Software Development -Topic :: Scientific/Engineering -Operating System :: Microsoft :: Windows -Operating System :: POSIX -Operating System :: Unix -Operating System :: MacOS -""" - -setup( - name='control', - version=version, - author='Python Control Developers', - author_email='python-control-developers@lists.sourceforge.net', - url='http://python-control.org', - project_urls={ - 'Source': 'https://github.com/python-control/python-control', - }, - description='Python Control Systems Library', - long_description=long_description, - packages=find_packages(exclude=['benchmarks']), - classifiers=[f for f in CLASSIFIERS.split('\n') if f], - install_requires=['numpy', - 'scipy>=1.3', - 'matplotlib'], - extras_require={ - 'test': ['pytest', 'pytest-timeout'], - 'slycot': [ 'slycot>=0.4.0' ], - 'cvxopt': [ 'cvxopt>=1.2.0' ] - } -) From bfb591b702986fe5e5f3b8aba9cb8c6c17755373 Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Sat, 6 Aug 2022 10:17:03 +0200 Subject: [PATCH 039/157] Bump upper tested version to Python 3.10 --- .github/conda-env/test-env.yml | 2 +- .github/workflows/python-package-conda.yml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/conda-env/test-env.yml b/.github/conda-env/test-env.yml index cc91a1ade..adaf685cf 100644 --- a/.github/conda-env/test-env.yml +++ b/.github/conda-env/test-env.yml @@ -8,4 +8,4 @@ dependencies: - pytest-timeout - numpy - matplotlib - - scipy \ No newline at end of file + - scipy diff --git a/.github/workflows/python-package-conda.yml b/.github/workflows/python-package-conda.yml index 4e287b45a..c2bf21bce 100644 --- a/.github/workflows/python-package-conda.yml +++ b/.github/workflows/python-package-conda.yml @@ -8,20 +8,20 @@ jobs: Py${{ matrix.python-version }}; ${{ matrix.slycot || 'no' }} Slycot; ${{ matrix.pandas || 'no' }} Pandas; - ${{ matrix.cvxopt || 'no' }} CVXOPT; + ${{ matrix.cvxopt || 'no' }} CVXOPT ${{ matrix.array-and-matrix == 1 && '; array and matrix' || '' }} runs-on: ubuntu-latest strategy: max-parallel: 5 matrix: - python-version: [3.7, 3.9] + python-version: ['3.7', '3.10'] slycot: ["", "conda"] pandas: [""] cvxopt: ["", "conda"] array-and-matrix: [0] include: - - python-version: 3.9 + - python-version: '3.10' slycot: conda pandas: conda array-and-matrix: 1 @@ -55,7 +55,7 @@ jobs: mamba install pandas fi if [[ '${{matrix.cvxopt}}' == 'conda' ]]; then - mamba install cvxopt + mamba install cvxopt fi - name: Test with pytest From 9fc6005f3de9e910fc5c61ce156cf40160b1ee85 Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Sat, 20 Aug 2022 12:48:38 +0200 Subject: [PATCH 040/157] don't fail fast --- .github/workflows/python-package-conda.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/python-package-conda.yml b/.github/workflows/python-package-conda.yml index c2bf21bce..fb924d39c 100644 --- a/.github/workflows/python-package-conda.yml +++ b/.github/workflows/python-package-conda.yml @@ -14,6 +14,7 @@ jobs: strategy: max-parallel: 5 + fail-fast: false matrix: python-version: ['3.7', '3.10'] slycot: ["", "conda"] From e8fd49e3bca517959a1532555253cf69a184eaf5 Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Sat, 6 Aug 2022 12:00:57 +0200 Subject: [PATCH 041/157] Use pytest-xvfb in conda and enforce QtAgg only in one run --- .github/conda-env/test-env.yml | 1 + .github/workflows/control-slycot-src.yml | 14 +++----------- .github/workflows/install_examples.yml | 2 +- .github/workflows/python-package-conda.yml | 11 ++++++----- control/tests/rlocus_test.py | 2 ++ control/tests/sisotool_test.py | 4 ++++ 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.github/conda-env/test-env.yml b/.github/conda-env/test-env.yml index adaf685cf..a4944f768 100644 --- a/.github/conda-env/test-env.yml +++ b/.github/conda-env/test-env.yml @@ -6,6 +6,7 @@ dependencies: - pytest - pytest-cov - pytest-timeout + - pytest-xvfb - numpy - matplotlib - scipy diff --git a/.github/workflows/control-slycot-src.yml b/.github/workflows/control-slycot-src.yml index f42de8802..2ce2a11dd 100644 --- a/.github/workflows/control-slycot-src.yml +++ b/.github/workflows/control-slycot-src.yml @@ -13,16 +13,8 @@ jobs: path: python-control - name: Set up Python uses: actions/setup-python@v2 - - name: Install Python dependencies - run: | - # Set up (virtual) X11 - sudo apt install -y xvfb - - # Install test tools - pip install pytest pytest-timeout - - # Install python-control dependencies - pip install numpy matplotlib scipy + - name: Install Python dependencies and test tools + run: pip install -v -e './python-control[test]' - name: Checkout Slycot uses: actions/checkout@v3 @@ -46,4 +38,4 @@ jobs: - name: Test with pytest working-directory: python-control - run: xvfb-run --auto-servernum pytest control/tests + run: pytest -v control/tests diff --git a/.github/workflows/install_examples.yml b/.github/workflows/install_examples.yml index b1e7adb3c..84cd706f5 100644 --- a/.github/workflows/install_examples.yml +++ b/.github/workflows/install_examples.yml @@ -10,7 +10,7 @@ jobs: - uses: actions/checkout@v3 - name: Install Python dependencies from conda-forge run: | - # Set up conda using the preinstalled GHA conda environment + # Set up conda using the preinstalled GHA Miniconda environment echo $CONDA/bin >> $GITHUB_PATH conda config --add channels conda-forge conda config --set channel_priority strict diff --git a/.github/workflows/python-package-conda.yml b/.github/workflows/python-package-conda.yml index fb924d39c..ae889fd05 100644 --- a/.github/workflows/python-package-conda.yml +++ b/.github/workflows/python-package-conda.yml @@ -10,6 +10,7 @@ jobs: ${{ matrix.pandas || 'no' }} Pandas; ${{ matrix.cvxopt || 'no' }} CVXOPT ${{ matrix.array-and-matrix == 1 && '; array and matrix' || '' }} + ${{ matrix.mplbackend && format('; {0}', matrix.mplbackend) }} runs-on: ubuntu-latest strategy: @@ -20,19 +21,19 @@ jobs: slycot: ["", "conda"] pandas: [""] cvxopt: ["", "conda"] + mplbackend: [""] array-and-matrix: [0] include: - python-version: '3.10' slycot: conda pandas: conda + cvxopt: conda + mplbackend: QtAgg array-and-matrix: 1 steps: - uses: actions/checkout@v3 - - name: Set up (virtual) X11 - run: sudo apt install -y xvfb - - name: Setup Conda uses: conda-incubator/setup-miniconda@v2 with: @@ -63,8 +64,8 @@ jobs: shell: bash -l {0} env: PYTHON_CONTROL_ARRAY_AND_MATRIX: ${{ matrix.array-and-matrix }} - run: | - xvfb-run --auto-servernum pytest --cov=control --cov-config=.coveragerc control/tests + MPLBACKEND: ${{ matrix.mplbackend }} + run: pytest -v --cov=control --cov-config=.coveragerc control/tests - name: Coveralls parallel # https://github.com/coverallsapp/github-action diff --git a/control/tests/rlocus_test.py b/control/tests/rlocus_test.py index a0ecebb15..4fbe70c4f 100644 --- a/control/tests/rlocus_test.py +++ b/control/tests/rlocus_test.py @@ -85,6 +85,8 @@ def test_root_locus_neg_false_gain_nonproper(self): # TODO: cover and validate negative false_gain branch in _default_gains() + @pytest.mark.skipif(plt.get_current_fig_manager().toolbar is None, + reason="Requires the zoom toolbar") def test_root_locus_zoom(self): """Check the zooming functionality of the Root locus plot""" system = TransferFunction([1000], [1, 25, 100, 0]) diff --git a/control/tests/sisotool_test.py b/control/tests/sisotool_test.py index d5e9dd013..a1f468eea 100644 --- a/control/tests/sisotool_test.py +++ b/control/tests/sisotool_test.py @@ -46,6 +46,8 @@ def sys221(self): D221 = [[1., -1.]] return StateSpace(A222, B222, C221, D221) + @pytest.mark.skipif(plt.get_current_fig_manager().toolbar is None, + reason="Requires the zoom toolbar") def test_sisotool(self, tsys): sisotool(tsys, Hz=False) fig = plt.gcf() @@ -114,6 +116,8 @@ def test_sisotool(self, tsys): assert_array_almost_equal( ax_step.lines[0].get_data()[1][:10], step_response_moved, 4) + @pytest.mark.skipif(plt.get_current_fig_manager().toolbar is None, + reason="Requires the zoom toolbar") @pytest.mark.parametrize('tsys', [0, True], indirect=True, ids=['ctime', 'dtime']) def test_sisotool_tvect(self, tsys): From 69fb34a462015193d514ea881001b503553f2493 Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Sat, 20 Aug 2022 12:03:36 +0200 Subject: [PATCH 042/157] Replace mplcleanup decorator use by fixture --- control/tests/config_test.py | 20 ++++++-------------- control/tests/conftest.py | 14 ++++++-------- 2 files changed, 12 insertions(+), 22 deletions(-) diff --git a/control/tests/config_test.py b/control/tests/config_test.py index 295c68bdd..c36f67280 100644 --- a/control/tests/config_test.py +++ b/control/tests/config_test.py @@ -8,7 +8,6 @@ from math import pi, log10 import matplotlib.pyplot as plt -from matplotlib.testing.decorators import cleanup as mplcleanup import numpy as np import pytest @@ -18,7 +17,6 @@ @pytest.mark.usefixtures("editsdefaults") # makes sure to reset the defaults # to the test configuration class TestConfig: - # Create a simple second order system to use for testing sys = ct.tf([10], [1, 2, 1]) @@ -28,8 +26,7 @@ def test_set_defaults(self): assert ct.config.defaults['freqplot.deg'] == 2 assert ct.config.defaults['freqplot.Hz'] is None - @mplcleanup - def test_get_param(self): + def test_get_param(self, mplcleanup): assert ct.config._get_param('freqplot', 'dB')\ == ct.config.defaults['freqplot.dB'] assert ct.config._get_param('freqplot', 'dB', 1) == 1 @@ -92,8 +89,7 @@ def test_default_deprecation(self): assert ct.config.defaults['bode.Hz'] \ == ct.config.defaults['freqplot.Hz'] - @mplcleanup - def test_fbs_bode(self): + def test_fbs_bode(self, mplcleanup): ct.use_fbs_defaults() # Generate a Bode plot @@ -137,8 +133,7 @@ def test_fbs_bode(self): phase_x, phase_y = (((plt.gcf().axes[1]).get_lines())[0]).get_data() np.testing.assert_almost_equal(phase_y[-1], -pi, decimal=2) - @mplcleanup - def test_matlab_bode(self): + def test_matlab_bode(self, mplcleanup): ct.use_matlab_defaults() # Generate a Bode plot @@ -182,8 +177,7 @@ def test_matlab_bode(self): phase_x, phase_y = (((plt.gcf().axes[1]).get_lines())[0]).get_data() np.testing.assert_almost_equal(phase_y[-1], -pi, decimal=2) - @mplcleanup - def test_custom_bode_default(self): + def test_custom_bode_default(self, mplcleanup): ct.config.defaults['freqplot.dB'] = True ct.config.defaults['freqplot.deg'] = True ct.config.defaults['freqplot.Hz'] = True @@ -204,8 +198,7 @@ def test_custom_bode_default(self): np.testing.assert_almost_equal(mag_y[0], 20*log10(10), decimal=3) np.testing.assert_almost_equal(phase_y[-1], -pi, decimal=2) - @mplcleanup - def test_bode_number_of_samples(self): + def test_bode_number_of_samples(self, mplcleanup): # Set the number of samples (default is 50, from np.logspace) mag_ret, phase_ret, omega_ret = ct.bode_plot(self.sys, omega_num=87) assert len(mag_ret) == 87 @@ -219,8 +212,7 @@ def test_bode_number_of_samples(self): mag_ret, phase_ret, omega_ret = ct.bode_plot(self.sys, omega_num=87) assert len(mag_ret) == 87 - @mplcleanup - def test_bode_feature_periphery_decade(self): + def test_bode_feature_periphery_decade(self, mplcleanup): # Generate a sample Bode plot to figure out the range it uses ct.reset_defaults() # Make sure starting state is correct mag_ret, phase_ret, omega_ret = ct.bode_plot(self.sys, Hz=False) diff --git a/control/tests/conftest.py b/control/tests/conftest.py index 1201b8746..3f798f26c 100644 --- a/control/tests/conftest.py +++ b/control/tests/conftest.py @@ -1,13 +1,11 @@ """conftest.py - pytest local plugins and fixtures""" import os -import sys from contextlib import contextmanager import matplotlib as mpl import numpy as np import pytest -import scipy as sp import control @@ -45,7 +43,7 @@ def control_defaults(): params=[pytest.param("arrayout", marks=matrixerrorfilter), pytest.param("matrixout", marks=matrixfilter)]) def matarrayout(request): - """Switch the config to use np.ndarray and np.matrix as returns""" + """Switch the config to use np.ndarray and np.matrix as returns.""" restore = control.config.defaults['statesp.use_numpy_matrix'] control.use_numpy_matrix(request.param == "matrixout", warn=False) yield @@ -53,7 +51,7 @@ def matarrayout(request): def ismatarrayout(obj): - """Test if the returned object has the correct type as configured + """Test if the returned object has the correct type as configured. note that isinstance(np.matrix(obj), np.ndarray) is True """ @@ -63,7 +61,7 @@ def ismatarrayout(obj): def asmatarrayout(obj): - """Return a object according to the configured default""" + """Return a object according to the configured default.""" use_matrix = control.config.defaults['statesp.use_numpy_matrix'] matarray = np.asmatrix if use_matrix else np.asarray return matarray(obj) @@ -71,7 +69,7 @@ def asmatarrayout(obj): @contextmanager def check_deprecated_matrix(): - """Check that a call produces a deprecation warning because of np.matrix""" + """Check that a call produces a deprecation warning because of np.matrix.""" use_matrix = control.config.defaults['statesp.use_numpy_matrix'] if use_matrix: with pytest.deprecated_call(): @@ -94,13 +92,13 @@ def check_deprecated_matrix(): False)] if usebydefault or TEST_MATRIX_AND_ARRAY]) def matarrayin(request): - """Use array and matrix to construct input data in tests""" + """Use array and matrix to construct input data in tests.""" return request.param @pytest.fixture(scope="function") def editsdefaults(): - """Make sure any changes to the defaults only last during a test""" + """Make sure any changes to the defaults only last during a test.""" restore = control.config.defaults.copy() yield control.config.defaults = restore.copy() From 3dcad07abdfe694fc6fbe480a3cab574e0abf3e9 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Thu, 4 Aug 2022 08:13:16 -0700 Subject: [PATCH 043/157] initial bspline implementation --- control/flatsys/__init__.py | 1 + control/flatsys/basis.py | 5 + control/flatsys/bspline.py | 198 ++++++++++++++++++++++++++++++++++ control/flatsys/flatsys.py | 4 + control/tests/bspline_test.py | 181 +++++++++++++++++++++++++++++++ control/tests/flatsys_test.py | 15 +-- 6 files changed, 398 insertions(+), 6 deletions(-) create mode 100644 control/flatsys/bspline.py create mode 100644 control/tests/bspline_test.py diff --git a/control/flatsys/__init__.py b/control/flatsys/__init__.py index 0926fa81a..6e8eac030 100644 --- a/control/flatsys/__init__.py +++ b/control/flatsys/__init__.py @@ -54,6 +54,7 @@ from .basis import BasisFamily from .poly import PolyFamily from .bezier import BezierFamily +from .bspline import BSplineFamily # Classes from .systraj import SystemTrajectory diff --git a/control/flatsys/basis.py b/control/flatsys/basis.py index 1ea957f52..a0986bd61 100644 --- a/control/flatsys/basis.py +++ b/control/flatsys/basis.py @@ -61,5 +61,10 @@ def __call__(self, i, t): """Evaluate the ith basis function at a point in time""" return self.eval_deriv(i, 0, t) + def eval(self, coeffs, tlist): + return [ + sum([coeffs[i] * self(i, t) for i in range(self.N)]) + for t in tlist] + def eval_deriv(self, i, j, t): raise NotImplementedError("Internal error; improper basis functions") diff --git a/control/flatsys/bspline.py b/control/flatsys/bspline.py new file mode 100644 index 000000000..5e3b98cb5 --- /dev/null +++ b/control/flatsys/bspline.py @@ -0,0 +1,198 @@ +# bspline.py - B-spline basis functions +# RMM, 2 Aug 2022 +# +# This class implements a set of B-spline basis functions that implement a +# piecewise polynomial at a set of breakpoints t0, ..., tn with given orders +# and smoothness. +# + +import numpy as np +from .basis import BasisFamily +from scipy.interpolate import BSpline, splev + +class BSplineFamily(BasisFamily): + """B-spline basis functions. + + This class represents a B-spline basis for piecewise polynomials defined + across a set of breakpoints with given order and smoothness. + + """ + def __init__(self, breakpoints, degree, smoothness=None, vars=1): + """Create a B-spline basis for piecewise smooth polynomials + + Define B-spline polynomials for a set of one or more variables. + B-splines are characterized by a set of intervals separated by break + points. On each interval we have a polynomial of a certain order + and the spline is continuous up to a given smoothness at interior + break points. + + Parameters + ---------- + breakpoints : 1D array or 2D array of float + The breakpoints for the spline(s). + + degree : int or list of ints + For each spline variable, the degree of the polynomial between + break points. If a single number is given and more than one + spline variable is specified, the same order is used for each + spline variable. + + smoothness : int or list of ints + For each spline variable, the smoothness at breakpoints (number + of derivatives that should match). + + vars : int or list of str, option + The number of spline variables or a list of spline variable names. + + """ + # Process the breakpoints for the spline */ + breakpoints = np.array(breakpoints, dtype=float) + if breakpoints.ndim == 2: + raise NotImplementedError( + "breakpoints for each spline variable not yet supported") + elif breakpoints.ndim != 1: + raise ValueError("breakpoints must be convertable to a 1D array") + elif breakpoints.size < 2: + raise ValueError("break point vector must have at least 2 values") + elif np.any(np.diff(breakpoints) <= 0): + raise ValueError("break points must be strictly increasing values") + + # Decide on the number of spline variables + if isinstance(vars, list) and all([isinstance(v, str) for v in vars]): + raise NotImplemented("list of variable names not yet supported") + elif not isinstance(vars, int): + raise TypeError("vars must be an integer or list of strings") + else: + nvars = vars + + # + # Process B-spline parameters (order, smoothness) + # + # B-splines are characterized by a set of intervals separated by + # breakpoints. On each interval we have a polynomial of a certain + # order and the spline is continuous up to a given smoothness at + # breakpoints. The code in this section allows some flexibility in + # the way that all of this information is supplied, including using + # scalar values for parameters (which are then broadcast to each + # output) and inferring values and dimensions from other + # information, when possible. + # + + # Utility function for broadcasting spline params (order, smoothness) + def process_spline_parameters( + values, length, allowed_types, minimum=0, + default=None, name='unknown'): + + # Preprocessing + if values is None and default is None: + return None + elif values is None: + values = default + elif isinstance(values, np.ndarray): + # Convert ndarray to list + values = values.tolist() + + # Figure out what type of object we were passed + if isinstance(values, allowed_types): + # Single number of an allowed type => broadcast to list + values = [values for i in range(length)] + elif all([isinstance(v, allowed_types) for v in values]): + # List of values => make sure it is the right size + if len(values) != length: + raise ValueError(f"length of '{name}' does not match n") + else: + raise ValueError(f"could not parse '{name}' keyword") + + # Check to make sure the values are OK + if values is not None and any([val < minimum for val in values]): + raise ValueError( + f"invalid value for {name}; must be at least {minimum}") + + return values + + # Degree of polynomial + degree = process_spline_parameters( + degree, nvars, (int), name='degree', minimum=1) + + # Smoothness at breakpoints; set default to degree - 1 (max possible) + smoothness = process_spline_parameters( + smoothness, nvars, (int), name='smoothness', minimum=0, + default=[d - 1 for d in degree]) + + # Make sure degree is sufficent for the level of smoothness + if any([degree[i] - smoothness[i] < 1 for i in range(nvars)]): + raise ValueError("degree must be greater than smoothness") + + # Store the parameters and process them in call_ntg() + self.nvars = nvars + self.breakpoints = breakpoints + self.degree = degree + self.smoothness = smoothness + self.nintervals = breakpoints.size - 1 + + # + # Compute parameters for a SciPy BSpline object + # + # To create a B-spline, we need to compute the knot points, keeping + # track of the use of repeated knot points at the initial knot and + # final knot as well as repeated knots at intermediate points + # depending on the desired smoothness. + # + + # Store the coefficients for each output (useful later) + self.coef_offset, self.coef_length, offset = [], [], 0 + for i in range(self.nvars): + # Compute number of coefficients for the piecewise polynomial + ncoefs = (self.degree[i] + 1) * (len(self.breakpoints) - 1) - \ + (self.smoothness[i] + 1) * (len(self.breakpoints) - 2) + + self.coef_offset.append(offset) + self.coef_length.append(ncoefs) + offset += ncoefs + self.N = offset # save the total number of coefficients + + # Create knot points for each spline variable + # TODO: extend to multi-dimensional breakpoints + self.knotpoints = [] + for i in range(self.nvars): + # Allocate space for the knotpoints + self.knotpoints.append(np.empty( + (self.degree[i] + 1) + (len(self.breakpoints) - 2) * \ + (self.degree[i] - self.smoothness[i]) + (self.degree[i] + 1))) + + # Initial knot points + self.knotpoints[i][0:self.degree[i] + 1] = self.breakpoints[0] + offset = self.degree[i] + 1 + + # Interior knot points + nknots = self.degree[i] - self.smoothness[i] + assert nknots > 0 # just in case + for j in range(1, self.breakpoints.size - 1): + self.knotpoints[i][offset:offset+nknots] = self.breakpoints[j] + offset += nknots + + # Final knot point + self.knotpoints[i][offset:offset + self.degree[i] + 1] = \ + self.breakpoints[-1] + + def eval(self, coefs, tlist): + return np.array([ + BSpline(self.knotpoints[i], + coefs[self.coef_offset[i]: + self.coef_offset[i] + self.coef_length[i]], + self.degree[i])(tlist) + for i in range(self.nvars)]) + + # Compute the kth derivative of the ith basis function at time t + def eval_deriv(self, i, k, t, squeeze=True): + """Evaluate the kth derivative of the ith basis function at time t.""" + if self.nvars > 1 or not squeeze: + raise NotImplementedError( + "derivatives of multi-variable splines not yet supported") + + # Create a coefficient vector for this spline + coefs = np.zeros(self.coef_length[0]); coefs[i] = 1 + + # Evaluate the derivative of the spline at the desired point in time + return BSpline(self.knotpoints[0], coefs, + self.degree[0]).derivative(k)(t) diff --git a/control/flatsys/flatsys.py b/control/flatsys/flatsys.py index c01eb9127..aa701af4b 100644 --- a/control/flatsys/flatsys.py +++ b/control/flatsys/flatsys.py @@ -413,7 +413,10 @@ def point_to_point( # # Start by solving the least squares problem + # TODO: add warning if rank is too small alpha, residuals, rank, s = np.linalg.lstsq(M, Z, rcond=None) + if rank < Z.size: + warnings.warn("basis too small; solution may not exist") if cost is not None or trajectory_constraints is not None: # Search over the null space to minimize cost/satisfy constraints @@ -425,6 +428,7 @@ def traj_cost(null_coeffs): coeffs = alpha + N @ null_coeffs # Evaluate the costs at the listed time points + # TODO: store Mt ahead of time, since it doesn't change costval = 0 for t in timepts: M_t = _basis_flag_matrix(sys, basis, zflag_T0, t) diff --git a/control/tests/bspline_test.py b/control/tests/bspline_test.py new file mode 100644 index 000000000..b00f856dc --- /dev/null +++ b/control/tests/bspline_test.py @@ -0,0 +1,181 @@ +"""bspline_test.py - test bsplines and their use in flat system + +RMM, 2 Aug 2022 + +This test suite checks to make sure that the bspline basic functions +supporting differential flat systetms are functioning. It doesn't do +exhaustive testing of operations on flat systems. Separate unit tests +should be created for that purpose. + +""" + +import numpy as np +import pytest +import scipy as sp + +import control as ct +import control.flatsys as fs +import control.optimal as opt + +def test_bspline_basis(): + Tf = 10 + degree = 5 + maxderiv = 4 + bspline = fs.BSplineFamily([0, Tf/3, Tf/2, Tf], degree, maxderiv) + time = np.linspace(0, Tf, 100) + + # Make sure that the knotpoint vector looks right + np.testing.assert_equal( + bspline.knotpoints, + [np.array([0, 0, 0, 0, 0, 0, + Tf/3, Tf/2, + Tf, Tf, Tf, Tf, Tf, Tf])]) + + # Repeat with default smoothness + bspline = fs.BSplineFamily([0, Tf/3, Tf/2, Tf], degree) + np.testing.assert_equal( + bspline.knotpoints, + [np.array([0, 0, 0, 0, 0, 0, + Tf/3, Tf/2, + Tf, Tf, Tf, Tf, Tf, Tf])]) + + # Sum of the B-spline curves should be one + np.testing.assert_almost_equal( + 1, sum([bspline(i, time) for i in range(bspline.N)])) + + # Sum of derivatives should be zero + for k in range(1, maxderiv): + np.testing.assert_almost_equal( + 0, sum([bspline.eval_deriv(i, k, time) + for i in range(0, bspline.N)])) + + # Make sure that the second derivative integrates to the first + time = np.linspace(0, Tf, 1000) + dt = time[1] - time[0] + for i in range(bspline.N): + for j in range(1, maxderiv): + np.testing.assert_allclose( + np.diff(bspline.eval_deriv(i, j-1, time)) / dt, + bspline.eval_deriv(i, j, time)[0:-1], + atol=0.01, rtol=0.01) + + # Exception check + with pytest.raises(IndexError, match="out of bounds"): + bspline.eval_deriv(bspline.N, 0, time) + + +@pytest.mark.parametrize( + "xf, uf, Tf", + [([1, 0], [0], 2), + ([0, 1], [0], 3), + ([1, 1], [1], 4)]) +def test_double_integrator(xf, uf, Tf): + # Define a second order integrator + sys = ct.StateSpace([[-1, 1], [0, -2]], [[0], [1]], [[1, 0]], 0) + flatsys = fs.LinearFlatSystem(sys) + + # Define the basis set + bspline = fs.BSplineFamily([0, Tf/2, Tf], 4, 2) + + x0, u0, = [0, 0], [0] + traj = fs.point_to_point(flatsys, Tf, x0, u0, xf, uf, basis=bspline) + + # Verify that the trajectory computation is correct + x, u = traj.eval([0, Tf]) + np.testing.assert_array_almost_equal(x0, x[:, 0]) + np.testing.assert_array_almost_equal(u0, u[:, 0]) + np.testing.assert_array_almost_equal(xf, x[:, 1]) + np.testing.assert_array_almost_equal(uf, u[:, 1]) + + # Simulate the system and make sure we stay close to desired traj + T = np.linspace(0, Tf, 200) + xd, ud = traj.eval(T) + + t, y, x = ct.forced_response(sys, T, ud, x0, return_x=True) + np.testing.assert_array_almost_equal(x, xd, decimal=3) + + # Multi-dimensional splines not yet implemented + bspline = fs.BSplineFamily([0, Tf/3, Tf/2, Tf], [4, 5], [2, 3], vars=2) + with pytest.raises(NotImplementedError, match="not yet supported"): + fs.point_to_point(flatsys, Tf, x0, u0, xf, uf, basis=bspline) + + +# Bicycle model +def vehicle_flat_forward(x, u, params={}): + b = params.get('wheelbase', 3.) # get parameter values + zflag = [np.zeros(3), np.zeros(3)] # list for flag arrays + zflag[0][0] = x[0] # flat outputs + zflag[1][0] = x[1] + zflag[0][1] = u[0] * np.cos(x[2]) # first derivatives + zflag[1][1] = u[0] * np.sin(x[2]) + thdot = (u[0]/b) * np.tan(u[1]) # dtheta/dt + zflag[0][2] = -u[0] * thdot * np.sin(x[2]) # second derivatives + zflag[1][2] = u[0] * thdot * np.cos(x[2]) + return zflag + +def vehicle_flat_reverse(zflag, params={}): + b = params.get('wheelbase', 3.) # get parameter values + x = np.zeros(3); u = np.zeros(2) # vectors to store x, u + x[0] = zflag[0][0] # x position + x[1] = zflag[1][0] # y position + x[2] = np.arctan2(zflag[1][1], zflag[0][1]) # angle + u[0] = zflag[0][1] * np.cos(x[2]) + zflag[1][1] * np.sin(x[2]) + thdot_v = zflag[1][2] * np.cos(x[2]) - zflag[0][2] * np.sin(x[2]) + u[1] = np.arctan2(thdot_v, u[0]**2 / b) + return x, u + +def vehicle_update(t, x, u, params): + b = params.get('wheelbase', 3.) # get parameter values + dx = np.array([ + np.cos(x[2]) * u[0], + np.sin(x[2]) * u[0], + (u[0]/b) * np.tan(u[1]) + ]) + return dx + +def vehicle_output(t, x, u, params): return x + +# Create differentially flat input/output system +vehicle_flat = fs.FlatSystem( + vehicle_flat_forward, vehicle_flat_reverse, vehicle_update, + vehicle_output, inputs=('v', 'delta'), outputs=('x', 'y', 'theta'), + states=('x', 'y', 'theta')) + +def test_kinematic_car(): + # Define the endpoints of the trajectory + x0 = [0., -2., 0.]; u0 = [10., 0.] + xf = [100., 2., 0.]; uf = [10., 0.] + Tf = 10 + + # Set up a basis vector + bspline = fs.BSplineFamily([0, Tf/2, Tf], 5, 3) + + # Find trajectory between initial and final conditions + traj = fs.point_to_point(vehicle_flat, Tf, x0, u0, xf, uf, basis=bspline) + + # Verify that the trajectory computation is correct + x, u = traj.eval([0, Tf]) + np.testing.assert_array_almost_equal(x0, x[:, 0]) + np.testing.assert_array_almost_equal(u0, u[:, 0]) + np.testing.assert_array_almost_equal(xf, x[:, 1]) + np.testing.assert_array_almost_equal(uf, u[:, 1]) + +def test_bspline_errors(): + # Breakpoints must be a 1D array, in increasing order + with pytest.raises(NotImplementedError, match="not yet supported"): + basis = fs.BSplineFamily([[0, 1, 3], [0, 2, 3]], [3, 3]) + + with pytest.raises(ValueError, + match="breakpoints must be convertable to a 1D array"): + basis = fs.BSplineFamily([[[0, 1], [0, 1]], [[0, 1], [0, 1]]], [3, 3]) + + with pytest.raises(ValueError, match="must have at least 2 values"): + basis = fs.BSplineFamily([10], 2) + + with pytest.raises(ValueError, match="must be strictly increasing"): + basis = fs.BSplineFamily([1, 3, 2], 2) + + # Smoothness can't be more than dimension of splines + basis = fs.BSplineFamily([0, 1], 4, 3) # OK + with pytest.raises(ValueError, match="degree must be greater"): + basis = fs.BSplineFamily([0, 1], 4, 4) # not OK diff --git a/control/tests/flatsys_test.py b/control/tests/flatsys_test.py index e3584d459..4eb091b14 100644 --- a/control/tests/flatsys_test.py +++ b/control/tests/flatsys_test.py @@ -150,7 +150,10 @@ def test_flat_default_output(self, vehicle_flat): resp2 = ct.input_output_response(flatsys, T, u1, x0) np.testing.assert_array_almost_equal(resp1.outputs[0:2], resp2.outputs) - def test_flat_cost_constr(self): + @pytest.mark.parametrize("basis", [ + fs.PolyFamily(8), + fs.BSplineFamily([0, 3, 7, 10], 4, 2)]) + def test_flat_cost_constr(self, basis): # Double integrator system sys = ct.ss([[0, 1], [0, 0]], [[0], [1]], [[1, 0]], 0) flat_sys = fs.LinearFlatSystem(sys) @@ -159,11 +162,11 @@ def test_flat_cost_constr(self): x0 = [1, 0]; u0 = [0] xf = [0, 0]; uf = [0] Tf = 10 - T = np.linspace(0, Tf, 500) + T = np.linspace(0, Tf, 100) # Find trajectory between initial and final conditions traj = fs.point_to_point( - flat_sys, Tf, x0, u0, xf, uf, basis=fs.PolyFamily(8)) + flat_sys, Tf, x0, u0, xf, uf, basis=basis) x, u = traj.eval(T) np.testing.assert_array_almost_equal(x0, x[:, 0]) @@ -178,7 +181,7 @@ def test_flat_cost_constr(self): traj_cost = fs.point_to_point( flat_sys, timepts, x0, u0, xf, uf, cost=cost_fcn, - basis=fs.PolyFamily(8), + basis=basis, # initial_guess='lstsq', # minimize_kwargs={'method': 'trust-constr'} ) @@ -204,7 +207,7 @@ def test_flat_cost_constr(self): traj_const = fs.point_to_point( flat_sys, timepts, x0, u0, xf, uf, cost=cost_fcn, - constraints=constraints, basis=fs.PolyFamily(8), + constraints=constraints, basis=basis, ) # Verify that the trajectory computation is correct @@ -224,7 +227,7 @@ def test_flat_cost_constr(self): (sp.optimize.NonlinearConstraint, lambda x, u: x, lb, ub)] traj_nlconst = fs.point_to_point( flat_sys, timepts, x0, u0, xf, uf, cost=cost_fcn, - constraints=nl_constraints, basis=fs.PolyFamily(8), + constraints=nl_constraints, basis=basis, ) x_nlconst, u_nlconst = traj_nlconst.eval(T) np.testing.assert_almost_equal(x_const, x_nlconst) From 0ab0d0703f22281aee4bf8d7db65bda8effeb372 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Mon, 8 Aug 2022 22:14:47 -0700 Subject: [PATCH 044/157] add support for multiple spline variables; update docstrings --- control/flatsys/basis.py | 43 +++++++++++++++---- control/flatsys/bezier.py | 2 +- control/flatsys/bspline.py | 77 +++++++++++++++++------------------ control/flatsys/flatsys.py | 31 +++++++++----- control/flatsys/poly.py | 2 +- control/flatsys/systraj.py | 4 +- control/tests/bspline_test.py | 50 ++++++++++++++++++++--- 7 files changed, 142 insertions(+), 67 deletions(-) diff --git a/control/flatsys/basis.py b/control/flatsys/basis.py index a0986bd61..ad1d8b2a3 100644 --- a/control/flatsys/basis.py +++ b/control/flatsys/basis.py @@ -47,7 +47,11 @@ class BasisFamily: :math:`z_i^{(q)}(t)` = basis.eval_deriv(self, i, j, t) - Parameters + A basis set can either consist of a single variable that is used for + each flat output (nvars = None) or a different variable for different + flat outputs (nvars > 0). + + Attributes ---------- N : int Order of the basis set. @@ -56,15 +60,38 @@ class BasisFamily: def __init__(self, N): """Create a basis family of order N.""" self.N = N # save number of basis functions + self.nvars = None # default number of variables + self.coef_offset = [0] # coefficient offset for each variable + self.coef_length = [N] # coefficient length for each variable - def __call__(self, i, t): + def __call__(self, i, t, var=None): """Evaluate the ith basis function at a point in time""" - return self.eval_deriv(i, 0, t) + return self.eval_deriv(i, 0, t, var=var) + + def var_ncoefs(self, var): + """Get the number of coefficients for a variable""" + return self.N if self.nvars is None else self.coef_length[var] + + def eval(self, coeffs, tlist, var=None): + if self.nvars is None and var != None: + raise SystemError("multi-variable call to a scalar basis") + + elif self.nvars is None: + # Single variable basis + return [ + sum([coeffs[i] * self(i, t) for i in range(self.N)]) + for t in tlist] - def eval(self, coeffs, tlist): - return [ - sum([coeffs[i] * self(i, t) for i in range(self.N)]) - for t in tlist] + else: + # Multi-variable basis + values = np.empty((self.nvars, tlist.size)) + for j in range(self.nvars): + coef_len = self.var_ncoefs(j) + values[j] = np.array([ + sum([coeffs[i] * self(i, t, var=j) + for i in range(coef_len)]) + for t in tlist]) + return values - def eval_deriv(self, i, j, t): + def eval_deriv(self, i, j, t, var=None): raise NotImplementedError("Internal error; improper basis functions") diff --git a/control/flatsys/bezier.py b/control/flatsys/bezier.py index 7e41c546e..d2ab1f275 100644 --- a/control/flatsys/bezier.py +++ b/control/flatsys/bezier.py @@ -59,7 +59,7 @@ def __init__(self, N, T=1): self.T = T # save end of time interval # Compute the kth derivative of the ith basis function at time t - def eval_deriv(self, i, k, t): + def eval_deriv(self, i, k, t, var=None): """Evaluate the kth derivative of the ith basis function at time t.""" if i >= self.N: raise ValueError("Basis function index too high") diff --git a/control/flatsys/bspline.py b/control/flatsys/bspline.py index 5e3b98cb5..a0e5bf3f0 100644 --- a/control/flatsys/bspline.py +++ b/control/flatsys/bspline.py @@ -17,14 +17,14 @@ class BSplineFamily(BasisFamily): across a set of breakpoints with given order and smoothness. """ - def __init__(self, breakpoints, degree, smoothness=None, vars=1): + def __init__(self, breakpoints, degree, smoothness=None, vars=None): """Create a B-spline basis for piecewise smooth polynomials Define B-spline polynomials for a set of one or more variables. - B-splines are characterized by a set of intervals separated by break - points. On each interval we have a polynomial of a certain order - and the spline is continuous up to a given smoothness at interior - break points. + B-splines are used as a basis for a set of piecewise smooth + polynomials joined at breakpoints. On each interval we have a + polynomial of a given order and the spline is continuous up to a + given smoothness at interior breakpoints. Parameters ---------- @@ -41,8 +41,11 @@ def __init__(self, breakpoints, degree, smoothness=None, vars=1): For each spline variable, the smoothness at breakpoints (number of derivatives that should match). - vars : int or list of str, option - The number of spline variables or a list of spline variable names. + vars : None or int, optional + The number of spline variables. If specified as None (default), + then the spline basis describes a single variable, with no + indexing. If the number of spine variables is > 0, then the + spline basis is index using the `var` keyword. """ # Process the breakpoints for the spline */ @@ -58,17 +61,19 @@ def __init__(self, breakpoints, degree, smoothness=None, vars=1): raise ValueError("break points must be strictly increasing values") # Decide on the number of spline variables - if isinstance(vars, list) and all([isinstance(v, str) for v in vars]): - raise NotImplemented("list of variable names not yet supported") + if vars is None: + nvars = 1 + self.nvars = None # track as single variable elif not isinstance(vars, int): - raise TypeError("vars must be an integer or list of strings") + raise TypeError("vars must be an integer") else: nvars = vars + self.nvars = nvars # # Process B-spline parameters (order, smoothness) # - # B-splines are characterized by a set of intervals separated by + # B-splines are defined on a set of intervals separated by # breakpoints. On each interval we have a polynomial of a certain # order and the spline is continuous up to a given smoothness at # breakpoints. The code in this section allows some flexibility in @@ -99,14 +104,15 @@ def process_spline_parameters( elif all([isinstance(v, allowed_types) for v in values]): # List of values => make sure it is the right size if len(values) != length: - raise ValueError(f"length of '{name}' does not match n") + raise ValueError(f"length of '{name}' does not match" + f" number of variables") else: raise ValueError(f"could not parse '{name}' keyword") # Check to make sure the values are OK if values is not None and any([val < minimum for val in values]): raise ValueError( - f"invalid value for {name}; must be at least {minimum}") + f"invalid value for '{name}'; must be at least {minimum}") return values @@ -123,25 +129,23 @@ def process_spline_parameters( if any([degree[i] - smoothness[i] < 1 for i in range(nvars)]): raise ValueError("degree must be greater than smoothness") - # Store the parameters and process them in call_ntg() - self.nvars = nvars + # Store the parameters for the spline (self.nvars already stored) self.breakpoints = breakpoints self.degree = degree self.smoothness = smoothness - self.nintervals = breakpoints.size - 1 # # Compute parameters for a SciPy BSpline object # - # To create a B-spline, we need to compute the knot points, keeping - # track of the use of repeated knot points at the initial knot and + # To create a B-spline, we need to compute the knotpoints, keeping + # track of the use of repeated knotpoints at the initial knot and # final knot as well as repeated knots at intermediate points # depending on the desired smoothness. # # Store the coefficients for each output (useful later) self.coef_offset, self.coef_length, offset = [], [], 0 - for i in range(self.nvars): + for i in range(nvars): # Compute number of coefficients for the piecewise polynomial ncoefs = (self.degree[i] + 1) * (len(self.breakpoints) - 1) - \ (self.smoothness[i] + 1) * (len(self.breakpoints) - 2) @@ -151,48 +155,43 @@ def process_spline_parameters( offset += ncoefs self.N = offset # save the total number of coefficients - # Create knot points for each spline variable + # Create knotpoints for each spline variable # TODO: extend to multi-dimensional breakpoints self.knotpoints = [] - for i in range(self.nvars): + for i in range(nvars): # Allocate space for the knotpoints self.knotpoints.append(np.empty( (self.degree[i] + 1) + (len(self.breakpoints) - 2) * \ (self.degree[i] - self.smoothness[i]) + (self.degree[i] + 1))) - # Initial knot points + # Initial knotpoints (multiplicity = order) self.knotpoints[i][0:self.degree[i] + 1] = self.breakpoints[0] offset = self.degree[i] + 1 - # Interior knot points + # Interior knotpoints (multiplicity = degree - smoothness) nknots = self.degree[i] - self.smoothness[i] assert nknots > 0 # just in case for j in range(1, self.breakpoints.size - 1): self.knotpoints[i][offset:offset+nknots] = self.breakpoints[j] offset += nknots - # Final knot point + # Final knotpoint (multiplicity = order) self.knotpoints[i][offset:offset + self.degree[i] + 1] = \ self.breakpoints[-1] - def eval(self, coefs, tlist): - return np.array([ - BSpline(self.knotpoints[i], - coefs[self.coef_offset[i]: - self.coef_offset[i] + self.coef_length[i]], - self.degree[i])(tlist) - for i in range(self.nvars)]) - # Compute the kth derivative of the ith basis function at time t - def eval_deriv(self, i, k, t, squeeze=True): + def eval_deriv(self, i, k, t, var=None): """Evaluate the kth derivative of the ith basis function at time t.""" - if self.nvars > 1 or not squeeze: - raise NotImplementedError( - "derivatives of multi-variable splines not yet supported") + if self.nvars is None or (self.nvars == 1 and var is None): + # Use same variable for all requests + var = 0 + elif self.nvars > 1 and var is None: + raise SystemError( + "scalar variable call to multi-variable splines") # Create a coefficient vector for this spline - coefs = np.zeros(self.coef_length[0]); coefs[i] = 1 + coefs = np.zeros(self.coef_length[var]); coefs[i] = 1 # Evaluate the derivative of the spline at the desired point in time - return BSpline(self.knotpoints[0], coefs, - self.degree[0]).derivative(k)(t) + return BSpline(self.knotpoints[var], coefs, + self.degree[var]).derivative(k)(t) diff --git a/control/flatsys/flatsys.py b/control/flatsys/flatsys.py index aa701af4b..96034158e 100644 --- a/control/flatsys/flatsys.py +++ b/control/flatsys/flatsys.py @@ -155,6 +155,7 @@ def __init__(self, if reverse is not None: self.reverse = reverse # Save the length of the flat flag + # TODO: missing def __str__(self): return f"{NonlinearIOSystem.__str__(self)}\n\n" \ @@ -233,17 +234,19 @@ def _basis_flag_matrix(sys, basis, flag, t, params={}): column of the matrix corresponds to a basis function and each row is a derivative, with the derivatives (flag) for each output stacked on top of each other. - +l """ flagshape = [len(f) for f in flag] - M = np.zeros((sum(flagshape), basis.N * sys.ninputs)) + M = np.zeros((sum(flagshape), + sum([basis.var_ncoefs(i) for i in range(sys.ninputs)]))) flag_off = 0 - coeff_off = 0 + coef_off = 0 for i, flag_len in enumerate(flagshape): - for j, k in itertools.product(range(basis.N), range(flag_len)): - M[flag_off + k, coeff_off + j] = basis.eval_deriv(j, k, t) + coef_len = basis.var_ncoefs(i) + for j, k in itertools.product(range(coef_len), range(flag_len)): + M[flag_off + k, coef_off + j] = basis.eval_deriv(j, k, t, var=i) flag_off += flag_len - coeff_off += basis.N + coef_off += coef_len return M @@ -362,11 +365,16 @@ def point_to_point( if basis is None: basis = PolyFamily(2 * (sys.nstates + sys.ninputs)) + # If a multivariable basis was given, make sure the size is correct + if basis.nvars is not None and basis.nvars != sys.ninputs: + raise ValueError("size of basis does not match flat system size") + # Make sure we have enough basis functions to solve the problem - if basis.N * sys.ninputs < 2 * (sys.nstates + sys.ninputs): + ncoefs = sum([basis.var_ncoefs(i) for i in range(sys.ninputs)]) + if ncoefs < 2 * (sys.nstates + sys.ninputs): raise ValueError("basis set is too small") elif (cost is not None or trajectory_constraints is not None) and \ - basis.N * sys.ninputs == 2 * (sys.nstates + sys.ninputs): + ncoefs == 2 * (sys.nstates + sys.ninputs): warnings.warn("minimal basis specified; optimization not possible") cost = None trajectory_constraints = None @@ -531,11 +539,12 @@ def traj_const(null_coeffs): # Store the flag lengths and coefficients # TODO: make this more pythonic - coeff_off = 0 + coef_off = 0 for i in range(sys.ninputs): # Grab the coefficients corresponding to this flat output - systraj.coeffs.append(alpha[coeff_off:coeff_off + basis.N]) - coeff_off += basis.N + coef_len = basis.var_ncoefs(i) + systraj.coeffs.append(alpha[coef_off:coef_off + coef_len]) + coef_off += coef_len # Keep track of the length of the flat flag for this output systraj.flaglen.append(len(zflag_T0[i])) diff --git a/control/flatsys/poly.py b/control/flatsys/poly.py index 08dcfb1c9..bb3677c48 100644 --- a/control/flatsys/poly.py +++ b/control/flatsys/poly.py @@ -55,7 +55,7 @@ def __init__(self, N): super(PolyFamily, self).__init__(N) # Compute the kth derivative of the ith basis function at time t - def eval_deriv(self, i, k, t): + def eval_deriv(self, i, k, t, var=None): """Evaluate the kth derivative of the ith basis function at time t.""" if (i < k): return 0; # higher derivative than power return factorial(i)/factorial(i-k) * np.power(t, i-k) diff --git a/control/flatsys/systraj.py b/control/flatsys/systraj.py index 9d425295b..c9bde6d7a 100644 --- a/control/flatsys/systraj.py +++ b/control/flatsys/systraj.py @@ -106,11 +106,11 @@ def eval(self, tlist): for i in range(self.ninputs): flag_len = self.flaglen[i] zflag.append(np.zeros(flag_len)) - for j in range(self.basis.N): + for j in range(self.basis.var_ncoefs(i)): for k in range(flag_len): #! TODO: rewrite eval_deriv to take in time vector zflag[i][k] += self.coeffs[i][j] * \ - self.basis.eval_deriv(j, k, t) + self.basis.eval_deriv(j, k, t, var=i) # Now copy the states and inputs # TODO: revisit order of list arguments diff --git a/control/tests/bspline_test.py b/control/tests/bspline_test.py index b00f856dc..0ac59094d 100644 --- a/control/tests/bspline_test.py +++ b/control/tests/bspline_test.py @@ -59,6 +59,11 @@ def test_bspline_basis(): bspline.eval_deriv(i, j, time)[0:-1], atol=0.01, rtol=0.01) + # Make sure that ndarrays are processed the same as integer lists + degree = np.array(degree) + bspline2 = fs.BSplineFamily([0, Tf/3, Tf/2, Tf], degree, maxderiv) + np.testing.assert_equal(bspline(0, time), bspline2(0, time)) + # Exception check with pytest.raises(IndexError, match="out of bounds"): bspline.eval_deriv(bspline.N, 0, time) @@ -94,11 +99,6 @@ def test_double_integrator(xf, uf, Tf): t, y, x = ct.forced_response(sys, T, ud, x0, return_x=True) np.testing.assert_array_almost_equal(x, xd, decimal=3) - # Multi-dimensional splines not yet implemented - bspline = fs.BSplineFamily([0, Tf/3, Tf/2, Tf], [4, 5], [2, 3], vars=2) - with pytest.raises(NotImplementedError, match="not yet supported"): - fs.point_to_point(flatsys, Tf, x0, u0, xf, uf, basis=bspline) - # Bicycle model def vehicle_flat_forward(x, u, params={}): @@ -160,6 +160,25 @@ def test_kinematic_car(): np.testing.assert_array_almost_equal(xf, x[:, 1]) np.testing.assert_array_almost_equal(uf, u[:, 1]) +def test_kinematic_car_multivar(): + # Define the endpoints of the trajectory + x0 = [0., -2., 0.]; u0 = [10., 0.] + xf = [100., 2., 0.]; uf = [10., 0.] + Tf = 10 + + # Set up a basis vector + bspline = fs.BSplineFamily([0, Tf/2, Tf], [5, 6], [3, 4], vars=2) + + # Find trajectory between initial and final conditions + traj = fs.point_to_point(vehicle_flat, Tf, x0, u0, xf, uf, basis=bspline) + + # Verify that the trajectory computation is correct + x, u = traj.eval([0, Tf]) + np.testing.assert_array_almost_equal(x0, x[:, 0]) + np.testing.assert_array_almost_equal(u0, u[:, 0]) + np.testing.assert_array_almost_equal(xf, x[:, 1]) + np.testing.assert_array_almost_equal(uf, u[:, 1]) + def test_bspline_errors(): # Breakpoints must be a 1D array, in increasing order with pytest.raises(NotImplementedError, match="not yet supported"): @@ -179,3 +198,24 @@ def test_bspline_errors(): basis = fs.BSplineFamily([0, 1], 4, 3) # OK with pytest.raises(ValueError, match="degree must be greater"): basis = fs.BSplineFamily([0, 1], 4, 4) # not OK + + # nvars must be an integer + with pytest.raises(TypeError, match="vars must be an integer"): + basis = fs.BSplineFamily([0, 1], 4, 3, vars=['x1', 'x2']) + + # degree, smoothness must match nvars + with pytest.raises(ValueError, match="length of 'degree' does not match"): + basis = fs.BSplineFamily([0, 1], [4, 4, 4], 3, vars=2) + + # degree, smoothness must be list of ints + basis = fs.BSplineFamily([0, 1], [4, 4], 3, vars=2) # OK + with pytest.raises(ValueError, match="could not parse 'degree'"): + basis = fs.BSplineFamily([0, 1], [4, '4'], 3, vars=2) + + # degree must be strictly positive + with pytest.raises(ValueError, match="'degree'; must be at least 1"): + basis = fs.BSplineFamily([0, 1], 0, 1) + + # smoothness must be non-negative + with pytest.raises(ValueError, match="'smoothness'; must be at least 0"): + basis = fs.BSplineFamily([0, 1], 2, -1) From 78f7bbb4361b8c6118e938e6fff7946a23324500 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 12 Aug 2022 20:32:55 -0700 Subject: [PATCH 045/157] initial implementation of solve_flat_ocp --- control/flatsys/__init__.py | 2 +- control/flatsys/flatsys.py | 316 +++++++++++++++++++++++++++++++++- control/tests/flatsys_test.py | 98 +++++++++++ 3 files changed, 411 insertions(+), 5 deletions(-) diff --git a/control/flatsys/__init__.py b/control/flatsys/__init__.py index 6e8eac030..8ead08dce 100644 --- a/control/flatsys/__init__.py +++ b/control/flatsys/__init__.py @@ -62,4 +62,4 @@ from .linflat import LinearFlatSystem # Package functions -from .flatsys import point_to_point +from .flatsys import point_to_point, solve_flat_ocp diff --git a/control/flatsys/flatsys.py b/control/flatsys/flatsys.py index 96034158e..18b3d7ec6 100644 --- a/control/flatsys/flatsys.py +++ b/control/flatsys/flatsys.py @@ -411,7 +411,7 @@ def point_to_point( # Solve for the coefficients of the flat outputs # # At this point, we need to solve the equation M alpha = zflag, where M - # is the matrix constrains for initial and final conditions and zflag = + # is the matrix constraints for initial and final conditions and zflag = # [zflag_T0; zflag_tf]. # # If there are no constraints, then we just need to solve a linear @@ -426,6 +426,11 @@ def point_to_point( if rank < Z.size: warnings.warn("basis too small; solution may not exist") + # Precompute the collocation matrix the defines the flag at timepts + Mt_list = [] + for t in timepts: + Mt_list.append(_basis_flag_matrix(sys, basis, zflag_T0, t)) + if cost is not None or trajectory_constraints is not None: # Search over the null space to minimize cost/satisfy constraints N = sp.linalg.null_space(M) @@ -438,8 +443,8 @@ def traj_cost(null_coeffs): # Evaluate the costs at the listed time points # TODO: store Mt ahead of time, since it doesn't change costval = 0 - for t in timepts: - M_t = _basis_flag_matrix(sys, basis, zflag_T0, t) + for i, t in enumerate(timepts): + M_t = Mt_list[i] # Compute flag at this time point zflag = (M_t @ coeffs).reshape(sys.ninputs, -1) @@ -477,7 +482,7 @@ def traj_const(null_coeffs): values = [] for i, t in enumerate(timepts): # Calculate the states and inputs for the flat output - M_t = _basis_flag_matrix(sys, basis, zflag_T0, t) + M_t = Mt_list[i] # Compute flag at this time point zflag = (M_t @ coeffs).reshape(sys.ninputs, -1) @@ -551,3 +556,306 @@ def traj_const(null_coeffs): # Return a function that computes inputs and states as a function of time return systraj + + +# Solve a point to point trajectory generation problem for a flat system +def solve_flat_ocp( + sys, timepts, x0=0, u0=0, basis=None, trajectory_cost=None, + terminal_cost=None, trajectory_constraints=None, + initial_guess=None, params=None, **kwargs): + """Compute trajectory between an initial and final conditions. + + Compute an optimial trajectory for a differentially flat system starting + from an initial state and input value. + + Parameters + ---------- + flatsys : FlatSystem object + Description of the differentially flat system. This object must + define a function `flatsys.forward()` that takes the system state and + produceds the flag of flat outputs and a system `flatsys.reverse()` + that takes the flag of the flat output and prodes the state and + input. + + timepts : float or 1D array_like + The list of points for evaluating cost and constraints, as well as + the time horizon. If given as a float, indicates the final time for + the trajectory (corresponding to xf) + + x0, u0 : 1D arrays + Define the initial conditions for the system. If either of the + values are given as None, they are replaced by a vector of zeros of + the appropriate dimension. + + basis : :class:`~control.flatsys.BasisFamily` object, optional + The basis functions to use for generating the trajectory. If not + specified, the :class:`~control.flatsys.PolyFamily` basis family + will be used, with the minimal number of elements required to find a + feasible trajectory (twice the number of system states) + + trajectory_cost : callable + Function that returns the integral cost given the current state + and input. Called as `cost(x, u)`. + + terminal_cost : callable + Function that returns the terminal cost given the state and input. + Called as `cost(x, u)`. + + trajectory_constraints : list of tuples, optional + List of constraints that should hold at each point in the time vector. + Each element of the list should consist of a tuple with first element + given by :class:`scipy.optimize.LinearConstraint` or + :class:`scipy.optimize.NonlinearConstraint` and the remaining + elements of the tuple are the arguments that would be passed to those + functions. The following tuples are supported: + + * (LinearConstraint, A, lb, ub): The matrix A is multiplied by stacked + vector of the state and input at each point on the trajectory for + comparison against the upper and lower bounds. + + * (NonlinearConstraint, fun, lb, ub): a user-specific constraint + function `fun(x, u)` is called at each point along the trajectory + and compared against the upper and lower bounds. + + The constraints are applied at each time point along the trajectory. + + minimize_kwargs : str, optional + Pass additional keywords to :func:`scipy.optimize.minimize`. + + Returns + ------- + traj : :class:`~control.flatsys.SystemTrajectory` object + The system trajectory is returned as an object that implements the + `eval()` function, we can be used to compute the value of the state + and input and a given time t. + + Notes + ----- + Additional keyword parameters can be used to fine tune the behavior of + the underlying optimization function. See `minimize_*` keywords in + :func:`OptimalControlProblem` for more information. + + """ + # + # Make sure the problem is one that we can handle + # + x0 = _check_convert_array(x0, [(sys.nstates,), (sys.nstates, 1)], + 'Initial state: ', squeeze=True) + u0 = _check_convert_array(u0, [(sys.ninputs,), (sys.ninputs, 1)], + 'Initial input: ', squeeze=True) + + # Process final time + timepts = np.atleast_1d(timepts) + Tf = timepts[-1] + T0 = timepts[0] if len(timepts) > 1 else T0 + + # Process keyword arguments + if trajectory_constraints is None: + # Backwards compatibility + trajectory_constraints = kwargs.pop('constraints', None) + if trajectory_cost is None: + # Compatibility with point_to_point + trajectory_cost = kwargs.pop('cost', None) + + minimize_kwargs = {} + minimize_kwargs['method'] = kwargs.pop('minimize_method', None) + minimize_kwargs['options'] = kwargs.pop('minimize_options', {}) + minimize_kwargs.update(kwargs.pop('minimize_kwargs', {})) + + if trajectory_cost is None and terminal_cost is None: + raise TypeError("need trajectory and/or terminal cost required") + + if kwargs: + raise TypeError("unrecognized keywords: ", str(kwargs)) + + # + # Determine the basis function set to use and make sure it is big enough + # + + # If no basis set was specified, use a polynomial basis (poor choice...) + if basis is None: + basis = PolyFamily(2 * (sys.nstates + sys.ninputs)) + + # If a multivariable basis was given, make sure the size is correct + if basis.nvars is not None and basis.nvars != sys.ninputs: + raise ValueError("size of basis does not match flat system size") + + # Make sure we have enough basis functions to solve the problem + ncoefs = sum([basis.var_ncoefs(i) for i in range(sys.ninputs)]) + if ncoefs <= sys.nstates + sys.ninputs: + raise ValueError("basis set is too small") + + # Figure out the parameters to use, if any + params = sys.params if params is None else params + + # + # Map the initial and conditions to flat output conditions + # + # We need to compute the output "flag": [z(t), z'(t), z''(t), ...] + # and then evaluate this at the initial and final condition. + # + + zflag_T0 = sys.forward(x0, u0, params) + Z_T0 = np.hstack(zflag_T0) + + # + # Compute the matrix constraints for initial conditions + # + # This computation depends on the basis function we are using. It + # essentially amounts to evaluating the basis functions and their + # derivatives at the initial conditions. + + # Compute the flags for the initial and final states + M_T0 = _basis_flag_matrix(sys, basis, zflag_T0, T0) + + # + # Solve for the coefficients of the flat outputs + # + # At this point, we need to solve the equation M_T0 alpha = zflag_T0. + # + # If there are no additional constraints, then we just need to solve a + # linear system of equations => use least squares. Otherwise, we have a + # nonlinear optimal control problem with equality constraints => use + # scipy.optimize.minimize(). + # + + # Start by solving the least squares problem + alpha, residuals, rank, s = np.linalg.lstsq(M_T0, Z_T0, rcond=None) + if rank < Z_T0.size: + warnings.warn("basis too small; solution may not exist") + + # Precompute the collocation matrix the defines the flag at timepts + # TODO: only compute if we have trajectory cost/constraints + Mt_list = [] + for t in timepts: + Mt_list.append(_basis_flag_matrix(sys, basis, zflag_T0, t)) + + # Search over the null space to minimize cost/satisfy constraints + N = sp.linalg.null_space(M_T0) + + # Define a function to evaluate the cost along a trajectory + def traj_cost(null_coeffs): + # Add this to the existing solution + coeffs = alpha + N @ null_coeffs + costval = 0 + + # Evaluate the trajectory costs at the listed time points + if trajectory_cost is not None: + for i, t in enumerate(timepts[0:-1]): + M_t = Mt_list[i] + + # Compute flag at this time point + zflag = (M_t @ coeffs).reshape(sys.ninputs, -1) + + # Find states and inputs at the time points + x, u = sys.reverse(zflag, params) + + # Evaluate the cost at this time point + # TODO: make use of time interval + costval += trajectory_cost(x, u) + + # Evaluate the terminal_cost + if terminal_cost is not None: + M_t = Mt_list[-1] + zflag = (M_t @ coeffs).reshape(sys.ninputs, -1) + x, u = sys.reverse(zflag, params) + costval += terminal_cost(x, u) + + return costval + + # Process the constraints we were given + traj_constraints = trajectory_constraints + if traj_constraints is None: + traj_constraints = [] + elif isinstance(traj_constraints, tuple): + # TODO: Check to make sure this is really a constraint + traj_constraints = [traj_constraints] + elif not isinstance(traj_constraints, list): + raise TypeError("trajectory constraints must be a list") + + # Process constraints + minimize_constraints = [] + if len(traj_constraints) > 0: + # Set up a nonlinear function to evaluate the constraints + def traj_const(null_coeffs): + # Add this to the existing solution + coeffs = alpha + N @ null_coeffs + + # Evaluate the constraints at the listed time points + values = [] + for i, t in enumerate(timepts): + # Calculate the states and inputs for the flat output + M_t = Mt_list[i] + + # Compute flag at this time point + zflag = (M_t @ coeffs).reshape(sys.ninputs, -1) + + # Find states and inputs at the time points + states, inputs = sys.reverse(zflag, params) + + # Evaluate the constraint function along the trajectory + for type, fun, lb, ub in traj_constraints: + if type == sp.optimize.LinearConstraint: + # `fun` is A matrix associated with polytope... + values.append(fun @ np.hstack([states, inputs])) + elif type == sp.optimize.NonlinearConstraint: + values.append(fun(states, inputs)) + else: + raise TypeError( + "unknown constraint type %s" % type) + return np.array(values).flatten() + + # Store upper and lower bounds + const_lb, const_ub = [], [] + for t in timepts: + for type, fun, lb, ub in traj_constraints: + const_lb.append(lb) + const_ub.append(ub) + const_lb = np.array(const_lb).flatten() + const_ub = np.array(const_ub).flatten() + + # Store the constraint as a nonlinear constraint + minimize_constraints = [sp.optimize.NonlinearConstraint( + traj_const, const_lb, const_ub)] + + # Add initial and terminal constraints + # minimize_constraints += [sp.optimize.LinearConstraint(M, Z, Z)] + + # Process the initial condition + if initial_guess is None: + initial_guess = np.zeros(M_T0.shape[1] - M_T0.shape[0]) + else: + raise NotImplementedError("Initial guess not yet implemented.") + + # Find the optimal solution + res = sp.optimize.minimize( + traj_cost, initial_guess, constraints=minimize_constraints, + **minimize_kwargs) + if res.success: + alpha += N @ res.x + else: + raise RuntimeError( + "Unable to solve optimal control problem\n" + + "scipy.optimize.minimize returned " + res.message) + + # + # Transform the trajectory from flat outputs to states and inputs + # + + # Create a trajectory object to store the result + systraj = SystemTrajectory(sys, basis, params=params) + + # Store the flag lengths and coefficients + # TODO: make this more pythonic + coef_off = 0 + for i in range(sys.ninputs): + # Grab the coefficients corresponding to this flat output + coef_len = basis.var_ncoefs(i) + systraj.coeffs.append(alpha[coef_off:coef_off + coef_len]) + coef_off += coef_len + + # Keep track of the length of the flat flag for this output + systraj.flaglen.append(len(zflag_T0[i])) + + # Return a function that computes inputs and states as a function of time + return systraj diff --git a/control/tests/flatsys_test.py b/control/tests/flatsys_test.py index 4eb091b14..710841d60 100644 --- a/control/tests/flatsys_test.py +++ b/control/tests/flatsys_test.py @@ -233,6 +233,104 @@ def test_flat_cost_constr(self, basis): np.testing.assert_almost_equal(x_const, x_nlconst) np.testing.assert_almost_equal(u_const, u_nlconst) + @pytest.mark.parametrize("basis", [ + # fs.PolyFamily(8), + fs.BSplineFamily([0, 3, 7, 10], 5, 2)]) + def test_flat_solve_ocp(self, basis): + # Double integrator system + sys = ct.ss([[0, 1], [0, 0]], [[0], [1]], [[1, 0]], 0) + flat_sys = fs.LinearFlatSystem(sys) + + # Define the endpoints of the trajectory + x0 = [1, 0]; u0 = [0] + xf = [-1, 0]; uf = [0] + Tf = 10 + T = np.linspace(0, Tf, 100) + + # Find trajectory between initial and final conditions + traj = fs.point_to_point( + flat_sys, Tf, x0, u0, xf, uf, basis=basis) + x, u = traj.eval(T) + + np.testing.assert_array_almost_equal(x0, x[:, 0]) + np.testing.assert_array_almost_equal(u0, u[:, 0]) + np.testing.assert_array_almost_equal(xf, x[:, -1]) + np.testing.assert_array_almost_equal(uf, u[:, -1]) + + # Solve with a terminal cost function + timepts = np.linspace(0, Tf, 10) + terminal_cost = opt.quadratic_cost( + flat_sys, 1e3, 1e3, x0=xf, u0=uf) + + traj_cost = fs.solve_flat_ocp( + flat_sys, timepts, x0, u0, + terminal_cost=terminal_cost, basis=basis) + + # Verify that the trajectory computation is correct + x_cost, u_cost = traj_cost.eval(T) + np.testing.assert_array_almost_equal(x0, x_cost[:, 0]) + np.testing.assert_array_almost_equal(u0, u_cost[:, 0]) + np.testing.assert_array_almost_equal(xf, x_cost[:, -1]) + np.testing.assert_array_almost_equal(uf, u_cost[:, -1]) + + # Solve with trajectory and terminal cost functions + trajectory_cost = opt.quadratic_cost(flat_sys, 0, 1, x0=xf, u0=uf) + + traj_cost = fs.solve_flat_ocp( + flat_sys, timepts, x0, u0, terminal_cost=terminal_cost, + trajectory_cost=trajectory_cost, basis=basis) + + # Verify that the trajectory computation is correct + x_cost, u_cost = traj_cost.eval(T) + np.testing.assert_array_almost_equal(x0, x_cost[:, 0]) + np.testing.assert_array_almost_equal(u0, u_cost[:, 0]) + + # Make sure we got close on the terminal condition + assert all(np.abs(x_cost[:, -1] - xf) < 0.1) + + # Make sure that we got a different answer than before + assert np.any(np.abs(x - x_cost) > 0.1) + + # Re-solve with constraint on the y deviation + lb, ub = [-2, np.min(x_cost[1])*0.95], [2, 1] + constraints = [opt.state_range_constraint(flat_sys, lb, ub)] + + # Make sure that the previous solution violated at least one constraint + assert np.any(x_cost[0, :] < lb[0]) or np.any(x_cost[0, :] > ub[0]) \ + or np.any(x_cost[1, :] < lb[1]) or np.any(x_cost[1, :] > ub[1]) + + traj_const = fs.solve_flat_ocp( + flat_sys, timepts, x0, u0, + terminal_cost=terminal_cost, trajectory_cost=trajectory_cost, + trajectory_constraints=constraints, basis=basis, + ) + + # Verify that the trajectory computation is correct + x_const, u_const = traj_const.eval(timepts) + np.testing.assert_array_almost_equal(x0, x_const[:, 0]) + np.testing.assert_array_almost_equal(u0, u_const[:, 0]) + + # Make sure we got close on the terminal condition + assert all(np.abs(x_cost[:, -1] - xf) < 0.1) + + # Make sure that the solution respects the bounds (with some slop) + for i in range(x_const.shape[0]): + assert np.all(x_const[i] >= lb[i] * 1.02) + assert np.all(x_const[i] <= ub[i] * 1.02) + + # Solve the same problem with a nonlinear constraint type + # Use alternative keywords as well + nl_constraints = [ + (sp.optimize.NonlinearConstraint, lambda x, u: x, lb, ub)] + traj_nlconst = fs.solve_flat_ocp( + flat_sys, timepts, x0, u0, + cost=trajectory_cost, terminal_cost=terminal_cost, + constraints=nl_constraints, basis=basis, + ) + x_nlconst, u_nlconst = traj_nlconst.eval(timepts) + np.testing.assert_almost_equal(x_const, x_nlconst) + np.testing.assert_almost_equal(u_const, u_nlconst) + def test_bezier_basis(self): bezier = fs.BezierFamily(4) time = np.linspace(0, 1, 100) From 05084f35c933f4a02112830271b23dd2d3fee198 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Thu, 18 Aug 2022 22:48:54 -0700 Subject: [PATCH 046/157] unit tests, bug fixes, algorithm improvements * add initial_guess functionality to solve_flat_ocp * pre-compute collocation matrices in point_to_point, solve_flat_ocp * updated return values for solve_flat_ocp * add __repr__ for flat basis functions * docstring improvements * additional unit tests + examples --- control/flatsys/basis.py | 4 + control/flatsys/bezier.py | 3 +- control/flatsys/bspline.py | 4 + control/flatsys/flatsys.py | 98 +++++++++------ control/flatsys/poly.py | 6 +- control/tests/flatsys_test.py | 231 +++++++++++++++++++++++++++++----- control/tests/kwargs_test.py | 4 +- examples/kincar-flatsys.py | 63 ++++++++-- 8 files changed, 335 insertions(+), 78 deletions(-) diff --git a/control/flatsys/basis.py b/control/flatsys/basis.py index ad1d8b2a3..baf2b926b 100644 --- a/control/flatsys/basis.py +++ b/control/flatsys/basis.py @@ -64,6 +64,10 @@ def __init__(self, N): self.coef_offset = [0] # coefficient offset for each variable self.coef_length = [N] # coefficient length for each variable + def __repr__(self): + return f'<{self.__class__.__name__}: nvars={self.nvars}, ' + \ + f'N={self.N}>' + def __call__(self, i, t, var=None): """Evaluate the ith basis function at a point in time""" return self.eval_deriv(i, 0, t, var=var) diff --git a/control/flatsys/bezier.py b/control/flatsys/bezier.py index d2ab1f275..241274268 100644 --- a/control/flatsys/bezier.py +++ b/control/flatsys/bezier.py @@ -78,6 +78,7 @@ def eval_deriv(self, i, k, t, var=None): # Return the kth derivative of the ith Bezier basis function return binom(n, i) * sum([ (-1)**(j-i) * - binom(n-i, j-i) * factorial(j)/factorial(j-k) * np.power(u, j-k) + binom(n-i, j-i) * factorial(j)/factorial(j-k) * \ + np.power(u, j-k) / np.power(self.T, k) for j in range(max(i, k), n+1) ]) diff --git a/control/flatsys/bspline.py b/control/flatsys/bspline.py index a0e5bf3f0..12cf49431 100644 --- a/control/flatsys/bspline.py +++ b/control/flatsys/bspline.py @@ -179,6 +179,10 @@ def process_spline_parameters( self.knotpoints[i][offset:offset + self.degree[i] + 1] = \ self.breakpoints[-1] + def __repr__(self): + return f'<{self.__class__.__name__}: nvars={self.nvars}, ' + \ + f'degree={self.degree}, smoothness={self.smoothness}>' + # Compute the kth derivative of the ith basis function at time t def eval_deriv(self, i, k, t, var=None): """Evaluate the kth derivative of the ith basis function at time t.""" diff --git a/control/flatsys/flatsys.py b/control/flatsys/flatsys.py index 18b3d7ec6..78d7696f9 100644 --- a/control/flatsys/flatsys.py +++ b/control/flatsys/flatsys.py @@ -426,24 +426,23 @@ def point_to_point( if rank < Z.size: warnings.warn("basis too small; solution may not exist") - # Precompute the collocation matrix the defines the flag at timepts - Mt_list = [] - for t in timepts: - Mt_list.append(_basis_flag_matrix(sys, basis, zflag_T0, t)) - if cost is not None or trajectory_constraints is not None: # Search over the null space to minimize cost/satisfy constraints N = sp.linalg.null_space(M) + # Precompute the collocation matrix the defines the flag at timepts + Mt_list = [] + for t in timepts[1:-1]: + Mt_list.append(_basis_flag_matrix(sys, basis, zflag_T0, t)) + # Define a function to evaluate the cost along a trajectory def traj_cost(null_coeffs): # Add this to the existing solution coeffs = alpha + N @ null_coeffs # Evaluate the costs at the listed time points - # TODO: store Mt ahead of time, since it doesn't change costval = 0 - for i, t in enumerate(timepts): + for i, t in enumerate(timepts[1:-1]): M_t = Mt_list[i] # Compute flag at this time point @@ -453,7 +452,7 @@ def traj_cost(null_coeffs): x, u = sys.reverse(zflag, params) # Evaluate the cost at this time point - costval += cost(x, u) + costval += cost(x, u) * (timepts[i+1] - timepts[i]) return costval # If no cost given, override with magnitude of the coefficients @@ -480,7 +479,7 @@ def traj_const(null_coeffs): # Evaluate the constraints at the listed time points values = [] - for i, t in enumerate(timepts): + for i, t in enumerate(timepts[1:-1]): # Calculate the states and inputs for the flat output M_t = Mt_list[i] @@ -504,7 +503,7 @@ def traj_const(null_coeffs): # Store upper and lower bounds const_lb, const_ub = [], [] - for t in timepts: + for t in timepts[1:-1]: for type, fun, lb, ub in traj_constraints: const_lb.append(lb) const_ub.append(ub) @@ -515,9 +514,6 @@ def traj_const(null_coeffs): minimize_constraints = [sp.optimize.NonlinearConstraint( traj_const, const_lb, const_ub)] - # Add initial and terminal constraints - # minimize_constraints += [sp.optimize.LinearConstraint(M, Z, Z)] - # Process the initial condition if initial_guess is None: initial_guess = np.zeros(M.shape[1] - M.shape[0]) @@ -528,12 +524,13 @@ def traj_const(null_coeffs): res = sp.optimize.minimize( traj_cost, initial_guess, constraints=minimize_constraints, **minimize_kwargs) - if res.success: - alpha += N @ res.x - else: - raise RuntimeError( - "Unable to solve optimal control problem\n" + - "scipy.optimize.minimize returned " + res.message) + alpha += N @ res.x + + # See if we got an answer + if not res.success: + warnings.warn( + "unable to solve optimal control problem\n" + f"scipy.optimize.minimize: '{res.message}'", UserWarning) # # Transform the trajectory from flat outputs to states and inputs @@ -541,6 +538,11 @@ def traj_const(null_coeffs): # Create a trajectory object to store the result systraj = SystemTrajectory(sys, basis, params=params) + if cost is not None or trajectory_constraints is not None: + # Store the result of the optimization + systraj.cost = res.fun + systraj.success = res.success + systraj.message = res.message # Store the flag lengths and coefficients # TODO: make this more pythonic @@ -560,7 +562,7 @@ def traj_const(null_coeffs): # Solve a point to point trajectory generation problem for a flat system def solve_flat_ocp( - sys, timepts, x0=0, u0=0, basis=None, trajectory_cost=None, + sys, timepts, x0=0, u0=0, trajectory_cost=None, basis=None, terminal_cost=None, trajectory_constraints=None, initial_guess=None, params=None, **kwargs): """Compute trajectory between an initial and final conditions. @@ -619,6 +621,9 @@ def solve_flat_ocp( The constraints are applied at each time point along the trajectory. + initial_guess : 2D array_like, optional + Initial guess for the optimal trajectory of the flat outputs. + minimize_kwargs : str, optional Pass additional keywords to :func:`scipy.optimize.minimize`. @@ -631,9 +636,14 @@ def solve_flat_ocp( Notes ----- - Additional keyword parameters can be used to fine tune the behavior of - the underlying optimization function. See `minimize_*` keywords in - :func:`OptimalControlProblem` for more information. + 1. Additional keyword parameters can be used to fine tune the behavior + of the underlying optimization function. See `minimize_*` keywords + in :func:`OptimalControlProblem` for more information. + + 2. The return data structure includes the following additional attributes: + * success : bool indicating whether the optimization succeeded + * cost : computed cost of the returned trajectory + * message : message returned by optimization if success if False """ # @@ -705,7 +715,7 @@ def solve_flat_ocp( # essentially amounts to evaluating the basis functions and their # derivatives at the initial conditions. - # Compute the flags for the initial and final states + # Compute the flag for the initial state M_T0 = _basis_flag_matrix(sys, basis, zflag_T0, T0) # @@ -752,7 +762,7 @@ def traj_cost(null_coeffs): # Evaluate the cost at this time point # TODO: make use of time interval - costval += trajectory_cost(x, u) + costval += trajectory_cost(x, u) * (timepts[i+1] - timepts[i]) # Evaluate the terminal_cost if terminal_cost is not None: @@ -821,22 +831,37 @@ def traj_const(null_coeffs): # Add initial and terminal constraints # minimize_constraints += [sp.optimize.LinearConstraint(M, Z, Z)] - # Process the initial condition + # Process the initial guess if initial_guess is None: - initial_guess = np.zeros(M_T0.shape[1] - M_T0.shape[0]) + initial_coefs = np.ones(M_T0.shape[1] - M_T0.shape[0]) else: - raise NotImplementedError("Initial guess not yet implemented.") + # Compute the map from coefficients to flat outputs + initial_coefs = [] + for i in range(sys.ninputs): + M_z = np.array([ + basis.eval_deriv(j, 0, timepts, var=i) + for j in range(basis.var_ncoefs(i))]).transpose() + + # Compute the parameters that give the best least squares fit + coefs, _, _, _ = np.linalg.lstsq( + M_z, initial_guess[i], rcond=None) + initial_coefs.append(coefs) + initial_coefs = np.hstack(initial_coefs) + + # Project the parameters onto the independent variables + initial_coefs, _, _, _ = np.linalg.lstsq(N, initial_coefs, rcond=None) # Find the optimal solution res = sp.optimize.minimize( - traj_cost, initial_guess, constraints=minimize_constraints, + traj_cost, initial_coefs, constraints=minimize_constraints, **minimize_kwargs) - if res.success: - alpha += N @ res.x - else: - raise RuntimeError( - "Unable to solve optimal control problem\n" + - "scipy.optimize.minimize returned " + res.message) + alpha += N @ res.x + + # See if we got an answer + if not res.success: + warnings.warn( + "unable to solve optimal control problem\n" + f"scipy.optimize.minimize: '{res.message}'", UserWarning) # # Transform the trajectory from flat outputs to states and inputs @@ -844,6 +869,9 @@ def traj_const(null_coeffs): # Create a trajectory object to store the result systraj = SystemTrajectory(sys, basis, params=params) + systraj.cost = res.fun + systraj.success = res.success + systraj.message = res.message # Store the flag lengths and coefficients # TODO: make this more pythonic diff --git a/control/flatsys/poly.py b/control/flatsys/poly.py index bb3677c48..65d1b500d 100644 --- a/control/flatsys/poly.py +++ b/control/flatsys/poly.py @@ -50,12 +50,14 @@ class PolyFamily(BasisFamily): \phi_i(t) = t^i """ - def __init__(self, N): + def __init__(self, N, T=1.): """Create a polynomial basis of order N.""" super(PolyFamily, self).__init__(N) + self.T = T # Compute the kth derivative of the ith basis function at time t def eval_deriv(self, i, k, t, var=None): """Evaluate the kth derivative of the ith basis function at time t.""" if (i < k): return 0; # higher derivative than power - return factorial(i)/factorial(i-k) * np.power(t, i-k) + return factorial(i)/factorial(i-k) * \ + np.power(t/self.T, i-k) / np.power(self.T, k) diff --git a/control/tests/flatsys_test.py b/control/tests/flatsys_test.py index 710841d60..00f5827d3 100644 --- a/control/tests/flatsys_test.py +++ b/control/tests/flatsys_test.py @@ -11,29 +11,35 @@ import numpy as np import pytest import scipy as sp +import re import control as ct import control.flatsys as fs import control.optimal as opt +# Set tolerances for lower/upper bound tests +atol = 1e-4 +rtol = 1e-4 + class TestFlatSys: """Test differential flat systems""" @pytest.mark.parametrize( - "xf, uf, Tf", - [([1, 0], [0], 2), - ([0, 1], [0], 3), - ([1, 1], [1], 4)]) - def test_double_integrator(self, xf, uf, Tf): + " xf, uf, Tf, basis", + [([1, 0], [0], 2, fs.PolyFamily(6)), + ([0, 1], [0], 3, fs.PolyFamily(6)), + ([0, 1], [0], 3, fs.BezierFamily(6)), + ([0, 1], [0], 3, fs.BSplineFamily([0, 1.5, 3], 4)), + ([1, 1], [1], 4, fs.PolyFamily(6)), + ([1, 1], [1], 4, fs.BezierFamily(6)), + ([1, 1], [1], 4, fs.BSplineFamily([0, 1.5, 3], 4))]) + def test_double_integrator(self, xf, uf, Tf, basis): # Define a second order integrator sys = ct.StateSpace([[-1, 1], [0, -2]], [[0], [1]], [[1, 0]], 0) flatsys = fs.LinearFlatSystem(sys) - # Define the basis set - poly = fs.PolyFamily(6) - x1, u1, = [0, 0], [0] - traj = fs.point_to_point(flatsys, Tf, x1, u1, xf, uf, basis=poly) + traj = fs.point_to_point(flatsys, Tf, x1, u1, xf, uf, basis=basis) # Verify that the trajectory computation is correct x, u = traj.eval([0, Tf]) @@ -92,16 +98,19 @@ def vehicle_output(t, x, u, params): return x vehicle_output, inputs=('v', 'delta'), outputs=('x', 'y', 'theta'), states=('x', 'y', 'theta')) - @pytest.mark.parametrize("poly", [ - fs.PolyFamily(6), fs.PolyFamily(8), fs.BezierFamily(6)]) - def test_kinematic_car(self, vehicle_flat, poly): + @pytest.mark.parametrize("basis", [ + fs.PolyFamily(6), fs.PolyFamily(8), fs.BezierFamily(6), + fs.BSplineFamily([0, 10], 8), + fs.BSplineFamily([0, 5, 10], 4) + ]) + def test_kinematic_car(self, vehicle_flat, basis): # Define the endpoints of the trajectory x0 = [0., -2., 0.]; u0 = [10., 0.] xf = [100., 2., 0.]; uf = [10., 0.] Tf = 10 # Find trajectory between initial and final conditions - traj = fs.point_to_point(vehicle_flat, Tf, x0, u0, xf, uf, basis=poly) + traj = fs.point_to_point(vehicle_flat, Tf, x0, u0, xf, uf, basis=basis) # Verify that the trajectory computation is correct x, u = traj.eval([0, Tf]) @@ -111,16 +120,97 @@ def test_kinematic_car(self, vehicle_flat, poly): np.testing.assert_array_almost_equal(uf, u[:, 1]) # Simulate the system and make sure we stay close to desired traj + # Note: this can sometimes fail since system is open loop unstable T = np.linspace(0, Tf, 100) xd, ud = traj.eval(T) resp = ct.input_output_response(vehicle_flat, T, ud, x0) - np.testing.assert_array_almost_equal(resp.states, xd, decimal=2) + if not np.allclose(resp.states, xd, atol=1e-2, rtol=1e-2): + pytest.xfail("system is open loop unstable => errors can build") # integrate equations and compare to desired t, y, x = ct.input_output_response( vehicle_flat, T, ud, x0, return_x=True) np.testing.assert_allclose(x, xd, atol=0.01, rtol=0.01) + @pytest.mark.parametrize( + "basis, guess, constraints, method", [ + (fs.PolyFamily(8, T=10), 'prev', None, None), + (fs.BezierFamily(8, T=10), 'linear', None, None), + (fs.BSplineFamily([0, 10], 8), None, None, None), + (fs.BSplineFamily([0, 10], 8), 'prev', None, 'trust-constr'), + (fs.BSplineFamily([0, 10], [6, 8], vars=2), 'prev', None, None), + (fs.BSplineFamily([0, 5, 10], 5), 'linear', None, 'slsqp'), + (fs.BSplineFamily([0, 10], 8), None, ([8, -0.1], [12, 0.1]), None), + (fs.BSplineFamily([0, 5, 10], 5, 3), None, None, None), + ]) + def test_kinematic_car_ocp( + self, vehicle_flat, basis, guess, constraints, method): + + # Define the endpoints of the trajectory + x0 = [0., -2., 0.]; u0 = [10., 0.] + xf = [40., 2., 0.]; uf = [10., 0.] + Tf = 4 + timepts = np.linspace(0, Tf, 10) + + # Find trajectory between initial and final conditions + traj_p2p = fs.point_to_point( + vehicle_flat, Tf, x0, u0, xf, uf, basis=basis) + + # Verify that the trajectory computation is correct + x, u = traj_p2p.eval(timepts) + np.testing.assert_array_almost_equal(x0, x[:, 0]) + np.testing.assert_array_almost_equal(u0, u[:, 0]) + np.testing.assert_array_almost_equal(xf, x[:, -1]) + np.testing.assert_array_almost_equal(uf, u[:, -1]) + + # + # Re-solve as optimal control problem + # + + # Define the cost function (mainly penalize steering angle) + traj_cost = opt.quadratic_cost( + vehicle_flat, None, np.diag([0.1, 10]), x0=xf, u0=uf) + + # Set terminal cost to bring us close to xf + terminal_cost = opt.quadratic_cost( + vehicle_flat, 1e3 * np.eye(3), None, x0=xf) + + # Implement terminal constraints if specified + if constraints: + input_constraints = opt.input_range_constraint( + vehicle_flat, *constraints) + else: + input_constraints = None + + # Use a straight line as an initial guess for the trajectory + if guess == 'prev': + initial_guess = traj_p2p.eval(timepts)[0][0:2] + elif guess == 'linear': + initial_guess = np.array( + [x0[i] + (xf[i] - x0[i]) * timepts/Tf for i in (0, 1)]) + else: + initial_guess = None + + # Solve the optimal trajectory + traj_ocp = fs.solve_flat_ocp( + vehicle_flat, timepts, x0, u0, + cost=traj_cost, constraints=input_constraints, + terminal_cost=terminal_cost, basis=basis, + initial_guess=initial_guess, + minimize_kwargs={'method': method}, + ) + xd, ud = traj_ocp.eval(timepts) + if not traj_ocp.success: + # If unsuccessful, make sure the error is just about precision + assert re.match(".*precision loss.*", traj_ocp.message) is not None + + # Make sure the constraints are satisfied + if input_constraints: + _, _, lb, ub = input_constraints + for i in range(ud.shape[0]): + assert all(lb[i] - ud[i] < rtol * abs(lb[i]) + atol) + assert all(ud[i] - ub[i] < rtol * abs(ub[i]) + atol) + def test_flat_default_output(self, vehicle_flat): # Construct a flat system with the default outputs flatsys = fs.FlatSystem( @@ -134,9 +224,9 @@ def test_flat_default_output(self, vehicle_flat): Tf = 10 # Find trajectory between initial and final conditions - poly = fs.PolyFamily(6) - traj1 = fs.point_to_point(vehicle_flat, Tf, x0, u0, xf, uf, basis=poly) - traj2 = fs.point_to_point(flatsys, Tf, x0, u0, xf, uf, basis=poly) + basis = fs.PolyFamily(6) + traj1 = fs.point_to_point(vehicle_flat, Tf, x0, u0, xf, uf, basis=basis) + traj2 = fs.point_to_point(flatsys, Tf, x0, u0, xf, uf, basis=basis) # Verify that the trajectory computation is correct T = np.linspace(0, Tf, 10) @@ -152,7 +242,9 @@ def test_flat_default_output(self, vehicle_flat): @pytest.mark.parametrize("basis", [ fs.PolyFamily(8), - fs.BSplineFamily([0, 3, 7, 10], 4, 2)]) + fs.BSplineFamily([0, 5, 10], 6), + fs.BSplineFamily([0, 3, 7, 10], 4, 2) + ]) def test_flat_cost_constr(self, basis): # Double integrator system sys = ct.ss([[0, 1], [0, 0]], [[0], [1]], [[1, 0]], 0) @@ -208,19 +300,22 @@ def test_flat_cost_constr(self, basis): traj_const = fs.point_to_point( flat_sys, timepts, x0, u0, xf, uf, cost=cost_fcn, constraints=constraints, basis=basis, + # minimize_kwargs={'method': 'trust-constr'} ) + assert traj_const.success # Verify that the trajectory computation is correct - x_const, u_const = traj_const.eval(T) + x_cost, u_cost = traj_cost.eval(timepts) # re-eval on timepts + x_const, u_const = traj_const.eval(timepts) np.testing.assert_array_almost_equal(x0, x_const[:, 0]) np.testing.assert_array_almost_equal(u0, u_const[:, 0]) np.testing.assert_array_almost_equal(xf, x_const[:, -1]) np.testing.assert_array_almost_equal(uf, u_const[:, -1]) # Make sure that the solution respects the bounds (with some slop) - for i in range(x_const.shape[0]): - assert np.all(x_const[i] >= lb[i] * 1.02) - assert np.all(x_const[i] <= ub[i] * 1.02) + for i in range(x_const.shape[0]): + assert all(lb[i] - x_const[i] < rtol * abs(lb[i]) + atol) + assert all(x_const[i] - ub[i] < rtol * abs(ub[i]) + atol) # Solve the same problem with a nonlinear constraint type nl_constraints = [ @@ -229,9 +324,9 @@ def test_flat_cost_constr(self, basis): flat_sys, timepts, x0, u0, xf, uf, cost=cost_fcn, constraints=nl_constraints, basis=basis, ) - x_nlconst, u_nlconst = traj_nlconst.eval(T) - np.testing.assert_almost_equal(x_const, x_nlconst) - np.testing.assert_almost_equal(u_const, u_nlconst) + x_nlconst, u_nlconst = traj_nlconst.eval(timepts) + np.testing.assert_almost_equal(x_const, x_nlconst, decimal=2) + np.testing.assert_almost_equal(u_const, u_nlconst, decimal=2) @pytest.mark.parametrize("basis", [ # fs.PolyFamily(8), @@ -315,8 +410,8 @@ def test_flat_solve_ocp(self, basis): # Make sure that the solution respects the bounds (with some slop) for i in range(x_const.shape[0]): - assert np.all(x_const[i] >= lb[i] * 1.02) - assert np.all(x_const[i] <= ub[i] * 1.02) + assert all(lb[i] - x_const[i] < rtol * abs(lb[i]) + atol) + assert all(x_const[i] - ub[i] < rtol * abs(ub[i]) + atol) # Solve the same problem with a nonlinear constraint type # Use alternative keywords as well @@ -456,10 +551,11 @@ def test_point_to_point_errors(self): # Unsolvable optimization constraint = [opt.input_range_constraint(flat_sys, -0.01, 0.01)] - with pytest.raises(RuntimeError, match="Unable to solve optimal"): + with pytest.warns(UserWarning, match="unable to solve"): traj = fs.point_to_point( flat_sys, timepts, x0, u0, xf, uf, constraints=constraint, basis=fs.PolyFamily(8)) + assert not traj.success # Method arguments, parameters traj_method = fs.point_to_point( @@ -477,6 +573,81 @@ def test_point_to_point_errors(self): traj_method = fs.point_to_point( flat_sys, timepts, x0, u0, xf, uf, solve_ivp_method=None) + def test_solve_flat_ocp_errors(self): + """Test error and warning conditions in point_to_point()""" + # Double integrator system + sys = ct.ss([[0, 1], [0, 0]], [[0], [1]], [[1, 0]], 0) + flat_sys = fs.LinearFlatSystem(sys) + + # Define the endpoints of the trajectory + x0 = [1, 0]; u0 = [0] + xf = [0, 0]; uf = [0] + Tf = 10 + T = np.linspace(0, Tf, 500) + + # Cost function + timepts = np.linspace(0, Tf, 10) + cost_fcn = opt.quadratic_cost( + flat_sys, np.diag([1, 1]), 1, x0=xf, u0=uf) + + # Solving without basis specified should be OK + traj = fs.solve_flat_ocp(flat_sys, timepts, x0, u0, cost_fcn) + x, u = traj.eval(timepts) + np.testing.assert_array_almost_equal(x0, x[:, 0]) + if not traj.success: + # If unsuccessful, make sure the error is just about precision + assert re.match(".* precision loss.*", traj.message) is not None + + x, u = traj.eval(timepts) + np.testing.assert_array_almost_equal(x0, x[:, 0]) + np.testing.assert_array_almost_equal(u0, u[:, 0]) + + # Solving without a cost function generates an error + with pytest.raises(TypeError, match="cost required"): + traj = fs.solve_flat_ocp(flat_sys, timepts, x0, u0) + + # Try to optimize with insufficient degrees of freedom + with pytest.raises(ValueError, match="basis set is too small"): + traj = fs.solve_flat_ocp( + flat_sys, timepts, x0, u0, cost=cost_fcn, + basis=fs.PolyFamily(2)) + + # Solve with the errors in the various input arguments + with pytest.raises(ValueError, match="Initial state: Wrong shape"): + traj = fs.solve_flat_ocp( + flat_sys, timepts, np.zeros(3), u0, cost_fcn) + with pytest.raises(ValueError, match="Initial input: Wrong shape"): + traj = fs.solve_flat_ocp( + flat_sys, timepts, x0, np.zeros(3), cost_fcn) + + # Constraint that isn't a constraint + with pytest.raises(TypeError, match="must be a list"): + traj = fs.solve_flat_ocp( + flat_sys, timepts, x0, u0, cost_fcn, + constraints=np.eye(2), basis=fs.PolyFamily(8)) + + # Unknown constraint type + with pytest.raises(TypeError, match="unknown constraint type"): + traj = fs.solve_flat_ocp( + flat_sys, timepts, x0, u0, cost_fcn, + constraints=[(None, 0, 0, 0)], basis=fs.PolyFamily(8)) + + # Method arguments, parameters + traj_method = fs.solve_flat_ocp( + flat_sys, timepts, x0, u0, cost=cost_fcn, + basis=fs.PolyFamily(6), minimize_method='slsqp') + traj_kwarg = fs.solve_flat_ocp( + flat_sys, timepts, x0, u0, cost=cost_fcn, + basis=fs.PolyFamily(6), minimize_kwargs={'method': 'slsqp'}) + np.testing.assert_allclose( + traj_method.eval(timepts)[0], traj_kwarg.eval(timepts)[0], + atol=1e-5) + + # Unrecognized keywords + with pytest.raises(TypeError, match="unrecognized keyword"): + traj_method = fs.solve_flat_ocp( + flat_sys, timepts, x0, u0, cost_fcn, solve_ivp_method=None) + @pytest.mark.parametrize( "xf, uf, Tf", [([1, 0], [0], 2), @@ -488,10 +659,10 @@ def test_response(self, xf, uf, Tf): flatsys = fs.LinearFlatSystem(sys) # Define the basis set - poly = fs.PolyFamily(6) + basis = fs.PolyFamily(6) x1, u1, = [0, 0], [0] - traj = fs.point_to_point(flatsys, Tf, x1, u1, xf, uf, basis=poly) + traj = fs.point_to_point(flatsys, Tf, x1, u1, xf, uf, basis=basis) # Compute the response the regular way T = np.linspace(0, Tf, 10) diff --git a/control/tests/kwargs_test.py b/control/tests/kwargs_test.py index ada16a46a..598a8ccca 100644 --- a/control/tests/kwargs_test.py +++ b/control/tests/kwargs_test.py @@ -2,7 +2,7 @@ # RMM, 20 Mar 2022 # # Allowing unrecognized keywords to be passed to a function without -# generating and error message can generate annoying bugs, since you +# generating an error message can generate annoying bugs, since you # sometimes think you are telling the function to do something and actually # you have a misspelling or other error and your input is being ignored. # @@ -190,6 +190,8 @@ def test_matplotlib_kwargs(): 'tf2ss' : test_unrecognized_kwargs, 'flatsys.point_to_point': flatsys_test.TestFlatSys.test_point_to_point_errors, + 'flatsys.solve_flat_ocp': + flatsys_test.TestFlatSys.test_solve_flat_ocp_errors, 'FrequencyResponseData.__init__': frd_test.TestFRD.test_unrecognized_keyword, 'InputOutputSystem.__init__': test_unrecognized_kwargs, diff --git a/examples/kincar-flatsys.py b/examples/kincar-flatsys.py index 967bdb310..2ebee3133 100644 --- a/examples/kincar-flatsys.py +++ b/examples/kincar-flatsys.py @@ -74,12 +74,13 @@ def vehicle_update(t, x, u, params): return dx # Plot the trajectory in xy coordinates -def plot_results(t, x, ud): +def plot_results(t, x, ud, rescale=True): plt.subplot(4, 1, 2) plt.plot(x[0], x[1]) plt.xlabel('x [m]') plt.ylabel('y [m]') - plt.axis([x0[0], xf[0], x0[1]-1, xf[1]+1]) + if rescale: + plt.axis([x0[0], xf[0], x0[1]-1, xf[1]+1]) # Time traces of the state and input plt.subplot(2, 4, 5) @@ -94,7 +95,8 @@ def plot_results(t, x, ud): plt.plot(t, ud[0]) plt.xlabel('Time t [sec]') plt.ylabel('v [m/s]') - plt.axis([0, Tf, u0[0] - 1, uf[0] + 1]) + if rescale: + plt.axis([0, Tf, u0[0] - 1, uf[0] + 1]) plt.subplot(2, 4, 8) plt.plot(t, ud[1]) @@ -121,11 +123,11 @@ def plot_results(t, x, ud): poly = fs.PolyFamily(6) # Find a trajectory between the initial condition and the final condition -traj = fs.point_to_point(vehicle_flat, Tf, x0, u0, xf, uf, basis=poly) +traj1 = fs.point_to_point(vehicle_flat, Tf, x0, u0, xf, uf, basis=poly) # Create the desired trajectory between the initial and final condition T = np.linspace(0, Tf, 500) -xd, ud = traj.eval(T) +xd, ud = traj1.eval(T) # Simulation the open system dynamics with the full input t, y, x = ct.input_output_response( @@ -149,10 +151,10 @@ def plot_results(t, x, ud): vehicle_flat, np.diag([0, 0.1, 0]), np.diag([0.1, 1]), x0=xf, u0=uf) # Solve for an optimal solution -traj = fs.point_to_point( +traj2 = fs.point_to_point( vehicle_flat, timepts, x0, u0, xf, uf, cost=traj_cost, basis=basis, ) -xd, ud = traj.eval(T) +xd, ud = traj2.eval(T) plt.figure(2) plt.suptitle("Lane change with lateral error + steering penalties") @@ -164,15 +166,19 @@ def plot_results(t, x, ud): # Resolve the problem with constraints on the inputs # +# Constraint the input values constraints = [ opt.input_range_constraint(vehicle_flat, [8, -0.1], [12, 0.1]) ] +# TEST: Change the basis to use B-splines +basis = fs.BSplineFamily([0, Tf/2, Tf], 6) + # Solve for an optimal solution -traj = fs.point_to_point( +traj3 = fs.point_to_point( vehicle_flat, timepts, x0, u0, xf, uf, cost=traj_cost, constraints=constraints, basis=basis, ) -xd, ud = traj.eval(T) +xd, ud = traj3.eval(T) plt.figure(3) plt.suptitle("Lane change with penalty + steering constraints") @@ -181,3 +187,42 @@ def plot_results(t, x, ud): # Show the results unless we are running in batch mode if 'PYCONTROL_TEST_EXAMPLES' not in os.environ: plt.show() + + +# +# Approach #4: optimal trajectory, final cost with trajectory constraints +# +# Resolve the problem with constraints on the inputs and also replacing the +# point to point problem with one using a terminal cost to set the final +# state. +# + +# Define the cost function (mainly penalize steering angle) +traj_cost = opt.quadratic_cost( + vehicle_flat, None, np.diag([0.1, 10]), x0=xf, u0=uf) + +# Set terminal cost to bring us close to xf +terminal_cost = opt.quadratic_cost(vehicle_flat, 1e3 * np.eye(3), None, x0=xf) + +# Change the basis to use B-splines +basis = fs.BSplineFamily([0, Tf/2, Tf], [4, 6], vars=2) + +# Use a straight line as an initial guess for the trajectory +initial_guess = np.array( + [x0[i] + (xf[i] - x0[i]) * timepts/Tf for i in (0, 1)]) + +# Solve for an optimal solution +traj4 = fs.solve_flat_ocp( + vehicle_flat, timepts, x0, u0, cost=traj_cost, constraints=constraints, + terminal_cost=terminal_cost, basis=basis, initial_guess=initial_guess, + # minimize_kwargs={'method': 'trust-constr'}, +) +xd, ud = traj4.eval(T) + +plt.figure(4) +plt.suptitle("Lane change with terminal cost + steering constraints") +plot_results(T, xd, ud, rescale=False) # TODO: remove rescale + +# Show the results unless we are running in batch mode +if 'PYCONTROL_TEST_EXAMPLES' not in os.environ: + plt.show() From e65c4f29e1e4a8fa096859a032337fa02f9ee20f Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 19 Aug 2022 22:09:21 -0700 Subject: [PATCH 047/157] additional unit tests for coverage + bug fixes --- control/flatsys/basis.py | 16 ++++++++++--- control/tests/flatsys_test.py | 42 ++++++++++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/control/flatsys/basis.py b/control/flatsys/basis.py index baf2b926b..8a3a46af0 100644 --- a/control/flatsys/basis.py +++ b/control/flatsys/basis.py @@ -36,6 +36,8 @@ # OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF # SUCH DAMAGE. +import numpy as np + # Basis family class (for use as a base class) class BasisFamily: @@ -86,16 +88,24 @@ def eval(self, coeffs, tlist, var=None): sum([coeffs[i] * self(i, t) for i in range(self.N)]) for t in tlist] - else: - # Multi-variable basis + elif var is None: + # Multi-variable basis with single list of coefficients values = np.empty((self.nvars, tlist.size)) + offset = 0 for j in range(self.nvars): coef_len = self.var_ncoefs(j) values[j] = np.array([ - sum([coeffs[i] * self(i, t, var=j) + sum([coeffs[offset + i] * self(i, t, var=j) for i in range(coef_len)]) for t in tlist]) + offset += coef_len return values + else: + return np.array([ + sum([coeffs[i] * self(i, t, var=var) + for i in range(self.var_ncoefs(var))]) + for t in tlist]) + def eval_deriv(self, i, j, t, var=None): raise NotImplementedError("Internal error; improper basis functions") diff --git a/control/tests/flatsys_test.py b/control/tests/flatsys_test.py index 00f5827d3..e096c0c73 100644 --- a/control/tests/flatsys_test.py +++ b/control/tests/flatsys_test.py @@ -313,7 +313,7 @@ def test_flat_cost_constr(self, basis): np.testing.assert_array_almost_equal(uf, u_const[:, -1]) # Make sure that the solution respects the bounds (with some slop) - for i in range(x_const.shape[0]): + for i in range(x_const.shape[0]): assert all(lb[i] - x_const[i] < rtol * abs(lb[i]) + atol) assert all(x_const[i] - ub[i] < rtol * abs(ub[i]) + atol) @@ -673,3 +673,43 @@ def test_response(self, xf, uf, Tf): np.testing.assert_equal(T, response.time) np.testing.assert_equal(u, response.inputs) np.testing.assert_equal(x, response.states) + + @pytest.mark.parametrize( + "basis", + [fs.PolyFamily(4), + fs.BezierFamily(4), + fs.BSplineFamily([0, 1], 4), + fs.BSplineFamily([0, 1], 4, vars=2), + fs.BSplineFamily([0, 1], [4, 3], [2, 1], vars=2), + ]) + def test_basis_class(self, basis): + timepts = np.linspace(0, 1, 10) + + if basis.nvars is None: + # Evaluate function on basis vectors + for j in range(basis.N): + coefs = np.zeros(basis.N) + coefs[j] = 1 + np.testing.assert_equal( + basis.eval(coefs, timepts), + basis.eval_deriv(j, 0, timepts)) + else: + # Evaluate each variable on basis vectors + for i in range(basis.nvars): + for j in range(basis.var_ncoefs(i)): + coefs = np.zeros(basis.var_ncoefs(i)) + coefs[j] = 1 + np.testing.assert_equal( + basis.eval(coefs, timepts, var=i), + basis.eval_deriv(j, 0, timepts, var=i)) + + # Evaluate multi-variable output + offset = 0 + for i in range(basis.nvars): + for j in range(basis.var_ncoefs(i)): + coefs = np.zeros(basis.N) + coefs[offset] = 1 + np.testing.assert_equal( + basis.eval(coefs, timepts)[i], + basis.eval_deriv(j, 0, timepts, var=i)) + offset += 1 From 2901cbe4e3863083dfbe9a674235ef7cdd01c267 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 20 Aug 2022 09:36:35 -0700 Subject: [PATCH 048/157] updated flatsys documentation --- control/flatsys/__init__.py | 4 +- control/flatsys/basis.py | 2 + control/flatsys/bezier.py | 16 +++++-- control/flatsys/bspline.py | 63 +++++++++++++--------------- control/flatsys/flatsys.py | 11 ++++- control/flatsys/poly.py | 14 +++++-- control/tests/flatsys_test.py | 7 +++- doc/conf.py | 2 +- doc/flatsys.rst | 78 ++++++++++++++++++++++++++++------- 9 files changed, 137 insertions(+), 60 deletions(-) diff --git a/control/flatsys/__init__.py b/control/flatsys/__init__.py index 8ead08dce..157800073 100644 --- a/control/flatsys/__init__.py +++ b/control/flatsys/__init__.py @@ -46,7 +46,9 @@ defined using the :class:`~control.flatsys.BasisFamily` class. The resulting trajectory is return as a :class:`~control.flatsys.SystemTrajectory` object and can be evaluated using the :func:`~control.flatsys.SystemTrajectory.eval` -member function. +member function. Alternatively, the :func:`~control.flatsys.solve_flat_ocp` +function can be used to solve an optimal control problem with trajectory and +final costs or constraints. """ diff --git a/control/flatsys/basis.py b/control/flatsys/basis.py index 8a3a46af0..04abce88a 100644 --- a/control/flatsys/basis.py +++ b/control/flatsys/basis.py @@ -79,6 +79,7 @@ def var_ncoefs(self, var): return self.N if self.nvars is None else self.coef_length[var] def eval(self, coeffs, tlist, var=None): + """Compute function values given the coefficients and time points.""" if self.nvars is None and var != None: raise SystemError("multi-variable call to a scalar basis") @@ -108,4 +109,5 @@ def eval(self, coeffs, tlist, var=None): for t in tlist]) def eval_deriv(self, i, j, t, var=None): + """Evaluate the kth derivative of the ith basis function at time t.""" raise NotImplementedError("Internal error; improper basis functions") diff --git a/control/flatsys/bezier.py b/control/flatsys/bezier.py index 241274268..02b4209a6 100644 --- a/control/flatsys/bezier.py +++ b/control/flatsys/bezier.py @@ -48,15 +48,23 @@ class BezierFamily(BasisFamily): This class represents the family of polynomials of the form .. math:: - \phi_i(t) = \sum_{i=0}^n {n \choose i} - \left( \frac{t}{T_\text{f}} - t \right)^{n-i} - \left( \frac{t}{T_f} \right)^i + \phi_i(t) = \sum_{i=0}^N {N \choose i} + \left( \frac{t}{T} - t \right)^{N-i} + \left( \frac{t}{T} \right)^i + + Parameters + ---------- + N : int + Degree of the Bezier curve. + + T : float + Final time (used for rescaling). """ def __init__(self, N, T=1): """Create a polynomial basis of order N.""" super(BezierFamily, self).__init__(N) - self.T = T # save end of time interval + self.T = float(T) # save end of time interval # Compute the kth derivative of the ith basis function at time t def eval_deriv(self, i, k, t, var=None): diff --git a/control/flatsys/bspline.py b/control/flatsys/bspline.py index 12cf49431..c771beb59 100644 --- a/control/flatsys/bspline.py +++ b/control/flatsys/bspline.py @@ -14,40 +14,35 @@ class BSplineFamily(BasisFamily): """B-spline basis functions. This class represents a B-spline basis for piecewise polynomials defined - across a set of breakpoints with given order and smoothness. + across a set of breakpoints with given degree and smoothness. On each + interval between two breakpoints, we have a polynomial of a given degree + and the spline is continuous up to a given smoothness at interior + breakpoints. + + Parameters + ---------- + breakpoints : 1D array or 2D array of float + The breakpoints for the spline(s). + + degree : int or list of ints + For each spline variable, the degree of the polynomial between + breakpoints. If a single number is given and more than one spline + variable is specified, the same degree is used for each spline + variable. + + smoothness : int or list of ints + For each spline variable, the smoothness at breakpoints (number of + derivatives that should match). + + vars : None or int, optional + The number of spline variables. If specified as None (default), + then the spline basis describes a single variable, with no indexing. + If the number of spine variables is > 0, then the spline basis is + index using the `var` keyword. """ def __init__(self, breakpoints, degree, smoothness=None, vars=None): - """Create a B-spline basis for piecewise smooth polynomials - - Define B-spline polynomials for a set of one or more variables. - B-splines are used as a basis for a set of piecewise smooth - polynomials joined at breakpoints. On each interval we have a - polynomial of a given order and the spline is continuous up to a - given smoothness at interior breakpoints. - - Parameters - ---------- - breakpoints : 1D array or 2D array of float - The breakpoints for the spline(s). - - degree : int or list of ints - For each spline variable, the degree of the polynomial between - break points. If a single number is given and more than one - spline variable is specified, the same order is used for each - spline variable. - - smoothness : int or list of ints - For each spline variable, the smoothness at breakpoints (number - of derivatives that should match). - - vars : None or int, optional - The number of spline variables. If specified as None (default), - then the spline basis describes a single variable, with no - indexing. If the number of spine variables is > 0, then the - spline basis is index using the `var` keyword. - - """ + """Create a B-spline basis for piecewise smooth polynomials.""" # Process the breakpoints for the spline */ breakpoints = np.array(breakpoints, dtype=float) if breakpoints.ndim == 2: @@ -71,11 +66,11 @@ def __init__(self, breakpoints, degree, smoothness=None, vars=None): self.nvars = nvars # - # Process B-spline parameters (order, smoothness) + # Process B-spline parameters (degree, smoothness) # # B-splines are defined on a set of intervals separated by # breakpoints. On each interval we have a polynomial of a certain - # order and the spline is continuous up to a given smoothness at + # degree and the spline is continuous up to a given smoothness at # breakpoints. The code in this section allows some flexibility in # the way that all of this information is supplied, including using # scalar values for parameters (which are then broadcast to each @@ -83,7 +78,7 @@ def __init__(self, breakpoints, degree, smoothness=None, vars=None): # information, when possible. # - # Utility function for broadcasting spline params (order, smoothness) + # Utility function for broadcasting spline params (degree, smoothness) def process_spline_parameters( values, length, allowed_types, minimum=0, default=None, name='unknown'): diff --git a/control/flatsys/flatsys.py b/control/flatsys/flatsys.py index 78d7696f9..849c41c72 100644 --- a/control/flatsys/flatsys.py +++ b/control/flatsys/flatsys.py @@ -61,8 +61,10 @@ class FlatSystem(NonlinearIOSystem): ---------- forward : callable A function to compute the flat flag given the states and input. + reverse : callable A function to compute the states and input given the flat flag. + updfcn : callable, optional Function returning the state update function @@ -73,6 +75,7 @@ class FlatSystem(NonlinearIOSystem): time, and `param` is an optional dict containing the values of parameters used by the function. If not specified, the state space update will be computed using the flat system coordinates. + outfcn : callable Function returning the output at the given state @@ -80,6 +83,7 @@ class FlatSystem(NonlinearIOSystem): where the arguments are the same as for `upfcn`. If not specified, the output will be the flat outputs. + inputs : int, list of str, or None Description of the system inputs. This can be given as an integer count or as a list of strings that name the individual signals. @@ -88,19 +92,24 @@ class FlatSystem(NonlinearIOSystem): this parameter is not given or given as `None`, the relevant quantity will be determined when possible based on other information provided to functions using the system. + outputs : int, list of str, or None Description of the system outputs. Same format as `inputs`. + states : int, list of str, or None Description of the system states. Same format as `inputs`. + dt : None, True or float, optional System timebase. None (default) indicates continuous time, True indicates discrete time with undefined sampling time, positive number is discrete time with specified sampling time. + params : dict, optional Parameter values for the systems. Passed to the evaluation functions for the system as default values, overriding internal defaults. + name : string, optional System name (used for specifying signals) @@ -638,7 +647,7 @@ def solve_flat_ocp( ----- 1. Additional keyword parameters can be used to fine tune the behavior of the underlying optimization function. See `minimize_*` keywords - in :func:`OptimalControlProblem` for more information. + in :func:`~control.optimal.OptimalControlProblem` for more information. 2. The return data structure includes the following additional attributes: * success : bool indicating whether the optimization succeeded diff --git a/control/flatsys/poly.py b/control/flatsys/poly.py index 65d1b500d..bfd8de633 100644 --- a/control/flatsys/poly.py +++ b/control/flatsys/poly.py @@ -47,13 +47,21 @@ class PolyFamily(BasisFamily): This class represents the family of polynomials of the form .. math:: - \phi_i(t) = t^i + \phi_i(t) = \left( \frac{t}{T} \right)^i + + Parameters + ---------- + N : int + Degree of the Bezier curve. + + T : float + Final time (used for rescaling). """ - def __init__(self, N, T=1.): + def __init__(self, N, T=1): """Create a polynomial basis of order N.""" super(PolyFamily, self).__init__(N) - self.T = T + self.T = float(T) # save end of time interval # Compute the kth derivative of the ith basis function at time t def eval_deriv(self, i, k, t, var=None): diff --git a/control/tests/flatsys_test.py b/control/tests/flatsys_test.py index e096c0c73..665bfd968 100644 --- a/control/tests/flatsys_test.py +++ b/control/tests/flatsys_test.py @@ -12,6 +12,7 @@ import pytest import scipy as sp import re +import warnings import control as ct import control.flatsys as fs @@ -590,8 +591,10 @@ def test_solve_flat_ocp_errors(self): cost_fcn = opt.quadratic_cost( flat_sys, np.diag([1, 1]), 1, x0=xf, u0=uf) - # Solving without basis specified should be OK - traj = fs.solve_flat_ocp(flat_sys, timepts, x0, u0, cost_fcn) + # Solving without basis specified should be OK (may generate warning) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + traj = fs.solve_flat_ocp(flat_sys, timepts, x0, u0, cost_fcn) x, u = traj.eval(timepts) np.testing.assert_array_almost_equal(x0, x[:, 0]) if not traj.success: diff --git a/doc/conf.py b/doc/conf.py index 19c2970e1..e2210aeaa 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -87,7 +87,7 @@ # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = 'en' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. diff --git a/doc/flatsys.rst b/doc/flatsys.rst index 7599dd2af..ab8d7bf4c 100644 --- a/doc/flatsys.rst +++ b/doc/flatsys.rst @@ -64,17 +64,17 @@ trajectory of the system. We can parameterize the flat output trajectory using a set of smooth basis functions :math:`\psi_i(t)`: .. math:: - z(t) = \sum_{i=1}^N \alpha_i \psi_i(t), \qquad \alpha_i \in R + z(t) = \sum_{i=1}^N c_i \psi_i(t), \qquad c_i \in R -We seek a set of coefficients :math:`\alpha_i`, :math:`i = 1, \dots, N` such +We seek a set of coefficients :math:`c_i`, :math:`i = 1, \dots, N` such that :math:`z(t)` satisfies the boundary conditions for :math:`x(0)` and :math:`x(T)`. The derivatives of the flat output can be computed in terms of the derivatives of the basis functions: .. math:: - \dot z(t) &= \sum_{i=1}^N \alpha_i \dot \psi_i(t) \\ + \dot z(t) &= \sum_{i=1}^N c_i \dot \psi_i(t) \\ &\,\vdots \\ - \dot z^{(q)}(t) &= \sum_{i=1}^N \alpha_i \psi^{(q)}_i(t). + \dot z^{(q)}(t) &= \sum_{i=1}^N c_i \psi^{(q)}_i(t). We can thus write the conditions on the flat outputs and their derivatives as @@ -90,7 +90,7 @@ derivatives as \vdots & \vdots & & \vdots \\ \psi^{(q)}_1(T) & \psi^{(q)}_2(T) & \dots & \psi^{(q)}_N(T) \\ \end{bmatrix} - \begin{bmatrix} \alpha_1 \\ \vdots \\ \alpha_N \end{bmatrix} = + \begin{bmatrix} c_1 \\ \vdots \\ c_N \end{bmatrix} = \begin{bmatrix} z(0) \\ \dot z(0) \\ \vdots \\ z^{(q)}(0) \\[1ex] z(T) \\ \dot z(T) \\ \vdots \\ z^{(q)}(T) \\ @@ -99,7 +99,7 @@ derivatives as This equation is a *linear* equation of the form .. math:: - M \alpha = \begin{bmatrix} \bar z(0) \\ \bar z(T) \end{bmatrix} + M c = \begin{bmatrix} \bar z(0) \\ \bar z(T) \end{bmatrix} where :math:`\bar z` is called the *flat flag* for the system. Assuming that :math:`M` has a sufficient number of columns and that it is full @@ -139,22 +139,28 @@ For a linear system, a flat system representation can be generated using the For more general systems, the `FlatSystem` object must be created manually:: - sys = control.flatsys.FlatSystem(nstate, ninputs, forward, reverse) + sys = control.flatsys.FlatSystem( + forward, reverse, states=['x1', ..., 'xn'], inputs=['u1', ..., 'um']) In addition to the flat system description, a set of basis functions -:math:`\phi_i(t)` must be chosen. The `FlatBasis` class is used to represent -the basis functions. A polynomial basis function of the form 1, :math:`t`, -:math:`t^2`, ... can be computed using the `PolyBasis` class, which is -initialized by passing the desired order of the polynomial basis set:: +:math:`\phi_i(t)` must be chosen. The `FlatBasis` class is used to +represent the basis functions. A polynomial basis function of the +form 1, :math:`t`, :math:`t^2`, ... can be computed using the +:class:`~control.flatsys.PolyFamily` class, which is initialized by +passing the desired order of the polynomial basis set:: - polybasis = control.flatsys.PolyBasis(N) + basis = control.flatsys.PolyFamily(N) + +Additional basis function families include Bezier curves +(:class:`~control.flatsys.BezierFamily`) and B-splines +(:class:`~control.flatsys.BSplineFamily`). Once the system and basis function have been defined, the :func:`~control.flatsys.point_to_point` function can be used to compute a trajectory between initial and final states and inputs:: traj = control.flatsys.point_to_point( - sys, Tf, x0, u0, xf, uf, basis=polybasis) + sys, Tf, x0, u0, xf, uf, basis=basis) The returned object has class :class:`~control.flatsys.SystemTrajectory` and can be used to compute the state and input trajectory between the initial and @@ -169,6 +175,18 @@ The :func:`~control.flatsys.point_to_point` function also allows the specification of a cost function and/or constraints, in the same format as :func:`~control.optimal.solve_ocp`. +The :func:`~control.flatsys.solve_flat_ocp` function can be used to +solve an optimal control problem without a final state:: + + traj = control.flatsys.solve_flat_ocp( + sys, timepts, x0, u0, cost, basis=basis) + +The `cost` parameter is a function function with call signature +`cost(x, u)` and should return the (incremental) cost at the given +state, and input. It will be evaluated at each point in the `timepts` +vector. The `terminal_cost` parameter can be used to specify a cost +function for the final point in the trajectory. + Example ======= @@ -179,7 +197,9 @@ derived *Feedback Systems* by Astrom and Murray, Example 3.11. .. code-block:: python + import control as ct import control.flatsys as fs + import numpy as np # Function to take states, inputs and return the flat flag def vehicle_flat_forward(x, u, params={}): @@ -228,7 +248,8 @@ derived *Feedback Systems* by Astrom and Murray, Example 3.11. return x, u vehicle_flat = fs.FlatSystem( - 3, 2, forward=vehicle_flat_forward, reverse=vehicle_flat_reverse) + vehicle_flat_forward, vehicle_flat_reverse, + inputs=('v', 'delta'), outputs=('x', 'y'), states=('x', 'y', 'theta')) To find a trajectory from an initial state :math:`x_0` to a final state :math:`x_\text{f}` in time :math:`T_\text{f}` we solve a point-to-point @@ -253,6 +274,33 @@ the endpoints. t = np.linspace(0, Tf, 100) x, u = traj.eval(t) +Alternatively, we can solve an optimal control problem in which we +minimize a cost function along the trajectory as well as a terminal +cost:` + +.. code-block:: python + + # Define the cost along the trajectory: penalize steering angle + traj_cost = ct.optimal.quadratic_cost( + vehicle_flat, None, np.diag([0.1, 10]), u0=uf) + + # Define the terminal cost: penalize distance from the end point + term_cost = ct.optimal.quadratic_cost( + vehicle_flat, np.diag([1e3, 1e3, 1e3]), None, x0=xf) + + # Use a straight line as the initial guess + timepts = np.linspace(0, Tf, 10) + initial_guess = np.array( + [x0[i] + (xf[i] - x0[i]) * timepts/Tf for i in (0, 1)]) + + # Solve the optimal control problem, evaluating cost at timepts + bspline = fs.BSplineFamily([0, Tf/2, Tf], 4) + traj = fs.solve_flat_ocp( + vehicle_flat, timepts, x0, u0, traj_cost, + terminal_cost=term_cost, initial_guess=initial_guess, basis=bspline) + + x, u = traj.eval(t) + Module classes and functions ============================ @@ -262,6 +310,7 @@ Module classes and functions ~control.flatsys.BasisFamily ~control.flatsys.BezierFamily + ~control.flatsys.BSplineFamily ~control.flatsys.FlatSystem ~control.flatsys.LinearFlatSystem ~control.flatsys.PolyFamily @@ -271,3 +320,4 @@ Module classes and functions :toctree: generated/ ~control.flatsys.point_to_point + ~control.flatsys.solve_flat_ocp From 4c954d21450ccfad4a13a98c1d08ddcad880cdff Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Wed, 24 Aug 2022 21:09:40 -0700 Subject: [PATCH 049/157] remove slycotonly from selected MIMO tests --- control/tests/matlab_test.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/control/tests/matlab_test.py b/control/tests/matlab_test.py index a379ce7f0..e5259c75b 100644 --- a/control/tests/matlab_test.py +++ b/control/tests/matlab_test.py @@ -208,7 +208,6 @@ def testStep(self, siso): np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) np.testing.assert_array_almost_equal(tout, t) - @slycotonly def testStep_mimo(self, mimo): """Test step for MIMO system""" sys = mimo.ss1 @@ -267,7 +266,6 @@ def testImpulse(self, siso): np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) np.testing.assert_array_almost_equal(tout, t) - @slycotonly def testImpulse_mimo(self, mimo): """Test impulse() for MIMO system""" t = np.linspace(0, 1, 10) @@ -296,7 +294,6 @@ def testInitial(self, siso): np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) np.testing.assert_array_almost_equal(tout, t) - @slycotonly def testInitial_mimo(self, mimo): """Test initial() for MIMO system""" t = np.linspace(0, 1, 10) @@ -333,7 +330,6 @@ def testLsim(self, siso): yout, _t, _xout = lsim(siso.ss1, u, t, x0) np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) - @slycotonly def testLsim_mimo(self, mimo): """Test lsim() for MIMO system. @@ -582,7 +578,6 @@ def testSISOssdata(self, siso): for i in range(len(ssdata_1)): np.testing.assert_array_almost_equal(ssdata_1[i], ssdata_2[i]) - @slycotonly def testMIMOssdata(self, mimo): """Test ssdata() MIMO""" m = (mimo.ss1.A, mimo.ss1.B, mimo.ss1.C, mimo.ss1.D) From 6e3113a0b82eec20712211b6ed77f2cc3692fa26 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Wed, 24 Aug 2022 21:37:13 -0700 Subject: [PATCH 050/157] remove bad dimension check in forced_response for dtime systems --- control/tests/matlab_test.py | 19 +++++++++++++++++++ control/tests/timeresp_test.py | 4 ++-- control/timeresp.py | 5 ----- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/control/tests/matlab_test.py b/control/tests/matlab_test.py index e5259c75b..abf86ce44 100644 --- a/control/tests/matlab_test.py +++ b/control/tests/matlab_test.py @@ -348,6 +348,25 @@ def testLsim_mimo(self, mimo): yout, _t, _xout = lsim(mimo.ss1, u, t, x0) np.testing.assert_array_almost_equal(yout, youttrue, decimal=4) + def test_lsim_mimo_dtime(self): + # https://github.com/python-control/python-control/issues/764 + time = np.linspace(0.0, 511.0e-6, 512) + DAC = np.sin(time) + ADC = np.cos(time) + + input_Kalman = np.transpose( + np.concatenate(([[DAC]], [[ADC]]), axis=1)[0]) + Af = [[0.45768416, -0.42025511], [-0.43354791, 0.51961178]] + Bf = [[2.84368641, 52.05922305], [-1.47286557, -19.94861943]] + Cf = [[1.0, 0.0], [0.0, 1.0]] + Df = [[0.0, 0.0], [0.0, 0.0]] + + ss_Kalman = ss(Af, Bf, Cf, Df, 1.0e-6) + y_est, t, x_est = lsim(ss_Kalman, input_Kalman, time) + assert y_est.shape == (time.size, ss_Kalman.ninputs) + assert t.shape == (time.size, ) + assert x_est.shape == (time.size, ss_Kalman.nstates) + def testMargin(self, siso): """Test margin()""" #! TODO: check results to make sure they are OK diff --git a/control/tests/timeresp_test.py b/control/tests/timeresp_test.py index ff712f849..124e16c1e 100644 --- a/control/tests/timeresp_test.py +++ b/control/tests/timeresp_test.py @@ -698,10 +698,10 @@ def test_forced_response_invalid_d(self, tsystem): """Test invalid parameters dtime with sys.dt > 0.""" with pytest.raises(ValueError, match="can't both be zero"): forced_response(tsystem.sys) - with pytest.raises(ValueError, match="must have same elements"): + with pytest.raises(ValueError, match="Parameter ``U``: Wrong shape"): forced_response(tsystem.sys, T=tsystem.t, U=np.random.randn(1, 12)) - with pytest.raises(ValueError, match="must have same elements"): + with pytest.raises(ValueError, match="Parameter ``U``: Wrong shape"): forced_response(tsystem.sys, T=tsystem.t, U=np.random.randn(12)) with pytest.raises(ValueError, match="must match sampling time"): diff --git a/control/timeresp.py b/control/timeresp.py index aa1261ccd..e029f2a44 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -972,11 +972,6 @@ def forced_response(sys, T=None, U=0., X0=0., transpose=False, dt = 1. if sys.dt in [True, None] else sys.dt T = np.array(range(n_steps)) * dt else: - # Make sure the input vector and time vector have same length - if (U.ndim == 1 and U.shape[0] != T.shape[0]) or \ - (U.ndim > 1 and U.shape[1] != T.shape[0]): - raise ValueError('Parameter ``T`` must have same elements as' - ' the number of columns in input array ``U``') if U.ndim == 0: U = np.full((n_inputs, T.shape[0]), U) else: From 47262f5ceaf5fc5a7d8c6947d9d24c03e3078fd6 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sun, 28 Aug 2022 20:52:47 -0700 Subject: [PATCH 051/157] update coeff handling to allow multi-variable basis --- control/optimal.py | 65 ++++++++++++++++------------------- control/tests/optimal_test.py | 21 ++++++----- 2 files changed, 43 insertions(+), 43 deletions(-) diff --git a/control/optimal.py b/control/optimal.py index da1bdcb8e..4913cc341 100644 --- a/control/optimal.py +++ b/control/optimal.py @@ -268,17 +268,16 @@ def _cost_function(self, coeffs): start_time = time.process_time() logging.info("_cost_function called at: %g", start_time) - # Retrieve the initial state and reshape the input vector + # Retrieve the saved initial state x = self.x - coeffs = coeffs.reshape((self.system.ninputs, -1)) - # Compute time points (if basis present) + # Compute inputs if self.basis: if self.log: logging.debug("coefficients = " + str(coeffs)) inputs = self._coeffs_to_inputs(coeffs) else: - inputs = coeffs + inputs = coeffs.reshape((self.system.ninputs, -1)) # See if we already have a simulation for this condition if np.array_equal(coeffs, self.last_coeffs) and \ @@ -391,15 +390,14 @@ def _constraint_function(self, coeffs): start_time = time.process_time() logging.info("_constraint_function called at: %g", start_time) - # Retrieve the initial state and reshape the input vector + # Retrieve the initial state x = self.x - coeffs = coeffs.reshape((self.system.ninputs, -1)) - # Compute time points (if basis present) + # Compute input at time points if self.basis: inputs = self._coeffs_to_inputs(coeffs) else: - inputs = coeffs + inputs = coeffs.reshape((self.system.ninputs, -1)) # See if we already have a simulation for this condition if np.array_equal(coeffs, self.last_coeffs) \ @@ -473,15 +471,14 @@ def _eqconst_function(self, coeffs): start_time = time.process_time() logging.info("_eqconst_function called at: %g", start_time) - # Retrieve the initial state and reshape the input vector + # Retrieve the initial state x = self.x - coeffs = coeffs.reshape((self.system.ninputs, -1)) - # Compute time points (if basis present) + # Compute input at time points if self.basis: inputs = self._coeffs_to_inputs(coeffs) else: - inputs = coeffs + inputs = coeffs.reshape((self.system.ninputs, -1)) # See if we already have a simulation for this condition if np.array_equal(coeffs, self.last_coeffs) and \ @@ -609,34 +606,36 @@ def _inputs_to_coeffs(self, inputs): return inputs # Solve least squares problems (M x = b) for coeffs on each input - coeffs = np.zeros((self.system.ninputs, self.basis.N)) + coeffs = [] for i in range(self.system.ninputs): # Set up the matrices to get inputs - M = np.zeros((self.timepts.size, self.basis.N)) + M = np.zeros((self.timepts.size, self.basis.var_ncoefs(i))) b = np.zeros(self.timepts.size) # Evaluate at each time point and for each basis function # TODO: vectorize for j, t in enumerate(self.timepts): - for k in range(self.basis.N): + for k in range(self.basis.var_ncoefs(i)): M[j, k] = self.basis(k, t) - b[j] = inputs[i, j] + b[j] = inputs[i, j] # Solve a least squares problem for the coefficients alpha, residuals, rank, s = np.linalg.lstsq(M, b, rcond=None) - coeffs[i, :] = alpha + coeffs.append(alpha) - return coeffs + return np.hstack(coeffs) # Utility function to convert coefficient vector to input vector def _coeffs_to_inputs(self, coeffs): # TODO: vectorize inputs = np.zeros((self.system.ninputs, self.timepts.size)) - for i, t in enumerate(self.timepts): - for k in range(self.basis.N): - phi_k = self.basis(k, t) - for inp in range(self.system.ninputs): - inputs[inp, i] += coeffs[inp, k] * phi_k + offset = 0 + for i in range(self.system.ninputs): + length = self.basis.var_ncoefs(i) + for j, t in enumerate(self.timepts): + for k in range(length): + inputs[i, j] += coeffs[offset + k] * self.basis(k, t) + offset += length return inputs # @@ -680,7 +679,7 @@ def _print_statistics(self, reset=True): # Compute the optimal trajectory from the current state def compute_trajectory( - self, x, squeeze=None, transpose=None, return_states=None, + self, x, squeeze=None, transpose=None, return_states=True, initial_guess=None, print_summary=True, **kwargs): """Compute the optimal input at state x @@ -689,8 +688,7 @@ def compute_trajectory( x : array-like or number, optional Initial state for the system. return_states : bool, optional - If True, return the values of the state at each time (default = - False). + If True (default), return the values of the state at each time. squeeze : bool, optional If True and if the system has a single output, return the system output as a 1D array rather than a 2D array. If False, return the @@ -837,7 +835,7 @@ class OptimalControlResult(sp.optimize.OptimizeResult): """ def __init__( - self, ocp, res, return_states=False, print_summary=False, + self, ocp, res, return_states=True, print_summary=False, transpose=None, squeeze=None): """Create a OptimalControlResult object""" @@ -848,14 +846,11 @@ def __init__( # Remember the optimal control problem that we solved self.problem = ocp - # Reshape and process the input vector - coeffs = res.x.reshape((ocp.system.ninputs, -1)) - - # Compute time points (if basis present) + # Compute input at time points if ocp.basis: - inputs = ocp._coeffs_to_inputs(coeffs) + inputs = ocp._coeffs_to_inputs(res.x) else: - inputs = coeffs + inputs = res.x.reshape((ocp.system.ninputs, -1)) # See if we got an answer if not res.success: @@ -894,7 +889,7 @@ def __init__( def solve_ocp( sys, horizon, X0, cost, trajectory_constraints=None, terminal_cost=None, terminal_constraints=[], initial_guess=None, basis=None, squeeze=None, - transpose=None, return_states=False, log=False, **kwargs): + transpose=None, return_states=True, log=False, **kwargs): """Compute the solution to an optimal control problem @@ -949,7 +944,7 @@ def solve_ocp( If `True`, turn on logging messages (using Python logging module). return_states : bool, optional - If True, return the values of the state at each time (default = False). + If True, return the values of the state at each time (default = True). squeeze : bool, optional If True and if the system has a single output, return the system diff --git a/control/tests/optimal_test.py b/control/tests/optimal_test.py index 027b55c75..b100e7e14 100644 --- a/control/tests/optimal_test.py +++ b/control/tests/optimal_test.py @@ -300,8 +300,9 @@ def test_terminal_constraints(sys_args): np.testing.assert_almost_equal(res.inputs, u1) # Re-run using a basis function and see if we get the same answer - res = opt.solve_ocp(sys, time, x0, cost, terminal_constraints=final_point, - basis=flat.BezierFamily(8, Tf)) + res = opt.solve_ocp( + sys, time, x0, cost, terminal_constraints=final_point, + basis=flat.BezierFamily(8, Tf)) # Final point doesn't affect cost => don't need to test np.testing.assert_almost_equal( @@ -471,8 +472,12 @@ def test_ocp_argument_errors(): sys, time, x0, cost, terminal_constraints=constraints) -def test_optimal_basis_simple(): - sys = ct.ss2io(ct.ss([[1, 1], [0, 1]], [[1], [0.5]], np.eye(2), 0, 1)) +@pytest.mark.parametrize("basis", [ + flat.PolyFamily(4), flat.PolyFamily(6), + flat.BezierFamily(4), flat.BSplineFamily([0, 4, 8], 6) + ]) +def test_optimal_basis_simple(basis): + sys = ct.ss([[1, 1], [0, 1]], [[1], [0.5]], np.eye(2), 0, 1) # State and input constraints constraints = [ @@ -492,7 +497,7 @@ def test_optimal_basis_simple(): # Basic optimal control problem res1 = opt.solve_ocp( sys, time, x0, cost, constraints, - basis=flat.BezierFamily(4, Tf), return_x=True) + terminal_cost=cost, basis=basis, return_x=True) assert res1.success # Make sure the constraints were satisfied @@ -503,14 +508,14 @@ def test_optimal_basis_simple(): # Pass an initial guess and rerun res2 = opt.solve_ocp( sys, time, x0, cost, constraints, initial_guess=0.99*res1.inputs, - basis=flat.BezierFamily(4, Tf), return_x=True) + terminal_cost=cost, basis=basis, return_x=True) assert res2.success np.testing.assert_allclose(res2.inputs, res1.inputs, atol=0.01, rtol=0.01) # Run with logging turned on for code coverage res3 = opt.solve_ocp( - sys, time, x0, cost, constraints, - basis=flat.BezierFamily(4, Tf), return_x=True, log=True) + sys, time, x0, cost, constraints, terminal_cost=cost, + basis=basis, return_x=True, log=True) assert res3.success np.testing.assert_almost_equal(res3.inputs, res1.inputs, decimal=3) From 86959e38ad3be6cfe35bf29da0f01b0acb4282bf Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 9 Sep 2022 09:02:35 -0700 Subject: [PATCH 052/157] CI: switch slycot and cvxopt installation order --- .github/workflows/python-package-conda.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-package-conda.yml b/.github/workflows/python-package-conda.yml index ae889fd05..459f9e69d 100644 --- a/.github/workflows/python-package-conda.yml +++ b/.github/workflows/python-package-conda.yml @@ -50,15 +50,15 @@ jobs: - name: Install optional dependencies shell: bash -l {0} run: | + if [[ '${{matrix.cvxopt}}' == 'conda' ]]; then + mamba install cvxopt + fi if [[ '${{matrix.slycot}}' == 'conda' ]]; then mamba install slycot fi if [[ '${{matrix.pandas}}' == 'conda' ]]; then mamba install pandas fi - if [[ '${{matrix.cvxopt}}' == 'conda' ]]; then - mamba install cvxopt - fi - name: Test with pytest shell: bash -l {0} From c1f18689af280918800463873ec3eee9607d2c95 Mon Sep 17 00:00:00 2001 From: Hang Yu Date: Mon, 3 Oct 2022 13:47:29 -0700 Subject: [PATCH 053/157] Move sys._update_params(params) ahead of TimeResponseData return when ntates==0 --- control/iosys.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index ce717c0fb..437c73d09 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -1779,6 +1779,9 @@ def input_output_response( else: noutputs = sys.noutputs + # Update the parameter values + sys._update_params(params) + # # Define a function to evaluate the input at an arbitrary time # @@ -1816,9 +1819,6 @@ def ufun(t): output_labels=sys.output_index, input_labels=sys.input_index, transpose=transpose, return_x=return_x, squeeze=squeeze) - # Update the parameter values - sys._update_params(params) - # Create a lambda function for the right hand side def ivp_rhs(t, x): return sys._rhs(t, x, ufun(t)) From ffee2acf192060ea207be05891e2d8ac21996bf8 Mon Sep 17 00:00:00 2001 From: fredrhen Date: Fri, 7 Oct 2022 11:28:19 +0200 Subject: [PATCH 054/157] Fixed a typo in conventions.rst --- doc/conventions.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/conventions.rst b/doc/conventions.rst index 1832b9525..476366714 100644 --- a/doc/conventions.rst +++ b/doc/conventions.rst @@ -29,7 +29,7 @@ of linear time-invariant (LTI) systems: where u is the input, y is the output, and x is the state. -To create a state space system, use the :fun:`ss` function: +To create a state space system, use the :func:`ss` function: sys = ct.ss(A, B, C, D) From 461c7c35418c98a9a9a72faaf1dc8981894cf2f7 Mon Sep 17 00:00:00 2001 From: fredrhen Date: Fri, 7 Oct 2022 15:23:01 +0200 Subject: [PATCH 055/157] Fixed more errors when compiling the documentation --- control/iosys.py | 5 ++--- control/passivity.py | 2 +- control/sisotool.py | 2 ++ 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index ce717c0fb..1bd9bcb9a 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -2242,7 +2242,7 @@ def ss(*args, **kwargs): Convert a linear system into space system form. Always creates a new system, even if sys is already a state space system. - ``ss(updfcn, outfucn)``` + ``ss(updfcn, outfucn)`` Create a nonlinear input/output system with update function ``updfcn`` and output function ``outfcn``. See :class:`NonlinearIOSystem` for more information. @@ -2269,8 +2269,7 @@ def ss(*args, **kwargs): Everything that the constructor of :class:`numpy.matrix` accepts is permissible here too. - ``ss(args, inputs=['u1', ..., 'up'], outputs=['y1', ..., 'yq'], - states=['x1', ..., 'xn']) + ``ss(args, inputs=['u1', ..., 'up'], outputs=['y1', ..., 'yq'], states=['x1', ..., 'xn'])`` Create a system with named input, output, and state signals. Parameters diff --git a/control/passivity.py b/control/passivity.py index 2ec1a7683..3777b3d92 100644 --- a/control/passivity.py +++ b/control/passivity.py @@ -281,7 +281,7 @@ def ispassive(sys, ofp_index=0, ifp_index=0): guaranteed to have an output of True (the system might not be passive with both indices at the same time). - For more details, see [1]. + For more details, see [1]_. References ---------- diff --git a/control/sisotool.py b/control/sisotool.py index 52c061249..781fabf40 100644 --- a/control/sisotool.py +++ b/control/sisotool.py @@ -213,6 +213,8 @@ def rootlocus_pid_designer(plant, gain='P', sign=+1, input_signal='r', derivative terms are given instead by Kp, Ki*dt/2*(z+1)/(z-1), and Kd/dt*(z-1)/z, respectively. + :: + ------> C_ff ------ d | | | r | e V V u y From dc74aec940d027a1857eb1a97ca3e6a3a7c0a3b1 Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Sat, 8 Oct 2022 16:01:15 +0200 Subject: [PATCH 056/157] parametrize kwargs tests; allow new matplotib 3.6 error message --- control/tests/kwargs_test.py | 135 +++++++++++++++++------------------ 1 file changed, 65 insertions(+), 70 deletions(-) diff --git a/control/tests/kwargs_test.py b/control/tests/kwargs_test.py index 598a8ccca..855bb9dda 100644 --- a/control/tests/kwargs_test.py +++ b/control/tests/kwargs_test.py @@ -75,79 +75,74 @@ def test_kwarg_search(module, prefix): test_kwarg_search(obj, prefix + obj.__name__ + '.') -@pytest.mark.usefixtures('editsdefaults') -def test_unrecognized_kwargs(): +@pytest.mark.parametrize( + "function, nsssys, ntfsys, moreargs, kwargs", + [(control.dlqe, 1, 0, ([[1]], [[1]]), {}), + (control.dlqr, 1, 0, ([[1, 0], [0, 1]], [[1]]), {}), + (control.drss, 0, 0, (2, 1, 1), {}), + (control.input_output_response, 1, 0, ([0, 1, 2], [1, 1, 1]), {}), + (control.lqe, 1, 0, ([[1]], [[1]]), {}), + (control.lqr, 1, 0, ([[1, 0], [0, 1]], [[1]]), {}), + (control.linearize, 1, 0, (0, 0), {}), + (control.pzmap, 1, 0, (), {}), + (control.rlocus, 0, 1, ( ), {}), + (control.root_locus, 0, 1, ( ), {}), + (control.rss, 0, 0, (2, 1, 1), {}), + (control.set_defaults, 0, 0, ('control',), {'default_dt': True}), + (control.ss, 0, 0, (0, 0, 0, 0), {'dt': 1}), + (control.ss2io, 1, 0, (), {}), + (control.ss2tf, 1, 0, (), {}), + (control.summing_junction, 0, 0, (2,), {}), + (control.tf, 0, 0, ([1], [1, 1]), {}), + (control.tf2io, 0, 1, (), {}), + (control.tf2ss, 0, 1, (), {}), + (control.InputOutputSystem, 0, 0, (), + {'inputs': 1, 'outputs': 1, 'states': 1}), + (control.InputOutputSystem.linearize, 1, 0, (0, 0), {}), + (control.StateSpace, 0, 0, ([[-1, 0], [0, -1]], [[1], [1]], [[1, 1]], 0), {}), + (control.TransferFunction, 0, 0, ([1], [1, 1]), {})] +) +def test_unrecognized_kwargs(function, nsssys, ntfsys, moreargs, kwargs, + mplcleanup, editsdefaults): + # Create SISO systems for use in parameterized tests + sssys = control.ss([[-1, 1], [0, -1]], [[0], [1]], [[1, 0]], 0, dt=None) + tfsys = control.tf([1], [1, 1]) + + args = (sssys, )*nsssys + (tfsys, )*ntfsys + moreargs + + # Call the function normally and make sure it works + function(*args, **kwargs) + + # Now add an unrecognized keyword and make sure there is an error + with pytest.raises(TypeError, match="unrecognized keyword"): + function(*args, **kwargs, unknown=None) + + +@pytest.mark.parametrize( + "function, nsysargs, moreargs, kwargs", + [(control.bode, 1, (), {}), + (control.bode_plot, 1, (), {}), + (control.describing_function_plot, 1, + (control.descfcn.saturation_nonlinearity(1), [1, 2, 3, 4]), {}), + (control.gangof4, 2, (), {}), + (control.gangof4_plot, 2, (), {}), + (control.nyquist, 1, (), {}), + (control.nyquist_plot, 1, (), {}), + (control.singular_values_plot, 1, (), {})] +) +def test_matplotlib_kwargs(function, nsysargs, moreargs, kwargs, mplcleanup): # Create a SISO system for use in parameterized tests sys = control.ss([[-1, 1], [0, -1]], [[0], [1]], [[1, 0]], 0, dt=None) - table = [ - [control.dlqe, (sys, [[1]], [[1]]), {}], - [control.dlqr, (sys, [[1, 0], [0, 1]], [[1]]), {}], - [control.drss, (2, 1, 1), {}], - [control.input_output_response, (sys, [0, 1, 2], [1, 1, 1]), {}], - [control.lqe, (sys, [[1]], [[1]]), {}], - [control.lqr, (sys, [[1, 0], [0, 1]], [[1]]), {}], - [control.linearize, (sys, 0, 0), {}], - [control.pzmap, (sys,), {}], - [control.rlocus, (control.tf([1], [1, 1]), ), {}], - [control.root_locus, (control.tf([1], [1, 1]), ), {}], - [control.rss, (2, 1, 1), {}], - [control.set_defaults, ('control',), {'default_dt': True}], - [control.ss, (0, 0, 0, 0), {'dt': 1}], - [control.ss2io, (sys,), {}], - [control.ss2tf, (sys,), {}], - [control.summing_junction, (2,), {}], - [control.tf, ([1], [1, 1]), {}], - [control.tf2io, (control.tf([1], [1, 1]),), {}], - [control.tf2ss, (control.tf([1], [1, 1]),), {}], - [control.InputOutputSystem, (), - {'inputs': 1, 'outputs': 1, 'states': 1}], - [control.InputOutputSystem.linearize, (sys, 0, 0), {}], - [control.StateSpace, ([[-1, 0], [0, -1]], [[1], [1]], [[1, 1]], 0), {}], - [control.TransferFunction, ([1], [1, 1]), {}], - ] - - for function, args, kwargs in table: - # Call the function normally and make sure it works - function(*args, **kwargs) - - # Now add an unrecognized keyword and make sure there is an error - with pytest.raises(TypeError, match="unrecognized keyword"): - function(*args, **kwargs, unknown=None) - - # If we opened any figures, close them to avoid matplotlib warnings - if plt.gca(): - plt.close('all') - - -def test_matplotlib_kwargs(): - # Create a SISO system for use in parameterized tests - sys = control.ss([[-1, 1], [0, -1]], [[0], [1]], [[1, 0]], 0, dt=None) - ctl = control.ss([[-1, 1], [0, -1]], [[0], [1]], [[1, 0]], 0, dt=None) - - table = [ - [control.bode, (sys, ), {}], - [control.bode_plot, (sys, ), {}], - [control.describing_function_plot, - (sys, control.descfcn.saturation_nonlinearity(1), [1, 2, 3, 4]), {}], - [control.gangof4, (sys, ctl), {}], - [control.gangof4_plot, (sys, ctl), {}], - [control.nyquist, (sys, ), {}], - [control.nyquist_plot, (sys, ), {}], - [control.singular_values_plot, (sys, ), {}], - ] - - for function, args, kwargs in table: - # Call the function normally and make sure it works - function(*args, **kwargs) - - # Now add an unrecognized keyword and make sure there is an error - with pytest.raises(AttributeError, match="has no property"): - function(*args, **kwargs, unknown=None) - - # If we opened any figures, close them to avoid matplotlib warnings - if plt.gca(): - plt.close('all') + # Call the function normally and make sure it works + args = (sys, )*nsysargs + moreargs + function(*args, **kwargs) + + # Now add an unrecognized keyword and make sure there is an error + with pytest.raises(AttributeError, + match="(has no property|unexpected keyword)"): + function(*args, **kwargs, unknown=None) + # From 1cf3ec7fbd5a12419c64db5125abc75a39e029ef Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Sat, 8 Oct 2022 16:33:00 +0200 Subject: [PATCH 057/157] xfail ill-conditioned passive test (issue #761) --- control/tests/passivity_test.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/control/tests/passivity_test.py b/control/tests/passivity_test.py index 4c95c96b9..6cee1bdb6 100644 --- a/control/tests/passivity_test.py +++ b/control/tests/passivity_test.py @@ -1,5 +1,5 @@ ''' -Author: Mark Yeatman +Author: Mark Yeatman Date: May 30, 2022 ''' import pytest @@ -99,20 +99,17 @@ def test_system_dimension(): @pytest.mark.parametrize( - "test_input,expected", + "systemmatrices, expected", [((A, B, C, D*0.0), True), ((A_d, B, C, D), True), - ((A*1e12, B, C, D*0), True), + pytest.param((A*1e12, B, C, D*0), True, + marks=pytest.mark.xfail(reason="gh-761")), ((A, B*0, C*0, D), True), ((A*0, B, C, D), True), ((A*0, B*0, C*0, D*0), True)]) -def test_ispassive_edge_cases(test_input, expected): - A = test_input[0] - B = test_input[1] - C = test_input[2] - D = test_input[3] - sys = ss(A, B, C, D) - assert(passivity.ispassive(sys) == expected) +def test_ispassive_edge_cases(systemmatrices, expected): + sys = ss(*systemmatrices) + assert passivity.ispassive(sys) == expected def test_rho_and_nu_are_none(): From a9b6c79f1008a5de673bcca368abab2d8e8314f7 Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Tue, 11 Oct 2022 18:34:35 +0200 Subject: [PATCH 058/157] Update MANIFEST.in --- MANIFEST.in | 1 - 1 file changed, 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index 1edd5f83f..5e06ec2d1 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,4 @@ include examples/*.py -include tests/*.py include README.rst include ChangeLog include Pending From 16741f4201982659e44bcd13a2584a0007650694 Mon Sep 17 00:00:00 2001 From: Gonzalo Molina Date: Fri, 14 Oct 2022 12:03:19 -0300 Subject: [PATCH 059/157] change latex equation delimiters \[ and \] for 186288 --- control/statesp.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/control/statesp.py b/control/statesp.py index 98d4a1633..374b036ca 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -519,7 +519,7 @@ def _latex_partitioned_stateless(self): s : string with LaTeX representation of model """ lines = [ - r'\[', + r'$$', (r'\left(' + r'\begin{array}' + r'{' + 'rll' * self.ninputs + '}') @@ -533,7 +533,7 @@ def _latex_partitioned_stateless(self): r'\end{array}' r'\right)' + self._latex_dt(), - r'\]']) + r'$$']) return '\n'.join(lines) @@ -551,7 +551,7 @@ def _latex_partitioned(self): return self._latex_partitioned_stateless() lines = [ - r'\[', + r'$$', (r'\left(' + r'\begin{array}' + r'{' + 'rll' * self.nstates + '|' + 'rll' * self.ninputs + '}') @@ -571,7 +571,7 @@ def _latex_partitioned(self): r'\end{array}' + r'\right)' + self._latex_dt(), - r'\]']) + r'$$']) return '\n'.join(lines) @@ -585,7 +585,7 @@ def _latex_separate(self): s : string with LaTeX representation of model """ lines = [ - r'\[', + r'$$', r'\begin{array}{ll}', ] @@ -615,7 +615,7 @@ def fmt_matrix(matrix, name): lines.extend([ r'\end{array}' + self._latex_dt(), - r'\]']) + r'$$']) return '\n'.join(lines) From 112af1a7392d1559bc66077313dcc7ed75af1d79 Mon Sep 17 00:00:00 2001 From: Gonzalo Molina Date: Fri, 14 Oct 2022 16:20:45 -0300 Subject: [PATCH 060/157] fixing tests for state space representation --- control/tests/statesp_test.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index 315b5f152..97dc84e3c 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -1011,23 +1011,23 @@ def test_statespace_defaults(self, matarrayout): [[1.2345, -2e-200], [-1, 0]]) LTX_G1_REF = { - 'p3_p' : '\\[\n\\left(\\begin{array}{rllrll|rll}\n3.&\\hspace{-1em}14&\\hspace{-1em}\\phantom{\\cdot}&1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{100}&0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n-1.&\\hspace{-1em}23&\\hspace{-1em}\\phantom{\\cdot}&5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-23}&1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\hline\n9.&\\hspace{-1em}88&\\hspace{-1em}\\cdot10^{8}&0.&\\hspace{-1em}00123&\\hspace{-1em}\\phantom{\\cdot}&5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\]', + 'p3_p' : '$$\n\\left(\\begin{array}{rllrll|rll}\n3.&\\hspace{-1em}14&\\hspace{-1em}\\phantom{\\cdot}&1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{100}&0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n-1.&\\hspace{-1em}23&\\hspace{-1em}\\phantom{\\cdot}&5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-23}&1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\hline\n9.&\\hspace{-1em}88&\\hspace{-1em}\\cdot10^{8}&0.&\\hspace{-1em}00123&\\hspace{-1em}\\phantom{\\cdot}&5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n$$', - 'p5_p' : '\\[\n\\left(\\begin{array}{rllrll|rll}\n3.&\\hspace{-1em}1416&\\hspace{-1em}\\phantom{\\cdot}&1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{100}&0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n-1.&\\hspace{-1em}2346&\\hspace{-1em}\\phantom{\\cdot}&5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-23}&1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\hline\n9.&\\hspace{-1em}8765&\\hspace{-1em}\\cdot10^{8}&0.&\\hspace{-1em}001234&\\hspace{-1em}\\phantom{\\cdot}&5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\]', + 'p5_p' : '$$\n\\left(\\begin{array}{rllrll|rll}\n3.&\\hspace{-1em}1416&\\hspace{-1em}\\phantom{\\cdot}&1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{100}&0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n-1.&\\hspace{-1em}2346&\\hspace{-1em}\\phantom{\\cdot}&5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-23}&1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\hline\n9.&\\hspace{-1em}8765&\\hspace{-1em}\\cdot10^{8}&0.&\\hspace{-1em}001234&\\hspace{-1em}\\phantom{\\cdot}&5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n$$', - 'p3_s' : '\\[\n\\begin{array}{ll}\nA = \\left(\\begin{array}{rllrll}\n3.&\\hspace{-1em}14&\\hspace{-1em}\\phantom{\\cdot}&1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{100}\\\\\n-1.&\\hspace{-1em}23&\\hspace{-1em}\\phantom{\\cdot}&5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-23}\\\\\n\\end{array}\\right)\n&\nB = \\left(\\begin{array}{rll}\n0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\\\\nC = \\left(\\begin{array}{rllrll}\n9.&\\hspace{-1em}88&\\hspace{-1em}\\cdot10^{8}&0.&\\hspace{-1em}00123&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n&\nD = \\left(\\begin{array}{rll}\n5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\end{array}\n\\]', + 'p3_s' : '$$\n\\begin{array}{ll}\nA = \\left(\\begin{array}{rllrll}\n3.&\\hspace{-1em}14&\\hspace{-1em}\\phantom{\\cdot}&1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{100}\\\\\n-1.&\\hspace{-1em}23&\\hspace{-1em}\\phantom{\\cdot}&5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-23}\\\\\n\\end{array}\\right)\n&\nB = \\left(\\begin{array}{rll}\n0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\\\\nC = \\left(\\begin{array}{rllrll}\n9.&\\hspace{-1em}88&\\hspace{-1em}\\cdot10^{8}&0.&\\hspace{-1em}00123&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n&\nD = \\left(\\begin{array}{rll}\n5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\end{array}\n$$', - 'p5_s' : '\\[\n\\begin{array}{ll}\nA = \\left(\\begin{array}{rllrll}\n3.&\\hspace{-1em}1416&\\hspace{-1em}\\phantom{\\cdot}&1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{100}\\\\\n-1.&\\hspace{-1em}2346&\\hspace{-1em}\\phantom{\\cdot}&5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-23}\\\\\n\\end{array}\\right)\n&\nB = \\left(\\begin{array}{rll}\n0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\\\\nC = \\left(\\begin{array}{rllrll}\n9.&\\hspace{-1em}8765&\\hspace{-1em}\\cdot10^{8}&0.&\\hspace{-1em}001234&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n&\nD = \\left(\\begin{array}{rll}\n5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\end{array}\n\\]', + 'p5_s' : '$$\n\\begin{array}{ll}\nA = \\left(\\begin{array}{rllrll}\n3.&\\hspace{-1em}1416&\\hspace{-1em}\\phantom{\\cdot}&1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{100}\\\\\n-1.&\\hspace{-1em}2346&\\hspace{-1em}\\phantom{\\cdot}&5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-23}\\\\\n\\end{array}\\right)\n&\nB = \\left(\\begin{array}{rll}\n0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\\\\nC = \\left(\\begin{array}{rllrll}\n9.&\\hspace{-1em}8765&\\hspace{-1em}\\cdot10^{8}&0.&\\hspace{-1em}001234&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n&\nD = \\left(\\begin{array}{rll}\n5\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\end{array}\n$$', } LTX_G2_REF = { - 'p3_p' : '\\[\n\\left(\\begin{array}{rllrll}\n1.&\\hspace{-1em}23&\\hspace{-1em}\\phantom{\\cdot}&-2\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-200}\\\\\n-1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}&0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\]', + 'p3_p' : '$$\n\\left(\\begin{array}{rllrll}\n1.&\\hspace{-1em}23&\\hspace{-1em}\\phantom{\\cdot}&-2\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-200}\\\\\n-1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}&0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n$$', - 'p5_p' : '\\[\n\\left(\\begin{array}{rllrll}\n1.&\\hspace{-1em}2345&\\hspace{-1em}\\phantom{\\cdot}&-2\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-200}\\\\\n-1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}&0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\]', + 'p5_p' : '$$\n\\left(\\begin{array}{rllrll}\n1.&\\hspace{-1em}2345&\\hspace{-1em}\\phantom{\\cdot}&-2\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-200}\\\\\n-1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}&0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n$$', - 'p3_s' : '\\[\n\\begin{array}{ll}\nD = \\left(\\begin{array}{rllrll}\n1.&\\hspace{-1em}23&\\hspace{-1em}\\phantom{\\cdot}&-2\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-200}\\\\\n-1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}&0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\end{array}\n\\]', + 'p3_s' : '$$\n\\begin{array}{ll}\nD = \\left(\\begin{array}{rllrll}\n1.&\\hspace{-1em}23&\\hspace{-1em}\\phantom{\\cdot}&-2\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-200}\\\\\n-1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}&0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\end{array}\n$$', - 'p5_s' : '\\[\n\\begin{array}{ll}\nD = \\left(\\begin{array}{rllrll}\n1.&\\hspace{-1em}2345&\\hspace{-1em}\\phantom{\\cdot}&-2\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-200}\\\\\n-1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}&0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\end{array}\n\\]', + 'p5_s' : '$$\n\\begin{array}{ll}\nD = \\left(\\begin{array}{rllrll}\n1.&\\hspace{-1em}2345&\\hspace{-1em}\\phantom{\\cdot}&-2\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\cdot10^{-200}\\\\\n-1\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}&0\\phantom{.}&\\hspace{-1em}&\\hspace{-1em}\\phantom{\\cdot}\\\\\n\\end{array}\\right)\n\\end{array}\n$$', } refkey_n = {None: 'p3', '.3g': 'p3', '.5g': 'p5'} From 45c3abdb534e8421c2582a7a1da8ecd6f10e1cbd Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 12 Nov 2022 15:45:10 -0800 Subject: [PATCH 061/157] add conversions for LTI systems in interconnect() --- control/iosys.py | 8 ++++- control/tests/type_conversion_test.py | 42 +++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index 19f527c22..b88bbb84d 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -894,6 +894,12 @@ def __init__(self, syslist, connections=[], inplist=[], outlist=[], # Go through the system list and keep track of counts, offsets for sysidx, sys in enumerate(syslist): + # If we were passed a SS or TF system, convert to LinearIOSystem + if isinstance(sys, (StateSpace, TransferFunction)) and \ + not isinstance(sys, LinearIOSystem): + sys = LinearIOSystem(sys) + syslist[sysidx] = sys + # Make sure time bases are consistent dt = common_timebase(dt, sys.dt) @@ -1781,7 +1787,7 @@ def input_output_response( # Update the parameter values sys._update_params(params) - + # # Define a function to evaluate the input at an arbitrary time # diff --git a/control/tests/type_conversion_test.py b/control/tests/type_conversion_test.py index cdf302015..02290d27b 100644 --- a/control/tests/type_conversion_test.py +++ b/control/tests/type_conversion_test.py @@ -59,7 +59,7 @@ def sys_dict(): rtype_list = ['ss', 'tf', 'frd', 'lio', 'ios', 'arr', 'flt'] conversion_table = [ # op left ss tf frd lio ios arr flt - ('add', 'ss', ['ss', 'ss', 'frd', 'ss', 'ios', 'ss', 'ss' ]), + ('add', 'ss', ['ss', 'ss', 'frd', 'lio', 'ios', 'ss', 'ss' ]), ('add', 'tf', ['tf', 'tf', 'frd', 'lio', 'ios', 'tf', 'tf' ]), ('add', 'frd', ['frd', 'frd', 'frd', 'frd', 'E', 'frd', 'frd']), ('add', 'lio', ['lio', 'lio', 'xrd', 'lio', 'ios', 'lio', 'lio']), @@ -68,7 +68,7 @@ def sys_dict(): ('add', 'flt', ['ss', 'tf', 'frd', 'lio', 'ios', 'arr', 'flt']), # op left ss tf frd lio ios arr flt - ('sub', 'ss', ['ss', 'ss', 'frd', 'ss', 'ios', 'ss', 'ss' ]), + ('sub', 'ss', ['ss', 'ss', 'frd', 'lio', 'ios', 'ss', 'ss' ]), ('sub', 'tf', ['tf', 'tf', 'frd', 'lio', 'ios', 'tf', 'tf' ]), ('sub', 'frd', ['frd', 'frd', 'frd', 'frd', 'E', 'frd', 'frd']), ('sub', 'lio', ['lio', 'lio', 'xrd', 'lio', 'ios', 'lio', 'lio']), @@ -77,7 +77,7 @@ def sys_dict(): ('sub', 'flt', ['ss', 'tf', 'frd', 'lio', 'ios', 'arr', 'flt']), # op left ss tf frd lio ios arr flt - ('mul', 'ss', ['ss', 'ss', 'frd', 'ss', 'ios', 'ss', 'ss' ]), + ('mul', 'ss', ['ss', 'ss', 'frd', 'lio', 'ios', 'ss', 'ss' ]), ('mul', 'tf', ['tf', 'tf', 'frd', 'lio', 'ios', 'tf', 'tf' ]), ('mul', 'frd', ['frd', 'frd', 'frd', 'frd', 'E', 'frd', 'frd']), ('mul', 'lio', ['lio', 'lio', 'xrd', 'lio', 'ios', 'lio', 'lio']), @@ -191,3 +191,39 @@ def test_binary_op_type_conversions(opname, ltype, rtype, sys_dict): assert len(result.output_labels) == result.noutputs if result.nstates is not None: assert len(result.state_labels) == result.nstates + +@pytest.mark.parametrize( + "typelist, connections, inplist, outlist, expected", [ + (['lio', 'lio'], [[(1, 0), (0, 0)]], [[(0, 0)]], [[(1, 0)]], 'lio'), + (['lio', 'ss'], [[(1, 0), (0, 0)]], [[(0, 0)]], [[(1, 0)]], 'lio'), + (['ss', 'lio'], [[(1, 0), (0, 0)]], [[(0, 0)]], [[(1, 0)]], 'lio'), + (['ss', 'ss'], [[(1, 0), (0, 0)]], [[(0, 0)]], [[(1, 0)]], 'lio'), + (['lio', 'tf'], [[(1, 0), (0, 0)]], [[(0, 0)]], [[(1, 0)]], 'lio'), + (['lio', 'frd'], [[(1, 0), (0, 0)]], [[(0, 0)]], [[(1, 0)]], 'E'), + (['ios', 'ios'], [[(1, 0), (0, 0)]], [[(0, 0)]], [[(1, 0)]], 'ios'), + (['lio', 'ios'], [[(1, 0), (0, 0)]], [[(0, 0)]], [[(1, 0)]], 'ios'), + (['ss', 'ios'], [[(1, 0), (0, 0)]], [[(0, 0)]], [[(1, 0)]], 'ios'), + (['tf', 'ios'], [[(1, 0), (0, 0)]], [[(0, 0)]], [[(1, 0)]], 'ios'), + (['lio', 'ss', 'tf'], + [[(1, 0), (0, 0)], [(2, 0), (1, 0)]], [[(0, 0)]], [[(2, 0)]], 'lio'), + (['ios', 'ss', 'tf'], + [[(1, 0), (0, 0)], [(2, 0), (1, 0)]], [[(0, 0)]], [[(2, 0)]], 'ios'), + ]) +def test_interconnect( + typelist, connections, inplist, outlist, expected, sys_dict): + # Create the system list + syslist = [sys_dict[_type] for _type in typelist] + + # Make copies of any duplicates + for sysidx, sys in enumerate(syslist): + if sys == syslist[0]: + syslist[sysidx] = sys.copy() + + # Make sure we get the right result + if expected == 'E' or expected[0] == 'x': + # Exception expected + with pytest.raises(TypeError): + result = ct.interconnect(syslist, connections, inplist, outlist) + else: + result = ct.interconnect(syslist, connections, inplist, outlist) + assert isinstance(result, type_dict[expected]) From 43ce56bd03a26ec52312535e4dacb0fc6579f778 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 12 Nov 2022 16:36:17 -0800 Subject: [PATCH 062/157] fix params processing in I/O system + unit tests --- control/iosys.py | 12 +++++++----- control/tests/type_conversion_test.py | 12 +++++++++++- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index b88bbb84d..8123b9050 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -357,7 +357,7 @@ def _update_params(self, params, warning=False): if warning: warn("Parameters passed to InputOutputSystem ignored.") - def _rhs(self, t, x, u, params={}): + def _rhs(self, t, x, u): """Evaluate right hand side of a differential or difference equation. Private function used to compute the right hand side of an @@ -369,7 +369,7 @@ def _rhs(self, t, x, u, params={}): NotImplemented("Evaluation not implemented for system of type ", type(self)) - def dynamics(self, t, x, u): + def dynamics(self, t, x, u, params={}): """Compute the dynamics of a differential or difference equation. Given time `t`, input `u` and state `x`, returns the value of the @@ -400,9 +400,10 @@ def dynamics(self, t, x, u): ------- dx/dt or x[t+dt] : ndarray """ + self._update_params(params) return self._rhs(t, x, u) - def _out(self, t, x, u, params={}): + def _out(self, t, x, u): """Evaluate the output of a system at a given state, input, and time Private function used to compute the output of of an input/output @@ -414,7 +415,7 @@ def _out(self, t, x, u, params={}): # If no output function was defined in subclass, return state return x - def output(self, t, x, u): + def output(self, t, x, u, params={}): """Compute the output of the system Given time `t`, input `u` and state `x`, returns the output of the @@ -437,6 +438,7 @@ def output(self, t, x, u): ------- y : ndarray """ + self._update_params(params) return self._out(t, x, u) def feedback(self, other=1, sign=-1, params={}): @@ -2248,7 +2250,7 @@ def ss(*args, **kwargs): Convert a linear system into space system form. Always creates a new system, even if sys is already a state space system. - ``ss(updfcn, outfucn)`` + ``ss(updfcn, outfcn)`` Create a nonlinear input/output system with update function ``updfcn`` and output function ``outfcn``. See :class:`NonlinearIOSystem` for more information. diff --git a/control/tests/type_conversion_test.py b/control/tests/type_conversion_test.py index 02290d27b..7163f7097 100644 --- a/control/tests/type_conversion_test.py +++ b/control/tests/type_conversion_test.py @@ -19,7 +19,9 @@ def sys_dict(): sdict['frd'] = ct.frd([10+0j, 9 + 1j, 8 + 2j, 7 + 3j], [1, 2, 3, 4]) sdict['lio'] = ct.LinearIOSystem(ct.ss([[-1]], [[5]], [[5]], [[0]])) sdict['ios'] = ct.NonlinearIOSystem( - sdict['lio']._rhs, sdict['lio']._out, inputs=1, outputs=1, states=1) + lambda t, x, u, params: sdict['lio']._rhs(t, x, u), + lambda t, x, u, params: sdict['lio']._out(t, x, u), + inputs=1, outputs=1, states=1) sdict['arr'] = np.array([[2.0]]) sdict['flt'] = 3. return sdict @@ -226,4 +228,12 @@ def test_interconnect( result = ct.interconnect(syslist, connections, inplist, outlist) else: result = ct.interconnect(syslist, connections, inplist, outlist) + + # Make sure the type is correct assert isinstance(result, type_dict[expected]) + + # Make sure we can evaluate the dynamics + np.testing.assert_equal( + result.dynamics( + 0, np.zeros(result.nstates), np.zeros(result.ninputs)), + np.zeros(result.nstates)) From 1a78cb8eb11f53caa444e0833391ffe5f561bcdb Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 12 Nov 2022 18:48:09 -0800 Subject: [PATCH 063/157] update params docstrings in iosys --- control/iosys.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index 8123b9050..e3f37b8b6 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -376,16 +376,17 @@ def dynamics(self, t, x, u, params={}): right hand side of the dynamical system. If the system is continuous, returns the time derivative - dx/dt = f(t, x, u) + dx/dt = f(t, x, u, params) where `f` is the system's (possibly nonlinear) dynamics function. If the system is discrete-time, returns the next value of `x`: - x[t+dt] = f(t, x[t], u[t]) + x[t+dt] = f(t, x[t], u[t], params) - Where `t` is a scalar. + where `t` is a scalar. - The inputs `x` and `u` must be of the correct length. + The inputs `x` and `u` must be of the correct length. The `params` + argument is an optional dictionary of parameter values. Parameters ---------- @@ -395,6 +396,8 @@ def dynamics(self, t, x, u, params={}): current state u : array_like input + params : dict (optional) + system parameter values Returns ------- @@ -421,7 +424,7 @@ def output(self, t, x, u, params={}): Given time `t`, input `u` and state `x`, returns the output of the system: - y = g(t, x, u) + y = g(t, x, u[, params]) The inputs `x` and `u` must be of the correct length. @@ -433,6 +436,8 @@ def output(self, t, x, u, params={}): current state u : array_like input + params : dict (optional) + system parameter values Returns ------- From c425d2c9312af16eb49c13222f81ce0e94ff7b16 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 12 Nov 2022 22:03:18 -0800 Subject: [PATCH 064/157] fix _isstatic() to use nstates==0 --- control/statesp.py | 10 ++-------- control/tests/iosys_test.py | 10 +++++++++- control/tests/statesp_test.py | 33 ++++++++++++++------------------- 3 files changed, 25 insertions(+), 28 deletions(-) diff --git a/control/statesp.py b/control/statesp.py index 374b036ca..ca14a5690 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -326,7 +326,7 @@ def __init__(self, *args, init_namedio=True, **kwargs): D = np.zeros((C.shape[0], B.shape[1])) D = _ssmatrix(D) - # Matrices definining the linear system + # Matrices defining the linear system self.A = A self.B = B self.C = C @@ -346,9 +346,8 @@ def __init__(self, *args, init_namedio=True, **kwargs): defaults = args[0] if len(args) == 1 else \ {'inputs': D.shape[1], 'outputs': D.shape[0], 'states': A.shape[0]} - static = (A.size == 0) name, inputs, outputs, states, dt = _process_namedio_keywords( - kwargs, defaults, static=static, end=True) + kwargs, defaults, static=(A.size == 0), end=True) # Initialize LTI (NamedIOSystem) object super().__init__( @@ -1478,11 +1477,6 @@ def output(self, t, x, u=None): return (self.C @ x).reshape((-1,)) \ + (self.D @ u).reshape((-1,)) # return as row vector - def _isstatic(self): - """True if and only if the system has no dynamics, that is, - if A and B are zero. """ - return not np.any(self.A) and not np.any(self.B) - # TODO: add discrete time check def _convert_to_statespace(sys): diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index 693be979e..8a6ed8165 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -41,6 +41,9 @@ class TSys: [[-1, 1], [0, -2]], [[0, 1], [1, 0]], [[1, 0], [0, 1]], np.zeros((2, 2))) + # Create a static gain linear system + T.staticgain = ct.StateSpace([], [], [], 1) + # Create simulation parameters T.T = np.linspace(0, 10, 100) T.U = np.sin(T.T) @@ -51,7 +54,7 @@ class TSys: def test_linear_iosys(self, tsys): # Create an input/output system from the linear system linsys = tsys.siso_linsys - iosys = ios.LinearIOSystem(linsys) + iosys = ios.LinearIOSystem(linsys).copy() # Make sure that the right hand side matches linear system for x, u in (([0, 0], 0), ([1, 0], 0), ([0, 1], 0), ([0, 0], 1)): @@ -66,6 +69,11 @@ def test_linear_iosys(self, tsys): np.testing.assert_array_almost_equal(lti_t, ios_t) np.testing.assert_allclose(lti_y, ios_y, atol=0.002, rtol=0.) + # Make sure that a static linear system has dt=None + # and otherwise dt is as specified + assert ios.LinearIOSystem(tsys.staticgain).dt is None + assert ios.LinearIOSystem(tsys.staticgain, dt=.1).dt == .1 + def test_tf2io(self, tsys): # Create a transfer function from the state space system linsys = tsys.siso_linsys diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index 97dc84e3c..5fe7d36af 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -399,25 +399,6 @@ def test_freq_resp(self): mag, phase, omega = sys.freqresp(true_omega) np.testing.assert_almost_equal(mag, true_mag) - def test__isstatic(self): - A0 = np.zeros((2,2)) - A1 = A0.copy() - A1[0,1] = 1.1 - B0 = np.zeros((2,1)) - B1 = B0.copy() - B1[0,0] = 1.3 - C0 = A0 - C1 = np.eye(2) - D0 = 0 - D1 = np.ones((2,1)) - assert StateSpace(A0, B0, C1, D1)._isstatic() - assert not StateSpace(A1, B0, C1, D1)._isstatic() - assert not StateSpace(A0, B1, C1, D1)._isstatic() - assert not StateSpace(A1, B1, C1, D1)._isstatic() - assert StateSpace(A0, B0, C0, D0)._isstatic() - assert StateSpace(A0, B0, C0, D1)._isstatic() - assert StateSpace(A0, B0, C1, D0)._isstatic() - @slycotonly def test_minreal(self): """Test a minreal model reduction.""" @@ -1158,3 +1139,17 @@ def test_linfnorm_ct_mimo(self, ct_siso): gpeak, fpeak = linfnorm(sys) np.testing.assert_allclose(gpeak, refgpeak) np.testing.assert_allclose(fpeak, reffpeak) + +@pytest.mark.parametrize("args, static", [ + (([], [], [], 1), True), # ctime, empty state + (([], [], [], 1, 1), True), # dtime, empty state + ((0, 0, 0, 1), False), # ctime, unused state + ((-1, 0, 0, 1), False), # ctime, exponential decay + ((-1, 0, 0, 0), False), # ctime, no input, no output + ((0, 0, 0, 1, 1), False), # dtime, integrator + ((1, 0, 0, 1, 1), False), # dtime, unused state + ((0, 0, 0, 1, None), False), # unspecified, unused state +]) +def test_isstatic(args, static): + sys = ct.StateSpace(*args) + assert sys._isstatic() == static From 878cf98479f6240bcfa1d29f81ca26454642581f Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 12 Nov 2022 23:12:44 -0800 Subject: [PATCH 065/157] replace mutables as arguments with None, per @roryyorke suggestion --- control/iosys.py | 66 +++++++++++++++++++++++++----------------------- 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index e3f37b8b6..58eb47db8 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -126,7 +126,7 @@ class for a set of subclasses that are used to implement specific # Allow ndarray * InputOutputSystem to give IOSystem._rmul_() priority __array_priority__ = 12 # override ndarray, matrix, SS types - def __init__(self, params={}, **kwargs): + def __init__(self, params=None, **kwargs): """Create an input/output system. The InputOutputSystem constructor is used to create an input/output @@ -148,7 +148,7 @@ def __init__(self, params={}, **kwargs): states=states, name=name, dt=dt) # default parameters - self.params = params.copy() + self.params = {} if params is None else params.copy() def __mul__(sys2, sys1): """Multiply two input/output systems (series interconnection)""" @@ -369,7 +369,7 @@ def _rhs(self, t, x, u): NotImplemented("Evaluation not implemented for system of type ", type(self)) - def dynamics(self, t, x, u, params={}): + def dynamics(self, t, x, u, params=None): """Compute the dynamics of a differential or difference equation. Given time `t`, input `u` and state `x`, returns the value of the @@ -418,7 +418,7 @@ def _out(self, t, x, u): # If no output function was defined in subclass, return state return x - def output(self, t, x, u, params={}): + def output(self, t, x, u, params=None): """Compute the output of the system Given time `t`, input `u` and state `x`, returns the output of the @@ -446,7 +446,7 @@ def output(self, t, x, u, params={}): self._update_params(params) return self._out(t, x, u) - def feedback(self, other=1, sign=-1, params={}): + def feedback(self, other=1, sign=-1, params=None): """Feedback interconnection between two input/output systems Parameters @@ -514,7 +514,7 @@ def feedback(self, other=1, sign=-1, params={}): # Return the newly created system return newsys - def linearize(self, x0, u0, t=0, params={}, eps=1e-6, + def linearize(self, x0, u0, t=0, params=None, eps=1e-6, name=None, copy=False, **kwargs): """Linearize an input/output system at a given state and input. @@ -658,7 +658,7 @@ def __init__(self, linsys, **kwargs): # Note: don't use super() to override StateSpace MRO InputOutputSystem.__init__( self, inputs=inputs, outputs=outputs, states=states, - params={}, dt=dt, name=name) + params=None, dt=dt, name=name) # Initalize additional state space variables StateSpace.__init__( @@ -675,7 +675,7 @@ def __init__(self, linsys, **kwargs): #: number of states, use :attr:`nstates`. states = property(StateSpace._get_states, StateSpace._set_states) - def _update_params(self, params={}, warning=True): + def _update_params(self, params=None, warning=True): # Parameters not supported; issue a warning if params and warning: warn("Parameters passed to LinearIOSystems are ignored.") @@ -763,7 +763,7 @@ class NonlinearIOSystem(InputOutputSystem): defaults. """ - def __init__(self, updfcn, outfcn=None, params={}, **kwargs): + def __init__(self, updfcn, outfcn=None, params=None, **kwargs): """Create a nonlinear I/O system given update and output functions.""" # Process keyword arguments name, inputs, outputs, states, dt = _process_namedio_keywords( @@ -798,7 +798,7 @@ def __init__(self, updfcn, outfcn=None, params={}, **kwargs): "(and nstates not known).") # Initialize current parameters to default parameters - self._current_params = params.copy() + self._current_params = {} if params is None else params.copy() def __str__(self): return f"{InputOutputSystem.__str__(self)}\n\n" + \ @@ -845,7 +845,8 @@ def __call__(sys, u, params=None, squeeze=None): def _update_params(self, params, warning=False): # Update the current parameter values self._current_params = self.params.copy() - self._current_params.update(params) + if params: + self._current_params.update(params) def _rhs(self, t, x, u): xdot = self.updfcn(t, x, u, self._current_params) \ @@ -869,20 +870,22 @@ class InterconnectedSystem(InputOutputSystem): See :func:`~control.interconnect` for a list of parameters. """ - def __init__(self, syslist, connections=[], inplist=[], outlist=[], - params={}, warn_duplicate=None, **kwargs): + def __init__(self, syslist, connections=None, inplist=None, outlist=None, + params=None, warn_duplicate=None, **kwargs): """Create an I/O system from a list of systems + connection info.""" # Convert input and output names to lists if they aren't already - if not isinstance(inplist, (list, tuple)): + if inplist is not None and not isinstance(inplist, (list, tuple)): inplist = [inplist] - if not isinstance(outlist, (list, tuple)): + if outlist is not None and not isinstance(outlist, (list, tuple)): outlist = [outlist] # Check if dt argument was given; if not, pull from systems dt = kwargs.pop('dt', None) # Process keyword arguments (except dt) - defaults = {'inputs': len(inplist), 'outputs': len(outlist)} + defaults = { + 'inputs': len(inplist or []), + 'outputs': len(outlist or [])} name, inputs, outputs, states, _ = _process_namedio_keywords( kwargs, defaults, end=True) @@ -982,7 +985,7 @@ def __init__(self, syslist, connections=[], inplist=[], outlist=[], # Convert the list of interconnections to a connection map (matrix) self.connect_map = np.zeros((ninputs, noutputs)) - for connection in connections: + for connection in connections or []: input_index = self._parse_input_spec(connection[0]) for output_spec in connection[1:]: output_index, gain = self._parse_output_spec(output_spec) @@ -993,7 +996,7 @@ def __init__(self, syslist, connections=[], inplist=[], outlist=[], # Convert the input list to a matrix: maps system to subsystems self.input_map = np.zeros((ninputs, self.ninputs)) - for index, inpspec in enumerate(inplist): + for index, inpspec in enumerate(inplist or []): if isinstance(inpspec, (int, str, tuple)): inpspec = [inpspec] if not isinstance(inpspec, list): @@ -1008,7 +1011,7 @@ def __init__(self, syslist, connections=[], inplist=[], outlist=[], # Convert the output list to a matrix: maps subsystems to system self.output_map = np.zeros((self.noutputs, noutputs + ninputs)) - for index, outspec in enumerate(outlist): + for index, outspec in enumerate(outlist or []): if isinstance(outspec, (int, str, tuple)): outspec = [outspec] if not isinstance(outspec, list): @@ -1022,13 +1025,14 @@ def __init__(self, syslist, connections=[], inplist=[], outlist=[], self.output_map[index, ylist_index] += gain # Save the parameters for the system - self.params = params.copy() + self.params = {} if params is None else params.copy() def _update_params(self, params, warning=False): for sys in self.syslist: local = sys.params.copy() # start with system parameters local.update(self.params) # update with global params - local.update(params) # update with locally passed parameters + if params: + local.update(params) # update with locally passed parameters sys._update_params(local, warning=warning) def _rhs(self, t, x, u): @@ -1578,7 +1582,7 @@ def __init__(self, io_sys, ss_sys=None): def input_output_response( - sys, T, U=0., X0=0, params={}, + sys, T, U=0., X0=0, params=None, transpose=False, return_x=False, squeeze=None, solve_ivp_kwargs={}, t_eval='T', **kwargs): """Compute the output response of a system to a given input. @@ -1913,7 +1917,7 @@ def ivp_rhs(t, x): transpose=transpose, return_x=return_x, squeeze=squeeze) -def find_eqpt(sys, x0, u0=[], y0=None, t=0, params={}, +def find_eqpt(sys, x0, u0=None, y0=None, t=0, params=None, iu=None, iy=None, ix=None, idx=None, dx0=None, return_y=False, return_result=False): """Find the equilibrium point for an input/output system. @@ -2164,7 +2168,7 @@ def rootfun(z): # Linearize an input/output system -def linearize(sys, xeq, ueq=[], t=0, params={}, **kw): +def linearize(sys, xeq, ueq=None, t=0, params=None, **kw): """Linearize an input/output system at a given state and input. This function computes the linearization of an input/output system at a @@ -2536,9 +2540,9 @@ def tf2io(*args, **kwargs): # Function to create an interconnected system -def interconnect(syslist, connections=None, inplist=[], outlist=[], params={}, - check_unused=True, ignore_inputs=None, ignore_outputs=None, - warn_duplicate=None, **kwargs): +def interconnect(syslist, connections=None, inplist=None, outlist=None, + params=None, check_unused=True, ignore_inputs=None, + ignore_outputs=None, warn_duplicate=None, **kwargs): """Interconnect a set of input/output systems. This function creates a new system that is an interconnection of a set of @@ -2780,10 +2784,10 @@ def interconnect(syslist, connections=None, inplist=[], outlist=[], params={}, connections = [] # If inplist/outlist is not present, try using inputs/outputs instead - if not inplist and inputs is not None: - inplist = list(inputs) - if not outlist and outputs is not None: - outlist = list(outputs) + if inplist is None: + inplist = list(inputs or []) + if outlist is None: + outlist = list(outputs or []) # Process input list if not isinstance(inplist, (list, tuple)): From a9a62267923ada25f44b18772ee2a5a3f1a08157 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 12 Nov 2022 23:22:36 -0800 Subject: [PATCH 066/157] update params docstrings + warnings per @sawyerbfuller suggestion --- control/iosys.py | 4 ++-- control/statesp.py | 10 ++++++++-- control/tests/statesp_test.py | 12 ++++++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index 58eb47db8..2bb445bdd 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -376,12 +376,12 @@ def dynamics(self, t, x, u, params=None): right hand side of the dynamical system. If the system is continuous, returns the time derivative - dx/dt = f(t, x, u, params) + dx/dt = f(t, x, u[, params]) where `f` is the system's (possibly nonlinear) dynamics function. If the system is discrete-time, returns the next value of `x`: - x[t+dt] = f(t, x[t], u[t], params) + x[t+dt] = f(t, x[t], u[t][, params]) where `t` is a scalar. diff --git a/control/statesp.py b/control/statesp.py index 374b036ca..a1fd84b20 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -1389,7 +1389,7 @@ def dcgain(self, warn_infinite=False): """ return self._dcgain(warn_infinite) - def dynamics(self, t, x, u=None): + def dynamics(self, t, x, u=None, params=None): """Compute the dynamics of the system Given input `u` and state `x`, returns the dynamics of the state-space @@ -1423,6 +1423,9 @@ def dynamics(self, t, x, u=None): dx/dt or x[t+dt] : ndarray """ + if params is not None: + warn("params keyword ignored for StateSpace object") + x = np.reshape(x, (-1, 1)) # force to a column in case matrix if np.size(x) != self.nstates: raise ValueError("len(x) must be equal to number of states") @@ -1435,7 +1438,7 @@ def dynamics(self, t, x, u=None): return (self.A @ x).reshape((-1,)) \ + (self.B @ u).reshape((-1,)) # return as row vector - def output(self, t, x, u=None): + def output(self, t, x, u=None, params=None): """Compute the output of the system Given input `u` and state `x`, returns the output `y` of the @@ -1465,6 +1468,9 @@ def output(self, t, x, u=None): ------- y : ndarray """ + if params is not None: + warn("params keyword ignored for StateSpace object") + x = np.reshape(x, (-1, 1)) # force to a column in case matrix if np.size(x) != self.nstates: raise ValueError("len(x) must be equal to number of states") diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index 97dc84e3c..e97584fbb 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -1158,3 +1158,15 @@ def test_linfnorm_ct_mimo(self, ct_siso): gpeak, fpeak = linfnorm(sys) np.testing.assert_allclose(gpeak, refgpeak) np.testing.assert_allclose(fpeak, reffpeak) + + +# Make sure that using params for StateSpace objects generates a warning +def test_params_warning(): + sys = StateSpace(-1, 1, 1, 0) + + with pytest.warns(UserWarning, match="params keyword ignored"): + sys.dynamics(0, [0], [0], {'k': 5}) + + with pytest.warns(UserWarning, match="params keyword ignored"): + sys.output(0, [0], [0], {'k': 5}) + From 3729027aa0dd1c2170f78367b560cdc0deb0926d Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Wed, 16 Nov 2022 21:11:02 -0800 Subject: [PATCH 067/157] fix error when an IOSystem is combined with a TransferFunction system --- control/iosys.py | 12 ++++++------ control/tests/interconnect_test.py | 22 ++++++++++++++++++++++ control/tests/iosys_test.py | 17 +++++++++++++++++ 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index 2bb445bdd..df75f3b54 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -890,7 +890,7 @@ def __init__(self, syslist, connections=None, inplist=None, outlist=None, kwargs, defaults, end=True) # Initialize the system list and index - self.syslist = syslist + self.syslist = list(syslist) # insure modifications can be made self.syslist_index = {} # Initialize the input, output, and state counts, indices @@ -903,12 +903,12 @@ def __init__(self, syslist, connections=None, inplist=None, outlist=None, sysname_count_dct = {} # Go through the system list and keep track of counts, offsets - for sysidx, sys in enumerate(syslist): + for sysidx, sys in enumerate(self.syslist): # If we were passed a SS or TF system, convert to LinearIOSystem if isinstance(sys, (StateSpace, TransferFunction)) and \ not isinstance(sys, LinearIOSystem): - sys = LinearIOSystem(sys) - syslist[sysidx] = sys + sys = LinearIOSystem(sys, name=sys.name) + self.syslist[sysidx] = sys # Make sure time bases are consistent dt = common_timebase(dt, sys.dt) @@ -2850,12 +2850,12 @@ def interconnect(syslist, connections=None, inplist=None, outlist=None, inputs=inputs, outputs=outputs, states=states, params=params, dt=dt, name=name, warn_duplicate=warn_duplicate) - # check for implicity dropped signals + # check for implicitly dropped signals if check_unused: newsys.check_unused_signals(ignore_inputs, ignore_outputs) # If all subsystems are linear systems, maintain linear structure - if all([isinstance(sys, LinearIOSystem) for sys in syslist]): + if all([isinstance(sys, LinearIOSystem) for sys in newsys.syslist]): return LinearICSystem(newsys, None) return newsys diff --git a/control/tests/interconnect_test.py b/control/tests/interconnect_test.py index 3b99adc6e..2c29aeaca 100644 --- a/control/tests/interconnect_test.py +++ b/control/tests/interconnect_test.py @@ -230,3 +230,25 @@ def test_string_inputoutput(): P_s2 = ct.interconnect([P1_iosys, P2_iosys], inputs=['u1'], output='y2') assert P_s2.output_index == {'y2' : 0} + +def test_linear_interconnect(): + tf_ctrl = ct.tf(1, (10.1, 1), inputs='e', outputs='u') + tf_plant = ct.tf(1, (10.1, 1), inputs='u', outputs='y') + ss_ctrl = ct.ss(1, 2, 1, 2, inputs='e', outputs='u') + ss_plant = ct.ss(1, 2, 1, 2, inputs='u', outputs='y') + nl_ctrl = ct.NonlinearIOSystem( + lambda t, x, u, params: x*x, + lambda t, x, u, params: u*x, states=1, inputs='e', outputs='u') + nl_plant = ct.NonlinearIOSystem( + lambda t, x, u, params: x*x, + lambda t, x, u, params: u*x, states=1, inputs='u', outputs='y') + + assert isinstance(ct.interconnect((tf_ctrl, tf_plant), inputs='e', outputs='y'), ct.LinearIOSystem) + assert isinstance(ct.interconnect((ss_ctrl, ss_plant), inputs='e', outputs='y'), ct.LinearIOSystem) + assert isinstance(ct.interconnect((tf_ctrl, ss_plant), inputs='e', outputs='y'), ct.LinearIOSystem) + assert isinstance(ct.interconnect((ss_ctrl, tf_plant), inputs='e', outputs='y'), ct.LinearIOSystem) + + assert ~isinstance(ct.interconnect((nl_ctrl, ss_plant), inputs='e', outputs='y'), ct.LinearIOSystem) + assert ~isinstance(ct.interconnect((nl_ctrl, tf_plant), inputs='e', outputs='y'), ct.LinearIOSystem) + assert ~isinstance(ct.interconnect((ss_ctrl, nl_plant), inputs='e', outputs='y'), ct.LinearIOSystem) + assert ~isinstance(ct.interconnect((tf_ctrl, nl_plant), inputs='e', outputs='y'), ct.LinearIOSystem) \ No newline at end of file diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index 8a6ed8165..7d3b9fee9 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -1454,6 +1454,11 @@ def test_linear_interconnection(): inputs = ('u[0]', 'u[1]'), outputs = ('y[0]', 'y[1]'), name = 'sys2') + tf_siso = ct.tf(1, [0.1, 1]) + ss_siso = ct.ss(1, 2, 1, 1) + nl_siso = ios.NonlinearIOSystem( + lambda t, x, u, params: x*x, + lambda t, x, u, params: u*x, states=1, inputs=1, outputs=1) # Create a "regular" InterconnectedSystem nl_connect = ios.interconnect( @@ -1500,6 +1505,18 @@ def test_linear_interconnection(): np.testing.assert_array_almost_equal(io_connect.C, ss_connect.C) np.testing.assert_array_almost_equal(io_connect.D, ss_connect.D) + # make sure interconnections of linear systems are linear and + # if a nonlinear system is included then system is nonlinear + assert isinstance(ss_siso*ss_siso, ios.LinearIOSystem) + assert isinstance(tf_siso*ss_siso, ios.LinearIOSystem) + assert isinstance(ss_siso*tf_siso, ios.LinearIOSystem) + assert ~isinstance(ss_siso*nl_siso, ios.LinearIOSystem) + assert ~isinstance(nl_siso*ss_siso, ios.LinearIOSystem) + assert ~isinstance(nl_siso*nl_siso, ios.LinearIOSystem) + assert ~isinstance(tf_siso*nl_siso, ios.LinearIOSystem) + assert ~isinstance(nl_siso*tf_siso, ios.LinearIOSystem) + assert ~isinstance(nl_siso*nl_siso, ios.LinearIOSystem) + def predprey(t, x, u, params={}): """Predator prey dynamics""" From 4968cb3ed4b76a147439b20c6f58e36c6e2b4839 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 18 Nov 2022 22:00:22 -0800 Subject: [PATCH 068/157] check for and fix mutable keyword args --- control/flatsys/flatsys.py | 12 +++--- control/flatsys/linflat.py | 4 +- control/iosys.py | 5 ++- control/tests/kwargs_test.py | 71 ++++++++++++++++++++++++++++++++++-- 4 files changed, 78 insertions(+), 14 deletions(-) diff --git a/control/flatsys/flatsys.py b/control/flatsys/flatsys.py index 849c41c72..e0023c4de 100644 --- a/control/flatsys/flatsys.py +++ b/control/flatsys/flatsys.py @@ -142,7 +142,7 @@ def __init__(self, forward, reverse, # flat system updfcn=None, outfcn=None, # I/O system inputs=None, outputs=None, - states=None, params={}, dt=None, name=None): + states=None, params=None, dt=None, name=None): """Create a differentially flat I/O system. The FlatIOSystem constructor is used to create an input/output system @@ -171,7 +171,7 @@ def __str__(self): + f"Forward: {self.forward}\n" \ + f"Reverse: {self.reverse}" - def forward(self, x, u, params={}): + def forward(self, x, u, params=None): """Compute the flat flag given the states and input. @@ -200,7 +200,7 @@ def forward(self, x, u, params={}): """ raise NotImplementedError("internal error; forward method not defined") - def reverse(self, zflag, params={}): + def reverse(self, zflag, params=None): """Compute the states and input given the flat flag. Parameters @@ -224,18 +224,18 @@ def reverse(self, zflag, params={}): """ raise NotImplementedError("internal error; reverse method not defined") - def _flat_updfcn(self, t, x, u, params={}): + def _flat_updfcn(self, t, x, u, params=None): # TODO: implement state space update using flat coordinates raise NotImplementedError("update function for flat system not given") - def _flat_outfcn(self, t, x, u, params={}): + def _flat_outfcn(self, t, x, u, params=None): # Return the flat output zflag = self.forward(x, u, params) return np.array([zflag[i][0] for i in range(len(zflag))]) # Utility function to compute flag matrix given a basis -def _basis_flag_matrix(sys, basis, flag, t, params={}): +def _basis_flag_matrix(sys, basis, flag, t): """Compute the matrix of basis functions and their derivatives This function computes the matrix ``M`` that is used to solve for the diff --git a/control/flatsys/linflat.py b/control/flatsys/linflat.py index e4a31c6de..8e6c23604 100644 --- a/control/flatsys/linflat.py +++ b/control/flatsys/linflat.py @@ -142,11 +142,11 @@ def reverse(self, zflag, params): return np.reshape(x, self.nstates), np.reshape(u, self.ninputs) # Update function - def _rhs(self, t, x, u, params={}): + def _rhs(self, t, x, u): # Use LinearIOSystem._rhs instead of default (MRO) NonlinearIOSystem return LinearIOSystem._rhs(self, t, x, u) # output function - def _out(self, t, x, u, params={}): + def _out(self, t, x, u): # Use LinearIOSystem._out instead of default (MRO) NonlinearIOSystem return LinearIOSystem._out(self, t, x, u) diff --git a/control/iosys.py b/control/iosys.py index df75f3b54..6fa4a3e76 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -1584,7 +1584,7 @@ def __init__(self, io_sys, ss_sys=None): def input_output_response( sys, T, U=0., X0=0, params=None, transpose=False, return_x=False, squeeze=None, - solve_ivp_kwargs={}, t_eval='T', **kwargs): + solve_ivp_kwargs=None, t_eval='T', **kwargs): """Compute the output response of a system to a given input. Simulate a dynamical system with a given input and return its output @@ -1650,7 +1650,7 @@ def input_output_response( solve_ivp_method : str, optional Set the method used by :func:`scipy.integrate.solve_ivp`. Defaults to 'RK45'. - solve_ivp_kwargs : str, optional + solve_ivp_kwargs : dict, optional Pass additional keywords to :func:`scipy.integrate.solve_ivp`. Raises @@ -1676,6 +1676,7 @@ def input_output_response( # # Figure out the method to be used + solve_ivp_kwargs = solve_ivp_kwargs.copy() if solve_ivp_kwargs else {} if kwargs.get('solve_ivp_method', None): if kwargs.get('method', None): raise ValueError("ivp_method specified more than once") diff --git a/control/tests/kwargs_test.py b/control/tests/kwargs_test.py index 855bb9dda..2dc7f0563 100644 --- a/control/tests/kwargs_test.py +++ b/control/tests/kwargs_test.py @@ -38,6 +38,10 @@ def test_kwarg_search(module, prefix): # Skip anything that isn't part of the control package continue + # Look for classes and then check member functions + if inspect.isclass(obj): + test_kwarg_search(obj, prefix + obj.__name__ + '.') + # Only look for functions with keyword arguments if not inspect.isfunction(obj): continue @@ -70,10 +74,6 @@ def test_kwarg_search(module, prefix): f"'unrecognized keyword' not found in unit test " f"for {name}") - # Look for classes and then check member functions - if inspect.isclass(obj): - test_kwarg_search(obj, prefix + obj.__name__ + '.') - @pytest.mark.parametrize( "function, nsssys, ntfsys, moreargs, kwargs", @@ -201,3 +201,66 @@ def test_matplotlib_kwargs(function, nsysargs, moreargs, kwargs, mplcleanup): 'TimeResponseData.__call__': trdata_test.test_response_copy, 'TransferFunction.__init__': test_unrecognized_kwargs, } + +# +# Look for keywords with mutable defaults +# +# This test goes through every function and looks for signatures that have a +# default value for a keyword that is mutable. An error is generated unless +# the function is listed in the `mutable_ok` set (which should only be used +# for cases were the code has been explicitly checked to make sure that the +# value of the mutable is not modified in the code). +# +mutable_ok = { # initial and date + control.flatsys.SystemTrajectory.__init__, # RMM, 18 Nov 2022 + control.freqplot._add_arrows_to_line2D, # RMM, 18 Nov 2022 + control.namedio._process_dt_keyword, # RMM, 13 Nov 2022 + control.namedio._process_namedio_keywords, # RMM, 18 Nov 2022 + control.optimal.OptimalControlProblem.__init__, # RMM, 18 Nov 2022 + control.optimal.solve_ocp, # RMM, 18 Nov 2022 + control.optimal.create_mpc_iosystem, # RMM, 18 Nov 2022 +} + +@pytest.mark.parametrize("module", [control, control.flatsys]) +def test_mutable_defaults(module, recurse=True): + # Look through every object in the package + for name, obj in inspect.getmembers(module): + # Skip anything that is outside of this module + if inspect.getmodule(obj) is not None and \ + not inspect.getmodule(obj).__name__.startswith('control'): + # Skip anything that isn't part of the control package + continue + + # Look for classes and then check member functions + if inspect.isclass(obj): + test_mutable_defaults(obj, True) + + # Look for modules and check for internal functions (w/ no recursion) + if inspect.ismodule(obj) and recurse: + test_mutable_defaults(obj, False) + + # Only look at functions and skip any that are marked as OK + if not inspect.isfunction(obj) or obj in mutable_ok: + continue + + # Get the signature for the function + sig = inspect.signature(obj) + + # Skip anything that is inherited + if inspect.isclass(module) and obj.__name__ not in module.__dict__: + continue + + # See if there is a variable keyword argument + for argname, par in sig.parameters.items(): + if par.default is inspect._empty or \ + not par.kind == inspect.Parameter.KEYWORD_ONLY and \ + not par.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: + continue + + # Check to see if the default value is mutable + if par.default is not None and not \ + isinstance(par.default, (bool, int, float, tuple, str)): + pytest.fail( + f"function '{obj.__name__}' in module '{module.__name__}'" + f" has mutable default for keyword '{par.name}'") + From e14d7b37ae9b3958d7adbc970f0f57fbda593d84 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 27 Aug 2022 16:15:15 -0700 Subject: [PATCH 069/157] move optimal_bench.py to steering_bench.py + small fixes --- benchmarks/README | 2 +- benchmarks/{optimal_bench.py => steering_bench.py} | 0 control/flatsys/bezier.py | 2 +- control/flatsys/poly.py | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename benchmarks/{optimal_bench.py => steering_bench.py} (100%) diff --git a/benchmarks/README b/benchmarks/README index a10bbfc21..9c788b250 100644 --- a/benchmarks/README +++ b/benchmarks/README @@ -11,7 +11,7 @@ you can use the following command from the root directory of the repository: PYTHONPATH=`pwd` asv run --python=python -You can also run benchmarks against specific commits usuing +You can also run benchmarks against specific commits using asv run diff --git a/benchmarks/optimal_bench.py b/benchmarks/steering_bench.py similarity index 100% rename from benchmarks/optimal_bench.py rename to benchmarks/steering_bench.py diff --git a/control/flatsys/bezier.py b/control/flatsys/bezier.py index 02b4209a6..fcf6201e9 100644 --- a/control/flatsys/bezier.py +++ b/control/flatsys/bezier.py @@ -73,7 +73,7 @@ def eval_deriv(self, i, k, t, var=None): raise ValueError("Basis function index too high") elif k >= self.N: # Higher order derivatives are zero - return np.zeros(t.shape) + return 0 * t # Compute the variables used in Bezier curve formulas n = self.N - 1 diff --git a/control/flatsys/poly.py b/control/flatsys/poly.py index bfd8de633..f315091aa 100644 --- a/control/flatsys/poly.py +++ b/control/flatsys/poly.py @@ -66,6 +66,6 @@ def __init__(self, N, T=1): # Compute the kth derivative of the ith basis function at time t def eval_deriv(self, i, k, t, var=None): """Evaluate the kth derivative of the ith basis function at time t.""" - if (i < k): return 0; # higher derivative than power + if (i < k): return 0 * t # higher derivative than power return factorial(i)/factorial(i-k) * \ np.power(t/self.T, i-k) / np.power(self.T, k) From 08f6464d616bda5cd2e33ff85ec80c06779d60e9 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 27 Aug 2022 16:16:17 -0700 Subject: [PATCH 070/157] improved flat system benchmarking (+ docstring, unit test updates) --- benchmarks/flatsys_bench.py | 84 +++++++++++++++++++++++++++++------ control/flatsys/flatsys.py | 5 +++ control/tests/flatsys_test.py | 20 +++++++++ 3 files changed, 96 insertions(+), 13 deletions(-) diff --git a/benchmarks/flatsys_bench.py b/benchmarks/flatsys_bench.py index 0c0a5e53a..05a2e7066 100644 --- a/benchmarks/flatsys_bench.py +++ b/benchmarks/flatsys_bench.py @@ -11,6 +11,10 @@ import control.flatsys as flat import control.optimal as opt +# +# System setup: vehicle steering (bicycle model) +# + # Vehicle steering dynamics def vehicle_update(t, x, u, params): # Get the parameters for the model @@ -67,11 +71,28 @@ def vehicle_reverse(zflag, params={}): # Define the time points where the cost/constraints will be evaluated timepts = np.linspace(0, Tf, 10, endpoint=True) -def time_steering_point_to_point(basis_name, basis_size): - if basis_name == 'poly': - basis = flat.PolyFamily(basis_size) - elif basis_name == 'bezier': - basis = flat.BezierFamily(basis_size) +# +# Benchmark test parameters +# + +basis_params = (['poly', 'bezier', 'bspline'], [8, 10, 12]) +basis_param_names = ["basis", "size"] + +def get_basis(name, size): + if name == 'poly': + basis = flat.PolyFamily(size, T=Tf) + elif name == 'bezier': + basis = flat.BezierFamily(size, T=Tf) + elif name == 'bspline': + basis = flat.BSplineFamily([0, Tf/2, Tf], size) + return basis + +# +# Benchmarks +# + +def time_point_to_point(basis_name, basis_size): + basis = get_basis(basis_name, basis_size) # Find trajectory between initial and final conditions traj = flat.point_to_point(vehicle, Tf, x0, u0, xf, uf, basis=basis) @@ -80,13 +101,16 @@ def time_steering_point_to_point(basis_name, basis_size): x, u = traj.eval([0, Tf]) np.testing.assert_array_almost_equal(x0, x[:, 0]) np.testing.assert_array_almost_equal(u0, u[:, 0]) - np.testing.assert_array_almost_equal(xf, x[:, 1]) - np.testing.assert_array_almost_equal(uf, u[:, 1]) + np.testing.assert_array_almost_equal(xf, x[:, -1]) + np.testing.assert_array_almost_equal(uf, u[:, -1]) + +time_point_to_point.params = basis_params +time_point_to_point.param_names = basis_param_names -time_steering_point_to_point.params = (['poly', 'bezier'], [6, 8]) -time_steering_point_to_point.param_names = ["basis", "size"] -def time_steering_cost(): +def time_point_to_point_with_cost(basis_name, basis_size): + basis = get_basis(basis_name, basis_size) + # Define cost and constraints traj_cost = opt.quadratic_cost( vehicle, None, np.diag([0.1, 1]), u0=uf) @@ -95,13 +119,47 @@ def time_steering_cost(): traj = flat.point_to_point( vehicle, timepts, x0, u0, xf, uf, - cost=traj_cost, constraints=constraints, basis=flat.PolyFamily(8) + cost=traj_cost, constraints=constraints, basis=basis, ) # Verify that the trajectory computation is correct x, u = traj.eval([0, Tf]) np.testing.assert_array_almost_equal(x0, x[:, 0]) np.testing.assert_array_almost_equal(u0, u[:, 0]) - np.testing.assert_array_almost_equal(xf, x[:, 1]) - np.testing.assert_array_almost_equal(uf, u[:, 1]) + np.testing.assert_array_almost_equal(xf, x[:, -1]) + np.testing.assert_array_almost_equal(uf, u[:, -1]) + +time_point_to_point_with_cost.params = basis_params +time_point_to_point_with_cost.param_names = basis_param_names + + +def time_solve_flat_ocp_terminal_cost(method, basis_name, basis_size): + basis = get_basis(basis_name, basis_size) + + # Define cost and constraints + traj_cost = opt.quadratic_cost( + vehicle, None, np.diag([0.1, 1]), u0=uf) + term_cost = opt.quadratic_cost( + vehicle, np.diag([1e3, 1e3, 1e3]), None, x0=xf) + constraints = [ + opt.input_range_constraint(vehicle, [8, -0.1], [12, 0.1]) ] + + # Initial guess = straight line + initial_guess = np.array( + [x0[i] + (xf[i] - x0[i]) * timepts/Tf for i in (0, 1)]) + + traj = flat.solve_flat_ocp( + vehicle, timepts, x0, u0, basis=basis, initial_guess=initial_guess, + trajectory_cost=traj_cost, constraints=constraints, + terminal_cost=term_cost, minimize_method=method, + ) + + # Verify that the trajectory computation is correct + x, u = traj.eval([0, Tf]) + np.testing.assert_array_almost_equal(x0, x[:, 0]) + np.testing.assert_array_almost_equal(xf, x[:, -1], decimal=2) +time_solve_flat_ocp_terminal_cost.params = tuple( + [['slsqp', 'trust-constr']] + list(basis_params)) +time_solve_flat_ocp_terminal_cost.param_names = tuple( + ['method'] + basis_param_names) diff --git a/control/flatsys/flatsys.py b/control/flatsys/flatsys.py index 849c41c72..04e5c323a 100644 --- a/control/flatsys/flatsys.py +++ b/control/flatsys/flatsys.py @@ -654,6 +654,11 @@ def solve_flat_ocp( * cost : computed cost of the returned trajectory * message : message returned by optimization if success if False + 3. A common failure in solving optimal control problem is that the + default initial guess violates the constraints and the optimizer + can't find a feasible solution. Using the `initial_guess` parameter + can often be used to overcome these errors. + """ # # Make sure the problem is one that we can handle diff --git a/control/tests/flatsys_test.py b/control/tests/flatsys_test.py index 665bfd968..3f308c79b 100644 --- a/control/tests/flatsys_test.py +++ b/control/tests/flatsys_test.py @@ -464,6 +464,26 @@ def test_bezier_basis(self): with pytest.raises(ValueError, match="index too high"): bezier.eval_deriv(4, 0, time) + @pytest.mark.parametrize("basis, degree, T", [ + (fs.PolyFamily(4), 4, 1), + (fs.PolyFamily(4, 100), 4, 100), + (fs.BezierFamily(4), 4, 1), + (fs.BezierFamily(4, 100), 4, 100), + (fs.BSplineFamily([0, 0.5, 1], 4), 3, 1), + (fs.BSplineFamily([0, 50, 100], 4), 3, 100), + ]) + def test_basis_derivs(self, basis, degree, T): + """Make sure that that basis function derivates are correct""" + timepts = np.linspace(0, T, 10000) + dt = timepts[1] - timepts[0] + for i in range(basis.N): + for j in range(degree-1): + # Compare numerical and analytical derivative + np.testing.assert_allclose( + np.diff(basis.eval_deriv(i, j, timepts)) / dt, + basis.eval_deriv(i, j+1, timepts)[0:-1], + atol=1e-2, rtol=1e-4) + def test_point_to_point_errors(self): """Test error and warning conditions in point_to_point()""" # Double integrator system From efca9caffad823bf785d8b51d1d5ad300247674c Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 27 Aug 2022 22:48:36 -0700 Subject: [PATCH 071/157] new optimal benchmarks (replace steering with rss) --- benchmarks/optimal_bench.py | 247 +++++++++++++++++++++++++++++++++++ benchmarks/steering_bench.py | 220 ------------------------------- 2 files changed, 247 insertions(+), 220 deletions(-) create mode 100644 benchmarks/optimal_bench.py delete mode 100644 benchmarks/steering_bench.py diff --git a/benchmarks/optimal_bench.py b/benchmarks/optimal_bench.py new file mode 100644 index 000000000..bcc598527 --- /dev/null +++ b/benchmarks/optimal_bench.py @@ -0,0 +1,247 @@ +# optimal_bench.py - benchmarks for optimal control package +# RMM, 27 Feb 2021 +# +# This benchmark tests the timing for the optimal control module +# (control.optimal) and is intended to be used for helping tune the +# performance of the functions used for optimization-base control. + +import numpy as np +import math +import control as ct +import control.flatsys as fs +import control.optimal as opt + +# +# Benchmark test parameters +# + +# Define integrator and minimizer methods and options/keywords +integrator_table = { + 'default': (None, {}), + 'RK23': ('RK23', {}), + 'RK23_sloppy': ('RK23', {'atol': 1e-4, 'rtol': 1e-2}), + 'RK45': ('RK45', {}), + 'RK45': ('RK45', {}), + 'RK45_sloppy': ('RK45', {'atol': 1e-4, 'rtol': 1e-2}), + 'LSODA': ('LSODA', {}), +} + +minimizer_table = { + 'default': (None, {}), + 'trust': ('trust-constr', {}), + 'trust_bigstep': ('trust-constr', {'finite_diff_rel_step': 0.01}), + 'SLSQP': ('SLSQP', {}), + 'SLSQP_bigstep': ('SLSQP', {'eps': 0.01}), + 'COBYLA': ('COBYLA', {}), +} + +# Utility function to create a basis of a given size +def get_basis(name, size, Tf): + if name == 'poly': + basis = fs.PolyFamily(size, T=Tf) + elif name == 'bezier': + basis = fs.BezierFamily(size, T=Tf) + elif name == 'bspline': + basis = fs.BSplineFamily([0, Tf/2, Tf], size) + return basis + + +# +# Optimal trajectory generation with linear quadratic cost +# + +def time_optimal_lq_basis(basis_name, basis_size, npoints): + # Create a sufficiently controllable random system to control + ntrys = 20 + while ntrys > 0: + # Create a random system + sys = ct.rss(2, 2, 2) + + # Compute the controllability Gramian + Wc = ct.gram(sys, 'c') + + # Make sure the condition number is reasonable + if np.linalg.cond(Wc) < 1e6: + break + + ntrys -= 1 + assert ntrys > 0 # Something wrong if we needed > 20 tries + + # Define cost functions + Q = np.eye(sys.nstates) + R = np.eye(sys.ninputs) * 10 + + # Figure out the LQR solution (for terminal cost) + K, S, E = ct.lqr(sys, Q, R) + + # Define the cost functions + traj_cost = opt.quadratic_cost(sys, Q, R) + term_cost = opt.quadratic_cost(sys, S, None) + constraints = opt.input_range_constraint( + sys, -np.ones(sys.ninputs), np.ones(sys.ninputs)) + + # Define the initial condition, time horizon, and time points + x0 = np.ones(sys.nstates) + Tf = 10 + timepts = np.linspace(0, Tf, npoints) + + # Create the basis function to use + basis = get_basis(basis_name, basis_size, Tf) + + res = opt.solve_ocp( + sys, timepts, x0, traj_cost, constraints, terminal_cost=term_cost, + ) + # Only count this as a benchmark if we converged + assert res.success + +# Parameterize the test against different choices of integrator and minimizer +time_optimal_lq_basis.param_names = ['basis', 'size', 'npoints'] +time_optimal_lq_basis.params = ( + ['poly', 'bezier', 'bspline'], [8, 10, 12], [5, 10, 20]) + + +def time_optimal_lq_methods(integrator_name, minimizer_name): + # Get the integrator and minimizer parameters to use + integrator = integrator_table[integrator_name] + minimizer = minimizer_table[minimizer_name] + + # Create a random system to control + sys = ct.rss(2, 1, 1) + + # Define cost functions + Q = np.eye(sys.nstates) + R = np.eye(sys.ninputs) * 10 + + # Figure out the LQR solution (for terminal cost) + K, S, E = ct.lqr(sys, Q, R) + + # Define the cost functions + traj_cost = opt.quadratic_cost(sys, Q, R) + term_cost = opt.quadratic_cost(sys, S, None) + constraints = opt.input_range_constraint( + sys, -np.ones(sys.ninputs), np.ones(sys.ninputs)) + + # Define the initial condition, time horizon, and time points + x0 = np.ones(sys.nstates) + Tf = 10 + timepts = np.linspace(0, Tf, 20) + + # Create the basis function to use + basis = get_basis('poly', 12, Tf) + + res = opt.solve_ocp( + sys, timepts, x0, traj_cost, constraints, terminal_cost=term_cost, + solve_ivp_method=integrator[0], solve_ivp_kwargs=integrator[1], + minimize_method=minimizer[0], minimize_options=minimizer[1], + ) + # Only count this as a benchmark if we converged + assert res.success + +# Parameterize the test against different choices of integrator and minimizer +time_optimal_lq_methods.param_names = ['integrator', 'minimizer'] +time_optimal_lq_methods.params = ( + ['RK23', 'RK45', 'LSODA'], ['trust', 'SLSQP', 'COBYLA']) + + +def time_optimal_lq_size(nstates, ninputs, npoints): + # Create a sufficiently controllable random system to control + ntrys = 20 + while ntrys > 0: + # Create a random system + sys = ct.rss(nstates, ninputs, ninputs) + + # Compute the controllability Gramian + Wc = ct.gram(sys, 'c') + + # Make sure the condition number is reasonable + if np.linalg.cond(Wc) < 1e6: + break + + ntrys -= 1 + assert ntrys > 0 # Something wrong if we needed > 20 tries + + # Define cost functions + Q = np.eye(sys.nstates) + R = np.eye(sys.ninputs) * 10 + + # Figure out the LQR solution (for terminal cost) + K, S, E = ct.lqr(sys, Q, R) + + # Define the cost functions + traj_cost = opt.quadratic_cost(sys, Q, R) + term_cost = opt.quadratic_cost(sys, S, None) + constraints = opt.input_range_constraint( + sys, -np.ones(sys.ninputs), np.ones(sys.ninputs)) + + # Define the initial condition, time horizon, and time points + x0 = np.ones(sys.nstates) + Tf = 10 + timepts = np.linspace(0, Tf, npoints) + + res = opt.solve_ocp( + sys, timepts, x0, traj_cost, constraints, terminal_cost=term_cost, + ) + # Only count this as a benchmark if we converged + assert res.success + +# Parameterize the test against different choices of integrator and minimizer +time_optimal_lq_size.param_names = ['nstates', 'ninputs', 'npoints'] +time_optimal_lq_size.params = ([1, 2, 4], [1, 2, 4], [5, 10, 20]) + + +# +# Aircraft MPC example (from multi-parametric toolbox) +# + +def time_aircraft_mpc(): + # model of an aircraft discretized with 0.2s sampling time + # Source: https://www.mpt3.org/UI/RegulationProblem + A = [[0.99, 0.01, 0.18, -0.09, 0], + [ 0, 0.94, 0, 0.29, 0], + [ 0, 0.14, 0.81, -0.9, 0], + [ 0, -0.2, 0, 0.95, 0], + [ 0, 0.09, 0, 0, 0.9]] + B = [[ 0.01, -0.02], + [-0.14, 0], + [ 0.05, -0.2], + [ 0.02, 0], + [-0.01, 0]] + C = [[0, 1, 0, 0, -1], + [0, 0, 1, 0, 0], + [0, 0, 0, 1, 0], + [1, 0, 0, 0, 0]] + model = ct.ss2io(ct.ss(A, B, C, 0, 0.2)) + + # For the simulation we need the full state output + sys = ct.ss2io(ct.ss(A, B, np.eye(5), 0, 0.2)) + + # compute the steady state values for a particular value of the input + ud = np.array([0.8, -0.3]) + xd = np.linalg.inv(np.eye(5) - A) @ B @ ud + yd = C @ xd + + # provide constraints on the system signals + constraints = [opt.input_range_constraint(sys, [-5, -6], [5, 6])] + + # provide penalties on the system signals + Q = model.C.transpose() @ np.diag([10, 10, 10, 10]) @ model.C + R = np.diag([3, 2]) + cost = opt.quadratic_cost(model, Q, R, x0=xd, u0=ud) + + # online MPC controller object is constructed with a horizon 6 + ctrl = opt.create_mpc_iosystem( + model, np.arange(0, 6) * 0.2, cost, constraints) + + # Define an I/O system implementing model predictive control + loop = ct.feedback(sys, ctrl, 1) + + # Choose a nearby initial condition to speed up computation + X0 = np.hstack([xd, np.kron(ud, np.ones(6))]) * 0.99 + + Nsim = 12 + tout, xout = ct.input_output_response( + loop, np.arange(0, Nsim) * 0.2, 0, X0) + + # Make sure the system converged to the desired state + np.testing.assert_allclose( + xout[0:sys.nstates, -1], xd, atol=0.1, rtol=0.01) diff --git a/benchmarks/steering_bench.py b/benchmarks/steering_bench.py deleted file mode 100644 index 21cabef7e..000000000 --- a/benchmarks/steering_bench.py +++ /dev/null @@ -1,220 +0,0 @@ -# optimal_bench.py - benchmarks for optimal control package -# RMM, 27 Feb 2021 -# -# This benchmark tests the timing for the optimal control module -# (control.optimal) and is intended to be used for helping tune the -# performance of the functions used for optimization-base control. - -import numpy as np -import math -import control as ct -import control.flatsys as flat -import control.optimal as opt - -# Vehicle steering dynamics -def vehicle_update(t, x, u, params): - # Get the parameters for the model - l = params.get('wheelbase', 3.) # vehicle wheelbase - phimax = params.get('maxsteer', 0.5) # max steering angle (rad) - - # Saturate the steering input (use min/max instead of clip for speed) - phi = max(-phimax, min(u[1], phimax)) - - # Return the derivative of the state - return np.array([ - math.cos(x[2]) * u[0], # xdot = cos(theta) v - math.sin(x[2]) * u[0], # ydot = sin(theta) v - (u[0] / l) * math.tan(phi) # thdot = v/l tan(phi) - ]) - -def vehicle_output(t, x, u, params): - return x # return x, y, theta (full state) - -vehicle = ct.NonlinearIOSystem( - vehicle_update, vehicle_output, states=3, name='vehicle', - inputs=('v', 'phi'), outputs=('x', 'y', 'theta')) - -# Initial and final conditions -x0 = [0., -2., 0.]; u0 = [10., 0.] -xf = [100., 2., 0.]; uf = [10., 0.] -Tf = 10 - -# Define the time horizon (and spacing) for the optimization -horizon = np.linspace(0, Tf, 10, endpoint=True) - -# Provide an intial guess (will be extended to entire horizon) -bend_left = [10, 0.01] # slight left veer - -def time_steering_integrated_cost(): - # Set up the cost functions - Q = np.diag([.1, 10, .1]) # keep lateral error low - R = np.diag([.1, 1]) # minimize applied inputs - quad_cost = opt.quadratic_cost( - vehicle, Q, R, x0=xf, u0=uf) - - res = opt.solve_ocp( - vehicle, horizon, x0, quad_cost, - initial_guess=bend_left, print_summary=False, - # solve_ivp_kwargs={'atol': 1e-2, 'rtol': 1e-2}, - minimize_method='trust-constr', - minimize_options={'finite_diff_rel_step': 0.01}, - ) - - # Only count this as a benchmark if we converged - assert res.success - -def time_steering_terminal_cost(): - # Define cost and constraints - traj_cost = opt.quadratic_cost( - vehicle, None, np.diag([0.1, 1]), u0=uf) - term_cost = opt.quadratic_cost( - vehicle, np.diag([1, 10, 10]), None, x0=xf) - constraints = [ - opt.input_range_constraint(vehicle, [8, -0.1], [12, 0.1]) ] - - res = opt.solve_ocp( - vehicle, horizon, x0, traj_cost, constraints, - terminal_cost=term_cost, initial_guess=bend_left, print_summary=False, - solve_ivp_kwargs={'atol': 1e-4, 'rtol': 1e-2}, - # minimize_method='SLSQP', minimize_options={'eps': 0.01} - minimize_method='trust-constr', - minimize_options={'finite_diff_rel_step': 0.01}, - ) - # Only count this as a benchmark if we converged - assert res.success - -# Define integrator and minimizer methods and options/keywords -integrator_table = { - 'RK23_default': ('RK23', {'atol': 1e-4, 'rtol': 1e-2}), - 'RK23_sloppy': ('RK23', {}), - 'RK45_default': ('RK45', {}), - 'RK45_sloppy': ('RK45', {'atol': 1e-4, 'rtol': 1e-2}), -} - -minimizer_table = { - 'trust_default': ('trust-constr', {}), - 'trust_bigstep': ('trust-constr', {'finite_diff_rel_step': 0.01}), - 'SLSQP_default': ('SLSQP', {}), - 'SLSQP_bigstep': ('SLSQP', {'eps': 0.01}), -} - - -def time_steering_terminal_constraint(integrator_name, minimizer_name): - # Get the integrator and minimizer parameters to use - integrator = integrator_table[integrator_name] - minimizer = minimizer_table[minimizer_name] - - # Input cost and terminal constraints - R = np.diag([1, 1]) # minimize applied inputs - cost = opt.quadratic_cost(vehicle, np.zeros((3,3)), R, u0=uf) - constraints = [ - opt.input_range_constraint(vehicle, [8, -0.1], [12, 0.1]) ] - terminal = [ opt.state_range_constraint(vehicle, xf, xf) ] - - res = opt.solve_ocp( - vehicle, horizon, x0, cost, constraints, - terminal_constraints=terminal, initial_guess=bend_left, log=False, - solve_ivp_method=integrator[0], solve_ivp_kwargs=integrator[1], - minimize_method=minimizer[0], minimize_options=minimizer[1], - ) - # Only count this as a benchmark if we converged - assert res.success - -# Reset the timeout value to allow for longer runs -time_steering_terminal_constraint.timeout = 120 - -# Parameterize the test against different choices of integrator and minimizer -time_steering_terminal_constraint.param_names = ['integrator', 'minimizer'] -time_steering_terminal_constraint.params = ( - ['RK23_default', 'RK23_sloppy', 'RK45_default', 'RK45_sloppy'], - ['trust_default', 'trust_bigstep', 'SLSQP_default', 'SLSQP_bigstep'] -) - -def time_steering_bezier_basis(nbasis, ntimes): - # Set up costs and constriants - Q = np.diag([.1, 10, .1]) # keep lateral error low - R = np.diag([1, 1]) # minimize applied inputs - cost = opt.quadratic_cost(vehicle, Q, R, x0=xf, u0=uf) - constraints = [ opt.input_range_constraint(vehicle, [0, -0.1], [20, 0.1]) ] - terminal = [ opt.state_range_constraint(vehicle, xf, xf) ] - - # Set up horizon - horizon = np.linspace(0, Tf, ntimes, endpoint=True) - - # Set up the optimal control problem - res = opt.solve_ocp( - vehicle, horizon, x0, cost, - constraints, - terminal_constraints=terminal, - initial_guess=bend_left, - basis=flat.BezierFamily(nbasis, T=Tf), - # solve_ivp_kwargs={'atol': 1e-4, 'rtol': 1e-2}, - minimize_method='trust-constr', - minimize_options={'finite_diff_rel_step': 0.01}, - # minimize_method='SLSQP', minimize_options={'eps': 0.01}, - return_states=True, print_summary=False - ) - t, u, x = res.time, res.inputs, res.states - - # Make sure we found a valid solution - assert res.success - -# Reset the timeout value to allow for longer runs -time_steering_bezier_basis.timeout = 120 - -# Set the parameter values for the number of times and basis vectors -time_steering_bezier_basis.param_names = ['nbasis', 'ntimes'] -time_steering_bezier_basis.params = ([2, 4, 6], [5, 10, 20]) - -def time_aircraft_mpc(): - # model of an aircraft discretized with 0.2s sampling time - # Source: https://www.mpt3.org/UI/RegulationProblem - A = [[0.99, 0.01, 0.18, -0.09, 0], - [ 0, 0.94, 0, 0.29, 0], - [ 0, 0.14, 0.81, -0.9, 0], - [ 0, -0.2, 0, 0.95, 0], - [ 0, 0.09, 0, 0, 0.9]] - B = [[ 0.01, -0.02], - [-0.14, 0], - [ 0.05, -0.2], - [ 0.02, 0], - [-0.01, 0]] - C = [[0, 1, 0, 0, -1], - [0, 0, 1, 0, 0], - [0, 0, 0, 1, 0], - [1, 0, 0, 0, 0]] - model = ct.ss2io(ct.ss(A, B, C, 0, 0.2)) - - # For the simulation we need the full state output - sys = ct.ss2io(ct.ss(A, B, np.eye(5), 0, 0.2)) - - # compute the steady state values for a particular value of the input - ud = np.array([0.8, -0.3]) - xd = np.linalg.inv(np.eye(5) - A) @ B @ ud - yd = C @ xd - - # provide constraints on the system signals - constraints = [opt.input_range_constraint(sys, [-5, -6], [5, 6])] - - # provide penalties on the system signals - Q = model.C.transpose() @ np.diag([10, 10, 10, 10]) @ model.C - R = np.diag([3, 2]) - cost = opt.quadratic_cost(model, Q, R, x0=xd, u0=ud) - - # online MPC controller object is constructed with a horizon 6 - ctrl = opt.create_mpc_iosystem( - model, np.arange(0, 6) * 0.2, cost, constraints) - - # Define an I/O system implementing model predictive control - loop = ct.feedback(sys, ctrl, 1) - - # Choose a nearby initial condition to speed up computation - X0 = np.hstack([xd, np.kron(ud, np.ones(6))]) * 0.99 - - Nsim = 12 - tout, xout = ct.input_output_response( - loop, np.arange(0, Nsim) * 0.2, 0, X0) - - # Make sure the system converged to the desired state - np.testing.assert_allclose( - xout[0:sys.nstates, -1], xd, atol=0.1, rtol=0.01) From 2860d897ff49c8bd1c95ee435933f230d80d5b76 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Mon, 29 Aug 2022 07:46:25 -0700 Subject: [PATCH 072/157] add error for using solve_ivp for discrete time + formatting tweaks --- benchmarks/optimal_bench.py | 27 +++++++++++++++++++++------ control/optimal.py | 8 ++++++++ control/tests/optimal_test.py | 9 +++++++++ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/benchmarks/optimal_bench.py b/benchmarks/optimal_bench.py index bcc598527..a424c7827 100644 --- a/benchmarks/optimal_bench.py +++ b/benchmarks/optimal_bench.py @@ -89,7 +89,8 @@ def time_optimal_lq_basis(basis_name, basis_size, npoints): basis = get_basis(basis_name, basis_size, Tf) res = opt.solve_ocp( - sys, timepts, x0, traj_cost, constraints, terminal_cost=term_cost, + sys, timepts, x0, traj_cost, constraints, terminal_cost=term_cost, + basis=basis, ) # Only count this as a benchmark if we converged assert res.success @@ -130,7 +131,7 @@ def time_optimal_lq_methods(integrator_name, minimizer_name): basis = get_basis('poly', 12, Tf) res = opt.solve_ocp( - sys, timepts, x0, traj_cost, constraints, terminal_cost=term_cost, + sys, timepts, x0, traj_cost, constraints, terminal_cost=term_cost, solve_ivp_method=integrator[0], solve_ivp_kwargs=integrator[1], minimize_method=minimizer[0], minimize_options=minimizer[1], ) @@ -179,7 +180,7 @@ def time_optimal_lq_size(nstates, ninputs, npoints): timepts = np.linspace(0, Tf, npoints) res = opt.solve_ocp( - sys, timepts, x0, traj_cost, constraints, terminal_cost=term_cost, + sys, timepts, x0, traj_cost, constraints, terminal_cost=term_cost, ) # Only count this as a benchmark if we converged assert res.success @@ -187,13 +188,13 @@ def time_optimal_lq_size(nstates, ninputs, npoints): # Parameterize the test against different choices of integrator and minimizer time_optimal_lq_size.param_names = ['nstates', 'ninputs', 'npoints'] time_optimal_lq_size.params = ([1, 2, 4], [1, 2, 4], [5, 10, 20]) - + # # Aircraft MPC example (from multi-parametric toolbox) # -def time_aircraft_mpc(): +def time_discrete_aircraft_mpc(minimizer_name): # model of an aircraft discretized with 0.2s sampling time # Source: https://www.mpt3.org/UI/RegulationProblem A = [[0.99, 0.01, 0.18, -0.09, 0], @@ -228,9 +229,18 @@ def time_aircraft_mpc(): R = np.diag([3, 2]) cost = opt.quadratic_cost(model, Q, R, x0=xd, u0=ud) + # Set the time horizon and time points + Tf = 3 + timepts = np.arange(0, 6) * 0.2 + + # Get the minimizer parameters to use + minimizer = minimizer_table[minimizer_name] + # online MPC controller object is constructed with a horizon 6 ctrl = opt.create_mpc_iosystem( - model, np.arange(0, 6) * 0.2, cost, constraints) + model, timepts, cost, constraints, + minimize_method=minimizer[0], minimize_options=minimizer[1], + ) # Define an I/O system implementing model predictive control loop = ct.feedback(sys, ctrl, 1) @@ -245,3 +255,8 @@ def time_aircraft_mpc(): # Make sure the system converged to the desired state np.testing.assert_allclose( xout[0:sys.nstates, -1], xd, atol=0.1, rtol=0.01) + +# Parameterize the test against different choices of minimizer and basis +time_discrete_aircraft_mpc.param_names = ['minimizer'] +time_discrete_aircraft_mpc.params = ( + ['trust', 'trust_bigstep', 'SLSQP', 'SLSQP_bigstep', 'COBYLA']) diff --git a/control/optimal.py b/control/optimal.py index 4913cc341..3da236e75 100644 --- a/control/optimal.py +++ b/control/optimal.py @@ -154,6 +154,14 @@ def __init__( self.minimize_kwargs.update(kwargs.pop( 'minimize_kwargs', config.defaults['optimal.minimize_kwargs'])) + # Check to make sure arguments for discrete-time systems are OK + if sys.isdtime(strict=True): + if self.solve_ivp_kwargs['method'] is not None or \ + len(self.solve_ivp_kwargs) > 1: + raise TypeError( + "solve_ivp method, kwargs not allowed for" + " discrete time systems") + # Make sure there were no extraneous keywords if kwargs: raise TypeError("unrecognized keyword(s): ", str(kwargs)) diff --git a/control/tests/optimal_test.py b/control/tests/optimal_test.py index b100e7e14..0e5e35d79 100644 --- a/control/tests/optimal_test.py +++ b/control/tests/optimal_test.py @@ -471,6 +471,15 @@ def test_ocp_argument_errors(): res = opt.solve_ocp( sys, time, x0, cost, terminal_constraints=constraints) + # Discrete time system checks: solve_ivp keywords not allowed + sys = ct.rss(2, 1, 1, dt=True) + with pytest.raises(TypeError, match="solve_ivp method, kwargs not allowed"): + res = opt.solve_ocp( + sys, time, x0, cost, solve_ivp_method='LSODA') + with pytest.raises(TypeError, match="solve_ivp method, kwargs not allowed"): + res = opt.solve_ocp( + sys, time, x0, cost, solve_ivp_kwargs={'eps': 0.1}) + @pytest.mark.parametrize("basis", [ flat.PolyFamily(4), flat.PolyFamily(6), From 54de2a3c33b8819c9d6146166d24af9ceda82a75 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Mon, 29 Aug 2022 17:50:20 -0700 Subject: [PATCH 073/157] allow print_summary to be set in solve_ocp --- control/optimal.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/control/optimal.py b/control/optimal.py index 3da236e75..9650272f7 100644 --- a/control/optimal.py +++ b/control/optimal.py @@ -897,7 +897,8 @@ def __init__( def solve_ocp( sys, horizon, X0, cost, trajectory_constraints=None, terminal_cost=None, terminal_constraints=[], initial_guess=None, basis=None, squeeze=None, - transpose=None, return_states=True, log=False, **kwargs): + transpose=None, return_states=True, print_summary=True, log=False, + **kwargs): """Compute the solution to an optimal control problem @@ -951,6 +952,9 @@ def solve_ocp( log : bool, optional If `True`, turn on logging messages (using Python logging module). + print_summary : bool, optional + If `True` (default), print a short summary of the computation. + return_states : bool, optional If True, return the values of the state at each time (default = True). @@ -1017,7 +1021,8 @@ def solve_ocp( # Solve for the optimal input from the current state return ocp.compute_trajectory( - X0, squeeze=squeeze, transpose=transpose, return_states=return_states) + X0, squeeze=squeeze, transpose=transpose, print_summary=print_summary, + return_states=return_states) # Create a model predictive controller for an optimal control problem From c70a377fc82e2bbe637e24760cf105f957843748 Mon Sep 17 00:00:00 2001 From: Rory Yorke Date: Sun, 20 Nov 2022 07:24:43 +0200 Subject: [PATCH 074/157] Use `raise NotImplentedError`, not `NotImplemented` in InputOutputSystem._rhs --- control/iosys.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index df75f3b54..49ab29c77 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -366,8 +366,8 @@ def _rhs(self, t, x, u): you may want to use :meth:`dynamics`. """ - NotImplemented("Evaluation not implemented for system of type ", - type(self)) + raise NotImplementedError("Evaluation not implemented for system of type ", + type(self)) def dynamics(self, t, x, u, params=None): """Compute the dynamics of a differential or difference equation. From 3732a2132402033ab14c7f0716b44b71e466fc48 Mon Sep 17 00:00:00 2001 From: Rory Yorke Date: Sun, 20 Nov 2022 07:26:06 +0200 Subject: [PATCH 075/157] Correct warnings.warn call in modelsimp.markov --- control/modelsimp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control/modelsimp.py b/control/modelsimp.py index 432b76b96..b1c1ae31c 100644 --- a/control/modelsimp.py +++ b/control/modelsimp.py @@ -476,7 +476,7 @@ def markov(Y, U, m=None, transpose=False): # Make sure there is enough data to compute parameters if m > n: - warn.warning("Not enough data for requested number of parameters") + warnings.warn("Not enough data for requested number of parameters") # # Original algorithm (with mapping to standard order) From 3f7b640c521c28fd8caf11a9fd70d5eb8a89e907 Mon Sep 17 00:00:00 2001 From: Rory Yorke Date: Sun, 20 Nov 2022 07:26:33 +0200 Subject: [PATCH 076/157] Remove non-existent __all__ entry in optimal --- control/optimal.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/control/optimal.py b/control/optimal.py index 4913cc341..de45a8dd0 100644 --- a/control/optimal.py +++ b/control/optimal.py @@ -20,8 +20,6 @@ from .exception import ControlNotImplemented from .timeresp import TimeResponseData -__all__ = ['find_optimal_input'] - # Define module default parameter values _optimal_defaults = { 'optimal.minimize_method': None, From 5d7b79888520276c3129ab0923a83f2cc75da0c7 Mon Sep 17 00:00:00 2001 From: Rory Yorke Date: Sun, 20 Nov 2022 07:26:55 +0200 Subject: [PATCH 077/157] Import ControlSlycot exception in statesp --- control/statesp.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/control/statesp.py b/control/statesp.py index a2fa3dd73..af549cff6 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -59,6 +59,8 @@ from scipy.signal import cont2discrete from scipy.signal import StateSpace as signalStateSpace from warnings import warn + +from .exception import ControlSlycot from .frdata import FrequencyResponseData from .lti import LTI, _process_frequency_response from .namedio import common_timebase, isdtime From f591c080142177e07e3393b4d73356ef0d761d92 Mon Sep 17 00:00:00 2001 From: Rory Yorke Date: Sun, 20 Nov 2022 07:27:40 +0200 Subject: [PATCH 078/157] Merge constraint constructor error tests in optimal_test --- control/tests/optimal_test.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/control/tests/optimal_test.py b/control/tests/optimal_test.py index b100e7e14..cdea19bfa 100644 --- a/control/tests/optimal_test.py +++ b/control/tests/optimal_test.py @@ -381,17 +381,8 @@ def test_optimal_logging(capsys): @pytest.mark.parametrize("fun, args, exception, match", [ [opt.quadratic_cost, (np.zeros((2, 3)), np.eye(2)), ValueError, "Q matrix is the wrong shape"], - [opt.quadratic_cost, (np.eye(2), 1), ValueError, + [opt.quadratic_cost, (np.eye(2), np.eye(2, 3)), ValueError, "R matrix is the wrong shape"], -]) -def test_constraint_constructor_errors(fun, args, exception, match): - """Test various error conditions for constraint constructors""" - sys = ct.ss2io(ct.rss(2, 2, 2)) - with pytest.raises(exception, match=match): - fun(sys, *args) - - -@pytest.mark.parametrize("fun, args, exception, match", [ [opt.input_poly_constraint, (np.zeros((2, 3)), [0, 0]), ValueError, "polytope matrix must match number of inputs"], [opt.output_poly_constraint, (np.zeros((2, 3)), [0, 0]), ValueError, From 9e1048c8efb2f25c2956dd27c6cc0aaa35473370 Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Sun, 20 Nov 2022 15:19:51 +0100 Subject: [PATCH 079/157] Add Python 3.11 classifier --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 89690ac8d..691ea4643 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ classifiers = [ "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Topic :: Software Development", "Topic :: Scientific/Engineering", "Operating System :: Microsoft :: Windows", From f9cacd7ad562cce9fe2562b5152d5df303ae831b Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Sun, 20 Nov 2022 15:18:43 +0100 Subject: [PATCH 080/157] bump upper conda version to 3.11 --- .github/workflows/python-package-conda.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-package-conda.yml b/.github/workflows/python-package-conda.yml index 459f9e69d..6a41a7227 100644 --- a/.github/workflows/python-package-conda.yml +++ b/.github/workflows/python-package-conda.yml @@ -17,14 +17,14 @@ jobs: max-parallel: 5 fail-fast: false matrix: - python-version: ['3.7', '3.10'] + python-version: ['3.7', '3.11'] slycot: ["", "conda"] pandas: [""] cvxopt: ["", "conda"] mplbackend: [""] array-and-matrix: [0] include: - - python-version: '3.10' + - python-version: '3.11' slycot: conda pandas: conda cvxopt: conda From d18e49655680bdd7f3f962c14347b30997d2a81e Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Sun, 20 Nov 2022 15:19:15 +0100 Subject: [PATCH 081/157] bump setup-python action tag --- .github/workflows/control-slycot-src.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/control-slycot-src.yml b/.github/workflows/control-slycot-src.yml index 2ce2a11dd..8d1e1cdf2 100644 --- a/.github/workflows/control-slycot-src.yml +++ b/.github/workflows/control-slycot-src.yml @@ -12,7 +12,7 @@ jobs: with: path: python-control - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 - name: Install Python dependencies and test tools run: pip install -v -e './python-control[test]' From d95e29e521ae8bd984c63c7519b8073e0b801141 Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Sun, 20 Nov 2022 15:23:11 +0100 Subject: [PATCH 082/157] rename jobs --- .github/workflows/install_examples.yml | 2 +- .github/workflows/python-package-conda.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/install_examples.yml b/.github/workflows/install_examples.yml index 84cd706f5..d50f8fda6 100644 --- a/.github/workflows/install_examples.yml +++ b/.github/workflows/install_examples.yml @@ -3,7 +3,7 @@ name: Setup, Examples, Notebooks on: [push, pull_request] jobs: - build-linux: + install-examples: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/python-package-conda.yml b/.github/workflows/python-package-conda.yml index 6a41a7227..1c4411ec6 100644 --- a/.github/workflows/python-package-conda.yml +++ b/.github/workflows/python-package-conda.yml @@ -3,7 +3,7 @@ name: Conda-based pytest on: [push, pull_request] jobs: - test-linux: + test-linux-conda: name: > Py${{ matrix.python-version }}; ${{ matrix.slycot || 'no' }} Slycot; @@ -75,7 +75,7 @@ jobs: coveralls: name: coveralls completion - needs: test-linux + needs: test-linux-conda runs-on: ubuntu-latest steps: - name: Coveralls Finished From 511ea46c46ed8183ea03eb8f4637e60ce2b89358 Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Sun, 20 Nov 2022 15:24:29 +0100 Subject: [PATCH 083/157] do not install PEP517 in editable mode --- .github/workflows/control-slycot-src.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/control-slycot-src.yml b/.github/workflows/control-slycot-src.yml index 8d1e1cdf2..bc80d84f5 100644 --- a/.github/workflows/control-slycot-src.yml +++ b/.github/workflows/control-slycot-src.yml @@ -14,7 +14,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 - name: Install Python dependencies and test tools - run: pip install -v -e './python-control[test]' + run: pip install -v './python-control[test]' - name: Checkout Slycot uses: actions/checkout@v3 From c9e4064934495a73b4dc1bace69934a58b739e87 Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Sun, 20 Nov 2022 15:30:45 +0100 Subject: [PATCH 084/157] hardcode python version in slycot-src workflow --- .github/workflows/control-slycot-src.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/control-slycot-src.yml b/.github/workflows/control-slycot-src.yml index bc80d84f5..811a89216 100644 --- a/.github/workflows/control-slycot-src.yml +++ b/.github/workflows/control-slycot-src.yml @@ -13,6 +13,8 @@ jobs: path: python-control - name: Set up Python uses: actions/setup-python@v4 + with: + python-version: '3.11' - name: Install Python dependencies and test tools run: pip install -v './python-control[test]' From 055ed394ef76b198d21d3839c04019c4305822ee Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Tue, 22 Nov 2022 21:30:12 -0800 Subject: [PATCH 085/157] initial commit that preserves signal names during cont-to-discrete transformation --- control/dtime.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/control/dtime.py b/control/dtime.py index b05d22b96..a09f5f501 100644 --- a/control/dtime.py +++ b/control/dtime.py @@ -47,7 +47,8 @@ """ -from .namedio import isctime +from .namedio import isctime, _process_namedio_keywords +from .iosys import ss from .statesp import StateSpace __all__ = ['sample_system', 'c2d'] @@ -92,9 +93,10 @@ def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None): # Make sure we have a continuous time system if not isctime(sysc): raise ValueError("First argument must be continuous time system") - - return sysc.sample(Ts, - method=method, alpha=alpha, prewarp_frequency=prewarp_frequency) + name, inputs, outputs, states, _ = _process_namedio_keywords(defaults=sysc) + return ss(sysc.sample(Ts, + method=method, alpha=alpha, prewarp_frequency=prewarp_frequency), + name=name, inputs=inputs, outputs=outputs, states=states) def c2d(sysc, Ts, method='zoh', prewarp_frequency=None): From 1fd68c7f7d1d28b800ec29926b6ff620e701dff0 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Tue, 22 Nov 2022 21:59:12 -0800 Subject: [PATCH 086/157] changed named signal handling to occur in sys.sample methods. added unit tests. --- control/dtime.py | 10 ++++------ control/statesp.py | 7 +++++-- control/tests/discrete_test.py | 13 +++++++++++++ control/xferfcn.py | 5 ++++- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/control/dtime.py b/control/dtime.py index a09f5f501..b05d22b96 100644 --- a/control/dtime.py +++ b/control/dtime.py @@ -47,8 +47,7 @@ """ -from .namedio import isctime, _process_namedio_keywords -from .iosys import ss +from .namedio import isctime from .statesp import StateSpace __all__ = ['sample_system', 'c2d'] @@ -93,10 +92,9 @@ def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None): # Make sure we have a continuous time system if not isctime(sysc): raise ValueError("First argument must be continuous time system") - name, inputs, outputs, states, _ = _process_namedio_keywords(defaults=sysc) - return ss(sysc.sample(Ts, - method=method, alpha=alpha, prewarp_frequency=prewarp_frequency), - name=name, inputs=inputs, outputs=outputs, states=states) + + return sysc.sample(Ts, + method=method, alpha=alpha, prewarp_frequency=prewarp_frequency) def c2d(sysc, Ts, method='zoh', prewarp_frequency=None): diff --git a/control/statesp.py b/control/statesp.py index af549cff6..b47a2894f 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -1347,14 +1347,17 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None): if not self.isctime(): raise ValueError("System must be continuous time system") - sys = (self.A, self.B, self.C, self.D) if (method == 'bilinear' or (method == 'gbt' and alpha == 0.5)) and \ prewarp_frequency is not None: Twarp = 2 * np.tan(prewarp_frequency * Ts/2)/prewarp_frequency else: Twarp = Ts + sys = (self.A, self.B, self.C, self.D) Ad, Bd, C, D, _ = cont2discrete(sys, Twarp, method, alpha) - return StateSpace(Ad, Bd, C, D, Ts) + # get and pass along same signal names + _, inputs, outputs, states, _ = _process_namedio_keywords(defaults=self) + return StateSpace(Ad, Bd, C, D, Ts, + inputs=inputs, outputs=outputs, states=states) def dcgain(self, warn_infinite=False): """Return the zero-frequency gain diff --git a/control/tests/discrete_test.py b/control/tests/discrete_test.py index cb0ce3c76..0842cbf59 100644 --- a/control/tests/discrete_test.py +++ b/control/tests/discrete_test.py @@ -446,3 +446,16 @@ def test_discrete_bode(self, tsys): np.testing.assert_array_almost_equal(omega, omega_out) np.testing.assert_array_almost_equal(mag_out, np.absolute(H_z)) np.testing.assert_array_almost_equal(phase_out, np.angle(H_z)) + + def test_signal_names(self, tsys): + "test that signal names are preserved in conversion to discrete-time" + ssc = StateSpace(tsys.siso_ss1c, + inputs='u', outputs='y', states=['a', 'b', 'c']) + ssd = ssc.sample(0.1) + tfc = TransferFunction(tsys.siso_tf1c, inputs='u', outputs='y') + tfd = tfc.sample(0.1) + assert ssd.input_labels == ['u'] + assert ssd.state_labels == ['a', 'b', 'c'] + assert ssd.output_labels == ['y'] + assert tfd.input_labels == ['u'] + assert tfd.output_labels == ['y'] \ No newline at end of file diff --git a/control/xferfcn.py b/control/xferfcn.py index d3671c533..bcb1130e2 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -1149,7 +1149,10 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None): else: Twarp = Ts numd, dend, _ = cont2discrete(sys, Twarp, method, alpha) - return TransferFunction(numd[0, :], dend, Ts) + # get and pass along same signal names + _, inputs, outputs, _, _ = _process_namedio_keywords(defaults=self) + return TransferFunction(numd[0, :], dend, Ts, + inputs=inputs, outputs=outputs) def dcgain(self, warn_infinite=False): """Return the zero-frequency (or DC) gain From e46f22e6602242c5b1a821367ffb0563bcf1a2ab Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Wed, 23 Nov 2022 16:22:37 +0100 Subject: [PATCH 087/157] Drop Python 3.7 --- .github/workflows/python-package-conda.yml | 2 +- pyproject.toml | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-package-conda.yml b/.github/workflows/python-package-conda.yml index 1c4411ec6..cea5e542f 100644 --- a/.github/workflows/python-package-conda.yml +++ b/.github/workflows/python-package-conda.yml @@ -17,7 +17,7 @@ jobs: max-parallel: 5 fail-fast: false matrix: - python-version: ['3.7', '3.11'] + python-version: ['3.8', '3.11'] slycot: ["", "conda"] pandas: [""] cvxopt: ["", "conda"] diff --git a/pyproject.toml b/pyproject.toml index 691ea4643..493594155 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,6 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", @@ -29,7 +28,7 @@ classifiers = [ "Operating System :: Unix", "Operating System :: MacOS", ] -requires-python = ">=3.7" +requires-python = ">=3.8" dependencies = [ "numpy", "scipy>=1.3", From 65e051fdedd1239141d24b955d34a214db24d10a Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Wed, 23 Nov 2022 10:51:10 -0800 Subject: [PATCH 088/157] create function to copy system names, move default name parameters to namedio, update sys.sample to enable signal names to be passed. --- control/config.py | 3 +++ control/iosys.py | 48 +++++++++++++++------------------------- control/namedio.py | 33 +++++++++++++++++++++++----- control/statesp.py | 55 ++++++++++++++++++++++++++++++++++++---------- control/xferfcn.py | 46 +++++++++++++++++++++++++++++++++----- 5 files changed, 132 insertions(+), 53 deletions(-) diff --git a/control/config.py b/control/config.py index 32f5f2eef..3f2814d42 100644 --- a/control/config.py +++ b/control/config.py @@ -97,6 +97,9 @@ def reset_defaults(): from .rlocus import _rlocus_defaults defaults.update(_rlocus_defaults) + from .namedio import _namedio_defaults + defaults.update(_namedio_defaults) + from .xferfcn import _xferfcn_defaults defaults.update(_xferfcn_defaults) diff --git a/control/iosys.py b/control/iosys.py index 9b33d2161..fd3dcd749 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -47,13 +47,7 @@ 'interconnect', 'summing_junction'] # Define module default parameter values -_iosys_defaults = { - 'iosys.state_name_delim': '_', - 'iosys.duplicate_system_name_prefix': '', - 'iosys.duplicate_system_name_suffix': '$copy', - 'iosys.linearized_system_name_prefix': '', - 'iosys.linearized_system_name_suffix': '$linearized' -} +_iosys_defaults = {} class InputOutputSystem(NamedIOSystem): @@ -515,7 +509,7 @@ def feedback(self, other=1, sign=-1, params=None): return newsys def linearize(self, x0, u0, t=0, params=None, eps=1e-6, - name=None, copy=False, **kwargs): + name=None, copy_names=False, **kwargs): """Linearize an input/output system at a given state and input. Return the linearization of an input/output system at a given state @@ -574,20 +568,14 @@ def linearize(self, x0, u0, t=0, params=None, eps=1e-6, StateSpace(A, B, C, D, self.dt, remove_useless_states=False), name=name, **kwargs) - # Set the names the system, inputs, outputs, and states - if copy: + # Set the system name, inputs, outputs, and states + if copy_names: if name is None: - linsys.name = \ - config.defaults['iosys.linearized_system_name_prefix'] + \ + name = \ + config.defaults['namedio.linearized_system_name_prefix'] +\ self.name + \ - config.defaults['iosys.linearized_system_name_suffix'] - linsys.ninputs, linsys.input_index = self.ninputs, \ - self.input_index.copy() - linsys.noutputs, linsys.output_index = \ - self.noutputs, self.output_index.copy() - linsys.nstates, linsys.state_index = \ - self.nstates, self.state_index.copy() - + config.defaults['namedio.linearized_system_name_suffix'] + linsys._copy_names(self, name=name) return linsys @@ -966,7 +954,7 @@ def __init__(self, syslist, connections=None, inplist=None, outlist=None, if states is None: states = [] - state_name_delim = config.defaults['iosys.state_name_delim'] + state_name_delim = config.defaults['namedio.state_name_delim'] for sys, sysname in sysobj_name_dct.items(): states += [sysname + state_name_delim + statename for statename in sys.state_index.keys()] @@ -2192,18 +2180,18 @@ def linearize(sys, xeq, ueq=None, t=0, params=None, **kw): params : dict, optional Parameter values for the systems. Passed to the evaluation functions for the system as default values, overriding internal defaults. - copy : bool, Optional - If `copy` is True, copy the names of the input signals, output signals, - and states to the linearized system. If `name` is not specified, - the system name is set to the input system name with the string - '_linearized' appended. + copy_names : bool, Optional + If `copy_names` is True, copy the names of the input signals, output + signals, and states to the linearized system. If `name` is not + specified, the system name is set to the input system name with the + string '_linearized' appended. name : string, optional Set the name of the linearized system. If not specified and if `copy` is `False`, a generic name is generated with a unique integer id. If `copy` is `True`, the new system name is determined by adding the prefix and suffix strings in - config.defaults['iosys.linearized_system_name_prefix'] and - config.defaults['iosys.linearized_system_name_suffix'], with the + config.defaults['namedio.linearized_system_name_prefix'] and + config.defaults['namedio.linearized_system_name_suffix'], with the default being to add the suffix '$linearized'. Returns @@ -2728,8 +2716,8 @@ def interconnect(syslist, connections=None, inplist=None, outlist=None, If a system is duplicated in the list of systems to be connected, a warning is generated and a copy of the system is created with the name of the new system determined by adding the prefix and suffix - strings in config.defaults['iosys.linearized_system_name_prefix'] - and config.defaults['iosys.linearized_system_name_suffix'], with the + strings in config.defaults['namedio.linearized_system_name_prefix'] + and config.defaults['namedio.linearized_system_name_suffix'], with the default being to add the suffix '$copy'$ to the system name. It is possible to replace lists in most of arguments with tuples instead, diff --git a/control/namedio.py b/control/namedio.py index 254f310ff..52e68671b 100644 --- a/control/namedio.py +++ b/control/namedio.py @@ -12,7 +12,18 @@ __all__ = ['issiso', 'timebase', 'common_timebase', 'timebaseEqual', 'isdtime', 'isctime'] - +# Define module default parameter values +_namedio_defaults = { + 'namedio.state_name_delim': '_', + 'namedio.duplicate_system_name_prefix': '', + 'namedio.duplicate_system_name_suffix': '$copy', + 'namedio.linearized_system_name_prefix': '', + 'namedio.linearized_system_name_suffix': '$linearized', + 'namedio.sampled_system_name_prefix': '', + 'namedio.sampled_system_name_suffix': '$sampled' +} + + class NamedIOSystem(object): def __init__( self, name=None, inputs=None, outputs=None, states=None, **kwargs): @@ -88,14 +99,26 @@ def __str__(self): def _find_signal(self, name, sigdict): return sigdict.get(name, None) + def _copy_names(self, sys, name=None): + """copy the signal and system name of sys. Name is given as a keyword + in case a specific name (e.g. append 'linearized') is desired. """ + if name is None: + self.name = sys.name + self.ninputs, self.input_index = \ + sys.ninputs, sys.input_index.copy() + self.noutputs, self.output_index = \ + sys.noutputs, sys.output_index.copy() + self.nstates, self.state_index = \ + sys.nstates, sys.state_index.copy() + def copy(self, name=None, use_prefix_suffix=True): """Make a copy of an input/output system A copy of the system is made, with a new name. The `name` keyword can be used to specify a specific name for the system. If no name is given and `use_prefix_suffix` is True, the name is constructed - by prepending config.defaults['iosys.duplicate_system_name_prefix'] - and appending config.defaults['iosys.duplicate_system_name_suffix']. + by prepending config.defaults['namedio.duplicate_system_name_prefix'] + and appending config.defaults['namedio.duplicate_system_name_suffix']. Otherwise, a generic system name of the form `sys[]` is used, where `` is based on an internal counter. @@ -106,8 +129,8 @@ def copy(self, name=None, use_prefix_suffix=True): # Update the system name if name is None and use_prefix_suffix: # Get the default prefix and suffix to use - dup_prefix = config.defaults['iosys.duplicate_system_name_prefix'] - dup_suffix = config.defaults['iosys.duplicate_system_name_suffix'] + dup_prefix = config.defaults['namedio.duplicate_system_name_prefix'] + dup_suffix = config.defaults['namedio.duplicate_system_name_suffix'] newsys.name = self._name_or_default( dup_prefix + self.name + dup_suffix) else: diff --git a/control/statesp.py b/control/statesp.py index b47a2894f..b5a5fe26f 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -63,8 +63,8 @@ from .exception import ControlSlycot from .frdata import FrequencyResponseData from .lti import LTI, _process_frequency_response -from .namedio import common_timebase, isdtime -from .namedio import _process_namedio_keywords +from .namedio import common_timebase, isdtime, _process_namedio_keywords, \ + _process_dt_keyword from . import config from copy import deepcopy @@ -357,9 +357,9 @@ def __init__(self, *args, init_namedio=True, **kwargs): states=states, dt=dt) elif kwargs: raise TypeError("unrecognized keyword(s): ", str(kwargs)) - + # Reset shapes (may not be needed once np.matrix support is removed) - if 0 == self.nstates: + if self._isstatic(): # static gain # matrix's default "empty" shape is 1x0 A.shape = (0, 0) @@ -1298,7 +1298,8 @@ def __getitem__(self, indices): return StateSpace(self.A, self.B[:, j], self.C[i, :], self.D[i, j], self.dt) - def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None): + def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None, + name=None, copy_names=True, **kwargs): """Convert a continuous time system to discrete time Creates a discrete-time system from a continuous-time system by @@ -1317,22 +1318,44 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None): alpha=0) * backward_diff: Backwards differencing ("gbt" with alpha=1.0) * zoh: zero-order hold (default) - alpha : float within [0, 1] The generalized bilinear transformation weighting parameter, which should only be specified with method="gbt", and is ignored otherwise - prewarp_frequency : float within [0, infinity) The frequency [rad/s] at which to match with the input continuous- time system's magnitude and phase (the gain=1 crossover frequency, for example). Should only be specified with method='bilinear' or 'gbt' with alpha=0.5 and ignored otherwise. + copy_names : bool, Optional + If `copy_names` is True, copy the names of the input signals, output + signals, and states to the sampled system. If `name` is not + specified, the system name is set to the input system name with the + string '_sampled' appended. + name : string, optional + Set the name of the sampled system. If not specified and + if `copy` is `False`, a generic name is generated + with a unique integer id. If `copy` is `True`, the new system + name is determined by adding the prefix and suffix strings in + config.defaults['namedio.sampled_system_name_prefix'] and + config.defaults['namedio.sampled_system_name_suffix'], with the + default being to add the suffix '$sampled'. Returns ------- sysd : StateSpace - Discrete time system, with sampling rate Ts + Discrete-time system, with sampling rate Ts + + Additional Parameters + --------------------- + inputs : int, list of str or None, optional + Description of the system inputs. If not specified, the origional + system inputs are used. See :class:`InputOutputSystem` for more + information. + outputs : int, list of str or None, optional + Description of the system outputs. Same format as `inputs`. + states : int, list of str, or None, optional + Description of the system states. Same format as `inputs`. Notes ----- @@ -1354,10 +1377,18 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None): Twarp = Ts sys = (self.A, self.B, self.C, self.D) Ad, Bd, C, D, _ = cont2discrete(sys, Twarp, method, alpha) - # get and pass along same signal names - _, inputs, outputs, states, _ = _process_namedio_keywords(defaults=self) - return StateSpace(Ad, Bd, C, D, Ts, - inputs=inputs, outputs=outputs, states=states) + sysd = StateSpace(Ad, Bd, C, D, Ts) + # copy over the system name, inputs, outputs, and states + if copy_names: + if name is None: + name = \ + config.defaults['namedio.sampled_system_name_prefix'] +\ + self.name + \ + config.defaults['namedio.sampled_system_name_suffix'] + sysd._copy_names(self, name=name) + # pass desired signal names if names were provided + sysd = StateSpace(sysd, **kwargs) + return sysd def dcgain(self, warn_infinite=False): """Return the zero-frequency gain diff --git a/control/xferfcn.py b/control/xferfcn.py index bcb1130e2..a5d52967a 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -1090,7 +1090,8 @@ def _common_den(self, imag_tol=None, allow_nonproper=False): return num, den, denorder - def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None): + def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None, + name=None, copy_names=True, **kwargs): """Convert a continuous-time system to discrete time Creates a discrete-time system from a continuous-time system by @@ -1118,11 +1119,35 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None): time system's magnitude and phase (the gain=1 crossover frequency, for example). Should only be specified with method='bilinear' or 'gbt' with alpha=0.5 and ignored otherwise. + copy_names : bool, Optional + If `copy_names` is True, copy the names of the input signals, output + signals, and states to the sampled system. If `name` is not + specified, the system name is set to the input system name with the + string '_sampled' appended. + name : string, optional + Set the name of the sampled system. If not specified and + if `copy` is `False`, a generic name is generated + with a unique integer id. If `copy` is `True`, the new system + name is determined by adding the prefix and suffix strings in + config.defaults['namedio.sampled_system_name_prefix'] and + config.defaults['namedio.sampled_system_name_suffix'], with the + default being to add the suffix '$sampled'. Returns ------- sysd : TransferFunction system - Discrete time system, with sample period Ts + Discrete-time system, with sample period Ts + + Additional Parameters + --------------------- + inputs : int, list of str or None, optional + Description of the system inputs. If not specified, the origional + system inputs are used. See :class:`NamedIOSystem` for more + information. + outputs : int, list of str or None, optional + Description of the system outputs. Same format as `inputs`. + states : int, list of str, or None, optional + Description of the system states. Same format as `inputs`. Notes ----- @@ -1149,11 +1174,20 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None): else: Twarp = Ts numd, dend, _ = cont2discrete(sys, Twarp, method, alpha) - # get and pass along same signal names - _, inputs, outputs, _, _ = _process_namedio_keywords(defaults=self) - return TransferFunction(numd[0, :], dend, Ts, - inputs=inputs, outputs=outputs) + sysd = TransferFunction(numd[0, :], dend, Ts) + # copy over the system name, inputs, outputs, and states + if copy_names: + if name is None: + name = \ + config.defaults['namedio.sampled_system_name_prefix'] +\ + self.name + \ + config.defaults['namedio.sampled_system_name_suffix'] + sysd._copy_names(self, name=name) + # pass desired signal names if names were provided + sysd = TransferFunction(sysd, **kwargs) + return sysd + def dcgain(self, warn_infinite=False): """Return the zero-frequency (or DC) gain From 28277f3e77b7b2871bec906af101e40f6735bce0 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Wed, 23 Nov 2022 12:09:41 -0800 Subject: [PATCH 089/157] add unit tests and fixes to pass unit tests --- control/config.py | 2 +- control/iosys.py | 2 +- control/namedio.py | 2 ++ control/tests/iosys_test.py | 8 +++---- control/tests/kwargs_test.py | 2 ++ control/tests/statesp_test.py | 40 +++++++++++++++++++++++++++++++++-- control/tests/xferfcn_test.py | 31 +++++++++++++++++++++++++++ 7 files changed, 79 insertions(+), 8 deletions(-) diff --git a/control/config.py b/control/config.py index 3f2814d42..ccee252fc 100644 --- a/control/config.py +++ b/control/config.py @@ -288,7 +288,7 @@ def use_legacy_defaults(version): set_defaults('control', default_dt=None) # changed iosys naming conventions - set_defaults('iosys', state_name_delim='.', + set_defaults('namedio', state_name_delim='.', duplicate_system_name_prefix='copy of ', duplicate_system_name_suffix='', linearized_system_name_prefix='', diff --git a/control/iosys.py b/control/iosys.py index fd3dcd749..40eaba9f5 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -2204,7 +2204,7 @@ def linearize(sys, xeq, ueq=None, t=0, params=None, **kw): --------------------- inputs : int, list of str or None, optional Description of the system inputs. If not specified, the origional - system inputs are used. See :class:`InputOutputSystem` for more + system inputs are used. See :class:`NamedIOSystem` for more information. outputs : int, list of str or None, optional Description of the system outputs. Same format as `inputs`. diff --git a/control/namedio.py b/control/namedio.py index 52e68671b..9f82e5929 100644 --- a/control/namedio.py +++ b/control/namedio.py @@ -104,6 +104,8 @@ def _copy_names(self, sys, name=None): in case a specific name (e.g. append 'linearized') is desired. """ if name is None: self.name = sys.name + else: + self.name = name self.ninputs, self.input_index = \ sys.ninputs, sys.input_index.copy() self.noutputs, self.output_index = \ diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index 7d3b9fee9..09542bcaa 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -216,7 +216,7 @@ def test_linearize(self, tsys, kincar): @pytest.mark.usefixtures("editsdefaults") def test_linearize_named_signals(self, kincar): # Full form of the call - linearized = kincar.linearize([0, 0, 0], [0, 0], copy=True, + linearized = kincar.linearize([0, 0, 0], [0, 0], copy_names=True, name='linearized') assert linearized.name == 'linearized' assert linearized.find_input('v') == 0 @@ -228,17 +228,17 @@ def test_linearize_named_signals(self, kincar): assert linearized.find_state('theta') == 2 # If we copy signal names w/out a system name, append '$linearized' - linearized = kincar.linearize([0, 0, 0], [0, 0], copy=True) + linearized = kincar.linearize([0, 0, 0], [0, 0], copy_names=True) assert linearized.name == kincar.name + '$linearized' # Test legacy version as well ct.use_legacy_defaults('0.8.4') ct.config.use_numpy_matrix(False) # np.matrix deprecated - linearized = kincar.linearize([0, 0, 0], [0, 0], copy=True) + linearized = kincar.linearize([0, 0, 0], [0, 0], copy_names=True) assert linearized.name == kincar.name + '_linearized' # If copy is False, signal names should not be copied - lin_nocopy = kincar.linearize(0, 0, copy=False) + lin_nocopy = kincar.linearize(0, 0, copy_names=False) assert lin_nocopy.find_input('v') is None assert lin_nocopy.find_output('x') is None assert lin_nocopy.find_state('x') is None diff --git a/control/tests/kwargs_test.py b/control/tests/kwargs_test.py index 2dc7f0563..065c4673f 100644 --- a/control/tests/kwargs_test.py +++ b/control/tests/kwargs_test.py @@ -198,8 +198,10 @@ def test_matplotlib_kwargs(function, nsysargs, moreargs, kwargs, mplcleanup): 'NonlinearIOSystem.__init__': interconnect_test.test_interconnect_exceptions, 'StateSpace.__init__': test_unrecognized_kwargs, + 'StateSpace.sample': test_unrecognized_kwargs, 'TimeResponseData.__call__': trdata_test.test_response_copy, 'TransferFunction.__init__': test_unrecognized_kwargs, + 'TransferFunction.sample': test_unrecognized_kwargs, } # diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index 6fcdade22..41f0c893a 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -820,8 +820,42 @@ def test_error_u_dynamics_mimo(self, u, sys222): sys222.dynamics(0, (1, 1), u) with pytest.raises(ValueError): sys222.output(0, (1, 1), u) - - + + def test_sample_named_signals(self): + sysc = ct.StateSpace(1.1, 1, 1, 1, inputs='u', outputs='y', states='a') + + # Full form of the call + sysd = sysc.sample(0.1, name='sampled') + assert sysd.name == 'sampled' + assert sysd.find_input('u') == 0 + assert sysd.find_output('y') == 0 + assert sysd.find_state('a') == 0 + + # If we copy signal names w/out a system name, append '$sampled' + sysd = sysc.sample(0.1) + assert sysd.name == sysc.name + '$sampled' + + # If copy is False, signal names should not be copied + sysd_nocopy = sysc.sample(0.1, copy_names=False) + assert sysd_nocopy.find_input('u') is None + assert sysd_nocopy.find_output('y') is None + assert sysd_nocopy.find_state('a') is None + + # if signal names are provided, they should override those of sysc + sysd_newnames = sysc.sample(0.1, inputs='v', outputs='x', states='b') + assert sysd_newnames.find_input('v') == 0 + assert sysd_newnames.find_input('u') is None + assert sysd_newnames.find_output('x') == 0 + assert sysd_newnames.find_output('y') is None + assert sysd_newnames.find_state('b') == 0 + assert sysd_newnames.find_state('a') is None + # test just one name + sysd_newnames = sysc.sample(0.1, inputs='v') + assert sysd_newnames.find_input('v') == 0 + assert sysd_newnames.find_input('u') is None + assert sysd_newnames.find_output('y') == 0 + assert sysd_newnames.find_output('x') is None + class TestRss: """These are tests for the proper functionality of statesp.rss.""" @@ -1164,3 +1198,5 @@ def test_params_warning(): with pytest.warns(UserWarning, match="params keyword ignored"): sys.output(0, [0], [0], {'k': 5}) + + diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py index 894da5594..e4a2b3ec0 100644 --- a/control/tests/xferfcn_test.py +++ b/control/tests/xferfcn_test.py @@ -986,6 +986,37 @@ def test_repr(self, Hargs, ref): np.testing.assert_array_almost_equal(H.num[p][m], H2.num[p][m]) np.testing.assert_array_almost_equal(H.den[p][m], H2.den[p][m]) assert H.dt == H2.dt + + def test_sample_named_signals(self): + sysc = ct.TransferFunction(1.1, (1, 2), inputs='u', outputs='y') + + # Full form of the call + sysd = sysc.sample(0.1, name='sampled') + assert sysd.name == 'sampled' + assert sysd.find_input('u') == 0 + assert sysd.find_output('y') == 0 + + # If we copy signal names w/out a system name, append '$sampled' + sysd = sysc.sample(0.1) + assert sysd.name == sysc.name + '$sampled' + + # If copy is False, signal names should not be copied + sysd_nocopy = sysc.sample(0.1, copy_names=False) + assert sysd_nocopy.find_input('u') is None + assert sysd_nocopy.find_output('y') is None + + # if signal names are provided, they should override those of sysc + sysd_newnames = sysc.sample(0.1, inputs='v', outputs='x') + assert sysd_newnames.find_input('v') == 0 + assert sysd_newnames.find_input('u') is None + assert sysd_newnames.find_output('x') == 0 + assert sysd_newnames.find_output('y') is None + # test just one name + sysd_newnames = sysc.sample(0.1, inputs='v') + assert sysd_newnames.find_input('v') == 0 + assert sysd_newnames.find_input('u') is None + assert sysd_newnames.find_output('y') == 0 + assert sysd_newnames.find_output('x') is None class TestLTIConverter: From c34526108c58ba681a0da3f1d8fcfa6a09e448c7 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Wed, 23 Nov 2022 12:35:53 -0800 Subject: [PATCH 090/157] a few more unit tests and improvements to system name handling: --- control/iosys.py | 4 ++-- control/statesp.py | 2 +- control/tests/iosys_test.py | 14 ++++++++++++++ control/xferfcn.py | 4 ++-- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index 40eaba9f5..5b941e964 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -565,8 +565,7 @@ def linearize(self, x0, u0, t=0, params=None, eps=1e-6, # Create the state space system linsys = LinearIOSystem( - StateSpace(A, B, C, D, self.dt, remove_useless_states=False), - name=name, **kwargs) + StateSpace(A, B, C, D, self.dt, remove_useless_states=False)) # Set the system name, inputs, outputs, and states if copy_names: @@ -576,6 +575,7 @@ def linearize(self, x0, u0, t=0, params=None, eps=1e-6, self.name + \ config.defaults['namedio.linearized_system_name_suffix'] linsys._copy_names(self, name=name) + linsys = LinearIOSystem(linsys, name=name, **kwargs) return linsys diff --git a/control/statesp.py b/control/statesp.py index b5a5fe26f..561b2d343 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -1387,7 +1387,7 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None, config.defaults['namedio.sampled_system_name_suffix'] sysd._copy_names(self, name=name) # pass desired signal names if names were provided - sysd = StateSpace(sysd, **kwargs) + sysd = StateSpace(sysd, name=name, **kwargs) return sysd def dcgain(self, warn_infinite=False): diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index 09542bcaa..6bed7cd16 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -243,6 +243,20 @@ def test_linearize_named_signals(self, kincar): assert lin_nocopy.find_output('x') is None assert lin_nocopy.find_state('x') is None + # if signal names are provided, they should override those of kincar + linearized_newnames = kincar.linearize([0, 0, 0], [0, 0], + name='linearized', + copy_names=True, inputs=['v2', 'phi2'], outputs=['x2','y2']) + assert linearized_newnames.name == 'linearized' + assert linearized_newnames.find_input('v2') == 0 + assert linearized_newnames.find_input('phi2') == 1 + assert linearized_newnames.find_input('v') is None + assert linearized_newnames.find_input('phi') is None + assert linearized_newnames.find_output('x2') == 0 + assert linearized_newnames.find_output('y2') == 1 + assert linearized_newnames.find_output('x') is None + assert linearized_newnames.find_output('y') is None + def test_connect(self, tsys): # Define a couple of (linear) systems to interconnection linsys1 = tsys.siso_linsys diff --git a/control/xferfcn.py b/control/xferfcn.py index a5d52967a..79e46de7e 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -1185,9 +1185,9 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None, config.defaults['namedio.sampled_system_name_suffix'] sysd._copy_names(self, name=name) # pass desired signal names if names were provided - sysd = TransferFunction(sysd, **kwargs) + sysd = TransferFunction(sysd, name=name, **kwargs) return sysd - + def dcgain(self, warn_infinite=False): """Return the zero-frequency (or DC) gain From dbf64f2e57bc457a63384424abd32defae2bbd1a Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Wed, 23 Nov 2022 20:20:43 -0800 Subject: [PATCH 091/157] simplify _copy_names, fix docstring errors, add names to sample_system and unit tests, namedio.copy is now deepcopy --- control/dtime.py | 29 ++++++++++++++++++++-- control/iosys.py | 31 +++++++++++++---------- control/namedio.py | 11 +++------ control/statesp.py | 29 +++++++++++----------- control/tests/discrete_test.py | 45 +++++++++++++++++++++++++++++++++- control/tests/iosys_test.py | 12 ++++----- control/tests/kwargs_test.py | 1 + control/xferfcn.py | 29 ++++++++++------------ 8 files changed, 127 insertions(+), 60 deletions(-) diff --git a/control/dtime.py b/control/dtime.py index b05d22b96..9dddd86a3 100644 --- a/control/dtime.py +++ b/control/dtime.py @@ -53,7 +53,8 @@ __all__ = ['sample_system', 'c2d'] # Sample a continuous time system -def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None): +def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None, + name=None, copy_names=True, **kwargs): """ Convert a continuous time system to discrete time by sampling @@ -72,12 +73,35 @@ def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None): prewarp_frequency : float within [0, infinity) The frequency [rad/s] at which to match with the input continuous- time system's magnitude and phase (only valid for method='bilinear') + name : string, optional + Set the name of the sampled system. If not specified and + if `copy_names` is `False`, a generic name is generated + with a unique integer id. If `copy_names` is `True`, the new system + name is determined by adding the prefix and suffix strings in + config.defaults['namedio.sampled_system_name_prefix'] and + config.defaults['namedio.sampled_system_name_suffix'], with the + default being to add the suffix '$sampled'. + copy_names : bool, Optional + If True, copy the names of the input signals, output + signals, and states to the sampled system. Returns ------- sysd : linsys Discrete time system, with sampling rate Ts + Additional Parameters + --------------------- + inputs : int, list of str or None, optional + Description of the system inputs. If not specified, the origional + system inputs are used. See :class:`NamedIOSystem` for more + information. + outputs : int, list of str or None, optional + Description of the system outputs. Same format as `inputs`. + states : int, list of str, or None, optional + Description of the system states. Same format as `inputs`. Only + available if the system is :class:`StateSpace`. + Notes ----- See :meth:`StateSpace.sample` or :meth:`TransferFunction.sample` for @@ -94,7 +118,8 @@ def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None): raise ValueError("First argument must be continuous time system") return sysc.sample(Ts, - method=method, alpha=alpha, prewarp_frequency=prewarp_frequency) + method=method, alpha=alpha, prewarp_frequency=prewarp_frequency, + name=name, copy_names=copy_names, **kwargs) def c2d(sysc, Ts, method='zoh', prewarp_frequency=None): diff --git a/control/iosys.py b/control/iosys.py index 5b941e964..b90d35ee3 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -568,16 +568,23 @@ def linearize(self, x0, u0, t=0, params=None, eps=1e-6, StateSpace(A, B, C, D, self.dt, remove_useless_states=False)) # Set the system name, inputs, outputs, and states + if copy in kwargs: + copy_names = kwargs.pop('copy') + warn("keyword 'copy' is deprecated. please use 'copy_names'", + DeprecationWarning) + if copy_names: + linsys._copy_names(self) if name is None: - name = \ - config.defaults['namedio.linearized_system_name_prefix'] +\ - self.name + \ + linsys.name = \ + config.defaults['namedio.linearized_system_name_prefix']+\ + linsys.name+\ config.defaults['namedio.linearized_system_name_suffix'] - linsys._copy_names(self, name=name) - linsys = LinearIOSystem(linsys, name=name, **kwargs) - return linsys + else: + linsys.name = name + # re-init to include desired signal names if names were provided + return LinearIOSystem(linsys, **kwargs) class LinearIOSystem(InputOutputSystem, StateSpace): """Input/output representation of a linear (state space) system. @@ -2180,19 +2187,17 @@ def linearize(sys, xeq, ueq=None, t=0, params=None, **kw): params : dict, optional Parameter values for the systems. Passed to the evaluation functions for the system as default values, overriding internal defaults. - copy_names : bool, Optional - If `copy_names` is True, copy the names of the input signals, output - signals, and states to the linearized system. If `name` is not - specified, the system name is set to the input system name with the - string '_linearized' appended. name : string, optional Set the name of the linearized system. If not specified and - if `copy` is `False`, a generic name is generated - with a unique integer id. If `copy` is `True`, the new system + if `copy_names` is `False`, a generic name is generated + with a unique integer id. If `copy_names` is `True`, the new system name is determined by adding the prefix and suffix strings in config.defaults['namedio.linearized_system_name_prefix'] and config.defaults['namedio.linearized_system_name_suffix'], with the default being to add the suffix '$linearized'. + copy_names : bool, Optional + If True, Copy the names of the input signals, output signals, and + states to the linearized system. Returns ------- diff --git a/control/namedio.py b/control/namedio.py index 9f82e5929..a94d1a9f5 100644 --- a/control/namedio.py +++ b/control/namedio.py @@ -6,7 +6,7 @@ # and other similar classes to allow naming of signals. import numpy as np -from copy import copy +from copy import deepcopy from warnings import warn from . import config @@ -99,13 +99,10 @@ def __str__(self): def _find_signal(self, name, sigdict): return sigdict.get(name, None) - def _copy_names(self, sys, name=None): + def _copy_names(self, sys): """copy the signal and system name of sys. Name is given as a keyword in case a specific name (e.g. append 'linearized') is desired. """ - if name is None: - self.name = sys.name - else: - self.name = name + self.name = sys.name self.ninputs, self.input_index = \ sys.ninputs, sys.input_index.copy() self.noutputs, self.output_index = \ @@ -126,7 +123,7 @@ def copy(self, name=None, use_prefix_suffix=True): """ # Create a copy of the system - newsys = copy(self) + newsys = deepcopy(self) # Update the system name if name is None and use_prefix_suffix: diff --git a/control/statesp.py b/control/statesp.py index 561b2d343..7843cb33f 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -357,7 +357,7 @@ def __init__(self, *args, init_namedio=True, **kwargs): states=states, dt=dt) elif kwargs: raise TypeError("unrecognized keyword(s): ", str(kwargs)) - + # Reset shapes (may not be needed once np.matrix support is removed) if self._isstatic(): # static gain @@ -1298,7 +1298,7 @@ def __getitem__(self, indices): return StateSpace(self.A, self.B[:, j], self.C[i, :], self.D[i, j], self.dt) - def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None, + def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None, name=None, copy_names=True, **kwargs): """Convert a continuous time system to discrete time @@ -1327,19 +1327,17 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None, time system's magnitude and phase (the gain=1 crossover frequency, for example). Should only be specified with method='bilinear' or 'gbt' with alpha=0.5 and ignored otherwise. - copy_names : bool, Optional - If `copy_names` is True, copy the names of the input signals, output - signals, and states to the sampled system. If `name` is not - specified, the system name is set to the input system name with the - string '_sampled' appended. name : string, optional Set the name of the sampled system. If not specified and - if `copy` is `False`, a generic name is generated - with a unique integer id. If `copy` is `True`, the new system + if `copy_names` is `False`, a generic name is generated + with a unique integer id. If `copy_names` is `True`, the new system name is determined by adding the prefix and suffix strings in config.defaults['namedio.sampled_system_name_prefix'] and config.defaults['namedio.sampled_system_name_suffix'], with the default being to add the suffix '$sampled'. + copy_names : bool, Optional + If True, copy the names of the input signals, output + signals, and states to the sampled system. Returns ------- @@ -1380,15 +1378,16 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None, sysd = StateSpace(Ad, Bd, C, D, Ts) # copy over the system name, inputs, outputs, and states if copy_names: + sysd._copy_names(self) if name is None: - name = \ + sysd.name = \ config.defaults['namedio.sampled_system_name_prefix'] +\ - self.name + \ + sysd.name + \ config.defaults['namedio.sampled_system_name_suffix'] - sysd._copy_names(self, name=name) - # pass desired signal names if names were provided - sysd = StateSpace(sysd, name=name, **kwargs) - return sysd + else: + sysd.name = name + # pass desired signal names if names were provided + return StateSpace(sysd, **kwargs) def dcgain(self, warn_infinite=False): """Return the zero-frequency gain diff --git a/control/tests/discrete_test.py b/control/tests/discrete_test.py index 0842cbf59..4cf28a21b 100644 --- a/control/tests/discrete_test.py +++ b/control/tests/discrete_test.py @@ -458,4 +458,47 @@ def test_signal_names(self, tsys): assert ssd.state_labels == ['a', 'b', 'c'] assert ssd.output_labels == ['y'] assert tfd.input_labels == ['u'] - assert tfd.output_labels == ['y'] \ No newline at end of file + assert tfd.output_labels == ['y'] + + ssd = sample_system(ssc, 0.1) + tfd = sample_system(tfc, 0.1) + assert ssd.input_labels == ['u'] + assert ssd.state_labels == ['a', 'b', 'c'] + assert ssd.output_labels == ['y'] + assert tfd.input_labels == ['u'] + assert tfd.output_labels == ['y'] + + # system names and signal name override + sysc = StateSpace(1.1, 1, 1, 1, inputs='u', outputs='y', states='a') + + sysd = sample_system(sysc, 0.1, name='sampled') + assert sysd.name == 'sampled' + assert sysd.find_input('u') == 0 + assert sysd.find_output('y') == 0 + assert sysd.find_state('a') == 0 + + # If we copy signal names w/out a system name, append '$sampled' + sysd = sample_system(sysc, 0.1) + assert sysd.name == sysc.name + '$sampled' + + # If copy is False, signal names should not be copied + sysd_nocopy = sample_system(sysc, 0.1, copy_names=False) + assert sysd_nocopy.find_input('u') is None + assert sysd_nocopy.find_output('y') is None + assert sysd_nocopy.find_state('a') is None + + # if signal names are provided, they should override those of sysc + sysd_newnames = sample_system(sysc, 0.1, + inputs='v', outputs='x', states='b') + assert sysd_newnames.find_input('v') == 0 + assert sysd_newnames.find_input('u') is None + assert sysd_newnames.find_output('x') == 0 + assert sysd_newnames.find_output('y') is None + assert sysd_newnames.find_state('b') == 0 + assert sysd_newnames.find_state('a') is None + # test just one name + sysd_newnames = sample_system(sysc, 0.1, inputs='v') + assert sysd_newnames.find_input('v') == 0 + assert sysd_newnames.find_input('u') is None + assert sysd_newnames.find_output('y') == 0 + assert sysd_newnames.find_output('x') is None diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index 6bed7cd16..cc9c3e721 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -231,12 +231,6 @@ def test_linearize_named_signals(self, kincar): linearized = kincar.linearize([0, 0, 0], [0, 0], copy_names=True) assert linearized.name == kincar.name + '$linearized' - # Test legacy version as well - ct.use_legacy_defaults('0.8.4') - ct.config.use_numpy_matrix(False) # np.matrix deprecated - linearized = kincar.linearize([0, 0, 0], [0, 0], copy_names=True) - assert linearized.name == kincar.name + '_linearized' - # If copy is False, signal names should not be copied lin_nocopy = kincar.linearize(0, 0, copy_names=False) assert lin_nocopy.find_input('v') is None @@ -257,6 +251,12 @@ def test_linearize_named_signals(self, kincar): assert linearized_newnames.find_output('x') is None assert linearized_newnames.find_output('y') is None + # Test legacy version as well + ct.use_legacy_defaults('0.8.4') + ct.config.use_numpy_matrix(False) # np.matrix deprecated + linearized = kincar.linearize([0, 0, 0], [0, 0], copy_names=True) + assert linearized.name == kincar.name + '_linearized' + def test_connect(self, tsys): # Define a couple of (linear) systems to interconnection linsys1 = tsys.siso_linsys diff --git a/control/tests/kwargs_test.py b/control/tests/kwargs_test.py index 065c4673f..20a1c8e9c 100644 --- a/control/tests/kwargs_test.py +++ b/control/tests/kwargs_test.py @@ -183,6 +183,7 @@ def test_matplotlib_kwargs(function, nsysargs, moreargs, kwargs, mplcleanup): 'tf': test_unrecognized_kwargs, 'tf2io' : test_unrecognized_kwargs, 'tf2ss' : test_unrecognized_kwargs, + 'sample_system' : test_unrecognized_kwargs, 'flatsys.point_to_point': flatsys_test.TestFlatSys.test_point_to_point_errors, 'flatsys.solve_flat_ocp': diff --git a/control/xferfcn.py b/control/xferfcn.py index 79e46de7e..84188a63f 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -1090,7 +1090,7 @@ def _common_den(self, imag_tol=None, allow_nonproper=False): return num, den, denorder - def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None, + def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None, name=None, copy_names=True, **kwargs): """Convert a continuous-time system to discrete time @@ -1119,19 +1119,17 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None, time system's magnitude and phase (the gain=1 crossover frequency, for example). Should only be specified with method='bilinear' or 'gbt' with alpha=0.5 and ignored otherwise. - copy_names : bool, Optional - If `copy_names` is True, copy the names of the input signals, output - signals, and states to the sampled system. If `name` is not - specified, the system name is set to the input system name with the - string '_sampled' appended. name : string, optional Set the name of the sampled system. If not specified and - if `copy` is `False`, a generic name is generated - with a unique integer id. If `copy` is `True`, the new system + if `copy_names` is `False`, a generic name is generated + with a unique integer id. If `copy_names` is `True`, the new system name is determined by adding the prefix and suffix strings in config.defaults['namedio.sampled_system_name_prefix'] and config.defaults['namedio.sampled_system_name_suffix'], with the default being to add the suffix '$sampled'. + copy_names : bool, Optional + If True, copy the names of the input signals, output + signals, and states to the sampled system. Returns ------- @@ -1146,8 +1144,6 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None, information. outputs : int, list of str or None, optional Description of the system outputs. Same format as `inputs`. - states : int, list of str, or None, optional - Description of the system states. Same format as `inputs`. Notes ----- @@ -1178,15 +1174,16 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None, sysd = TransferFunction(numd[0, :], dend, Ts) # copy over the system name, inputs, outputs, and states if copy_names: + sysd._copy_names(self) if name is None: - name = \ + sysd.name = \ config.defaults['namedio.sampled_system_name_prefix'] +\ - self.name + \ + sysd.name + \ config.defaults['namedio.sampled_system_name_suffix'] - sysd._copy_names(self, name=name) - # pass desired signal names if names were provided - sysd = TransferFunction(sysd, name=name, **kwargs) - return sysd + else: + sysd.name = name + # pass desired signal names if names were provided + return TransferFunction(sysd, name=name, **kwargs) def dcgain(self, warn_infinite=False): """Return the zero-frequency (or DC) gain From 16d9e6af15ba245dc9964aa062e704bf833909ee Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Wed, 23 Nov 2022 20:39:35 -0800 Subject: [PATCH 092/157] fix deprecated functionality of copy keyword in iosys.linearize --- control/iosys.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control/iosys.py b/control/iosys.py index b90d35ee3..205bfe1f5 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -568,7 +568,7 @@ def linearize(self, x0, u0, t=0, params=None, eps=1e-6, StateSpace(A, B, C, D, self.dt, remove_useless_states=False)) # Set the system name, inputs, outputs, and states - if copy in kwargs: + if 'copy' in kwargs: copy_names = kwargs.pop('copy') warn("keyword 'copy' is deprecated. please use 'copy_names'", DeprecationWarning) From 57ad2f102ae1473851ade16b0415bd329e587f1a Mon Sep 17 00:00:00 2001 From: adswid <37661704+adswid@users.noreply.github.com> Date: Thu, 24 Nov 2022 13:52:00 +0100 Subject: [PATCH 093/157] Fix find_eqpt when y0 is None --- control/iosys.py | 11 ++++++++--- control/tests/iosys_test.py | 11 +++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index 9b33d2161..a32978b58 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -2138,10 +2138,15 @@ def rootfun(z): dx = sys._rhs(t, x, u) - dx0 if dtime: dx -= x # TODO: check - dy = sys._out(t, x, u) - y0 - # Map the results into the constrained variables - return np.concatenate((dx[deriv_vars], dy[output_vars]), axis=0) + # If no y0 is given, don't evaluate the output function + if y0 is None: + return dx[deriv_vars] + else: + dy = sys._out(t, x, u) - y0 + + # Map the results into the constrained variables + return np.concatenate((dx[deriv_vars], dy[output_vars]), axis=0) # Set the initial condition for the root finding algorithm z0 = np.concatenate((x[state_vars], u[input_vars]), axis=0) diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index 7d3b9fee9..003c43d03 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -822,6 +822,17 @@ def test_find_eqpts(self, tsys): np.testing.assert_array_almost_equal( nlsys_full._rhs(0, xeq, ueq)[-4:], np.zeros((4,)), decimal=5) + # The same test as previous, but now all constraints are in the state vector + nlsys_full = ios.NonlinearIOSystem(pvtol_full, None) + xeq, ueq, result = ios.find_eqpt( + nlsys_full, [0, 0, 0.1, 0.1, 0, 0], [0.01, 4*9.8], + idx=[2, 3, 4, 5], ix=[0, 1, 2, 3], return_result=True) + assert result.success + np.testing.assert_array_almost_equal( + nlsys_full._out(0, xeq, ueq)[[2, 3]], [0.1, 0.1], decimal=5) + np.testing.assert_array_almost_equal( + nlsys_full._rhs(0, xeq, ueq)[-4:], np.zeros((4,)), decimal=5) + # Fix one input and vary the other nlsys_full = ios.NonlinearIOSystem(pvtol_full, None) xeq, ueq, result = ios.find_eqpt( From d9ec3ec5bb4d3963bd8b6c502ce36baed7cd1760 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Thu, 24 Nov 2022 11:37:03 -0800 Subject: [PATCH 094/157] add trajectory_method='shooting' --- control/optimal.py | 221 ++++++++++++++++++++++++--------------------- 1 file changed, 116 insertions(+), 105 deletions(-) diff --git a/control/optimal.py b/control/optimal.py index 2842aa0ad..c63f1523e 100644 --- a/control/optimal.py +++ b/control/optimal.py @@ -65,6 +65,9 @@ class OptimalControlProblem(): inputs should either be a 2D vector of shape (ninputs, horizon) or a 1D input of shape (ninputs,) that will be broadcast by extension of the time axis. + method : string, optional + Method to use for carrying out the optimization. Currently only + 'shooting' is supported. log : bool, optional If `True`, turn on logging messages (using Python logging module). Use ``logging.basicConfig`` to enable logging output (e.g., to a file). @@ -79,6 +82,9 @@ class OptimalControlProblem(): Additional parameters --------------------- + basis : BasisFamily, optional + Use the given set of basis functions for the inputs instead of + setting the value of the input at each point in the timepts vector. solve_ivp_method : str, optional Set the method used by :func:`scipy.integrate.solve_ivp`. solve_ivp_kwargs : str, optional @@ -127,7 +133,7 @@ class OptimalControlProblem(): def __init__( self, sys, timepts, integral_cost, trajectory_constraints=[], terminal_cost=None, terminal_constraints=[], initial_guess=None, - basis=None, log=False, **kwargs): + method='shooting', basis=None, log=False, **kwargs): """Set up an optimal control problem.""" # Save the basic information for use later self.system = sys @@ -137,6 +143,13 @@ def __init__( self.terminal_constraints = terminal_constraints self.basis = basis + # Keep track of what type of method we are using + if method not in {'shooting', 'collocation'}: + raise NotImplementedError(f"Unkown method {method}") + else: + self.shooting = method in {'shooting'} + self.collocation = method in {'collocation'} + # Process keyword arguments self.solve_ivp_kwargs = {} self.solve_ivp_kwargs['method'] = kwargs.pop( @@ -206,6 +219,7 @@ def __init__( constraint_lb, constraint_ub, eqconst_value = [], [], [] # Go through each time point and stack the bounds + # TODO: for collocation method, keep track of linear vs nonlinear for t in self.timepts: for type, fun, lb, ub in self.trajectory_constraints: if np.all(lb == ub): @@ -241,6 +255,11 @@ def __init__( self.constraints.append(sp.optimize.NonlinearConstraint( self._eqconst_function, self.eqconst_value, self.eqconst_value)) + if self.collocation: + # Add the collocation constraints + colloc_zeros = np.zeros((self.timepts.size - 1) * sys.nstates) + self.constraints.append(sp.optimize.NonlinearConstraint( + self._collocation_constraint, colloc_zeros, colloc_zeros)) # Process the initial guess self.initial_guess = self._process_initial_guess(initial_guess) @@ -274,40 +293,8 @@ def _cost_function(self, coeffs): start_time = time.process_time() logging.info("_cost_function called at: %g", start_time) - # Retrieve the saved initial state - x = self.x - - # Compute inputs - if self.basis: - if self.log: - logging.debug("coefficients = " + str(coeffs)) - inputs = self._coeffs_to_inputs(coeffs) - else: - inputs = coeffs.reshape((self.system.ninputs, -1)) - - # See if we already have a simulation for this condition - if np.array_equal(coeffs, self.last_coeffs) and \ - np.array_equal(x, self.last_x): - states = self.last_states - else: - if self.log: - logging.debug("calling input_output_response from state\n" - + str(x)) - logging.debug("initial input[0:3] =\n" + str(inputs[:, 0:3])) - - # Simulate the system to get the state - # TODO: try calling solve_ivp directly for better speed? - _, _, states = ct.input_output_response( - self.system, self.timepts, inputs, x, return_x=True, - solve_ivp_kwargs=self.solve_ivp_kwargs, t_eval=self.timepts) - self.system_simulations += 1 - self.last_x = x - self.last_coeffs = coeffs - self.last_states = states - - if self.log: - logging.debug("input_output_response returned states\n" - + str(states)) + # Compute the states and inputs + states, inputs = self._compute_states_inputs(coeffs) # Trajectory cost if ct.isctime(self.system): @@ -380,12 +367,15 @@ def _cost_function(self, coeffs): # holds at a specific point in time, and implements the original # constraint. # - # To do this, we basically create a function that simulates the system - # dynamics and returns a vector of values corresponding to the value of - # the function at each time. The class initialization methods takes - # care of replicating the upper and lower bounds for each point in time - # so that the SciPy optimization algorithm can do the proper - # evaluation. + # For collocation methods, we can directly evaluate the constraints at + # the collocation points. + # + # For shooting methods, we do this by creating a function that + # simulates the system dynamics and returns a vector of values + # corresponding to the value of the function at each time. The + # class initialization methods takes care of replicating the upper + # and lower bounds for each point in time so that the SciPy + # optimization algorithm can do the proper evaluation. # # In addition, since SciPy's optimization function does not allow us to # pass arguments to the constraint function, we have to store the initial @@ -396,35 +386,12 @@ def _constraint_function(self, coeffs): start_time = time.process_time() logging.info("_constraint_function called at: %g", start_time) - # Retrieve the initial state - x = self.x - - # Compute input at time points - if self.basis: - inputs = self._coeffs_to_inputs(coeffs) - else: - inputs = coeffs.reshape((self.system.ninputs, -1)) - - # See if we already have a simulation for this condition - if np.array_equal(coeffs, self.last_coeffs) \ - and np.array_equal(x, self.last_x): - states = self.last_states - else: - if self.log: - logging.debug("calling input_output_response from state\n" - + str(x)) - logging.debug("initial input[0:3] =\n" + str(inputs[:, 0:3])) - - # Simulate the system to get the state - _, _, states = ct.input_output_response( - self.system, self.timepts, inputs, x, return_x=True, - solve_ivp_kwargs=self.solve_ivp_kwargs, t_eval=self.timepts) - self.system_simulations += 1 - self.last_x = x - self.last_coeffs = coeffs - self.last_states = states + # Compute the states and inputs + states, inputs = self._compute_states_inputs(coeffs) + # # Evaluate the constraint function along the trajectory + # value = [] for i, t in enumerate(self.timepts): for ctype, fun, lb, ub in self.trajectory_constraints: @@ -477,37 +444,8 @@ def _eqconst_function(self, coeffs): start_time = time.process_time() logging.info("_eqconst_function called at: %g", start_time) - # Retrieve the initial state - x = self.x - - # Compute input at time points - if self.basis: - inputs = self._coeffs_to_inputs(coeffs) - else: - inputs = coeffs.reshape((self.system.ninputs, -1)) - - # See if we already have a simulation for this condition - if np.array_equal(coeffs, self.last_coeffs) and \ - np.array_equal(x, self.last_x): - states = self.last_states - else: - if self.log: - logging.debug("calling input_output_response from state\n" - + str(x)) - logging.debug("initial input[0:3] =\n" + str(inputs[:, 0:3])) - - # Simulate the system to get the state - _, _, states = ct.input_output_response( - self.system, self.timepts, inputs, x, return_x=True, - solve_ivp_kwargs=self.solve_ivp_kwargs, t_eval=self.timepts) - self.system_simulations += 1 - self.last_x = x - self.last_coeffs = coeffs - self.last_states = states - - if self.log: - logging.debug("input_output_response returned states\n" - + str(states)) + # Compute the states and inputs + states, inputs = self._compute_states_inputs(coeffs) # Evaluate the constraint function along the trajectory value = [] @@ -538,6 +476,10 @@ def _eqconst_function(self, coeffs): # Checked above => we should never get here raise TypeError("unknown constraint type {ctype}") + # Add the collocation constraints + if self.collocation: + raise NotImplementedError("collocation not yet implemented") + # Update statistics self.eqconst_evaluations += 1 if self.log: @@ -555,6 +497,12 @@ def _eqconst_function(self, coeffs): # Return the value of the constraint function return np.hstack(value) + def _collocation_constraints(self, coeffs): + # Compute the states and inputs + states, inputs = self._compute_states_inputs(coeffs) + + raise NotImplementedError("collocation not yet implemented") + # # Initial guess # @@ -566,8 +514,10 @@ def _eqconst_function(self, coeffs): # vector. If a basis is specified, this is converted to coefficient # values (which are generally of smaller dimension). # + # TODO: figure out how to modify this for collocation + # def _process_initial_guess(self, initial_guess): - if initial_guess is not None: + if self.shooting and initial_guess is not None: # Convert to a 1D array (or higher) initial_guess = np.atleast_1d(initial_guess) @@ -592,10 +542,15 @@ def _process_initial_guess(self, initial_guess): # Reshape for use by scipy.optimize.minimize() return initial_guess.reshape(-1) - # Default is zero - return np.zeros( - self.system.ninputs * - (self.timepts.size if self.basis is None else self.basis.N)) + elif self.collocation and initial_guess is not None: + raise NotImplementedError("collocation not yet implemented") + + # Default is no initial guess + input_size = self.system.ninputs * \ + (self.timepts.size if self.basis is None else self.basis.N) + state_size = 0 if not self.collocation else \ + (self.timepts.size - 1) * self.sys.nstates + return np.zeros(input_size + state_size) # # Utility function to convert input vector to coefficient vector @@ -679,6 +634,62 @@ def _print_statistics(self, reset=True): if reset: self._reset_statistics(self.log) + # + # Compute the states and inputs from the coefficient vector + # + # These internal functions return the states and inputs at the + # collocation points given the ceofficient (optimizer state) vector. + # They keep track of whether a shooting method is being used or not and + # simulate the dynamis of needed. + # + + # Compute the states and inputs from the coefficients + def _compute_states_inputs(self, coeffs): + # + # Compute out the states and inputs + # + if self.collocation: + # States are appended to end of (input) coefficients + states = coeffs[-self.sys.nstates * self.timepts.size:0] + coeffs = coeffs[0:-self.sys.nstates * self.timepts.size] + + # Compute input at time points + if self.basis: + inputs = self._coeffs_to_inputs(coeffs) + else: + inputs = coeffs.reshape((self.system.ninputs, -1)) + + if self.shooting: + # See if we already have a simulation for this condition + if np.array_equal(coeffs, self.last_coeffs) \ + and np.array_equal(self.x, self.last_x): + states = self.last_states + else: + states = self._simulate_states(self.x, inputs) + self.last_x = self.x + self.last_states = states + self.last_coeffs = coeffs + + return states, inputs + + # Simulate the system dynamis to retrieve the state + def _simulate_states(self, x0, inputs): + if self.log: + logging.debug( + "calling input_output_response from state\n" + str(x0)) + logging.debug("input =\n" + str(inputs)) + + # Simulate the system to get the state + _, _, states = ct.input_output_response( + self.system, self.timepts, inputs, x0, return_x=True, + solve_ivp_kwargs=self.solve_ivp_kwargs, t_eval=self.timepts) + self.system_simulations += 1 + + if self.log: + logging.debug( + "input_output_response returned states\n" + str(states)) + return states + # # Optimal control computations # @@ -992,7 +1003,7 @@ def solve_ocp( Notes ----- Additional keyword parameters can be used to fine tune the behavior of - the underlying optimization and integrations functions. See + the underlying optimization and integration functions. See :func:`OptimalControlProblem` for more information. """ From ce5e67ac10c9363fb8674258dddc2c9120f17fed Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Thu, 24 Nov 2022 15:06:58 -0800 Subject: [PATCH 095/157] add trajectory_method='collocation' --- control/optimal.py | 154 ++++++++++++++++++++++------------ control/tests/optimal_test.py | 115 +++++++++++++++++++++---- 2 files changed, 200 insertions(+), 69 deletions(-) diff --git a/control/optimal.py b/control/optimal.py index c63f1523e..a7354a100 100644 --- a/control/optimal.py +++ b/control/optimal.py @@ -21,7 +21,9 @@ from .timeresp import TimeResponseData # Define module default parameter values +_optimal_trajectory_methods = {'shooting', 'collocation'} _optimal_defaults = { + 'optimal.trajectory_method': 'shooting', 'optimal.minimize_method': None, 'optimal.minimize_options': {}, 'optimal.minimize_kwargs': {}, @@ -65,9 +67,11 @@ class OptimalControlProblem(): inputs should either be a 2D vector of shape (ninputs, horizon) or a 1D input of shape (ninputs,) that will be broadcast by extension of the time axis. - method : string, optional - Method to use for carrying out the optimization. Currently only - 'shooting' is supported. + trajectory_method : string, optional + Method to use for carrying out the optimization. Currently supported + methods are 'shooting' and 'collocation' (continuous time only). The + default value is 'shooting' for discrete time systems and + 'collocation' for continuous time systems log : bool, optional If `True`, turn on logging messages (using Python logging module). Use ``logging.basicConfig`` to enable logging output (e.g., to a file). @@ -133,7 +137,7 @@ class OptimalControlProblem(): def __init__( self, sys, timepts, integral_cost, trajectory_constraints=[], terminal_cost=None, terminal_constraints=[], initial_guess=None, - method='shooting', basis=None, log=False, **kwargs): + trajectory_method=None, basis=None, log=False, **kwargs): """Set up an optimal control problem.""" # Save the basic information for use later self.system = sys @@ -144,11 +148,15 @@ def __init__( self.basis = basis # Keep track of what type of method we are using - if method not in {'shooting', 'collocation'}: + if trajectory_method is None: + # TODO: change default + # trajectory_method = 'collocation' if sys.isctime() else 'shooting' + trajectory_method = 'shooting' if sys.isctime() else 'shooting' + elif trajectory_method not in _optimal_trajectory_methods: raise NotImplementedError(f"Unkown method {method}") - else: - self.shooting = method in {'shooting'} - self.collocation = method in {'collocation'} + + self.shooting = trajectory_method in {'shooting'} + self.collocation = trajectory_method in {'collocation'} # Process keyword arguments self.solve_ivp_kwargs = {} @@ -257,10 +265,14 @@ def __init__( self.eqconst_value)) if self.collocation: # Add the collocation constraints - colloc_zeros = np.zeros((self.timepts.size - 1) * sys.nstates) + colloc_zeros = np.zeros(sys.nstates * self.timepts.size) + self.colloc_vals = np.zeros((sys.nstates, self.timepts.size)) self.constraints.append(sp.optimize.NonlinearConstraint( self._collocation_constraint, colloc_zeros, colloc_zeros)) + # Initialize run-time statistics + self._reset_statistics(log) + # Process the initial guess self.initial_guess = self._process_initial_guess(initial_guess) @@ -268,9 +280,6 @@ def __init__( self.last_x = np.full(self.system.nstates, np.nan) self.last_coeffs = np.full(self.initial_guess.shape, np.nan) - # Reset run-time statistics - self._reset_statistics(log) - # Log information if log: logging.info("New optimal control problem initailized") @@ -476,10 +485,6 @@ def _eqconst_function(self, coeffs): # Checked above => we should never get here raise TypeError("unknown constraint type {ctype}") - # Add the collocation constraints - if self.collocation: - raise NotImplementedError("collocation not yet implemented") - # Update statistics self.eqconst_evaluations += 1 if self.log: @@ -497,11 +502,32 @@ def _eqconst_function(self, coeffs): # Return the value of the constraint function return np.hstack(value) - def _collocation_constraints(self, coeffs): + def _collocation_constraint(self, coeffs): # Compute the states and inputs states, inputs = self._compute_states_inputs(coeffs) - raise NotImplementedError("collocation not yet implemented") + if self.system.isctime(): + # Compute the collocation constraints + for i, t in enumerate(self.timepts): + if i == 0: + # Initial condition constraint (self.x = initial point) + self.colloc_vals[:, 0] = states[:, 0] - self.x + fk = self.system._rhs( + self.timepts[0], states[:, 0], inputs[:, 0]) + continue + + # M. Kelly, SIAM Review (2017), equation (3.2), i = k+1 + # x[k+1] - x[k] = 0.5 hk (f(x[k+1], u[k+1] + f(x[k], u[k])) + fkp1 = self.system._rhs(t, states[:, i], inputs[:, i]) + self.colloc_vals[:, i] = states[:, i] - states[:, i-1] - \ + 0.5 * (self.timepts[i] - self.timepts[i-1]) * (fkp1 + fk) + fk = fkp1 + else: + raise NotImplementedError( + "collocation not yet implemented for discrete time systems") + + # Return the value of the constraint function + return self.colloc_vals.reshape(-1) # # Initial guess @@ -517,40 +543,61 @@ def _collocation_constraints(self, coeffs): # TODO: figure out how to modify this for collocation # def _process_initial_guess(self, initial_guess): - if self.shooting and initial_guess is not None: - # Convert to a 1D array (or higher) - initial_guess = np.atleast_1d(initial_guess) - - # See whether we got entire guess or just first time point - if initial_guess.ndim == 1: - # Broadcast inputs to entire time vector - try: - initial_guess = np.broadcast_to( - initial_guess.reshape(-1, 1), - (self.system.ninputs, self.timepts.size)) - except ValueError: - raise ValueError("initial guess is the wrong shape") - - elif initial_guess.shape != \ - (self.system.ninputs, self.timepts.size): - raise ValueError("initial guess is the wrong shape") + # Sort out the input guess and the state guess + if self.collocation and initial_guess is not None and \ + isinstance(initial_guess, tuple): + state_guess, input_guess = initial_guess + else: + state_guess, input_guess = None, initial_guess + + # Process the input guess + if input_guess is not None: + input_guess = self._broadcast_initial_guess( + input_guess, (self.system.ninputs, self.timepts.size)) # If we were given a basis, project onto the basis elements if self.basis is not None: - initial_guess = self._inputs_to_coeffs(initial_guess) + input_guess = self._inputs_to_coeffs(input_guess) + else: + input_guess = np.zeros( + self.system.ninputs * + (self.timepts.size if self.basis is None else self.basis.N)) + + # Process the state guess + if self.collocation: + if state_guess is None: + # Run a simulation to get the initial guess + inputs = input_guess.reshape(self.system.ninputs, -1) + state_guess = self._simulate_states( + np.zeros(self.system.nstates), inputs) + else: + state_guess = self._broadcast_initial_guess( + state_guess, (self.system.nstates, self.timepts.size)) # Reshape for use by scipy.optimize.minimize() - return initial_guess.reshape(-1) + return np.hstack([ + input_guess.reshape(-1), state_guess.reshape(-1)]) + else: + # Reshape for use by scipy.optimize.minimize() + return input_guess.reshape(-1) + + def _broadcast_initial_guess(self, initial_guess, shape): + # Convert to a 1D array (or higher) + initial_guess = np.atleast_1d(initial_guess) + + # See whether we got entire guess or just first time point + if initial_guess.ndim == 1: + # Broadcast inputs to entire time vector + try: + initial_guess = np.broadcast_to( + initial_guess.reshape(-1, 1), shape) + except ValueError: + raise ValueError("initial guess is the wrong shape") - elif self.collocation and initial_guess is not None: - raise NotImplementedError("collocation not yet implemented") + elif initial_guess.shape != shape: + raise ValueError("initial guess is the wrong shape") - # Default is no initial guess - input_size = self.system.ninputs * \ - (self.timepts.size if self.basis is None else self.basis.N) - state_size = 0 if not self.collocation else \ - (self.timepts.size - 1) * self.sys.nstates - return np.zeros(input_size + state_size) + return initial_guess # # Utility function to convert input vector to coefficient vector @@ -650,9 +697,10 @@ def _compute_states_inputs(self, coeffs): # if self.collocation: # States are appended to end of (input) coefficients - states = coeffs[-self.sys.nstates * self.timepts.size:0] - coeffs = coeffs[0:-self.sys.nstates * self.timepts.size] - + states = coeffs[-self.system.nstates * self.timepts.size:].reshape( + self.system.nstates, -1) + coeffs = coeffs[:-self.system.nstates * self.timepts.size] + # Compute input at time points if self.basis: inputs = self._coeffs_to_inputs(coeffs) @@ -863,11 +911,8 @@ def __init__( # Remember the optimal control problem that we solved self.problem = ocp - # Compute input at time points - if ocp.basis: - inputs = ocp._coeffs_to_inputs(res.x) - else: - inputs = res.x.reshape((ocp.system.ninputs, -1)) + # Parse the optimization variables into states and inputs + states, inputs = ocp._compute_states_inputs(res.x) # See if we got an answer if not res.success: @@ -885,6 +930,7 @@ def __init__( if return_states and inputs.shape[1] == ocp.timepts.shape[0]: # Simulate the system if we need the state back + # TODO: this is probably not needed due to compute_states_inputs() _, _, states = ct.input_output_response( ocp.system, ocp.timepts, inputs, ocp.x, return_x=True, solve_ivp_kwargs=ocp.solve_ivp_kwargs, t_eval=ocp.timepts) @@ -1017,7 +1063,7 @@ def solve_ocp( 'optimal', 'return_x', kwargs, return_states, pop=True) # Process (legacy) method keyword - if kwargs.get('method'): + if kwargs.get('method') and method not in optimal_methods: if kwargs.get('minimize_method'): raise ValueError("'minimize_method' specified more than once") kwargs['minimize_method'] = kwargs.pop('method') diff --git a/control/tests/optimal_test.py b/control/tests/optimal_test.py index 4359b693c..48d11fc82 100644 --- a/control/tests/optimal_test.py +++ b/control/tests/optimal_test.py @@ -16,11 +16,50 @@ from numpy.lib import NumpyVersion -def test_finite_horizon_simple(): - # Define a linear system with constraints +@pytest.mark.parametrize("method, npts", [ + ('shooting', 5), + ('collocation', 20), +]) +def test_continuous_lqr(method, npts): + # Create a lightly dampled, second order system + sys = ct.ss([[0, 1], [-0.1, -0.01]], [[0], [1]], [[1, 0]], 0) + + # Define cost functions + Q = np.eye(sys.nstates) + R = np.eye(sys.ninputs) * 10 + + # Figure out the LQR solution (for terminal cost) + K, S, E = ct.lqr(sys, Q, R) + + # Define the cost functions + traj_cost = opt.quadratic_cost(sys, Q, R) + term_cost = opt.quadratic_cost(sys, S, None) + constraints = opt.input_range_constraint( + sys, -np.ones(sys.ninputs), np.ones(sys.ninputs)) + + # Define the initial condition, time horizon, and time points + x0 = np.ones(sys.nstates) + Tf = 10 + timepts = np.linspace(0, Tf, npts) + + res = opt.solve_ocp( + sys, timepts, x0, traj_cost, constraints, terminal_cost=term_cost, + trajectory_method=method + ) + + # Make sure the optimization was successful + assert res.success + + # Make sure we were reasonable close to the optimal cost + assert res.cost / (x0 @ S @ x0) < 1.2 # shouldn't be too far off + + +@pytest.mark.parametrize("method", ['shooting']) # TODO: add 'collocation' +def test_finite_horizon_simple(method): + # Define a (discrete time) linear system with constraints # Source: https://www.mpt3.org/UI/RegulationProblem - # LTI prediction model + # LTI prediction model (discrete time) sys = ct.ss2io(ct.ss([[1, 1], [0, 1]], [[1], [0.5]], np.eye(2), 0, 1)) # State and input constraints @@ -40,6 +79,7 @@ def test_finite_horizon_simple(): # Retrieve the full open-loop predictions res = opt.solve_ocp( sys, time, x0, cost, constraints, squeeze=True, + trajectory_method=method, terminal_cost=cost) # include to match MPT3 formulation t, u_openloop = res.time, res.inputs np.testing.assert_almost_equal( @@ -297,7 +337,8 @@ def test_terminal_constraints(sys_args): # Re-run using initial guess = optional and make sure nothing changes res = optctrl.compute_trajectory(x0, initial_guess=u1) - np.testing.assert_almost_equal(res.inputs, u1) + np.testing.assert_almost_equal(res.inputs, u1, decimal=2) + np.testing.assert_almost_equal(res.states, x1, decimal=4) # Re-run using a basis function and see if we get the same answer res = opt.solve_ocp( @@ -574,7 +615,18 @@ def final_point_eval(x, u): res = optctrl.compute_trajectory(x0, squeeze=True, return_x=True) -def test_optimal_doc(): +@pytest.mark.parametrize( + "method, npts, initial_guess", [ + # ('shooting', 3, None), # doesn't converge + # ('shooting', 3, 'zero'), # doesn't converge + ('shooting', 3, 'input'), # github issue #782 + ('shooting', 5, 'input'), + # ('collocation', 5, 'zero'), # doesn't converge + ('collocation', 5, 'input'), + ('collocation', 10, 'input'), + ('collocation', 10, 'state'), + ]) +def test_optimal_doc(method, npts, initial_guess): """Test optimal control problem from documentation""" def vehicle_update(t, x, u, params): # Get the parameters for the model @@ -600,8 +652,8 @@ def vehicle_output(t, x, u, params): inputs=('v', 'phi'), outputs=('x', 'y', 'theta')) # Define the initial and final points and time interval - x0 = [0., -2., 0.]; u0 = [10., 0.] - xf = [100., 2., 0.]; uf = [10., 0.] + x0 = np.array([0., -2., 0.]); u0 = np.array([10., 0.]) + xf = np.array([100., 2., 0.]); uf = np.array([10., 0.]) Tf = 10 # Define the cost functions @@ -614,17 +666,50 @@ def vehicle_output(t, x, u, params): # Define the constraints constraints = [ opt.input_range_constraint(vehicle, [8, -0.1], [12, 0.1]) ] + # Define an initial guess at the trajectory + timepts = np.linspace(0, Tf, npts, endpoint=True) + if initial_guess == 'zero': + initial_guess = 0 + + elif initial_guess == 'input': + # Velocity = constant that gets us from start to end + initial_guess = np.zeros((vehicle.ninputs, timepts.size)) + initial_guess[0, :] = (xf[0] - x0[0]) / Tf + + # Steering = rate required to turn to proper slope in first segment + straight_seg_length = timepts[-2] - timepts[1] + curved_seg_length = (Tf - straight_seg_length)/2 + approximate_angle = math.atan2(xf[1] - x0[1], xf[0] - x0[0]) + initial_guess[1, 0] = approximate_angle / (timepts[1] - timepts[0]) + initial_guess[1, -1] = -approximate_angle / (timepts[-1] - timepts[-2]) + + elif initial_guess == 'state': + input_guess = np.outer(u0, np.ones((1, npts))) + state_guess = np.array([ + x0 + (xf - x0) * time/Tf for time in timepts]).transpose() + initial_guess = (state_guess, input_guess) + # Solve the optimal control problem - horizon = np.linspace(0, Tf, 3, endpoint=True) result = opt.solve_ocp( - vehicle, horizon, x0, traj_cost, constraints, - terminal_cost= term_cost, initial_guess=u0) + vehicle, timepts, x0, traj_cost, trajectory_method=method, + # minimize_method='COBYLA', # SLSQP', + # constraints, + terminal_cost=term_cost, initial_guess=initial_guess, + ) + + # Make sure we got a successful result (or precision loss error) + assert result.success or result.status == 2 - # Make sure the resulting trajectory generate a good solution + # Make sure the resulting trajectory generated a good solution resp = ct.input_output_response( - vehicle, horizon, result.inputs, x0, + vehicle, timepts, result.inputs, x0, t_eval=np.linspace(0, Tf, 10)) t, y = resp - assert (y[0, -1] - xf[0]) / xf[0] < 0.01 - assert (y[1, -1] - xf[1]) / xf[1] < 0.01 - assert y[2, -1] < 0.1 + + assert abs(y[0, 0] - x0[0]) < 0.01 + assert abs((y[1, 0] - x0[1]) / x0[1]) < 0.01 + assert abs(y[2, 0]) < 0.01 + + assert abs((y[0, -1] - xf[0]) / xf[0]) < 0.2 # TODO: reset to 0.1 + assert abs((y[1, -1] - xf[1]) / xf[1]) < 0.2 # TODO: reset to 0.1 + assert abs(y[2, -1]) < 0.1 From 8256fa05eca508aa75b346c7bb53b91472549c3c Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Thu, 24 Nov 2022 23:13:38 -0800 Subject: [PATCH 096/157] set default trajectory_methood for ctime systems to collocation --- control/optimal.py | 18 ++++++++++-------- control/tests/optimal_test.py | 19 +++++++++++++------ doc/optimal.rst | 21 +++++++++++++-------- 3 files changed, 36 insertions(+), 22 deletions(-) diff --git a/control/optimal.py b/control/optimal.py index a7354a100..e0cf48f0a 100644 --- a/control/optimal.py +++ b/control/optimal.py @@ -147,11 +147,10 @@ def __init__( self.terminal_constraints = terminal_constraints self.basis = basis - # Keep track of what type of method we are using + # Keep track of what type of trajector method we are using if trajectory_method is None: # TODO: change default - # trajectory_method = 'collocation' if sys.isctime() else 'shooting' - trajectory_method = 'shooting' if sys.isctime() else 'shooting' + trajectory_method = 'collocation' if sys.isctime() else 'shooting' elif trajectory_method not in _optimal_trajectory_methods: raise NotImplementedError(f"Unkown method {method}") @@ -422,9 +421,9 @@ def _constraint_function(self, coeffs): # Skip equality constraints continue elif ctype == opt.LinearConstraint: - value.append(fun @ np.hstack([states[:, i], inputs[:, i]])) + value.append(fun @ np.hstack([states[:, -1], inputs[:, -1]])) elif ctype == opt.NonlinearConstraint: - value.append(fun(states[:, i], inputs[:, i])) + value.append(fun(states[:, -1], inputs[:, -1])) else: # pragma: no cover # Checked above => we should never get here raise TypeError(f"unknown constraint type {ctype}") @@ -478,9 +477,9 @@ def _eqconst_function(self, coeffs): # Skip inequality constraints continue elif ctype == opt.LinearConstraint: - value.append(fun @ np.hstack([states[:, i], inputs[:, i]])) + value.append(fun @ np.hstack([states[:, -1], inputs[:, -1]])) elif ctype == opt.NonlinearConstraint: - value.append(fun(states[:, i], inputs[:, i])) + value.append(fun(states[:, -1], inputs[:, -1])) else: # pragma: no cover # Checked above => we should never get here raise TypeError("unknown constraint type {ctype}") @@ -567,7 +566,10 @@ def _process_initial_guess(self, initial_guess): if self.collocation: if state_guess is None: # Run a simulation to get the initial guess - inputs = input_guess.reshape(self.system.ninputs, -1) + if self.basis: + inputs = self._coeffs_to_inputs(input_guess) + else: + inputs = input_guess.reshape(self.system.ninputs, -1) state_guess = self._simulate_states( np.zeros(self.system.nstates), inputs) else: diff --git a/control/tests/optimal_test.py b/control/tests/optimal_test.py index 48d11fc82..0aded3531 100644 --- a/control/tests/optimal_test.py +++ b/control/tests/optimal_test.py @@ -367,7 +367,7 @@ def test_terminal_constraints(sys_args): # Not all configurations are able to converge (?) if res.success: - np.testing.assert_almost_equal(x2[:,-1], 0) + np.testing.assert_almost_equal(x2[:,-1], 0, decimal=5) # Make sure that it is *not* a straight line path assert np.any(np.abs(x2 - x1) > 0.1) @@ -619,12 +619,16 @@ def final_point_eval(x, u): "method, npts, initial_guess", [ # ('shooting', 3, None), # doesn't converge # ('shooting', 3, 'zero'), # doesn't converge - ('shooting', 3, 'input'), # github issue #782 - ('shooting', 5, 'input'), - # ('collocation', 5, 'zero'), # doesn't converge + ('shooting', 3, 'u0'), # github issue #782 + # ('shooting', 3, 'input'), # precision loss + # ('shooting', 5, 'input'), # precision loss + # ('collocation', 3, 'u0'), # doesn't converge + ('collocation', 5, 'u0'), # from documenentation ('collocation', 5, 'input'), ('collocation', 10, 'input'), + ('collocation', 10, 'u0'), ('collocation', 10, 'state'), + ('collocation', 20, 'state'), ]) def test_optimal_doc(method, npts, initial_guess): """Test optimal control problem from documentation""" @@ -671,6 +675,9 @@ def vehicle_output(t, x, u, params): if initial_guess == 'zero': initial_guess = 0 + elif initial_guess == 'u0': + initial_guess = u0 + elif initial_guess == 'input': # Velocity = constant that gets us from start to end initial_guess = np.zeros((vehicle.ninputs, timepts.size)) @@ -710,6 +717,6 @@ def vehicle_output(t, x, u, params): assert abs((y[1, 0] - x0[1]) / x0[1]) < 0.01 assert abs(y[2, 0]) < 0.01 - assert abs((y[0, -1] - xf[0]) / xf[0]) < 0.2 # TODO: reset to 0.1 - assert abs((y[1, -1] - xf[1]) / xf[1]) < 0.2 # TODO: reset to 0.1 + assert abs((y[0, -1] - xf[0]) / xf[0]) < 0.12 + assert abs((y[1, -1] - xf[1]) / xf[1]) < 0.12 assert abs(y[2, -1]) < 0.1 diff --git a/doc/optimal.rst b/doc/optimal.rst index bb952e9cc..107373915 100644 --- a/doc/optimal.rst +++ b/doc/optimal.rst @@ -112,15 +112,15 @@ problem into a standard optimization problem that can be solved by :func:`scipy.optimize.minimize`. The optimal control problem can be solved by using the :func:`~control.obc.solve_ocp` function:: - res = obc.solve_ocp(sys, horizon, X0, cost, constraints) + res = obc.solve_ocp(sys, timepts, X0, cost, constraints) The `sys` parameter should be an :class:`~control.InputOutputSystem` and the -`horizon` parameter should represent a time vector that gives the list of +`timepts` parameter should represent a time vector that gives the list of times at which the cost and constraints should be evaluated. The `cost` function has call signature `cost(t, x, u)` and should return the (incremental) cost at the given time, state, and input. It will be -evaluated at each point in the `horizon` vector. The `terminal_cost` +evaluated at each point in the `timepts` vector. The `terminal_cost` parameter can be used to specify a cost function for the final point in the trajectory. @@ -157,7 +157,7 @@ that has the following elements: * `res.success`: `True` if the optimization was successfully solved * `res.inputs`: optimal input * `res.states`: state trajectory (if `return_x` was `True`) - * `res.time`: copy of the time horizon vector + * `res.time`: copy of the time timepts vector In addition, the results from :func:`scipy.optimize.minimize` are also available. @@ -235,16 +235,16 @@ and constrain the velocity to be in the range of 9 m/s to 11 m/s:: Finally, we solve for the optimal inputs:: - horizon = np.linspace(0, Tf, 3, endpoint=True) + timepts = np.linspace(0, Tf, 10, endpoint=True) result = opt.solve_ocp( - vehicle, horizon, x0, traj_cost, constraints, + vehicle, timepts, x0, traj_cost, constraints, terminal_cost=term_cost, initial_guess=u0) Plotting the results:: # Simulate the system dynamics (open loop) resp = ct.input_output_response( - vehicle, horizon, result.inputs, x0, + vehicle, timepts, result.inputs, x0, t_eval=np.linspace(0, Tf, 100)) t, y, u = resp.time, resp.outputs, resp.inputs @@ -262,7 +262,7 @@ Plotting the results:: plt.subplot(3, 1, 3) plt.plot(t, u[1]) - plt.axis([0, 10, -0.01, 0.01]) + plt.axis([0, 10, -0.015, 0.015]) plt.xlabel("t [sec]") plt.ylabel("u2 [rad/s]") @@ -282,6 +282,11 @@ toolbox and it can sometimes be tricky to get the optimization to converge. If you are getting errors when solving optimal control problems or your solutions do not seem close to optimal, here are a few things to try: +* The initial guess matters: providing a reasonable initial guess is often + needed in order for the optimizer to find a good answer. For an optimal + control problem that uses a larger terminal cost to get to a neighborhood + of a final point, a straight line in the state space often works well. + * Less is more: try using a smaller number of time points in your optimiation. The default optimal control problem formulation uses the value of the inputs at each time point as a free variable and this can From d121102cef6a9a8f44a85151ff852fa4f08fe83d Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 25 Nov 2022 13:29:28 -0800 Subject: [PATCH 097/157] updated examples + code cleanup --- control/optimal.py | 148 +++++++++++++++++----------------- control/tests/optimal_test.py | 80 ++++++++++-------- doc/optimal.rst | 4 +- doc/steering-optimal.png | Bin 29001 -> 28691 bytes examples/steering-optimal.py | 104 +++++++++++++++--------- 5 files changed, 190 insertions(+), 146 deletions(-) diff --git a/control/optimal.py b/control/optimal.py index e0cf48f0a..0478505bc 100644 --- a/control/optimal.py +++ b/control/optimal.py @@ -23,7 +23,6 @@ # Define module default parameter values _optimal_trajectory_methods = {'shooting', 'collocation'} _optimal_defaults = { - 'optimal.trajectory_method': 'shooting', 'optimal.minimize_method': None, 'optimal.minimize_options': {}, 'optimal.minimize_kwargs': {}, @@ -147,9 +146,8 @@ def __init__( self.terminal_constraints = terminal_constraints self.basis = basis - # Keep track of what type of trajector method we are using + # Keep track of what type of trajectory method we are using if trajectory_method is None: - # TODO: change default trajectory_method = 'collocation' if sys.isctime() else 'shooting' elif trajectory_method not in _optimal_trajectory_methods: raise NotImplementedError(f"Unkown method {method}") @@ -185,30 +183,23 @@ def __init__( raise TypeError("unrecognized keyword(s): ", str(kwargs)) # Process trajectory constraints - if isinstance(trajectory_constraints, tuple): - self.trajectory_constraints = [trajectory_constraints] - elif not isinstance(trajectory_constraints, list): - raise TypeError("trajectory constraints must be a list") - else: - self.trajectory_constraints = trajectory_constraints + def _process_constraints(constraint_list, name): + if isinstance(constraint_list, tuple): + constraint_list = [constraint_list] + elif not isinstance(constraint_list, list): + raise TypeError(f"{name} constraints must be a list") - # Make sure that we recognize all of the constraint types - for ctype, fun, lb, ub in self.trajectory_constraints: - if not ctype in [opt.LinearConstraint, opt.NonlinearConstraint]: - raise TypeError(f"unknown constraint type {ctype}") + # Make sure that we recognize all of the constraint types + for ctype, fun, lb, ub in constraint_list: + if not ctype in [opt.LinearConstraint, opt.NonlinearConstraint]: + raise TypeError(f"unknown {name} constraint type {ctype}") - # Process terminal constraints - if isinstance(terminal_constraints, tuple): - self.terminal_constraints = [terminal_constraints] - elif not isinstance(terminal_constraints, list): - raise TypeError("terminal constraints must be a list") - else: - self.terminal_constraints = terminal_constraints + return constraint_list - # Make sure that we recognize all of the constraint types - for ctype, fun, lb, ub in self.terminal_constraints: - if not ctype in [opt.LinearConstraint, opt.NonlinearConstraint]: - raise TypeError(f"unknown constraint type {ctype}") + self.trajectory_constraints = _process_constraints( + trajectory_constraints, "trajectory") + self.terminal_constraints = _process_constraints( + terminal_constraints, "terminal") # # Compute and store constraints @@ -223,10 +214,13 @@ def __init__( # is consistent with the `_constraint_function` that is used at # evaluation time. # + # TODO: when using the collocation method, linear constraints on the + # states and inputs can potentially maintain their linear structure + # rather than being converted to nonlinear constraints. + # constraint_lb, constraint_ub, eqconst_value = [], [], [] # Go through each time point and stack the bounds - # TODO: for collocation method, keep track of linear vs nonlinear for t in self.timepts: for type, fun, lb, ub in self.trajectory_constraints: if np.all(lb == ub): @@ -253,15 +247,19 @@ def __init__( self.eqconst_value = np.hstack(eqconst_value) if eqconst_value else [] # Create the constraints (inequality and equality) + # TODO: for collocation method, keep track of linear vs nonlinear self.constraints = [] + if len(self.constraint_lb) != 0: self.constraints.append(sp.optimize.NonlinearConstraint( self._constraint_function, self.constraint_lb, self.constraint_ub)) + if len(self.eqconst_value) != 0: self.constraints.append(sp.optimize.NonlinearConstraint( self._eqconst_function, self.eqconst_value, self.eqconst_value)) + if self.collocation: # Add the collocation constraints colloc_zeros = np.zeros(sys.nstates * self.timepts.size) @@ -275,7 +273,7 @@ def __init__( # Process the initial guess self.initial_guess = self._process_initial_guess(initial_guess) - # Store states, input, used later to minimize re-computation + # Store states, input (used later to minimize re-computation) self.last_x = np.full(self.system.nstates, np.nan) self.last_coeffs = np.full(self.initial_guess.shape, np.nan) @@ -286,10 +284,14 @@ def __init__( # # Cost function # - # Given the input U = [u[0], ... u[N]], we need to compute the cost of - # the trajectory generated by that input. This means we have to - # simulate the system to get the state trajectory X = [x[0], ..., - # x[N]] and then compute the cost at each point: + # For collocation methods we are given the states and inputs at each + # time point and we use a trapezoidal approximation to compute the + # integral cost, then add on the terminal cost. + # + # For shooting methods, given the input U = [u[0], ... u[N]] we need to + # compute the cost of the trajectory generated by that input. This + # means we have to simulate the system to get the state trajectory X = + # [x[0], ..., x[N]] and then compute the cost at each point: # # cost = sum_k integral_cost(x[k], u[k]) + terminal_cost(x[N], u[N]) # @@ -362,11 +364,12 @@ def _cost_function(self, coeffs): # is called at each point along the trajectory and compared against the # upper and lower bounds. # - # * If the upper and lower bound for the constraint are identical, then we - # separate out the evaluation into two different constraints, which - # allows the SciPy optimizers to be more efficient (and stops them from - # generating a warning about mixed constraints). This is handled - # through the use of the `_eqconst_function` and `eqconst_value` members. + # * If the upper and lower bound for the constraint are identical, then + # we separate out the evaluation into two different constraints, which + # allows the SciPy optimizers to be more efficient (and stops them + # from generating a warning about mixed constraints). This is handled + # through the use of the `_eqconst_function` and `eqconst_value` + # members. # # In both cases, the constraint is specified at a single point, but we # extend this to apply to each point in the trajectory. This means @@ -378,16 +381,12 @@ def _cost_function(self, coeffs): # For collocation methods, we can directly evaluate the constraints at # the collocation points. # - # For shooting methods, we do this by creating a function that - # simulates the system dynamics and returns a vector of values - # corresponding to the value of the function at each time. The - # class initialization methods takes care of replicating the upper - # and lower bounds for each point in time so that the SciPy - # optimization algorithm can do the proper evaluation. - # - # In addition, since SciPy's optimization function does not allow us to - # pass arguments to the constraint function, we have to store the initial - # state prior to optimization and retrieve it here. + # For shooting methods, we do this by creating a function that simulates + # the system dynamics and returns a vector of values corresponding to + # the value of the function at each time. The class initialization + # methods takes care of replicating the upper and lower bounds for each + # point in time so that the SciPy optimization algorithm can do the + # proper evaluation. # def _constraint_function(self, coeffs): if self.log: @@ -437,8 +436,7 @@ def _constraint_function(self, coeffs): "_constraint_function elapsed time: %g", stop_time - start_time) - # Debugging information - if self.log: + # Debugging information logging.debug( "constraint values\n" + str(value) + "\n" + "lb, ub =\n" + str(self.constraint_lb) + "\n" + @@ -492,8 +490,7 @@ def _eqconst_function(self, coeffs): logging.info( "_eqconst_function elapsed time: %g", stop_time - start_time) - # Debugging information - if self.log: + # Debugging information logging.debug( "eqconst values\n" + str(value) + "\n" + "desired =\n" + str(self.eqconst_value)) @@ -515,7 +512,7 @@ def _collocation_constraint(self, coeffs): self.timepts[0], states[:, 0], inputs[:, 0]) continue - # M. Kelly, SIAM Review (2017), equation (3.2), i = k+1 + # From M. Kelly, SIAM Review (2017), equation (3.2), i = k+1 # x[k+1] - x[k] = 0.5 hk (f(x[k+1], u[k+1] + f(x[k], u[k])) fkp1 = self.system._rhs(t, states[:, i], inputs[:, i]) self.colloc_vals[:, i] = states[:, i] - states[:, i-1] - \ @@ -529,18 +526,20 @@ def _collocation_constraint(self, coeffs): return self.colloc_vals.reshape(-1) # - # Initial guess + # Initial guess processing # # We store an initial guess in case it is not specified later. Note # that create_mpc_iosystem() will reset the initial guess based on # the current state of the MPC controller. # - # Note: the initial guess is passed as the inputs at the given time + # The functions below are used to process the initial guess, which can + # either consist of an input only (for shooting methods) or an input + # and/or state trajectory (for collocaiton methods). + # + # Note: The initial input guess is passed as the inputs at the given time # vector. If a basis is specified, this is converted to coefficient # values (which are generally of smaller dimension). # - # TODO: figure out how to modify this for collocation - # def _process_initial_guess(self, initial_guess): # Sort out the input guess and the state guess if self.collocation and initial_guess is not None and \ @@ -583,6 +582,7 @@ def _process_initial_guess(self, initial_guess): # Reshape for use by scipy.optimize.minimize() return input_guess.reshape(-1) + # Utility function to broadcast an initial guess to the right shape def _broadcast_initial_guess(self, initial_guess, shape): # Convert to a 1D array (or higher) initial_guess = np.atleast_1d(initial_guess) @@ -604,11 +604,11 @@ def _broadcast_initial_guess(self, initial_guess, shape): # # Utility function to convert input vector to coefficient vector # - # Initially guesses from the user are passed as input vectors as a + # Initial guesses from the user are passed as input vectors as a # function of time, but internally we store the guess in terms of the # basis coefficients. We do this by solving a least squares problem to - # find coefficients that match the input functions at the time points (as - # much as possible, if the problem is under-determined). + # find coefficients that match the input functions at the time points + # (as much as possible, if the problem is under-determined). # def _inputs_to_coeffs(self, inputs): # If there is no basis function, just return inputs as coeffs @@ -638,6 +638,7 @@ def _inputs_to_coeffs(self, inputs): # Utility function to convert coefficient vector to input vector def _coeffs_to_inputs(self, coeffs): # TODO: vectorize + # TODO: use BasisFamily eval() method (if more efficient)? inputs = np.zeros((self.system.ninputs, self.timepts.size)) offset = 0 for i in range(self.system.ninputs): @@ -689,7 +690,7 @@ def _print_statistics(self, reset=True): # These internal functions return the states and inputs at the # collocation points given the ceofficient (optimizer state) vector. # They keep track of whether a shooting method is being used or not and - # simulate the dynamis of needed. + # simulate the dynamics if needed. # # Compute the states and inputs from the coefficients @@ -738,6 +739,7 @@ def _simulate_states(self, x0, inputs): if self.log: logging.debug( "input_output_response returned states\n" + str(states)) + return states # @@ -930,16 +932,6 @@ def __init__( ocp._print_statistics() print("* Final cost:", self.cost) - if return_states and inputs.shape[1] == ocp.timepts.shape[0]: - # Simulate the system if we need the state back - # TODO: this is probably not needed due to compute_states_inputs() - _, _, states = ct.input_output_response( - ocp.system, ocp.timepts, inputs, ocp.x, return_x=True, - solve_ivp_kwargs=ocp.solve_ivp_kwargs, t_eval=ocp.timepts) - ocp.system_simulations += 1 - else: - states = None - # Process data as a time response (with "outputs" = inputs) response = TimeResponseData( ocp.timepts, inputs, states, issiso=ocp.system.issiso(), @@ -1065,10 +1057,22 @@ def solve_ocp( 'optimal', 'return_x', kwargs, return_states, pop=True) # Process (legacy) method keyword - if kwargs.get('method') and method not in optimal_methods: - if kwargs.get('minimize_method'): - raise ValueError("'minimize_method' specified more than once") - kwargs['minimize_method'] = kwargs.pop('method') + if kwargs.get('method'): + method = kwargs.pop('method') + if method not in optimal_methods: + if kwargs.get('minimize_method'): + raise ValueError("'minimize_method' specified more than once") + warnings.warn( + "'method' parameter is deprecated; assuming minimize_method", + DeprecationWarning) + kwargs['minimize_method'] = method + else: + if kwargs.get('trajectory_method'): + raise ValueError("'trajectory_method' specified more than once") + warnings.warn( + "'method' parameter is deprecated; assuming trajectory_method", + DeprecationWarning) + kwargs['trajectory_method'] = method # Set up the optimal control problem ocp = OptimalControlProblem( diff --git a/control/tests/optimal_test.py b/control/tests/optimal_test.py index 0aded3531..4fc90464a 100644 --- a/control/tests/optimal_test.py +++ b/control/tests/optimal_test.py @@ -494,12 +494,12 @@ def test_ocp_argument_errors(): # Unrecognized trajectory constraint type constraints = [(None, np.eye(3), [0, 0, 0], [0, 0, 0])] - with pytest.raises(TypeError, match="unknown constraint type"): + with pytest.raises(TypeError, match="unknown trajectory constraint type"): res = opt.solve_ocp( sys, time, x0, cost, trajectory_constraints=constraints) # Unrecognized terminal constraint type - with pytest.raises(TypeError, match="unknown constraint type"): + with pytest.raises(TypeError, match="unknown terminal constraint type"): res = opt.solve_ocp( sys, time, x0, cost, terminal_constraints=constraints) @@ -609,28 +609,28 @@ def final_point_eval(x, u): # Try passing and unknown constraint type final_point = [(None, final_point_eval, [0, 0], [0, 0])] - with pytest.raises(TypeError, match="unknown constraint type"): + with pytest.raises(TypeError, match="unknown terminal constraint type"): optctrl = opt.OptimalControlProblem( sys, time, cost, terminal_constraints=final_point) res = optctrl.compute_trajectory(x0, squeeze=True, return_x=True) @pytest.mark.parametrize( - "method, npts, initial_guess", [ - # ('shooting', 3, None), # doesn't converge - # ('shooting', 3, 'zero'), # doesn't converge - ('shooting', 3, 'u0'), # github issue #782 - # ('shooting', 3, 'input'), # precision loss - # ('shooting', 5, 'input'), # precision loss - # ('collocation', 3, 'u0'), # doesn't converge - ('collocation', 5, 'u0'), # from documenentation - ('collocation', 5, 'input'), - ('collocation', 10, 'input'), - ('collocation', 10, 'u0'), - ('collocation', 10, 'state'), - ('collocation', 20, 'state'), + "method, npts, initial_guess, fail", [ + ('shooting', 3, None, 'xfail'), # doesn't converge + ('shooting', 3, 'zero', 'xfail'), # doesn't converge + ('shooting', 3, 'u0', None), # github issue #782 + ('shooting', 3, 'input', 'endpoint'), # doesn't converge to optimal + ('shooting', 5, 'input', 'endpoint'), # doesn't converge to optimal + ('collocation', 3, 'u0', 'endpoint'), # doesn't converge to optimal + ('collocation', 5, 'u0', 'endpoint'), + ('collocation', 5, 'input', 'openloop'),# open loop sim fails + ('collocation', 10, 'input', None), + ('collocation', 10, 'u0', None), # from documenentation + ('collocation', 10, 'state', None), + ('collocation', 20, 'state', None), ]) -def test_optimal_doc(method, npts, initial_guess): +def test_optimal_doc(method, npts, initial_guess, fail): """Test optimal control problem from documentation""" def vehicle_update(t, x, u, params): # Get the parameters for the model @@ -698,25 +698,35 @@ def vehicle_output(t, x, u, params): # Solve the optimal control problem result = opt.solve_ocp( - vehicle, timepts, x0, traj_cost, trajectory_method=method, - # minimize_method='COBYLA', # SLSQP', - # constraints, + vehicle, timepts, x0, traj_cost, constraints, terminal_cost=term_cost, initial_guess=initial_guess, + trajectory_method=method, + # minimize_method='COBYLA', # SLSQP', ) - # Make sure we got a successful result (or precision loss error) - assert result.success or result.status == 2 - - # Make sure the resulting trajectory generated a good solution - resp = ct.input_output_response( - vehicle, timepts, result.inputs, x0, - t_eval=np.linspace(0, Tf, 10)) - t, y = resp - - assert abs(y[0, 0] - x0[0]) < 0.01 - assert abs((y[1, 0] - x0[1]) / x0[1]) < 0.01 - assert abs(y[2, 0]) < 0.01 + if fail == 'xfail': + assert not result.success + pytest.xfail("optimization fails to converge") + elif fail == 'precision': + assert result.status == 2 + pytest.xfail("optimization precision not achieved") + else: + # Make sure the optimization was successful + assert result.success - assert abs((y[0, -1] - xf[0]) / xf[0]) < 0.12 - assert abs((y[1, -1] - xf[1]) / xf[1]) < 0.12 - assert abs(y[2, -1]) < 0.1 + # Make sure we started and stopped at the right spot + if fail == 'endpoint': + pytest.xfail("optimization does not converge to endpoint") + else: + np.testing.assert_almost_equal(result.states[:, 0], x0, decimal=4) + + # Simulate the trajectory to make sure it looks OK + resp = ct.input_output_response( + vehicle, timepts, result.inputs, x0, + t_eval=np.linspace(0, Tf, 10)) + t, y = resp + if fail == 'openloop': + with pytest.raises(AssertionError): + np.testing.assert_almost_equal(y[:,-1], xf, decimal=1) + else: + np.testing.assert_almost_equal(y[:,-1], xf, decimal=1) diff --git a/doc/optimal.rst b/doc/optimal.rst index 107373915..00e39c24f 100644 --- a/doc/optimal.rst +++ b/doc/optimal.rst @@ -215,8 +215,8 @@ moving from the point x = 0 m, y = -2 m, :math:`\theta` = 0 to the point x = 100 m, y = 2 m, :math:`\theta` = 0) over a period of 10 seconds and with a with a starting and ending velocity of 10 m/s:: - x0 = [0., -2., 0.]; u0 = [10., 0.] - xf = [100., 2., 0.]; uf = [10., 0.] + x0 = np.array([0., -2., 0.]); u0 = np.array([10., 0.]) + xf = np.array([100., 2., 0.]); uf = np.array([10., 0.]) Tf = 10 To set up the optimal control problem we design a cost function that diff --git a/doc/steering-optimal.png b/doc/steering-optimal.png index f847923b5f8f86f6901d2d60c95d714f36289f18..518de89a499fff9e76ebfd81d014774e636a44cb 100644 GIT binary patch literal 28691 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 QSVPJVePg{G9h<=a0(3Y8y8r+H diff --git a/examples/steering-optimal.py b/examples/steering-optimal.py index 5661e0f38..778ac3c25 100644 --- a/examples/steering-optimal.py +++ b/examples/steering-optimal.py @@ -57,7 +57,7 @@ def vehicle_output(t, x, u, params): # # Utility function to plot the results # -def plot_results(t, y, u, figure=None, yf=None): +def plot_lanechange(t, y, u, yf=None, figure=None): plt.figure(figure) # Plot the xy trajectory @@ -65,7 +65,7 @@ def plot_results(t, y, u, figure=None, yf=None): plt.plot(y[0], y[1]) plt.xlabel("x [m]") plt.ylabel("y [m]") - if yf: + if yf is not None: plt.plot(yf[0], yf[1], 'ro') # Plot the inputs as a function of time @@ -90,8 +90,8 @@ def plot_results(t, y, u, figure=None, yf=None): # # Initial and final conditions -x0 = [0., -2., 0.]; u0 = [10., 0.] -xf = [100., 2., 0.]; uf = [10., 0.] +x0 = np.array([0., -2., 0.]); u0 = np.array([10., 0.]) +xf = np.array([100., 2., 0.]); uf = np.array([10., 0.]) Tf = 10 # @@ -109,10 +109,13 @@ def plot_results(t, y, u, figure=None, yf=None): quad_cost = opt.quadratic_cost(vehicle, Q, R, x0=xf, u0=uf) # Define the time horizon (and spacing) for the optimization -horizon = np.linspace(0, Tf, 10, endpoint=True) +timepts = np.linspace(0, Tf, 20, endpoint=True) -# Provide an intial guess (will be extended to entire horizon) -bend_left = [10, 0.01] # slight left veer +# Provide an initial guess +straight_line = ( + np.array([x0 + (xf - x0) * time/Tf for time in timepts]).transpose(), + np.outer(u0, np.ones_like(timepts)) +) # Turn on debug level logging so that we can see what the optimizer is doing logging.basicConfig( @@ -122,9 +125,9 @@ def plot_results(t, y, u, figure=None, yf=None): # Compute the optimal control, setting step size for gradient calculation (eps) start_time = time.process_time() result1 = opt.solve_ocp( - vehicle, horizon, x0, quad_cost, initial_guess=bend_left, log=True, - minimize_method='trust-constr', - minimize_options={'finite_diff_rel_step': 0.01}, + vehicle, timepts, x0, quad_cost, initial_guess=straight_line, log=True, + # minimize_method='trust-constr', + # minimize_options={'finite_diff_rel_step': 0.01}, ) print("* Total time = %5g seconds\n" % (time.process_time() - start_time)) @@ -132,10 +135,15 @@ def plot_results(t, y, u, figure=None, yf=None): if 'PYCONTROL_TEST_EXAMPLES' in os.environ: assert result1.success -# Extract and plot the results (+ state trajectory) +# Plot the results from the optimization +plot_lanechange(timepts, result1.states, result1.inputs, xf, figure=1) +print("Final computed state: ", result1.states[:,-1]) + +# Simulate the system and see what happens t1, u1 = result1.time, result1.inputs -t1, y1 = ct.input_output_response(vehicle, horizon, u1, x0) -plot_results(t1, y1, u1, figure=1, yf=xf[0:2]) +t1, y1 = ct.input_output_response(vehicle, timepts, u1, x0) +plot_lanechange(t1, y1, u1, yf=xf[0:2], figure=1) +print("Final simulated state:", y1[:,-1]) # # Approach 2: input cost, input constraints, terminal cost @@ -147,7 +155,7 @@ def plot_results(t, y, u, figure=None, yf=None): # # We also set the solver explicitly. # -print("Approach 2: input cost and constraints plus terminal cost") +print("\nApproach 2: input cost and constraints plus terminal cost") # Add input constraint, input cost, terminal cost constraints = [ opt.input_range_constraint(vehicle, [8, -0.1], [12, 0.1]) ] @@ -159,22 +167,34 @@ def plot_results(t, y, u, figure=None, yf=None): level=logging.INFO, filename="./steering-terminal_cost.log", filemode='w', force=True) +# Use a straight line between initial and final position as initial guesss +input_guess = np.outer(u0, np.ones((1, timepts.size))) +state_guess = np.array([ + x0 + (xf - x0) * time/Tf for time in timepts]).transpose() +straight_line = (state_guess, input_guess) + # Compute the optimal control start_time = time.process_time() result2 = opt.solve_ocp( - vehicle, horizon, x0, traj_cost, constraints, terminal_cost=term_cost, - initial_guess=bend_left, log=True, - minimize_method='SLSQP', minimize_options={'eps': 0.01}) + vehicle, timepts, x0, traj_cost, constraints, terminal_cost=term_cost, + initial_guess=straight_line, log=True, + # minimize_method='SLSQP', minimize_options={'eps': 0.01} +) print("* Total time = %5g seconds\n" % (time.process_time() - start_time)) # If we are running CI tests, make sure we succeeded if 'PYCONTROL_TEST_EXAMPLES' in os.environ: assert result2.success -# Extract and plot the results (+ state trajectory) +# Plot the results from the optimization +plot_lanechange(timepts, result2.states, result2.inputs, xf, figure=2) +print("Final computed state: ", result2.states[:,-1]) + +# Simulate the system and see what happens t2, u2 = result2.time, result2.inputs -t2, y2 = ct.input_output_response(vehicle, horizon, u2, x0) -plot_results(t2, y2, u2, figure=2, yf=xf[0:2]) +t2, y2 = ct.input_output_response(vehicle, timepts, u2, x0) +plot_lanechange(t2, y2, u2, yf=xf[0:2], figure=2) +print("Final simulated state:", y2[:,-1]) # # Approach 3: terminal constraints @@ -183,7 +203,7 @@ def plot_results(t, y, u, figure=None, yf=None): # with a terminal *constraint* on the state. If a solution is found, # it guarantees we get to exactly the final state. # -print("Approach 3: terminal constraints") +print("\nApproach 3: terminal constraints") # Input cost and terminal constraints R = np.diag([1, 1]) # minimize applied inputs @@ -200,10 +220,10 @@ def plot_results(t, y, u, figure=None, yf=None): # Compute the optimal control start_time = time.process_time() result3 = opt.solve_ocp( - vehicle, horizon, x0, cost3, constraints, - terminal_constraints=terminal, initial_guess=bend_left, log=False, - solve_ivp_kwargs={'atol': 1e-3, 'rtol': 1e-2}, - minimize_method='trust-constr', + vehicle, timepts, x0, cost3, constraints, + terminal_constraints=terminal, initial_guess=straight_line, log=False, + # solve_ivp_kwargs={'atol': 1e-3, 'rtol': 1e-2}, + # minimize_method='trust-constr', ) print("* Total time = %5g seconds\n" % (time.process_time() - start_time)) @@ -211,10 +231,15 @@ def plot_results(t, y, u, figure=None, yf=None): if 'PYCONTROL_TEST_EXAMPLES' in os.environ: assert result3.success -# Extract and plot the results (+ state trajectory) +# Plot the results from the optimization +plot_lanechange(timepts, result3.states, result3.inputs, xf, figure=3) +print("Final computed state: ", result3.states[:,-1]) + +# Simulate the system and see what happens t3, u3 = result3.time, result3.inputs -t3, y3 = ct.input_output_response(vehicle, horizon, u3, x0) -plot_results(t3, y3, u3, figure=3, yf=xf[0:2]) +t3, y3 = ct.input_output_response(vehicle, timepts, u3, x0) +plot_lanechange(t3, y3, u3, yf=xf[0:2], figure=3) +print("Final simulated state:", y3[:,-1]) # # Approach 4: terminal constraints w/ basis functions @@ -224,20 +249,20 @@ def plot_results(t, y, u, figure=None, yf=None): # Here we parameterize the input by a set of 4 Bezier curves but solve # for a much more time resolved set of inputs. -print("Approach 4: Bezier basis") +print("\nApproach 4: Bezier basis") import control.flatsys as flat # Compute the optimal control start_time = time.process_time() result4 = opt.solve_ocp( - vehicle, horizon, x0, quad_cost, + vehicle, timepts, x0, quad_cost, constraints, terminal_constraints=terminal, - initial_guess=bend_left, - basis=flat.BezierFamily(4, T=Tf), + initial_guess=straight_line, + basis=flat.BezierFamily(6, T=Tf), # solve_ivp_kwargs={'method': 'RK45', 'atol': 1e-2, 'rtol': 1e-2}, - solve_ivp_kwargs={'atol': 1e-3, 'rtol': 1e-2}, - minimize_method='trust-constr', minimize_options={'disp': True}, + # solve_ivp_kwargs={'atol': 1e-3, 'rtol': 1e-2}, + # minimize_method='trust-constr', minimize_options={'disp': True}, log=False ) print("* Total time = %5g seconds\n" % (time.process_time() - start_time)) @@ -246,10 +271,15 @@ def plot_results(t, y, u, figure=None, yf=None): if 'PYCONTROL_TEST_EXAMPLES' in os.environ: assert result4.success -# Extract and plot the results (+ state trajectory) +# Plot the results from the optimization +plot_lanechange(timepts, result4.states, result4.inputs, xf, figure=4) +print("Final computed state: ", result3.states[:,-1]) + +# Simulate the system and see what happens t4, u4 = result4.time, result4.inputs -t4, y4 = ct.input_output_response(vehicle, horizon, u4, x0) -plot_results(t4, y4, u4, figure=4, yf=xf[0:2]) +t4, y4 = ct.input_output_response(vehicle, timepts, u4, x0) +plot_lanechange(t4, y4, u4, yf=xf[0:2], figure=4) +print("Final simulated state: ", y4[:,-1]) # If we are not running CI tests, display the results if 'PYCONTROL_TEST_EXAMPLES' not in os.environ: From e0f71f4c743cc60a8076546a187a96ad0dd2c55d Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 25 Nov 2022 22:01:45 -0800 Subject: [PATCH 098/157] update documentation + allow constraints in scipy.optimize form --- control/optimal.py | 97 +++++++++++++++++++++++------------ control/tests/optimal_test.py | 10 ++-- doc/optimal.rst | 12 ++--- 3 files changed, 74 insertions(+), 45 deletions(-) diff --git a/control/optimal.py b/control/optimal.py index 0478505bc..377a6972e 100644 --- a/control/optimal.py +++ b/control/optimal.py @@ -50,32 +50,33 @@ class OptimalControlProblem(): integral_cost : callable Function that returns the integral cost given the current state and input. Called as integral_cost(x, u). - trajectory_constraints : list of tuples, optional + trajectory_constraints : list of constraints, optional List of constraints that should hold at each point in the time - vector. Each element of the list should consist of a tuple with - first element given by :meth:`~scipy.optimize.LinearConstraint` or - :meth:`~scipy.optimize.NonlinearConstraint` and the remaining - elements of the tuple are the arguments that would be passed to - those functions. The constraints will be applied at each time - point along the trajectory. + vector. Each element of the list should be an object of type + :class:`~scipy.optimize.LinearConstraint` with arguments `(A, lb, + ub)` or :class:`~scipy.optimize.NonlinearConstraint` with arguments + `(fun, lb, ub)`. The constraints will be applied at each time point + along the trajectory. terminal_cost : callable, optional Function that returns the terminal cost given the current state and input. Called as terminal_cost(x, u). - initial_guess : 1D or 2D array_like - Initial inputs to use as a guess for the optimal input. The - inputs should either be a 2D vector of shape (ninputs, horizon) - or a 1D input of shape (ninputs,) that will be broadcast by - extension of the time axis. trajectory_method : string, optional Method to use for carrying out the optimization. Currently supported methods are 'shooting' and 'collocation' (continuous time only). The default value is 'shooting' for discrete time systems and 'collocation' for continuous time systems + initial_guess : (tuple of) 1D or 2D array_like + Initial states and/or inputs to use as a guess for the optimal + trajectory. For shooting methods, an array of inputs for each time + point should be specified. For collocation methods, the initial + guess is either the input vector or a tuple consisting guesses for + the state and the input. Guess should either be a 2D vector of + shape (ninputs, ntimepts) or a 1D input of shape (ninputs,) that + will be broadcast by extension of the time axis. log : bool, optional If `True`, turn on logging messages (using Python logging module). - Use ``logging.basicConfig`` to enable logging output (e.g., to a file). - kwargs : dict, optional - Additional parameters (passed to :func:`scipy.optimal.minimize`). + Use :py:func:`logging.basicConfig` to enable logging output + (e.g., to a file). Returns ------- @@ -83,11 +84,14 @@ class OptimalControlProblem(): Optimal control problem object, to be used in computing optimal controllers. - Additional parameters - --------------------- + Other Parameters + ---------------- basis : BasisFamily, optional Use the given set of basis functions for the inputs instead of setting the value of the input at each point in the timepts vector. + terminal_constraints : list of constraints, optional + List of constraints that should hold at the terminal point in time, + in the same form as `trajectory_constraints`. solve_ivp_method : str, optional Set the method used by :func:`scipy.integrate.solve_ivp`. solve_ivp_kwargs : str, optional @@ -182,20 +186,6 @@ def __init__( if kwargs: raise TypeError("unrecognized keyword(s): ", str(kwargs)) - # Process trajectory constraints - def _process_constraints(constraint_list, name): - if isinstance(constraint_list, tuple): - constraint_list = [constraint_list] - elif not isinstance(constraint_list, list): - raise TypeError(f"{name} constraints must be a list") - - # Make sure that we recognize all of the constraint types - for ctype, fun, lb, ub in constraint_list: - if not ctype in [opt.LinearConstraint, opt.NonlinearConstraint]: - raise TypeError(f"unknown {name} constraint type {ctype}") - - return constraint_list - self.trajectory_constraints = _process_constraints( trajectory_constraints, "trajectory") self.terminal_constraints = _process_constraints( @@ -1017,9 +1007,6 @@ def solve_ocp( If True, assume that 2D input arrays are transposed from the standard format. Used to convert MATLAB-style inputs to our format. - kwargs : dict, optional - Additional parameters (passed to :func:`scipy.optimal.minimize`). - Returns ------- res : OptimalControlResult @@ -1456,3 +1443,45 @@ def _evaluate_output_range_constraint(x, u): # Return a nonlinear constraint object based on the polynomial return (opt.NonlinearConstraint, _evaluate_output_range_constraint, lb, ub) + +# +# Utility functions +# + +# +# Process trajectory constraints +# +# Constraints were originally specified as a tuple with the type of +# constraint followed by the arguments. However, they are now specified +# directly as SciPy constraint objects. +# +# The _process_constraints() function will covert everything to a consistent +# internal representation (currently a tuple with the constraint type as the +# first element. +# +def _process_constraints(clist, name): + if isinstance( + clist, (tuple, opt.LinearConstraint, opt.NonlinearConstraint)): + clist = [clist] + elif not isinstance(clist, list): + raise TypeError(f"{name} constraints must be a list") + + # Process individual list elements + constraint_list = [] + for constraint in clist: + if isinstance(constraint, tuple): + # Original style of constraint + ctype, fun, lb, ub = constraint + if not ctype in [opt.LinearConstraint, opt.NonlinearConstraint]: + raise TypeError(f"unknown {name} constraint type {ctype}") + constraint_list.append(constraint) + elif isinstance(constraint, opt.LinearConstraint): + constraint_list.append( + (opt.LinearConstraint, constraint.A, + constraint.lb, constraint.ub)) + elif isinstance(constraint, opt.NonlinearConstraint): + constraint_list.append( + (opt.NonlinearConstraint, constraint.fun, + constraint.lb, constraint.ub)) + + return constraint_list diff --git a/control/tests/optimal_test.py b/control/tests/optimal_test.py index 4fc90464a..c76859dfc 100644 --- a/control/tests/optimal_test.py +++ b/control/tests/optimal_test.py @@ -64,7 +64,7 @@ def test_finite_horizon_simple(method): # State and input constraints constraints = [ - (sp.optimize.LinearConstraint, np.eye(3), [-5, -5, -1], [5, 5, 1]), + sp.optimize.LinearConstraint(np.eye(3), [-5, -5, -1], [5, 5, 1]), ] # Quadratic state and input penalty @@ -148,7 +148,7 @@ def test_discrete_lqr(): # Add state and input constraints trajectory_constraints = [ - (sp.optimize.LinearConstraint, np.eye(3), [-5, -5, -.5], [5, 5, 0.5]), + sp.optimize.LinearConstraint(np.eye(3), [-5, -5, -.5], [5, 5, 0.5]), ] # Re-solve @@ -461,7 +461,7 @@ def test_ocp_argument_errors(): # State and input constraints constraints = [ - (sp.optimize.LinearConstraint, np.eye(3), [-5, -5, -1], [5, 5, 1]), + sp.optimize.LinearConstraint(np.eye(3), [-5, -5, -1], [5, 5, 1]), ] # Quadratic state and input penalty @@ -522,7 +522,7 @@ def test_optimal_basis_simple(basis): # State and input constraints constraints = [ - (sp.optimize.LinearConstraint, np.eye(3), [-5, -5, -1], [5, 5, 1]), + sp.optimize.LinearConstraint(np.eye(3), [-5, -5, -1], [5, 5, 1]), ] # Quadratic state and input penalty @@ -594,7 +594,7 @@ def test_equality_constraints(): def final_point_eval(x, u): return x final_point = [ - (sp.optimize.NonlinearConstraint, final_point_eval, [0, 0], [0, 0])] + sp.optimize.NonlinearConstraint(final_point_eval, [0, 0], [0, 0])] optctrl = opt.OptimalControlProblem( sys, time, cost, terminal_constraints=final_point) diff --git a/doc/optimal.rst b/doc/optimal.rst index 00e39c24f..3033b38fc 100644 --- a/doc/optimal.rst +++ b/doc/optimal.rst @@ -125,16 +125,16 @@ parameter can be used to specify a cost function for the final point in the trajectory. The `constraints` parameter is a list of constraints similar to that used by -the :func:`scipy.optimize.minimize` function. Each constraint is a tuple of -one of the following forms:: +the :func:`scipy.optimize.minimize` function. Each constraint is specified +using one of the following forms:: - (LinearConstraint, A, lb, ub) - (NonlinearConstraint, f, lb, ub) + LinearConstraint(A, lb, ub) + NonlinearConstraint(f, lb, ub) For a linear constraint, the 2D array `A` is multiplied by a vector consisting of the current state `x` and current input `u` stacked -vertically, then compared with the upper and lower bound. This constrain is -satisfied if +vertically, then compared with the upper and lower bound. This constraint +is satisfied if .. code:: python From 172cb1a0f40afac5ade00f6eedc60f852d4cd75b Mon Sep 17 00:00:00 2001 From: Tadashi Date: Sat, 26 Nov 2022 23:29:37 +0100 Subject: [PATCH 099/157] Update optimal.rst Fixed typo --- doc/optimal.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/optimal.rst b/doc/optimal.rst index bb952e9cc..d161b77ad 100644 --- a/doc/optimal.rst +++ b/doc/optimal.rst @@ -283,7 +283,7 @@ If you are getting errors when solving optimal control problems or your solutions do not seem close to optimal, here are a few things to try: * Less is more: try using a smaller number of time points in your - optimiation. The default optimal control problem formulation uses the + optimization. The default optimal control problem formulation uses the value of the inputs at each time point as a free variable and this can generate a large number of parameters quickly. Often you can find very good solutions with a small number of free variables (the example above From dd5e42d16e81e5a1e30899f8bf277ee85282bf91 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 26 Nov 2022 17:08:45 -0800 Subject: [PATCH 100/157] add benchmarks for shooting vs collocation --- benchmarks/optimal_bench.py | 38 ++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/benchmarks/optimal_bench.py b/benchmarks/optimal_bench.py index a424c7827..997b5a241 100644 --- a/benchmarks/optimal_bench.py +++ b/benchmarks/optimal_bench.py @@ -35,6 +35,7 @@ 'COBYLA': ('COBYLA', {}), } + # Utility function to create a basis of a given size def get_basis(name, size, Tf): if name == 'poly': @@ -43,14 +44,13 @@ def get_basis(name, size, Tf): basis = fs.BezierFamily(size, T=Tf) elif name == 'bspline': basis = fs.BSplineFamily([0, Tf/2, Tf], size) + else: + basis = None return basis -# -# Optimal trajectory generation with linear quadratic cost -# - -def time_optimal_lq_basis(basis_name, basis_size, npoints): +# Assess performance as a function of basis type and size +def time_optimal_lq_basis(basis_name, basis_size, npoints, method): # Create a sufficiently controllable random system to control ntrys = 20 while ntrys > 0: @@ -90,18 +90,20 @@ def time_optimal_lq_basis(basis_name, basis_size, npoints): res = opt.solve_ocp( sys, timepts, x0, traj_cost, constraints, terminal_cost=term_cost, - basis=basis, + basis=basis, trajectory_method=method, ) # Only count this as a benchmark if we converged assert res.success # Parameterize the test against different choices of integrator and minimizer -time_optimal_lq_basis.param_names = ['basis', 'size', 'npoints'] +time_optimal_lq_basis.param_names = ['basis', 'size', 'npoints', 'method'] time_optimal_lq_basis.params = ( - ['poly', 'bezier', 'bspline'], [8, 10, 12], [5, 10, 20]) + [None, 'poly', 'bezier', 'bspline'], + [4, 8], [5, 10], ['shooting', 'collocation']) -def time_optimal_lq_methods(integrator_name, minimizer_name): +# Assess performance as a function of optimization and integration methods +def time_optimal_lq_methods(integrator_name, minimizer_name, method): # Get the integrator and minimizer parameters to use integrator = integrator_table[integrator_name] minimizer = minimizer_table[minimizer_name] @@ -134,17 +136,20 @@ def time_optimal_lq_methods(integrator_name, minimizer_name): sys, timepts, x0, traj_cost, constraints, terminal_cost=term_cost, solve_ivp_method=integrator[0], solve_ivp_kwargs=integrator[1], minimize_method=minimizer[0], minimize_options=minimizer[1], + trajectory_method=method, ) # Only count this as a benchmark if we converged assert res.success # Parameterize the test against different choices of integrator and minimizer -time_optimal_lq_methods.param_names = ['integrator', 'minimizer'] +time_optimal_lq_methods.param_names = ['integrator', 'minimizer', 'method'] time_optimal_lq_methods.params = ( - ['RK23', 'RK45', 'LSODA'], ['trust', 'SLSQP', 'COBYLA']) + ['RK23', 'RK45', 'LSODA'], ['trust', 'SLSQP', 'COBYLA'], + ['shooting', 'collocation']) -def time_optimal_lq_size(nstates, ninputs, npoints): +# Assess performance as a function system size +def time_optimal_lq_size(nstates, ninputs, npoints, method): # Create a sufficiently controllable random system to control ntrys = 20 while ntrys > 0: @@ -181,19 +186,18 @@ def time_optimal_lq_size(nstates, ninputs, npoints): res = opt.solve_ocp( sys, timepts, x0, traj_cost, constraints, terminal_cost=term_cost, + trajectory_method=method, ) # Only count this as a benchmark if we converged assert res.success # Parameterize the test against different choices of integrator and minimizer -time_optimal_lq_size.param_names = ['nstates', 'ninputs', 'npoints'] -time_optimal_lq_size.params = ([1, 2, 4], [1, 2, 4], [5, 10, 20]) +time_optimal_lq_size.param_names = ['nstates', 'ninputs', 'npoints', 'method'] +time_optimal_lq_size.params = ( + [2, 4], [2, 4], [10, 20], ['shooting', 'collocation']) -# # Aircraft MPC example (from multi-parametric toolbox) -# - def time_discrete_aircraft_mpc(minimizer_name): # model of an aircraft discretized with 0.2s sampling time # Source: https://www.mpt3.org/UI/RegulationProblem From 3a554b5070661d6b9b1554ee0b716afa30f9b390 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Wed, 30 Nov 2022 12:09:42 -0800 Subject: [PATCH 101/157] docstring fixes: standardize description for lyap and care to be consistent with other functions, fix formatting in a few other functions. add references to NonlinearIOSystem and __call__ and remove evalfr --- control/dtime.py | 13 +++++-------- control/iosys.py | 19 ++++++++----------- control/mateqn.py | 17 ++++++++++++----- control/sisotool.py | 8 ++++---- control/statesp.py | 2 +- doc/control.rst | 11 ++++++----- 6 files changed, 36 insertions(+), 34 deletions(-) diff --git a/control/dtime.py b/control/dtime.py index 9dddd86a3..18584fc6d 100644 --- a/control/dtime.py +++ b/control/dtime.py @@ -84,14 +84,6 @@ def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None, copy_names : bool, Optional If True, copy the names of the input signals, output signals, and states to the sampled system. - - Returns - ------- - sysd : linsys - Discrete time system, with sampling rate Ts - - Additional Parameters - --------------------- inputs : int, list of str or None, optional Description of the system inputs. If not specified, the origional system inputs are used. See :class:`NamedIOSystem` for more @@ -102,6 +94,11 @@ def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None, Description of the system states. Same format as `inputs`. Only available if the system is :class:`StateSpace`. + Returns + ------- + sysd : linsys + Discrete time system, with sampling rate Ts + Notes ----- See :meth:`StateSpace.sample` or :meth:`TransferFunction.sample` for diff --git a/control/iosys.py b/control/iosys.py index cbbd4cecc..132c074d3 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -2203,15 +2203,6 @@ def linearize(sys, xeq, ueq=None, t=0, params=None, **kw): copy_names : bool, Optional If True, Copy the names of the input signals, output signals, and states to the linearized system. - - Returns - ------- - ss_sys : LinearIOSystem - The linearization of the system, as a :class:`~control.LinearIOSystem` - object (which is also a :class:`~control.StateSpace` object. - - Additional Parameters - --------------------- inputs : int, list of str or None, optional Description of the system inputs. If not specified, the origional system inputs are used. See :class:`NamedIOSystem` for more @@ -2221,6 +2212,12 @@ def linearize(sys, xeq, ueq=None, t=0, params=None, **kw): states : int, list of str, or None, optional Description of the system states. Same format as `inputs`. + Returns + ------- + ss_sys : LinearIOSystem + The linearization of the system, as a :class:`~control.LinearIOSystem` + object (which is also a :class:`~control.StateSpace` object. + """ if not isinstance(sys, InputOutputSystem): raise TypeError("Can only linearize InputOutputSystem types") @@ -2716,8 +2713,8 @@ def interconnect(syslist, connections=None, inplist=None, outlist=None, :func:`~control.summing_block` function and the ability to automatically interconnect signals with the same names: - >>> P = control.tf2io(control.tf(1, [1, 0]), inputs='u', outputs='y') - >>> C = control.tf2io(control.tf(10, [1, 1]), inputs='e', outputs='u') + >>> P = control.tf(1, [1, 0], inputs='u', outputs='y') + >>> C = control.tf(10, [1, 1], inputs='e', outputs='u') >>> sumblk = control.summing_junction(inputs=['r', '-y'], output='e') >>> T = control.interconnect([P, C, sumblk], inputs='r', outputs='y') diff --git a/control/mateqn.py b/control/mateqn.py index 23ae1e64e..1cf2e65d9 100644 --- a/control/mateqn.py +++ b/control/mateqn.py @@ -88,7 +88,9 @@ def sb03md(n, C, A, U, dico, job='X', fact='N', trana='N', ldwork=None): def lyap(A, Q, C=None, E=None, method=None): - """X = lyap(A, Q) solves the continuous-time Lyapunov equation + """Solves the continuous-time Lyapunov equation + + X = lyap(A, Q) solves :math:`A X + X A^T + Q = 0` @@ -217,7 +219,9 @@ def lyap(A, Q, C=None, E=None, method=None): def dlyap(A, Q, C=None, E=None, method=None): - """dlyap(A, Q) solves the discrete-time Lyapunov equation + """Solves the discrete-time Lyapunov equation + + X = dlyap(A, Q) solves :math:`A X A^T - X + Q = 0` @@ -348,8 +352,9 @@ def dlyap(A, Q, C=None, E=None, method=None): def care(A, B, Q, R=None, S=None, E=None, stabilizing=True, method=None, A_s="A", B_s="B", Q_s="Q", R_s="R", S_s="S", E_s="E"): - """X, L, G = care(A, B, Q, R=None) solves the continuous-time - algebraic Riccati equation + """Solves the continuous-time algebraic Riccati equation + + X, L, G = care(A, B, Q, R=None) solves :math:`A^T X + X A - X B R^{-1} B^T X + Q = 0` @@ -505,9 +510,11 @@ def care(A, B, Q, R=None, S=None, E=None, stabilizing=True, method=None, def dare(A, B, Q, R, S=None, E=None, stabilizing=True, method=None, A_s="A", B_s="B", Q_s="Q", R_s="R", S_s="S", E_s="E"): - """X, L, G = dare(A, B, Q, R) solves the discrete-time algebraic Riccati + """Solves the discrete-time algebraic Riccati equation + X, L, G = dare(A, B, Q, R) solves + :math:`A^T X A - X - A^T X B (B^T X B + R)^{-1} B^T X A + Q = 0` where A and Q are square matrices of the same dimension. Further, Q diff --git a/control/sisotool.py b/control/sisotool.py index 781fabf40..598bdcf09 100644 --- a/control/sisotool.py +++ b/control/sisotool.py @@ -209,10 +209,6 @@ def rootlocus_pid_designer(plant, gain='P', sign=+1, input_signal='r', C_f = Kp + Ki/s + Kd*s/(tau*s + 1). - If `plant` is a discrete-time system, then the proportional, integral, and - derivative terms are given instead by Kp, Ki*dt/2*(z+1)/(z-1), and - Kd/dt*(z-1)/z, respectively. - :: ------> C_ff ------ d @@ -224,6 +220,10 @@ def rootlocus_pid_designer(plant, gain='P', sign=+1, input_signal='r', | ----- C_b <-------| --------------------------------- + If `plant` is a discrete-time system, then the proportional, integral, and + derivative terms are given instead by Kp, Ki*dt/2*(z+1)/(z-1), and + Kd/dt*(z-1)/z, respectively. + It is also possible to move the derivative term into the feedback path `C_b` using `derivative_in_feedback_path=True`. This may be desired to avoid that the plant is subject to an impulse function when the reference diff --git a/control/statesp.py b/control/statesp.py index 7843cb33f..47885ae3d 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -807,7 +807,7 @@ def __rdiv__(self, other): "StateSpace.__rdiv__ is not implemented yet.") def __call__(self, x, squeeze=None, warn_infinite=True): - """Evaluate system's transfer function at complex frequency. + """Evaluate system's frequency response at complex frequencies. Returns the complex frequency response `sys(x)` where `x` is `s` for continuous-time systems and `z` for discrete-time systems. diff --git a/doc/control.rst b/doc/control.rst index 172790f83..14d47af58 100644 --- a/doc/control.rst +++ b/doc/control.rst @@ -20,6 +20,8 @@ System creation frd rss drss + NonlinearIOSystem + System interconnections ======================= @@ -32,9 +34,8 @@ System interconnections negate parallel series + interconnect -See also the :ref:`iosys-module` module, which can be used to create and -interconnect nonlinear input/output systems. Frequency domain plotting ========================= @@ -78,8 +79,9 @@ Control system analysis dcgain describing_function - evalfr - freqresp + frequency_response + TransferFunction.__call__ + StateSpace.__call__ get_input_ff_index get_output_fb_index ispassive @@ -141,7 +143,6 @@ Nonlinear system support describing_function find_eqpt - interconnect linearize input_output_response ss2io From 11d935048c71560dc91a0b4eac33aca7e175b203 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Wed, 30 Nov 2022 15:01:32 -0800 Subject: [PATCH 102/157] a few more fixes to sys.sample --- control/dtime.py | 2 +- control/iosys.py | 2 +- control/statesp.py | 14 ++++++-------- control/xferfcn.py | 15 ++++++--------- 4 files changed, 14 insertions(+), 19 deletions(-) diff --git a/control/dtime.py b/control/dtime.py index 18584fc6d..e242923fb 100644 --- a/control/dtime.py +++ b/control/dtime.py @@ -86,7 +86,7 @@ def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None, signals, and states to the sampled system. inputs : int, list of str or None, optional Description of the system inputs. If not specified, the origional - system inputs are used. See :class:`NamedIOSystem` for more + system inputs are used. See :class:`InputOutputSystem` for more information. outputs : int, list of str or None, optional Description of the system outputs. Same format as `inputs`. diff --git a/control/iosys.py b/control/iosys.py index 132c074d3..4f9c674c0 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -2205,7 +2205,7 @@ def linearize(sys, xeq, ueq=None, t=0, params=None, **kw): states to the linearized system. inputs : int, list of str or None, optional Description of the system inputs. If not specified, the origional - system inputs are used. See :class:`NamedIOSystem` for more + system inputs are used. See :class:`InputOutputSystem` for more information. outputs : int, list of str or None, optional Description of the system outputs. Same format as `inputs`. diff --git a/control/statesp.py b/control/statesp.py index 47885ae3d..80b4601f2 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -1338,14 +1338,6 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None, copy_names : bool, Optional If True, copy the names of the input signals, output signals, and states to the sampled system. - - Returns - ------- - sysd : StateSpace - Discrete-time system, with sampling rate Ts - - Additional Parameters - --------------------- inputs : int, list of str or None, optional Description of the system inputs. If not specified, the origional system inputs are used. See :class:`InputOutputSystem` for more @@ -1355,6 +1347,11 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None, states : int, list of str, or None, optional Description of the system states. Same format as `inputs`. + Returns + ------- + sysd : StateSpace + Discrete-time system, with sampling rate Ts + Notes ----- Uses :func:`scipy.signal.cont2discrete` @@ -1520,6 +1517,7 @@ def output(self, t, x, u=None, params=None): # TODO: add discrete time check +# TODO: copy signal names def _convert_to_statespace(sys): """Convert a system to state space form (if needed). diff --git a/control/xferfcn.py b/control/xferfcn.py index 84188a63f..d7de8fedd 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -1130,21 +1130,18 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None, copy_names : bool, Optional If True, copy the names of the input signals, output signals, and states to the sampled system. - - Returns - ------- - sysd : TransferFunction system - Discrete-time system, with sample period Ts - - Additional Parameters - --------------------- inputs : int, list of str or None, optional Description of the system inputs. If not specified, the origional - system inputs are used. See :class:`NamedIOSystem` for more + system inputs are used. See :class:`InputOutputSystem` for more information. outputs : int, list of str or None, optional Description of the system outputs. Same format as `inputs`. + Returns + ------- + sysd : TransferFunction system + Discrete-time system, with sample period Ts + Notes ----- 1. Available only for SISO systems From bf50ded659ec285a69d95699aa9c4bfba482acc0 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Fri, 2 Dec 2022 09:28:34 -0800 Subject: [PATCH 103/157] mention lqe and dlqe in matlab module, reorder so that eg lqr and dlqr appear sequentially --- doc/control.rst | 6 ++++-- doc/matlab.rst | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/doc/control.rst b/doc/control.rst index 14d47af58..5ac5102f7 100644 --- a/doc/control.rst +++ b/doc/control.rst @@ -116,10 +116,10 @@ Control system synthesis acker create_statefbk_iosystem - dlqr h2syn hinfsyn lqr + dlqr mixsyn place rootlocus_pid_designer @@ -157,8 +157,8 @@ Stochastic system support correlation create_estimator_iosystem - dlqe lqe + dlqe white_noise .. _utility-and-conversions: @@ -194,3 +194,5 @@ Utility functions and conversions use_fbs_defaults use_matlab_defaults use_numpy_matrix + + diff --git a/doc/matlab.rst b/doc/matlab.rst index c14a67e1f..eac1d157a 100644 --- a/doc/matlab.rst +++ b/doc/matlab.rst @@ -86,6 +86,9 @@ Compensator design sisotool place lqr + dlqr + lqe + dlqe State-space (SS) models ======================= From 385405086bc0010e634c77b877240814c4bd11b7 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Wed, 7 Dec 2022 11:20:53 -0800 Subject: [PATCH 104/157] allow sisotool to receive kvect as a singleton rather than always an array --- control/sisotool.py | 4 ++++ control/tests/sisotool_test.py | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/control/sisotool.py b/control/sisotool.py index 781fabf40..7f5e2fc69 100644 --- a/control/sisotool.py +++ b/control/sisotool.py @@ -9,6 +9,7 @@ from .bdalg import append, connect from .iosys import tf2io, ss2io, summing_junction, interconnect from control.statesp import _convert_to_statespace, StateSpace +import numpy as np import matplotlib.pyplot as plt import warnings @@ -101,6 +102,9 @@ def sisotool(sys, kvect=None, xlim_rlocus=None, ylim_rlocus=None, 'margins': margins_bode } + # make sure kvect is an array + if kvect is not None and ~hasattr(kvect, '__len__'): + kvect = np.atleast_1d(kvect) # First time call to setup the bode and step response plots _SisotoolUpdate(sys, fig, 1 if kvect is None else kvect[0], bode_plot_params) diff --git a/control/tests/sisotool_test.py b/control/tests/sisotool_test.py index a1f468eea..beb7ee098 100644 --- a/control/tests/sisotool_test.py +++ b/control/tests/sisotool_test.py @@ -136,6 +136,16 @@ def test_sisotool_tvect(self, tsys): bode_plot_params=dict(), tvect=tvect) assert_array_almost_equal(tvect, ax_step.lines[0].get_data()[0]) + @pytest.mark.skipif(plt.get_current_fig_manager().toolbar is None, + reason="Requires the zoom toolbar") + def test_sisotool_kvect(self, tsys): + # test supply kvect + kvect = np.linspace(0, 1, 10) + # check if it can receive an array + sisotool(tsys, kvect=kvect) + # check if it can receive a singleton + sisotool(tsys, kvect=1) + def test_sisotool_mimo(self, sys222, sys221): # a 2x2 should not raise an error: From 6ca56e949a8acfc2ccc532d9445e9c829d8c3d96 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller <58706249+sawyerbfuller@users.noreply.github.com> Date: Wed, 7 Dec 2022 16:05:12 -0800 Subject: [PATCH 105/157] Update README.rst In response to #792 : * Added citation information and article link to readme * Mention nonlinear, hinfsyn, and interconnect functionality in readme --- README.rst | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/README.rst b/README.rst index 7e2058293..58dd901de 100644 --- a/README.rst +++ b/README.rst @@ -31,20 +31,17 @@ Try out the examples in the examples folder using the binder service. :target: https://mybinder.org/v2/gh/python-control/python-control/HEAD - - - Features -------- - Linear input/output systems in state-space and frequency domain -- Block diagram algebra: serial, parallel, and feedback interconnections +- Block diagram algebra: serial, parallel, feedback, and other interconnections - Time response: initial, step, impulse -- Frequency response: Bode and Nyquist plots -- Control analysis: stability, reachability, observability, stability margins -- Control design: eigenvalue placement, linear quadratic regulator +- Frequency response: Bode, Nyquist, and Nichols plots +- Control analysis: stability, reachability, observability, stability margins, root locus +- Control design: eigenvalue placement, linear quadratic regulator, sisotool, hinfsyn - Estimator design: linear quadratic estimator (Kalman filter) - +- Nonlinear systems: optimization-based control, describing functions, differential flatness Links ===== @@ -105,6 +102,22 @@ from the github repository or archive, unpack, and run from within the toplevel `python-control` directory:: pip install . + +Article and Citation Information +================================ + +A `2021 article `_ about the library is available on IEEE Explore. If the Python Control Systems Library helped you in your research, please cite:: + + @inproceedings{python-control2021, + title={The python control systems library (python-control)}, + author={Fuller, Sawyer and Greiner, Ben and Moore, Jason and Murray, Richard and van Paassen, Ren{\'e} and Yorke, Rory}, + booktitle={2021 60th IEEE Conference on Decision and Control (CDC)}, + pages={4875--4881}, + year={2021}, + organization={IEEE} + } + +or the GitHub site: https://github.com/python-control/python-control Development From feb901033f095038054f4869fdb7bf14ae225d3b Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Fri, 9 Dec 2022 10:03:18 -0800 Subject: [PATCH 106/157] improved docstring for kvect in sisotool --- control/matlab/wrappers.py | 2 +- control/sisotool.py | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/control/matlab/wrappers.py b/control/matlab/wrappers.py index 8eafdaad2..4f9d97e31 100644 --- a/control/matlab/wrappers.py +++ b/control/matlab/wrappers.py @@ -117,7 +117,7 @@ def nyquist(*args, **kwargs): def _parse_freqplot_args(*args): """Parse arguments to frequency plot routines (bode, nyquist)""" syslist, plotstyle, omega, other = [], [], None, {} - i = 0; + i = 0 while i < len(args): # Check to see if this is a system of some sort if issys(args[i]): diff --git a/control/sisotool.py b/control/sisotool.py index 7f5e2fc69..d4d4b9d68 100644 --- a/control/sisotool.py +++ b/control/sisotool.py @@ -37,8 +37,13 @@ def sisotool(sys, kvect=None, xlim_rlocus=None, ylim_rlocus=None, the step response. This allows you to see the step responses of more complex systems, for example, systems with a feedforward path into the plant or in which the gain appears in the feedback path. - kvect : list or ndarray, optional - List of gains to use for plotting root locus + kvect : float or array_like, optional + List of gains to use for plotting root locus. If only one value is + provided, the set of gains in the root locus plot is calculated + automatically, and kvect is interpreted as if it was the value of + the gain associated with the first mouse click on the root locus + plot. This is useful if it is not possible to use interactive + plotting. xlim_rlocus : tuple or list, optional control of x-axis range, normally with tuple (see :doc:`matplotlib:api/axes_api`). From e808adb08d6d85d24584d807e9d9a3e574068090 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Fri, 9 Dec 2022 11:25:33 -0800 Subject: [PATCH 107/157] docstring improvements, pep8 cleanup, more descriptive names for internal variables --- control/rlocus.py | 69 ++++++++++++++++++++++----------------------- control/sisotool.py | 23 +++++++++------ 2 files changed, 48 insertions(+), 44 deletions(-) diff --git a/control/rlocus.py b/control/rlocus.py index 9d531de94..facb9251a 100644 --- a/control/rlocus.py +++ b/control/rlocus.py @@ -88,7 +88,7 @@ def root_locus(sys, kvect=None, xlim=None, ylim=None, ---------- sys : LTI object Linear input/output systems (SISO only, for now). - kvect : list or ndarray, optional + kvect : float or array_like, optional List of gains to use in computing diagram. xlim : tuple or list, optional Set limits of x axis, normally with tuple @@ -110,10 +110,11 @@ def root_locus(sys, kvect=None, xlim=None, ylim=None, Returns ------- - rlist : ndarray - Computed root locations, given as a 2D array - klist : ndarray or list - Gains used. Same as klist keyword argument if provided. + roots : ndarray + Closed-loop root locations, arranged in which each row corresponds + to a gain in gains + gains : ndarray + Gains used. Same as kvect keyword argument if provided. Notes ----- @@ -145,10 +146,12 @@ def root_locus(sys, kvect=None, xlim=None, ylim=None, print_gain = config._get_param( 'rlocus', 'print_gain', print_gain, _rlocus_defaults) - sys_loop = sys if sys.issiso() else sys[0, 0] + if not sys.issiso(): + raise ControlMIMONotImplemented( + 'sys must be single-input single-output (SISO)') # Convert numerator and denominator to polynomials if they aren't - (nump, denp) = _systopoly1d(sys_loop) + (nump, denp) = _systopoly1d(sys) # if discrete-time system and if xlim and ylim are not given, # that we a view of the unit circle @@ -158,12 +161,13 @@ def root_locus(sys, kvect=None, xlim=None, ylim=None, xlim = (-1.3, 1.3) if kvect is None: - start_mat = _RLFindRoots(nump, denp, [1]) - kvect, mymat, xlim, ylim = _default_gains(nump, denp, xlim, ylim) + start_roots = _RLFindRoots(nump, denp, 1) + kvect, root_array, xlim, ylim = _default_gains(nump, denp, xlim, ylim) else: - start_mat = _RLFindRoots(nump, denp, [kvect[0]]) - mymat = _RLFindRoots(nump, denp, kvect) - mymat = _RLSortRoots(mymat) + kvect = np.atleast_1d(kvect) + start_roots = _RLFindRoots(nump, denp, kvect[0]) + root_array = _RLFindRoots(nump, denp, kvect) + root_array = _RLSortRoots(root_array) # Check for sisotool mode sisotool = False if 'sisotool' not in kwargs else True @@ -190,10 +194,10 @@ def root_locus(sys, kvect=None, xlim=None, ylim=None, ax_rlocus=fig.axes[0], plotstr=plotstr)) elif sisotool: fig.axes[1].plot( - [root.real for root in start_mat], - [root.imag for root in start_mat], + [root.real for root in start_roots], + [root.imag for root in start_roots], marker='s', markersize=6, zorder=20, color='k', label='gain_point') - s = start_mat[0][0] + s = start_roots[0][0] if isdtime(sys, strict=True): zeta = -np.cos(np.angle(np.log(s))) else: @@ -229,7 +233,7 @@ def root_locus(sys, kvect=None, xlim=None, ylim=None, ax.plot(real(zeros), imag(zeros), 'o') # Now plot the loci - for index, col in enumerate(mymat.T): + for index, col in enumerate(root_array.T): ax.plot(real(col), imag(col), plotstr, label='rootlocus') # Set up plot axes and labels @@ -257,7 +261,7 @@ def root_locus(sys, kvect=None, xlim=None, ylim=None, (0, 0), radius=1.0, linestyle=':', edgecolor='k', linewidth=0.75, fill=False, zorder=-20)) - return mymat, kvect + return root_array, kvect def _default_gains(num, den, xlim, ylim, zoom_xlim=None, zoom_ylim=None): @@ -509,28 +513,27 @@ def _RLFindRoots(nump, denp, kvect): """Find the roots for the root locus.""" # Convert numerator and denominator to polynomials if they aren't roots = [] - for k in np.array(kvect, ndmin=1): + for k in np.atleast_1d(kvect): curpoly = denp + k * nump curroots = curpoly.r if len(curroots) < denp.order: # if I have fewer poles than open loop, it is because i have # one at infinity - curroots = np.insert(curroots, len(curroots), np.inf) + curroots = np.append(curroots, np.inf) curroots.sort() roots.append(curroots) - mymat = row_stack(roots) - return mymat + return row_stack(roots) -def _RLSortRoots(mymat): - """Sort the roots from sys._RLFindRoots, so that the root +def _RLSortRoots(roots): + """Sort the roots from _RLFindRoots, so that the root locus doesn't show weird pseudo-branches as roots jump from one branch to another.""" - sorted = zeros_like(mymat) - for n, row in enumerate(mymat): + sorted = zeros_like(roots) + for n, row in enumerate(roots): if n == 0: sorted[n, :] = row else: @@ -539,7 +542,7 @@ def _RLSortRoots(mymat): # previous row available = list(range(len(prevrow))) for elem in row: - evect = elem-prevrow[available] + evect = elem - prevrow[available] ind1 = abs(evect).argmin() ind = available.pop(ind1) sorted[n, ind] = elem @@ -549,9 +552,7 @@ def _RLSortRoots(mymat): def _RLZoomDispatcher(event, sys, ax_rlocus, plotstr): """Rootlocus plot zoom dispatcher""" - sys_loop = sys if sys.issiso() else sys[0,0] - - nump, denp = _systopoly1d(sys_loop) + nump, denp = _systopoly1d(sys) xlim, ylim = ax_rlocus.get_xlim(), ax_rlocus.get_ylim() kvect, mymat, xlim, ylim = _default_gains( @@ -583,9 +584,7 @@ def _RLClickDispatcher(event, sys, fig, ax_rlocus, plotstr, sisotool=False, def _RLFeedbackClicksPoint(event, sys, fig, ax_rlocus, sisotool=False): """Display root-locus gain feedback point for clicks on root-locus plot""" - sys_loop = sys if sys.issiso() else sys[0,0] - - (nump, denp) = _systopoly1d(sys_loop) + (nump, denp) = _systopoly1d(sys) xlim = ax_rlocus.get_xlim() ylim = ax_rlocus.get_ylim() @@ -596,10 +595,10 @@ def _RLFeedbackClicksPoint(event, sys, fig, ax_rlocus, sisotool=False): # Catch type error when event click is in the figure but not in an axis try: s = complex(event.xdata, event.ydata) - K = -1. / sys_loop(s) - K_xlim = -1. / sys_loop( + K = -1. / sys(s) + K_xlim = -1. / sys( complex(event.xdata + 0.05 * abs(xlim[1] - xlim[0]), event.ydata)) - K_ylim = -1. / sys_loop( + K_ylim = -1. / sys( complex(event.xdata, event.ydata + 0.05 * abs(ylim[1] - ylim[0]))) except TypeError: diff --git a/control/sisotool.py b/control/sisotool.py index d4d4b9d68..018514d31 100644 --- a/control/sisotool.py +++ b/control/sisotool.py @@ -29,14 +29,19 @@ def sisotool(sys, kvect=None, xlim_rlocus=None, ylim_rlocus=None, sys : LTI object Linear input/output systems. If sys is SISO, use the same system for the root locus and step response. If it is desired to - see a different step response than feedback(K*loop,1), sys can be - provided as a two-input, two-output system (e.g. by using - :func:`bdgalg.connect' or :func:`iosys.interconnect`). Sisotool - inserts the negative of the selected gain K between the first output - and first input and uses the second input and output for computing - the step response. This allows you to see the step responses of more - complex systems, for example, systems with a feedforward path into the - plant or in which the gain appears in the feedback path. + see a different step response than feedback(K*sys,1), such as a + disturbance response, sys can be provided as a two-input, two-output + system (e.g. by using :func:`bdgalg.connect' or + :func:`iosys.interconnect`). For two-input, two-output + system, sisotool inserts the negative of the selected gain K between + the first output and first input and uses the second input and output + for computing the step response. To see the disturbance response, + configure your plant to have as its second input the disturbance input. + To view the step response with a feedforward controller, give your + plant two identical inputs, and sum your feedback controller and your + feedforward controller and multiply them into your plant's second + input. It is also possible to accomodate a system with a gain in the + feedback. kvect : float or array_like, optional List of gains to use for plotting root locus. If only one value is provided, the set of gains in the root locus plot is calculated @@ -115,7 +120,7 @@ def sisotool(sys, kvect=None, xlim_rlocus=None, ylim_rlocus=None, 1 if kvect is None else kvect[0], bode_plot_params) # Setup the root-locus plot window - root_locus(sys, kvect=kvect, xlim=xlim_rlocus, + root_locus(sys[0,0], kvect=kvect, xlim=xlim_rlocus, ylim=ylim_rlocus, plotstr=plotstr_rlocus, grid=rlocus_grid, fig=fig, bode_plot_params=bode_plot_params, tvect=tvect, sisotool=True) From 4107950d57e9722fd66eae32ecd0880c48aa3090 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Fri, 9 Dec 2022 14:50:53 -0800 Subject: [PATCH 108/157] introduce new keyword for sisotool: initial_gain, which denotes gain that the plots start with. Deprecate kvect kwarg with warning. rlocus now doesn't recomupte kvect if it is passed --- control/config.py | 3 ++ control/rlocus.py | 60 ++++++++++++++++++++-------------- control/sisotool.py | 35 +++++++++++--------- control/tests/sisotool_test.py | 13 +++----- 4 files changed, 63 insertions(+), 48 deletions(-) diff --git a/control/config.py b/control/config.py index ccee252fc..37763a6b8 100644 --- a/control/config.py +++ b/control/config.py @@ -97,6 +97,9 @@ def reset_defaults(): from .rlocus import _rlocus_defaults defaults.update(_rlocus_defaults) + from .sisotool import _sisotool_defaults + defaults.update(_sisotool_defaults) + from .namedio import _namedio_defaults defaults.update(_namedio_defaults) diff --git a/control/rlocus.py b/control/rlocus.py index facb9251a..c6ed717e2 100644 --- a/control/rlocus.py +++ b/control/rlocus.py @@ -61,6 +61,7 @@ from .sisotool import _SisotoolUpdate from .grid import sgrid, zgrid from . import config +import warnings __all__ = ['root_locus', 'rlocus'] @@ -76,7 +77,7 @@ # Main function: compute a root locus diagram def root_locus(sys, kvect=None, xlim=None, ylim=None, plotstr=None, plot=True, print_gain=None, grid=None, ax=None, - **kwargs): + initial_gain=None, **kwargs): """Root locus plot @@ -88,8 +89,8 @@ def root_locus(sys, kvect=None, xlim=None, ylim=None, ---------- sys : LTI object Linear input/output systems (SISO only, for now). - kvect : float or array_like, optional - List of gains to use in computing diagram. + kvect : array_like, optional + Gains to use in computing plot of closed-loop poles. xlim : tuple or list, optional Set limits of x axis, normally with tuple (see :doc:`matplotlib:api/axes_api`). @@ -107,6 +108,8 @@ def root_locus(sys, kvect=None, xlim=None, ylim=None, If True plot omega-damping grid. Default is False. ax : :class:`matplotlib.axes.Axes` Axes on which to create root locus plot + initial_gain : float, optional + Used by :func:`sisotool` to indicate initial gain. Returns ------- @@ -126,7 +129,6 @@ def root_locus(sys, kvect=None, xlim=None, ylim=None, """ # Check to see if legacy 'Plot' keyword was used if 'Plot' in kwargs: - import warnings warnings.warn("'Plot' keyword is deprecated in root_locus; " "use 'plot'", FutureWarning) # Map 'Plot' keyword to 'plot' keyword @@ -134,7 +136,6 @@ def root_locus(sys, kvect=None, xlim=None, ylim=None, # Check to see if legacy 'PrintGain' keyword was used if 'PrintGain' in kwargs: - import warnings warnings.warn("'PrintGain' keyword is deprecated in root_locus; " "use 'print_gain'", FutureWarning) # Map 'PrintGain' keyword to 'print_gain' keyword @@ -146,12 +147,17 @@ def root_locus(sys, kvect=None, xlim=None, ylim=None, print_gain = config._get_param( 'rlocus', 'print_gain', print_gain, _rlocus_defaults) - if not sys.issiso(): + # Check for sisotool mode + sisotool = kwargs.get('sisotool', False) + + # make sure siso. sisotool has different requirements + if not sys.issiso() and not sisotool: raise ControlMIMONotImplemented( 'sys must be single-input single-output (SISO)') + sys_loop = sys[0,0] # Convert numerator and denominator to polynomials if they aren't - (nump, denp) = _systopoly1d(sys) + (nump, denp) = _systopoly1d(sys_loop) # if discrete-time system and if xlim and ylim are not given, # that we a view of the unit circle @@ -161,16 +167,16 @@ def root_locus(sys, kvect=None, xlim=None, ylim=None, xlim = (-1.3, 1.3) if kvect is None: - start_roots = _RLFindRoots(nump, denp, 1) kvect, root_array, xlim, ylim = _default_gains(nump, denp, xlim, ylim) + recompute_on_zoom = True else: kvect = np.atleast_1d(kvect) - start_roots = _RLFindRoots(nump, denp, kvect[0]) root_array = _RLFindRoots(nump, denp, kvect) root_array = _RLSortRoots(root_array) + recompute_on_zoom = False - # Check for sisotool mode - sisotool = False if 'sisotool' not in kwargs else True + if sisotool: + start_roots = _RLFindRoots(nump, denp, initial_gain) # Make sure there were no extraneous keywords if not sisotool and kwargs: @@ -204,7 +210,7 @@ def root_locus(sys, kvect=None, xlim=None, ylim=None, zeta = -1 * s.real / abs(s) fig.suptitle( "Clicked at: %10.4g%+10.4gj gain: %10.4g damp: %10.4g" % - (s.real, s.imag, kvect[0], zeta), + (s.real, s.imag, initial_gain, zeta), fontsize=12 if int(mpl.__version__[0]) == 1 else 10) fig.canvas.mpl_connect( 'button_release_event', @@ -214,14 +220,16 @@ def root_locus(sys, kvect=None, xlim=None, ylim=None, bode_plot_params=kwargs['bode_plot_params'], tvect=kwargs['tvect'])) - # zoom update on xlim/ylim changed, only then data on new limits - # is available, i.e., cannot combine with _RLClickDispatcher - dpfun = partial( - _RLZoomDispatcher, sys=sys, ax_rlocus=ax, plotstr=plotstr) - # TODO: the next too lines seem to take a long time to execute - # TODO: is there a way to speed them up? (RMM, 6 Jun 2019) - ax.callbacks.connect('xlim_changed', dpfun) - ax.callbacks.connect('ylim_changed', dpfun) + + if recompute_on_zoom: + # update gains and roots when xlim/ylim change. Only then are + # data on available. I.e., cannot combine with _RLClickDispatcher + dpfun = partial( + _RLZoomDispatcher, sys=sys, ax_rlocus=ax, plotstr=plotstr) + # TODO: the next too lines seem to take a long time to execute + # TODO: is there a way to speed them up? (RMM, 6 Jun 2019) + ax.callbacks.connect('xlim_changed', dpfun) + ax.callbacks.connect('ylim_changed', dpfun) # plot open loop poles poles = array(denp.r) @@ -552,7 +560,8 @@ def _RLSortRoots(roots): def _RLZoomDispatcher(event, sys, ax_rlocus, plotstr): """Rootlocus plot zoom dispatcher""" - nump, denp = _systopoly1d(sys) + sys_loop = sys[0,0] + nump, denp = _systopoly1d(sys_loop) xlim, ylim = ax_rlocus.get_xlim(), ax_rlocus.get_ylim() kvect, mymat, xlim, ylim = _default_gains( @@ -584,7 +593,8 @@ def _RLClickDispatcher(event, sys, fig, ax_rlocus, plotstr, sisotool=False, def _RLFeedbackClicksPoint(event, sys, fig, ax_rlocus, sisotool=False): """Display root-locus gain feedback point for clicks on root-locus plot""" - (nump, denp) = _systopoly1d(sys) + sys_loop = sys[0,0] + (nump, denp) = _systopoly1d(sys_loop) xlim = ax_rlocus.get_xlim() ylim = ax_rlocus.get_ylim() @@ -595,10 +605,10 @@ def _RLFeedbackClicksPoint(event, sys, fig, ax_rlocus, sisotool=False): # Catch type error when event click is in the figure but not in an axis try: s = complex(event.xdata, event.ydata) - K = -1. / sys(s) - K_xlim = -1. / sys( + K = -1. / sys_loop(s) + K_xlim = -1. / sys_loop( complex(event.xdata + 0.05 * abs(xlim[1] - xlim[0]), event.ydata)) - K_ylim = -1. / sys( + K_ylim = -1. / sys_loop( complex(event.xdata, event.ydata + 0.05 * abs(ylim[1] - ylim[0]))) except TypeError: diff --git a/control/sisotool.py b/control/sisotool.py index 018514d31..ae2497b66 100644 --- a/control/sisotool.py +++ b/control/sisotool.py @@ -9,14 +9,19 @@ from .bdalg import append, connect from .iosys import tf2io, ss2io, summing_junction, interconnect from control.statesp import _convert_to_statespace, StateSpace +from . import config import numpy as np import matplotlib.pyplot as plt import warnings -def sisotool(sys, kvect=None, xlim_rlocus=None, ylim_rlocus=None, +_sisotool_defaults = { + 'sisotool.initial_gain': 1 +} + +def sisotool(sys, initial_gain=None, xlim_rlocus=None, ylim_rlocus=None, plotstr_rlocus='C0', rlocus_grid=False, omega=None, dB=None, Hz=None, deg=None, omega_limits=None, omega_num=None, - margins_bode=True, tvect=None): + margins_bode=True, tvect=None, kvect=None): """ Sisotool style collection of plots inspired by MATLAB's sisotool. The left two plots contain the bode magnitude and phase diagrams. @@ -42,13 +47,9 @@ def sisotool(sys, kvect=None, xlim_rlocus=None, ylim_rlocus=None, feedforward controller and multiply them into your plant's second input. It is also possible to accomodate a system with a gain in the feedback. - kvect : float or array_like, optional - List of gains to use for plotting root locus. If only one value is - provided, the set of gains in the root locus plot is calculated - automatically, and kvect is interpreted as if it was the value of - the gain associated with the first mouse click on the root locus - plot. This is useful if it is not possible to use interactive - plotting. + initial_gain : float, optional + Initial gain to use for plotting root locus. Defaults to 1 + (config.defaults['sisotool.initial_gain']). xlim_rlocus : tuple or list, optional control of x-axis range, normally with tuple (see :doc:`matplotlib:api/axes_api`). @@ -112,15 +113,19 @@ def sisotool(sys, kvect=None, xlim_rlocus=None, ylim_rlocus=None, 'margins': margins_bode } - # make sure kvect is an array - if kvect is not None and ~hasattr(kvect, '__len__'): - kvect = np.atleast_1d(kvect) + # Check to see if legacy 'PrintGain' keyword was used + if kvect is not None: + warnings.warn("'kvect' keyword is deprecated in sisotool; " + "use 'initial_gain' instead", FutureWarning) + initial_gain = np.atleast1d(kvect)[0] + initial_gain = config._get_param('sisotool', 'initial_gain', + initial_gain, _sisotool_defaults) + # First time call to setup the bode and step response plots - _SisotoolUpdate(sys, fig, - 1 if kvect is None else kvect[0], bode_plot_params) + _SisotoolUpdate(sys, fig, initial_gain, bode_plot_params) # Setup the root-locus plot window - root_locus(sys[0,0], kvect=kvect, xlim=xlim_rlocus, + root_locus(sys, initial_gain=initial_gain, xlim=xlim_rlocus, ylim=ylim_rlocus, plotstr=plotstr_rlocus, grid=rlocus_grid, fig=fig, bode_plot_params=bode_plot_params, tvect=tvect, sisotool=True) diff --git a/control/tests/sisotool_test.py b/control/tests/sisotool_test.py index beb7ee098..d4a291052 100644 --- a/control/tests/sisotool_test.py +++ b/control/tests/sisotool_test.py @@ -138,14 +138,11 @@ def test_sisotool_tvect(self, tsys): @pytest.mark.skipif(plt.get_current_fig_manager().toolbar is None, reason="Requires the zoom toolbar") - def test_sisotool_kvect(self, tsys): - # test supply kvect - kvect = np.linspace(0, 1, 10) - # check if it can receive an array - sisotool(tsys, kvect=kvect) - # check if it can receive a singleton - sisotool(tsys, kvect=1) - + def test_sisotool_initial_gain(self, tsys): + sisotool(tsys, initial_gain=1.2) + # kvect keyword should give deprecation warning + with pytest.warns(FutureWarning): + sisotool(tsys, kvect=1.2) def test_sisotool_mimo(self, sys222, sys221): # a 2x2 should not raise an error: From eadd496bf5810273b89cd7315663c36ebd9e8ace Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Fri, 9 Dec 2022 14:55:56 -0800 Subject: [PATCH 109/157] test to make sure kvect gains are respected even if plotting --- control/tests/rlocus_test.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/control/tests/rlocus_test.py b/control/tests/rlocus_test.py index 4fbe70c4f..a25928e27 100644 --- a/control/tests/rlocus_test.py +++ b/control/tests/rlocus_test.py @@ -54,6 +54,12 @@ def testRootLocus(self, sys): np.testing.assert_allclose(klist, k_out) self.check_cl_poles(sys, roots, klist) + # now check with plotting + roots, k_out = root_locus(sys, klist) + np.testing.assert_equal(len(roots), len(klist)) + np.testing.assert_allclose(klist, k_out) + self.check_cl_poles(sys, roots, klist) + def test_without_gains(self, sys): roots, kvect = root_locus(sys, plot=False) self.check_cl_poles(sys, roots, kvect) From 15543ffe9d00cdc734dc7398f5f765444b1226fa Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Fri, 9 Dec 2022 15:02:11 -0800 Subject: [PATCH 110/157] rename mymat to root_array for clarity --- control/rlocus.py | 72 ++++++++++++++++++++++----------------------- control/sisotool.py | 2 +- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/control/rlocus.py b/control/rlocus.py index c6ed717e2..53c5c9031 100644 --- a/control/rlocus.py +++ b/control/rlocus.py @@ -286,8 +286,8 @@ def _default_gains(num, den, xlim, ylim, zoom_xlim=None, zoom_ylim=None): kvect = np.hstack((np.linspace(0, kmax, 50), np.real(k_break))) kvect.sort() - mymat = _RLFindRoots(num, den, kvect) - mymat = _RLSortRoots(mymat) + root_array = _RLFindRoots(num, den, kvect) + root_array = _RLSortRoots(root_array) open_loop_poles = den.roots open_loop_zeros = num.roots @@ -297,13 +297,13 @@ def _default_gains(num, den, xlim, ylim, zoom_xlim=None, zoom_ylim=None): open_loop_zeros, np.ones(open_loop_poles.size - open_loop_zeros.size) * open_loop_zeros[-1]) - mymat_xl = np.append(mymat, open_loop_zeros_xl) + root_array_xl = np.append(root_array, open_loop_zeros_xl) else: - mymat_xl = mymat + root_array_xl = root_array singular_points = np.concatenate((num.roots, den.roots), axis=0) important_points = np.concatenate((singular_points, real_break), axis=0) important_points = np.concatenate((important_points, np.zeros(2)), axis=0) - mymat_xl = np.append(mymat_xl, important_points) + root_array_xl = np.append(root_array_xl, important_points) false_gain = float(den.coeffs[0]) / float(num.coeffs[0]) if false_gain < 0 and not den.order > num.order: @@ -312,27 +312,27 @@ def _default_gains(num, den, xlim, ylim, zoom_xlim=None, zoom_ylim=None): "with equal order of numerator and denominator.") if xlim is None and false_gain > 0: - x_tolerance = 0.05 * (np.max(np.real(mymat_xl)) - - np.min(np.real(mymat_xl))) - xlim = _ax_lim(mymat_xl) + x_tolerance = 0.05 * (np.max(np.real(root_array_xl)) + - np.min(np.real(root_array_xl))) + xlim = _ax_lim(root_array_xl) elif xlim is None and false_gain < 0: axmin = np.min(np.real(important_points)) \ - (np.max(np.real(important_points)) - np.min(np.real(important_points))) - axmin = np.min(np.array([axmin, np.min(np.real(mymat_xl))])) + axmin = np.min(np.array([axmin, np.min(np.real(root_array_xl))])) axmax = np.max(np.real(important_points)) \ + np.max(np.real(important_points)) \ - np.min(np.real(important_points)) - axmax = np.max(np.array([axmax, np.max(np.real(mymat_xl))])) + axmax = np.max(np.array([axmax, np.max(np.real(root_array_xl))])) xlim = [axmin, axmax] x_tolerance = 0.05 * (axmax - axmin) else: x_tolerance = 0.05 * (xlim[1] - xlim[0]) if ylim is None: - y_tolerance = 0.05 * (np.max(np.imag(mymat_xl)) - - np.min(np.imag(mymat_xl))) - ylim = _ax_lim(mymat_xl * 1j) + y_tolerance = 0.05 * (np.max(np.imag(root_array_xl)) + - np.min(np.imag(root_array_xl))) + ylim = _ax_lim(root_array_xl * 1j) else: y_tolerance = 0.05 * (ylim[1] - ylim[0]) @@ -345,7 +345,7 @@ def _default_gains(num, den, xlim, ylim, zoom_xlim=None, zoom_ylim=None): tolerance = x_tolerance else: tolerance = np.min([x_tolerance, y_tolerance]) - indexes_too_far = _indexes_filt(mymat, tolerance, zoom_xlim, zoom_ylim) + indexes_too_far = _indexes_filt(root_array, tolerance, zoom_xlim, zoom_ylim) # Add more points into the root locus for points that are too far apart while len(indexes_too_far) > 0 and kvect.size < 5000: @@ -354,27 +354,27 @@ def _default_gains(num, den, xlim, ylim, zoom_xlim=None, zoom_ylim=None): new_gains = np.linspace(kvect[index], kvect[index + 1], 5) new_points = _RLFindRoots(num, den, new_gains[1:4]) kvect = np.insert(kvect, index + 1, new_gains[1:4]) - mymat = np.insert(mymat, index + 1, new_points, axis=0) + root_array = np.insert(root_array, index + 1, new_points, axis=0) - mymat = _RLSortRoots(mymat) - indexes_too_far = _indexes_filt(mymat, tolerance, zoom_xlim, zoom_ylim) + root_array = _RLSortRoots(root_array) + indexes_too_far = _indexes_filt(root_array, tolerance, zoom_xlim, zoom_ylim) new_gains = kvect[-1] * np.hstack((np.logspace(0, 3, 4))) new_points = _RLFindRoots(num, den, new_gains[1:4]) kvect = np.append(kvect, new_gains[1:4]) - mymat = np.concatenate((mymat, new_points), axis=0) - mymat = _RLSortRoots(mymat) - return kvect, mymat, xlim, ylim + root_array = np.concatenate((root_array, new_points), axis=0) + root_array = _RLSortRoots(root_array) + return kvect, root_array, xlim, ylim -def _indexes_filt(mymat, tolerance, zoom_xlim=None, zoom_ylim=None): +def _indexes_filt(root_array, tolerance, zoom_xlim=None, zoom_ylim=None): """Calculate the distance between points and return the indexes. Filter the indexes so only the resolution of points within the xlim and ylim is improved when zoom is used. """ - distance_points = np.abs(np.diff(mymat, axis=0)) + distance_points = np.abs(np.diff(root_array, axis=0)) indexes_too_far = list(np.unique(np.where(distance_points > tolerance)[0])) if zoom_xlim is not None and zoom_ylim is not None: @@ -386,23 +386,23 @@ def _indexes_filt(mymat, tolerance, zoom_xlim=None, zoom_ylim=None): indexes_too_far_filtered = [] for index in indexes_too_far_zoom: - for point in mymat[index]: + for point in root_array[index]: if (zoom_xlim[0] <= point.real <= zoom_xlim[1]) and \ (zoom_ylim[0] <= point.imag <= zoom_ylim[1]): indexes_too_far_filtered.append(index) break # Check if zoom box is not overshot & insert points where neccessary - if len(indexes_too_far_filtered) == 0 and len(mymat) < 500: + if len(indexes_too_far_filtered) == 0 and len(root_array) < 500: limits = [zoom_xlim[0], zoom_xlim[1], zoom_ylim[0], zoom_ylim[1]] for index, limit in enumerate(limits): if index <= 1: - asign = np.sign(real(mymat)-limit) + asign = np.sign(real(root_array)-limit) else: - asign = np.sign(imag(mymat) - limit) + asign = np.sign(imag(root_array) - limit) signchange = ((np.roll(asign, 1, axis=0) - asign) != 0).astype(int) - signchange[0] = np.zeros((len(mymat[0]))) + signchange[0] = np.zeros((len(root_array[0]))) if len(np.where(signchange == 1)[0]) > 0: indexes_too_far_filtered.append( np.where(signchange == 1)[0][0]-1) @@ -411,7 +411,7 @@ def _indexes_filt(mymat, tolerance, zoom_xlim=None, zoom_ylim=None): if indexes_too_far_filtered[0] != 0: indexes_too_far_filtered.insert( 0, indexes_too_far_filtered[0]-1) - if not indexes_too_far_filtered[-1] + 1 >= len(mymat) - 2: + if not indexes_too_far_filtered[-1] + 1 >= len(root_array) - 2: indexes_too_far_filtered.append( indexes_too_far_filtered[-1] + 1) @@ -441,10 +441,10 @@ def _break_points(num, den): return k_break, real_break_pts -def _ax_lim(mymat): +def _ax_lim(root_array): """Utility to get the axis limits""" - axmin = np.min(np.real(mymat)) - axmax = np.max(np.real(mymat)) + axmin = np.min(np.real(root_array)) + axmax = np.max(np.real(root_array)) if axmax != axmin: deltax = (axmax - axmin) * 0.02 else: @@ -564,11 +564,11 @@ def _RLZoomDispatcher(event, sys, ax_rlocus, plotstr): nump, denp = _systopoly1d(sys_loop) xlim, ylim = ax_rlocus.get_xlim(), ax_rlocus.get_ylim() - kvect, mymat, xlim, ylim = _default_gains( + kvect, root_array, xlim, ylim = _default_gains( nump, denp, xlim=None, ylim=None, zoom_xlim=xlim, zoom_ylim=ylim) _removeLine('rootlocus', ax_rlocus) - for i, col in enumerate(mymat.T): + for i, col in enumerate(root_array.T): ax_rlocus.plot(real(col), imag(col), plotstr, label='rootlocus', scalex=False, scaley=False) @@ -640,10 +640,10 @@ def _RLFeedbackClicksPoint(event, sys, fig, ax_rlocus, sisotool=False): # Visualise clicked point, display all roots for sisotool mode if sisotool: - mymat = _RLFindRoots(nump, denp, K.real) + root_array = _RLFindRoots(nump, denp, K.real) ax_rlocus.plot( - [root.real for root in mymat], - [root.imag for root in mymat], + [root.real for root in root_array], + [root.imag for root in root_array], marker='s', markersize=6, zorder=20, label='gain_point', color='k') else: ax_rlocus.plot(s.real, s.imag, 'k.', marker='s', markersize=8, diff --git a/control/sisotool.py b/control/sisotool.py index ae2497b66..d3f597d77 100644 --- a/control/sisotool.py +++ b/control/sisotool.py @@ -117,7 +117,7 @@ def sisotool(sys, initial_gain=None, xlim_rlocus=None, ylim_rlocus=None, if kvect is not None: warnings.warn("'kvect' keyword is deprecated in sisotool; " "use 'initial_gain' instead", FutureWarning) - initial_gain = np.atleast1d(kvect)[0] + initial_gain = np.atleast_1d(kvect)[0] initial_gain = config._get_param('sisotool', 'initial_gain', initial_gain, _sisotool_defaults) From f81b6d2617c14c0ca18b317bd92916b6ded5a71f Mon Sep 17 00:00:00 2001 From: Rory Yorke Date: Sat, 10 Dec 2022 11:59:25 +0200 Subject: [PATCH 111/157] Allow division by scalar for StateSpace & InputOutputSystem Attempt division by any non-LTI and non-NameIOSystem by translating G / k to G * (1/k). Division of StateSpace by TransferFunction, etc., unchanged. --- control/iosys.py | 11 +++++++++++ control/statesp.py | 19 ++++++++----------- control/tests/statesp_test.py | 11 +++++++++++ control/tests/type_conversion_test.py | 6 +++--- 4 files changed, 33 insertions(+), 14 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index cbbd4cecc..b6b665030 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -346,6 +346,17 @@ def __neg__(sys): # Return the newly created system return newsys + def __truediv__(sys2, sys1): + """Multiply two input/output systems (series interconnection)""" + # Note: order of arguments is flipped so that self = sys2, + # corresponding to the ordering convention of sys2 * sys1 + + if not isinstance(sys1, (LTI, NamedIOSystem)): + return sys2 * (1/sys1) + else: + return NotImplemented + + # Update parameters used for _rhs, _out (used by subclasses) def _update_params(self, params, warning=False): if warning: diff --git a/control/statesp.py b/control/statesp.py index 7843cb33f..6203eda66 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -64,7 +64,7 @@ from .frdata import FrequencyResponseData from .lti import LTI, _process_frequency_response from .namedio import common_timebase, isdtime, _process_namedio_keywords, \ - _process_dt_keyword + _process_dt_keyword, NamedIOSystem from . import config from copy import deepcopy @@ -794,17 +794,14 @@ def __rmul__(self, other): pass raise TypeError("can't interconnect systems") - # TODO: __div__ and __rdiv__ are not written yet. - def __div__(self, other): - """Divide two LTI systems.""" - - raise NotImplementedError("StateSpace.__div__ is not implemented yet.") - - def __rdiv__(self, other): - """Right divide two LTI systems.""" + # TODO: general __truediv__, and __rtruediv__; requires descriptor system support + def __truediv__(self, other): + """Divide a StateSpace object; only division by scalars is supported""" + if not isinstance(other, (LTI, NamedIOSystem)): + return self * (1/other) + else: + return NotImplemented - raise NotImplementedError( - "StateSpace.__rdiv__ is not implemented yet.") def __call__(self, x, squeeze=None, warn_infinite=True): """Evaluate system's transfer function at complex frequency. diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index 41f0c893a..fa837f30d 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -333,6 +333,17 @@ def test_multiply_ss(self, sys222, sys322): np.testing.assert_array_almost_equal(sys.C, C) np.testing.assert_array_almost_equal(sys.D, D) + @pytest.mark.parametrize("k", [2, -3.141, np.float32(2.718), np.array([[4.321], [5.678]])]) + def test_truediv_ss_scalar(self, sys322, k): + """Divide SS by scalar.""" + sys = sys322 / k + syscheck = sys322 * (1/k) + + np.testing.assert_array_almost_equal(sys.A, syscheck.A) + np.testing.assert_array_almost_equal(sys.B, syscheck.B) + np.testing.assert_array_almost_equal(sys.C, syscheck.C) + np.testing.assert_array_almost_equal(sys.D, syscheck.D) + @pytest.mark.parametrize("omega, resp", [(1., np.array([[ 4.37636761e-05-0.01522976j, diff --git a/control/tests/type_conversion_test.py b/control/tests/type_conversion_test.py index 7163f7097..0deb68f88 100644 --- a/control/tests/type_conversion_test.py +++ b/control/tests/type_conversion_test.py @@ -88,11 +88,11 @@ def sys_dict(): ('mul', 'flt', ['ss', 'tf', 'frd', 'lio', 'ios', 'arr', 'flt']), # op left ss tf frd lio ios arr flt - ('truediv', 'ss', ['xs', 'tf', 'frd', 'xio', 'xos', 'xs', 'xs' ]), + ('truediv', 'ss', ['xs', 'tf', 'frd', 'xio', 'xos', 'ss', 'ss' ]), ('truediv', 'tf', ['tf', 'tf', 'xrd', 'tf', 'xos', 'tf', 'tf' ]), ('truediv', 'frd', ['frd', 'frd', 'frd', 'frd', 'E', 'frd', 'frd']), - ('truediv', 'lio', ['xio', 'tf', 'frd', 'xio', 'xio', 'xio', 'xio']), - ('truediv', 'ios', ['xos', 'xos', 'E', 'xos', 'xos' 'xos', 'xos']), + ('truediv', 'lio', ['xio', 'tf', 'frd', 'xio', 'xio', 'lio', 'lio']), + ('truediv', 'ios', ['xos', 'xos', 'E', 'xos', 'xos', 'ios', 'ios']), ('truediv', 'arr', ['xs', 'tf', 'frd', 'xio', 'xos', 'arr', 'arr']), ('truediv', 'flt', ['xs', 'tf', 'frd', 'xio', 'xos', 'arr', 'flt'])] From 0efae636cb831ecba53274e61a33f828d529b5b3 Mon Sep 17 00:00:00 2001 From: Rory Yorke Date: Sat, 10 Dec 2022 12:08:07 +0200 Subject: [PATCH 112/157] Remove __div__ and __rdiv__ methods These aren't used in Python 3. --- control/frdata.py | 8 -------- control/xferfcn.py | 8 -------- 2 files changed, 16 deletions(-) diff --git a/control/frdata.py b/control/frdata.py index a33775afb..c78607a07 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -408,10 +408,6 @@ def __truediv__(self, other): smooth=(self.ifunc is not None) and (other.ifunc is not None)) - # TODO: Remove when transition to python3 complete - def __div__(self, other): - return self.__truediv__(other) - # TODO: Division of MIMO transfer function objects is not written yet. def __rtruediv__(self, other): """Right divide two LTI objects.""" @@ -429,10 +425,6 @@ def __rtruediv__(self, other): return other / self - # TODO: Remove when transition to python3 complete - def __rdiv__(self, other): - return self.__rtruediv__(other) - def __pow__(self, other): if not type(other) == int: raise ValueError("Exponent must be an integer") diff --git a/control/xferfcn.py b/control/xferfcn.py index 84188a63f..a27b46623 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -702,10 +702,6 @@ def __truediv__(self, other): return TransferFunction(num, den, dt) - # TODO: Remove when transition to python3 complete - def __div__(self, other): - return TransferFunction.__truediv__(self, other) - # TODO: Division of MIMO transfer function objects is not written yet. def __rtruediv__(self, other): """Right divide two LTI objects.""" @@ -724,10 +720,6 @@ def __rtruediv__(self, other): return other / self - # TODO: Remove when transition to python3 complete - def __rdiv__(self, other): - return TransferFunction.__rtruediv__(self, other) - def __pow__(self, other): if not type(other) == int: raise ValueError("Exponent must be an integer") From af7a7d711b1e0504f197ab62cfb446e11cdbbb82 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller <58706249+sawyerbfuller@users.noreply.github.com> Date: Sat, 10 Dec 2022 02:34:17 -0800 Subject: [PATCH 113/157] Update timeresp.py Update impulse_response doc string to indicate that the impulse size is unit area for discrete systems. --- control/timeresp.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/control/timeresp.py b/control/timeresp.py index e029f2a44..da158e0cc 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -1796,7 +1796,8 @@ def impulse_response(sys, T=None, X0=0., input=None, output=None, T_num=None, ----- This function uses the `forced_response` function to compute the time response. For continuous time systems, the initial condition is altered to - account for the initial impulse. + account for the initial impulse. For discrete-time aystems, the impulse is + sized so that it has unit area. Examples -------- From 4792163bde887b2e4bc1f5716299d14985a5ef7b Mon Sep 17 00:00:00 2001 From: mark-yeatman Date: Sun, 11 Dec 2022 22:09:19 -0500 Subject: [PATCH 114/157] Removed epsilon perturbation value in solve_passivity_LMI. Change unit test to reflect scaling values from empirical testing. --- control/passivity.py | 34 +++++++++++++++++++-------------- control/tests/passivity_test.py | 17 ++++++++--------- 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/control/passivity.py b/control/passivity.py index 3777b3d92..3d48f34f6 100644 --- a/control/passivity.py +++ b/control/passivity.py @@ -14,7 +14,6 @@ except ImportError: cvx = None -eps = np.nextafter(0, 1) __all__ = ["get_output_fb_index", "get_input_ff_index", "ispassive", "solve_passivity_LMI"] @@ -28,8 +27,8 @@ def solve_passivity_LMI(sys, rho=None, nu=None): passive. Inputs of None for `rho` or `nu` indicate that the function should solve for that index (they are mutually exclusive, they can't both be None, otherwise you're trying to solve a nonconvex bilinear matrix - inequality.) The last element of `solution` is either the output or input - passivity index, for `rho` = None and `nu` = None respectively. + inequality.) The last element of the output `solution` is either the output or input + passivity index, for `rho` = None and `nu` = None respectively. The sources for the algorithm are: @@ -74,11 +73,8 @@ def solve_passivity_LMI(sys, rho=None, nu=None): D = sys.D # account for strictly proper systems - [n, m] = D.shape - D = D + eps * np.eye(n, m) - + [_, m] = D.shape [n, _] = A.shape - A = A - eps*np.eye(n) def make_LMI_matrix(P, rho, nu, one): q = sys.noutputs @@ -113,7 +109,7 @@ def make_P_basis_matrices(n, rho, nu): P[i, j] = 1 P[j, i] = 1 matrix_list.append(make_LMI_matrix(P, 0, 0, 0).flatten()) - zeros = eps*np.eye(n) + zeros = 0.0*np.eye(n) if rho is None: matrix_list.append(make_LMI_matrix(zeros, 1, 0, 0).flatten()) elif nu is None: @@ -149,9 +145,9 @@ def P_pos_def_constraint(n): if rho is not None and nu is not None: sys_constants = -make_LMI_matrix(np.zeros_like(A), rho, nu, 1.0) elif rho is not None: - sys_constants = -make_LMI_matrix(np.zeros_like(A), rho, eps, 1.0) + sys_constants = -make_LMI_matrix(np.zeros_like(A), rho, 0.0, 1.0) elif nu is not None: - sys_constants = -make_LMI_matrix(np.zeros_like(A), eps, nu, 1.0) + sys_constants = -make_LMI_matrix(np.zeros_like(A), 0.0, nu, 1.0) sys_coefficents = np.vstack(sys_matrix_list).T @@ -174,8 +170,18 @@ def P_pos_def_constraint(n): # crunch feasibility solution cvx.solvers.options['show_progress'] = False - sol = cvx.solvers.sdp(c, Gs=Gs, hs=hs) - return sol["x"] + try: + sol = cvx.solvers.sdp(c, Gs=Gs, hs=hs) + return sol["x"] + + except ZeroDivisionError as e: + print(e) + print( + """The system is probably ill conditioned. + Consider perturbing the system matrices a small amount.""" + ) + raise(ZeroDivisionError) + def get_output_fb_index(sys): @@ -194,7 +200,7 @@ def get_output_fb_index(sys): float The OFP index """ - sol = solve_passivity_LMI(sys, nu=eps) + sol = solve_passivity_LMI(sys, nu=0.0) if sol is None: raise RuntimeError("LMI passivity problem is infeasible") else: @@ -218,7 +224,7 @@ def get_input_ff_index(sys): float The IFP index """ - sol = solve_passivity_LMI(sys, rho=eps) + sol = solve_passivity_LMI(sys, rho=0.0) if sol is None: raise RuntimeError("LMI passivity problem is infeasible") else: diff --git a/control/tests/passivity_test.py b/control/tests/passivity_test.py index 6cee1bdb6..bc1ddb871 100644 --- a/control/tests/passivity_test.py +++ b/control/tests/passivity_test.py @@ -99,16 +99,15 @@ def test_system_dimension(): @pytest.mark.parametrize( - "systemmatrices, expected", - [((A, B, C, D*0.0), True), + "system_matrices, expected", + [((A, B, C, D*1e-8), True), ((A_d, B, C, D), True), - pytest.param((A*1e12, B, C, D*0), True, - marks=pytest.mark.xfail(reason="gh-761")), - ((A, B*0, C*0, D), True), - ((A*0, B, C, D), True), - ((A*0, B*0, C*0, D*0), True)]) -def test_ispassive_edge_cases(systemmatrices, expected): - sys = ss(*systemmatrices) + pytest.param((A*1e8, B, C, D*1e-8), True), + ((A, B*1e-8, C*1e-8, D), True), + ((A*1e-8, B, C, D), True), + ((A*1e-8, B*1e-8, C*1e-8, D*1e-8), True)]) +def test_ispassive_edge_cases(system_matrices, expected): + sys = ss(*system_matrices) assert passivity.ispassive(sys) == expected From 5b7df09b6117ac2ceeef394385861e3a48210cf0 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller <58706249+sawyerbfuller@users.noreply.github.com> Date: Mon, 12 Dec 2022 08:52:10 -0800 Subject: [PATCH 115/157] Apply suggestions from code review --- README.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 58dd901de..f3e3a13ff 100644 --- a/README.rst +++ b/README.rst @@ -39,7 +39,7 @@ Features - Time response: initial, step, impulse - Frequency response: Bode, Nyquist, and Nichols plots - Control analysis: stability, reachability, observability, stability margins, root locus -- Control design: eigenvalue placement, linear quadratic regulator, sisotool, hinfsyn +- Control design: eigenvalue placement, linear quadratic regulator, sisotool, hinfsyn, rootlocus_pid_designer - Estimator design: linear quadratic estimator (Kalman filter) - Nonlinear systems: optimization-based control, describing functions, differential flatness @@ -106,12 +106,12 @@ toplevel `python-control` directory:: Article and Citation Information ================================ -A `2021 article `_ about the library is available on IEEE Explore. If the Python Control Systems Library helped you in your research, please cite:: +An `article `_ about the library is available on IEEE Explore. If the Python Control Systems Library helped you in your research, please cite:: @inproceedings{python-control2021, - title={The python control systems library (python-control)}, + title={The Python Control Systems Library (python-control)}, author={Fuller, Sawyer and Greiner, Ben and Moore, Jason and Murray, Richard and van Paassen, Ren{\'e} and Yorke, Rory}, - booktitle={2021 60th IEEE Conference on Decision and Control (CDC)}, + booktitle={60th IEEE Conference on Decision and Control (CDC)}, pages={4875--4881}, year={2021}, organization={IEEE} From 2311a4d62280675d88291566d3b164f124d2b595 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Mon, 12 Dec 2022 11:49:06 -0800 Subject: [PATCH 116/157] bring back 'Other parameters' in docstrings, alphabetize TOC --- control/dtime.py | 13 ++++++++----- control/iosys.py | 16 +++++++++------- control/statesp.py | 13 ++++++++----- control/xferfcn.py | 15 +++++++++------ doc/control.rst | 14 +++++++------- 5 files changed, 41 insertions(+), 30 deletions(-) diff --git a/control/dtime.py b/control/dtime.py index e242923fb..724eafb76 100644 --- a/control/dtime.py +++ b/control/dtime.py @@ -84,6 +84,14 @@ def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None, copy_names : bool, Optional If True, copy the names of the input signals, output signals, and states to the sampled system. + + Returns + ------- + sysd : linsys + Discrete time system, with sampling rate Ts + + Other Parameters + ---------------- inputs : int, list of str or None, optional Description of the system inputs. If not specified, the origional system inputs are used. See :class:`InputOutputSystem` for more @@ -94,11 +102,6 @@ def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None, Description of the system states. Same format as `inputs`. Only available if the system is :class:`StateSpace`. - Returns - ------- - sysd : linsys - Discrete time system, with sampling rate Ts - Notes ----- See :meth:`StateSpace.sample` or :meth:`TransferFunction.sample` for diff --git a/control/iosys.py b/control/iosys.py index 4f9c674c0..665c60e4d 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -2203,6 +2203,15 @@ def linearize(sys, xeq, ueq=None, t=0, params=None, **kw): copy_names : bool, Optional If True, Copy the names of the input signals, output signals, and states to the linearized system. + + Returns + ------- + ss_sys : LinearIOSystem + The linearization of the system, as a :class:`~control.LinearIOSystem` + object (which is also a :class:`~control.StateSpace` object. + + Other Parameters + ---------------- inputs : int, list of str or None, optional Description of the system inputs. If not specified, the origional system inputs are used. See :class:`InputOutputSystem` for more @@ -2211,13 +2220,6 @@ def linearize(sys, xeq, ueq=None, t=0, params=None, **kw): Description of the system outputs. Same format as `inputs`. states : int, list of str, or None, optional Description of the system states. Same format as `inputs`. - - Returns - ------- - ss_sys : LinearIOSystem - The linearization of the system, as a :class:`~control.LinearIOSystem` - object (which is also a :class:`~control.StateSpace` object. - """ if not isinstance(sys, InputOutputSystem): raise TypeError("Can only linearize InputOutputSystem types") diff --git a/control/statesp.py b/control/statesp.py index 80b4601f2..0303ff70a 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -1338,6 +1338,14 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None, copy_names : bool, Optional If True, copy the names of the input signals, output signals, and states to the sampled system. + + Returns + ------- + sysd : StateSpace + Discrete-time system, with sampling rate Ts + + Other Parameters + ---------------- inputs : int, list of str or None, optional Description of the system inputs. If not specified, the origional system inputs are used. See :class:`InputOutputSystem` for more @@ -1347,11 +1355,6 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None, states : int, list of str, or None, optional Description of the system states. Same format as `inputs`. - Returns - ------- - sysd : StateSpace - Discrete-time system, with sampling rate Ts - Notes ----- Uses :func:`scipy.signal.cont2discrete` diff --git a/control/xferfcn.py b/control/xferfcn.py index d7de8fedd..490132952 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -1130,6 +1130,14 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None, copy_names : bool, Optional If True, copy the names of the input signals, output signals, and states to the sampled system. + + Returns + ------- + sysd : TransferFunction system + Discrete-time system, with sample period Ts + + Other Parameters + ---------------- inputs : int, list of str or None, optional Description of the system inputs. If not specified, the origional system inputs are used. See :class:`InputOutputSystem` for more @@ -1137,11 +1145,6 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None, outputs : int, list of str or None, optional Description of the system outputs. Same format as `inputs`. - Returns - ------- - sysd : TransferFunction system - Discrete-time system, with sample period Ts - Notes ----- 1. Available only for SISO systems @@ -1582,7 +1585,7 @@ def tf(*args, **kwargs): else: raise ValueError("Needs 1 or 2 arguments; received %i." % len(args)) - +# TODO: copy signal names def ss2tf(*args, **kwargs): """ss2tf(sys) diff --git a/doc/control.rst b/doc/control.rst index 5ac5102f7..54e233746 100644 --- a/doc/control.rst +++ b/doc/control.rst @@ -31,10 +31,10 @@ System interconnections append connect feedback + interconnect negate parallel series - interconnect Frequency domain plotting @@ -80,8 +80,6 @@ Control system analysis dcgain describing_function frequency_response - TransferFunction.__call__ - StateSpace.__call__ get_input_ff_index get_output_fb_index ispassive @@ -93,6 +91,8 @@ Control system analysis pzmap root_locus sisotool + StateSpace.__call__ + TransferFunction.__call__ @@ -102,10 +102,10 @@ Matrix computations :toctree: generated/ care + ctrb dare - lyap dlyap - ctrb + lyap obsv gram @@ -116,10 +116,10 @@ Control system synthesis acker create_statefbk_iosystem + dlqr h2syn hinfsyn lqr - dlqr mixsyn place rootlocus_pid_designer @@ -157,8 +157,8 @@ Stochastic system support correlation create_estimator_iosystem - lqe dlqe + lqe white_noise .. _utility-and-conversions: From 7acd3fc650a3d3074c17dcdea7ee768a31fbf290 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Mon, 12 Dec 2022 12:11:19 -0800 Subject: [PATCH 117/157] added signature for drss, import missing stochsys for matlab module, docstring typos --- control/iosys.py | 7 +++++-- control/matlab/__init__.py | 1 + control/matlab/timeresp.py | 38 +++++--------------------------------- control/matlab/wrappers.py | 3 +-- 4 files changed, 12 insertions(+), 37 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index 665c60e4d..e3af5d9f7 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -2428,7 +2428,10 @@ def rss(states=1, outputs=1, inputs=1, strictly_proper=False, **kwargs): def drss(*args, **kwargs): - """Create a stable, discrete-time, random state space system + """ + drss([states, outputs, inputs, strictly_proper]) + + Create a stable, discrete-time, random state space system Create a stable *discrete time* random state space object. This function calls :func:`rss` using either the `dt` keyword provided by @@ -2466,7 +2469,7 @@ def ss2io(*args, **kwargs): # Convert a transfer function into an input/output system (wrapper) def tf2io(*args, **kwargs): - """tf2io(sys) + """tf2io(sys[, ...]) Convert a transfer function into an I/O system diff --git a/control/matlab/__init__.py b/control/matlab/__init__.py index 80f2a0a65..4dd34c41b 100644 --- a/control/matlab/__init__.py +++ b/control/matlab/__init__.py @@ -84,6 +84,7 @@ from ..rlocus import rlocus from ..dtime import c2d from ..sisotool import sisotool +from ..stochsys import * # Functions that are renamed in MATLAB pole, zero = poles, zeros diff --git a/control/matlab/timeresp.py b/control/matlab/timeresp.py index b1fa24bb0..58b5e589d 100644 --- a/control/matlab/timeresp.py +++ b/control/matlab/timeresp.py @@ -7,8 +7,7 @@ __all__ = ['step', 'stepinfo', 'impulse', 'initial', 'lsim'] def step(sys, T=None, X0=0., input=0, output=None, return_x=False): - ''' - Step response of a linear system + '''Step response of a linear system If the system has multiple inputs or outputs (MIMO), one input has to be selected for the simulation. Optionally, one output may be @@ -20,19 +19,14 @@ def step(sys, T=None, X0=0., input=0, output=None, return_x=False): ---------- sys: StateSpace, or TransferFunction LTI system to simulate - T: array-like or number, optional Time vector, or simulation time duration if a number (time vector is autocomputed if not given) - X0: array-like or number, optional Initial condition (default = 0) - Numbers are converted to constant arrays with the correct shape. - input: int Index of the input that will be used in this simulation. - output: int If given, index of the output that is returned by this simulation. @@ -40,15 +34,12 @@ def step(sys, T=None, X0=0., input=0, output=None, return_x=False): ------- yout: array Response of the system - T: array Time values of the output - xout: array (if selected) Individual response of each x variable - See Also -------- lsim, initial, impulse @@ -67,8 +58,7 @@ def step(sys, T=None, X0=0., input=0, output=None, return_x=False): def stepinfo(sysdata, T=None, yfinal=None, SettlingTimeThreshold=0.02, RiseTimeLimits=(0.1, 0.9)): - """ - Step response characteristics (Rise time, Settling Time, Peak and others). + """Step response characteristics (Rise time, Settling Time, Peak and others) Parameters ---------- @@ -137,8 +127,7 @@ def stepinfo(sysdata, T=None, yfinal=None, SettlingTimeThreshold=0.02, return S def impulse(sys, T=None, X0=0., input=0, output=None, return_x=False): - ''' - Impulse response of a linear system + '''Impulse response of a linear system If the system has multiple inputs or outputs (MIMO), one input has to be selected for the simulation. Optionally, one output may be @@ -150,19 +139,15 @@ def impulse(sys, T=None, X0=0., input=0, output=None, return_x=False): ---------- sys: StateSpace, TransferFunction LTI system to simulate - T: array-like or number, optional Time vector, or simulation time duration if a number (time vector is autocomputed if not given) - X0: array-like or number, optional Initial condition (default = 0) Numbers are converted to constant arrays with the correct shape. - input: int Index of the input that will be used in this simulation. - output: int Index of the output that will be used in this simulation. @@ -170,10 +155,8 @@ def impulse(sys, T=None, X0=0., input=0, output=None, return_x=False): ------- yout: array Response of the system - T: array Time values of the output - xout: array (if selected) Individual response of each x variable @@ -193,8 +176,7 @@ def impulse(sys, T=None, X0=0., input=0, output=None, return_x=False): return (out[1], out[0], out[2]) if return_x else (out[1], out[0]) def initial(sys, T=None, X0=0., input=None, output=None, return_x=False): - ''' - Initial condition response of a linear system + '''Initial condition response of a linear system If the system has multiple outputs (?IMO), optionally, one output may be selected. If no selection is made for the output, all @@ -204,20 +186,16 @@ def initial(sys, T=None, X0=0., input=None, output=None, return_x=False): ---------- sys: StateSpace, or TransferFunction LTI system to simulate - T: array-like or number, optional Time vector, or simulation time duration if a number (time vector is autocomputed if not given) - X0: array-like object or number, optional Initial condition (default = 0) Numbers are converted to constant arrays with the correct shape. - input: int This input is ignored, but present for compatibility with step and impulse. - output: int If given, index of the output that is returned by this simulation. @@ -225,10 +203,8 @@ def initial(sys, T=None, X0=0., input=None, output=None, return_x=False): ------- yout: array Response of the system - T: array Time values of the output - xout: array (if selected) Individual response of each x variable @@ -250,8 +226,7 @@ def initial(sys, T=None, X0=0., input=None, output=None, return_x=False): def lsim(sys, U=0., T=None, X0=0.): - ''' - Simulate the output of a linear system. + '''Simulate the output of a linear system As a convenience for parameters `U`, `X0`: Numbers (scalars) are converted to constant arrays with the correct shape. @@ -261,16 +236,13 @@ def lsim(sys, U=0., T=None, X0=0.): ---------- sys: LTI (StateSpace, or TransferFunction) LTI system to simulate - U: array-like or number, optional Input array giving input at each time `T` (default = 0). If `U` is ``None`` or ``0``, a special algorithm is used. This special algorithm is faster than the general algorithm, which is used otherwise. - T: array-like, optional for discrete LTI `sys` Time steps at which the input is defined; values must be evenly spaced. - X0: array-like or number, optional Initial condition (default = 0). diff --git a/control/matlab/wrappers.py b/control/matlab/wrappers.py index 8eafdaad2..6a6c4ad35 100644 --- a/control/matlab/wrappers.py +++ b/control/matlab/wrappers.py @@ -178,8 +178,7 @@ def ngrid(): def dcgain(*args): - ''' - Compute the gain of the system in steady state. + '''Compute the gain of the system in steady state The function takes either 1, 2, 3, or 4 parameters: From a17e3c823e733f20a428f79ea91f9383612f79b8 Mon Sep 17 00:00:00 2001 From: mark-yeatman Date: Thu, 15 Dec 2022 02:50:00 -0500 Subject: [PATCH 118/157] Remove test case. --- control/tests/passivity_test.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/control/tests/passivity_test.py b/control/tests/passivity_test.py index bc1ddb871..4d7c8e6eb 100644 --- a/control/tests/passivity_test.py +++ b/control/tests/passivity_test.py @@ -100,12 +100,11 @@ def test_system_dimension(): @pytest.mark.parametrize( "system_matrices, expected", - [((A, B, C, D*1e-8), True), + [((A, B, C, D*0), True), ((A_d, B, C, D), True), - pytest.param((A*1e8, B, C, D*1e-8), True), - ((A, B*1e-8, C*1e-8, D), True), - ((A*1e-8, B, C, D), True), - ((A*1e-8, B*1e-8, C*1e-8, D*1e-8), True)]) + ((A, B*0, C*0, D), True), + ((A*0, B, C, D), True), + ((A*0, B*0, C*0, D*0), True)]) def test_ispassive_edge_cases(system_matrices, expected): sys = ss(*system_matrices) assert passivity.ispassive(sys) == expected From cc3fb2fd903275ade892898b8c5b6779b6b05807 Mon Sep 17 00:00:00 2001 From: Rory Yorke Date: Fri, 16 Dec 2022 06:00:47 +0200 Subject: [PATCH 119/157] Update __truediv__ docstring for InputOutputSystem and StateSpace --- control/iosys.py | 4 +++- control/statesp.py | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index b6b665030..67cca74de 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -347,7 +347,9 @@ def __neg__(sys): return newsys def __truediv__(sys2, sys1): - """Multiply two input/output systems (series interconnection)""" + """Division of input/output systems + + Only division by scalars and arrays of scalars is supported""" # Note: order of arguments is flipped so that self = sys2, # corresponding to the ordering convention of sys2 * sys1 diff --git a/control/statesp.py b/control/statesp.py index 6203eda66..249c6f5e0 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -796,7 +796,11 @@ def __rmul__(self, other): # TODO: general __truediv__, and __rtruediv__; requires descriptor system support def __truediv__(self, other): - """Divide a StateSpace object; only division by scalars is supported""" + """Division of StateSpace systems + + Only division by TFs, FRDs, scalars, and arrays of scalars is + supported. + """ if not isinstance(other, (LTI, NamedIOSystem)): return self * (1/other) else: From e6b837d4ff463547708e8a155862473adb34290b Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 16 Dec 2022 15:44:58 -0500 Subject: [PATCH 120/157] Update control/passivity.py Co-authored-by: Ben Greiner --- control/passivity.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/control/passivity.py b/control/passivity.py index 3d48f34f6..0f4104186 100644 --- a/control/passivity.py +++ b/control/passivity.py @@ -175,12 +175,9 @@ def P_pos_def_constraint(n): return sol["x"] except ZeroDivisionError as e: - print(e) - print( - """The system is probably ill conditioned. - Consider perturbing the system matrices a small amount.""" - ) - raise(ZeroDivisionError) + raise ValueError("The system is probably ill conditioned. " + "Consider perturbing the system matrices by a small amount." + ) from e From 16457d7a0d7aaaca6fbaf3d596ea599e76e336d4 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 16 Dec 2022 21:18:40 -0800 Subject: [PATCH 121/157] explicit import of stochsys in matlab, per @bnavigator --- control/matlab/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control/matlab/__init__.py b/control/matlab/__init__.py index 4dd34c41b..fc0cd445b 100644 --- a/control/matlab/__init__.py +++ b/control/matlab/__init__.py @@ -84,7 +84,7 @@ from ..rlocus import rlocus from ..dtime import c2d from ..sisotool import sisotool -from ..stochsys import * +from ..stochsys import lqe, dlqe # Functions that are renamed in MATLAB pole, zero = poles, zeros From 91466d1276a3bd43501ab5b5a8e2146e1b0215a5 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Thu, 15 Dec 2022 22:41:20 -0800 Subject: [PATCH 122/157] add zpk() function --- control/matlab/__init__.py | 2 +- control/tests/kwargs_test.py | 2 ++ control/tests/xferfcn_test.py | 40 ++++++++++++++++++++++++++- control/xferfcn.py | 51 +++++++++++++++++++++++++++++++++-- doc/control.rst | 1 + 5 files changed, 92 insertions(+), 4 deletions(-) diff --git a/control/matlab/__init__.py b/control/matlab/__init__.py index fc0cd445b..1a524b33f 100644 --- a/control/matlab/__init__.py +++ b/control/matlab/__init__.py @@ -115,7 +115,7 @@ == ========================== ============================================ \* :func:`tf` create transfer function (TF) models -\ zpk create zero/pole/gain (ZPK) models. +\* :func:`zpk` create zero/pole/gain (ZPK) models. \* :func:`ss` create state-space (SS) models \ dss create descriptor state-space models \ delayss create state-space models with delayed terms diff --git a/control/tests/kwargs_test.py b/control/tests/kwargs_test.py index 20a1c8e9c..8116f013a 100644 --- a/control/tests/kwargs_test.py +++ b/control/tests/kwargs_test.py @@ -96,6 +96,7 @@ def test_kwarg_search(module, prefix): (control.tf, 0, 0, ([1], [1, 1]), {}), (control.tf2io, 0, 1, (), {}), (control.tf2ss, 0, 1, (), {}), + (control.zpk, 0, 0, ([1], [2, 3], 4), {}), (control.InputOutputSystem, 0, 0, (), {'inputs': 1, 'outputs': 1, 'states': 1}), (control.InputOutputSystem.linearize, 1, 0, (0, 0), {}), @@ -184,6 +185,7 @@ def test_matplotlib_kwargs(function, nsysargs, moreargs, kwargs, mplcleanup): 'tf2io' : test_unrecognized_kwargs, 'tf2ss' : test_unrecognized_kwargs, 'sample_system' : test_unrecognized_kwargs, + 'zpk': test_unrecognized_kwargs, 'flatsys.point_to_point': flatsys_test.TestFlatSys.test_point_to_point_errors, 'flatsys.solve_flat_ocp': diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py index e4a2b3ec0..915ac4dc6 100644 --- a/control/tests/xferfcn_test.py +++ b/control/tests/xferfcn_test.py @@ -986,7 +986,7 @@ def test_repr(self, Hargs, ref): np.testing.assert_array_almost_equal(H.num[p][m], H2.num[p][m]) np.testing.assert_array_almost_equal(H.den[p][m], H2.den[p][m]) assert H.dt == H2.dt - + def test_sample_named_signals(self): sysc = ct.TransferFunction(1.1, (1, 2), inputs='u', outputs='y') @@ -1073,3 +1073,41 @@ def test_xferfcn_ndarray_precedence(op, tf, arr): # Apply the operator to the array and transfer function result = op(arr, tf) assert isinstance(result, ct.TransferFunction) + + +@pytest.mark.parametrize( + "zeros, poles, gain, args, kwargs", [ + ([], [-1], 1, [], {}), + ([1, 2], [-1, -2, -3], 5, [], {}), + ([1, 2], [-1, -2, -3], 5, [], {'name': "sys"}), + ([1, 2], [-1, -2, -3], 5, [], {'inputs': ["in"], 'outputs': ["out"]}), + ([1, 2], [-1, -2, -3], 5, [0.1], {}), + (np.array([1, 2]), np.array([-1, -2, -3]), 5, [], {}), +]) +def test_zpk(zeros, poles, gain, args, kwargs): + # Create the transfer function + sys = ct.zpk(zeros, poles, gain, *args, **kwargs) + + # Make sure the poles and zeros match + np.testing.assert_equal(sys.zeros().sort(), zeros.sort()) + np.testing.assert_equal(sys.poles().sort(), poles.sort()) + + # Check to make sure the gain is OK + np.testing.assert_almost_equal( + gain, sys(0) * np.prod(-sys.poles()) / np.prod(-sys.zeros())) + + # Check time base + if args: + assert sys.dt == args[0] + + # Check inputs, outputs, name + input_labels = kwargs.get('inputs', []) + for i, label in enumerate(input_labels): + assert sys.input_labels[i] == label + + output_labels = kwargs.get('outputs', []) + for i, label in enumerate(output_labels): + assert sys.output_labels[i] == label + + if kwargs.get('name'): + assert sys.name == kwargs.get('name') diff --git a/control/xferfcn.py b/control/xferfcn.py index 5ebd35c13..726f6297b 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -65,7 +65,7 @@ from .frdata import FrequencyResponseData from . import config -__all__ = ['TransferFunction', 'tf', 'ss2tf', 'tfdata'] +__all__ = ['TransferFunction', 'tf', 'zpk', 'ss2tf', 'tfdata'] # Define module default parameter values @@ -796,7 +796,7 @@ def zeros(self): """Compute the zeros of a transfer function.""" if self.ninputs > 1 or self.noutputs > 1: raise NotImplementedError( - "TransferFunction.zero is currently only implemented " + "TransferFunction.zeros is currently only implemented " "for SISO systems.") else: # for now, just give zeros of a SISO tf @@ -1577,8 +1577,55 @@ def tf(*args, **kwargs): else: raise ValueError("Needs 1 or 2 arguments; received %i." % len(args)) + +def zpk(zeros, poles, gain, *args, **kwargs): + """zpk(zeros, poles, gain[, dt]) + + Create a transfer function from zeros, poles, gain. + + Given a list of zeros z_i, poles p_j, and gain k, return the transfer + function: + + .. math:: + H(s) = k \\frac{(s - z_1) (s - z_2) \\cdots (s - z_m)} + {(s - p_1) (s - p_2) \\cdots (s - p_n)} + + Parameters + ---------- + zeros : array_like + Array containing the location of zeros. + poles : array_like + Array containing the location of zeros. + gain : float + System gain + dt : None, True or float, optional + System timebase. 0 (default) indicates continuous + time, True indicates discrete time with unspecified sampling + time, positive number is discrete time with specified + sampling time, None indicates unspecified timebase (either + continuous or discrete time). + inputs, outputs, states : str, or list of str, optional + List of strings that name the individual signals. If this parameter + is not given or given as `None`, the signal names will be of the + form `s[i]` (where `s` is one of `u`, `y`, or `x`). See + :class:`InputOutputSystem` for more information. + name : string, optional + System name (used for specifying signals). If unspecified, a generic + name is generated with a unique integer id. + + Returns + ------- + out: :class:`TransferFunction` + Transfer function with given zeros, poles, and gain. + + """ + num, den = zpk2tf(zeros, poles, gain) + return TransferFunction(num, den, *args, **kwargs) + + # TODO: copy signal names def ss2tf(*args, **kwargs): + """ss2tf(sys) Transform a state space system to a transfer function. diff --git a/doc/control.rst b/doc/control.rst index 54e233746..79702dc6a 100644 --- a/doc/control.rst +++ b/doc/control.rst @@ -18,6 +18,7 @@ System creation ss tf frd + zpk rss drss NonlinearIOSystem From 7ee0c0e3358d703e93054bca8e3e34d5c86987c9 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 16 Dec 2022 23:05:56 -0800 Subject: [PATCH 123/157] fix up copying of signal names --- control/statesp.py | 1 - control/tests/xferfcn_test.py | 34 +++++++++++++++++++++++++++++++++- control/xferfcn.py | 13 +++++++------ 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/control/statesp.py b/control/statesp.py index 4524af396..aac4dd8bd 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -1521,7 +1521,6 @@ def output(self, t, x, u=None, params=None): # TODO: add discrete time check -# TODO: copy signal names def _convert_to_statespace(sys): """Convert a system to state space form (if needed). diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py index 915ac4dc6..6e1cf6ce2 100644 --- a/control/tests/xferfcn_test.py +++ b/control/tests/xferfcn_test.py @@ -8,7 +8,8 @@ import operator import control as ct -from control import StateSpace, TransferFunction, rss, ss2tf, evalfr +from control import StateSpace, TransferFunction, rss, evalfr +from control import ss, ss2tf, tf, tf2ss from control import isctime, isdtime, sample_system, defaults from control.statesp import _convert_to_statespace from control.xferfcn import _convert_to_transfer_function @@ -1111,3 +1112,34 @@ def test_zpk(zeros, poles, gain, args, kwargs): if kwargs.get('name'): assert sys.name == kwargs.get('name') + +@pytest.mark.parametrize("create, args, kwargs, convert", [ + (StateSpace, ([-1], [1], [1], [0]), {}, ss2tf), + (StateSpace, ([-1], [1], [1], [0]), {}, ss), + (StateSpace, ([-1], [1], [1], [0]), {}, tf), + (StateSpace, ([-1], [1], [1], [0]), dict(inputs='i', outputs='o'), ss2tf), + (StateSpace, ([-1], [1], [1], [0]), dict(inputs=1, outputs=1), ss2tf), + (StateSpace, ([-1], [1], [1], [0]), dict(inputs='i', outputs='o'), ss), + (StateSpace, ([-1], [1], [1], [0]), dict(inputs='i', outputs='o'), tf), + (TransferFunction, ([1], [1, 1]), {}, tf2ss), + (TransferFunction, ([1], [1, 1]), {}, tf), + (TransferFunction, ([1], [1, 1]), {}, ss), + (TransferFunction, ([1], [1, 1]), dict(inputs='i', outputs='o'), tf2ss), + (TransferFunction, ([1], [1, 1]), dict(inputs=1, outputs=1), tf2ss), + (TransferFunction, ([1], [1, 1]), dict(inputs='i', outputs='o'), tf), + (TransferFunction, ([1], [1, 1]), dict(inputs='i', outputs='o'), ss), +]) +def test_copy_names(create, args, kwargs, convert): + # Convert a system with no renaming + sys = create(*args, **kwargs) + cpy = convert(sys) + + assert cpy.input_labels == sys.input_labels + assert cpy.input_labels == sys.input_labels + if cpy.nstates is not None and sys.nstates is not None: + assert cpy.state_labels == sys.state_labels + + # Relabel inputs and outputs + cpy = convert(sys, inputs='myin', outputs='myout') + assert cpy.input_labels == ['myin'] + assert cpy.output_labels == ['myout'] diff --git a/control/xferfcn.py b/control/xferfcn.py index 726f6297b..0bc84e096 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -1424,16 +1424,13 @@ def _convert_to_transfer_function(sys, inputs=1, outputs=1): num = squeeze(num) # Convert to 1D array den = squeeze(den) # Probably not needed - return TransferFunction( - num, den, sys.dt, inputs=sys.input_labels, - outputs=sys.output_labels) + return TransferFunction(num, den, sys.dt) elif isinstance(sys, (int, float, complex, np.number)): num = [[[sys] for j in range(inputs)] for i in range(outputs)] den = [[[1] for j in range(inputs)] for i in range(outputs)] - return TransferFunction( - num, den, inputs=inputs, outputs=outputs) + return TransferFunction(num, den) elif isinstance(sys, FrequencyResponseData): raise TypeError("Can't convert given FRD to TransferFunction system.") @@ -1623,7 +1620,6 @@ def zpk(zeros, poles, gain, *args, **kwargs): return TransferFunction(num, den, *args, **kwargs) -# TODO: copy signal names def ss2tf(*args, **kwargs): """ss2tf(sys) @@ -1705,6 +1701,11 @@ def ss2tf(*args, **kwargs): if len(args) == 1: sys = args[0] if isinstance(sys, StateSpace): + kwargs = kwargs.copy() + if not kwargs.get('inputs'): + kwargs['inputs'] = sys.input_labels + if not kwargs.get('outputs'): + kwargs['outputs'] = sys.output_labels return TransferFunction( _convert_to_transfer_function(sys), **kwargs) else: From 02c39ca09e6576f3b8777909f86b588715a13277 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 17 Dec 2022 22:33:23 -0800 Subject: [PATCH 124/157] update readthedocs build process --- .readthedocs.yaml | 21 ++++++++++++++++++++ doc-requirements.txt => doc/requirements.txt | 0 2 files changed, 21 insertions(+) create mode 100644 .readthedocs.yaml rename doc-requirements.txt => doc/requirements.txt (100%) diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 000000000..79afa7451 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,21 @@ +# .readthedocs.yaml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the version of Python and other tools you might need +build: + os: ubuntu-20.04 + tools: + python: "3.9" + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: doc/conf.py + +# Optionally declare the Python requirements required to build your docs +python: + install: + - requirements: doc/requirements.txt diff --git a/doc-requirements.txt b/doc/requirements.txt similarity index 100% rename from doc-requirements.txt rename to doc/requirements.txt From b7f394ce47239097f039c454a4b9dcc5f49fa198 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 17 Dec 2022 22:57:04 -0800 Subject: [PATCH 125/157] pin docutils to 0.16 until sphinx_rtd_theme is fixed https://github.com/readthedocs/sphinx_rtd_theme/issues/1115 --- doc/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/requirements.txt b/doc/requirements.txt index cf1a3a76e..123dcc0a2 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -6,3 +6,4 @@ sphinx_rtd_theme numpydoc ipykernel nbsphinx +docutils==0.16 # pin until sphinx_rtd_theme is compatible with 0.17 or later From 7278651aa11e311a0ca2bf0dc300a75d39f06cc4 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sun, 18 Dec 2022 08:08:02 -0800 Subject: [PATCH 126/157] use ubuntu-22.04 --- .readthedocs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 79afa7451..dca7c8bc4 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -7,7 +7,7 @@ version: 2 # Set the version of Python and other tools you might need build: - os: ubuntu-20.04 + os: ubuntu-22.04 tools: python: "3.9" From 089ca21813b8c41063f3530810100990bad89072 Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Sun, 18 Dec 2022 21:32:44 +0100 Subject: [PATCH 127/157] compare floats as almost equal --- control/tests/flatsys_test.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/control/tests/flatsys_test.py b/control/tests/flatsys_test.py index 3f308c79b..5e3fe5d7c 100644 --- a/control/tests/flatsys_test.py +++ b/control/tests/flatsys_test.py @@ -693,9 +693,9 @@ def test_response(self, xf, uf, Tf): # Recompute using response() response = traj.response(T, squeeze=False) - np.testing.assert_equal(T, response.time) - np.testing.assert_equal(u, response.inputs) - np.testing.assert_equal(x, response.states) + np.testing.assert_array_almost_equal(T, response.time) + np.testing.assert_array_almost_equal(u, response.inputs) + np.testing.assert_array_almost_equal(x, response.states) @pytest.mark.parametrize( "basis", @@ -713,7 +713,7 @@ def test_basis_class(self, basis): for j in range(basis.N): coefs = np.zeros(basis.N) coefs[j] = 1 - np.testing.assert_equal( + np.testing.assert_array_almost_equal( basis.eval(coefs, timepts), basis.eval_deriv(j, 0, timepts)) else: @@ -722,7 +722,7 @@ def test_basis_class(self, basis): for j in range(basis.var_ncoefs(i)): coefs = np.zeros(basis.var_ncoefs(i)) coefs[j] = 1 - np.testing.assert_equal( + np.testing.assert_array_almost_equal( basis.eval(coefs, timepts, var=i), basis.eval_deriv(j, 0, timepts, var=i)) @@ -732,7 +732,7 @@ def test_basis_class(self, basis): for j in range(basis.var_ncoefs(i)): coefs = np.zeros(basis.N) coefs[offset] = 1 - np.testing.assert_equal( + np.testing.assert_array_almost_equal( basis.eval(coefs, timepts)[i], basis.eval_deriv(j, 0, timepts, var=i)) offset += 1 From e13f57f3e7929b82cf8a5b909065ad9d01115b93 Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Sun, 18 Dec 2022 21:37:08 +0100 Subject: [PATCH 128/157] increase nulps --- control/tests/delay_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/control/tests/delay_test.py b/control/tests/delay_test.py index 25f37eeb5..24263c3b8 100644 --- a/control/tests/delay_test.py +++ b/control/tests/delay_test.py @@ -52,8 +52,8 @@ def testTvalues(self, T, dendeg, numdeg, baseden, basenum): refnum /= refden[0] refden /= refden[0] num, den = pade(T, dendeg, numdeg) - np.testing.assert_array_almost_equal_nulp(refden, den, nulp=2) - np.testing.assert_array_almost_equal_nulp(refnum, num, nulp=2) + np.testing.assert_array_almost_equal_nulp(refden, den, nulp=4) + np.testing.assert_array_almost_equal_nulp(refnum, num, nulp=4) def testErrors(self): "ValueError raised for invalid arguments" From 53522222ac4ca2f9055a87f09758d7b7343abd10 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Tue, 20 Dec 2022 20:56:51 -0800 Subject: [PATCH 129/157] fix FRD test errors (use exact freq points) --- control/tests/frd_test.py | 150 ++++++++++++++++++++------------------ 1 file changed, 80 insertions(+), 70 deletions(-) diff --git a/control/tests/frd_test.py b/control/tests/frd_test.py index ff88c3dea..1a383c2a7 100644 --- a/control/tests/frd_test.py +++ b/control/tests/frd_test.py @@ -51,72 +51,74 @@ def testOperators(self): h1 = TransferFunction([1], [1, 2, 2]) h2 = TransferFunction([1], [0.1, 1]) omega = np.logspace(-1, 2, 10) + chkpts = omega[::3] f1 = FRD(h1, omega) f2 = FRD(h2, omega) np.testing.assert_array_almost_equal( - (f1 + f2).frequency_response([0.1, 1.0, 10])[0], - (h1 + h2).frequency_response([0.1, 1.0, 10])[0]) + (f1 + f2).frequency_response(chkpts)[0], + (h1 + h2).frequency_response(chkpts)[0]) np.testing.assert_array_almost_equal( - (f1 + f2).frequency_response([0.1, 1.0, 10])[1], - (h1 + h2).frequency_response([0.1, 1.0, 10])[1]) + (f1 + f2).frequency_response(chkpts)[1], + (h1 + h2).frequency_response(chkpts)[1]) np.testing.assert_array_almost_equal( - (f1 - f2).frequency_response([0.1, 1.0, 10])[0], - (h1 - h2).frequency_response([0.1, 1.0, 10])[0]) + (f1 - f2).frequency_response(chkpts)[0], + (h1 - h2).frequency_response(chkpts)[0]) np.testing.assert_array_almost_equal( - (f1 - f2).frequency_response([0.1, 1.0, 10])[1], - (h1 - h2).frequency_response([0.1, 1.0, 10])[1]) + (f1 - f2).frequency_response(chkpts)[1], + (h1 - h2).frequency_response(chkpts)[1]) # multiplication and division np.testing.assert_array_almost_equal( - (f1 * f2).frequency_response([0.1, 1.0, 10])[1], - (h1 * h2).frequency_response([0.1, 1.0, 10])[1]) + (f1 * f2).frequency_response(chkpts)[1], + (h1 * h2).frequency_response(chkpts)[1]) np.testing.assert_array_almost_equal( - (f1 / f2).frequency_response([0.1, 1.0, 10])[1], - (h1 / h2).frequency_response([0.1, 1.0, 10])[1]) + (f1 / f2).frequency_response(chkpts)[1], + (h1 / h2).frequency_response(chkpts)[1]) # with default conversion from scalar np.testing.assert_array_almost_equal( - (f1 * 1.5).frequency_response([0.1, 1.0, 10])[1], - (h1 * 1.5).frequency_response([0.1, 1.0, 10])[1]) + (f1 * 1.5).frequency_response(chkpts)[1], + (h1 * 1.5).frequency_response(chkpts)[1]) np.testing.assert_array_almost_equal( - (f1 / 1.7).frequency_response([0.1, 1.0, 10])[1], - (h1 / 1.7).frequency_response([0.1, 1.0, 10])[1]) + (f1 / 1.7).frequency_response(chkpts)[1], + (h1 / 1.7).frequency_response(chkpts)[1]) np.testing.assert_array_almost_equal( - (2.2 * f2).frequency_response([0.1, 1.0, 10])[1], - (2.2 * h2).frequency_response([0.1, 1.0, 10])[1]) + (2.2 * f2).frequency_response(chkpts)[1], + (2.2 * h2).frequency_response(chkpts)[1]) np.testing.assert_array_almost_equal( - (1.3 / f2).frequency_response([0.1, 1.0, 10])[1], - (1.3 / h2).frequency_response([0.1, 1.0, 10])[1]) + (1.3 / f2).frequency_response(chkpts)[1], + (1.3 / h2).frequency_response(chkpts)[1]) def testOperatorsTf(self): # get two SISO transfer functions h1 = TransferFunction([1], [1, 2, 2]) h2 = TransferFunction([1], [0.1, 1]) omega = np.logspace(-1, 2, 10) + chkpts = omega[::3] f1 = FRD(h1, omega) f2 = FRD(h2, omega) f2 # reference to avoid pyflakes error np.testing.assert_array_almost_equal( - (f1 + h2).frequency_response([0.1, 1.0, 10])[0], - (h1 + h2).frequency_response([0.1, 1.0, 10])[0]) + (f1 + h2).frequency_response(chkpts)[0], + (h1 + h2).frequency_response(chkpts)[0]) np.testing.assert_array_almost_equal( - (f1 + h2).frequency_response([0.1, 1.0, 10])[1], - (h1 + h2).frequency_response([0.1, 1.0, 10])[1]) + (f1 + h2).frequency_response(chkpts)[1], + (h1 + h2).frequency_response(chkpts)[1]) np.testing.assert_array_almost_equal( - (f1 - h2).frequency_response([0.1, 1.0, 10])[0], - (h1 - h2).frequency_response([0.1, 1.0, 10])[0]) + (f1 - h2).frequency_response(chkpts)[0], + (h1 - h2).frequency_response(chkpts)[0]) np.testing.assert_array_almost_equal( - (f1 - h2).frequency_response([0.1, 1.0, 10])[1], - (h1 - h2).frequency_response([0.1, 1.0, 10])[1]) + (f1 - h2).frequency_response(chkpts)[1], + (h1 - h2).frequency_response(chkpts)[1]) # multiplication and division np.testing.assert_array_almost_equal( - (f1 * h2).frequency_response([0.1, 1.0, 10])[1], - (h1 * h2).frequency_response([0.1, 1.0, 10])[1]) + (f1 * h2).frequency_response(chkpts)[1], + (h1 * h2).frequency_response(chkpts)[1]) np.testing.assert_array_almost_equal( - (f1 / h2).frequency_response([0.1, 1.0, 10])[1], - (h1 / h2).frequency_response([0.1, 1.0, 10])[1]) + (f1 / h2).frequency_response(chkpts)[1], + (h1 / h2).frequency_response(chkpts)[1]) # the reverse does not work def testbdalg(self): @@ -124,49 +126,51 @@ def testbdalg(self): h1 = TransferFunction([1], [1, 2, 2]) h2 = TransferFunction([1], [0.1, 1]) omega = np.logspace(-1, 2, 10) + chkpts = omega[::3] f1 = FRD(h1, omega) f2 = FRD(h2, omega) np.testing.assert_array_almost_equal( - (bdalg.series(f1, f2)).frequency_response([0.1, 1.0, 10])[0], - (bdalg.series(h1, h2)).frequency_response([0.1, 1.0, 10])[0]) + (bdalg.series(f1, f2)).frequency_response(chkpts)[0], + (bdalg.series(h1, h2)).frequency_response(chkpts)[0]) np.testing.assert_array_almost_equal( - (bdalg.parallel(f1, f2)).frequency_response([0.1, 1.0, 10])[0], - (bdalg.parallel(h1, h2)).frequency_response([0.1, 1.0, 10])[0]) + (bdalg.parallel(f1, f2)).frequency_response(chkpts)[0], + (bdalg.parallel(h1, h2)).frequency_response(chkpts)[0]) np.testing.assert_array_almost_equal( - (bdalg.feedback(f1, f2)).frequency_response([0.1, 1.0, 10])[0], - (bdalg.feedback(h1, h2)).frequency_response([0.1, 1.0, 10])[0]) + (bdalg.feedback(f1, f2)).frequency_response(chkpts)[0], + (bdalg.feedback(h1, h2)).frequency_response(chkpts)[0]) np.testing.assert_array_almost_equal( - (bdalg.negate(f1)).frequency_response([0.1, 1.0, 10])[0], - (bdalg.negate(h1)).frequency_response([0.1, 1.0, 10])[0]) + (bdalg.negate(f1)).frequency_response(chkpts)[0], + (bdalg.negate(h1)).frequency_response(chkpts)[0]) # append() and connect() not implemented for FRD objects # np.testing.assert_array_almost_equal( -# (bdalg.append(f1, f2)).frequency_response([0.1, 1.0, 10])[0], -# (bdalg.append(h1, h2)).frequency_response([0.1, 1.0, 10])[0]) +# (bdalg.append(f1, f2)).frequency_response(chkpts)[0], +# (bdalg.append(h1, h2)).frequency_response(chkpts)[0]) # # f3 = bdalg.append(f1, f2, f2) # h3 = bdalg.append(h1, h2, h2) # Q = np.mat([ [1, 2], [2, -1] ]) # np.testing.assert_array_almost_equal( -# (bdalg.connect(f3, Q, [2], [1])).frequency_response([0.1, 1.0, 10])[0], -# (bdalg.connect(h3, Q, [2], [1])).frequency_response([0.1, 1.0, 10])[0]) +# (bdalg.connect(f3, Q, [2], [1])).frequency_response(chkpts)[0], +# (bdalg.connect(h3, Q, [2], [1])).frequency_response(chkpts)[0]) def testFeedback(self): h1 = TransferFunction([1], [1, 2, 2]) omega = np.logspace(-1, 2, 10) + chkpts = omega[::3] f1 = FRD(h1, omega) np.testing.assert_array_almost_equal( - f1.feedback(1).frequency_response([0.1, 1.0, 10])[0], - h1.feedback(1).frequency_response([0.1, 1.0, 10])[0]) + f1.feedback(1).frequency_response(chkpts)[0], + h1.feedback(1).frequency_response(chkpts)[0]) # Make sure default argument also works np.testing.assert_array_almost_equal( - f1.feedback().frequency_response([0.1, 1.0, 10])[0], - h1.feedback().frequency_response([0.1, 1.0, 10])[0]) + f1.feedback().frequency_response(chkpts)[0], + h1.feedback().frequency_response(chkpts)[0]) def testFeedback2(self): h2 = StateSpace([[-1.0, 0], [0, -2.0]], [[0.4], [0.1]], @@ -197,13 +201,14 @@ def testMIMO(self): [[1.0, 0.0], [0.0, 1.0]], [[0.0, 0.0], [0.0, 0.0]]) omega = np.logspace(-1, 2, 10) + chkpts = omega[::3] f1 = FRD(sys, omega) np.testing.assert_array_almost_equal( - sys.frequency_response([0.1, 1.0, 10])[0], - f1.frequency_response([0.1, 1.0, 10])[0]) + sys.frequency_response(chkpts)[0], + f1.frequency_response(chkpts)[0]) np.testing.assert_array_almost_equal( - sys.frequency_response([0.1, 1.0, 10])[1], - f1.frequency_response([0.1, 1.0, 10])[1]) + sys.frequency_response(chkpts)[1], + f1.frequency_response(chkpts)[1]) @slycotonly def testMIMOfb(self): @@ -212,14 +217,15 @@ def testMIMOfb(self): [[1.0, 0.0], [0.0, 1.0]], [[0.0, 0.0], [0.0, 0.0]]) omega = np.logspace(-1, 2, 10) + chkpts = omega[::3] f1 = FRD(sys, omega).feedback([[0.1, 0.3], [0.0, 1.0]]) f2 = FRD(sys.feedback([[0.1, 0.3], [0.0, 1.0]]), omega) np.testing.assert_array_almost_equal( - f1.frequency_response([0.1, 1.0, 10])[0], - f2.frequency_response([0.1, 1.0, 10])[0]) + f1.frequency_response(chkpts)[0], + f2.frequency_response(chkpts)[0]) np.testing.assert_array_almost_equal( - f1.frequency_response([0.1, 1.0, 10])[1], - f2.frequency_response([0.1, 1.0, 10])[1]) + f1.frequency_response(chkpts)[1], + f2.frequency_response(chkpts)[1]) @slycotonly def testMIMOfb2(self): @@ -229,15 +235,16 @@ def testMIMOfb2(self): np.array([[1.0, 0], [0, 0], [0, 1]]), np.eye(3), np.zeros((3, 2))) omega = np.logspace(-1, 2, 10) + chkpts = omega[::3] K = np.array([[1, 0.3, 0], [0.1, 0, 0]]) f1 = FRD(sys, omega).feedback(K) f2 = FRD(sys.feedback(K), omega) np.testing.assert_array_almost_equal( - f1.frequency_response([0.1, 1.0, 10])[0], - f2.frequency_response([0.1, 1.0, 10])[0]) + f1.frequency_response(chkpts)[0], + f2.frequency_response(chkpts)[0]) np.testing.assert_array_almost_equal( - f1.frequency_response([0.1, 1.0, 10])[1], - f2.frequency_response([0.1, 1.0, 10])[1]) + f1.frequency_response(chkpts)[1], + f2.frequency_response(chkpts)[1]) @slycotonly def testMIMOMult(self): @@ -246,14 +253,15 @@ def testMIMOMult(self): [[1.0, 0.0], [0.0, 1.0]], [[0.0, 0.0], [0.0, 0.0]]) omega = np.logspace(-1, 2, 10) + chkpts = omega[::3] f1 = FRD(sys, omega) f2 = FRD(sys, omega) np.testing.assert_array_almost_equal( - (f1*f2).frequency_response([0.1, 1.0, 10])[0], - (sys*sys).frequency_response([0.1, 1.0, 10])[0]) + (f1*f2).frequency_response(chkpts)[0], + (sys*sys).frequency_response(chkpts)[0]) np.testing.assert_array_almost_equal( - (f1*f2).frequency_response([0.1, 1.0, 10])[1], - (sys*sys).frequency_response([0.1, 1.0, 10])[1]) + (f1*f2).frequency_response(chkpts)[1], + (sys*sys).frequency_response(chkpts)[1]) @slycotonly def testMIMOSmooth(self): @@ -263,17 +271,18 @@ def testMIMOSmooth(self): [[0.0, 0.0], [0.0, 0.0], [0.0, 0.0]]) sys2 = np.array([[1, 0, 0], [0, 1, 0]]) * sys omega = np.logspace(-1, 2, 10) + chkpts = omega[::3] f1 = FRD(sys, omega, smooth=True) f2 = FRD(sys2, omega, smooth=True) np.testing.assert_array_almost_equal( - (f1*f2).frequency_response([0.1, 1.0, 10])[0], - (sys*sys2).frequency_response([0.1, 1.0, 10])[0]) + (f1*f2).frequency_response(chkpts)[0], + (sys*sys2).frequency_response(chkpts)[0]) np.testing.assert_array_almost_equal( - (f1*f2).frequency_response([0.1, 1.0, 10])[1], - (sys*sys2).frequency_response([0.1, 1.0, 10])[1]) + (f1*f2).frequency_response(chkpts)[1], + (sys*sys2).frequency_response(chkpts)[1]) np.testing.assert_array_almost_equal( - (f1*f2).frequency_response([0.1, 1.0, 10])[2], - (sys*sys2).frequency_response([0.1, 1.0, 10])[2]) + (f1*f2).frequency_response(chkpts)[2], + (sys*sys2).frequency_response(chkpts)[2]) def testAgainstOctave(self): # with data from octave: @@ -284,6 +293,7 @@ def testAgainstOctave(self): np.array([[1.0, 0], [0, 0], [0, 1]]), np.eye(3), np.zeros((3, 2))) omega = np.logspace(-1, 2, 10) + chkpts = omega[::3] f1 = FRD(sys, omega) np.testing.assert_array_almost_equal( (f1.frequency_response([1.0])[0] * From 9d72d79f38e4bf7bb91a7af4c76fc5141e4ceaad Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Thu, 22 Dec 2022 15:19:18 -0800 Subject: [PATCH 130/157] add OS/BLAS pip-based test matrix as GitHub workflow --- .github/scripts/set-pip-test-matrix.py | 28 ++++ .github/workflows/os-blas-test-matrix.yml | 176 ++++++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 .github/scripts/set-pip-test-matrix.py create mode 100644 .github/workflows/os-blas-test-matrix.yml diff --git a/.github/scripts/set-pip-test-matrix.py b/.github/scripts/set-pip-test-matrix.py new file mode 100644 index 000000000..ed18239d0 --- /dev/null +++ b/.github/scripts/set-pip-test-matrix.py @@ -0,0 +1,28 @@ +""" set-pip-test-matrix.py + +Create test matrix for pip wheels +""" +import json +from pathlib import Path + +system_opt_blas_libs = {'ubuntu': ['OpenBLAS'], + 'macos' : ['OpenBLAS', 'Apple']} + +wheel_jobs = [] +for wkey in Path("slycot-wheels").iterdir(): + wos, wpy, wbl = wkey.name.split("-") + wheel_jobs.append({'packagekey': wkey.name, + 'os': wos, + 'python': wpy, + 'blas_lib': wbl, + }) + if wbl == "Generic": + for bl in system_opt_blas_libs[wos]: + wheel_jobs.append({ 'packagekey': wkey.name, + 'os': wos, + 'python': wpy, + 'blas_lib': bl, + }) + +matrix = { 'include': wheel_jobs } +print(json.dumps(matrix)) \ No newline at end of file diff --git a/.github/workflows/os-blas-test-matrix.yml b/.github/workflows/os-blas-test-matrix.yml new file mode 100644 index 000000000..ff5b37744 --- /dev/null +++ b/.github/workflows/os-blas-test-matrix.yml @@ -0,0 +1,176 @@ +name: OS/BLAS test matrix + +on: push + +jobs: + build-pip: + name: Build pip Py${{ matrix.python }}, ${{ matrix.os }}, ${{ matrix.bla_vendor}} BLA_VENDOR + runs-on: ${{ matrix.os }}-latest + strategy: + fail-fast: false + matrix: + os: + - 'ubuntu' + - 'macos' + python: + - '3.8' + - '3.11' + bla_vendor: [ 'unset' ] + include: + - os: 'ubuntu' + python: '3.11' + bla_vendor: 'Generic' + - os: 'ubuntu' + python: '3.11' + bla_vendor: 'OpenBLAS' + - os: 'macos' + python: '3.11' + bla_vendor: 'Apple' + - os: 'macos' + python: '3.11' + bla_vendor: 'Generic' + - os: 'macos' + python: '3.11' + bla_vendor: 'OpenBLAS' + + steps: + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python }} + - name: Checkout Slycot + uses: actions/checkout@v3 + with: + repository: python-control/Slycot + fetch-depth: 0 + submodules: 'recursive' + - name: Setup Ubuntu + if: matrix.os == 'ubuntu' + run: | + sudo apt-get -y update + sudo apt-get -y install gfortran cmake --fix-missing + case ${{ matrix.bla_vendor }} in + unset | Generic ) sudo apt-get -y install libblas-dev liblapack-dev ;; + OpenBLAS ) sudo apt-get -y install libopenblas-dev ;; + *) + echo "bla_vendor option ${{ matrix.bla_vendor }} not supported" + exit 1 ;; + esac + - name: Setup macOS + if: matrix.os == 'macos' + run: | + case ${{ matrix.bla_vendor }} in + unset | Generic | Apple ) ;; # Found in system + OpenBLAS ) + brew install openblas + echo "BLAS_ROOT=/usr/local/opt/openblas/" >> $GITHUB_ENV + echo "LAPACK_ROOT=/usr/local/opt/openblas/" >> $GITHUB_ENV + ;; + *) + echo "bla_vendor option ${{ matrix.bla_vendor }} not supported" + exit 1 ;; + esac + echo "FC=gfortran-11" >> $GITHUB_ENV + - name: Build wheel + env: + BLA_VENDOR: ${{ matrix.bla_vendor }} + CMAKE_GENERATOR: Unix Makefiles + run: | + if [[ $BLA_VENDOR = unset ]]; then unset BLA_VENDOR; fi + python -m pip install --upgrade pip + pip wheel -v -w . . + wheeldir=slycot-wheels/${{ matrix.os }}-${{ matrix.python }}-${{ matrix.bla_vendor }} + mkdir -p ${wheeldir} + cp ./slycot*.whl ${wheeldir}/ + - name: Save wheel + uses: actions/upload-artifact@v3 + with: + name: slycot-wheels + path: slycot-wheels + + + create-wheel-test-matrix: + name: Create wheel test matrix + runs-on: ubuntu-latest + needs: build-pip + if: always() # run tests for all successful builds, even if others failed + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + steps: + - name: Checkout python-control + uses: actions/checkout@v3 + - name: Download wheels (if any) + uses: actions/download-artifact@v3 + with: + name: slycot-wheels + path: slycot-wheels + - id: set-matrix + run: echo "matrix=$(python3 .github/scripts/set-pip-test-matrix.py)" >> $GITHUB_OUTPUT + + + test-wheel: + name: Test wheel ${{ matrix.packagekey }}, ${{matrix.blas_lib}} BLAS lib ${{ matrix.failok }} + needs: create-wheel-test-matrix + runs-on: ${{ matrix.os }}-latest + continue-on-error: ${{ matrix.failok == 'FAILOK' }} + + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.create-wheel-test-matrix.outputs.matrix) }} + + steps: + - name: Checkout Slycot + uses: actions/checkout@v3 + with: + repository: 'python-control/Slycot' + path: slycot-src + - name: Checkout python-control + uses: actions/checkout@v3 + with: + repository: 'python-control/python-control' + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python }} + - name: Setup Ubuntu + if: matrix.os == 'ubuntu' + run: | + set -xe + sudo apt-get -y update + case ${{ matrix.blas_lib }} in + Generic ) sudo apt-get -y install libblas3 liblapack3 ;; + unset | OpenBLAS ) sudo apt-get -y install libopenblas-base ;; + *) + echo "BLAS ${{ matrix.blas_lib }} not supported for wheels on Ubuntu" + exit 1 ;; + esac + update-alternatives --display libblas.so.3-x86_64-linux-gnu + update-alternatives --display liblapack.so.3-x86_64-linux-gnu + - name: Setup macOS + if: matrix.os == 'macos' + run: | + set -xe + brew install coreutils + case ${{ matrix.blas_lib }} in + unset | Generic | Apple ) ;; # system provided (Uses Apple Accelerate Framework) + OpenBLAS ) + brew install openblas + echo "DYLIB_LIBRARY_PATH=/usr/local/opt/openblas/lib" >> $GITHUB_ENV + ;; + *) + echo "BLAS option ${{ matrix.blas_lib }} not supported for wheels on MacOS" + exit 1 ;; + esac + - name: Download wheels + uses: actions/download-artifact@v3 + with: + name: slycot-wheels + path: slycot-wheels + - name: Install Wheel + run: | + python -m pip install --upgrade pip + pip install matplotlib scipy pytest pytest-cov pytest-timeout coverage coveralls + pip install slycot-wheels/${{ matrix.packagekey }}/slycot*.whl + pip show slycot + - name: Test with pytest + run: pytest -v control/tests From 120120253a398c30a1cb0c28d590be6f6c7594e6 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Thu, 22 Dec 2022 22:12:53 -0800 Subject: [PATCH 131/157] add OS/BLAS conda-based test matrix as GitHub workflow --- .github/conda-env/build-env.yml | 4 + .github/conda-env/test-env.yml | 1 + .github/scripts/set-conda-test-matrix.py | 34 ++++++ .github/workflows/os-blas-test-matrix.yml | 139 ++++++++++++++++++++++ 4 files changed, 178 insertions(+) create mode 100644 .github/conda-env/build-env.yml create mode 100644 .github/scripts/set-conda-test-matrix.py diff --git a/.github/conda-env/build-env.yml b/.github/conda-env/build-env.yml new file mode 100644 index 000000000..f75973640 --- /dev/null +++ b/.github/conda-env/build-env.yml @@ -0,0 +1,4 @@ +name: build-env +dependencies: + - boa + - numpy !=1.23.0 diff --git a/.github/conda-env/test-env.yml b/.github/conda-env/test-env.yml index a4944f768..1c28589a4 100644 --- a/.github/conda-env/test-env.yml +++ b/.github/conda-env/test-env.yml @@ -1,5 +1,6 @@ name: test-env dependencies: + - conda-build # for conda index - pip - coverage - coveralls diff --git a/.github/scripts/set-conda-test-matrix.py b/.github/scripts/set-conda-test-matrix.py new file mode 100644 index 000000000..954480cb0 --- /dev/null +++ b/.github/scripts/set-conda-test-matrix.py @@ -0,0 +1,34 @@ +""" set-conda-test-matrix.py + +Create test matrix for conda packages +""" +import json, re +from pathlib import Path + +osmap = {'linux': 'ubuntu', + 'osx': 'macos', + 'win': 'windows', + } + +blas_implementations = ['unset', 'Generic', 'OpenBLAS', 'Intel10_64lp'] + +combinations = {'ubuntu': blas_implementations, + 'macos': blas_implementations, + 'windows': ['unset', 'Intel10_64lp'], + } + +conda_jobs = [] +for conda_pkg_file in Path("slycot-conda-pkgs").glob("*/*.tar.bz2"): + cos = osmap[conda_pkg_file.parent.name.split("-")[0]] + m = re.search(r'py(\d)(\d+)_', conda_pkg_file.name) + pymajor, pyminor = int(m[1]), int(m[2]) + cpy = f'{pymajor}.{pyminor}' + for cbl in combinations[cos]: + cjob = {'packagekey': f'{cos}-{cpy}', + 'os': cos, + 'python': cpy, + 'blas_lib': cbl} + conda_jobs.append(cjob) + +matrix = { 'include': conda_jobs } +print(json.dumps(matrix)) diff --git a/.github/workflows/os-blas-test-matrix.yml b/.github/workflows/os-blas-test-matrix.yml index ff5b37744..2a08da31e 100644 --- a/.github/workflows/os-blas-test-matrix.yml +++ b/.github/workflows/os-blas-test-matrix.yml @@ -89,6 +89,57 @@ jobs: path: slycot-wheels + build-conda: + name: Build conda Py${{ matrix.python }}, ${{ matrix.os }} + runs-on: ${{ matrix.os }}-latest + strategy: + fail-fast: false + matrix: + os: + - 'ubuntu' + - 'macos' + - 'windows' + python: + - '3.9' + - '3.11' + + steps: + - name: Checkout Slycot + uses: actions/checkout@v3 + with: + repository: python-control/Slycot + fetch-depth: 0 + submodules: 'recursive' + - name: Setup Conda + uses: conda-incubator/setup-miniconda@v2 + with: + python-version: ${{ matrix.python }} + activate-environment: build-env + environment-file: .github/conda-env/build-env.yml + miniforge-version: latest + miniforge-variant: Mambaforge + channel-priority: strict + auto-update-conda: false + auto-activate-base: false + - name: Conda build + shell: bash -l {0} + run: | + set -e + numpyversion=$(python -c 'import numpy; print(numpy.version.version)') + conda mambabuild --python "${{ matrix.python }}" --numpy $numpyversion conda-recipe + # preserve directory structure for custom conda channel + find "${CONDA_PREFIX}/conda-bld" -maxdepth 2 -name 'slycot*.tar.bz2' | while read -r conda_pkg; do + conda_platform=$(basename $(dirname "${conda_pkg}")) + mkdir -p "slycot-conda-pkgs/${conda_platform}" + cp "${conda_pkg}" "slycot-conda-pkgs/${conda_platform}/" + done + - name: Save to local conda pkg channel + uses: actions/upload-artifact@v3 + with: + name: slycot-conda-pkgs + path: slycot-conda-pkgs + + create-wheel-test-matrix: name: Create wheel test matrix runs-on: ubuntu-latest @@ -108,6 +159,25 @@ jobs: run: echo "matrix=$(python3 .github/scripts/set-pip-test-matrix.py)" >> $GITHUB_OUTPUT + create-conda-test-matrix: + name: Create conda test matrix + runs-on: ubuntu-latest + needs: build-conda + if: always() # run tests for all successful builds, even if others failed + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + steps: + - name: Checkout python-control + uses: actions/checkout@v3 + - name: Download conda packages + uses: actions/download-artifact@v3 + with: + name: slycot-conda-pkgs + path: slycot-conda-pkgs + - id: set-matrix + run: echo "matrix=$(python3 .github/scripts/set-conda-test-matrix.py)" >> $GITHUB_OUTPUT + + test-wheel: name: Test wheel ${{ matrix.packagekey }}, ${{matrix.blas_lib}} BLAS lib ${{ matrix.failok }} needs: create-wheel-test-matrix @@ -174,3 +244,72 @@ jobs: pip show slycot - name: Test with pytest run: pytest -v control/tests + + + test-conda: + name: Test conda ${{ matrix.packagekey }}, ${{matrix.blas_lib}} BLAS lib ${{ matrix.failok }} + needs: create-conda-test-matrix + runs-on: ${{ matrix.os }}-latest + continue-on-error: ${{ matrix.failok == 'FAILOK' }} + + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.create-conda-test-matrix.outputs.matrix) }} + + defaults: + run: + shell: bash -l {0} + + steps: + - name: Checkout Slycot + uses: actions/checkout@v3 + with: + repository: 'python-control/Slycot' + path: slycot-src + - name: Checkout python-control + uses: actions/checkout@v3 + - name: Setup macOS + if: matrix.os == 'macos' + run: brew install coreutils + - name: Setup Conda + uses: conda-incubator/setup-miniconda@v2 + with: + python-version: ${{ matrix.python }} + miniforge-version: latest + miniforge-variant: Mambaforge + activate-environment: test-env + environment-file: .github/conda-env/test-env.yml + channel-priority: strict + auto-activate-base: false + - name: Download conda packages + uses: actions/download-artifact@v3 + with: + name: slycot-conda-pkgs + path: slycot-conda-pkgs + - name: Install Conda package + run: | + set -e + case ${{ matrix.blas_lib }} in + unset ) # the conda-forge default (os dependent) + mamba install libblas libcblas liblapack + ;; + Generic ) + mamba install 'libblas=*=*netlib' 'libcblas=*=*netlib' 'liblapack=*=*netlib' + echo "libblas * *netlib" >> $CONDA_PREFIX/conda-meta/pinned + ;; + OpenBLAS ) + mamba install 'libblas=*=*openblas' openblas + echo "libblas * *openblas" >> $CONDA_PREFIX/conda-meta/pinned + ;; + Intel10_64lp ) + mamba install 'libblas=*=*mkl' mkl + echo "libblas * *mkl" >> $CONDA_PREFIX/conda-meta/pinned + ;; + esac + conda index --no-progress ./slycot-conda-pkgs + mamba install -c ./slycot-conda-pkgs slycot + conda list + - name: Test with pytest + run: JOBNAME=$JOBNAME pytest control/tests + env: + JOBNAME: ${{ matrix.packagekey }} ${{ matrix.blas_lib }} From f477c1336e983f2eafe5d79a4b9360b8d01d2464 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 23 Dec 2022 20:00:38 -0800 Subject: [PATCH 132/157] triger on pull_request --- .github/workflows/os-blas-test-matrix.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/os-blas-test-matrix.yml b/.github/workflows/os-blas-test-matrix.yml index 2a08da31e..fdd59763e 100644 --- a/.github/workflows/os-blas-test-matrix.yml +++ b/.github/workflows/os-blas-test-matrix.yml @@ -1,6 +1,6 @@ name: OS/BLAS test matrix -on: push +on: pull_request jobs: build-pip: From b8c4e5c9f961a17bab76d2395998a1f21882a61b Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 23 Dec 2022 20:55:37 -0800 Subject: [PATCH 133/157] add flatsys xfails for platform=ubunto, BLAS=Generic, numpy=1.24.0 --- control/tests/flatsys_test.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/control/tests/flatsys_test.py b/control/tests/flatsys_test.py index 5e3fe5d7c..4d4c8cf6b 100644 --- a/control/tests/flatsys_test.py +++ b/control/tests/flatsys_test.py @@ -13,6 +13,8 @@ import scipy as sp import re import warnings +import os +import platform import control as ct import control.flatsys as fs @@ -201,9 +203,26 @@ def test_kinematic_car_ocp( minimize_kwargs={'method': method}, ) xd, ud = traj_ocp.eval(timepts) + if not traj_ocp.success: - # If unsuccessful, make sure the error is just about precision - assert re.match(".*precision loss.*", traj_ocp.message) is not None + # Known failure cases + if re.match(".*precision loss.*", traj_ocp.message): + pytest.xfail("precision loss in some configurations") + + elif re.match("Iteration limit.*", traj_ocp.message) and \ + re.match("ubuntu-3.* Generic", os.getenv('JOBNAME')) and \ + np.__version__ == '1.24.0': + pytest.xfail("gh820: iteration limit exceeded") + + else: + # Dump out information to allow creation of an exception + print("Platform: ", platform.platform()) + print("Python: ", platform.python_version()) + np.show_config() + print("JOBNAME: ", os.getenv('JOBNAME')) + + pytest.fail( + "unknown failure; view output to identify configuration") # Make sure the constraints are satisfied if input_constraints: From 4d6a6bf7b53b8c4b074738a54f7696e5669f9acd Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 24 Dec 2022 07:05:56 -0800 Subject: [PATCH 134/157] change trigger to workflow_dispatch or PR against relevant files --- .github/workflows/os-blas-test-matrix.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/os-blas-test-matrix.yml b/.github/workflows/os-blas-test-matrix.yml index fdd59763e..fede19025 100644 --- a/.github/workflows/os-blas-test-matrix.yml +++ b/.github/workflows/os-blas-test-matrix.yml @@ -1,7 +1,15 @@ name: OS/BLAS test matrix -on: pull_request - +on: + workflow_dispatch: + pull_request: + paths: + - .github/workflows/os-blas-test-matrix.yml + - .github/scripts/set-conda-test-matrix.py + - .github/scripts/set-conda-pip-matrix.py + - .github/conda-env/build-env.yml + - .github/conda-env/test-env.yml + jobs: build-pip: name: Build pip Py${{ matrix.python }}, ${{ matrix.os }}, ${{ matrix.bla_vendor}} BLA_VENDOR From 5d0b3d0ab1ff003ba8772bf8debfc5301791119f Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 24 Dec 2022 13:59:14 -0800 Subject: [PATCH 135/157] add code + tests for discrete time find_eqpt --- control/iosys.py | 38 ++++++++-------- control/tests/iosys_test.py | 87 +++++++++++++++++++++++++++++++++---- 2 files changed, 100 insertions(+), 25 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index 2152092fc..58c99ff9d 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -2010,11 +2010,6 @@ def find_eqpt(sys, x0, u0=None, y0=None, t=0, params=None, if np.isscalar(y0): y0 = np.ones((ninputs,)) * y0 - # Discrete-time not yet supported - if isdtime(sys, strict=True): - raise NotImplementedError( - "Discrete time systems are not yet supported.") - # Make sure the input arguments match the sizes of the system if len(x0) != nstates or \ (u0 is not None and len(u0) != ninputs) or \ @@ -2030,18 +2025,28 @@ def find_eqpt(sys, x0, u0=None, y0=None, t=0, params=None, # Special cases: either inputs or outputs are constrained if y0 is None: # Take u0 as fixed and minimize over x - # TODO: update to allow discrete time systems - def ode_rhs(z): return sys._rhs(t, z, u0) - result = root(ode_rhs, x0) + if sys.isdtime(strict=True): + def state_rhs(z): return sys._rhs(t, z, u0) - z + else: + def state_rhs(z): return sys._rhs(t, z, u0) + + result = root(state_rhs, x0) z = (result.x, u0, sys._out(t, result.x, u0)) + else: # Take y0 as fixed and minimize over x and u - def rootfun(z): - # Split z into x and u - x, u = np.split(z, [nstates]) - # TODO: update to allow discrete time systems - return np.concatenate( - (sys._rhs(t, x, u), sys._out(t, x, u) - y0), axis=0) + if sys.isdtime(strict=True): + def rootfun(z): + x, u = np.split(z, [nstates]) + return np.concatenate( + (sys._rhs(t, x, u) - x, sys._out(t, x, u) - y0), + axis=0) + else: + def rootfun(z): + x, u = np.split(z, [nstates]) + return np.concatenate( + (sys._rhs(t, x, u), sys._out(t, x, u) - y0), axis=0) + z0 = np.concatenate((x0, u0), axis=0) # Put variables together result = root(rootfun, z0) # Find the eq point x, u = np.split(result.x, [nstates]) # Split result back in two @@ -2135,7 +2140,6 @@ def rootfun(z): # Keep track of the number of states in the set of free variables nstate_vars = len(state_vars) - dtime = isdtime(sys, strict=True) def rootfun(z): # Map the vector of values into the states and inputs @@ -2144,8 +2148,8 @@ def rootfun(z): # Compute the update and output maps dx = sys._rhs(t, x, u) - dx0 - if dtime: - dx -= x # TODO: check + if sys.isdtime(strict=True): + dx -= x # If no y0 is given, don't evaluate the output function if y0 is None: diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index c6b8d9d15..527f597ba 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -10,9 +10,10 @@ import re import warnings +import pytest import numpy as np -import pytest +from math import sqrt import control as ct from control import iosys as ios @@ -238,7 +239,7 @@ def test_linearize_named_signals(self, kincar): assert lin_nocopy.find_state('x') is None # if signal names are provided, they should override those of kincar - linearized_newnames = kincar.linearize([0, 0, 0], [0, 0], + linearized_newnames = kincar.linearize([0, 0, 0], [0, 0], name='linearized', copy_names=True, inputs=['v2', 'phi2'], outputs=['x2','y2']) assert linearized_newnames.name == 'linearized' @@ -766,8 +767,8 @@ def nlsys_output(t, x, u, params): np.testing.assert_allclose(ios_t, lin_t,atol=0.002,rtol=0.) np.testing.assert_allclose(ios_y, lin_y,atol=0.002,rtol=0.) - def test_find_eqpts(self, tsys): - """Test find_eqpt function""" + def test_find_eqpts_dfan(self, tsys): + """Test find_eqpt function on dfan example""" # Simple equilibrium point with no inputs nlsys = ios.NonlinearIOSystem(predprey) xeq, ueq, result = ios.find_eqpt( @@ -836,7 +837,7 @@ def test_find_eqpts(self, tsys): np.testing.assert_array_almost_equal( nlsys_full._rhs(0, xeq, ueq)[-4:], np.zeros((4,)), decimal=5) - # The same test as previous, but now all constraints are in the state vector + # Same test as before, but now all constraints are in the state vector nlsys_full = ios.NonlinearIOSystem(pvtol_full, None) xeq, ueq, result = ios.find_eqpt( nlsys_full, [0, 0, 0.1, 0.1, 0, 0], [0.01, 4*9.8], @@ -1482,7 +1483,7 @@ def test_linear_interconnection(): tf_siso = ct.tf(1, [0.1, 1]) ss_siso = ct.ss(1, 2, 1, 1) nl_siso = ios.NonlinearIOSystem( - lambda t, x, u, params: x*x, + lambda t, x, u, params: x*x, lambda t, x, u, params: u*x, states=1, inputs=1, outputs=1) # Create a "regular" InterconnectedSystem @@ -1530,7 +1531,7 @@ def test_linear_interconnection(): np.testing.assert_array_almost_equal(io_connect.C, ss_connect.C) np.testing.assert_array_almost_equal(io_connect.D, ss_connect.D) - # make sure interconnections of linear systems are linear and + # make sure interconnections of linear systems are linear and # if a nonlinear system is included then system is nonlinear assert isinstance(ss_siso*ss_siso, ios.LinearIOSystem) assert isinstance(tf_siso*ss_siso, ios.LinearIOSystem) @@ -1541,7 +1542,7 @@ def test_linear_interconnection(): assert ~isinstance(tf_siso*nl_siso, ios.LinearIOSystem) assert ~isinstance(nl_siso*tf_siso, ios.LinearIOSystem) assert ~isinstance(nl_siso*nl_siso, ios.LinearIOSystem) - + def predprey(t, x, u, params={}): """Predator prey dynamics""" @@ -1898,3 +1899,73 @@ def test_rss(): with pytest.warns(UserWarning, match="may be interpreted as continuous"): sys = ct.drss(2, 1, 1, dt=None) assert np.all(np.abs(sys.poles()) < 1) + + +def eqpt_rhs(t, x, u, params): + return np.array([x[0]/2 + u[0], x[0] - x[1]**2 + u[1], x[1] - x[2]]) + +def eqpt_out(t, x, u, params): + return np.array([x[0], x[1] + u[1]]) + +@pytest.mark.parametrize( + "x0, ix, u0, iu, y0, iy, dx0, idx, dt, x_expect, u_expect", [ + # Equilibrium points with input given + (0, None, 0, None, None, None, None, None, 0, [0, 0, 0], [0, 0]), + (0, None, 0, None, None, None, None, None, None, [0, 0, 0], [0, 0]), + ([0.9, 0.9, 0.9], None, [-1, 0], None, None, None, None, None, 0, + [2, sqrt(2), sqrt(2)], [-1, 0]), + ([0.9, -0.9, 0.9], None, [-1, 0], None, None, None, None, None, 0, + [2, -sqrt(2), -sqrt(2)], [-1, 0]), # same input, different eqpt + (0, None, 0, None, None, None, None, None, 1, [0, 0, 0], [0, 0]), #DT + (0, None, [-1, 0], None, None, None, None, None, 1, None, None), #DT + ([0, -0.1, 0], None, [0, -0.25], None, None, None, None, None, 1, #DT + [0, -0.5, -0.25], [0, -0.25]), + + # Equilibrium points with output given + ([0.9, 0.9, 0.9], None, [-0.9, 0], None, [2, sqrt(2)], None, None, + None, 0, [2, sqrt(2), sqrt(2)], [-1, 0]), + (0, None, [0, -0.25], None, [0, -0.75], None, None, None, 1, #DT + [0, -0.5, -0.25], [0, -0.25]), + + # Equilibrium points with mixture of inputs and outputs given + ([0.9, 0.9, 0.9], None, [-1, 0], [0], [2, sqrt(2)], [1], None, + None, 0, [2, sqrt(2), sqrt(2)], [-1, 0]), + (0, None, [0, -0.22], [0], [0, -0.75], [1], None, None, 1, #DT + [0, -0.5, -0.25], [0, -0.25]), + ]) + +def test_find_eqpt(x0, ix, u0, iu, y0, iy, dx0, idx, dt, x_expect, u_expect): + sys = ct.NonlinearIOSystem( + eqpt_rhs, eqpt_out, dt=dt, states=3, inputs=2, outputs=2) + + xeq, ueq = ct.find_eqpt( + sys, x0, u0, y0, ix=ix, iu=iu, iy=iy, dx0=dx0, idx=idx) + + # If no equilibrium points, skip remaining tests + if x_expect is None: + assert xeq is None + assert ueq is None + return + + # Make sure we are at an appropriate equilibrium point + if dt is None or dt == 0: + # Continuous time system + np.testing.assert_allclose(eqpt_rhs(0, xeq, ueq, {}), 0, atol=1e-6) + if y0 is not None: + y0 = np.array(y0) + iy = np.s_[:] if iy is None else np.array(iy) + np.testing.assert_allclose( + eqpt_out(0, xeq, ueq, {})[iy], y0[iy], atol=1e-6) + + else: + # Discrete time system + np.testing.assert_allclose(eqpt_rhs(0, xeq, ueq, {}), xeq, atol=1e-6) + if y0 is not None: + y0 = np.array(y0) + iy = np.s_[:] if iy is None else np.array(iy) + np.testing.assert_allclose( + eqpt_out(0, xeq, ueq, {})[iy], y0[iy], atol=1e-6) + + # Check that we got the expected result as well + np.testing.assert_allclose(np.array(xeq), x_expect, atol=1e-6) + np.testing.assert_allclose(np.array(ueq), u_expect, atol=1e-6) From 57ec9ff59135eb6c2b3cfba7edcb51e9e377ae39 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 25 Nov 2022 22:29:47 -0800 Subject: [PATCH 136/157] fix improper exception in timeresp.py --- control/timeresp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control/timeresp.py b/control/timeresp.py index da158e0cc..509107cc8 100644 --- a/control/timeresp.py +++ b/control/timeresp.py @@ -642,7 +642,7 @@ def __len__(self): # Convert to pandas def to_pandas(self): if not pandas_check(): - ImportError('pandas not installed') + raise ImportError("pandas not installed") import pandas # Create a dict for setting up the data frame From 9837a978cdd2d0dbf41e1f1c8d507b508f0ce698 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 25 Nov 2022 22:30:05 -0800 Subject: [PATCH 137/157] fix typo in describing_function docstring --- control/descfcn.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control/descfcn.py b/control/descfcn.py index 149db1bd2..9adcc5dd1 100644 --- a/control/descfcn.py +++ b/control/descfcn.py @@ -74,7 +74,7 @@ def _f(self, x): def describing_function( F, A, num_points=100, zero_check=True, try_method=True): - """Numerical compute the describing function of a nonlinear function + """Numerically compute the describing function of a nonlinear function The describing function of a nonlinearity is given by magnitude and phase of the first harmonic of the function when evaluated along a sinusoidal From 3955064c811e943b61b674ca12b4595e202c0be1 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 24 Dec 2022 18:38:21 -0800 Subject: [PATCH 138/157] handle lack of $JOBNAME in flatsys properly --- control/tests/flatsys_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control/tests/flatsys_test.py b/control/tests/flatsys_test.py index 4d4c8cf6b..db449b1d9 100644 --- a/control/tests/flatsys_test.py +++ b/control/tests/flatsys_test.py @@ -210,7 +210,7 @@ def test_kinematic_car_ocp( pytest.xfail("precision loss in some configurations") elif re.match("Iteration limit.*", traj_ocp.message) and \ - re.match("ubuntu-3.* Generic", os.getenv('JOBNAME')) and \ + re.match("ubuntu-3.* Generic", os.getenv('JOBNAME', '')) and \ np.__version__ == '1.24.0': pytest.xfail("gh820: iteration limit exceeded") From 363ea2b7823bebb0a05f6a6e4281610b9f8b7af5 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Mon, 26 Dec 2022 06:42:46 -0800 Subject: [PATCH 139/157] add quotes to JOBNAME expansion + build type --- .github/workflows/os-blas-test-matrix.yml | 8 +++++--- control/tests/flatsys_test.py | 3 ++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/os-blas-test-matrix.yml b/.github/workflows/os-blas-test-matrix.yml index fede19025..4470e2454 100644 --- a/.github/workflows/os-blas-test-matrix.yml +++ b/.github/workflows/os-blas-test-matrix.yml @@ -251,7 +251,9 @@ jobs: pip install slycot-wheels/${{ matrix.packagekey }}/slycot*.whl pip show slycot - name: Test with pytest - run: pytest -v control/tests + run: JOBNAME="$JOBNAME" pytest control/tests + env: + JOBNAME: wheel ${{ matrix.packagekey }} ${{ matrix.blas_lib }} test-conda: @@ -318,6 +320,6 @@ jobs: mamba install -c ./slycot-conda-pkgs slycot conda list - name: Test with pytest - run: JOBNAME=$JOBNAME pytest control/tests + run: JOBNAME="$JOBNAME" pytest control/tests env: - JOBNAME: ${{ matrix.packagekey }} ${{ matrix.blas_lib }} + JOBNAME: conda ${{ matrix.packagekey }} ${{ matrix.blas_lib }} diff --git a/control/tests/flatsys_test.py b/control/tests/flatsys_test.py index db449b1d9..196cddbd2 100644 --- a/control/tests/flatsys_test.py +++ b/control/tests/flatsys_test.py @@ -210,7 +210,8 @@ def test_kinematic_car_ocp( pytest.xfail("precision loss in some configurations") elif re.match("Iteration limit.*", traj_ocp.message) and \ - re.match("ubuntu-3.* Generic", os.getenv('JOBNAME', '')) and \ + re.match( + "conda ubuntu-3.* Generic", os.getenv('JOBNAME', '')) and \ np.__version__ == '1.24.0': pytest.xfail("gh820: iteration limit exceeded") From 5757f2f02fb979b857b94a6d7a26d6c1294728ae Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Mon, 26 Dec 2022 08:47:54 -0800 Subject: [PATCH 140/157] more careful checking of xfails in optimal_test.py --- control/tests/optimal_test.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/control/tests/optimal_test.py b/control/tests/optimal_test.py index c76859dfc..53f2c29ad 100644 --- a/control/tests/optimal_test.py +++ b/control/tests/optimal_test.py @@ -626,7 +626,7 @@ def final_point_eval(x, u): ('collocation', 5, 'u0', 'endpoint'), ('collocation', 5, 'input', 'openloop'),# open loop sim fails ('collocation', 10, 'input', None), - ('collocation', 10, 'u0', None), # from documenentation + ('collocation', 10, 'u0', None), # from documentation ('collocation', 10, 'state', None), ('collocation', 20, 'state', None), ]) @@ -716,9 +716,11 @@ def vehicle_output(t, x, u, params): # Make sure we started and stopped at the right spot if fail == 'endpoint': + assert not np.allclose(result.states[:, -1], xf, rtol=1e-4) pytest.xfail("optimization does not converge to endpoint") else: np.testing.assert_almost_equal(result.states[:, 0], x0, decimal=4) + np.testing.assert_almost_equal(result.states[:, -1], xf, decimal=2) # Simulate the trajectory to make sure it looks OK resp = ct.input_output_response( From 8710ee2df653a003f5412a97d8b344086fc29b9d Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Wed, 28 Dec 2022 08:39:43 -0800 Subject: [PATCH 141/157] initial implementation of gain scheduling --- control/iosys.py | 26 +++-- control/statefbk.py | 179 +++++++++++++++++++++++++-------- control/tests/iosys_test.py | 13 ++- control/tests/statefbk_test.py | 125 +++++++++++++++++++++++ 4 files changed, 290 insertions(+), 53 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index 2152092fc..5a3897200 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -536,6 +536,10 @@ def linearize(self, x0, u0, t=0, params=None, eps=1e-6, # functions. # + # If x0 and u0 are specified as lists, concatenate the elements + x0 = _concatenate_list_elements(x0, 'x0') + u0 = _concatenate_list_elements(u0, 'u0') + # Figure out dimensions if they were not specified. nstates = _find_size(self.nstates, x0) ninputs = _find_size(self.ninputs, u0) @@ -1758,14 +1762,7 @@ def input_output_response( ninputs = U.shape[0] # If we were passed a list of initial states, concatenate them - if isinstance(X0, (tuple, list)): - X0_list = [] - for i, x0 in enumerate(X0): - x0 = np.array(x0).reshape(-1) # convert everyting to 1D array - X0_list += x0.tolist() # add elements to initial state - - # Save the newly created input vector - X0 = np.array(X0_list) + X0 = _concatenate_list_elements(X0, 'X0') # If the initial state is too short, make it longer (NB: sys.nstates # could be None if nstates comes from size of initial condition) @@ -2377,6 +2374,19 @@ def ss(*args, **kwargs): return sys +# Utility function to allow lists states, inputs +def _concatenate_list_elements(X, name='X'): + # If we were passed a list, concatenate the elements together + if isinstance(X, (tuple, list)): + X_list = [] + for i, x in enumerate(X): + x = np.array(x).reshape(-1) # convert everyting to 1D array + X_list += x.tolist() # add elements to initial state + return np.array(X_list) + + # Otherwise, do nothing + return X + def rss(states=1, outputs=1, inputs=1, strictly_proper=False, **kwargs): """Create a stable random state space object. diff --git a/control/statefbk.py b/control/statefbk.py index 97f314da5..0901684a3 100644 --- a/control/statefbk.py +++ b/control/statefbk.py @@ -41,6 +41,7 @@ # External packages and modules import numpy as np +import scipy as sp from . import statesp from .mateqn import care, dare, _check_shape @@ -71,7 +72,7 @@ def sb03md(n, C, A, U, dico, job='X',fact='N',trana='N',ldwork=None): sb03od = None -__all__ = ['ctrb', 'obsv', 'gram', 'place', 'place_varga', 'lqr', +__all__ = ['ctrb', 'obsv', 'gram', 'place', 'place_varga', 'lqr', 'dlqr', 'acker', 'create_statefbk_iosystem'] @@ -600,8 +601,8 @@ def dlqr(*args, **kwargs): # Function to create an I/O sytems representing a state feedback controller def create_statefbk_iosystem( - sys, K, integral_action=None, xd_labels='xd[{i}]', ud_labels='ud[{i}]', - estimator=None, type='linear'): + sys, gain, integral_action=None, estimator=None, type=None, + xd_labels='xd[{i}]', ud_labels='ud[{i}]', gainsched_indices=None): """Create an I/O system using a (full) state feedback controller This function creates an input/output system that implements a @@ -617,26 +618,46 @@ def create_statefbk_iosystem( feedback gain (eg, from LQR). The function returns the controller ``ctrl`` and the closed loop systems ``clsys``, both as I/O systems. + A gain scheduled controller can also be created, by passing a list of + gains and a corresponding list of values of a set of scheduling + variables. In this case, the controller has the form + + u = ud - K_p(mu) (x - xd) - K_i(mu) integral(C x - C x_d) + + where mu represents the scheduling variable. + Parameters ---------- sys : InputOutputSystem The I/O system that represents the process dynamics. If no estimator is given, the output of this system should represent the full state. - K : ndarray - The state feedback gain. This matrix defines the gains to be - applied to the system. If ``integral_action`` is None, then the - dimensions of this array should be (sys.ninputs, sys.nstates). If - `integral action` is set to a matrix or a function, then additional - columns represent the gains of the integral states of the - controller. + gain : ndarray or tuple + If a array is give, it represents the state feedback gain (K). + This matrix defines the gains to be applied to the system. If + ``integral_action`` is None, then the dimensions of this array + should be (sys.ninputs, sys.nstates). If `integral action` is + set to a matrix or a function, then additional columns + represent the gains of the integral states of the controller. + + If a tuple is given, then it specifies a gain schedule. The + tuple should be of the form + + (gains, points[, method]) + + where gains is a list of gains :math:`K_j` and points is a list of + values :math:`\\mu_j` at which the gains are computed. If `method` + is specified, it is passed to :func:`scipy.interpolate.griddata` to + specify the method of interpolation. Possible values include + `linear`, `nearest`, and `cubic`. xd_labels, ud_labels : str or list of str, optional Set the name of the signals to use for the desired state and inputs. If a single string is specified, it should be a format string using - the variable ``i`` as an index. Otherwise, a list of strings matching - the size of xd and ud, respectively, should be used. Default is - ``'xd[{i}]'`` for xd_labels and ``'xd[{i}]'`` for ud_labels. + the variable ``i`` as an index. Otherwise, a list of strings + matching the size of xd and ud, respectively, should be used. + Default is ``'xd[{i}]'`` for xd_labels and ``'ud[{i}]'`` for + ud_labels. integral_action : None, ndarray, or func, optional If this keyword is specified, the controller can include integral @@ -650,30 +671,48 @@ def create_statefbk_iosystem( ``K`` matrix. estimator : InputOutputSystem, optional - If an estimator is provided, using the states of the estimator as + If an estimator is provided, use the states of the estimator as the system inputs for the controller. - type : 'nonlinear' or 'linear', optional - Set the type of controller to create. The default is a linear - controller implementing the LQR regulator. If the type is 'nonlinear', - a :class:NonlinearIOSystem is created instead, with the gain ``K`` as - a parameter (allowing modifications of the gain at runtime). + gainsched_indices : list of integers, optional + If a gain scheduled controller is specified, specify the indices of + the controller input to use for scheduling the gain. The input to + the controller is the desired state xd, the desired input ud, and + either the system state x or the system output y (if an estimator is + given). + + type : 'linear' or 'nonlinear', optional + Set the type of controller to create. The default for a linear gain + is a linear controller implementing the LQR regulator. If the type + is 'nonlinear', a :class:NonlinearIOSystem is created instead, with + the gain ``K`` as a parameter (allowing modifications of the gain at + runtime). If the gain parameter is a tuple, the a nonlinear, + gain-scheduled controller is created. Returns ------- ctrl : InputOutputSystem Input/output system representing the controller. This system takes - as inputs the desired state xd, the desired input ud, and the system - state x. It outputs the controller action u according to the - formula u = ud - K(x - xd). If the keyword `integral_action` is - specified, then an additional set of integrators is included in the - control system (with the gain matrix K having the integral gains - appended after the state gains). + as inputs the desired state xd, the desired input ud, and either the + system state x or the system output y (if an estimator is given). + It outputs the controller action u according to the formula u = ud - + K(x - xd). If the keyword `integral_action` is specified, then an + additional set of integrators is included in the control system + (with the gain matrix K having the integral gains appended after the + state gains). If a gain scheduled controller is specified, the gain + (proportional and integral) are evaluated using the input mu. clsys : InputOutputSystem Input/output system representing the closed loop system. This - systems takes as inputs the desired trajectory (xd, ud) and outputs - the system state x and the applied input u (vertically stacked). + systems takes as inputs the desired trajectory (xd, ud) along with + any unassigned gain scheduling values mu and outputs the system + state x and the applied input u (vertically stacked). + + Notes + ----- + 1. If the gain scheduling variable labes are set to the names of system + states, inputs, or outputs or desired states or inputs, then the + scheduling variables are internally connected to those variables. """ # Make sure that we were passed an I/O system as an input @@ -709,52 +748,104 @@ def create_statefbk_iosystem( C = np.zeros((0, sys.nstates)) # Check to make sure that state feedback has the right shape - if not isinstance(K, np.ndarray) or \ - K.shape != (sys.ninputs, estimator.noutputs + nintegrators): + if isinstance(gain, np.ndarray): + K = gain + if K.shape != (sys.ninputs, estimator.noutputs + nintegrators): + raise ControlArgument( + f'Control gain must be an array of size {sys.ninputs}' + f'x {sys.nstates}' + + (f'+{nintegrators}' if nintegrators > 0 else '')) + gainsched = False + + elif isinstance(gain, tuple): + # Check for gain scheduled controller + gains, points = gain[0:2] + method = 'nearest' if len(gain) < 3 else gain[2] + + # Stack gains and points if past as a list + gains = np.stack(gains) + points = np.stack(points) + gainsched=True + + else: + raise ControlArgument("gain must be an array or a tuple") + + # Decide on the type of system to create + if gainsched and type == 'linear': raise ControlArgument( - f'Control gain must be an array of size {sys.ninputs}' - f'x {sys.nstates}' + - (f'+{nintegrators}' if nintegrators > 0 else '')) + "type 'linear' not allowed for gain scheduled controller") + elif type is None: + type = 'nonlinear' if gainsched else 'linear' + elif type not in {'linear', 'nonlinear'}: + raise ControlArgument(f"unknown type '{type}'") # Figure out the labels to use if isinstance(xd_labels, str): - # Gnerate the list of labels using the argument as a format string + # Generate the list of labels using the argument as a format string xd_labels = [xd_labels.format(i=i) for i in range(sys.nstates)] if isinstance(ud_labels, str): - # Gnerate the list of labels using the argument as a format string + # Generate the list of labels using the argument as a format string ud_labels = [ud_labels.format(i=i) for i in range(sys.ninputs)] + # Process gainscheduling variables, if present + if gainsched: + # Create a copy of the scheduling variable indices (default = empty) + gainsched_indices = [] if gainsched_indices is None \ + else list(gainsched_indices) + + # Make sure the scheduling variable indices are the right length + if len(gainsched_indices) != points.shape[1]: + raise ControlArgument( + "Length of gainsched_indices must match dimension of" + " scheduling variables") + + # TODO: Process scheduling variables + # Define the controller system if type == 'nonlinear': # Create an I/O system for the state feedback gains - def _control_update(t, x, inputs, params): + def _control_update(t, states, inputs, params): # Split input into desired state, nominal input, and current state xd_vec = inputs[0:sys.nstates] x_vec = inputs[-estimator.nstates:] # Compute the integral error in the xy coordinates - return C @ x_vec - C @ xd_vec + return C @ (x_vec - xd_vec) + + def _compute_gain(mu, gains_, points_): + K = np.array([ + [sp.interpolate.griddata( + points_, gains_[:, i, j], mu, method=method).item() + for j in range(gains_.shape[2])] + for i in range(gains_.shape[1]) + ]) + return K - def _control_output(t, e, z, params): - K = params.get('K') + def _control_output(t, states, inputs, params): + if gainsched: + mu = inputs[gainsched_indices] + K_ = _compute_gain(mu, gains, points) + else: + K_ = params.get('K') # Split input into desired state, nominal input, and current state - xd_vec = z[0:sys.nstates] - ud_vec = z[sys.nstates:sys.nstates + sys.ninputs] - x_vec = z[-sys.nstates:] + xd_vec = inputs[0:sys.nstates] + ud_vec = inputs[sys.nstates:sys.nstates + sys.ninputs] + x_vec = inputs[-sys.nstates:] # Compute the control law - u = ud_vec - K[:, 0:sys.nstates] @ (x_vec - xd_vec) + u = ud_vec - K_[:, 0:sys.nstates] @ (x_vec - xd_vec) if nintegrators > 0: - u -= K[:, sys.nstates:] @ e + u -= K_[:, sys.nstates:] @ states return u + params = {} if gainsched else {'K': K} ctrl = NonlinearIOSystem( _control_update, _control_output, name='control', inputs=xd_labels + ud_labels + estimator.output_labels, - outputs=list(sys.input_index.keys()), params={'K': K}, + outputs=list(sys.input_index.keys()), params=params, states=nintegrators) elif type == 'linear' or type is None: diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index c6b8d9d15..71b59e5ce 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -1187,7 +1187,7 @@ def test_signals_naming_convention_0_8_4(self, tsys): assert "copy of namedsys.x0" in same_name_series.state_index def test_named_signals_linearize_inconsistent(self, tsys): - """Mare sure that providing inputs or outputs not consistent with + """Make sure that providing inputs or outputs not consistent with updfcn or outfcn fail """ @@ -1232,6 +1232,17 @@ def outfcn(t, x, u, params): with pytest.raises(ValueError): sys2.linearize(x0, u0) + def test_linearize_concatenation(self, kincar): + # Create a simple nonlinear system to check (kinematic car) + iosys = kincar + linearized = iosys.linearize([0, np.array([0, 0])], [0, 0]) + np.testing.assert_array_almost_equal(linearized.A, np.zeros((3,3))) + np.testing.assert_array_almost_equal( + linearized.B, [[1, 0], [0, 0], [0, 1]]) + np.testing.assert_array_almost_equal( + linearized.C, [[1, 0, 0], [0, 1, 0]]) + np.testing.assert_array_almost_equal(linearized.D, np.zeros((2,2))) + def test_lineariosys_statespace(self, tsys): """Make sure that a LinearIOSystem is also a StateSpace object""" iosys_siso = ct.LinearIOSystem(tsys.siso_linsys, name='siso') diff --git a/control/tests/statefbk_test.py b/control/tests/statefbk_test.py index 13f164e1f..80ad37b08 100644 --- a/control/tests/statefbk_test.py +++ b/control/tests/statefbk_test.py @@ -5,6 +5,8 @@ import numpy as np import pytest +import itertools +from math import pi, atan import control as ct from control import lqe, dlqe, poles, rss, ss, tf @@ -777,3 +779,126 @@ def test_statefbk_errors(self): with pytest.raises(ControlArgument, match="must be an array of size"): ctrl, clsys = ct.create_statefbk_iosystem( sys, K, integral_action=C_int) + + +# Kinematic car example for testing gain scheduling +@pytest.fixture +def unicycle(): + # Create a simple nonlinear system to check (kinematic car) + def unicycle_update(t, x, u, params): + return np.array([np.cos(x[2]) * u[0], np.sin(x[2]) * u[0], u[1]]) + + def unicycle_output(t, x, u, params): + return x + + return ct.NonlinearIOSystem( + unicycle_update, unicycle_output, + inputs = ['v', 'phi'], + outputs = ['x', 'y', 'theta'], + states = ['x_', 'y_', 'theta_']) + +from math import pi + +@pytest.mark.parametrize("method", [None, 'nearest']) +def test_gainsched_unicycle(unicycle, method): + # Speeds and angles at which to compute the gains + speeds = [1, 5, 10] + angles = -pi + np.linspace(0, 2*pi, 10) + points = list(itertools.product(speeds, angles)) + + # Gains for each speed (using LQR controller) + Q = np.identity(unicycle.nstates) + R = np.identity(unicycle.ninputs) + gains = [ct.lqr(unicycle.linearize( + [0, 0, angle], [speed, 0]), Q, R)[0] for speed, angle in points] + + # + # Schedule on desired speed and angle + # + + # Create gain scheduled controller + ctrl, clsys = ct.create_statefbk_iosystem( + unicycle, + (gains, points) if method is None else (gains, points, method), + gainsched_indices=[3, 2]) + + # Check the gain at the selected points + for speed, angle in points: + # Figure out the desired state and input + xe, ue = np.array([0, 0, angle]), np.array([speed, 0]) + xd, ud = np.array([0, 0, angle]), np.array([speed, 0]) + + # Check the control system at the scheduling points + ctrl_lin = ctrl.linearize([], [xd, ud, xe*0]) + K, S, E = ct.lqr(unicycle.linearize(xd, ud), Q, R) + np.testing.assert_allclose( + ctrl_lin.D[-xe.size:, -xe.size:], -K, rtol=1e-2) + + # Check the closed loop system at the scheduling points + clsys_lin = clsys.linearize(xe, [xd, ud]) + np.testing.assert_allclose(np.sort( + clsys_lin.poles()), np.sort(E), rtol=1e-2) + + # Run a simulation following a curved path + T = 10 # length of the trajectory [sec] + r = 10 # radius of the circle [m] + timepts = np.linspace(0, T, 100) + Xd = np.vstack([ + r * np.cos(timepts/T * pi/2 + 3*pi/2), + r * np.sin(timepts/T * pi/2 + 3*pi/2) + r, + timepts/T * pi/2 + ]) + Ud = np.vstack([ + np.ones_like(timepts) * (r * pi/2) / T, + np.ones_like(timepts) * (pi / 2) / T + ]) + X0 = Xd[:, 0] + np.array([-0.1, -0.1, -0.1]) + + resp = ct.input_output_response(clsys, timepts, [Xd, Ud], X0) + # plt.plot(resp.states[0], resp.states[1]) + np.testing.assert_allclose( + resp.states[:, -1], Xd[:, -1], atol=1e-2, rtol=1e-2) + + # + # Schedule on actual speed + # + + # Create gain scheduled controller + ctrl, clsys = ct.create_statefbk_iosystem( + unicycle, (gains, points), gainsched_indices=[3, 7]) + + # Check the gain at the selected points + for speed, angle in points: + # Figure out the desired state and input + xe, ue = np.array([0, 0, angle]), np.array([speed, 0]) + xd, ud = np.array([0, 0, angle]), np.array([speed, 0]) + + # Check the control system at the scheduling points + ctrl_lin = ctrl.linearize([], [xd*0, ud, xe]) + K, S, E = ct.lqr(unicycle.linearize(xe, ue), Q, R) + np.testing.assert_allclose( + ctrl_lin.D[-xe.size:, -xe.size:], -K, rtol=1e-2) + + # Check the closed loop system at the scheduling points + clsys_lin = clsys.linearize(xe, [xd, ud]) + np.testing.assert_allclose(np.sort( + clsys_lin.poles()), np.sort(E), rtol=1e-2) + + # Run a simulation following a curved path + T = 10 # length of the trajectory [sec] + r = 10 # radius of the circle [m] + timepts = np.linspace(0, T, 100) + Xd = np.vstack([ + r * np.cos(timepts/T * pi/2 + 3*pi/2), + r * np.sin(timepts/T * pi/2 + 3*pi/2) + r, + timepts/T * pi/2 + ]) + Ud = np.vstack([ + np.ones_like(timepts) * (r * pi/2) / T, + np.ones_like(timepts) * (pi / 2) / T + ]) + X0 = Xd[:, 0] + np.array([-0.1, -0.1, -0.1]) + + resp = ct.input_output_response(clsys, timepts, [Xd, Ud], X0) + np.testing.assert_allclose( + resp.states[:, -1], Xd[:, -1], atol=1e-2, rtol=1e-2) From 5fcf2b57dc3eec173ff91b215c99393b02e23eaa Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Wed, 28 Dec 2022 11:05:12 -0800 Subject: [PATCH 142/157] add processing for linear, cubic interpolation --- control/statefbk.py | 31 ++++++++++++++++-------- control/tests/statefbk_test.py | 43 +++++++++++++++++----------------- 2 files changed, 43 insertions(+), 31 deletions(-) diff --git a/control/statefbk.py b/control/statefbk.py index 0901684a3..199c63bb9 100644 --- a/control/statefbk.py +++ b/control/statefbk.py @@ -802,6 +802,26 @@ def create_statefbk_iosystem( # TODO: Process scheduling variables + # Create interpolating function + if method == 'nearest': + _interp = sp.interpolate.NearestNDInterpolator(points, gains) + _nearest = _interp + elif method == 'linear': + _interp = sp.interpolate.LinearNDInterpolator(points, gains) + _nearest = sp.interpolate.NearestNDInterpolator(points, gains) + elif method == 'cubic': + _interp = sp.interpolate.CloughTocher2DInterpolator(points, gains) + _nearest = sp.interpolate.NearestNDInterpolator(points, gains) + else: + raise ControlArgument( + f"unknown gain scheduling method '{method}'") + + def _compute_gain(mu): + K = _interp(mu) + if np.isnan(K).any(): + K = _nearest(mu) + return K + # Define the controller system if type == 'nonlinear': # Create an I/O system for the state feedback gains @@ -813,19 +833,10 @@ def _control_update(t, states, inputs, params): # Compute the integral error in the xy coordinates return C @ (x_vec - xd_vec) - def _compute_gain(mu, gains_, points_): - K = np.array([ - [sp.interpolate.griddata( - points_, gains_[:, i, j], mu, method=method).item() - for j in range(gains_.shape[2])] - for i in range(gains_.shape[1]) - ]) - return K - def _control_output(t, states, inputs, params): if gainsched: mu = inputs[gainsched_indices] - K_ = _compute_gain(mu, gains, points) + K_ = _compute_gain(mu) else: K_ = params.get('K') diff --git a/control/tests/statefbk_test.py b/control/tests/statefbk_test.py index 80ad37b08..998e94c85 100644 --- a/control/tests/statefbk_test.py +++ b/control/tests/statefbk_test.py @@ -799,18 +799,18 @@ def unicycle_output(t, x, u, params): from math import pi -@pytest.mark.parametrize("method", [None, 'nearest']) +@pytest.mark.parametrize("method", [None, 'nearest', 'linear', 'cubic']) def test_gainsched_unicycle(unicycle, method): # Speeds and angles at which to compute the gains speeds = [1, 5, 10] - angles = -pi + np.linspace(0, 2*pi, 10) + angles = np.linspace(0, pi/2, 4) points = list(itertools.product(speeds, angles)) # Gains for each speed (using LQR controller) Q = np.identity(unicycle.nstates) R = np.identity(unicycle.ninputs) - gains = [ct.lqr(unicycle.linearize( - [0, 0, angle], [speed, 0]), Q, R)[0] for speed, angle in points] + gains = [np.array(ct.lqr(unicycle.linearize( + [0, 0, angle], [speed, 0]), Q, R)[0]) for speed, angle in points] # # Schedule on desired speed and angle @@ -836,13 +836,28 @@ def test_gainsched_unicycle(unicycle, method): # Check the closed loop system at the scheduling points clsys_lin = clsys.linearize(xe, [xd, ud]) - np.testing.assert_allclose(np.sort( - clsys_lin.poles()), np.sort(E), rtol=1e-2) + np.testing.assert_allclose( + np.sort(clsys_lin.poles()), np.sort(E), rtol=1e-2) + + # Check the gain at an intermediate point and confirm stability + speed, angle = 2, pi/3 + xe, ue = np.array([0, 0, angle]), np.array([speed, 0]) + xd, ud = np.array([0, 0, angle]), np.array([speed, 0]) + clsys_lin = clsys.linearize(xe, [xd, ud]) + assert np.all(np.real(clsys_lin.poles()) < 0) + + # Make sure that gains are different from 'nearest' + if method is not None and method != 'nearest': + ctrl_nearest, clsys_nearest = ct.create_statefbk_iosystem( + unicycle, (gains, points, 'nearest'), gainsched_indices=[3, 2]) + nearest_lin = clsys_nearest.linearize(xe, [xd, ud]) + assert not np.allclose( + np.sort(clsys_lin.poles()), np.sort(nearest_lin.poles()), rtol=1e-2) # Run a simulation following a curved path T = 10 # length of the trajectory [sec] r = 10 # radius of the circle [m] - timepts = np.linspace(0, T, 100) + timepts = np.linspace(0, T, 50) Xd = np.vstack([ r * np.cos(timepts/T * pi/2 + 3*pi/2), r * np.sin(timepts/T * pi/2 + 3*pi/2) + r, @@ -885,20 +900,6 @@ def test_gainsched_unicycle(unicycle, method): clsys_lin.poles()), np.sort(E), rtol=1e-2) # Run a simulation following a curved path - T = 10 # length of the trajectory [sec] - r = 10 # radius of the circle [m] - timepts = np.linspace(0, T, 100) - Xd = np.vstack([ - r * np.cos(timepts/T * pi/2 + 3*pi/2), - r * np.sin(timepts/T * pi/2 + 3*pi/2) + r, - timepts/T * pi/2 - ]) - Ud = np.vstack([ - np.ones_like(timepts) * (r * pi/2) / T, - np.ones_like(timepts) * (pi / 2) / T - ]) - X0 = Xd[:, 0] + np.array([-0.1, -0.1, -0.1]) - resp = ct.input_output_response(clsys, timepts, [Xd, Ud], X0) np.testing.assert_allclose( resp.states[:, -1], Xd[:, -1], atol=1e-2, rtol=1e-2) From 6e56b733830cb339f82abd4ef7d07d90911e878e Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Wed, 28 Dec 2022 11:33:44 -0800 Subject: [PATCH 143/157] add gainsched label processing + unit tests for errors --- control/statefbk.py | 15 +++++++---- control/tests/statefbk_test.py | 46 ++++++++++++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/control/statefbk.py b/control/statefbk.py index 199c63bb9..5ec989cf7 100644 --- a/control/statefbk.py +++ b/control/statefbk.py @@ -788,6 +788,9 @@ def create_statefbk_iosystem( # Generate the list of labels using the argument as a format string ud_labels = [ud_labels.format(i=i) for i in range(sys.ninputs)] + # Create the string of labels for the control system + input_labels = xd_labels + ud_labels + estimator.output_labels + # Process gainscheduling variables, if present if gainsched: # Create a copy of the scheduling variable indices (default = empty) @@ -797,10 +800,13 @@ def create_statefbk_iosystem( # Make sure the scheduling variable indices are the right length if len(gainsched_indices) != points.shape[1]: raise ControlArgument( - "Length of gainsched_indices must match dimension of" + "length of gainsched_indices must match dimension of" " scheduling variables") - # TODO: Process scheduling variables + # Process scheduling variables + for i, idx in enumerate(gainsched_indices): + if isinstance(idx, str): + gainsched_indices[i] = input_labels.index(gainsched_indices[i]) # Create interpolating function if method == 'nearest': @@ -855,9 +861,8 @@ def _control_output(t, states, inputs, params): params = {} if gainsched else {'K': K} ctrl = NonlinearIOSystem( _control_update, _control_output, name='control', - inputs=xd_labels + ud_labels + estimator.output_labels, - outputs=list(sys.input_index.keys()), params=params, - states=nintegrators) + inputs=input_labels, outputs=list(sys.input_index.keys()), + params=params, states=nintegrators) elif type == 'linear' or type is None: # Create the matrices implementing the controller diff --git a/control/tests/statefbk_test.py b/control/tests/statefbk_test.py index 998e94c85..792fc59b7 100644 --- a/control/tests/statefbk_test.py +++ b/control/tests/statefbk_test.py @@ -849,7 +849,8 @@ def test_gainsched_unicycle(unicycle, method): # Make sure that gains are different from 'nearest' if method is not None and method != 'nearest': ctrl_nearest, clsys_nearest = ct.create_statefbk_iosystem( - unicycle, (gains, points, 'nearest'), gainsched_indices=[3, 2]) + unicycle, (gains, points, 'nearest'), + gainsched_indices=['ud[0]', 2]) nearest_lin = clsys_nearest.linearize(xe, [xd, ud]) assert not np.allclose( np.sort(clsys_lin.poles()), np.sort(nearest_lin.poles()), rtol=1e-2) @@ -880,7 +881,8 @@ def test_gainsched_unicycle(unicycle, method): # Create gain scheduled controller ctrl, clsys = ct.create_statefbk_iosystem( - unicycle, (gains, points), gainsched_indices=[3, 7]) + unicycle, (gains, points), + ud_labels=['vd', 'phid'], gainsched_indices=['vd', 'theta']) # Check the gain at the selected points for speed, angle in points: @@ -903,3 +905,43 @@ def test_gainsched_unicycle(unicycle, method): resp = ct.input_output_response(clsys, timepts, [Xd, Ud], X0) np.testing.assert_allclose( resp.states[:, -1], Xd[:, -1], atol=1e-2, rtol=1e-2) + +def test_gainsched_errors(unicycle): + # Set up gain schedule (same as previous test) + speeds = [1, 5, 10] + angles = np.linspace(0, pi/2, 4) + points = list(itertools.product(speeds, angles)) + + Q = np.identity(unicycle.nstates) + R = np.identity(unicycle.ninputs) + gains = [np.array(ct.lqr(unicycle.linearize( + [0, 0, angle], [speed, 0]), Q, R)[0]) for speed, angle in points] + + # Make sure the generic case works OK + ctrl, clsys = ct.create_statefbk_iosystem( + unicycle, (gains, points), gainsched_indices=[3, 2]) + xd, ud = np.array([0, 0, angles[0]]), np.array([speeds[0], 0]) + ctrl_lin = ctrl.linearize([], [xd, ud, xd*0]) + K, S, E = ct.lqr(unicycle.linearize(xd, ud), Q, R) + np.testing.assert_allclose( + ctrl_lin.D[-xd.size:, -xd.size:], -K, rtol=1e-2) + + # Wrong type of gain schedule argument + with pytest.raises(ControlArgument, match="gain must be an array"): + ctrl, clsys = ct.create_statefbk_iosystem( + unicycle, [gains, points], gainsched_indices=[3, 2]) + + # Mismatched dimensions for gains and points + with pytest.raises(ControlArgument, match="length of gainsched_indices"): + ctrl, clsys = ct.create_statefbk_iosystem( + unicycle, (gains, [speeds]), gainsched_indices=[3, 2]) + + # Unknown gain scheduling variable label + with pytest.raises(ValueError, match=".* not in list"): + ctrl, clsys = ct.create_statefbk_iosystem( + unicycle, (gains, points), gainsched_indices=['stuff', 2]) + + # Unknown gain scheduling method + with pytest.raises(ControlArgument, match="unknown gain scheduling method"): + ctrl, clsys = ct.create_statefbk_iosystem( + unicycle, (gains, points, 'stuff'), gainsched_indices=[3, 2]) From 0bde5710c6a770054f50cb02f524b3a0e59b7ef2 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Wed, 28 Dec 2022 12:25:06 -0800 Subject: [PATCH 144/157] add documentation for continuous vs discrete find_eqpt computation --- control/iosys.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/control/iosys.py b/control/iosys.py index 58c99ff9d..23429b308 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -1994,6 +1994,14 @@ def find_eqpt(sys, x0, u0=None, y0=None, t=0, params=None, If `return_result` is True, returns the `result` from the :func:`scipy.optimize.root` function. + Notes + ----- + For continuous time systems, equilibrium points are defined as points for + which the right hand side of the differential equation is zero: + :math:`f(t, x_e, u_e) = 0`. For discrete time systems, equilibrium points + are defined as points for which the right hand side of the difference + equation returns the current state: :math:`f(t, x_e, u_e) = x_e`. + """ from scipy.optimize import root From 21aa0a6fd563e360b23d8957a7aac6f27df4b937 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Wed, 28 Dec 2022 17:50:30 -0800 Subject: [PATCH 145/157] add examples, update documentation, small refactoring of code --- control/statefbk.py | 117 +++++++++++++++++++-------------- control/tests/statefbk_test.py | 19 ++++-- doc/iosys.rst | 96 ++++++++++++++++++++++++--- doc/steering-gainsched.rst | 2 + examples/steering-gainsched.py | 95 ++++++++++++++++++++++++-- 5 files changed, 259 insertions(+), 70 deletions(-) diff --git a/control/statefbk.py b/control/statefbk.py index 5ec989cf7..9052d159d 100644 --- a/control/statefbk.py +++ b/control/statefbk.py @@ -602,7 +602,9 @@ def dlqr(*args, **kwargs): # Function to create an I/O sytems representing a state feedback controller def create_statefbk_iosystem( sys, gain, integral_action=None, estimator=None, type=None, - xd_labels='xd[{i}]', ud_labels='ud[{i}]', gainsched_indices=None): + xd_labels='xd[{i}]', ud_labels='ud[{i}]', gainsched_indices=None, + gainsched_method='linear', name=None, inputs=None, outputs=None, + states=None): """Create an I/O system using a (full) state feedback controller This function creates an input/output system that implements a @@ -640,16 +642,11 @@ def create_statefbk_iosystem( set to a matrix or a function, then additional columns represent the gains of the integral states of the controller. - If a tuple is given, then it specifies a gain schedule. The - tuple should be of the form - - (gains, points[, method]) - - where gains is a list of gains :math:`K_j` and points is a list of - values :math:`\\mu_j` at which the gains are computed. If `method` - is specified, it is passed to :func:`scipy.interpolate.griddata` to - specify the method of interpolation. Possible values include - `linear`, `nearest`, and `cubic`. + If a tuple is given, then it specifies a gain schedule. The tuple + should be of the form ``(gains, points)`` where gains is a list of + gains :math:`K_j` and points is a list of values :math:`\\mu_j` at + which the gains are computed. The `gainsched_indices` parameter + should be used to specify the scheduling variables. xd_labels, ud_labels : str or list of str, optional Set the name of the signals to use for the desired state and inputs. @@ -657,12 +654,13 @@ def create_statefbk_iosystem( the variable ``i`` as an index. Otherwise, a list of strings matching the size of xd and ud, respectively, should be used. Default is ``'xd[{i}]'`` for xd_labels and ``'ud[{i}]'`` for - ud_labels. + ud_labels. These settings can also be overriden using the `inputs` + keyword. integral_action : None, ndarray, or func, optional If this keyword is specified, the controller can include integral - action in addition to state feedback. If ``integral_action`` is an - ndarray, it will be multiplied by the current and desired state to + action in addition to state feedback. If ``integral_action`` is a + matrix, it will be multiplied by the current and desired state to generate the error for the internal integrator states of the control law. If ``integral_action`` is a function ``h``, that function will be called with the signature h(t, x, u, params) to obtain the @@ -674,19 +672,28 @@ def create_statefbk_iosystem( If an estimator is provided, use the states of the estimator as the system inputs for the controller. - gainsched_indices : list of integers, optional + gainsched_indices : list of int or str, optional If a gain scheduled controller is specified, specify the indices of - the controller input to use for scheduling the gain. The input to + the controller input to use for scheduling the gain. The input to the controller is the desired state xd, the desired input ud, and either the system state x or the system output y (if an estimator is - given). + given). The indices can either be specified as integer offsets into + the input vector or as strings matching the signal names of the + input vector. + + gainsched_method : str, optional + The method to use for gain scheduling. Possible values include + `linear` (default), `nearest`, and `cubic`. More information is + available in :func:`scipy.interpolate.griddata`. For points outside + of the convex hull of the scheduling points, the gain at the nearest + point is used. type : 'linear' or 'nonlinear', optional Set the type of controller to create. The default for a linear gain is a linear controller implementing the LQR regulator. If the type is 'nonlinear', a :class:NonlinearIOSystem is created instead, with the gain ``K`` as a parameter (allowing modifications of the gain at - runtime). If the gain parameter is a tuple, the a nonlinear, + runtime). If the gain parameter is a tuple, then a nonlinear, gain-scheduled controller is created. Returns @@ -694,25 +701,30 @@ def create_statefbk_iosystem( ctrl : InputOutputSystem Input/output system representing the controller. This system takes as inputs the desired state xd, the desired input ud, and either the - system state x or the system output y (if an estimator is given). - It outputs the controller action u according to the formula u = ud - - K(x - xd). If the keyword `integral_action` is specified, then an - additional set of integrators is included in the control system - (with the gain matrix K having the integral gains appended after the - state gains). If a gain scheduled controller is specified, the gain - (proportional and integral) are evaluated using the input mu. + system state x or the estimated state xhat. It outputs the + controller action u according to the formula u = ud - K(x - xd). If + the keyword `integral_action` is specified, then an additional set + of integrators is included in the control system (with the gain + matrix K having the integral gains appended after the state gains). + If a gain scheduled controller is specified, the gain (proportional + and integral) are evaluated using the scheduling variables specified + by ``gainsched_indices``. clsys : InputOutputSystem Input/output system representing the closed loop system. This - systems takes as inputs the desired trajectory (xd, ud) along with - any unassigned gain scheduling values mu and outputs the system - state x and the applied input u (vertically stacked). - - Notes - ----- - 1. If the gain scheduling variable labes are set to the names of system - states, inputs, or outputs or desired states or inputs, then the - scheduling variables are internally connected to those variables. + systems takes as inputs the desired trajectory (xd, ud) and outputs + the system state x and the applied input u (vertically stacked). + + Other Parameters + ---------------- + inputs, outputs : str, or list of str, optional + List of strings that name the individual signals of the transformed + system. If not given, the inputs and outputs are the same as the + original system. + + name : string, optional + System name. If unspecified, a generic name is generated + with a unique integer id. """ # Make sure that we were passed an I/O system as an input @@ -759,8 +771,9 @@ def create_statefbk_iosystem( elif isinstance(gain, tuple): # Check for gain scheduled controller + if len(gain) != 2: + raise ControlArgument("gain must be a 2-tuple for gain scheduling") gains, points = gain[0:2] - method = 'nearest' if len(gain) < 3 else gain[2] # Stack gains and points if past as a list gains = np.stack(gains) @@ -788,8 +801,13 @@ def create_statefbk_iosystem( # Generate the list of labels using the argument as a format string ud_labels = [ud_labels.format(i=i) for i in range(sys.ninputs)] - # Create the string of labels for the control system - input_labels = xd_labels + ud_labels + estimator.output_labels + # Create the signal and system names + if inputs is None: + inputs = xd_labels + ud_labels + estimator.output_labels + if outputs is None: + outputs = list(sys.input_index.keys()) + if states is None: + states = nintegrators # Process gainscheduling variables, if present if gainsched: @@ -806,21 +824,22 @@ def create_statefbk_iosystem( # Process scheduling variables for i, idx in enumerate(gainsched_indices): if isinstance(idx, str): - gainsched_indices[i] = input_labels.index(gainsched_indices[i]) + gainsched_indices[i] = inputs.index(gainsched_indices[i]) # Create interpolating function - if method == 'nearest': + if gainsched_method == 'nearest': _interp = sp.interpolate.NearestNDInterpolator(points, gains) - _nearest = _interp - elif method == 'linear': + def _nearest(mu): + raise SystemError(f"could not find nearest gain at mu = {mu}") + elif gainsched_method == 'linear': _interp = sp.interpolate.LinearNDInterpolator(points, gains) _nearest = sp.interpolate.NearestNDInterpolator(points, gains) - elif method == 'cubic': + elif gainsched_method == 'cubic': _interp = sp.interpolate.CloughTocher2DInterpolator(points, gains) _nearest = sp.interpolate.NearestNDInterpolator(points, gains) else: raise ControlArgument( - f"unknown gain scheduling method '{method}'") + f"unknown gain scheduling method '{gainsched_method}'") def _compute_gain(mu): K = _interp(mu) @@ -860,9 +879,8 @@ def _control_output(t, states, inputs, params): params = {} if gainsched else {'K': K} ctrl = NonlinearIOSystem( - _control_update, _control_output, name='control', - inputs=input_labels, outputs=list(sys.input_index.keys()), - params=params, states=nintegrators) + _control_update, _control_output, name=name, inputs=inputs, + outputs=outputs, states=states, params=params) elif type == 'linear' or type is None: # Create the matrices implementing the controller @@ -879,9 +897,8 @@ def _control_output(t, states, inputs, params): ]) ctrl = ss( - A_lqr, B_lqr, C_lqr, D_lqr, dt=sys.dt, name='control', - inputs=xd_labels + ud_labels + estimator.output_labels, - outputs=list(sys.input_index.keys()), states=nintegrators) + A_lqr, B_lqr, C_lqr, D_lqr, dt=sys.dt, name=name, + inputs=inputs, outputs=outputs, states=states) else: raise ControlArgument(f"unknown type '{type}'") @@ -890,7 +907,7 @@ def _control_output(t, states, inputs, params): closed = interconnect( [sys, ctrl] if estimator == sys else [sys, ctrl, estimator], name=sys.name + "_" + ctrl.name, - inplist=xd_labels + ud_labels, inputs=xd_labels + ud_labels, + inplist=inputs[:-sys.nstates], inputs=inputs[:-sys.nstates], outlist=sys.output_labels + sys.input_labels, outputs=sys.output_labels + sys.input_labels ) diff --git a/control/tests/statefbk_test.py b/control/tests/statefbk_test.py index 792fc59b7..343c8790b 100644 --- a/control/tests/statefbk_test.py +++ b/control/tests/statefbk_test.py @@ -799,7 +799,7 @@ def unicycle_output(t, x, u, params): from math import pi -@pytest.mark.parametrize("method", [None, 'nearest', 'linear', 'cubic']) +@pytest.mark.parametrize("method", ['nearest', 'linear', 'cubic']) def test_gainsched_unicycle(unicycle, method): # Speeds and angles at which to compute the gains speeds = [1, 5, 10] @@ -818,9 +818,8 @@ def test_gainsched_unicycle(unicycle, method): # Create gain scheduled controller ctrl, clsys = ct.create_statefbk_iosystem( - unicycle, - (gains, points) if method is None else (gains, points, method), - gainsched_indices=[3, 2]) + unicycle, (gains, points), + gainsched_indices=[3, 2], gainsched_method=method) # Check the gain at the selected points for speed, angle in points: @@ -849,8 +848,8 @@ def test_gainsched_unicycle(unicycle, method): # Make sure that gains are different from 'nearest' if method is not None and method != 'nearest': ctrl_nearest, clsys_nearest = ct.create_statefbk_iosystem( - unicycle, (gains, points, 'nearest'), - gainsched_indices=['ud[0]', 2]) + unicycle, (gains, points), + gainsched_indices=['ud[0]', 2], gainsched_method='nearest') nearest_lin = clsys_nearest.linearize(xe, [xd, ud]) assert not np.allclose( np.sort(clsys_lin.poles()), np.sort(nearest_lin.poles()), rtol=1e-2) @@ -931,6 +930,11 @@ def test_gainsched_errors(unicycle): ctrl, clsys = ct.create_statefbk_iosystem( unicycle, [gains, points], gainsched_indices=[3, 2]) + # Wrong number of gain schedule argument + with pytest.raises(ControlArgument, match="gain must be a 2-tuple"): + ctrl, clsys = ct.create_statefbk_iosystem( + unicycle, (gains, speeds, angles), gainsched_indices=[3, 2]) + # Mismatched dimensions for gains and points with pytest.raises(ControlArgument, match="length of gainsched_indices"): ctrl, clsys = ct.create_statefbk_iosystem( @@ -944,4 +948,5 @@ def test_gainsched_errors(unicycle): # Unknown gain scheduling method with pytest.raises(ControlArgument, match="unknown gain scheduling method"): ctrl, clsys = ct.create_statefbk_iosystem( - unicycle, (gains, points, 'stuff'), gainsched_indices=[3, 2]) + unicycle, (gains, points), + gainsched_indices=[3, 2], gainsched_method='unknown') diff --git a/doc/iosys.rst b/doc/iosys.rst index 1da7f5884..994d4900e 100644 --- a/doc/iosys.rst +++ b/doc/iosys.rst @@ -73,18 +73,18 @@ We begin by defining the dynamics of the system d = params.get('d', 0.56) k = params.get('k', 125) r = params.get('r', 1.6) - + # Map the states into local variable names H = x[0] L = x[1] # Compute the control action (only allow addition of food) u_0 = u if u > 0 else 0 - + # Compute the discrete updates dH = (r + u_0) * H * (1 - H/k) - (a * H * L)/(c + H) dL = b * (a * H * L)/(c + H) - d * L - + return [dH, dL] We now create an input/output system using these dynamics: @@ -105,10 +105,10 @@ of the system: X0 = [25, 20] # Initial H, L T = np.linspace(0, 70, 500) # Simulation 70 years of time - + # Simulate the system t, y = ct.input_output_response(io_predprey, T, 0, X0) - + # Plot the response plt.figure(1) plt.plot(t, y[0]) @@ -168,14 +168,14 @@ function: inplist=['control.Ld'], outlist=['predprey.H', 'predprey.L', 'control.y[0]'] ) - + Finally, we simulate the closed loop system: .. code-block:: python # Simulate the system t, y = ct.input_output_response(io_closed, T, 30, [15, 20]) - + # Plot the response plt.figure(2) plt.subplot(2, 1, 1) @@ -198,7 +198,7 @@ Summing junction The :func:`~control.summing_junction` function can be used to create an input/output system that takes the sum of an arbitrary number of inputs. For -ezample, to create an input/output system that takes the sum of three inputs, +example, to create an input/output system that takes the sum of three inputs, use the command .. code-block:: python @@ -256,6 +256,86 @@ of the interconnected system) is not found, but inputs and outputs of individual systems that are not connected to other systems are left unconnected (so be careful!). +Automated creation of state feedback systems +-------------------------------------------- + +The :func:`~control.create_statefbk_iosystem` function can be used to +create an I/O system consisting of a state feedback gain (with +optional integral action and gain scheduling) and an estimator. A +basic state feedback controller of the form + +.. math:: + + u = u_\text{d} - K (x - x_\text{d}) + +can be created with the command:: + + ctrl, clsys = ct.create_statefbk_iosystem(sys, K) + +where `sys` is the process dynamics and `K` is the state feedback gain +(e.g., from LQR). The function returns the controller `ctrl` and the +closed loop systems `clsys`, both as I/O systems. The input to the +controller is the vector of desired states :math:`x_\text{d}`, desired +inputs :math:`u_\text{d}`, and system states :math:`x`. + +If the full system state is not available, the output of a state +estimator can be used to construct the controller using the command:: + + ctrl, clsys = ct.create_statefbk_iosystem(sys, K, estimator=estim) + +where `estim` is the state estimator I/O system. The controller will +have the same form as above, but with the system state :math:`x` +replaced by the estimated state :math:`\hat x` (output of `estim`). +The closed loop controller will include both the state feedback and +the estimator. + +Integral action can be included using the `integral_action` keyword. +The value of this keyword can either be an matrix (ndarray) or a +function. If a matrix :math:`C` is specified, the difference between +the desired state and system state will be multiplied by this matrix +and integrated. The controller gain should then consist of a set of +proportional gains :math:`K_\text{p}` and integral gains +:math:`K_\text{i}` with + +.. math:: + + K = \begin{bmatrix} K_\text{p} \\ K_\text{i} \end{bmatrix} + +and the control action will be given by + +.. math:: + + u = u_\text{d} - K\text{p} (x - x_\text{d}) - + K_\text{i} \int C (x - x_\text{d}) dt. + +(If an estimator is specified, :math:`\hat x` will be used in place of +:math:`x`.) + +Finally, gain scheduling on the desired state, desired input, or +system state can be implemented by setting the gain to a 2-tuple +consisting of a list of gains and a list of points at which the gains +were computed, as well as a description of the scheduling variables:: + + ctrl, clsys = ct.create_statefbk_iosystem( + sys, ([g1, ..., gN], [p1, ..., pN]), gainsched_indices=[s1, ..., sq]) + +The list of indices can either be integers indicating the offset into +the controller input vector :math:`(x_\text{d}, u_\text{d}, x)` or a +list of strings matching the names of the input signals. The +controller implemented in this case has the form + +.. math:: + + u = u_\text{d} - K(\mu) (x - x_\text{d}) + +where :math:`\mu` represents the scheduling variables. See +:ref:`steering-gainsched.py` for an example implementation of a gain +scheduled controller (in the alternative formulation section at the +bottom of the file). + +Integral action and state estimation can also be used with gain +scheduled controllers. + Module classes and functions ============================ diff --git a/doc/steering-gainsched.rst b/doc/steering-gainsched.rst index 511f76b8e..a5ec2e0c8 100644 --- a/doc/steering-gainsched.rst +++ b/doc/steering-gainsched.rst @@ -1,3 +1,5 @@ +.. _steering-gainsched.py: + Gain scheduled control for vehicle steeering (I/O system) --------------------------------------------------------- diff --git a/examples/steering-gainsched.py b/examples/steering-gainsched.py index 7ddc6b5b8..88eed9a95 100644 --- a/examples/steering-gainsched.py +++ b/examples/steering-gainsched.py @@ -70,7 +70,7 @@ def control_output(t, x, u, params): latpole1 = params.get('latpole1', -1/2 + sqrt(-7)/2) latpole2 = params.get('latpole2', -1/2 - sqrt(-7)/2) l = params.get('wheelbase', 3) - + # Extract the system inputs ex, ey, etheta, vd, phid = u @@ -85,7 +85,7 @@ def control_output(t, x, u, params): else: # We aren't moving, so don't turn the steering wheel phi = phid - + return np.array([v, phi]) # Define the controller as an input/output system @@ -135,7 +135,7 @@ def trajgen_output(t, x, u, params): # +----------- [-1] -----------+ # # We construct the system using the InterconnectedSystem constructor and using -# signal labels to keep track of everything. +# signal labels to keep track of everything. steering = ct.interconnect( # List of subsystems @@ -186,8 +186,93 @@ def trajgen_output(t, x, u, params): plt.plot([0, 5], [vref, vref], 'k--') # Plot the system output - y_line, = plt.plot(tout, yout[y_index, :], 'r') # lateral position - v_line, = plt.plot(tout, yout[v_index, :], 'b') # vehicle velocity + y_line, = plt.plot(tout, yout[y_index], 'r') # lateral position + v_line, = plt.plot(tout, yout[v_index], 'b') # vehicle velocity + +# Add axis labels +plt.xlabel('Time (s)') +plt.ylabel('x vel (m/s), y pos (m)') +plt.legend((v_line, y_line), ('v', 'y'), loc='center right', frameon=False) + +# +# Alternative formulation, using create_statefbk_iosystem() +# +# A different way to implement gain scheduling is to use the gain scheduling +# functionality built into the create_statefbk_iosystem() function, where we +# pass a table of gains instead of a single gain. To generate a more +# interesting plot, we scale the feedforward input to generate some error. +# + +import itertools +from math import pi + +# Define the points for the scheduling variables +speeds = [1, 10, 20] +angles = np.linspace(-pi, pi, 4) +points = list(itertools.product(speeds, angles)) + +# Create controllers at each scheduling point +Q = np.diag([1, 1, 1]) +R = np.diag([0.1, 0.1]) +gains = [np.array(ct.lqr(vehicle.linearize( + [0, 0, angle], [speed, 0]), Q, R)[0]) for speed, angle in points] + +# Create the gain scheduled system +controller, _ = ct.create_statefbk_iosystem( + vehicle, (gains, points), name='controller', ud_labels=['vd', 'phid'], + gainsched_indices=['vd', 'theta'], gainsched_method='linear') + +# Connect everything together (note that controller inputs are different +steering = ct.interconnect( + # List of subsystems + (trajgen, controller, vehicle), name='steering', + + # Interconnections between subsystems + connections=( + ['controller.xd[0]', 'trajgen.xd'], + ['controller.xd[1]', 'trajgen.yd'], + ['controller.xd[2]', 'trajgen.thetad'], + ['controller.x', 'vehicle.x'], + ['controller.y', 'vehicle.y'], + ['controller.theta', 'vehicle.theta'], + ['controller.vd', ('trajgen', 'vd', 0.2)], # create error + ['controller.phid', 'trajgen.phid'], + ['vehicle.v', 'controller.v'], + ['vehicle.phi', 'controller.phi'] + ), + + # System inputs + inplist=['trajgen.vref', 'trajgen.yref'], + inputs=['yref', 'vref'], + + # System outputs + outlist=['vehicle.x', 'vehicle.y', 'vehicle.theta', 'controller.v', + 'controller.phi'], + outputs=['x', 'y', 'theta', 'v', 'phi'] +) + +# Plot the results to compare to the previous case +plt.figure(); + +# Plot the reference trajectory for the y position +plt.plot([0, 5], [yref, yref], 'k--') + +# Find the signals we want to plot +y_index = steering.find_output('y') +v_index = steering.find_output('v') + +# Do an iteration through different speeds +for vref in [8, 10, 12]: + # Simulate the closed loop controller response + tout, yout = ct.input_output_response( + steering, T, [vref * np.ones(len(T)), yref * np.ones(len(T))]) + + # Plot the reference speed + plt.plot([0, 5], [vref, vref], 'k--') + + # Plot the system output + y_line, = plt.plot(tout, yout[y_index], 'r') # lateral position + v_line, = plt.plot(tout, yout[v_index], 'b') # vehicle velocity # Add axis labels plt.xlabel('Time (s)') From 0bfe92142367f4580cb173402c286b846e024bdb Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Wed, 28 Dec 2022 20:39:25 -0800 Subject: [PATCH 146/157] small documentation updates --- control/statefbk.py | 35 ++++++++++++++++++----------------- doc/iosys.rst | 8 ++++++-- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/control/statefbk.py b/control/statefbk.py index 9052d159d..87f191ff0 100644 --- a/control/statefbk.py +++ b/control/statefbk.py @@ -676,17 +676,17 @@ def create_statefbk_iosystem( If a gain scheduled controller is specified, specify the indices of the controller input to use for scheduling the gain. The input to the controller is the desired state xd, the desired input ud, and - either the system state x or the system output y (if an estimator is + the system state x (or state estimate xhat, if an estimator is given). The indices can either be specified as integer offsets into the input vector or as strings matching the signal names of the input vector. gainsched_method : str, optional - The method to use for gain scheduling. Possible values include - `linear` (default), `nearest`, and `cubic`. More information is - available in :func:`scipy.interpolate.griddata`. For points outside - of the convex hull of the scheduling points, the gain at the nearest - point is used. + The method to use for gain scheduling. Possible values are 'linear' + (default), 'nearest', and 'cubic'. More information is available in + :func:`scipy.interpolate.griddata`. For points outside of the convex + hull of the scheduling points, the gain at the nearest point is + used. type : 'linear' or 'nonlinear', optional Set the type of controller to create. The default for a linear gain @@ -700,20 +700,21 @@ def create_statefbk_iosystem( ------- ctrl : InputOutputSystem Input/output system representing the controller. This system takes - as inputs the desired state xd, the desired input ud, and either the - system state x or the estimated state xhat. It outputs the - controller action u according to the formula u = ud - K(x - xd). If - the keyword `integral_action` is specified, then an additional set - of integrators is included in the control system (with the gain - matrix K having the integral gains appended after the state gains). - If a gain scheduled controller is specified, the gain (proportional - and integral) are evaluated using the scheduling variables specified - by ``gainsched_indices``. + as inputs the desired state ``xd``, the desired input ``ud``, and + either the system state ``x`` or the estimated state ``xhat``. It + outputs the controller action u according to the formula :math:`u = + u_d - K(x - x_d)`. If the keyword ``integral_action`` is specified, + then an additional set of integrators is included in the control + system (with the gain matrix ``K`` having the integral gains + appended after the state gains). If a gain scheduled controller is + specified, the gain (proportional and integral) are evaluated using + the scheduling variables specified by ``gainsched_indices``. clsys : InputOutputSystem Input/output system representing the closed loop system. This - systems takes as inputs the desired trajectory (xd, ud) and outputs - the system state x and the applied input u (vertically stacked). + systems takes as inputs the desired trajectory ``(xd, ud)`` and + outputs the system state ``x`` and the applied input ``u`` + (vertically stacked). Other Parameters ---------------- diff --git a/doc/iosys.rst b/doc/iosys.rst index 994d4900e..1f5f21e69 100644 --- a/doc/iosys.rst +++ b/doc/iosys.rst @@ -308,8 +308,12 @@ and the control action will be given by u = u_\text{d} - K\text{p} (x - x_\text{d}) - K_\text{i} \int C (x - x_\text{d}) dt. -(If an estimator is specified, :math:`\hat x` will be used in place of -:math:`x`.) +If `integral_action` is a function `h`, that function will be called +with the signature `h(t, x, u, params)` to obtain the outputs that +should be integrated. The number of outputs that are to be integrated +must match the number of additional columns in the `K` matrix. If an +estimator is specified, :math:`\hat x` will be used in place of +:math:`x`. Finally, gain scheduling on the desired state, desired input, or system state can be implemented by setting the gain to a 2-tuple From da6b08880ad19a61ae6d3ef62b9de9cf4fd3cba4 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Wed, 28 Dec 2022 22:47:25 -0800 Subject: [PATCH 147/157] use numpydoc + linkcode --- control/descfcn.py | 4 +-- control/freqplot.py | 4 +-- control/iosys.py | 8 ++--- control/lti.py | 11 ++++--- control/sisotool.py | 2 +- control/statefbk.py | 4 --- doc/Makefile | 2 +- doc/conf.py | 78 +++++++++++++++++++++++++++++++++++++++++++-- 8 files changed, 92 insertions(+), 21 deletions(-) diff --git a/control/descfcn.py b/control/descfcn.py index 149db1bd2..cfcc02170 100644 --- a/control/descfcn.py +++ b/control/descfcn.py @@ -236,8 +236,8 @@ def describing_function_plot( given by the first value of the tuple and frequency given by the second value. - Example - ------- + Examples + -------- >>> H_simple = ct.tf([8], [1, 2, 2, 1]) >>> F_saturation = ct.descfcn.saturation_nonlinearity(1) >>> amp = np.linspace(1, 4, 10) diff --git a/control/freqplot.py b/control/freqplot.py index 05ae9da55..d34906855 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -589,8 +589,8 @@ def nyquist_plot( if `return_contour` is Tue. To obtain the Nyquist curve values, evaluate system(s) along contour. - Additional Parameters - --------------------- + Other Parameters + ---------------- arrows : int or 1D/2D array of floats, optional Specify the number of arrows to plot on the Nyquist curve. If an integer is passed. that number of equally spaced arrows will be diff --git a/control/iosys.py b/control/iosys.py index 2152092fc..76c8c8fd5 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -2713,8 +2713,8 @@ def interconnect(syslist, connections=None, inplist=None, outlist=None, generated and if `True` then warnings are always generated. - Example - ------- + Examples + -------- >>> P = control.LinearIOSystem( >>> control.rss(2, 2, 2, strictly_proper=True), name='P') >>> C = control.LinearIOSystem(control.rss(2, 2, 2), name='C') @@ -2914,8 +2914,8 @@ def summing_junction( Linear input/output system object with no states and only a direct term that implements the summing junction. - Example - ------- + Examples + -------- >>> P = control.tf2io(ct.tf(1, [1, 0]), inputs='u', outputs='y') >>> C = control.tf2io(ct.tf(10, [1, 1]), inputs='e', outputs='u') >>> sumblk = control.summing_junction(inputs=['r', '-y'], output='e') diff --git a/control/lti.py b/control/lti.py index b87944cd0..1bc08229d 100644 --- a/control/lti.py +++ b/control/lti.py @@ -304,8 +304,12 @@ def damp(sys, doprint=True): poles: array Pole locations - Algorithm - --------- + See Also + -------- + pole + + Notes + ----- If the system is continuous, wn = abs(poles) Z = -real(poles)/poles. @@ -320,9 +324,6 @@ def damp(sys, doprint=True): wn = abs(s) Z = -real(s)/wn. - See Also - -------- - pole """ wn, damping, poles = sys.damp() if doprint: diff --git a/control/sisotool.py b/control/sisotool.py index d8db7b082..2b735c0af 100644 --- a/control/sisotool.py +++ b/control/sisotool.py @@ -285,7 +285,7 @@ def rootlocus_pid_designer(plant, gain='P', sign=+1, input_signal='r', Whether to create Sisotool interactive plot. Returns - ---------- + ------- closedloop : class:`StateSpace` system The closed-loop system using initial gains. diff --git a/control/statefbk.py b/control/statefbk.py index 97f314da5..c88b98c54 100644 --- a/control/statefbk.py +++ b/control/statefbk.py @@ -126,10 +126,6 @@ def place(A, B, p): -------- place_varga, acker - Notes - ----- - The return type for 2D arrays depends on the default class set for - state space operations. See :func:`~control.use_numpy_matrix`. """ from scipy.signal import place_poles diff --git a/doc/Makefile b/doc/Makefile index 3f372684c..b2f9eaeed 100644 --- a/doc/Makefile +++ b/doc/Makefile @@ -20,5 +20,5 @@ classes.pdf: classes.fig; fig2dev -Lpdf $< $@ # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -html pdf: Makefile $(FIGS) +html pdf clean: Makefile $(FIGS) @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/doc/conf.py b/doc/conf.py index e2210aeaa..961179119 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -30,7 +30,7 @@ # -- Project information ----------------------------------------------------- project = u'Python Control Systems Library' -copyright = u'2020, python-control.org' +copyright = u'2022, python-control.org' author = u'Python Control Developers' # Version information - read from the source code @@ -56,7 +56,7 @@ extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.todo', 'sphinx.ext.napoleon', 'sphinx.ext.intersphinx', 'sphinx.ext.imgmath', - 'sphinx.ext.autosummary', 'nbsphinx', + 'sphinx.ext.autosummary', 'nbsphinx', 'numpydoc', 'sphinx.ext.linkcode' ] # scan documents for autosummary directives and generate stub pages for each. @@ -139,6 +139,80 @@ # # html_sidebars = {} +# ----------------------------------------------------------------------------- +# Source code links (from numpy) +# ----------------------------------------------------------------------------- + +import inspect +from os.path import relpath, dirname + +def linkcode_resolve(domain, info): + """ + Determine the URL corresponding to Python object + """ + if domain != 'py': + return None + + modname = info['module'] + fullname = info['fullname'] + + submod = sys.modules.get(modname) + if submod is None: + return None + + obj = submod + for part in fullname.split('.'): + try: + obj = getattr(obj, part) + except Exception: + return None + + # strip decorators, which would resolve to the source of the decorator + # possibly an upstream bug in getsourcefile, bpo-1764286 + try: + unwrap = inspect.unwrap + except AttributeError: + pass + else: + obj = unwrap(obj) + + # Get the filename for the function + try: + fn = inspect.getsourcefile(obj) + except Exception: + fn = None + if not fn: + return None + + # Ignore re-exports as their source files are not within the numpy repo + module = inspect.getmodule(obj) + if module is not None and not module.__name__.startswith("control"): + return None + + try: + source, lineno = inspect.getsourcelines(obj) + except Exception: + lineno = None + + fn = relpath(fn, start=dirname(control.__file__)) + + if lineno: + linespec = "#L%d-L%d" % (lineno, lineno + len(source) - 1) + else: + linespec = "" + + base_url = "https://github.com/python-control/python-control/blob/" + if 'dev' in control.__version__ or 'post' in control.__version__: + return base_url + "main/control/%s%s" % (fn, linespec) + else: + return base_url + "%s/control/%s%s" % ( + control.__version__, fn, linespec) + +# Don't automaticall show all members of class in Methods & Attributes section +numpydoc_show_class_members = False + +# Don't create a Sphinx TOC for the lists of class methods and attributes +numpydoc_class_members_toctree = False # -- Options for HTMLHelp output --------------------------------------------- From ad7ce5f76ce339e4097e8547dc5227b086d91e42 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Thu, 29 Dec 2022 12:14:33 -0800 Subject: [PATCH 148/157] add missing documentation for t_eval in input_output_response --- control/iosys.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/control/iosys.py b/control/iosys.py index 76c8c8fd5..a0d22a985 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -1618,6 +1618,10 @@ def input_output_response( number of states in the system, the initial condition will be padded with zeros. + t_eval : array-list, optional + List of times at which the time response should be computed. + Defaults to ``T``. + return_x : bool, optional If True, return the state vector when assigning to a tuple (default = False). See :func:`forced_response` for more details. From 51f3b6b3f9b3860813c5003dcb0e442a055be9e0 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 30 Dec 2022 08:29:03 -0800 Subject: [PATCH 149/157] set default for gainsched_indices to xd + add unit test --- control/statefbk.py | 6 +++--- control/tests/statefbk_test.py | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/control/statefbk.py b/control/statefbk.py index 87f191ff0..fa4ac3551 100644 --- a/control/statefbk.py +++ b/control/statefbk.py @@ -679,7 +679,7 @@ def create_statefbk_iosystem( the system state x (or state estimate xhat, if an estimator is given). The indices can either be specified as integer offsets into the input vector or as strings matching the signal names of the - input vector. + input vector. The default is to use the desire state xd. gainsched_method : str, optional The method to use for gain scheduling. Possible values are 'linear' @@ -812,8 +812,8 @@ def create_statefbk_iosystem( # Process gainscheduling variables, if present if gainsched: - # Create a copy of the scheduling variable indices (default = empty) - gainsched_indices = [] if gainsched_indices is None \ + # Create a copy of the scheduling variable indices (default = xd) + gainsched_indices = range(sys.nstates) if gainsched_indices is None \ else list(gainsched_indices) # Make sure the scheduling variable indices are the right length diff --git a/control/tests/statefbk_test.py b/control/tests/statefbk_test.py index 343c8790b..9e8feb4c9 100644 --- a/control/tests/statefbk_test.py +++ b/control/tests/statefbk_test.py @@ -905,6 +905,33 @@ def test_gainsched_unicycle(unicycle, method): np.testing.assert_allclose( resp.states[:, -1], Xd[:, -1], atol=1e-2, rtol=1e-2) + +def test_gainsched_default_indices(): + # Define a linear system to test + sys = ct.ss([[-1, 0.1], [0, -2]], [[0], [1]], np.eye(2), 0) + + # Define gains at origin + corners of unit cube + points = [[0, 0]] + list(itertools.product([-1, 1], [-1, 1])) + + # Define gain to be constant + K, _, _ = ct.lqr(sys, np.eye(sys.nstates), np.eye(sys.ninputs)) + gains = [K for p in points] + + # Define the paramters for the simulations + timepts = np.linspace(0, 10, 100) + X0 = np.ones(sys.nstates) * 0.9 + + # Create a controller and simulate the initial response + gs_ctrl, gs_clsys = ct.create_statefbk_iosystem(sys, (gains, points)) + gs_resp = ct.input_output_response(gs_clsys, timepts, 0, X0) + + # Verify that we get the same result as a constant gain + ck_clsys = ct.ss(sys.A - sys.B @ K, sys.B, sys.C, 0) + ck_resp = ct.input_output_response(ck_clsys, timepts, 0, X0) + + np.testing.assert_allclose(gs_resp.states, ck_resp.states) + + def test_gainsched_errors(unicycle): # Set up gain schedule (same as previous test) speeds = [1, 5, 10] From 700ff80ac73471aa7f492d343d957e8d4515884a Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 30 Dec 2022 09:19:08 -0800 Subject: [PATCH 150/157] continuous time system support for create_estimator_iosystem --- control/stochsys.py | 52 +++++++++++++++----- control/tests/stochsys_test.py | 88 +++++++++++++++++++++++++++------- 2 files changed, 113 insertions(+), 27 deletions(-) diff --git a/control/stochsys.py b/control/stochsys.py index 2b8233070..a10e566b8 100644 --- a/control/stochsys.py +++ b/control/stochsys.py @@ -20,7 +20,7 @@ import scipy as sp from math import sqrt -from .iosys import InputOutputSystem, NonlinearIOSystem +from .iosys import InputOutputSystem, LinearIOSystem, NonlinearIOSystem from .lti import LTI from .namedio import isctime, isdtime from .mateqn import care, dare, _check_shape @@ -384,8 +384,8 @@ def create_estimator_iosystem( """ # Make sure that we were passed an I/O system as an input - if not isinstance(sys, InputOutputSystem): - raise ControlArgument("Input system must be I/O system") + if not isinstance(sys, LinearIOSystem): + raise ControlArgument("Input system must be a linear I/O system") # Extract the matrices that we need for easy reference A, B = sys.A, sys.B @@ -409,7 +409,7 @@ def create_estimator_iosystem( # Initialize the covariance matrix if P0 is None: # Initalize P0 to the steady state value - _, P0, _ = lqe(A, G, C, QN, RN) + L0, P0, _ = lqe(A, G, C, QN, RN) # Figure out the labels to use if isinstance(state_labels, str): @@ -432,16 +432,46 @@ def create_estimator_iosystem( sensor_labels = [sensor_labels.format(i=i) for i in range(C.shape[0])] if isctime(sys): - raise NotImplementedError("continuous time not yet implemented") - - else: # Create an I/O system for the state feedback gains # Note: reshape vectors into column vectors for legacy np.matrix + + R_inv = np.linalg.inv(RN) + Reps_inv = C.T @ R_inv @ C + + def _estim_update(t, x, u, params): + # See if we are estimating or predicting + correct = params.get('correct', True) + + # Get the state of the estimator + xhat = x[0:sys.nstates].reshape(-1, 1) + P = x[sys.nstates:].reshape(sys.nstates, sys.nstates) + + # Extract the inputs to the estimator + y = u[0:C.shape[0]].reshape(-1, 1) + u = u[C.shape[0]:].reshape(-1, 1) + + # Compute the optimal gain + L = P @ C.T @ R_inv + + # Update the state estimate + dxhat = A @ xhat + B @ u # prediction + if correct: + dxhat -= L @ (C @ xhat - y) # correction + + # Update the covariance + dP = A @ P + P @ A.T + G @ QN @ G.T + if correct: + dP -= P @ Reps_inv @ P + + # Return the update + return np.hstack([dxhat.reshape(-1), dP.reshape(-1)]) + + else: def _estim_update(t, x, u, params): # See if we are estimating or predicting correct = params.get('correct', True) - # Get the state of the estimator + # Get the state of the estimator xhat = x[0:sys.nstates].reshape(-1, 1) P = x[sys.nstates:].reshape(sys.nstates, sys.nstates) @@ -449,7 +479,7 @@ def _estim_update(t, x, u, params): y = u[0:C.shape[0]].reshape(-1, 1) u = u[C.shape[0]:].reshape(-1, 1) - # Compute the optimal again + # Compute the optimal gain Reps_inv = np.linalg.inv(RN + C @ P @ C.T) L = A @ P @ C.T @ Reps_inv @@ -466,8 +496,8 @@ def _estim_update(t, x, u, params): # Return the update return np.hstack([dxhat.reshape(-1), dP.reshape(-1)]) - def _estim_output(t, x, u, params): - return x[0:sys.nstates] + def _estim_output(t, x, u, params): + return x[0:sys.nstates] # Define the estimator system return NonlinearIOSystem( diff --git a/control/tests/stochsys_test.py b/control/tests/stochsys_test.py index 11084d9db..8ec170fb4 100644 --- a/control/tests/stochsys_test.py +++ b/control/tests/stochsys_test.py @@ -7,6 +7,7 @@ import control as ct from control import lqe, dlqe, rss, drss, tf, ss, ControlArgument, slycot_check +from math import log, pi # Utility function to check LQE answer def check_LQE(L, P, poles, G, QN, RN): @@ -48,7 +49,7 @@ def test_lqe_call_format(cdlqe): # Standard calling format Lref, Pref, Eref = cdlqe(sys.A, sys.B, sys.C, Q, R) - + # Call with system instead of matricees L, P, E = cdlqe(sys, Q, R) np.testing.assert_almost_equal(Lref, L) @@ -58,15 +59,15 @@ def test_lqe_call_format(cdlqe): # Make sure we get an error if we specify N with pytest.raises(ct.ControlNotImplemented): L, P, E = cdlqe(sys, Q, R, N) - + # Inconsistent system dimensions with pytest.raises(ct.ControlDimension, match="Incompatible"): L, P, E = cdlqe(sys.A, sys.C, sys.B, Q, R) - + # Incorrect covariance matrix dimensions with pytest.raises(ct.ControlDimension, match="Incompatible"): L, P, E = cdlqe(sys.A, sys.B, sys.C, R, Q) - + # Too few input arguments with pytest.raises(ct.ControlArgument, match="not enough input"): L, P, E = cdlqe(sys.A, sys.C) @@ -99,14 +100,14 @@ def test_lqe_discrete(): np.testing.assert_almost_equal(K_csys, K_expl) np.testing.assert_almost_equal(S_csys, S_expl) np.testing.assert_almost_equal(E_csys, E_expl) - + # Calling lqe() with a discrete time system should call dlqe() K_lqe, S_lqe, E_lqe = ct.lqe(dsys, Q, R) K_dlqe, S_dlqe, E_dlqe = ct.dlqe(dsys, Q, R) np.testing.assert_almost_equal(K_lqe, K_dlqe) np.testing.assert_almost_equal(S_lqe, S_dlqe) np.testing.assert_almost_equal(E_lqe, E_dlqe) - + # Calling lqe() with no timebase should call lqe() asys = ct.ss(csys.A, csys.B, csys.C, csys.D, dt=None) K_asys, S_asys, E_asys = ct.lqe(asys, Q, R) @@ -114,11 +115,11 @@ def test_lqe_discrete(): np.testing.assert_almost_equal(K_asys, K_expl) np.testing.assert_almost_equal(S_asys, S_expl) np.testing.assert_almost_equal(E_asys, E_expl) - + # Calling dlqe() with a continuous time system should raise an error with pytest.raises(ControlArgument, match="called with a continuous"): K, S, E = ct.dlqe(csys, Q, R) - + def test_estimator_iosys(): sys = ct.drss(4, 2, 2, strictly_proper=True) @@ -129,7 +130,7 @@ def test_estimator_iosys(): QN = np.eye(sys.ninputs) RN = np.eye(sys.noutputs) estim = ct.create_estimator_iosystem(sys, QN, RN, P0) - + ctrl, clsys = ct.create_statefbk_iosystem(sys, K, estimator=estim) # Extract the elements of the estimator @@ -162,20 +163,75 @@ def test_estimator_iosys(): np.testing.assert_almost_equal(cls.D, D_clchk) +@pytest.mark.parametrize("sys_args", [ + ([[-1]], [[1]], [[1]], 0), # scalar system + ([[-1, 0.1], [0, -2]], [[0], [1]], [[1, 0]], 0), # SISO, 2 state + ([[-1, 0.1], [0, -2]], [[1, 0], [0, 1]], [[1, 0]], 0), # 2i, 1o, 2s + ([[-1, 0.1, 0.1], [0, -2, 0], [0.1, 0, -3]], # 2i, 2o, 3s + [[1, 0], [0, 0.1], [0, 1]], + [[1, 0, 0.1], [0, 1, 0.1]], 0), +]) +def test_estimator_iosys_ctime(sys_args): + # Define the system we want to test + sys = ct.ss(*sys_args) + T = 10 * log(1e-2) / np.max(sys.poles().real) + assert T > 0 + + # Create nonlinear version of the system to match integration methods + nl_sys = ct.NonlinearIOSystem( + lambda t, x, u, params : sys.A @ x + sys.B @ u, + lambda t, x, u, params : sys.C @ x + sys.D @ u, + inputs=sys.ninputs, outputs=sys.noutputs, states=sys.nstates) + + # Define an initial condition, inputs (small, to avoid integration errors) + timepts = np.linspace(0, T, 500) + U = 2e-2 * np.array([np.sin(timepts + i*pi/3) for i in range(sys.ninputs)]) + X0 = np.ones(sys.nstates) + + # Set up the parameters for the filter + P0 = np.eye(sys.nstates) + QN = np.eye(sys.ninputs) + RN = np.eye(sys.noutputs) + + # Construct the estimator + estim = ct.create_estimator_iosystem(sys, QN, RN) + + # Compute the system response and the optimal covariance + sys_resp = ct.input_output_response(nl_sys, timepts, U, X0) + _, Pf, _ = ct.lqe(sys, QN, RN) + + # Make sure that we converge to the optimal estimate + estim_resp = ct.input_output_response( + estim, timepts, [sys_resp.outputs, U], [0*X0, P0]) + np.testing.assert_allclose( + estim_resp.states[0:sys.nstates, -1], sys_resp.states[:, -1], + atol=1e-6, rtol=1e-3) + np.testing.assert_allclose( + estim_resp.states[sys.nstates:, -1], Pf.reshape(-1), + atol=1e-6, rtol=1e-3) + + # Make sure that optimal estimate is an eq pt + ss_resp = ct.input_output_response( + estim, timepts, [sys_resp.outputs, U], [X0, Pf]) + np.testing.assert_allclose( + ss_resp.states[sys.nstates:], + np.outer(Pf.reshape(-1), np.ones_like(timepts)), + atol=1e-4, rtol=1e-2) + np.testing.assert_allclose( + ss_resp.states[0:sys.nstates], sys_resp.states, + atol=1e-4, rtol=1e-2) + + def test_estimator_errors(): sys = ct.drss(4, 2, 2, strictly_proper=True) P0 = np.eye(sys.nstates) QN = np.eye(sys.ninputs) RN = np.eye(sys.noutputs) - with pytest.raises(ct.ControlArgument, match="Input system must be I/O"): + with pytest.raises(ct.ControlArgument, match=".* system must be a linear"): sys_tf = ct.tf([1], [1, 1], dt=True) estim = ct.create_estimator_iosystem(sys_tf, QN, RN) - - with pytest.raises(NotImplementedError, match="continuous time not"): - sys_ct = ct.rss(4, 2, 2, strictly_proper=True) - estim = ct.create_estimator_iosystem(sys_ct, QN, RN) - + with pytest.raises(ValueError, match="output must be full state"): C = np.eye(2, 4) estim = ct.create_estimator_iosystem(sys, QN, RN, C=C) @@ -246,7 +302,7 @@ def test_correlation(): # Try passing a second argument tau, Rneg = ct.correlation(T, V, -V) np.testing.assert_equal(Rtau, -Rneg) - + # Test error conditions with pytest.raises(ValueError, match="Time vector T must be 1D"): tau, Rtau = ct.correlation(V, V) From d31c9f4ae647e129bbc5180c7879b27993ca058b Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 30 Dec 2022 09:55:58 -0800 Subject: [PATCH 151/157] tweak to allow testing with matrix types --- control/tests/stochsys_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/control/tests/stochsys_test.py b/control/tests/stochsys_test.py index 8ec170fb4..75e5a510c 100644 --- a/control/tests/stochsys_test.py +++ b/control/tests/stochsys_test.py @@ -199,6 +199,7 @@ def test_estimator_iosys_ctime(sys_args): # Compute the system response and the optimal covariance sys_resp = ct.input_output_response(nl_sys, timepts, U, X0) _, Pf, _ = ct.lqe(sys, QN, RN) + Pf = np.array(Pf) # convert from matrix, if needed # Make sure that we converge to the optimal estimate estim_resp = ct.input_output_response( From e01181f5facf7e89d3211e3c57b30900ddce0e01 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 30 Dec 2022 10:03:03 -0800 Subject: [PATCH 152/157] add configuration info in flatsys_test to resolve os-blas error --- control/tests/flatsys_test.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/control/tests/flatsys_test.py b/control/tests/flatsys_test.py index 196cddbd2..3a032bd7e 100644 --- a/control/tests/flatsys_test.py +++ b/control/tests/flatsys_test.py @@ -217,10 +217,12 @@ def test_kinematic_car_ocp( else: # Dump out information to allow creation of an exception - print("Platform: ", platform.platform()) - print("Python: ", platform.python_version()) + print("Message:", traj_ocp.message) + print("Platform:", platform.platform()) + print("Python:", platform.python_version()) + print("NumPy version:", np.__version__) np.show_config() - print("JOBNAME: ", os.getenv('JOBNAME')) + print("JOBNAME:", os.getenv('JOBNAME')) pytest.fail( "unknown failure; view output to identify configuration") From 7e92635e163aafab5eb05716f94fda5d42c34f33 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 30 Dec 2022 10:59:43 -0800 Subject: [PATCH 153/157] updated docstrings --- control/stochsys.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/control/stochsys.py b/control/stochsys.py index a10e566b8..3b2978008 100644 --- a/control/stochsys.py +++ b/control/stochsys.py @@ -314,7 +314,13 @@ def create_estimator_iosystem( """Create an I/O system implementing a linqear quadratic estimator This function creates an input/output system that implements a - state estimator of the form + continuous time state estimator of the form + + \dot xhat = A x + B u - L (C xhat - y) + \dot P = A P + P A^T + F QN F^T - P C^T RN^{-1} C P + L = P C^T RN^{-1} + + or a discrete time state estimator of the form xhat[k + 1] = A x[k] + B u[k] - L (C xhat[k] - y[k]) P[k + 1] = A P A^T + F QN F^T - A P C^T Reps^{-1} C P A @@ -359,8 +365,9 @@ def create_estimator_iosystem( Returns ------- estim : InputOutputSystem - Input/output system representing the estimator. This system takes the - system input and output and generates the estimated state. + Input/output system representing the estimator. This system takes + the system output y and input u and generates the estimated state + xhat. Notes ----- From da1f162bb587a6043057c5610b15aa37843ca266 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 30 Dec 2022 11:40:30 -0800 Subject: [PATCH 154/157] add xfail condition for NumPy 1.24.1 --- control/tests/flatsys_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control/tests/flatsys_test.py b/control/tests/flatsys_test.py index 3a032bd7e..95fb8cf7c 100644 --- a/control/tests/flatsys_test.py +++ b/control/tests/flatsys_test.py @@ -212,7 +212,7 @@ def test_kinematic_car_ocp( elif re.match("Iteration limit.*", traj_ocp.message) and \ re.match( "conda ubuntu-3.* Generic", os.getenv('JOBNAME', '')) and \ - np.__version__ == '1.24.0': + re.match("1.24.[01]", np.__version__): pytest.xfail("gh820: iteration limit exceeded") else: From 1c16d626f75e49b006cfce08bd5610707dc6e56c Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 31 Dec 2022 09:11:10 -0800 Subject: [PATCH 155/157] update docstrings with math in stochsys, statesp for proper formatting --- control/statesp.py | 12 ++++++------ control/stochsys.py | 34 +++++++++++++++++++--------------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/control/statesp.py b/control/statesp.py index aac4dd8bd..9fff28d27 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -164,19 +164,19 @@ def _f2s(f): class StateSpace(LTI): - """StateSpace(A, B, C, D[, dt]) + r"""StateSpace(A, B, C, D[, dt]) A class for representing state-space models. The StateSpace class is used to represent state-space realizations of linear time-invariant (LTI) systems: - + .. math:: - dx/dt = A x + B u - - y = C x + D u + + dx/dt &= A x + B u \\ + y &= C x + D u - where u is the input, y is the output, and x is the state. + where `u` is the input, `y` is the output, and `x` is the state. Parameters ---------- diff --git a/control/stochsys.py b/control/stochsys.py index 3b2978008..90768a222 100644 --- a/control/stochsys.py +++ b/control/stochsys.py @@ -311,28 +311,32 @@ def create_estimator_iosystem( sys, QN, RN, P0=None, G=None, C=None, state_labels='xhat[{i}]', output_labels='xhat[{i}]', covariance_labels='P[{i},{j}]', sensor_labels=None): - """Create an I/O system implementing a linqear quadratic estimator + r"""Create an I/O system implementing a linear quadratic estimator This function creates an input/output system that implements a continuous time state estimator of the form - \dot xhat = A x + B u - L (C xhat - y) - \dot P = A P + P A^T + F QN F^T - P C^T RN^{-1} C P - L = P C^T RN^{-1} + .. math:: + + d \hat{x}/dt &= A \hat{x} + B u - L (C \hat{x} - y) \\ + dP/dt &= A P + P A^T + F Q_N F^T - P C^T R_N^{-1} C P \\ + L &= P C^T R_N^{-1} or a discrete time state estimator of the form - xhat[k + 1] = A x[k] + B u[k] - L (C xhat[k] - y[k]) - P[k + 1] = A P A^T + F QN F^T - A P C^T Reps^{-1} C P A - L = A P C^T Reps^{-1} + .. math:: + + \hat{x}[k+1] &= A \hat{x}[k] + B u[k] - L (C \hat{x}[k] - y[k]) \\ + P[k+1] &= A P A^T + F Q_N F^T - A P C^T R_e^{-1} C P A \\ + L &= A P C^T R_e^{-1} - where Reps = RN + C P C^T. It can be called in the form + where :math:`R_e = R_N + C P C^T`. It can be called in the form:: estim = ct.create_estimator_iosystem(sys, QN, RN) - where ``sys`` is the process dynamics and QN and RN are the covariance + where `sys` is the process dynamics and `QN` and `RN` are the covariance of the disturbance noise and sensor noise. The function returns the - estimator ``estim`` as I/O system with a parameter ``correct`` that can + estimator `estim` as I/O system with a parameter `correct` that can be used to turn off the correction term in the estimation (for forward predictions). @@ -356,8 +360,8 @@ def create_estimator_iosystem( {state, covariance, sensor, output}_labels : str or list of str, optional Set the name of the signals to use for the internal state, covariance, sensors, and outputs (state estimate). If a single string is - specified, it should be a format string using the variable ``i`` as an - index (or ``i`` and ``j`` for covariance). Otherwise, a list of + specified, it should be a format string using the variable `i` as an + index (or `i` and `j` for covariance). Otherwise, a list of strings matching the size of the respective signal should be used. Default is ``'xhat[{i}]'`` for state and output labels, ``'y[{i}]'`` for output labels and ``'P[{i},{j}]'`` for covariance labels. @@ -372,18 +376,18 @@ def create_estimator_iosystem( Notes ----- This function can be used with the ``create_statefbk_iosystem()`` function - to create a closed loop, output-feedback, state space controller: + to create a closed loop, output-feedback, state space controller:: K, _, _ = ct.lqr(sys, Q, R) est = ct.create_estimator_iosystem(sys, QN, RN, P0) ctrl, clsys = ct.create_statefbk_iosystem(sys, K, estimator=est) - The estimator can also be run on its own to process a noisy signal: + The estimator can also be run on its own to process a noisy signal:: resp = ct.input_output_response(est, T, [Y, U], [X0, P0]) If desired, the ``correct`` parameter can be set to ``False`` to allow - prediction with no additional sensor information: + prediction with no additional sensor information:: resp = ct.input_output_response( est, T, 0, [X0, P0], param={'correct': False) From 81510a59fb6dfffe4dff0473802b82dccb247cc6 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 31 Dec 2022 10:04:00 -0800 Subject: [PATCH 156/157] additional cleanup of math lines in iosys --- control/iosys.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index e16a15ed0..c9e2351ed 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -2273,7 +2273,7 @@ def _find_size(sysval, vecval): # Define a state space object that is an I/O system def ss(*args, **kwargs): - """ss(A, B, C, D[, dt]) + r"""ss(A, B, C, D[, dt]) Create a state space system. @@ -2293,18 +2293,18 @@ def ss(*args, **kwargs): output equations: .. math:: - \\dot x = A \\cdot x + B \\cdot u - y = C \\cdot x + D \\cdot u + dx/dt &= A x + B u \\ + y &= C x + D u ``ss(A, B, C, D, dt)`` Create a discrete-time state space system from the matrices of its state and output equations: .. math:: - x[k+1] = A \\cdot x[k] + B \\cdot u[k] - y[k] = C \\cdot x[k] + D \\cdot u[ki] + x[k+1] &= A x[k] + B u[k] \\ + y[k] &= C x[k] + D u[k] The matrices can be given as *array like* data types or strings. Everything that the constructor of :class:`numpy.matrix` accepts is From a4fa027efcd3f4cbf7111b69416a4c0c6c65cd66 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 31 Dec 2022 10:36:18 -0800 Subject: [PATCH 157/157] update project URL --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 493594155..0800bd51a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ slycot = [ "slycot>=0.4.0" ] cvxopt = [ "cvxopt>=1.2.0" ] [project.urls] -homepage = "https//python-control.org" +homepage = "https://python-control.org" source = "https://github.com/python-control/python-control" [tool.setuptools_scm]