Repository: scipy/scipy

Issue:
Passing a numpy array as sampling frequency to signal.iirfilter causes unexpected error message
<!-- 
Thank you for taking the time to file a bug report. 
Please fill in the fields below, deleting the sections that 
don't apply to your issue. You can view the final output
by clicking the preview button above.
Note: This is a comment, and won't appear in the output.
-->

My issue is about the interface for filter design. When passing a numpy array instead of a single float as fs to iirfilter (and derived filters), the function crashes with the error message: "Must specify a single critical frequency Wn for lowpass or highpass filter". While clearly the function should crash, the error message seems misleading, as indeed only one critical frequency was passed to the function.

As far as I can tell, this issue arises from the following line, because the division of a single critical frequency by an array of sampling rates will return an array of critical frequencies.
https://github.com/scipy/scipy/blob/12b77ad2e2d8ee766566e19d66845ca02e378ff4/scipy/signal/filter_design.py#L2374

I'd suggest to add an explicit check for the type of fs and raise a specific exception in case of wrong types.

#### Reproducing code example:
<!-- 
If you place your code between the triple backticks below, 
it will be rendered as a code block. 
-->

```
scipy.signal.butter(1, 1, "highpass", fs=np.array([10, 20]))
```

#### Error message:
<!-- If any, paste the *full* error message inside a code block
as above (starting from line Traceback)
-->

```
Traceback (most recent call last)
<ipython-input-5-0f771327b56e> in <module>
----> 1 scipy.signal.butter(1, 1, "highpass", fs=np.array([10, 20]))

~/python3.8/site-packages/scipy/signal/filter_design.py in butter(N, Wn, btype, analog, output, fs)
   2953     >>> plt.show()
   2954     """
-> 2955     return iirfilter(N, Wn, btype=btype, analog=analog,
   2956                      output=output, ftype='butter', fs=fs)
   2957 

~python3.8/site-packages/scipy/signal/filter_design.py in iirfilter(N, Wn, rp, rs, btype, analog, ftype, output, fs)
   2431     if btype in ('lowpass', 'highpass'):
   2432         if numpy.size(Wn) != 1:
-> 2433             raise ValueError('Must specify a single critical frequency Wn for lowpass or highpass filter')
   2434 
   2435         if btype == 'lowpass':

ValueError: Must specify a single critical frequency Wn for lowpass or highpass filter

```

#### Scipy/Numpy/Python version information:
<!-- You can simply run the following and paste the result in a code block
```
import sys, scipy, numpy; print(scipy.__version__, numpy.__version__, sys.version_info)
```
-->
1.6.2 1.20.2 sys.version_info(major=3, minor=8, micro=8, releaselevel='final', serial=0)


Discussion:
Hi @yetinam, thanks for finding this 😃  You are right, the input validation is not correct here. Would you like to propose a PR to fix it?
Hi @tupui , thanks for the quick reply. I'll propose a PR to fix it.

Resolving patch:
diff --git a/scipy/signal/_arraytools.py b/scipy/signal/_arraytools.py
--- a/scipy/signal/_arraytools.py
+++ b/scipy/signal/_arraytools.py
@@ -245,3 +245,20 @@ def zero_ext(x, n, axis=-1):
     zeros = np.zeros(zeros_shape, dtype=x.dtype)
     ext = np.concatenate((zeros, x, zeros), axis=axis)
     return ext
+
+
+def _validate_fs(fs, allow_none=True):
+    """
+    Check if the given sampling frequency is a scalar and raises an exception
+    otherwise. If allow_none is False, also raises an exception for none
+    sampling rates. Returns the sampling frequency as float or none if the
+    input is none.
+    """
+    if fs is None:
+        if not allow_none:
+            raise ValueError("Sampling frequency can not be none.")
+    else:  # should be float
+        if not np.isscalar(fs):
+            raise ValueError("Sampling frequency fs must be a single scalar.")
+        fs = float(fs)
+    return fs
diff --git a/scipy/signal/_filter_design.py b/scipy/signal/_filter_design.py
--- a/scipy/signal/_filter_design.py
+++ b/scipy/signal/_filter_design.py
@@ -16,6 +16,7 @@
 from scipy import special, optimize, fft as sp_fft
 from scipy.special import comb
 from scipy._lib._util import float_factorial
+from scipy.signal._arraytools import _validate_fs
 
 
 __all__ = ['findfreqs', 'freqs', 'freqz', 'tf2zpk', 'zpk2tf', 'normalize',
@@ -431,6 +432,8 @@ def freqz(b, a=1, worN=512, whole=False, plot=None, fs=2*pi,
     b = atleast_1d(b)
     a = atleast_1d(a)
 
+    fs = _validate_fs(fs, allow_none=False)
+
     if worN is None:
         # For backwards compatibility
         worN = 512
@@ -571,6 +574,8 @@ def freqz_zpk(z, p, k, worN=512, whole=False, fs=2*pi):
     """
     z, p = map(atleast_1d, (z, p))
 
+    fs = _validate_fs(fs, allow_none=False)
+
     if whole:
         lastpoint = 2 * pi
     else:
@@ -673,6 +678,8 @@ def group_delay(system, w=512, whole=False, fs=2*pi):
         # For backwards compatibility
         w = 512
 
+    fs = _validate_fs(fs, allow_none=False)
+
     if _is_int_type(w):
         if whole:
             w = np.linspace(0, 2 * pi, w, endpoint=False)
@@ -838,6 +845,7 @@ def sosfreqz(sos, worN=512, whole=False, fs=2*pi):
     >>> plt.show()
 
     """
+    fs = _validate_fs(fs, allow_none=False)
 
     sos, n_sections = _validate_sos(sos)
     if n_sections == 0:
@@ -2200,7 +2208,7 @@ def bilinear(b, a, fs=1.0):
     >>> plt.ylabel('Magnitude [dB]')
     >>> plt.grid(True)
     """
-    fs = float(fs)
+    fs = _validate_fs(fs, allow_none=False)
     a, b = map(atleast_1d, (a, b))
     D = len(a) - 1
     N = len(b) - 1
@@ -2380,6 +2388,8 @@ def iirdesign(wp, ws, gpass, gstop, analog=False, ftype='ellip', output='ba',
     wp = atleast_1d(wp)
     ws = atleast_1d(ws)
 
+    fs = _validate_fs(fs, allow_none=True)
+
     if wp.shape[0] != ws.shape[0] or wp.shape not in [(1,), (2,)]:
         raise ValueError("wp and ws must have one or two elements each, and"
                          f"the same shape, got {wp.shape} and {ws.shape}")
@@ -2548,6 +2558,7 @@ def iirfilter(N, Wn, rp=None, rs=None, btype='band', analog=False,
     >>> plt.show()
 
     """
+    fs = _validate_fs(fs, allow_none=True)
     ftype, btype, output = (x.lower() for x in (ftype, btype, output))
     Wn = asarray(Wn)
     if fs is not None:
@@ -2731,6 +2742,8 @@ def bilinear_zpk(z, p, k, fs):
     z = atleast_1d(z)
     p = atleast_1d(p)
 
+    fs = _validate_fs(fs, allow_none=False)
+
     degree = _relative_degree(z, p)
 
     fs2 = 2.0*fs
@@ -3948,6 +3961,7 @@ def buttord(wp, ws, gpass, gstop, analog=False, fs=None):
 
     """
     _validate_gpass_gstop(gpass, gstop)
+    fs = _validate_fs(fs, allow_none=True)
     wp, ws, filter_type = _validate_wp_ws(wp, ws, fs, analog)
     passb, stopb = _pre_warp(wp, ws, analog)
     nat, passb = _find_nat_freq(stopb, passb, gpass, gstop, filter_type, 'butter')
@@ -4069,6 +4083,7 @@ def cheb1ord(wp, ws, gpass, gstop, analog=False, fs=None):
     >>> plt.show()
 
     """
+    fs = _validate_fs(fs, allow_none=True)
     _validate_gpass_gstop(gpass, gstop)
     wp, ws, filter_type = _validate_wp_ws(wp, ws, fs, analog)
     passb, stopb = _pre_warp(wp, ws, analog)
@@ -4163,6 +4178,7 @@ def cheb2ord(wp, ws, gpass, gstop, analog=False, fs=None):
     >>> plt.show()
 
     """
+    fs = _validate_fs(fs, allow_none=True)
     _validate_gpass_gstop(gpass, gstop)
     wp, ws, filter_type = _validate_wp_ws(wp, ws, fs, analog)
     passb, stopb = _pre_warp(wp, ws, analog)
@@ -4285,7 +4301,7 @@ def ellipord(wp, ws, gpass, gstop, analog=False, fs=None):
     >>> plt.show()
 
     """
-
+    fs = _validate_fs(fs, allow_none=True)
     _validate_gpass_gstop(gpass, gstop)
     wp, ws, filter_type = _validate_wp_ws(wp, ws, fs, analog)
     passb, stopb = _pre_warp(wp, ws, analog)
@@ -5112,6 +5128,7 @@ def _design_notch_peak_filter(w0, Q, ftype, fs=2.0):
         Numerator (``b``) and denominator (``a``) polynomials
         of the IIR filter.
     """
+    fs = _validate_fs(fs, allow_none=False)
 
     # Guarantee that the inputs are floats
     w0 = float(w0)
@@ -5295,7 +5312,7 @@ def iircomb(w0, Q, ftype='notch', fs=2.0, *, pass_zero=False):
     # Convert w0, Q, and fs to float
     w0 = float(w0)
     Q = float(Q)
-    fs = float(fs)
+    fs = _validate_fs(fs, allow_none=False)
 
     # Check for invalid cutoff frequency or filter type
     ftype = ftype.lower()
@@ -5462,7 +5479,7 @@ def gammatone(freq, ftype, order=None, numtaps=None, fs=None):
     # Set sampling rate if not passed
     if fs is None:
         fs = 2
-    fs = float(fs)
+    fs = _validate_fs(fs, allow_none=False)
 
     # Check for invalid cutoff frequency or filter type
     ftype = ftype.lower()
diff --git a/scipy/signal/_fir_filter_design.py b/scipy/signal/_fir_filter_design.py
--- a/scipy/signal/_fir_filter_design.py
+++ b/scipy/signal/_fir_filter_design.py
@@ -10,6 +10,7 @@
 from scipy.linalg import (toeplitz, hankel, solve, LinAlgError, LinAlgWarning,
                           lstsq)
 from scipy._lib.deprecation import _NoValue, _deprecate_positional_args
+from scipy.signal._arraytools import _validate_fs
 
 from . import _sigtools
 
@@ -394,6 +395,7 @@ def firwin(numtaps, cutoff, *, width=None, window='hamming', pass_zero=True,
     """
     # The major enhancements to this function added in November 2010 were
     # developed by Tom Krauss (see ticket #902).
+    fs = _validate_fs(fs, allow_none=True)
 
     nyq = 0.5 * _get_fs(fs, nyq)
 
@@ -599,6 +601,7 @@ def firwin2(numtaps, freq, gain, *, nfreqs=None, window='hamming', nyq=_NoValue,
     [-0.02286961 -0.06362756  0.57310236  0.57310236 -0.06362756 -0.02286961]
 
     """
+    fs = _validate_fs(fs, allow_none=True)
     nyq = 0.5 * _get_fs(fs, nyq)
 
     if len(freq) != len(gain):
@@ -852,6 +855,7 @@ def remez(numtaps, bands, desired, *, weight=None, Hz=_NoValue, type='bandpass',
     >>> plt.show()
 
     """
+    fs = _validate_fs(fs, allow_none=True)
     if Hz is _NoValue and fs is None:
         fs = 1.0
     elif Hz is not _NoValue:
@@ -996,6 +1000,7 @@ def firls(numtaps, bands, desired, *, weight=None, nyq=_NoValue, fs=None):
     >>> plt.show()
 
     """
+    fs = _validate_fs(fs, allow_none=True)
     nyq = 0.5 * _get_fs(fs, nyq)
 
     numtaps = int(numtaps)


Test patch:
diff --git a/scipy/signal/tests/test_filter_design.py b/scipy/signal/tests/test_filter_design.py
--- a/scipy/signal/tests/test_filter_design.py
+++ b/scipy/signal/tests/test_filter_design.py
@@ -912,6 +912,13 @@ def test_17289(self, whole, nyquist, worN):
         _, Dpoly = freqz(d, worN=w)
         assert_allclose(Drfft, Dpoly)
 
+    def test_fs_validation(self):
+        with pytest.raises(ValueError, match="Sampling.*single scalar"):
+            freqz([1.0], fs=np.array([10, 20]))
+
+        with pytest.raises(ValueError, match="Sampling.*be none."):
+            freqz([1.0], fs=None)
+
 
 class TestSOSFreqz:
 
@@ -1105,6 +1112,11 @@ def test_w_or_N_types(self):
             assert_array_almost_equal(w_out, [8])
             assert_array_almost_equal(h, [1])
 
+    def test_fs_validation(self):
+        sos = butter(4, 0.2, output='sos')
+        with pytest.raises(ValueError, match="Sampling.*single scalar"):
+            sosfreqz(sos, fs=np.array([10, 20]))
+
 
 class TestFreqz_zpk:
 
@@ -1202,6 +1214,13 @@ def test_w_or_N_types(self):
             assert_array_almost_equal(w_out, [8])
             assert_array_almost_equal(h, [1])
 
+    def test_fs_validation(self):
+        with pytest.raises(ValueError, match="Sampling.*single scalar"):
+            freqz_zpk([1.0], [1.0], [1.0], fs=np.array([10, 20]))
+
+        with pytest.raises(ValueError, match="Sampling.*be none."):
+            freqz_zpk([1.0], [1.0], [1.0], fs=None)
+
 
 class TestNormalize:
 
@@ -1321,6 +1340,15 @@ def test_basic(self):
         assert_array_almost_equal(a_z, [1, -1.2158, 0.72826],
                                   decimal=4)
 
+    def test_fs_validation(self):
+        b = [0.14879732743343033]
+        a = [1, 0.54552236880522209, 0.14879732743343033]
+        with pytest.raises(ValueError, match="Sampling.*single scalar"):
+            bilinear(b, a, fs=np.array([10, 20]))
+
+        with pytest.raises(ValueError, match="Sampling.*be none"):
+            bilinear(b, a, fs=None)
+
 
 class TestLp2lp_zpk:
 
@@ -1342,6 +1370,17 @@ def test_basic(self):
         assert_allclose(sort(p_lp), sort([-15, -10-10j, -10+10j]))
         assert_allclose(k_lp, 60)
 
+    def test_fs_validation(self):
+        z = [-2j, +2j]
+        p = [-0.75, -0.5 - 0.5j, -0.5 + 0.5j]
+        k = 3
+
+        with pytest.raises(ValueError, match="Sampling.*single scalar"):
+            bilinear_zpk(z, p, k, fs=np.array([10, 20]))
+
+        with pytest.raises(ValueError, match="Sampling.*be none"):
+            bilinear_zpk(z, p, k, fs=None)
+
 
 class TestLp2hp_zpk:
 
@@ -1574,6 +1613,15 @@ def test_ellip_butter(self):
         n, wn = buttord([0.1, 0.6], [0.2, 0.5], 3, 60)
         assert n == 14
 
+    def test_fs_validation(self):
+        wp = 0.2
+        ws = 0.3
+        rp = 3
+        rs = 60
+
+        with pytest.raises(ValueError, match="Sampling.*single scalar"):
+            buttord(wp, ws, rp, rs, False, fs=np.array([10, 20]))
+
 
 class TestCheb1ord:
 
@@ -1695,6 +1743,15 @@ def test_ellip_cheb1(self):
         n2, w2 = cheb2ord([0.1, 0.6], [0.2, 0.5], 3, 60)
         assert not (wn == w2).all()
 
+    def test_fs_validation(self):
+        wp = 0.2
+        ws = 0.3
+        rp = 3
+        rs = 60
+
+        with pytest.raises(ValueError, match="Sampling.*single scalar"):
+            cheb1ord(wp, ws, rp, rs, False, fs=np.array([10, 20]))
+
 
 class TestCheb2ord:
 
@@ -1819,6 +1876,15 @@ def test_ellip_cheb2(self):
         n1, w1 = cheb1ord([0.1, 0.6], [0.2, 0.5], 3, 60)
         assert not (wn == w1).all()
 
+    def test_fs_validation(self):
+        wp = 0.2
+        ws = 0.3
+        rp = 3
+        rs = 60
+
+        with pytest.raises(ValueError, match="Sampling.*single scalar"):
+            cheb2ord(wp, ws, rp, rs, False, fs=np.array([10, 20]))
+
 
 class TestEllipord:
 
@@ -1954,6 +2020,15 @@ def test_ellip_butter(self):
         n, wn = ellipord([0.1, 0.6], [0.2, 0.5], 3, 60)
         assert n == 5
 
+    def test_fs_validation(self):
+        wp = 0.2
+        ws = 0.3
+        rp = 3
+        rs = 60
+
+        with pytest.raises(ValueError, match="Sampling.*single scalar"):
+            ellipord(wp, ws, rp, rs, False, fs=np.array([10, 20]))
+
 
 class TestBessel:
 
@@ -3538,6 +3613,13 @@ def test_fs_param(self):
                             ba2 = ellip(N, 1, 20, fcnorm, btype)
                             assert_allclose(ba1, ba2)
 
+    def test_fs_validation(self):
+        with pytest.raises(ValueError, match="Sampling.*single scalar"):
+            iirnotch(0.06, 30, fs=np.array([10, 20]))
+
+        with pytest.raises(ValueError, match="Sampling.*be none"):
+            iirnotch(0.06, 30, fs=None)
+
 
 def test_sos_consistency():
     # Consistency checks of output='sos' for the specialized IIR filter
@@ -3856,6 +3938,13 @@ def test_nearest_divisor(self):
         # Now N = 882 correctly and 22 kHz should be a notch <-220 dB
         assert abs(response[0]) < 1e-10
 
+    def test_fs_validation(self):
+        with pytest.raises(ValueError, match="Sampling.*single scalar"):
+            iircomb(1000, 30, fs=np.array([10, 20]))
+
+        with pytest.raises(ValueError, match="Sampling.*be none"):
+            iircomb(1000, 30, fs=None)
+
 
 class TestIIRDesign:
 
@@ -3973,6 +4062,10 @@ def test_exceptions(self):
         with pytest.raises(ValueError, match="strictly inside stopband"):
             iirdesign([0.4, 0.7], [0.3, 0.6], 1, 40)
 
+    def test_fs_validation(self):
+        with pytest.raises(ValueError, match="Sampling.*single scalar"):
+            iirfilter(1, 1, btype="low", fs=np.array([10, 20]))
+
 
 class TestIIRFilter:
 
@@ -4125,6 +4218,13 @@ def test_w_or_N_types(self):
             assert_array_almost_equal(w_out, [8])
             assert_array_almost_equal(gd, [0])
 
+    def test_fs_validation(self):
+        with pytest.raises(ValueError, match="Sampling.*single scalar"):
+            group_delay((1, 1), fs=np.array([10, 20]))
+
+        with pytest.raises(ValueError, match="Sampling.*be none"):
+            group_delay((1, 1), fs=None)
+
 
 class TestGammatone:
     # Test erroneous input cases.
@@ -4210,6 +4310,10 @@ def test_iir_ba_output(self):
         assert_allclose(b, b2)
         assert_allclose(a, a2)
 
+    def test_fs_validation(self):
+        with pytest.raises(ValueError, match="Sampling.*single scalar"):
+            gammatone(440, 'iir', fs=np.array([10, 20]))
+
 
 class TestOrderFilter:
     def test_doc_example(self):
diff --git a/scipy/signal/tests/test_fir_filter_design.py b/scipy/signal/tests/test_fir_filter_design.py
--- a/scipy/signal/tests/test_fir_filter_design.py
+++ b/scipy/signal/tests/test_fir_filter_design.py
@@ -122,6 +122,10 @@ def test_scaling(self):
                 'least squares violation')
             self.check_response(hs, [expected_response], 1e-12)
 
+    def test_fs_validation(self):
+        with pytest.raises(ValueError, match="Sampling.*single scalar"):
+            firwin(51, .5, fs=np.array([10, 20]))
+
 
 class TestFirWinMore:
     """Different author, different style, different tests..."""
@@ -284,6 +288,11 @@ def test_firwin_deprecations(self):
         with pytest.deprecated_call(match="use keyword arguments"):
             firwin(58, 0.1, 0.03)
 
+    def test_fs_validation(self):
+        with pytest.raises(ValueError, match="Sampling.*single scalar"):
+            firwin2(51, .5, 1, fs=np.array([10, 20]))
+
+
 class TestFirwin2:
 
     def test_invalid_args(self):
@@ -508,6 +517,10 @@ def test_remez_deprecations(self):
             # from test_hilbert
             remez(11, [0.1, 0.4], [1], None)
 
+    def test_fs_validation(self):
+        with pytest.raises(ValueError, match="Sampling.*single scalar"):
+            remez(11, .1, 1, fs=np.array([10, 20]))
+
 class TestFirls:
 
     def test_bad_args(self):
@@ -627,6 +640,9 @@ def test_firls_deprecations(self):
             # from test_firls
             firls(11, [0, 0.1, 0.4, 0.5], [1, 1, 0, 0], None)
 
+    def test_fs_validation(self):
+        with pytest.raises(ValueError, match="Sampling.*single scalar"):
+            firls(11, .1, 1, fs=np.array([10, 20]))
 
 class TestMinimumPhase: