ezmsg-sigproc 2.2.0__py3-none-any.whl → 2.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ezmsg/sigproc/__version__.py +16 -3
- ezmsg/sigproc/aggregate.py +69 -0
- ezmsg/sigproc/denormalize.py +86 -0
- ezmsg/sigproc/fbcca.py +332 -0
- ezmsg/sigproc/filter.py +16 -0
- ezmsg/sigproc/filterbankdesign.py +136 -0
- ezmsg/sigproc/firfilter.py +119 -0
- ezmsg/sigproc/kaiser.py +110 -0
- ezmsg/sigproc/resample.py +186 -185
- ezmsg/sigproc/sampler.py +71 -83
- ezmsg/sigproc/util/axisarray_buffer.py +379 -0
- ezmsg/sigproc/util/buffer.py +470 -0
- ezmsg/sigproc/window.py +12 -10
- {ezmsg_sigproc-2.2.0.dist-info → ezmsg_sigproc-2.4.0.dist-info}/METADATA +1 -1
- {ezmsg_sigproc-2.2.0.dist-info → ezmsg_sigproc-2.4.0.dist-info}/RECORD +17 -10
- {ezmsg_sigproc-2.2.0.dist-info → ezmsg_sigproc-2.4.0.dist-info}/WHEEL +1 -1
- {ezmsg_sigproc-2.2.0.dist-info → ezmsg_sigproc-2.4.0.dist-info}/licenses/LICENSE.txt +0 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
import ezmsg.core as ez
|
|
4
|
+
import numpy as np
|
|
5
|
+
import numpy.typing as npt
|
|
6
|
+
|
|
7
|
+
from ezmsg.util.messages.util import replace
|
|
8
|
+
from ezmsg.util.messages.axisarray import AxisArray
|
|
9
|
+
|
|
10
|
+
from .base import (
|
|
11
|
+
BaseStatefulTransformer,
|
|
12
|
+
processor_state,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from .filterbank import (
|
|
16
|
+
FilterbankTransformer,
|
|
17
|
+
FilterbankSettings,
|
|
18
|
+
FilterbankMode,
|
|
19
|
+
MinPhaseMode,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from .kaiser import KaiserFilterSettings, kaiser_design_fun
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FilterbankDesignSettings(ez.Settings):
|
|
26
|
+
filters: typing.Iterable[KaiserFilterSettings]
|
|
27
|
+
|
|
28
|
+
mode: FilterbankMode = FilterbankMode.CONV
|
|
29
|
+
"""
|
|
30
|
+
"conv", "fft", or "auto". If "auto", the mode is determined by the size of the input data.
|
|
31
|
+
fft mode is more efficient for long kernels. However, fft mode uses non-overlapping windows and will
|
|
32
|
+
incur a delay equal to the window length, which is larger than the largest kernel.
|
|
33
|
+
conv mode is less efficient but will return data for every incoming chunk regardless of how small it is
|
|
34
|
+
and thus can provide shorter latency updates.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
min_phase: MinPhaseMode = MinPhaseMode.NONE
|
|
38
|
+
"""
|
|
39
|
+
If not None, convert the kernels to minimum-phase equivalents. Valid options are
|
|
40
|
+
'hilbert', 'homomorphic', and 'homomorphic-full'. Complex filters not supported.
|
|
41
|
+
See `scipy.signal.minimum_phase` for details.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
axis: str = "time"
|
|
45
|
+
"""The name of the axis to operate on. This should usually be "time"."""
|
|
46
|
+
|
|
47
|
+
new_axis: str = "kernel"
|
|
48
|
+
"""The name of the new axis corresponding to the kernel index."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@processor_state
|
|
52
|
+
class FilterbankDesignState:
|
|
53
|
+
filterbank: FilterbankTransformer | None = None
|
|
54
|
+
needs_redesign: bool = False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class FilterbankDesignTransformer(
|
|
58
|
+
BaseStatefulTransformer[
|
|
59
|
+
FilterbankDesignSettings, AxisArray, AxisArray, FilterbankDesignState
|
|
60
|
+
],
|
|
61
|
+
):
|
|
62
|
+
"""
|
|
63
|
+
Transformer that designs and applies a filterbank based on Kaiser windowed FIR filters.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def get_message_type(cls, dir: str) -> type[AxisArray]:
|
|
68
|
+
if dir in ("in", "out"):
|
|
69
|
+
return AxisArray
|
|
70
|
+
else:
|
|
71
|
+
raise ValueError(f"Invalid direction: {dir}. Must be 'in' or 'out'.")
|
|
72
|
+
|
|
73
|
+
def update_settings(
|
|
74
|
+
self, new_settings: typing.Optional[FilterbankDesignSettings] = None, **kwargs
|
|
75
|
+
) -> None:
|
|
76
|
+
"""
|
|
77
|
+
Update settings and mark that filter coefficients need to be recalculated.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
new_settings: Complete new settings object to replace current settings
|
|
81
|
+
**kwargs: Individual settings to update
|
|
82
|
+
"""
|
|
83
|
+
# Update settings
|
|
84
|
+
if new_settings is not None:
|
|
85
|
+
self.settings = new_settings
|
|
86
|
+
else:
|
|
87
|
+
self.settings = replace(self.settings, **kwargs)
|
|
88
|
+
|
|
89
|
+
# Set flag to trigger recalculation on next message
|
|
90
|
+
if self.state.filterbank is not None:
|
|
91
|
+
self.state.needs_redesign = True
|
|
92
|
+
|
|
93
|
+
def _calculate_kernels(self, fs: float) -> list[npt.NDArray]:
|
|
94
|
+
kernels = []
|
|
95
|
+
for filter in self.settings.filters:
|
|
96
|
+
output = kaiser_design_fun(
|
|
97
|
+
fs,
|
|
98
|
+
cutoff=filter.cutoff,
|
|
99
|
+
ripple=filter.ripple,
|
|
100
|
+
width=filter.width,
|
|
101
|
+
pass_zero=filter.pass_zero,
|
|
102
|
+
wn_hz=filter.wn_hz,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
kernels.append(np.array([1.0]) if output is None else output[0])
|
|
106
|
+
return kernels
|
|
107
|
+
|
|
108
|
+
def __call__(self, message: AxisArray) -> AxisArray:
|
|
109
|
+
if self.state.filterbank is not None and self.state.needs_redesign:
|
|
110
|
+
self._reset_state(message)
|
|
111
|
+
self.state.needs_redesign = False
|
|
112
|
+
return super().__call__(message)
|
|
113
|
+
|
|
114
|
+
def _hash_message(self, message: AxisArray) -> int:
|
|
115
|
+
axis = message.dims[0] if self.settings.axis is None else self.settings.axis
|
|
116
|
+
gain = message.axes[axis].gain if hasattr(message.axes[axis], "gain") else 1
|
|
117
|
+
axis_idx = message.get_axis_idx(axis)
|
|
118
|
+
samp_shape = message.data.shape[:axis_idx] + message.data.shape[axis_idx + 1 :]
|
|
119
|
+
return hash((message.key, samp_shape, gain))
|
|
120
|
+
|
|
121
|
+
def _reset_state(self, message: AxisArray) -> None:
|
|
122
|
+
axis_obj = message.axes[self.settings.axis]
|
|
123
|
+
assert isinstance(axis_obj, AxisArray.LinearAxis)
|
|
124
|
+
fs = 1 / axis_obj.gain
|
|
125
|
+
kernels = self._calculate_kernels(fs)
|
|
126
|
+
new_settings = FilterbankSettings(
|
|
127
|
+
kernels=kernels,
|
|
128
|
+
mode=self.settings.mode,
|
|
129
|
+
min_phase=self.settings.min_phase,
|
|
130
|
+
axis=self.settings.axis,
|
|
131
|
+
new_axis=self.settings.new_axis,
|
|
132
|
+
)
|
|
133
|
+
self.state.filterbank = FilterbankTransformer(settings=new_settings)
|
|
134
|
+
|
|
135
|
+
def _process(self, message: AxisArray) -> AxisArray:
|
|
136
|
+
return self.state.filterbank(message)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import typing
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import numpy.typing as npt
|
|
6
|
+
import scipy.signal
|
|
7
|
+
|
|
8
|
+
from .filter import (
|
|
9
|
+
FilterBaseSettings,
|
|
10
|
+
FilterByDesignTransformer,
|
|
11
|
+
BACoeffs,
|
|
12
|
+
BaseFilterByDesignTransformerUnit,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FIRFilterSettings(FilterBaseSettings):
|
|
17
|
+
"""Settings for :obj:`FIRFilter`. See scipy.signal.firwin for more details"""
|
|
18
|
+
|
|
19
|
+
# axis and coef_type are inherited from FilterBaseSettings
|
|
20
|
+
|
|
21
|
+
order: int = 0
|
|
22
|
+
"""
|
|
23
|
+
Filter order/number of taps
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
cutoff: float | npt.ArrayLike | None = None
|
|
27
|
+
"""
|
|
28
|
+
Cutoff frequency of filter (expressed in the same units as fs) OR an array of cutoff frequencies
|
|
29
|
+
(that is, band edges). In the former case, as a float, the cutoff frequency should correspond with
|
|
30
|
+
the half-amplitude point, where the attenuation will be -6dB. In the latter case, the frequencies in
|
|
31
|
+
cutoff should be positive and monotonically increasing between 0 and fs/2. The values 0 and fs/2 must
|
|
32
|
+
not be included in cutoff.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
width: float | None = None
|
|
36
|
+
"""
|
|
37
|
+
If width is not None, then assume it is the approximate width of the transition region (expressed in
|
|
38
|
+
the same units as fs) for use in Kaiser FIR filter design. In this case, the window argument is ignored.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
window: str | None = "hamming"
|
|
42
|
+
"""
|
|
43
|
+
Desired window to use. See scipy.signal.get_window for a list of windows and required parameters.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
pass_zero: bool | str = True
|
|
47
|
+
"""
|
|
48
|
+
If True, the gain at the frequency 0 (i.e., the “DC gain”) is 1. If False, the DC gain is 0. Can also
|
|
49
|
+
be a string argument for the desired filter type (equivalent to btype in IIR design functions).
|
|
50
|
+
{‘lowpass’, ‘highpass’, ‘bandpass’, ‘bandstop’}
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
scale: bool = True
|
|
54
|
+
"""
|
|
55
|
+
Set to True to scale the coefficients so that the frequency response is exactly unity at a certain
|
|
56
|
+
frequency. That frequency is either:
|
|
57
|
+
* 0 (DC) if the first passband starts at 0 (i.e. pass_zero is True)
|
|
58
|
+
* fs/2 (the Nyquist frequency) if the first passband ends at fs/2
|
|
59
|
+
(i.e the filter is a single band highpass filter);
|
|
60
|
+
center of first passband otherwise
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
wn_hz: bool = True
|
|
64
|
+
"""
|
|
65
|
+
Set False if provided Wn are normalized from 0 to 1, where 1 is the Nyquist frequency
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def firwin_design_fun(
|
|
70
|
+
fs: float,
|
|
71
|
+
order: int = 0,
|
|
72
|
+
cutoff: float | npt.ArrayLike | None = None,
|
|
73
|
+
width: float | None = None,
|
|
74
|
+
window: str | None = "hamming",
|
|
75
|
+
pass_zero: bool | str = True,
|
|
76
|
+
scale: bool = True,
|
|
77
|
+
wn_hz: bool = True,
|
|
78
|
+
) -> BACoeffs | None:
|
|
79
|
+
"""
|
|
80
|
+
Design an `order`th-order FIR filter and return the filter coefficients.
|
|
81
|
+
See :obj:`FIRFilterSettings` for argument description.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
The filter taps as designed by firwin
|
|
85
|
+
"""
|
|
86
|
+
if order > 0:
|
|
87
|
+
taps = scipy.signal.firwin(
|
|
88
|
+
numtaps=order,
|
|
89
|
+
cutoff=cutoff,
|
|
90
|
+
width=width,
|
|
91
|
+
window=window,
|
|
92
|
+
pass_zero=pass_zero,
|
|
93
|
+
scale=scale,
|
|
94
|
+
fs=fs if wn_hz else None,
|
|
95
|
+
)
|
|
96
|
+
return (taps, np.array([1.0]))
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class FIRFilterTransformer(FilterByDesignTransformer[FIRFilterSettings, BACoeffs]):
|
|
101
|
+
def get_design_function(
|
|
102
|
+
self,
|
|
103
|
+
) -> typing.Callable[[float], BACoeffs | None]:
|
|
104
|
+
return functools.partial(
|
|
105
|
+
firwin_design_fun,
|
|
106
|
+
order=self.settings.order,
|
|
107
|
+
cutoff=self.settings.cutoff,
|
|
108
|
+
width=self.settings.width,
|
|
109
|
+
window=self.settings.window,
|
|
110
|
+
pass_zero=self.settings.pass_zero,
|
|
111
|
+
scale=self.settings.scale,
|
|
112
|
+
wn_hz=self.settings.wn_hz,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class FIRFilter(
|
|
117
|
+
BaseFilterByDesignTransformerUnit[FIRFilterSettings, FIRFilterTransformer]
|
|
118
|
+
):
|
|
119
|
+
SETTINGS = FIRFilterSettings
|
ezmsg/sigproc/kaiser.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import typing
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import numpy.typing as npt
|
|
6
|
+
import scipy.signal
|
|
7
|
+
|
|
8
|
+
from .filter import (
|
|
9
|
+
FilterBaseSettings,
|
|
10
|
+
FilterByDesignTransformer,
|
|
11
|
+
BACoeffs,
|
|
12
|
+
BaseFilterByDesignTransformerUnit,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class KaiserFilterSettings(FilterBaseSettings):
|
|
17
|
+
"""Settings for :obj:`KaiserFilter`"""
|
|
18
|
+
|
|
19
|
+
# axis and coef_type are inherited from FilterBaseSettings
|
|
20
|
+
|
|
21
|
+
cutoff: float | npt.ArrayLike | None = None
|
|
22
|
+
"""
|
|
23
|
+
Cutoff frequency of filter (expressed in the same units as fs) OR an array of cutoff frequencies
|
|
24
|
+
(that is, band edges). In the former case, as a float, the cutoff frequency should correspond with
|
|
25
|
+
the half-amplitude point, where the attenuation will be -6dB. In the latter case, the frequencies in
|
|
26
|
+
cutoff should be positive and monotonically increasing between 0 and fs/2. The values 0 and fs/2 must
|
|
27
|
+
not be included in cutoff.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
ripple: float | None = None
|
|
31
|
+
"""
|
|
32
|
+
Upper bound for the deviation (in dB) of the magnitude of the filter's frequency response from that of
|
|
33
|
+
the desired filter (not including frequencies in any transition intervals).
|
|
34
|
+
See scipy.signal.kaiserord for more information.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
width: float | None = None
|
|
38
|
+
"""
|
|
39
|
+
If width is not None, then assume it is the approximate width of the transition region (expressed in
|
|
40
|
+
the same units as fs) for use in Kaiser FIR filter design.
|
|
41
|
+
See scipy.signal.kaiserord for more information.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
pass_zero: bool | str = True
|
|
45
|
+
"""
|
|
46
|
+
If True, the gain at the frequency 0 (i.e., the “DC gain”) is 1. If False, the DC gain is 0. Can also
|
|
47
|
+
be a string argument for the desired filter type (equivalent to btype in IIR design functions).
|
|
48
|
+
{‘lowpass’, ‘highpass’, ‘bandpass’, ‘bandstop’}
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
wn_hz: bool = True
|
|
52
|
+
"""
|
|
53
|
+
Set False if cutoff and width are normalized from 0 to 1, where 1 is the Nyquist frequency
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def kaiser_design_fun(
|
|
58
|
+
fs: float,
|
|
59
|
+
cutoff: float | npt.ArrayLike | None = None,
|
|
60
|
+
ripple: float | None = None,
|
|
61
|
+
width: float | None = None,
|
|
62
|
+
pass_zero: bool | str = True,
|
|
63
|
+
wn_hz: bool = True,
|
|
64
|
+
) -> BACoeffs | None:
|
|
65
|
+
"""
|
|
66
|
+
Design an `order`th-order FIR Kaiser filter and return the filter coefficients.
|
|
67
|
+
See :obj:`FIRFilterSettings` for argument description.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
The filter taps as designed by firwin
|
|
71
|
+
"""
|
|
72
|
+
if ripple is None or width is None or cutoff is None:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
width = width / (0.5 * fs) if wn_hz else width
|
|
76
|
+
n_taps, beta = scipy.signal.kaiserord(ripple, width)
|
|
77
|
+
if n_taps % 2 == 0:
|
|
78
|
+
n_taps += 1
|
|
79
|
+
taps = scipy.signal.firwin(
|
|
80
|
+
numtaps=n_taps,
|
|
81
|
+
cutoff=cutoff,
|
|
82
|
+
window=("kaiser", beta), # type: ignore
|
|
83
|
+
pass_zero=pass_zero, # type: ignore
|
|
84
|
+
scale=False,
|
|
85
|
+
fs=fs if wn_hz else None,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return (taps, np.array([1.0]))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class KaiserFilterTransformer(
|
|
92
|
+
FilterByDesignTransformer[KaiserFilterSettings, BACoeffs]
|
|
93
|
+
):
|
|
94
|
+
def get_design_function(
|
|
95
|
+
self,
|
|
96
|
+
) -> typing.Callable[[float], BACoeffs | None]:
|
|
97
|
+
return functools.partial(
|
|
98
|
+
kaiser_design_fun,
|
|
99
|
+
cutoff=self.settings.cutoff,
|
|
100
|
+
ripple=self.settings.ripple,
|
|
101
|
+
width=self.settings.width,
|
|
102
|
+
pass_zero=self.settings.pass_zero,
|
|
103
|
+
wn_hz=self.settings.wn_hz,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class KaiserFilter(
|
|
108
|
+
BaseFilterByDesignTransformerUnit[KaiserFilterSettings, KaiserFilterTransformer]
|
|
109
|
+
):
|
|
110
|
+
SETTINGS = KaiserFilterSettings
|