def testConvertToTransferFunction(self): """Test for correct state space to transfer function conversion.""" A = [[1., -2.], [-3., 4.]] B = [[6., 5.], [4., 3.]] C = [[1., -2.], [3., -4.], [5., -6.]] D = [[1., 0.], [0., 1.], [1., 0.]] sys = StateSpace(A, B, C, D) tfsys = _convertToTransferFunction(sys) num = [[np.array([1., -7., 10.]), np.array([-1., 10.])], [np.array([2., -8.]), np.array([1., -2., -8.])], [np.array([1., 1., -30.]), np.array([7., -22.])]] den = [[np.array([1., -5., -2.]) for j in range(sys.inputs)] for i in range(sys.outputs)] for i in range(sys.outputs): for j in range(sys.inputs): np.testing.assert_array_almost_equal(tfsys.num[i][j], num[i][j]) np.testing.assert_array_almost_equal(tfsys.den[i][j], den[i][j])
def setUp(self): """Set up some systems for testing out MATLAB functions""" A = np.matrix("1. -2.; 3. -4.") B = np.matrix("5.; 7.") C = np.matrix("6. 8.") D = np.matrix("9.") self.siso_ss1 = StateSpace(A, B, C, D) # Create some transfer functions self.siso_tf1 = TransferFunction([1], [1, 2, 1]) self.siso_tf2 = _convertToTransferFunction(self.siso_ss1) # Create MIMO system, contains ``siso_ss1`` twice A = np.matrix("1. -2. 0. 0.;" "3. -4. 0. 0.;" "0. 0. 1. -2.;" "0. 0. 3. -4. ") B = np.matrix("5. 0.;" "7. 0.;" "0. 5.;" "0. 7. ") C = np.matrix("6. 8. 0. 0.;" "0. 0. 6. 8. ") D = np.matrix("9. 0.;" "0. 9. ") self.mimo_ss1 = StateSpace(A, B, C, D)
def stability_margins(sysdata, deg=True, returnall=False, epsw=1e-12): """Calculate gain, phase and stability margins and associated crossover frequencies. Usage ----- gm, pm, sm, wg, wp, ws = stability_margins(sysdata, deg=True) Parameters ---------- sysdata: linsys or (mag, phase, omega) sequence sys : linsys Linear SISO system mag, phase, omega : sequence of array_like Input magnitude, phase, and frequencies (rad/sec) sequence from bode frequency response data deg=True: boolean If true, all input and output phases in degrees, else in radians returnall=False: boolean If true, return all margins found. Note that for frequency data or FRD systems, only one margin is found and returned. epsw=1e-12: float frequencies below this value are considered static gain, and not returned as margin. Returns ------- gm, pm, sm, wg, wp, ws: float or array_like Gain margin gm, phase margin pm, stability margin sm, and associated crossover frequencies wg, wp, and ws of SISO open-loop. If more than one crossover frequency is detected, returns the lowest corresponding margin. When requesting all margins, the return values are array_like, and all margins are returns for linear systems not equal to FRD """ try: if isinstance(sysdata, frdata.FRD): sys = frdata.FRD(sysdata, smooth=True) elif isinstance(sysdata, xferfcn.TransferFunction): sys = sysdata elif getattr(sysdata, '__iter__', False) and len(sysdata) == 3: mag, phase, omega = sysdata sys = frdata.FRD(mag * np.exp((1j / 360.) * phase), omega, smooth=True) else: sys = xferfcn._convertToTransferFunction(sysdata) except Exception, e: print(e) raise ValueError("Margin sysdata must be either a linear system or " "a 3-sequence of mag, phase, omega.")
def testMIMO(self): """Test conversion of a single input, two-output state-space system against the same TF""" s = TransferFunction([1, 0], [1]) b0 = 0.2 b1 = 0.1 b2 = 0.5 a0 = 2.3 a1 = 6.3 a2 = 3.6 a3 = 1.0 h = (b0 + b1 * s + b2 * s ** 2) / (a0 + a1 * s + a2 * s ** 2 + a3 * s ** 3) H = TransferFunction([[h.num[0][0]], [(h * s).num[0][0]]], [[h.den[0][0]], [h.den[0][0]]]) sys = _convertToStateSpace(H) H2 = _convertToTransferFunction(sys) np.testing.assert_array_almost_equal(H.num[0][0], H2.num[0][0]) np.testing.assert_array_almost_equal(H.den[0][0], H2.den[0][0]) np.testing.assert_array_almost_equal(H.num[1][0], H2.num[1][0]) np.testing.assert_array_almost_equal(H.den[1][0], H2.den[1][0])
def phase_crossover_frequencies(sys): """ Compute frequencies and gains at intersections with real axis in Nyquist plot. Call as: omega, gain = phase_crossover_frequencies() Returns ------- omega: 1d array of (non-negative) frequencies where Nyquist plot intersects the real axis gain: 1d array of corresponding gains Examples -------- >>> tf = TransferFunction([1], [1, 2, 3, 4]) >>> PhaseCrossoverFrequenies(tf) (array([ 1.73205081, 0. ]), array([-0.5 , 0.25])) """ # Convert to a transfer function tf = xferfcn._convertToTransferFunction(sys) # if not siso, fall back to (0,0) element #! TODO: should add a check and warning here num = tf.num[0][0] den = tf.den[0][0] # Compute frequencies that we cross over the real axis numj = (1.j)**np.arange(len(num)-1,-1,-1)*num denj = (-1.j)**np.arange(len(den)-1,-1,-1)*den allfreq = np.roots(np.imag(np.polymul(numj,denj))) realfreq = np.real(allfreq[np.isreal(allfreq)]) realposfreq = realfreq[realfreq >= 0.] # using real() to avoid rounding errors and results like 1+0j # it would be nice to have a vectorized version of self.evalfr here gain = np.real(np.asarray([tf.evalfr(f)[0][0] for f in realposfreq])) return realposfreq, gain
def phase_crossover_frequencies(sys): """ Compute frequencies and gains at intersections with real axis in Nyquist plot. Call as: omega, gain = phase_crossover_frequencies() Returns ------- omega: 1d array of (non-negative) frequencies where Nyquist plot intersects the real axis gain: 1d array of corresponding gains Examples -------- >>> tf = TransferFunction([1], [1, 2, 3, 4]) >>> PhaseCrossoverFrequenies(tf) (array([ 1.73205081, 0. ]), array([-0.5 , 0.25])) """ # Convert to a transfer function tf = xferfcn._convertToTransferFunction(sys) # if not siso, fall back to (0,0) element #! TODO: should add a check and warning here num = tf.num[0][0] den = tf.den[0][0] # Compute frequencies that we cross over the real axis numj = (1.j)**np.arange(len(num) - 1, -1, -1) * num denj = (-1.j)**np.arange(len(den) - 1, -1, -1) * den allfreq = np.roots(np.imag(np.polymul(numj, denj))) realfreq = np.real(allfreq[np.isreal(allfreq)]) realposfreq = realfreq[realfreq >= 0.] # using real() to avoid rounding errors and results like 1+0j # it would be nice to have a vectorized version of self.evalfr here gain = np.real(np.asarray([tf.evalfr(f)[0][0] for f in realposfreq])) return realposfreq, gain
def testMIMO(self): """Test conversion of a single input, two-output state-space system against the same TF""" s = TransferFunction([1, 0], [1]) b0 = 0.2 b1 = 0.1 b2 = 0.5 a0 = 2.3 a1 = 6.3 a2 = 3.6 a3 = 1.0 h = (b0 + b1 * s + b2 * s**2) / (a0 + a1 * s + a2 * s**2 + a3 * s**3) H = TransferFunction([[h.num[0][0]], [(h * s).num[0][0]]], [[h.den[0][0]], [h.den[0][0]]]) sys = _convertToStateSpace(H) H2 = _convertToTransferFunction(sys) np.testing.assert_array_almost_equal(H.num[0][0], H2.num[0][0]) np.testing.assert_array_almost_equal(H.den[0][0], H2.den[0][0]) np.testing.assert_array_almost_equal(H.num[1][0], H2.num[1][0]) np.testing.assert_array_almost_equal(H.den[1][0], H2.den[1][0])
def sample_system(sysc, Ts, method='matched'): # TODO: add docstring # Make sure we have a continuous time system if not isctime(sysc): raise ValueError("First argument must be continuous time system") # TODO: impelement MIMO version if (sysc.inputs != 1 or sysc.outputs != 1): raise NotImplementedError("MIMO implementation not available") # If we are passed a state space system, convert to transfer function first if isinstance(sysc, StateSpace): warn("sample_system: converting to transfer function") sysc = _convertToTransferFunction(sysc) # Decide what to do based on the methods available if method == 'matched': sysd = _c2dmatched(sysc, Ts) elif method == 'tustin': sys = [sysc.num[0][0], sysc.den[0][0]] scipySysD = cont2discrete(sys, Ts, method='bilinear') sysd = TransferFunction(scipySysD[0][0], scipySysD[1], Ts) elif method == 'zoh': sys = [sysc.num[0][0], sysc.den[0][0]] scipySysD = cont2discrete(sys, Ts, method='zoh') sysd = TransferFunction(scipySysD[0][0], scipySysD[1], Ts) elif method == 'foh' or method == 'impulse': raise ValueError("Method not developed yet") else: raise ValueError("Invalid discretization method: %s" % method) # TODO: Convert back into the input form # Set sampling time return sysd
def tfdata(sys, **kw): ''' Return transfer function data objects for a system Parameters ---------- sys: Lti (StateSpace, or TransferFunction) LTI system whose data will be returned Keywords -------- inputs = int; outputs = int For MIMO transfer function, return num, den for given inputs, outputs Returns ------- (num, den): numerator and denominator arrays Transfer function coefficients (SISO only) ''' tf = _convertToTransferFunction(sys, **kw) return (tf.num, tf.den)
def _systopoly1d(sys): """Extract numerator and denominator polynomails for a system""" # Allow inputs from the signal processing toolbox if (isinstance(sys, scipy.signal.lti)): nump = sys.num; denp = sys.den; else: # Convert to a transfer function, if needed sys = xferfcn._convertToTransferFunction(sys) # Make sure we have a SISO system if (sys.inputs > 1 or sys.outputs > 1): raise ControlMIMONotImplemented() # Start by extracting the numerator and denominator from system object nump = sys.num[0][0]; denp = sys.den[0][0]; # Check to see if num, den are already polynomials; otherwise convert if (not isinstance(nump, poly1d)): nump = poly1d(nump) if (not isinstance(denp, poly1d)): denp = poly1d(denp) return (nump, denp)
def sample_system(sysc, Ts, method='matched'): # TODO: add docstring # Make sure we have a continuous time system if not isctime(sysc): raise ValueError("First argument must be continuous time system") # TODO: impelement MIMO version if (sysc.inputs != 1 or sysc.outputs != 1): raise NotImplementedError("MIMO implementation not available") # If we are passed a state space system, convert to transfer function first if isinstance(sysc, StateSpace): warn("sample_system: converting to transfer function") sysc = _convertToTransferFunction(sysc) # Decide what to do based on the methods available if method == 'matched': sysd = _c2dmatched(sysc, Ts) elif method == 'tustin': sys = [sysc.num[0][0], sysc.den[0][0]] scipySysD = cont2discrete(sys, Ts, method='bilinear') sysd = TransferFunction(scipySysD[0][0], scipySysD[1], dt) elif method == 'zoh': sys = [sysc.num[0][0], sysc.den[0][0]] scipySysD = cont2discrete(sys, Ts, method='zoh') sysd = TransferFunction(scipySysD[0][0],scipySysD[1], dt) elif method == 'foh' or method == 'impulse': raise ValueError("Method not developed yet") else: raise ValueError("Invalid discretization method: %s" % method) # TODO: Convert back into the input form # Set sampling time return sysd
def _systopoly1d(sys): """Extract numerator and denominator polynomails for a system""" # Allow inputs from the signal processing toolbox if (isinstance(sys, scipy.signal.lti)): nump = sys.num denp = sys.den else: # Convert to a transfer function, if needed sys = xferfcn._convertToTransferFunction(sys) # Make sure we have a SISO system if (sys.inputs > 1 or sys.outputs > 1): raise ControlMIMONotImplemented() # Start by extracting the numerator and denominator from system object nump = sys.num[0][0] denp = sys.den[0][0] # Check to see if num, den are already polynomials; otherwise convert if (not isinstance(nump, poly1d)): nump = poly1d(nump) if (not isinstance(denp, poly1d)): denp = poly1d(denp) return (nump, denp)
def ss2tf(*args): """ Transform a state space system to a transfer function. The function accepts either 1 or 4 parameters: ``ss2tf(sys)`` Convert a linear system into space system form. Always creates a new system, even if sys is already a StateSpace object. ``ss2tf(A, B, C, D)`` Create a state space system from the matrices of its state and output equations. For details see: :func:`ss` Parameters ---------- sys: StateSpace A linear system A: array_like or string System matrix B: array_like or string Control matrix C: array_like or string Output matrix D: array_like or string Feed forward matrix Returns ------- out: TransferFunction New linear system in transfer function form Raises ------ ValueError if matrix sizes are not self-consistent, or if an invalid number of arguments is passed in TypeError if `sys` is not a StateSpace object See Also -------- tf ss tf2ss Examples -------- >>> A = [[1., -2], [3, -4]] >>> B = [[5.], [7]] >>> C = [[6., 8]] >>> D = [[9.]] >>> sys1 = ss2tf(A, B, C, D) >>> sys_ss = ss(A, B, C, D) >>> sys2 = ss2tf(sys_ss) """ if len(args) == 4 or len(args) == 5: # Assume we were given the A, B, C, D matrix and (optional) dt return _convertToTransferFunction(StateSpace(*args)) elif len(args) == 1: sys = args[0] if isinstance(sys, StateSpace): return _convertToTransferFunction(sys) else: raise TypeError("ss2tf(sys): sys must be a StateSpace object. It \ is %s." % type(sys)) else: raise ValueError("Needs 1 or 4 arguments; received %i." % len(args))
def feedback(sys1, sys2=1, sign=-1): """ Feedback interconnection between two I/O systems. Parameters ---------- sys1: scalar, StateSpace, or TransferFunction The primary plant. sys2: scalar, StateSpace, or TransferFunction The feedback plant (often a feedback controller). sign: scalar The sign of feedback. `sign` = -1 indicates negative feedback, and `sign` = 1 indicates positive feedback. `sign` is an optional argument; it assumes a value of -1 if not specified. Returns ------- out: StateSpace or TransferFunction Raises ------ ValueError if `sys1` does not have as many inputs as `sys2` has outputs, or if `sys2` does not have as many inputs as `sys1` has outputs NotImplementedError if an attempt is made to perform a feedback on a MIMO TransferFunction object See Also -------- series parallel Notes ----- This function is a wrapper for the feedback function in the StateSpace and TransferFunction classes. It calls TransferFunction.feedback if `sys1` is a TransferFunction object, and StateSpace.feedback if `sys1` is a StateSpace object. If `sys1` is a scalar, then it is converted to `sys2`'s type, and the corresponding feedback function is used. If `sys1` and `sys2` are both scalars, then TransferFunction.feedback is used. """ # Check for correct input types. if not isinstance(sys1, (int, float, complex, tf.TransferFunction, ss.StateSpace)): raise TypeError("sys1 must be a TransferFunction or StateSpace object, \ or a scalar.") if not isinstance(sys2, (int, float, complex, tf.TransferFunction, ss.StateSpace)): raise TypeError("sys2 must be a TransferFunction or StateSpace object, \ or a scalar.") # If sys1 is a scalar, convert it to the appropriate LTI type so that we can # its feedback member function. if isinstance(sys1, (int, float, complex)): if isinstance(sys2, tf.TransferFunction): sys1 = tf._convertToTransferFunction(sys1) elif isinstance(sys2, ss.StateSpace): sys1 = ss._convertToStateSpace(sys1) else: # sys2 is a scalar. sys1 = tf._convertToTransferFunction(sys1) sys2 = tf._convertToTransferFunction(sys2) return sys1.feedback(sys2, sign)
def feedback(sys1, sys2=1, sign=-1): """ Feedback interconnection between two I/O systems. Parameters ---------- sys1: scalar, StateSpace, or TransferFunction The primary plant. sys2: scalar, StateSpace, or TransferFunction The feedback plant (often a feedback controller). sign: scalar The sign of feedback. `sign` = -1 indicates negative feedback, and `sign` = 1 indicates positive feedback. `sign` is an optional argument; it assumes a value of -1 if not specified. Returns ------- out: StateSpace or TransferFunction Raises ------ ValueError if `sys1` does not have as many inputs as `sys2` has outputs, or if `sys2` does not have as many inputs as `sys1` has outputs NotImplementedError if an attempt is made to perform a feedback on a MIMO TransferFunction object See Also -------- series parallel Notes ----- This function is a wrapper for the feedback function in the StateSpace and TransferFunction classes. It calls TransferFunction.feedback if `sys1` is a TransferFunction object, and StateSpace.feedback if `sys1` is a StateSpace object. If `sys1` is a scalar, then it is converted to `sys2`'s type, and the corresponding feedback function is used. If `sys1` and `sys2` are both scalars, then TransferFunction.feedback is used. """ # Check for correct input types. if not isinstance( sys1, (int, float, complex, tf.TransferFunction, ss.StateSpace)): raise TypeError( "sys1 must be a TransferFunction or StateSpace object, \ or a scalar.") if not isinstance( sys2, (int, float, complex, tf.TransferFunction, ss.StateSpace)): raise TypeError( "sys2 must be a TransferFunction or StateSpace object, \ or a scalar.") # If sys1 is a scalar, convert it to the appropriate LTI type so that we can # its feedback member function. if isinstance(sys1, (int, float, complex)): if isinstance(sys2, tf.TransferFunction): sys1 = tf._convertToTransferFunction(sys1) elif isinstance(sys2, ss.StateSpace): sys1 = ss._convertToStateSpace(sys1) else: # sys2 is a scalar. sys1 = tf._convertToTransferFunction(sys1) sys2 = tf._convertToTransferFunction(sys2) return sys1.feedback(sys2, sign)
def stability_margins(sysdata, deg=True, returnall=False, epsw=1e-12): """Calculate gain, phase and stability margins and associated crossover frequencies. Usage ----- gm, pm, sm, wg, wp, ws = stability_margins(sysdata, deg=True) Parameters ---------- sysdata: linsys or (mag, phase, omega) sequence sys : linsys Linear SISO system mag, phase, omega : sequence of array_like Input magnitude, phase, and frequencies (rad/sec) sequence from bode frequency response data deg=True: boolean If true, all input and output phases in degrees, else in radians returnall=False: boolean If true, return all margins found. Note that for frequency data or FRD systems, only one margin is found and returned. epsw=1e-12: float frequencies below this value are considered static gain, and not returned as margin. Returns ------- gm, pm, sm, wg, wp, ws: float or array_like Gain margin gm, phase margin pm, stability margin sm, and associated crossover frequencies wg, wp, and ws of SISO open-loop. If more than one crossover frequency is detected, returns the lowest corresponding margin. When requesting all margins, the return values are array_like, and all margins are returns for linear systems not equal to FRD """ try: if isinstance(sysdata, frdata.FRD): sys = frdata.FRD(sysdata, smooth=True) elif isinstance(sysdata, xferfcn.TransferFunction): sys = sysdata elif getattr(sysdata, '__iter__', False) and len(sysdata) == 3: mag, phase, omega = sysdata sys = frdata.FRD(mag*np.exp((1j/360.)*phase), omega, smooth=True) else: sys = xferfcn._convertToTransferFunction(sysdata) except Exception as e: print (e) raise ValueError("Margin sysdata must be either a linear system or " "a 3-sequence of mag, phase, omega.") # calculate gain of system if isinstance(sys, xferfcn.TransferFunction): # check for siso if not issiso(sys): raise ValueError("Can only do margins for SISO system") # real and imaginary part polynomials in omega: rnum, inum = _polyimsplit(sys.num[0][0]) rden, iden = _polyimsplit(sys.den[0][0]) # test imaginary part of tf == 0, for phase crossover/gain margins test_w_180 = np.polyadd(np.polymul(inum, rden), np.polymul(rnum, -iden)) w_180 = np.roots(test_w_180) w_180 = np.real(w_180[(np.imag(w_180) == 0) * (w_180 > epsw)]) w_180.sort() # test magnitude is 1 for gain crossover/phase margins test_wc = np.polysub(np.polyadd(_polysqr(rnum), _polysqr(inum)), np.polyadd(_polysqr(rden), _polysqr(iden))) wc = np.roots(test_wc) wc = np.real(wc[(np.imag(wc) == 0) * (wc > epsw)]) wc.sort() # stability margin was a bitch to elaborate, relies on magnitude to # point -1, then take the derivative. Second derivative needs to be >0 # to have a minimum test_wstabn = np.polyadd(_polysqr(rnum), _polysqr(inum)) test_wstabd = np.polyadd(_polysqr(np.polyadd(rnum,rden)), _polysqr(np.polyadd(inum,iden))) test_wstab = np.polysub( np.polymul(np.polyder(test_wstabn),test_wstabd), np.polymul(np.polyder(test_wstabd),test_wstabn)) # find the solutions wstab = np.roots(test_wstab) # and find the value of the 2nd derivative there, needs to be positive wstabplus = np.polyval(np.polyder(test_wstab), wstab) wstab = np.real(wstab[(np.imag(wstab) == 0) * (wstab > epsw) * (np.abs(wstabplus) > 0.)]) wstab.sort() else: # a bit coarse, have the interpolated frd evaluated again def mod(w): """to give the function to calculate |G(jw)| = 1""" return [np.abs(sys.evalfr(w[0])[0][0]) - 1] def arg(w): """function to calculate the phase angle at -180 deg""" return [np.angle(sys.evalfr(w[0])[0][0]) + np.pi] def dstab(w): """function to calculate the distance from -1 point""" return np.abs(sys.evalfr(w[0])[0][0] + 1.) # how to calculate the frequency at which |G(jw)| = 1 wc = np.array([sp.optimize.fsolve(mod, sys.omega[0])])[0] w_180 = np.array([sp.optimize.fsolve(arg, sys.omega[0])])[0] wstab = np.real( np.array([sp.optimize.fmin(dstab, sys.omega[0], disp=0)])[0]) # margins, as iterables, converted frdata and xferfcn calculations to # vector for this PM = np.angle(sys.evalfr(wc)[0][0], deg=True) + 180 GM = 1/(np.abs(sys.evalfr(w_180)[0][0])) SM = np.abs(sys.evalfr(wstab)[0][0]+1) if returnall: return GM, PM, SM, wc, w_180, wstab else: return ( (GM.shape[0] or None) and GM[0], (PM.shape[0] or None) and PM[0], (SM.shape[0] or None) and SM[0], (wc.shape[0] or None) and wc[0], (w_180.shape[0] or None) and w_180[0], (wstab.shape[0] or None) and wstab[0])