ezmsg-sigproc 1.7.0__py3-none-any.whl → 2.10.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 +22 -4
- ezmsg/sigproc/activation.py +31 -40
- ezmsg/sigproc/adaptive_lattice_notch.py +212 -0
- ezmsg/sigproc/affinetransform.py +171 -169
- ezmsg/sigproc/aggregate.py +190 -97
- ezmsg/sigproc/bandpower.py +60 -55
- ezmsg/sigproc/base.py +143 -33
- ezmsg/sigproc/butterworthfilter.py +34 -38
- ezmsg/sigproc/butterworthzerophase.py +305 -0
- ezmsg/sigproc/cheby.py +23 -17
- ezmsg/sigproc/combfilter.py +160 -0
- ezmsg/sigproc/coordinatespaces.py +159 -0
- ezmsg/sigproc/decimate.py +15 -10
- ezmsg/sigproc/denormalize.py +78 -0
- ezmsg/sigproc/detrend.py +28 -0
- ezmsg/sigproc/diff.py +82 -0
- ezmsg/sigproc/downsample.py +72 -81
- ezmsg/sigproc/ewma.py +217 -0
- ezmsg/sigproc/ewmfilter.py +1 -1
- ezmsg/sigproc/extract_axis.py +39 -0
- ezmsg/sigproc/fbcca.py +307 -0
- ezmsg/sigproc/filter.py +254 -148
- ezmsg/sigproc/filterbank.py +226 -214
- ezmsg/sigproc/filterbankdesign.py +129 -0
- ezmsg/sigproc/fir_hilbert.py +336 -0
- ezmsg/sigproc/fir_pmc.py +209 -0
- ezmsg/sigproc/firfilter.py +117 -0
- ezmsg/sigproc/gaussiansmoothing.py +89 -0
- ezmsg/sigproc/kaiser.py +106 -0
- ezmsg/sigproc/linear.py +120 -0
- ezmsg/sigproc/math/abs.py +23 -22
- ezmsg/sigproc/math/add.py +120 -0
- ezmsg/sigproc/math/clip.py +33 -25
- ezmsg/sigproc/math/difference.py +117 -43
- ezmsg/sigproc/math/invert.py +18 -25
- ezmsg/sigproc/math/log.py +38 -33
- ezmsg/sigproc/math/scale.py +24 -25
- ezmsg/sigproc/messages.py +1 -2
- ezmsg/sigproc/quantize.py +68 -0
- ezmsg/sigproc/resample.py +278 -0
- ezmsg/sigproc/rollingscaler.py +232 -0
- ezmsg/sigproc/sampler.py +209 -254
- ezmsg/sigproc/scaler.py +93 -218
- ezmsg/sigproc/signalinjector.py +44 -43
- ezmsg/sigproc/slicer.py +74 -102
- ezmsg/sigproc/spectral.py +3 -3
- ezmsg/sigproc/spectrogram.py +70 -70
- ezmsg/sigproc/spectrum.py +187 -173
- ezmsg/sigproc/transpose.py +134 -0
- ezmsg/sigproc/util/__init__.py +0 -0
- ezmsg/sigproc/util/asio.py +25 -0
- ezmsg/sigproc/util/axisarray_buffer.py +365 -0
- ezmsg/sigproc/util/buffer.py +449 -0
- ezmsg/sigproc/util/message.py +17 -0
- ezmsg/sigproc/util/profile.py +23 -0
- ezmsg/sigproc/util/sparse.py +115 -0
- ezmsg/sigproc/util/typeresolution.py +17 -0
- ezmsg/sigproc/wavelets.py +147 -154
- ezmsg/sigproc/window.py +248 -210
- ezmsg_sigproc-2.10.0.dist-info/METADATA +60 -0
- ezmsg_sigproc-2.10.0.dist-info/RECORD +65 -0
- {ezmsg_sigproc-1.7.0.dist-info → ezmsg_sigproc-2.10.0.dist-info}/WHEEL +1 -1
- ezmsg/sigproc/synth.py +0 -621
- ezmsg_sigproc-1.7.0.dist-info/METADATA +0 -58
- ezmsg_sigproc-1.7.0.dist-info/RECORD +0 -36
- /ezmsg_sigproc-1.7.0.dist-info/licenses/LICENSE.txt → /ezmsg_sigproc-2.10.0.dist-info/licenses/LICENSE +0 -0
ezmsg/sigproc/window.py
CHANGED
|
@@ -1,137 +1,178 @@
|
|
|
1
|
+
import enum
|
|
1
2
|
import traceback
|
|
2
3
|
import typing
|
|
3
4
|
|
|
4
5
|
import ezmsg.core as ez
|
|
5
|
-
import numpy as np
|
|
6
6
|
import numpy.typing as npt
|
|
7
|
+
import sparse
|
|
8
|
+
from array_api_compat import get_namespace, is_pydata_sparse_namespace
|
|
9
|
+
from ezmsg.baseproc import (
|
|
10
|
+
BaseStatefulTransformer,
|
|
11
|
+
BaseTransformerUnit,
|
|
12
|
+
processor_state,
|
|
13
|
+
)
|
|
7
14
|
from ezmsg.util.messages.axisarray import (
|
|
8
15
|
AxisArray,
|
|
16
|
+
replace,
|
|
9
17
|
slice_along_axis,
|
|
10
18
|
sliding_win_oneaxis,
|
|
11
|
-
replace,
|
|
12
19
|
)
|
|
13
|
-
from ezmsg.util.generator import consumer
|
|
14
20
|
|
|
15
|
-
from .
|
|
21
|
+
from .util.profile import profile_subpub
|
|
22
|
+
from .util.sparse import sliding_win_oneaxis as sparse_sliding_win_oneaxis
|
|
16
23
|
|
|
17
24
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
window_dur: float | None = None,
|
|
23
|
-
window_shift: float | None = None,
|
|
24
|
-
zero_pad_until: str = "input",
|
|
25
|
-
) -> typing.Generator[AxisArray, AxisArray, None]:
|
|
26
|
-
"""
|
|
27
|
-
Apply a sliding window along the specified axis to input streaming data.
|
|
28
|
-
The `windowing` method is perhaps the most useful and versatile method in ezmsg.sigproc, but its parameterization
|
|
29
|
-
can be difficult. Please read the argument descriptions carefully.
|
|
25
|
+
class Anchor(enum.Enum):
|
|
26
|
+
BEGINNING = "beginning"
|
|
27
|
+
END = "end"
|
|
28
|
+
MIDDLE = "middle"
|
|
30
29
|
|
|
31
|
-
Args:
|
|
32
|
-
axis: The axis along which to segment windows.
|
|
33
|
-
If None, defaults to the first dimension of the first seen AxisArray.
|
|
34
|
-
Note: The windowed axis must be an AxisArray.LinearAxis, not an AxisArray.CoordinateAxis.
|
|
35
|
-
newaxis: New axis on which windows are delimited, immediately
|
|
36
|
-
preceding the target windowed axis. The data length along newaxis may be 0 if
|
|
37
|
-
this most recent push did not provide enough data for a new window.
|
|
38
|
-
If window_shift is None then the newaxis length will always be 1.
|
|
39
|
-
window_dur: The duration of the window in seconds.
|
|
40
|
-
If None, the function acts as a passthrough and all other parameters are ignored.
|
|
41
|
-
window_shift: The shift of the window in seconds.
|
|
42
|
-
If None (default), windowing operates in "1:1 mode", where each input yields exactly one most-recent window.
|
|
43
|
-
zero_pad_until: Determines how the function initializes the buffer.
|
|
44
|
-
Can be one of "input" (default), "full", "shift", or "none". If `window_shift` is None then this field is
|
|
45
|
-
ignored and "input" is always used.
|
|
46
|
-
|
|
47
|
-
- "input" (default) initializes the buffer with the input then prepends with zeros to the window size.
|
|
48
|
-
The first input will always yield at least one output.
|
|
49
|
-
- "shift" fills the buffer until `window_shift`.
|
|
50
|
-
No outputs will be yielded until at least `window_shift` data has been seen.
|
|
51
|
-
- "none" does not pad the buffer. No outputs will be yielded until at least `window_dur` data has been seen.
|
|
52
|
-
|
|
53
|
-
Returns:
|
|
54
|
-
A primed generator that accepts an :obj:`AxisArray` via `.send(axis_array)`
|
|
55
|
-
and yields an :obj:`AxisArray` with the data payload containing a windowed version of the input data.
|
|
56
|
-
"""
|
|
57
|
-
# Check arguments
|
|
58
|
-
if newaxis is None:
|
|
59
|
-
ez.logger.warning("`newaxis` must not be None. Setting to 'win'.")
|
|
60
|
-
newaxis = "win"
|
|
61
|
-
if window_shift is None and zero_pad_until != "input":
|
|
62
|
-
ez.logger.warning(
|
|
63
|
-
"`zero_pad_until` must be 'input' if `window_shift` is None. "
|
|
64
|
-
f"Ignoring received argument value: {zero_pad_until}"
|
|
65
|
-
)
|
|
66
|
-
zero_pad_until = "input"
|
|
67
|
-
elif window_shift is not None and zero_pad_until == "input":
|
|
68
|
-
ez.logger.warning(
|
|
69
|
-
"windowing is non-deterministic with `zero_pad_until='input'` as it depends on the size "
|
|
70
|
-
"of the first input. We recommend using 'shift' when `window_shift` is float-valued."
|
|
71
|
-
)
|
|
72
|
-
msg_out = AxisArray(np.array([]), dims=[""])
|
|
73
30
|
|
|
74
|
-
|
|
75
|
-
|
|
31
|
+
class WindowSettings(ez.Settings):
|
|
32
|
+
axis: str | None = None
|
|
33
|
+
newaxis: str | None = None # new axis for output. No new axes if None
|
|
34
|
+
window_dur: float | None = None # Sec. passthrough if None
|
|
35
|
+
window_shift: float | None = None # Sec. Use "1:1 mode" if None
|
|
36
|
+
zero_pad_until: str = "full" # "full", "shift", "input", "none"
|
|
37
|
+
anchor: str | Anchor = Anchor.BEGINNING
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@processor_state
|
|
41
|
+
class WindowState:
|
|
42
|
+
buffer: npt.NDArray | sparse.SparseArray | None = None
|
|
43
|
+
|
|
76
44
|
window_samples: int | None = None
|
|
45
|
+
|
|
77
46
|
window_shift_samples: int | None = None
|
|
78
|
-
|
|
47
|
+
|
|
79
48
|
shift_deficit: int = 0
|
|
80
|
-
|
|
81
|
-
|
|
49
|
+
""" Number of incoming samples to ignore. Only relevant when shift > window."""
|
|
50
|
+
|
|
51
|
+
newaxis_warned: bool = False
|
|
52
|
+
|
|
82
53
|
out_newaxis: AxisArray.LinearAxis | None = None
|
|
54
|
+
|
|
83
55
|
out_dims: list[str] | None = None
|
|
84
56
|
|
|
85
|
-
check_inputs = {"samp_shape": None, "fs": None, "key": None}
|
|
86
57
|
|
|
87
|
-
|
|
88
|
-
|
|
58
|
+
class WindowTransformer(BaseStatefulTransformer[WindowSettings, AxisArray, AxisArray, WindowState]):
|
|
59
|
+
"""
|
|
60
|
+
Apply a sliding window along the specified axis to input streaming data.
|
|
61
|
+
The `windowing` method is perhaps the most useful and versatile method in ezmsg.sigproc, but its parameterization
|
|
62
|
+
can be difficult. Please read the argument descriptions carefully.
|
|
63
|
+
"""
|
|
89
64
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
65
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
axis: The axis along which to segment windows.
|
|
70
|
+
If None, defaults to the first dimension of the first seen AxisArray.
|
|
71
|
+
Note: The windowed axis must be an AxisArray.LinearAxis, not an AxisArray.CoordinateAxis.
|
|
72
|
+
newaxis: New axis on which windows are delimited, immediately
|
|
73
|
+
preceding the target windowed axis. The data length along newaxis may be 0 if
|
|
74
|
+
this most recent push did not provide enough data for a new window.
|
|
75
|
+
If window_shift is None then the newaxis length will always be 1.
|
|
76
|
+
window_dur: The duration of the window in seconds.
|
|
77
|
+
If None, the function acts as a passthrough and all other parameters are ignored.
|
|
78
|
+
window_shift: The shift of the window in seconds.
|
|
79
|
+
If None (default), windowing operates in "1:1 mode",
|
|
80
|
+
where each input yields exactly one most-recent window.
|
|
81
|
+
zero_pad_until: Determines how the function initializes the buffer.
|
|
82
|
+
Can be one of "input" (default), "full", "shift", or "none".
|
|
83
|
+
If `window_shift` is None then this field is ignored and "input" is always used.
|
|
84
|
+
|
|
85
|
+
- "input" (default) initializes the buffer with the input then prepends with zeros to the window size.
|
|
86
|
+
The first input will always yield at least one output.
|
|
87
|
+
- "shift" fills the buffer until `window_shift`.
|
|
88
|
+
No outputs will be yielded until at least `window_shift` data has been seen.
|
|
89
|
+
- "none" does not pad the buffer. No outputs will be yielded until
|
|
90
|
+
at least `window_dur` data has been seen.
|
|
91
|
+
anchor: Determines the entry in `axis` that gets assigned `0`, which references the
|
|
92
|
+
value in `newaxis`. Can be of class :obj:`Anchor` or a string representation of an :obj:`Anchor`.
|
|
93
|
+
"""
|
|
94
|
+
super().__init__(*args, **kwargs)
|
|
95
|
+
|
|
96
|
+
# Sanity-check settings
|
|
97
|
+
# if self.settings.newaxis is None:
|
|
98
|
+
# ez.logger.warning("`newaxis=None` will be replaced with `newaxis='win'`.")
|
|
99
|
+
# object.__setattr__(self.settings, "newaxis", "win")
|
|
100
|
+
if self.settings.window_shift is None and self.settings.zero_pad_until != "input":
|
|
101
|
+
ez.logger.warning(
|
|
102
|
+
"`zero_pad_until` must be 'input' if `window_shift` is None. "
|
|
103
|
+
f"Ignoring received argument value: {self.settings.zero_pad_until}"
|
|
104
|
+
)
|
|
105
|
+
object.__setattr__(self.settings, "zero_pad_until", "input")
|
|
106
|
+
elif self.settings.window_shift is not None and self.settings.zero_pad_until == "input":
|
|
107
|
+
ez.logger.warning(
|
|
108
|
+
"windowing is non-deterministic with `zero_pad_until='input'` as it depends on the size "
|
|
109
|
+
"of the first input. We recommend using `zero_pad_until='shift'` when `window_shift` is float-valued."
|
|
110
|
+
)
|
|
111
|
+
try:
|
|
112
|
+
object.__setattr__(self.settings, "anchor", Anchor(self.settings.anchor))
|
|
113
|
+
except ValueError:
|
|
114
|
+
raise ValueError(
|
|
115
|
+
f"Invalid anchor: {self.settings.anchor}. Valid anchor are: {', '.join([e.value for e in Anchor])}"
|
|
116
|
+
)
|
|
93
117
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
118
|
+
def _hash_message(self, message: AxisArray) -> int:
|
|
119
|
+
axis = self.settings.axis or message.dims[0]
|
|
120
|
+
axis_idx = message.get_axis_idx(axis)
|
|
121
|
+
axis_info = message.get_axis(axis)
|
|
97
122
|
fs = 1.0 / axis_info.gain
|
|
123
|
+
samp_shape = message.data.shape[:axis_idx] + message.data.shape[axis_idx + 1 :]
|
|
98
124
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
newaxis
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
125
|
+
return hash(samp_shape + (fs, message.key))
|
|
126
|
+
|
|
127
|
+
def _reset_state(self, message: AxisArray) -> None:
|
|
128
|
+
_newaxis = self.settings.newaxis or "win"
|
|
129
|
+
if not self._state.newaxis_warned and _newaxis in message.dims:
|
|
130
|
+
ez.logger.warning(f"newaxis {_newaxis} present in input dims. Using {_newaxis}_win instead")
|
|
131
|
+
self._state.newaxis_warned = True
|
|
132
|
+
self.settings.newaxis = f"{_newaxis}_win"
|
|
133
|
+
|
|
134
|
+
axis = self.settings.axis or message.dims[0]
|
|
135
|
+
axis_idx = message.get_axis_idx(axis)
|
|
136
|
+
axis_info = message.get_axis(axis)
|
|
137
|
+
fs = 1.0 / axis_info.gain
|
|
138
|
+
|
|
139
|
+
xp = get_namespace(message.data)
|
|
140
|
+
|
|
141
|
+
self._state.window_samples = int(self.settings.window_dur * fs)
|
|
142
|
+
if self.settings.window_shift is not None:
|
|
143
|
+
# If window_shift is None, we are in "1:1 mode" and window_shift_samples is not used.
|
|
144
|
+
self._state.window_shift_samples = int(self.settings.window_shift * fs)
|
|
145
|
+
if self.settings.zero_pad_until == "none":
|
|
146
|
+
req_samples = self._state.window_samples
|
|
147
|
+
elif self.settings.zero_pad_until == "shift" and self.settings.window_shift is not None:
|
|
148
|
+
req_samples = self._state.window_shift_samples
|
|
149
|
+
else: # i.e. zero_pad_until == "input"
|
|
150
|
+
req_samples = message.data.shape[axis_idx]
|
|
151
|
+
n_zero = max(0, self._state.window_samples - req_samples)
|
|
152
|
+
init_buffer_shape = message.data.shape[:axis_idx] + (n_zero,) + message.data.shape[axis_idx + 1 :]
|
|
153
|
+
self._state.buffer = xp.zeros(init_buffer_shape, dtype=message.data.dtype)
|
|
154
|
+
|
|
155
|
+
# Prepare reusable parts of output
|
|
156
|
+
if self._state.out_newaxis is None:
|
|
157
|
+
self._state.out_dims = list(message.dims[:axis_idx]) + [_newaxis] + list(message.dims[axis_idx:])
|
|
158
|
+
self._state.out_newaxis = replace(
|
|
159
|
+
axis_info,
|
|
160
|
+
gain=0.0 if self.settings.window_shift is None else axis_info.gain * self._state.window_shift_samples,
|
|
161
|
+
offset=0.0, # offset modified per-msg below
|
|
133
162
|
)
|
|
134
|
-
|
|
163
|
+
|
|
164
|
+
def __call__(self, message: AxisArray) -> AxisArray:
|
|
165
|
+
if self.settings.window_dur is None:
|
|
166
|
+
# Shortcut for no windowing
|
|
167
|
+
return message
|
|
168
|
+
return super().__call__(message)
|
|
169
|
+
|
|
170
|
+
def _process(self, message: AxisArray) -> AxisArray:
|
|
171
|
+
axis = self.settings.axis or message.dims[0]
|
|
172
|
+
axis_idx = message.get_axis_idx(axis)
|
|
173
|
+
axis_info = message.get_axis(axis)
|
|
174
|
+
|
|
175
|
+
xp = get_namespace(message.data)
|
|
135
176
|
|
|
136
177
|
# Add new data to buffer.
|
|
137
178
|
# Currently, we concatenate the new time samples and clip the output.
|
|
@@ -140,151 +181,148 @@ def windowing(
|
|
|
140
181
|
# is generally faster than np.roll and slicing anyway, but this could still
|
|
141
182
|
# be a performance bottleneck for large memory arrays.
|
|
142
183
|
# A circular buffer might be faster.
|
|
143
|
-
buffer =
|
|
184
|
+
self._state.buffer = xp.concatenate((self._state.buffer, message.data), axis=axis_idx)
|
|
144
185
|
|
|
145
186
|
# Create a vector of buffer timestamps to track axis `offset` in output(s)
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
187
|
+
buffer_t0 = 0.0
|
|
188
|
+
buffer_tlen = self._state.buffer.shape[axis_idx]
|
|
189
|
+
|
|
190
|
+
# Adjust so first _new_ sample at index 0.
|
|
191
|
+
buffer_t0 -= self._state.buffer.shape[axis_idx] - message.data.shape[axis_idx]
|
|
192
|
+
|
|
149
193
|
# Convert form indices to 'units' (probably seconds).
|
|
150
|
-
|
|
151
|
-
|
|
194
|
+
buffer_t0 *= axis_info.gain
|
|
195
|
+
buffer_t0 += axis_info.offset
|
|
152
196
|
|
|
153
|
-
if not
|
|
154
|
-
n_skip = min(buffer.shape[axis_idx], shift_deficit)
|
|
197
|
+
if self.settings.window_shift is not None and self._state.shift_deficit > 0:
|
|
198
|
+
n_skip = min(self._state.buffer.shape[axis_idx], self._state.shift_deficit)
|
|
155
199
|
if n_skip > 0:
|
|
156
|
-
buffer = slice_along_axis(buffer, slice(n_skip, None), axis_idx)
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
# Prepare reusable parts of output
|
|
161
|
-
if out_newaxis is None:
|
|
162
|
-
out_dims = msg_in.dims[:axis_idx] + [newaxis] + msg_in.dims[axis_idx:]
|
|
163
|
-
out_newaxis = replace(
|
|
164
|
-
axis_info,
|
|
165
|
-
gain=0.0 if b_1to1 else axis_info.gain * window_shift_samples,
|
|
166
|
-
offset=0.0, # offset modified per-msg below
|
|
167
|
-
)
|
|
200
|
+
self._state.buffer = slice_along_axis(self._state.buffer, slice(n_skip, None), axis_idx)
|
|
201
|
+
buffer_t0 += n_skip * axis_info.gain
|
|
202
|
+
buffer_tlen -= n_skip
|
|
203
|
+
self._state.shift_deficit -= n_skip
|
|
168
204
|
|
|
169
205
|
# Generate outputs.
|
|
170
206
|
# Preliminary copy of axes without the axes that we are modifying.
|
|
171
|
-
|
|
207
|
+
_newaxis = self.settings.newaxis or "win"
|
|
208
|
+
out_axes = {k: v for k, v in message.axes.items() if k not in [_newaxis, axis]}
|
|
172
209
|
|
|
173
210
|
# Update targeted (windowed) axis so that its offset is relative to the new axis
|
|
174
|
-
|
|
175
|
-
|
|
211
|
+
if self.settings.anchor == Anchor.BEGINNING:
|
|
212
|
+
out_axes[axis] = replace(axis_info, offset=0.0)
|
|
213
|
+
elif self.settings.anchor == Anchor.END:
|
|
214
|
+
out_axes[axis] = replace(axis_info, offset=-self.settings.window_dur)
|
|
215
|
+
elif self.settings.anchor == Anchor.MIDDLE:
|
|
216
|
+
out_axes[axis] = replace(axis_info, offset=-self.settings.window_dur / 2)
|
|
176
217
|
|
|
177
218
|
# How we update .data and .axes[newaxis] depends on the windowing mode.
|
|
178
|
-
if
|
|
219
|
+
if self.settings.window_shift is None:
|
|
179
220
|
# one-to-one mode -- Each send yields exactly one window containing only the most recent samples.
|
|
180
|
-
buffer = slice_along_axis(
|
|
181
|
-
|
|
182
|
-
|
|
221
|
+
self._state.buffer = slice_along_axis(
|
|
222
|
+
self._state.buffer, slice(-self._state.window_samples, None), axis_idx
|
|
223
|
+
)
|
|
224
|
+
out_dat = self._state.buffer.reshape(
|
|
225
|
+
self._state.buffer.shape[:axis_idx] + (1,) + self._state.buffer.shape[axis_idx:]
|
|
183
226
|
)
|
|
184
|
-
|
|
185
|
-
elif buffer.shape[axis_idx] >= window_samples:
|
|
227
|
+
win_offset = buffer_t0 + axis_info.gain * (buffer_tlen - self._state.window_samples)
|
|
228
|
+
elif self._state.buffer.shape[axis_idx] >= self._state.window_samples:
|
|
186
229
|
# Deterministic window shifts.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
230
|
+
sliding_win_fun = sparse_sliding_win_oneaxis if is_pydata_sparse_namespace(xp) else sliding_win_oneaxis
|
|
231
|
+
out_dat = sliding_win_fun(
|
|
232
|
+
self._state.buffer,
|
|
233
|
+
self._state.window_samples,
|
|
234
|
+
axis_idx,
|
|
235
|
+
step=self._state.window_shift_samples,
|
|
192
236
|
)
|
|
193
|
-
|
|
194
|
-
::window_shift_samples
|
|
195
|
-
]
|
|
196
|
-
out_newaxis = replace(out_newaxis, offset=offset_view[0, 0])
|
|
237
|
+
win_offset = buffer_t0
|
|
197
238
|
|
|
198
239
|
# Drop expired beginning of buffer and update shift_deficit
|
|
199
|
-
multi_shift = window_shift_samples * out_dat.shape[axis_idx]
|
|
200
|
-
shift_deficit = max(0, multi_shift - buffer.shape[axis_idx])
|
|
201
|
-
buffer = slice_along_axis(buffer, slice(multi_shift, None), axis_idx)
|
|
240
|
+
multi_shift = self._state.window_shift_samples * out_dat.shape[axis_idx]
|
|
241
|
+
self._state.shift_deficit = max(0, multi_shift - self._state.buffer.shape[axis_idx])
|
|
242
|
+
self._state.buffer = slice_along_axis(self._state.buffer, slice(multi_shift, None), axis_idx)
|
|
202
243
|
else:
|
|
203
244
|
# Not enough data to make a new window. Return empty data.
|
|
204
245
|
empty_data_shape = (
|
|
205
|
-
|
|
206
|
-
+ (0, window_samples)
|
|
207
|
-
+ msg_in.data.shape[axis_idx + 1 :]
|
|
246
|
+
message.data.shape[:axis_idx] + (0, self._state.window_samples) + message.data.shape[axis_idx + 1 :]
|
|
208
247
|
)
|
|
209
|
-
out_dat =
|
|
248
|
+
out_dat = xp.zeros(empty_data_shape, dtype=message.data.dtype)
|
|
210
249
|
# out_newaxis will have first timestamp in input... but mostly meaningless because output is size-zero.
|
|
211
|
-
|
|
250
|
+
win_offset = axis_info.offset
|
|
251
|
+
|
|
252
|
+
if self.settings.anchor == Anchor.END:
|
|
253
|
+
win_offset += self.settings.window_dur
|
|
254
|
+
elif self.settings.anchor == Anchor.MIDDLE:
|
|
255
|
+
win_offset += self.settings.window_dur / 2
|
|
256
|
+
self._state.out_newaxis = replace(self._state.out_newaxis, offset=win_offset)
|
|
212
257
|
|
|
213
258
|
msg_out = replace(
|
|
214
|
-
|
|
259
|
+
message,
|
|
260
|
+
data=out_dat,
|
|
261
|
+
dims=self._state.out_dims,
|
|
262
|
+
axes={**out_axes, _newaxis: self._state.out_newaxis},
|
|
215
263
|
)
|
|
264
|
+
return msg_out
|
|
216
265
|
|
|
217
266
|
|
|
218
|
-
class WindowSettings
|
|
219
|
-
axis: str | None = None
|
|
220
|
-
newaxis: str | None = None # new axis for output. No new axes if None
|
|
221
|
-
window_dur: float | None = None # Sec. passthrough if None
|
|
222
|
-
window_shift: float | None = None # Sec. Use "1:1 mode" if None
|
|
223
|
-
zero_pad_until: str = "full" # "full", "shift", "input", "none"
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
class WindowState(ez.State):
|
|
227
|
-
cur_settings: WindowSettings
|
|
228
|
-
gen: typing.Generator
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
class Window(GenAxisArray):
|
|
232
|
-
""":obj:`Unit` for :obj:`bandpower`."""
|
|
233
|
-
|
|
267
|
+
class Window(BaseTransformerUnit[WindowSettings, AxisArray, AxisArray, WindowTransformer]):
|
|
234
268
|
SETTINGS = WindowSettings
|
|
235
|
-
|
|
236
269
|
INPUT_SIGNAL = ez.InputStream(AxisArray)
|
|
237
270
|
OUTPUT_SIGNAL = ez.OutputStream(AxisArray)
|
|
238
271
|
|
|
239
|
-
def construct_generator(self):
|
|
240
|
-
self.STATE.gen = windowing(
|
|
241
|
-
axis=self.SETTINGS.axis,
|
|
242
|
-
newaxis=self.SETTINGS.newaxis,
|
|
243
|
-
window_dur=self.SETTINGS.window_dur,
|
|
244
|
-
window_shift=self.SETTINGS.window_shift,
|
|
245
|
-
zero_pad_until=self.SETTINGS.zero_pad_until,
|
|
246
|
-
)
|
|
247
|
-
|
|
248
272
|
@ez.subscriber(INPUT_SIGNAL, zero_copy=True)
|
|
249
273
|
@ez.publisher(OUTPUT_SIGNAL)
|
|
250
|
-
|
|
274
|
+
@profile_subpub(trace_oldest=False)
|
|
275
|
+
async def on_signal(self, message: AxisArray) -> typing.AsyncGenerator:
|
|
276
|
+
"""
|
|
277
|
+
override superclass on_signal so we can opt to yield once or multiple times after dropping the win axis.
|
|
278
|
+
"""
|
|
279
|
+
# TODO: The transfomer overwrites settings.newaxis from None to "win",
|
|
280
|
+
# then we no longer know if the user wants to trim out the newaxis from the unit.
|
|
281
|
+
xp = get_namespace(message.data)
|
|
251
282
|
try:
|
|
252
|
-
|
|
253
|
-
if
|
|
254
|
-
if
|
|
255
|
-
self.SETTINGS.newaxis is not None
|
|
256
|
-
or self.SETTINGS.window_dur is None
|
|
257
|
-
):
|
|
283
|
+
ret = self.processor(message)
|
|
284
|
+
if ret.data.size > 0:
|
|
285
|
+
if self.SETTINGS.newaxis is not None or self.SETTINGS.window_dur is None:
|
|
258
286
|
# Multi-win mode or pass-through mode.
|
|
259
|
-
yield self.OUTPUT_SIGNAL,
|
|
287
|
+
yield self.OUTPUT_SIGNAL, ret
|
|
260
288
|
else:
|
|
261
289
|
# We need to split out_msg into multiple yields, dropping newaxis.
|
|
262
|
-
axis_idx =
|
|
263
|
-
win_axis =
|
|
264
|
-
offsets = (
|
|
265
|
-
|
|
266
|
-
+ win_axis.offset
|
|
267
|
-
)
|
|
268
|
-
for msg_ix in range(out_msg.data.shape[axis_idx]):
|
|
290
|
+
axis_idx = ret.get_axis_idx("win")
|
|
291
|
+
win_axis = ret.axes["win"]
|
|
292
|
+
offsets = win_axis.value(xp.asarray(range(ret.data.shape[axis_idx])))
|
|
293
|
+
for msg_ix in range(ret.data.shape[axis_idx]):
|
|
269
294
|
# Need to drop 'win' and replace self.SETTINGS.axis from axes.
|
|
270
295
|
_out_axes = {
|
|
271
|
-
**{
|
|
272
|
-
|
|
273
|
-
for k, v in out_msg.axes.items()
|
|
274
|
-
if k not in ["win", self.SETTINGS.axis]
|
|
275
|
-
},
|
|
276
|
-
self.SETTINGS.axis: replace(
|
|
277
|
-
out_msg.axes[self.SETTINGS.axis], offset=offsets[msg_ix]
|
|
278
|
-
),
|
|
296
|
+
**{k: v for k, v in ret.axes.items() if k not in ["win", self.SETTINGS.axis]},
|
|
297
|
+
self.SETTINGS.axis: replace(ret.axes[self.SETTINGS.axis], offset=offsets[msg_ix]),
|
|
279
298
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
data=slice_along_axis(
|
|
283
|
-
dims=
|
|
299
|
+
_ret = replace(
|
|
300
|
+
ret,
|
|
301
|
+
data=slice_along_axis(ret.data, msg_ix, axis_idx),
|
|
302
|
+
dims=ret.dims[:axis_idx] + ret.dims[axis_idx + 1 :],
|
|
284
303
|
axes=_out_axes,
|
|
285
304
|
)
|
|
286
|
-
yield self.OUTPUT_SIGNAL,
|
|
287
|
-
|
|
288
|
-
ez.logger.debug(f"Window closed in {self.address}")
|
|
305
|
+
yield self.OUTPUT_SIGNAL, _ret
|
|
306
|
+
|
|
289
307
|
except Exception:
|
|
290
308
|
ez.logger.info(traceback.format_exc())
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def windowing(
|
|
312
|
+
axis: str | None = None,
|
|
313
|
+
newaxis: str | None = None,
|
|
314
|
+
window_dur: float | None = None,
|
|
315
|
+
window_shift: float | None = None,
|
|
316
|
+
zero_pad_until: str = "full",
|
|
317
|
+
anchor: str | Anchor = Anchor.BEGINNING,
|
|
318
|
+
) -> WindowTransformer:
|
|
319
|
+
return WindowTransformer(
|
|
320
|
+
WindowSettings(
|
|
321
|
+
axis=axis,
|
|
322
|
+
newaxis=newaxis,
|
|
323
|
+
window_dur=window_dur,
|
|
324
|
+
window_shift=window_shift,
|
|
325
|
+
zero_pad_until=zero_pad_until,
|
|
326
|
+
anchor=anchor,
|
|
327
|
+
)
|
|
328
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ezmsg-sigproc
|
|
3
|
+
Version: 2.10.0
|
|
4
|
+
Summary: Timeseries signal processing implementations in ezmsg
|
|
5
|
+
Author-email: Griffin Milsap <griffin.milsap@gmail.com>, Preston Peranich <pperanich@gmail.com>, Chadwick Boulay <chadwick.boulay@gmail.com>, Kyle McGraw <kmcgraw@blackrockneuro.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.10.15
|
|
9
|
+
Requires-Dist: array-api-compat>=1.11.1
|
|
10
|
+
Requires-Dist: ezmsg-baseproc>=1.1.0
|
|
11
|
+
Requires-Dist: ezmsg>=3.6.0
|
|
12
|
+
Requires-Dist: numba>=0.61.0
|
|
13
|
+
Requires-Dist: numpy>=1.26.0
|
|
14
|
+
Requires-Dist: pywavelets>=1.6.0
|
|
15
|
+
Requires-Dist: scipy>=1.13.1
|
|
16
|
+
Requires-Dist: sparse>=0.15.4
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# ezmsg-sigproc
|
|
20
|
+
|
|
21
|
+
Signal processing primitives for the [ezmsg](https://www.ezmsg.org) message-passing framework.
|
|
22
|
+
|
|
23
|
+
## Features
|
|
24
|
+
|
|
25
|
+
* **Filtering** - Chebyshev, comb filters, and more
|
|
26
|
+
* **Spectral analysis** - Spectrogram, spectrum, and wavelet transforms
|
|
27
|
+
* **Resampling** - Downsample, decimate, and resample operations
|
|
28
|
+
* **Windowing** - Sliding windows and buffering utilities
|
|
29
|
+
* **Math operations** - Arithmetic, log, abs, difference, and more
|
|
30
|
+
* **Signal generation** - Synthetic signal generators
|
|
31
|
+
|
|
32
|
+
All modules use `AxisArray` as the primary data structure for passing signals between components.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
Install from PyPI:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install ezmsg-sigproc
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or install from GitHub for the latest development version:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install git+https://github.com/ezmsg-org/ezmsg-sigproc.git@dev
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Documentation
|
|
49
|
+
|
|
50
|
+
Full documentation is available at [ezmsg.org](https://www.ezmsg.org).
|
|
51
|
+
|
|
52
|
+
## Development
|
|
53
|
+
|
|
54
|
+
We use [`uv`](https://docs.astral.sh/uv/) for development.
|
|
55
|
+
|
|
56
|
+
1. Fork and clone the repository
|
|
57
|
+
2. `uv sync` to create a virtual environment and install dependencies
|
|
58
|
+
3. `uv run pre-commit install` to set up linting and formatting hooks
|
|
59
|
+
4. `uv run pytest tests` to run the test suite
|
|
60
|
+
5. Submit a PR against the `dev` branch
|