Skip to content

Commit 26c44e1

Browse files
authored
Merge pull request #869 from henklaak/feature_print_zpk
Add zpk display option for transfer functions
2 parents 177cba9 + 6933973 commit 26c44e1

File tree

2 files changed

+238
-24
lines changed

2 files changed

+238
-24
lines changed

control/tests/xferfcn_test.py

Lines changed: 125 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99

1010
import control as ct
1111
from control import StateSpace, TransferFunction, rss, evalfr
12-
from control import ss, ss2tf, tf, tf2ss
13-
from control import isctime, isdtime, sample_system, defaults
12+
from control import ss, ss2tf, tf, tf2ss, zpk
13+
from control import isctime, isdtime, sample_system
14+
from control import defaults, reset_defaults, set_defaults
1415
from control.statesp import _convert_to_statespace
1516
from control.xferfcn import _convert_to_transfer_function
1617
from control.tests.conftest import slycotonly, matrixfilter
@@ -906,6 +907,128 @@ def test_printing_mimo(self):
906907
assert isinstance(str(sys), str)
907908
assert isinstance(sys._repr_latex_(), str)
908909

910+
@pytest.mark.parametrize(
911+
"zeros, poles, gain, output",
912+
[([0], [-1], 1,
913+
'\n'
914+
' s\n'
915+
'-----\n'
916+
's + 1\n'),
917+
([-1], [-1], 1,
918+
'\n'
919+
's + 1\n'
920+
'-----\n'
921+
's + 1\n'),
922+
([-1], [1], 1,
923+
'\n'
924+
's + 1\n'
925+
'-----\n'
926+
's - 1\n'),
927+
([1], [-1], 1,
928+
'\n'
929+
's - 1\n'
930+
'-----\n'
931+
's + 1\n'),
932+
([-1], [-1], 2,
933+
'\n'
934+
'2 (s + 1)\n'
935+
'---------\n'
936+
' s + 1\n'),
937+
([-1], [-1], 0,
938+
'\n'
939+
'0\n'
940+
'-\n'
941+
'1\n'),
942+
([-1], [1j, -1j], 1,
943+
'\n'
944+
' s + 1\n'
945+
'-----------------\n'
946+
'(s - 1j) (s + 1j)\n'),
947+
([4j, -4j], [2j, -2j], 2,
948+
'\n'
949+
'2 (s - 4j) (s + 4j)\n'
950+
'-------------------\n'
951+
' (s - 2j) (s + 2j)\n'),
952+
([1j, -1j], [-1, -4], 2,
953+
'\n'
954+
'2 (s - 1j) (s + 1j)\n'
955+
'-------------------\n'
956+
' (s + 1) (s + 4)\n'),
957+
([1], [-1 + 1j, -1 - 1j], 1,
958+
'\n'
959+
' s - 1\n'
960+
'-------------------------\n'
961+
'(s + (1-1j)) (s + (1+1j))\n'),
962+
([1], [1 + 1j, 1 - 1j], 1,
963+
'\n'
964+
' s - 1\n'
965+
'-------------------------\n'
966+
'(s - (1+1j)) (s - (1-1j))\n'),
967+
])
968+
def test_printing_zpk(self, zeros, poles, gain, output):
969+
"""Test _tf_polynomial_to_string for constant systems"""
970+
G = zpk(zeros, poles, gain, display_format='zpk')
971+
res = str(G)
972+
assert res == output
973+
974+
@pytest.mark.parametrize(
975+
"zeros, poles, gain, format, output",
976+
[([1], [1 + 1j, 1 - 1j], 1, ".2f",
977+
'\n'
978+
' 1.00\n'
979+
'-------------------------------------\n'
980+
'(s + (1.00-1.41j)) (s + (1.00+1.41j))\n'),
981+
([1], [1 + 1j, 1 - 1j], 1, ".3f",
982+
'\n'
983+
' 1.000\n'
984+
'-----------------------------------------\n'
985+
'(s + (1.000-1.414j)) (s + (1.000+1.414j))\n'),
986+
([1], [1 + 1j, 1 - 1j], 1, ".6g",
987+
'\n'
988+
' 1\n'
989+
'-------------------------------------\n'
990+
'(s + (1-1.41421j)) (s + (1+1.41421j))\n')
991+
])
992+
def test_printing_zpk_format(self, zeros, poles, gain, format, output):
993+
"""Test _tf_polynomial_to_string for constant systems"""
994+
G = tf([1], [1,2,3], display_format='zpk')
995+
996+
set_defaults('xferfcn', floating_point_format=format)
997+
res = str(G)
998+
reset_defaults()
999+
1000+
assert res == output
1001+
1002+
@pytest.mark.parametrize(
1003+
"num, den, output",
1004+
[([[[11], [21]], [[12], [22]]],
1005+
[[[1, -3, 2], [1, 1, -6]], [[1, 0, 1], [1, -1, -20]]],
1006+
('\n'
1007+
'Input 1 to output 1:\n'
1008+
' 11\n'
1009+
'---------------\n'
1010+
'(s - 2) (s - 1)\n'
1011+
'\n'
1012+
'Input 1 to output 2:\n'
1013+
' 12\n'
1014+
'-----------------\n'
1015+
'(s - 1j) (s + 1j)\n'
1016+
'\n'
1017+
'Input 2 to output 1:\n'
1018+
' 21\n'
1019+
'---------------\n'
1020+
'(s - 2) (s + 3)\n'
1021+
'\n'
1022+
'Input 2 to output 2:\n'
1023+
' 22\n'
1024+
'---------------\n'
1025+
'(s - 5) (s + 4)\n'))])
1026+
def test_printing_zpk_mimo(self, num, den, output):
1027+
"""Test _tf_polynomial_to_string for constant systems"""
1028+
G = tf(num, den, display_format='zpk')
1029+
res = str(G)
1030+
assert res == output
1031+
9091032
@slycotonly
9101033
def test_size_mismatch(self):
9111034
"""Test size mismacht"""

control/xferfcn.py

Lines changed: 113 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,14 @@
6969

7070

7171
# Define module default parameter values
72-
_xferfcn_defaults = {}
72+
_xferfcn_defaults = {
73+
'xferfcn.display_format': 'poly',
74+
'xferfcn.floating_point_format': '.4g'
75+
}
76+
77+
def _float2str(value):
78+
_num_format = config.defaults.get('xferfcn.floating_point_format', ':.4g')
79+
return f"{value:{_num_format}}"
7380

7481

7582
class TransferFunction(LTI):
@@ -92,6 +99,10 @@ class TransferFunction(LTI):
9299
time, positive number is discrete time with specified
93100
sampling time, None indicates unspecified timebase (either
94101
continuous or discrete time).
102+
display_format: None, 'poly' or 'zpk'
103+
Set the display format used in printing the TransferFunction object.
104+
Default behavior is polynomial display and can be changed by
105+
changing config.defaults['xferfcn.display_format'].
95106
96107
Attributes
97108
----------
@@ -198,6 +209,17 @@ def __init__(self, *args, **kwargs):
198209
#
199210
# Process keyword arguments
200211
#
212+
# During module init, TransferFunction.s and TransferFunction.z
213+
# get initialized when defaults are not fully initialized yet.
214+
# Use 'poly' in these cases.
215+
216+
self.display_format = kwargs.pop(
217+
'display_format',
218+
config.defaults.get('xferfcn.display_format', 'poly'))
219+
220+
if self.display_format not in ('poly', 'zpk'):
221+
raise ValueError("display_format must be 'poly' or 'zpk',"
222+
" got '%s'" % self.display_format)
201223

202224
# Determine if the transfer function is static (needed for dt)
203225
static = True
@@ -432,22 +454,29 @@ def _truncatecoeff(self):
432454
[self.num, self.den] = data
433455

434456
def __str__(self, var=None):
435-
"""String representation of the transfer function."""
457+
"""String representation of the transfer function.
436458
437-
mimo = self.ninputs > 1 or self.noutputs > 1
459+
Based on the display_format property, the output will be formatted as
460+
either polynomials or in zpk form.
461+
"""
462+
mimo = not self.issiso()
438463
if var is None:
439-
# TODO: replace with standard calls to lti functions
440-
var = 's' if self.dt is None or self.dt == 0 else 'z'
464+
var = 's' if self.isctime() else 'z'
441465
outstr = ""
442466

443-
for i in range(self.ninputs):
444-
for j in range(self.noutputs):
467+
for ni in range(self.ninputs):
468+
for no in range(self.noutputs):
445469
if mimo:
446-
outstr += "\nInput %i to output %i:" % (i + 1, j + 1)
470+
outstr += "\nInput %i to output %i:" % (ni + 1, no + 1)
447471

448472
# Convert the numerator and denominator polynomials to strings.
449-
numstr = _tf_polynomial_to_string(self.num[j][i], var=var)
450-
denstr = _tf_polynomial_to_string(self.den[j][i], var=var)
473+
if self.display_format == 'poly':
474+
numstr = _tf_polynomial_to_string(self.num[no][ni], var=var)
475+
denstr = _tf_polynomial_to_string(self.den[no][ni], var=var)
476+
elif self.display_format == 'zpk':
477+
z, p, k = tf2zpk(self.num[no][ni], self.den[no][ni])
478+
numstr = _tf_factorized_polynomial_to_string(z, gain=k, var=var)
479+
denstr = _tf_factorized_polynomial_to_string(p, var=var)
451480

452481
# Figure out the length of the separating line
453482
dashcount = max(len(numstr), len(denstr))
@@ -461,10 +490,9 @@ def __str__(self, var=None):
461490

462491
outstr += "\n" + numstr + "\n" + dashes + "\n" + denstr + "\n"
463492

464-
# See if this is a discrete time system with specific sampling time
465-
if not (self.dt is None) and type(self.dt) != bool and self.dt > 0:
466-
# TODO: replace with standard calls to lti functions
467-
outstr += "\ndt = " + self.dt.__str__() + "\n"
493+
# If this is a strict discrete time system, print the sampling time
494+
if type(self.dt) != bool and self.isdtime(strict=True):
495+
outstr += "\ndt = " + str(self.dt) + "\n"
468496

469497
return outstr
470498

@@ -485,7 +513,7 @@ def __repr__(self):
485513
def _repr_latex_(self, var=None):
486514
"""LaTeX representation of transfer function, for Jupyter notebook"""
487515

488-
mimo = self.ninputs > 1 or self.noutputs > 1
516+
mimo = not self.issiso()
489517

490518
if var is None:
491519
# ! TODO: replace with standard calls to lti functions
@@ -496,18 +524,23 @@ def _repr_latex_(self, var=None):
496524
if mimo:
497525
out.append(r"\begin{bmatrix}")
498526

499-
for i in range(self.noutputs):
500-
for j in range(self.ninputs):
527+
for no in range(self.noutputs):
528+
for ni in range(self.ninputs):
501529
# Convert the numerator and denominator polynomials to strings.
502-
numstr = _tf_polynomial_to_string(self.num[i][j], var=var)
503-
denstr = _tf_polynomial_to_string(self.den[i][j], var=var)
530+
if self.display_format == 'poly':
531+
numstr = _tf_polynomial_to_string(self.num[no][ni], var=var)
532+
denstr = _tf_polynomial_to_string(self.den[no][ni], var=var)
533+
elif self.display_format == 'zpk':
534+
z, p, k = tf2zpk(self.num[no][ni], self.den[no][ni])
535+
numstr = _tf_factorized_polynomial_to_string(z, gain=k, var=var)
536+
denstr = _tf_factorized_polynomial_to_string(p, var=var)
504537

505538
numstr = _tf_string_to_latex(numstr, var=var)
506539
denstr = _tf_string_to_latex(denstr, var=var)
507540

508541
out += [r"\frac{", numstr, "}{", denstr, "}"]
509542

510-
if mimo and j < self.noutputs - 1:
543+
if mimo and ni < self.ninputs - 1:
511544
out.append("&")
512545

513546
if mimo:
@@ -1285,7 +1318,7 @@ def _tf_polynomial_to_string(coeffs, var='s'):
12851318
N = len(coeffs) - 1
12861319

12871320
for k in range(len(coeffs)):
1288-
coefstr = '%.4g' % abs(coeffs[k])
1321+
coefstr = _float2str(abs(coeffs[k]))
12891322
power = (N - k)
12901323
if power == 0:
12911324
if coefstr != '0':
@@ -1323,6 +1356,48 @@ def _tf_polynomial_to_string(coeffs, var='s'):
13231356
return thestr
13241357

13251358

1359+
def _tf_factorized_polynomial_to_string(roots, gain=1, var='s'):
1360+
"""Convert a factorized polynomial to a string"""
1361+
1362+
if roots.size == 0:
1363+
return _float2str(gain)
1364+
1365+
factors = []
1366+
for root in sorted(roots, reverse=True):
1367+
if np.isreal(root):
1368+
if root == 0:
1369+
factor = f"{var}"
1370+
factors.append(factor)
1371+
elif root > 0:
1372+
factor = f"{var} - {_float2str(np.abs(root))}"
1373+
factors.append(factor)
1374+
else:
1375+
factor = f"{var} + {_float2str(np.abs(root))}"
1376+
factors.append(factor)
1377+
elif np.isreal(root * 1j):
1378+
if root.imag > 0:
1379+
factor = f"{var} - {_float2str(np.abs(root))}j"
1380+
factors.append(factor)
1381+
else:
1382+
factor = f"{var} + {_float2str(np.abs(root))}j"
1383+
factors.append(factor)
1384+
else:
1385+
if root.real > 0:
1386+
factor = f"{var} - ({_float2str(root)})"
1387+
factors.append(factor)
1388+
else:
1389+
factor = f"{var} + ({_float2str(-root)})"
1390+
factors.append(factor)
1391+
1392+
multiplier = ''
1393+
if round(gain, 4) != 1.0:
1394+
multiplier = _float2str(gain) + " "
1395+
1396+
if len(factors) > 1 or multiplier:
1397+
factors = [f"({factor})" for factor in factors]
1398+
1399+
return multiplier + " ".join(factors)
1400+
13261401
def _tf_string_to_latex(thestr, var='s'):
13271402
""" make sure to superscript all digits in a polynomial string
13281403
and convert float coefficients in scientific notation
@@ -1486,6 +1561,10 @@ def tf(*args, **kwargs):
14861561
Polynomial coefficients of the numerator
14871562
den: array_like, or list of list of array_like
14881563
Polynomial coefficients of the denominator
1564+
display_format: None, 'poly' or 'zpk'
1565+
Set the display format used in printing the TransferFunction object.
1566+
Default behavior is polynomial display and can be changed by
1567+
changing config.defaults['xferfcn.display_format']..
14891568
14901569
Returns
14911570
-------
@@ -1538,7 +1617,7 @@ def tf(*args, **kwargs):
15381617
15391618
>>> # Create a variable 's' to allow algebra operations for SISO systems
15401619
>>> s = tf('s')
1541-
>>> G = (s + 1)/(s**2 + 2*s + 1)
1620+
>>> G = (s + 1) / (s**2 + 2*s + 1)
15421621
15431622
>>> # Convert a StateSpace to a TransferFunction object.
15441623
>>> sys_ss = ss("1. -2; 3. -4", "5.; 7", "6. 8", "9.")
@@ -1609,12 +1688,24 @@ def zpk(zeros, poles, gain, *args, **kwargs):
16091688
name : string, optional
16101689
System name (used for specifying signals). If unspecified, a generic
16111690
name <sys[id]> is generated with a unique integer id.
1691+
display_format: None, 'poly' or 'zpk'
1692+
Set the display format used in printing the TransferFunction object.
1693+
Default behavior is polynomial display and can be changed by
1694+
changing config.defaults['xferfcn.display_format'].
16121695
16131696
Returns
16141697
-------
16151698
out: :class:`TransferFunction`
16161699
Transfer function with given zeros, poles, and gain.
16171700
1701+
Examples
1702+
--------
1703+
>>> from control import tf
1704+
>>> G = zpk([1],[2, 3], gain=1, display_format='zpk')
1705+
>>> G
1706+
s - 1
1707+
---------------
1708+
(s - 2) (s - 3)
16181709
"""
16191710
num, den = zpk2tf(zeros, poles, gain)
16201711
return TransferFunction(num, den, *args, **kwargs)

0 commit comments

Comments
 (0)