Repository: python-control/python-control
Pull request #816 (2022-12-16 06:56:01)

add zpk() function
This PR adds the function `zpk`, which creates a transfer function (as a `NamedIOSystem`) from a zero, pole, gain representation.  This responds to issue #749.

Also added in code to make sure that signal names are copied correctly in various conversions, removing some comments entered in PR #804.  In `_convert_to_transfer_function` this involved removing renaming from the low level function, as documented in the comment string.

Notes:

* Unlike in MATLAB and SciPy, which have zpk classes, here we just create a transfer function from the zpk data.  That seems like the most common use case.

* In the `matlab` module there are also functions `zpk2ss` and `zpk2tf` that implement lower level behavior (eg, zpk2ss returns A, B, C, D).  This is similar to MATLAB.  if you want to get a `StateSpace` system from a zpk representation, use `sys = ct.ss(ct.zpk(zeros, poles, gain))`.

* Unit tests and updated documentation are also included.


coveralls: 
[![Coverage Status](https://coveralls.io/builds/55221648/badge)](https://coveralls.io/builds/55221648)

Coverage increased (+0.002%) to 94.839% when pulling **8614cc7f5b5dd1a125ae75a52df44fe0d34f5a5f on murrayrm:zpk-13Dec2022** into **b1d55a1983bc26f0ee9057c0579d5b50b78fff0a on python-control:main**.


Patch:
diff --git a/control/matlab/__init__.py b/control/matlab/__init__.py
index fc0cd445..1a524b33 100644
--- a/control/matlab/__init__.py
+++ b/control/matlab/__init__.py
@@ -115,7 +115,7 @@ Creating linear models
 
 ==  ==========================  ============================================
 \*  :func:`tf`                  create transfer function (TF) models
-\   zpk                         create zero/pole/gain (ZPK) models.
+\*  :func:`zpk`                 create zero/pole/gain (ZPK) models.
 \*  :func:`ss`                  create state-space (SS) models
 \   dss                         create descriptor state-space models
 \   delayss                     create state-space models with delayed terms
diff --git a/control/statesp.py b/control/statesp.py
index 4524af39..aac4dd8b 100644
--- a/control/statesp.py
+++ b/control/statesp.py
@@ -1521,7 +1521,6 @@ class StateSpace(LTI):
 
 
 # TODO: add discrete time check
-# TODO: copy signal names
 def _convert_to_statespace(sys):
     """Convert a system to state space form (if needed).
 
diff --git a/control/xferfcn.py b/control/xferfcn.py
index 5ebd35c1..0bc84e09 100644
--- a/control/xferfcn.py
+++ b/control/xferfcn.py
@@ -65,7 +65,7 @@ from .exception import ControlMIMONotImplemented
 from .frdata import FrequencyResponseData
 from . import config
 
-__all__ = ['TransferFunction', 'tf', 'ss2tf', 'tfdata']
+__all__ = ['TransferFunction', 'tf', 'zpk', 'ss2tf', 'tfdata']
 
 
 # Define module default parameter values
@@ -796,7 +796,7 @@ class TransferFunction(LTI):
         """Compute the zeros of a transfer function."""
         if self.ninputs > 1 or self.noutputs > 1:
             raise NotImplementedError(
-                "TransferFunction.zero is currently only implemented "
+                "TransferFunction.zeros is currently only implemented "
                 "for SISO systems.")
         else:
             # for now, just give zeros of a SISO tf
@@ -1424,16 +1424,13 @@ def _convert_to_transfer_function(sys, inputs=1, outputs=1):
                 num = squeeze(num)  # Convert to 1D array
                 den = squeeze(den)  # Probably not needed
 
-        return TransferFunction(
-            num, den, sys.dt, inputs=sys.input_labels,
-            outputs=sys.output_labels)
+        return TransferFunction(num, den, sys.dt)
 
     elif isinstance(sys, (int, float, complex, np.number)):
         num = [[[sys] for j in range(inputs)] for i in range(outputs)]
         den = [[[1] for j in range(inputs)] for i in range(outputs)]
 
-        return TransferFunction(
-            num, den, inputs=inputs, outputs=outputs)
+        return TransferFunction(num, den)
 
     elif isinstance(sys, FrequencyResponseData):
         raise TypeError("Can't convert given FRD to TransferFunction system.")
@@ -1577,8 +1574,54 @@ def tf(*args, **kwargs):
     else:
         raise ValueError("Needs 1 or 2 arguments; received %i." % len(args))
 
-# TODO: copy signal names
+
+def zpk(zeros, poles, gain, *args, **kwargs):
+    """zpk(zeros, poles, gain[, dt])
+
+    Create a transfer function from zeros, poles, gain.
+
+    Given a list of zeros z_i, poles p_j, and gain k, return the transfer
+    function:
+
+    .. math::
+      H(s) = k \\frac{(s - z_1) (s - z_2) \\cdots (s - z_m)}
+                     {(s - p_1) (s - p_2) \\cdots (s - p_n)}
+
+    Parameters
+    ----------
+    zeros : array_like
+        Array containing the location of zeros.
+    poles : array_like
+        Array containing the location of zeros.
+    gain : float
+        System gain
+    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).
+    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.
+    name : string, optional
+        System name (used for specifying signals). If unspecified, a generic
+        name <sys[id]> is generated with a unique integer id.
+
+    Returns
+    -------
+    out: :class:`TransferFunction`
+        Transfer function with given zeros, poles, and gain.
+
+    """
+    num, den = zpk2tf(zeros, poles, gain)
+    return TransferFunction(num, den, *args, **kwargs)
+
+
 def ss2tf(*args, **kwargs):
+
     """ss2tf(sys)
 
     Transform a state space system to a transfer function.
@@ -1658,6 +1701,11 @@ def ss2tf(*args, **kwargs):
     if len(args) == 1:
         sys = args[0]
         if isinstance(sys, StateSpace):
+            kwargs = kwargs.copy()
+            if not kwargs.get('inputs'):
+                kwargs['inputs'] = sys.input_labels
+            if not kwargs.get('outputs'):
+                kwargs['outputs'] = sys.output_labels
             return TransferFunction(
                 _convert_to_transfer_function(sys), **kwargs)
         else:
diff --git a/doc/control.rst b/doc/control.rst
index 54e23374..79702dc6 100644
--- a/doc/control.rst
+++ b/doc/control.rst
@@ -18,6 +18,7 @@ System creation
     ss
     tf
     frd
+    zpk
     rss
     drss
     NonlinearIOSystem


Test patch:
diff --git a/control/tests/kwargs_test.py b/control/tests/kwargs_test.py
index 20a1c8e9..8116f013 100644
--- a/control/tests/kwargs_test.py
+++ b/control/tests/kwargs_test.py
@@ -96,6 +96,7 @@ def test_kwarg_search(module, prefix):
      (control.tf, 0, 0, ([1], [1, 1]), {}),
      (control.tf2io, 0, 1, (), {}),
      (control.tf2ss, 0, 1, (), {}),
+     (control.zpk, 0, 0, ([1], [2, 3], 4), {}),
      (control.InputOutputSystem, 0, 0, (),
       {'inputs': 1, 'outputs': 1, 'states': 1}),
      (control.InputOutputSystem.linearize, 1, 0, (0, 0), {}),
@@ -184,6 +185,7 @@ kwarg_unittest = {
     'tf2io' : test_unrecognized_kwargs,
     'tf2ss' : test_unrecognized_kwargs,
     'sample_system' : test_unrecognized_kwargs,
+    'zpk': test_unrecognized_kwargs,
     'flatsys.point_to_point':
         flatsys_test.TestFlatSys.test_point_to_point_errors,
     'flatsys.solve_flat_ocp':
diff --git a/control/tests/xferfcn_test.py b/control/tests/xferfcn_test.py
index e4a2b3ec..6e1cf6ce 100644
--- a/control/tests/xferfcn_test.py
+++ b/control/tests/xferfcn_test.py
@@ -8,7 +8,8 @@ import pytest
 import operator
 
 import control as ct
-from control import StateSpace, TransferFunction, rss, ss2tf, evalfr
+from control import StateSpace, TransferFunction, rss, evalfr
+from control import ss, ss2tf, tf, tf2ss
 from control import isctime, isdtime, sample_system, defaults
 from control.statesp import _convert_to_statespace
 from control.xferfcn import _convert_to_transfer_function
@@ -986,7 +987,7 @@ class TestXferFcn:
                 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):
         sysc = ct.TransferFunction(1.1, (1, 2), inputs='u', outputs='y')
 
@@ -1073,3 +1074,72 @@ def test_xferfcn_ndarray_precedence(op, tf, arr):
     # Apply the operator to the array and transfer function
     result = op(arr, tf)
     assert isinstance(result, ct.TransferFunction)
+
+
+@pytest.mark.parametrize(
+    "zeros, poles, gain, args, kwargs", [
+        ([], [-1], 1, [], {}),
+        ([1, 2], [-1, -2, -3], 5, [], {}),
+        ([1, 2], [-1, -2, -3], 5, [], {'name': "sys"}),
+        ([1, 2], [-1, -2, -3], 5, [], {'inputs': ["in"], 'outputs': ["out"]}),
+        ([1, 2], [-1, -2, -3], 5, [0.1], {}),
+        (np.array([1, 2]), np.array([-1, -2, -3]), 5, [], {}),
+])
+def test_zpk(zeros, poles, gain, args, kwargs):
+    # Create the transfer function
+    sys = ct.zpk(zeros, poles, gain, *args, **kwargs)
+
+    # Make sure the poles and zeros match
+    np.testing.assert_equal(sys.zeros().sort(), zeros.sort())
+    np.testing.assert_equal(sys.poles().sort(), poles.sort())
+
+    # Check to make sure the gain is OK
+    np.testing.assert_almost_equal(
+        gain, sys(0) * np.prod(-sys.poles()) / np.prod(-sys.zeros()))
+
+    # Check time base
+    if args:
+        assert sys.dt == args[0]
+
+    # Check inputs, outputs, name
+    input_labels = kwargs.get('inputs', [])
+    for i, label in enumerate(input_labels):
+        assert sys.input_labels[i] == label
+
+    output_labels = kwargs.get('outputs', [])
+    for i, label in enumerate(output_labels):
+        assert sys.output_labels[i] == label
+
+    if kwargs.get('name'):
+        assert sys.name == kwargs.get('name')
+
+@pytest.mark.parametrize("create, args, kwargs, convert", [
+    (StateSpace, ([-1], [1], [1], [0]), {}, ss2tf),
+    (StateSpace, ([-1], [1], [1], [0]), {}, ss),
+    (StateSpace, ([-1], [1], [1], [0]), {}, tf),
+    (StateSpace, ([-1], [1], [1], [0]), dict(inputs='i', outputs='o'), ss2tf),
+    (StateSpace, ([-1], [1], [1], [0]), dict(inputs=1, outputs=1), ss2tf),
+    (StateSpace, ([-1], [1], [1], [0]), dict(inputs='i', outputs='o'), ss),
+    (StateSpace, ([-1], [1], [1], [0]), dict(inputs='i', outputs='o'), tf),
+    (TransferFunction, ([1], [1, 1]), {}, tf2ss),
+    (TransferFunction, ([1], [1, 1]), {}, tf),
+    (TransferFunction, ([1], [1, 1]), {}, ss),
+    (TransferFunction, ([1], [1, 1]), dict(inputs='i', outputs='o'), tf2ss),
+    (TransferFunction, ([1], [1, 1]), dict(inputs=1, outputs=1), tf2ss),
+    (TransferFunction, ([1], [1, 1]), dict(inputs='i', outputs='o'), tf),
+    (TransferFunction, ([1], [1, 1]), dict(inputs='i', outputs='o'), ss),
+])
+def test_copy_names(create, args, kwargs, convert):
+    # Convert a system with no renaming
+    sys = create(*args, **kwargs)
+    cpy = convert(sys)
+
+    assert cpy.input_labels == sys.input_labels
+    assert cpy.input_labels == sys.input_labels
+    if cpy.nstates is not None and sys.nstates is not None:
+        assert cpy.state_labels == sys.state_labels
+
+    # Relabel inputs and outputs
+    cpy = convert(sys, inputs='myin', outputs='myout')
+    assert cpy.input_labels == ['myin']
+    assert cpy.output_labels == ['myout']