Repository: python-control/python-control
Pull request #951 (2023-12-26 16:45:27)

Fix bug in matched transformation + address other issues in #950
This PR fixes the calculation of the DC gain in the 'matched' transformation from continuous time to discrete time.  It also fixes some other issues identified in #950.  Also adds unit test for `sample_system` that catches the original error.



Patch:
diff --git a/control/dtime.py b/control/dtime.py
index 2ae48281..9b91eabd 100644
--- a/control/dtime.py
+++ b/control/dtime.py
@@ -55,8 +55,7 @@ __all__ = ['sample_system', 'c2d']
 # Sample a continuous time system
 def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None,
         name=None, copy_names=True, **kwargs):
-    """
-    Convert a continuous time system to discrete time by sampling.
+    """Convert a continuous time system to discrete time by sampling.
 
     Parameters
     ----------
@@ -67,9 +66,9 @@ def sample_system(sysc, Ts, method='zoh', alpha=None, prewarp_frequency=None,
     method : string
         Method to use for conversion, e.g. 'bilinear', 'zoh' (default)
     alpha : float within [0, 1]
-            The generalized bilinear transformation weighting parameter, which
-            should only be specified with method="gbt", and is ignored
-            otherwise. See :func:`scipy.signal.cont2discrete`.
+        The generalized bilinear transformation weighting parameter, which
+        should only be specified with method="gbt", and is ignored
+        otherwise. See :func:`scipy.signal.cont2discrete`.
     prewarp_frequency : float within [0, infinity)
         The frequency [rad/s] at which to match with the input continuous-
         time system's magnitude and phase (only valid for method='bilinear',
diff --git a/control/freqplot.py b/control/freqplot.py
index 164ec28a..53351541 100644
--- a/control/freqplot.py
+++ b/control/freqplot.py
@@ -894,8 +894,10 @@ def bode_plot(
     # list of systems (e.g., "Step response for sys[1], sys[2]").
     #
 
-    # Set the initial title for the data (unique system names)
-    sysnames = list(set([response.sysname for response in data]))
+    # Set the initial title for the data (unique system names, preserving order)
+    seen = set()
+    sysnames = [response.sysname for response in data \
+                if not (response.sysname in seen or seen.add(response.sysname))]
     if title is None:
         if data[0].title is None:
             title = "Bode plot for " + ", ".join(sysnames)
diff --git a/control/xferfcn.py b/control/xferfcn.py
index b5334b7b..206f0bc9 100644
--- a/control/xferfcn.py
+++ b/control/xferfcn.py
@@ -1181,7 +1181,9 @@ class TransferFunction(LTI):
         if not self.issiso():
             raise ControlMIMONotImplemented("Not implemented for MIMO systems")
         if method == "matched":
-            return _c2d_matched(self, Ts)
+            if prewarp_frequency is not None:
+                warn('prewarp_frequency ignored: incompatible conversion')
+            return _c2d_matched(self, Ts, name=name, **kwargs)
         sys = (self.num[0][0], self.den[0][0])
         if prewarp_frequency is not None:
             if method in ('bilinear', 'tustin') or \
@@ -1284,9 +1286,12 @@ class TransferFunction(LTI):
 
 
 # c2d function contributed by Benjamin White, Oct 2012
-def _c2d_matched(sysC, Ts):
+def _c2d_matched(sysC, Ts, **kwargs):
+    if not sysC.issiso():
+        raise ControlMIMONotImplemented("Not implemented for MIMO systems")
+
     # Pole-zero match method of continuous to discrete time conversion
-    szeros, spoles, sgain = tf2zpk(sysC.num[0][0], sysC.den[0][0])
+    szeros, spoles, _ = tf2zpk(sysC.num[0][0], sysC.den[0][0])
     zzeros = [0] * len(szeros)
     zpoles = [0] * len(spoles)
     pregainnum = [0] * len(szeros)
@@ -1302,9 +1307,9 @@ def _c2d_matched(sysC, Ts):
         zpoles[idx] = z
         pregainden[idx] = 1 - z
     zgain = np.multiply.reduce(pregainnum) / np.multiply.reduce(pregainden)
-    gain = sgain / zgain
+    gain = sysC.dcgain() / zgain
     sysDnum, sysDden = zpk2tf(zzeros, zpoles, gain)
-    return TransferFunction(sysDnum, sysDden, Ts)
+    return TransferFunction(sysDnum, sysDden, Ts, **kwargs)
 
 
 # Utility function to convert a transfer function polynomial to a string


Test patch:
diff --git a/control/tests/discrete_test.py b/control/tests/discrete_test.py
index 96777011..cccb5370 100644
--- a/control/tests/discrete_test.py
+++ b/control/tests/discrete_test.py
@@ -5,11 +5,12 @@ RMM, 9 Sep 2012
 
 import numpy as np
 import pytest
+import cmath
 
-from control import (StateSpace, TransferFunction, bode, common_timebase,
-                     feedback, forced_response, impulse_response,
-                     isctime, isdtime, rss, c2d, sample_system, step_response,
-                     timebase)
+import control as ct
+from control import StateSpace, TransferFunction, bode, common_timebase, \
+    feedback, forced_response, impulse_response, isctime, isdtime, rss, \
+    c2d, sample_system, step_response, timebase
 
 
 class TestDiscrete:
@@ -526,3 +527,33 @@ class TestDiscrete:
         assert sysd_newnames.find_input('u') is None
         assert sysd_newnames.find_output('y') == 0
         assert sysd_newnames.find_output('x') is None
+
+
+@pytest.mark.parametrize("num, den", [
+    ([1], [1, 1]),
+    ([1, 2], [1, 3]),
+    ([1, 2], [3, 4, 5])
+])
+@pytest.mark.parametrize("dt", [True, 0.1, 2])
+@pytest.mark.parametrize("method", ['zoh', 'bilinear', 'matched'])
+def test_c2d_matched(num, den, dt, method):
+    sys_ct = ct.tf(num, den)
+    sys_dt = ct.sample_system(sys_ct, dt, method=method)
+    assert sys_dt.dt == dt                      # make sure sampling time is OK
+    assert cmath.isclose(sys_ct(0), sys_dt(1))  # check zero frequency gain
+    assert cmath.isclose(
+        sys_ct.dcgain(), sys_dt.dcgain())       # another way to check
+
+    if method in ['zoh', 'matched']:
+        # Make sure that poles were properly matched
+        zpoles = sys_dt.poles()
+        for cpole in sys_ct.poles():
+            zpole = zpoles[(np.abs(zpoles - cmath.exp(cpole * dt))).argmin()]
+            assert cmath.isclose(cmath.exp(cpole * dt), zpole)
+
+    if method in ['matched']:
+        # Make sure that zeros were properly matched
+        zzeros = sys_dt.zeros()
+        for czero in sys_ct.zeros():
+            zzero = zzeros[(np.abs(zzeros - cmath.exp(czero * dt))).argmin()]
+            assert cmath.isclose(cmath.exp(czero * dt), zzero)