Repository: pyfar/pyfar (python)

Issue:
Filterclasses confusing state parameter
```
import pyfar as pf
filter_fir = pf.FilterFIR(coefficients=1, sampling_rate=100, state=0)
filter_fir.init_state(cshape=(1,), state='zeros')
```

In this example, the word "state" has two different meanings. First, it refers to the current state of the filter (a numpy array), and second, it refers to a configuration. This can be a bit confusing. Usually, filter objects are created using the `pyfar.dsp.filter` module, so users rarely interact with them directly.

this came up in #756 

Pull request description:
Add filter state setter
Add setter for filter state property

Closes #757 

Patch:
diff --git a/pyfar/classes/filter.py b/pyfar/classes/filter.py
index 5bf09657..50f12839 100644
--- a/pyfar/classes/filter.py
+++ b/pyfar/classes/filter.py
@@ -146,16 +146,8 @@ class Filter(object):
         else:
             self._coefficients = None
 
-        if state is not None:
-            if coefficients is None:
-                raise ValueError(
-                    "Cannot set a state without filter coefficients")
-            state = _atleast_3d_first_dim(state)
-            self._initialized = True
-        else:
-            self._initialized = False
+        self.state = state
 
-        self._state = state
         self._sampling_rate = sampling_rate
         self.comment = comment
 
@@ -208,12 +200,22 @@ class Filter(object):
 
     @property
     def state(self):
-        """
-        The current state of the filter as an array with dimensions
-        corresponding to the order of the filter and number of filter channels.
-        """
+        """Get or set the filter state."""
         return self._state
 
+    @state.setter
+    def state(self, state):
+        """Set state of the filter."""
+        if state is not None:
+            if self.coefficients is None:
+                raise ValueError(
+                    "Cannot set a state without filter coefficients")
+            self._initialized = True
+        else:
+            self._initialized = False
+
+        self._state = state
+
     @staticmethod
     def _process(coefficients, data, zi=None):
         raise NotImplementedError("Abstract class method.")
@@ -345,6 +347,13 @@ class FilterFIR(Filter):
 
     def __init__(self, coefficients, sampling_rate, state=None, comment=""):
 
+        if state is not None and np.asarray(state).ndim < 3:
+            state = _atleast_3d_first_dim(state)
+            warnings.warn(
+                "The state should be of shape (n_channels, *cshape, order). "
+                f"The state has been reshaped to shape=({state.shape}",
+                stacklevel=2)
+
         super().__init__(coefficients, sampling_rate, state, comment)
 
     @property
@@ -378,6 +387,48 @@ class FilterFIR(Filter):
         coeff = np.stack((b, a), axis=-2)
         self._coefficients = _atleast_3d_first_dim(coeff)
 
+    @property
+    def state(self):
+        """
+        Get or set the filter state.
+
+        The filter state sets the initial condition of the filter and can for
+        example be used for block-wise filtering.
+
+        Note that the state can also be initialized with the
+        :py:func:`~pyfar.classes.filter.FilterFIR.init_state` method.
+
+        Shape of the state must be  ``(n_filter_channels, *cshape, order)``,
+        with ``cshape`` being the channel shape of the
+        :py:class:`~pyfar.Signal` to be filtered.
+        """
+        return self._state
+
+    @state.setter
+    def state(self, state):
+        """
+        Set initial state of FIR filter.
+
+        Parameters
+        ----------
+        state : array, double
+            The state of the filter with dimensions
+            ``(n_channels, *cshape, order)``, where ``cshape`` is
+            the channel shape of the :py:class:`~pyfar.Signal`
+            to be filtered.
+        """
+        if state is not None:
+            state = np.asarray(state)
+            if (state.shape[-1] != self.order
+                or state.ndim < 3
+                or state.shape[0] != self.n_channels):
+                raise ValueError(
+                    "The state does not match the filter structure. Required "
+                    "shape for FilterFIR is (n_channels, *cshape, order).")
+
+        # sets filter state in parent class
+        Filter.state.fset(self, state)
+
     def init_state(self, cshape, state='zeros'):
         """Initialize the buffer elements to pre-defined initial conditions.
 
@@ -405,6 +456,11 @@ class FilterFIR(Filter):
         This is a hidden static method required for a shared processing
         function in the parent class.
         """
+        if zi is not None and zi.shape[0:-1] != data.shape[0:-1]:
+            raise ValueError("The initial state does not match the cshape of "
+                             "the signal. Required shape for `state` in "
+                             "FilterFIR is (n_channels, *cshape, order).")
+
         return spsignal.lfilter(coefficients[0], 1, data, zi=zi)
 
     def __repr__(self):
@@ -445,6 +501,13 @@ class FilterIIR(Filter):
 
     def __init__(self, coefficients, sampling_rate, state=None, comment=""):
 
+        if state is not None and np.asarray(state).ndim < 3:
+            state = _atleast_3d_first_dim(state)
+            warnings.warn(
+                "The state should be of shape (n_channels, *cshape, order). "
+                f"The state has been reshaped to shape=({state.shape}",
+                stacklevel=2)
+
         super().__init__(coefficients, sampling_rate, state, comment)
 
     @property
@@ -452,6 +515,48 @@ class FilterIIR(Filter):
         """The order of the filter."""
         return np.max(self._coefficients.shape[-2:]) - 1
 
+    @property
+    def state(self):
+        """
+        Get or set the filter state.
+
+        The filter state sets the initial condition of the filter and can for
+        example be used for block-wise filtering.
+
+        Note that the state can also be initialized with the
+        :py:func:`~pyfar.classes.filter.FilterIIR.init_state` method.
+
+        Shape of the state must be  ``(n_filter_channels, *cshape, order)``,
+        with ``cshape`` being the channel shape of the
+        :py:class:`~pyfar.Signal` to be filtered.
+        """
+        return self._state
+
+    @state.setter
+    def state(self, state):
+        """
+        Set initial state of IIR filter.
+
+        Parameters
+        ----------
+        state : array, double
+            The state of the filter with dimensions
+            ``(n_channels, *cshape, order)``, where ``cshape`` is
+            the channel shape of the :py:class:`~pyfar.Signal`
+            to be filtered.
+        """
+        if state is not None:
+            state = np.asarray(state)
+            if (state.shape[-1] != self.order
+                or state.ndim < 3
+                or state.shape[0] != self.n_channels):
+                raise ValueError(
+                    "The state does not match the filter structure. Required "
+                    "shape for FilterIIR is (n_channels, *cshape, order).")
+
+        # sets filter state in parent class
+        Filter.state.fset(self, state)
+
     def init_state(self, cshape, state='zeros'):
         """Initialize the buffer elements to pre-defined initial conditions.
 
@@ -479,6 +584,10 @@ class FilterIIR(Filter):
         This is a hidden static method required for a shared processing
         function in the parent class.
         """
+        if zi is not None and zi.shape[0:-1] != data.shape[0:-1]:
+            raise ValueError("The initial state does not match the cshape of "
+                             "the signal. Required shape for `state` in "
+                             "FilterIIR is (n_channels, *cshape, order).")
         return spsignal.lfilter(coefficients[0], coefficients[1], data, zi=zi)
 
     def __repr__(self):
@@ -519,8 +628,12 @@ class FilterSOS(Filter):
 
     def __init__(self, coefficients, sampling_rate, state=None, comment=""):
 
-        if state is not None:
+        if state is not None and np.asarray(state).ndim < 4:
             state = _atleast_4d_first_dim(state)
+            warnings.warn(
+                "The state should be of shape (n_channels, *cshape, n_sections"
+                f", 2). The state has been reshaped to shape=({state.shape}",
+                stacklevel=2)
 
         super().__init__(coefficients, sampling_rate, state, comment)
 
@@ -548,6 +661,51 @@ class FilterSOS(Filter):
         """The number of sections."""
         return self._coefficients.shape[-2]
 
+    @property
+    def state(self):
+        """
+        Get or set the filter state.
+
+        The filter state sets the initial condition of the filter and can for
+        example be used for block-wise filtering.
+
+        Note that the state can also be initialized with the
+        :py:func:`~pyfar.classes.filter.FilterSOS.init_state` method.
+
+        Shape of the state must be
+        ``(n_filter_channels, *cshape, n_sections, 2))``,
+        with ``cshape`` being the channel shape of the
+        :py:class:`~pyfar.Signal` to be filtered.
+        """
+        return self._state
+
+    @state.setter
+    def state(self, state):
+        """
+        Set initial state of SOS filter.
+
+        Parameters
+        ----------
+        state : array, double
+            The state of the filter with dimensions
+            ``(n_channels, *cshape, n_sections, 2)``, where ``cshape`` is
+            the channel shape of the :py:class:`~pyfar.Signal`
+            to be filtered.
+        """
+        if state is not None:
+            state = np.asarray(state)
+            if (state.shape[0] != self.n_channels
+                or state.shape[-1] != 2
+                or state.shape[-2] != self.n_sections
+                or state.ndim < 4):
+                raise ValueError(
+                    "The state does not match the filter structure. Required "
+                    "shape for FilterSOS is (n_channels, *cshape, "
+                    "n_sections, 2).")
+
+        # sets filter state in parent class
+        Filter.state.fset(self, state)
+
     def init_state(self, cshape, state='zeros'):
         """Initialize the buffer elements to pre-defined initial conditions.
 
@@ -575,6 +733,11 @@ class FilterSOS(Filter):
         This is a hidden static method required for a shared processing
         function in the parent class.
         """
+        if zi is not None and zi.shape[0:-2] != data.shape[0:-1]:
+            raise ValueError("The initial state does not match the cshape of "
+                             "the signal. Required shape for `state` in "
+                             "FilterSOS is (n_channels, *cshape, n_sections,"
+                             " 2).")
         if zi is not None:
             zi = zi.transpose(1, 0, 2)
         res = spsignal.sosfilt(sos, data, zi=zi, axis=-1)


Test patch:
diff --git a/tests/test_filter_classes.py b/tests/test_filter_classes.py
index c646af5f..4363b022 100644
--- a/tests/test_filter_classes.py
+++ b/tests/test_filter_classes.py
@@ -3,6 +3,7 @@ import numpy as np
 import numpy.testing as npt
 import pyfar.classes.filter as fo
 import pyfar as pf
+import re
 from scipy import signal as spsignal
 
 
@@ -34,6 +35,61 @@ def test_filter_init_with_state():
     npt.assert_array_equal(filt.state, state)
 
 
+def test_filter_state_setter():
+    coeff = np.array([[[1, 0, 0], [1, 0, 0]]])
+    state = np.array([[[1, 0]]])
+    filt = fo.Filter(coefficients=coeff)
+    filt.state = state
+    npt.assert_array_equal(filt.state, state)
+
+
+def test_filter_state_setter_errors():
+    """Test errors when setting a state with wrong shape."""
+    fs = 44100
+    coeff_iir = np.array([[[1, 0, 0], [1, 0, 0]]])
+    with pytest.raises(ValueError, match=re.escape(
+        "The state does not match the filter structure. Required "
+        "shape for FilterIIR is (n_channels, *cshape, order).")):
+        fo.FilterIIR(coeff_iir, fs, state=[[[1, 0, 0]]])
+
+    coeff_fir = np.array([1, 0, 0])
+    with pytest.raises(ValueError, match=re.escape(
+        "The state does not match the filter structure. Required "
+        "shape for FilterFIR is (n_channels, *cshape, order).")):
+        fo.FilterFIR(coeff_fir, fs, state=[[[1, 0, 0]]])
+
+    coeff_sos = [[[1, .5, .25, 1, .5, .25], [1, .5, .25, 1, .5, .25]]]
+    with pytest.raises(ValueError, match=re.escape(
+        "The state does not match the filter structure. Required shape for "
+        "FilterSOS is (n_channels, *cshape, n_sections, 2).")):
+        fo.FilterSOS(coeff_sos, fs, state=[[[[0], [1]]]])
+
+
+def test_filter_state_process_errors():
+    """Test errors when processing a signal with wrong state shape."""
+    fs = 44100
+    impulse = pf.signals.impulse(256, amplitude=np.ones((4, 1)))
+    coeff_iir = np.array([[[1, 0, 0], [1, 0, 0]]])
+    with pytest.raises(ValueError, match=re.escape(
+        "The initial state does not match the cshape of the signal. Required "
+        "shape for `state` in FilterIIR is (n_channels, *cshape, order).")):
+        fo.FilterIIR(coeff_iir, fs, state=[[[1, 0]]]).process(impulse)
+
+    coeff_fir = np.array([1, 0, 0])
+    with pytest.raises(ValueError, match=re.escape(
+        "The initial state does not match the cshape of the signal. Required "
+        "shape for `state` in FilterFIR is (n_channels, *cshape, order).")):
+        fo.FilterFIR(coeff_fir, fs, state=[[[1, 0]]]).process(impulse)
+
+    coeff_sos = [[[1, .5, .25, 1, .5, .25], [1, .5, .25, 1, .5, .25]]]
+    with pytest.raises(ValueError, match=re.escape(
+        "The initial state does not match the cshape of the signal. Required "
+        "shape for `state` in FilterSOS is (n_channels, *cshape, n_sections,"
+        " 2).")):
+        fo.FilterSOS(
+            coeff_sos, fs, state=[[[[0, 0], [1, 0]]]]).process(impulse)
+
+
 def test_filter_comment():
     filt = fo.Filter(coefficients=None, state=None, comment='Bla')
     assert filt.comment == 'Bla'