Repository: scipy/scipy

Issue:
signal.resample could be twice as fast using rfft
[`signal.resample`](https://github.com/scipy/scipy/blob/master/scipy/signal/signaltools.py#L1722) could be twice as fast for real input by using `rfft`/`irfft`, same as `fftconvolve`



Discussion:

Resolving patch:
diff --git a/benchmarks/benchmarks/signal.py b/benchmarks/benchmarks/signal.py
--- a/benchmarks/benchmarks/signal.py
+++ b/benchmarks/benchmarks/signal.py
@@ -12,7 +12,25 @@
 from .common import Benchmark
 
 
+class Resample(Benchmark):
+
+    # Some slow (prime), some fast (in radix)
+    param_names = ['N', 'num']
+    params = [[977, 9973, 2 ** 14, 2 ** 16]] * 2
+
+    def setup(self, N, num):
+        x = np.linspace(0, 10, N, endpoint=False)
+        self.y = np.cos(-x**2/6.0)
+
+    def time_complex(self, N, num):
+        signal.resample(self.y + 0j, num)
+
+    def time_real(self, N, num):
+        signal.resample(self.y, num)
+
+
 class CalculateWindowedFFT(Benchmark):
+
     def setup(self):
         np.random.seed(5678)
         # Create some long arrays for computation
@@ -142,6 +160,7 @@ def time_correlate2d(self, mode):
 
 
 class LTI(Benchmark):
+
     def setup(self):
         self.system = signal.lti(1.0, [1, 0, 1])
         self.t = np.arange(0, 100, 0.5)
diff --git a/scipy/signal/signaltools.py b/scipy/signal/signaltools.py
--- a/scipy/signal/signaltools.py
+++ b/scipy/signal/signaltools.py
@@ -4,14 +4,12 @@
 from __future__ import division, print_function, absolute_import
 
 import operator
-import threading
 import sys
 import timeit
 
 from . import sigtools, dlti
 from ._upfirdn import upfirdn, _output_len
 from scipy._lib.six import callable
-from scipy._lib._version import NumpyVersion
 from scipy import linalg, fft as sp_fft
 from scipy.fft._helper import _init_nd_shape_and_axes_sorted
 from numpy import (allclose, angle, arange, argsort, array, asarray,
@@ -398,17 +396,16 @@ def fftconvolve(in1, in2, mode="full", axes=None):
         # Convolution is commutative; order doesn't have any effect on output
         in1, s1, in2, s2 = in2, s2, in1, s1
 
-    # Speed up FFT by padding to optimal size for FFTPACK
+    # Speed up FFT by padding to optimal size
     fshape = [sp_fft.next_fast_len(d) for d in shape[axes]]
     fslice = tuple([slice(sz) for sz in shape])
     if not complex_result:
-        sp1 = sp_fft.rfftn(in1, fshape, axes=axes)
-        sp2 = sp_fft.rfftn(in2, fshape, axes=axes)
-        ret = sp_fft.irfftn(sp1 * sp2, fshape, axes=axes)[fslice].copy()
+        fft, ifft = sp_fft.rfftn, sp_fft.irfftn
     else:
-        sp1 = sp_fft.fftn(in1, fshape, axes=axes)
-        sp2 = sp_fft.fftn(in2, fshape, axes=axes)
-        ret = sp_fft.ifftn(sp1 * sp2, axes=axes)[fslice].copy()
+        fft, ifft = sp_fft.fftn, sp_fft.ifftn
+    sp1 = fft(in1, fshape, axes=axes)
+    sp2 = fft(in2, fshape, axes=axes)
+    ret = ifft(sp1 * sp2, fshape, axes=axes)[fslice].copy()
 
     if mode == "full":
         return ret
@@ -2226,6 +2223,17 @@ def resample(x, num, t=None, axis=0, window=None):
     x = asarray(x)
     X = sp_fft.fft(x, axis=axis)
     Nx = x.shape[axis]
+
+    # Check if we can use faster real FFT
+    real_input = np.isrealobj(x)
+
+    # Forward transform
+    if real_input:
+        X = sp_fft.rfft(x, axis=axis)
+    else:  # Full complex FFT
+        X = sp_fft.fft(x, axis=axis)
+
+    # Apply window to spectrum
     if window is not None:
         if callable(window):
             W = window(sp_fft.fftfreq(Nx))
@@ -2235,36 +2243,71 @@ def resample(x, num, t=None, axis=0, window=None):
             W = window
         else:
             W = sp_fft.ifftshift(get_window(window, Nx))
-        newshape = [1] * x.ndim
-        newshape[axis] = len(W)
-        W.shape = newshape
-        X = X * W
-        W.shape = (Nx,)
-    sl = [slice(None)] * x.ndim
+
+        newshape_W = [1] * x.ndim
+        newshape_W[axis] = X.shape[axis]
+        if real_input:
+            # Fold the window back on itself to mimic complex behavior
+            W_real = W.copy()
+            W_real[1:] += W_real[-1:0:-1]
+            W_real[1:] *= 0.5
+            X *= W_real[:newshape_W[axis]].reshape(newshape_W)
+        else:
+            X *= W.reshape(newshape_W)
+
+    # Copy each half of the original spectrum to the output spectrum, either
+    # truncating high frequences (downsampling) or zero-padding them
+    # (upsampling)
+
+    # Placeholder array for output spectrum
     newshape = list(x.shape)
-    newshape[axis] = num
-    N = int(np.minimum(num, Nx))
-    Y = zeros(newshape, 'D')
-    sl[axis] = slice(0, (N + 1) // 2)
-    Y[tuple(sl)] = X[tuple(sl)]
-    sl[axis] = slice(-(N - 1) // 2, None)
+    if real_input:
+        newshape[axis] = num // 2 + 1
+    else:
+        newshape[axis] = num
+    Y = zeros(newshape, X.dtype)
+
+    # Copy positive frequency components (and Nyquist, if present)
+    N = min(num, Nx)
+    nyq = N // 2 + 1  # Slice index that includes Nyquist if present
+    sl = [slice(None)] * x.ndim
+    sl[axis] = slice(0, nyq)
     Y[tuple(sl)] = X[tuple(sl)]
+    if not real_input:
+        # Copy negative frequency components
+        if N > 2:  # (slice expression doesn't collapse to empty array)
+            sl[axis] = slice(nyq - N, None)
+            Y[tuple(sl)] = X[tuple(sl)]
+
+    # Split/join Nyquist component(s) if present
+    # So far we have set Y[+N/2]=X[+N/2]
+    if N % 2 == 0:
+        if num < Nx:  # downsampling
+            if real_input:
+                sl[axis] = slice(N//2, N//2 + 1)
+                Y[tuple(sl)] *= 2.
+            else:
+                # select the component of Y at frequency +N/2,
+                # add the component of X at -N/2
+                sl[axis] = slice(-N//2, -N//2 + 1)
+                Y[tuple(sl)] += X[tuple(sl)]
+        elif Nx < num:  # upsampling
+            # select the component at frequency +N/2 and halve it
+            sl[axis] = slice(N//2, N//2 + 1)
+            Y[tuple(sl)] *= 0.5
+            if not real_input:
+                temp = Y[tuple(sl)]
+                # set the component at -N/2 equal to the component at +N/2
+                sl[axis] = slice(num-N//2, num-N//2 + 1)
+                Y[tuple(sl)] = temp
+
+    # Inverse transform
+    if real_input:
+        y = sp_fft.irfft(Y, num, axis=axis)
+    else:
+        y = sp_fft.ifft(Y, axis=axis, overwrite_x=True)
 
-    if N % 2 == 0:  # special treatment if low number of points is even. So far we have set Y[-N/2]=X[-N/2]
-        if N < Nx:  # if downsampling
-            sl[axis] = slice(N//2,N//2+1,None)  # select the component at frequency N/2
-            Y[tuple(sl)] += X[tuple(sl)]  # add the component of X at N/2
-        elif N < num:  # if upsampling
-            sl[axis] = slice(num-N//2,num-N//2+1,None)  # select the component at frequency -N/2
-            Y[tuple(sl)] /= 2  # halve the component at -N/2
-            temp = Y[tuple(sl)]
-            sl[axis] = slice(N//2,N//2+1,None)  # select the component at +N/2
-            Y[tuple(sl)] = temp  # set that equal to the component at -N/2
-
-    y = sp_fft.ifft(Y, axis=axis) * (float(num) / float(Nx))
-
-    if x.dtype.char not in ['F', 'D']:
-        y = y.real
+    y *= (float(num) / float(Nx))
 
     if t is None:
         return y


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
@@ -884,21 +884,35 @@ def test_basic(self):
         assert_raises(ValueError, signal.resample_poly, sig, 1, 0)
 
         # test for issue #6505 - should not modify window.shape when axis ≠ 0
-        sig2 = np.tile(np.arange(160), (2,1))
+        sig2 = np.tile(np.arange(160), (2, 1))
         signal.resample(sig2, num, axis=-1, window=win)
         assert_(win.shape == (160,))
 
-    def test_fft(self):
-        # Test FFT-based resampling
-        self._test_data(method='fft')
-
-    def test_polyphase(self):
-        # Test polyphase resampling
-        self._test_data(method='polyphase')
-
-    def test_polyphase_extfilter(self):
-        # Test external specification of downsampling filter
-        self._test_data(method='polyphase', ext=True)
+    @pytest.mark.parametrize('window', (None, 'hamming'))
+    @pytest.mark.parametrize('N', (20, 19))
+    @pytest.mark.parametrize('num', (100, 101, 10, 11))
+    def test_rfft(self, N, num, window):
+        # Make sure the speed up using rfft gives the same result as the normal
+        # way using fft
+        x = np.linspace(0, 10, N, endpoint=False)
+        y = np.cos(-x**2/6.0)
+        assert_allclose(signal.resample(y, num, window=window),
+                        signal.resample(y + 0j, num, window=window).real)
+
+        y = np.array([np.cos(-x**2/6.0), np.sin(-x**2/6.0)])
+        y_complex = y + 0j
+        assert_allclose(
+            signal.resample(y, num, axis=1, window=window),
+            signal.resample(y_complex, num, axis=1, window=window).real,
+            atol=1e-9)
+
+    @pytest.mark.parametrize('nx', (1, 2, 3, 5, 8))
+    @pytest.mark.parametrize('ny', (1, 2, 3, 5, 8))
+    @pytest.mark.parametrize('dtype', ('float', 'complex'))
+    def test_dc(self, nx, ny, dtype):
+        x = np.array([1] * nx, dtype)
+        y = signal.resample(x, ny)
+        assert_allclose(y, [1] * ny)
 
     def test_mutable_window(self):
         # Test that a mutable window is not modified
@@ -911,11 +925,16 @@ def test_mutable_window(self):
     def test_output_float32(self):
         # Test that float32 inputs yield a float32 output
         x = np.arange(10, dtype=np.float32)
-        h = np.array([1,1,1], dtype=np.float32)
+        h = np.array([1, 1, 1], dtype=np.float32)
         y = signal.resample_poly(x, 1, 2, window=h)
         assert_(y.dtype == np.float32)
 
-    def _test_data(self, method, ext=False):
+    @pytest.mark.parametrize('method, ext', [
+        ('fft', False),
+        ('polyphase', False),
+        ('polyphase', True),
+    ])
+    def test_resample_methods(self, method, ext):
         # Test resampling of sinusoids and random noise (1-sec)
         rate = 100
         rates_to = [49, 50, 51, 99, 100, 101, 199, 200, 201]
@@ -1061,6 +1080,7 @@ def test_basic(self):
 
 
 class _TestLinearFilter(object):
+
     def generate(self, shape):
         x = np.linspace(0, np.prod(shape) - 1, np.prod(shape)).reshape(shape)
         return self.convert_dtype(x)
@@ -2273,6 +2293,7 @@ def test_bad_args(self):
 
 
 class TestPartialFractionExpansion(object):
+
     def test_invresz_one_coefficient_bug(self):
         # Regression test for issue in gh-4646.
         r = [1]
diff --git a/scipy/signal/tests/test_windows.py b/scipy/signal/tests/test_windows.py
--- a/scipy/signal/tests/test_windows.py
+++ b/scipy/signal/tests/test_windows.py
@@ -562,8 +562,8 @@ def test_array_as_window(self):
         sig = np.arange(128)
 
         win = windows.get_window(('kaiser', 8.0), osfactor // 2)
-        assert_raises(ValueError, resample,
-                      (sig, len(sig) * osfactor), {'window': win})
+        with assert_raises(ValueError, match='must have the same length'):
+            resample(sig, len(sig) * osfactor, window=win)
 
 
 def test_windowfunc_basics():