From 8c707dd91459ed888fe459985fa3891315040fec Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Wed, 30 Dec 2020 23:08:45 -0800 Subject: [PATCH] generate error for tf2ss of non-proper transfer function + PEP8 cleanup --- control/statesp.py | 175 ++++++++++++++++++++-------------- control/tests/convert_test.py | 13 +++ control/tests/iosys_test.py | 6 +- 3 files changed, 119 insertions(+), 75 deletions(-) diff --git a/control/statesp.py b/control/statesp.py index 0f6638881..7b9549a8a 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. @@ -162,8 +162,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 @@ -210,7 +210,6 @@ class StateSpace(LTI): # Allow ndarray * StateSpace to give StateSpace._rmul_() priority __array_priority__ = 11 # override ndarray and matrix types - def __init__(self, *args, **kw): """ StateSpace(A, B, C, D[, dt]) @@ -234,8 +233,9 @@ def __init__(self, *args, **kw): 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 @@ -245,11 +245,13 @@ def __init__(self, *args, **kw): except NameError: dt = config.defaults['statesp.default_dt'] else: - raise ValueError("Needs 1 or 4 arguments; received %i." % len(args)) + raise ValueError( + "Needs 1 or 4 arguments; received %i." % len(args)) # Process keyword arguments - remove_useless = kw.get('remove_useless', - config.defaults['statesp.remove_useless_states']) + remove_useless = kw.get( + 'remove_useless', + config.defaults['statesp.remove_useless_states']) # Convert all matrices to standard form A = _ssmatrix(A) @@ -261,7 +263,7 @@ def __init__(self, *args, **kw): 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])) @@ -279,9 +281,9 @@ def __init__(self, *args, **kw): 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]: @@ -296,14 +298,15 @@ def __init__(self, *args, **kw): 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. """ @@ -481,7 +484,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): @@ -519,10 +523,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 @@ -566,9 +569,9 @@ def __mul__(self, other): but B has %i row(s)\n(output(s))." % (self.inputs, other.outputs)) # Figure out the sampling time to use - if (self.dt == None and other.dt != None): + if (self.dt is None and other.dt is not None): dt = other.dt # use dt from second argument - elif (other.dt == None and self.dt != None) or \ + elif (other.dt is None and self.dt is not None) or \ (timebaseEqual(self, other)): dt = self.dt # use dt from first argument else: @@ -582,7 +585,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) @@ -626,7 +629,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. @@ -773,7 +777,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]: @@ -795,7 +800,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 @@ -806,13 +812,15 @@ 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.") # Figure out the sampling time to use if self.dt is None and other.dt is not None: dt = other.dt # use dt from second argument - elif other.dt is None and self.dt is not None or timebaseEqual(self, other): + elif other.dt is None and self.dt is not None \ + or timebaseEqual(self, other): dt = self.dt # use dt from first argument else: raise ValueError("Systems have different sampling times") @@ -828,7 +836,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 @@ -886,9 +895,9 @@ def lft(self, other, nu=-1, ny=-1): # TODO # Figure out the sampling time to use - if (self.dt == None and other.dt != None): + if (self.dt is None and other.dt is not None): dt = other.dt # use dt from second argument - elif (other.dt == None and self.dt != None) or \ + elif (other.dt is None and self.dt is not None) or \ timebaseEqual(self, other): dt = self.dt # use dt from first argument else: @@ -924,16 +933,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([ @@ -964,13 +977,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: @@ -1061,7 +1074,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 @@ -1113,9 +1127,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) @@ -1142,7 +1156,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,36 +1166,43 @@ def dcgain(self): return np.squeeze(gain) def is_static_gain(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) + """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 _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): @@ -1197,8 +1219,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 @@ -1208,7 +1232,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: @@ -1218,7 +1243,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)): @@ -1235,18 +1261,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. @@ -1272,13 +1299,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 @@ -1366,7 +1393,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.) @@ -1406,7 +1433,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. " @@ -1557,8 +1584,8 @@ def ss(*args): 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 or 4 arguments; received %i." % len(args)) @@ -1582,16 +1609,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 @@ -1628,8 +1655,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 740416507..42765480c 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