diff --git a/control/statesp.py b/control/statesp.py index 2419b4e62..288b0831d 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -10,7 +10,7 @@ # Python 3 compatibility (needs to go here) from __future__ import print_function -from __future__ import division # for _convertToStateSpace +from __future__ import division # for _convertToStateSpace """Copyright (c) 2010 by California Institute of Technology All rights reserved. @@ -161,8 +161,8 @@ class StateSpace(LTI): A class for representing state-space models - The StateSpace class is used to represent state-space realizations of linear - time-invariant (LTI) systems: + The StateSpace class is used to represent state-space realizations of + linear time-invariant (LTI) systems: dx/dt = A x + B u y = C x + D u @@ -216,7 +216,6 @@ class StateSpace(LTI): # Allow ndarray * StateSpace to give StateSpace._rmul_() priority __array_priority__ = 11 # override ndarray and matrix types - def __init__(self, *args, **kwargs): """ StateSpace(A, B, C, D[, dt]) @@ -240,18 +239,21 @@ def __init__(self, *args, **kwargs): elif len(args) == 1: # Use the copy constructor. if not isinstance(args[0], StateSpace): - raise TypeError("The one-argument constructor can only take in a StateSpace " - "object. Received %s." % type(args[0])) + raise TypeError( + "The one-argument constructor can only take in a " + "StateSpace object. Received %s." % type(args[0])) A = args[0].A B = args[0].B C = args[0].C D = args[0].D else: - raise ValueError("Expected 1, 4, or 5 arguments; received %i." % len(args)) + raise ValueError( + "Expected 1, 4, or 5 arguments; received %i." % len(args)) # Process keyword arguments - remove_useless = kwargs.get('remove_useless', - config.defaults['statesp.remove_useless_states']) + remove_useless = kwargs.get( + 'remove_useless', + config.defaults['statesp.remove_useless_states']) # Convert all matrices to standard form A = _ssmatrix(A) @@ -263,7 +265,7 @@ def __init__(self, *args, **kwargs): if np.asarray(C).ndim == 1 and len(C) == A.shape[0]: C = _ssmatrix(C, axis=1) else: - C = _ssmatrix(C, axis=0) #if this doesn't work, error below + C = _ssmatrix(C, axis=0) # if this doesn't work, error below if np.isscalar(D) and D == 0 and B.shape[1] > 0 and C.shape[0] > 0: # If D is a scalar zero, broadcast it to the proper size D = np.zeros((C.shape[0], B.shape[1])) @@ -302,9 +304,9 @@ def __init__(self, *args, **kwargs): if 0 == self.states: # static gain # matrix's default "empty" shape is 1x0 - A.shape = (0,0) - B.shape = (0,self.inputs) - C.shape = (self.outputs,0) + A.shape = (0, 0) + B.shape = (0, self.inputs) + C.shape = (self.outputs, 0) # Check that the matrix sizes are consistent. if self.states != A.shape[0]: @@ -319,14 +321,15 @@ def __init__(self, *args, **kwargs): raise ValueError("C and D must have the same number of rows.") # Check for states that don't do anything, and remove them. - if remove_useless: self._remove_useless_states() + if remove_useless: + self._remove_useless_states() def _remove_useless_states(self): """Check for states that don't do anything, and remove them. Scan the A, B, and C matrices for rows or columns of zeros. If the - zeros are such that a particular state has no effect on the input-output - dynamics, then remove that state from the A, B, and C matrices. + zeros are such that a particular state has no effect on the input- + output dynamics, then remove that state from the A, B, and C matrices. """ @@ -504,7 +507,8 @@ def _repr_latex_(self): return self._latex_separate() else: cfg = config.defaults['statesp.latex_repr_type'] - raise ValueError("Unknown statesp.latex_repr_type '{cfg}'".format(cfg=cfg)) + raise ValueError( + "Unknown statesp.latex_repr_type '{cfg}'".format(cfg=cfg)) # Negation of a system def __neg__(self): @@ -535,10 +539,9 @@ def __add__(self, other): # Concatenate the various arrays A = concatenate(( concatenate((self.A, zeros((self.A.shape[0], - other.A.shape[-1]))),axis=1), + other.A.shape[-1]))), axis=1), concatenate((zeros((other.A.shape[0], self.A.shape[-1])), - other.A),axis=1) - ),axis=0) + other.A), axis=1)), axis=0) B = concatenate((self.B, other.B), axis=0) C = concatenate((self.C, other.C), axis=1) D = self.D + other.D @@ -590,7 +593,7 @@ def __mul__(self, other): concatenate((np.dot(self.B, other.C), self.A), axis=1)), axis=0) B = concatenate((other.B, np.dot(self.B, other.D)), axis=0) - C = concatenate((np.dot(self.D, other.C), self.C),axis=1) + C = concatenate((np.dot(self.D, other.C), self.C), axis=1) D = np.dot(self.D, other.D) return StateSpace(A, B, C, D, dt) @@ -634,7 +637,8 @@ def __div__(self, other): def __rdiv__(self, other): """Right divide two LTI systems.""" - raise NotImplementedError("StateSpace.__rdiv__ is not implemented yet.") + raise NotImplementedError( + "StateSpace.__rdiv__ is not implemented yet.") def evalfr(self, omega): """Evaluate a SS system's transfer function at a single frequency. @@ -779,7 +783,8 @@ def zero(self): if nu == 0: return np.array([]) else: - return sp.linalg.eigvals(out[8][0:nu, 0:nu], out[9][0:nu, 0:nu]) + return sp.linalg.eigvals(out[8][0:nu, 0:nu], + out[9][0:nu, 0:nu]) except ImportError: # Slycot unavailable. Fall back to scipy. if self.C.shape[0] != self.D.shape[1]: @@ -801,7 +806,8 @@ def zero(self): concatenate((self.C, self.D), axis=1)), axis=0) M = pad(eye(self.A.shape[0]), ((0, self.C.shape[0]), (0, self.B.shape[1])), "constant") - return np.array([x for x in sp.linalg.eigvals(L, M, overwrite_a=True) + return np.array([x for x in sp.linalg.eigvals(L, M, + overwrite_a=True) if not isinf(x)]) # Feedback around a state space system @@ -812,8 +818,8 @@ def feedback(self, other=1, sign=-1): # Check to make sure the dimensions are OK if (self.inputs != other.outputs) or (self.outputs != other.inputs): - raise ValueError("State space systems don't have compatible inputs/outputs for " - "feedback.") + raise ValueError("State space systems don't have compatible " + "inputs/outputs for feedback.") dt = common_timebase(self.dt, other.dt) A1 = self.A @@ -827,7 +833,8 @@ def feedback(self, other=1, sign=-1): F = eye(self.inputs) - sign * np.dot(D2, D1) if matrix_rank(F) != self.inputs: - raise ValueError("I - sign * D2 * D1 is singular to working precision.") + raise ValueError( + "I - sign * D2 * D1 is singular to working precision.") # Precompute F\D2 and F\C2 (E = inv(F)) # We can solve two linear systems in one pass, since the @@ -916,16 +923,20 @@ def lft(self, other, nu=-1, ny=-1): # solve for the resulting ss by solving for [y, u] using [x, # xbar] and [w1, w2]. TH = np.linalg.solve(F, np.block( - [[C2, np.zeros((ny, other.states)), D21, np.zeros((ny, other.inputs - ny))], - [np.zeros((nu, self.states)), Cbar1, np.zeros((nu, self.inputs - nu)), Dbar12]] + [[C2, np.zeros((ny, other.states)), + D21, np.zeros((ny, other.inputs - ny))], + [np.zeros((nu, self.states)), Cbar1, + np.zeros((nu, self.inputs - nu)), Dbar12]] )) T11 = TH[:ny, :self.states] T12 = TH[:ny, self.states: self.states + other.states] T21 = TH[ny:, :self.states] T22 = TH[ny:, self.states: self.states + other.states] - H11 = TH[:ny, self.states + other.states: self.states + other.states + self.inputs - nu] + H11 = TH[:ny, self.states + other.states:self.states + + other.states + self.inputs - nu] H12 = TH[:ny, self.states + other.states + self.inputs - nu:] - H21 = TH[ny:, self.states + other.states: self.states + other.states + self.inputs - nu] + H21 = TH[ny:, self.states + other.states:self.states + + other.states + self.inputs - nu] H22 = TH[ny:, self.states + other.states + self.inputs - nu:] Ares = np.block([ @@ -956,13 +967,13 @@ def minreal(self, tol=0.0): try: from slycot import tb01pd B = empty((self.states, max(self.inputs, self.outputs))) - B[:,:self.inputs] = self.B + B[:, :self.inputs] = self.B C = empty((max(self.outputs, self.inputs), self.states)) - C[:self.outputs,:] = self.C + C[:self.outputs, :] = self.C A, B, C, nr = tb01pd(self.states, self.inputs, self.outputs, self.A, B, C, tol=tol) - return StateSpace(A[:nr,:nr], B[:nr,:self.inputs], - C[:self.outputs,:nr], self.D) + return StateSpace(A[:nr, :nr], B[:nr, :self.inputs], + C[:self.outputs, :nr], self.D) except ImportError: raise TypeError("minreal requires slycot tb01pd") else: @@ -1052,7 +1063,8 @@ def __getitem__(self, indices): raise IOError('must provide indices of length 2 for state space') i = indices[0] j = indices[1] - return StateSpace(self.A, self.B[:, j], self.C[i, :], self.D[i, j], self.dt) + 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): """Convert a continuous time system to discrete time @@ -1104,9 +1116,9 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None): 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 \ + 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 + Twarp = 2 * np.tan(prewarp_frequency * Ts/2)/prewarp_frequency else: Twarp = Ts Ad, Bd, C, D, _ = cont2discrete(sys, Twarp, method, alpha) @@ -1133,7 +1145,8 @@ def dcgain(self): """ try: if self.isctime(): - gain = np.asarray(self.D-self.C.dot(np.linalg.solve(self.A, self.B))) + gain = np.asarray(self.D - + self.C.dot(np.linalg.solve(self.A, self.B))) else: gain = self.horner(1) except LinAlgError: @@ -1151,28 +1164,34 @@ def is_static_gain(self): def _convertToStateSpace(sys, **kw): """Convert a system to state space form (if needed). - If sys is already a state space, then it is returned. If sys is a transfer - function object, then it is converted to a state space and returned. If sys - is a scalar, then the number of inputs and outputs can be specified - manually, as in: + If sys is already a state space, then it is returned. If sys is a + transfer function object, then it is converted to a state space and + returned. If sys is a scalar, then the number of inputs and outputs can + be specified manually, as in: >>> sys = _convertToStateSpace(3.) # Assumes inputs = outputs = 1 >>> sys = _convertToStateSpace(1., inputs=3, outputs=2) In the latter example, A = B = C = 0 and D = [[1., 1., 1.] [1., 1., 1.]]. - """ from .xferfcn import TransferFunction import itertools + if isinstance(sys, StateSpace): if len(kw): - raise TypeError("If sys is a StateSpace, _convertToStateSpace \ -cannot take keywords.") + raise TypeError("If sys is a StateSpace, _convertToStateSpace " + "cannot take keywords.") # Already a state space system; just return it return sys + elif isinstance(sys, TransferFunction): + # Make sure the transfer function is proper + if any([[len(num) for num in col] for col in sys.num] > + [[len(num) for num in col] for col in sys.den]): + raise ValueError("Transfer function is non-proper; can't " + "convert to StateSpace system.") try: from slycot import td04ad if len(kw): @@ -1189,8 +1208,10 @@ def _convertToStateSpace(sys, **kw): denorder, den, num, tol=0) states = ssout[0] - return StateSpace(ssout[1][:states, :states], ssout[2][:states, :sys.inputs], - ssout[3][:sys.outputs, :states], ssout[4], sys.dt) + return StateSpace(ssout[1][:states, :states], + ssout[2][:states, :sys.inputs], + ssout[3][:sys.outputs, :states], ssout[4], + sys.dt) except ImportError: # No Slycot. Scipy tf->ss can't handle MIMO, but static # MIMO is an easy special case we can check for here @@ -1200,7 +1221,8 @@ def _convertToStateSpace(sys, **kw): for drow in sys.den) if 1 == maxn and 1 == maxd: D = empty((sys.outputs, sys.inputs), dtype=float) - for i, j in itertools.product(range(sys.outputs), range(sys.inputs)): + for i, j in itertools.product(range(sys.outputs), + range(sys.inputs)): D[i, j] = sys.num[i][j][0] / sys.den[i][j][0] return StateSpace([], [], [], D, sys.dt) else: @@ -1210,7 +1232,8 @@ def _convertToStateSpace(sys, **kw): # TODO: do we want to squeeze first and check dimenations? # I think this will fail if num and den aren't 1-D after # the squeeze - A, B, C, D = sp.signal.tf2ss(squeeze(sys.num), squeeze(sys.den)) + A, B, C, D = \ + sp.signal.tf2ss(squeeze(sys.num), squeeze(sys.den)) return StateSpace(A, B, C, D, sys.dt) elif isinstance(sys, (int, float, complex, np.number)): @@ -1227,18 +1250,19 @@ def _convertToStateSpace(sys, **kw): # The following Doesn't work due to inconsistencies in ltisys: # return StateSpace([[]], [[]], [[]], eye(outputs, inputs)) return StateSpace(0., zeros((1, inputs)), zeros((outputs, 1)), - sys * ones((outputs, inputs))) + sys * ones((outputs, inputs))) # If this is a matrix, try to create a constant feedthrough try: D = _ssmatrix(sys) return StateSpace([], [], [], D) except Exception as e: - print("Failure to assume argument is matrix-like in" \ - " _convertToStateSpace, result %s" % e) + print("Failure to assume argument is matrix-like in" + " _convertToStateSpace, result %s" % e) raise TypeError("Can't convert given type to StateSpace system.") + # TODO: add discrete time option def _rss_generate(states, inputs, outputs, type): """Generate a random state space. @@ -1264,13 +1288,13 @@ def _rss_generate(states, inputs, outputs, type): # Check for valid input arguments. if states < 1 or states % 1: raise ValueError("states must be a positive integer. states = %g." % - states) + states) if inputs < 1 or inputs % 1: raise ValueError("inputs must be a positive integer. inputs = %g." % - inputs) + inputs) if outputs < 1 or outputs % 1: raise ValueError("outputs must be a positive integer. outputs = %g." % - outputs) + outputs) # Make some poles for A. Preallocate a complex array. poles = zeros(states) + zeros(states) * 0.j @@ -1358,7 +1382,7 @@ def _rss_generate(states, inputs, outputs, type): # Convert a MIMO system to a SISO system # TODO: add discrete time check def _mimo2siso(sys, input, output, warn_conversion=False): - #pylint: disable=W0622 + # pylint: disable=W0622 """ Convert a MIMO system to a SISO system. (Convert a system with multiple inputs and/or outputs, to a system with a single input and output.) @@ -1398,7 +1422,7 @@ def _mimo2siso(sys, input, output, warn_conversion=False): "Selected output: {sel}, " "number of system outputs: {ext}." .format(sel=output, ext=sys.outputs)) - #Convert sys to SISO if necessary + # Convert sys to SISO if necessary if sys.inputs > 1 or sys.outputs > 1: if warn_conversion: warn("Converting MIMO system to SISO system. " @@ -1547,8 +1571,8 @@ def ss(*args, **kwargs): elif isinstance(sys, TransferFunction): return tf2ss(sys) else: - raise TypeError("ss(sys): sys must be a StateSpace or \ -TransferFunction object. It is %s." % type(sys)) + raise TypeError("ss(sys): sys must be a StateSpace or " + "TransferFunction object. It is %s." % type(sys)) else: raise ValueError("Needs 1, 4, or 5 arguments; received %i." % len(args)) @@ -1572,16 +1596,16 @@ def tf2ss(*args): Parameters ---------- - sys: LTI (StateSpace or TransferFunction) + sys : LTI (StateSpace or TransferFunction) A linear system - num: array_like, or list of list of array_like + num : array_like, or list of list of array_like Polynomial coefficients of the numerator - den: array_like, or list of list of array_like + den : array_like, or list of list of array_like Polynomial coefficients of the denominator Returns ------- - out: StateSpace + out : StateSpace New linear system in state space form Raises @@ -1618,8 +1642,8 @@ def tf2ss(*args): elif len(args) == 1: sys = args[0] if not isinstance(sys, TransferFunction): - raise TypeError("tf2ss(sys): sys must be a TransferFunction \ -object.") + raise TypeError("tf2ss(sys): sys must be a TransferFunction " + "object.") return _convertToStateSpace(sys) else: raise ValueError("Needs 1 or 2 arguments; received %i." % len(args)) diff --git a/control/tests/convert_test.py b/control/tests/convert_test.py index de1cf01d1..f92029fe3 100644 --- a/control/tests/convert_test.py +++ b/control/tests/convert_test.py @@ -250,3 +250,16 @@ def test_tf2ss_robustness(self): np.testing.assert_array_almost_equal(np.sort(sys2tf.pole()), np.sort(sys2ss.pole())) + def test_tf2ss_nonproper(self): + """Unit tests for non-proper transfer functions""" + # Easy case: input 2 to output 1 is 's' + num = [ [[0], [1, 0]], [[1], [0]] ] + den1 = [ [[1], [1]], [[1,4], [1]] ] + with pytest.raises(ValueError): + tf2ss(tf(num, den1)) + + # Trickier case (make sure that leading zeros in den are handled) + num = [ [[0], [1, 0]], [[1], [0]] ] + den1 = [ [[1], [0, 1]], [[1,4], [1]] ] + with pytest.raises(ValueError): + tf2ss(tf(num, den1)) diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index faed39e07..d96eefd39 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -18,7 +18,6 @@ from control import iosys as ios from control.tests.conftest import noscipy0 - class TestIOSys: @pytest.fixture @@ -81,6 +80,11 @@ def test_tf2io(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 non-proper transfer functions generate an error + tfsys = ct.tf('s') + with pytest.raises(ValueError): + iosys=ct.tf2io(tfsys) + def test_ss2io(self, tsys): # Create an input/output system from the linear system linsys = tsys.siso_linsys