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.
Files changed (66) hide show
  1. ezmsg/sigproc/__version__.py +22 -4
  2. ezmsg/sigproc/activation.py +31 -40
  3. ezmsg/sigproc/adaptive_lattice_notch.py +212 -0
  4. ezmsg/sigproc/affinetransform.py +171 -169
  5. ezmsg/sigproc/aggregate.py +190 -97
  6. ezmsg/sigproc/bandpower.py +60 -55
  7. ezmsg/sigproc/base.py +143 -33
  8. ezmsg/sigproc/butterworthfilter.py +34 -38
  9. ezmsg/sigproc/butterworthzerophase.py +305 -0
  10. ezmsg/sigproc/cheby.py +23 -17
  11. ezmsg/sigproc/combfilter.py +160 -0
  12. ezmsg/sigproc/coordinatespaces.py +159 -0
  13. ezmsg/sigproc/decimate.py +15 -10
  14. ezmsg/sigproc/denormalize.py +78 -0
  15. ezmsg/sigproc/detrend.py +28 -0
  16. ezmsg/sigproc/diff.py +82 -0
  17. ezmsg/sigproc/downsample.py +72 -81
  18. ezmsg/sigproc/ewma.py +217 -0
  19. ezmsg/sigproc/ewmfilter.py +1 -1
  20. ezmsg/sigproc/extract_axis.py +39 -0
  21. ezmsg/sigproc/fbcca.py +307 -0
  22. ezmsg/sigproc/filter.py +254 -148
  23. ezmsg/sigproc/filterbank.py +226 -214
  24. ezmsg/sigproc/filterbankdesign.py +129 -0
  25. ezmsg/sigproc/fir_hilbert.py +336 -0
  26. ezmsg/sigproc/fir_pmc.py +209 -0
  27. ezmsg/sigproc/firfilter.py +117 -0
  28. ezmsg/sigproc/gaussiansmoothing.py +89 -0
  29. ezmsg/sigproc/kaiser.py +106 -0
  30. ezmsg/sigproc/linear.py +120 -0
  31. ezmsg/sigproc/math/abs.py +23 -22
  32. ezmsg/sigproc/math/add.py +120 -0
  33. ezmsg/sigproc/math/clip.py +33 -25
  34. ezmsg/sigproc/math/difference.py +117 -43
  35. ezmsg/sigproc/math/invert.py +18 -25
  36. ezmsg/sigproc/math/log.py +38 -33
  37. ezmsg/sigproc/math/scale.py +24 -25
  38. ezmsg/sigproc/messages.py +1 -2
  39. ezmsg/sigproc/quantize.py +68 -0
  40. ezmsg/sigproc/resample.py +278 -0
  41. ezmsg/sigproc/rollingscaler.py +232 -0
  42. ezmsg/sigproc/sampler.py +209 -254
  43. ezmsg/sigproc/scaler.py +93 -218
  44. ezmsg/sigproc/signalinjector.py +44 -43
  45. ezmsg/sigproc/slicer.py +74 -102
  46. ezmsg/sigproc/spectral.py +3 -3
  47. ezmsg/sigproc/spectrogram.py +70 -70
  48. ezmsg/sigproc/spectrum.py +187 -173
  49. ezmsg/sigproc/transpose.py +134 -0
  50. ezmsg/sigproc/util/__init__.py +0 -0
  51. ezmsg/sigproc/util/asio.py +25 -0
  52. ezmsg/sigproc/util/axisarray_buffer.py +365 -0
  53. ezmsg/sigproc/util/buffer.py +449 -0
  54. ezmsg/sigproc/util/message.py +17 -0
  55. ezmsg/sigproc/util/profile.py +23 -0
  56. ezmsg/sigproc/util/sparse.py +115 -0
  57. ezmsg/sigproc/util/typeresolution.py +17 -0
  58. ezmsg/sigproc/wavelets.py +147 -154
  59. ezmsg/sigproc/window.py +248 -210
  60. ezmsg_sigproc-2.10.0.dist-info/METADATA +60 -0
  61. ezmsg_sigproc-2.10.0.dist-info/RECORD +65 -0
  62. {ezmsg_sigproc-1.7.0.dist-info → ezmsg_sigproc-2.10.0.dist-info}/WHEEL +1 -1
  63. ezmsg/sigproc/synth.py +0 -621
  64. ezmsg_sigproc-1.7.0.dist-info/METADATA +0 -58
  65. ezmsg_sigproc-1.7.0.dist-info/RECORD +0 -36
  66. /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 .base import GenAxisArray
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
- @consumer
19
- def windowing(
20
- axis: str | None = None,
21
- newaxis: str = "win",
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
- # State variables
75
- buffer: npt.NDArray | None = None
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
- # Number of incoming samples to ignore. Only relevant when shift > window.:
47
+
79
48
  shift_deficit: int = 0
80
- b_1to1 = window_shift is None
81
- newaxis_warned: bool = b_1to1
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
- while True:
88
- msg_in: AxisArray = yield msg_out
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
- if window_dur is None:
91
- msg_out = msg_in
92
- continue
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
- axis = axis or msg_in.dims[0]
95
- axis_idx = msg_in.get_axis_idx(axis)
96
- axis_info = msg_in.get_axis(axis)
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
- if not newaxis_warned and newaxis in msg_in.dims:
100
- ez.logger.warning(
101
- f"newaxis {newaxis} present in input dims. Using {newaxis}_win instead"
102
- )
103
- newaxis_warned = True
104
- newaxis = f"{newaxis}_win"
105
-
106
- samp_shape = msg_in.data.shape[:axis_idx] + msg_in.data.shape[axis_idx + 1 :]
107
-
108
- # If buffer unset or input stats changed, create a new buffer
109
- b_reset = buffer is None
110
- b_reset = b_reset or samp_shape != check_inputs["samp_shape"]
111
- b_reset = b_reset or fs != check_inputs["fs"]
112
- b_reset = b_reset or msg_in.key != check_inputs["key"]
113
- if b_reset:
114
- # Update check variables
115
- check_inputs["samp_shape"] = samp_shape
116
- check_inputs["fs"] = fs
117
- check_inputs["key"] = msg_in.key
118
-
119
- window_samples = int(window_dur * fs)
120
- if not b_1to1:
121
- window_shift_samples = int(window_shift * fs)
122
- if zero_pad_until == "none":
123
- req_samples = window_samples
124
- elif zero_pad_until == "shift" and not b_1to1:
125
- req_samples = window_shift_samples
126
- else: # i.e. zero_pad_until == "input"
127
- req_samples = msg_in.data.shape[axis_idx]
128
- n_zero = max(0, window_samples - req_samples)
129
- init_buffer_shape = (
130
- msg_in.data.shape[:axis_idx]
131
- + (n_zero,)
132
- + msg_in.data.shape[axis_idx + 1 :]
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
- buffer = np.zeros(init_buffer_shape, dtype=msg_in.data.dtype)
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 = np.concatenate((buffer, msg_in.data), axis=axis_idx)
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
- buffer_offset = np.arange(buffer.shape[axis_idx]).astype(float)
147
- # Adjust so first _new_ sample at index 0
148
- buffer_offset -= buffer_offset[-msg_in.data.shape[axis_idx]]
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
- buffer_offset *= axis_info.gain
151
- buffer_offset += axis_info.offset
194
+ buffer_t0 *= axis_info.gain
195
+ buffer_t0 += axis_info.offset
152
196
 
153
- if not b_1to1 and shift_deficit > 0:
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
- buffer_offset = buffer_offset[n_skip:]
158
- shift_deficit -= n_skip
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
- out_axes = {k: v for k, v in msg_in.axes.items() if k not in [newaxis, axis]}
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
- # TODO: If we have `anchor_newest=True` then offset should be -win_dur
175
- out_axes[axis] = replace(axis_info, offset=0.0)
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 b_1to1:
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(buffer, slice(-window_samples, None), axis_idx)
181
- out_dat = buffer.reshape(
182
- buffer.shape[:axis_idx] + (1,) + buffer.shape[axis_idx:]
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
- out_newaxis = replace(out_newaxis, offset=buffer_offset[-window_samples])
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
- # Note: After https://github.com/ezmsg-org/ezmsg/pull/152, add `window_shift_samples` as the last arg
188
- # to `sliding_win_oneaxis` and remove the call to `slice_along_axis`.
189
- out_dat = sliding_win_oneaxis(buffer, window_samples, axis_idx)
190
- out_dat = slice_along_axis(
191
- out_dat, slice(None, None, window_shift_samples), axis_idx
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
- offset_view = sliding_win_oneaxis(buffer_offset, window_samples, 0)[
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
- msg_in.data.shape[:axis_idx]
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 = np.zeros(empty_data_shape, dtype=msg_in.data.dtype)
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
- out_newaxis = replace(out_newaxis, offset=axis_info.offset)
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
- msg_in, data=out_dat, dims=out_dims, axes={**out_axes, newaxis: out_newaxis}
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(ez.Settings):
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
- async def on_signal(self, msg: AxisArray) -> typing.AsyncGenerator:
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
- out_msg = self.STATE.gen.send(msg)
253
- if out_msg.data.size > 0:
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, out_msg
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 = out_msg.get_axis_idx("win")
263
- win_axis = out_msg.axes["win"]
264
- offsets = (
265
- np.arange(out_msg.data.shape[axis_idx]) * win_axis.gain
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
- k: v
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
- _out_msg = replace(
281
- out_msg,
282
- data=slice_along_axis(out_msg.data, msg_ix, axis_idx),
283
- dims=out_msg.dims[:axis_idx] + out_msg.dims[axis_idx + 1 :],
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, _out_msg
287
- except (StopIteration, GeneratorExit):
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