Repository: pyfar/pyfar
Pull request #676 (2024-08-29 11:05:39)

Harmonize use of squeeze in pyfar
Collection of approved pull requests


Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index ad649f1e..3da4d538 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -94,24 +94,6 @@ jobs:
             cd docs/
             make html SPHINXOPTS="-W"
 
-  test_deprecation_warnings:
-    docker:
-      - image: cimg/python:3.12
-    steps:
-      - checkout
-      - run:
-          name: Install System Dependencies
-          command: sudo apt-get update && sudo apt-get install -y libsndfile1
-      - python/install-packages:
-          pkg-manager: pip
-          # app-dir: ~/project/package-directory/  # If you're requirements.txt isn't in the root directory.
-          pip-dependency-file: requirements_dev.txt
-      - run: |
-          python --version
-      - run:
-          name: Run tests
-          command: pytest tests -W error::DeprecationWarning
-
   test_pypi_publish:
     parameters:
       version:
@@ -156,7 +138,7 @@ workflows:
           matrix:
             parameters:
               version:
-                - "3.12"
+                - "3.9"
           requires:
             - build_and_test
 
@@ -164,11 +146,7 @@ workflows:
           matrix:
             parameters:
               version:
-                - "3.12"
-          requires:
-            - build_and_test
-
-      - test_deprecation_warnings:
+                - "3.9"
           requires:
             - build_and_test
 
@@ -197,7 +175,7 @@ workflows:
           matrix:
             parameters:
               version:
-                - "3.12"
+                - "3.9"
           requires:
             - build_and_test
           filters:
@@ -211,17 +189,7 @@ workflows:
           matrix:
             parameters:
               version:
-                - "3.12"
-          requires:
-            - build_and_test
-          filters:
-            branches:
-              ignore: /.*/
-            # only act on version tags
-            tags:
-              only: /^v[0-9]+(\.[0-9]+)*$/
-
-      - test_deprecation_warnings:
+                - "3.9"
           requires:
             - build_and_test
           filters:
@@ -235,7 +203,7 @@ workflows:
           matrix:
             parameters:
               version:
-                - "3.12"
+                - "3.9"
           requires:
             - build_and_test
             - flake
diff --git a/pyfar/classes/coordinates.py b/pyfar/classes/coordinates.py
index 4c2f6069..cd016a45 100644
--- a/pyfar/classes/coordinates.py
+++ b/pyfar/classes/coordinates.py
@@ -2765,10 +2765,10 @@ class Coordinates():
 
         return new
 
-    def __array__(self, copy=True, dtype=None):
+    def __array__(self):
         """Instances of Coordinates behave like `numpy.ndarray`, array_like."""
         # copy to avoid changing the coordinate system of the original object
-        return np.array(self.cartesian, copy=copy, dtype=dtype)
+        return self.copy().cartesian
 
     def __repr__(self):
         """Get info about Coordinates object."""
diff --git a/pyfar/classes/filter.py b/pyfar/classes/filter.py
index 6a3b709f..4583567d 100644
--- a/pyfar/classes/filter.py
+++ b/pyfar/classes/filter.py
@@ -247,7 +247,12 @@ class Filter(object):
 
         # prepare output signal
         filtered_signal = deepcopy(signal)
-        filtered_signal.time = np.squeeze(filtered_signal_data)
+
+        # squeeze first dimension if there is only one filter channel
+        if self.n_channels == 1:
+            filtered_signal.time = np.squeeze(filtered_signal_data, axis=0)
+        else:
+            filtered_signal.time = filtered_signal_data
 
         return filtered_signal
 
diff --git a/pyfar/dsp/dsp.py b/pyfar/dsp/dsp.py
index ba0d2357..2b3d51b0 100644
--- a/pyfar/dsp/dsp.py
+++ b/pyfar/dsp/dsp.py
@@ -67,8 +67,9 @@ def group_delay(signal, frequencies=None, method='fft'):
     Returns
     -------
     group_delay : numpy array
-        Frequency dependent group delay in samples. The array is flattened if
-        a single channel signal was passed to the function.
+        Frequency dependent group delay of shape
+        (:py:func:`~pyfar.classes.audio.Signal.cshape`,
+        frequencies).
 
     References
     ----------
@@ -116,10 +117,6 @@ def group_delay(signal, frequencies=None, method='fft'):
         raise ValueError(
             "Invalid method, needs to be either 'scipy' or 'fft'.")
 
-    # flatten in numpy fashion if a single channel is returned
-    if signal.cshape == (1, ):
-        group_delay = np.squeeze(group_delay)
-
     return group_delay
 
 
@@ -299,7 +296,7 @@ def spectrogram(signal, window='hann', window_length=1024,
     window = sgn.get_window(window, window_length)
 
     frequencies, times, spectrogram = sgn.spectrogram(
-        x=signal.time.squeeze(), fs=signal.sampling_rate, window=window,
+        x=signal.time, fs=signal.sampling_rate, window=window,
         noverlap=window_overlap, mode='magnitude', scaling='spectrum')
 
     # remove normalization from scipy.signal.spectrogram
@@ -1430,7 +1427,7 @@ def find_impulse_response_start(
                     f'No values below threshold found found for channel {ch}',
                     'defaulting to 0')
 
-    return np.squeeze(start_sample)
+    return start_sample
 
 
 @rename_arg({"freq_range": "frequency_range"},
diff --git a/pyfar/dsp/filter/gammatone.py b/pyfar/dsp/filter/gammatone.py
index f9d20e34..1986b827 100644
--- a/pyfar/dsp/filter/gammatone.py
+++ b/pyfar/dsp/filter/gammatone.py
@@ -309,7 +309,7 @@ class GammatoneBands():
             delay_samples + 3, sampling_rate=self.sampling_rate))
 
         # compute the envelope
-        ir = real.time + 1j * imag.time
+        ir = real.time[:, 0, :] + 1j * imag.time[:, 0, :]
         env = np.abs(ir)
 
         # sample at which the maximum occurs
@@ -444,10 +444,6 @@ class GammatoneBands():
         time_out = np.reshape(time_out,
                               (self.n_bands, ) + signal.cshape + (-1, ))
 
-        # squeeze dimension of single channel signal
-        if signal.cshape == (1, ):
-            time_out = np.squeeze(time_out)
-
         # return real and immaginary part of output as pyfar Signal objects
         real = signal.copy()
         real.time = np.real(time_out)
diff --git a/pyfar/plot/_two_d.py b/pyfar/plot/_two_d.py
index 8a46f0d9..7e15ff8d 100644
--- a/pyfar/plot/_two_d.py
+++ b/pyfar/plot/_two_d.py
@@ -337,6 +337,11 @@ def _spectrogram(signal, dB=True, log_prefix=None, log_reference=1,
     frequencies, times, spectrogram = dsp.spectrogram(
         signal[first_channel], window, window_length, window_overlap_fct)
 
+    # squeeze dsp.spectrogram returns for correct plotting
+    times = times.squeeze()
+    frequencies = frequencies.squeeze()
+    spectrogram = spectrogram.squeeze()
+
     # get magnitude data in dB
     if dB:
         if log_prefix is None:


Test patch:
diff --git a/tests/test_dsp.py b/tests/test_dsp.py
index 0788f5f0..78a2f483 100644
--- a/tests/test_dsp.py
+++ b/tests/test_dsp.py
@@ -49,20 +49,20 @@ def test_group_delay_single_channel(impulse_group_delay):
         dsp.group_delay(signal, method='fft', frequencies=[1, 2, 3])
 
     grp = dsp.group_delay(signal, method='scipy')
-    assert grp.shape == (signal.n_bins, )
+    assert grp.shape == (1, signal.n_bins)
     npt.assert_allclose(
-        grp, impulse_group_delay[1].flatten(), rtol=1e-10, atol=1e-10)
+        grp[0, :], impulse_group_delay[1].flatten(), rtol=1e-10, atol=1e-10)
 
     grp = dsp.group_delay(signal, method='fft')
-    assert grp.shape == (signal.n_bins, )
+    assert grp.shape == (1, signal.n_bins)
     npt.assert_allclose(
-        grp, impulse_group_delay[1].flatten(), rtol=1e-10, atol=1e-10)
+        grp[0, :], impulse_group_delay[1].flatten(), rtol=1e-10, atol=1e-10)
 
     grp = dsp.group_delay(
         signal, method='fft')
-    assert grp.shape == (signal.n_bins, )
+    assert grp.shape == (1, signal.n_bins)
     npt.assert_allclose(
-        grp, impulse_group_delay[1].flatten(), rtol=1e-10, atol=1e-10)
+        grp[0, :], impulse_group_delay[1].flatten(), rtol=1e-10, atol=1e-10)
 
 
 def test_group_delay_two_channel(impulse_group_delay_two_channel):
@@ -97,7 +97,7 @@ def test_group_delay_custom_frequencies(impulse_group_delay):
     frequency = 1000
     frequency_idx = np.abs(signal.frequencies-frequency).argmin()
     grp = dsp.group_delay(signal, frequency, method='scipy')
-    assert grp.shape == ()
+    assert grp.shape == (1, 1)
     npt.assert_allclose(
         grp, impulse_group_delay[1][0, frequency_idx], atol=1e-10)
 
@@ -106,9 +106,20 @@ def test_group_delay_custom_frequencies(impulse_group_delay):
     frequency_idx = np.abs(
         signal.frequencies-frequency[..., np.newaxis]).argmin(axis=-1)
     grp = dsp.group_delay(signal, frequency, method='scipy')
-    assert grp.shape == (2,)
+    assert grp.shape == (1, 2)
     npt.assert_allclose(
-        grp, impulse_group_delay[1][0, frequency_idx], atol=1e-10)
+        grp[0, :], impulse_group_delay[1][0, frequency_idx], atol=1e-10)
+
+
+@pytest.mark.parametrize("shape", [(4, 1), (1, 4), (1, ), (1, 1)])
+def test_group_delay_cshape(shape):
+    """Test if group delay function keeps cshape of signals of shape `m x n`
+     with `m = 1` or `n = 1`."""
+    impulse = pf.signals.impulse(256, 0, np.ones(shape))
+
+    grp = dsp.group_delay(impulse)
+
+    assert grp.shape == (*impulse.cshape, impulse.n_bins)
 
 
 def test_linear_phase():
@@ -121,13 +132,13 @@ def test_linear_phase():
     y = dsp.linear_phase(x, N/2)
     # test output
     assert isinstance(y, pf.Signal)
-    npt.assert_allclose(dsp.group_delay(y), N / 2 * np.ones(y.n_bins))
+    npt.assert_allclose(dsp.group_delay(y)[0, :], N / 2 * np.ones(y.n_bins))
     # test if input did not change
     npt.assert_allclose(x.time, pf.signals.impulse(N).time)
 
     # test group delay in seconds
     y = dsp.linear_phase(x, N / 2 / fs, unit="s")
-    npt.assert_allclose(dsp.group_delay(y), N / 2 * np.ones(y.n_bins))
+    npt.assert_allclose(dsp.group_delay(y)[0, :], N / 2 * np.ones(y.n_bins))
 
     # test assertion
     with pytest.raises(TypeError, match="signal must be a pyfar Signal"):
@@ -144,13 +155,13 @@ def test_linear_phase_multichannel():
 
     # test with scalar group delay
     y = dsp.linear_phase(x, N/2)
-    npt.assert_allclose(dsp.group_delay(y[0]), N / 2 * np.ones(y.n_bins))
-    npt.assert_allclose(dsp.group_delay(y[1]), N / 2 * np.ones(y.n_bins))
+    npt.assert_allclose(dsp.group_delay(y[0])[0, :], N / 2 * np.ones(y.n_bins))
+    npt.assert_allclose(dsp.group_delay(y[1])[0, :], N / 2 * np.ones(y.n_bins))
 
     # test with array like group delay
     y = dsp.linear_phase(x, [N/2, N/4])
-    npt.assert_allclose(dsp.group_delay(y[0]), N / 2 * np.ones(y.n_bins))
-    npt.assert_allclose(dsp.group_delay(y[1]), N / 4 * np.ones(y.n_bins))
+    npt.assert_allclose(dsp.group_delay(y[0])[0, :], N / 2 * np.ones(y.n_bins))
+    npt.assert_allclose(dsp.group_delay(y[1])[0, :], N / 4 * np.ones(y.n_bins))
 
 
 def test_zero_phase():
@@ -654,6 +665,16 @@ def test_impulse_response_delay_multidim():
     npt.assert_allclose(start_sample_est, start_sample, atol=1e-2)
 
 
+@pytest.mark.parametrize("shape", [(4, 1), (1, 4), (1, ), (1, 1)])
+def test_impulse_response_delay_cshape(shape):
+    """Test if find_impulse_response_delay function keeps cshape of signals of
+    shape `m x n` with `m = 1` or `n = 1`."""
+    ir = pf.signals.impulse(256, 24, np.ones(shape))
+    start_sample = dsp.find_impulse_response_delay(ir)
+
+    assert start_sample.shape == ir.cshape
+
+
 def test_impulse_response_start_insufficient_snr():
     n_samples = 2**9
     snr = 15
@@ -756,6 +777,16 @@ def test_impulse_response_start_multidim():
     npt.assert_allclose(start_sample_est, start_samples - 1)
 
 
+@pytest.mark.parametrize("shape", [(4, 1), (1, 4), (1, ), (1, 1)])
+def test_impulse_response_start_cshape(shape):
+    """Test if find_impulse_response_start function keeps cshape of signals of
+    shape `m x n` with `m = 1` or `n = 1`."""
+    ir = pf.signals.impulse(256, 24, np.ones(shape))
+    start_sample = dsp.find_impulse_response_start(ir)
+
+    assert start_sample.shape == ir.cshape
+
+
 def test_convolve_default():
     x = pf.Signal([1, 0.5, 0.25, 0], 44100)
     y = pf.Signal([1, -1, 0], 44100)
diff --git a/tests/test_dsp_interpolation.py b/tests/test_dsp_interpolation.py
index d607c78b..81a2a1c4 100644
--- a/tests/test_dsp_interpolation.py
+++ b/tests/test_dsp_interpolation.py
@@ -203,7 +203,7 @@ def test_fractional_delay_mode_cyclic(delay):
     delayed = fractional_time_shift(signal, delay, mode="cyclic")
 
     # if the delay is too large, it is cyclicly shifted
-    group_delay = pf.dsp.group_delay(delayed)[0]
+    group_delay = pf.dsp.group_delay(delayed)[0, 0]
     npt.assert_allclose(group_delay, (16+delay) % 32, atol=.05)
 
 
diff --git a/tests/test_dsp_spectrogram.py b/tests/test_dsp_spectrogram.py
index 24479f49..db2266af 100644
--- a/tests/test_dsp_spectrogram.py
+++ b/tests/test_dsp_spectrogram.py
@@ -31,9 +31,9 @@ def test_return_values():
     npt.assert_allclose(times, [0, 512/1024, 1])
 
     # check middle slice
-    npt.assert_allclose(spectro[:256, 1], 0, atol=1e-13)
-    npt.assert_allclose(spectro[256, 1], 1, atol=1e-13)
-    npt.assert_allclose(spectro[257:, 1], 0, atol=1e-13)
+    npt.assert_allclose(spectro[0, :256, 1], 0, atol=1e-13)
+    npt.assert_allclose(spectro[0, 256, 1], 1, atol=1e-13)
+    npt.assert_allclose(spectro[0, 257:, 1], 0, atol=1e-13)
 
 
 @pytest.mark.parametrize('window,value', [
@@ -47,7 +47,7 @@ def test_window(window, value):
     _, _, spectro = pf.dsp.spectrogram(signal, window=window)
 
     # check middle slice
-    npt.assert_allclose(spectro[255:258, 1], value, atol=1e-13)
+    npt.assert_allclose(spectro[0, 255:258, 1], value, atol=1e-13)
 
 
 def test_normalize(sine):
@@ -55,3 +55,15 @@ def test_normalize(sine):
     sine.fft_norm = 'amplitude'
     assert pf.dsp.spectrogram(sine)[-1].max() < 1
     assert pf.dsp.spectrogram(sine, normalize=False)[-1].max() > 1
+
+
+@pytest.mark.parametrize('shape', [(2, 1), (1, 2), (1,), (1, 1)])
+def test_spectrogram_shape(shape):
+    """Test cshape of spectrogram returns"""
+    impulse = pf.signals.impulse(2048, 0, np.ones((shape)))
+    freq, time, spectro = pf.dsp.spectrogram(impulse)
+
+    assert spectro.shape == (*shape, *freq.shape, *time.shape)
+    assert time.ndim == 1
+    assert freq.ndim == 1
+
diff --git a/tests/test_filter.py b/tests/test_filter.py
index 8d8c66fc..2c3441ee 100644
--- a/tests/test_filter.py
+++ b/tests/test_filter.py
@@ -223,7 +223,7 @@ def test_crossover(impulse):
     for order in [2, 4, 6, 8]:
         for frequency in [4000, (1e2, 10e3)]:
             x = pfilt.crossover(impulse, order, frequency)
-            x_sum = np.sum(x.freq, axis=-2).flatten()
+            x_sum = np.sum(x.freq, axis=-3).flatten()
             x_ref = np.ones(x.n_bins)
             npt.assert_allclose(x_ref, np.abs(x_sum), atol=.0005)
 
@@ -254,7 +254,7 @@ def test_reconstructing_fractional_octave_bands():
     x = pf.signals.impulse(2**12)
     y, f = pfilt.reconstructing_fractional_octave_bands(x)
     assert isinstance(y, Signal)
-    assert y.cshape == (9, )
+    assert y.cshape == (9, 1)
     assert y.fft_norm == 'none'
 
     # test reconstruction (sum has a group delay of half the filter length)
@@ -279,7 +279,7 @@ def test_reconstructing_fractional_octave_bands_filter_slopes():
         # restricting rtol was not needed locally. It was added for tests to
         # pass on the testing platform
         npt.assert_allclose(
-            y.time, np.atleast_2d(reference), rtol=.01, atol=1e-10)
+            y.time[:, 0, :], np.atleast_2d(reference), rtol=.01, atol=1e-10)
 
 
 def test_reconstructing_fractional_octave_bands_warning():
diff --git a/tests/test_filter_classes.py b/tests/test_filter_classes.py
index 7a2599fc..64eae07c 100644
--- a/tests/test_filter_classes.py
+++ b/tests/test_filter_classes.py
@@ -198,7 +198,7 @@ def test_filter_iir_process_multi_dim_filt(impulse):
 
     res = filt.process(impulse)
 
-    npt.assert_allclose(res.time[:, :3], coeff[:, 0])
+    npt.assert_allclose(res.time[:, 0, :3], coeff[:, 0])
 
     impulse.time = np.vstack((impulse.time, impulse.time))
     filt = fo.FilterIIR(coeff, impulse.sampling_rate)
@@ -218,7 +218,7 @@ def test_filter_fir_process_multi_dim_filt(impulse):
 
     filt = fo.FilterFIR(coeff, impulse.sampling_rate)
     res = filt.process(impulse)
-    npt.assert_allclose(res.time[:, :3], coeff)
+    npt.assert_allclose(res.time[:, 0, :3], coeff)
 
     impulse.time = np.vstack((impulse.time, impulse.time))
     filt = fo.FilterFIR(coeff, impulse.sampling_rate)
@@ -325,7 +325,7 @@ def test_filter_sos_process_multi_dim_filt(impulse):
     filt = fo.FilterSOS(sos, impulse.sampling_rate)
     res = filt.process(impulse)
 
-    npt.assert_allclose(res.time[:, :3], coeff[:, 0])
+    npt.assert_allclose(res.time[:, 0, :3], coeff[:, 0])
 
     impulse.time = np.vstack((impulse.time, impulse.time))
     filt = fo.FilterSOS(sos, impulse.sampling_rate)
diff --git a/tests/test_filter_gammatone.py b/tests/test_filter_gammatone.py
index d376a1d7..0bab5326 100644
--- a/tests/test_filter_gammatone.py
+++ b/tests/test_filter_gammatone.py
@@ -69,9 +69,9 @@ def test_gammatone_bands_init_and_getter():
 
 
 @pytest.mark.parametrize('amplitudes,shape_filtered,sampling_rate', (
-    [np.array([1]), (85, 2048), 44100],
-    [np.array([1]), (85, 2048), 48000],
-    [np.array([1]), (85, 2048), 96000],
+    [np.array([1]), (85, 1, 2048), 44100],
+    [np.array([1]), (85, 1, 2048), 48000],
+    [np.array([1]), (85, 1, 2048), 96000],
     [np.array([[1, 2], [3, 4]]), (85, 2, 2, 2048), 44100]
 ))
 def test_gammatone_bands_roundtrip(amplitudes, shape_filtered, sampling_rate):
@@ -122,11 +122,11 @@ def test_gammatone_bands_reset_state():
         pf.Signal(np.zeros(2**11), 44100), reset=False)
 
     # check for equality
-    npt.assert_array_equal(real_a.time, real.time[:, :2**11])
-    npt.assert_array_equal(imag_a.time, imag.time[:, :2**11])
+    npt.assert_array_equal(real_a.time, real.time[:, :, :2**11])
+    npt.assert_array_equal(imag_a.time, imag.time[:, :, :2**11])
 
-    npt.assert_array_equal(real_b.time, real.time[:, -2**11:])
-    npt.assert_array_equal(imag_b.time, imag.time[:, -2**11:])
+    npt.assert_array_equal(real_b.time, real.time[:, :, -2**11:])
+    npt.assert_array_equal(imag_b.time, imag.time[:, :, -2**11:])
 
 
 def test_gammatone_bands_assertions():
@@ -165,6 +165,18 @@ def test_gammatone_bands_repr():
                         "@ 44100 Hz sampling rate")
 
 
+@pytest.mark.parametrize('shape', [(4, 1), (1, 4), (1,), (1, 1)])
+def test_gammatone_bands_shape(shape):
+    """Test the shape of GammatoneBands-filtered signals"""
+    impulse = pf.signals.impulse(2048, 0, np.ones(shape))
+    GFB = filter.GammatoneBands([0, 22050])
+
+    real, imag = GFB.process(impulse)
+
+    assert real.time.shape == (GFB.n_bands, *shape, impulse.n_samples)
+    assert imag.time.shape == (GFB.n_bands, *shape, impulse.n_samples)
+
+
 def test_erb_frequencies():
     """Test erb_frequencies against reference from the AMT toolbox"""
 
diff --git a/tests/test_fractional_octaves.py b/tests/test_fractional_octaves.py
index 645dfdc8..6dcf569c 100644
--- a/tests/test_fractional_octaves.py
+++ b/tests/test_fractional_octaves.py
@@ -135,5 +135,5 @@ def test_sum_bands_din():
 
     mask = (impulse.frequencies > 30) & (impulse.frequencies < 20e3)
 
-    assert not np.any(diff[mask] > 10**(1/10))
-    assert not np.any(diff[mask] < 10**(-1/10))
+    assert not np.any(diff[:, mask] > 10**(1/10))
+    assert not np.any(diff[:, mask] < 10**(-1/10))
diff --git a/tests/test_signals.py b/tests/test_signals.py
index 0821cba3..ead3ccf0 100644
--- a/tests/test_signals.py
+++ b/tests/test_signals.py
@@ -108,9 +108,7 @@ def test_impulse_different_cshapes(delay_shape, amp_shape,):
         0 if delay_shape is None else np.zeros(delay_shape, dtype=int),
         1 if amp_shape is None else np.ones(amp_shape),
         )
-    shapes = [(1,) if shape is None else shape for shape in (
-        delay_shape, amp_shape, (1,))]
-    desired = np.ones(np.broadcast_shapes(*shapes))
+    desired = np.ones(np.broadcast_shapes(delay_shape, amp_shape, (1,)))
     npt.assert_allclose(signal.time[..., 0], desired)
     npt.assert_allclose(np.sum(signal.time, axis=-1), desired)
 
@@ -129,9 +127,8 @@ def test_sine_different_cshapes(frequency_shape, amp_shape, phase_shape,):
         1 if amp_shape is None else np.ones(amp_shape),
         0 if phase_shape is None else np.zeros(phase_shape),
         )
-    shapes = [(1,) if shape is None else shape for shape in (
-        frequency_shape, amp_shape, phase_shape, (1,))]
-    desired_cshape = np.broadcast_shapes(*shapes)
+    desired_cshape = np.broadcast_shapes(
+        frequency_shape, amp_shape, phase_shape, (1,))
     npt.assert_allclose(signal.cshape, desired_cshape)