Repository: scipy/scipy

Issue:
BUG: Misc residuez issues
From https://github.com/scipy/scipy/issues/4464
1. `r, p, k = residue([-4, 8], [1, 6, 8])` should return `k == []` instead of `[0]`
2. `residuez(1, [1, -1j])` should work as well as `residuez([1], [1, -1j])` (`atleast_1d`)
3. Diagnose failures and fix:

```
r = [4, -5, 3]
p = [-1, -1, -1]
k = []
b, a = invresz(r, p, k)
b == [2, 3, 4]
a == [1, 3, 3, 1]
```

```
B = [1, 6, 6, 2]
A = [1, -(2+1j), (1+2j), -1j]
r, p, f = residuez(B, A)
r == [-2+2.5j, 7.5+7.5j, -4.5-12j]
p == [1j, 1, 1]
f == -2j
```

Double check polarities:

```
r, p, k = residuez([1, 2, 1], [1, -1, 0.3561])
r == [-0.9041 - 5.9928j, -0.9041 + 5.9928j]
p == [-0.5 - 0.3257j, -0.5000 + 0.3257j]
k == 2.8082
```



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
@@ -2324,9 +2324,58 @@ def invres(r, p, k, tol=1e-3, rtype='avg'):
     return b, a
 
 
+def _compute_factors(roots, multiplicity):
+    """Compute the total polynomial divided by factors for each root."""
+    suffixes = []
+    current = np.array([1])
+    for pole, mult in zip(roots[-1:0:-1], multiplicity[-1:0:-1]):
+        monomial = np.array([1, -pole])
+        for _ in range(mult):
+            current = np.polymul(current, monomial)
+        suffixes.append(current)
+    suffixes = suffixes[::-1]
+
+    result = []
+    current = np.array([1])
+    for pole, mult, suffix in zip(roots[:-1], multiplicity[:-1], suffixes):
+        result.append(np.polymul(current, suffix))
+        monomial = np.array([1, -pole])
+        for _ in range(mult):
+            current = np.polymul(current, monomial)
+    result.append(current)
+
+    return result
+
+
+def _compute_residues(poles, multiplicity, numerator):
+    denominator_factors = _compute_factors(poles, multiplicity)
+    numerator = numerator.astype(poles.dtype)
+
+    residues = []
+    for pole, mult, factor in zip(poles, multiplicity,
+                                  denominator_factors):
+        if mult == 1:
+            residues.append(np.polyval(numerator, pole) /
+                            np.polyval(factor, pole))
+        else:
+            numer = numerator.copy()
+            monomial = np.array([1, -pole])
+            factor, d = np.polydiv(factor, monomial)
+
+            block = []
+            for _ in range(mult):
+                numer, n = np.polydiv(numer, monomial)
+                r = n[0] / d[0]
+                numer = np.polysub(numer, r * factor)
+                block.append(r)
+
+            residues.extend(reversed(block))
+
+    return np.asarray(residues)
+
+
 def residue(b, a, tol=1e-3, rtype='avg'):
-    """
-    Compute partial-fraction expansion of b(s) / a(s).
+    """Compute partial-fraction expansion of b(s) / a(s).
 
     If `M` is the degree of numerator `b` and `N` the degree of denominator
     `a`::
@@ -2352,19 +2401,29 @@ def residue(b, a, tol=1e-3, rtype='avg'):
     such as analog filters or digital filters in controls engineering.  For
     negative powers of z (typical for digital filters in DSP), use `residuez`.
 
+    See Notes for details about the algorithm.
+
     Parameters
     ----------
     b : array_like
         Numerator polynomial coefficients.
     a : array_like
         Denominator polynomial coefficients.
+    tol : float, optional
+        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
     -------
     r : ndarray
-        Residues.
+        Residues corresponding to the poles. For repeated poles, the residues
+        are ordered to correspond to ascending by power fractions.
     p : ndarray
-        Poles order by magnitude in ascending order.
+        Poles ordered by magnitude in ascending order.
     k : ndarray
         Coefficients of the direct polynomial term.
 
@@ -2372,57 +2431,63 @@ def residue(b, a, tol=1e-3, rtype='avg'):
     --------
     invres, residuez, numpy.poly, unique_roots
 
+    Notes
+    -----
+    The "deflation through subtraction" algorithm is used for
+    computations --- method 6 in [1]_.
+
+    The form of partial fraction expansion depends on poles multiplicity in
+    the exact mathematical sense. However there is no way to exactly
+    determine multiplicity of roots of a polynomial in numerical computing.
+    Thus you should think of the result of `residue` with given `tol` as
+    partial fraction expansion computed for the denominator composed of the
+    computed poles with empirically determined multiplicity. The choice of
+    `tol` can drastically change the result if there are close poles.
+
+    References
+    ----------
+    .. [1] J. F. Mahoney, B. D. Sivazlian, "Partial fractions expansion: a
+           review of computational methodology and efficiency", Journal of
+           Computational and Applied Mathematics, Vol. 9, 1983.
     """
+    b = np.asarray(b)
+    a = np.asarray(a)
+    if (np.issubdtype(b.dtype, np.complexfloating)
+            or np.issubdtype(a.dtype, np.complexfloating)):
+        b = b.astype(complex)
+        a = a.astype(complex)
+    else:
+        b = b.astype(float)
+        a = a.astype(float)
+
     b = np.trim_zeros(np.atleast_1d(b), 'f')
     a = np.trim_zeros(np.atleast_1d(a), 'f')
 
     if a.size == 0:
         raise ValueError("Denominator `a` is zero.")
 
-    p = np.roots(a)
+    poles = np.roots(a)
     if b.size == 0:
-        return np.zeros(p.shape), p, np.array([])
+        return np.zeros(poles.shape), cmplx_sort(poles)[0], np.array([])
 
-    rscale = a[0]
     k, b = np.polydiv(b, a)
-    r = p * 0.0
-    pout, mult = unique_roots(p, tol=tol, rtype=rtype)
-    pout, order = cmplx_sort(pout)
-    mult = mult[order]
 
-    p = []
-    for n in range(len(pout)):
-        p.extend([pout[n]] * mult[n])
-    p = np.asarray(p)
-    # Compute the residue from the general formula
-    indx = 0
-    for n in range(len(pout)):
-        bn = b.copy()
-        pn = []
-        for l in range(len(pout)):
-            if l != n:
-                pn.extend([pout[l]] * mult[l])
-        an = np.atleast_1d(np.poly(pn))
-        # bn(s) / an(s) is (s-po[n])**Nn * b(s) / a(s) where Nn is
-        # multiplicity of pole at po[n]
-        sig = mult[n]
-        for m in range(sig, 0, -1):
-            if sig > m:
-                # compute next derivative of bn(s) / an(s)
-                term1 = np.polymul(np.polyder(bn, 1), an)
-                term2 = np.polymul(bn, np.polyder(an, 1))
-                bn = np.polysub(term1, term2)
-                an = np.polymul(an, an)
-            r[indx + m - 1] = (np.polyval(bn, pout[n]) /
-                               np.polyval(an, pout[n]) /
-                               factorial(sig - m))
-        indx += sig
-    return r / rscale, p, np.trim_zeros(k, 'f')
+    unique_poles, multiplicity = unique_roots(poles, tol=tol, rtype=rtype)
+    unique_poles, order = cmplx_sort(unique_poles)
+    multiplicity = multiplicity[order]
+
+    residues = _compute_residues(unique_poles, multiplicity, b)
+
+    index = 0
+    for pole, mult in zip(unique_poles, multiplicity):
+        poles[index:index + mult] = pole
+        index += mult
+
+    return residues / a[0], poles, np.trim_zeros(k, 'f')
 
 
 def residuez(b, a, tol=1e-3, rtype='avg'):
-    """
-    Compute partial-fraction expansion of b(z) / a(z).
+    """Compute partial-fraction expansion of b(z) / a(z).
 
     If `M` is the degree of numerator `b` and `N` the degree of denominator
     `a`::
@@ -2447,70 +2512,79 @@ def residuez(b, a, tol=1e-3, rtype='avg'):
     This function is used for polynomials in negative powers of z,
     such as digital filters in DSP.  For positive powers, use `residue`.
 
+    See Notes of `residue` for details about the algorithm.
+
     Parameters
     ----------
     b : array_like
         Numerator polynomial coefficients.
     a : array_like
         Denominator polynomial coefficients.
+    tol : float, optional
+        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
     -------
     r : ndarray
-        Residues.
+        Residues corresponding to the poles. For repeated poles, the residues
+        are ordered to correspond to ascending by power fractions.
     p : ndarray
-        Poles.
+        Poles ordered by magnitude in ascending order.
     k : ndarray
         Coefficients of the direct polynomial term.
 
     See Also
     --------
     invresz, residue, unique_roots
-
     """
-    b, a = map(np.asarray, (b, a))
-    gain = a[0]
-    brev, arev = b[::-1], a[::-1]
-    krev, brev = np.polydiv(brev, arev)
-    if krev == []:
-        k = []
+    b = np.asarray(b)
+    a = np.asarray(a)
+    if (np.issubdtype(b.dtype, np.complexfloating)
+            or np.issubdtype(a.dtype, np.complexfloating)):
+        b = b.astype(complex)
+        a = a.astype(complex)
     else:
-        k = krev[::-1]
-    b = brev[::-1]
-    p = np.roots(a)
-    r = p * 0.0
-    pout, mult = unique_roots(p, tol=tol, rtype=rtype)
-    p = []
-    for n in range(len(pout)):
-        p.extend([pout[n]] * mult[n])
-    p = np.asarray(p)
-    # Compute the residue from the general formula (for discrete-time)
-    #  the polynomial is in z**(-1) and the multiplication is by terms
-    #  like this (1-p[i] z**(-1))**mult[i].  After differentiation,
-    #  we must divide by (-p[i])**(m-k) as well as (m-k)!
-    indx = 0
-    for n in range(len(pout)):
-        bn = brev.copy()
-        pn = []
-        for l in range(len(pout)):
-            if l != n:
-                pn.extend([pout[l]] * mult[l])
-        an = np.atleast_1d(np.poly(pn))[::-1]
-        # bn(z) / an(z) is (1-po[n] z**(-1))**Nn * b(z) / a(z) where Nn is
-        # multiplicity of pole at po[n] and b(z) and a(z) are polynomials.
-        sig = mult[n]
-        for m in range(sig, 0, -1):
-            if sig > m:
-                # compute next derivative of bn(s) / an(s)
-                term1 = np.polymul(np.polyder(bn, 1), an)
-                term2 = np.polymul(bn, np.polyder(an, 1))
-                bn = np.polysub(term1, term2)
-                an = np.polymul(an, an)
-            r[indx + m - 1] = (np.polyval(bn, 1.0 / pout[n]) /
-                               np.polyval(an, 1.0 / pout[n]) /
-                               factorial(sig - m) / (-pout[n]) ** (sig - m))
-        indx += sig
-    return r / gain, p, k
+        b = b.astype(float)
+        a = a.astype(float)
+
+    b = np.trim_zeros(np.atleast_1d(b), 'b')
+    a = np.trim_zeros(np.atleast_1d(a), 'b')
+
+    if a.size == 0:
+        raise ValueError("Denominator `a` is zero.")
+    elif a[0] == 0:
+        raise ValueError("First coefficient of determinant `a` must be "
+                         "non-zero.")
+
+    poles = np.roots(a)
+    if b.size == 0:
+        return np.zeros(poles.shape), cmplx_sort(poles)[0], np.array([])
+
+    b_rev = b[::-1]
+    a_rev = a[::-1]
+    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)
+    multiplicity = multiplicity[order]
+
+    residues = _compute_residues(1 / unique_poles, multiplicity, b_rev)
+
+    index = 0
+    powers = np.empty(len(residues), dtype=int)
+    for pole, mult in zip(unique_poles, multiplicity):
+        poles[index:index + mult] = pole
+        powers[index:index + mult] = 1 + np.arange(mult)
+        index += mult
+
+    residues *= (-poles) ** powers / a_rev[0]
+
+    return residues, poles, np.trim_zeros(k_rev[::-1], 'b')
 
 
 def invresz(r, p, k, tol=1e-3, rtype='avg'):


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
@@ -25,7 +25,8 @@
     fftconvolve, oaconvolve, choose_conv_method,
     hilbert, hilbert2, lfilter, lfilter_zi, filtfilt, butter, zpk2tf, zpk2sos,
     invres, invresz, vectorstrength, lfiltic, tf2sos, sosfilt, sosfiltfilt,
-    sosfilt_zi, tf2zpk, BadCoefficients, detrend, unique_roots, residue)
+    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._upfirdn import _upfirdn_modes
@@ -2509,18 +2510,19 @@ def test_bad_args(self):
 
 
 class TestPartialFractionExpansion(object):
-    def test_residue_general(self):
-        def assert_rp_almost_equal(r, p, r_true, p_true):
-            r_true = np.asarray(r_true)
-            p_true = np.asarray(p_true)
+    @staticmethod
+    def assert_rp_almost_equal(r, p, r_true, p_true, decimal=7):
+        r_true = np.asarray(r_true)
+        p_true = np.asarray(p_true)
 
-            distance = np.hypot(abs(p[:, None] - p_true),
-                                abs(r[:, None] - r_true))
+        distance = np.hypot(abs(p[:, None] - p_true),
+                            abs(r[:, None] - r_true))
 
-            rows, cols = linear_sum_assignment(distance)
-            assert_allclose(p[rows], p_true[cols])
-            assert_allclose(r[rows], r_true[cols])
+        rows, cols = linear_sum_assignment(distance)
+        assert_almost_equal(p[rows], p_true[cols], decimal=decimal)
+        assert_almost_equal(r[rows], r_true[cols], decimal=decimal)
 
+    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.
         r, p, k = residue([5, 3, -2, 7], [-4, 0, 8, 3])
@@ -2539,13 +2541,13 @@ def assert_rp_almost_equal(r, p, r_true, p_true):
         assert_equal(k.size, 0)
 
         r, p, k = residue([4, 3], [2, -3.4, 1.98, -0.406])
-        assert_rp_almost_equal(r, p,
-                               [-18.125 - 13.125j, -18.125 + 13.125j, 36.25],
-                               [0.5 - 0.2j, 0.5 + 0.2j, 0.7])
+        self.assert_rp_almost_equal(
+            r, p, [-18.125 - 13.125j, -18.125 + 13.125j, 36.25],
+            [0.5 - 0.2j, 0.5 + 0.2j, 0.7])
         assert_equal(k.size, 0)
 
         r, p, k = residue([2, 1], [1, 5, 8, 4])
-        assert_rp_almost_equal(r, p, [-1, 1, 3], [-1, -2, -2])
+        self.assert_rp_almost_equal(r, p, [-1, 1, 3], [-1, -2, -2])
         assert_equal(k.size, 0)
 
         r, p, k = residue([3, -1.1, 0.88, -2.396, 1.348],
@@ -2560,11 +2562,12 @@ def assert_rp_almost_equal(r, p, r_true, p_true):
         assert_equal(k.size, 0)
 
         r, p, k = residue([1, 0, -5], [1, 0, 0, 0, -1])
-        assert_rp_almost_equal(r, p, [1, 1.5j, -1.5j, -1], [-1, -1j, 1j, 1])
+        self.assert_rp_almost_equal(r, p,
+                                    [1, 1.5j, -1.5j, -1], [-1, -1j, 1j, 1])
         assert_equal(k.size, 0)
 
         r, p, k = residue([3, 8, 6], [1, 3, 3, 1])
-        assert_rp_almost_equal(r, p, [1,2, 3], [-1, -1, -1])
+        self.assert_rp_almost_equal(r, p, [1, 2, 3], [-1, -1, -1])
         assert_equal(k.size, 0)
 
         r, p, k = residue([3, -1], [1, -3, 2])
@@ -2583,9 +2586,8 @@ def assert_rp_almost_equal(r, p, r_true, p_true):
         assert_almost_equal(k, [7, 23])
 
         r, p, k = residue([2, 3, -1], [1, -3, 4, -2])
-        assert_rp_almost_equal(r, p,
-                              [4, -1 + 3.5j, -1 - 3.5j],
-                               [1, 1 - 1j, 1 + 1j])
+        self.assert_rp_almost_equal(r, p, [4, -1 + 3.5j, -1 - 3.5j],
+                                    [1, 1 - 1j, 1 + 1j])
         assert_almost_equal(k.size, 0)
 
     def test_residue_leading_zeros(self):
@@ -2608,7 +2610,7 @@ def test_resiude_degenerate(self):
         # Several tests for zero numerator and denominator.
         r, p, k = residue([0, 0], [1, 6, 8])
         assert_almost_equal(r, [0, 0])
-        assert_almost_equal(p, [-4, -2])
+        assert_almost_equal(p, [-2, -4])
         assert_equal(k.size, 0)
 
         r, p, k = residue(0, 1)
@@ -2616,7 +2618,125 @@ def test_resiude_degenerate(self):
         assert_equal(p.size, 0)
         assert_equal(k.size, 0)
 
-        assert_raises(ValueError, residue, 1, 0)
+        with pytest.raises(ValueError, match="Denominator `a` is zero."):
+            residue(1, 0)
+
+    def test_residuez_general(self):
+        r, p, k = residuez([1, 6, 6, 2], [1, -(2 + 1j), (1 + 2j), -1j])
+        self.assert_rp_almost_equal(r, p, [-2+2.5j, 7.5+7.5j, -4.5-12j],
+                                    [1j, 1, 1])
+        assert_almost_equal(k, [2j])
+
+        r, p, k = residuez([1, 2, 1], [1, -1, 0.3561])
+        self.assert_rp_almost_equal(r, p,
+                                    [-0.9041 - 5.9928j, -0.9041 + 5.9928j],
+                                    [0.5 + 0.3257j, 0.5 - 0.3257j],
+                                    decimal=4)
+        assert_almost_equal(k, [2.8082], decimal=4)
+
+        r, p, k = residuez([1, -1], [1, -5, 6])
+        assert_almost_equal(r, [-1, 2])
+        assert_almost_equal(p, [2, 3])
+        assert_equal(k.size, 0)
+
+        r, p, k = residuez([2, 3, 4], [1, 3, 3, 1])
+        self.assert_rp_almost_equal(r, p, [4, -5, 3], [-1, -1, -1])
+        assert_equal(k.size, 0)
+
+        r, p, k = residuez([1, -10, -4, 4], [2, -2, -4])
+        assert_almost_equal(r, [0.5, -1.5])
+        assert_almost_equal(p, [-1, 2])
+        assert_almost_equal(k, [1.5, -1])
+
+        r, p, k = residuez([18], [18, 3, -4, -1])
+        self.assert_rp_almost_equal(r, p,
+                                    [0.36, 0.24, 0.4], [0.5, -1/3, -1/3])
+        assert_equal(k.size, 0)
+
+        r, p, k = residuez([2, 3], np.polymul([1, -1/2], [1, 1/4]))
+        assert_almost_equal(r, [-10/3, 16/3])
+        assert_almost_equal(p, [-0.25, 0.5])
+        assert_equal(k.size, 0)
+
+        r, p, k = residuez([1, -2, 1], [1, -1])
+        assert_almost_equal(r, [0])
+        assert_almost_equal(p, [1])
+        assert_almost_equal(k, [1, -1])
+
+        r, p, k = residuez(1, [1, -1j])
+        assert_almost_equal(r, [1])
+        assert_almost_equal(p, [1j])
+        assert_equal(k.size, 0)
+
+        r, p, k = residuez(1, [1, -1, 0.25])
+        assert_almost_equal(r, [0, 1])
+        assert_almost_equal(p, [0.5, 0.5])
+        assert_equal(k.size, 0)
+
+        r, p, k = residuez(1, [1, -0.75, .125])
+        assert_almost_equal(r, [-1, 2])
+        assert_almost_equal(p, [0.25, 0.5])
+        assert_equal(k.size, 0)
+
+        r, p, k = residuez([1, 6, 2], [1, -2, 1])
+        assert_almost_equal(r, [-10, 9])
+        assert_almost_equal(p, [1, 1])
+        assert_almost_equal(k, [2])
+
+        r, p, k = residuez([6, 2], [1, -2, 1])
+        assert_almost_equal(r, [-2, 8])
+        assert_almost_equal(p, [1, 1])
+        assert_equal(k.size, 0)
+
+        r, p, k = residuez([1, 6, 6, 2], [1, -2, 1])
+        assert_almost_equal(r, [-24, 15])
+        assert_almost_equal(p, [1, 1])
+        assert_almost_equal(k, [10, 2])
+
+        r, p, k = residuez([1, 0, 1], [1, 0, 0, 0, 0, -1])
+        self.assert_rp_almost_equal(r, p,
+                                    [0.2618 + 0.1902j, 0.2618 - 0.1902j,
+                                     0.4, 0.0382 - 0.1176j, 0.0382 + 0.1176j],
+                                    [-0.8090 + 0.5878j, -0.8090 - 0.5878j,
+                                     1.0, 0.3090 + 0.9511j, 0.3090 - 0.9511j],
+                                    decimal=4)
+        assert_equal(k.size, 0)
+
+    def test_residuez_trailing_zeros(self):
+        # Trailing zeros in numerator or denominator must not affect the
+        # answer.
+        r0, p0, k0 = residuez([5, 3, -2, 7], [-4, 0, 8, 3])
+        r1, p1, k1 = residuez([5, 3, -2, 7, 0], [-4, 0, 8, 3])
+        r2, p2, k2 = residuez([5, 3, -2, 7], [-4, 0, 8, 3, 0])
+        r3, p3, k3 = residuez([5, 3, -2, 7, 0, 0], [-4, 0, 8, 3, 0, 0, 0])
+        assert_almost_equal(r0, r1)
+        assert_almost_equal(r0, r2)
+        assert_almost_equal(r0, r3)
+        assert_almost_equal(p0, p1)
+        assert_almost_equal(p0, p2)
+        assert_almost_equal(p0, p3)
+        assert_almost_equal(k0, k1)
+        assert_almost_equal(k0, k2)
+        assert_almost_equal(k0, k3)
+
+    def test_residuez_degenerate(self):
+        r, p, k = residuez([0, 0], [1, 6, 8])
+        assert_almost_equal(r, [0, 0])
+        assert_almost_equal(p, [-2, -4])
+        assert_equal(k.size, 0)
+
+        r, p, k = residuez(0, 1)
+        assert_equal(r.size, 0)
+        assert_equal(p.size, 0)
+        assert_equal(k.size, 0)
+
+        with pytest.raises(ValueError, match="Denominator `a` is zero."):
+            residuez(1, 0)
+
+        with pytest.raises(ValueError,
+                           match="First coefficient of determinant `a` must "
+                                 "be non-zero."):
+            residuez(1, [0, 1, 2, 3])
 
     def test_invresz_one_coefficient_bug(self):
         # Regression test for issue in gh-4646.