diff --git a/control/config.py b/control/config.py index b4950ae5e..8ffe06845 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_frequency_response': None } defaults = dict(_control_defaults) diff --git a/control/frdata.py b/control/frdata.py index 8f148a3fa..844ac9ab9 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -50,7 +50,8 @@ 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'] @@ -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 @@ -352,19 +353,33 @@ def eval(self, omega, squeeze=True): 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 `sys` is single input single output (SISO), returns a - 1D array rather than a 3D array. + 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. + """ 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") @@ -384,16 +399,12 @@ def eval(self, omega, squeeze=True): 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 `m = sys.inputs` number of inputs and `p = sys.outputs` number of outputs. @@ -403,18 +414,24 @@ def __call__(self, s, squeeze=True): Parameters ---------- - s : complex scalar or array_like + s : complex scalar or 1D 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 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 ------ @@ -423,9 +440,14 @@ def __call__(self, s, squeeze=True): :class:`FrequencyDomainData` systems are only defined at imaginary frequency values. """ - if any(abs(np.array(s, ndmin=1).real) > 0): + # Make sure that we are operating on a simple list + if len(np.atleast_1d(s).shape) > 1: + raise ValueError("input list must be 1D") + + if any(abs(np.atleast_1d(s).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 152c5c73b..514944f75 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', @@ -111,7 +112,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,30 +125,36 @@ 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 + 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 (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 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. - + """ omega = np.sort(np.array(omega, ndmin=1)) if isdtime(self, strict=True): @@ -463,9 +470,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 @@ -481,17 +487,24 @@ def evalfr(sys, x, squeeze=True): ---------- 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 `sys` is single input single output (SISO), i.e. `m=1`, - `p=1`, return a 1D array rather than a 3D array. + 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 -------- @@ -511,13 +524,13 @@ 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 outputs. @@ -526,22 +539,27 @@ def freqresp(sys, omega, squeeze=True): ---------- 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 `sys` is single input, single output (SISO), returns - 1D array rather than a 3D array. + 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 @@ -579,6 +597,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) @@ -593,3 +612,34 @@ 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): + # 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 + # + # 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] + elif squeeze is False or squeeze is None: + return out + else: + raise ValueError("unknown squeeze value") diff --git a/control/statesp.py b/control/statesp.py index ff4c73c4e..df4be85e6 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 @@ -640,15 +640,11 @@ 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 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 @@ -657,28 +653,29 @@ def __call__(self, x, squeeze=True): Parameters ---------- - x : complex or complex array_like + x : complex or complex 1D 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 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 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 @@ -698,9 +695,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 @@ -760,6 +761,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 ee9d95a09..e165f9c60 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,98 @@ 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, ct.ss2io]) + @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, omega, squeeze, shape): + # Create the system to be tested + if fcn == ct.frd: + 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)) + + # 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) + 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 0ff21a42a..b732bafc0 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'] @@ -234,16 +234,16 @@ 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 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,28 +251,31 @@ def __call__(self, x, squeeze=True): Parameters ---------- - x : complex array_like or complex + x : complex or complex 1D array_like 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. + 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) - 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 @@ -295,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):