Repository: scipy/scipy
Pull request: BUG: freqz rfft grid fix
<!-- 
Thanks for contributing a pull request! Please ensure that
your PR satisfies the checklist before submitting:
http://scipy.github.io/devdocs/dev/contributor/development_workflow.html#checklist-before-submitting-a-pr

Also, please name and describe your PR as you would write a
commit message:
http://scipy.github.io/devdocs/dev/contributor/development_workflow.html#writing-the-commit-message

Depending on your changes, you can skip CI operations and save time and energy: 
http://scipy.github.io/devdocs/dev/contributor/continuous_integration.html#skipping

Note that we are a team of volunteers; we appreciate your
patience during the review process.

Again, thanks for contributing!
-->

#### Reference issue
closes #17289 
closes #15273

#### What does this implement/fix?
`signal.freqz()` includes a mechanism to improve performance by using `numpy.fft` for certain parameters. The frequency grid created in `freqz()` is not consistent with the grid that is assumed by using `numpy.fft` (it is off by one) and thus the result is not correct. The test case added proves this: results are different for two calls of `freqz`, one where `numpy.fft` is used and one where the frequency bins are passed explicitly and the polynomial method is used.

#### Additional information
<!--Any additional information you think is important.-->
The fix effectively implement a new rule to select the set of parameters where `numpy.fft` will be used. The new rule is more permissive and does result in a consistent performance improvement, according to a new benchmark (see anecdotal results below).


Problem statement:
freqz: suboptimal performance for worN=2**n+1, include_nyquist=True and real coefficients
### Describe your issue.

freqz can be way faster, when the coefficients are real, `worN=2**n+1` and `include_nyquist=True`. In this case `rfft` can be used directly.

The attached code illustrates this, I hope.

Thanks for the great work!

### Reproducing Code Example

```python
import numpy as np
from scipy.signal import freqz
import timeit

N = 128

for n in range(7):
    worN = 2**n*N//2+1
    print('worN = {}'.format(worN))

    h = np.random.rand(N)*2-1

    f0 = lambda: freqz(h, worN=worN, include_nyquist=True)[1]
    f1 = lambda: np.fft.rfft(h, n=2*(worN-1))

    H0 = f0()
    H1 = f1()

    err = np.sum(np.abs(H0-H1)**2)/np.sum(np.abs(H0)**2)
    print('error = {}'.format(err))

    t0 = timeit.timeit(f0, number=10000)
    t1 = timeit.timeit(f1, number=10000)
    print('speedup = {}\n'.format(t0/t1))
```


### Error message

```shell
worN = 65
error = 7.582436736470968e-29
speedup = 118.5314640968968

worN = 129
error = 1.2033665898383146e-28
speedup = 62.96460648165139

worN = 257
error = 6.748460212611348e-29
speedup = 54.55989853561076

worN = 513
error = 6.378075125203393e-29
speedup = 47.368120583063835

worN = 1025
error = 8.559451508895459e-29
speedup = 38.34191925764777

worN = 2049
error = 7.356556838100076e-29
speedup = 29.080013414218744

worN = 4097
error = 8.396001469974994e-29
speedup = 22.058802358213786
```


### SciPy/NumPy/Python version information

1.7.3 1.21.4 sys.version_info(major=3, minor=10, micro=1, releaselevel='final', serial=0)

BUG: Different results between numpy.fft.rfft and scipy.signal.freqz
### Describe your issue.

I'm using the following two functions to calculate the frequency response of an impulse response: `numpy.fft.rfft` and `scipy.signal.freqz`, but I get different results. Let's simplify the problem using a small number of FFT.

```py
import numpy as np
import scipy.signal as signal
h = np.array([1,2,3])
H1 = np.fft.rfft(h, n=4)
w, H2 = signal.freqz(h, 1, worN=len(H1), include_nyquist=True)
```

NFFT = 4, `rfft` calculates DFT at frequencies `0, pi/2, pi` which is exactly equal to `w` returned by `freqz`
```py
>>> print(w)
[0.         1.57079633 3.14159265]
```

However `H2` is different from `H1`
```py
>>> print(H1)
[ 6.+0.j -2.-2.j  2.+0.j]
>>> print(H2)
[ 6. +0.j          0.5-4.33012702j -1.5+0.8660254j ]
```

The DFT at `pi/2` should be real-valued for a real-valued signal, so `rfft` gives the correct result.

From the source code of `freqz` in line 438-455 of `scipy/signal/_filter_design.py` we can see why it gives the wrong result. It doesn't give the nyquist result at all even `include_nyquist` is set to `True`.


### Reproducing Code Example

```python
import numpy as np
import scipy.signal as signal
h = np.array([1,2,3])
H1 = np.fft.rfft(h, n=4)
w, H2 = signal.freqz(h, 1, worN=len(H1), include_nyquist=True)
print(H1)
print(H2)
```


### Error message

```shell
No error occurs.
```


### SciPy/NumPy/Python version information

1.9.3 1.23.4 sys.version_info(major=3, minor=9, micro=0, releaselevel='final', serial=0)

Hints from issue discussion:
Hi @drestebon, thanks for reporting. Looking at the documentation, there are some rules to observe so that (R)FFT is used (I did not look at the implementation yet). https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.freqz.html
 

Did you have a look? Are you suggesting something else?
Hi @tupui, thanks for your reply.

It seems like the rules implemented to use (R)FFT do not reflect the case I'm referring to.

I made an attempt at this. You can see it here:

https://github.com/drestebon/scipy/blob/f4c9c947b2220f52415fff349a79147ec6afc4ab/scipy/signal/_filter_design.py#L445

My feeling is that the way I did it is a bit hackish and clumsy, but it shows what is required: `n_fft` for this case is `2*(N-1)`.

It still looks like numpy's fft does a better job though.

```
worN = 65
error = 5.63413763959627e-29
speedup = 121.96746809708475

worN = 129
error = 4.657502702331312e-32
speedup = 11.959434175189896

worN = 257
error = 4.2029261309019205e-32
speedup = 9.685627976708963

worN = 513
error = 4.568295653285404e-32
speedup = 7.261198177906964

worN = 1025
error = 3.702060680434987e-32
speedup = 4.869401531950861

worN = 2049
error = 3.6579413212381833e-32
speedup = 3.173570125199443

worN = 4097
error = 4.7836664035146534e-32
speedup = 2.060847729547769
```
When worN is small, it is faster, but the error looks big. We should be careful for backward compatibility.
```
worN = 3
error = 1.9374009635386986
speedup = 21.30843158223846

worN = 5
error = 0.18741190054056306
speedup = 16.77478100125656

worN = 9
error = 8.612108265284007e-32
speedup = 15.351992009731502

worN = 17
error = 5.22888645839279e-31
speedup = 13.55208721451381

worN = 33
error = 0.10942613312814237
speedup = 12.463569226773217

worN = 65
error = 3.79794227671082e-31
speedup = 14.018722794359956
```
Hi @AtsushiSakai, if we use `rfft` with `2*(worN-1) < b.shape[0]`, it means we are truncating the impulse response, which causes the error. My guess would be, that the user wouldn't want `freqz` to make any approximations.

In my branch I included the condition `2*(N-1) >= b.shape[0]` to reflect this.
I ran a bit more exhaustive benchmarks and it seems the `rfft` approach is faster than the `polyval` one for any value of `worN`.

For these simulations I introduced a `force_poly` argument, to skip the `fft`-related optimizations in `freqz`.

![rfft_speedup](https://user-images.githubusercontent.com/6323472/147413756-ea90a073-fcc8-4666-b27f-5747d9e6c14c.png)
![rfft_speedup_long](https://user-images.githubusercontent.com/6323472/147413757-fb054bd4-b144-4111-b7d5-2f7d1eebc7d3.png)

The code:

```
import matplotlib.pyplot as plt
import numpy as np
import scipy
from scipy.signal import freqz
import timeit

from scipy import fft as sp_fft

print(scipy.__file__)

fig = plt.figure(1, figsize=(19, 12))
fig.clf()
ax = fig.subplots(3,4)

Ntop = 20

# for Ntc, N in enumerate([64, 128, 256, 512]):
for Ntc, N in enumerate([4, 8, 16, 32]):

    NN = np.arange(N//2+1, Ntop*N//2+1)
    speedup = np.zeros((len(NN),))
    error   = np.zeros((len(NN),))
    t0   = np.zeros((len(NN),))
    t1   = np.zeros((len(NN),))

    for tc, n in enumerate(NN):
        # worN = 2**n*N//2+1
        worN = n

        h = np.random.rand(N)*2-1

        f0 = lambda: freqz(h, worN=worN, include_nyquist=True, force_poly=True)[1]
        f1 = lambda: sp_fft.rfft(h, n=2*(worN-1), axis=0)
        # f1 = lambda: np.fft.rfft(h, n=2*(worN-1))

        H0 = f0()
        H1 = f1()

        error[tc] = np.sum(np.abs(H0-H1)**2)/np.sum(np.abs(H0)**2)

        t0[tc] = timeit.timeit(f0, number=200)
        t1[tc] = timeit.timeit(f1, number=200)
        speedup[tc] = t0[tc]/t1[tc]

        print('N = {}\tworN = {:5d}\terror = {:.10e}\tspeedup = {:.5f}'.format(N, worN, error[tc], speedup[tc]))

    ax[0,Ntc].semilogy(NN, error)
    ax[1,Ntc].semilogy(NN, speedup)
    ax[1,Ntc].hlines(1, NN[0], NN[-1], color='k', ls=':')
    ax[2,Ntc].semilogy(NN, t0, label='freqz{polyval}')
    ax[2,Ntc].semilogy(NN, t1, label='rfft')
    ax[0,Ntc].set_xlabel('worN')
    ax[1,Ntc].set_xlabel('worN')
    ax[2,Ntc].set_xlabel('worN')
    ax[0,Ntc].set_ylabel('error')
    ax[1,Ntc].set_ylabel('speedup')
    ax[2,Ntc].set_ylabel('time')
    ax[2,Ntc].legend()
    ax[0,Ntc].set_title('sig.freqz{{polyval}} v/s scipy.fft.rfft\nlen(b)={}'.format(N))

fig.savefig('rfft_speedup.png')

```
Thanks you for the benchmark @drestebon. It looks like we could benefit for another condition to benefit from this. @ilayn does this look right to you?
_edited for clarity_

I did a bit more digging and found and issue in the result produced with `rfft` in the current implementation.

It seems that when `freqz` uses `rfft`, it uses the wrong window length. In the example below, it should do an rfft on 126 samples, to get an evenly spaced grid of 64 points in [0, pi]. Instead, it does an rfft on 128 samples, which gives an evenly spaced grid of 65 points in [0, pi]. The resulting frequency response is off-by-one.

In the example below I am calculating the frequency response of `H(z) = z^-4`. You should see two whole sinusoid cycles in [0,pi]. In the plot you can see that `freqz[poly]` and `freqz[rfft]` give different results and that the later corresponds to the `freqz[poly]` on a grid of 65 points (which is the result of an fft of length 128). To the best of my knowledge, the correct result is the one produced by `freqz[poly]`

One problem in the current implementation is that the result of the fft is truncated: `h = fft_func(b, n=n_fft, axis=0)[:N]`, masking errors in the dimensions.

I think this is a separate issue. Is the use to open a separate issue, or should we widen the scope of this one?

![freqz rfft grid error](https://user-images.githubusercontent.com/6323472/147605973-972486ec-a7e9-43b8-b86b-bab067811a4e.png)


Script for reproducing the plot:

```
import numpy as np
import matplotlib.pyplot as plt

import scipy
import scipy.signal as sig
from scipy import fft as sp_fft

print(scipy.__file__)

worN = 64
N = 5

# impulse response for H(z) = z**-4
h = np.zeros(N)
h[-1] = 1

fig = plt.figure(1, figsize=[18,  8])
plt.clf()

# default behaviour
w, H = sig.freqz(h, worN=worN, include_nyquist=True)

plt.plot(np.real(H), lw=1, label='freqz[rfft]')
plt.plot(np.imag(H), lw=1, label='freqz[rfft]')

# poly
w, Hp = sig.freqz(h, worN=w)

plt.plot(np.real(Hp), lw=2, label='freqz[poly]')
plt.plot(np.imag(Hp), lw=2, label='freqz[poly]')

# poly 65 points -- 128 sample window
w2N, Hp2N = sig.freqz(h, worN=sp_fft.rfftfreq(2*worN)*2*np.pi)

plt.plot(np.real(Hp2N), 'd', label='freqz[poly_2N]')
plt.plot(np.imag(Hp2N), 'd', label='freqz[poly_2N]')

# Analyic
zm1 = np.exp(-1j * w)
Ha = zm1**(N-1)

plt.plot(np.real(Ha), 'x', label='analytic re')
plt.plot(np.imag(Ha), 'x', label='analytic im')

# rfft
nrfft = 2*(worN-1)
Hrfft = sp_fft.rfft(h, n=nrfft, axis=0)
plt.plot(np.real(Hrfft),  '.', lw=1, label='rfft re')
plt.plot(np.imag(Hrfft),  '.', lw=1, label='rfft im')


# error
plt.plot(np.real(Hp - H),  lw=1, label='Error: freqz[poly] - freqz[rfft] re')
plt.plot(np.imag(Hp - H),  lw=1, label='Error: freqz[poly] - freqz[rfft] im')

plt.legend()

plt.xlabel('frequency bin')
plt.ylabel('frequency response (real & imag)')
plt.title('$H(z)=z^{{{}}}$\nfreqz[poly] v/s freqz[rfft] worN={} '.format(-(N-1), worN))

plt.savefig('freqz rfft grid error.png')

plt.show()
```
_Sorry for yet another very long comment._

I did some benchmarking using the fix in the commit above.

I'm measuring the execution time of the _poly_ and _(r)fft_ implementations. The plots below show the error (difference between both approaches) and the speed improvement of the later as a function of `worN` and of `n_fft`. The column on the left corresponds to randomized impulse responses of length `n_fft`. The column on the right corresponds to impulse responses where `len(b) << n_fft`, which means the fft will do some zero-padding. They also include an allpass component. The purpose of this IR is to show the limitation of the current implementation (mentioned earlier).


## Things to note:

* The fix removes the spikes in the error that appear on the right column
* The fix improves the execution time across the board, except for small values of `n_fft`. An ideal solution would include a threshold on `n_fft`. I tried the fix on two very different machines (see below) and the results look equivalent. A conservative threshold would be, for example, that the _(r)fft_ is applied only for `n_fft >= 32`.
* The point on the `n_fft` axis where the _(r)fft_ improves the execution time increases as the amount of zero-padding increases: for a small number of coefficients, the _poly_ approach is faster than _(r)fft_, and improvements only appear for longer values of `n_fft`. An ideal solution would have a threshold on `n_fft` that is a function of `n_fft / len(b)`. This would require a bit more benchmarking that I'd be happy to do.

So far, the fix boils down to correctly calculating `n_fft` for `include_nyquist=True` and removing the constraint `sp_fft.next_fast_len(N) == N` (see the commit mentioned above).


Benchmark for the current implementation:

![freq_fft_benchmark_i5_mainline](https://user-images.githubusercontent.com/6323472/147794259-fbfa6ac7-fd75-410c-a753-d3ab32b7ae3e.png)


Benchmark for the fix:

![freq_fft_benchmark_i5_fix](https://user-images.githubusercontent.com/6323472/147794284-d8c3f344-dc0a-4378-b60d-5c56253f4fd1.png)


Both these benchmarks were carried out on this computer:

    model name	: Intel(R) Core(TM) i5-7260U CPU @ 2.20GHz
    MemTotal:       32763848 kB


# Run on a bigger computer

Further results were obtained on this computer:

    model name      : Intel(R) Xeon(R) Gold 6154 CPU @ 3.00GHz
    MemTotal:       197564584 kB


Benchmark for the current implementation

![freq_fft_benchmark_xeon_mainline](https://user-images.githubusercontent.com/6323472/147794342-54a318f3-df0c-4938-a158-c3163bee8c5a.png)


Benchmark for the fix

![freq_fft_benchmark_xeon_fix](https://user-images.githubusercontent.com/6323472/147794325-304cfe46-4c74-43e0-a52a-a7bb4af9c470.png)


# script
<details>
  <summary>Click to expand!</summary>

```
import sys

if len(sys.argv) < 2:
    print('Hey! give a name to the run with an argument')
    sys.exit()

import matplotlib.pyplot as plt

import numpy as np
import numpy.random

import scipy
from scipy.signal import freqz
from scipy import fft as sp_fft

import timeit

print(scipy.__file__)

fig = plt.figure(1, figsize=(12, 12))
fig.clf()
ax = fig.subplots(3,2)

attempts = 256

NN      = np.arange(4, 256)
speedup = np.zeros((len(NN),))
error   = np.zeros((len(NN),))
t0      = np.zeros((len(NN),))
t1      = np.zeros((len(NN),))

rng = np.random.RandomState(0)

for zp in [False, True]:
    for cplx in [False, True]:
        for whole in [False, True]:
            for include_nyquist in [False, True]:
                for tc, n in enumerate(NN):
                    worN = n

                    N = n if whole else 2*(n - 1) if include_nyquist else 2*n

                    # Bad things happen with zero padding (len(impulse response) << n_fft)
                    if zp:
                        N = N//2

                    h = rng.randn(N)
                    if cplx:
                        h = h + 1.0j*rng.randn(N)

                    # Bad things happen with a wide band response
                    if zp:
                        k = min(4, N-1)
                        h[k] = h[k]+10.0 # mala leche

                    ww, Hf = freqz(h, worN=worN, include_nyquist=include_nyquist, whole=whole)
                    f0 = lambda: freqz(h, worN=ww)[1] # poly
                    f1 = lambda: freqz(h, worN=worN, include_nyquist=include_nyquist, whole=whole)[1]

                    H0 = f0()
                    H1 = f1()

                    error[tc] = np.sum(np.abs(H0-H1)**2)/np.sum(np.abs(H0)**2)

                    t0[tc] = timeit.timeit(f0, number=attempts)
                    t1[tc] = timeit.timeit(f1, number=attempts)
                    speedup[tc] = t0[tc]/t1[tc]

                    print('{}{}{}{} N = {}\tworN = {:5d}\terror = {:.10e}\tspeedup = {:.5f}'.format(
                           int(zp), int(cplx), int(whole), int(include_nyquist), N, worN, error[tc], speedup[tc]))

                lbl = []
                if cplx:
                    lbl.append('c')
                if whole:
                    lbl.append('wh')
                if include_nyquist:
                    lbl.append('ny')
                if not lbl:
                    lbl.append('-')

                Nfft = NN if whole else 2*(NN - 1) if include_nyquist else 2*NN
                ax[0, int(zp)].semilogy(NN , error   , label=' '.join(lbl))
                ax[1, int(zp)].semilogy(NN , speedup , label=' '.join(lbl))
                ax[2, int(zp)].semilogy(Nfft , speedup , label=' '.join(lbl))

title = ['sig.freqz[poly] v/s sig.freqz[(r)fft]',
         '$\\bf{c}$plx in [False, True]',
         '$\\bf{wh}$ole in [False, True]',
         'include_$\\bf{ny}$quist in [False, True]']

for k in range(2):
    ax[0, k].set_title('\n'.join(title+['zero_padding='+str(bool(k))]))
    ax[0, k].set_ylabel('error')
    ax[0, k].set_xlabel('worN')
    ax[0, k].legend()
    ax[1, k].hlines(1, NN[0], NN[-1], color='k', ls=':')
    ax[1, k].set_xlabel('worN')
    ax[1, k].set_ylabel('speedup')
    ax[1, k].legend()
    ax[2, k].hlines(1, NN[0], 2*NN[-1], color='k', ls=':')
    ax[2, k].set_xlabel('n_fft')
    ax[2, k].set_ylabel('speedup')
    ax[2, k].legend()

fig.savefig('freq_fft_benchmark_{}.png'.format(sys.argv[-1]))

plt.show()

import sys, scipy, numpy; print(scipy.__version__, numpy.__version__, sys.version_info)
```

</details>
Thanks for reporting the issue, @happyTonakai.  That's definitely a bug. It works in some cases (specifically the IIR case where `a` is not a scalar, or when `worN` is less than the length of `b`), but in examples such yours or the following, setting `include_nyquist=True` doesn't actually cause the Nyquist frequency to be included in the calculation of `h`, so the values returned in `h` do not correspond to the frequencies returned in `w` (except at `w=0`):

```
In [56]: freqz([1, 2, 3, 2], worN=8)
Out[56]: 
(array([0.        , 0.39269908, 0.78539816, 1.17809725, 1.57079633,
        1.96349541, 2.35619449, 2.74889357]),
 array([ 8.        +0.j        ,  5.73444627-4.73444627j,
         1.        -5.82842712j, -2.20371254-3.20371254j,
        -2.        +0.j        , -0.03892814+1.03892814j,
         1.        +0.17157288j,  0.50819441-0.49180559j]))

In [57]: freqz([1, 2, 3, 2], worN=8, include_nyquist=True)
Out[57]: 
(array([0.        , 0.44879895, 0.8975979 , 1.34639685, 1.7951958 ,
        2.24399475, 2.6927937 , 3.14159265]),
 array([ 8.        +0.j        ,  5.73444627-4.73444627j,
         1.        -5.82842712j, -2.20371254-3.20371254j,
        -2.        +0.j        , -0.03892814+1.03892814j,
         1.        +0.17157288j,  0.50819441-0.49180559j]))
```
The parameter `include_nyquist` was added in https://github.com/scipy/scipy/pull/11368; @AtsushiSakai and @larsoner, can you take a look at this?
Yikes I think we just changed the frequency returned, not the actual values.

To avoid this problem, I think we should do two things:

1. Don't use `np.linspace` to construct the frequencies, use `rfftfreq` instead. It makes sure they're always at least consistent.
2. Fix the actual bug mentioned here by changing the number of FFT points and slicing afterward correctly

@AtsushiSakai are you up for giving it a shot?
I discussed this in #15273, there is a solution proposed in one my posts there.

The frequency grid is wrong, when using rfft. To illustrate, two calls of to freqz give different results:

```
import numpy as np
import scipy.signal as sig
d = sig.unit_impulse(4,3)
d_ = np.pad(d, (0,126-len(d)))
w, D = sig.freqz(d,1,worN=64,include_nyquist=True)
w, Dp = sig.freqz(d,1,worN=w)
D_ = np.fft.rfft(d_)
w_ = np.fft.rfftfreq(len(d_)) * 2 * np.pi
print('error freqz[rfft] v/s freqz[poly] = ', np.sum(np.abs(D-Dp)**2))
print('error freqz[rfft] v/s np.fft.rfft = ', np.sum(np.abs(D-D_)**2))
print('error freqz[poly] v/s np.fft.rfft = ', np.sum(np.abs(D_-Dp)**2))
```

The output:

```
error freqz[rfft] v/s freqz[poly] =  0.4657960974715796
error freqz[rfft] v/s np.fft.rfft =  0.4657960974715789
error freqz[poly] v/s np.fft.rfft =  1.1300975888209927e-29
```
@drestebon would you be up for opening a PR to fix the accuracy / grid problem? Ultimately we should make sure the performance is correct first, i.e., in a first PR with test cases showing errant behavior on `main` and fixes on your PR. Then we can improve performance (assuming it's not already part of the fixes) in a separate PR. WDYT?
@larsoner I will try to squeeze a couple of hours out of next week (or the following) ;)

Commit message: BENCH: removed vectorized input #17289 #15273

Patch to review:
diff --git a/benchmarks/benchmarks/signal_filtering.py b/benchmarks/benchmarks/signal_filtering.py
index b887bc90fd57..8c688b02caaa 100644
--- a/benchmarks/benchmarks/signal_filtering.py
+++ b/benchmarks/benchmarks/signal_filtering.py
@@ -6,7 +6,7 @@
 
 with safe_import():
     from scipy.signal import (lfilter, firwin, decimate, butter, sosfilt,
-                              medfilt2d)
+                              medfilt2d, freqz)
 
 
 class Decimate(Benchmark):
@@ -102,3 +102,19 @@ def time_medfilt2d(self, threads):
 
     def peakmem_medfilt2d(self, threads):
         self._medfilt2d(threads)
+
+
+class FreqzRfft(Benchmark):
+    param_names = ['whole', 'nyquist', 'worN']
+    params = [
+        [False, True],
+        [False, True],
+        [64, 65, 128, 129, 256, 257, 258, 512, 513, 65536, 65537, 65538],
+    ]
+
+    def setup(self, whole, nyquist, worN):
+        self.y = np.zeros(worN)
+        self.y[worN//2] = 1.0
+
+    def time_freqz(self, whole, nyquist, worN):
+        freqz(self.y, whole=whole, include_nyquist=nyquist, worN=worN)
diff --git a/scipy/signal/_filter_design.py b/scipy/signal/_filter_design.py
index 32a45bd5c829..b8d88abbb0e0 100644
--- a/scipy/signal/_filter_design.py
+++ b/scipy/signal/_filter_design.py
@@ -445,12 +445,12 @@ def freqz(b, a=1, worN=512, whole=False, plot=None, fs=2*pi,
         lastpoint = 2 * pi if whole else pi
         # if include_nyquist is true and whole is false, w should
         # include end point
-        w = np.linspace(0, lastpoint, N, endpoint=include_nyquist and not whole)
-        if (a.size == 1 and N >= b.shape[0] and
-                sp_fft.next_fast_len(N) == N and
-                (b.ndim == 1 or (b.shape[-1] == 1))):
-            # if N is fast, 2 * N will be fast, too, so no need to check
-            n_fft = N if whole else N * 2
+        w = np.linspace(0, lastpoint, N,
+                        endpoint=include_nyquist and not whole)
+        n_fft = N if whole else 2 * (N - 1) if include_nyquist else 2 * N
+        if (a.size == 1 and (b.ndim == 1 or (b.shape[-1] == 1))
+                and n_fft >= b.shape[0]
+                and n_fft > 0):  # TODO: review threshold acc. to benchmark?
             if np.isrealobj(b) and np.isrealobj(a):
                 fft_func = sp_fft.rfft
             else:
diff --git a/scipy/signal/tests/test_filter_design.py b/scipy/signal/tests/test_filter_design.py
index 61dfb456c9a1..e33d5d35aa6e 100644
--- a/scipy/signal/tests/test_filter_design.py
+++ b/scipy/signal/tests/test_filter_design.py
@@ -893,6 +893,23 @@ def test_nyquist(self):
         assert_array_almost_equal(w1, w2)
         assert_array_almost_equal(h1, h2)
 
+    # https://github.com/scipy/scipy/issues/17289
+    # https://github.com/scipy/scipy/issues/15273
+    @pytest.mark.parametrize('whole,nyquist,worN',
+                             [(False, False, 32),
+                              (False, True, 32),
+                              (True, False, 32),
+                              (True, True, 32),
+                              (False, False, 257),
+                              (False, True, 257),
+                              (True, False, 257),
+                              (True, True, 257)])
+    def test_17289(self, whole, nyquist, worN):
+        d = [0,1]
+        w, Drfft = freqz(d, worN=32, whole=whole, include_nyquist=nyquist)
+        _, Dpoly = freqz(d, worN=w)
+        assert_allclose(Drfft, Dpoly)
+
 
 class TestSOSFreqz:
 


Code review comments:
diff_hunk: @@ -893,6 +893,23 @@ def test_nyquist(self):
         assert_array_almost_equal(w1, w2)
         assert_array_almost_equal(h1, h2)
 
+    # https://github.com/scipy/scipy/issues/17289
+    # https://github.com/scipy/scipy/issues/15273
+    @pytest.mark.parametrize('whole,nyquist,worN',
+                             [(False, False, 32),
+                              (False, True, 32),
+                              (True, False, 32),
+                              (True, True, 32),
+                              (False, False, 257),
+                              (False, True, 257),
+                              (True, False, 257),
+                              (True, True, 257)])
+    def test_17289(self, whole, nyquist, worN):
+        d = [0,1]
path: scipy/signal/tests/test_filter_design.py
text: @user1:
```suggestion
        d = [0, 1]
```