From 848112d101d2b655e5549642473e6cec4029b155 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Tue, 12 Jan 2021 21:39:12 -0800 Subject: [PATCH 1/5] unify use of squeeze keyword in frequency response functions --- control/config.py | 3 ++- control/frdata.py | 26 +++++++++++++++++++------- control/lti.py | 39 +++++++++++++++++++++------------------ control/margins.py | 14 +++++++------- control/statesp.py | 13 +++++++++---- control/xferfcn.py | 11 ++++++++--- 6 files changed, 66 insertions(+), 40 deletions(-) diff --git a/control/config.py b/control/config.py index b4950ae5e..0d7c93ae9 100644 --- a/control/config.py +++ b/control/config.py @@ -15,7 +15,8 @@ # Package level default values _control_defaults = { - 'control.default_dt':0 + 'control.default_dt': 0, + 'control.squeeze': True } defaults = dict(_control_defaults) diff --git a/control/frdata.py b/control/frdata.py index 8f148a3fa..e6792a430 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -51,6 +51,7 @@ real, imag, absolute, eye, linalg, where, dot, sort from scipy.interpolate import splprep, splev from .lti import LTI +from . import config __all__ = ['FrequencyResponseData', 'FRD', 'frd'] @@ -343,7 +344,7 @@ def __pow__(self, other): # G(s) for a transfer function and G(omega) for an FRD object. # 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): + def eval(self, omega, squeeze=None): """Evaluate a transfer function at angular frequency omega. Note that a "normal" FRD only returns values for which there is an @@ -355,15 +356,21 @@ 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 rather than a 3D array. + If True and the system is single-input single-output (SISO), + return a 1D array rather than a 3D array. Default value (True) + set by config.defaults['control.squeeze']. 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``. + """ + # Set value of squeeze argument if not set + if squeeze is None: + squeeze = config.defaults['control.squeeze'] + 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") @@ -406,8 +413,9 @@ def __call__(self, s, squeeze=True): s : complex scalar or array_like Complex frequencies 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. + If True and the system is single-input single-output (SISO), + return a 1D array rather than a 3D array. Default value (True) + set by config.defaults['control.squeeze']. Returns ------- @@ -423,6 +431,10 @@ def __call__(self, s, squeeze=True): :class:`FrequencyDomainData` systems are only defined at imaginary frequency values. """ + # Set value of squeeze argument if not set + if squeeze is None: + squeeze = config.defaults['control.squeeze'] + if any(abs(np.array(s, ndmin=1).real) > 0): raise ValueError("__call__: FRD systems can only accept " "purely imaginary frequencies") diff --git a/control/lti.py b/control/lti.py index 152c5c73b..5e15150bf 100644 --- a/control/lti.py +++ b/control/lti.py @@ -111,7 +111,7 @@ def damp(self): Z = -real(splane_poles)/wn return wn, Z, poles - def frequency_response(self, omega, squeeze=True): + def frequency_response(self, omega, squeeze=None): """Evaluate the linear time-invariant system at an array of angular frequencies. @@ -124,18 +124,19 @@ 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. + 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 : 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 the system is single input single output (SISO), i.e. `m=1`, - `p=1`, return a 1D array rather than a 3D array. + squeeze : bool, optional + If True and the system is single-input single-output (SISO), + return a 1D array rather than a 3D array. Default value (True) + set by config.defaults['control.squeeze']. Returns ------- @@ -147,7 +148,7 @@ def frequency_response(self, omega, squeeze=True): 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,9 +464,8 @@ def damp(sys, doprint=True): (p.real, p.imag, d, w)) return wn, damping, poles -def evalfr(sys, x, squeeze=True): - """ - Evaluate the transfer function of an LTI system for complex frequency x. +def evalfr(sys, x, squeeze=None): + """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, with @@ -484,8 +484,9 @@ def evalfr(sys, x, squeeze=True): x : complex scalar or array_like Complex frequency(s) 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. + If True and the system is single-input single-output (SISO), return a + 1D array rather than a 3D array. Default value (True) set by + config.defaults['control.squeeze']. Returns ------- @@ -511,12 +512,12 @@ def evalfr(sys, x, squeeze=True): >>> # This is the transfer function matrix evaluated at s = i. .. todo:: Add example with MIMO system + """ return sys.__call__(x, squeeze=squeeze) -def freqresp(sys, omega, squeeze=True): - """ - Frequency response of an LTI system at multiple angular frequencies. +def freqresp(sys, omega, squeeze=None): + """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 @@ -531,8 +532,9 @@ def freqresp(sys, omega, squeeze=True): 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 rather than a 3D array. + If True and the system is single-input single-output (SISO), return a + 1D array rather than a 3D array. Default value (True) set by + config.defaults['control.squeeze']. Returns ------- @@ -579,6 +581,7 @@ def freqresp(sys, omega, squeeze=True): #>>> # input to the 1st output, and the phase (in radians) of the #>>> # frequency response from the 1st input to the 2nd output, for #>>> # s = 0.1i, i, 10i. + """ return sys.frequency_response(omega, squeeze=squeeze) diff --git a/control/margins.py b/control/margins.py index 20da2a879..0bbf2e30b 100644 --- a/control/margins.py +++ b/control/margins.py @@ -294,25 +294,25 @@ def stability_margins(sysdata, returnall=False, epsw=0.0): # frequency for gain margin: phase crosses -180 degrees w_180 = _poly_iw_real_crossing(num_iw, den_iw, epsw) with np.errstate(all='ignore'): # den=0 is okay - w180_resp = evalfr(sys, 1J * w_180) + w180_resp = evalfr(sys, 1J * w_180, squeeze=True) # frequency for phase margin : gain crosses magnitude 1 wc = _poly_iw_mag1_crossing(num_iw, den_iw, epsw) - wc_resp = evalfr(sys, 1J * wc) + wc_resp = evalfr(sys, 1J * wc, squeeze=True) # stability margin wstab = _poly_iw_wstab(num_iw, den_iw, epsw) - ws_resp = evalfr(sys, 1J * wstab) + ws_resp = evalfr(sys, 1J * wstab, squeeze=True) else: # Discrete Time zargs = _poly_z_invz(sys) # gain margin z, w_180 = _poly_z_real_crossing(*zargs, epsw=epsw) - w180_resp = evalfr(sys, z) + w180_resp = evalfr(sys, z, squeeze=True) # phase margin z, wc = _poly_z_mag1_crossing(*zargs, epsw=epsw) - wc_resp = evalfr(sys, z) + wc_resp = evalfr(sys, z, squeeze=True) # stability margin z, wstab = _poly_z_wstab(*zargs, epsw=epsw) @@ -437,11 +437,11 @@ def phase_crossover_frequencies(sys): omega = _poly_iw_real_crossing(num_iw, den_iw, 0.) # using real() to avoid rounding errors and results like 1+0j - gain = np.real(evalfr(sys, 1J * omega)) + gain = np.real(evalfr(sys, 1J * omega, squeeze=True)) else: zargs = _poly_z_invz(sys) z, omega = _poly_z_real_crossing(*zargs, epsw=0.) - gain = np.real(evalfr(sys, z)) + gain = np.real(evalfr(sys, z, squeeze=True)) return omega, gain diff --git a/control/statesp.py b/control/statesp.py index ff4c73c4e..798eeff06 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -640,7 +640,7 @@ def __rdiv__(self, other): raise NotImplementedError( "StateSpace.__rdiv__ is not implemented yet.") - def __call__(self, x, squeeze=True): + def __call__(self, x, squeeze=None): """Evaluate system's transfer function at complex frequency. Returns the complex frequency response `sys(x)` where `x` is `s` for @@ -659,9 +659,10 @@ def __call__(self, x, squeeze=True): ---------- x : complex or complex array_like Complex frequencies - squeeze : bool, optional (default=True) - If True and `self` is single input single output (SISO), returns a - 1D array rather than a 3D array. + squeeze : bool, optional + If True and the system is single-input single-output (SISO), + return a 1D array rather than a 3D array. Default value (True) + set by config.defaults['control.squeeze']. Returns ------- @@ -670,6 +671,10 @@ def __call__(self, x, squeeze=True): only if system is SISO and ``squeeze=True``. """ + # Set value of squeeze argument if not set + if squeeze is None: + squeeze = config.defaults['control.squeeze'] + # Use Slycot if available out = self.horner(x) if not hasattr(x, '__len__'): diff --git a/control/xferfcn.py b/control/xferfcn.py index 0ff21a42a..f4aa201d7 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -234,7 +234,7 @@ def __init__(self, *args, **kwargs): dt = config.defaults['control.default_dt'] self.dt = dt - def __call__(self, x, squeeze=True): + def __call__(self, x, squeeze=None): """Evaluate system's transfer function at complex frequencies. Returns the complex frequency response `sys(x)` where `x` is `s` for @@ -254,8 +254,9 @@ def __call__(self, x, squeeze=True): 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 rather than a 3D array. + If True and the system is single-input single-output (SISO), + return a 1D array rather than a 3D array. Default value (True) + set by config.defaults['control.squeeze']. Returns ------- @@ -264,6 +265,10 @@ def __call__(self, x, squeeze=True): only if system is SISO and ``squeeze=True``. """ + # Set value of squeeze argument if not set + if squeeze is None: + squeeze = config.defaults['control.squeeze'] + out = self.horner(x) if not hasattr(x, '__len__'): # received a scalar x, squeeze down the array along last dim From f367cf657c295e612c53e9208901ed7f4be5ee85 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sun, 17 Jan 2021 08:18:51 -0800 Subject: [PATCH 2/5] unify frequency response processing + unit tests --- control/frdata.py | 14 ++++-------- control/lti.py | 16 +++++++++++++ control/statesp.py | 20 ++++------------ control/tests/lti_test.py | 48 ++++++++++++++++++++++++++++++++++++++- control/xferfcn.py | 15 ++---------- 5 files changed, 75 insertions(+), 38 deletions(-) diff --git a/control/frdata.py b/control/frdata.py index e6792a430..1617daa0a 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -50,7 +50,7 @@ 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 +from .lti import LTI, _process_frequency_response from . import config __all__ = ['FrequencyResponseData', 'FRD', 'frd'] @@ -391,14 +391,10 @@ def eval(self, omega, squeeze=None): 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 - - def __call__(self, s, squeeze=True): + + return _process_frequency_response(self, omega, out, squeeze=squeeze) + + def __call__(self, s, squeeze=None): """Evaluate system's transfer function at complex frequencies. Returns the complex frequency response `sys(s)` of system `sys` with diff --git a/control/lti.py b/control/lti.py index 5e15150bf..d554a2c24 100644 --- a/control/lti.py +++ b/control/lti.py @@ -15,6 +15,7 @@ import numpy as np from numpy import absolute, real, angle, abs from warnings import warn +from . import config __all__ = ['issiso', 'timebase', 'common_timebase', 'timebaseEqual', 'isdtime', 'isctime', 'pole', 'zero', 'damp', 'evalfr', @@ -596,3 +597,18 @@ def dcgain(sys): at the origin """ return sys.dcgain() + + +# Process frequency responses in a uniform way +def _process_frequency_response(sys, omega, out, squeeze=None): + if not hasattr(omega, '__len__'): + # received a scalar x, squeeze down the array along last dim + out = np.squeeze(out, axis=2) + + # Get rid of unneeded dimensions + if squeeze is None: + squeeze = config.defaults['control.squeeze'] + if squeeze and sys.issiso(): + return out[0][0] + else: + return out diff --git a/control/statesp.py b/control/statesp.py index 798eeff06..e48052c04 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -62,7 +62,7 @@ from scipy.signal import cont2discrete from scipy.signal import StateSpace as signalStateSpace from warnings import warn -from .lti import LTI, common_timebase, isdtime +from .lti import LTI, common_timebase, isdtime, _process_frequency_response from . import config from copy import deepcopy @@ -646,9 +646,9 @@ def __call__(self, x, squeeze=None): 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. + 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 @@ -671,19 +671,9 @@ def __call__(self, x, squeeze=None): only if system is SISO and ``squeeze=True``. """ - # Set value of squeeze argument if not set - if squeeze is None: - squeeze = config.defaults['control.squeeze'] - # 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 + return _process_frequency_response(self, x, out, squeeze=squeeze) def slycot_laub(self, x): """Evaluate system's transfer function at complex frequency diff --git a/control/tests/lti_test.py b/control/tests/lti_test.py index ee9d95a09..511d976f5 100644 --- a/control/tests/lti_test.py +++ b/control/tests/lti_test.py @@ -2,12 +2,14 @@ import numpy as np import pytest +from .conftest import editsdefaults +import control as ct from control import c2d, tf, tf2ss, NonlinearIOSystem from control.lti import (LTI, common_timebase, damp, dcgain, isctime, isdtime, issiso, pole, timebaseEqual, zero) from control.tests.conftest import slycotonly - +from control.exception import slycot_check class TestLTI: @@ -153,3 +155,47 @@ def test_isdtime(self, objfun, arg, dt, ref, strictref): strictref = not strictref assert isctime(obj) == ref assert isctime(obj, strict=True) == strictref + + @pytest.mark.usefixtures("editsdefaults") + @pytest.mark.parametrize("fcn", [ct.ss, ct.tf, ct.frd]) + @pytest.mark.parametrize("nstate, nout, ninp, squeeze, shape", [ + [1, 1, 1, None, (8,)], # SISO + [2, 1, 1, True, (8,)], + [3, 1, 1, False, (1, 1, 8)], + [1, 2, 1, None, (2, 1, 8)], # SIMO + [2, 2, 1, True, (2, 1, 8)], + [3, 2, 1, False, (2, 1, 8)], + [1, 1, 2, None, (1, 2, 8)], # MISO + [2, 1, 2, True, (1, 2, 8)], + [3, 1, 2, False, (1, 2, 8)], + [1, 2, 2, None, (2, 2, 8)], # MIMO + [2, 2, 2, True, (2, 2, 8)], + [3, 2, 2, False, (2, 2, 8)] + ]) + def test_squeeze(self, fcn, nstate, nout, ninp, squeeze, shape): + # Compute the length of the frequency array + omega = np.logspace(-2, 2, 8) + + # Create the system to be tested + if fcn == ct.frd: + sys = fcn(ct.rss(nstate, nout, ninp), omega) + elif fcn == ct.tf and (nout > 1 or ninp > 1) and not slycot_check(): + pytest.skip("Conversion of MIMO systems to transfer functions " + "requires slycot.") + else: + sys = fcn(ct.rss(nstate, nout, ninp)) + + # Pass squeeze argument and make sure the shape is correct + mag, phase, _ = sys.frequency_response(omega, squeeze=squeeze) + assert mag.shape == shape + assert phase.shape == shape + assert sys(omega * 1j, squeeze=squeeze).shape == shape + assert ct.evalfr(sys, omega * 1j, squeeze=squeeze).shape == shape + + # Changing config.default to False should return 3D frequency response + ct.config.set_defaults('control', squeeze=False) + mag, phase, _ = sys.frequency_response(omega) + assert mag.shape == (sys.outputs, sys.inputs, 8) + assert phase.shape == (sys.outputs, sys.inputs, 8) + assert sys(omega * 1j).shape == (sys.outputs, sys.inputs, 8) + assert ct.evalfr(sys, omega * 1j).shape == (sys.outputs, sys.inputs, 8) diff --git a/control/xferfcn.py b/control/xferfcn.py index f4aa201d7..3dfdb86e7 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -63,7 +63,7 @@ from warnings import warn from itertools import chain from re import sub -from .lti import LTI, common_timebase, isdtime +from .lti import LTI, common_timebase, isdtime, _process_frequency_response from . import config __all__ = ['TransferFunction', 'tf', 'ss2tf', 'tfdata'] @@ -265,19 +265,8 @@ def __call__(self, x, squeeze=None): only if system is SISO and ``squeeze=True``. """ - # Set value of squeeze argument if not set - if squeeze is None: - squeeze = config.defaults['control.squeeze'] - 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 out + return _process_frequency_response(self, x, out, squeeze=squeeze) def horner(self, x): """Evaluate system's transfer function at complex frequency From 451d6d20f9d171893c9635c6d311c8b4f26e3051 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sun, 17 Jan 2021 08:28:56 -0800 Subject: [PATCH 3/5] change control.squeeze to control.squeeze_frequency_response --- control/config.py | 2 +- control/frdata.py | 8 ++++---- control/lti.py | 8 ++++---- control/statesp.py | 2 +- control/tests/lti_test.py | 4 ++-- control/xferfcn.py | 2 +- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/control/config.py b/control/config.py index 0d7c93ae9..026e76240 100644 --- a/control/config.py +++ b/control/config.py @@ -16,7 +16,7 @@ # Package level default values _control_defaults = { 'control.default_dt': 0, - 'control.squeeze': True + 'control.squeeze_frequency_response': True } defaults = dict(_control_defaults) diff --git a/control/frdata.py b/control/frdata.py index 1617daa0a..c2e2ccfa3 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -358,7 +358,7 @@ def eval(self, omega, squeeze=None): squeeze : bool, optional (default=True) If True and the system is single-input single-output (SISO), return a 1D array rather than a 3D array. Default value (True) - set by config.defaults['control.squeeze']. + set by config.defaults['control.squeeze_frequency_response']. Returns ------- @@ -369,7 +369,7 @@ def eval(self, omega, squeeze=None): """ # Set value of squeeze argument if not set if squeeze is None: - squeeze = config.defaults['control.squeeze'] + squeeze = config.defaults['control.squeeze_frequency_response'] omega_array = np.array(omega, ndmin=1) # array-like version of omega if any(omega_array.imag > 0): @@ -411,7 +411,7 @@ def __call__(self, s, squeeze=None): squeeze : bool, optional (default=True) If True and the system is single-input single-output (SISO), return a 1D array rather than a 3D array. Default value (True) - set by config.defaults['control.squeeze']. + set by config.defaults['control.squeeze_frequency_response']. Returns ------- @@ -429,7 +429,7 @@ def __call__(self, s, squeeze=None): """ # Set value of squeeze argument if not set if squeeze is None: - squeeze = config.defaults['control.squeeze'] + squeeze = config.defaults['control.squeeze_frequency_response'] 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 d554a2c24..18904a245 100644 --- a/control/lti.py +++ b/control/lti.py @@ -137,7 +137,7 @@ def frequency_response(self, omega, squeeze=None): squeeze : bool, optional If True and the system is single-input single-output (SISO), return a 1D array rather than a 3D array. Default value (True) - set by config.defaults['control.squeeze']. + set by config.defaults['control.squeeze_frequency_response']. Returns ------- @@ -487,7 +487,7 @@ def evalfr(sys, x, squeeze=None): squeeze : bool, optional (default=True) If True and the system is single-input single-output (SISO), return a 1D array rather than a 3D array. Default value (True) set by - config.defaults['control.squeeze']. + config.defaults['control.squeeze_frequency_response']. Returns ------- @@ -535,7 +535,7 @@ def freqresp(sys, omega, squeeze=None): squeeze : bool, optional (default=True) If True and the system is single-input single-output (SISO), return a 1D array rather than a 3D array. Default value (True) set by - config.defaults['control.squeeze']. + config.defaults['control.squeeze_frequency_response']. Returns ------- @@ -607,7 +607,7 @@ def _process_frequency_response(sys, omega, out, squeeze=None): # Get rid of unneeded dimensions if squeeze is None: - squeeze = config.defaults['control.squeeze'] + squeeze = config.defaults['control.squeeze_frequency_response'] if squeeze and sys.issiso(): return out[0][0] else: diff --git a/control/statesp.py b/control/statesp.py index e48052c04..1561ff21c 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -662,7 +662,7 @@ def __call__(self, x, squeeze=None): squeeze : bool, optional If True and the system is single-input single-output (SISO), return a 1D array rather than a 3D array. Default value (True) - set by config.defaults['control.squeeze']. + set by config.defaults['control.squeeze_frequency_response']. Returns ------- diff --git a/control/tests/lti_test.py b/control/tests/lti_test.py index 511d976f5..f09daa97e 100644 --- a/control/tests/lti_test.py +++ b/control/tests/lti_test.py @@ -157,7 +157,7 @@ def test_isdtime(self, objfun, arg, dt, ref, strictref): assert isctime(obj, strict=True) == strictref @pytest.mark.usefixtures("editsdefaults") - @pytest.mark.parametrize("fcn", [ct.ss, ct.tf, ct.frd]) + @pytest.mark.parametrize("fcn", [ct.ss, ct.tf, ct.frd, ct.ss2io]) @pytest.mark.parametrize("nstate, nout, ninp, squeeze, shape", [ [1, 1, 1, None, (8,)], # SISO [2, 1, 1, True, (8,)], @@ -193,7 +193,7 @@ def test_squeeze(self, fcn, nstate, nout, ninp, squeeze, shape): assert ct.evalfr(sys, omega * 1j, squeeze=squeeze).shape == shape # Changing config.default to False should return 3D frequency response - ct.config.set_defaults('control', squeeze=False) + ct.config.set_defaults('control', squeeze_frequency_response=False) mag, phase, _ = sys.frequency_response(omega) assert mag.shape == (sys.outputs, sys.inputs, 8) assert phase.shape == (sys.outputs, sys.inputs, 8) diff --git a/control/xferfcn.py b/control/xferfcn.py index 3dfdb86e7..cea50d3c0 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -256,7 +256,7 @@ def __call__(self, x, squeeze=None): squeeze : bool, optional (default=True) If True and the system is single-input single-output (SISO), return a 1D array rather than a 3D array. Default value (True) - set by config.defaults['control.squeeze']. + set by config.defaults['control.squeeze_frequency_response']. Returns ------- From 90da4fbdf102f371449fc992d9b80cc611b16533 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sun, 17 Jan 2021 12:33:13 -0800 Subject: [PATCH 4/5] update squeeze processing for freq response + unit tests, doc updates --- control/config.py | 2 +- control/frdata.py | 62 ++++++++++++++--------- control/lti.py | 87 +++++++++++++++++++++----------- control/margins.py | 14 +++--- control/statesp.py | 33 ++++++++---- control/tests/lti_test.py | 103 ++++++++++++++++++++++++++++---------- control/xferfcn.py | 34 +++++++++---- 7 files changed, 230 insertions(+), 105 deletions(-) diff --git a/control/config.py b/control/config.py index 026e76240..8ffe06845 100644 --- a/control/config.py +++ b/control/config.py @@ -16,7 +16,7 @@ # Package level default values _control_defaults = { 'control.default_dt': 0, - 'control.squeeze_frequency_response': True + 'control.squeeze_frequency_response': None } defaults = dict(_control_defaults) diff --git a/control/frdata.py b/control/frdata.py index c2e2ccfa3..b91f2e645 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -353,25 +353,33 @@ def eval(self, omega, squeeze=None): Parameters ---------- - omega : float or array_like + omega : float or 1D array_like Frequencies in radians per second - squeeze : bool, optional (default=True) - If True and the system is single-input single-output (SISO), - return a 1D array rather than a 3D array. Default value (True) - set by config.defaults['control.squeeze_frequency_response']. + squeeze : bool, optional + If squeeze=True, remove single-dimensional entries from the shape + of the output even if the system is not SISO. If squeeze=False, + keep all indices (output, input and, if omega is array_like, + frequency) even if the system is SISO. The default value can be + set using config.defaults['control.squeeze_frequency_response']. 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``. + fresp : complex ndarray + The frequency response of the system. If the system is SISO and + squeeze is not True, the shape of the array matches the shape of + omega. If the system is not SISO or squeeze is False, the first + two dimensions of the array are indices for the output and input + and the remaining dimensions match omega. If ``squeeze`` is True + then single-dimensional axes are removed. """ - # Set value of squeeze argument if not set - if squeeze is None: - squeeze = config.defaults['control.squeeze_frequency_response'] - omega_array = np.array(omega, ndmin=1) # array-like version of omega + + # Make sure that we are operating on a simple list + if len(omega_array.shape) > 1: + raise ValueError("input list must be 1D") + + # Make sure that frequencies are all real-valued if any(omega_array.imag > 0): raise ValueError("FRD.eval can only accept real-valued omega") @@ -396,7 +404,7 @@ def eval(self, omega, squeeze=None): def __call__(self, s, squeeze=None): """Evaluate system's transfer function at complex frequencies. - + Returns the complex frequency response `sys(s)` of system `sys` with `m = sys.inputs` number of inputs and `p = sys.outputs` number of outputs. @@ -406,19 +414,24 @@ def __call__(self, s, squeeze=None): Parameters ---------- - s : complex scalar or array_like + s : complex scalar or 1D array_like Complex frequencies squeeze : bool, optional (default=True) - If True and the system is single-input single-output (SISO), - return a 1D array rather than a 3D array. Default value (True) - set by config.defaults['control.squeeze_frequency_response']. + If squeeze=True, remove single-dimensional entries from the shape + of the output even if the system is not SISO. If squeeze=False, + keep all indices (output, input and, if omega is array_like, + frequency) even if the system is SISO. The default value can be + set using config.defaults['control.squeeze_frequency_response']. Returns ------- - 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``. - + fresp : complex ndarray + The frequency response of the system. If the system is SISO and + squeeze is not True, the shape of the array matches the shape of + omega. If the system is not SISO or squeeze is False, the first + two dimensions of the array are indices for the output and input + and the remaining dimensions match omega. If ``squeeze`` is True + then single-dimensional axes are removed. Raises ------ @@ -427,13 +440,14 @@ def __call__(self, s, squeeze=None): :class:`FrequencyDomainData` systems are only defined at imaginary frequency values. """ - # Set value of squeeze argument if not set - if squeeze is None: - squeeze = config.defaults['control.squeeze_frequency_response'] + # Make sure that we are operating on a simple list + if len(np.array(s, ndmin=1).shape) > 1: + raise ValueError("input list must be 1D") 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) diff --git a/control/lti.py b/control/lti.py index 18904a245..514944f75 100644 --- a/control/lti.py +++ b/control/lti.py @@ -131,21 +131,26 @@ def frequency_response(self, omega, squeeze=None): Parameters ---------- - omega : float or array_like + omega : float or 1D array_like A list, tuple, array, or scalar value of frequencies in radians/sec at which the system will be evaluated. squeeze : bool, optional - If True and the system is single-input single-output (SISO), - return a 1D array rather than a 3D array. Default value (True) - set by config.defaults['control.squeeze_frequency_response']. + If squeeze=True, remove single-dimensional entries from the shape + of the output even if the system is not SISO. If squeeze=False, + keep all indices (output, input and, if omega is array_like, + frequency) even if the system is SISO. The default value can be + set using config.defaults['control.squeeze_frequency_response']. Returns ------- - mag : (p, m, len(omega)) ndarray or (len(omega),) ndarray + mag : ndarray The magnitude (absolute value, not dB or log10) of the system - 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 + frequency response. If the system is SISO and squeeze is not + True, the array is 1D, indexed by frequency. If the system is not + SISO or squeeze is False, the array is 3D, indexed by the output, + input, and frequency. If ``squeeze`` is True then + single-dimensional axes are removed. + phase : ndarray The wrapped phase in radians of the system frequency response. omega : ndarray The (sorted) frequencies at which the response was evaluated. @@ -482,18 +487,24 @@ def evalfr(sys, x, squeeze=None): ---------- sys: StateSpace or TransferFunction Linear system - x : complex scalar or array_like + x : complex scalar or 1D array_like Complex frequency(s) squeeze : bool, optional (default=True) - If True and the system is single-input single-output (SISO), return a - 1D array rather than a 3D array. Default value (True) set by + If squeeze=True, remove single-dimensional entries from the shape of + the output even if the system is not SISO. If squeeze=False, keep all + indices (output, input and, if omega is array_like, frequency) even if + the system is SISO. The default value can be set using config.defaults['control.squeeze_frequency_response']. Returns ------- - 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``. + fresp : complex ndarray + The frequency response of the system. If the system is SISO and + squeeze is not True, the shape of the array matches the shape of + omega. If the system is not SISO or squeeze is False, the first two + dimensions of the array are indices for the output and input and the + remaining dimensions match omega. If ``squeeze`` is True then + single-dimensional axes are removed. See Also -------- @@ -519,7 +530,7 @@ def evalfr(sys, x, squeeze=None): def freqresp(sys, omega, squeeze=None): """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. @@ -528,23 +539,27 @@ def freqresp(sys, omega, squeeze=None): ---------- sys: StateSpace or TransferFunction Linear system - omega : float or array_like + omega : float or 1D 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 the system is single-input single-output (SISO), return a - 1D array rather than a 3D array. Default value (True) set by + squeeze : bool, optional + If squeeze=True, remove single-dimensional entries from the shape of + the output even if the system is not SISO. If squeeze=False, keep all + indices (output, input and, if omega is array_like, frequency) even if + the system is SISO. The default value can be set using config.defaults['control.squeeze_frequency_response']. Returns ------- - mag : (p, m, len(omega)) ndarray or (len(omega),) ndarray + mag : ndarray The magnitude (absolute value, not dB or log10) of the system - 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 + frequency response. If the system is SISO and squeeze is not True, + the array is 1D, indexed by frequency. If the system is not SISO or + squeeze is False, the array is 3D, indexed by the output, input, and + frequency. If ``squeeze`` is True then single-dimensional axes are + removed. + phase : ndarray The wrapped phase in radians of the system frequency response. omega : ndarray The list of sorted frequencies at which the response was @@ -601,14 +616,30 @@ def dcgain(sys): # Process frequency responses in a uniform way def _process_frequency_response(sys, omega, out, squeeze=None): + # Set value of squeeze argument if not set + if squeeze is None: + squeeze = config.defaults['control.squeeze_frequency_response'] + if not hasattr(omega, '__len__'): # received a scalar x, squeeze down the array along last dim out = np.squeeze(out, axis=2) + # # Get rid of unneeded dimensions - if squeeze is None: - squeeze = config.defaults['control.squeeze_frequency_response'] - if squeeze and sys.issiso(): + # + # There are three possible values for the squeeze keyword at this point: + # + # squeeze=None: squeeze input/output axes iff SISO + # squeeze=True: squeeze all single dimensional axes (ala numpy) + # squeeze-False: don't squeeze any axes + # + if squeeze is True: + # Squeeze everything that we can if that's what the user wants + return np.squeeze(out) + elif squeeze is None and sys.issiso(): + # SISO system output squeezed unless explicitly specified otherwise return out[0][0] - else: + elif squeeze is False or squeeze is None: return out + else: + raise ValueError("unknown squeeze value") diff --git a/control/margins.py b/control/margins.py index 0bbf2e30b..20da2a879 100644 --- a/control/margins.py +++ b/control/margins.py @@ -294,25 +294,25 @@ def stability_margins(sysdata, returnall=False, epsw=0.0): # frequency for gain margin: phase crosses -180 degrees w_180 = _poly_iw_real_crossing(num_iw, den_iw, epsw) with np.errstate(all='ignore'): # den=0 is okay - w180_resp = evalfr(sys, 1J * w_180, squeeze=True) + w180_resp = evalfr(sys, 1J * w_180) # frequency for phase margin : gain crosses magnitude 1 wc = _poly_iw_mag1_crossing(num_iw, den_iw, epsw) - wc_resp = evalfr(sys, 1J * wc, squeeze=True) + wc_resp = evalfr(sys, 1J * wc) # stability margin wstab = _poly_iw_wstab(num_iw, den_iw, epsw) - ws_resp = evalfr(sys, 1J * wstab, squeeze=True) + ws_resp = evalfr(sys, 1J * wstab) else: # Discrete Time zargs = _poly_z_invz(sys) # gain margin z, w_180 = _poly_z_real_crossing(*zargs, epsw=epsw) - w180_resp = evalfr(sys, z, squeeze=True) + w180_resp = evalfr(sys, z) # phase margin z, wc = _poly_z_mag1_crossing(*zargs, epsw=epsw) - wc_resp = evalfr(sys, z, squeeze=True) + wc_resp = evalfr(sys, z) # stability margin z, wstab = _poly_z_wstab(*zargs, epsw=epsw) @@ -437,11 +437,11 @@ def phase_crossover_frequencies(sys): omega = _poly_iw_real_crossing(num_iw, den_iw, 0.) # using real() to avoid rounding errors and results like 1+0j - gain = np.real(evalfr(sys, 1J * omega, squeeze=True)) + gain = np.real(evalfr(sys, 1J * omega)) else: zargs = _poly_z_invz(sys) z, omega = _poly_z_real_crossing(*zargs, epsw=0.) - gain = np.real(evalfr(sys, z, squeeze=True)) + gain = np.real(evalfr(sys, z)) return omega, gain diff --git a/control/statesp.py b/control/statesp.py index 1561ff21c..7c1b311b2 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -645,7 +645,7 @@ def __call__(self, x, squeeze=None): 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. @@ -657,18 +657,24 @@ def __call__(self, x, squeeze=None): Parameters ---------- - x : complex or complex array_like + x : complex or complex 1D array_like Complex frequencies squeeze : bool, optional - If True and the system is single-input single-output (SISO), - return a 1D array rather than a 3D array. Default value (True) - set by config.defaults['control.squeeze_frequency_response']. + If squeeze=True, remove single-dimensional entries from the shape + of the output even if the system is not SISO. If squeeze=False, + keep all indices (output, input and, if omega is array_like, + frequency) even if the system is SISO. The default value can be + set using config.defaults['control.squeeze_frequency_response']. Returns ------- - 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``. + fresp : complex ndarray + The frequency response of the system. If the system is SISO and + squeeze is not True, the shape of the array matches the shape of + omega. If the system is not SISO or squeeze is False, the first + two dimensions of the array are indices for the output and input + and the remaining dimensions match omega. If ``squeeze`` is True + then single-dimensional axes are removed. """ # Use Slycot if available @@ -693,9 +699,13 @@ def slycot_laub(self, x): Frequency response """ from slycot import tb05ad + x_arr = np.atleast_1d(x) # array-like version of x + + # Make sure that we are operating on a simple list + if len(x_arr.shape) > 1: + raise ValueError("input list must be 1D") # preallocate - x_arr = np.atleast_1d(x) # array-like version of x n = self.states m = self.inputs p = self.outputs @@ -755,6 +765,11 @@ def horner(self, x): # Fall back because either Slycot unavailable or cannot handle # certain cases. x_arr = np.atleast_1d(x) # force to be an array + + # Make sure that we are operating on a simple list + if len(x_arr.shape) > 1: + raise ValueError("input list must be 1D") + # Preallocate out = empty((self.outputs, self.inputs, len(x_arr)), dtype=complex) diff --git a/control/tests/lti_test.py b/control/tests/lti_test.py index f09daa97e..e165f9c60 100644 --- a/control/tests/lti_test.py +++ b/control/tests/lti_test.py @@ -158,44 +158,95 @@ def test_isdtime(self, objfun, arg, dt, ref, strictref): @pytest.mark.usefixtures("editsdefaults") @pytest.mark.parametrize("fcn", [ct.ss, ct.tf, ct.frd, ct.ss2io]) - @pytest.mark.parametrize("nstate, nout, ninp, squeeze, shape", [ - [1, 1, 1, None, (8,)], # SISO - [2, 1, 1, True, (8,)], - [3, 1, 1, False, (1, 1, 8)], - [1, 2, 1, None, (2, 1, 8)], # SIMO - [2, 2, 1, True, (2, 1, 8)], - [3, 2, 1, False, (2, 1, 8)], - [1, 1, 2, None, (1, 2, 8)], # MISO - [2, 1, 2, True, (1, 2, 8)], - [3, 1, 2, False, (1, 2, 8)], - [1, 2, 2, None, (2, 2, 8)], # MIMO - [2, 2, 2, True, (2, 2, 8)], - [3, 2, 2, False, (2, 2, 8)] + @pytest.mark.parametrize("nstate, nout, ninp, omega, squeeze, shape", [ + [1, 1, 1, 0.1, None, ()], # SISO + [1, 1, 1, [0.1], None, (1,)], + [1, 1, 1, [0.1, 1, 10], None, (3,)], + [2, 1, 1, 0.1, True, ()], + [2, 1, 1, [0.1], True, ()], + [2, 1, 1, [0.1, 1, 10], True, (3,)], + [3, 1, 1, 0.1, False, (1, 1)], + [3, 1, 1, [0.1], False, (1, 1, 1)], + [3, 1, 1, [0.1, 1, 10], False, (1, 1, 3)], + [1, 2, 1, 0.1, None, (2, 1)], # SIMO + [1, 2, 1, [0.1], None, (2, 1, 1)], + [1, 2, 1, [0.1, 1, 10], None, (2, 1, 3)], + [2, 2, 1, 0.1, True, (2,)], + [2, 2, 1, [0.1], True, (2,)], + [3, 2, 1, 0.1, False, (2, 1)], + [3, 2, 1, [0.1], False, (2, 1, 1)], + [3, 2, 1, [0.1, 1, 10], False, (2, 1, 3)], + [1, 1, 2, [0.1, 1, 10], None, (1, 2, 3)], # MISO + [2, 1, 2, [0.1, 1, 10], True, (2, 3)], + [3, 1, 2, [0.1, 1, 10], False, (1, 2, 3)], + [1, 2, 2, [0.1, 1, 10], None, (2, 2, 3)], # MIMO + [2, 2, 2, [0.1, 1, 10], True, (2, 2, 3)], + [3, 2, 2, [0.1, 1, 10], False, (2, 2, 3)] ]) - def test_squeeze(self, fcn, nstate, nout, ninp, squeeze, shape): - # Compute the length of the frequency array - omega = np.logspace(-2, 2, 8) - + def test_squeeze(self, fcn, nstate, nout, ninp, omega, squeeze, shape): # Create the system to be tested if fcn == ct.frd: - sys = fcn(ct.rss(nstate, nout, ninp), omega) + sys = fcn(ct.rss(nstate, nout, ninp), [1e-2, 1e-1, 1, 1e1, 1e2]) elif fcn == ct.tf and (nout > 1 or ninp > 1) and not slycot_check(): pytest.skip("Conversion of MIMO systems to transfer functions " "requires slycot.") else: sys = fcn(ct.rss(nstate, nout, ninp)) - # Pass squeeze argument and make sure the shape is correct - mag, phase, _ = sys.frequency_response(omega, squeeze=squeeze) - assert mag.shape == shape - assert phase.shape == shape + # Convert the frequency list to an array for easy of use + isscalar = not hasattr(omega, '__len__') + omega = np.array(omega) + + # Call the transfer function directly and make sure shape is correct assert sys(omega * 1j, squeeze=squeeze).shape == shape + + # Make sure that evalfr also works as expected assert ct.evalfr(sys, omega * 1j, squeeze=squeeze).shape == shape + # Check frequency response + mag, phase, _ = sys.frequency_response(omega, squeeze=squeeze) + if isscalar and squeeze is not True: + # sys.frequency_response() expects a list as an argument + # Add the shape of the input to the expected shape + assert mag.shape == shape + (1,) + assert phase.shape == shape + (1,) + else: + assert mag.shape == shape + assert phase.shape == shape + + # Make sure the default shape lines up with squeeze=None case + if squeeze is None: + assert sys(omega * 1j).shape == shape + # Changing config.default to False should return 3D frequency response ct.config.set_defaults('control', squeeze_frequency_response=False) mag, phase, _ = sys.frequency_response(omega) - assert mag.shape == (sys.outputs, sys.inputs, 8) - assert phase.shape == (sys.outputs, sys.inputs, 8) - assert sys(omega * 1j).shape == (sys.outputs, sys.inputs, 8) - assert ct.evalfr(sys, omega * 1j).shape == (sys.outputs, sys.inputs, 8) + if isscalar: + assert mag.shape == (sys.outputs, sys.inputs, 1) + assert phase.shape == (sys.outputs, sys.inputs, 1) + assert sys(omega * 1j).shape == (sys.outputs, sys.inputs) + assert ct.evalfr(sys, omega * 1j).shape == (sys.outputs, sys.inputs) + else: + assert mag.shape == (sys.outputs, sys.inputs, len(omega)) + assert phase.shape == (sys.outputs, sys.inputs, len(omega)) + assert sys(omega * 1j).shape == \ + (sys.outputs, sys.inputs, len(omega)) + assert ct.evalfr(sys, omega * 1j).shape == \ + (sys.outputs, sys.inputs, len(omega)) + + @pytest.mark.parametrize("fcn", [ct.ss, ct.tf, ct.frd, ct.ss2io]) + def test_squeeze_exceptions(self, fcn): + if fcn == ct.frd: + sys = fcn(ct.rss(2, 1, 1), [1e-2, 1e-1, 1, 1e1, 1e2]) + else: + sys = fcn(ct.rss(2, 1, 1)) + + with pytest.raises(ValueError, match="unknown squeeze value"): + sys.frequency_response([1], squeeze=1) + sys([1], squeeze='siso') + evalfr(sys, [1], squeeze='siso') + + with pytest.raises(ValueError, match="must be 1D"): + sys.frequency_response([[0.1, 1], [1, 10]]) + sys([[0.1, 1], [1, 10]]) + evalfr(sys, [[0.1, 1], [1, 10]]) diff --git a/control/xferfcn.py b/control/xferfcn.py index cea50d3c0..b732bafc0 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -240,10 +240,10 @@ def __call__(self, x, squeeze=None): 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. - + 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 @@ -251,18 +251,27 @@ def __call__(self, x, squeeze=None): Parameters ---------- - x : complex array_like or complex + x : complex or complex 1D array_like Complex frequencies - squeeze : bool, optional (default=True) + squeeze : bool, optional + If squeeze=True, remove single-dimensional entries from the shape + of the output even if the system is not SISO. If squeeze=False, + keep all indices (output, input and, if omega is array_like, + frequency) even if the system is SISO. The default value can be + set using config.defaults['control.squeeze_frequency_response']. If True and the system is single-input single-output (SISO), return a 1D array rather than a 3D array. Default value (True) set by config.defaults['control.squeeze_frequency_response']. Returns ------- - 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``. + fresp : complex ndarray + The frequency response of the system. If the system is SISO and + squeeze is not True, the shape of the array matches the shape of + omega. If the system is not SISO or squeeze is False, the first + two dimensions of the array are indices for the output and input + and the remaining dimensions match omega. If ``squeeze`` is True + then single-dimensional axes are removed. """ out = self.horner(x) @@ -289,7 +298,12 @@ def horner(self, x): Frequency response """ - x_arr = np.atleast_1d(x) # force to be an array + x_arr = np.atleast_1d(x) # force to be an array + + # Make sure that we are operating on a simple list + if len(x_arr.shape) > 1: + raise ValueError("input list must be 1D") + out = empty((self.outputs, self.inputs, len(x_arr)), dtype=complex) for i in range(self.outputs): for j in range(self.inputs): From b338e32e95ec154d3cb40b58c7d5eb157b6becbf Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Mon, 18 Jan 2021 08:31:41 -0800 Subject: [PATCH 5/5] address @sawyerbfuller review comments --- control/frdata.py | 4 ++-- control/statesp.py | 4 ---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/control/frdata.py b/control/frdata.py index b91f2e645..844ac9ab9 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -441,10 +441,10 @@ def __call__(self, s, squeeze=None): frequency values. """ # Make sure that we are operating on a simple list - if len(np.array(s, ndmin=1).shape) > 1: + if len(np.atleast_1d(s).shape) > 1: raise ValueError("input list must be 1D") - if any(abs(np.array(s, ndmin=1).real) > 0): + if any(abs(np.atleast_1d(s).real) > 0): raise ValueError("__call__: FRD systems can only accept " "purely imaginary frequencies") diff --git a/control/statesp.py b/control/statesp.py index 7c1b311b2..df4be85e6 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -646,10 +646,6 @@ def __call__(self, x, squeeze=None): 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