diff --git a/control/config.py b/control/config.py index 800fe7f26..4d4512af7 100644 --- a/control/config.py +++ b/control/config.py @@ -15,7 +15,7 @@ # Package level default values _control_defaults = { - # No package level defaults (yet) + 'control.default_dt':0 } defaults = dict(_control_defaults) @@ -59,6 +59,9 @@ def reset_defaults(): from .statesp import _statesp_defaults defaults.update(_statesp_defaults) + from .iosys import _iosys_defaults + defaults.update(_iosys_defaults) + def _get_param(module, param, argval=None, defval=None, pop=False): """Return the default value for a configuration option. @@ -208,8 +211,11 @@ def use_legacy_defaults(version): # Go backwards through releases and reset defaults # - # Version 0.9.0: switched to 'array' as default for state space objects + # Version 0.9.0: if major == 0 and minor < 9: + # switched to 'array' as default for state space objects set_defaults('statesp', use_numpy_matrix=True) + # switched to 0 (=continuous) as default timestep + set_defaults('control', default_dt=None) return (major, minor, patch) diff --git a/control/dtime.py b/control/dtime.py index 89f17c4af..725bcde47 100644 --- a/control/dtime.py +++ b/control/dtime.py @@ -53,21 +53,19 @@ # Sample a continuous time system def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None): - """Convert a continuous time system to discrete time - - Creates a discrete time system from a continuous time system by - sampling. Multiple methods of conversion are supported. + """ + Convert a continuous time system to discrete time by sampling Parameters ---------- - sysc : linsys + sysc : LTI (StateSpace or TransferFunction) Continuous time system to be converted - Ts : real + Ts : real > 0 Sampling period method : string - Method to use for conversion: 'matched', 'tustin', 'zoh' (default) + Method to use for conversion, e.g. 'bilinear', 'zoh' (default) - prewarp_frequency : float within [0, infinity) + prewarp_frequency : real within [0, infinity) The frequency [rad/s] at which to match with the input continuous- time system's magnitude and phase @@ -78,13 +76,13 @@ def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None): Notes ----- - See `TransferFunction.sample` and `StateSpace.sample` for + See :meth:`StateSpace.sample` or :meth:`TransferFunction.sample`` for further details. Examples -------- >>> sysc = TransferFunction([1], [1, 2, 1]) - >>> sysd = sample_system(sysc, 1, method='matched') + >>> sysd = sample_system(sysc, 1, method='bilinear') """ # Make sure we have a continuous time system @@ -95,35 +93,39 @@ def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None): def c2d(sysc, Ts, method='zoh', prewarp_frequency=None): - ''' - Return a discrete-time system + """ + Convert a continuous time system to discrete time by sampling Parameters ---------- - sysc: LTI (StateSpace or TransferFunction), continuous - System to be converted + sysc : LTI (StateSpace or TransferFunction) + Continuous time system to be converted + Ts : real > 0 + Sampling period + method : string + Method to use for conversion, e.g. 'bilinear', 'zoh' (default) - Ts: number - Sample time for the conversion + prewarp_frequency : real within [0, infinity) + The frequency [rad/s] at which to match with the input continuous- + time system's magnitude and phase - method: string, optional - Method to be applied, - 'zoh' Zero-order hold on the inputs (default) - 'foh' First-order hold, currently not implemented - 'impulse' Impulse-invariant discretization, currently not implemented - 'tustin' Bilinear (Tustin) approximation, only SISO - 'matched' Matched pole-zero method, only SISO + Returns + ------- + sysd : linsys + Discrete time system, with sampling rate Ts + + Notes + ----- + See :meth:`StateSpace.sample` or :meth:`TransferFunction.sample`` for + further details. - prewarp_frequency : float within [0, infinity) - The frequency [rad/s] at which to match with the input continuous- - time system's magnitude and phase + Examples + -------- + >>> sysc = TransferFunction([1], [1, 2, 1]) + >>> sysd = sample_system(sysc, 1, method='bilinear') + """ - ''' # Call the sample_system() function to do the work sysd = sample_system(sysc, Ts, method, prewarp_frequency) - # TODO: is this check needed? If sysc is StateSpace, sysd is too? - if isinstance(sysc, StateSpace) and not isinstance(sysd, StateSpace): - return _convertToStateSpace(sysd) # pragma: no cover - return sysd diff --git a/control/iosys.py b/control/iosys.py index a90b5193c..65d6c1228 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -38,12 +38,15 @@ from .statesp import StateSpace, tf2ss from .timeresp import _check_convert_array -from .lti import isctime, isdtime, _find_timebase +from .lti import isctime, isdtime, common_timebase +from . import config __all__ = ['InputOutputSystem', 'LinearIOSystem', 'NonlinearIOSystem', 'InterconnectedSystem', 'input_output_response', 'find_eqpt', 'linearize', 'ss2io', 'tf2io'] +# Define module default parameter values +_iosys_defaults = {} class InputOutputSystem(object): """A class for representing input/output systems. @@ -69,9 +72,11 @@ class for a set of subclasses that are used to implement specific states : int, list of str, or None Description of the system states. Same format as `inputs`. dt : None, True or float, optional - System timebase. None (default) indicates continuous time, True - indicates discrete time with undefined sampling time, positive number - is discrete time with specified sampling time. + System timebase. 0 (default) indicates continuous + time, True indicates discrete time with unspecified sampling + time, positive number is discrete time with specified + sampling time, None indicates unspecified timebase (either + continuous or discrete time). params : dict, optional Parameter values for the systems. Passed to the evaluation functions for the system as default values, overriding internal defaults. @@ -87,9 +92,11 @@ class for a set of subclasses that are used to implement specific Dictionary of signal names for the inputs, outputs and states and the index of the corresponding array dt : None, True or float - System timebase. None (default) indicates continuous time, True - indicates discrete time with undefined sampling time, positive number - is discrete time with specified sampling time. + System timebase. 0 (default) indicates continuous + time, True indicates discrete time with unspecified sampling + time, positive number is discrete time with specified + sampling time, None indicates unspecified timebase (either + continuous or discrete time). params : dict, optional Parameter values for the systems. Passed to the evaluation functions for the system as default values, overriding internal defaults. @@ -118,7 +125,7 @@ def name_or_default(self, name=None): return name def __init__(self, inputs=None, outputs=None, states=None, params={}, - dt=None, name=None): + name=None, **kwargs): """Create an input/output system. The InputOutputSystem contructor is used to create an input/output @@ -143,10 +150,11 @@ def __init__(self, inputs=None, outputs=None, states=None, params={}, states : int, list of str, or None Description of the system states. Same format as `inputs`. dt : None, True or float, optional - System timebase. None (default) indicates continuous - time, True indicates discrete time with undefined sampling + System timebase. 0 (default) indicates continuous + time, True indicates discrete time with unspecified sampling time, positive number is discrete time with specified - sampling time. + sampling time, None indicates unspecified timebase (either + continuous or discrete time). params : dict, optional Parameter values for the systems. Passed to the evaluation functions for the system as default values, overriding internal @@ -162,9 +170,13 @@ def __init__(self, inputs=None, outputs=None, states=None, params={}, """ # Store the input arguments - self.params = params.copy() # default parameters - self.dt = dt # timebase - self.name = self.name_or_default(name) # system name + + # default parameters + self.params = params.copy() + # timebase + self.dt = kwargs.get('dt', config.defaults['control.default_dt']) + # system name + self.name = self.name_or_default(name) # Parse and store the number of inputs, outputs, and states self.set_inputs(inputs) @@ -210,9 +222,7 @@ def __mul__(sys2, sys1): "inputs and outputs") # Make sure timebase are compatible - dt = _find_timebase(sys1, sys2) - if dt is False: - raise ValueError("System timebases are not compabile") + dt = common_timebase(sys1.dt, sys2.dt) inplist = [(0,i) for i in range(sys1.ninputs)] outlist = [(1,i) for i in range(sys2.noutputs)] @@ -464,12 +474,11 @@ def feedback(self, other=1, sign=-1, params={}): "inputs and outputs") # Make sure timebases are compatible - dt = _find_timebase(self, other) - if dt is False: - raise ValueError("System timebases are not compabile") + dt = common_timebase(self.dt, other.dt) inplist = [(0,i) for i in range(self.ninputs)] outlist = [(0,i) for i in range(self.noutputs)] + # Return the series interconnection between the systems newsys = InterconnectedSystem((self, other), inplist=inplist, outlist=outlist, params=params, dt=dt) @@ -580,10 +589,11 @@ def __init__(self, linsys, inputs=None, outputs=None, states=None, states : int, list of str, or None, optional Description of the system states. Same format as `inputs`. dt : None, True or float, optional - System timebase. None (default) indicates continuous - time, True indicates discrete time with undefined sampling + System timebase. 0 (default) indicates continuous + time, True indicates discrete time with unspecified sampling time, positive number is discrete time with specified - sampling time. + sampling time, None indicates unspecified timebase (either + continuous or discrete time). params : dict, optional Parameter values for the systems. Passed to the evaluation functions for the system as default values, overriding internal @@ -650,7 +660,8 @@ class NonlinearIOSystem(InputOutputSystem): """ def __init__(self, updfcn, outfcn=None, inputs=None, outputs=None, - states=None, params={}, dt=None, name=None): + states=None, params={}, + name=None, **kwargs): """Create a nonlinear I/O system given update and output functions. Creates an `InputOutputSystem` for a nonlinear system by specifying a @@ -702,10 +713,10 @@ def __init__(self, updfcn, outfcn=None, inputs=None, outputs=None, operating in continuous or discrete time. It can have the following values: - * dt = None No timebase specified - * dt = 0 Continuous time system - * dt > 0 Discrete time system with sampling time dt - * dt = True Discrete time with unspecified sampling time + * dt = 0: continuous time system (default) + * dt > 0: discrete time system with sampling period 'dt' + * dt = True: discrete time with unspecified sampling period + * dt = None: no timebase specified name : string, optional System name (used for specifying signals). If unspecified, a generic @@ -722,6 +733,7 @@ def __init__(self, updfcn, outfcn=None, inputs=None, outputs=None, self.outfcn = outfcn # Initialize the rest of the structure + dt = kwargs.get('dt', config.defaults['control.default_dt']) super(NonlinearIOSystem, self).__init__( inputs=inputs, outputs=outputs, states=states, params=params, dt=dt, name=name @@ -871,10 +883,10 @@ def __init__(self, syslist, connections=[], inplist=[], outlist=[], operating in continuous or discrete time. It can have the following values: - * dt = None No timebase specified - * dt = 0 Continuous time system - * dt > 0 Discrete time system with sampling time dt - * dt = True Discrete time with unspecified sampling time + * dt = 0: continuous time system (default) + * dt > 0: discrete time system with sampling period 'dt' + * dt = True: discrete time with unspecified sampling period + * dt = None: no timebase specified name : string, optional System name (used for specifying signals). If unspecified, a generic @@ -888,7 +900,6 @@ def __init__(self, syslist, connections=[], inplist=[], outlist=[], # Check to make sure all systems are consistent self.syslist = syslist self.syslist_index = {} - dt = None nstates = 0; self.state_offset = [] ninputs = 0; self.input_offset = [] noutputs = 0; self.output_offset = [] @@ -896,12 +907,7 @@ def __init__(self, syslist, connections=[], inplist=[], outlist=[], sysname_count_dct = {} for sysidx, sys in enumerate(syslist): # Make sure time bases are consistent - # TODO: Use lti._find_timebase() instead? - if dt is None and sys.dt is not None: - # Timebase was not specified; set to match this system - dt = sys.dt - elif dt != sys.dt: - raise TypeError("System timebases are not compatible") + dt = common_timebase(dt, sys.dt) # Make sure number of inputs, outputs, states is given if sys.ninputs is None or sys.noutputs is None or \ diff --git a/control/lti.py b/control/lti.py index 8db14794b..e41fe416b 100644 --- a/control/lti.py +++ b/control/lti.py @@ -9,14 +9,16 @@ isdtime() isctime() timebase() -timebaseEqual() +common_timebase() """ import numpy as np from numpy import absolute, real +from warnings import warn -__all__ = ['issiso', 'timebase', 'timebaseEqual', 'isdtime', 'isctime', - 'pole', 'zero', 'damp', 'evalfr', 'freqresp', 'dcgain'] +__all__ = ['issiso', 'timebase', 'common_timebase', 'timebaseEqual', + 'isdtime', 'isctime', 'pole', 'zero', 'damp', 'evalfr', + 'freqresp', 'dcgain'] class LTI: """LTI is a parent class to linear time-invariant (LTI) system objects. @@ -157,9 +159,59 @@ def timebase(sys, strict=True): return sys.dt +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 + or StateSpace system) + + Returns + ------- + dt: number + The common timebase of dt1 and dt2, as specified in + :ref:`conventions-ref`. + + Raises + ------ + ValueError + when no compatible time base can be found + """ + # explanation: + # if either dt is None, they are compatible with anything + # 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 + if hasattr(dt1, 'dt'): + dt1 = dt1.dt + if hasattr(dt2, 'dt'): + dt2 = dt2.dt + + if dt1 is None: + return dt2 + elif dt2 is None: + return dt1 + elif dt1 is True: + if dt2 > 0: + return dt2 + else: + raise ValueError("Systems have incompatible timebases") + elif dt2 is True: + if dt1 > 0: + return dt1 + else: + raise ValueError("Systems have incompatible timebases") + elif np.isclose(dt1, dt2): + return dt1 + else: + raise ValueError("Systems have incompatible timebases") + # Check to see if two timebases are equal def timebaseEqual(sys1, sys2): - """Check to see if two systems have the same timebase + """ + Check to see if two systems have the same timebase timebaseEqual(sys1, sys2) @@ -168,7 +220,10 @@ def timebaseEqual(sys1, sys2): discrete or continuous timebase systems. If two systems have a discrete 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", + 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 @@ -178,27 +233,6 @@ def timebaseEqual(sys1, sys2): else: return sys1.dt == sys2.dt -# Find a common timebase between two or more systems -def _find_timebase(sys1, *sysn): - """Find the common timebase between systems, otherwise return False""" - - # Create a list of systems to check - syslist = [sys1] - syslist.append(*sysn) - - # Look for a common timebase - dt = None - - for sys in syslist: - # Make sure time bases are consistent - if (dt is None and sys.dt is not None) or \ - (dt is True and isdiscrete(sys)): - # Timebase was not specified; set to match this system - dt = sys.dt - elif dt != sys.dt: - return False - return dt - # Check to see if a system is a discrete time system def isdtime(sys, strict=False): diff --git a/control/statesp.py b/control/statesp.py index 0f6638881..2419b4e62 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, timebase, timebaseEqual, isdtime +from .lti import LTI, common_timebase, isdtime from . import config from copy import deepcopy @@ -72,7 +72,6 @@ # Define module default parameter values _statesp_defaults = { 'statesp.use_numpy_matrix': False, # False is default in 0.9.0 and above - 'statesp.default_dt': None, 'statesp.remove_useless_states': True, 'statesp.latex_num_format': '.3g', 'statesp.latex_repr_type': 'partitioned', @@ -178,14 +177,22 @@ class StateSpace(LTI): `numpy.ndarray` objects. The :func:`~control.use_numpy_matrix` function can be used to set the storage type. - Discrete-time state space system are implemented by using the 'dt' - instance variable and setting it to the sampling period. If 'dt' is not - None, then it must match whenever two state space systems are combined. - Setting dt = 0 specifies a continuous system, while leaving dt = None - means the system timebase is not specified. If 'dt' is set to True, the - system will be treated as a discrete time system with unspecified sampling - time. The default value of 'dt' is None and can be changed by changing the - value of ``control.config.defaults['statesp.default_dt']``. + A discrete time system is created by specifying a nonzero 'timebase', dt + when the system is constructed: + + * dt = 0: continuous time system (default) + * dt > 0: discrete time system with sampling period 'dt' + * dt = True: discrete time with unspecified sampling period + * dt = None: no timebase specified + + Systems must have compatible timebases in order to be combined. A discrete + time system with unspecified sampling time (`dt = True`) can be combined + with a system having a specified sampling time; the result will be a + discrete time system with the sample time of the latter system. Similarly, + a system with timebase `None` can be combined with a system having any + timebase; the result will have the timebase of the latter system. + The default value of dt can be changed by changing the value of + ``control.config.defaults['control.default_dt']``. StateSpace instances have support for IPython LaTeX output, intended for pretty-printing in Jupyter notebooks. The LaTeX @@ -204,14 +211,13 @@ class StateSpace(LTI): `'partitioned'` or `'separate'`. If `'partitioned'`, the A, B, C, D matrices are shown as a single, partitioned matrix; if `'separate'`, the matrices are shown separately. - """ # Allow ndarray * StateSpace to give StateSpace._rmul_() priority __array_priority__ = 11 # override ndarray and matrix types - def __init__(self, *args, **kw): + def __init__(self, *args, **kwargs): """ StateSpace(A, B, C, D[, dt]) @@ -224,13 +230,13 @@ def __init__(self, *args, **kw): call StateSpace(sys), where sys is a StateSpace object. """ + # first get A, B, C, D matrices if len(args) == 4: # The user provided A, B, C, and D matrices. (A, B, C, D) = args - dt = config.defaults['statesp.default_dt'] elif len(args) == 5: # Discrete time system - (A, B, C, D, dt) = args + (A, B, C, D, _) = args elif len(args) == 1: # Use the copy constructor. if not isinstance(args[0], StateSpace): @@ -240,16 +246,12 @@ def __init__(self, *args, **kw): B = args[0].B C = args[0].C D = args[0].D - try: - dt = args[0].dt - except NameError: - dt = config.defaults['statesp.default_dt'] else: - raise ValueError("Needs 1 or 4 arguments; received %i." % len(args)) + raise ValueError("Expected 1, 4, or 5 arguments; received %i." % len(args)) # Process keyword arguments - remove_useless = kw.get('remove_useless', - config.defaults['statesp.remove_useless_states']) + remove_useless = kwargs.get('remove_useless', + config.defaults['statesp.remove_useless_states']) # Convert all matrices to standard form A = _ssmatrix(A) @@ -268,12 +270,33 @@ def __init__(self, *args, **kw): D = _ssmatrix(D) # TODO: use super here? - LTI.__init__(self, inputs=D.shape[1], outputs=D.shape[0], dt=dt) + LTI.__init__(self, inputs=D.shape[1], outputs=D.shape[0]) self.A = A self.B = B self.C = C self.D = D + # now set dt + if len(args) == 4: + if 'dt' in kwargs: + dt = kwargs['dt'] + elif self.is_static_gain(): + dt = None + else: + dt = config.defaults['control.default_dt'] + elif len(args) == 5: + dt = args[4] + if 'dt' in kwargs: + warn('received multiple dt arguments, using positional arg dt=%s'%dt) + elif len(args) == 1: + try: + dt = args[0].dt + except AttributeError: + if self.is_static_gain(): + dt = None + else: + dt = config.defaults['control.default_dt'] + self.dt = dt self.states = A.shape[1] if 0 == self.states: @@ -507,14 +530,7 @@ def __add__(self, other): (self.outputs != other.outputs)): raise ValueError("Systems have different shapes.") - # Figure out the sampling time to use - if self.dt is None and other.dt is not None: - dt = other.dt # use dt from second argument - elif (other.dt is None and self.dt is not None) or \ - (timebaseEqual(self, other)): - dt = self.dt # use dt from first argument - else: - raise ValueError("Systems have different sampling times") + dt = common_timebase(self.dt, other.dt) # Concatenate the various arrays A = concatenate(( @@ -563,16 +579,8 @@ def __mul__(self, other): # Check to make sure the dimensions are OK if self.inputs != other.outputs: raise ValueError("C = A * B: A has %i column(s) (input(s)), \ -but B has %i row(s)\n(output(s))." % (self.inputs, other.outputs)) - - # Figure out the sampling time to use - if (self.dt == None and other.dt != None): - dt = other.dt # use dt from second argument - elif (other.dt == None and self.dt != None) or \ - (timebaseEqual(self, other)): - dt = self.dt # use dt from first argument - else: - raise ValueError("Systems have different sampling times") + but B has %i row(s)\n(output(s))." % (self.inputs, other.outputs)) + dt = common_timebase(self.dt, other.dt) # Concatenate the various arrays A = concatenate( @@ -644,9 +652,8 @@ 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: + 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 @@ -703,9 +710,8 @@ def freqresp(self, omega): # 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: + 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 @@ -808,14 +814,7 @@ def feedback(self, other=1, sign=-1): if (self.inputs != other.outputs) or (self.outputs != other.inputs): raise ValueError("State space systems don't have compatible inputs/outputs for " "feedback.") - - # Figure out the sampling time to use - if self.dt is None and other.dt is not None: - dt = other.dt # use dt from second argument - elif other.dt is None and self.dt is not None or timebaseEqual(self, other): - dt = self.dt # use dt from first argument - else: - raise ValueError("Systems have different sampling times") + dt = common_timebase(self.dt, other.dt) A1 = self.A B1 = self.B @@ -885,14 +884,7 @@ def lft(self, other, nu=-1, ny=-1): # dimension check # TODO - # Figure out the sampling time to use - if (self.dt == None and other.dt != None): - dt = other.dt # use dt from second argument - elif (other.dt == None and self.dt != None) or \ - timebaseEqual(self, other): - dt = self.dt # use dt from first argument - else: - raise ValueError("Systems have different time bases") + dt = common_timebase(self.dt, other.dt) # submatrices A = self.A @@ -1035,8 +1027,7 @@ def append(self, other): if not isinstance(other, StateSpace): other = _convertToStateSpace(other) - if self.dt != other.dt: - raise ValueError("Systems must have the same time step") + self.dt = common_timebase(self.dt, other.dt) n = self.states + other.states m = self.inputs + other.inputs @@ -1151,9 +1142,10 @@ def dcgain(self): return np.squeeze(gain) def is_static_gain(self): - """True if and only if the system has no dynamics, that is, - if A and B are zero. """ - return not np.any(self.A) and not np.any(self.B) + """True if and only if the system has no dynamics, that is, + if A and B are zero. """ + return not np.any(self.A) and not np.any(self.B) + # TODO: add discrete time check def _convertToStateSpace(sys, **kw): @@ -1470,8 +1462,7 @@ def _mimo2simo(sys, input, warn_conversion=False): return sys - -def ss(*args): +def ss(*args, **kwargs): """ss(A, B, C, D[, dt]) Create a state space system. @@ -1516,8 +1507,7 @@ def ss(*args): Output matrix D: array_like or string Feed forward matrix - dt: If present, specifies the sampling period and a discrete time - system is created + dt: If present, specifies the timebase of the system Returns ------- @@ -1548,7 +1538,7 @@ def ss(*args): """ if len(args) == 4 or len(args) == 5: - return StateSpace(*args) + return StateSpace(*args, **kwargs) elif len(args) == 1: from .xferfcn import TransferFunction sys = args[0] @@ -1560,7 +1550,7 @@ def ss(*args): raise TypeError("ss(sys): sys must be a StateSpace or \ TransferFunction object. It is %s." % type(sys)) else: - raise ValueError("Needs 1 or 4 arguments; received %i." % len(args)) + raise ValueError("Needs 1, 4, or 5 arguments; received %i." % len(args)) def tf2ss(*args): diff --git a/control/tests/config_test.py b/control/tests/config_test.py index 3b2a11f12..0e68ec8a7 100644 --- a/control/tests/config_test.py +++ b/control/tests/config_test.py @@ -234,17 +234,14 @@ def test_legacy_defaults(self): @pytest.mark.parametrize("dt", [0, None]) def test_change_default_dt(self, dt): """Test that system with dynamics uses correct default dt""" - ct.set_defaults('statesp', default_dt=dt) + ct.set_defaults('control', default_dt=dt) assert ct.ss(1, 0, 0, 1).dt == dt - ct.set_defaults('xferfcn', default_dt=dt) assert ct.tf(1, [1, 1]).dt == dt + nlsys = ct.iosys.NonlinearIOSystem( + lambda t, x, u: u * x * x, + lambda t, x, u: x, inputs=1, outputs=1) + assert nlsys.dt == dt - # nlsys = ct.iosys.NonlinearIOSystem( - # lambda t, x, u: u * x * x, - # lambda t, x, u: x, inputs=1, outputs=1) - # assert nlsys.dt == dt - - @pytest.mark.skip("implemented in gh-431") def test_change_default_dt_static(self): """Test that static gain systems always have dt=None""" ct.set_defaults('control', default_dt=0) diff --git a/control/tests/discrete_test.py b/control/tests/discrete_test.py index 7aee216d4..3dcbb7f3b 100644 --- a/control/tests/discrete_test.py +++ b/control/tests/discrete_test.py @@ -6,9 +6,10 @@ import numpy as np import pytest -from control import StateSpace, TransferFunction, feedback, step_response, \ - isdtime, timebase, isctime, sample_system, bode, impulse_response, \ - evalfr, timebaseEqual, forced_response, rss +from control import (StateSpace, TransferFunction, bode, common_timebase, + evalfr, feedback, forced_response, impulse_response, + isctime, isdtime, rss, sample_system, step_response, + timebase) class TestDiscrete: @@ -51,13 +52,21 @@ class Tsys: return T - def testTimebaseEqual(self, tsys): - """Test for equal timebases and not so equal ones""" - assert timebaseEqual(tsys.siso_ss1, tsys.siso_tf1) - assert timebaseEqual(tsys.siso_ss1, tsys.siso_ss1c) - assert not timebaseEqual(tsys.siso_ss1d, tsys.siso_ss1c) - assert not timebaseEqual(tsys.siso_ss1d, tsys.siso_ss2d) - assert not timebaseEqual(tsys.siso_ss1d, tsys.siso_ss3d) + def testCompatibleTimebases(self, tsys): + """test that compatible timebases don't throw errors and vice versa""" + common_timebase(tsys.siso_ss1.dt, tsys.siso_tf1.dt) + common_timebase(tsys.siso_ss1.dt, tsys.siso_ss1c.dt) + common_timebase(tsys.siso_ss1d.dt, tsys.siso_ss1.dt) + common_timebase(tsys.siso_ss1.dt, tsys.siso_ss1d.dt) + common_timebase(tsys.siso_ss1.dt, tsys.siso_ss1d.dt) + common_timebase(tsys.siso_ss1d.dt, tsys.siso_ss3d.dt) + common_timebase(tsys.siso_ss3d.dt, tsys.siso_ss1d.dt) + with pytest.raises(ValueError): + # cont + discrete + common_timebase(tsys.siso_ss1d.dt, tsys.siso_ss1c.dt) + with pytest.raises(ValueError): + # incompatible discrete + common_timebase(tsys.siso_ss1d.dt, tsys.siso_ss2d.dt) def testSystemInitialization(self, tsys): # Check to make sure systems are discrete time with proper variables @@ -75,6 +84,18 @@ def testSystemInitialization(self, tsys): assert tsys.siso_tf2d.dt == 0.2 assert tsys.siso_tf3d.dt is True + # keyword argument check + # dynamic systems + assert TransferFunction(1, [1, 1], dt=0.1).dt == 0.1 + assert TransferFunction(1, [1, 1], 0.1).dt == 0.1 + assert StateSpace(1,1,1,1, dt=0.1).dt == 0.1 + assert StateSpace(1,1,1,1, 0.1).dt == 0.1 + # static gain system, dt argument should still override default dt + assert TransferFunction(1, [1,], dt=0.1).dt == 0.1 + assert TransferFunction(1, [1,], 0.1).dt == 0.1 + assert StateSpace(0,0,1,1, dt=0.1).dt == 0.1 + assert StateSpace(0,0,1,1, 0.1).dt == 0.1 + def testCopyConstructor(self, tsys): for sys in (tsys.siso_ss1, tsys.siso_ss1c, tsys.siso_ss1d): newsys = StateSpace(sys) @@ -114,6 +135,7 @@ def test_timebase_conversions(self, tsys): assert timebase(tf1*tf2) == timebase(tf2) assert timebase(tf1*tf3) == timebase(tf3) assert timebase(tf1*tf4) == timebase(tf4) + assert timebase(tf3*tf4) == timebase(tf4) assert timebase(tf2*tf1) == timebase(tf2) assert timebase(tf3*tf1) == timebase(tf3) assert timebase(tf4*tf1) == timebase(tf4) @@ -128,33 +150,36 @@ def test_timebase_conversions(self, tsys): # Make sure discrete time without sampling is converted correctly assert timebase(tf3*tf3) == timebase(tf3) + assert timebase(tf3*tf4) == timebase(tf4) assert timebase(tf3+tf3) == timebase(tf3) + assert timebase(tf3+tf4) == timebase(tf4) assert timebase(feedback(tf3, tf3)) == timebase(tf3) + assert timebase(feedback(tf3, tf4)) == timebase(tf4) # Make sure all other combinations are errors - with pytest.raises(ValueError, match="different sampling times"): + with pytest.raises(ValueError, match="incompatible timebases"): tf2 * tf3 - with pytest.raises(ValueError, match="different sampling times"): + with pytest.raises(ValueError, match="incompatible timebases"): tf3 * tf2 - with pytest.raises(ValueError, match="different sampling times"): + with pytest.raises(ValueError, match="incompatible timebases"): tf2 * tf4 - with pytest.raises(ValueError, match="different sampling times"): + with pytest.raises(ValueError, match="incompatible timebases"): tf4 * tf2 - with pytest.raises(ValueError, match="different sampling times"): + with pytest.raises(ValueError, match="incompatible timebases"): tf2 + tf3 - with pytest.raises(ValueError, match="different sampling times"): + with pytest.raises(ValueError, match="incompatible timebases"): tf3 + tf2 - with pytest.raises(ValueError, match="different sampling times"): + with pytest.raises(ValueError, match="incompatible timebases"): tf2 + tf4 - with pytest.raises(ValueError, match="different sampling times"): + with pytest.raises(ValueError, match="incompatible timebases"): tf4 + tf2 - with pytest.raises(ValueError, match="different sampling times"): + with pytest.raises(ValueError, match="incompatible timebases"): feedback(tf2, tf3) - with pytest.raises(ValueError, match="different sampling times"): + with pytest.raises(ValueError, match="incompatible timebases"): feedback(tf3, tf2) - with pytest.raises(ValueError, match="different sampling times"): + with pytest.raises(ValueError, match="incompatible timebases"): feedback(tf2, tf4) - with pytest.raises(ValueError, match="different sampling times"): + with pytest.raises(ValueError, match="incompatible timebases"): feedback(tf4, tf2) def testisdtime(self, tsys): @@ -212,13 +237,12 @@ def testAddition(self, tsys): sys = tsys.siso_ss1c + tsys.siso_ss1c sys = tsys.siso_ss1d + tsys.siso_ss1d sys = tsys.siso_ss3d + tsys.siso_ss3d + sys = tsys.siso_ss1d + tsys.siso_ss3d with pytest.raises(ValueError): StateSpace.__add__(tsys.mimo_ss1c, tsys.mimo_ss1d) with pytest.raises(ValueError): StateSpace.__add__(tsys.mimo_ss1d, tsys.mimo_ss2d) - with pytest.raises(ValueError): - StateSpace.__add__(tsys.siso_ss1d, tsys.siso_ss3d) # Transfer function addition sys = tsys.siso_tf1 + tsys.siso_tf1d @@ -228,13 +252,12 @@ def testAddition(self, tsys): sys = tsys.siso_tf1c + tsys.siso_tf1c sys = tsys.siso_tf1d + tsys.siso_tf1d sys = tsys.siso_tf2d + tsys.siso_tf2d + sys = tsys.siso_tf1d + tsys.siso_tf3d with pytest.raises(ValueError): TransferFunction.__add__(tsys.siso_tf1c, tsys.siso_tf1d) with pytest.raises(ValueError): TransferFunction.__add__(tsys.siso_tf1d, tsys.siso_tf2d) - with pytest.raises(ValueError): - TransferFunction.__add__(tsys.siso_tf1d, tsys.siso_tf3d) # State space + transfer function sys = tsys.siso_ss1c + tsys.siso_tf1c @@ -252,13 +275,12 @@ def testMultiplication(self, tsys): sys = tsys.siso_ss1d * tsys.siso_ss1 sys = tsys.siso_ss1c * tsys.siso_ss1c sys = tsys.siso_ss1d * tsys.siso_ss1d + sys = tsys.siso_ss1d * tsys.siso_ss3d with pytest.raises(ValueError): StateSpace.__mul__(tsys.mimo_ss1c, tsys.mimo_ss1d) with pytest.raises(ValueError): StateSpace.__mul__(tsys.mimo_ss1d, tsys.mimo_ss2d) - with pytest.raises(ValueError): - StateSpace.__mul__(tsys.siso_ss1d, tsys.siso_ss3d) # Transfer function multiplication sys = tsys.siso_tf1 * tsys.siso_tf1d @@ -267,13 +289,12 @@ def testMultiplication(self, tsys): sys = tsys.siso_tf1d * tsys.siso_tf1 sys = tsys.siso_tf1c * tsys.siso_tf1c sys = tsys.siso_tf1d * tsys.siso_tf1d + sys = tsys.siso_tf1d * tsys.siso_tf3d with pytest.raises(ValueError): TransferFunction.__mul__(tsys.siso_tf1c, tsys.siso_tf1d) with pytest.raises(ValueError): TransferFunction.__mul__(tsys.siso_tf1d, tsys.siso_tf2d) - with pytest.raises(ValueError): - TransferFunction.__mul__(tsys.siso_tf1d, tsys.siso_tf3d) # State space * transfer function sys = tsys.siso_ss1c * tsys.siso_tf1c @@ -293,13 +314,12 @@ def testFeedback(self, tsys): sys = feedback(tsys.siso_ss1d, tsys.siso_ss1) sys = feedback(tsys.siso_ss1c, tsys.siso_ss1c) sys = feedback(tsys.siso_ss1d, tsys.siso_ss1d) + sys = feedback(tsys.siso_ss1d, tsys.siso_ss3d) with pytest.raises(ValueError): feedback(tsys.mimo_ss1c, tsys.mimo_ss1d) with pytest.raises(ValueError): feedback(tsys.mimo_ss1d, tsys.mimo_ss2d) - with pytest.raises(ValueError): - feedback(tsys.siso_ss1d, tsys.siso_ss3d) # Transfer function feedback sys = feedback(tsys.siso_tf1, tsys.siso_tf1d) @@ -308,13 +328,12 @@ def testFeedback(self, tsys): sys = feedback(tsys.siso_tf1d, tsys.siso_tf1) sys = feedback(tsys.siso_tf1c, tsys.siso_tf1c) sys = feedback(tsys.siso_tf1d, tsys.siso_tf1d) + sys = feedback(tsys.siso_tf1d, tsys.siso_tf3d) with pytest.raises(ValueError): feedback(tsys.siso_tf1c, tsys.siso_tf1d) with pytest.raises(ValueError): feedback(tsys.siso_tf1d, tsys.siso_tf2d) - with pytest.raises(ValueError): - feedback(tsys.siso_tf1d, tsys.siso_tf3d) # State space, transfer function sys = feedback(tsys.siso_ss1c, tsys.siso_tf1c) diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index 740416507..faed39e07 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -29,17 +29,17 @@ class TSys: """Return some test systems""" # Create a single input/single output linear system T.siso_linsys = ct.StateSpace( - [[-1, 1], [0, -2]], [[0], [1]], [[1, 0]], [[0]], 0) + [[-1, 1], [0, -2]], [[0], [1]], [[1, 0]], [[0]]) # Create a multi input/multi output linear system T.mimo_linsys1 = ct.StateSpace( [[-1, 1], [0, -2]], [[1, 0], [0, 1]], - [[1, 0], [0, 1]], np.zeros((2, 2)), 0) + [[1, 0], [0, 1]], np.zeros((2, 2))) # Create a multi input/multi output linear system T.mimo_linsys2 = ct.StateSpace( [[-1, 1], [0, -2]], [[0, 1], [1, 0]], - [[1, 0], [0, 1]], np.zeros((2, 2)), 0) + [[1, 0], [0, 1]], np.zeros((2, 2))) # Create simulation parameters T.T = np.linspace(0, 10, 100) @@ -281,7 +281,7 @@ def test_algebraic_loop(self, tsys): linsys = tsys.siso_linsys lnios = ios.LinearIOSystem(linsys) nlios = ios.NonlinearIOSystem(None, \ - lambda t, x, u, params: u*u, inputs=1, outputs=1, dt=0) + lambda t, x, u, params: u*u, inputs=1, outputs=1) nlios1 = nlios.copy() nlios2 = nlios.copy() @@ -310,7 +310,7 @@ def test_algebraic_loop(self, tsys): iosys = ios.InterconnectedSystem( (lnios, nlios), # linear system w/ nonlinear feedback ((1,), # feedback interconnection (sig to 0) - (0, (1, 0, -1))), + (0, (1, 0, -1))), 0, # input to linear system 0 # output from linear system ) @@ -331,7 +331,7 @@ def test_algebraic_loop(self, tsys): # Algebraic loop due to feedthrough term linsys = ct.StateSpace( - [[-1, 1], [0, -2]], [[0], [1]], [[1, 0]], [[1]], 0) + [[-1, 1], [0, -2]], [[0], [1]], [[1, 0]], [[1]]) lnios = ios.LinearIOSystem(linsys) iosys = ios.InterconnectedSystem( (nlios, lnios), # linear system w/ nonlinear feedback @@ -374,7 +374,7 @@ def test_rmul(self, tsys): # Also creates a nested interconnected system ioslin = ios.LinearIOSystem(tsys.siso_linsys) nlios = ios.NonlinearIOSystem(None, \ - lambda t, x, u, params: u*u, inputs=1, outputs=1, dt=0) + lambda t, x, u, params: u*u, inputs=1, outputs=1) sys1 = nlios * ioslin sys2 = ios.InputOutputSystem.__rmul__(nlios, sys1) @@ -414,7 +414,7 @@ def test_feedback(self, tsys): # Linear system with constant feedback (via "nonlinear" mapping) ioslin = ios.LinearIOSystem(tsys.siso_linsys) nlios = ios.NonlinearIOSystem(None, \ - lambda t, x, u, params: u, inputs=1, outputs=1, dt=0) + lambda t, x, u, params: u, inputs=1, outputs=1) iosys = ct.feedback(ioslin, nlios) linsys = ct.feedback(tsys.siso_linsys, 1) @@ -740,7 +740,7 @@ def test_named_signals(self, tsys): inputs = ('u[0]', 'u[1]'), outputs = ('y[0]', 'y[1]'), states = tsys.mimo_linsys1.states, - name = 'sys1', dt=0) + name = 'sys1') sys2 = ios.LinearIOSystem(tsys.mimo_linsys2, inputs = ('u[0]', 'u[1]'), outputs = ('y[0]', 'y[1]'), @@ -1015,7 +1015,7 @@ def test_duplicates(self, tsys): nlios = ios.NonlinearIOSystem(lambda t, x, u, params: x, lambda t, x, u, params: u * u, inputs=1, outputs=1, states=1, - name="sys", dt=0) + name="sys") # Duplicate objects with pytest.warns(UserWarning, match="Duplicate object"): @@ -1033,10 +1033,10 @@ def test_duplicates(self, tsys): iosys_siso = ct.LinearIOSystem(tsys.siso_linsys) nlios1 = ios.NonlinearIOSystem(None, lambda t, x, u, params: u * u, - inputs=1, outputs=1, name="sys", dt=0) + inputs=1, outputs=1, name="sys") nlios2 = ios.NonlinearIOSystem(None, lambda t, x, u, params: u * u, - inputs=1, outputs=1, name="sys", dt=0) + inputs=1, outputs=1, name="sys") with pytest.warns(UserWarning, match="Duplicate name"): ct.InterconnectedSystem((nlios1, iosys_siso, nlios2), @@ -1045,10 +1045,10 @@ def test_duplicates(self, tsys): # Same system, different names => everything should be OK nlios1 = ios.NonlinearIOSystem(None, lambda t, x, u, params: u * u, - inputs=1, outputs=1, name="nlios1", dt=0) + inputs=1, outputs=1, name="nlios1") nlios2 = ios.NonlinearIOSystem(None, lambda t, x, u, params: u * u, - inputs=1, outputs=1, name="nlios2", dt=0) + inputs=1, outputs=1, name="nlios2") with pytest.warns(None) as record: ct.InterconnectedSystem((nlios1, iosys_siso, nlios2), inputs=0, outputs=0, states=0) diff --git a/control/tests/lti_test.py b/control/tests/lti_test.py index 762e1435a..ee9d95a09 100644 --- a/control/tests/lti_test.py +++ b/control/tests/lti_test.py @@ -4,7 +4,7 @@ import pytest from control import c2d, tf, tf2ss, NonlinearIOSystem -from control.lti import (LTI, damp, dcgain, isctime, isdtime, +from control.lti import (LTI, common_timebase, damp, dcgain, isctime, isdtime, issiso, pole, timebaseEqual, zero) from control.tests.conftest import slycotonly @@ -72,3 +72,84 @@ def test_dcgain(self): sys = tf(84, [1, 2]) np.testing.assert_equal(sys.dcgain(), 42) np.testing.assert_equal(dcgain(sys), 42) + + @pytest.mark.parametrize("dt1, dt2, expected", + [(None, None, True), + (None, 0, True), + (None, 1, True), + pytest.param(None, True, True, + marks=pytest.mark.xfail( + reason="returns false")), + (0, 0, True), + (0, 1, False), + (0, True, False), + (1, 1, True), + (1, 2, False), + (1, True, False), + (True, True, True)]) + def test_timebaseEqual_deprecated(self, dt1, dt2, expected): + """Test that timbaseEqual throws a warning and returns as documented""" + sys1 = tf([1], [1, 2, 3], dt1) + sys2 = tf([1], [1, 4, 5], dt2) + + print(sys1.dt) + print(sys2.dt) + + with pytest.deprecated_call(): + assert timebaseEqual(sys1, sys2) is expected + # Make sure behaviour is symmetric + with pytest.deprecated_call(): + assert timebaseEqual(sys2, sys1) is expected + + @pytest.mark.parametrize("dt1, dt2, expected", + [(None, None, None), + (None, 0, 0), + (None, 1, 1), + (None, True, True), + (True, True, True), + (True, 1, 1), + (1, 1, 1), + (0, 0, 0), + ]) + @pytest.mark.parametrize("sys1", [True, False]) + @pytest.mark.parametrize("sys2", [True, False]) + def test_common_timebase(self, dt1, dt2, expected, sys1, sys2): + """Test that common_timbase adheres to :ref:`conventions-ref`""" + i1 = tf([1], [1, 2, 3], dt1) if sys1 else dt1 + i2 = tf([1], [1, 4, 5], dt2) if sys2 else dt2 + assert common_timebase(i1, i2) == expected + # Make sure behaviour is symmetric + assert common_timebase(i2, i1) == expected + + @pytest.mark.parametrize("i1, i2", + [(True, 0), + (0, 1), + (1, 2)]) + def test_common_timebase_errors(self, i1, i2): + """Test that common_timbase throws errors on invalid combinations""" + with pytest.raises(ValueError): + common_timebase(i1, i2) + # Make sure behaviour is symmetric + with pytest.raises(ValueError): + common_timebase(i2, i1) + + @pytest.mark.parametrize("dt, ref, strictref", + [(None, True, False), + (0, False, False), + (1, True, True), + (True, True, True)]) + @pytest.mark.parametrize("objfun, arg", + [(LTI, ()), + (NonlinearIOSystem, (lambda x: x, ))]) + def test_isdtime(self, objfun, arg, dt, ref, strictref): + """Test isdtime and isctime functions to follow convention""" + obj = objfun(*arg, dt=dt) + + assert isdtime(obj) == ref + assert isdtime(obj, strict=True) == strictref + + if dt is not None: + ref = not ref + strictref = not strictref + assert isctime(obj) == ref + assert isctime(obj, strict=True) == strictref diff --git a/control/tests/matlab_test.py b/control/tests/matlab_test.py index 6c7f6f14f..3a15a5aff 100644 --- a/control/tests/matlab_test.py +++ b/control/tests/matlab_test.py @@ -736,20 +736,20 @@ def testCombi01(self): margin command should remove the solution for w = nearly zero. """ # Example is a concocted two-body satellite with flexible link - Jb = 400; - Jp = 1000; - k = 10; - b = 5; + Jb = 400 + Jp = 1000 + k = 10 + b = 5 # can now define an "s" variable, to make TF's - s = tf([1, 0], [1]); - hb1 = 1/(Jb*s); - hb2 = 1/s; - hp1 = 1/(Jp*s); - hp2 = 1/s; + s = tf([1, 0], [1]) + hb1 = 1/(Jb*s) + hb2 = 1/s + hp1 = 1/(Jp*s) + hp2 = 1/s # convert to ss and append - sat0 = append(ss(hb1), ss(hb2), k, b, ss(hp1), ss(hp2)); + sat0 = append(ss(hb1), ss(hb2), k, b, ss(hp1), ss(hp2)) # connection of the elements with connect call Q = [[1, -3, -4], # link moment (spring, damper), feedback to body @@ -758,9 +758,9 @@ def testCombi01(self): [4, 1, -5], # damper input [5, 3, 4], # link moment, acting on payload [6, 5, 0]] - inputs = [1]; - outputs = [1, 2, 5, 6]; - sat1 = connect(sat0, Q, inputs, outputs); + inputs = [1] + outputs = [1, 2, 5, 6] + sat1 = connect(sat0, Q, inputs, outputs) # matched notch filter wno = 0.19 diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index 3fcf5b45b..a69672d36 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -81,13 +81,16 @@ def sys623(self): @pytest.mark.parametrize( "dt", - [(None, ), (0, ), (1, ), (0.1, ), (True, )], + [(), (None, ), (0, ), (1, ), (0.1, ), (True, )], ids=lambda i: "dt " + ("unspec" if len(i) == 0 else str(i[0]))) @pytest.mark.parametrize( "argfun", [pytest.param( lambda ABCDdt: (ABCDdt, {}), id="A, B, C, D[, dt]"), + pytest.param( + lambda ABCDdt: (ABCDdt[:4], {'dt': dt_ for dt_ in ABCDdt[4:]}), + id="A, B, C, D[, dt=dt]"), pytest.param( lambda ABCDdt: ((StateSpace(*ABCDdt), ), {}), id="sys") @@ -107,7 +110,7 @@ def test_constructor(self, sys322ABCD, dt, argfun): @pytest.mark.parametrize("args, exc, errmsg", [((True, ), TypeError, "(can only take in|sys must be) a StateSpace"), - ((1, 2), ValueError, "1 or 4 arguments"), + ((1, 2), ValueError, "1, 4, or 5 arguments"), ((np.ones((3, 2)), np.ones((3, 2)), np.ones((2, 2)), np.ones((2, 2))), ValueError, "A must be square"), @@ -131,6 +134,16 @@ def test_constructor_invalid(self, args, exc, errmsg): with pytest.raises(exc, match=errmsg): ss(*args) + def test_constructor_warns(self, sys322ABCD): + """Test ambiguos input to StateSpace() constructor""" + with pytest.warns(UserWarning, match="received multiple dt"): + sys = StateSpace(*(sys322ABCD + (0.1, )), dt=0.2) + np.testing.assert_almost_equal(sys.A, sys322ABCD[0]) + np.testing.assert_almost_equal(sys.B, sys322ABCD[1]) + np.testing.assert_almost_equal(sys.C, sys322ABCD[2]) + np.testing.assert_almost_equal(sys.D, sys322ABCD[3]) + assert sys.dt == 0.1 + def test_copy_constructor(self): """Test the copy constructor""" # Create a set of matrices for a simple linear system @@ -152,6 +165,19 @@ def test_copy_constructor(self): linsys.A[0, 0] = -3 np.testing.assert_array_equal(cpysys.A, [[-1]]) # original value + def test_copy_constructor_nodt(self, sys322): + """Test the copy constructor when an object without dt is passed""" + sysin = sample_system(sys322, 1.) + del sysin.dt + sys = StateSpace(sysin) + assert sys.dt == defaults['control.default_dt'] + + # test for static gain + sysin = StateSpace([], [], [], [[1, 2], [3, 4]], 1.) + del sysin.dt + sys = StateSpace(sysin) + assert sys.dt is None + def test_matlab_style_constructor(self): """Use (deprecated) matrix-style construction string""" with pytest.deprecated_call(): @@ -354,7 +380,6 @@ def test_freq_resp(self): np.testing.assert_almost_equal(phase, true_phase) np.testing.assert_equal(omega, true_omega) - @pytest.mark.skip("is_static_gain is introduced in gh-431") def test_is_static_gain(self): A0 = np.zeros((2,2)) A1 = A0.copy() diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py index 62c4bfb23..c0b5e227f 100644 --- a/control/tests/xferfcn_test.py +++ b/control/tests/xferfcn_test.py @@ -13,6 +13,7 @@ from control.tests.conftest import slycotonly, nopython2, matrixfilter from control.lti import isctime, isdtime from control.dtime import sample_system +from control.config import defaults class TestXferFcn: @@ -85,6 +86,28 @@ def test_constructor_zero_denominator(self): TransferFunction([[[1.], [2., 3.]], [[-1., 4.], [3., 2.]]], [[[1., 0.], [0.]], [[0., 0.], [2.]]]) + def test_constructor_nodt(self): + """Test the constructor when an object without dt is passed""" + sysin = TransferFunction([[[0., 1.], [2., 3.]]], + [[[5., 2.], [3., 0.]]]) + del sysin.dt + sys = TransferFunction(sysin) + assert sys.dt == defaults['control.default_dt'] + + # test for static gain + sysin = TransferFunction([[[2.], [3.]]], + [[[1.], [.1]]]) + del sysin.dt + sys = TransferFunction(sysin) + assert sys.dt is None + + def test_constructor_double_dt(self): + """Test that providing dt as arg and kwarg prefers arg with warning""" + with pytest.warns(UserWarning, match="received multiple dt.*" + "using positional arg"): + sys = TransferFunction(1, [1, 2, 3], 0.1, dt=0.2) + assert sys.dt == 0.1 + def test_add_inconsistent_dimension(self): """Add two transfer function matrices of different sizes.""" sys1 = TransferFunction([[[1., 2.]]], [[[4., 5.]]]) @@ -409,7 +432,6 @@ def test_evalfr_siso(self, dt, omega, resp): resp, atol=1e-3) - @pytest.mark.skip("is_static_gain is introduced in gh-431") def test_is_static_gain(self): numstatic = 1.1 denstatic = 1.2 diff --git a/control/xferfcn.py b/control/xferfcn.py index 4077080e3..93743deb1 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -63,18 +63,16 @@ from warnings import warn from itertools import chain from re import sub -from .lti import LTI, timebaseEqual, timebase, isdtime +from .lti import LTI, common_timebase, isdtime from . import config __all__ = ['TransferFunction', 'tf', 'ss2tf', 'tfdata'] # Define module default parameter values -_xferfcn_defaults = { - 'xferfcn.default_dt': None} +_xferfcn_defaults = {} class TransferFunction(LTI): - """TransferFunction(num, den[, dt]) A class for representing transfer functions @@ -90,13 +88,22 @@ class TransferFunction(LTI): means that the numerator of the transfer function from the 6th input to the 3rd output is set to s^2 + 4s + 8. - Discrete-time transfer functions are implemented by using the 'dt' - instance variable and setting it to something other than 'None'. If 'dt' - has a non-zero value, then it must match whenever two transfer functions - are combined. If 'dt' is set to True, the system will be treated as a - discrete time system with unspecified sampling time. The default value of - 'dt' is None and can be changed by changing the value of - ``control.config.defaults['xferfcn.default_dt']``. + A discrete time transfer function is created by specifying a nonzero + 'timebase' dt when the system is constructed: + + * dt = 0: continuous time system (default) + * dt > 0: discrete time system with sampling period 'dt' + * dt = True: discrete time with unspecified sampling period + * dt = None: no timebase specified + + Systems must have compatible timebases in order to be combined. A discrete + time system with unspecified sampling time (`dt = True`) can be combined + with a system having a specified sampling time; the result will be a + discrete time system with the sample time of the latter system. Similarly, + a system with timebase `None` can be combined with a system having any + timebase; the result will have the timebase of the latter system. + The default value of dt can be changed by changing the value of + ``control.config.defaults['control.default_dt']``. The TransferFunction class defines two constants ``s`` and ``z`` that represent the differentiation and delay operators in continuous and @@ -105,9 +112,9 @@ class TransferFunction(LTI): >>> s = TransferFunction.s >>> G = (s + 1)/(s**2 + 2*s + 1) - """ - def __init__(self, *args): + + def __init__(self, *args, **kwargs): """TransferFunction(num, den[, dt]) Construct a transfer function. @@ -125,7 +132,6 @@ def __init__(self, *args): if len(args) == 2: # The user provided a numerator and a denominator. (num, den) = args - dt = config.defaults['xferfcn.default_dt'] elif len(args) == 3: # Discrete time transfer function (num, den, dt) = args @@ -137,11 +143,6 @@ def __init__(self, *args): % type(args[0])) num = args[0].num den = args[0].den - # TODO: not sure this can ever happen since dt is always present - try: - dt = args[0].dt - except NameError: # pragma: no coverage - dt = config.defaults['xferfcn.default_dt'] else: raise ValueError("Needs 1, 2 or 3 arguments; received %i." % len(args)) @@ -199,12 +200,37 @@ def __init__(self, *args): if zeronum: den[i][j] = ones(1) - LTI.__init__(self, inputs, outputs, dt) + LTI.__init__(self, inputs, outputs) self.num = num self.den = den self._truncatecoeff() + # get dt + if len(args) == 2: + # no dt given in positional arguments + if 'dt' in kwargs: + dt = kwargs['dt'] + elif self.is_static_gain(): + dt = None + else: + dt = config.defaults['control.default_dt'] + elif len(args) == 3: + # Discrete time transfer function + if 'dt' in kwargs: + warn('received multiple dt arguments, ' + 'using positional arg dt=%s' % dt) + elif len(args) == 1: + # TODO: not sure this can ever happen since dt is always present + try: + dt = args[0].dt + except AttributeError: + if self.is_static_gain(): + dt = None + else: + dt = config.defaults['control.default_dt'] + self.dt = dt + def __call__(self, s): """Evaluate the system's transfer function for a complex variable @@ -370,14 +396,7 @@ def __add__(self, other): "The first summand has %i output(s), but the second has %i." % (self.outputs, other.outputs)) - # Figure out the sampling time to use - if self.dt is None and other.dt is not None: - dt = other.dt # use dt from second argument - elif (other.dt is None and self.dt is not None) or \ - (timebaseEqual(self, other)): - dt = self.dt # use dt from first argument - else: - raise ValueError("Systems have different sampling times") + dt = common_timebase(self.dt, other.dt) # Preallocate the numerator and denominator of the sum. num = [[[] for j in range(self.inputs)] for i in range(self.outputs)] @@ -421,14 +440,7 @@ def __mul__(self, other): inputs = other.inputs outputs = self.outputs - # Figure out the sampling time to use - if self.dt is None and other.dt is not None: - dt = other.dt # use dt from second argument - elif (other.dt is None and self.dt is not None) or \ - (self.dt == other.dt): - dt = self.dt # use dt from first argument - else: - raise ValueError("Systems have different sampling times") + dt = common_timebase(self.dt, other.dt) # Preallocate the numerator and denominator of the sum. num = [[[0] for j in range(inputs)] for i in range(outputs)] @@ -472,14 +484,7 @@ def __rmul__(self, other): inputs = self.inputs outputs = other.outputs - # Figure out the sampling time to use - if self.dt is None and other.dt is not None: - dt = other.dt # use dt from second argument - elif (other.dt is None and self.dt is not None) \ - or (self.dt == other.dt): - dt = self.dt # use dt from first argument - else: - raise ValueError("Systems have different sampling times") + dt = common_timebase(self.dt, other.dt) # Preallocate the numerator and denominator of the sum. num = [[[0] for j in range(inputs)] for i in range(outputs)] @@ -519,14 +524,7 @@ def __truediv__(self, other): "TransferFunction.__truediv__ is currently \ implemented only for SISO systems.") - # Figure out the sampling time to use - if self.dt is None and other.dt is not None: - dt = other.dt # use dt from second argument - elif (other.dt is None and self.dt is not None) or \ - (self.dt == other.dt): - dt = self.dt # use dt from first argument - else: - raise ValueError("Systems have different sampling times") + dt = common_timebase(self.dt, other.dt) num = polymul(self.num[0][0], other.den[0][0]) den = polymul(self.den[0][0], other.num[0][0]) @@ -626,9 +624,8 @@ def _evalfr(self, omega): # 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): + 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 @@ -692,9 +689,8 @@ def freqresp(self, omega): # 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: + 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]) @@ -737,15 +733,7 @@ def feedback(self, other=1, sign=-1): raise NotImplementedError( "TransferFunction.feedback is currently only implemented " "for SISO functions.") - - # Figure out the sampling time to use - if self.dt is None and other.dt is not None: - dt = other.dt # use dt from second argument - elif (other.dt is None and self.dt is not None) or \ - (self.dt == other.dt): - dt = self.dt # use dt from first argument - else: - raise ValueError("Systems have different sampling times") + dt = common_timebase(self.dt, other.dt) num1 = self.num[0][0] den1 = self.den[0][0] @@ -1048,7 +1036,7 @@ def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None): Returns ------- - sysd : StateSpace system + sysd : TransferFunction system Discrete time system, with sampling rate Ts Notes @@ -1115,16 +1103,16 @@ def _dcgain_cont(self): return np.squeeze(gain) def is_static_gain(self): - """returns True if and only if all of the numerator and denominator - polynomials of the (possibly MIMO) transfer function are zeroth order, + """returns True if and only if all of the numerator and denominator + polynomials of the (possibly MIMO) transfer function are zeroth order, that is, if the system has no dynamics. """ - for list_of_polys in self.num, self.den: + for list_of_polys in self.num, self.den: for row in list_of_polys: for poly in row: - if len(poly) > 1: + if len(poly) > 1: return False return True - + # c2d function contributed by Benjamin White, Oct 2012 def _c2d_matched(sysC, Ts): # Pole-zero match method of continuous to discrete time conversion @@ -1335,7 +1323,7 @@ def _convert_to_transfer_function(sys, **kw): raise TypeError("Can't convert given type to TransferFunction system.") -def tf(*args): +def tf(*args, **kwargs): """tf(num, den[, dt]) Create a transfer function system. Can create MIMO systems. @@ -1425,7 +1413,7 @@ def tf(*args): """ if len(args) == 2 or len(args) == 3: - return TransferFunction(*args) + return TransferFunction(*args, **kwargs) elif len(args) == 1: # Look for special cases defining differential/delay operator if args[0] == 's': @@ -1446,7 +1434,7 @@ def tf(*args): raise ValueError("Needs 1 or 2 arguments; received %i." % len(args)) -def ss2tf(*args): +def ss2tf(*args, **kwargs): """ss2tf(sys) Transform a state space system to a transfer function. @@ -1511,7 +1499,7 @@ def ss2tf(*args): from .statesp import StateSpace if len(args) == 4 or len(args) == 5: # Assume we were given the A, B, C, D matrix and (optional) dt - return _convert_to_transfer_function(StateSpace(*args)) + return _convert_to_transfer_function(StateSpace(*args, **kwargs)) elif len(args) == 1: sys = args[0] @@ -1597,7 +1585,6 @@ def _clean_part(data): return data - # Define constants to represent differentiation, unit delay TransferFunction.s = TransferFunction([1, 0], [1], 0) TransferFunction.z = TransferFunction([1, 0], [1], True) diff --git a/doc/conventions.rst b/doc/conventions.rst index 99789bc9e..4a3d78926 100644 --- a/doc/conventions.rst +++ b/doc/conventions.rst @@ -80,27 +80,24 @@ Discrete time systems A discrete time system is created by specifying a nonzero 'timebase', dt. The timebase argument can be given when a system is constructed: -* dt = None: no timebase specified (default) -* dt = 0: continuous time system +* dt = 0: continuous time system (default) * dt > 0: discrete time system with sampling period 'dt' * dt = True: discrete time with unspecified sampling period +* dt = None: no timebase specified Only the :class:`StateSpace`, :class:`TransferFunction`, and :class:`InputOutputSystem` classes allow explicit representation of discrete time systems. -Systems must have compatible timebases in order to be combined. A system -with timebase `None` can be combined with a system having a specified -timebase; the result will have the timebase of the latter system. -Similarly, a discrete time system with unspecified sampling time (`dt = -True`) can be combined with a system having a specified sampling time; -the result will be a discrete time system with the sample time of the latter -system. For continuous time systems, the :func:`sample_system` function or -the :meth:`StateSpace.sample` and :meth:`TransferFunction.sample` methods +Systems must have compatible timebases in order to be combined. A discrete time +system with unspecified sampling time (`dt = True`) can be combined with a system +having a specified sampling time; the result will be a discrete time system with the sample time of the latter +system. Similarly, a system with timebase `None` can be combined with a system having a specified +timebase; the result will have the timebase of the latter system. For continuous +time systems, the :func:`sample_system` function or the :meth:`StateSpace.sample` and :meth:`TransferFunction.sample` methods can be used to create a discrete time system from a continuous time system. See :ref:`utility-and-conversions`. The default value of 'dt' can be changed by -changing the values of ``control.config.defaults['statesp.default_dt']`` and -``control.config.defaults['xferfcn.default_dt']``. +changing the value of ``control.config.defaults['control.default_dt']``. Conversion between representations ----------------------------------