From 1a6d80668b3ea81329f0e474400be1a84205fd86 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Fri, 14 Aug 2020 13:45:03 -0700 Subject: [PATCH 01/32] eliminate _evalfr in lti system classes, replaced with __call__, which evaluates at many frequencies at once --- control/bench/time_freqresp.py | 4 +- control/frdata.py | 168 +++++++++++--------------- control/freqplot.py | 14 +-- control/lti.py | 74 ++++++++++-- control/margins.py | 16 +-- control/nichols.py | 2 +- control/statesp.py | 147 ++++++++--------------- control/tests/frd_test.py | 179 ++++++++++++---------------- control/tests/freqresp_test.py | 12 +- control/tests/statesp_array_test.py | 23 +--- control/tests/statesp_test.py | 39 ++---- control/tests/xferfcn_test.py | 38 ++---- control/xferfcn.py | 110 +++-------------- doc/control.rst | 1 + examples/robust_mimo.py | 2 +- examples/robust_siso.py | 8 +- 16 files changed, 333 insertions(+), 504 deletions(-) diff --git a/control/bench/time_freqresp.py b/control/bench/time_freqresp.py index 1945cbc24..3ae837082 100644 --- a/control/bench/time_freqresp.py +++ b/control/bench/time_freqresp.py @@ -8,7 +8,7 @@ sys_tf = tf(sys) w = logspace(-1,1,50) ntimes = 1000 -time_ss = timeit("sys.freqresp(w)", setup="from __main__ import sys, w", number=ntimes) -time_tf = timeit("sys_tf.freqresp(w)", setup="from __main__ import sys_tf, w", number=ntimes) +time_ss = timeit("sys.freqquency_response(w)", setup="from __main__ import sys, w", number=ntimes) +time_tf = timeit("sys_tf.frequency_response(w)", setup="from __main__ import sys_tf, w", number=ntimes) print("State-space model on %d states: %f" % (nstates, time_ss)) print("Transfer-function model on %d states: %f" % (nstates, time_tf)) diff --git a/control/frdata.py b/control/frdata.py index 8ca9dfd9d..084f4aba3 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -47,8 +47,8 @@ # External function declarations from warnings import warn import numpy as np -from numpy import angle, array, empty, ones, \ - real, imag, absolute, eye, linalg, where, dot +from numpy import angle, array, empty, ones, isin, \ + real, imag, absolute, eye, linalg, where, dot, sort from scipy.interpolate import splprep, splev from .lti import LTI @@ -107,17 +107,10 @@ def __init__(self, *args, **kwargs): # not an FRD, but still a system, second argument should be # the frequency range otherlti = args[0] - self.omega = array(args[1], dtype=float) - self.omega.sort() + self.omega = sort(np.asarray(args[1], dtype=float)) numfreq = len(self.omega) - # calculate frequency response at my points - self.fresp = empty( - (otherlti.outputs, otherlti.inputs, numfreq), - dtype=complex) - for k, w in enumerate(self.omega): - self.fresp[:, :, k] = otherlti._evalfr(w) - + self.fresp = otherlti(1j * self.omega, squeeze=False) else: # The user provided a response and a freq vector self.fresp = array(args[0], dtype=complex) @@ -141,7 +134,7 @@ def __init__(self, *args, **kwargs): self.omega = args[0].omega self.fresp = args[0].fresp else: - raise ValueError("Needs 1 or 2 arguments; receivd %i." % len(args)) + raise ValueError("Needs 1 or 2 arguments; received %i." % len(args)) # create interpolation functions if smooth: @@ -163,7 +156,7 @@ def __str__(self): mimo = self.inputs > 1 or self.outputs > 1 outstr = ['Frequency response data'] - mt, pt, wt = self.freqresp(self.omega) + #mt, pt, wt = self.frequency_response(self.omega) for i in range(self.inputs): for j in range(self.outputs): if mimo: @@ -173,7 +166,7 @@ def __str__(self): outstr.extend( ['%12.3f %10.4g%+10.4gj' % (w, m, p) for m, p, w in zip(real(self.fresp[j, i, :]), - imag(self.fresp[j, i, :]), wt)]) + imag(self.fresp[j, i, :]), self.omega)]) return '\n'.join(outstr) @@ -342,28 +335,14 @@ def __pow__(self, other): return (FRD(ones(self.fresp.shape), self.omega) / self) * \ (self**(other+1)) - def evalfr(self, omega): - """Evaluate a transfer function at a single angular frequency. - - self._evalfr(omega) returns the value of the frequency response - at frequency omega. - - Note that a "normal" FRD only returns values for which there is an - entry in the omega vector. An interpolating FRD can return - intermediate values. - - """ - warn("FRD.evalfr(omega) will be deprecated in a future release " - "of python-control; use sys.eval(omega) instead", - PendingDeprecationWarning) # pragma: no coverage - return self._evalfr(omega) - # Define the `eval` function to evaluate an FRD at a given (real) # frequency. Note that we choose to use `eval` instead of `evalfr` to # avoid confusion with :func:`evalfr`, which takes a complex number as its # argument. Similarly, we don't use `__call__` to avoid confusion between # G(s) for a transfer function and G(omega) for an FRD object. - def eval(self, omega): + # update Sawyer B. Fuller 2020.08.14: __call__ added to provide a uniform + # interface to systems in general and the lti.frequency_response method + def eval(self, omega, squeeze=True): """Evaluate a transfer function at a single angular frequency. self.evalfr(omega) returns the value of the frequency response @@ -371,81 +350,69 @@ def eval(self, omega): Note that a "normal" FRD only returns values for which there is an entry in the omega vector. An interpolating FRD can return - intermediate values. + intermediate values. + + squeeze: bool, optional (default=True) + If True and sys is single input, single output (SISO), return a + 1D array or scalar depending on omega's length. """ - return self._evalfr(omega) - - # Internal function to evaluate the frequency responses - def _evalfr(self, omega): - """Evaluate a transfer function at a single angular frequency.""" - # Preallocate the output. - if getattr(omega, '__iter__', False): - out = empty((self.outputs, self.inputs, len(omega)), dtype=complex) - else: - out = empty((self.outputs, self.inputs), dtype=complex) + omega_array = np.array(omega, ndmin=1) # array-like version of omega + if any(omega_array.imag > 0): + raise ValueError("FRD.eval can only accept real-valued omega") if self.ifunc is None: - try: - out = self.fresp[:, :, where(self.omega == omega)[0][0]] - except Exception: + elements = isin(self.omega, omega) + if sum(elements) < len(omega_array): raise ValueError( - "Frequency %f not in frequency list, try an interpolating" - " FRD if you want additional points" % omega) - else: - if getattr(omega, '__iter__', False): - for i in range(self.outputs): - for j in range(self.inputs): - for k, w in enumerate(omega): - frraw = splev(w, self.ifunc[i, j], der=0) - out[i, j, k] = frraw[0] + 1.0j * frraw[1] + "not all frequencies omega are in frequency list of FRD " + "system. Try an interpolating FRD for additional points.") else: - for i in range(self.outputs): - for j in range(self.inputs): - frraw = splev(omega, self.ifunc[i, j], der=0) - out[i, j] = frraw[0] + 1.0j * frraw[1] - + out = self.fresp[:, :, elements] + else: + out = empty((self.outputs, self.inputs, len(omega_array))) + for i in range(self.outputs): + for j in range(self.inputs): + for k, w in enumerate(omega_array): + frraw = splev(w, self.ifunc[i, j], der=0) + out[i, j, k] = frraw[0] + 1.0j * frraw[1] + if not hasattr(omega, '__len__'): + # omega is a scalar, squeeze down array along last dim + out = np.squeeze(out, axis=2) + if squeeze and self.issiso(): + out = out[0][0] return out - # Method for generating the frequency response of the system - def freqresp(self, omega): - """Evaluate the frequency response at a list of angular frequencies. - - Reports the value of the magnitude, phase, and angular frequency of - the requency response evaluated at omega, where omega is a list of - angular frequencies, and is a sorted version of the input omega. - - Parameters - ---------- - omega : array_like - A list of frequencies in radians/sec at which the system should be - evaluated. The list can be either a python list or a numpy array - and will be sorted before evaluation. - - Returns - ------- - mag : (self.outputs, self.inputs, len(omega)) ndarray - The magnitude (absolute value, not dB or log10) of the system - frequency response. - phase : (self.outputs, self.inputs, len(omega)) ndarray - The wrapped phase in radians of the system frequency response. - omega : ndarray or list or tuple - The list of sorted frequencies at which the response was - evaluated. - """ - # Preallocate outputs. - numfreq = len(omega) - mag = empty((self.outputs, self.inputs, numfreq)) - phase = empty((self.outputs, self.inputs, numfreq)) - - omega.sort() + def __call__(self, s, squeeze=True): + """Evaluate the system's transfer function at complex frequencies s. - for k, w in enumerate(omega): - fresp = self._evalfr(w) - mag[:, :, k] = abs(fresp) - phase[:, :, k] = angle(fresp) + For a SISO system, returns the complex value of the + transfer function. For a MIMO transfer fuction, returns a + matrix of values. + + Raises an error if s is not purely imaginary. + + squeeze: bool, optional (default=True) + If True and sys is single input, single output (SISO), return a + 1D array or scalar depending on omega's length. - return mag, phase, omega + """ + if any(abs(np.array(s, ndmin=1).real) > 0): + raise ValueError("__call__: FRD systems can only accept" + "purely imaginary frequencies") + # need to preserve array or scalar status + if hasattr(s, '__len__'): + return self.eval(np.asarray(s).imag, squeeze=squeeze) + else: + return self.eval(complex(s).imag, squeeze=squeeze) + + def freqresp(self, omega): + warn("FrequencyResponseData.freqresp(omega) will be deprecated in a " + "future release of python-control; use " + "FrequencyResponseData.frequency_response(sys, omega), or " + "freqresp(sys, omega) in the MATLAB compatibility module " + "instead", PendingDeprecationWarning) + return self.frequency_response(omega) def feedback(self, other=1, sign=-1): """Feedback interconnection between two FRD objects.""" @@ -515,11 +482,10 @@ def _convertToFRD(sys, omega, inputs=1, outputs=1): "Frequency ranges of FRD do not match, conversion not implemented") elif isinstance(sys, LTI): - omega.sort() - fresp = empty((sys.outputs, sys.inputs, len(omega)), dtype=complex) - for k, w in enumerate(omega): - fresp[:, :, k] = sys._evalfr(w) - + omega = np.sort(omega) + fresp = sys(1j * omega) + if len(fresp.shape) == 1: + fresp = fresp[np.newaxis, np.newaxis, :] return FRD(fresp, omega, smooth=True) elif isinstance(sys, (int, float, complex, np.number)): diff --git a/control/freqplot.py b/control/freqplot.py index 7b296c111..ef63dd2fb 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -136,7 +136,7 @@ def bode_plot(syslist, omega=None, Notes ----- 1. Alternatively, you may use the lower-level method (mag, phase, freq) - = sys.freqresp(freq) to generate the frequency response for a system, + = sys.frequency_response(freq) to generate the frequency response for a system, but it returns a MIMO response. 2. If a discrete time model is given, the frequency response is plotted @@ -207,7 +207,7 @@ def bode_plot(syslist, omega=None, else: nyquistfrq = None # Get the magnitude and phase of the system - mag_tmp, phase_tmp, omega_sys = sys.freqresp(omega_sys) + mag_tmp, phase_tmp, omega_sys = sys.frequency_response(omega_sys) mag = np.atleast_1d(np.squeeze(mag_tmp)) phase = np.atleast_1d(np.squeeze(phase_tmp)) phase = unwrap(phase) @@ -524,7 +524,7 @@ def nyquist_plot(syslist, omega=None, plot=True, label_freq=0, "Nyquist is currently only implemented for SISO systems.") else: # Get the magnitude and phase of the system - mag_tmp, phase_tmp, omega = sys.freqresp(omega) + mag_tmp, phase_tmp, omega = sys.frequency_response(omega) mag = np.squeeze(mag_tmp) phase = np.squeeze(phase_tmp) @@ -654,7 +654,7 @@ def gangof4_plot(P, C, omega=None, **kwargs): omega_plot = omega / (2. * math.pi) if Hz else omega # TODO: Need to add in the mag = 1 lines - mag_tmp, phase_tmp, omega = S.freqresp(omega) + mag_tmp, phase_tmp, omega = S.frequency_response(omega) mag = np.squeeze(mag_tmp) if dB: plot_axes['s'].semilogx(omega_plot, 20 * np.log10(mag), **kwargs) @@ -664,7 +664,7 @@ def gangof4_plot(P, C, omega=None, **kwargs): plot_axes['s'].tick_params(labelbottom=False) plot_axes['s'].grid(grid, which='both') - mag_tmp, phase_tmp, omega = (P * S).freqresp(omega) + mag_tmp, phase_tmp, omega = (P * S).frequency_response(omega) mag = np.squeeze(mag_tmp) if dB: plot_axes['ps'].semilogx(omega_plot, 20 * np.log10(mag), **kwargs) @@ -674,7 +674,7 @@ def gangof4_plot(P, C, omega=None, **kwargs): plot_axes['ps'].set_ylabel("$|PS|$" + " (dB)" if dB else "") plot_axes['ps'].grid(grid, which='both') - mag_tmp, phase_tmp, omega = (C * S).freqresp(omega) + mag_tmp, phase_tmp, omega = (C * S).frequency_response(omega) mag = np.squeeze(mag_tmp) if dB: plot_axes['cs'].semilogx(omega_plot, 20 * np.log10(mag), **kwargs) @@ -685,7 +685,7 @@ def gangof4_plot(P, C, omega=None, **kwargs): plot_axes['cs'].set_ylabel("$|CS|$" + " (dB)" if dB else "") plot_axes['cs'].grid(grid, which='both') - mag_tmp, phase_tmp, omega = T.freqresp(omega) + mag_tmp, phase_tmp, omega = T.frequency_response(omega) mag = np.squeeze(mag_tmp) if dB: plot_axes['t'].semilogx(omega_plot, 20 * np.log10(mag), **kwargs) diff --git a/control/lti.py b/control/lti.py index 8db14794b..6d39955bb 100644 --- a/control/lti.py +++ b/control/lti.py @@ -13,7 +13,8 @@ """ import numpy as np -from numpy import absolute, real +from numpy import absolute, real, angle, abs +from warnings import warn __all__ = ['issiso', 'timebase', 'timebaseEqual', 'isdtime', 'isctime', 'pole', 'zero', 'damp', 'evalfr', 'freqresp', 'dcgain'] @@ -109,10 +110,55 @@ def damp(self): Z = -real(splane_poles)/wn return wn, Z, poles + def frequency_response(self, omega, squeeze=True): + """Evaluate the linear time-invariant system at an array of angular + frequencies. + + Reports the frequency response of the system, + + G(j*omega) = mag*exp(j*phase) + + for continuous time systems. For discrete time systems, the response is + evaluated around the unit circle such that + + G(exp(j*omega*dt)) = mag*exp(j*phase). + + Parameters + ---------- + omega : array_like or float + A list, tuple, array, or scalar value of frequencies in + radians/sec at which the system will be evaluated. + squeeze: bool, optional (default=True) + If True and sys is single input, single output (SISO), return a + 1D array or scalar depending on omega's length. + + Returns + ------- + mag : (self.outputs, self.inputs, len(omega)) ndarray + The magnitude (absolute value, not dB or log10) of the system + frequency response. + phase : (self.outputs, self.inputs, len(omega)) ndarray + The wrapped phase in radians of the system frequency response. + omega : ndarray + The (sorted) frequencies at which the response was evaluated. + + """ + omega = np.sort(np.array(omega, ndmin=1)) + if isdtime(self, strict=True): + # Convert the frequency to discrete time + if np.any(omega * self.dt > np.pi): + warn("__call__: evaluation above Nyquist frequency") + s = np.exp(1j * omega * self.dt) + else: + s = 1j * omega + response = self.__call__(s, squeeze=squeeze) + return abs(response), angle(response), omega + def dcgain(self): """Return the zero-frequency gain""" raise NotImplementedError("dcgain not implemented for %s objects" % str(self.__class__)) + # Test to see if a system is SISO def issiso(sys, strict=False): @@ -379,20 +425,22 @@ def damp(sys, doprint=True): (p.real, p.imag, d, w)) return wn, damping, poles -def evalfr(sys, x): +def evalfr(sys, x, squeeze=True): """ - Evaluate the transfer function of an LTI system for a single complex - number x. + Evaluate the transfer function of an LTI system for complex frequency x. To evaluate at a frequency, enter x = omega*j, where omega is the - frequency in radians + frequency in radians per second Parameters ---------- sys: StateSpace or TransferFunction Linear system - x: scalar + x: scalar or array-like Complex number + squeeze: bool, optional (default=True) + If True and sys is single input, single output (SISO), return a + 1D array or scalar depending on omega's length. Returns ------- @@ -417,12 +465,12 @@ def evalfr(sys, x): .. todo:: Add example with MIMO system """ - if issiso(sys): + if squeeze and issiso(sys): return sys.horner(x)[0][0] return sys.horner(x) -def freqresp(sys, omega): +def freqresp(sys, omega, squeeze=True): """ Frequency response of an LTI system at multiple angular frequencies. @@ -434,6 +482,9 @@ def freqresp(sys, omega): A list of frequencies in radians/sec at which the system should be evaluated. The list can be either a python list or a numpy array and will be sorted before evaluation. + squeeze: bool, optional (default=True) + If True and sys is single input, single output (SISO), return a + 1D array or scalar depending on omega's length. Returns ------- @@ -453,9 +504,8 @@ def freqresp(sys, omega): Notes ----- - This function is a wrapper for StateSpace.freqresp and - TransferFunction.freqresp. The output omega is a sorted version of the - input omega. + This function is a wrapper for StateSpace.frequency_response and + TransferFunction.frequency_response. Examples -------- @@ -480,7 +530,7 @@ def freqresp(sys, omega): #>>> # frequency response from the 1st input to the 2nd output, for #>>> # s = 0.1i, i, 10i. """ - return sys.freqresp(omega) + return sys.frequency_response(omega, squeeze=squeeze) def dcgain(sys): diff --git a/control/margins.py b/control/margins.py index 7bdcf6caa..4094b43bf 100644 --- a/control/margins.py +++ b/control/margins.py @@ -213,15 +213,15 @@ def stability_margins(sysdata, returnall=False, epsw=0.0): # a bit coarse, have the interpolated frd evaluated again def _mod(w): """Calculate |G(jw)| - 1""" - return np.abs(sys._evalfr(w)[0][0]) - 1 + return np.abs(sys(1j * w)[0][0]) - 1 def _arg(w): """Calculate the phase angle at -180 deg""" - return np.angle(-sys._evalfr(w)[0][0]) + return np.angle(-sys(1j * w)[0][0]) def _dstab(w): """Calculate the distance from -1 point""" - return np.abs(sys._evalfr(w)[0][0] + 1.) + return np.abs(sys(1j * w)[0][0] + 1.) # Find all crossings, note that this depends on omega having # a correct range @@ -232,7 +232,7 @@ def _dstab(w): # find the phase crossings ang(H(jw) == -180 widx = np.where(np.diff(np.sign(_arg(sys.omega))))[0] - widx = widx[np.real(sys._evalfr(sys.omega[widx])[0][0]) <= 0] + widx = widx[np.real(sys(1j * sys.omega[widx])[0][0]) <= 0] w_180 = np.array( [sp.optimize.brentq(_arg, sys.omega[i], sys.omega[i+1]) for i in widx]) @@ -249,10 +249,10 @@ def _dstab(w): # margins, as iterables, converted frdata and xferfcn calculations to # vector for this with np.errstate(all='ignore'): - gain_w_180 = np.abs(sys._evalfr(w_180)[0][0]) + gain_w_180 = np.abs(sys(1j * w_180)) GM = 1.0/gain_w_180 - SM = np.abs(sys._evalfr(wstab)[0][0]+1) - PM = np.remainder(np.angle(sys._evalfr(wc)[0][0], deg=True), 360.0) - 180.0 + SM = np.abs(sys(1j * wstab)+1) + PM = np.remainder(np.angle(sys(1j * wc), deg=True), 360.0) - 180.0 if returnall: return GM, PM, SM, w_180, wc, wstab @@ -313,7 +313,7 @@ def phase_crossover_frequencies(sys): # 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])) + gain = np.real(np.asarray([tf(1j * f)[0][0] for f in realposfreq])) return realposfreq, gain diff --git a/control/nichols.py b/control/nichols.py index c8a98ed5e..c4ac4de0c 100644 --- a/control/nichols.py +++ b/control/nichols.py @@ -96,7 +96,7 @@ def nichols_plot(sys_list, omega=None, grid=None): for sys in sys_list: # Get the magnitude and phase of the system - mag_tmp, phase_tmp, omega = sys.freqresp(omega) + mag_tmp, phase_tmp, omega = sys.frequency_response(omega) mag = np.squeeze(mag_tmp) phase = np.squeeze(phase_tmp) diff --git a/control/statesp.py b/control/statesp.py index 522d187a9..a4f3aec71 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -54,7 +54,7 @@ import math import numpy as np from numpy import any, array, asarray, concatenate, cos, delete, \ - dot, empty, exp, eye, isinf, ones, pad, sin, zeros, squeeze + dot, empty, exp, eye, isinf, ones, pad, sin, zeros, squeeze, pi from numpy.random import rand, randn from numpy.linalg import solve, eigvals, matrix_rank from numpy.linalg.linalg import LinAlgError @@ -437,99 +437,27 @@ def __rdiv__(self, other): raise NotImplementedError("StateSpace.__rdiv__ is not implemented yet.") - def evalfr(self, omega): - """Evaluate a SS system's transfer function at a single frequency. + def __call__(self, s, squeeze=True): + """Evaluate system's transfer function at complex frequencies s/z. + + If squeeze is True (default) and sys is single input, single output + (SISO), return a 1D array or scalar depending on s. - self._evalfr(omega) returns the value of the transfer function matrix - with input value s = i * omega. - - """ - warn("StateSpace.evalfr(omega) will be deprecated in a future " - "release of python-control; use evalfr(sys, omega*1j) instead", - PendingDeprecationWarning) - return self._evalfr(omega) - - def _evalfr(self, omega): - """Evaluate a SS system's transfer function at a single frequency""" - # Figure out the point to evaluate the transfer function - if isdtime(self, strict=True): - dt = timebase(self) - s = exp(1.j * omega * dt) - if omega * dt > math.pi: - warn("_evalfr: frequency evaluation above Nyquist frequency") - else: - s = omega * 1.j - - return self.horner(s) - - def horner(self, s): - """Evaluate the systems's transfer function for a complex variable - - Returns a matrix of values evaluated at complex variable s. - """ - resp = np.dot(self.C, solve(s * eye(self.states) - self.A, - self.B)) + self.D - return array(resp) - - def freqresp(self, omega): - """Evaluate the system's transfer function at a list of frequencies - - Reports the frequency response of the system, - - G(j*omega) = mag*exp(j*phase) - - for continuous time. For discrete time systems, the response is - evaluated around the unit circle such that - - G(exp(j*omega*dt)) = mag*exp(j*phase). - - Parameters - ---------- - omega : array_like - A list of frequencies in radians/sec at which the system should be - evaluated. The list can be either a python list or a numpy array - and will be sorted before evaluation. - - Returns - ------- - mag : (self.outputs, self.inputs, len(omega)) ndarray - The magnitude (absolute value, not dB or log10) of the system - frequency response. - phase : (self.outputs, self.inputs, len(omega)) ndarray - The wrapped phase in radians of the system frequency response. - omega : ndarray - The list of sorted frequencies at which the response was - evaluated. - """ - # In case omega is passed in as a list, rather than a proper array. - omega = np.asarray(omega) - - numFreqs = len(omega) - Gfrf = np.empty((self.outputs, self.inputs, numFreqs), - dtype=np.complex128) - - # Sort frequency and calculate complex frequencies on either imaginary - # axis (continuous time) or unit circle (discrete time). - omega.sort() - if isdtime(self, strict=True): - dt = timebase(self) - cmplx_freqs = exp(1.j * omega * dt) - if max(np.abs(omega)) * dt > math.pi: - warn("freqresp: frequency evaluation above Nyquist frequency") - else: - cmplx_freqs = omega * 1.j - - # Do the frequency response evaluation. Use TB05AD from Slycot - # if it's available, otherwise use the built-in horners function. + """ + # Use TB05AD from Slycot if available try: from slycot import tb05ad - n = np.shape(self.A)[0] + # preallocate + s_arr = np.array(s, ndmin=1) # array-like version of s + out = np.empty((self.outputs, self.inputs, len(s_arr)), + dtype=complex) + n = self.states m = self.inputs p = self.outputs # The first call both evaluates C(sI-A)^-1 B and also returns # Hessenberg transformed matrices at, bt, ct. - result = tb05ad(n, m, p, cmplx_freqs[0], self.A, + result = tb05ad(n, m, p, s_arr[0], self.A, self.B, self.C, job='NG') # When job='NG', result = (at, bt, ct, g_i, hinvb, info) at = result[0] @@ -537,27 +465,54 @@ def freqresp(self, omega): ct = result[2] # TB05AD frequency evaluation does not include direct feedthrough. - Gfrf[:, :, 0] = result[3] + self.D + out[:, :, 0] = result[3] + self.D # Now, iterate through the remaining frequencies using the # transformed state matrices, at, bt, ct. # Start at the second frequency, already have the first. - for kk, cmplx_freqs_kk in enumerate(cmplx_freqs[1:numFreqs]): - result = tb05ad(n, m, p, cmplx_freqs_kk, at, - bt, ct, job='NH') + for kk, s_kk in enumerate(s_arr[1:len(s_arr)]): + result = tb05ad(n, m, p, s_kk, at, bt, ct, job='NH') # When job='NH', result = (g_i, hinvb, info) # kk+1 because enumerate starts at kk = 0. # but zero-th spot is already filled. - Gfrf[:, :, kk+1] = result[0] + self.D - + out[:, :, kk+1] = result[0] + self.D + + if not hasattr(s, '__len__'): + # received a scalar s, squeeze down the array + out = np.squeeze(out, axis=2) except ImportError: # Slycot unavailable. Fall back to horner. - for kk, cmplx_freqs_kk in enumerate(cmplx_freqs): - Gfrf[:, :, kk] = self.horner(cmplx_freqs_kk) + out = self.horner(s) + if squeeze and self.issiso(): + return out[0][0] + else: + return out - # mag phase omega - return np.abs(Gfrf), np.angle(Gfrf), omega + def horner(self, s): + """Evaluate systems's transfer function at complex frequencies s. + """ + s_arr = np.array(s, ndmin=1) # force to be an array + # Preallocate + out = empty((self.outputs, self.inputs, len(s_arr)), dtype=complex) + + for idx, s_idx in enumerate(s_arr): + out[:,:,idx] = \ + np.dot(self.C, + solve(s_idx * eye(self.states) - self.A, self.B)) \ + + self.D + if not hasattr(s, '__len__'): + # received a scalar s, squeeze down the array along last dim + out = np.squeeze(out, axis=2) + return out + + def freqresp(self, omega): + warn("StateSpace.freqresp(omega) will be deprecated in a " + "future release of python-control; use " + "StateSpace.frequency_response(sys, omega), or " + "freqresp(sys, omega) in the MATLAB compatibility module " + "instead", PendingDeprecationWarning) + return self.frequency_response(omega) # Compute poles and zeros def pole(self): diff --git a/control/tests/frd_test.py b/control/tests/frd_test.py index fcbc10263..3d57753bc 100644 --- a/control/tests/frd_test.py +++ b/control/tests/frd_test.py @@ -37,7 +37,7 @@ def testSISOtf(self): assert isinstance(frd, FRD) np.testing.assert_array_almost_equal( - frd.freqresp([1.0]), h.freqresp([1.0])) + frd.frequency_response([1.0]), h.frequency_response([1.0])) def testOperators(self): # get two SISO transfer functions @@ -48,39 +48,39 @@ def testOperators(self): f2 = FRD(h2, omega) np.testing.assert_array_almost_equal( - (f1 + f2).freqresp([0.1, 1.0, 10])[0], - (h1 + h2).freqresp([0.1, 1.0, 10])[0]) + (f1 + f2).frequency_response([0.1, 1.0, 10])[0], + (h1 + h2).frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - (f1 + f2).freqresp([0.1, 1.0, 10])[1], - (h1 + h2).freqresp([0.1, 1.0, 10])[1]) + (f1 + f2).frequency_response([0.1, 1.0, 10])[1], + (h1 + h2).frequency_response([0.1, 1.0, 10])[1]) np.testing.assert_array_almost_equal( - (f1 - f2).freqresp([0.1, 1.0, 10])[0], - (h1 - h2).freqresp([0.1, 1.0, 10])[0]) + (f1 - f2).frequency_response([0.1, 1.0, 10])[0], + (h1 - h2).frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - (f1 - f2).freqresp([0.1, 1.0, 10])[1], - (h1 - h2).freqresp([0.1, 1.0, 10])[1]) + (f1 - f2).frequency_response([0.1, 1.0, 10])[1], + (h1 - h2).frequency_response([0.1, 1.0, 10])[1]) # multiplication and division np.testing.assert_array_almost_equal( - (f1 * f2).freqresp([0.1, 1.0, 10])[1], - (h1 * h2).freqresp([0.1, 1.0, 10])[1]) + (f1 * f2).frequency_response([0.1, 1.0, 10])[1], + (h1 * h2).frequency_response([0.1, 1.0, 10])[1]) np.testing.assert_array_almost_equal( - (f1 / f2).freqresp([0.1, 1.0, 10])[1], - (h1 / h2).freqresp([0.1, 1.0, 10])[1]) + (f1 / f2).frequency_response([0.1, 1.0, 10])[1], + (h1 / h2).frequency_response([0.1, 1.0, 10])[1]) # with default conversion from scalar np.testing.assert_array_almost_equal( - (f1 * 1.5).freqresp([0.1, 1.0, 10])[1], - (h1 * 1.5).freqresp([0.1, 1.0, 10])[1]) + (f1 * 1.5).frequency_response([0.1, 1.0, 10])[1], + (h1 * 1.5).frequency_response([0.1, 1.0, 10])[1]) np.testing.assert_array_almost_equal( - (f1 / 1.7).freqresp([0.1, 1.0, 10])[1], - (h1 / 1.7).freqresp([0.1, 1.0, 10])[1]) + (f1 / 1.7).frequency_response([0.1, 1.0, 10])[1], + (h1 / 1.7).frequency_response([0.1, 1.0, 10])[1]) np.testing.assert_array_almost_equal( - (2.2 * f2).freqresp([0.1, 1.0, 10])[1], - (2.2 * h2).freqresp([0.1, 1.0, 10])[1]) + (2.2 * f2).frequency_response([0.1, 1.0, 10])[1], + (2.2 * h2).frequency_response([0.1, 1.0, 10])[1]) np.testing.assert_array_almost_equal( - (1.3 / f2).freqresp([0.1, 1.0, 10])[1], - (1.3 / h2).freqresp([0.1, 1.0, 10])[1]) + (1.3 / f2).frequency_response([0.1, 1.0, 10])[1], + (1.3 / h2).frequency_response([0.1, 1.0, 10])[1]) def testOperatorsTf(self): # get two SISO transfer functions @@ -92,24 +92,24 @@ def testOperatorsTf(self): f2 # reference to avoid pyflakes error np.testing.assert_array_almost_equal( - (f1 + h2).freqresp([0.1, 1.0, 10])[0], - (h1 + h2).freqresp([0.1, 1.0, 10])[0]) + (f1 + h2).frequency_response([0.1, 1.0, 10])[0], + (h1 + h2).frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - (f1 + h2).freqresp([0.1, 1.0, 10])[1], - (h1 + h2).freqresp([0.1, 1.0, 10])[1]) + (f1 + h2).frequency_response([0.1, 1.0, 10])[1], + (h1 + h2).frequency_response([0.1, 1.0, 10])[1]) np.testing.assert_array_almost_equal( - (f1 - h2).freqresp([0.1, 1.0, 10])[0], - (h1 - h2).freqresp([0.1, 1.0, 10])[0]) + (f1 - h2).frequency_response([0.1, 1.0, 10])[0], + (h1 - h2).frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - (f1 - h2).freqresp([0.1, 1.0, 10])[1], - (h1 - h2).freqresp([0.1, 1.0, 10])[1]) + (f1 - h2).frequency_response([0.1, 1.0, 10])[1], + (h1 - h2).frequency_response([0.1, 1.0, 10])[1]) # multiplication and division np.testing.assert_array_almost_equal( - (f1 * h2).freqresp([0.1, 1.0, 10])[1], - (h1 * h2).freqresp([0.1, 1.0, 10])[1]) + (f1 * h2).frequency_response([0.1, 1.0, 10])[1], + (h1 * h2).frequency_response([0.1, 1.0, 10])[1]) np.testing.assert_array_almost_equal( - (f1 / h2).freqresp([0.1, 1.0, 10])[1], - (h1 / h2).freqresp([0.1, 1.0, 10])[1]) + (f1 / h2).frequency_response([0.1, 1.0, 10])[1], + (h1 / h2).frequency_response([0.1, 1.0, 10])[1]) # the reverse does not work def testbdalg(self): @@ -121,45 +121,45 @@ def testbdalg(self): f2 = FRD(h2, omega) np.testing.assert_array_almost_equal( - (bdalg.series(f1, f2)).freqresp([0.1, 1.0, 10])[0], - (bdalg.series(h1, h2)).freqresp([0.1, 1.0, 10])[0]) + (bdalg.series(f1, f2)).frequency_response([0.1, 1.0, 10])[0], + (bdalg.series(h1, h2)).frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - (bdalg.parallel(f1, f2)).freqresp([0.1, 1.0, 10])[0], - (bdalg.parallel(h1, h2)).freqresp([0.1, 1.0, 10])[0]) + (bdalg.parallel(f1, f2)).frequency_response([0.1, 1.0, 10])[0], + (bdalg.parallel(h1, h2)).frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - (bdalg.feedback(f1, f2)).freqresp([0.1, 1.0, 10])[0], - (bdalg.feedback(h1, h2)).freqresp([0.1, 1.0, 10])[0]) + (bdalg.feedback(f1, f2)).frequency_response([0.1, 1.0, 10])[0], + (bdalg.feedback(h1, h2)).frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - (bdalg.negate(f1)).freqresp([0.1, 1.0, 10])[0], - (bdalg.negate(h1)).freqresp([0.1, 1.0, 10])[0]) + (bdalg.negate(f1)).frequency_response([0.1, 1.0, 10])[0], + (bdalg.negate(h1)).frequency_response([0.1, 1.0, 10])[0]) # append() and connect() not implemented for FRD objects # np.testing.assert_array_almost_equal( -# (bdalg.append(f1, f2)).freqresp([0.1, 1.0, 10])[0], -# (bdalg.append(h1, h2)).freqresp([0.1, 1.0, 10])[0]) +# (bdalg.append(f1, f2)).frequency_response([0.1, 1.0, 10])[0], +# (bdalg.append(h1, h2)).frequency_response([0.1, 1.0, 10])[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])).freqresp([0.1, 1.0, 10])[0], -# (bdalg.connect(h3, Q, [2], [1])).freqresp([0.1, 1.0, 10])[0]) +# (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]) def testFeedback(self): h1 = TransferFunction([1], [1, 2, 2]) omega = np.logspace(-1, 2, 10) f1 = FRD(h1, omega) np.testing.assert_array_almost_equal( - f1.feedback(1).freqresp([0.1, 1.0, 10])[0], - h1.feedback(1).freqresp([0.1, 1.0, 10])[0]) + f1.feedback(1).frequency_response([0.1, 1.0, 10])[0], + h1.feedback(1).frequency_response([0.1, 1.0, 10])[0]) # Make sure default argument also works np.testing.assert_array_almost_equal( - f1.feedback().freqresp([0.1, 1.0, 10])[0], - h1.feedback().freqresp([0.1, 1.0, 10])[0]) + f1.feedback().frequency_response([0.1, 1.0, 10])[0], + h1.feedback().frequency_response([0.1, 1.0, 10])[0]) def testFeedback2(self): h2 = StateSpace([[-1.0, 0], [0, -2.0]], [[0.4], [0.1]], @@ -192,11 +192,11 @@ def testMIMO(self): omega = np.logspace(-1, 2, 10) f1 = FRD(sys, omega) np.testing.assert_array_almost_equal( - sys.freqresp([0.1, 1.0, 10])[0], - f1.freqresp([0.1, 1.0, 10])[0]) + sys.frequency_response([0.1, 1.0, 10])[0], + f1.frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - sys.freqresp([0.1, 1.0, 10])[1], - f1.freqresp([0.1, 1.0, 10])[1]) + sys.frequency_response([0.1, 1.0, 10])[1], + f1.frequency_response([0.1, 1.0, 10])[1]) @unittest.skipIf(not slycot_check(), "slycot not installed") def testMIMOfb(self): @@ -208,11 +208,11 @@ def testMIMOfb(self): 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.freqresp([0.1, 1.0, 10])[0], - f2.freqresp([0.1, 1.0, 10])[0]) + f1.frequency_response([0.1, 1.0, 10])[0], + f2.frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - f1.freqresp([0.1, 1.0, 10])[1], - f2.freqresp([0.1, 1.0, 10])[1]) + f1.frequency_response([0.1, 1.0, 10])[1], + f2.frequency_response([0.1, 1.0, 10])[1]) @unittest.skipIf(not slycot_check(), "slycot not installed") def testMIMOfb2(self): @@ -224,11 +224,11 @@ def testMIMOfb2(self): f1 = FRD(sys, omega).feedback(K) f2 = FRD(sys.feedback(K), omega) np.testing.assert_array_almost_equal( - f1.freqresp([0.1, 1.0, 10])[0], - f2.freqresp([0.1, 1.0, 10])[0]) + f1.frequency_response([0.1, 1.0, 10])[0], + f2.frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - f1.freqresp([0.1, 1.0, 10])[1], - f2.freqresp([0.1, 1.0, 10])[1]) + f1.frequency_response([0.1, 1.0, 10])[1], + f2.frequency_response([0.1, 1.0, 10])[1]) @unittest.skipIf(not slycot_check(), "slycot not installed") def testMIMOMult(self): @@ -240,11 +240,11 @@ def testMIMOMult(self): f1 = FRD(sys, omega) f2 = FRD(sys, omega) np.testing.assert_array_almost_equal( - (f1*f2).freqresp([0.1, 1.0, 10])[0], - (sys*sys).freqresp([0.1, 1.0, 10])[0]) + (f1*f2).frequency_response([0.1, 1.0, 10])[0], + (sys*sys).frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - (f1*f2).freqresp([0.1, 1.0, 10])[1], - (sys*sys).freqresp([0.1, 1.0, 10])[1]) + (f1*f2).frequency_response([0.1, 1.0, 10])[1], + (sys*sys).frequency_response([0.1, 1.0, 10])[1]) @unittest.skipIf(not slycot_check(), "slycot not installed") def testMIMOSmooth(self): @@ -257,14 +257,14 @@ def testMIMOSmooth(self): f1 = FRD(sys, omega, smooth=True) f2 = FRD(sys2, omega, smooth=True) np.testing.assert_array_almost_equal( - (f1*f2).freqresp([0.1, 1.0, 10])[0], - (sys*sys2).freqresp([0.1, 1.0, 10])[0]) + (f1*f2).frequency_response([0.1, 1.0, 10])[0], + (sys*sys2).frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - (f1*f2).freqresp([0.1, 1.0, 10])[1], - (sys*sys2).freqresp([0.1, 1.0, 10])[1]) + (f1*f2).frequency_response([0.1, 1.0, 10])[1], + (sys*sys2).frequency_response([0.1, 1.0, 10])[1]) np.testing.assert_array_almost_equal( - (f1*f2).freqresp([0.1, 1.0, 10])[2], - (sys*sys2).freqresp([0.1, 1.0, 10])[2]) + (f1*f2).frequency_response([0.1, 1.0, 10])[2], + (sys*sys2).frequency_response([0.1, 1.0, 10])[2]) def testAgainstOctave(self): # with data from octave: @@ -277,8 +277,8 @@ def testAgainstOctave(self): omega = np.logspace(-1, 2, 10) f1 = FRD(sys, omega) np.testing.assert_array_almost_equal( - (f1.freqresp([1.0])[0] * - np.exp(1j*f1.freqresp([1.0])[1])).reshape(3, 2), + (f1.frequency_response([1.0])[0] * + np.exp(1j*f1.frequency_response([1.0])[1])).reshape(3, 2), np.matrix('0.4-0.2j 0; 0 0.1-0.2j; 0 0.3-0.1j')) def test_string_representation(self): @@ -383,36 +383,13 @@ def test_operator_conversion(self): def test_eval(self): sys_tf = ct.tf([1], [1, 2, 1]) frd_tf = FRD(sys_tf, np.logspace(-1, 1, 3)) - np.testing.assert_almost_equal(sys_tf.evalfr(1), frd_tf.eval(1)) + np.testing.assert_almost_equal(sys_tf(1j), frd_tf.eval(1)) + np.testing.assert_almost_equal(sys_tf(1j), frd_tf(1j)) # Should get an error if we evaluate at an unknown frequency - self.assertRaises(ValueError, frd_tf.eval, 2) - - # This test only works in Python 3 due to a conflict with the same - # warning type in other test modules (frd_test.py). See - # https://bugs.python.org/issue4180 for more details - @unittest.skipIf(pysys.version_info < (3, 0), "test requires Python 3+") - def test_evalfr_deprecated(self): - sys_tf = ct.tf([1], [1, 2, 1]) - frd_tf = FRD(sys_tf, np.logspace(-1, 1, 3)) - - # Deprecated version of the call (should generate warning) - import warnings - with warnings.catch_warnings(): - # Make warnings generate an exception - warnings.simplefilter('error') - - # Make sure that we get a pending deprecation warning - self.assertRaises(PendingDeprecationWarning, frd_tf.evalfr, 1.) - - # FRD.evalfr() is being deprecated - import warnings - with warnings.catch_warnings(): - # Make warnings generate an exception - warnings.simplefilter('error') - - # Make sure that we get a pending deprecation warning - self.assertRaises(PendingDeprecationWarning, frd_tf.evalfr, 1.) + self.assertRaises(Exception, frd_tf.eval(2)) + # Should get an error if we use __call__ at real-valued frequency + self.assertRaises(ValueError, frd_tf(2)) def test_repr_str(self): # repr printing diff --git a/control/tests/freqresp_test.py b/control/tests/freqresp_test.py index 9d59a1972..991a4cacd 100644 --- a/control/tests/freqresp_test.py +++ b/control/tests/freqresp_test.py @@ -18,7 +18,7 @@ from control.exception import slycot_check -class TestFreqresp(unittest.TestCase): +class TestFrequencyResponse(unittest.TestCase): def setUp(self): self.A = np.matrix('1,1;0,1') self.C = np.matrix('1,0') @@ -30,7 +30,7 @@ def test_siso(self): sys = StateSpace(self.A,B,self.C,D) # test frequency response - frq=sys.freqresp(self.omega) + frq=sys.frequency_response(self.omega) # test bode plot bode(sys) @@ -84,7 +84,7 @@ def test_superimpose(self): self.assertEqual(len(ax.get_lines()), 2) def test_doubleint(self): - # 30 May 2016, RMM: added to replicate typecast bug in freqresp.py + # 30 May 2016, RMM: added to replicate typecast bug in frequency_response.py A = np.matrix('0, 1; 0, 0'); B = np.matrix('0; 1'); C = np.matrix('1, 0'); @@ -99,7 +99,7 @@ def test_mimo(self): D = np.matrix('0,0') sysMIMO = ss(self.A,B,self.C,D) - frqMIMO = sysMIMO.freqresp(self.omega) + frqMIMO = sysMIMO.frequency_response(self.omega) tfMIMO = tf(sysMIMO) #bode(sysMIMO) # - should throw not implemented exception @@ -167,7 +167,7 @@ def test_discrete(self): omega_ok = np.linspace(10e-4,0.99,100) * np.pi/sys.dt # Test frequency response - ret = sys.freqresp(omega_ok) + ret = sys.frequency_response(omega_ok) # Check for warning if frequency is out of range import warnings @@ -179,7 +179,7 @@ def test_discrete(self): # Look for a warning about sampling above Nyquist frequency omega_bad = np.linspace(10e-4,1.1,10) * np.pi/sys.dt - ret = sys.freqresp(omega_bad) + ret = sys.frequency_response(omega_bad) print("len(w) =", len(w)) self.assertEqual(len(w), 1) self.assertIn("above", str(w[-1].message)) diff --git a/control/tests/statesp_array_test.py b/control/tests/statesp_array_test.py index a2d034075..02e5d69d6 100644 --- a/control/tests/statesp_array_test.py +++ b/control/tests/statesp_array_test.py @@ -180,7 +180,7 @@ def test_multiply_ss(self): np.testing.assert_array_almost_equal(sys.C, C) np.testing.assert_array_almost_equal(sys.D, D) - def test_evalfr(self): + def test_call(self): """Evaluate the frequency response at one frequency.""" A = [[-2, 0.5], [0.5, -0.3]] @@ -196,22 +196,7 @@ def test_evalfr(self): # Correct versions of the call np.testing.assert_almost_equal(evalfr(sys, 1j), resp) - np.testing.assert_almost_equal(sys._evalfr(1.), resp) - - # Deprecated version of the call (should generate warning) - import warnings - with warnings.catch_warnings(record=True) as w: - # Set up warnings filter to only show warnings in control module - warnings.filterwarnings("ignore") - warnings.filterwarnings("always", module="control") - - # Make sure that we get a pending deprecation warning - sys.evalfr(1.) - assert len(w) == 1 - assert issubclass(w[-1].category, PendingDeprecationWarning) - - # Leave the warnings filter like we found it - warnings.resetwarnings() + np.testing.assert_almost_equal(sys(1.j), resp) @unittest.skipIf(not slycot_check(), "slycot not installed") def test_freq_resp(self): @@ -233,7 +218,7 @@ def test_freq_resp(self): [-0.438157380501337, -1.40720969147217]]] true_omega = [0.1, 10.] - mag, phase, omega = sys.freqresp(true_omega) + mag, phase, omega = sys.frequency_response(true_omega) np.testing.assert_almost_equal(mag, true_mag) np.testing.assert_almost_equal(phase, true_phase) @@ -518,7 +503,7 @@ def test_horner(self): self.sys322.horner(1.+1.j) # Make sure result agrees with frequency response - mag, phase, omega = self.sys322.freqresp([1]) + mag, phase, omega = self.sys322.frequency_response([1]) np.testing.assert_array_almost_equal( self.sys322.horner(1.j), mag[:,:,0] * np.exp(1.j * phase[:,:,0])) diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index 96404d79f..77a6d3ec2 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -189,7 +189,7 @@ def test_multiply_ss(self): np.testing.assert_array_almost_equal(sys.C, C) np.testing.assert_array_almost_equal(sys.D, D) - def test_evalfr(self): + def test_call(self): """Evaluate the frequency response at one frequency.""" A = [[-2, 0.5], [0.5, -0.3]] @@ -205,7 +205,14 @@ def test_evalfr(self): # Correct versions of the call np.testing.assert_almost_equal(evalfr(sys, 1j), resp) - np.testing.assert_almost_equal(sys._evalfr(1.), resp) + np.testing.assert_almost_equal(sys(1.j), resp) + + def test_freqresp_deprecated(self): + A = [[-2, 0.5], [0.5, -0.3]] + B = [[0.3, -1.3], [0.1, 0.]] + C = [[0., 0.1], [-0.3, -0.2]] + D = [[0., -0.8], [-0.3, 0.]] + sys = StateSpace(A, B, C, D) # Deprecated version of the call (should generate warning) import warnings @@ -215,8 +222,7 @@ def test_evalfr(self): warnings.filterwarnings("always", module="control") # Make sure that we get a pending deprecation warning - sys.evalfr(1.) - assert len(w) == 1 + sys.freqresp(1.) assert issubclass(w[-1].category, PendingDeprecationWarning) @unittest.skipIf(not slycot_check(), "slycot not installed") @@ -239,12 +245,12 @@ def test_freq_resp(self): [-0.438157380501337, -1.40720969147217]]] true_omega = [0.1, 10.] - mag, phase, omega = sys.freqresp(true_omega) + mag, phase, omega = sys.frequency_response(true_omega) np.testing.assert_almost_equal(mag, true_mag) np.testing.assert_almost_equal(phase, true_phase) np.testing.assert_equal(omega, true_omega) - + @unittest.skipIf(not slycot_check(), "slycot not installed") def test_minreal(self): """Test a minreal model reduction.""" @@ -652,26 +658,5 @@ def test_copy_constructor(self): # Change the A matrix for the original system linsys.A[0, 0] = -3 np.testing.assert_array_equal(cpysys.A, [[-1]]) # original value - - def test_sample_system_prewarping(self): - """test that prewarping works when converting from cont to discrete time system""" - A = np.array([ - [ 0.00000000e+00, 1.00000000e+00, 0.00000000e+00, 0.00000000e+00], - [-3.81097561e+01, -1.12500000e+00, 0.00000000e+00, 0.00000000e+00], - [ 0.00000000e+00, 0.00000000e+00, 0.00000000e+00, 1.00000000e+00], - [ 0.00000000e+00, 0.00000000e+00, -1.66356135e+04, -1.34748470e+01]]) - B = np.array([ - [ 0. ], [ 38.1097561 ],[ 0. ],[16635.61352143]]) - C = np.array([[0.90909091, 0. , 0.09090909, 0. ],]) - wwarp = 50 - Ts = 0.025 - plant = StateSpace(A,B,C,0) - plant_d_warped = plant.sample(Ts, 'bilinear', prewarp_frequency=wwarp) - np.testing.assert_array_almost_equal( - evalfr(plant, wwarp*1j), - evalfr(plant_d_warped, np.exp(wwarp*1j*Ts)), - decimal=4) - - if __name__ == "__main__": unittest.main() diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py index 02e6c2b37..4ee314df1 100644 --- a/control/tests/xferfcn_test.py +++ b/control/tests/xferfcn_test.py @@ -386,7 +386,7 @@ def test_slice(self): self.assertEqual((sys1.inputs, sys1.outputs), (2, 1)) self.assertEqual(sys1.dt, 0.5) - def test_evalfr_siso(self): + def test_call_siso(self): """Evaluate the frequency response of a SISO system at one frequency.""" sys = TransferFunction([1., 3., 5], [1., 6., 2., -1]) @@ -400,38 +400,22 @@ def test_evalfr_siso(self): # Test call version as well np.testing.assert_almost_equal(sys(1.j), -0.5 - 0.5j) np.testing.assert_almost_equal( - sys(32.j), 0.00281959302585077 - 0.030628473607392j) + sys(32j), 0.00281959302585077 - 0.030628473607392j) # Test internal version (with real argument) np.testing.assert_array_almost_equal( - sys._evalfr(1.), np.array([[-0.5 - 0.5j]])) + sys(1j), np.array([[-0.5 - 0.5j]])) np.testing.assert_array_almost_equal( - sys._evalfr(32.), + sys(32j), np.array([[0.00281959302585077 - 0.030628473607392j]])) - # This test only works in Python 3 due to a conflict with the same - # warning type in other test modules (frd_test.py). See - # https://bugs.python.org/issue4180 for more details @unittest.skipIf(pysys.version_info < (3, 0), "test requires Python 3+") - def test_evalfr_deprecated(self): - sys = TransferFunction([1., 3., 5], [1., 6., 2., -1]) - - # Deprecated version of the call (should generate warning) - import warnings - with warnings.catch_warnings(): - # Make warnings generate an exception - warnings.simplefilter('error') - - # Make sure that we get a pending deprecation warning - self.assertRaises(PendingDeprecationWarning, sys.evalfr, 1.) - - @unittest.skipIf(pysys.version_info < (3, 0), "test requires Python 3+") - def test_evalfr_dtime(self): + 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) @unittest.skipIf(not slycot_check(), "slycot not installed") - def test_evalfr_mimo(self): + def test_call_mimo(self): """Evaluate the frequency response of a MIMO system at one frequency.""" num = [[[1., 2.], [0., 3.], [2., -1.]], @@ -443,12 +427,12 @@ def test_evalfr_mimo(self): [-0.083333333333333, -0.188235294117647 - 0.847058823529412j, -1. - 8.j]] - np.testing.assert_array_almost_equal(sys._evalfr(2.), resp) + np.testing.assert_array_almost_equal(evalfr(sys, 2j), resp) # Test call version as well np.testing.assert_array_almost_equal(sys(2.j), resp) - def test_freqresp_siso(self): + def test_frequency_response_siso(self): """Evaluate the magnitude and phase of a SISO system at multiple frequencies.""" @@ -459,14 +443,14 @@ def test_freqresp_siso(self): -1.32655885133871]]] trueomega = [0.1, 1., 10.] - mag, phase, omega = sys.freqresp(trueomega) + mag, phase, omega = sys.frequency_response(trueomega, squeeze=False) np.testing.assert_array_almost_equal(mag, truemag) np.testing.assert_array_almost_equal(phase, truephase) np.testing.assert_array_almost_equal(omega, trueomega) @unittest.skipIf(not slycot_check(), "slycot not installed") - def test_freqresp_mimo(self): + def test_frequency_response_mimo(self): """Evaluate the magnitude and phase of a MIMO system at multiple frequencies.""" @@ -489,7 +473,7 @@ def test_freqresp_mimo(self): [-1.66852323, -1.89254688, -1.62050658], [-0.13298964, -1.10714871, -2.75046720]]] - mag, phase, omega = sys.freqresp(true_omega) + mag, phase, omega = sys.frequency_response(true_omega) np.testing.assert_array_almost_equal(mag, true_mag) np.testing.assert_array_almost_equal(phase, true_phase) diff --git a/control/xferfcn.py b/control/xferfcn.py index f50d5141d..bb342c732 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -204,18 +204,21 @@ def __init__(self, *args): self._truncatecoeff() - def __call__(self, s): - """Evaluate the system's transfer function for a complex variable + def __call__(self, s, squeeze=True): + """Evaluate the system's transfer function at the complex frequency s/z. - For a SISO transfer function, returns the value of the + For a SISO transfer function, returns the complex value of the transfer function. For a MIMO transfer fuction, returns a - matrix of values evaluated at complex variable s.""" + matrix of values. + + If squeeze is True (default) and sys is single input, single output + (SISO), return a 1D array or scalar depending on s. - if self.issiso(): - # return a scalar + """ + if squeeze and self.issiso(): + # return a scalar/1d array of outputs return self.horner(s)[0][0] else: - # return a matrix return self.horner(s) def _truncatecoeff(self): @@ -608,40 +611,10 @@ def __getitem__(self, key): else: return TransferFunction(num, den, self.dt) - def evalfr(self, omega): - """Evaluate a transfer function at a single angular frequency. - - self._evalfr(omega) returns the value of the transfer function - matrix with input value s = i * omega. - - """ - warn("TransferFunction.evalfr(omega) will be deprecated in a " - "future release of python-control; use evalfr(sys, omega*1j) " - "instead", PendingDeprecationWarning) - return self._evalfr(omega) - - def _evalfr(self, omega): - """Evaluate a transfer function at a single angular frequency.""" - # TODO: implement for discrete time systems - if isdtime(self, strict=True): - # Convert the frequency to discrete time - dt = timebase(self) - s = exp(1.j * omega * dt) - if np.any(omega * dt > pi): - warn("_evalfr: frequency evaluation above Nyquist frequency") - else: - s = 1.j * omega - - return self.horner(s) - def horner(self, s): - """Evaluate the systems's transfer function for a complex variable - - Returns a matrix of values evaluated at complex variable s. - """ - + "Evaluate system's transfer function at complex frequencies s." # Preallocate the output. - if getattr(s, '__iter__', False): + if hasattr(s, '__len__'): out = empty((self.outputs, self.inputs, len(s)), dtype=complex) else: out = empty((self.outputs, self.inputs), dtype=complex) @@ -654,59 +627,12 @@ def horner(self, s): return out def freqresp(self, omega): - """Evaluate the transfer function at a list of angular frequencies. - - Reports the frequency response of the system, - - G(j*omega) = mag*exp(j*phase) - - for continuous time. For discrete time systems, the response is - evaluated around the unit circle such that - - G(exp(j*omega*dt)) = mag*exp(j*phase). - - Parameters - ---------- - omega : array_like - A list of frequencies in radians/sec at which the system should be - evaluated. The list can be either a python list or a numpy array - and will be sorted before evaluation. - - Returns - ------- - mag : (self.outputs, self.inputs, len(omega)) ndarray - The magnitude (absolute value, not dB or log10) of the system - frequency response. - phase : (self.outputs, self.inputs, len(omega)) ndarray - The wrapped phase in radians of the system frequency response. - omega : ndarray or list or tuple - The list of sorted frequencies at which the response was - evaluated. - """ - # Preallocate outputs. - numfreq = len(omega) - mag = empty((self.outputs, self.inputs, numfreq)) - phase = empty((self.outputs, self.inputs, numfreq)) - - # Figure out the frequencies - omega.sort() - if isdtime(self, strict=True): - dt = timebase(self) - slist = np.array([exp(1.j * w * dt) for w in omega]) - if max(omega) * dt > pi: - warn("freqresp: frequency evaluation above Nyquist frequency") - else: - slist = np.array([1j * w for w in omega]) - - # Compute frequency response for each input/output pair - for i in range(self.outputs): - for j in range(self.inputs): - fresp = (polyval(self.num[i][j], slist) / - polyval(self.den[i][j], slist)) - mag[i, j, :] = abs(fresp) - phase[i, j, :] = angle(fresp) - - return mag, phase, omega + warn("TransferFunction.freqresp(omega) will be deprecated in a " + "future release of python-control; use " + "TransferFunction.frequency_response(sys, omega), or " + "freqresp(sys, omega) in the MATLAB compatibility module " + "instead", PendingDeprecationWarning) + return self.frequency_response(omega) def pole(self): """Compute the poles of a transfer function.""" diff --git a/doc/control.rst b/doc/control.rst index 57d64b1eb..0705e1b9f 100644 --- a/doc/control.rst +++ b/doc/control.rst @@ -172,6 +172,7 @@ Utility functions and conversions tfdata timebase timebaseEqual + common_timebase unwrap use_fbs_defaults use_matlab_defaults diff --git a/examples/robust_mimo.py b/examples/robust_mimo.py index d4e1335e6..9cc5a1c3b 100644 --- a/examples/robust_mimo.py +++ b/examples/robust_mimo.py @@ -43,7 +43,7 @@ def triv_sigma(g, w): g - LTI object, order n w - frequencies, length m s - (m,n) array of singular values of g(1j*w)""" - m, p, _ = g.freqresp(w) + m, p, _ = g.frequency_response(w) sjw = (m*np.exp(1j*p)).transpose(2, 0, 1) sv = np.linalg.svd(sjw, compute_uv=False) return sv diff --git a/examples/robust_siso.py b/examples/robust_siso.py index 87fcdb707..17ce10927 100644 --- a/examples/robust_siso.py +++ b/examples/robust_siso.py @@ -50,10 +50,10 @@ # frequency response omega = np.logspace(-2, 2, 101) -ws1mag, _, _ = ws1.freqresp(omega) -s1mag, _, _ = s1.freqresp(omega) -ws2mag, _, _ = ws2.freqresp(omega) -s2mag, _, _ = s2.freqresp(omega) +ws1mag, _, _ = ws1.frequency_response(omega) +s1mag, _, _ = s1.frequency_response(omega) +ws2mag, _, _ = ws2.frequency_response(omega) +s2mag, _, _ = s2.frequency_response(omega) plt.figure(1) # text uses log-scaled absolute, but dB are probably more familiar to most control engineers From b05492c08e73ebb29739acdf6924df6a4fe73764 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Sun, 16 Aug 2020 00:51:38 -0700 Subject: [PATCH 02/32] fixed remaining failing unit tests and cleanup of docfiles --- control/frdata.py | 13 +-- control/lti.py | 9 +- control/margins.py | 17 ++-- control/rlocus.py | 10 +-- control/statesp.py | 122 +++++++++++++++++----------- control/tests/frd_test.py | 4 +- control/tests/margin_test.py | 8 +- control/tests/statesp_array_test.py | 2 +- control/xferfcn.py | 63 ++++++++------ 9 files changed, 147 insertions(+), 101 deletions(-) diff --git a/control/frdata.py b/control/frdata.py index 084f4aba3..6016e1460 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -47,7 +47,7 @@ # External function declarations from warnings import warn import numpy as np -from numpy import angle, array, empty, ones, isin, \ +from numpy import angle, array, empty, ones, \ real, imag, absolute, eye, linalg, where, dot, sort from scipy.interpolate import splprep, splev from .lti import LTI @@ -354,7 +354,7 @@ def eval(self, omega, squeeze=True): squeeze: bool, optional (default=True) If True and sys is single input, single output (SISO), return a - 1D array or scalar depending on omega's length. + 1D array or scalar the same length as omega. """ omega_array = np.array(omega, ndmin=1) # array-like version of omega @@ -362,15 +362,16 @@ def eval(self, omega, squeeze=True): raise ValueError("FRD.eval can only accept real-valued omega") if self.ifunc is None: - elements = isin(self.omega, omega) + elements = np.isin(self.omega, omega) # binary array if sum(elements) < len(omega_array): raise ValueError( - "not all frequencies omega are in frequency list of FRD " - "system. Try an interpolating FRD for additional points.") + '''not all frequencies omega are in frequency list of FRD + system. Try an interpolating FRD for additional points.''') else: out = self.fresp[:, :, elements] else: - out = empty((self.outputs, self.inputs, len(omega_array))) + out = empty((self.outputs, self.inputs, len(omega_array)), + dtype=complex) for i in range(self.outputs): for j in range(self.inputs): for k, w in enumerate(omega_array): diff --git a/control/lti.py b/control/lti.py index 6d39955bb..c6bf318a9 100644 --- a/control/lti.py +++ b/control/lti.py @@ -465,9 +465,14 @@ def evalfr(sys, x, squeeze=True): .. todo:: Add example with MIMO system """ + out = sys.horner(x) + if not hasattr(x, '__len__'): + # received a scalar x, squeeze down the array along last dim + out = np.squeeze(out, axis=2) if squeeze and issiso(sys): - return sys.horner(x)[0][0] - return sys.horner(x) + return out[0][0] + else: + return out def freqresp(sys, omega, squeeze=True): diff --git a/control/margins.py b/control/margins.py index 4094b43bf..cda5095aa 100644 --- a/control/margins.py +++ b/control/margins.py @@ -213,15 +213,15 @@ def stability_margins(sysdata, returnall=False, epsw=0.0): # a bit coarse, have the interpolated frd evaluated again def _mod(w): """Calculate |G(jw)| - 1""" - return np.abs(sys(1j * w)[0][0]) - 1 + return np.abs(sys(1j * w)) - 1 def _arg(w): """Calculate the phase angle at -180 deg""" - return np.angle(-sys(1j * w)[0][0]) + return np.angle(-sys(1j * w)) def _dstab(w): """Calculate the distance from -1 point""" - return np.abs(sys(1j * w)[0][0] + 1.) + return np.abs(sys(1j * w) + 1.) # Find all crossings, note that this depends on omega having # a correct range @@ -232,7 +232,7 @@ def _dstab(w): # find the phase crossings ang(H(jw) == -180 widx = np.where(np.diff(np.sign(_arg(sys.omega))))[0] - widx = widx[np.real(sys(1j * sys.omega[widx])[0][0]) <= 0] + widx = widx[np.real(sys(1j * sys.omega[widx])) <= 0] w_180 = np.array( [sp.optimize.brentq(_arg, sys.omega[i], sys.omega[i+1]) for i in widx]) @@ -296,11 +296,10 @@ def phase_crossover_frequencies(sys): (array([ 1.73205081, 0. ]), array([-0.5 , 0.25])) """ + if not sys.issiso(): + raise ValueError("MIMO systems not yet implemented.") # Convert to a transfer function tf = xferfcn._convert_to_transfer_function(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] @@ -313,7 +312,9 @@ def phase_crossover_frequencies(sys): # 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(1j * f)[0][0] for f in realposfreq])) + # update Sawyer B. Fuller 2020.08.15: your wish is my command. + #gain = np.real(np.asarray([tf(1j * f) for f in realposfreq])) + gain = np.real(tf(1j * realposfreq)) return realposfreq, gain diff --git a/control/rlocus.py b/control/rlocus.py index 9f7ff4568..39c2fcfb6 100644 --- a/control/rlocus.py +++ b/control/rlocus.py @@ -493,7 +493,7 @@ 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 kvect: + for k in np.array(kvect, ndmin=1): curpoly = denp + k * nump curroots = curpoly.r if len(curroots) < denp.order: @@ -577,10 +577,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.horner(s) - K_xlim = -1. / sys.horner( + K = -1. / sys(s) + K_xlim = -1. / sys( complex(event.xdata + 0.05 * abs(xlim[1] - xlim[0]), event.ydata)) - K_ylim = -1. / sys.horner( + K_ylim = -1. / sys( complex(event.xdata, event.ydata + 0.05 * abs(ylim[1] - ylim[0]))) except TypeError: @@ -621,7 +621,7 @@ def _RLFeedbackClicksPoint(event, sys, fig, ax_rlocus, sisotool=False): ax_rlocus.plot(s.real, s.imag, 'k.', marker='s', markersize=8, zorder=20, label='gain_point') - return K.real[0][0] + return K.real def _removeLine(label, ax): diff --git a/control/statesp.py b/control/statesp.py index a4f3aec71..40748c9fa 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -437,60 +437,91 @@ def __rdiv__(self, other): raise NotImplementedError("StateSpace.__rdiv__ is not implemented yet.") - def __call__(self, s, squeeze=True): - """Evaluate system's transfer function at complex frequencies s/z. + def __call__(self, x, squeeze=True): + """Evaluate system's transfer function at complex frequencies. + + Evaluates at complex frequency x, where x is s or z dependong on + whether the system is continuous or discrete-time. If squeeze is True (default) and sys is single input, single output - (SISO), return a 1D array or scalar depending on s. + (SISO), return a 1D array or scalar depending on the size of x. + For a MIMO system, returns an (n_outputs, n_inputs, n_x) array. """ - # Use TB05AD from Slycot if available + # Use Slycot if available try: - from slycot import tb05ad - - # preallocate - s_arr = np.array(s, ndmin=1) # array-like version of s - out = np.empty((self.outputs, self.inputs, len(s_arr)), - dtype=complex) - n = self.states - m = self.inputs - p = self.outputs - # The first call both evaluates C(sI-A)^-1 B and also returns - # Hessenberg transformed matrices at, bt, ct. - result = tb05ad(n, m, p, s_arr[0], self.A, - self.B, self.C, job='NG') - # When job='NG', result = (at, bt, ct, g_i, hinvb, info) - at = result[0] - bt = result[1] - ct = result[2] - - # TB05AD frequency evaluation does not include direct feedthrough. - out[:, :, 0] = result[3] + self.D - - # Now, iterate through the remaining frequencies using the - # transformed state matrices, at, bt, ct. - - # Start at the second frequency, already have the first. - for kk, s_kk in enumerate(s_arr[1:len(s_arr)]): - result = tb05ad(n, m, p, s_kk, at, bt, ct, job='NH') - # When job='NH', result = (g_i, hinvb, info) - - # kk+1 because enumerate starts at kk = 0. - # but zero-th spot is already filled. - out[:, :, kk+1] = result[0] + self.D - - if not hasattr(s, '__len__'): - # received a scalar s, squeeze down the array - out = np.squeeze(out, axis=2) - except ImportError: # Slycot unavailable. Fall back to horner. - out = self.horner(s) + out = self.slycot_horner(x) + except ImportError: # Slycot unavailable. use built-in horner. + out = self.horner(x) + if not hasattr(x, '__len__'): + # received a scalar x, squeeze down the array along last dim + out = np.squeeze(out, axis=2) if squeeze and self.issiso(): return out[0][0] else: return out + def slycot_horner(self, s): + """Evaluate system's transfer function at complex frequencies s + using Horner's method from Slycot. + + Expects inputs and outputs to be formatted correctly. Use __call__ + for a more user-friendly interface. + + Parameters + s : array-like + + Returns + output : array of size (outputs, inputs, len(s)) + + """ + from slycot import tb05ad + + # preallocate + s_arr = np.array(s, ndmin=1) # array-like version of s + out = np.empty((self.outputs, self.inputs, len(s_arr)), + dtype=complex) + n = self.states + m = self.inputs + p = self.outputs + # The first call both evaluates C(sI-A)^-1 B and also returns + # Hessenberg transformed matrices at, bt, ct. + result = tb05ad(n, m, p, s_arr[0], self.A, + self.B, self.C, job='NG') + # When job='NG', result = (at, bt, ct, g_i, hinvb, info) + at = result[0] + bt = result[1] + ct = result[2] + + # TB05AD frequency evaluation does not include direct feedthrough. + out[:, :, 0] = result[3] + self.D + + # Now, iterate through the remaining frequencies using the + # transformed state matrices, at, bt, ct. + + # Start at the second frequency, already have the first. + for kk, s_kk in enumerate(s_arr[1:len(s_arr)]): + result = tb05ad(n, m, p, s_kk, at, bt, ct, job='NH') + # When job='NH', result = (g_i, hinvb, info) + + # kk+1 because enumerate starts at kk = 0. + # but zero-th spot is already filled. + out[:, :, kk+1] = result[0] + self.D + return out + def horner(self, s): - """Evaluate systems's transfer function at complex frequencies s. + """Evaluate system's transfer function at complex frequencies s + using Horner's method. + + Expects inputs and outputs to be formatted correctly. Use __call__ + for a more user-friendly interface. + + Parameters + s : array-like + + Returns + output : array of size (outputs, inputs, len(s)) + """ s_arr = np.array(s, ndmin=1) # force to be an array # Preallocate @@ -501,9 +532,6 @@ def horner(self, s): np.dot(self.C, solve(s_idx * eye(self.states) - self.A, self.B)) \ + self.D - if not hasattr(s, '__len__'): - # received a scalar s, squeeze down the array along last dim - out = np.squeeze(out, axis=2) return out def freqresp(self, omega): @@ -879,7 +907,7 @@ def dcgain(self): if self.isctime(): gain = np.asarray(self.D-self.C.dot(np.linalg.solve(self.A, self.B))) else: - gain = self.horner(1) + gain = np.squeeze(self.horner(1)) except LinAlgError: # eigenvalue at DC gain = np.tile(np.nan, (self.outputs, self.inputs)) diff --git a/control/tests/frd_test.py b/control/tests/frd_test.py index 3d57753bc..5335a473e 100644 --- a/control/tests/frd_test.py +++ b/control/tests/frd_test.py @@ -387,9 +387,9 @@ def test_eval(self): np.testing.assert_almost_equal(sys_tf(1j), frd_tf(1j)) # Should get an error if we evaluate at an unknown frequency - self.assertRaises(Exception, frd_tf.eval(2)) + self.assertRaises(ValueError, frd_tf.eval, 2) # Should get an error if we use __call__ at real-valued frequency - self.assertRaises(ValueError, frd_tf(2)) + self.assertRaises(ValueError, frd_tf, 2) def test_repr_str(self): # repr printing diff --git a/control/tests/margin_test.py b/control/tests/margin_test.py index 2f60c7bc6..70d6b9b5b 100755 --- a/control/tests/margin_test.py +++ b/control/tests/margin_test.py @@ -76,7 +76,7 @@ def test_stability_margins_omega(sys, refout, refoutall): def test_stability_margins_3input(sys, refout, refoutall): """Test stability_margins() function with mag, phase, omega input""" omega = np.logspace(-2, 2, 2000) - mag, phase, omega_ = sys.freqresp(omega) + mag, phase, omega_ = sys.frequency_response(omega) out = stability_margins((mag, phase*180/np.pi, omega_)) assert_allclose(out, refout, atol=1.5e-3) @@ -92,7 +92,7 @@ def test_margin_sys(sys, refout, refoutall): def test_margin_3input(sys, refout, refoutall): """Test margin() function with mag, phase, omega input""" omega = np.logspace(-2, 2, 2000) - mag, phase, omega_ = sys.freqresp(omega) + mag, phase, omega_ = sys.frequency_response(omega) out = margin((mag, phase*180/np.pi, omega_)) assert_allclose(out, np.array(refout)[[0, 1, 3, 4]], atol=1.5e-3) @@ -113,7 +113,7 @@ def test_phase_crossover_frequencies(): [[3], [4]]], [[[1, 2, 3, 4], [1, 1]], [[1, 1], [1, 1]]]) - omega, gain = phase_crossover_frequencies(tf) + omega, gain = phase_crossover_frequencies(tf[0,0]) assert_allclose(omega, [1.73205, 0.], atol=1.5e-3) assert_allclose(gain, [-0.5, 0.25], atol=1.5e-3) @@ -123,7 +123,7 @@ def test_mag_phase_omega(): sys = TransferFunction(15, [1, 6, 11, 6]) out = stability_margins(sys) omega = np.logspace(-2, 2, 1000) - mag, phase, omega = sys.freqresp(omega) + mag, phase, omega = sys.frequency_response(omega) out2 = stability_margins((mag, phase*180/np.pi, omega)) ind = [0, 1, 3, 4] # indices of gm, pm, wg, wp -- ignore sm marg1 = np.array(out)[ind] diff --git a/control/tests/statesp_array_test.py b/control/tests/statesp_array_test.py index 02e5d69d6..ffc89ab97 100644 --- a/control/tests/statesp_array_test.py +++ b/control/tests/statesp_array_test.py @@ -505,7 +505,7 @@ def test_horner(self): # Make sure result agrees with frequency response mag, phase, omega = self.sys322.frequency_response([1]) np.testing.assert_array_almost_equal( - self.sys322.horner(1.j), + np.squeeze(self.sys322.horner(1.j)), mag[:,:,0] * np.exp(1.j * phase[:,:,0])) def tearDown(self): diff --git a/control/xferfcn.py b/control/xferfcn.py index bb342c732..f43f282b4 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -204,22 +204,48 @@ def __init__(self, *args): self._truncatecoeff() - def __call__(self, s, squeeze=True): - """Evaluate the system's transfer function at the complex frequency s/z. - - For a SISO transfer function, returns the complex value of the - transfer function. For a MIMO transfer fuction, returns a - matrix of values. + def __call__(self, x, squeeze=True): + """Evaluate system's transfer function at complex frequencies. + + Evaluates at complex frequencies x, where x is s or z dependong on + whether the system is continuous or discrete-time. If squeeze is True (default) and sys is single input, single output - (SISO), return a 1D array or scalar depending on s. - - """ + (SISO), return a 1D array or scalar depending on the shape of x. + For a MIMO system, returns an (n_outputs, n_inputs, n_x) array. + + """ + out = self.horner(x) + if not hasattr(x, '__len__'): + # received a scalar x, squeeze down the array along last dim + out = np.squeeze(out, axis=2) if squeeze and self.issiso(): # return a scalar/1d array of outputs - return self.horner(s)[0][0] + return out[0][0] else: - return self.horner(s) + return out + + def horner(self, s): + """Evaluate system's transfer function at complex frequencies s + using Horner's method. + + Expects inputs and outputs to be formatted correctly. Use __call__ + for a more user-friendly interface. + + Parameters + s : array-like + + Returns + output : array of size (outputs, inputs, len(s)) + + """ + s_arr = np.array(s, ndmin=1) # force to be an array + out = empty((self.outputs, self.inputs, len(s_arr)), dtype=complex) + for i in range(self.outputs): + for j in range(self.inputs): + out[i][j] = (polyval(self.num[i][j], s) / + polyval(self.den[i][j], s)) + return out def _truncatecoeff(self): """Remove extraneous zero coefficients from num and den. @@ -611,21 +637,6 @@ def __getitem__(self, key): else: return TransferFunction(num, den, self.dt) - def horner(self, s): - "Evaluate system's transfer function at complex frequencies s." - # Preallocate the output. - if hasattr(s, '__len__'): - out = empty((self.outputs, self.inputs, len(s)), dtype=complex) - else: - out = empty((self.outputs, self.inputs), dtype=complex) - - for i in range(self.outputs): - for j in range(self.inputs): - out[i][j] = (polyval(self.num[i][j], s) / - polyval(self.den[i][j], s)) - - return out - def freqresp(self, omega): warn("TransferFunction.freqresp(omega) will be deprecated in a " "future release of python-control; use " From 2c4ac62e5b69d6a8511dc2173c32338d98b43e01 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Sun, 16 Aug 2020 00:59:08 -0700 Subject: [PATCH 03/32] minor fix following suggested change in frd str method --- control/frdata.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/control/frdata.py b/control/frdata.py index 6016e1460..cd0680f8d 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -156,7 +156,6 @@ def __str__(self): mimo = self.inputs > 1 or self.outputs > 1 outstr = ['Frequency response data'] - #mt, pt, wt = self.frequency_response(self.omega) for i in range(self.inputs): for j in range(self.outputs): if mimo: @@ -164,9 +163,10 @@ def __str__(self): outstr.append('Freq [rad/s] Response') outstr.append('------------ ---------------------') outstr.extend( - ['%12.3f %10.4g%+10.4gj' % (w, m, p) - for m, p, w in zip(real(self.fresp[j, i, :]), - imag(self.fresp[j, i, :]), self.omega)]) + ['%12.3f %10.4g%+10.4gj' % (w, re, im) + for w, re, im in zip(self.omega, + real(self.fresp[j, i, :]), + imag(self.fresp[j, i, :]))]) return '\n'.join(outstr) From 6213a543debba42c270d4cf7cad88776ade554b9 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Sun, 16 Aug 2020 01:11:03 -0700 Subject: [PATCH 04/32] fixed a few bugs that slipped through --- control/matlab/__init__.py | 4 ++-- control/statesp.py | 1 + control/tests/iosys_test.py | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/control/matlab/__init__.py b/control/matlab/__init__.py index 413dc6d86..a2ec5efc1 100644 --- a/control/matlab/__init__.py +++ b/control/matlab/__init__.py @@ -224,8 +224,8 @@ \* :func:`~control.nichols` Nichols plot \* :func:`margin` gain and phase margins \ lti/allmargin all crossover frequencies and margins -\* :func:`freqresp` frequency response over a frequency grid -\* :func:`evalfr` frequency response at single frequency +\* :func:`freqresp` frequency response +\* :func:`evalfr` frequency response at complex frequency s == ========================== ============================================ diff --git a/control/statesp.py b/control/statesp.py index 40748c9fa..1e502323e 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -527,6 +527,7 @@ def horner(self, s): # Preallocate out = empty((self.outputs, self.inputs, len(s_arr)), dtype=complex) + #TODO: can this be vectorized? for idx, s_idx in enumerate(s_arr): out[:,:,idx] = \ np.dot(self.C, diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index 0738e8b18..fdbcc6088 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -860,8 +860,8 @@ def test_lineariosys_statespace(self): np.testing.assert_array_equal( iosys_siso.pole(), self.siso_linsys.pole()) omega = np.logspace(.1, 10, 100) - mag_io, phase_io, omega_io = iosys_siso.freqresp(omega) - mag_ss, phase_ss, omega_ss = self.siso_linsys.freqresp(omega) + mag_io, phase_io, omega_io = iosys_siso.frequency_response(omega) + mag_ss, phase_ss, omega_ss = self.siso_linsys.frequency_response(omega) np.testing.assert_array_equal(mag_io, mag_ss) np.testing.assert_array_equal(phase_io, phase_ss) np.testing.assert_array_equal(omega_io, omega_ss) From 8486b7a46cb4d518614d0845b3c6f76f8bb20887 Mon Sep 17 00:00:00 2001 From: sawyerbfuller <58706249+sawyerbfuller@users.noreply.github.com> Date: Mon, 17 Aug 2020 10:49:14 -0700 Subject: [PATCH 05/32] Update control/frdata.py Co-authored-by: Ben Greiner --- control/frdata.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/control/frdata.py b/control/frdata.py index cd0680f8d..686ae8823 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -391,12 +391,24 @@ def __call__(self, s, squeeze=True): transfer function. For a MIMO transfer fuction, returns a matrix of values. - Raises an error if s is not purely imaginary. - + Parameters + ---------- + s : scalar or array_like + Complex frequencies squeeze: bool, optional (default=True) If True and sys is single input, single output (SISO), return a 1D array or scalar depending on omega's length. + Returns + ------- + ndarray or scalar + Frequency response + + Raises + ------ + ValueError + If `s` is not purely imaginary. + """ if any(abs(np.array(s, ndmin=1).real) > 0): raise ValueError("__call__: FRD systems can only accept" From c8bf92f15729a973d011566b08b8159ad95f3841 Mon Sep 17 00:00:00 2001 From: sawyerbfuller <58706249+sawyerbfuller@users.noreply.github.com> Date: Mon, 17 Aug 2020 10:49:51 -0700 Subject: [PATCH 06/32] Update control/frdata.py Co-authored-by: Ben Greiner --- control/frdata.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/control/frdata.py b/control/frdata.py index 686ae8823..1fd359d67 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -352,10 +352,11 @@ def eval(self, omega, squeeze=True): entry in the omega vector. An interpolating FRD can return intermediate values. + Parameters + ---------- squeeze: bool, optional (default=True) If True and sys is single input, single output (SISO), return a 1D array or scalar the same length as omega. - """ omega_array = np.array(omega, ndmin=1) # array-like version of omega if any(omega_array.imag > 0): From 371986c50d1319af713095d19a509faeb2115a5b Mon Sep 17 00:00:00 2001 From: sawyerbfuller <58706249+sawyerbfuller@users.noreply.github.com> Date: Mon, 17 Aug 2020 10:50:43 -0700 Subject: [PATCH 07/32] Update control/lti.py doc fix Co-authored-by: Ben Greiner --- control/lti.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control/lti.py b/control/lti.py index c6bf318a9..e89e81825 100644 --- a/control/lti.py +++ b/control/lti.py @@ -436,7 +436,7 @@ def evalfr(sys, x, squeeze=True): ---------- sys: StateSpace or TransferFunction Linear system - x: scalar or array-like + x: scalar or array_like Complex number squeeze: bool, optional (default=True) If True and sys is single input, single output (SISO), return a From d8fbaa0c42b26aba42376060926768b4d0b9c892 Mon Sep 17 00:00:00 2001 From: sawyerbfuller <58706249+sawyerbfuller@users.noreply.github.com> Date: Mon, 17 Aug 2020 11:04:50 -0700 Subject: [PATCH 08/32] Update control/statesp.py suggested doctoring fix for statesspace.slycot_horner Co-authored-by: Ben Greiner --- control/statesp.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/control/statesp.py b/control/statesp.py index 1e502323e..debe58d70 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -469,7 +469,9 @@ def slycot_horner(self, s): for a more user-friendly interface. Parameters - s : array-like + ---------- + s : array_like + Complex frequency Returns output : array of size (outputs, inputs, len(s)) From 17bc2a818da112881e49e9daa48a8b16c08ebace Mon Sep 17 00:00:00 2001 From: sawyerbfuller <58706249+sawyerbfuller@users.noreply.github.com> Date: Mon, 17 Aug 2020 11:08:13 -0700 Subject: [PATCH 09/32] Apply suggestions from code review doctoring fixes to adhere to numpydoc convention Co-authored-by: Ben Greiner --- control/statesp.py | 14 +++++++++----- control/xferfcn.py | 9 ++++++--- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/control/statesp.py b/control/statesp.py index debe58d70..08eab1beb 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -474,8 +474,9 @@ def slycot_horner(self, s): Complex frequency Returns - output : array of size (outputs, inputs, len(s)) - + ------- + output : (outputs, inputs, len(s)) complex ndarray + Frequency response """ from slycot import tb05ad @@ -519,11 +520,14 @@ def horner(self, s): for a more user-friendly interface. Parameters - s : array-like + ---------- + s : array_like + Complex frequencies Returns - output : array of size (outputs, inputs, len(s)) - + ------- + output : (outputs, inputs, len(s)) complex ndarray + Frequency response """ s_arr = np.array(s, ndmin=1) # force to be an array # Preallocate diff --git a/control/xferfcn.py b/control/xferfcn.py index f43f282b4..c8de0b265 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -233,11 +233,14 @@ def horner(self, s): for a more user-friendly interface. Parameters - s : array-like + ---------- + s : array_like + Complex frequencies Returns - output : array of size (outputs, inputs, len(s)) - + ------- + output : (outputs, inputs, len(s)) complex ndarray + Frequency response """ s_arr = np.array(s, ndmin=1) # force to be an array out = empty((self.outputs, self.inputs, len(s_arr)), dtype=complex) From 4ad33db513b13f916608442712d449001918d11d Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Mon, 17 Aug 2020 11:13:06 -0700 Subject: [PATCH 10/32] suggested changes in docstrings --- control/frdata.py | 4 ++-- control/margins.py | 6 ++++-- control/rlocus.py | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/control/frdata.py b/control/frdata.py index cd0680f8d..9ece4d068 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -365,8 +365,8 @@ def eval(self, omega, squeeze=True): elements = np.isin(self.omega, omega) # binary array if sum(elements) < len(omega_array): raise ValueError( - '''not all frequencies omega are in frequency list of FRD - system. Try an interpolating FRD for additional points.''') + "not all frequencies omega are in frequency list of FRD " + "system. Try an interpolating FRD for additional points.") else: out = self.fresp[:, :, elements] else: diff --git a/control/margins.py b/control/margins.py index cda5095aa..35178831d 100644 --- a/control/margins.py +++ b/control/margins.py @@ -56,6 +56,8 @@ from . import xferfcn from .lti import issiso from . import frdata +from .exception import ControlMIMONotImplemented + __all__ = ['stability_margins', 'phase_crossover_frequencies', 'margin'] @@ -155,7 +157,7 @@ def stability_margins(sysdata, returnall=False, epsw=0.0): # check for siso if not issiso(sys): - raise ValueError("Can only do margins for SISO system") + raise ControlMIMONotImplemented() # real and imaginary part polynomials in omega: rnum, inum = _polyimsplit(sys.num[0][0]) @@ -297,7 +299,7 @@ def phase_crossover_frequencies(sys): """ if not sys.issiso(): - raise ValueError("MIMO systems not yet implemented.") + raise ControlMIMONotImplemented() # Convert to a transfer function tf = xferfcn._convert_to_transfer_function(sys) num = tf.num[0][0] diff --git a/control/rlocus.py b/control/rlocus.py index 39c2fcfb6..40eb0b8c8 100644 --- a/control/rlocus.py +++ b/control/rlocus.py @@ -472,7 +472,7 @@ def _systopoly1d(sys): sys = _convert_to_transfer_function(sys) # Make sure we have a SISO system - if (sys.inputs > 1 or sys.outputs > 1): + if not sys.issiso(): raise ControlMIMONotImplemented() # Start by extracting the numerator and denominator from system object From 60433e028d1d7feff6ed97b456b8d93c229a9d7e Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Mon, 17 Aug 2020 12:54:04 -0700 Subject: [PATCH 11/32] better docstrings conforming to numpydoc --- control/frdata.py | 29 +++++++++++++++++------------ control/lti.py | 36 +++++++++++++++++------------------- control/statesp.py | 26 ++++++++++++++++++++------ control/xferfcn.py | 32 +++++++++++++++++++++++--------- 4 files changed, 77 insertions(+), 46 deletions(-) diff --git a/control/frdata.py b/control/frdata.py index 0a332d44e..9f36ab8f8 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -100,6 +100,7 @@ def __init__(self, *args, **kwargs): object, other than an FRD, call FRD(sys, omega) """ + # TODO: discrete-time FRD systems? smooth = kwargs.get('smooth', False) if len(args) == 2: @@ -386,29 +387,33 @@ def eval(self, omega, squeeze=True): return out def __call__(self, s, squeeze=True): - """Evaluate the system's transfer function at complex frequencies s. + """Evaluate system's transfer function at complex frequencies. + + Returns the complex frequency response `sys(x)` where `x` is `s` for + continuous-time systems and `x` is `z` for discrete-time systems. - For a SISO system, returns the complex value of the - transfer function. For a MIMO transfer fuction, returns a - matrix of values. + To evaluate at a frequency omega in radians per second, enter + x = omega*j. Parameters ---------- - s : scalar or array_like - Complex frequencies + x: complex scalar or array_like + Complex frequency(s) squeeze: bool, optional (default=True) - If True and sys is single input, single output (SISO), return a - 1D array or scalar depending on omega's length. - + If True and sys is single input single output (SISO), returns a + 1D array or scalar depending on the length of x. + Returns ------- - ndarray or scalar - Frequency response + fresp : (num_outputs, num_inputs, len(x)) or len(x) complex ndarray + The frequency response of the system. Array is len(x) if and only if + system is SISO and squeeze=True. Raises ------ ValueError - If `s` is not purely imaginary. + If `s` is not purely imaginary, because FrequencyDomainData systems + are only defined at imaginary frequency values. """ if any(abs(np.array(s, ndmin=1).real) > 0): diff --git a/control/lti.py b/control/lti.py index e89e81825..8820e2a36 100644 --- a/control/lti.py +++ b/control/lti.py @@ -428,23 +428,29 @@ def damp(sys, doprint=True): def evalfr(sys, x, squeeze=True): """ Evaluate the transfer function of an LTI system for complex frequency x. + + Returns the complex frequency response `sys(x)` where `x` is `s` for + continuous-time systems and `x` is `z` for discrete-time systems. - To evaluate at a frequency, enter x = omega*j, where omega is the - frequency in radians per second + To evaluate at a frequency omega in radians per second, enter x = omega*j, + for continuous-time systems, or x = exp(j*omega*dt) for discrete-time + systems. Parameters ---------- sys: StateSpace or TransferFunction Linear system - x: scalar or array_like - Complex number + x: complex scalar or array_like + Complex frequency(s) squeeze: bool, optional (default=True) - If True and sys is single input, single output (SISO), return a - 1D array or scalar depending on omega's length. - + If True and sys is single input single output (SISO), returns a + 1D array or scalar depending on the length of x. + Returns ------- - fresp: ndarray + fresp : (num_outputs, num_inputs, len(x)) or len(x) complex ndarray + The frequency response of the system. If system is SISO and squeezeThe size of the array depends on + whether system is SISO and squeeze keyword. See Also -------- @@ -453,8 +459,8 @@ def evalfr(sys, x, squeeze=True): Notes ----- - This function is a wrapper for StateSpace.evalfr and - TransferFunction.evalfr. + This function is a wrapper for StateSpace.__call__ and + TransferFunction.__call__. Examples -------- @@ -465,15 +471,7 @@ def evalfr(sys, x, squeeze=True): .. todo:: Add example with MIMO system """ - out = sys.horner(x) - if not hasattr(x, '__len__'): - # received a scalar x, squeeze down the array along last dim - out = np.squeeze(out, axis=2) - if squeeze and issiso(sys): - return out[0][0] - else: - return out - + return sys.__call__(x, squeeze=squeeze) def freqresp(sys, omega, squeeze=True): """ diff --git a/control/statesp.py b/control/statesp.py index 08eab1beb..c5685e713 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -440,12 +440,26 @@ def __rdiv__(self, other): def __call__(self, x, squeeze=True): """Evaluate system's transfer function at complex frequencies. - Evaluates at complex frequency x, where x is s or z dependong on - whether the system is continuous or discrete-time. - - If squeeze is True (default) and sys is single input, single output - (SISO), return a 1D array or scalar depending on the size of x. - For a MIMO system, returns an (n_outputs, n_inputs, n_x) array. + Returns the complex frequency response `sys(x)` where `x` is `s` for + continuous-time systems and `x` is `z` for discrete-time systems. + + To evaluate at a frequency omega in radians per second, enter + x = omega*j, for continuous-time systems, or x = exp(j*omega*dt) for + discrete-time systems. + + Parameters + ---------- + x: complex scalar or array_like + Complex frequency(s) + squeeze: bool, optional (default=True) + If True and sys is single input single output (SISO), returns a + 1D array or scalar depending on the length of x. + + Returns + ------- + fresp : (num_outputs, num_inputs, len(x)) or len(x) complex ndarray + The frequency response of the system. Array is len(x) if and only if + system is SISO and squeeze=True. """ # Use Slycot if available diff --git a/control/xferfcn.py b/control/xferfcn.py index c8de0b265..ad527325b 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -207,14 +207,28 @@ def __init__(self, *args): def __call__(self, x, squeeze=True): """Evaluate system's transfer function at complex frequencies. - Evaluates at complex frequencies x, where x is s or z dependong on - whether the system is continuous or discrete-time. - - If squeeze is True (default) and sys is single input, single output - (SISO), return a 1D array or scalar depending on the shape of x. - For a MIMO system, returns an (n_outputs, n_inputs, n_x) array. + Returns the complex frequency response `sys(x)` where `x` is `s` for + continuous-time systems and `x` is `z` for discrete-time systems. + + To evaluate at a frequency omega in radians per second, enter + x = omega*j, for continuous-time systems, or x = exp(j*omega*dt) for + discrete-time systems. + + Parameters + ---------- + x: complex scalar or array_like + Complex frequency(s) + squeeze: bool, optional (default=True) + If True and sys is single input single output (SISO), returns a + 1D array or scalar depending on the length of x. + + Returns + ------- + fresp : (num_outputs, num_inputs, len(x)) or len(x) complex ndarray + The frequency response of the system. Array is len(x) if and only if + system is SISO and squeeze=True. - """ + """ out = self.horner(x) if not hasattr(x, '__len__'): # received a scalar x, squeeze down the array along last dim @@ -226,7 +240,7 @@ def __call__(self, x, squeeze=True): return out def horner(self, s): - """Evaluate system's transfer function at complex frequencies s + """Evaluates system's transfer function at complex frequencies s using Horner's method. Expects inputs and outputs to be formatted correctly. Use __call__ @@ -234,7 +248,7 @@ def horner(self, s): Parameters ---------- - s : array_like + s : array_like or scalar Complex frequencies Returns From 262fde685bfd5a5ba07578142d46ac42cfcd6e3d Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Mon, 17 Aug 2020 13:32:00 -0700 Subject: [PATCH 12/32] converted statesp.horner to take care of trying to use slycot_horner and doing fallback --- control/statesp.py | 45 +++++++++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/control/statesp.py b/control/statesp.py index c5685e713..3ea597424 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -463,10 +463,7 @@ def __call__(self, x, squeeze=True): """ # Use Slycot if available - try: - out = self.slycot_horner(x) - except ImportError: # Slycot unavailable. use built-in horner. - out = self.horner(x) + out = self.horner(x) if not hasattr(x, '__len__'): # received a scalar x, squeeze down the array along last dim out = np.squeeze(out, axis=2) @@ -526,33 +523,45 @@ def slycot_horner(self, s): out[:, :, kk+1] = result[0] + self.D return out - def horner(self, s): - """Evaluate system's transfer function at complex frequencies s - using Horner's method. + def horner(self, x): + """Evaluate system's transfer function at complex frequencies x + using Horner's method. + Evaluates sys(x) where `x` is `s` for continuous-time systems and `z` + for discrete-time systems. + Expects inputs and outputs to be formatted correctly. Use __call__ for a more user-friendly interface. Parameters ---------- - s : array_like + x : array_like Complex frequencies Returns ------- output : (outputs, inputs, len(s)) complex ndarray Frequency response + + Notes + ----- + Attempts to use Slycot library, with a fall-back to python code. """ - s_arr = np.array(s, ndmin=1) # force to be an array - # Preallocate - out = empty((self.outputs, self.inputs, len(s_arr)), dtype=complex) - - #TODO: can this be vectorized? - for idx, s_idx in enumerate(s_arr): - out[:,:,idx] = \ - np.dot(self.C, - solve(s_idx * eye(self.states) - self.A, self.B)) \ - + self.D + try: + out = self.slycot_horner(x) + except (ImportError, Exception): + # Fall back because either Slycot unavailable or cannot handle + # certain cases. + x_arr = np.array(x, ndmin=1) # force to be an array + # Preallocate + out = empty((self.outputs, self.inputs, len(x_arr)), dtype=complex) + + #TODO: can this be vectorized? + for idx, x_idx in enumerate(x_arr): + out[:,:,idx] = \ + np.dot(self.C, + solve(x_idx * eye(self.states) - self.A, self.B)) \ + + self.D return out def freqresp(self, omega): From 4c3ed0b7ad8c6b1a2f41378c8800263b32eb7483 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Mon, 17 Aug 2020 14:40:30 -0700 Subject: [PATCH 13/32] renamed slycot_horner to slycot_laub --- control/statesp.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/control/statesp.py b/control/statesp.py index 3ea597424..0671ed1dc 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -472,9 +472,9 @@ def __call__(self, x, squeeze=True): else: return out - def slycot_horner(self, s): + def slycot_laub(self, s): """Evaluate system's transfer function at complex frequencies s - using Horner's method from Slycot. + using Laub's method from Slycot. Expects inputs and outputs to be formatted correctly. Use __call__ for a more user-friendly interface. @@ -525,7 +525,7 @@ def slycot_horner(self, s): def horner(self, x): """Evaluate system's transfer function at complex frequencies x - using Horner's method. + using Laub's or Horner's method. Evaluates sys(x) where `x` is `s` for continuous-time systems and `z` for discrete-time systems. @@ -545,10 +545,11 @@ def horner(self, x): Notes ----- - Attempts to use Slycot library, with a fall-back to python code. + Attempts to use Laub's method from Slycot library, with a + fall-back to python code. """ try: - out = self.slycot_horner(x) + out = self.slycot_laub(x) except (ImportError, Exception): # Fall back because either Slycot unavailable or cannot handle # certain cases. From b37c16b6d53e9ec0f7d63b2645cb3ef0a75b8ae7 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Mon, 17 Aug 2020 15:11:14 -0700 Subject: [PATCH 14/32] docs more consistent --- control/frdata.py | 31 ++++++++++++++++++------------- control/lti.py | 26 +++++++++++++------------- control/statesp.py | 40 +++++++++++++++++++--------------------- control/xferfcn.py | 24 ++++++++++++++---------- 4 files changed, 64 insertions(+), 57 deletions(-) diff --git a/control/frdata.py b/control/frdata.py index 9f36ab8f8..61b70a8b6 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -344,10 +344,7 @@ def __pow__(self, other): # update Sawyer B. Fuller 2020.08.14: __call__ added to provide a uniform # interface to systems in general and the lti.frequency_response method def eval(self, omega, squeeze=True): - """Evaluate a transfer function at a single angular frequency. - - self.evalfr(omega) returns the value of the frequency response - at frequency omega. + """Evaluate a transfer function at angular frequency omega. Note that a "normal" FRD only returns values for which there is an entry in the omega vector. An interpolating FRD can return @@ -355,10 +352,19 @@ def eval(self, omega, squeeze=True): Parameters ---------- + omega: float or array_like + Frequencies in radians per second squeeze: bool, optional (default=True) - If True and sys is single input, single output (SISO), return a - 1D array or scalar the same length as omega. - """ + If True and sys is single input single output (SISO), returns a + 1D array or scalar depending on the length of omega + + Returns + ------- + fresp : (num_outputs, num_inputs, len(x)) or len(x) complex ndarray + The frequency response of the system. Array is len(x) if and only if + system is SISO and squeeze=True. + + """ omega_array = np.array(omega, ndmin=1) # array-like version of omega if any(omega_array.imag > 0): raise ValueError("FRD.eval can only accept real-valued omega") @@ -389,16 +395,15 @@ def eval(self, omega, squeeze=True): def __call__(self, s, squeeze=True): """Evaluate system's transfer function at complex frequencies. - Returns the complex frequency response `sys(x)` where `x` is `s` for - continuous-time systems and `x` is `z` for discrete-time systems. + Returns the complex frequency response `sys(s)`. To evaluate at a frequency omega in radians per second, enter - x = omega*j. - + x = omega*1j or use FRD.eval(omega) + Parameters ---------- - x: complex scalar or array_like - Complex frequency(s) + s: complex scalar or array_like + Complex frequencies squeeze: bool, optional (default=True) If True and sys is single input single output (SISO), returns a 1D array or scalar depending on the length of x. diff --git a/control/lti.py b/control/lti.py index 8820e2a36..b0b9d7060 100644 --- a/control/lti.py +++ b/control/lti.py @@ -134,10 +134,10 @@ def frequency_response(self, omega, squeeze=True): Returns ------- - mag : (self.outputs, self.inputs, len(omega)) ndarray + mag : (self.outputs, self.inputs, len(omega)) or len(omega) ndarray The magnitude (absolute value, not dB or log10) of the system frequency response. - phase : (self.outputs, self.inputs, len(omega)) ndarray + phase : (self.outputs, self.inputs, len(omega)) or len(omega) ndarray The wrapped phase in radians of the system frequency response. omega : ndarray The (sorted) frequencies at which the response was evaluated. @@ -430,10 +430,10 @@ def evalfr(sys, x, squeeze=True): Evaluate the transfer function of an LTI system for complex frequency x. Returns the complex frequency response `sys(x)` where `x` is `s` for - continuous-time systems and `x` is `z` for discrete-time systems. + continuous-time systems and `z` for discrete-time systems. - To evaluate at a frequency omega in radians per second, enter x = omega*j, - for continuous-time systems, or x = exp(j*omega*dt) for discrete-time + To evaluate at a frequency omega in radians per second, enter x = omega*1j, + for continuous-time systems, or x = exp(1j*omega*dt) for discrete-time systems. Parameters @@ -448,9 +448,9 @@ def evalfr(sys, x, squeeze=True): Returns ------- - fresp : (num_outputs, num_inputs, len(x)) or len(x) complex ndarray - The frequency response of the system. If system is SISO and squeezeThe size of the array depends on - whether system is SISO and squeeze keyword. + fresp : (sys.outputs, sys.inputs, len(x)) or len(x) complex ndarray + The frequency response of the system. Array is len(x) if and only if + system is SISO and squeeze=True. See Also -------- @@ -481,22 +481,22 @@ def freqresp(sys, omega, squeeze=True): ---------- sys: StateSpace or TransferFunction Linear system - omega: array_like + omega: float or array_like A list of frequencies in radians/sec at which the system should be evaluated. The list can be either a python list or a numpy array and will be sorted before evaluation. squeeze: bool, optional (default=True) - If True and sys is single input, single output (SISO), return a + If True and sys is single input, single output (SISO), returns 1D array or scalar depending on omega's length. Returns ------- - mag : (self.outputs, self.inputs, len(omega)) ndarray + mag : (sys.outputs, sys.inputs, len(omega)) or len(omega) ndarray The magnitude (absolute value, not dB or log10) of the system frequency response. - phase : (self.outputs, self.inputs, len(omega)) ndarray + phase : (sys.outputs, sys.inputs, len(omega)) or len(omega) ndarray The wrapped phase in radians of the system frequency response. - omega : ndarray or list or tuple + omega : ndarray The list of sorted frequencies at which the response was evaluated. diff --git a/control/statesp.py b/control/statesp.py index 0671ed1dc..2b9ef016a 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -438,26 +438,26 @@ def __rdiv__(self, other): raise NotImplementedError("StateSpace.__rdiv__ is not implemented yet.") def __call__(self, x, squeeze=True): - """Evaluate system's transfer function at complex frequencies. + """Evaluate system's transfer function at complex frequency. Returns the complex frequency response `sys(x)` where `x` is `s` for - continuous-time systems and `x` is `z` for discrete-time systems. + continuous-time systems and `z` for discrete-time systems. To evaluate at a frequency omega in radians per second, enter - x = omega*j, for continuous-time systems, or x = exp(j*omega*dt) for + x = omega*1j, for continuous-time systems, or x = exp(1j*omega*dt) for discrete-time systems. Parameters ---------- - x: complex scalar or array_like - Complex frequency(s) + x: complex or complex array_like + Complex frequencies squeeze: bool, optional (default=True) If True and sys is single input single output (SISO), returns a 1D array or scalar depending on the length of x. Returns ------- - fresp : (num_outputs, num_inputs, len(x)) or len(x) complex ndarray + fresp : (self.outputs, self.inputs, len(x)) or len(x) complex ndarray The frequency response of the system. Array is len(x) if and only if system is SISO and squeeze=True. @@ -472,8 +472,8 @@ def __call__(self, x, squeeze=True): else: return out - def slycot_laub(self, s): - """Evaluate system's transfer function at complex frequencies s + def slycot_laub(self, x): + """Evaluate system's transfer function at complex frequency using Laub's method from Slycot. Expects inputs and outputs to be formatted correctly. Use __call__ @@ -481,27 +481,25 @@ def slycot_laub(self, s): Parameters ---------- - s : array_like + x : complex array_like or complex Complex frequency Returns ------- - output : (outputs, inputs, len(s)) complex ndarray + output : (number_outputs, number_inputs, len(x)) complex ndarray Frequency response """ from slycot import tb05ad # preallocate - s_arr = np.array(s, ndmin=1) # array-like version of s - out = np.empty((self.outputs, self.inputs, len(s_arr)), - dtype=complex) + x_arr = np.array(x, ndmin=1) # array-like version of s n = self.states m = self.inputs p = self.outputs + out = np.empty((p, m, len(x_arr)), dtype=complex) # The first call both evaluates C(sI-A)^-1 B and also returns # Hessenberg transformed matrices at, bt, ct. - result = tb05ad(n, m, p, s_arr[0], self.A, - self.B, self.C, job='NG') + result = tb05ad(n, m, p, x_arr[0], self.A, self.B, self.C, job='NG') # When job='NG', result = (at, bt, ct, g_i, hinvb, info) at = result[0] bt = result[1] @@ -514,8 +512,8 @@ def slycot_laub(self, s): # transformed state matrices, at, bt, ct. # Start at the second frequency, already have the first. - for kk, s_kk in enumerate(s_arr[1:len(s_arr)]): - result = tb05ad(n, m, p, s_kk, at, bt, ct, job='NH') + for kk, x_kk in enumerate(x_arr[1:len(x_arr)]): + result = tb05ad(n, m, p, x_kk, at, bt, ct, job='NH') # When job='NH', result = (g_i, hinvb, info) # kk+1 because enumerate starts at kk = 0. @@ -524,10 +522,10 @@ def slycot_laub(self, s): return out def horner(self, x): - """Evaluate system's transfer function at complex frequencies x + """Evaluate system's transfer function at complex frequency using Laub's or Horner's method. - Evaluates sys(x) where `x` is `s` for continuous-time systems and `z` + Evaluates `sys(x)` where `x` is `s` for continuous-time systems and `z` for discrete-time systems. Expects inputs and outputs to be formatted correctly. Use __call__ @@ -535,12 +533,12 @@ def horner(self, x): Parameters ---------- - x : array_like + x : complex array_like or complex Complex frequencies Returns ------- - output : (outputs, inputs, len(s)) complex ndarray + output : (number_outputs, number_inputs, len(x)) complex ndarray Frequency response Notes diff --git a/control/xferfcn.py b/control/xferfcn.py index ad527325b..3c898a320 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -208,23 +208,23 @@ def __call__(self, x, squeeze=True): """Evaluate system's transfer function at complex frequencies. Returns the complex frequency response `sys(x)` where `x` is `s` for - continuous-time systems and `x` is `z` for discrete-time systems. + continuous-time systems and `z` for discrete-time systems. To evaluate at a frequency omega in radians per second, enter - x = omega*j, for continuous-time systems, or x = exp(j*omega*dt) for + x = omega*1j, for continuous-time systems, or x = exp(1j*omega*dt) for discrete-time systems. Parameters ---------- - x: complex scalar or array_like - Complex frequency(s) + x: complex array_like or complex + Complex frequencies squeeze: bool, optional (default=True) If True and sys is single input single output (SISO), returns a 1D array or scalar depending on the length of x. Returns ------- - fresp : (num_outputs, num_inputs, len(x)) or len(x) complex ndarray + fresp : (self.outputs, self.inputs, len(x)) or len(x) complex ndarray The frequency response of the system. Array is len(x) if and only if system is SISO and squeeze=True. @@ -239,22 +239,26 @@ def __call__(self, x, squeeze=True): else: return out - def horner(self, s): - """Evaluates system's transfer function at complex frequencies s - using Horner's method. + def horner(self, x): + """Evaluate system's transfer function at complex frequency + using Horner's method. + Evaluates `sys(x)` where `x` is `s` for continuous-time systems and `z` + for discrete-time systems. + Expects inputs and outputs to be formatted correctly. Use __call__ for a more user-friendly interface. Parameters ---------- - s : array_like or scalar + x : complex array_like or complex Complex frequencies Returns ------- - output : (outputs, inputs, len(s)) complex ndarray + output : (self.outputs, self.inputs, len(x)) complex ndarray Frequency response + """ s_arr = np.array(s, ndmin=1) # force to be an array out = empty((self.outputs, self.inputs, len(s_arr)), dtype=complex) From 4e4b97f05d0bb17b7682f3c238deada9037cb3a1 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Mon, 17 Aug 2020 15:51:06 -0700 Subject: [PATCH 15/32] forgot to change a variable name everywhere in a function --- control/xferfcn.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/control/xferfcn.py b/control/xferfcn.py index 3c898a320..abd6338dd 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -260,12 +260,12 @@ def horner(self, x): Frequency response """ - s_arr = np.array(s, ndmin=1) # force to be an array + s_arr = np.array(x, ndmin=1) # force to be an array out = empty((self.outputs, self.inputs, len(s_arr)), dtype=complex) for i in range(self.outputs): for j in range(self.inputs): - out[i][j] = (polyval(self.num[i][j], s) / - polyval(self.den[i][j], s)) + out[i][j] = (polyval(self.num[i][j], x) / + polyval(self.den[i][j], x)) return out def _truncatecoeff(self): From 5626a3a2744744c2342346a73991b1dab933ea5b Mon Sep 17 00:00:00 2001 From: Ben Greiner Date: Tue, 18 Aug 2020 12:47:06 +0200 Subject: [PATCH 16/32] revert doc/control.rst common_timebase is no longer existent --- doc/control.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/doc/control.rst b/doc/control.rst index 0705e1b9f..57d64b1eb 100644 --- a/doc/control.rst +++ b/doc/control.rst @@ -172,7 +172,6 @@ Utility functions and conversions tfdata timebase timebaseEqual - common_timebase unwrap use_fbs_defaults use_matlab_defaults From 5caffa414afbab1dafee8537bf54a26787bb5eaf Mon Sep 17 00:00:00 2001 From: sawyerbfuller <58706249+sawyerbfuller@users.noreply.github.com> Date: Wed, 19 Aug 2020 09:38:49 -0700 Subject: [PATCH 17/32] Update control/xferfcn.py numpydoc convention docstrings Co-authored-by: Ben Greiner --- control/xferfcn.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/control/xferfcn.py b/control/xferfcn.py index abd6338dd..1f7fd1243 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -211,8 +211,8 @@ def __call__(self, x, squeeze=True): continuous-time systems and `z` for discrete-time systems. To evaluate at a frequency omega in radians per second, enter - x = omega*1j, for continuous-time systems, or x = exp(1j*omega*dt) for - discrete-time systems. + ``x = omega*1j``, for continuous-time systems, or ``x = exp(1j*omega*dt)`` + for discrete-time systems. Parameters ---------- From c888b6ee86554e94a8393bb1afcf676a24dc91a6 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Wed, 19 Aug 2020 10:27:57 -0700 Subject: [PATCH 18/32] numpydoc convention updates --- control/frdata.py | 40 ++++++++++++++++++++++++---------------- control/lti.py | 7 ++++--- control/statesp.py | 41 ++++++++++++++++++++++++----------------- control/xferfcn.py | 33 ++++++++++++++++++++------------- 4 files changed, 72 insertions(+), 49 deletions(-) diff --git a/control/frdata.py b/control/frdata.py index 61b70a8b6..ffb83c73e 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -355,14 +355,14 @@ def eval(self, omega, squeeze=True): omega: float or array_like Frequencies in radians per second squeeze: bool, optional (default=True) - If True and sys is single input single output (SISO), returns a - 1D array or scalar depending on the length of omega + If True and `sys` is single input single output (SISO), returns a + 1D array or scalar depending on the length of `omega` Returns ------- - fresp : (num_outputs, num_inputs, len(x)) or len(x) complex ndarray - The frequency response of the system. Array is len(x) if and only if - system is SISO and squeeze=True. + fresp : (self.outputs, self.inputs, len(x)) or len(x) complex ndarray + The frequency response of the system. Array is ``len(x)`` if and only + if system is SISO and ``squeeze=True``. """ omega_array = np.array(omega, ndmin=1) # array-like version of omega @@ -398,27 +398,28 @@ def __call__(self, s, squeeze=True): Returns the complex frequency response `sys(s)`. To evaluate at a frequency omega in radians per second, enter - x = omega*1j or use FRD.eval(omega) + ``x = omega * 1j`` or use ``sys.eval(omega)`` Parameters ---------- s: complex scalar or array_like Complex frequencies squeeze: bool, optional (default=True) - If True and sys is single input single output (SISO), returns a - 1D array or scalar depending on the length of x. + If True and `sys` is single input single output (SISO), returns a + 1D array or scalar depending on the length of x`. Returns ------- - fresp : (num_outputs, num_inputs, len(x)) or len(x) complex ndarray - The frequency response of the system. Array is len(x) if and only if - system is SISO and squeeze=True. + fresp : (self.outputs, self.inputs, len(s)) or len(s) complex ndarray + The frequency response of the system. Array is ``len(s)`` if and + only if system is SISO and ``squeeze=True``. Raises ------ ValueError - If `s` is not purely imaginary, because FrequencyDomainData systems - are only defined at imaginary frequency values. + If `s` is not purely imaginary, because + :class:`FrequencyDomainData` systems are only defined at imaginary + frequency values. """ if any(abs(np.array(s, ndmin=1).real) > 0): @@ -431,11 +432,18 @@ def __call__(self, s, squeeze=True): return self.eval(complex(s).imag, squeeze=squeeze) def freqresp(self, omega): - warn("FrequencyResponseData.freqresp(omega) will be deprecated in a " + """(deprecated) Evaluate transfer function at complex frequencies. + + .. deprecated::0.9.0 + Method has been given the more pythonic name + :meth:`FrequencyResponseData.frequency_response`. Or use + :func:`freqresp` in the MATLAB compatibility module. + """ + warn("FrequencyResponseData.freqresp(omega) will be removed in a " "future release of python-control; use " - "FrequencyResponseData.frequency_response(sys, omega), or " + "FrequencyResponseData.frequency_response(omega), or " "freqresp(sys, omega) in the MATLAB compatibility module " - "instead", PendingDeprecationWarning) + "instead", DeprecationWarning) return self.frequency_response(omega) def feedback(self, other=1, sign=-1): diff --git a/control/lti.py b/control/lti.py index b0b9d7060..4af9dfc86 100644 --- a/control/lti.py +++ b/control/lti.py @@ -432,9 +432,10 @@ def evalfr(sys, x, squeeze=True): Returns the complex frequency response `sys(x)` where `x` is `s` for continuous-time systems and `z` for discrete-time systems. - To evaluate at a frequency omega in radians per second, enter x = omega*1j, - for continuous-time systems, or x = exp(1j*omega*dt) for discrete-time - systems. + To evaluate at a frequency omega in radians per second, enter + ``x = omega * 1j`` for continuous-time systems, or + ``x = exp(1j * omega * dt)`` for discrete-time systems, or use + ``freqresp(sys, omega)``. Parameters ---------- diff --git a/control/statesp.py b/control/statesp.py index 2b9ef016a..d131e1aca 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -444,22 +444,23 @@ def __call__(self, x, squeeze=True): continuous-time systems and `z` for discrete-time systems. To evaluate at a frequency omega in radians per second, enter - x = omega*1j, for continuous-time systems, or x = exp(1j*omega*dt) for - discrete-time systems. + ``x = omega * 1j``, for continuous-time systems, or + ``x = exp(1j * omega * dt)`` for discrete-time systems. Or use + :meth:`StateSpace.frequency_response`. Parameters ---------- x: complex or complex array_like Complex frequencies squeeze: bool, optional (default=True) - If True and sys is single input single output (SISO), returns a - 1D array or scalar depending on the length of x. + If True and `sys` is single input single output (SISO), returns a + 1D array or scalar depending on the length of `x`. Returns ------- fresp : (self.outputs, self.inputs, len(x)) or len(x) complex ndarray - The frequency response of the system. Array is len(x) if and only if - system is SISO and squeeze=True. + The frequency response of the system. Array is ``len(x)`` if and + only if system is SISO and ``squeeze=True``. """ # Use Slycot if available @@ -476,7 +477,7 @@ def slycot_laub(self, x): """Evaluate system's transfer function at complex frequency using Laub's method from Slycot. - Expects inputs and outputs to be formatted correctly. Use __call__ + Expects inputs and outputs to be formatted correctly. Use ``sys(x)`` for a more user-friendly interface. Parameters @@ -492,7 +493,7 @@ def slycot_laub(self, x): from slycot import tb05ad # preallocate - x_arr = np.array(x, ndmin=1) # array-like version of s + x_arr = np.atleast_1d(x) # array-like version of x n = self.states m = self.inputs p = self.outputs @@ -528,7 +529,7 @@ def horner(self, x): Evaluates `sys(x)` where `x` is `s` for continuous-time systems and `z` for discrete-time systems. - Expects inputs and outputs to be formatted correctly. Use __call__ + Expects inputs and outputs to be formatted correctly. Use ``sys(x)`` for a more user-friendly interface. Parameters @@ -538,20 +539,20 @@ def horner(self, x): Returns ------- - output : (number_outputs, number_inputs, len(x)) complex ndarray + output : (self.outputs, self.inputs, len(x)) complex ndarray Frequency response Notes ----- - Attempts to use Laub's method from Slycot library, with a - fall-back to python code. + Attempts to use Laub's method from Slycot library, with a + fall-back to python code. """ try: out = self.slycot_laub(x) except (ImportError, Exception): # Fall back because either Slycot unavailable or cannot handle # certain cases. - x_arr = np.array(x, ndmin=1) # force to be an array + x_arr = np.atleast_1d(x) # force to be an array # Preallocate out = empty((self.outputs, self.inputs, len(x_arr)), dtype=complex) @@ -564,11 +565,17 @@ def horner(self, x): return out def freqresp(self, omega): - warn("StateSpace.freqresp(omega) will be deprecated in a " + """(deprecated) Evaluate transfer function at complex frequencies. + + .. deprecated::0.9.0 + Method has been given the more pythonic name + :meth:`StateSpace.frequency_response`. Or use + :func:`freqresp` in the MATLAB compatibility module. + """ + warn("StateSpace.freqresp(omega) will be removed in a " "future release of python-control; use " - "StateSpace.frequency_response(sys, omega), or " - "freqresp(sys, omega) in the MATLAB compatibility module " - "instead", PendingDeprecationWarning) + "sys.frequency_response(omega), or freqresp(sys, omega) in the " + "MATLAB compatibility module instead", DeprecationWarning) return self.frequency_response(omega) # Compute poles and zeros diff --git a/control/xferfcn.py b/control/xferfcn.py index abd6338dd..9613fa18c 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -211,22 +211,23 @@ def __call__(self, x, squeeze=True): continuous-time systems and `z` for discrete-time systems. To evaluate at a frequency omega in radians per second, enter - x = omega*1j, for continuous-time systems, or x = exp(1j*omega*dt) for - discrete-time systems. + ``x = omega * 1j``, for continuous-time systems, or + ``x = exp(1j * omega * dt)`` for discrete-time systems. Or use + :meth:`TransferFunction.frequency_response`. Parameters ---------- x: complex array_like or complex Complex frequencies squeeze: bool, optional (default=True) - If True and sys is single input single output (SISO), returns a - 1D array or scalar depending on the length of x. + If True and `sys` is single input single output (SISO), returns a + 1D array or scalar depending on the length of `x`. Returns ------- fresp : (self.outputs, self.inputs, len(x)) or len(x) complex ndarray - The frequency response of the system. Array is len(x) if and only if - system is SISO and squeeze=True. + The frequency response of the system. Array is `len(x)` if and + only if system is SISO and ``squeeze=True``. """ out = self.horner(x) @@ -246,7 +247,7 @@ def horner(self, x): Evaluates `sys(x)` where `x` is `s` for continuous-time systems and `z` for discrete-time systems. - Expects inputs and outputs to be formatted correctly. Use __call__ + Expects inputs and outputs to be formatted correctly. Use ``sys(x)`` for a more user-friendly interface. Parameters @@ -260,8 +261,8 @@ def horner(self, x): Frequency response """ - s_arr = np.array(x, ndmin=1) # force to be an array - out = empty((self.outputs, self.inputs, len(s_arr)), dtype=complex) + x_arr = np.atleast_1d(x) # force to be an array + out = empty((self.outputs, self.inputs, len(x_arr)), dtype=complex) for i in range(self.outputs): for j in range(self.inputs): out[i][j] = (polyval(self.num[i][j], x) / @@ -659,11 +660,17 @@ def __getitem__(self, key): return TransferFunction(num, den, self.dt) def freqresp(self, omega): - warn("TransferFunction.freqresp(omega) will be deprecated in a " + """(deprecated) Evaluate transfer function at complex frequencies. + + .. deprecated::0.9.0 + Method has been given the more pythonic name + :meth:`TransferFunction.frequency_response`. Or use + :func:`freqresp` in the MATLAB compatibility module. + """ + warn("TransferFunction.freqresp(omega) will be removed in a " "future release of python-control; use " - "TransferFunction.frequency_response(sys, omega), or " - "freqresp(sys, omega) in the MATLAB compatibility module " - "instead", PendingDeprecationWarning) + "sys.frequency_response(omega), or freqresp(sys, omega) in the " + "MATLAB compatibility module instead", DeprecationWarning) return self.frequency_response(omega) def pole(self): From 3cf80afbbb3bd9ccf4ce7a761709f7c2535756a0 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Wed, 19 Aug 2020 10:35:17 -0700 Subject: [PATCH 19/32] small numpydoc change in trasnferfunction to resolve merge --- control/xferfcn.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/control/xferfcn.py b/control/xferfcn.py index cfe963e11..9613fa18c 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -211,8 +211,9 @@ def __call__(self, x, squeeze=True): continuous-time systems and `z` for discrete-time systems. To evaluate at a frequency omega in radians per second, enter - ``x = omega*1j``, for continuous-time systems, or ``x = exp(1j*omega*dt)`` - for discrete-time systems. + ``x = omega * 1j``, for continuous-time systems, or + ``x = exp(1j * omega * dt)`` for discrete-time systems. Or use + :meth:`TransferFunction.frequency_response`. Parameters ---------- From 330e517002114e00cd07fc00f978a1be3c6b2fc4 Mon Sep 17 00:00:00 2001 From: Sawyer Fuller Date: Wed, 19 Aug 2020 10:44:32 -0700 Subject: [PATCH 20/32] unit tests now test for deprecation of sys.freqresp --- control/tests/statesp_test.py | 2 +- control/tests/xferfcn_test.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index 77a6d3ec2..e96a91014 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -223,7 +223,7 @@ def test_freqresp_deprecated(self): # Make sure that we get a pending deprecation warning sys.freqresp(1.) - assert issubclass(w[-1].category, PendingDeprecationWarning) + assert issubclass(w[-1].category, DeprecationWarning) @unittest.skipIf(not slycot_check(), "slycot not installed") def test_freq_resp(self): diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py index 4ee314df1..140979a79 100644 --- a/control/tests/xferfcn_test.py +++ b/control/tests/xferfcn_test.py @@ -432,6 +432,19 @@ def test_call_mimo(self): # Test call version as well np.testing.assert_array_almost_equal(sys(2.j), resp) + def test_freqresp_deprecated(self): + sys = TransferFunction([1., 3., 5], [1., 6., 2., -1.]) + # Deprecated version of the call (should generate warning) + import warnings + with warnings.catch_warnings(record=True) as w: + # Set up warnings filter to only show warnings in control module + warnings.filterwarnings("ignore") + warnings.filterwarnings("always", module="control") + + # Make sure that we get a pending deprecation warning + sys.freqresp(1.) + assert issubclass(w[-1].category, DeprecationWarning) + def test_frequency_response_siso(self): """Evaluate the magnitude and phase of a SISO system at multiple frequencies.""" From 195bec3effa50052518ebf4cd042c846eecdd964 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Tue, 5 Jan 2021 22:20:28 -0800 Subject: [PATCH 21/32] rebase sawyerbfuller-call-method against master --- control/bench/time_freqresp.py | 4 +- control/frdata.py | 192 ++++++++++++++-------------- control/freqplot.py | 24 ++-- control/lti.py | 141 ++++++++++++++------- control/margins.py | 14 +- control/matlab/__init__.py | 4 +- control/nichols.py | 2 +- control/rlocus.py | 12 +- control/statesp.py | 225 ++++++++++++++++++--------------- control/tests/frd_test.py | 158 +++++++++++------------ control/tests/freqresp_test.py | 12 +- control/tests/iosys_test.py | 4 +- control/tests/margin_test.py | 12 +- control/tests/statesp_test.py | 45 +++++-- control/tests/xferfcn_test.py | 84 +++++++----- control/xferfcn.py | 175 +++++++++++-------------- examples/robust_mimo.py | 2 +- examples/robust_siso.py | 8 +- 18 files changed, 594 insertions(+), 524 deletions(-) diff --git a/control/bench/time_freqresp.py b/control/bench/time_freqresp.py index 1945cbc24..3ae837082 100644 --- a/control/bench/time_freqresp.py +++ b/control/bench/time_freqresp.py @@ -8,7 +8,7 @@ sys_tf = tf(sys) w = logspace(-1,1,50) ntimes = 1000 -time_ss = timeit("sys.freqresp(w)", setup="from __main__ import sys, w", number=ntimes) -time_tf = timeit("sys_tf.freqresp(w)", setup="from __main__ import sys_tf, w", number=ntimes) +time_ss = timeit("sys.freqquency_response(w)", setup="from __main__ import sys, w", number=ntimes) +time_tf = timeit("sys_tf.frequency_response(w)", setup="from __main__ import sys_tf, w", number=ntimes) print("State-space model on %d states: %f" % (nstates, time_ss)) print("Transfer-function model on %d states: %f" % (nstates, time_tf)) diff --git a/control/frdata.py b/control/frdata.py index 8ca9dfd9d..95c84e0ba 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -48,7 +48,7 @@ from warnings import warn import numpy as np from numpy import angle, array, empty, ones, \ - real, imag, absolute, eye, linalg, where, dot + real, imag, absolute, eye, linalg, where, dot, sort from scipy.interpolate import splprep, splev from .lti import LTI @@ -100,6 +100,7 @@ def __init__(self, *args, **kwargs): object, other than an FRD, call FRD(sys, omega) """ + # TODO: discrete-time FRD systems? smooth = kwargs.get('smooth', False) if len(args) == 2: @@ -107,17 +108,10 @@ def __init__(self, *args, **kwargs): # not an FRD, but still a system, second argument should be # the frequency range otherlti = args[0] - self.omega = array(args[1], dtype=float) - self.omega.sort() + self.omega = sort(np.asarray(args[1], dtype=float)) numfreq = len(self.omega) - # calculate frequency response at my points - self.fresp = empty( - (otherlti.outputs, otherlti.inputs, numfreq), - dtype=complex) - for k, w in enumerate(self.omega): - self.fresp[:, :, k] = otherlti._evalfr(w) - + self.fresp = otherlti(1j * self.omega, squeeze=False) else: # The user provided a response and a freq vector self.fresp = array(args[0], dtype=complex) @@ -141,7 +135,7 @@ def __init__(self, *args, **kwargs): self.omega = args[0].omega self.fresp = args[0].fresp else: - raise ValueError("Needs 1 or 2 arguments; receivd %i." % len(args)) + raise ValueError("Needs 1 or 2 arguments; received %i." % len(args)) # create interpolation functions if smooth: @@ -163,7 +157,6 @@ def __str__(self): mimo = self.inputs > 1 or self.outputs > 1 outstr = ['Frequency response data'] - mt, pt, wt = self.freqresp(self.omega) for i in range(self.inputs): for j in range(self.outputs): if mimo: @@ -171,9 +164,10 @@ def __str__(self): outstr.append('Freq [rad/s] Response') outstr.append('------------ ---------------------') outstr.extend( - ['%12.3f %10.4g%+10.4gj' % (w, m, p) - for m, p, w in zip(real(self.fresp[j, i, :]), - imag(self.fresp[j, i, :]), wt)]) + ['%12.3f %10.4g%+10.4gj' % (w, re, im) + for w, re, im in zip(self.omega, + real(self.fresp[j, i, :]), + imag(self.fresp[j, i, :]))]) return '\n'.join(outstr) @@ -342,110 +336,115 @@ def __pow__(self, other): return (FRD(ones(self.fresp.shape), self.omega) / self) * \ (self**(other+1)) - def evalfr(self, omega): - """Evaluate a transfer function at a single angular frequency. - - self._evalfr(omega) returns the value of the frequency response - at frequency omega. - - Note that a "normal" FRD only returns values for which there is an - entry in the omega vector. An interpolating FRD can return - intermediate values. - - """ - warn("FRD.evalfr(omega) will be deprecated in a future release " - "of python-control; use sys.eval(omega) instead", - PendingDeprecationWarning) # pragma: no coverage - return self._evalfr(omega) - # Define the `eval` function to evaluate an FRD at a given (real) # frequency. Note that we choose to use `eval` instead of `evalfr` to # avoid confusion with :func:`evalfr`, which takes a complex number as its # argument. Similarly, we don't use `__call__` to avoid confusion between # G(s) for a transfer function and G(omega) for an FRD object. - def eval(self, omega): - """Evaluate a transfer function at a single angular frequency. - - self.evalfr(omega) returns the value of the frequency response - at frequency omega. + # update Sawyer B. Fuller 2020.08.14: __call__ added to provide a uniform + # interface to systems in general and the lti.frequency_response method + def eval(self, omega, squeeze=True): + """Evaluate a transfer function at angular frequency omega. Note that a "normal" FRD only returns values for which there is an entry in the omega vector. An interpolating FRD can return intermediate values. + Parameters + ---------- + omega: float or array_like + Frequencies in radians per second + squeeze: bool, optional (default=True) + If True and `sys` is single input single output (SISO), returns a + 1D array or scalar depending on the length of `omega` + + Returns + ------- + fresp : (self.outputs, self.inputs, len(x)) or len(x) complex ndarray + The frequency response of the system. Array is ``len(x)`` if and + only if system is SISO and ``squeeze=True``. + """ - return self._evalfr(omega) - - # Internal function to evaluate the frequency responses - def _evalfr(self, omega): - """Evaluate a transfer function at a single angular frequency.""" - # Preallocate the output. - if getattr(omega, '__iter__', False): - out = empty((self.outputs, self.inputs, len(omega)), dtype=complex) - else: - out = empty((self.outputs, self.inputs), dtype=complex) + omega_array = np.array(omega, ndmin=1) # array-like version of omega + if any(omega_array.imag > 0): + raise ValueError("FRD.eval can only accept real-valued omega") if self.ifunc is None: - try: - out = self.fresp[:, :, where(self.omega == omega)[0][0]] - except Exception: + elements = np.isin(self.omega, omega) # binary array + if sum(elements) < len(omega_array): raise ValueError( - "Frequency %f not in frequency list, try an interpolating" - " FRD if you want additional points" % omega) - else: - if getattr(omega, '__iter__', False): - for i in range(self.outputs): - for j in range(self.inputs): - for k, w in enumerate(omega): - frraw = splev(w, self.ifunc[i, j], der=0) - out[i, j, k] = frraw[0] + 1.0j * frraw[1] + "not all frequencies omega are in frequency list of FRD " + "system. Try an interpolating FRD for additional points.") else: - for i in range(self.outputs): - for j in range(self.inputs): - frraw = splev(omega, self.ifunc[i, j], der=0) - out[i, j] = frraw[0] + 1.0j * frraw[1] - + out = self.fresp[:, :, elements] + else: + out = empty((self.outputs, self.inputs, len(omega_array)), + dtype=complex) + for i in range(self.outputs): + for j in range(self.inputs): + for k, w in enumerate(omega_array): + frraw = splev(w, self.ifunc[i, j], der=0) + out[i, j, k] = frraw[0] + 1.0j * frraw[1] + if not hasattr(omega, '__len__'): + # omega is a scalar, squeeze down array along last dim + out = np.squeeze(out, axis=2) + if squeeze and self.issiso(): + out = out[0][0] return out - # Method for generating the frequency response of the system - def freqresp(self, omega): - """Evaluate the frequency response at a list of angular frequencies. + def __call__(self, s, squeeze=True): + """Evaluate system's transfer function at complex frequencies. - Reports the value of the magnitude, phase, and angular frequency of - the requency response evaluated at omega, where omega is a list of - angular frequencies, and is a sorted version of the input omega. + Returns the complex frequency response `sys(s)`. + + To evaluate at a frequency omega in radians per second, enter + ``x = omega * 1j`` or use ``sys.eval(omega)`` Parameters ---------- - omega : array_like - A list of frequencies in radians/sec at which the system should be - evaluated. The list can be either a python list or a numpy array - and will be sorted before evaluation. + s: complex scalar or array_like + Complex frequencies + squeeze: bool, optional (default=True) + If True and `sys` is single input single output (SISO), returns a + 1D array or scalar depending on the length of x`. Returns ------- - mag : (self.outputs, self.inputs, len(omega)) ndarray - The magnitude (absolute value, not dB or log10) of the system - frequency response. - phase : (self.outputs, self.inputs, len(omega)) ndarray - The wrapped phase in radians of the system frequency response. - omega : ndarray or list or tuple - The list of sorted frequencies at which the response was - evaluated. - """ - # Preallocate outputs. - numfreq = len(omega) - mag = empty((self.outputs, self.inputs, numfreq)) - phase = empty((self.outputs, self.inputs, numfreq)) + fresp : (self.outputs, self.inputs, len(s)) or len(s) complex ndarray + The frequency response of the system. Array is ``len(s)`` if and + only if system is SISO and ``squeeze=True``. - omega.sort() + Raises + ------ + ValueError + If `s` is not purely imaginary, because + :class:`FrequencyDomainData` systems are only defined at imaginary + frequency values. + + """ + if any(abs(np.array(s, ndmin=1).real) > 0): + raise ValueError("__call__: FRD systems can only accept" + "purely imaginary frequencies") + # need to preserve array or scalar status + if hasattr(s, '__len__'): + return self.eval(np.asarray(s).imag, squeeze=squeeze) + else: + return self.eval(complex(s).imag, squeeze=squeeze) - for k, w in enumerate(omega): - fresp = self._evalfr(w) - mag[:, :, k] = abs(fresp) - phase[:, :, k] = angle(fresp) + def freqresp(self, omega): + """(deprecated) Evaluate transfer function at complex frequencies. - return mag, phase, omega + .. deprecated::0.9.0 + Method has been given the more pythonic name + :meth:`FrequencyResponseData.frequency_response`. Or use + :func:`freqresp` in the MATLAB compatibility module. + """ + warn("FrequencyResponseData.freqresp(omega) will be removed in a " + "future release of python-control; use " + "FrequencyResponseData.frequency_response(omega), or " + "freqresp(sys, omega) in the MATLAB compatibility module " + "instead", DeprecationWarning) + return self.frequency_response(omega) def feedback(self, other=1, sign=-1): """Feedback interconnection between two FRD objects.""" @@ -515,11 +514,10 @@ def _convertToFRD(sys, omega, inputs=1, outputs=1): "Frequency ranges of FRD do not match, conversion not implemented") elif isinstance(sys, LTI): - omega.sort() - fresp = empty((sys.outputs, sys.inputs, len(omega)), dtype=complex) - for k, w in enumerate(omega): - fresp[:, :, k] = sys._evalfr(w) - + omega = np.sort(omega) + fresp = sys(1j * omega) + if len(fresp.shape) == 1: + fresp = fresp[np.newaxis, np.newaxis, :] return FRD(fresp, omega, smooth=True) elif isinstance(sys, (int, float, complex, np.number)): diff --git a/control/freqplot.py b/control/freqplot.py index 5a03694db..38b525aec 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -151,14 +151,14 @@ def bode_plot(syslist, omega=None, Notes ----- - 1. Alternatively, you may use the lower-level method - ``(mag, phase, freq) = sys.freqresp(freq)`` to generate the frequency - response for a system, but it returns a MIMO response. + 1. Alternatively, you may use the lower-level methods + :meth:`LTI.frequency_response` or ``sys(s)`` or ``sys(z)`` or to + generate the frequency response for a single system. 2. If a discrete time model is given, the frequency response is plotted - along the upper branch of the unit circle, using the mapping z = exp(j - \\omega dt) where omega ranges from 0 to pi/dt and dt is the discrete - timebase. If not timebase is specified (dt = True), dt is set to 1. + along the upper branch of the unit circle, using the mapping ``z = exp(1j + * omega * dt)`` where `omega` ranges from 0 to `pi/dt` and `dt` is the discrete + timebase. If timebase not specified (``dt=True``), `dt` is set to 1. Examples -------- @@ -228,7 +228,7 @@ def bode_plot(syslist, omega=None, nyquistfrq = None # Get the magnitude and phase of the system - mag_tmp, phase_tmp, omega_sys = sys.freqresp(omega_sys) + mag_tmp, phase_tmp, omega_sys = sys.frequency_response(omega_sys) mag = np.atleast_1d(np.squeeze(mag_tmp)) phase = np.atleast_1d(np.squeeze(phase_tmp)) @@ -588,7 +588,7 @@ def nyquist_plot(syslist, omega=None, plot=True, label_freq=0, "Nyquist is currently only implemented for SISO systems.") else: # Get the magnitude and phase of the system - mag_tmp, phase_tmp, omega = sys.freqresp(omega) + mag_tmp, phase_tmp, omega = sys.frequency_response(omega) mag = np.squeeze(mag_tmp) phase = np.squeeze(phase_tmp) @@ -718,7 +718,7 @@ def gangof4_plot(P, C, omega=None, **kwargs): omega_plot = omega / (2. * math.pi) if Hz else omega # TODO: Need to add in the mag = 1 lines - mag_tmp, phase_tmp, omega = S.freqresp(omega) + mag_tmp, phase_tmp, omega = S.frequency_response(omega) mag = np.squeeze(mag_tmp) if dB: plot_axes['s'].semilogx(omega_plot, 20 * np.log10(mag), **kwargs) @@ -728,7 +728,7 @@ def gangof4_plot(P, C, omega=None, **kwargs): plot_axes['s'].tick_params(labelbottom=False) plot_axes['s'].grid(grid, which='both') - mag_tmp, phase_tmp, omega = (P * S).freqresp(omega) + mag_tmp, phase_tmp, omega = (P * S).frequency_response(omega) mag = np.squeeze(mag_tmp) if dB: plot_axes['ps'].semilogx(omega_plot, 20 * np.log10(mag), **kwargs) @@ -738,7 +738,7 @@ def gangof4_plot(P, C, omega=None, **kwargs): plot_axes['ps'].set_ylabel("$|PS|$" + " (dB)" if dB else "") plot_axes['ps'].grid(grid, which='both') - mag_tmp, phase_tmp, omega = (C * S).freqresp(omega) + mag_tmp, phase_tmp, omega = (C * S).frequency_response(omega) mag = np.squeeze(mag_tmp) if dB: plot_axes['cs'].semilogx(omega_plot, 20 * np.log10(mag), **kwargs) @@ -749,7 +749,7 @@ def gangof4_plot(P, C, omega=None, **kwargs): plot_axes['cs'].set_ylabel("$|CS|$" + " (dB)" if dB else "") plot_axes['cs'].grid(grid, which='both') - mag_tmp, phase_tmp, omega = T.freqresp(omega) + mag_tmp, phase_tmp, omega = T.frequency_response(omega) mag = np.squeeze(mag_tmp) if dB: plot_axes['t'].semilogx(omega_plot, 20 * np.log10(mag), **kwargs) diff --git a/control/lti.py b/control/lti.py index e41fe416b..eff14be2a 100644 --- a/control/lti.py +++ b/control/lti.py @@ -13,11 +13,11 @@ """ import numpy as np -from numpy import absolute, real +from numpy import absolute, real, angle, abs from warnings import warn -__all__ = ['issiso', 'timebase', 'common_timebase', 'timebaseEqual', - 'isdtime', 'isctime', 'pole', 'zero', 'damp', 'evalfr', +__all__ = ['issiso', 'timebase', 'common_timebase', 'timebaseEqual', + 'isdtime', 'isctime', 'pole', 'zero', 'damp', 'evalfr', 'freqresp', 'dcgain'] class LTI: @@ -111,11 +111,56 @@ def damp(self): Z = -real(splane_poles)/wn return wn, Z, poles + def frequency_response(self, omega, squeeze=True): + """Evaluate the linear time-invariant system at an array of angular + frequencies. + + Reports the frequency response of the system, + + G(j*omega) = mag*exp(j*phase) + + for continuous time systems. For discrete time systems, the response is + evaluated around the unit circle such that + + G(exp(j*omega*dt)) = mag*exp(j*phase). + + Parameters + ---------- + omega : array_like or float + A list, tuple, array, or scalar value of frequencies in + radians/sec at which the system will be evaluated. + squeeze: bool, optional (default=True) + If True and sys is single input, single output (SISO), return a + 1D array or scalar depending on omega's length. + + Returns + ------- + mag : (self.outputs, self.inputs, len(omega)) or len(omega) ndarray + The magnitude (absolute value, not dB or log10) of the system + frequency response. + phase : (self.outputs, self.inputs, len(omega)) or len(omega) ndarray + The wrapped phase in radians of the system frequency response. + omega : ndarray + The (sorted) frequencies at which the response was evaluated. + + """ + omega = np.sort(np.array(omega, ndmin=1)) + if isdtime(self, strict=True): + # Convert the frequency to discrete time + if np.any(omega * self.dt > np.pi): + warn("__call__: evaluation above Nyquist frequency") + s = np.exp(1j * omega * self.dt) + else: + s = 1j * omega + response = self.__call__(s, squeeze=squeeze) + return abs(response), angle(response), omega + def dcgain(self): """Return the zero-frequency gain""" raise NotImplementedError("dcgain not implemented for %s objects" % str(self.__class__)) + # Test to see if a system is SISO def issiso(sys, strict=False): """ @@ -162,50 +207,50 @@ def timebase(sys, strict=True): def common_timebase(dt1, dt2): """ Find the common timebase when interconnecting systems - + Parameters ---------- - dt1, dt2: number or system with a 'dt' attribute (e.g. TransferFunction + dt1, dt2: number or system with a 'dt' attribute (e.g. TransferFunction or StateSpace system) Returns ------- dt: number - The common timebase of dt1 and dt2, as specified in - :ref:`conventions-ref`. - + The common timebase of dt1 and dt2, as specified in + :ref:`conventions-ref`. + Raises ------ ValueError when no compatible time base can be found """ - # explanation: + # explanation: # if either dt is None, they are compatible with anything - # if either dt is True (discrete with unspecified time base), + # if either dt is True (discrete with unspecified time base), # use the timebase of the other, if it is also discrete - # otherwise both dts must be equal + # otherwise both dts must be equal if hasattr(dt1, 'dt'): dt1 = dt1.dt if hasattr(dt2, 'dt'): dt2 = dt2.dt - if dt1 is None: + if dt1 is None: return dt2 - elif dt2 is None: + elif dt2 is None: return dt1 - elif dt1 is True: + elif dt1 is True: if dt2 > 0: return dt2 - else: + else: raise ValueError("Systems have incompatible timebases") - elif dt2 is True: - if dt1 > 0: + elif dt2 is True: + if dt1 > 0: return dt1 - else: + else: raise ValueError("Systems have incompatible timebases") elif np.isclose(dt1, dt2): return dt1 - else: + else: raise ValueError("Systems have incompatible timebases") # Check to see if two timebases are equal @@ -221,9 +266,9 @@ def timebaseEqual(sys1, sys2): timebase (dt > 0) then their timebases must be equal. """ warn("timebaseEqual will be deprecated in a future release of " - "python-control; use :func:`common_timebase` instead", + "python-control; use :func:`common_timebase` instead", PendingDeprecationWarning) - + if (type(sys1.dt) == bool or type(sys2.dt) == bool): # Make sure both are unspecified discrete timebases return type(sys1.dt) == type(sys2.dt) and sys1.dt == sys2.dt @@ -413,24 +458,33 @@ def damp(sys, doprint=True): (p.real, p.imag, d, w)) return wn, damping, poles -def evalfr(sys, x): +def evalfr(sys, x, squeeze=True): """ - Evaluate the transfer function of an LTI system for a single complex - number x. + Evaluate the transfer function of an LTI system for complex frequency x. + + Returns the complex frequency response `sys(x)` where `x` is `s` for + continuous-time systems and `z` for discrete-time systems. - To evaluate at a frequency, enter x = omega*j, where omega is the - frequency in radians + To evaluate at a frequency omega in radians per second, enter + ``x = omega * 1j`` for continuous-time systems, or + ``x = exp(1j * omega * dt)`` for discrete-time systems, or use + ``freqresp(sys, omega)``. Parameters ---------- sys: StateSpace or TransferFunction Linear system - x: scalar - Complex number + x: complex scalar or array_like + Complex frequency(s) + squeeze: bool, optional (default=True) + If True and sys is single input single output (SISO), returns a + 1D array or scalar depending on the length of x. Returns ------- - fresp: ndarray + fresp : (sys.outputs, sys.inputs, len(x)) or len(x) complex ndarray + The frequency response of the system. Array is len(x) if and only if + system is SISO and squeeze=True. See Also -------- @@ -439,8 +493,8 @@ def evalfr(sys, x): Notes ----- - This function is a wrapper for StateSpace.evalfr and - TransferFunction.evalfr. + This function is a wrapper for StateSpace.__call__ and + TransferFunction.__call__. Examples -------- @@ -451,12 +505,9 @@ def evalfr(sys, x): .. todo:: Add example with MIMO system """ - if issiso(sys): - return sys.horner(x)[0][0] - return sys.horner(x) - + return sys.__call__(x, squeeze=squeeze) -def freqresp(sys, omega): +def freqresp(sys, omega, squeeze=True): """ Frequency response of an LTI system at multiple angular frequencies. @@ -464,19 +515,22 @@ def freqresp(sys, omega): ---------- sys: StateSpace or TransferFunction Linear system - omega: array_like + omega: float or array_like A list of frequencies in radians/sec at which the system should be evaluated. The list can be either a python list or a numpy array and will be sorted before evaluation. + squeeze: bool, optional (default=True) + If True and sys is single input, single output (SISO), returns + 1D array or scalar depending on omega's length. Returns ------- - mag : (self.outputs, self.inputs, len(omega)) ndarray + mag : (sys.outputs, sys.inputs, len(omega)) or len(omega) ndarray The magnitude (absolute value, not dB or log10) of the system frequency response. - phase : (self.outputs, self.inputs, len(omega)) ndarray + phase : (sys.outputs, sys.inputs, len(omega)) or len(omega) ndarray The wrapped phase in radians of the system frequency response. - omega : ndarray or list or tuple + omega : ndarray The list of sorted frequencies at which the response was evaluated. @@ -487,9 +541,8 @@ def freqresp(sys, omega): Notes ----- - This function is a wrapper for StateSpace.freqresp and - TransferFunction.freqresp. The output omega is a sorted version of the - input omega. + This function is a wrapper for StateSpace.frequency_response and + TransferFunction.frequency_response. Examples -------- @@ -514,7 +567,7 @@ def freqresp(sys, omega): #>>> # frequency response from the 1st input to the 2nd output, for #>>> # s = 0.1i, i, 10i. """ - return sys.freqresp(omega) + return sys.frequency_response(omega, squeeze=squeeze) def dcgain(sys): diff --git a/control/margins.py b/control/margins.py index 03e78352f..20da2a879 100644 --- a/control/margins.py +++ b/control/margins.py @@ -339,24 +339,24 @@ def stability_margins(sysdata, returnall=False, epsw=0.0): # a bit coarse, have the interpolated frd evaluated again def _mod(w): """Calculate |G(jw)| - 1""" - return np.abs(sys._evalfr(w)[0][0]) - 1 + return np.abs(sys(1j * w)) - 1 def _arg(w): """Calculate the phase angle at -180 deg""" - return np.angle(-sys._evalfr(w)[0][0]) + return np.angle(-sys(1j * w)) def _dstab(w): """Calculate the distance from -1 point""" - return np.abs(sys._evalfr(w)[0][0] + 1.) + return np.abs(sys(1j * w) + 1.) # find the phase crossings ang(H(jw) == -180 widx = np.where(np.diff(np.sign(_arg(sys.omega))))[0] - widx = widx[np.real(sys._evalfr(sys.omega[widx])[0][0]) <= 0] + widx = widx[np.real(sys(1j * sys.omega[widx])) <= 0] w_180 = np.array( [sp.optimize.brentq(_arg, sys.omega[i], sys.omega[i+1]) for i in widx]) # TODO: replace by evalfr(sys, 1J*w) or sys(1J*w), (needs gh-449) - w180_resp = sys._evalfr(w_180)[0][0] + w180_resp = sys(1j * w_180) # Find all crossings, note that this depends on omega having # a correct range @@ -364,7 +364,7 @@ def _dstab(w): wc = np.array( [sp.optimize.brentq(_mod, sys.omega[i], sys.omega[i+1]) for i in widx]) - wc_resp = sys._evalfr(wc)[0][0] + wc_resp = sys(1j * wc) # find all stab margins? widx, = np.where(np.diff(np.sign(np.diff(_dstab(sys.omega)))) > 0) @@ -374,7 +374,7 @@ def _dstab(w): ).x for i in widx]) wstab = wstab[(wstab >= sys.omega[0]) * (wstab <= sys.omega[-1])] - ws_resp = sys._evalfr(wstab)[0][0] + ws_resp = sys(1j * wstab) with np.errstate(all='ignore'): # |G|=0 is okay and yields inf GM = 1. / np.abs(w180_resp) diff --git a/control/matlab/__init__.py b/control/matlab/__init__.py index 413dc6d86..6b88214c6 100644 --- a/control/matlab/__init__.py +++ b/control/matlab/__init__.py @@ -224,8 +224,8 @@ \* :func:`~control.nichols` Nichols plot \* :func:`margin` gain and phase margins \ lti/allmargin all crossover frequencies and margins -\* :func:`freqresp` frequency response over a frequency grid -\* :func:`evalfr` frequency response at single frequency +\* :func:`freqresp` frequency response +\* :func:`evalfr` frequency response at complex frequency s == ========================== ============================================ diff --git a/control/nichols.py b/control/nichols.py index ca0505957..c1d8ff9b6 100644 --- a/control/nichols.py +++ b/control/nichols.py @@ -95,7 +95,7 @@ def nichols_plot(sys_list, omega=None, grid=None): for sys in sys_list: # Get the magnitude and phase of the system - mag_tmp, phase_tmp, omega = sys.freqresp(omega) + mag_tmp, phase_tmp, omega = sys.frequency_response(omega) mag = np.squeeze(mag_tmp) phase = np.squeeze(phase_tmp) diff --git a/control/rlocus.py b/control/rlocus.py index 479a833ab..3a3c1c2b6 100644 --- a/control/rlocus.py +++ b/control/rlocus.py @@ -476,7 +476,7 @@ def _systopoly1d(sys): sys = _convert_to_transfer_function(sys) # Make sure we have a SISO system - if (sys.inputs > 1 or sys.outputs > 1): + if not sys.issiso(): raise ControlMIMONotImplemented() # Start by extracting the numerator and denominator from system object @@ -497,7 +497,7 @@ 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 kvect: + for k in np.array(kvect, ndmin=1): curpoly = denp + k * nump curroots = curpoly.r if len(curroots) < denp.order: @@ -581,10 +581,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.horner(s) - K_xlim = -1. / sys.horner( + K = -1. / sys(s) + K_xlim = -1. / sys( complex(event.xdata + 0.05 * abs(xlim[1] - xlim[0]), event.ydata)) - K_ylim = -1. / sys.horner( + K_ylim = -1. / sys( complex(event.xdata, event.ydata + 0.05 * abs(ylim[1] - ylim[0]))) except TypeError: @@ -625,7 +625,7 @@ def _RLFeedbackClicksPoint(event, sys, fig, ax_rlocus, sisotool=False): ax_rlocus.plot(s.real, s.imag, 'k.', marker='s', markersize=8, zorder=20, label='gain_point') - return K.real[0][0] + return K.real def _removeLine(label, ax): diff --git a/control/statesp.py b/control/statesp.py index b41f18c7d..8bf4c0d99 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -54,7 +54,7 @@ import math import numpy as np from numpy import any, array, asarray, concatenate, cos, delete, \ - dot, empty, exp, eye, isinf, ones, pad, sin, zeros, squeeze + dot, empty, exp, eye, isinf, ones, pad, sin, zeros, squeeze, pi from numpy.random import rand, randn from numpy.linalg import solve, eigvals, matrix_rank from numpy.linalg.linalg import LinAlgError @@ -640,125 +640,146 @@ def __rdiv__(self, other): raise NotImplementedError( "StateSpace.__rdiv__ is not implemented yet.") - def evalfr(self, omega): - """Evaluate a SS system's transfer function at a single frequency. + def __call__(self, x, squeeze=True): + """Evaluate system's transfer function at complex frequency. - self._evalfr(omega) returns the value of the transfer function matrix - with input value s = i * omega. + Returns the complex frequency response `sys(x)` where `x` is `s` for + continuous-time systems and `z` for discrete-time systems. - """ - warn("StateSpace.evalfr(omega) will be deprecated in a future " - "release of python-control; use evalfr(sys, omega*1j) instead", - PendingDeprecationWarning) - return self._evalfr(omega) - - def _evalfr(self, omega): - """Evaluate a SS system's transfer function at a single frequency""" - # Figure out the point to evaluate the transfer function - if isdtime(self, strict=True): - s = exp(1.j * omega * self.dt) - if omega * self.dt > math.pi: - warn("_evalfr: frequency evaluation above Nyquist frequency") - else: - s = omega * 1.j + To evaluate at a frequency omega in radians per second, enter + ``x = omega * 1j``, for continuous-time systems, or + ``x = exp(1j * omega * dt)`` for discrete-time systems. Or use + :meth:`StateSpace.frequency_response`. - return self.horner(s) + Parameters + ---------- + x: complex or complex array_like + Complex frequencies + squeeze: bool, optional (default=True) + If True and `sys` is single input single output (SISO), returns a + 1D array or scalar depending on the length of `x`. - def horner(self, s): - """Evaluate the systems's transfer function for a complex variable + Returns + ------- + fresp : (self.outputs, self.inputs, len(x)) or len(x) complex ndarray + The frequency response of the system. Array is ``len(x)`` if and + only if system is SISO and ``squeeze=True``. - Returns a matrix of values evaluated at complex variable s. """ - resp = np.dot(self.C, solve(s * eye(self.states) - self.A, - self.B)) + self.D - return array(resp) + # Use Slycot if available + out = self.horner(x) + if not hasattr(x, '__len__'): + # received a scalar x, squeeze down the array along last dim + out = np.squeeze(out, axis=2) + if squeeze and self.issiso(): + return out[0][0] + else: + return out - def freqresp(self, omega): - """Evaluate the system's transfer function at a list of frequencies + def slycot_laub(self, x): + """Evaluate system's transfer function at complex frequency + using Laub's method from Slycot. + + Expects inputs and outputs to be formatted correctly. Use ``sys(x)`` + for a more user-friendly interface. + + Parameters + ---------- + x : complex array_like or complex + Complex frequency - Reports the frequency response of the system, + Returns + ------- + output : (number_outputs, number_inputs, len(x)) complex ndarray + Frequency response + """ + from slycot import tb05ad + + # preallocate + x_arr = np.atleast_1d(x) # array-like version of x + n = self.states + m = self.inputs + p = self.outputs + out = np.empty((p, m, len(x_arr)), dtype=complex) + # The first call both evaluates C(sI-A)^-1 B and also returns + # Hessenberg transformed matrices at, bt, ct. + result = tb05ad(n, m, p, x_arr[0], self.A, self.B, self.C, job='NG') + # When job='NG', result = (at, bt, ct, g_i, hinvb, info) + at = result[0] + bt = result[1] + ct = result[2] + + # TB05AD frequency evaluation does not include direct feedthrough. + out[:, :, 0] = result[3] + self.D + + # Now, iterate through the remaining frequencies using the + # transformed state matrices, at, bt, ct. + + # Start at the second frequency, already have the first. + for kk, x_kk in enumerate(x_arr[1:len(x_arr)]): + result = tb05ad(n, m, p, x_kk, at, bt, ct, job='NH') + # When job='NH', result = (g_i, hinvb, info) + + # kk+1 because enumerate starts at kk = 0. + # but zero-th spot is already filled. + out[:, :, kk+1] = result[0] + self.D + return out - G(j*omega) = mag*exp(j*phase) + def horner(self, x): + """Evaluate system's transfer function at complex frequency + using Laub's or Horner's method. - for continuous time. For discrete time systems, the response is - evaluated around the unit circle such that + Evaluates `sys(x)` where `x` is `s` for continuous-time systems and `z` + for discrete-time systems. - G(exp(j*omega*dt)) = mag*exp(j*phase). + Expects inputs and outputs to be formatted correctly. Use ``sys(x)`` + for a more user-friendly interface. Parameters ---------- - omega : array_like - A list of frequencies in radians/sec at which the system should be - evaluated. The list can be either a python list or a numpy array - and will be sorted before evaluation. + x : complex array_like or complex + Complex frequencies Returns ------- - mag : (self.outputs, self.inputs, len(omega)) ndarray - The magnitude (absolute value, not dB or log10) of the system - frequency response. - phase : (self.outputs, self.inputs, len(omega)) ndarray - The wrapped phase in radians of the system frequency response. - omega : ndarray - The list of sorted frequencies at which the response was - evaluated. - """ - # In case omega is passed in as a list, rather than a proper array. - omega = np.asarray(omega) - - numFreqs = len(omega) - Gfrf = np.empty((self.outputs, self.inputs, numFreqs), - dtype=np.complex128) - - # Sort frequency and calculate complex frequencies on either imaginary - # axis (continuous time) or unit circle (discrete time). - omega.sort() - if isdtime(self, strict=True): - cmplx_freqs = exp(1.j * omega * self.dt) - if max(np.abs(omega)) * self.dt > math.pi: - warn("freqresp: frequency evaluation above Nyquist frequency") - else: - cmplx_freqs = omega * 1.j + output : (self.outputs, self.inputs, len(x)) complex ndarray + Frequency response - # Do the frequency response evaluation. Use TB05AD from Slycot - # if it's available, otherwise use the built-in horners function. + Notes + ----- + Attempts to use Laub's method from Slycot library, with a + fall-back to python code. + """ try: - from slycot import tb05ad - - n = np.shape(self.A)[0] - m = self.inputs - p = self.outputs - # The first call both evaluates C(sI-A)^-1 B and also returns - # Hessenberg transformed matrices at, bt, ct. - result = tb05ad(n, m, p, cmplx_freqs[0], self.A, - self.B, self.C, job='NG') - # When job='NG', result = (at, bt, ct, g_i, hinvb, info) - at = result[0] - bt = result[1] - ct = result[2] - - # TB05AD frequency evaluation does not include direct feedthrough. - Gfrf[:, :, 0] = result[3] + self.D - - # Now, iterate through the remaining frequencies using the - # transformed state matrices, at, bt, ct. - - # Start at the second frequency, already have the first. - for kk, cmplx_freqs_kk in enumerate(cmplx_freqs[1:numFreqs]): - result = tb05ad(n, m, p, cmplx_freqs_kk, at, - bt, ct, job='NH') - # When job='NH', result = (g_i, hinvb, info) - - # kk+1 because enumerate starts at kk = 0. - # but zero-th spot is already filled. - Gfrf[:, :, kk+1] = result[0] + self.D - - except ImportError: # Slycot unavailable. Fall back to horner. - for kk, cmplx_freqs_kk in enumerate(cmplx_freqs): - Gfrf[:, :, kk] = self.horner(cmplx_freqs_kk) - - # mag phase omega - return np.abs(Gfrf), np.angle(Gfrf), omega + out = self.slycot_laub(x) + except (ImportError, Exception): + # Fall back because either Slycot unavailable or cannot handle + # certain cases. + x_arr = np.atleast_1d(x) # force to be an array + # Preallocate + out = empty((self.outputs, self.inputs, len(x_arr)), dtype=complex) + + #TODO: can this be vectorized? + for idx, x_idx in enumerate(x_arr): + out[:,:,idx] = \ + np.dot(self.C, + solve(x_idx * eye(self.states) - self.A, self.B)) \ + + self.D + return out + + def freqresp(self, omega): + """(deprecated) Evaluate transfer function at complex frequencies. + + .. deprecated::0.9.0 + Method has been given the more pythonic name + :meth:`StateSpace.frequency_response`. Or use + :func:`freqresp` in the MATLAB compatibility module. + """ + warn("StateSpace.freqresp(omega) will be removed in a " + "future release of python-control; use " + "sys.frequency_response(omega), or freqresp(sys, omega) in the " + "MATLAB compatibility module instead", DeprecationWarning) + return self.frequency_response(omega) # Compute poles and zeros def pole(self): @@ -1148,7 +1169,7 @@ def dcgain(self): gain = np.asarray(self.D - self.C.dot(np.linalg.solve(self.A, self.B))) else: - gain = self.horner(1) + gain = np.squeeze(self.horner(1)) except LinAlgError: # eigenvalue at DC gain = np.tile(np.nan, (self.outputs, self.inputs)) diff --git a/control/tests/frd_test.py b/control/tests/frd_test.py index 18f2f17b1..5343112fe 100644 --- a/control/tests/frd_test.py +++ b/control/tests/frd_test.py @@ -39,8 +39,8 @@ def testSISOtf(self): frd = FRD(h, omega) assert isinstance(frd, FRD) - mag1, phase1, omega1 = frd.freqresp([1.0]) - mag2, phase2, omega2 = h.freqresp([1.0]) + mag1, phase1, omega1 = frd.frequency_response([1.0]) + mag2, phase2, omega2 = h.frequency_response([1.0]) np.testing.assert_array_almost_equal(mag1, mag2) np.testing.assert_array_almost_equal(phase1, phase2) np.testing.assert_array_almost_equal(omega1, omega2) @@ -54,39 +54,39 @@ def testOperators(self): f2 = FRD(h2, omega) np.testing.assert_array_almost_equal( - (f1 + f2).freqresp([0.1, 1.0, 10])[0], - (h1 + h2).freqresp([0.1, 1.0, 10])[0]) + (f1 + f2).frequency_response([0.1, 1.0, 10])[0], + (h1 + h2).frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - (f1 + f2).freqresp([0.1, 1.0, 10])[1], - (h1 + h2).freqresp([0.1, 1.0, 10])[1]) + (f1 + f2).frequency_response([0.1, 1.0, 10])[1], + (h1 + h2).frequency_response([0.1, 1.0, 10])[1]) np.testing.assert_array_almost_equal( - (f1 - f2).freqresp([0.1, 1.0, 10])[0], - (h1 - h2).freqresp([0.1, 1.0, 10])[0]) + (f1 - f2).frequency_response([0.1, 1.0, 10])[0], + (h1 - h2).frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - (f1 - f2).freqresp([0.1, 1.0, 10])[1], - (h1 - h2).freqresp([0.1, 1.0, 10])[1]) + (f1 - f2).frequency_response([0.1, 1.0, 10])[1], + (h1 - h2).frequency_response([0.1, 1.0, 10])[1]) # multiplication and division np.testing.assert_array_almost_equal( - (f1 * f2).freqresp([0.1, 1.0, 10])[1], - (h1 * h2).freqresp([0.1, 1.0, 10])[1]) + (f1 * f2).frequency_response([0.1, 1.0, 10])[1], + (h1 * h2).frequency_response([0.1, 1.0, 10])[1]) np.testing.assert_array_almost_equal( - (f1 / f2).freqresp([0.1, 1.0, 10])[1], - (h1 / h2).freqresp([0.1, 1.0, 10])[1]) + (f1 / f2).frequency_response([0.1, 1.0, 10])[1], + (h1 / h2).frequency_response([0.1, 1.0, 10])[1]) # with default conversion from scalar np.testing.assert_array_almost_equal( - (f1 * 1.5).freqresp([0.1, 1.0, 10])[1], - (h1 * 1.5).freqresp([0.1, 1.0, 10])[1]) + (f1 * 1.5).frequency_response([0.1, 1.0, 10])[1], + (h1 * 1.5).frequency_response([0.1, 1.0, 10])[1]) np.testing.assert_array_almost_equal( - (f1 / 1.7).freqresp([0.1, 1.0, 10])[1], - (h1 / 1.7).freqresp([0.1, 1.0, 10])[1]) + (f1 / 1.7).frequency_response([0.1, 1.0, 10])[1], + (h1 / 1.7).frequency_response([0.1, 1.0, 10])[1]) np.testing.assert_array_almost_equal( - (2.2 * f2).freqresp([0.1, 1.0, 10])[1], - (2.2 * h2).freqresp([0.1, 1.0, 10])[1]) + (2.2 * f2).frequency_response([0.1, 1.0, 10])[1], + (2.2 * h2).frequency_response([0.1, 1.0, 10])[1]) np.testing.assert_array_almost_equal( - (1.3 / f2).freqresp([0.1, 1.0, 10])[1], - (1.3 / h2).freqresp([0.1, 1.0, 10])[1]) + (1.3 / f2).frequency_response([0.1, 1.0, 10])[1], + (1.3 / h2).frequency_response([0.1, 1.0, 10])[1]) def testOperatorsTf(self): # get two SISO transfer functions @@ -98,24 +98,24 @@ def testOperatorsTf(self): f2 # reference to avoid pyflakes error np.testing.assert_array_almost_equal( - (f1 + h2).freqresp([0.1, 1.0, 10])[0], - (h1 + h2).freqresp([0.1, 1.0, 10])[0]) + (f1 + h2).frequency_response([0.1, 1.0, 10])[0], + (h1 + h2).frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - (f1 + h2).freqresp([0.1, 1.0, 10])[1], - (h1 + h2).freqresp([0.1, 1.0, 10])[1]) + (f1 + h2).frequency_response([0.1, 1.0, 10])[1], + (h1 + h2).frequency_response([0.1, 1.0, 10])[1]) np.testing.assert_array_almost_equal( - (f1 - h2).freqresp([0.1, 1.0, 10])[0], - (h1 - h2).freqresp([0.1, 1.0, 10])[0]) + (f1 - h2).frequency_response([0.1, 1.0, 10])[0], + (h1 - h2).frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - (f1 - h2).freqresp([0.1, 1.0, 10])[1], - (h1 - h2).freqresp([0.1, 1.0, 10])[1]) + (f1 - h2).frequency_response([0.1, 1.0, 10])[1], + (h1 - h2).frequency_response([0.1, 1.0, 10])[1]) # multiplication and division np.testing.assert_array_almost_equal( - (f1 * h2).freqresp([0.1, 1.0, 10])[1], - (h1 * h2).freqresp([0.1, 1.0, 10])[1]) + (f1 * h2).frequency_response([0.1, 1.0, 10])[1], + (h1 * h2).frequency_response([0.1, 1.0, 10])[1]) np.testing.assert_array_almost_equal( - (f1 / h2).freqresp([0.1, 1.0, 10])[1], - (h1 / h2).freqresp([0.1, 1.0, 10])[1]) + (f1 / h2).frequency_response([0.1, 1.0, 10])[1], + (h1 / h2).frequency_response([0.1, 1.0, 10])[1]) # the reverse does not work def testbdalg(self): @@ -127,45 +127,45 @@ def testbdalg(self): f2 = FRD(h2, omega) np.testing.assert_array_almost_equal( - (bdalg.series(f1, f2)).freqresp([0.1, 1.0, 10])[0], - (bdalg.series(h1, h2)).freqresp([0.1, 1.0, 10])[0]) + (bdalg.series(f1, f2)).frequency_response([0.1, 1.0, 10])[0], + (bdalg.series(h1, h2)).frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - (bdalg.parallel(f1, f2)).freqresp([0.1, 1.0, 10])[0], - (bdalg.parallel(h1, h2)).freqresp([0.1, 1.0, 10])[0]) + (bdalg.parallel(f1, f2)).frequency_response([0.1, 1.0, 10])[0], + (bdalg.parallel(h1, h2)).frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - (bdalg.feedback(f1, f2)).freqresp([0.1, 1.0, 10])[0], - (bdalg.feedback(h1, h2)).freqresp([0.1, 1.0, 10])[0]) + (bdalg.feedback(f1, f2)).frequency_response([0.1, 1.0, 10])[0], + (bdalg.feedback(h1, h2)).frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - (bdalg.negate(f1)).freqresp([0.1, 1.0, 10])[0], - (bdalg.negate(h1)).freqresp([0.1, 1.0, 10])[0]) + (bdalg.negate(f1)).frequency_response([0.1, 1.0, 10])[0], + (bdalg.negate(h1)).frequency_response([0.1, 1.0, 10])[0]) # append() and connect() not implemented for FRD objects # np.testing.assert_array_almost_equal( -# (bdalg.append(f1, f2)).freqresp([0.1, 1.0, 10])[0], -# (bdalg.append(h1, h2)).freqresp([0.1, 1.0, 10])[0]) +# (bdalg.append(f1, f2)).frequency_response([0.1, 1.0, 10])[0], +# (bdalg.append(h1, h2)).frequency_response([0.1, 1.0, 10])[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])).freqresp([0.1, 1.0, 10])[0], -# (bdalg.connect(h3, Q, [2], [1])).freqresp([0.1, 1.0, 10])[0]) +# (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]) def testFeedback(self): h1 = TransferFunction([1], [1, 2, 2]) omega = np.logspace(-1, 2, 10) f1 = FRD(h1, omega) np.testing.assert_array_almost_equal( - f1.feedback(1).freqresp([0.1, 1.0, 10])[0], - h1.feedback(1).freqresp([0.1, 1.0, 10])[0]) + f1.feedback(1).frequency_response([0.1, 1.0, 10])[0], + h1.feedback(1).frequency_response([0.1, 1.0, 10])[0]) # Make sure default argument also works np.testing.assert_array_almost_equal( - f1.feedback().freqresp([0.1, 1.0, 10])[0], - h1.feedback().freqresp([0.1, 1.0, 10])[0]) + f1.feedback().frequency_response([0.1, 1.0, 10])[0], + h1.feedback().frequency_response([0.1, 1.0, 10])[0]) def testFeedback2(self): h2 = StateSpace([[-1.0, 0], [0, -2.0]], [[0.4], [0.1]], @@ -198,11 +198,11 @@ def testMIMO(self): omega = np.logspace(-1, 2, 10) f1 = FRD(sys, omega) np.testing.assert_array_almost_equal( - sys.freqresp([0.1, 1.0, 10])[0], - f1.freqresp([0.1, 1.0, 10])[0]) + sys.frequency_response([0.1, 1.0, 10])[0], + f1.frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - sys.freqresp([0.1, 1.0, 10])[1], - f1.freqresp([0.1, 1.0, 10])[1]) + sys.frequency_response([0.1, 1.0, 10])[1], + f1.frequency_response([0.1, 1.0, 10])[1]) @slycotonly def testMIMOfb(self): @@ -214,11 +214,11 @@ def testMIMOfb(self): 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.freqresp([0.1, 1.0, 10])[0], - f2.freqresp([0.1, 1.0, 10])[0]) + f1.frequency_response([0.1, 1.0, 10])[0], + f2.frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - f1.freqresp([0.1, 1.0, 10])[1], - f2.freqresp([0.1, 1.0, 10])[1]) + f1.frequency_response([0.1, 1.0, 10])[1], + f2.frequency_response([0.1, 1.0, 10])[1]) @slycotonly def testMIMOfb2(self): @@ -232,11 +232,11 @@ def testMIMOfb2(self): f1 = FRD(sys, omega).feedback(K) f2 = FRD(sys.feedback(K), omega) np.testing.assert_array_almost_equal( - f1.freqresp([0.1, 1.0, 10])[0], - f2.freqresp([0.1, 1.0, 10])[0]) + f1.frequency_response([0.1, 1.0, 10])[0], + f2.frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - f1.freqresp([0.1, 1.0, 10])[1], - f2.freqresp([0.1, 1.0, 10])[1]) + f1.frequency_response([0.1, 1.0, 10])[1], + f2.frequency_response([0.1, 1.0, 10])[1]) @slycotonly def testMIMOMult(self): @@ -248,11 +248,11 @@ def testMIMOMult(self): f1 = FRD(sys, omega) f2 = FRD(sys, omega) np.testing.assert_array_almost_equal( - (f1*f2).freqresp([0.1, 1.0, 10])[0], - (sys*sys).freqresp([0.1, 1.0, 10])[0]) + (f1*f2).frequency_response([0.1, 1.0, 10])[0], + (sys*sys).frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - (f1*f2).freqresp([0.1, 1.0, 10])[1], - (sys*sys).freqresp([0.1, 1.0, 10])[1]) + (f1*f2).frequency_response([0.1, 1.0, 10])[1], + (sys*sys).frequency_response([0.1, 1.0, 10])[1]) @slycotonly def testMIMOSmooth(self): @@ -265,14 +265,14 @@ def testMIMOSmooth(self): f1 = FRD(sys, omega, smooth=True) f2 = FRD(sys2, omega, smooth=True) np.testing.assert_array_almost_equal( - (f1*f2).freqresp([0.1, 1.0, 10])[0], - (sys*sys2).freqresp([0.1, 1.0, 10])[0]) + (f1*f2).frequency_response([0.1, 1.0, 10])[0], + (sys*sys2).frequency_response([0.1, 1.0, 10])[0]) np.testing.assert_array_almost_equal( - (f1*f2).freqresp([0.1, 1.0, 10])[1], - (sys*sys2).freqresp([0.1, 1.0, 10])[1]) + (f1*f2).frequency_response([0.1, 1.0, 10])[1], + (sys*sys2).frequency_response([0.1, 1.0, 10])[1]) np.testing.assert_array_almost_equal( - (f1*f2).freqresp([0.1, 1.0, 10])[2], - (sys*sys2).freqresp([0.1, 1.0, 10])[2]) + (f1*f2).frequency_response([0.1, 1.0, 10])[2], + (sys*sys2).frequency_response([0.1, 1.0, 10])[2]) def testAgainstOctave(self): # with data from octave: @@ -285,8 +285,8 @@ def testAgainstOctave(self): omega = np.logspace(-1, 2, 10) f1 = FRD(sys, omega) np.testing.assert_array_almost_equal( - (f1.freqresp([1.0])[0] * - np.exp(1j * f1.freqresp([1.0])[1])).reshape(3, 2), + (f1.frequency_response([1.0])[0] * + np.exp(1j * f1.frequency_response([1.0])[1])).reshape(3, 2), np.array([[0.4 - 0.2j, 0], [0, 0.1 - 0.2j], [0, 0.3 - 0.1j]])) def test_string_representation(self, capsys): @@ -407,13 +407,9 @@ def test_eval(self): with pytest.raises(ValueError): frd_tf.eval(2) - - def test_evalfr_deprecated(self): - sys_tf = ct.tf([1], [1, 2, 1]) - frd_tf = FRD(sys_tf, np.logspace(-1, 1, 3)) - - with pytest.deprecated_call(): - frd_tf.evalfr(1.) + # Should get an error if we use __call__ at real-valued frequency + with pytest.raises(ValueError): + frd_tf(2) def test_repr_str(self): # repr printing diff --git a/control/tests/freqresp_test.py b/control/tests/freqresp_test.py index 111d4296c..e6a6934e8 100644 --- a/control/tests/freqresp_test.py +++ b/control/tests/freqresp_test.py @@ -18,7 +18,6 @@ from control.matlab import ss, tf, bode, rss from control.tests.conftest import slycotonly - pytestmark = pytest.mark.usefixtures("mplcleanup") @@ -32,7 +31,7 @@ def test_siso(): omega = np.linspace(10e-2, 10e2, 1000) # test frequency response - sys.freqresp(omega) + sys.frequency_response(omega) # test bode plot bode(sys) @@ -97,7 +96,7 @@ def test_superimpose(): def test_doubleint(): """Test typcast bug with double int - 30 May 2016, RMM: added to replicate typecast bug in freqresp.py + 30 May 2016, RMM: added to replicate typecast bug in frequency_response.py """ A = np.array([[0, 1], [0, 0]]) B = np.array([[0], [1]]) @@ -117,7 +116,7 @@ def test_mimo(): omega = np.linspace(10e-2, 10e2, 1000) sysMIMO = ss(A, B, C, D) - sysMIMO.freqresp(omega) + sysMIMO.frequency_response(omega) tf(sysMIMO) @@ -215,13 +214,13 @@ def test_discrete(dsystem_type): omega_ok = np.linspace(10e-4, 0.99, 100) * np.pi / dsys.dt # Test frequency response - dsys.freqresp(omega_ok) + dsys.frequency_response(omega_ok) # Check for warning if frequency is out of range with pytest.warns(UserWarning, match="above.*Nyquist"): # Look for a warning about sampling above Nyquist frequency omega_bad = np.linspace(10e-4, 1.1, 10) * np.pi / dsys.dt - dsys.freqresp(omega_bad) + dsys.frequency_response(omega_bad) # Test bode plots (currently only implemented for SISO) if (dsys.inputs == 1 and dsys.outputs == 1): @@ -332,6 +331,7 @@ def test_initial_phase(TF, initial_phase, default_phase, expected_phase): pytest.param(ctrl.tf([1], [1, 0, 0, 0, 0, 0]), -270, -3*math.pi/2, math.pi/2, id="order5, -270"), ]) + def test_phase_wrap(TF, wrap_phase, min_phase, max_phase): mag, phase, omega = ctrl.bode(TF, wrap_phase=wrap_phase) assert(min(phase) >= min_phase) diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index 4a8e09930..0dcbf3325 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -1132,8 +1132,8 @@ def test_lineariosys_statespace(self, tsys): np.testing.assert_array_equal( iosys_siso.pole(), tsys.siso_linsys.pole()) omega = np.logspace(.1, 10, 100) - mag_io, phase_io, omega_io = iosys_siso.freqresp(omega) - mag_ss, phase_ss, omega_ss = tsys.siso_linsys.freqresp(omega) + mag_io, phase_io, omega_io = iosys_siso.frequency_response(omega) + mag_ss, phase_ss, omega_ss = tsys.siso_linsys.frequency_response(omega) np.testing.assert_array_equal(mag_io, mag_ss) np.testing.assert_array_equal(phase_io, phase_ss) np.testing.assert_array_equal(omega_io, omega_ss) diff --git a/control/tests/margin_test.py b/control/tests/margin_test.py index 80916da1b..81a4b68b4 100644 --- a/control/tests/margin_test.py +++ b/control/tests/margin_test.py @@ -93,7 +93,7 @@ def test_stability_margins_3input(tsys): sys, refout, refoutall = tsys """Test stability_margins() function with mag, phase, omega input""" omega = np.logspace(-2, 2, 2000) - mag, phase, omega_ = sys.freqresp(omega) + mag, phase, omega_ = sys.frequency_response(omega) out = stability_margins((mag, phase*180/np.pi, omega_)) assert_allclose(out, refout, atol=1.5e-3) @@ -109,7 +109,7 @@ def test_margin_3input(tsys): sys, refout, refoutall = tsys """Test margin() function with mag, phase, omega input""" omega = np.logspace(-2, 2, 2000) - mag, phase, omega_ = sys.freqresp(omega) + mag, phase, omega_ = sys.frequency_response(omega) out = margin((mag, phase*180/np.pi, omega_)) assert_allclose(out, np.array(refout)[[0, 1, 3, 4]], atol=1.5e-3) @@ -136,8 +136,10 @@ def test_phase_crossover_frequencies_mimo(): [[3], [4]]], [[[1, 2, 3, 4], [1, 1]], [[1, 1], [1, 1]]]) - with pytest.raises(ControlMIMONotImplemented): - omega, gain = phase_crossover_frequencies(tf) + + omega, gain = phase_crossover_frequencies(tf[0,0]) + assert_allclose(omega, [1.73205, 0.], atol=1.5e-3) + assert_allclose(gain, [-0.5, 0.25], atol=1.5e-3) def test_mag_phase_omega(): @@ -145,7 +147,7 @@ def test_mag_phase_omega(): sys = TransferFunction(15, [1, 6, 11, 6]) out = stability_margins(sys) omega = np.logspace(-2, 2, 1000) - mag, phase, omega = sys.freqresp(omega) + mag, phase, omega = sys.frequency_response(omega) out2 = stability_margins((mag, phase*180/np.pi, omega)) ind = [0, 1, 3, 4] # indices of gm, pm, wg, wp -- ignore sm marg1 = np.array(out)[ind] diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index 02fac1776..7bf90f4c6 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -341,18 +341,14 @@ def test_evalfr(self, dt, omega, resp): else: s = omega * 1j - # Correct version of the call + # Correct versions of the call np.testing.assert_allclose(evalfr(sys, s), resp, atol=1e-3) - # Deprecated version of the call (should generate warning) - with pytest.deprecated_call(): + np.testing.assert_allclose(sys(s), resp, atol=1e-3) + + # Deprecated version of the call (should generate error) + with pytest.raises(AttributeError): np.testing.assert_allclose(sys.evalfr(omega), resp, atol=1e-3) - # call above nyquist frequency - if dt: - with pytest.warns(UserWarning): - np.testing.assert_allclose(sys._evalfr(omega + 2 * np.pi / dt), - resp, - atol=1e-3) @slycotonly def test_freq_resp(self): @@ -374,7 +370,7 @@ def test_freq_resp(self): [-0.438157380501337, -1.40720969147217]]] true_omega = [0.1, 10.] - mag, phase, omega = sys.freqresp(true_omega) + mag, phase, omega = sys.frequency_response(true_omega) np.testing.assert_almost_equal(mag, true_mag) np.testing.assert_almost_equal(phase, true_phase) @@ -739,9 +735,9 @@ def test_horner(self, sys322): sys322.horner(1. + 1.j) # Make sure result agrees with frequency response - mag, phase, omega = sys322.freqresp([1]) + mag, phase, omega = sys322.frequency_response([1]) np.testing.assert_array_almost_equal( - sys322.horner(1.j), + np.squeeze(sys322.horner(1.j)), mag[:, :, 0] * np.exp(1.j * phase[:, :, 0])) class TestRss: @@ -902,7 +898,6 @@ def test_statespace_defaults(self, matarrayout): refkey_n = {None: 'p3', '.3g': 'p3', '.5g': 'p5'} refkey_r = {None: 'p', 'partitioned': 'p', 'separate': 's'} - @pytest.mark.parametrize(" gmats, ref", [(LTX_G1, LTX_G1_REF), (LTX_G2, LTX_G2_REF)]) @@ -928,3 +923,27 @@ def test_latex_repr(gmats, ref, repr_type, num_format, editsdefaults): refkey = "{}_{}".format(refkey_n[num_format], refkey_r[repr_type]) assert g._repr_latex_() == ref[refkey] + def test_pole_static(self): + """Regression: pole() of static gain is empty array.""" + np.testing.assert_array_equal(np.array([]), + StateSpace([], [], [], [[1]]).pole()) + + def test_copy_constructor(self): + # Create a set of matrices for a simple linear system + A = np.array([[-1]]) + B = np.array([[1]]) + C = np.array([[1]]) + D = np.array([[0]]) + + # Create the first linear system and a copy + linsys = StateSpace(A, B, C, D) + cpysys = StateSpace(linsys) + + # Change the original A matrix + A[0, 0] = -2 + np.testing.assert_array_equal(linsys.A, [[-1]]) # original value + np.testing.assert_array_equal(cpysys.A, [[-1]]) # original value + + # Change the A matrix for the original system + linsys.A[0, 0] = -3 + np.testing.assert_array_equal(cpysys.A, [[-1]]) # original value diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py index c0b5e227f..1ec596a6e 100644 --- a/control/tests/xferfcn_test.py +++ b/control/tests/xferfcn_test.py @@ -404,34 +404,6 @@ def test_slice(self): assert (sys1.inputs, sys1.outputs) == (2, 1) assert sys1.dt == 0.5 - @pytest.mark.parametrize("omega, resp", - [(1, np.array([[-0.5 - 0.5j]])), - (32, np.array([[0.002819593 - 0.03062847j]]))]) - @pytest.mark.parametrize("dt", [None, 0, 1e-3]) - def test_evalfr_siso(self, dt, omega, resp): - """Evaluate the frequency response at single frequencies""" - sys = TransferFunction([1., 3., 5], [1., 6., 2., -1]) - - if dt: - sys = sample_system(sys, dt) - s = np.exp(omega * 1j * dt) - else: - s = omega * 1j - - # Correct versions of the call - np.testing.assert_allclose(evalfr(sys, s), resp, atol=1e-3) - np.testing.assert_allclose(sys(s), resp, atol=1e-3) - # Deprecated version of the call (should generate warning) - with pytest.deprecated_call(): - np.testing.assert_allclose(sys.evalfr(omega), resp, atol=1e-3) - - # call above nyquist frequency - if dt: - with pytest.warns(UserWarning): - np.testing.assert_allclose(sys._evalfr(omega + 2 * np.pi / dt), - resp, - atol=1e-3) - def test_is_static_gain(self): numstatic = 1.1 denstatic = 1.2 @@ -454,10 +426,37 @@ def test_is_static_gain(self): assert not TransferFunction(numdynamicmimo, denstaticmimo).is_static_gain() + @pytest.mark.parametrize("omega, resp", + [(1, np.array([[-0.5 - 0.5j]])), + (32, np.array([[0.002819593 - 0.03062847j]]))]) + @pytest.mark.parametrize("dt", [None, 0, 1e-3]) + def test_call_siso(self, dt, omega, resp): + """Evaluate the frequency response of a SISO system at one frequency.""" + sys = TransferFunction([1., 3., 5], [1., 6., 2., -1]) + + if dt: + sys = sample_system(sys, dt) + s = np.exp(omega * 1j * dt) + else: + s = omega * 1j + + # Correct versions of the call + np.testing.assert_allclose(evalfr(sys, s), resp, atol=1e-3) + np.testing.assert_allclose(sys(s), resp, atol=1e-3) + # Deprecated version of the call (should generate exception) + with pytest.raises(AttributeError): + 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) @slycotonly - def test_evalfr_mimo(self): - """Evaluate the frequency response of a MIMO system at a freq""" + def test_call_mimo(self): + """Evaluate the frequency response of a MIMO system at one frequency.""" + num = [[[1., 2.], [0., 3.], [2., -1.]], [[1.], [4., 0.], [1., -4., 3.]]] den = [[[-3., 2., 4.], [1., 0., 0.], [2., -1.]], @@ -467,13 +466,28 @@ def test_evalfr_mimo(self): [-0.083333333333333, -0.188235294117647 - 0.847058823529412j, -1. - 8.j]] - np.testing.assert_array_almost_equal(sys._evalfr(2.), resp) + np.testing.assert_array_almost_equal(evalfr(sys, 2j), resp) # Test call version as well np.testing.assert_array_almost_equal(sys(2.j), resp) - def test_freqresp_siso(self): - """Evaluate the SISO magnitude and phase at multiple frequencies""" + def test_freqresp_deprecated(self): + sys = TransferFunction([1., 3., 5], [1., 6., 2., -1.]) + # Deprecated version of the call (should generate warning) + import warnings + with warnings.catch_warnings(record=True) as w: + # Set up warnings filter to only show warnings in control module + warnings.filterwarnings("ignore") + warnings.filterwarnings("always", module="control") + + # Make sure that we get a pending deprecation warning + sys.freqresp(1.) + assert issubclass(w[-1].category, DeprecationWarning) + + def test_frequency_response_siso(self): + """Evaluate the magnitude and phase of a SISO system at + multiple frequencies.""" + sys = TransferFunction([1., 3., 5], [1., 6., 2., -1]) truemag = [[[4.63507337473906, 0.707106781186548, 0.0866592803995351]]] @@ -481,7 +495,7 @@ def test_freqresp_siso(self): -1.32655885133871]]] trueomega = [0.1, 1., 10.] - mag, phase, omega = sys.freqresp(trueomega) + mag, phase, omega = sys.frequency_response(trueomega, squeeze=False) np.testing.assert_array_almost_equal(mag, truemag) np.testing.assert_array_almost_equal(phase, truephase) @@ -509,7 +523,7 @@ def test_freqresp_mimo(self): [-1.66852323, -1.89254688, -1.62050658], [-0.13298964, -1.10714871, -2.75046720]]] - mag, phase, omega = sys.freqresp(true_omega) + mag, phase, omega = sys.frequency_response(true_omega) np.testing.assert_array_almost_equal(mag, true_mag) np.testing.assert_array_almost_equal(phase, true_phase) diff --git a/control/xferfcn.py b/control/xferfcn.py index 9a70e36b6..a0941b74a 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -231,19 +231,70 @@ def __init__(self, *args, **kwargs): dt = config.defaults['control.default_dt'] self.dt = dt - def __call__(self, s): - """Evaluate the system's transfer function for a complex variable + def __call__(self, x, squeeze=True): + """Evaluate system's transfer function at complex frequencies. - For a SISO transfer function, returns the value of the - transfer function. For a MIMO transfer fuction, returns a - matrix of values evaluated at complex variable s.""" + Returns the complex frequency response `sys(x)` where `x` is `s` for + continuous-time systems and `z` for discrete-time systems. - if self.issiso(): - # return a scalar - return self.horner(s)[0][0] + To evaluate at a frequency omega in radians per second, enter + ``x = omega * 1j``, for continuous-time systems, or + ``x = exp(1j * omega * dt)`` for discrete-time systems. Or use + :meth:`TransferFunction.frequency_response`. + + Parameters + ---------- + x: complex array_like or complex + Complex frequencies + squeeze: bool, optional (default=True) + If True and `sys` is single input single output (SISO), returns a + 1D array or scalar depending on the length of `x`. + + Returns + ------- + fresp : (self.outputs, self.inputs, len(x)) or len(x) complex ndarray + The frequency response of the system. Array is `len(x)` if and + only if system is SISO and ``squeeze=True``. + + """ + out = self.horner(x) + if not hasattr(x, '__len__'): + # received a scalar x, squeeze down the array along last dim + out = np.squeeze(out, axis=2) + if squeeze and self.issiso(): + # return a scalar/1d array of outputs + return out[0][0] else: - # return a matrix - return self.horner(s) + return out + + def horner(self, x): + """Evaluate system's transfer function at complex frequency + using Horner's method. + + Evaluates `sys(x)` where `x` is `s` for continuous-time systems and `z` + for discrete-time systems. + + Expects inputs and outputs to be formatted correctly. Use ``sys(x)`` + for a more user-friendly interface. + + Parameters + ---------- + x : complex array_like or complex + Complex frequencies + + Returns + ------- + output : (self.outputs, self.inputs, len(x)) complex ndarray + Frequency response + + """ + x_arr = np.atleast_1d(x) # force to be an array + out = empty((self.outputs, self.inputs, len(x_arr)), dtype=complex) + for i in range(self.outputs): + for j in range(self.inputs): + out[i][j] = (polyval(self.num[i][j], x) / + polyval(self.den[i][j], x)) + return out def _truncatecoeff(self): """Remove extraneous zero coefficients from num and den. @@ -607,103 +658,19 @@ def __getitem__(self, key): else: return TransferFunction(num, den, self.dt) - def evalfr(self, omega): - """Evaluate a transfer function at a single angular frequency. - - self._evalfr(omega) returns the value of the transfer function - matrix with input value s = i * omega. - - """ - warn("TransferFunction.evalfr(omega) will be deprecated in a " - "future release of python-control; use evalfr(sys, omega*1j) " - "instead", PendingDeprecationWarning) - return self._evalfr(omega) - - def _evalfr(self, omega): - """Evaluate a transfer function at a single angular frequency.""" - # TODO: implement for discrete time systems - if isdtime(self, strict=True): - # Convert the frequency to discrete time - s = exp(1.j * omega * self.dt) - if np.any(omega * self.dt > pi): - warn("_evalfr: frequency evaluation above Nyquist frequency") - else: - s = 1.j * omega - - return self.horner(s) - - def horner(self, s): - """Evaluate the systems's transfer function for a complex variable - - Returns a matrix of values evaluated at complex variable s. - """ - - # Preallocate the output. - if getattr(s, '__iter__', False): - out = empty((self.outputs, self.inputs, len(s)), dtype=complex) - else: - out = empty((self.outputs, self.inputs), dtype=complex) - - for i in range(self.outputs): - for j in range(self.inputs): - out[i][j] = (polyval(self.num[i][j], s) / - polyval(self.den[i][j], s)) - - return out - def freqresp(self, omega): - """Evaluate the transfer function at a list of angular frequencies. - - Reports the frequency response of the system, + """(deprecated) Evaluate transfer function at complex frequencies. - G(j*omega) = mag*exp(j*phase) - - for continuous time. For discrete time systems, the response is - evaluated around the unit circle such that - - G(exp(j*omega*dt)) = mag*exp(j*phase). - - Parameters - ---------- - omega : array_like - A list of frequencies in radians/sec at which the system should be - evaluated. The list can be either a python list or a numpy array - and will be sorted before evaluation. - - Returns - ------- - mag : (self.outputs, self.inputs, len(omega)) ndarray - The magnitude (absolute value, not dB or log10) of the system - frequency response. - phase : (self.outputs, self.inputs, len(omega)) ndarray - The wrapped phase in radians of the system frequency response. - omega : ndarray or list or tuple - The list of sorted frequencies at which the response was - evaluated. + .. deprecated::0.9.0 + Method has been given the more pythonic name + :meth:`TransferFunction.frequency_response`. Or use + :func:`freqresp` in the MATLAB compatibility module. """ - # Preallocate outputs. - numfreq = len(omega) - mag = empty((self.outputs, self.inputs, numfreq)) - phase = empty((self.outputs, self.inputs, numfreq)) - - # Figure out the frequencies - omega.sort() - if isdtime(self, strict=True): - slist = np.array([exp(1.j * w * self.dt) for w in omega]) - if max(omega) * self.dt > pi: - warn("freqresp: frequency evaluation above Nyquist frequency") - else: - slist = np.array([1j * w for w in omega]) - - # Compute frequency response for each input/output pair - for i in range(self.outputs): - for j in range(self.inputs): - fresp = (polyval(self.num[i][j], slist) / - polyval(self.den[i][j], slist)) - mag[i, j, :] = abs(fresp) - phase[i, j, :] = angle(fresp) - - return mag, phase, omega + warn("TransferFunction.freqresp(omega) will be removed in a " + "future release of python-control; use " + "sys.frequency_response(omega), or freqresp(sys, omega) in the " + "MATLAB compatibility module instead", DeprecationWarning) + return self.frequency_response(omega) def pole(self): """Compute the poles of a transfer function.""" diff --git a/examples/robust_mimo.py b/examples/robust_mimo.py index d4e1335e6..9cc5a1c3b 100644 --- a/examples/robust_mimo.py +++ b/examples/robust_mimo.py @@ -43,7 +43,7 @@ def triv_sigma(g, w): g - LTI object, order n w - frequencies, length m s - (m,n) array of singular values of g(1j*w)""" - m, p, _ = g.freqresp(w) + m, p, _ = g.frequency_response(w) sjw = (m*np.exp(1j*p)).transpose(2, 0, 1) sv = np.linalg.svd(sjw, compute_uv=False) return sv diff --git a/examples/robust_siso.py b/examples/robust_siso.py index 87fcdb707..17ce10927 100644 --- a/examples/robust_siso.py +++ b/examples/robust_siso.py @@ -50,10 +50,10 @@ # frequency response omega = np.logspace(-2, 2, 101) -ws1mag, _, _ = ws1.freqresp(omega) -s1mag, _, _ = s1.freqresp(omega) -ws2mag, _, _ = ws2.freqresp(omega) -s2mag, _, _ = s2.freqresp(omega) +ws1mag, _, _ = ws1.frequency_response(omega) +s1mag, _, _ = s1.frequency_response(omega) +ws2mag, _, _ = ws2.frequency_response(omega) +s2mag, _, _ = s2.frequency_response(omega) plt.figure(1) # text uses log-scaled absolute, but dB are probably more familiar to most control engineers From bddc7926f752a9e64b0e0beb939fde515abdf847 Mon Sep 17 00:00:00 2001 From: bnavigator Date: Wed, 6 Jan 2021 23:16:38 +0100 Subject: [PATCH 22/32] fix merge strategy 'errors' --- control/frdata.py | 26 +++++--------------------- control/lti.py | 1 - 2 files changed, 5 insertions(+), 22 deletions(-) diff --git a/control/frdata.py b/control/frdata.py index 168163d28..32d3d2c00 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -348,36 +348,21 @@ def eval(self, omega, squeeze=True): Note that a "normal" FRD only returns values for which there is an entry in the omega vector. An interpolating FRD can return - intermediate values. - - Parameters - ---------- - omega: float or array_like - Frequencies in radians per second - squeeze: bool, optional (default=True) - If True and `sys` is single input single output (SISO), returns a - 1D array or scalar depending on the length of `omega` - - Returns - ------- - fresp : (self.outputs, self.inputs, len(x)) or len(x) complex ndarray - The frequency response of the system. Array is ``len(x)`` if and only - if system is SISO and ``squeeze=True``. + intermediate values. Parameters ---------- - omega: float or array_like + omega : float or array_like Frequencies in radians per second - squeeze: bool, optional (default=True) + squeeze : bool, optional (default=True) If True and `sys` is single input single output (SISO), returns a 1D array or scalar depending on the length of `omega` Returns ------- fresp : (self.outputs, self.inputs, len(x)) or len(x) complex ndarray - The frequency response of the system. Array is ``len(x)`` if and - only if system is SISO and ``squeeze=True``. - + The frequency response of the system. Array is ``len(x)`` if and only + if system is SISO and ``squeeze=True``. """ omega_array = np.array(omega, ndmin=1) # array-like version of omega if any(omega_array.imag > 0): @@ -434,7 +419,6 @@ def __call__(self, s, squeeze=True): If `s` is not purely imaginary, because :class:`FrequencyDomainData` systems are only defined at imaginary frequency values. - """ if any(abs(np.array(s, ndmin=1).real) > 0): raise ValueError("__call__: FRD systems can only accept" diff --git a/control/lti.py b/control/lti.py index 70fbcdc7f..eff14be2a 100644 --- a/control/lti.py +++ b/control/lti.py @@ -159,7 +159,6 @@ def dcgain(self): """Return the zero-frequency gain""" raise NotImplementedError("dcgain not implemented for %s objects" % str(self.__class__)) - # Test to see if a system is SISO From e9bff6a4071012f208f1577336450790978e584c Mon Sep 17 00:00:00 2001 From: bnavigator Date: Wed, 6 Jan 2021 23:29:02 +0100 Subject: [PATCH 23/32] restore test_phase_crossover_frequencies_mimo --- control/tests/margin_test.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/control/tests/margin_test.py b/control/tests/margin_test.py index 81a4b68b4..4965bbe89 100644 --- a/control/tests/margin_test.py +++ b/control/tests/margin_test.py @@ -136,10 +136,8 @@ def test_phase_crossover_frequencies_mimo(): [[3], [4]]], [[[1, 2, 3, 4], [1, 1]], [[1, 1], [1, 1]]]) - - omega, gain = phase_crossover_frequencies(tf[0,0]) - assert_allclose(omega, [1.73205, 0.], atol=1.5e-3) - assert_allclose(gain, [-0.5, 0.25], atol=1.5e-3) + with pytest.raises(ControlMIMONotImplemented): + omega, gain = phase_crossover_frequencies(tf) def test_mag_phase_omega(): From 73fc6a6056aba8051959df9705cd3f7f88844e13 Mon Sep 17 00:00:00 2001 From: bnavigator Date: Wed, 6 Jan 2021 23:43:04 +0100 Subject: [PATCH 24/32] reinstate second check in frd_test test_eval() --- control/tests/frd_test.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/control/tests/frd_test.py b/control/tests/frd_test.py index 5343112fe..581ee9b25 100644 --- a/control/tests/frd_test.py +++ b/control/tests/frd_test.py @@ -401,7 +401,8 @@ def test_operator_conversion(self): def test_eval(self): sys_tf = ct.tf([1], [1, 2, 1]) frd_tf = FRD(sys_tf, np.logspace(-1, 1, 3)) - np.testing.assert_almost_equal(evalfr(sys_tf, 1J), frd_tf.eval(1)) + np.testing.assert_almost_equal(sys_tf(1j), frd_tf.eval(1)) + np.testing.assert_almost_equal(sys_tf(1j), frd_tf(1j)) # Should get an error if we evaluate at an unknown frequency with pytest.raises(ValueError): From 7acfc779f10806610db0680c3e305ab9e26beb08 Mon Sep 17 00:00:00 2001 From: bnavigator Date: Thu, 7 Jan 2021 00:01:35 +0100 Subject: [PATCH 25/32] statesp_test: rename from test_evalfr to test_call --- control/tests/statesp_test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index 7bf90f4c6..041792377 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -327,7 +327,7 @@ def test_multiply_ss(self, sys222, sys322): [-3.00137118e-01+3.42881660e-03j, 6.32015038e-04-1.21462255e-02j]]))]) @pytest.mark.parametrize("dt", [None, 0, 1e-3]) - def test_evalfr(self, dt, omega, resp): + def test_call(self, dt, omega, resp): """Evaluate the frequency response at single frequencies""" A = [[-2, 0.5], [0.5, -0.3]] B = [[0.3, -1.3], [0.1, 0.]] @@ -345,9 +345,9 @@ def test_evalfr(self, dt, omega, resp): np.testing.assert_allclose(evalfr(sys, s), resp, atol=1e-3) np.testing.assert_allclose(sys(s), resp, atol=1e-3) - # Deprecated version of the call (should generate error) + # Deprecated name of the call (should generate error) with pytest.raises(AttributeError): - np.testing.assert_allclose(sys.evalfr(omega), resp, atol=1e-3) + sys.evalfr(omega) @slycotonly From a26520d6b1d124b5f13c6083082b3fde5971099c Mon Sep 17 00:00:00 2001 From: bnavigator Date: Thu, 7 Jan 2021 00:02:08 +0100 Subject: [PATCH 26/32] statesp_test: remove duplicate test_pole_static and test_copy_constructor --- control/tests/statesp_test.py | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index 041792377..27c37e247 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -922,28 +922,3 @@ def test_latex_repr(gmats, ref, repr_type, num_format, editsdefaults): g = StateSpace(*gmats) refkey = "{}_{}".format(refkey_n[num_format], refkey_r[repr_type]) assert g._repr_latex_() == ref[refkey] - - def test_pole_static(self): - """Regression: pole() of static gain is empty array.""" - np.testing.assert_array_equal(np.array([]), - StateSpace([], [], [], [[1]]).pole()) - - def test_copy_constructor(self): - # Create a set of matrices for a simple linear system - A = np.array([[-1]]) - B = np.array([[1]]) - C = np.array([[1]]) - D = np.array([[0]]) - - # Create the first linear system and a copy - linsys = StateSpace(A, B, C, D) - cpysys = StateSpace(linsys) - - # Change the original A matrix - A[0, 0] = -2 - np.testing.assert_array_equal(linsys.A, [[-1]]) # original value - np.testing.assert_array_equal(cpysys.A, [[-1]]) # original value - - # Change the A matrix for the original system - linsys.A[0, 0] = -3 - np.testing.assert_array_equal(cpysys.A, [[-1]]) # original value From dae8d4292bab4a9b12a6433bfd5ea4f48eec4d18 Mon Sep 17 00:00:00 2001 From: sawyerbfuller <58706249+sawyerbfuller@users.noreply.github.com> Date: Wed, 6 Jan 2021 15:17:26 -0800 Subject: [PATCH 27/32] Update control/xferfcn.py Co-authored-by: Ben Greiner --- control/xferfcn.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control/xferfcn.py b/control/xferfcn.py index a0941b74a..4c0d26a07 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -279,7 +279,7 @@ def horner(self, x): Parameters ---------- - x : complex array_like or complex + x : complex array_like or complex scalar Complex frequencies Returns From 3b10af001bf07644062c0c2da0067bc501cd3eff Mon Sep 17 00:00:00 2001 From: bnavigator Date: Thu, 7 Jan 2021 00:27:32 +0100 Subject: [PATCH 28/32] check for warning the pytest way --- control/tests/xferfcn_test.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py index 1ec596a6e..eb8755f82 100644 --- a/control/tests/xferfcn_test.py +++ b/control/tests/xferfcn_test.py @@ -474,15 +474,8 @@ def test_call_mimo(self): def test_freqresp_deprecated(self): sys = TransferFunction([1., 3., 5], [1., 6., 2., -1.]) # Deprecated version of the call (should generate warning) - import warnings - with warnings.catch_warnings(record=True) as w: - # Set up warnings filter to only show warnings in control module - warnings.filterwarnings("ignore") - warnings.filterwarnings("always", module="control") - - # Make sure that we get a pending deprecation warning + with pytest.warns(DeprecationWarning): sys.freqresp(1.) - assert issubclass(w[-1].category, DeprecationWarning) def test_frequency_response_siso(self): """Evaluate the magnitude and phase of a SISO system at From 1bf445bbc885c3ccf1fb7c9ead3538262afc983a Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Wed, 6 Jan 2021 21:42:12 -0800 Subject: [PATCH 29/32] add unit tests for freqresp/evalfr warnings/errors --- control/frdata.py | 2 +- control/tests/frd_test.py | 8 ++++++-- control/tests/statesp_test.py | 6 ++++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/control/frdata.py b/control/frdata.py index 32d3d2c00..837bc1dac 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -421,7 +421,7 @@ def __call__(self, s, squeeze=True): frequency values. """ if any(abs(np.array(s, ndmin=1).real) > 0): - raise ValueError("__call__: FRD systems can only accept" + raise ValueError("__call__: FRD systems can only accept " "purely imaginary frequencies") # need to preserve array or scalar status if hasattr(s, '__len__'): diff --git a/control/tests/frd_test.py b/control/tests/frd_test.py index 581ee9b25..7418dddda 100644 --- a/control/tests/frd_test.py +++ b/control/tests/frd_test.py @@ -405,11 +405,15 @@ def test_eval(self): np.testing.assert_almost_equal(sys_tf(1j), frd_tf(1j)) # Should get an error if we evaluate at an unknown frequency - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="not .* in frequency list"): frd_tf.eval(2) + # Should get an error if we evaluate at an complex number + with pytest.raises(ValueError, match="can only accept real-valued"): + frd_tf.eval(2 + 1j) + # Should get an error if we use __call__ at real-valued frequency - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="only accept purely imaginary"): frd_tf(2) def test_repr_str(self): diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index 27c37e247..25cfe6f47 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -376,6 +376,12 @@ def test_freq_resp(self): np.testing.assert_almost_equal(phase, true_phase) np.testing.assert_equal(omega, true_omega) + # Deprecated version of the call (should return warning) + with pytest.warns(DeprecationWarning, match="will be removed"): + from control import freqresp + mag, phase, omega = sys.freqresp(true_omega) + np.testing.assert_almost_equal(mag, true_mag) + def test_is_static_gain(self): A0 = np.zeros((2,2)) A1 = A0.copy() From 3dbaacd7d9d03da4558ad1a8b88dcdcbe47e9261 Mon Sep 17 00:00:00 2001 From: bnavigator Date: Thu, 7 Jan 2021 13:03:57 +0100 Subject: [PATCH 30/32] test frd freqresp deprecation --- control/tests/frd_test.py | 6 ++++ control/tests/freqresp_test.py | 54 +++++++++++++++++++--------------- control/tests/statesp_test.py | 1 - 3 files changed, 37 insertions(+), 24 deletions(-) diff --git a/control/tests/frd_test.py b/control/tests/frd_test.py index 7418dddda..f8ee3eb20 100644 --- a/control/tests/frd_test.py +++ b/control/tests/frd_test.py @@ -416,6 +416,12 @@ def test_eval(self): with pytest.raises(ValueError, match="only accept purely imaginary"): frd_tf(2) + def test_freqresp_deprecated(self): + sys_tf = ct.tf([1], [1, 2, 1]) + frd_tf = FRD(sys_tf, np.logspace(-1, 1, 3)) + with pytest.warns(DeprecationWarning): + frd_tf.freqresp(1.) + def test_repr_str(self): # repr printing array = np.array diff --git a/control/tests/freqresp_test.py b/control/tests/freqresp_test.py index e6a6934e8..752dacb79 100644 --- a/control/tests/freqresp_test.py +++ b/control/tests/freqresp_test.py @@ -21,24 +21,46 @@ pytestmark = pytest.mark.usefixtures("mplcleanup") -def test_siso(): - """Test SISO frequency response""" +@pytest.fixture +def ss_siso(): A = np.array([[1, 1], [0, 1]]) B = np.array([[0], [1]]) C = np.array([[1, 0]]) D = 0 - sys = StateSpace(A, B, C, D) + return StateSpace(A, B, C, D) + + +@pytest.fixture +def ss_mimo(): + A = np.array([[1, 1], [0, 1]]) + B = np.array([[1, 0], [0, 1]]) + C = np.array([[1, 0]]) + D = np.array([[0, 0]]) + return StateSpace(A, B, C, D) + +def test_freqresp_siso(ss_siso): + """Test SISO frequency response""" omega = np.linspace(10e-2, 10e2, 1000) # test frequency response - sys.frequency_response(omega) + ctrl.freqresp(ss_siso, omega) - # test bode plot - bode(sys) - # Convert to transfer function and test bode - systf = tf(sys) - bode(systf) +@slycotonly +def test_freqresp_mimo(ss_mimo): + """Test MIMO frequency response calls""" + omega = np.linspace(10e-2, 10e2, 1000) + ctrl.freqresp(ss_mimo, omega) + tf_mimo = tf(ss_mimo) + ctrl.freqresp(tf_mimo, omega) + + +def test_bode_basic(ss_siso): + """Test bode plot call (Very basic)""" + # TODO: proper test + tf_siso = tf(ss_siso) + bode(ss_siso) + bode(tf_siso) @pytest.mark.filterwarnings("ignore:.*non-positive left xlim:UserWarning") @@ -106,20 +128,6 @@ def test_doubleint(): bode(sys) -@slycotonly -def test_mimo(): - """Test MIMO frequency response calls""" - A = np.array([[1, 1], [0, 1]]) - B = np.array([[1, 0], [0, 1]]) - C = np.array([[1, 0]]) - D = np.array([[0, 0]]) - omega = np.linspace(10e-2, 10e2, 1000) - sysMIMO = ss(A, B, C, D) - - sysMIMO.frequency_response(omega) - tf(sysMIMO) - - @pytest.mark.parametrize( "Hz, Wcp, Wcg", [pytest.param(False, 6.0782869, 10., id="omega"), diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index 25cfe6f47..48f27a3b5 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -378,7 +378,6 @@ def test_freq_resp(self): # Deprecated version of the call (should return warning) with pytest.warns(DeprecationWarning, match="will be removed"): - from control import freqresp mag, phase, omega = sys.freqresp(true_omega) np.testing.assert_almost_equal(mag, true_mag) From 178bfa0e6cc6bd24b6f2f6aff906f940ea3b9183 Mon Sep 17 00:00:00 2001 From: sawyerbfuller <58706249+sawyerbfuller@users.noreply.github.com> Date: Thu, 7 Jan 2021 11:40:01 -0800 Subject: [PATCH 31/32] doc updates to specify frequency response outputs more precisely Co-authored-by: Ben Greiner --- control/frdata.py | 27 ++++++++++--------- control/lti.py | 67 +++++++++++++++++++++++++++------------------- control/statesp.py | 14 ++++++---- 3 files changed, 64 insertions(+), 44 deletions(-) diff --git a/control/frdata.py b/control/frdata.py index 837bc1dac..22dbb298f 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -356,11 +356,11 @@ def eval(self, omega, squeeze=True): Frequencies in radians per second squeeze : bool, optional (default=True) If True and `sys` is single input single output (SISO), returns a - 1D array or scalar depending on the length of `omega` + 1D array rather than a 3D array. Returns ------- - fresp : (self.outputs, self.inputs, len(x)) or len(x) complex ndarray + fresp : (self.outputs, self.inputs, len(x)) or (len(x), ) complex ndarray The frequency response of the system. Array is ``len(x)`` if and only if system is SISO and ``squeeze=True``. """ @@ -393,25 +393,28 @@ def eval(self, omega, squeeze=True): def __call__(self, s, squeeze=True): """Evaluate system's transfer function at complex frequencies. - - Returns the complex frequency response `sys(s)`. + + Returns the complex frequency response `sys(s)` of system `sys` with + `m = sys.inputs` number of inputs and `p = sys.outputs` number of + outputs. To evaluate at a frequency omega in radians per second, enter - ``x = omega * 1j`` or use ``sys.eval(omega)`` + ``s = omega * 1j`` or use ``sys.eval(omega)`` Parameters ---------- - s: complex scalar or array_like + s : complex scalar or array_like Complex frequencies - squeeze: bool, optional (default=True) - If True and `sys` is single input single output (SISO), returns a - 1D array or scalar depending on the length of x`. + squeeze : bool, optional (default=True) + If True and `sys` is single input single output (SISO), i.e. `m=1`, + `p=1`, return a 1D array rather than a 3D array. Returns ------- - fresp : (self.outputs, self.inputs, len(s)) or len(s) complex ndarray - The frequency response of the system. Array is ``len(s)`` if and - only if system is SISO and ``squeeze=True``. + fresp : (p, m, len(s)) complex ndarray or (len(s),) complex ndarray + The frequency response of the system. Array is ``(len(s), )`` if + and only if system is SISO and ``squeeze=True``. + Raises ------ diff --git a/control/lti.py b/control/lti.py index eff14be2a..152c5c73b 100644 --- a/control/lti.py +++ b/control/lti.py @@ -124,25 +124,30 @@ def frequency_response(self, omega, squeeze=True): G(exp(j*omega*dt)) = mag*exp(j*phase). + In general the system may be multiple input, multiple output (MIMO), where + `m = self.inputs` number of inputs and `p = self.outputs` number of + outputs. + Parameters ---------- - omega : array_like or float + omega : float or array_like A list, tuple, array, or scalar value of frequencies in radians/sec at which the system will be evaluated. - squeeze: bool, optional (default=True) - If True and sys is single input, single output (SISO), return a - 1D array or scalar depending on omega's length. + squeeze : bool, optional (default=True) + If True and the system is single input single output (SISO), i.e. `m=1`, + `p=1`, return a 1D array rather than a 3D array. Returns ------- - mag : (self.outputs, self.inputs, len(omega)) or len(omega) ndarray + mag : (p, m, len(omega)) ndarray or (len(omega),) ndarray The magnitude (absolute value, not dB or log10) of the system - frequency response. - phase : (self.outputs, self.inputs, len(omega)) or len(omega) ndarray + frequency response. Array is ``(len(omega), )`` if + and only if system is SISO and ``squeeze=True``. + phase : (p, m, len(omega)) ndarray or (len(omega),) ndarray The wrapped phase in radians of the system frequency response. omega : ndarray The (sorted) frequencies at which the response was evaluated. - + """ omega = np.sort(np.array(omega, ndmin=1)) if isdtime(self, strict=True): @@ -463,7 +468,9 @@ def evalfr(sys, x, squeeze=True): Evaluate the transfer function of an LTI system for complex frequency x. Returns the complex frequency response `sys(x)` where `x` is `s` for - continuous-time systems and `z` for discrete-time systems. + continuous-time systems and `z` for discrete-time systems, with + `m = sys.inputs` number of inputs and `p = sys.outputs` number of + outputs. To evaluate at a frequency omega in radians per second, enter ``x = omega * 1j`` for continuous-time systems, or @@ -474,17 +481,17 @@ def evalfr(sys, x, squeeze=True): ---------- sys: StateSpace or TransferFunction Linear system - x: complex scalar or array_like + x : complex scalar or array_like Complex frequency(s) - squeeze: bool, optional (default=True) - If True and sys is single input single output (SISO), returns a - 1D array or scalar depending on the length of x. + squeeze : bool, optional (default=True) + If True and `sys` is single input single output (SISO), i.e. `m=1`, + `p=1`, return a 1D array rather than a 3D array. Returns ------- - fresp : (sys.outputs, sys.inputs, len(x)) or len(x) complex ndarray - The frequency response of the system. Array is len(x) if and only if - system is SISO and squeeze=True. + fresp : (p, m, len(x)) complex ndarray or (len(x),) complex ndarray + The frequency response of the system. Array is ``(len(x), )`` if + and only if system is SISO and ``squeeze=True``. See Also -------- @@ -493,8 +500,8 @@ def evalfr(sys, x, squeeze=True): Notes ----- - This function is a wrapper for StateSpace.__call__ and - TransferFunction.__call__. + This function is a wrapper for :meth:`StateSpace.__call__` and + :meth:`TransferFunction.__call__`. Examples -------- @@ -510,25 +517,31 @@ def evalfr(sys, x, squeeze=True): def freqresp(sys, omega, squeeze=True): """ Frequency response of an LTI system at multiple angular frequencies. + + In general the system may be multiple input, multiple output (MIMO), where + `m = sys.inputs` number of inputs and `p = sys.outputs` number of + outputs. Parameters ---------- sys: StateSpace or TransferFunction Linear system - omega: float or array_like + omega : float or array_like A list of frequencies in radians/sec at which the system should be evaluated. The list can be either a python list or a numpy array and will be sorted before evaluation. - squeeze: bool, optional (default=True) - If True and sys is single input, single output (SISO), returns - 1D array or scalar depending on omega's length. + squeeze : bool, optional (default=True) + If True and `sys` is single input, single output (SISO), returns + 1D array rather than a 3D array. Returns ------- - mag : (sys.outputs, sys.inputs, len(omega)) or len(omega) ndarray + mag : (p, m, len(omega)) ndarray or (len(omega),) ndarray The magnitude (absolute value, not dB or log10) of the system - frequency response. - phase : (sys.outputs, sys.inputs, len(omega)) or len(omega) ndarray + frequency response. Array is ``(len(omega), )`` if and only if system + is SISO and ``squeeze=True``. + + phase : (p, m, len(omega)) ndarray or (len(omega),) ndarray The wrapped phase in radians of the system frequency response. omega : ndarray The list of sorted frequencies at which the response was @@ -541,8 +554,8 @@ def freqresp(sys, omega, squeeze=True): Notes ----- - This function is a wrapper for StateSpace.frequency_response and - TransferFunction.frequency_response. + This function is a wrapper for :meth:`StateSpace.frequency_response` and + :meth:`TransferFunction.frequency_response`. Examples -------- diff --git a/control/statesp.py b/control/statesp.py index 8bf4c0d99..ffd229108 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -645,6 +645,10 @@ def __call__(self, x, squeeze=True): Returns the complex frequency response `sys(x)` where `x` is `s` for continuous-time systems and `z` for discrete-time systems. + + In general the system may be multiple input, multiple output (MIMO), where + `m = self.inputs` number of inputs and `p = self.outputs` number of + outputs. To evaluate at a frequency omega in radians per second, enter ``x = omega * 1j``, for continuous-time systems, or @@ -653,15 +657,15 @@ def __call__(self, x, squeeze=True): Parameters ---------- - x: complex or complex array_like + x : complex or complex array_like Complex frequencies - squeeze: bool, optional (default=True) - If True and `sys` is single input single output (SISO), returns a - 1D array or scalar depending on the length of `x`. + squeeze : bool, optional (default=True) + If True and `self` is single input single output (SISO), returns a + 1D array rather than a 3D array. Returns ------- - fresp : (self.outputs, self.inputs, len(x)) or len(x) complex ndarray + fresp : (p, m, len(x)) complex ndarray or (len(x),) complex ndarray The frequency response of the system. Array is ``len(x)`` if and only if system is SISO and ``squeeze=True``. From a6310988374348128f038420d480d593f7995765 Mon Sep 17 00:00:00 2001 From: sawyerbfuller <58706249+sawyerbfuller@users.noreply.github.com> Date: Thu, 7 Jan 2021 11:57:47 -0800 Subject: [PATCH 32/32] missed a couple of doc updates in xferfcn Co-authored-by: Ben Greiner --- control/xferfcn.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/control/xferfcn.py b/control/xferfcn.py index 4c0d26a07..fba674efe 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -237,6 +237,10 @@ def __call__(self, x, squeeze=True): Returns the complex frequency response `sys(x)` where `x` is `s` for continuous-time systems and `z` for discrete-time systems. + In general the system may be multiple input, multiple output (MIMO), where + `m = self.inputs` number of inputs and `p = self.outputs` number of + outputs. + To evaluate at a frequency omega in radians per second, enter ``x = omega * 1j``, for continuous-time systems, or ``x = exp(1j * omega * dt)`` for discrete-time systems. Or use @@ -244,15 +248,15 @@ def __call__(self, x, squeeze=True): Parameters ---------- - x: complex array_like or complex + x : complex array_like or complex Complex frequencies - squeeze: bool, optional (default=True) + squeeze : bool, optional (default=True) If True and `sys` is single input single output (SISO), returns a - 1D array or scalar depending on the length of `x`. + 1D array rather than a 3D array. Returns ------- - fresp : (self.outputs, self.inputs, len(x)) or len(x) complex ndarray + fresp : (p, m, len(x)) complex ndarray or or (len(x), ) complex ndarray The frequency response of the system. Array is `len(x)` if and only if system is SISO and ``squeeze=True``. pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

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:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy