Repository: scipy/scipy

Issue:
BUG: tf filter trailing and leading zeros in residuez
Originally in https://github.com/scipy/scipy/issues/4464, a bug I noticed:

```
residue([6, 0], [1, 5, 8, 4])
Out[33]: (array([  6.,  12.,  -6.]), array([-2., -2., -1.]), array([ 0.]))

residue([0, 0, 6, 0], [1, 5, 8, 4])
Out[34]: (array([  6.,  12.,  -6.]), array([-2., -2., -1.]), array([ 0.]))

residue([0, 0, 6, 0], [0, 0, 0, 1, 5, 8, 4])
Out[36]: (array([ inf,  inf, -inf]), array([-2., -2., -1.]), array([ 0.]))
```

These should all be identical.  Same for these:

```
residuez([6], [1, 5, 8, 4])
Out[37]: (array([-12.,  12.,   6.]), array([-2., -2., -1.]), array([ 0.]))

residuez([6, 0, 0], [1, 5, 8, 4])
Out[38]: (array([-12.,  12.,   6.]), array([-2., -2., -1.]), array([ 0.]))

residuez([6, 0, 0], [1, 5, 8, 4, 0, 0])
Out[39]:
(array([-12.,  12.,   6.,  nan,  nan]),
 array([-2., -2., -1.,  0.,  0.]),
 array([ 0.]))
```

```
invresz(array([-9.,  3.,  6.]), array([-2., -2., -1.]), [])
Out[55]: (array([ 0.,  0.,  6.]), array([ 1.,  5.,  8.,  4.]))

invresz(array([-9.,  3.,  6.]), array([-2., -2., -1.]), [0])
Out[56]: (array([ 0.,  0.,  6.,  0.]), array([ 1.,  5.,  8.,  4.]))
```

For negative powers tf like in `residuez` and `invresz`, trailing zeros don't matter, so shouldn't be returned.  `[6, 0, 0]` represents 6 + 0_z^-1 + 0_z^-2 = `[6]`, for instance.

For positive powers tf like in `residue`, `invres`, and `tf2__`, leading zeros don't matter. `[0, 0, 6, 0]` represents 0_s^3 + 0_s^2 + 6*s + 0 = `[6, 0]`, for instance.

invres seems ok:

```
invres(array([  6.,  12.,  -6.]), array([-2., -2., -1.]), array([ 0.]))
Out[71]: (array([ 6.,  0.]), array([ 1.,  5.,  8.,  4.]))

invres(array([  6.,  12.,  -6.]), array([-2., -2., -1.]), array([ ]))
Out[72]: (array([ 6.,  0.]), array([ 1.,  5.,  8.,  4.]))

invres(array([  6.,  12.,  -6.]), array([-2., -2., -1.]), array([0, 0]))
Out[73]: (array([ 6.,  0.]), array([ 1.,  5.,  8.,  4.]))
```

Other functions that input or output `tf` format should also be checked.



Discussion:

Resolving patch:
diff --git a/scipy/signal/signaltools.py b/scipy/signal/signaltools.py
--- a/scipy/signal/signaltools.py
+++ b/scipy/signal/signaltools.py
@@ -2239,8 +2239,7 @@ def unique_roots(p, tol=1e-3, rtype='min'):
 
 
 def invres(r, p, k, tol=1e-3, rtype='avg'):
-    """
-    Compute b(s) and a(s) from partial fraction expansion.
+    """Compute b(s) and a(s) from partial fraction expansion.
 
     If `M` is the degree of numerator `b` and `N` the degree of denominator
     `a`::
@@ -2269,20 +2268,19 @@ def invres(r, p, k, tol=1e-3, rtype='avg'):
     Parameters
     ----------
     r : array_like
-        Residues.
+        Residues corresponding to the poles. For repeated poles, the residues
+        must be ordered to correspond to ascending by power fractions.
     p : array_like
-        Poles.
+        Poles. Equal poles must be adjacent.
     k : array_like
         Coefficients of the direct polynomial term.
     tol : float, optional
-        The tolerance for two roots to be considered equal. Default is 1e-3.
-    rtype : {'max', 'min, 'avg'}, optional
-        How to determine the returned root if multiple roots are within
-        `tol` of each other.
-
-          - 'max': pick the maximum of those roots.
-          - 'min': pick the minimum of those roots.
-          - 'avg': take the average of those roots.
+        The tolerance for two roots to be considered equal in terms of
+        the distance between them. Default is 1e-3. See `unique_roots`
+        for further details.
+    rtype : {'avg', 'min', 'max'}, optional
+        Method for computing a root to represent a group of identical roots.
+        Default is 'avg'. See `unique_roots` for further details.
 
     Returns
     -------
@@ -2296,39 +2294,29 @@ def invres(r, p, k, tol=1e-3, rtype='avg'):
     residue, invresz, unique_roots
 
     """
-    extra = k
-    p, indx = cmplx_sort(p)
-    r = np.take(r, indx, 0)
-    pout, mult = unique_roots(p, tol=tol, rtype=rtype)
-    p = []
-    for k in range(len(pout)):
-        p.extend([pout[k]] * mult[k])
-    a = np.atleast_1d(np.poly(p))
-    if len(extra) > 0:
-        b = np.polymul(extra, a)
+    r = np.atleast_1d(r)
+    p = np.atleast_1d(p)
+    k = np.trim_zeros(np.atleast_1d(k), 'f')
+
+    unique_poles, multiplicity = _group_poles(p, tol, rtype)
+    factors, denominator = _compute_factors(unique_poles, multiplicity,
+                                            include_powers=True)
+
+    if len(k) == 0:
+        numerator = 0
     else:
-        b = [0]
-    indx = 0
-    for k in range(len(pout)):
-        temp = []
-        for l in range(len(pout)):
-            if l != k:
-                temp.extend([pout[l]] * mult[l])
-        for m in range(mult[k]):
-            t2 = temp[:]
-            t2.extend([pout[k]] * (mult[k] - m - 1))
-            b = np.polyadd(b, r[indx] * np.atleast_1d(np.poly(t2)))
-            indx += 1
-    b = np.real_if_close(b)
-    while np.allclose(b[0], 0, rtol=1e-14) and (b.shape[-1] > 1):
-        b = b[1:]
-    return b, a
-
-
-def _compute_factors(roots, multiplicity):
+        numerator = np.polymul(k, denominator)
+
+    for residue, factor in zip(r, factors):
+        numerator = np.polyadd(numerator, residue * factor)
+
+    return numerator, denominator
+
+
+def _compute_factors(roots, multiplicity, include_powers=False):
     """Compute the total polynomial divided by factors for each root."""
-    suffixes = []
     current = np.array([1])
+    suffixes = [current]
     for pole, mult in zip(roots[-1:0:-1], multiplicity[-1:0:-1]):
         monomial = np.array([1, -pole])
         for _ in range(mult):
@@ -2336,20 +2324,22 @@ def _compute_factors(roots, multiplicity):
         suffixes.append(current)
     suffixes = suffixes[::-1]
 
-    result = []
+    factors = []
     current = np.array([1])
-    for pole, mult, suffix in zip(roots[:-1], multiplicity[:-1], suffixes):
-        result.append(np.polymul(current, suffix))
+    for pole, mult, suffix in zip(roots, multiplicity, suffixes):
         monomial = np.array([1, -pole])
-        for _ in range(mult):
+        block = []
+        for i in range(mult):
+            if i == 0 or include_powers:
+                block.append(np.polymul(current, suffix))
             current = np.polymul(current, monomial)
-    result.append(current)
+        factors.extend(reversed(block))
 
-    return result
+    return factors, current
 
 
 def _compute_residues(poles, multiplicity, numerator):
-    denominator_factors = _compute_factors(poles, multiplicity)
+    denominator_factors, _ = _compute_factors(poles, multiplicity)
     numerator = numerator.astype(poles.dtype)
 
     residues = []
@@ -2471,7 +2461,10 @@ def residue(b, a, tol=1e-3, rtype='avg'):
     if b.size == 0:
         return np.zeros(poles.shape), cmplx_sort(poles)[0], np.array([])
 
-    k, b = np.polydiv(b, a)
+    if len(b) < len(a):
+        k = np.empty(0)
+    else:
+        k, b = np.polydiv(b, a)
 
     unique_poles, multiplicity = unique_roots(poles, tol=tol, rtype=rtype)
     unique_poles, order = cmplx_sort(unique_poles)
@@ -2484,7 +2477,7 @@ def residue(b, a, tol=1e-3, rtype='avg'):
         poles[index:index + mult] = pole
         index += mult
 
-    return residues / a[0], poles, np.trim_zeros(k, 'f')
+    return residues / a[0], poles, k
 
 
 def residuez(b, a, tol=1e-3, rtype='avg'):
@@ -2568,7 +2561,11 @@ def residuez(b, a, tol=1e-3, rtype='avg'):
 
     b_rev = b[::-1]
     a_rev = a[::-1]
-    k_rev, b_rev = np.polydiv(b_rev, a_rev)
+
+    if len(b_rev) < len(a_rev):
+        k_rev = np.empty(0)
+    else:
+        k_rev, b_rev = np.polydiv(b_rev, a_rev)
 
     unique_poles, multiplicity = unique_roots(poles, tol=tol, rtype=rtype)
     unique_poles, order = cmplx_sort(unique_poles)
@@ -2585,12 +2582,42 @@ def residuez(b, a, tol=1e-3, rtype='avg'):
 
     residues *= (-poles) ** powers / a_rev[0]
 
-    return residues, poles, np.trim_zeros(k_rev[::-1], 'b')
+    return residues, poles, k_rev[::-1]
+
+
+def _group_poles(poles, tol, rtype):
+    if rtype in ['max', 'maximum']:
+        reduce = np.max
+    elif rtype in ['min', 'minimum']:
+        reduce = np.min
+    elif rtype in ['avg', 'mean']:
+        reduce = np.mean
+    else:
+        raise ValueError("`rtype` must be one of "
+                         "{'max', 'maximum', 'min', 'minimum', 'avg', 'mean'}")
+
+    unique = []
+    multiplicity = []
+
+    pole = poles[0]
+    block = [pole]
+    for i in range(1, len(poles)):
+        if abs(poles[i] - pole) <= tol:
+            block.append(pole)
+        else:
+            unique.append(reduce(block))
+            multiplicity.append(len(block))
+            pole = poles[i]
+            block = [pole]
+
+    unique.append(reduce(block))
+    multiplicity.append(len(block))
+
+    return np.asarray(unique), np.asarray(multiplicity)
 
 
 def invresz(r, p, k, tol=1e-3, rtype='avg'):
-    """
-    Compute b(z) and a(z) from partial fraction expansion.
+    """Compute b(z) and a(z) from partial fraction expansion.
 
     If `M` is the degree of numerator `b` and `N` the degree of denominator
     `a`::
@@ -2618,20 +2645,19 @@ def invresz(r, p, k, tol=1e-3, rtype='avg'):
     Parameters
     ----------
     r : array_like
-        Residues.
+        Residues corresponding to the poles. For repeated poles, the residues
+        must be ordered to correspond to ascending by power fractions.
     p : array_like
-        Poles.
+        Poles. Equal poles must be adjacent.
     k : array_like
         Coefficients of the direct polynomial term.
     tol : float, optional
-        The tolerance for two roots to be considered equal. Default is 1e-3.
-    rtype : {'max', 'min, 'avg'}, optional
-        How to determine the returned root if multiple roots are within
-        `tol` of each other.
-
-          - 'max': pick the maximum of those roots.
-          - 'min': pick the minimum of those roots.
-          - 'avg': take the average of those roots.
+        The tolerance for two roots to be considered equal in terms of
+        the distance between them. Default is 1e-3. See `unique_roots`
+        for further details.
+    rtype : {'avg', 'min', 'max'}, optional
+        Method for computing a root to represent a group of identical roots.
+        Default is 'avg'. See `unique_roots` for further details.
 
     Returns
     -------
@@ -2645,34 +2671,23 @@ def invresz(r, p, k, tol=1e-3, rtype='avg'):
     residuez, unique_roots, invres
 
     """
-    extra = np.asarray(k)
-    p, indx = cmplx_sort(p)
-    r = np.take(r, indx, 0)
-    pout, mult = unique_roots(p, tol=tol, rtype=rtype)
-    p = []
-    for k in range(len(pout)):
-        p.extend([pout[k]] * mult[k])
-    a = np.atleast_1d(np.poly(p))
-    if len(extra) > 0:
-        b = np.polymul(extra, a)
+    r = np.atleast_1d(r)
+    p = np.atleast_1d(p)
+    k = np.trim_zeros(np.atleast_1d(k), 'b')
+
+    unique_poles, multiplicity = _group_poles(p, tol, rtype)
+    factors, denominator = _compute_factors(unique_poles, multiplicity,
+                                            include_powers=True)
+
+    if len(k) == 0:
+        numerator = 0
     else:
-        b = [0]
-    indx = 0
-    brev = np.asarray(b)[::-1]
-    for k in range(len(pout)):
-        temp = []
-        # Construct polynomial which does not include any of this root
-        for l in range(len(pout)):
-            if l != k:
-                temp.extend([pout[l]] * mult[l])
-        for m in range(mult[k]):
-            t2 = temp[:]
-            t2.extend([pout[k]] * (mult[k] - m - 1))
-            brev = np.polyadd(brev,
-                              (r[indx] * np.atleast_1d(np.poly(t2)))[::-1])
-            indx += 1
-    b = np.real_if_close(brev[::-1])
-    return b, a
+        numerator = np.polymul(k[::-1], denominator[::-1])
+
+    for residue, factor in zip(r, factors):
+        numerator = np.polyadd(numerator, residue * factor[::-1])
+
+    return numerator[::-1], denominator
 
 
 def resample(x, num, t=None, axis=0, window=None):


Test patch:
diff --git a/scipy/signal/tests/test_signaltools.py b/scipy/signal/tests/test_signaltools.py
--- a/scipy/signal/tests/test_signaltools.py
+++ b/scipy/signal/tests/test_signaltools.py
@@ -28,7 +28,8 @@
     sosfilt_zi, tf2zpk, BadCoefficients, detrend, unique_roots, residue,
     residuez)
 from scipy.signal.windows import hann
-from scipy.signal.signaltools import _filtfilt_gust
+from scipy.signal.signaltools import (_filtfilt_gust, _compute_factors,
+                                      _group_poles)
 from scipy.signal._upfirdn import _upfirdn_modes
 
 
@@ -2522,6 +2523,31 @@ def assert_rp_almost_equal(r, p, r_true, p_true, decimal=7):
         assert_almost_equal(p[rows], p_true[cols], decimal=decimal)
         assert_almost_equal(r[rows], r_true[cols], decimal=decimal)
 
+    def test_compute_factors(self):
+        factors, poly = _compute_factors([1, 2, 3], [3, 2, 1])
+        assert_equal(len(factors), 3)
+        assert_almost_equal(factors[0], np.poly([2, 2, 3]))
+        assert_almost_equal(factors[1], np.poly([1, 1, 1, 3]))
+        assert_almost_equal(factors[2], np.poly([1, 1, 1, 2, 2]))
+        assert_almost_equal(poly, np.poly([1, 1, 1, 2, 2, 3]))
+
+        factors, poly = _compute_factors([1, 2, 3], [3, 2, 1],
+                                         include_powers=True)
+        assert_equal(len(factors), 6)
+        assert_almost_equal(factors[0], np.poly([1, 1, 2, 2, 3]))
+        assert_almost_equal(factors[1], np.poly([1, 2, 2, 3]))
+        assert_almost_equal(factors[2], np.poly([2, 2, 3]))
+        assert_almost_equal(factors[3], np.poly([1, 1, 1, 2, 3]))
+        assert_almost_equal(factors[4], np.poly([1, 1, 1, 3]))
+        assert_almost_equal(factors[5], np.poly([1, 1, 1, 2, 2]))
+        assert_almost_equal(poly, np.poly([1, 1, 1, 2, 2, 3]))
+
+    def test_group_poles(self):
+        unique, multiplicity = _group_poles(
+            [1.0, 1.001, 1.003, 2.0, 2.003, 3.0], 0.1, 'min')
+        assert_equal(unique, [1.0, 2.0, 3.0])
+        assert_equal(multiplicity, [3, 2, 1])
+
     def test_residue_general(self):
         # Test are taken from issue #4464, note that poles in scipy are
         # in increasing by absolute value order, opposite to MATLAB.
@@ -2738,60 +2764,116 @@ def test_residuez_degenerate(self):
                                  "be non-zero."):
             residuez(1, [0, 1, 2, 3])
 
-    def test_invresz_one_coefficient_bug(self):
-        # Regression test for issue in gh-4646.
-        r = [1]
-        p = [2]
-        k = [0]
-        a_expected = [1.0, 0.0]
-        b_expected = [1.0, -2.0]
-        a_observed, b_observed = invresz(r, p, k)
-
-        assert_allclose(a_observed, a_expected)
-        assert_allclose(b_observed, b_expected)
-
-    def test_invres_distinct_roots(self):
+    def test_inverse_unique_roots_different_rtypes(self):
         # This test was inspired by github issue 2496.
         r = [3 / 10, -1 / 6, -2 / 15]
         p = [0, -2, -5]
         k = []
-        a_expected = [1, 3]
-        b_expected = [1, 7, 10, 0]
-        a_observed, b_observed = invres(r, p, k)
-        assert_allclose(a_observed, a_expected)
-        assert_allclose(b_observed, b_expected)
-        rtypes = ('avg', 'mean', 'min', 'minimum', 'max', 'maximum')
+        b_expected = [0, 1, 3]
+        a_expected = [1, 7, 10, 0]
 
         # With the default tolerance, the rtype does not matter
         # for this example.
-        for rtype in rtypes:
-            a_observed, b_observed = invres(r, p, k, rtype=rtype)
-            assert_allclose(a_observed, a_expected)
-            assert_allclose(b_observed, b_expected)
-
-        # With unrealistically large tolerances, repeated roots may be inferred
-        # and the rtype comes into play.
-        ridiculous_tolerance = 1e10
-        for rtype in rtypes:
-            a, b = invres(r, p, k, tol=ridiculous_tolerance, rtype=rtype)
-
-    def test_invres_repeated_roots(self):
+        for rtype in ('avg', 'mean', 'min', 'minimum', 'max', 'maximum'):
+            b, a = invres(r, p, k, rtype=rtype)
+            assert_allclose(b, b_expected)
+            assert_allclose(a, a_expected)
+
+            b, a = invresz(r, p, k, rtype=rtype)
+            assert_allclose(b, b_expected)
+            assert_allclose(a, a_expected)
+
+    def test_inverse_repeated_roots_different_rtypes(self):
         r = [3 / 20, -7 / 36, -1 / 6, 2 / 45]
         p = [0, -2, -2, -5]
         k = []
-        a_expected = [1, 3]
-        b_expected = [1, 9, 24, 20, 0]
-        rtypes = ('avg', 'mean', 'min', 'minimum', 'max', 'maximum')
-        for rtype in rtypes:
-            a_observed, b_observed = invres(r, p, k, rtype=rtype)
-            assert_allclose(a_observed, a_expected)
-            assert_allclose(b_observed, b_expected)
-
-    def test_invres_bad_rtype(self):
+        b_expected = [0, 0, 1, 3]
+        b_expected_z = [-1/6, -2/3, 11/6, 3]
+        a_expected = [1, 9, 24, 20, 0]
+
+        for rtype in ('avg', 'mean', 'min', 'minimum', 'max', 'maximum'):
+            b, a = invres(r, p, k, rtype=rtype)
+            assert_allclose(b, b_expected, atol=1e-14)
+            assert_allclose(a, a_expected)
+
+            b, a = invresz(r, p, k, rtype=rtype)
+            assert_allclose(b, b_expected_z, atol=1e-14)
+            assert_allclose(a, a_expected)
+
+    def test_inverse_bad_rtype(self):
         r = [3 / 20, -7 / 36, -1 / 6, 2 / 45]
         p = [0, -2, -2, -5]
         k = []
-        assert_raises(ValueError, invres, r, p, k, rtype='median')
+        with pytest.raises(ValueError, match="`rtype` must be one of"):
+            invres(r, p, k, rtype='median')
+        with pytest.raises(ValueError, match="`rtype` must be one of"):
+            invresz(r, p, k, rtype='median')
+
+    def test_invresz_one_coefficient_bug(self):
+        # Regression test for issue in gh-4646.
+        r = [1]
+        p = [2]
+        k = [0]
+        b, a = invresz(r, p, k)
+        assert_allclose(b, [1.0])
+        assert_allclose(a, [1.0, -2.0])
+
+    def test_invres(self):
+        b, a = invres([1], [1], [])
+        assert_almost_equal(b, [1])
+        assert_almost_equal(a, [1, -1])
+
+        b, a = invres([1 - 1j, 2, 0.5 - 3j], [1, 0.5j, 1 + 1j], [])
+        assert_almost_equal(b, [3.5 - 4j, -8.5 + 0.25j, 3.5 + 3.25j])
+        assert_almost_equal(a, [1, -2 - 1.5j, 0.5 + 2j, 0.5 - 0.5j])
+
+        b, a = invres([0.5, 1], [1 - 1j, 2 + 2j], [1, 2, 3])
+        assert_almost_equal(b, [1, -1 - 1j, 1 - 2j, 0.5 - 3j, 10])
+        assert_almost_equal(a, [1, -3 - 1j, 4])
+
+        b, a = invres([-1, 2, 1j, 3 - 1j, 4, -2],
+                      [-1, 2 - 1j, 2 - 1j, 3, 3, 3], [])
+        assert_almost_equal(b, [4 - 1j, -28 + 16j, 40 - 62j, 100 + 24j,
+                                -292 + 219j, 192 - 268j])
+        assert_almost_equal(a, [1, -12 + 2j, 53 - 20j, -96 + 68j, 27 - 72j,
+                                108 - 54j, -81 + 108j])
+
+        b, a = invres([-1, 1j], [1, 1], [1, 2])
+        assert_almost_equal(b, [1, 0, -4, 3 + 1j])
+        assert_almost_equal(a, [1, -2, 1])
+
+    def test_invresz(self):
+        b, a = invresz([1], [1], [])
+        assert_almost_equal(b, [1])
+        assert_almost_equal(a, [1, -1])
+
+        b, a = invresz([1 - 1j, 2, 0.5 - 3j], [1, 0.5j, 1 + 1j], [])
+        assert_almost_equal(b, [3.5 - 4j, -8.5 + 0.25j, 3.5 + 3.25j])
+        assert_almost_equal(a, [1, -2 - 1.5j, 0.5 + 2j, 0.5 - 0.5j])
+
+        b, a = invresz([0.5, 1], [1 - 1j, 2 + 2j], [1, 2, 3])
+        assert_almost_equal(b, [2.5, -3 - 1j, 1 - 2j, -1 - 3j, 12])
+        assert_almost_equal(a, [1, -3 - 1j, 4])
+
+        b, a = invresz([-1, 2, 1j, 3 - 1j, 4, -2],
+                       [-1, 2 - 1j, 2 - 1j, 3, 3, 3], [])
+        assert_almost_equal(b, [6, -50 + 11j, 100 - 72j, 80 + 58j,
+                                -354 + 228j, 234 - 297j])
+        assert_almost_equal(a, [1, -12 + 2j, 53 - 20j, -96 + 68j, 27 - 72j,
+                                108 - 54j, -81 + 108j])
+
+        b, a = invresz([-1, 1j], [1, 1], [1, 2])
+        assert_almost_equal(b, [1j, 1, -3, 2])
+        assert_almost_equal(a, [1, -2, 1])
+
+    def test_inverse_scalar_arguments(self):
+        b, a = invres(1, 1, 1)
+        assert_almost_equal(b, [1, 0])
+        assert_almost_equal(a, [1, -1])
+
+        b, a = invresz(1, 1, 1)
+        assert_almost_equal(b, [2, -1])
+        assert_almost_equal(a, [1, -1])
 
 
 class TestVectorstrength(object):