From 00030472fefea87c2c0da4746b9eba2c622980e6 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 7 Dec 2024 08:26:14 -0800 Subject: [PATCH 01/16] add shape property to iosys --- control/iosys.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/control/iosys.py b/control/iosys.py index dd1566eb9..79ce64c71 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -432,6 +432,11 @@ def find_states(self, name_list): lambda self: list(self.state_index.keys()), # getter set_states) # setter + @property + def shape(self): + """2-tuple of I/O system dimension, (noutputs, ninputs).""" + return (self.noutputs, self.ninputs) + # TODO: add dict as a means to selective change names? [GH #1019] def update_names(self, **kwargs): """update_names([name, inputs, outputs, states]) From 6c852c3db7bf82836f0226dcb995b01061299794 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 20 Dec 2024 11:40:37 -0800 Subject: [PATCH 02/16] update FRD to copy signal/system names on sampling --- control/frdata.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/control/frdata.py b/control/frdata.py index ac032d3f7..e18d90363 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -21,8 +21,8 @@ from . import config from .exception import pandas_check -from .iosys import InputOutputSystem, NamedSignal, _process_iosys_keywords, \ - _process_subsys_index, common_timebase +from .iosys import InputOutputSystem, NamedSignal, _extended_system_name, \ + _process_iosys_keywords, _process_subsys_index, common_timebase from .lti import LTI, _process_frequency_response __all__ = ['FrequencyResponseData', 'FRD', 'frd'] @@ -195,6 +195,14 @@ def __init__(self, *args, **kwargs): self.fresp = otherlti(z, squeeze=False) arg_dt = otherlti.dt + # Copy over signal and system names, if not specified + kwargs['inputs'] = kwargs.get('inputs', otherlti.input_labels) + kwargs['outputs'] = kwargs.get( + 'outputs', otherlti.output_labels) + if not otherlti._generic_name_check(): + kwargs['name'] = kwargs.get('name', _extended_system_name( + otherlti.name, prefix_suffix_name='sampled')) + else: # The user provided a response and a freq vector self.fresp = array(args[0], dtype=complex, ndmin=1) @@ -219,6 +227,10 @@ def __init__(self, *args, **kwargs): self.fresp = args[0].fresp arg_dt = args[0].dt + # Copy over signal and system names, if not specified + kwargs['inputs'] = kwargs.get('inputs', args[0].input_labels) + kwargs['outputs'] = kwargs.get('outputs', args[0].output_labels) + else: raise ValueError( "Needs 1 or 2 arguments; received %i." % len(args)) @@ -249,7 +261,11 @@ def __init__(self, *args, **kwargs): # Process iosys keywords defaults = { - 'inputs': self.fresp.shape[1], 'outputs': self.fresp.shape[0]} + 'inputs': self.fresp.shape[1] if not getattr( + self, 'input_index', None) else self.input_labels, + 'outputs': self.fresp.shape[0] if not getattr( + self, 'output_index', None) else self.output_labels, + 'name': getattr(self, 'name', None)} if arg_dt is not None: defaults['dt'] = arg_dt # choose compatible timebase name, inputs, outputs, states, dt = _process_iosys_keywords( From 6df091928493338b1ea2a96c91063f5778356554 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Wed, 11 Dec 2024 20:19:32 -0800 Subject: [PATCH 03/16] convert internal representation of TF num/den to ndarray (vs list^2) --- control/bdalg.py | 8 +- control/statesp.py | 2 +- control/tests/bdalg_test.py | 8 +- control/tests/xferfcn_input_test.py | 4 +- control/tests/xferfcn_test.py | 34 ++-- control/xferfcn.py | 279 +++++++++++++++------------- 6 files changed, 177 insertions(+), 158 deletions(-) diff --git a/control/bdalg.py b/control/bdalg.py index d907cd3c5..f6fa7d27f 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -600,8 +600,8 @@ def combine_tf(tf_array): f"row {row_index}." ) for j_in in range(col.ninputs): - num_row.append(col.num[j_out][j_in]) - den_row.append(col.den[j_out][j_in]) + num_row.append(col._num[j_out, j_in]) + den_row.append(col._den[j_out, j_in]) num.append(num_row) den.append(den_row) for row_index, row in enumerate(num): @@ -657,8 +657,8 @@ def split_tf(transfer_function): for i_in in range(transfer_function.ninputs): row.append( tf.TransferFunction( - transfer_function.num[i_out][i_in], - transfer_function.den[i_out][i_in], + transfer_function._num[i_out, i_in], + transfer_function._den[i_out, i_in], dt=transfer_function.dt, ) ) diff --git a/control/statesp.py b/control/statesp.py index 7af9008f4..54d0c03ca 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -2291,7 +2291,7 @@ def _convert_to_statespace(sys, use_prefix_suffix=False, method=None): D = empty((sys.noutputs, sys.ninputs), dtype=float) for i, j in itertools.product(range(sys.noutputs), range(sys.ninputs)): - D[i, j] = sys.num[i][j][0] / sys.den[i][j][0] + D[i, j] = sys._num[i, j][0] / sys._den[i, j][0] newsys = StateSpace([], [], [], D, sys.dt) else: if not issiso(sys): diff --git a/control/tests/bdalg_test.py b/control/tests/bdalg_test.py index 8ea67e0f7..43a7a229a 100644 --- a/control/tests/bdalg_test.py +++ b/control/tests/bdalg_test.py @@ -903,15 +903,15 @@ def _tf_close_coeff(tf_a, tf_b, rtol=1e-5, atol=1e-8): for i in range(tf_a.noutputs): for j in range(tf_a.ninputs): if not np.allclose( - tf_a.num[i][j], - tf_b.num[i][j], + tf_a._num[i, j], + tf_b._num[i, j], rtol=rtol, atol=atol, ): return False if not np.allclose( - tf_a.den[i][j], - tf_b.den[i][j], + tf_a._den[i, j], + tf_b._den[i, j], rtol=rtol, atol=atol, ): diff --git a/control/tests/xferfcn_input_test.py b/control/tests/xferfcn_input_test.py index 46efbd257..970385a1e 100644 --- a/control/tests/xferfcn_input_test.py +++ b/control/tests/xferfcn_input_test.py @@ -64,8 +64,8 @@ def test_clean_part(num, fun, dtype): num_ = _clean_part(numa) ref_ = np.array(num, dtype=float, ndmin=3) - assert isinstance(num_, list) - assert np.all([isinstance(part, list) for part in num_]) + assert isinstance(num_, np.ndarray) + assert num_.ndim == 2 for i, numi in enumerate(num_): assert len(numi) == ref_.shape[1] for j, numj in enumerate(numi): diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py index d480cef6e..9066616a8 100644 --- a/control/tests/xferfcn_test.py +++ b/control/tests/xferfcn_test.py @@ -190,8 +190,8 @@ def test_reverse_sign_mimo(self): for i in range(sys3.noutputs): for j in range(sys3.ninputs): - np.testing.assert_allclose(sys2.num[i][j], sys3.num[i][j]) - np.testing.assert_allclose(sys2.den[i][j], sys3.den[i][j]) + np.testing.assert_allclose(sys2._num[i, j], sys3._num[i, j]) + np.testing.assert_allclose(sys2._den[i, j], sys3._den[i, j]) # Tests for TransferFunction.__add__ @@ -236,8 +236,8 @@ def test_add_mimo(self): for i in range(sys3.noutputs): for j in range(sys3.ninputs): - np.testing.assert_allclose(sys3.num[i][j], num3[i][j]) - np.testing.assert_allclose(sys3.den[i][j], den3[i][j]) + np.testing.assert_allclose(sys3._num[i, j], num3[i][j]) + np.testing.assert_allclose(sys3._den[i, j], den3[i][j]) # Tests for TransferFunction.__sub__ @@ -284,8 +284,8 @@ def test_subtract_mimo(self): for i in range(sys3.noutputs): for j in range(sys3.ninputs): - np.testing.assert_allclose(sys3.num[i][j], num3[i][j]) - np.testing.assert_allclose(sys3.den[i][j], den3[i][j]) + np.testing.assert_allclose(sys3._num[i, j], num3[i][j]) + np.testing.assert_allclose(sys3._den[i, j], den3[i][j]) # Tests for TransferFunction.__mul__ @@ -340,8 +340,8 @@ def test_multiply_mimo(self): for i in range(sys3.noutputs): for j in range(sys3.ninputs): - np.testing.assert_allclose(sys3.num[i][j], num3[i][j]) - np.testing.assert_allclose(sys3.den[i][j], den3[i][j]) + np.testing.assert_allclose(sys3._num[i, j], num3[i][j]) + np.testing.assert_allclose(sys3._den[i, j], den3[i][j]) # Tests for TransferFunction.__div__ @@ -662,10 +662,10 @@ def test_convert_to_transfer_function(self): for i in range(sys.noutputs): for j in range(sys.ninputs): - np.testing.assert_array_almost_equal(tfsys.num[i][j], - num[i][j]) - np.testing.assert_array_almost_equal(tfsys.den[i][j], - den[i][j]) + np.testing.assert_array_almost_equal( + tfsys._num[i, j], num[i][j]) + np.testing.assert_array_almost_equal( + tfsys._den[i, j], den[i][j]) def test_minreal(self): """Try the minreal function, and also test easy entry by creation @@ -1121,8 +1121,10 @@ def test_repr(self, Hargs, ref): H2 = eval(H.__repr__()) for p in range(len(H.num)): for m in range(len(H.num[0])): - np.testing.assert_array_almost_equal(H.num[p][m], H2.num[p][m]) - np.testing.assert_array_almost_equal(H.den[p][m], H2.den[p][m]) + np.testing.assert_array_almost_equal( + H._num[p, m], H2._num[p, m]) + np.testing.assert_array_almost_equal( + H._den[p, m], H2._den[p, m]) assert H.dt == H2.dt def test_sample_named_signals(self): @@ -1180,8 +1182,8 @@ def test_returnScipySignalLTI(self, mimotf): sslti = mimotf.returnScipySignalLTI(strict=False) for i in range(2): for j in range(3): - np.testing.assert_allclose(sslti[i][j].num, mimotf.num[i][j]) - np.testing.assert_allclose(sslti[i][j].den, mimotf.den[i][j]) + np.testing.assert_allclose(sslti[i][j].num, mimotf._num[i, j]) + np.testing.assert_allclose(sslti[i][j].den, mimotf._den[i, j]) if mimotf.dt == 0: assert sslti[i][j].dt is None else: diff --git a/control/xferfcn.py b/control/xferfcn.py index 5304ea636..1d776fecd 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -49,7 +49,7 @@ from collections.abc import Iterable from copy import deepcopy -from itertools import chain +from itertools import chain, product from re import sub from warnings import warn @@ -108,8 +108,9 @@ class TransferFunction(LTI): ---------- ninputs, noutputs, nstates : int Number of input, output and state variables. - num, den : 2D list of array - Polynomial coefficients of the numerator and denominator. + _num, _den : 2D array of array + Polynomial coefficients of the numerator and denominator. Access + as lists via `num` and `den` properties. dt : None, True or float System timebase. 0 (default) indicates continuous time, True indicates discrete time with unspecified sampling time, positive number is @@ -118,14 +119,17 @@ class TransferFunction(LTI): Notes ----- - The attribues 'num' and 'den' are 2-D lists of arrays containing MIMO + The attribues '_num' and '_den' are 2D arrays of arrays containing MIMO numerator and denominator coefficients. For example, - >>> num[2][5] = numpy.array([1., 4., 8.]) # doctest: +SKIP + >>> _num[2, 5] = numpy.array([1., 4., 8.]) # doctest: +SKIP means that the numerator of the transfer function from the 6th input to the 3rd output is set to s^2 + 4s + 8. + For backward compatibility, the numerator and denominator coeffients + can be accessed as lists using the `num` and `den` properties. + A discrete time transfer function is created by specifying a nonzero 'timebase' dt when the system is constructed: @@ -210,8 +214,8 @@ def __init__(self, *args, **kwargs): raise TypeError("Needs 1, 2 or 3 arguments; received %i." % len(args)) - num = _clean_part(num) - den = _clean_part(den) + num = _clean_part(num, "numerator") + den = _clean_part(den, "denominator") # # Process keyword arguments @@ -230,13 +234,12 @@ def __init__(self, *args, **kwargs): # Determine if the transfer function is static (needed for dt) static = True - for col in num + den: - for poly in col: - if len(poly) > 1: - static = False + for i, j in product(range(num.shape[0]), range(num.shape[1])): + if num[i, j].size > 1 or den[i, j].size > 1: + static = False defaults = args[0] if len(args) == 1 else \ - {'inputs': len(num[0]), 'outputs': len(num)} + {'inputs': num.shape[1], 'outputs': num.shape[0]} name, inputs, outputs, states, dt = _process_iosys_keywords( kwargs, defaults, static=static) @@ -252,27 +255,17 @@ def __init__(self, *args, **kwargs): # Check to make sure everything is consistent # # Make sure numerator and denominator matrices have consistent sizes - if self.ninputs != len(den[0]): + if self.ninputs != den.shape[1]: raise ValueError( "The numerator has %i input(s), but the denominator has " - "%i input(s)." % (self.ninputs, len(den[0]))) - if self.noutputs != len(den): + "%i input(s)." % (self.ninputs, den.shape[1])) + if self.noutputs != den.shape[0]: raise ValueError( "The numerator has %i output(s), but the denominator has " - "%i output(s)." % (self.noutputs, len(den))) + "%i output(s)." % (self.noutputs, den.shape[0])) # Additional checks/updates on structure of the transfer function for i in range(self.noutputs): - # Make sure that each row has the same number of columns - if len(num[i]) != self.ninputs: - raise ValueError( - "Row 0 of the numerator matrix has %i elements, but row " - "%i has %i." % (self.ninputs, i, len(num[i]))) - if len(den[i]) != self.ninputs: - raise ValueError( - "Row 0 of the denominator matrix has %i elements, but row " - "%i has %i." % (self.ninputs, i, len(den[i]))) - # Check for zeros in numerator or denominator # TODO: Right now these checks are only done during construction. # It might be worthwhile to think of a way to perform checks if the @@ -280,8 +273,8 @@ def __init__(self, *args, **kwargs): for j in range(self.ninputs): # Check that we don't have any zero denominators. zeroden = True - for k in den[i][j]: - if k: + for k in den[i, j]: + if np.any(k): zeroden = False break if zeroden: @@ -291,16 +284,16 @@ def __init__(self, *args, **kwargs): # If we have zero numerators, set the denominator to 1. zeronum = True - for k in num[i][j]: - if k: + for k in num[i, j]: + if np.any(k): zeronum = False break if zeronum: den[i][j] = ones(1) # Store the numerator and denominator - self.num = num - self.den = den + self._num = num + self._den = den # # Final processing @@ -327,25 +320,29 @@ def __init__(self, *args, **kwargs): #: Transfer function numerator polynomial (array) #: - #: The numerator of the transfer function is stored as an 2D list of - #: arrays containing MIMO numerator coefficients, indexed by outputs and - #: inputs. For example, ``num[2][5]`` is the array of coefficients for - #: the numerator of the transfer function from the sixth input to the - #: third output. + #: The numerator of the transfer function can be accessed as a 2D list + #: of arrays containing MIMO numerator coefficients, indexed by outputs + #: and inputs. For example, ``num[2][5]`` is the array of coefficients + #: for the numerator of the transfer function from the sixth input to + #: the third output. #: #: :meta hide-value: - num = [[0]] + @property + def num(self): + return self._num.tolist() #: Transfer function denominator polynomial (array) #: - #: The numerator of the transfer function is store as an 2D list of - #: arrays containing MIMO numerator coefficients, indexed by outputs and - #: inputs. For example, ``den[2][5]`` is the array of coefficients for - #: the denominator of the transfer function from the sixth input to the - #: third output. + #: The denominator of the transfer function can be accessed as a 2D list + #: of arrays containing MIMO numerator coefficients, indexed by outputs + #: and inputs. For example, ``den[2][5]`` is the array of coefficients + #: for the denominator of the transfer function from the sixth input to + #: the third output. #: #: :meta hide-value: - den = [[0]] + @property + def den(self): + return self._den.tolist() def __call__(self, x, squeeze=None, warn_infinite=True): """Evaluate system's transfer function at complex frequencies. @@ -427,8 +424,8 @@ def horner(self, x, warn_infinite=True): with np.errstate(all='warn' if warn_infinite else 'ignore'): for i in range(self.noutputs): for j in range(self.ninputs): - out[i][j] = (polyval(self.num[i][j], x_arr) / - polyval(self.den[i][j], x_arr)) + out[i][j] = (polyval(self._num[i, j], x_arr) / + polyval(self._den[i, j], x_arr)) return out def _truncatecoeff(self): @@ -441,14 +438,14 @@ def _truncatecoeff(self): """ # Beware: this is a shallow copy. This should be okay. - data = [self.num, self.den] + data = [self._num, self._den] for p in range(len(data)): for i in range(self.noutputs): for j in range(self.ninputs): # Find the first nontrivial coefficient. nonzero = None - for k in range(data[p][i][j].size): - if data[p][i][j][k]: + for k in range(data[p][i, j].size): + if data[p][i, j][k]: nonzero = k break @@ -458,7 +455,7 @@ def _truncatecoeff(self): else: # Truncate the trivial coefficients. data[p][i][j] = data[p][i][j][nonzero:] - [self.num, self.den] = data + [self._num, self._den] = data def __str__(self, var=None): """String representation of the transfer function. @@ -478,16 +475,18 @@ def __str__(self, var=None): # Convert the numerator and denominator polynomials to strings. if self.display_format == 'poly': - numstr = _tf_polynomial_to_string(self.num[no][ni], var=var) - denstr = _tf_polynomial_to_string(self.den[no][ni], var=var) + numstr = _tf_polynomial_to_string( + self._num[no, ni], var=var) + denstr = _tf_polynomial_to_string( + self._den[no, ni], var=var) elif self.display_format == 'zpk': - num = self.num[no][ni] + num = self._num[no, ni] if num.size == 1 and num.item() == 0: # Catch a special case that SciPy doesn't handle - z, p, k = tf2zpk([1.], self.den[no][ni]) + z, p, k = tf2zpk([1.], self._den[no, ni]) k = 0 else: - z, p, k = tf2zpk(self.num[no][ni], self.den[no][ni]) + z, p, k = tf2zpk(self.num[no][ni], self._den[no, ni]) numstr = _tf_factorized_polynomial_to_string( z, gain=k, var=var) denstr = _tf_factorized_polynomial_to_string(p, var=var) @@ -541,10 +540,12 @@ def _repr_latex_(self, var=None): for ni in range(self.ninputs): # Convert the numerator and denominator polynomials to strings. if self.display_format == 'poly': - numstr = _tf_polynomial_to_string(self.num[no][ni], var=var) - denstr = _tf_polynomial_to_string(self.den[no][ni], var=var) + numstr = _tf_polynomial_to_string( + self._num[no, ni], var=var) + denstr = _tf_polynomial_to_string( + self._den[no, ni], var=var) elif self.display_format == 'zpk': - z, p, k = tf2zpk(self.num[no][ni], self.den[no][ni]) + z, p, k = tf2zpk(self._num[no, ni], self._den[no, ni]) numstr = _tf_factorized_polynomial_to_string( z, gain=k, var=var) denstr = _tf_factorized_polynomial_to_string(p, var=var) @@ -573,10 +574,10 @@ def _repr_latex_(self, var=None): def __neg__(self): """Negate a transfer function.""" - num = deepcopy(self.num) + num = deepcopy(self._num) for i in range(self.noutputs): for j in range(self.ninputs): - num[i][j] *= -1 + num[i, j] *= -1 return TransferFunction(num, self.den, self.dt) def __add__(self, other): @@ -606,14 +607,14 @@ def __add__(self, other): dt = common_timebase(self.dt, other.dt) # Preallocate the numerator and denominator of the sum. - num = [[[] for j in range(self.ninputs)] for i in range(self.noutputs)] - den = [[[] for j in range(self.ninputs)] for i in range(self.noutputs)] + num = _create_poly_array((self.noutputs, self.ninputs)) + den = _create_poly_array((self.noutputs, self.ninputs)) for i in range(self.noutputs): for j in range(self.ninputs): - num[i][j], den[i][j] = _add_siso( - self.num[i][j], self.den[i][j], - other.num[i][j], other.den[i][j]) + num[i, j], den[i, j] = _add_siso( + self._num[i, j], self._den[i, j], + other._num[i, j], other._den[i, j]) return TransferFunction(num, den, dt) @@ -648,14 +649,14 @@ def __mul__(self, other): "C = A * B: A has %i column(s) (input(s)), but B has %i " "row(s)\n(output(s))." % (self.ninputs, other.noutputs)) - inputs = other.ninputs - outputs = self.noutputs + ninputs = other.ninputs + noutputs = self.noutputs 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)] - den = [[[1] for j in range(inputs)] for i in range(outputs)] + num = _create_poly_array((noutputs, ninputs), [0]) + den = _create_poly_array((noutputs, ninputs), [1]) # Temporary storage for the summands needed to find the (i, j)th # element of the product. @@ -663,17 +664,17 @@ def __mul__(self, other): den_summand = [[] for k in range(self.ninputs)] # Multiply & add. - for row in range(outputs): - for col in range(inputs): + for row in range(noutputs): + for col in range(ninputs): for k in range(self.ninputs): num_summand[k] = polymul( - self.num[row][k], other.num[k][col]) + self._num[row, k], other._num[k, col]) den_summand[k] = polymul( - self.den[row][k], other.den[k][col]) - num[row][col], den[row][col] = _add_siso( - num[row][col], den[row][col], + self._den[row, k], other._den[k, col]) + num[row, col], den[row, col] = _add_siso( + num[row, col], den[row, col], num_summand[k], den_summand[k]) - + print(f"{row}, {col}, {k}: {num=}, {den=}") return TransferFunction(num, den, dt) def __rmul__(self, other): @@ -692,14 +693,14 @@ def __rmul__(self, other): "C = A * B: A has %i column(s) (input(s)), but B has %i " "row(s)\n(output(s))." % (other.ninputs, self.noutputs)) - inputs = self.ninputs - outputs = other.noutputs + ninputs = self.ninputs + noutputs = other.noutputs 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)] - den = [[[1] for j in range(inputs)] for i in range(outputs)] + num = _create_poly_array((noutputs, ninputs), [0]) + den = _create_poly_array((noutputs, ninputs), [1]) # Temporary storage for the summands needed to find the # (i, j)th element @@ -707,13 +708,13 @@ def __rmul__(self, other): num_summand = [[] for k in range(other.ninputs)] den_summand = [[] for k in range(other.ninputs)] - for i in range(outputs): # Iterate through rows of product. - for j in range(inputs): # Iterate through columns of product. + for i in range(noutputs): # Iterate through rows of product. + for j in range(ninputs): # Iterate through columns of product. for k in range(other.ninputs): # Multiply & add. - num_summand[k] = polymul(other.num[i][k], self.num[k][j]) - den_summand[k] = polymul(other.den[i][k], self.den[k][j]) + num_summand[k] = polymul(other._num[i, k], self._num[k, j]) + den_summand[k] = polymul(other._den[i, k], self._den[k, j]) num[i][j], den[i][j] = _add_siso( - num[i][j], den[i][j], + num[i, j], den[i, j], num_summand[k], den_summand[k]) return TransferFunction(num, den, dt) @@ -736,8 +737,8 @@ def __truediv__(self, other): 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]) + num = polymul(self._num[0, 0], other._den[0, 0]) + den = polymul(self._den[0, 0], other._num[0, 0]) return TransferFunction(num, den, dt) @@ -785,15 +786,14 @@ def __getitem__(self, key): indices[1], self.input_labels, slice_to_list=True) # Construct the transfer function for the subsyste - num, den = [], [] - for i in outdx: - num_i = [] - den_i = [] - for j in inpdx: - num_i.append(self.num[i][j]) - den_i.append(self.den[i][j]) - num.append(num_i) - den.append(den_i) + num = _create_poly_array((len(outputs), len(inputs))) + den = _create_poly_array(num.shape) + for row, i in enumerate(outdx): + for col, j in enumerate(inpdx): + num[row, col] = self._num[i, j] + den[row, col] = self._den[i, j] + col += 1 + row += 1 # Create the system name sysname = config.defaults['iosys.indexed_system_name_prefix'] + \ @@ -832,7 +832,7 @@ def zeros(self): "for SISO systems.") else: # for now, just give zeros of a SISO tf - return roots(self.num[0][0]).astype(complex) + return roots(self._num[0, 0]).astype(complex) def feedback(self, other=1, sign=-1): """Feedback interconnection between two LTI objects.""" @@ -846,10 +846,10 @@ def feedback(self, other=1, sign=-1): "MIMO systems.") dt = common_timebase(self.dt, other.dt) - num1 = self.num[0][0] - den1 = self.den[0][0] - num2 = other.num[0][0] - den2 = other.den[0][0] + num1 = self._num[0, 0] + den1 = self._den[0, 0] + num2 = other._num[0, 0] + den2 = other._den[0, 0] num = polymul(num1, den2) den = polyadd(polymul(den2, den1), -sign * polymul(num2, num1)) @@ -870,17 +870,17 @@ def minreal(self, tol=None): sqrt_eps = sqrt(float_info.epsilon) # pre-allocate arrays - num = [[[] for j in range(self.ninputs)] for i in range(self.noutputs)] - den = [[[] for j in range(self.ninputs)] for i in range(self.noutputs)] + num = _create_poly_array((self.noutputs, self.ninputs)) + den = _create_poly_array((self.noutputs, self.ninputs)) for i in range(self.noutputs): for j in range(self.ninputs): # split up in zeros, poles and gain newzeros = [] - zeros = roots(self.num[i][j]) - poles = roots(self.den[i][j]) - gain = self.num[i][j][0] / self.den[i][j][0] + zeros = roots(self._num[i, j]) + poles = roots(self._den[i, j]) + gain = self._num[i, j][0] / self._den[i, j][0] # check all zeros for z in zeros: @@ -895,19 +895,19 @@ def minreal(self, tol=None): newzeros.append(z) # poly([]) returns a scalar, but we always want a 1d array - num[i][j] = np.atleast_1d(gain * real(poly(newzeros))) - den[i][j] = np.atleast_1d(real(poly(poles))) + num[i, j] = np.atleast_1d(gain * real(poly(newzeros))) + den[i, j] = np.atleast_1d(real(poly(poles))) # end result return TransferFunction(num, den, self.dt) def returnScipySignalLTI(self, strict=True): - """Return a list of a list of :class:`scipy.signal.lti` objects. + """Return a 2D array of :class:`scipy.signal.lti` objects. For instance, >>> out = tfobject.returnScipySignalLTI() # doctest: +SKIP - >>> out[3][5] # doctest: +SKIP + >>> out[3, 5] # doctest: +SKIP is a :class:`scipy.signal.lti` object corresponding to the transfer function from the 6th input to the 4th output. @@ -1846,7 +1846,7 @@ def tfdata(sys): return tf.num, tf.den -def _clean_part(data): +def _clean_part(data, name=""): """ Return a valid, cleaned up numerator or denominator for the TransferFunction class. @@ -1862,27 +1862,36 @@ def _clean_part(data): valid_types = (int, float, complex, np.number) valid_collection = (list, tuple, ndarray) - if (isinstance(data, valid_types) or + if isinstance(data, np.ndarray) and data.ndim == 2 and \ + data.dtype == object and isinstance(data[0, 0], np.ndarray): + # Data is already in the right format + return data + elif isinstance(data, ndarray) and data.ndim == 3 and \ + isinstance(data[0, 0, 0], valid_types): + out = np.empty(data.shape[0:2], dtype=np.ndarray) + for i, j in product(range(out.shape[0]), range(out.shape[1])): + out[i, j] = data[i, j, :] + elif (isinstance(data, valid_types) or (isinstance(data, ndarray) and data.ndim == 0)): # Data is a scalar (including 0d ndarray) - data = [[array([data])]] - elif (isinstance(data, ndarray) and data.ndim == 3 and - isinstance(data[0, 0, 0], valid_types)): - data = [[array(data[i, j]) - for j in range(data.shape[1])] - for i in range(data.shape[0])] + out = np.empty((1,1), dtype=np.ndarray) + out[0, 0] = array([data]) elif (isinstance(data, valid_collection) and all([isinstance(d, valid_types) for d in data])): - data = [[array(data)]] + out = np.empty((1,1), dtype=np.ndarray) + out[0, 0] = array(data) elif (isinstance(data, (list, tuple)) and isinstance(data[0], (list, tuple)) and (isinstance(data[0][0], valid_collection) and all([isinstance(d, valid_types) for d in data[0][0]]))): - data = list(data) - for j in range(len(data)): - data[j] = list(data[j]) - for k in range(len(data[j])): - data[j][k] = array(data[j][k]) + out = np.empty((len(data), len(data[0])), dtype=np.ndarray) + for i in range(out.shape[0]): + if len(data[i]) != out.shape[1]: + raise ValueError( + "Row 0 of the %s matrix has %i elements, but row " + "%i has %i." % (name, out.shape[1], i, len(data[i]))) + for j in range(out.shape[1]): + out[i, j] = array(data[i][j]) else: # If the user passed in anything else, then it's unclear what # the meaning is. @@ -1891,13 +1900,12 @@ def _clean_part(data): "(for\nSISO), or lists of lists of vectors (for SISO or MIMO).") # Check for coefficients that are ints and convert to floats - for i in range(len(data)): - for j in range(len(data[i])): - for k in range(len(data[i][j])): - if isinstance(data[i][j][k], (int, np.int32, np.int64)): - data[i][j][k] = float(data[i][j][k]) - - return data + for i in range(out.shape[0]): + for j in range(out.shape[1]): + for k in range(len(out[i, j])): + if isinstance(out[i, j][k], (int, np.int32, np.int64)): + out[i, j][k] = float(out[i, j][k]) + return out # Define constants to represent differentiation, unit delay @@ -1908,3 +1916,12 @@ def _clean_part(data): def _float2str(value): _num_format = config.defaults.get('xferfcn.floating_point_format', ':.4g') return f"{value:{_num_format}}" + + +def _create_poly_array(shape, default=None): + out = np.empty(shape, dtype=np.ndarray) + if default is not None: + default = np.array(default) + for i, j in product(range(shape[0]), range(shape[1])): + out[i, j] = default + return out From 9f8ff405d90300b9824c37aa642228f26cfd7f22 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 14 Dec 2024 06:52:37 -0800 Subject: [PATCH 04/16] add tf() support for array of SISO [combine_tf] + documentation updates --- control/tests/docstrings_test.py | 2 +- control/tests/xferfcn_input_test.py | 5 +- control/tests/xferfcn_test.py | 37 +++- control/xferfcn.py | 253 ++++++++++++++-------------- 4 files changed, 168 insertions(+), 129 deletions(-) diff --git a/control/tests/docstrings_test.py b/control/tests/docstrings_test.py index 991ead3e5..f05bbd2e9 100644 --- a/control/tests/docstrings_test.py +++ b/control/tests/docstrings_test.py @@ -37,7 +37,7 @@ control.series: '9aede1459667738f05cf4fc46603a4f6', control.ss: '1b9cfad5dbdf2f474cfdeadf5cb1ad80', control.ss2tf: '48ff25d22d28e7b396e686dd5eb58831', - control.tf: '53a13f4a7f75a31c81800e10c88730ef', + control.tf: '155a19afb95452ed19966c8d8ae23a84', control.tf2ss: '086a3692659b7321c2af126f79f4bc11', control.markov: 'a4199c54cb50f07c0163d3790739eafe', control.gangof4: '0e52eb6cf7ce024f9a41f3ae3ebf04f7', diff --git a/control/tests/xferfcn_input_test.py b/control/tests/xferfcn_input_test.py index 970385a1e..c375d768a 100644 --- a/control/tests/xferfcn_input_test.py +++ b/control/tests/xferfcn_input_test.py @@ -72,7 +72,10 @@ def test_clean_part(num, fun, dtype): np.testing.assert_allclose(numj, ref_[i, j, ...]) -@pytest.mark.parametrize("badinput", [[[0., 1.], [2., 3.]], "a"]) +@pytest.mark.parametrize("badinput", [ + # [[0., 1.], [2., 3.]], # OK: treated as static array + np.ones((2, 2, 2, 2)), + "a"]) def test_clean_part_bad_input(badinput): """Give the part cleaner invalid input type.""" with pytest.raises(TypeError): diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py index 9066616a8..3956cfa0a 100644 --- a/control/tests/xferfcn_test.py +++ b/control/tests/xferfcn_test.py @@ -31,8 +31,9 @@ class TestXferFcn: def test_constructor_bad_input_type(self): """Give the constructor invalid input types.""" # MIMO requires lists of lists of vectors (not lists of vectors) - with pytest.raises(TypeError): - TransferFunction([[0., 1.], [2., 3.]], [[5., 2.], [3., 0.]]) + # 13 Dec 2024: This now works correctly: creates static array (as tf) + # with pytest.raises(TypeError): + # TransferFunction([[0., 1.], [2., 3.]], [[5., 2.], [3., 0.]]) # good input TransferFunction([[[0., 1.], [2., 3.]]], [[[5., 2.], [3., 0.]]]) @@ -1288,3 +1289,35 @@ def test_copy_names(create, args, kwargs, convert): cpy = convert(sys, inputs='myin', outputs='myout') assert cpy.input_labels == ['myin'] assert cpy.output_labels == ['myout'] + +s = ct.TransferFunction.s +@pytest.mark.parametrize("args, num, den", [ + (('s', ), [[[1, 0]]], [[[1]]]), # ctime + (('z', ), [[[1, 0]]], [[[1]]]), # dtime + ((1, 1), [[[1]]], [[[1]]]), # scalars as scalars + (([[1]], [[1]]), [[[1]]], [[[1]]]), # scalars as lists + (([[[1, 2]]], [[[3, 4]]]), [[[1, 2]]], [[[3, 4]]]), # SISO as lists + (([[np.array([1, 2])]], [[np.array([3, 4])]]), # SISO as arrays + [[[1, 2]]], [[[3, 4]]]), + (([[ [1], [2] ], [[1, 1], [1, 0] ]], # MIMO + [[ [1, 0], [1, 0] ], [[1, 2], [1] ]]), + [[ [1], [2] ], [[1, 1], [1, 0] ]], + [[ [1, 0], [1, 0] ], [[1, 2], [1] ]]), + (([[[1, 2], [3, 4]]], [[[5, 6]]]), # common denominator + [[[1, 2], [3, 4]]], [[[5, 6], [5, 6]]]), + (([ [1/s, 2/s], [(s+1)/(s+2), s]], ), # 2x2 from SISO + [[ [1], [2] ], [[1, 1], [1, 0] ]], # num + [[ [1, 0], [1, 0] ], [[1, 2], [1] ]]), # den + (([[1, 2], [3, 4]], [[[1, 0], [1, 0]]]), ValueError, + r"numerator has 2 output\(s\), but the denominator has 1 output"), +]) +def test_tf_args(args, num, den): + if isinstance(num, type): + exception, match = num, den + with pytest.raises(exception, match=match): + sys = ct.tf(*args) + else: + sys = ct.tf(*args) + chk = ct.tf(num, den) + np.testing.assert_equal(sys.num, chk.num) + np.testing.assert_equal(sys.den, chk.den) diff --git a/control/xferfcn.py b/control/xferfcn.py index 1d776fecd..20bb3bc8d 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -7,45 +7,9 @@ for the python-control library. """ -"""Copyright (c) 2010 by California Institute of Technology -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions -are met: - -1. Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - -3. Neither the name of the California Institute of Technology nor - the names of its contributors may be used to endorse or promote - products derived from this software without specific prior - written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL CALTECH -OR THE CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF -USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT -OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF -SUCH DAMAGE. - -Author: Richard M. Murray -Date: 24 May 09 -Revised: Kevin K. Chen, Dec 10 - -$Id$ - -""" +# Author: Richard M. Murray +# Date: 24 May 09 +# Revised: Kevin K. Chen, Dec 2010 from collections.abc import Iterable from copy import deepcopy @@ -85,20 +49,20 @@ class TransferFunction(LTI): A class for representing transfer functions. The TransferFunction class is used to represent systems in transfer - function form. + function form. Transfer functions are usually created with the + :func:`~control.tf` factory function. Parameters ---------- - num : array_like, or list of list of array_like - Polynomial coefficients of the numerator - den : array_like, or list of list of array_like - Polynomial coefficients of the denominator + num : 2D list of coefficient arrays + Polynomial coefficients of the numerator. + den : 2D list of coefficient arrays + Polynomial coefficients of the denominator. dt : None, True or float, optional - 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). + 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). display_format : None, 'poly' or 'zpk', optional Set the display format used in printing the TransferFunction object. Default behavior is polynomial display and can be changed by @@ -108,27 +72,26 @@ class TransferFunction(LTI): ---------- ninputs, noutputs, nstates : int Number of input, output and state variables. - _num, _den : 2D array of array - Polynomial coefficients of the numerator and denominator. Access - as lists via `num` and `den` properties. - dt : None, True or float - 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). + input_labels, output_labels, state_labels : list of str + Signal labels for the system. + name : string, optional + System name (used for specifying signals). Notes ----- - The attribues '_num' and '_den' are 2D arrays of arrays containing MIMO - numerator and denominator coefficients. For example, + The attributes 'num' and 'den' are properties that return 3D nested lists + containing MIMO numerator and denominator coefficients. For example, - >>> _num[2, 5] = numpy.array([1., 4., 8.]) # doctest: +SKIP + >>> sys.num[2][5] # doctest: +SKIP - means that the numerator of the transfer function from the 6th input to - the 3rd output is set to s^2 + 4s + 8. + gives the numerator of the transfer function from the 6th input to the + 3rd output. - For backward compatibility, the numerator and denominator coeffients - can be accessed as lists using the `num` and `den` properties. + Internally, the numerator and denominator polynomials are stored as 2D + ndarrays with each element containing a 1D ndarray of coefficients. + These data structures can be retrieved using ``_num`` and ``_den``. (A + single 3D ndarray structure cannot be used because the numerators and + denominators can have different numbers of coefficients in each entry.) A discrete time transfer function is created by specifying a nonzero 'timebase' dt when the system is constructed: @@ -176,7 +139,7 @@ def __init__(self, *args, **kwargs): Construct a transfer function. The default constructor is TransferFunction(num, den), where num and - den are lists of lists of arrays containing polynomial coefficients. + den are 2D arrays of arrays containing polynomial coefficients. To create a discrete time transfer funtion, use TransferFunction(num, den, dt) where 'dt' is the sampling time (or True for unspecified sampling time). To call the copy constructor, call @@ -234,9 +197,10 @@ def __init__(self, *args, **kwargs): # Determine if the transfer function is static (needed for dt) static = True - for i, j in product(range(num.shape[0]), range(num.shape[1])): - if num[i, j].size > 1 or den[i, j].size > 1: - static = False + for arr in [num, den]: + for poly in np.nditer(arr, flags=['refs_ok']): + if poly.item().size > 1: + static = False defaults = args[0] if len(args) == 1 else \ {'inputs': num.shape[1], 'outputs': num.shape[0]} @@ -318,33 +282,19 @@ def __init__(self, *args, **kwargs): #: :meta hide-value: noutputs = 1 - #: Transfer function numerator polynomial (array) - #: - #: The numerator of the transfer function can be accessed as a 2D list - #: of arrays containing MIMO numerator coefficients, indexed by outputs - #: and inputs. For example, ``num[2][5]`` is the array of coefficients - #: for the numerator of the transfer function from the sixth input to - #: the third output. - #: - #: :meta hide-value: + # Numerator and denominator as lists of lists of lists @property def num(self): + """Numerator polynomial (as 3D nested lists).""" return self._num.tolist() - #: Transfer function denominator polynomial (array) - #: - #: The denominator of the transfer function can be accessed as a 2D list - #: of arrays containing MIMO numerator coefficients, indexed by outputs - #: and inputs. For example, ``den[2][5]`` is the array of coefficients - #: for the denominator of the transfer function from the sixth input to - #: the third output. - #: - #: :meta hide-value: @property def den(self): + """Denominator polynomial (as 3D nested lists).""" return self._den.tolist() def __call__(self, x, squeeze=None, warn_infinite=True): + """Evaluate system's transfer function at complex frequencies. Returns the complex frequency response `sys(x)` where `x` is `s` for @@ -511,7 +461,7 @@ def __str__(self, var=None): # represent to implement a re-loadable version def __repr__(self): - """Print transfer function in loadable form""" + """Print transfer function in loadable form.""" if self.issiso(): return "TransferFunction({num}, {den}{dt})".format( num=self.num[0][0].__repr__(), den=self.den[0][0].__repr__(), @@ -524,7 +474,7 @@ def __repr__(self): else '') def _repr_latex_(self, var=None): - """LaTeX representation of transfer function, for Jupyter notebook""" + """LaTeX representation of transfer function, for Jupyter notebook.""" mimo = not self.issiso() @@ -674,7 +624,6 @@ def __mul__(self, other): num[row, col], den[row, col] = _add_siso( num[row, col], den[row, col], num_summand[k], den_summand[k]) - print(f"{row}, {col}, {k}: {num=}, {den=}") return TransferFunction(num, den, dt) def __rmul__(self, other): @@ -862,7 +811,7 @@ def feedback(self, other=1, sign=-1): # large. def minreal(self, tol=None): - """Remove cancelling pole/zero pairs from a transfer function""" + """Remove cancelling pole/zero pairs from a transfer function.""" # based on octave minreal # default accuracy @@ -1116,7 +1065,7 @@ def _common_den(self, imag_tol=None, allow_nonproper=False): def sample(self, Ts, method='zoh', alpha=None, prewarp_frequency=None, name=None, copy_names=True, **kwargs): - """Convert a continuous-time system to discrete time + """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. @@ -1263,7 +1212,7 @@ def _isstatic(self): # the class attributes are set at the bottom of the file to avoid problems # with recursive calls. - #: Differentation operator (continuous time) + #: Differentation operator (continuous time). #: #: The ``s`` constant can be used to create continuous time transfer #: functions using algebraic expressions. @@ -1276,7 +1225,7 @@ def _isstatic(self): #: :meta hide-value: s = None - #: Delay operator (discrete time) + #: Delay operator (discrete time). #: #: The ``z`` constant can be used to create discrete time transfer #: functions using algebraic expressions. @@ -1320,7 +1269,7 @@ def _c2d_matched(sysC, Ts, **kwargs): # Utility function to convert a transfer function polynomial to a string # Borrowed from poly1d library def _tf_polynomial_to_string(coeffs, var='s'): - """Convert a transfer function polynomial to a string""" + """Convert a transfer function polynomial to a string.""" thestr = "0" @@ -1367,7 +1316,7 @@ def _tf_polynomial_to_string(coeffs, var='s'): def _tf_factorized_polynomial_to_string(roots, gain=1, var='s'): - """Convert a factorized polynomial to a string""" + """Convert a factorized polynomial to a string.""" if roots.size == 0: return _float2str(gain) @@ -1410,9 +1359,11 @@ def _tf_factorized_polynomial_to_string(roots, gain=1, var='s'): def _tf_string_to_latex(thestr, var='s'): - """ make sure to superscript all digits in a polynomial string - and convert float coefficients in scientific notation - to prettier LaTeX representation """ + """Superscript all digits in a polynomial string and convert float + coefficients in scientific notation to prettier LaTeX + representation. + + """ # TODO: make the multiplication sign configurable expmul = r' \\times' thestr = sub(var + r'\^(\d{2,})', var + r'^{\1}', thestr) @@ -1555,15 +1506,20 @@ def tf(*args, **kwargs): If `num` and `den` are 1D array_like objects, the function creates a SISO system. - To create a MIMO system, `num` and `den` need to be 2D nested lists - of array_like objects. (A 3 dimensional data structure in total.) - (For details see note below.) + To create a MIMO system, `num` and `den` need to be 2D arrays of + of array_like objects (a 3 dimensional data structure in total; + for details see note below). If the denominator for all transfer + function is the same, `den` can be specified as a 1D array. ``tf(num, den, dt)`` Create a discrete time transfer function system; dt can either be a positive number indicating the sampling time or 'True' if no specific timebase is given. + ``tf([[G11, ..., G1m], ..., [Gp1, ..., Gpm]][, dt])`` + Create a pxm MIMO system from SISO transfer functions Gij. See + :func:`combine_tf` for more details. + ``tf('s')`` or ``tf('z')`` Create a transfer function representing the differential operator ('s') or delay operator ('z'). @@ -1571,20 +1527,27 @@ def tf(*args, **kwargs): Parameters ---------- sys : LTI (StateSpace or TransferFunction) - A linear system + A linear system that will be converted to a transfer function. + arr : 2D list of TransferFunction + 2D list of SISO transfer functions to create MIMO transfer function. num : array_like, or list of list of array_like - Polynomial coefficients of the numerator + Polynomial coefficients of the numerator. den : array_like, or list of list of array_like - Polynomial coefficients of the denominator + Polynomial coefficients of the denominator. + dt : None, True or float, optional + 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). display_format : None, 'poly' or 'zpk' Set the display format used in printing the TransferFunction object. Default behavior is polynomial display and can be changed by - changing config.defaults['xferfcn.display_format'].. + changing config.defaults['xferfcn.display_format']. Returns ------- out: :class:`TransferFunction` - The new linear system + The new linear system. Other Parameters ---------------- @@ -1599,9 +1562,9 @@ def tf(*args, **kwargs): Raises ------ ValueError - if `num` and `den` have invalid or unequal dimensions + If `num` and `den` have invalid or unequal dimensions. TypeError - if `num` or `den` are of incorrect type + If `num` or `den` are of incorrect type. See Also -------- @@ -1612,9 +1575,10 @@ def tf(*args, **kwargs): Notes ----- + MIMO transfer functions are created by passing a 2D array of coeffients: ``num[i][j]`` contains the polynomial coefficients of the numerator - for the transfer function from the (j+1)st input to the (i+1)st output. - ``den[i][j]`` works the same way. + for the transfer function from the (j+1)st input to the (i+1)st output, + and ``den[i][j]`` works the same way. The list ``[2, 3, 4]`` denotes the polynomial :math:`2s^2 + 3s + 4`. @@ -1639,11 +1603,7 @@ def tf(*args, **kwargs): >>> sys_tf = ct.tf(sys_ss) """ - - if len(args) == 2 or len(args) == 3: - return TransferFunction(*args, **kwargs) - - elif len(args) == 1 and isinstance(args[0], str): + if len(args) == 1 and isinstance(args[0], str): # Make sure there were no extraneous keywords if kwargs: raise TypeError("unrecognized keywords: ", str(kwargs)) @@ -1654,19 +1614,53 @@ def tf(*args, **kwargs): elif args[0] == 'z': return TransferFunction.z + elif len(args) == 1 and isinstance(args[0], list): + # Allow passing an array of SISO transfer functions + from .bdalg import combine_tf + return combine_tf(*args) + elif len(args) == 1: from .statesp import StateSpace - sys = args[0] - if isinstance(sys, StateSpace): + if isinstance(sys := args[0], StateSpace): return ss2tf(sys, **kwargs) elif isinstance(sys, TransferFunction): # Use copy constructor return TransferFunction(sys, **kwargs) + elif isinstance(data := args[0], np.ndarray) and data.ndim == 2 or \ + isinstance(data, list) and isinstance(data[0], list): + raise NotImplementedError( + "arrays of transfer functions not (yet) supported") else: raise TypeError("tf(sys): sys must be a StateSpace or " "TransferFunction object. It is %s." % type(sys)) - else: - raise ValueError("Needs 1 or 2 arguments; received %i." % len(args)) + + elif len(args) == 3: + if 'dt' in kwargs: + warn("received multiple dt arguments, " + f"using positional arg {args[2]}") + kwargs['dt'] = args[2] + args = args[:2] + + elif len(args) != 2: + raise ValueError("Needs 1, 2, or 3 arguments; received %i." % len(args)) + + # + # Process the numerator and denominator arguments + # + # If we got through to here, we have two argume nts (num, den) and + # the keywords (including dt). The only thing left to do is look + # for some special cases, like having a common denominator. + # + num, den = args + + num = _clean_part(num, "numerator") + den = _clean_part(den, "denominator") + + if den.size == 1 and num.size > 1: + # Broadcast denominator to shape of numerator + den = np.broadcast_to(den, num.shape).copy() + + return TransferFunction(num, den, **kwargs) def zpk(zeros, poles, gain, *args, **kwargs): @@ -1880,10 +1874,11 @@ def _clean_part(data, name=""): all([isinstance(d, valid_types) for d in data])): out = np.empty((1,1), dtype=np.ndarray) out[0, 0] = array(data) - elif (isinstance(data, (list, tuple)) and - isinstance(data[0], (list, tuple)) and - (isinstance(data[0][0], valid_collection) and - all([isinstance(d, valid_types) for d in data[0][0]]))): + elif isinstance(data, (list, tuple)) and \ + isinstance(data[0], (list, tuple)) and \ + (isinstance(data[0][0], valid_collection) and + all([isinstance(d, valid_types) for d in data[0][0]]) or \ + isinstance(data[0][0], valid_types)): out = np.empty((len(data), len(data[0])), dtype=np.ndarray) for i in range(out.shape[0]): if len(data[i]) != out.shape[1]: @@ -1891,7 +1886,7 @@ def _clean_part(data, name=""): "Row 0 of the %s matrix has %i elements, but row " "%i has %i." % (name, out.shape[1], i, len(data[i]))) for j in range(out.shape[1]): - out[i, j] = array(data[i][j]) + out[i, j] = np.atleast_1d(data[i][j]) else: # If the user passed in anything else, then it's unclear what # the meaning is. @@ -1908,9 +1903,17 @@ def _clean_part(data, name=""): return out -# Define constants to represent differentiation, unit delay +# +# Define constants to represent differentiation, unit delay. +# +# Set the docstring explicitly to avoid having Sphinx document this as +# a method instead of a property/attribute. + TransferFunction.s = TransferFunction([1, 0], [1], 0, name='s') +TransferFunction.s.__doc__ = "Differentation operator (continuous time)." + TransferFunction.z = TransferFunction([1, 0], [1], True, name='z') +TransferFunction.z.__doc__ = "Delay operator (discrete time)." def _float2str(value): From a7fd763a3c25040a082a1886394a59a796ebfc81 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sun, 15 Dec 2024 16:22:03 -0800 Subject: [PATCH 05/16] change num/den and _num/_den to num/den_{list,array} --- control/bdalg.py | 8 +- control/tests/bdalg_test.py | 8 +- control/tests/xferfcn_test.py | 32 ++++---- control/xferfcn.py | 138 +++++++++++++++++++++------------- 4 files changed, 111 insertions(+), 75 deletions(-) diff --git a/control/bdalg.py b/control/bdalg.py index f6fa7d27f..2dbd5c8e9 100644 --- a/control/bdalg.py +++ b/control/bdalg.py @@ -600,8 +600,8 @@ def combine_tf(tf_array): f"row {row_index}." ) for j_in in range(col.ninputs): - num_row.append(col._num[j_out, j_in]) - den_row.append(col._den[j_out, j_in]) + num_row.append(col.num_array[j_out, j_in]) + den_row.append(col.den_array[j_out, j_in]) num.append(num_row) den.append(den_row) for row_index, row in enumerate(num): @@ -657,8 +657,8 @@ def split_tf(transfer_function): for i_in in range(transfer_function.ninputs): row.append( tf.TransferFunction( - transfer_function._num[i_out, i_in], - transfer_function._den[i_out, i_in], + transfer_function.num_array[i_out, i_in], + transfer_function.den_array[i_out, i_in], dt=transfer_function.dt, ) ) diff --git a/control/tests/bdalg_test.py b/control/tests/bdalg_test.py index 43a7a229a..e0d64f018 100644 --- a/control/tests/bdalg_test.py +++ b/control/tests/bdalg_test.py @@ -903,15 +903,15 @@ def _tf_close_coeff(tf_a, tf_b, rtol=1e-5, atol=1e-8): for i in range(tf_a.noutputs): for j in range(tf_a.ninputs): if not np.allclose( - tf_a._num[i, j], - tf_b._num[i, j], + tf_a.num_array[i, j], + tf_b.num_array[i, j], rtol=rtol, atol=atol, ): return False if not np.allclose( - tf_a._den[i, j], - tf_b._den[i, j], + tf_a.den_array[i, j], + tf_b.den_array[i, j], rtol=rtol, atol=atol, ): diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py index 3956cfa0a..bd6fedcf7 100644 --- a/control/tests/xferfcn_test.py +++ b/control/tests/xferfcn_test.py @@ -191,8 +191,10 @@ def test_reverse_sign_mimo(self): for i in range(sys3.noutputs): for j in range(sys3.ninputs): - np.testing.assert_allclose(sys2._num[i, j], sys3._num[i, j]) - np.testing.assert_allclose(sys2._den[i, j], sys3._den[i, j]) + np.testing.assert_allclose( + sys2.num_array[i, j], sys3.num_array[i, j]) + np.testing.assert_allclose( + sys2.den_array[i, j], sys3.den_array[i, j]) # Tests for TransferFunction.__add__ @@ -237,8 +239,8 @@ def test_add_mimo(self): for i in range(sys3.noutputs): for j in range(sys3.ninputs): - np.testing.assert_allclose(sys3._num[i, j], num3[i][j]) - np.testing.assert_allclose(sys3._den[i, j], den3[i][j]) + np.testing.assert_allclose(sys3.num_array[i, j], num3[i][j]) + np.testing.assert_allclose(sys3.den_array[i, j], den3[i][j]) # Tests for TransferFunction.__sub__ @@ -285,8 +287,8 @@ def test_subtract_mimo(self): for i in range(sys3.noutputs): for j in range(sys3.ninputs): - np.testing.assert_allclose(sys3._num[i, j], num3[i][j]) - np.testing.assert_allclose(sys3._den[i, j], den3[i][j]) + np.testing.assert_allclose(sys3.num_array[i, j], num3[i][j]) + np.testing.assert_allclose(sys3.den_array[i, j], den3[i][j]) # Tests for TransferFunction.__mul__ @@ -341,8 +343,8 @@ def test_multiply_mimo(self): for i in range(sys3.noutputs): for j in range(sys3.ninputs): - np.testing.assert_allclose(sys3._num[i, j], num3[i][j]) - np.testing.assert_allclose(sys3._den[i, j], den3[i][j]) + np.testing.assert_allclose(sys3.num_array[i, j], num3[i][j]) + np.testing.assert_allclose(sys3.den_array[i, j], den3[i][j]) # Tests for TransferFunction.__div__ @@ -664,9 +666,9 @@ def test_convert_to_transfer_function(self): for i in range(sys.noutputs): for j in range(sys.ninputs): np.testing.assert_array_almost_equal( - tfsys._num[i, j], num[i][j]) + tfsys.num_array[i, j], num[i][j]) np.testing.assert_array_almost_equal( - tfsys._den[i, j], den[i][j]) + tfsys.den_array[i, j], den[i][j]) def test_minreal(self): """Try the minreal function, and also test easy entry by creation @@ -1123,9 +1125,9 @@ def test_repr(self, Hargs, ref): for p in range(len(H.num)): for m in range(len(H.num[0])): np.testing.assert_array_almost_equal( - H._num[p, m], H2._num[p, m]) + H.num_array[p, m], H2.num_array[p, m]) np.testing.assert_array_almost_equal( - H._den[p, m], H2._den[p, m]) + H.den_array[p, m], H2.den_array[p, m]) assert H.dt == H2.dt def test_sample_named_signals(self): @@ -1183,8 +1185,10 @@ def test_returnScipySignalLTI(self, mimotf): sslti = mimotf.returnScipySignalLTI(strict=False) for i in range(2): for j in range(3): - np.testing.assert_allclose(sslti[i][j].num, mimotf._num[i, j]) - np.testing.assert_allclose(sslti[i][j].den, mimotf._den[i, j]) + np.testing.assert_allclose( + sslti[i][j].num, mimotf.num_array[i, j]) + np.testing.assert_allclose( + sslti[i][j].den, mimotf.den_array[i, j]) if mimotf.dt == 0: assert sslti[i][j].dt is None else: diff --git a/control/xferfcn.py b/control/xferfcn.py index 20bb3bc8d..9d59be9ee 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -70,6 +70,12 @@ class TransferFunction(LTI): Attributes ---------- + num_list, den_list : 2D list of 1D array + Numerator and denominator polynomial coefficients as 2D lists + of 1D array objects (of varying length) + num_array, den_array : 2D array of lists of float + Numerator and denominator polynomial coefficients as 2D array + of 1D array objects (of varying length). ninputs, noutputs, nstates : int Number of input, output and state variables. input_labels, output_labels, state_labels : list of str @@ -79,19 +85,26 @@ class TransferFunction(LTI): Notes ----- - The attributes 'num' and 'den' are properties that return 3D nested lists - containing MIMO numerator and denominator coefficients. For example, + The numerator and denominator polynomials are stored as 2D ndarray's + with each element containing a 1D ndarray of coefficients. These data + structures can be retrieved using ``num_array`` and ``den_array``. For + example, - >>> sys.num[2][5] # doctest: +SKIP + >>> sys.num_array[2, 5] # doctest: +SKIP gives the numerator of the transfer function from the 6th input to the - 3rd output. + 3rd output. (Note: a single 3D ndarray structure cannot be used because + the numerators and denominators can have different numbers of + coefficients in each entry.) - Internally, the numerator and denominator polynomials are stored as 2D - ndarrays with each element containing a 1D ndarray of coefficients. - These data structures can be retrieved using ``_num`` and ``_den``. (A - single 3D ndarray structure cannot be used because the numerators and - denominators can have different numbers of coefficients in each entry.) + The attributes ``num_list`` and ``den_list`` are properties that return + 2D nested lists containing MIMO numerator and denominator coefficients. + For example, + + >>> sys.num_list[2][5] # doctest: +SKIP + + For legacy purposes, this list-based representation can also be + obtained using ``num`` and ``den``. A discrete time transfer function is created by specifying a nonzero 'timebase' dt when the system is constructed: @@ -256,8 +269,8 @@ def __init__(self, *args, **kwargs): den[i][j] = ones(1) # Store the numerator and denominator - self._num = num - self._den = den + self.num_array = num + self.den_array = den # # Final processing @@ -284,14 +297,17 @@ def __init__(self, *args, **kwargs): # Numerator and denominator as lists of lists of lists @property - def num(self): - """Numerator polynomial (as 3D nested lists).""" - return self._num.tolist() + def num_list(self): + """Numerator polynomial (as 2D nested list of 1D arrays).""" + return self.num_array.tolist() @property - def den(self): - """Denominator polynomial (as 3D nested lists).""" - return self._den.tolist() + def den_list(self): + """Denominator polynomial (as 2D nested lists of 1D arrays).""" + return self.den_array.tolist() + + # Legacy versions (TODO: add DeprecationWarning in a later release?) + num, den = num_list, den_list def __call__(self, x, squeeze=None, warn_infinite=True): @@ -374,8 +390,8 @@ def horner(self, x, warn_infinite=True): with np.errstate(all='warn' if warn_infinite else 'ignore'): for i in range(self.noutputs): for j in range(self.ninputs): - out[i][j] = (polyval(self._num[i, j], x_arr) / - polyval(self._den[i, j], x_arr)) + out[i][j] = (polyval(self.num_array[i, j], x_arr) / + polyval(self.den_array[i, j], x_arr)) return out def _truncatecoeff(self): @@ -388,7 +404,7 @@ def _truncatecoeff(self): """ # Beware: this is a shallow copy. This should be okay. - data = [self._num, self._den] + data = [self.num_array, self.den_array] for p in range(len(data)): for i in range(self.noutputs): for j in range(self.ninputs): @@ -405,7 +421,7 @@ def _truncatecoeff(self): else: # Truncate the trivial coefficients. data[p][i][j] = data[p][i][j][nonzero:] - [self._num, self._den] = data + [self.num_array, self.den_array] = data def __str__(self, var=None): """String representation of the transfer function. @@ -426,17 +442,17 @@ def __str__(self, var=None): # Convert the numerator and denominator polynomials to strings. if self.display_format == 'poly': numstr = _tf_polynomial_to_string( - self._num[no, ni], var=var) + self.num_array[no, ni], var=var) denstr = _tf_polynomial_to_string( - self._den[no, ni], var=var) + self.den_array[no, ni], var=var) elif self.display_format == 'zpk': - num = self._num[no, ni] + num = self.num_array[no, ni] if num.size == 1 and num.item() == 0: # Catch a special case that SciPy doesn't handle - z, p, k = tf2zpk([1.], self._den[no, ni]) + z, p, k = tf2zpk([1.], self.den_array[no, ni]) k = 0 else: - z, p, k = tf2zpk(self.num[no][ni], self._den[no, ni]) + z, p, k = tf2zpk(self.num[no][ni], self.den_array[no, ni]) numstr = _tf_factorized_polynomial_to_string( z, gain=k, var=var) denstr = _tf_factorized_polynomial_to_string(p, var=var) @@ -464,14 +480,27 @@ def __repr__(self): """Print transfer function in loadable form.""" if self.issiso(): return "TransferFunction({num}, {den}{dt})".format( - num=self.num[0][0].__repr__(), den=self.den[0][0].__repr__(), + num=self.num_array[0, 0].__repr__(), + den=self.den_array[0, 0].__repr__(), dt=', {}'.format(self.dt) if isdtime(self, strict=True) else '') else: - return "TransferFunction({num}, {den}{dt})".format( - num=self.num.__repr__(), den=self.den.__repr__(), - dt=', {}'.format(self.dt) if isdtime(self, strict=True) - else '') + out = "TransferFunction([" + for entry in [self.num_array, self.den_array]: + for i in range(self.noutputs): + out += "[" if i == 0 else " [" + for j in range(self.ninputs): + out += ", " if j != 0 else "" + numstr = np.array_repr(entry[i, j]) + out += numstr + out += "]," if i < self.noutputs - 1 else "]" + out += "], [" if entry is self.num_array else "]" + + if config.defaults['control.default_dt'] != self.dt: + out += ", {dt}".format( + dt='None' if self.dt is None else self.dt) + out += ")" + return out def _repr_latex_(self, var=None): """LaTeX representation of transfer function, for Jupyter notebook.""" @@ -491,11 +520,12 @@ def _repr_latex_(self, var=None): # Convert the numerator and denominator polynomials to strings. if self.display_format == 'poly': numstr = _tf_polynomial_to_string( - self._num[no, ni], var=var) + self.num_array[no, ni], var=var) denstr = _tf_polynomial_to_string( - self._den[no, ni], var=var) + self.den_array[no, ni], var=var) elif self.display_format == 'zpk': - z, p, k = tf2zpk(self._num[no, ni], self._den[no, ni]) + z, p, k = tf2zpk( + self.num_array[no, ni], self.den_array[no, ni]) numstr = _tf_factorized_polynomial_to_string( z, gain=k, var=var) denstr = _tf_factorized_polynomial_to_string(p, var=var) @@ -524,7 +554,7 @@ def _repr_latex_(self, var=None): def __neg__(self): """Negate a transfer function.""" - num = deepcopy(self._num) + num = deepcopy(self.num_array) for i in range(self.noutputs): for j in range(self.ninputs): num[i, j] *= -1 @@ -563,8 +593,8 @@ def __add__(self, other): for i in range(self.noutputs): for j in range(self.ninputs): num[i, j], den[i, j] = _add_siso( - self._num[i, j], self._den[i, j], - other._num[i, j], other._den[i, j]) + self.num_array[i, j], self.den_array[i, j], + other.num_array[i, j], other.den_array[i, j]) return TransferFunction(num, den, dt) @@ -618,9 +648,9 @@ def __mul__(self, other): for col in range(ninputs): for k in range(self.ninputs): num_summand[k] = polymul( - self._num[row, k], other._num[k, col]) + self.num_array[row, k], other.num_array[k, col]) den_summand[k] = polymul( - self._den[row, k], other._den[k, col]) + self.den_array[row, k], other.den_array[k, col]) num[row, col], den[row, col] = _add_siso( num[row, col], den[row, col], num_summand[k], den_summand[k]) @@ -660,8 +690,10 @@ def __rmul__(self, other): for i in range(noutputs): # Iterate through rows of product. for j in range(ninputs): # Iterate through columns of product. for k in range(other.ninputs): # Multiply & add. - num_summand[k] = polymul(other._num[i, k], self._num[k, j]) - den_summand[k] = polymul(other._den[i, k], self._den[k, j]) + num_summand[k] = polymul( + other.num_array[i, k], self.num_array[k, j]) + den_summand[k] = polymul( + other.den_array[i, k], self.den_array[k, j]) num[i][j], den[i][j] = _add_siso( num[i, j], den[i, j], num_summand[k], den_summand[k]) @@ -686,8 +718,8 @@ def __truediv__(self, other): 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]) + num = polymul(self.num_array[0, 0], other.den_array[0, 0]) + den = polymul(self.den_array[0, 0], other.num_array[0, 0]) return TransferFunction(num, den, dt) @@ -739,8 +771,8 @@ def __getitem__(self, key): den = _create_poly_array(num.shape) for row, i in enumerate(outdx): for col, j in enumerate(inpdx): - num[row, col] = self._num[i, j] - den[row, col] = self._den[i, j] + num[row, col] = self.num_array[i, j] + den[row, col] = self.den_array[i, j] col += 1 row += 1 @@ -781,7 +813,7 @@ def zeros(self): "for SISO systems.") else: # for now, just give zeros of a SISO tf - return roots(self._num[0, 0]).astype(complex) + return roots(self.num_array[0, 0]).astype(complex) def feedback(self, other=1, sign=-1): """Feedback interconnection between two LTI objects.""" @@ -795,10 +827,10 @@ def feedback(self, other=1, sign=-1): "MIMO systems.") dt = common_timebase(self.dt, other.dt) - num1 = self._num[0, 0] - den1 = self._den[0, 0] - num2 = other._num[0, 0] - den2 = other._den[0, 0] + num1 = self.num_array[0, 0] + den1 = self.den_array[0, 0] + num2 = other.num_array[0, 0] + den2 = other.den_array[0, 0] num = polymul(num1, den2) den = polyadd(polymul(den2, den1), -sign * polymul(num2, num1)) @@ -827,9 +859,9 @@ def minreal(self, tol=None): # split up in zeros, poles and gain newzeros = [] - zeros = roots(self._num[i, j]) - poles = roots(self._den[i, j]) - gain = self._num[i, j][0] / self._den[i, j][0] + zeros = roots(self.num_array[i, j]) + poles = roots(self.den_array[i, j]) + gain = self.num_array[i, j][0] / self.den_array[i, j][0] # check all zeros for z in zeros: From 2c7ca9a80fd8d80a38e3b7e7133666992c73df66 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sun, 15 Dec 2024 22:48:07 -0800 Subject: [PATCH 06/16] updated iosys class/factory function documentation + docstring unit testing --- control/flatsys/flatsys.py | 37 ------ control/frdata.py | 160 +++++++++++++--------- control/freqplot.py | 2 +- control/iosys.py | 24 ++-- control/lti.py | 6 +- control/optimal.py | 2 +- control/statesp.py | 140 ++++++++------------ control/tests/docstrings_test.py | 219 ++++++++++++++++++++++++++++++- control/tests/frd_test.py | 2 +- control/xferfcn.py | 60 +++++---- 10 files changed, 415 insertions(+), 237 deletions(-) diff --git a/control/flatsys/flatsys.py b/control/flatsys/flatsys.py index 0101d126b..ff8683c14 100644 --- a/control/flatsys/flatsys.py +++ b/control/flatsys/flatsys.py @@ -1,42 +1,5 @@ # flatsys.py - trajectory generation for differentially flat systems # RMM, 10 Nov 2012 -# -# This file contains routines for computing trajectories for differentially -# flat nonlinear systems. It is (very) loosely based on the NTG software -# package developed by Mark Milam and Kudah Mushambi, but rewritten from -# scratch in python. -# -# Copyright (c) 2012 by California Institute of Technology -# All rights reserved. -# -# Redistribution and use in source and binary forms, with or without -# modification, are permitted provided that the following conditions -# are met: -# -# 1. Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. -# -# 2. Redistributions in binary form must reproduce the above copyright -# notice, this list of conditions and the following disclaimer in the -# documentation and/or other materials provided with the distribution. -# -# 3. Neither the name of the California Institute of Technology nor -# the names of its contributors may be used to endorse or promote -# products derived from this software without specific prior -# written permission. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL CALTECH -# OR THE CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF -# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT -# OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF -# SUCH DAMAGE. import itertools import numpy as np diff --git a/control/frdata.py b/control/frdata.py index e18d90363..4b58ad7af 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -3,11 +3,11 @@ # Author: M.M. (Rene) van Paassen (using xferfcn.py as basis) # Date: 02 Oct 12 -""" -Frequency response data representation and functions. +"""Frequency response data representation and functions. + +This module contains the FrequencyResponseData (FRD) class and also +functions that operate on FRD data. -This module contains the FRD class and also functions that operate on -FRD data. """ from collections.abc import Iterable @@ -35,38 +35,29 @@ class FrequencyResponseData(LTI): The FrequencyResponseData (FRD) class is used to represent systems in frequency response data form. It can be created manually using the - class constructor, using the :func:`~control.frd` factory function or + class constructor, using the :func:`~control.frd` factory function, or via the :func:`~control.frequency_response` function. Parameters ---------- - d : 1D or 3D complex array_like + response : 1D or 3D complex array_like The frequency response at each frequency point. If 1D, the system is assumed to be SISO. If 3D, the system is MIMO, with the first dimension corresponding to the output index of the FRD, the second dimension corresponding to the input index, and the 3rd dimension corresponding to the frequency points in omega - w : iterable of real frequencies + omega : iterable of real frequencies List of frequency points for which data are available. - sysname : str or None - Name of the system that generated the data. smooth : bool, optional If ``True``, create an interpolation function that allows the frequency response to be computed at any frequency within the range of frequencies give in ``w``. If ``False`` (default), frequency response can only be obtained at the frequencies specified in ``w``. - - Attributes - ---------- - ninputs, noutputs : int - Number of input and output variables. - omega : 1D array - Frequency points of the response. - fresp : 3D array - Frequency response, indexed by output index, input index, and - frequency point. - dt : float, True, or None - System timebase. + dt : None, True or float, optional + 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). squeeze : bool By default, if a system is single-input, single-output (SISO) then the outputs (and inputs) are returned as a 1D array (indexed by @@ -79,16 +70,46 @@ class constructor, using the :func:`~control.frd` factory function or returned as a 3D array (indexed by the output, input, and frequency) even if the system is SISO. The default value can be set using config.defaults['control.squeeze_frequency_response']. - ninputs, noutputs, nstates : int - Number of inputs, outputs, and states of the underlying system. + sysname : str or None + Name of the system that generated the data. + + Attributes + ---------- + fresp : 3D array + Frequency response, indexed by output index, input index, and + frequency point. + frequency : 1D array + Array of frequency points for which data are available. + ninputs, noutputs : int + Number of inputs and outputs signals. + shape : tuple + 2-tuple of I/O system dimension, (noutputs, ninputs). input_labels, output_labels : array of str - Names for the input and output variables. - sysname : str, optional - Name of the system. For data generated using - :func:`~control.frequency_response`, stores the name of the system - that created the data. + Names for the input and output signals. + name : str + System name. For data generated using + :func:`~control.frequency_response`, stores the name of the + system that created the data. + magnitude : array + Magnitude of the frequency response, indexed by frequency. + phase : array + Magnitude of the frequency response, indexed by frequency. + + Other Parameters + ---------------- + plot_type : str, optional + Set the type of plot to generate with ``plot()`` ('bode', 'nichols'). title : str, optional Set the title to use when plotting. + plot_magnitude, plot_phase : bool, optional + If set to `False`, don't plot the magnitude or phase, respectively. + return_magphase : bool, optional + If True, then a frequency response data object will enumerate as a + tuple of the form (mag, phase, omega) where where ``mag`` is the + magnitude (absolute value, not dB or log10) of the system + frequency response, ``phase`` is the wrapped phase in radians of + the system frequency response, and ``omega`` is the (sorted) + frequencies at which the response was evaluated. See Also -------- @@ -148,22 +169,26 @@ class constructor, using the :func:`~control.frd` factory function or _epsw = 1e-8 #: Bound for exact frequency match def __init__(self, *args, **kwargs): - """Construct an FRD object. + """FrequencyResponseData(d, w[, dt]) - The default constructor is FRD(d, w), where w is an iterable of - frequency points, and d is the matching frequency data. + Construct a frequency response data (FRD) object. - If d is a single list, 1D array, or tuple, a SISO system description - is assumed. d can also be - - To call the copy constructor, call FRD(sys), where sys is a - FRD object. + The default constructor is FrequencyResponseData(d, w), where w is + an iterable of frequency points, and d is the matching frequency + data. If d is a single list, 1D array, or tuple, a SISO system + description is assumed. d can also be a 2D array, in which case a + MIMO response is created. To call the copy constructor, call + FrequencyResponseData(sys), where sys is a FRD object. The + timebase for the frequency response can be provided using an + optional third argument or the 'dt' keyword. - To construct frequency response data for an existing LTI - object, other than an FRD, call FRD(sys, omega). + To construct frequency response data for an existing LTI object, + other than an FRD, call FrequencyResponseData(sys, omega). This + functionality can also be obtained using :func:`frequency_response` + (which has additional options available). - The timebase for the frequency response can be provided using an - optional third argument or the 'dt' keyword. + See :class:`FrequencyResponseData` and :func:`frd` for more + information. """ smooth = kwargs.pop('smooth', False) @@ -182,11 +207,12 @@ def __init__(self, *args, **kwargs): if len(args) == 2: if not isinstance(args[0], FRD) and isinstance(args[0], LTI): - # not an FRD, but still a system, second argument should be - # the frequency range + # not an FRD, but still an LTI system, second argument + # should be the frequency range otherlti = args[0] self.omega = sort(np.asarray(args[1], dtype=float)) - # calculate frequency response at my points + + # calculate frequency response at specified points if otherlti.isctime(): s = 1j * self.omega self.fresp = otherlti(s, squeeze=False) @@ -267,11 +293,13 @@ def __init__(self, *args, **kwargs): self, 'output_index', None) else self.output_labels, 'name': getattr(self, 'name', None)} if arg_dt is not None: - defaults['dt'] = arg_dt # choose compatible timebase - name, inputs, outputs, states, dt = _process_iosys_keywords( - kwargs, defaults, end=True) + if isinstance(args[0], LTI): + arg_dt = common_timebase(args[0].dt, arg_dt) + kwargs['dt'] = arg_dt # Process signal names + name, inputs, outputs, states, dt = _process_iosys_keywords( + kwargs, defaults, end=True) InputOutputSystem.__init__( self, name=name, inputs=inputs, outputs=outputs, dt=dt) @@ -282,17 +310,17 @@ def __init__(self, *args, **kwargs): raise ValueError("can't smooth with only 1 frequency") degree = 3 if self.omega.size > 3 else self.omega.size - 1 - self.ifunc = empty((self.fresp.shape[0], self.fresp.shape[1]), + self._ifunc = empty((self.fresp.shape[0], self.fresp.shape[1]), dtype=tuple) for i in range(self.fresp.shape[0]): for j in range(self.fresp.shape[1]): - self.ifunc[i, j], u = splprep( + self._ifunc[i, j], u = splprep( u=self.omega, x=[real(self.fresp[i, j, :]), imag(self.fresp[i, j, :])], w=1.0/(absolute(self.fresp[i, j, :]) + 0.001), s=0.0, k=degree) else: - self.ifunc = None + self._ifunc = None # # Frequency response properties @@ -395,7 +423,7 @@ def __repr__(self): """ return "FrequencyResponseData({d}, {w}{smooth})".format( d=repr(self.fresp), w=repr(self.omega), - smooth=(self.ifunc and ", smooth=True") or "") + smooth=(self._ifunc and ", smooth=True") or "") def __neg__(self): """Negate a transfer function.""" @@ -454,7 +482,7 @@ def __mul__(self, other): # Convert the second argument to a transfer function. if isinstance(other, (int, float, complex, np.number)): return FRD(self.fresp * other, self.omega, - smooth=(self.ifunc is not None)) + smooth=(self._ifunc is not None)) else: other = _convert_to_frd(other, omega=self.omega) @@ -472,8 +500,8 @@ def __mul__(self, other): for i in range(len(self.omega)): fresp[:, :, i] = self.fresp[:, :, i] @ other.fresp[:, :, i] return FRD(fresp, self.omega, - smooth=(self.ifunc is not None) and - (other.ifunc is not None)) + smooth=(self._ifunc is not None) and + (other._ifunc is not None)) def __rmul__(self, other): """Right Multiply two LTI objects (serial connection).""" @@ -481,7 +509,7 @@ def __rmul__(self, other): # Convert the second argument to an frd function. if isinstance(other, (int, float, complex, np.number)): return FRD(self.fresp * other, self.omega, - smooth=(self.ifunc is not None)) + smooth=(self._ifunc is not None)) else: other = _convert_to_frd(other, omega=self.omega) @@ -500,8 +528,8 @@ def __rmul__(self, other): for i in range(len(self.omega)): fresp[:, :, i] = other.fresp[:, :, i] @ self.fresp[:, :, i] return FRD(fresp, self.omega, - smooth=(self.ifunc is not None) and - (other.ifunc is not None)) + smooth=(self._ifunc is not None) and + (other._ifunc is not None)) # TODO: Division of MIMO transfer function objects is not written yet. def __truediv__(self, other): @@ -509,7 +537,7 @@ def __truediv__(self, other): if isinstance(other, (int, float, complex, np.number)): return FRD(self.fresp * (1/other), self.omega, - smooth=(self.ifunc is not None)) + smooth=(self._ifunc is not None)) else: other = _convert_to_frd(other, omega=self.omega) @@ -520,15 +548,15 @@ def __truediv__(self, other): "systems.") return FRD(self.fresp/other.fresp, self.omega, - smooth=(self.ifunc is not None) and - (other.ifunc is not None)) + smooth=(self._ifunc is not None) and + (other._ifunc is not None)) # TODO: Division of MIMO transfer function objects is not written yet. def __rtruediv__(self, other): """Right divide two LTI objects.""" if isinstance(other, (int, float, complex, np.number)): return FRD(other / self.fresp, self.omega, - smooth=(self.ifunc is not None)) + smooth=(self._ifunc is not None)) else: other = _convert_to_frd(other, omega=self.omega) @@ -545,7 +573,7 @@ def __pow__(self, other): raise ValueError("Exponent must be an integer") if other == 0: return FRD(ones(self.fresp.shape), self.omega, - smooth=(self.ifunc is not None)) # unity + smooth=(self._ifunc is not None)) # unity if other > 0: return self * (self**(other-1)) if other < 0: @@ -598,7 +626,7 @@ def eval(self, omega, squeeze=None): if any(omega_array.imag > 0): raise ValueError("FRD.eval can only accept real-valued omega") - if self.ifunc is None: + if self._ifunc is None: elements = np.isin(self.omega, omega) # binary array if sum(elements) < len(omega_array): raise ValueError( @@ -612,7 +640,7 @@ def eval(self, omega, squeeze=None): for i in range(self.noutputs): for j in range(self.ninputs): for k, w in enumerate(omega_array): - frraw = splev(w, self.ifunc[i, j], der=0) + frraw = splev(w, self._ifunc[i, j], der=0) out[i, j, k] = frraw[0] + 1.0j * frraw[1] return _process_frequency_response(self, omega, out, squeeze=squeeze) @@ -767,7 +795,7 @@ def feedback(self, other=1, sign=-1): resfresp = (myfresp @ linalg.inv(I_AB)) fresp = np.moveaxis(resfresp, 0, 2) - return FRD(fresp, other.omega, smooth=(self.ifunc is not None)) + return FRD(fresp, other.omega, smooth=(self._ifunc is not None)) # Plotting interface def plot(self, plot_type=None, *args, **kwargs): @@ -917,6 +945,8 @@ def frd(*args, **kwargs): Parameters ---------- + sys : LTI (StateSpace or TransferFunction) + A linear system that will be evaluated for frequency response data. response : array_like or LTI system Complex vector with the system response or an LTI system that can be used to copmute the frequency response at a list of frequencies. @@ -933,7 +963,7 @@ def frd(*args, **kwargs): Returns ------- - sys : :class:`FrequencyResponseData` + sys : FrequencyResponseData New frequency response data system. Other Parameters diff --git a/control/freqplot.py b/control/freqplot.py index 544425298..8d3e6468f 100644 --- a/control/freqplot.py +++ b/control/freqplot.py @@ -1305,7 +1305,7 @@ def nyquist_response( "Nyquist plot currently only supports SISO systems.") # Figure out the frequency range - if isinstance(sys, FrequencyResponseData) and sys.ifunc is None \ + if isinstance(sys, FrequencyResponseData) and sys._ifunc is None \ and not omega_range_given: omega_sys = sys.omega # use system frequencies else: diff --git a/control/iosys.py b/control/iosys.py index 79ce64c71..c1b110064 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -145,20 +145,16 @@ class InputOutputSystem(object): Attributes ---------- ninputs, noutputs, nstates : int - Number of input, output and state variables + Number of input, output, and state variables. input_index, output_index, state_index : dict - Dictionary of signal names for the inputs, outputs and states and the - index of the corresponding array - dt : None, True or float - 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. - name : string, optional - System name (used for specifying signals) + Dictionary of signal names for the inputs, outputs, and states and + the index of the corresponding array. + input_labels, output_labels, state_labels : list of str + List of signal names for inputs, outputs, and states. + repr_format : str + String representation format ('iosys' or 'loadable'). + shape : tuple + 2-tuple of I/O system dimension, (noutputs, ninputs). Other Parameters ---------------- @@ -194,7 +190,7 @@ def __init__( raise TypeError("unrecognized keywords: ", str(kwargs)) # Keep track of the keywords that we recognize - kwargs_list = [ + _kwargs_list = [ 'name', 'inputs', 'outputs', 'states', 'input_prefix', 'output_prefix', 'state_prefix', 'dt'] diff --git a/control/lti.py b/control/lti.py index e9455aed5..4b354f3f4 100644 --- a/control/lti.py +++ b/control/lti.py @@ -458,7 +458,7 @@ def frequency_response( Examples -------- >>> G = ct.ss([[-1, -2], [3, -4]], [[5], [7]], [[6, 8]], [[9]]) - >>> mag, phase, omega = ct.freqresp(G, [0.1, 1., 10.]) + >>> mag, phase, omega = ct.frequency_response(G, [0.1, 1., 10.]) .. todo:: Add example with MIMO system @@ -490,8 +490,8 @@ def frequency_response( responses = [] for sys_ in syslist: - if isinstance(sys_, FrequencyResponseData) and sys_.ifunc is None and \ - not omega_range_given: + if isinstance(sys_, FrequencyResponseData) and sys_._ifunc is None \ + and not omega_range_given: omega_sys = sys_.omega # use system properties else: omega_sys = omega_syslist.copy() # use common omega vector diff --git a/control/optimal.py b/control/optimal.py index 0eb49c823..77cfd370e 100644 --- a/control/optimal.py +++ b/control/optimal.py @@ -1203,7 +1203,7 @@ def create_mpc_iosystem( # Grab the keyword arguments known by this function iosys_kwargs = {} - for kw in InputOutputSystem.kwargs_list: + for kw in InputOutputSystem._kwargs_list: if kw in kwargs: iosys_kwargs[kw] = kwargs.pop(kw) diff --git a/control/statesp.py b/control/statesp.py index 54d0c03ca..a09045047 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -1,52 +1,17 @@ -"""statesp.py +# statesp.py - state space class and related functions +# +# Author: Richard M. Murray +# Date: 24 May 09 +# Revised: Kevin K. Chen, Dec 10 -State space representation and functions. +"""State space representation and functions. -This file contains the StateSpace class, which is used to represent linear -systems in state space. This is the primary representation for the +This module contains the StateSpace class, which is used to represent +linear systems in state space. This is the primary representation for the python-control library. """ -"""Copyright (c) 2010 by California Institute of Technology -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions -are met: - -1. Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - -3. Neither the name of the California Institute of Technology nor - the names of its contributors may be used to endorse or promote - products derived from this software without specific prior - written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL CALTECH -OR THE CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF -USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND -ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT -OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF -SUCH DAMAGE. - -Author: Richard M. Murray -Date: 24 May 09 -Revised: Kevin K. Chen, Dec 10 - -$Id$ -""" - import math from collections.abc import Iterable from copy import deepcopy @@ -101,30 +66,30 @@ class StateSpace(NonlinearIOSystem, LTI): dx/dt &= A x + B u \\ y &= C x + D u - where `u` is the input, `y` is the output, and `x` is the state. + where `u` is the input, `y` is the output, and `x` is the state. State + space systems are usually created with the :func:`~control.ss` factory + function. Parameters ---------- - A, B, C, D: array_like + A, B, C, D : array_like System matrices of the appropriate dimensions. dt : None, True or float, optional - 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). + 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). Attributes ---------- ninputs, noutputs, nstates : int Number of input, output and state variables. - A, B, C, D : 2D arrays - System matrices defining the input/output dynamics. - dt : None, True or float - 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). + shape : tuple + 2-tuple of I/O system dimension, (noutputs, ninputs). + input_labels, output_labels, state_labels : list of str + Names for the input, output, and state variables. + name : string, optional + System name. Notes ----- @@ -164,13 +129,12 @@ class StateSpace(NonlinearIOSystem, LTI): signal offsets. The subsystem is created by truncating the inputs and outputs, but leaving the full set of system states. - StateSpace instances have support for IPython LaTeX output, - intended for pretty-printing in Jupyter notebooks. The LaTeX - output can be configured using - `control.config.defaults['statesp.latex_num_format']` and - `control.config.defaults['statesp.latex_repr_type']`. The LaTeX output is - tailored for MathJax, as used in Jupyter, and may look odd when - typeset by non-MathJax LaTeX systems. + StateSpace instances have support for IPython LaTeX output, intended + for pretty-printing in Jupyter notebooks. The LaTeX output can be + configured using `control.config.defaults['statesp.latex_num_format']` + and `control.config.defaults['statesp.latex_repr_type']`. The LaTeX + output is tailored for MathJax, as used in Jupyter, and may look odd + when typeset by non-MathJax LaTeX systems. `control.config.defaults['statesp.latex_num_format']` is a format string fragment, specifically the part of the format string after `'{:'` @@ -194,12 +158,7 @@ def __init__(self, *args, **kwargs): True for unspecified sampling time). To call the copy constructor, call StateSpace(sys), where sys is a StateSpace object. - The `remove_useless_states` keyword can be used to scan the A, B, and - C matrices for rows or columns of zeros. If the zeros are such that a - particular state has no effect on the input-output dynamics, then that - state is removed from the A, B, and C matrices. If not specified, the - value is read from `config.defaults['statesp.remove_useless_states']` - (default = False). + See :class:`StateSpace` and :func:`ss` for more information. """ # @@ -1541,7 +1500,7 @@ def ss(*args, **kwargs): Create a state space system. - The function accepts either 1, 2, 4 or 5 parameters: + The function accepts either 1, 4 or 5 positional parameters: ``ss(sys)`` Convert a linear system into space system form. Always creates a @@ -1565,11 +1524,12 @@ def ss(*args, **kwargs): x[k+1] &= A x[k] + B u[k] \\ y[k] &= C x[k] + D u[k] - The matrices can be given as *array like* data types or strings. - Everything that the constructor of :class:`numpy.matrix` accepts is - permissible here too. + The matrices can be given as 2D array-like data types. For SISO + systems, `B` and `C` can be given as 1D arrays and D can be given + as a scalar. - ``ss(args, inputs=['u1', ..., 'up'], outputs=['y1', ..., 'yq'], states=['x1', ..., 'xn'])`` + ``ss(*args, inputs=['u1', ..., 'up'], outputs=['y1', ..., 'yq'], + states=['x1', ..., 'xn'])`` Create a system with named input, output, and state signals. Parameters @@ -1584,24 +1544,40 @@ def ss(*args, **kwargs): time, positive number is discrete time with specified sampling time, None indicates unspecified timebase (either continuous or discrete time). + remove_useless_states : bool, optional + If `True`, remove states that have no effect on the input/output + dynamics. If not specified, the value is read from + `config.defaults['statesp.remove_useless_states']` (default = False). + method : str, optional + Set the method used for converting a transfer function to a state + space system. Current methods are 'slycot' and 'scipy'. If set to + None (default), try 'slycot' first and then 'scipy' (SISO only). + + Returns + ------- + out: StateSpace + Linear input/output system. + + Other Parameters + ---------------- inputs, outputs, states : str, or list of str, optional List of strings that name the individual signals. If this parameter is not given or given as `None`, the signal names will be of the form `s[i]` (where `s` is one of `u`, `y`, or `x`). See :class:`InputOutputSystem` for more information. + input_prefix, output_prefix, state_prefix : string, optional + Set the prefix for input, output, and state signals. Defaults = + 'u', 'y', 'x'. name : string, optional System name (used for specifying signals). If unspecified, a generic name is generated with a unique integer id. - method : str, optional - Set the method used for computing the result. Current methods are - 'slycot' and 'scipy'. If set to None (default), try 'slycot' first - and then 'scipy' (SISO only). Returns ------- - out: :class:`StateSpace` + out: StateSpace Linear input/output system. +>>>>>>> 1f609a56 (updated iosys class/factory function documentation + docstring unit testing) Raises ------ ValueError @@ -1609,7 +1585,7 @@ def ss(*args, **kwargs): See Also -------- - tf, ss2tf, tf2ss + tf, ss2tf, tf2ss, zpk Notes ----- @@ -2291,7 +2267,7 @@ def _convert_to_statespace(sys, use_prefix_suffix=False, method=None): D = empty((sys.noutputs, sys.ninputs), dtype=float) for i, j in itertools.product(range(sys.noutputs), range(sys.ninputs)): - D[i, j] = sys._num[i, j][0] / sys._den[i, j][0] + D[i, j] = sys.num_array[i, j][0] / sys.den_array[i, j][0] newsys = StateSpace([], [], [], D, sys.dt) else: if not issiso(sys): diff --git a/control/tests/docstrings_test.py b/control/tests/docstrings_test.py index f05bbd2e9..f4e87cdad 100644 --- a/control/tests/docstrings_test.py +++ b/control/tests/docstrings_test.py @@ -21,6 +21,9 @@ control.ControlPlot.reshape, # needed for legacy interface control.phase_plot, # legacy function control.drss, # documention in rss + control.frd, # tested separately below + control.ss, # tested separately below + control.tf, # tested separately below ] # Checksums to use for checking whether a docstring has changed @@ -31,13 +34,10 @@ control.dlqr: '896cfa651dbbd80e417635904d13c9d6', control.lqe: '567bf657538935173f2e50700ba87168', control.lqr: 'a3e0a85f781fc9c0f69a4b7da4f0bd22', - control.frd: '099464bf2d14f25a8769ef951adf658b', control.margin: 'f02b3034f5f1d44ce26f916cc3e51600', control.parallel: '025c5195a34c57392223374b6244a8c4', control.series: '9aede1459667738f05cf4fc46603a4f6', - control.ss: '1b9cfad5dbdf2f474cfdeadf5cb1ad80', control.ss2tf: '48ff25d22d28e7b396e686dd5eb58831', - control.tf: '155a19afb95452ed19966c8d8ae23a84', control.tf2ss: '086a3692659b7321c2af126f79f4bc11', control.markov: 'a4199c54cb50f07c0163d3790739eafe', control.gangof4: '0e52eb6cf7ce024f9a41f3ae3ebf04f7', @@ -260,14 +260,213 @@ def test_deprecated_functions(module, prefix): f"{name} deprecated but w/ non-standard docs/warnings") assert name != 'ss2io' +# +# Tests for I/O system classes +# +# The tests below try to make sure that we document I/O system classes +# and the factory functions that create them in a uniform way. +# + +ct = control +fs = control.flatsys + +# Dictionary of factory functions associated with primary classes +class_factory_function = { + # fs.FlatSystem: fs.flatsys, + ct.FrequencyResponseData: ct.frd, + ct.InterconnectedSystem: ct.interconnect, + ct.LinearICSystem: ct.interconnect, + # (ct.NonlinearIOSystem: ct.nlsys, + ct.StateSpace: ct.ss, + ct.TransferFunction: ct.tf, +} + +# List of arguments described in class docstrings +class_args = { + # fs.FlatSystem: ['forward', 'reverse', 'updfcn', 'outfcn'], + ct.FrequencyResponseData: ['response', 'omega', 'dt'], + # (ct.NonlinearIOSystem: ['updfcn', 'outfcn', 'dt'], + ct.StateSpace: ['A', 'B', 'C', 'D', 'dt'], + ct.TransferFunction: ['num', 'den', 'dt'], +} + +# List of attributes defined for all I/O systems +std_class_attributes = [ + 'ninputs', 'noutputs', 'input_labels', 'output_labels', 'name'] + +# List of attributes defined for specific I/O systems +class_attributes = { + # fs.FlatSystem: [], + ct.FrequencyResponseData: [], + # (ct.NonlinearIOSystem: [], + ct.StateSpace: ['nstates', 'state_labels'], + ct.TransferFunction: [], +} + +# List of attributes defined in a parent class (no need to warn) +iosys_parent_attributes = [ + 'input_index', 'output_index', 'state_index', # rarely used + 'repr_format', # rarely used + 'states', 'nstates', 'state_labels', # not need in TF, FRD + 'params', 'outfcn', 'updfcn' # NL I/O, SS overlap +] + +# List of arguments described (only) in factory function docstrings +std_factory_args = ['inputs', 'outputs', 'name'] +factory_args = { + # fs.flatsys: [], + ct.frd: ['sys'], + # fs.nlsys: [], + ct.ss: ['sys', 'states'], + ct.tf: ['sys'], +} + + +@pytest.mark.parametrize( + "cls, fcn, args", + [(cls, class_factory_function[cls], class_args[cls]) + for cls in class_args.keys()]) +def test_iosys_primary_classes(cls, fcn, args): + docstring = inspect.getdoc(cls) + + # Make sure the typical arguments are there + for argname in args + std_class_attributes + class_attributes[cls]: + _check_parameter_docs(cls.__name__, argname, docstring) + + # Make sure we reference the factory function + if re.search( + r"created.*(with|by|using).*the[\s]*" + f":func:`~control.{fcn.__name__}`" + r"[\s]factory[\s]function", docstring, re.DOTALL) is None: + pytest.fail( + f"{cls.__name__} does not reference factory function " + f"{fcn.__name__}") + + # Make sure we don't reference parameters in the factory function + for argname in factory_args[fcn]: + if re.search(f"[\\s]+{argname}(, .*)*[\\s]*:", docstring) is not None: + pytest.fail( + f"{cls.__name__} references factory function parameter " + f"'{argname}'") + + +@pytest.mark.parametrize("cls", class_args.keys()) +def test_iosys_attribute_lists(cls, ignore_future_warning): + fcn = class_factory_function[cls] + + # Create a system that we can scan for attributes + sys = ct.rss(2, 1, 1) + match fcn: + case ct.tf: + sys = ct.tf(sys) + ignore_args = ['state_labels'] + case ct.frd: + sys = ct.frd(sys, [0.1, 1, 10]) + ignore_args = ['state_labels'] + case _: + ignore_args = [] + + docstring = inspect.getdoc(cls) + for name, obj in inspect.getmembers(sys): + if name.startswith('_') or inspect.ismethod(obj) or name in ignore_args: + # Skip hidden variables; class methods are checked elsewhere + continue + + # Try to find documentation in primary class + if _check_parameter_docs( + cls.__name__, name, docstring, fail_if_missing=False): + continue + + # Couldn't find in main documentation; look in parent classes + for parent in cls.__mro__: + if parent == object: + pytest.fail( + f"{cls.__name__} attribute '{name}' not documented") + + if _check_parameter_docs( + parent.__name__, name, inspect.getdoc(parent), + fail_if_missing=False): + if name not in iosys_parent_attributes + factory_args[fcn]: + warnings.warn( + f"{cls.__name__} attribute '{name}' only documented " + f"in parent class {parent.__name__}") + break + + +@pytest.mark.parametrize("cls", [ct.InputOutputSystem, ct.LTI]) +def test_iosys_container_classes(cls): + # Create a system that we can scan for attributes + sys = cls(states=2, outputs=1, inputs=1) + + docstring = inspect.getdoc(cls) + for name, obj in inspect.getmembers(sys): + if name.startswith('_') or inspect.ismethod(obj): + # Skip hidden variables; class methods are checked elsewhere + continue + + # Look through all classes in hierarchy + if verbose: + print(f"{name=}") + for parent in cls.__mro__: + if parent == object: + pytest.fail( + f"{cls.__name__} attribute '{name}' not documented") + + if verbose: + print(f" {parent=}") + if _check_parameter_docs( + parent.__name__, name, inspect.getdoc(parent), + fail_if_missing=False): + break + + +@pytest.mark.parametrize("cls", [ct.InterconnectedSystem, ct.LinearICSystem]) +def test_iosys_intermediate_classes(cls): + docstring = inspect.getdoc(cls) + + # Make sure there is not a parameters section + if re.search(r"\nParameters\n----", docstring) is not None: + pytest.fail(f"intermediate {cls} docstring contains Parameters section") + + # Make sure we reference the factory function + fcn = class_factory_function[cls] + if re.search(f":func:`~control.{fcn.__name__}`", docstring) is None: + pytest.fail( + f"{cls.__name__} does not reference factory function " + f"{fcn.__name__}") + + +@pytest.mark.parametrize("fcn", factory_args.keys()) +def test_iosys_factory_functions(fcn): + docstring = inspect.getdoc(fcn) + cls = list(class_factory_function.keys())[ + list(class_factory_function.values()).index(fcn)] + + # Make sure we reference parameters in class and factory function docstring + for argname in class_args[cls] + factory_args[fcn]: + _check_parameter_docs(fcn.__name__, argname, docstring) + + # Make sure we don't reference any class attributes + for argname in std_class_attributes + class_attributes[cls]: + if argname in std_factory_args: + continue + if re.search(f"[\\s]+{argname}(, .*)*[\\s]*:", docstring) is not None: + pytest.fail( + f"{fcn.__name__} references class attribute '{argname}'") + # Utility function to check for an argument in a docstring -def _check_parameter_docs(funcname, argname, docstring, prefix=""): +def _check_parameter_docs( + funcname, argname, docstring, prefix="", fail_if_missing=True): funcname = prefix + funcname # Find the "Parameters" section of docstring, where we start searching - if not (match := re.search(r"\nParameters\n----", docstring)): - pytest.fail(f"{funcname} docstring missing Parameters section") + if not (match := re.search( + r"\nParameters\n----", docstring)): + if fail_if_missing: + pytest.fail(f"{funcname} docstring missing Parameters section") + else: + return False else: start = match.start() @@ -299,7 +498,10 @@ def _check_parameter_docs(funcname, argname, docstring, prefix=""): docstring)): if verbose: print(f" {funcname}: {argname} not documented") - pytest.fail(f"{funcname} '{argname}' not documented") + if fail_if_missing: + pytest.fail(f"{funcname} '{argname}' not documented") + else: + return False # Make sure there isn't another instance second_match = re.search( @@ -307,3 +509,6 @@ def _check_parameter_docs(funcname, argname, docstring, prefix=""): docstring[match.end():]) if second_match: pytest.fail(f"{funcname} '{argname}' documented twice") + + return True + diff --git a/control/tests/frd_test.py b/control/tests/frd_test.py index c2a29ee2e..b08cd8260 100644 --- a/control/tests/frd_test.py +++ b/control/tests/frd_test.py @@ -482,7 +482,7 @@ def test_repr_str(self): sys1r = eval(repr(sys1)) np.testing.assert_array_almost_equal(sys1r.fresp, sys1.fresp) np.testing.assert_array_almost_equal(sys1r.omega, sys1.omega) - assert(sys1.ifunc is not None) + assert(sys1._ifunc is not None) refs = """: {sysname} Inputs (1): ['u[0]'] diff --git a/control/xferfcn.py b/control/xferfcn.py index 9d59be9ee..0bcc42915 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -1,23 +1,23 @@ -"""xferfcn.py - -Transfer function representation and functions. - -This file contains the TransferFunction class and also functions -that operate on transfer functions. This is the primary representation -for the python-control library. -""" - +# xferfcn.py - transfer function class and related functions +# # Author: Richard M. Murray # Date: 24 May 09 # Revised: Kevin K. Chen, Dec 2010 +"""Transfer function representation and functions. + +This module contains the TransferFunction class and also functions that +operate on transfer functions. This is the primary representation for the +python-control library. + +""" + from collections.abc import Iterable from copy import deepcopy from itertools import chain, product from re import sub from warnings import warn -# External function declarations import numpy as np import scipy as sp from numpy import angle, array, delete, empty, exp, finfo, ndarray, nonzero, \ @@ -63,25 +63,31 @@ class TransferFunction(LTI): 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). - display_format : None, 'poly' or 'zpk', optional - Set the display format used in printing the TransferFunction object. - Default behavior is polynomial display and can be changed by - changing config.defaults['xferfcn.display_format']. Attributes ---------- - num_list, den_list : 2D list of 1D array - Numerator and denominator polynomial coefficients as 2D lists - of 1D array objects (of varying length) + ninputs, noutputs : int + Number of input and output signals. + shape : tuple + 2-tuple of I/O system dimension, (noutputs, ninputs). + input_labels, output_labels : list of str + Names for the input and output signals. + name : string, optional + System name. num_array, den_array : 2D array of lists of float Numerator and denominator polynomial coefficients as 2D array of 1D array objects (of varying length). - ninputs, noutputs, nstates : int - Number of input, output and state variables. - input_labels, output_labels, state_labels : list of str - Signal labels for the system. - name : string, optional - System name (used for specifying signals). + num_list, den_list : 2D list of 1D array + Numerator and denominator polynomial coefficients as 2D lists + of 1D array objects (of varying length) + display_format : None, 'poly' or 'zpk' + Display format used in printing the TransferFunction object. + Default behavior is polynomial display and can be changed by + changing config.defaults['xferfcn.display_format']. + s : TransferFunction + Represents the continuous time differential operator. + z : TransferFunction + Represents the discrete time delay operator. Notes ----- @@ -159,6 +165,8 @@ def __init__(self, *args, **kwargs): TransferFunction(sys), where sys is a TransferFunction object (continuous or discrete). + See :class:`TransferFunction` and :func:`tf` for more information. + """ # # Process positional arguments @@ -1578,7 +1586,7 @@ def tf(*args, **kwargs): Returns ------- - out: :class:`TransferFunction` + sys : TransferFunction The new linear system. Other Parameters @@ -1714,7 +1722,7 @@ def zpk(zeros, poles, gain, *args, **kwargs): poles : array_like Array containing the location of poles. gain : float - System gain + System gain. dt : None, True or float, optional System timebase. 0 (default) indicates continuous time, True indicates discrete time with unspecified sampling @@ -1736,7 +1744,7 @@ def zpk(zeros, poles, gain, *args, **kwargs): Returns ------- - out: :class:`TransferFunction` + out: `TransferFunction` Transfer function with given zeros, poles, and gain. Examples From 1547326d53782405177294e3ace99769967a7b75 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Mon, 16 Dec 2024 08:02:23 -0800 Subject: [PATCH 07/16] ss -> nlsys with name preservation/override + docstring update --- control/iosys.py | 20 +++++--- control/nlsys.py | 78 ++++++++++++++++++++------------ control/tests/docstrings_test.py | 14 ++++-- control/tests/nlsys_test.py | 46 ++++++++++++++++++- 4 files changed, 116 insertions(+), 42 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index c1b110064..ec362df65 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -289,13 +289,8 @@ def _copy_names(self, sys, prefix="", suffix="", prefix_suffix_name=None): """copy the signal and system name of sys. Name is given as a keyword in case a specific name (e.g. append 'linearized') is desired. """ # Figure out the system name and assign it - if prefix == "" and prefix_suffix_name is not None: - prefix = config.defaults[ - 'iosys.' + prefix_suffix_name + '_system_name_prefix'] - if suffix == "" and prefix_suffix_name is not None: - suffix = config.defaults[ - 'iosys.' + prefix_suffix_name + '_system_name_suffix'] - self.name = prefix + sys.name + suffix + self.name = _extended_system_name( + sys.name, prefix, suffix, prefix_suffix_name) # Name the inputs, outputs, and states self.input_index = sys.input_index.copy() @@ -1058,3 +1053,14 @@ def _process_subsys_index(idx, sys_labels, slice_to_list=False): idx = range(len(sys_labels))[idx] return idx, labels + + +# Create an extended system name +def _extended_system_name(name, prefix="", suffix="", prefix_suffix_name=None): + if prefix == "" and prefix_suffix_name is not None: + prefix = config.defaults[ + 'iosys.' + prefix_suffix_name + '_system_name_prefix'] + if suffix == "" and prefix_suffix_name is not None: + suffix = config.defaults[ + 'iosys.' + prefix_suffix_name + '_system_name_suffix'] + return prefix + name + suffix diff --git a/control/nlsys.py b/control/nlsys.py index 62e4bf78e..7689db15e 100644 --- a/control/nlsys.py +++ b/control/nlsys.py @@ -39,10 +39,11 @@ class NonlinearIOSystem(InputOutputSystem): """Nonlinear I/O system. - Creates an :class:`~control.InputOutputSystem` for a nonlinear system by - specifying a state update function and an output function. The new system - can be a continuous or discrete time system (Note: discrete-time systems - are not yet supported by most functions.) + Creates an :class:`~control.InputOutputSystem` for a nonlinear system + by specifying a state update function and an output function. The new + system can be a continuous or discrete time system. Nonlinear I/O + systems are usually created with the :func:`~control.nlsys` factory + function. Parameters ---------- @@ -63,20 +64,13 @@ class NonlinearIOSystem(InputOutputSystem): where the arguments are the same as for `upfcn`. - inputs : int, list of str or None, optional - Description of the system inputs. This can be given as an integer - count or as a list of strings that name the individual signals. - If an integer count is specified, the names of the signal will be - of the form 's[i]' (where 's' is one of 'u', 'y', or 'x'). If - this parameter is not given or given as `None`, the relevant - quantity will be determined when possible based on other - information provided to functions using the system. - - outputs : int, list of str or None, optional - Description of the system outputs. Same format as `inputs`. + inputs, outputs, states : int, list of str or None, optional + Description of the system inputs, outputs, and states. See + :func:`control.nlsys` for more details. - states : int, list of str, or None, optional - Description of the system states. Same format as `inputs`. + params : dict, optional + Parameter values for the systems. Passed to the evaluation functions + for the system as default values, overriding internal defaults. dt : timebase, optional The timebase for the system, used to specify whether the system is @@ -88,13 +82,16 @@ class NonlinearIOSystem(InputOutputSystem): * dt = True: discrete time with unspecified sampling period * dt = None: no timebase specified + Attributes + ---------- + ninputs, noutputs, nstates : int + Number of input, output and state variables. + shape : tuple + 2-tuple of I/O system dimension, (noutputs, ninputs). + input_labels, output_labels, state_labels : list of str + Names for the input, output, and state variables. name : string, optional - System name (used for specifying signals). If unspecified, a - generic name is generated with a unique integer id. - - params : dict, optional - Parameter values for the system. Passed to the evaluation functions - for the system as default values, overriding internal defaults. + System name. See Also -------- @@ -1220,8 +1217,7 @@ def check_unused_signals( return dropped_inputs, dropped_outputs -def nlsys( - updfcn, outfcn=None, inputs=None, outputs=None, states=None, **kwargs): +def nlsys(updfcn, outfcn=None, **kwargs): """Create a nonlinear input/output system. Creates an :class:`~control.InputOutputSystem` for a nonlinear system by @@ -1230,7 +1226,7 @@ def nlsys( Parameters ---------- - updfcn : callable + updfcn : callable (or StateSpace) Function returning the state update function `updfcn(t, x, u, params) -> array` @@ -1240,6 +1236,10 @@ def nlsys( time, and `params` is a dict containing the values of parameters used by the function. + If a :class:`StateSpace` system is passed as the update function, + then a nonlinear I/O system is created that implements the linear + dynamics of the state space system. + outfcn : callable Function returning the output at the given state @@ -1308,9 +1308,31 @@ def nlsys( >>> timepts = np.linspace(0, 10) >>> response = ct.input_output_response( ... kincar, timepts, [10, 0.05 * np.sin(timepts)]) + """ - return NonlinearIOSystem( - updfcn, outfcn, inputs=inputs, outputs=outputs, states=states, **kwargs) + from .statesp import StateSpace + from .iosys import _extended_system_name + + if isinstance(updfcn, StateSpace): + sys_ss = updfcn + kwargs['inputs'] = kwargs.get('inputs', sys_ss.input_labels) + kwargs['outputs'] = kwargs.get('outputs', sys_ss.output_labels) + kwargs['states'] = kwargs.get('states', sys_ss.state_labels) + kwargs['name'] = kwargs.get('name', _extended_system_name( + sys_ss.name, prefix_suffix_name='converted')) + + sys_nl = NonlinearIOSystem( + lambda t, x, u, params: sys.A @ x + sys.B @ u, + lambda t, x, u, params: sys.C @ x + sys.D @ u, **kwargs) + + if sys_nl.nstates != sys_ss.nstates or sys_nl.shape != sys_ss.shape: + raise ValueError( + "new input, output, or state specification " + "doesn't match system size") + + return sys_nl + else: + return NonlinearIOSystem(updfcn, outfcn, **kwargs) def input_output_response( diff --git a/control/tests/docstrings_test.py b/control/tests/docstrings_test.py index f4e87cdad..54bffedd9 100644 --- a/control/tests/docstrings_test.py +++ b/control/tests/docstrings_test.py @@ -276,7 +276,7 @@ def test_deprecated_functions(module, prefix): ct.FrequencyResponseData: ct.frd, ct.InterconnectedSystem: ct.interconnect, ct.LinearICSystem: ct.interconnect, - # (ct.NonlinearIOSystem: ct.nlsys, + ct.NonlinearIOSystem: ct.nlsys, ct.StateSpace: ct.ss, ct.TransferFunction: ct.tf, } @@ -285,20 +285,21 @@ def test_deprecated_functions(module, prefix): class_args = { # fs.FlatSystem: ['forward', 'reverse', 'updfcn', 'outfcn'], ct.FrequencyResponseData: ['response', 'omega', 'dt'], - # (ct.NonlinearIOSystem: ['updfcn', 'outfcn', 'dt'], + ct.NonlinearIOSystem: [ + 'updfcn', 'outfcn', 'inputs', 'outputs', 'states', 'params', 'dt'], ct.StateSpace: ['A', 'B', 'C', 'D', 'dt'], ct.TransferFunction: ['num', 'den', 'dt'], } # List of attributes defined for all I/O systems std_class_attributes = [ - 'ninputs', 'noutputs', 'input_labels', 'output_labels', 'name'] + 'ninputs', 'noutputs', 'input_labels', 'output_labels', 'name', 'shape'] # List of attributes defined for specific I/O systems class_attributes = { # fs.FlatSystem: [], ct.FrequencyResponseData: [], - # (ct.NonlinearIOSystem: [], + ct.NonlinearIOSystem: [], ct.StateSpace: ['nstates', 'state_labels'], ct.TransferFunction: [], } @@ -316,7 +317,7 @@ def test_deprecated_functions(module, prefix): factory_args = { # fs.flatsys: [], ct.frd: ['sys'], - # fs.nlsys: [], + ct.nlsys: [], ct.ss: ['sys', 'states'], ct.tf: ['sys'], } @@ -363,6 +364,9 @@ def test_iosys_attribute_lists(cls, ignore_future_warning): case ct.frd: sys = ct.frd(sys, [0.1, 1, 10]) ignore_args = ['state_labels'] + case ct.nlsys: + sys = ct.nlsys(sys) + ignore_args = [] case _: ignore_args = [] diff --git a/control/tests/nlsys_test.py b/control/tests/nlsys_test.py index 7f649e0cc..f64057fd7 100644 --- a/control/tests/nlsys_test.py +++ b/control/tests/nlsys_test.py @@ -7,11 +7,15 @@ """ -import pytest -import numpy as np import math +import re + +import numpy as np +import pytest + import control as ct + # Basic test of nlsys() def test_nlsys_basic(): def kincar_update(t, x, u, params): @@ -154,3 +158,41 @@ def test_nlsys_empty_io(): resp = ct.forced_response(P, np.linspace(0, 1), 1) np.testing.assert_allclose(resp.states[:, -1], 1 - math.exp(-1)) + + +def test_ss2io(): + sys = ct.rss( + states=4, inputs=['u1', 'u2'], outputs=['y1', 'y2'], name='sys') + + # Standard conversion + nlsys = ct.nlsys(sys) + for attr in ['nstates', 'ninputs', 'noutputs']: + assert getattr(nlsys, attr) == getattr(sys, attr) + assert nlsys.name == 'sys$converted' + + # Put names back to defaults + nlsys = ct.nlsys( + sys, inputs=sys.ninputs, outputs=sys.noutputs, states=sys.nstates) + for attr, prefix in zip( + ['state_labels', 'input_labels', 'output_labels'], + ['x', 'u', 'y']): + for i in range(len(getattr(nlsys, attr))): + assert getattr(nlsys, attr)[i] == f"{prefix}[{i}]" + assert re.match(r"sys\$converted", nlsys.name) + + # Override the names with something new + nlsys = ct.nlsys( + sys, inputs=['U1', 'U2'], outputs=['Y1', 'Y2'], + states=['X1', 'X2', 'X3', 'X4'], name='nlsys') + for attr, prefix in zip( + ['state_labels', 'input_labels', 'output_labels'], + ['X', 'U', 'Y']): + for i in range(len(getattr(nlsys, attr))): + assert getattr(nlsys, attr)[i] == f"{prefix}{i+1}" + assert nlsys.name == 'nlsys' + + # Make sure dimension checking works + for attr in ['states', 'inputs', 'outputs']: + with pytest.raises(ValueError, match=r"new .* doesn't match"): + kwargs = {attr: getattr(sys, 'n' + attr) - 1} + nlsys = ct.nlsys(sys, **kwargs) From 319a8eaf541d46152b32af2d78ebdacf69f35821 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Mon, 16 Dec 2024 21:57:04 -0800 Subject: [PATCH 08/16] update flatsys() documentation --- control/flatsys/flatsys.py | 33 ++++++++++++++++++++++++++------ control/tests/docstrings_test.py | 17 ++++++++-------- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/control/flatsys/flatsys.py b/control/flatsys/flatsys.py index ff8683c14..fc10ce7dc 100644 --- a/control/flatsys/flatsys.py +++ b/control/flatsys/flatsys.py @@ -17,8 +17,30 @@ class FlatSystem(NonlinearIOSystem): """Base class for representing a differentially flat system. The FlatSystem class is used as a base class to describe differentially - flat systems for trajectory generation. The output of the system does not - need to be the differentially flat output. + flat systems for trajectory generation. The output of the system does + not need to be the differentially flat output. Flat systems are + usually created with the :func:`~control.flatsys.flatsys` factory + function. + + Parameters + ---------- + forward : callable + A function to compute the flat flag given the states and input. + reverse : callable + A function to compute the states and input given the flat flag. + dt : None, True or float, optional + System timebase. + + Attributes + ---------- + ninputs, noutputs, nstates : int + Number of input, output and state variables. + shape : tuple + 2-tuple of I/O system dimension, (noutputs, ninputs). + input_labels, output_labels, state_labels : list of str + Names for the input, output, and state variables. + name : string, optional + System name. Notes ----- @@ -197,10 +219,9 @@ def flatsys(*args, updfcn=None, outfcn=None, **kwargs): 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. None (default) indicates continuous time, True + indicates discrete time with undefined sampling time, positive + number is discrete time with specified sampling time. params : dict, optional Parameter values for the systems. Passed to the evaluation diff --git a/control/tests/docstrings_test.py b/control/tests/docstrings_test.py index 54bffedd9..22fe2d141 100644 --- a/control/tests/docstrings_test.py +++ b/control/tests/docstrings_test.py @@ -272,7 +272,7 @@ def test_deprecated_functions(module, prefix): # Dictionary of factory functions associated with primary classes class_factory_function = { - # fs.FlatSystem: fs.flatsys, + fs.FlatSystem: fs.flatsys, ct.FrequencyResponseData: ct.frd, ct.InterconnectedSystem: ct.interconnect, ct.LinearICSystem: ct.interconnect, @@ -283,7 +283,7 @@ def test_deprecated_functions(module, prefix): # List of arguments described in class docstrings class_args = { - # fs.FlatSystem: ['forward', 'reverse', 'updfcn', 'outfcn'], + fs.FlatSystem: ['forward', 'reverse'], ct.FrequencyResponseData: ['response', 'omega', 'dt'], ct.NonlinearIOSystem: [ 'updfcn', 'outfcn', 'inputs', 'outputs', 'states', 'params', 'dt'], @@ -297,7 +297,7 @@ def test_deprecated_functions(module, prefix): # List of attributes defined for specific I/O systems class_attributes = { - # fs.FlatSystem: [], + fs.FlatSystem: [], ct.FrequencyResponseData: [], ct.NonlinearIOSystem: [], ct.StateSpace: ['nstates', 'state_labels'], @@ -315,7 +315,7 @@ def test_deprecated_functions(module, prefix): # List of arguments described (only) in factory function docstrings std_factory_args = ['inputs', 'outputs', 'name'] factory_args = { - # fs.flatsys: [], + fs.flatsys: [], ct.frd: ['sys'], ct.nlsys: [], ct.ss: ['sys', 'states'], @@ -337,7 +337,7 @@ def test_iosys_primary_classes(cls, fcn, args): # Make sure we reference the factory function if re.search( r"created.*(with|by|using).*the[\s]*" - f":func:`~control.{fcn.__name__}`" + f":func:`~control\\..*{fcn.__name__}`" r"[\s]factory[\s]function", docstring, re.DOTALL) is None: pytest.fail( f"{cls.__name__} does not reference factory function " @@ -357,6 +357,7 @@ def test_iosys_attribute_lists(cls, ignore_future_warning): # Create a system that we can scan for attributes sys = ct.rss(2, 1, 1) + ignore_args = [] match fcn: case ct.tf: sys = ct.tf(sys) @@ -366,9 +367,9 @@ def test_iosys_attribute_lists(cls, ignore_future_warning): ignore_args = ['state_labels'] case ct.nlsys: sys = ct.nlsys(sys) - ignore_args = [] - case _: - ignore_args = [] + case fs.flatsys: + sys = fs.flatsys(sys) + sys = fs.flatsys(sys.forward, sys.reverse) docstring = inspect.getdoc(cls) for name, obj in inspect.getmembers(sys): From 2f2e372623f1ca003bfe360d42451c5e74e10de3 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Tue, 17 Dec 2024 08:10:19 -0800 Subject: [PATCH 09/16] add unit test to insure all factory functions handle renaming --- control/tests/iosys_test.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index 0216235b5..d1d6acccc 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -17,7 +17,8 @@ import scipy import control as ct - +import control.flatsys as fs +from control.tests.conftest import slycotonly class TestIOSys: @@ -2284,3 +2285,21 @@ def test_signal_indexing(): with pytest.raises(IndexError, match=r"signal name\(s\) not valid"): resp.outputs['y[0]', 'u[0]'] +@pytest.mark.parametrize("fcn", [ct.ss, ct.tf, ct.frd, ct.nlsys, fs.flatsys]) +def test_relabeling(fcn): + sys = ct.rss(1, 1, 1, name="sys") + + # Rename the inputs, outputs, (states,) system + match fcn: + case ct.tf: + sys = fcn(sys, inputs='u', outputs='y', name='new') + case ct.frd: + sys = fcn(sys, [0.1, 1, 10], inputs='u', outputs='y', name='new') + case _: + sys = fcn(sys, inputs='u', outputs='y', states='x', name='new') + + assert sys.input_labels == ['u'] + assert sys.output_labels == ['y'] + if sys.nstates: + assert sys.state_labels == ['x'] + assert sys.name == 'new' From 7a79342f6aa7d98dcaab1020ae1c5b2ea431b31c Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Tue, 17 Dec 2024 22:08:16 -0800 Subject: [PATCH 10/16] document and fix {input,output,state}_prefix parameters --- control/flatsys/flatsys.py | 6 +++++ control/frdata.py | 6 +++-- control/nlsys.py | 6 +++++ control/statesp.py | 1 - control/tests/docstrings_test.py | 44 +++++++++++++++++++++++++------- control/tests/iosys_test.py | 34 ++++++++++++++++++++++++ control/xferfcn.py | 8 +++--- 7 files changed, 90 insertions(+), 15 deletions(-) diff --git a/control/flatsys/flatsys.py b/control/flatsys/flatsys.py index fc10ce7dc..7d76b9d78 100644 --- a/control/flatsys/flatsys.py +++ b/control/flatsys/flatsys.py @@ -236,6 +236,12 @@ def flatsys(*args, updfcn=None, outfcn=None, **kwargs): sys: :class:`FlatSystem` Flat system. + Other Parameters + ---------------- + input_prefix, output_prefix, state_prefix : string, optional + Set the prefix for input, output, and state signals. Defaults = + 'u', 'y', 'x'. + """ from .linflat import LinearFlatSystem from ..statesp import StateSpace diff --git a/control/frdata.py b/control/frdata.py index 4b58ad7af..d43f22471 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -299,9 +299,9 @@ def __init__(self, *args, **kwargs): # Process signal names name, inputs, outputs, states, dt = _process_iosys_keywords( - kwargs, defaults, end=True) + kwargs, defaults) InputOutputSystem.__init__( - self, name=name, inputs=inputs, outputs=outputs, dt=dt) + self, name=name, inputs=inputs, outputs=outputs, dt=dt, **kwargs) # create interpolation functions if smooth: @@ -972,6 +972,8 @@ def frd(*args, **kwargs): List of strings that name the individual signals of the transformed system. If not given, the inputs and outputs are the same as the original system. + input_prefix, output_prefix : string, optional + Set the prefix for input and output signals. Defaults = 'u', 'y'. name : string, optional System name. If unspecified, a generic name is generated with a unique integer id. diff --git a/control/nlsys.py b/control/nlsys.py index 7689db15e..ed42ec6f8 100644 --- a/control/nlsys.py +++ b/control/nlsys.py @@ -1285,6 +1285,12 @@ def nlsys(updfcn, outfcn=None, **kwargs): sys : :class:`NonlinearIOSystem` Nonlinear input/output system. + Other Parameters + ---------------- + input_prefix, output_prefix, state_prefix : string, optional + Set the prefix for input, output, and state signals. Defaults = + 'u', 'y', 'x'. + See Also -------- ss, tf diff --git a/control/statesp.py b/control/statesp.py index a09045047..11f35066a 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -1577,7 +1577,6 @@ def ss(*args, **kwargs): out: StateSpace Linear input/output system. ->>>>>>> 1f609a56 (updated iosys class/factory function documentation + docstring unit testing) Raises ------ ValueError diff --git a/control/tests/docstrings_test.py b/control/tests/docstrings_test.py index 22fe2d141..53d692dd3 100644 --- a/control/tests/docstrings_test.py +++ b/control/tests/docstrings_test.py @@ -281,7 +281,13 @@ def test_deprecated_functions(module, prefix): ct.TransferFunction: ct.tf, } +# # List of arguments described in class docstrings +# +# These are the minimal arguments needed to initialized the class. Optional +# arguments should be documented in the factory functions and do not need +# to be duplicated in the class documentation. +# class_args = { fs.FlatSystem: ['forward', 'reverse'], ct.FrequencyResponseData: ['response', 'omega', 'dt'], @@ -291,7 +297,19 @@ def test_deprecated_functions(module, prefix): ct.TransferFunction: ['num', 'den', 'dt'], } -# List of attributes defined for all I/O systems +# +# List of attributes described in class docstrings +# +# This is the list of attributes for the class that are not already listed +# as parameters used to inialize the class. These should all be defined +# in the class docstring. +# +# Attributes that are part of all I/O system classes should be listed in +# `std_class_attributes`. Attributes that are not commonly needed are +# defined as part of a parent class can just be documented there, and +# should be listed in `iosys_parent_attributes` (these will be searched +# using the MRO). + std_class_attributes = [ 'ninputs', 'noutputs', 'input_labels', 'output_labels', 'name', 'shape'] @@ -299,7 +317,7 @@ def test_deprecated_functions(module, prefix): class_attributes = { fs.FlatSystem: [], ct.FrequencyResponseData: [], - ct.NonlinearIOSystem: [], + ct.NonlinearIOSystem: ['nstates', 'state_labels'], ct.StateSpace: ['nstates', 'state_labels'], ct.TransferFunction: [], } @@ -312,13 +330,22 @@ def test_deprecated_functions(module, prefix): 'params', 'outfcn', 'updfcn' # NL I/O, SS overlap ] +# # List of arguments described (only) in factory function docstrings -std_factory_args = ['inputs', 'outputs', 'name'] +# +# These lists consist of the arguments that should be documented in the +# factory functions and should not be duplicated in the class +# documentation, even though in some cases they are actually processed in +# the class __init__ function. +# +std_factory_args = [ + 'inputs', 'outputs', 'name', 'input_prefix', 'output_prefix'] + factory_args = { - fs.flatsys: [], + fs.flatsys: ['states', 'state_prefix'], ct.frd: ['sys'], - ct.nlsys: [], - ct.ss: ['sys', 'states'], + ct.nlsys: ['state_prefix'], + ct.ss: ['sys', 'states', 'state_prefix'], ct.tf: ['sys'], } @@ -343,7 +370,7 @@ def test_iosys_primary_classes(cls, fcn, args): f"{cls.__name__} does not reference factory function " f"{fcn.__name__}") - # Make sure we don't reference parameters in the factory function + # Make sure we don't reference parameters from the factory function for argname in factory_args[fcn]: if re.search(f"[\\s]+{argname}(, .*)*[\\s]*:", docstring) is not None: pytest.fail( @@ -448,7 +475,7 @@ def test_iosys_factory_functions(fcn): list(class_factory_function.values()).index(fcn)] # Make sure we reference parameters in class and factory function docstring - for argname in class_args[cls] + factory_args[fcn]: + for argname in class_args[cls] + std_factory_args + factory_args[fcn]: _check_parameter_docs(fcn.__name__, argname, docstring) # Make sure we don't reference any class attributes @@ -516,4 +543,3 @@ def _check_parameter_docs( pytest.fail(f"{funcname} '{argname}' documented twice") return True - diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index d1d6acccc..25136f475 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -2303,3 +2303,37 @@ def test_relabeling(fcn): if sys.nstates: assert sys.state_labels == ['x'] assert sys.name == 'new' + + +@pytest.mark.parametrize("fcn", [ct.ss, ct.tf, ct.frd, ct.nlsys, fs.flatsys]) +def test_signal_prefixing(fcn): + sys = ct.rss(2, 1, 1) + + # Recreate the system in different forms, with non-standard prefixes + match fcn: + case ct.ss: + sys = ct.ss( + sys.A, sys.B, sys.C, sys.D, state_prefix='xx', + input_prefix='uu', output_prefix='yy') + case ct.tf: + sys = ct.tf(sys) + sys = fcn(sys.num, sys.den, input_prefix='uu', output_prefix='yy') + case ct.frd: + freq = [0.1, 1, 10] + data = [sys(w * 1j) for w in freq] + sys = fcn(data, freq, input_prefix='uu', output_prefix='yy') + case ct.nlsys: + sys = ct.nlsys(sys) + sys = fcn( + sys.updfcn, sys.outfcn, inputs=1, outputs=1, states=2, + state_prefix='xx', input_prefix='uu', output_prefix='yy') + case fs.flatsys: + sys = fs.flatsys(sys) + sys = fcn( + sys.forward, sys.reverse, inputs=1, outputs=1, states=2, + state_prefix='xx', input_prefix='uu', output_prefix='yy') + + assert sys.input_labels == ['uu[0]'] + assert sys.output_labels == ['yy[0]'] + if sys.nstates: + assert sys.state_labels == ['xx[0]', 'xx[1]'] diff --git a/control/xferfcn.py b/control/xferfcn.py index 0bcc42915..b258079d4 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -20,9 +20,9 @@ import numpy as np import scipy as sp -from numpy import angle, array, delete, empty, exp, finfo, ndarray, nonzero, \ - ones, pi, poly, polyadd, polymul, polyval, real, roots, sqrt, squeeze, \ - where, zeros +from numpy import angle, array, delete, empty, exp, finfo, float64, ndarray, \ + nonzero, ones, pi, poly, polyadd, polymul, polyval, real, roots, sqrt, \ + squeeze, where, zeros from scipy.signal import TransferFunction as signalTransferFunction from scipy.signal import cont2discrete, tf2zpk, zpk2tf @@ -1595,6 +1595,8 @@ def tf(*args, **kwargs): List of strings that name the individual signals of the transformed system. If not given, the inputs and outputs are the same as the original system. + input_prefix, output_prefix : string, optional + Set the prefix for input and output signals. Defaults = 'u', 'y'. name : string, optional System name. If unspecified, a generic name is generated with a unique integer id. From 34d5d03495c1b16337a844bf8f7ce8c3a541f615 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 20 Dec 2024 12:17:49 -0800 Subject: [PATCH 11/16] small docstring and unit test warning cleanup --- control/statesp.py | 7 +------ control/tests/statesp_test.py | 10 +++++----- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/control/statesp.py b/control/statesp.py index 11f35066a..479f775e7 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -1555,7 +1555,7 @@ def ss(*args, **kwargs): Returns ------- - out: StateSpace + out : StateSpace Linear input/output system. Other Parameters @@ -1572,11 +1572,6 @@ def ss(*args, **kwargs): System name (used for specifying signals). If unspecified, a generic name is generated with a unique integer id. - Returns - ------- - out: StateSpace - Linear input/output system. - Raises ------ ValueError diff --git a/control/tests/statesp_test.py b/control/tests/statesp_test.py index 1798c524c..2bb0badc5 100644 --- a/control/tests/statesp_test.py +++ b/control/tests/statesp_test.py @@ -127,19 +127,19 @@ def test_constructor(self, sys322ABCD, dt, argfun): ((1, 2), TypeError, "1, 4, or 5 arguments"), ((np.ones((3, 2)), np.ones((3, 2)), np.ones((2, 2)), np.ones((2, 2))), ValueError, - "A is the wrong shape; expected \(3, 3\)"), + r"A is the wrong shape; expected \(3, 3\)"), ((np.ones((3, 3)), np.ones((2, 2)), np.ones((2, 3)), np.ones((2, 2))), ValueError, - "B is the wrong shape; expected \(3, 2\)"), + r"B is the wrong shape; expected \(3, 2\)"), ((np.ones((3, 3)), np.ones((3, 2)), np.ones((2, 2)), np.ones((2, 2))), ValueError, - "C is the wrong shape; expected \(2, 3\)"), + r"C is the wrong shape; expected \(2, 3\)"), ((np.ones((3, 3)), np.ones((3, 2)), np.ones((2, 3)), np.ones((2, 3))), ValueError, - "D is the wrong shape; expected \(2, 2\)"), + r"D is the wrong shape; expected \(2, 2\)"), ((np.ones((3, 3)), np.ones((3, 2)), np.ones((2, 3)), np.ones((3, 2))), ValueError, - "D is the wrong shape; expected \(2, 2\)"), + r"D is the wrong shape; expected \(2, 2\)"), ]) def test_constructor_invalid(self, args, exc, errmsg): """Test invalid input to StateSpace() constructor""" From 8f3615b46cb56a87cce3c55956d1d6b73916f39e Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sun, 22 Dec 2024 09:50:43 -0800 Subject: [PATCH 12/16] add param processing to create_statefbk_iosystem --- control/statefbk.py | 17 +++++++++---- control/tests/statefbk_test.py | 44 +++++++++++++++++++++++++++++++--- examples/steering-gainsched.py | 31 +++++++++++++++--------- 3 files changed, 73 insertions(+), 19 deletions(-) diff --git a/control/statefbk.py b/control/statefbk.py index 1dd0be325..8faeea1e5 100644 --- a/control/statefbk.py +++ b/control/statefbk.py @@ -586,7 +586,8 @@ def create_statefbk_iosystem( controller_type=None, xd_labels=None, ud_labels=None, ref_labels=None, feedfwd_pattern='trajgen', gainsched_indices=None, gainsched_method='linear', control_indices=None, state_indices=None, - name=None, inputs=None, outputs=None, states=None, **kwargs): + name=None, inputs=None, outputs=None, states=None, params=None, + **kwargs): r"""Create an I/O system using a (full) state feedback controller. This function creates an input/output system that implements a @@ -751,6 +752,10 @@ def create_statefbk_iosystem( System name. If unspecified, a generic name is generated with a unique integer id. + params : dict, optional + System parameter values. By default, these will be copied from + `sys` and `ctrl`, but can be overriden with this keyword. + Examples -------- >>> import control as ct @@ -773,7 +778,8 @@ def create_statefbk_iosystem( if not isinstance(sys, NonlinearIOSystem): raise ControlArgument("Input system must be I/O system") - # Process (legacy) keywords + # Process keywords + params = sys.params if params is None else params controller_type = _process_legacy_keyword( kwargs, 'type', 'controller_type', controller_type) if kwargs: @@ -970,10 +976,10 @@ def _control_output(t, states, inputs, params): return u - params = {} if gainsched else {'K': K} + ctrl_params = {} if gainsched else {'K': K} ctrl = NonlinearIOSystem( _control_update, _control_output, name=name, inputs=inputs, - outputs=outputs, states=states, params=params) + outputs=outputs, states=states, params=ctrl_params) elif controller_type == 'iosystem' and feedfwd_pattern == 'trajgen': # Use the passed system to compute feedback compensation @@ -1061,7 +1067,8 @@ def _control_output(t, states, inputs, params): [sys, ctrl] if estimator == sys else [sys, ctrl, estimator], name=sys.name + "_" + ctrl.name, add_unused=True, inplist=inplist, inputs=input_labels, - outlist=outlist, outputs=output_labels + outlist=outlist, outputs=output_labels, + params= ctrl.params | params ) return ctrl, closed diff --git a/control/tests/statefbk_test.py b/control/tests/statefbk_test.py index 3928fb725..22a946fe3 100644 --- a/control/tests/statefbk_test.py +++ b/control/tests/statefbk_test.py @@ -930,9 +930,10 @@ def unicycle_update(t, x, u, params): return ct.NonlinearIOSystem( unicycle_update, None, - inputs = ['v', 'phi'], - outputs = ['x', 'y', 'theta'], - states = ['x_', 'y_', 'theta_']) + inputs=['v', 'phi'], + outputs=['x', 'y', 'theta'], + states=['x_', 'y_', 'theta_'], + params={'a': 1}) # only used for testing params from math import pi @@ -1194,3 +1195,40 @@ def test_create_statefbk_errors(): with pytest.raises(ControlArgument, match="feedfwd_pattern != 'refgain'"): ct.create_statefbk_iosystem(sys, K, Kf, feedfwd_pattern='trajgen') + + +def test_create_statefbk_params(unicycle): + # Speeds and angles at which to compute the gains + speeds = [1, 5, 10] + angles = np.linspace(0, pi/2, 4) + points = list(itertools.product(speeds, angles)) + + # Gains for each speed (using LQR controller) + Q = np.identity(unicycle.nstates) + R = np.identity(unicycle.ninputs) + gain, _, _ = ct.lqr(unicycle.linearize([0, 0, 0], [5, 0]), Q, R) + + # + # Schedule on desired speed and angle + # + + # Create a linear controller + ctrl, clsys = ct.create_statefbk_iosystem(unicycle, gain) + assert [k for k in ctrl.params.keys()] == [] + assert [k for k in clsys.params.keys()] == ['a'] + assert clsys.params['a'] == 1 + + # Create a nonlinear controller + ctrl, clsys = ct.create_statefbk_iosystem( + unicycle, gain, controller_type='nonlinear') + assert [k for k in ctrl.params.keys()] == ['K'] + assert [k for k in clsys.params.keys()] == ['K', 'a'] + assert clsys.params['a'] == 1 + + # Override the default parameters + ctrl, clsys = ct.create_statefbk_iosystem( + unicycle, gain, controller_type='nonlinear', params={'a': 2, 'b': 1}) + assert [k for k in ctrl.params.keys()] == ['K'] + assert [k for k in clsys.params.keys()] == ['K', 'a', 'b'] + assert clsys.params['a'] == 2 + assert clsys.params['b'] == 1 diff --git a/examples/steering-gainsched.py b/examples/steering-gainsched.py index 85e8d8bda..36dafd617 100644 --- a/examples/steering-gainsched.py +++ b/examples/steering-gainsched.py @@ -46,10 +46,10 @@ def vehicle_output(t, x, u, params): return x # return x, y, theta (full state) # Define the vehicle steering dynamics as an input/output system -vehicle = ct.NonlinearIOSystem( +vehicle = ct.nlsys( vehicle_update, vehicle_output, states=3, name='vehicle', - inputs=('v', 'phi'), - outputs=('x', 'y', 'theta')) + inputs=('v', 'phi'), outputs=('x', 'y', 'theta'), + params={'wheelbase': 3, 'maxsteer': 0.5}) # # Gain scheduled controller @@ -89,10 +89,12 @@ def control_output(t, x, u, params): return np.array([v, phi]) # Define the controller as an input/output system -controller = ct.NonlinearIOSystem( +controller = ct.nlsys( None, control_output, name='controller', # static system inputs=('ex', 'ey', 'etheta', 'vd', 'phid'), # system inputs - outputs=('v', 'phi') # system outputs + outputs=('v', 'phi'), # system outputs + params={'longpole': -2, 'latpole1': -1/2 + sqrt(-7)/2, + 'latpole2': -1/2 - sqrt(-7)/2, 'wheelbase': 3} ) # @@ -113,7 +115,7 @@ def trajgen_output(t, x, u, params): return np.array([vref * t, yref, 0, vref, 0]) # Define the trajectory generator as an input/output system -trajgen = ct.NonlinearIOSystem( +trajgen = ct.nlsys( None, trajgen_output, name='trajgen', inputs=('vref', 'yref'), outputs=('xd', 'yd', 'thetad', 'vd', 'phid')) @@ -156,10 +158,13 @@ def trajgen_output(t, x, u, params): inplist=['trajgen.vref', 'trajgen.yref'], inputs=['yref', 'vref'], - # System outputs + # System outputs outlist=['vehicle.x', 'vehicle.y', 'vehicle.theta', 'controller.v', 'controller.phi'], - outputs=['x', 'y', 'theta', 'v', 'phi'] + outputs=['x', 'y', 'theta', 'v', 'phi'], + + # Parameters + params=trajgen.params | vehicle.params | controller.params, ) # Set up the simulation conditions @@ -220,7 +225,8 @@ def trajgen_output(t, x, u, params): # Create the gain scheduled system controller, _ = ct.create_statefbk_iosystem( vehicle, (gains, points), name='controller', ud_labels=['vd', 'phid'], - gainsched_indices=['vd', 'theta'], gainsched_method='linear') + gainsched_indices=['vd', 'theta'], gainsched_method='linear', + params=vehicle.params | controller.params) # Connect everything together (note that controller inputs are different) steering = ct.interconnect( @@ -245,10 +251,13 @@ def trajgen_output(t, x, u, params): inplist=['trajgen.vref', 'trajgen.yref'], inputs=['yref', 'vref'], - # System outputs + # System outputs outlist=['vehicle.x', 'vehicle.y', 'vehicle.theta', 'controller.v', 'controller.phi'], - outputs=['x', 'y', 'theta', 'v', 'phi'] + outputs=['x', 'y', 'theta', 'v', 'phi'], + + # Parameters + params=steering.params ) # Plot the results to compare to the previous case From e4d373cf8f9a73802e8d7a62208340e15efed112 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sun, 22 Dec 2024 22:51:47 -0800 Subject: [PATCH 13/16] update default params computation in interconnect() --- control/nlsys.py | 8 +++++++- control/tests/interconnect_test.py | 20 +++++++++++++++++--- control/tests/iosys_test.py | 4 +++- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/control/nlsys.py b/control/nlsys.py index ed42ec6f8..83a1745cf 100644 --- a/control/nlsys.py +++ b/control/nlsys.py @@ -710,6 +710,11 @@ def __init__(self, syslist, connections=None, inplist=None, outlist=None, if outputs is None and outlist is not None: outputs = len(outlist) + if params is None: + params = {} + for sys in self.syslist: + params = params | sys.params + # Create updfcn and outfcn def updfcn(t, x, u, params): self._update_params(params) @@ -2268,7 +2273,8 @@ def interconnect( params : dict, optional Parameter values for the systems. Passed to the evaluation functions - for the system as default values, overriding internal defaults. + for the system as default values, overriding internal defaults. If + not specified, defaults to parameters from subsystems. dt : timebase, optional The timebase for the system, used to specify whether the system is diff --git a/control/tests/interconnect_test.py b/control/tests/interconnect_test.py index 604488ca5..d124859fc 100644 --- a/control/tests/interconnect_test.py +++ b/control/tests/interconnect_test.py @@ -666,15 +666,29 @@ def test_interconnect_params(): # Create a nominally unstable system sys1 = ct.nlsys( lambda t, x, u, params: params['a'] * x[0] + u[0], - states=1, inputs='u', outputs='y', params={'a': 1}) + states=1, inputs='u', outputs='y', params={'a': 2, 'c':2}) # Simple system for serial interconnection sys2 = ct.nlsys( None, lambda t, x, u, params: u[0], - inputs='r', outputs='u') + inputs='r', outputs='u', params={'a': 4, 'b': 3}) - # Create a series interconnection + # Make sure default parameters get set as expected sys = ct.interconnect([sys1, sys2], inputs='r', outputs='y') + assert sys.params == {'a': 4, 'c': 2, 'b': 3} + assert sys.dynamics(0, [1], [0]).item() == 4 + + # Make sure we can override the parameters + sys = ct.interconnect( + [sys1, sys2], inputs='r', outputs='y', params={'b': 1}) + assert sys.params == {'b': 1} + assert sys.dynamics(0, [1], [0]).item() == 2 + assert sys.dynamics(0, [1], [0], params={'a': 5}).item() == 5 + + # Create final series interconnection, with proper parameter values + sys = ct.interconnect( + [sys1, sys2], inputs='r', outputs='y', params={'a': 1}) + assert sys.params == {'a': 1} # Make sure we can call the update function sys.updfcn(0, [0], [0], {}) diff --git a/control/tests/iosys_test.py b/control/tests/iosys_test.py index 25136f475..54d6d56c8 100644 --- a/control/tests/iosys_test.py +++ b/control/tests/iosys_test.py @@ -931,6 +931,8 @@ def test_params(self, tsys): ios_secord_update = ct.NonlinearIOSystem( secord_update, secord_output, inputs=1, outputs=1, states=2, params={'omega0':2, 'zeta':0}) + lin_secord_update = ct.linearize(ios_secord_update, [0, 0], [0]) + w_update, v_update = np.linalg.eig(lin_secord_update.A) # Make sure the default parameters haven't changed lin_secord_check = ct.linearize(ios_secord_default, [0, 0], [0]) @@ -960,7 +962,7 @@ def test_params(self, tsys): ios_series_default_local, [0, 0, 0, 0], [0]) w, v = np.linalg.eig(lin_series_default_local.A) np.testing.assert_array_almost_equal( - np.sort(w), np.sort(np.concatenate((w_default, [2j, -2j])))) + w, np.concatenate([w_update, w_update])) # Show that we can change the parameters at linearization lin_series_override = ct.linearize( From e10d4814d8b87112b593922227ac2b29445cd7d4 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sun, 29 Dec 2024 14:15:13 -0800 Subject: [PATCH 14/16] update to address 29 Dec comments by @slivingston --- control/frdata.py | 4 ++-- control/statesp.py | 7 ++++--- control/tests/docstrings_test.py | 4 ++-- control/xferfcn.py | 15 ++++++++------- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/control/frdata.py b/control/frdata.py index d43f22471..64a1e8227 100644 --- a/control/frdata.py +++ b/control/frdata.py @@ -81,7 +81,7 @@ class constructor, using the :func:`~control.frd` factory function, or frequency : 1D array Array of frequency points for which data are available. ninputs, noutputs : int - Number of inputs and outputs signals. + Number of input and output signals. shape : tuple 2-tuple of I/O system dimension, (noutputs, ninputs). input_labels, output_labels : array of str @@ -93,7 +93,7 @@ class constructor, using the :func:`~control.frd` factory function, or magnitude : array Magnitude of the frequency response, indexed by frequency. phase : array - Magnitude of the frequency response, indexed by frequency. + Phase of the frequency response, indexed by frequency. Other Parameters ---------------- diff --git a/control/statesp.py b/control/statesp.py index 479f775e7..8675e8aa3 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -1,8 +1,9 @@ # statesp.py - state space class and related functions # -# Author: Richard M. Murray -# Date: 24 May 09 -# Revised: Kevin K. Chen, Dec 10 +# Original author: Richard M. Murray +# Creation date: 24 May 2009 +# Pre-2014 revisions: Kevin K. Chen, Dec 10 +# Use `git shortlog -n -s statesp.py` for full list of contributors """State space representation and functions. diff --git a/control/tests/docstrings_test.py b/control/tests/docstrings_test.py index 53d692dd3..8e2fd5d57 100644 --- a/control/tests/docstrings_test.py +++ b/control/tests/docstrings_test.py @@ -284,7 +284,7 @@ def test_deprecated_functions(module, prefix): # # List of arguments described in class docstrings # -# These are the minimal arguments needed to initialized the class. Optional +# These are the minimal arguments needed to initialize the class. Optional # arguments should be documented in the factory functions and do not need # to be duplicated in the class documentation. # @@ -301,7 +301,7 @@ def test_deprecated_functions(module, prefix): # List of attributes described in class docstrings # # This is the list of attributes for the class that are not already listed -# as parameters used to inialize the class. These should all be defined +# as parameters used to initialize the class. These should all be defined # in the class docstring. # # Attributes that are part of all I/O system classes should be listed in diff --git a/control/xferfcn.py b/control/xferfcn.py index b258079d4..e960e97fd 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -1,8 +1,9 @@ # xferfcn.py - transfer function class and related functions # -# Author: Richard M. Murray -# Date: 24 May 09 -# Revised: Kevin K. Chen, Dec 2010 +# Original author: Richard M. Murray +# Creation date: 24 May 2009 +# Pre-2014 revisions: Kevin K. Chen, Dec 2010 +# Use `git shortlog -n -s xferfcn.py` for full list of contributors """Transfer function representation and functions. @@ -91,7 +92,7 @@ class TransferFunction(LTI): Notes ----- - The numerator and denominator polynomials are stored as 2D ndarray's + The numerator and denominator polynomials are stored as 2D ndarrays with each element containing a 1D ndarray of coefficients. These data structures can be retrieved using ``num_array`` and ``den_array``. For example, @@ -1399,9 +1400,9 @@ def _tf_factorized_polynomial_to_string(roots, gain=1, var='s'): def _tf_string_to_latex(thestr, var='s'): - """Superscript all digits in a polynomial string and convert float - coefficients in scientific notation to prettier LaTeX - representation. + """Superscript all digits in a polynomial string and convert + float coefficients in scientific notation to prettier LaTeX + representation. """ # TODO: make the multiplication sign configurable From bf191936e5e5da99da59e26c4fced6bdc646b43a Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 4 Jan 2025 08:42:12 -0800 Subject: [PATCH 15/16] Remove repr_format mentions; fix xferfcn static check per @slivingston --- control/iosys.py | 2 -- control/tests/docstrings_test.py | 1 - control/tests/xferfcn_test.py | 8 -------- control/xferfcn.py | 3 +++ 4 files changed, 3 insertions(+), 11 deletions(-) diff --git a/control/iosys.py b/control/iosys.py index ec362df65..2c1f9cea7 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -151,8 +151,6 @@ class InputOutputSystem(object): the index of the corresponding array. input_labels, output_labels, state_labels : list of str List of signal names for inputs, outputs, and states. - repr_format : str - String representation format ('iosys' or 'loadable'). shape : tuple 2-tuple of I/O system dimension, (noutputs, ninputs). diff --git a/control/tests/docstrings_test.py b/control/tests/docstrings_test.py index 8e2fd5d57..16647895a 100644 --- a/control/tests/docstrings_test.py +++ b/control/tests/docstrings_test.py @@ -325,7 +325,6 @@ def test_deprecated_functions(module, prefix): # List of attributes defined in a parent class (no need to warn) iosys_parent_attributes = [ 'input_index', 'output_index', 'state_index', # rarely used - 'repr_format', # rarely used 'states', 'nstates', 'state_labels', # not need in TF, FRD 'params', 'outfcn', 'updfcn' # NL I/O, SS overlap ] diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py index bd6fedcf7..3f87ef1d2 100644 --- a/control/tests/xferfcn_test.py +++ b/control/tests/xferfcn_test.py @@ -30,14 +30,6 @@ class TestXferFcn: def test_constructor_bad_input_type(self): """Give the constructor invalid input types.""" - # MIMO requires lists of lists of vectors (not lists of vectors) - # 13 Dec 2024: This now works correctly: creates static array (as tf) - # with pytest.raises(TypeError): - # TransferFunction([[0., 1.], [2., 3.]], [[5., 2.], [3., 0.]]) - # good input - TransferFunction([[[0., 1.], [2., 3.]]], - [[[5., 2.], [3., 0.]]]) - # Single argument of the wrong type with pytest.raises(TypeError): TransferFunction([1]) diff --git a/control/xferfcn.py b/control/xferfcn.py index e960e97fd..9ebbaf4f9 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -223,6 +223,9 @@ def __init__(self, *args, **kwargs): for poly in np.nditer(arr, flags=['refs_ok']): if poly.item().size > 1: static = False + break + if not static: + break defaults = args[0] if len(args) == 1 else \ {'inputs': num.shape[1], 'outputs': num.shape[0]} From 10614458501af4985db68d58ee401dcae318bbde Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 4 Jan 2025 10:44:04 -0800 Subject: [PATCH 16/16] addressed @slivingston review comments --- control/nlsys.py | 7 +++++-- control/statefbk.py | 2 +- control/statesp.py | 3 +-- control/tests/nlsys_test.py | 3 +++ 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/control/nlsys.py b/control/nlsys.py index 83a1745cf..beb2566e7 100644 --- a/control/nlsys.py +++ b/control/nlsys.py @@ -1333,8 +1333,11 @@ def nlsys(updfcn, outfcn=None, **kwargs): sys_ss.name, prefix_suffix_name='converted')) sys_nl = NonlinearIOSystem( - lambda t, x, u, params: sys.A @ x + sys.B @ u, - lambda t, x, u, params: sys.C @ x + sys.D @ u, **kwargs) + lambda t, x, u, params: + sys_ss.A @ np.atleast_1d(x) + sys_ss.B @ np.atleast_1d(u), + lambda t, x, u, params: + sys_ss.C @ np.atleast_1d(x) + sys_ss.D @ np.atleast_1d(u), + **kwargs) if sys_nl.nstates != sys_ss.nstates or sys_nl.shape != sys_ss.shape: raise ValueError( diff --git a/control/statefbk.py b/control/statefbk.py index 8faeea1e5..c5c5a8030 100644 --- a/control/statefbk.py +++ b/control/statefbk.py @@ -754,7 +754,7 @@ def create_statefbk_iosystem( params : dict, optional System parameter values. By default, these will be copied from - `sys` and `ctrl`, but can be overriden with this keyword. + `sys` and `ctrl`, but can be overridden with this keyword. Examples -------- diff --git a/control/statesp.py b/control/statesp.py index 8675e8aa3..3f53777e5 100644 --- a/control/statesp.py +++ b/control/statesp.py @@ -1529,8 +1529,7 @@ def ss(*args, **kwargs): systems, `B` and `C` can be given as 1D arrays and D can be given as a scalar. - ``ss(*args, inputs=['u1', ..., 'up'], outputs=['y1', ..., 'yq'], - states=['x1', ..., 'xn'])`` + ``ss(*args, inputs=['u1', ..., 'up'], outputs=['y1', ..., 'yq'], states=['x1', ..., 'xn'])`` Create a system with named input, output, and state signals. Parameters diff --git a/control/tests/nlsys_test.py b/control/tests/nlsys_test.py index f64057fd7..926ca4364 100644 --- a/control/tests/nlsys_test.py +++ b/control/tests/nlsys_test.py @@ -169,6 +169,9 @@ def test_ss2io(): for attr in ['nstates', 'ninputs', 'noutputs']: assert getattr(nlsys, attr) == getattr(sys, attr) assert nlsys.name == 'sys$converted' + np.testing.assert_allclose( + nlsys.dynamics(0, [1, 2, 3, 4], [0, 0], {}), + sys.A @ np.array([1, 2, 3, 4])) # Put names back to defaults nlsys = ct.nlsys(