diff --git a/control/canonical.py b/control/canonical.py index a62044322..7be7f88ad 100644 --- a/control/canonical.py +++ b/control/canonical.py @@ -205,7 +205,7 @@ def similarity_transform(xsys, T, timescale=1, inverse=False): timescale : float, optional If present, also rescale the time unit to tau = timescale * t inverse : bool, optional - If True (default), transform so z = T x. If False, transform + If False (default), transform so z = T x. If True, transform so x = T z. Returns diff --git a/control/modelsimp.py b/control/modelsimp.py index 966da1ce7..fe519b82d 100644 --- a/control/modelsimp.py +++ b/control/modelsimp.py @@ -1,4 +1,3 @@ -#! TODO: add module docstring # modelsimp.py - tools for model simplification # # Author: Steve Brunton, Kevin Chen, Lauren Padilla @@ -6,47 +5,16 @@ # # This file contains routines for obtaining reduced order models # -# Copyright (c) 2010 by California Institute of Technology -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions -# are met: -# -# 1. Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. -# -# 2. Redistributions in binary form must reproduce the above copyright -# notice, this list of conditions and the following disclaimer in the -# documentation and/or other materials provided with the distribution. -# -# 3. Neither the name of the California Institute of Technology nor -# the names of its contributors may be used to endorse or promote -# products derived from this software without specific prior -# written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL CALTECH -# OR THE CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF -# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT -# OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF -# SUCH DAMAGE. -# -# $Id$ + +import warnings # External packages and modules import numpy as np -import warnings -from .exception import ControlSlycot, ControlArgument, ControlDimension -from .iosys import isdtime, isctime -from .statesp import StateSpace + +from .exception import ControlArgument, ControlDimension, ControlSlycot +from .iosys import isctime, isdtime from .statefbk import gram +from .statesp import StateSpace from .timeresp import TimeResponseData __all__ = ['hankel_singular_values', 'balanced_reduction', 'model_reduction', @@ -108,89 +76,146 @@ def hankel_singular_values(sys): return hsv[::-1] -def model_reduction(sys, ELIM, method='matchdc'): - """Model reduction by state elimination. - - Model reduction of `sys` by eliminating the states in `ELIM` using a given - method. +def model_reduction( + sys, elim_states=None, method='matchdc', elim_inputs=None, + elim_outputs=None, keep_states=None, keep_inputs=None, + keep_outputs=None, warn_unstable=True): + """Model reduction by input, output, or state elimination. + + This function produces a reduced-order model of a system by eliminating + specified inputs, outputs, and/or states from the original system. The + specific states, inputs, or outputs that are eliminated can be + specified by either listing the states, inputs, or outputs to be + eliminated or those to be kept. + + Two methods of state reduction are possible: 'truncate' removes the + states marked for elimination, while 'matchdc' replaces the eliminated + states with their equilibrium values (thereby keeping the input/output + gain unchanged at zero frequency ["DC"]). Parameters ---------- sys : StateSpace Original system to reduce. - ELIM : array - Vector of states to eliminate. + elim_inputs, elim_outputs, elim_states : array of int or str, optional + Vector of inputs, outputs, or states to eliminate. Can be specified + either as an offset into the appropriate vector or as a signal name. + keep_inputs, keep_outputs, keep_states : array, optional + Vector of inputs, outputs, or states to keep. Can be specified + either as an offset into the appropriate vector or as a signal name. method : string - Method of removing states in `ELIM`: either 'truncate' or - 'matchdc'. + Method of removing states: either 'truncate' or 'matchdc' (default). + warn_unstable : bool, option + If `False`, don't warn if system is unstable. Returns ------- rsys : StateSpace - A reduced order model. + Reduced order model. Raises ------ ValueError - Raised under the following conditions: + If `method` is not either 'matchdc' or 'truncate'. + NotImplementedError + If the 'matchdc' method is used for a discrete time system. - * if `method` is not either ``'matchdc'`` or ``'truncate'`` - - * if eigenvalues of `sys.A` are not all in left half plane - (`sys` must be stable) + Warns + ----- + UserWarning + If eigenvalues of `sys.A` are not all stable. Examples -------- >>> G = ct.rss(4) - >>> Gr = ct.modred(G, [0, 2], method='matchdc') + >>> Gr = ct.model_reduction(G, [0, 2], method='matchdc') >>> Gr.nstates 2 - """ + See Also + -------- + balanced_reduction : Eliminate states using Hankel singular values. + minimal_realization : Eliminate unreachable or unobservable states. - # Check for ss system object, need a utility for this? + Notes + ----- + The model_reduction function issues a warning if the system has + unstable eigenvalues, since in those situations the stability of the + reduced order model may be different than the stability of the full + model. No other checking is done, so users must to be careful not to + render a system unobservable or unreachable. - # TODO: Check for continous or discrete, only continuous supported for now - # if isCont(): - # dico = 'C' - # elif isDisc(): - # dico = 'D' - # else: - if (isctime(sys)): - dico = 'C' - else: - raise NotImplementedError("Function not implemented in discrete time") + States, inputs, and outputs can be specified using integer offsets or + using signal names. Slices can also be specified, but must use the + Python ``slice()`` function. + + """ + if not isinstance(sys, StateSpace): + raise TypeError("system must be a StateSpace system") # Check system is stable - if np.any(np.linalg.eigvals(sys.A).real >= 0.0): - raise ValueError("Oops, the system is unstable!") - - ELIM = np.sort(ELIM) - # Create list of elements not to eliminate (NELIM) - NELIM = [i for i in range(len(sys.A)) if i not in ELIM] - # A1 is a matrix of all columns of sys.A not to eliminate - A1 = sys.A[:, NELIM[0]].reshape(-1, 1) - for i in NELIM[1:]: - A1 = np.hstack((A1, sys.A[:, i].reshape(-1, 1))) - A11 = A1[NELIM, :] - A21 = A1[ELIM, :] - # A2 is a matrix of all columns of sys.A to eliminate - A2 = sys.A[:, ELIM[0]].reshape(-1, 1) - for i in ELIM[1:]: - A2 = np.hstack((A2, sys.A[:, i].reshape(-1, 1))) - A12 = A2[NELIM, :] - A22 = A2[ELIM, :] - - C1 = sys.C[:, NELIM] - C2 = sys.C[:, ELIM] - B1 = sys.B[NELIM, :] - B2 = sys.B[ELIM, :] - - if method == 'matchdc': - # if matchdc, residualize + if warn_unstable: + if isctime(sys) and np.any(np.linalg.eigvals(sys.A).real >= 0.0) or \ + isdtime(sys) and np.any(np.abs(np.linalg.eigvals(sys.A)) >= 1): + warnings.warn("System is unstable; reduction may be meaningless") + + # Utility function to process keep/elim keywords + def _process_elim_or_keep(elim, keep, labels): + def _expand_key(key): + if key is None: + return [] + elif isinstance(key, str): + return labels.index(key) + elif isinstance(key, list): + return [_expand_key(k) for k in key] + elif isinstance(key, slice): + return range(len(labels))[key] + else: + return key + elim = np.atleast_1d(_expand_key(elim)) + keep = np.atleast_1d(_expand_key(keep)) + + if len(elim) > 0 and len(keep) > 0: + raise ValueError( + "can't provide both 'keep' and 'elim' for same variables") + elif len(keep) > 0: + keep = np.sort(keep).tolist() + elim = [i for i in range(len(labels)) if i not in keep] + else: + elim = [] if elim is None else np.sort(elim).tolist() + keep = [i for i in range(len(labels)) if i not in elim] + return elim, keep + + # Determine which states to keep + elim_states, keep_states = _process_elim_or_keep( + elim_states, keep_states, sys.state_labels) + elim_inputs, keep_inputs = _process_elim_or_keep( + elim_inputs, keep_inputs, sys.input_labels) + elim_outputs, keep_outputs = _process_elim_or_keep( + elim_outputs, keep_outputs, sys.output_labels) + + # Create submatrix of states we are keeping + A11 = sys.A[:, keep_states][keep_states, :] # states we are keeping + A12 = sys.A[:, elim_states][keep_states, :] # needed for 'matchdc' + A21 = sys.A[:, keep_states][elim_states, :] + A22 = sys.A[:, elim_states][elim_states, :] + + B1 = sys.B[keep_states, :] + B2 = sys.B[elim_states, :] + + C1 = sys.C[:, keep_states] + C2 = sys.C[:, elim_states] + + # Figure out the new state space system + if method == 'matchdc' and A22.size > 0: + if sys.isdtime(strict=True): + raise NotImplementedError( + "'matchdc' not (yet) supported for discrete time systems") + + # if matchdc, residualize # Check if the matrix A22 is invertible - if np.linalg.matrix_rank(A22) != len(ELIM): + if np.linalg.matrix_rank(A22) != len(elim_states): raise ValueError("Matrix A22 is singular to working precision.") # Now precompute A22\A21 and A22\B2 (A22I = inv(A22)) @@ -206,22 +231,29 @@ def model_reduction(sys, ELIM, method='matchdc'): Br = B1 - A12 @ A22I_B2 Cr = C1 - C2 @ A22I_A21 Dr = sys.D - C2 @ A22I_B2 - elif method == 'truncate': - # if truncate, simply discard state x2 + + elif method == 'truncate' or A22.size == 0: + # Get rid of unwanted states Ar = A11 Br = B1 Cr = C1 Dr = sys.D + else: raise ValueError("Oops, method is not supported!") + # Get rid of additional inputs and outputs + Br = Br[:, keep_inputs] + Cr = Cr[keep_outputs, :] + Dr = Dr[keep_outputs, :][:, keep_inputs] + rsys = StateSpace(Ar, Br, Cr, Dr) return rsys def balanced_reduction(sys, orders, method='truncate', alpha=None): """Balanced reduced order model of sys of a given order. - + States are eliminated based on Hankel singular value. If sys has unstable modes, they are removed, the balanced realization is done on the stable part, then @@ -276,7 +308,7 @@ def balanced_reduction(sys, orders, method='truncate', alpha=None): raise ValueError("supported methods are 'truncate' or 'matchdc'") elif method == 'truncate': try: - from slycot import ab09md, ab09ad + from slycot import ab09ad, ab09md except ImportError: raise ControlSlycot( "can't find slycot subroutine ab09md or ab09ad") @@ -346,7 +378,7 @@ def balanced_reduction(sys, orders, method='truncate', alpha=None): def minimal_realization(sys, tol=None, verbose=True): """ Eliminate uncontrollable or unobservable states. - + Eliminates uncontrollable or unobservable states in state-space models or cancelling pole-zero pairs in transfer functions. The output sysr has minimal order and the same response @@ -378,14 +410,14 @@ def _block_hankel(Y, m, n): """Create a block Hankel matrix from impulse response.""" q, p, _ = Y.shape YY = Y.transpose(0, 2, 1) # transpose for reshape - + H = np.zeros((q*m, p*n)) - + for r in range(m): # shift and add row to Hankel matrix new_row = YY[:, r:r+n, :] H[q*r:q*(r+1), :] = new_row.reshape((q, p*n)) - + return H @@ -431,7 +463,7 @@ def eigensys_realization(arg, r, m=None, n=None, dt=True, transpose=False): unit-area impulse response of python-control. Default is True. transpose : bool, optional Assume that input data is transposed relative to the standard - :ref:`time-series-convention`. For TimeResponseData this parameter + :ref:`time-series-convention`. For TimeResponseData this parameter is ignored. Default is False. Returns @@ -466,7 +498,7 @@ def eigensys_realization(arg, r, m=None, n=None, dt=True, transpose=False): YY = np.array(arg, ndmin=3) if transpose: YY = np.transpose(YY) - + q, p, l = YY.shape if m is None: @@ -476,14 +508,14 @@ def eigensys_realization(arg, r, m=None, n=None, dt=True, transpose=False): if m*q < r or n*p < r: raise ValueError("Hankel parameters are to small") - + if (l-1) < m+n: raise ValueError("not enough data for requested number of parameters") - + H = _block_hankel(YY[:, :, 1:], m, n+1) # Hankel matrix (q*m, p*(n+1)) Hf = H[:, :-p] # first p*n columns of H Hl = H[:, p:] # last p*n columns of H - + U,S,Vh = np.linalg.svd(Hf, True) Ur =U[:, 0:r] Vhr =Vh[0:r, :] @@ -500,7 +532,7 @@ def eigensys_realization(arg, r, m=None, n=None, dt=True, transpose=False): def markov(*args, m=None, transpose=False, dt=None, truncate=False): """markov(Y, U, [, m]) - + Calculate the first `m` Markov parameters [D CB CAB ...] from data. This function computes the Markov parameters for a discrete time @@ -579,7 +611,7 @@ def markov(*args, m=None, transpose=False, dt=None, truncate=False): # Get the system description if len(args) < 1: raise ControlArgument("not enough input arguments") - + if isinstance(args[0], TimeResponseData): data = args[0] Umat = np.array(data.inputs, ndmin=2) @@ -639,7 +671,7 @@ def markov(*args, m=None, transpose=False, dt=None, truncate=False): # This algorithm sets up the following problem and solves it for # the Markov parameters # - # (l,q) = (l,p*m) @ (p*m,q) + # (l,q) = (l,p*m) @ (p*m,q) # YY = UU @ H.T # # [ y(0) ] [ u(0) 0 0 ] [ D ] @@ -675,7 +707,7 @@ def markov(*args, m=None, transpose=False, dt=None, truncate=False): # Truncate first t=0 or t=m time steps, transpose the problem for lsq YY = Ymat[:, t:].T UU = UUT[:, t:].T - + # Solve for the Markov parameters from YY = UU @ H.T HT, _, _, _ = np.linalg.lstsq(UU, YY, rcond=None) H = HT.T/dt # scaling diff --git a/control/tests/docstrings_test.py b/control/tests/docstrings_test.py index 27ced105f..991ead3e5 100644 --- a/control/tests/docstrings_test.py +++ b/control/tests/docstrings_test.py @@ -39,7 +39,7 @@ control.ss2tf: '48ff25d22d28e7b396e686dd5eb58831', control.tf: '53a13f4a7f75a31c81800e10c88730ef', control.tf2ss: '086a3692659b7321c2af126f79f4bc11', - control.markov: 'eda7c4635bbb863ae6659e574285d356', + control.markov: 'a4199c54cb50f07c0163d3790739eafe', control.gangof4: '0e52eb6cf7ce024f9a41f3ae3ebf04f7', } diff --git a/control/tests/modelsimp_test.py b/control/tests/modelsimp_test.py index 616ef5f09..dead4eb75 100644 --- a/control/tests/modelsimp_test.py +++ b/control/tests/modelsimp_test.py @@ -3,11 +3,15 @@ RMM, 30 Mar 2011 (based on TestModelSimp from v0.4a) """ +import math +import warnings + import numpy as np import pytest +import control as ct from control import StateSpace, TimeResponseData, c2d, forced_response, \ - impulse_response, step_response, rss, tf + impulse_response, rss, step_response, tf from control.exception import ControlArgument, ControlDimension from control.modelsimp import balred, eigensys_realization, hsvd, markov, \ modred @@ -42,7 +46,7 @@ def testMarkovSignature(self): inputs=U, input_labels='u', ) - + # setup m = 3 Htrue = np.array([1., 0., 0.]) @@ -101,7 +105,6 @@ def testMarkovSignature(self): HT = markov(response, m) np.testing.assert_array_almost_equal(HT, np.transpose(Htrue)) response.transpose=False - # Test example from docstring # TODO: There is a problem here, last markov parameter does not fit @@ -212,7 +215,7 @@ def testMarkovResults(self, k, m, n): Mtrue = np.hstack([Hd.D] + [ Hd.C @ np.linalg.matrix_power(Hd.A, i) @ Hd.B for i in range(m-1)]) - + Mtrue = np.squeeze(Mtrue) # Generate input/output data @@ -238,8 +241,8 @@ def testMarkovResults(self, k, m, n): Mcomp_scaled = markov(response, m, dt=Ts) np.testing.assert_allclose(Mtrue, Mcomp, rtol=1e-6, atol=1e-8) - np.testing.assert_allclose(Mtrue_scaled, Mcomp_scaled, rtol=1e-6, atol=1e-8) - + np.testing.assert_allclose( + Mtrue_scaled, Mcomp_scaled, rtol=1e-6, atol=1e-8) def testERASignature(self): @@ -253,7 +256,7 @@ def testERASignature(self): B = np.array([[1.],[1.,]]) C = np.array([[1., 0.,]]) D = np.array([[0.,]]) - + T = np.arange(0,10,1) sysd_true = StateSpace(A,B,C,D,True) ir_true = impulse_response(sysd_true,T=T) @@ -262,7 +265,7 @@ def testERASignature(self): sysd_est, _ = eigensys_realization(ir_true,r=2) ir_est = impulse_response(sysd_est, T=T) _, H_est = ir_est - + np.testing.assert_allclose(H_true, H_est, rtol=1e-6, atol=1e-8) # test ndarray @@ -270,7 +273,7 @@ def testERASignature(self): sysd_est, _ = eigensys_realization(YY_true,r=2) ir_est = impulse_response(sysd_est, T=T) _, H_est = ir_est - + np.testing.assert_allclose(H_true, H_est, rtol=1e-6, atol=1e-8) # test mimo @@ -304,10 +307,10 @@ def testERASignature(self): step_true = step_response(sysd_true) step_est = step_response(sysd_est) - np.testing.assert_allclose(step_true.outputs, + np.testing.assert_allclose(step_true.outputs, step_est.outputs, rtol=1e-6, atol=1e-8) - + # test ndarray _, YY_true = ir_true sysd_est, _ = eigensys_realization(YY_true,r=4,dt=dt) @@ -315,7 +318,7 @@ def testERASignature(self): step_true = step_response(sysd_true, T=T) step_est = step_response(sysd_est, T=T) - np.testing.assert_allclose(step_true.outputs, + np.testing.assert_allclose(step_true.outputs, step_est.outputs, rtol=1e-6, atol=1e-8) @@ -343,7 +346,7 @@ def testModredMatchDC(self): np.testing.assert_array_almost_equal(rsys.D, Drtrue, decimal=2) def testModredUnstable(self): - """Check if an error is thrown when an unstable system is given""" + """Check if warning is issued when an unstable system is given""" A = np.array( [[4.5418, 3.3999, 5.0342, 4.3808], [0.3890, 0.3599, 0.4195, 0.1760], @@ -353,7 +356,16 @@ def testModredUnstable(self): C = np.array([[1.0, 2.0, 3.0, 4.0], [1.0, 2.0, 3.0, 4.0]]) D = np.array([[0.0, 0.0], [0.0, 0.0]]) sys = StateSpace(A, B, C, D) - np.testing.assert_raises(ValueError, modred, sys, [2, 3]) + + # Make sure we get a warning message + with pytest.warns(UserWarning, match="System is unstable"): + newsys1 = modred(sys, [2, 3]) + + # Make sure we can turn the warning off + with warnings.catch_warnings(): + warnings.simplefilter('error') + newsys2 = ct.model_reduction(sys, [2, 3], warn_unstable=False) + np.testing.assert_equal(newsys1.A, newsys2.A) def testModredTruncate(self): #balanced realization computed in matlab for the transfer function: @@ -391,7 +403,7 @@ def testBalredTruncate(self): B = np.array([[2.], [0.], [0.], [0.]]) C = np.array([[0.5, 0.6875, 0.7031, 0.5]]) D = np.array([[0.]]) - + sys = StateSpace(A, B, C, D) orders = 2 rsys = balred(sys, orders, method='truncate') @@ -412,7 +424,7 @@ def testBalredTruncate(self): # 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) @@ -432,12 +444,12 @@ def testBalredMatchDC(self): B = np.array([[2.], [0.], [0.], [0.]]) C = np.array([[0.5, 0.6875, 0.7031, 0.5]]) D = np.array([[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], @@ -445,7 +457,7 @@ def testBalredMatchDC(self): Brtrue = np.array([[1.36235673], [1.03114388]]) Crtrue = np.array([[1.36235673, 1.03114388]]) Drtrue = np.array([[-0.08383902]]) - + # Look for possible changes in state in slycot T1 = np.array([[1, 0], [0, -1]]) T2 = np.array([[-1, 0], [0, 1]]) @@ -455,9 +467,46 @@ def testBalredMatchDC(self): # 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) + + +@pytest.mark.parametrize("kwargs, nstates, noutputs, ninputs", [ + ({'elim_states': [1, 3]}, 3, 3, 3), + ({'elim_inputs': [1, 2], 'keep_states': [1, 3]}, 2, 3, 1), + ({'elim_outputs': [1, 2], 'keep_inputs': [0, 1],}, 5, 1, 2), + ({'keep_states': [2, 0], 'keep_outputs': [0, 1]}, 2, 2, 3), + ({'keep_states': slice(0, 4, 2), 'keep_outputs': slice(None, 2)}, 2, 2, 3), + ({'keep_states': ['x[0]', 'x[3]'], 'keep_inputs': 'u[0]'}, 2, 3, 1), + ({'elim_inputs': [0, 1, 2]}, 5, 3, 0), # no inputs + ({'elim_outputs': [0, 1, 2]}, 5, 0, 3), # no outputs + ({'elim_states': [0, 1, 2, 3, 4]}, 0, 3, 3), # no states + ({'elim_states': [0, 1], 'keep_states': [1, 2]}, None, None, None), +]) +@pytest.mark.parametrize("method", ['truncate', 'matchdc']) +def test_model_reduction(method, kwargs, nstates, noutputs, ninputs): + sys = ct.rss(5, 3, 3) + + if nstates is None: + # Arguments should generate an error + with pytest.raises(ValueError, match="can't provide both"): + red = ct.model_reduction(sys, **kwargs, method=method) + return + else: + red = ct.model_reduction(sys, **kwargs, method=method) + + assert red.nstates == nstates + assert red.ninputs == ninputs + assert red.noutputs == noutputs + + if method == 'matchdc': + # Define a new system with truncated inputs and outputs + # (assumes we always keep the initial inputs and outputs) + chk = ct.ss( + sys.A, sys.B[:, :ninputs], sys.C[:noutputs, :], + sys.D[:noutputs, :][:, :ninputs]) + np.testing.assert_allclose(red(0), chk(0))
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: