diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py index 6e1cf6ce2..ebb781b89 100644 --- a/control/tests/xferfcn_test.py +++ b/control/tests/xferfcn_test.py @@ -9,8 +9,9 @@ import control as ct from control import StateSpace, TransferFunction, rss, evalfr -from control import ss, ss2tf, tf, tf2ss -from control import isctime, isdtime, sample_system, defaults +from control import ss, ss2tf, tf, tf2ss, zpk +from control import isctime, isdtime, sample_system +from control import defaults, reset_defaults, set_defaults from control.statesp import _convert_to_statespace from control.xferfcn import _convert_to_transfer_function from control.tests.conftest import slycotonly, matrixfilter @@ -906,6 +907,128 @@ def test_printing_mimo(self): assert isinstance(str(sys), str) assert isinstance(sys._repr_latex_(), str) + @pytest.mark.parametrize( + "zeros, poles, gain, output", + [([0], [-1], 1, + '\n' + ' s\n' + '-----\n' + 's + 1\n'), + ([-1], [-1], 1, + '\n' + 's + 1\n' + '-----\n' + 's + 1\n'), + ([-1], [1], 1, + '\n' + 's + 1\n' + '-----\n' + 's - 1\n'), + ([1], [-1], 1, + '\n' + 's - 1\n' + '-----\n' + 's + 1\n'), + ([-1], [-1], 2, + '\n' + '2 (s + 1)\n' + '---------\n' + ' s + 1\n'), + ([-1], [-1], 0, + '\n' + '0\n' + '-\n' + '1\n'), + ([-1], [1j, -1j], 1, + '\n' + ' s + 1\n' + '-----------------\n' + '(s - 1j) (s + 1j)\n'), + ([4j, -4j], [2j, -2j], 2, + '\n' + '2 (s - 4j) (s + 4j)\n' + '-------------------\n' + ' (s - 2j) (s + 2j)\n'), + ([1j, -1j], [-1, -4], 2, + '\n' + '2 (s - 1j) (s + 1j)\n' + '-------------------\n' + ' (s + 1) (s + 4)\n'), + ([1], [-1 + 1j, -1 - 1j], 1, + '\n' + ' s - 1\n' + '-------------------------\n' + '(s + (1-1j)) (s + (1+1j))\n'), + ([1], [1 + 1j, 1 - 1j], 1, + '\n' + ' s - 1\n' + '-------------------------\n' + '(s - (1+1j)) (s - (1-1j))\n'), + ]) + def test_printing_zpk(self, zeros, poles, gain, output): + """Test _tf_polynomial_to_string for constant systems""" + G = zpk(zeros, poles, gain, display_format='zpk') + res = str(G) + assert res == output + + @pytest.mark.parametrize( + "zeros, poles, gain, format, output", + [([1], [1 + 1j, 1 - 1j], 1, ".2f", + '\n' + ' 1.00\n' + '-------------------------------------\n' + '(s + (1.00-1.41j)) (s + (1.00+1.41j))\n'), + ([1], [1 + 1j, 1 - 1j], 1, ".3f", + '\n' + ' 1.000\n' + '-----------------------------------------\n' + '(s + (1.000-1.414j)) (s + (1.000+1.414j))\n'), + ([1], [1 + 1j, 1 - 1j], 1, ".6g", + '\n' + ' 1\n' + '-------------------------------------\n' + '(s + (1-1.41421j)) (s + (1+1.41421j))\n') + ]) + def test_printing_zpk_format(self, zeros, poles, gain, format, output): + """Test _tf_polynomial_to_string for constant systems""" + G = tf([1], [1,2,3], display_format='zpk') + + set_defaults('xferfcn', floating_point_format=format) + res = str(G) + reset_defaults() + + assert res == output + + @pytest.mark.parametrize( + "num, den, output", + [([[[11], [21]], [[12], [22]]], + [[[1, -3, 2], [1, 1, -6]], [[1, 0, 1], [1, -1, -20]]], + ('\n' + 'Input 1 to output 1:\n' + ' 11\n' + '---------------\n' + '(s - 2) (s - 1)\n' + '\n' + 'Input 1 to output 2:\n' + ' 12\n' + '-----------------\n' + '(s - 1j) (s + 1j)\n' + '\n' + 'Input 2 to output 1:\n' + ' 21\n' + '---------------\n' + '(s - 2) (s + 3)\n' + '\n' + 'Input 2 to output 2:\n' + ' 22\n' + '---------------\n' + '(s - 5) (s + 4)\n'))]) + def test_printing_zpk_mimo(self, num, den, output): + """Test _tf_polynomial_to_string for constant systems""" + G = tf(num, den, display_format='zpk') + res = str(G) + assert res == output + @slycotonly def test_size_mismatch(self): """Test size mismacht""" diff --git a/control/xferfcn.py b/control/xferfcn.py index 0bc84e096..6edb26858 100644 --- a/control/xferfcn.py +++ b/control/xferfcn.py @@ -69,7 +69,14 @@ # Define module default parameter values -_xferfcn_defaults = {} +_xferfcn_defaults = { + 'xferfcn.display_format': 'poly', + 'xferfcn.floating_point_format': '.4g' +} + +def _float2str(value): + _num_format = config.defaults.get('xferfcn.floating_point_format', ':.4g') + return f"{value:{_num_format}}" class TransferFunction(LTI): @@ -92,6 +99,10 @@ class TransferFunction(LTI): 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']. Attributes ---------- @@ -198,6 +209,17 @@ def __init__(self, *args, **kwargs): # # Process keyword arguments # + # During module init, TransferFunction.s and TransferFunction.z + # get initialized when defaults are not fully initialized yet. + # Use 'poly' in these cases. + + self.display_format = kwargs.pop( + 'display_format', + config.defaults.get('xferfcn.display_format', 'poly')) + + if self.display_format not in ('poly', 'zpk'): + raise ValueError("display_format must be 'poly' or 'zpk'," + " got '%s'" % self.display_format) # Determine if the transfer function is static (needed for dt) static = True @@ -432,22 +454,29 @@ def _truncatecoeff(self): [self.num, self.den] = data def __str__(self, var=None): - """String representation of the transfer function.""" + """String representation of the transfer function. - mimo = self.ninputs > 1 or self.noutputs > 1 + Based on the display_format property, the output will be formatted as + either polynomials or in zpk form. + """ + mimo = not self.issiso() if var is None: - # TODO: replace with standard calls to lti functions - var = 's' if self.dt is None or self.dt == 0 else 'z' + var = 's' if self.isctime() else 'z' outstr = "" - for i in range(self.ninputs): - for j in range(self.noutputs): + for ni in range(self.ninputs): + for no in range(self.noutputs): if mimo: - outstr += "\nInput %i to output %i:" % (i + 1, j + 1) + outstr += "\nInput %i to output %i:" % (ni + 1, no + 1) # Convert the numerator and denominator polynomials to strings. - numstr = _tf_polynomial_to_string(self.num[j][i], var=var) - denstr = _tf_polynomial_to_string(self.den[j][i], var=var) + 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) + elif self.display_format == 'zpk': + 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) # Figure out the length of the separating line dashcount = max(len(numstr), len(denstr)) @@ -461,10 +490,9 @@ def __str__(self, var=None): outstr += "\n" + numstr + "\n" + dashes + "\n" + denstr + "\n" - # See if this is a discrete time system with specific sampling time - if not (self.dt is None) and type(self.dt) != bool and self.dt > 0: - # TODO: replace with standard calls to lti functions - outstr += "\ndt = " + self.dt.__str__() + "\n" + # If this is a strict discrete time system, print the sampling time + if type(self.dt) != bool and self.isdtime(strict=True): + outstr += "\ndt = " + str(self.dt) + "\n" return outstr @@ -485,7 +513,7 @@ def __repr__(self): def _repr_latex_(self, var=None): """LaTeX representation of transfer function, for Jupyter notebook""" - mimo = self.ninputs > 1 or self.noutputs > 1 + mimo = not self.issiso() if var is None: # ! TODO: replace with standard calls to lti functions @@ -496,18 +524,23 @@ def _repr_latex_(self, var=None): if mimo: out.append(r"\begin{bmatrix}") - for i in range(self.noutputs): - for j in range(self.ninputs): + for no in range(self.noutputs): + for ni in range(self.ninputs): # Convert the numerator and denominator polynomials to strings. - numstr = _tf_polynomial_to_string(self.num[i][j], var=var) - denstr = _tf_polynomial_to_string(self.den[i][j], var=var) + 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) + elif self.display_format == 'zpk': + 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) numstr = _tf_string_to_latex(numstr, var=var) denstr = _tf_string_to_latex(denstr, var=var) out += [r"\frac{", numstr, "}{", denstr, "}"] - if mimo and j < self.noutputs - 1: + if mimo and ni < self.ninputs - 1: out.append("&") if mimo: @@ -1285,7 +1318,7 @@ def _tf_polynomial_to_string(coeffs, var='s'): N = len(coeffs) - 1 for k in range(len(coeffs)): - coefstr = '%.4g' % abs(coeffs[k]) + coefstr = _float2str(abs(coeffs[k])) power = (N - k) if power == 0: if coefstr != '0': @@ -1323,6 +1356,48 @@ def _tf_polynomial_to_string(coeffs, var='s'): return thestr +def _tf_factorized_polynomial_to_string(roots, gain=1, var='s'): + """Convert a factorized polynomial to a string""" + + if roots.size == 0: + return _float2str(gain) + + factors = [] + for root in sorted(roots, reverse=True): + if np.isreal(root): + if root == 0: + factor = f"{var}" + factors.append(factor) + elif root > 0: + factor = f"{var} - {_float2str(np.abs(root))}" + factors.append(factor) + else: + factor = f"{var} + {_float2str(np.abs(root))}" + factors.append(factor) + elif np.isreal(root * 1j): + if root.imag > 0: + factor = f"{var} - {_float2str(np.abs(root))}j" + factors.append(factor) + else: + factor = f"{var} + {_float2str(np.abs(root))}j" + factors.append(factor) + else: + if root.real > 0: + factor = f"{var} - ({_float2str(root)})" + factors.append(factor) + else: + factor = f"{var} + ({_float2str(-root)})" + factors.append(factor) + + multiplier = '' + if round(gain, 4) != 1.0: + multiplier = _float2str(gain) + " " + + if len(factors) > 1 or multiplier: + factors = [f"({factor})" for factor in factors] + + return multiplier + " ".join(factors) + 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 @@ -1486,6 +1561,10 @@ def tf(*args, **kwargs): Polynomial coefficients of the numerator den: array_like, or list of list of array_like Polynomial coefficients of the denominator + 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'].. Returns ------- @@ -1538,7 +1617,7 @@ def tf(*args, **kwargs): >>> # Create a variable 's' to allow algebra operations for SISO systems >>> s = tf('s') - >>> G = (s + 1)/(s**2 + 2*s + 1) + >>> G = (s + 1) / (s**2 + 2*s + 1) >>> # Convert a StateSpace to a TransferFunction object. >>> sys_ss = ss("1. -2; 3. -4", "5.; 7", "6. 8", "9.") @@ -1609,12 +1688,24 @@ def zpk(zeros, poles, gain, *args, **kwargs): name : string, optional System name (used for specifying signals). If unspecified, a generic name is generated with a unique integer id. + 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']. Returns ------- out: :class:`TransferFunction` Transfer function with given zeros, poles, and gain. + Examples + -------- + >>> from control import tf + >>> G = zpk([1],[2, 3], gain=1, display_format='zpk') + >>> G + s - 1 + --------------- + (s - 2) (s - 3) """ num, den = zpk2tf(zeros, poles, gain) return TransferFunction(num, den, *args, **kwargs)