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 ----------------------------------
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: