ezmsg-event 0.2__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/event/__init__.py +1 -0
- ezmsg/event/__version__.py +16 -0
- ezmsg/event/message.py +17 -0
- ezmsg/event/peak.py +329 -0
- ezmsg/event/rate.py +82 -0
- ezmsg/event/sparse.py +32 -0
- ezmsg/event/util/__init__.py +0 -0
- ezmsg/event/util/array.py +17 -0
- ezmsg/event/util/simulate.py +117 -0
- ezmsg/event/window.py +225 -0
- ezmsg/py.typed +0 -0
- ezmsg_event-0.2.0.dist-info/METADATA +16 -0
- ezmsg_event-0.2.0.dist-info/RECORD +15 -0
- ezmsg_event-0.2.0.dist-info/WHEEL +4 -0
- ezmsg_event-0.2.0.dist-info/licenses/LICENSE +21 -0
ezmsg/event/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .__version__ import __version__ as __version__
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# file generated by setuptools_scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
TYPE_CHECKING = False
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from typing import Tuple, Union
|
|
6
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
7
|
+
else:
|
|
8
|
+
VERSION_TUPLE = object
|
|
9
|
+
|
|
10
|
+
version: str
|
|
11
|
+
__version__: str
|
|
12
|
+
__version_tuple__: VERSION_TUPLE
|
|
13
|
+
version_tuple: VERSION_TUPLE
|
|
14
|
+
|
|
15
|
+
__version__ = version = '0.2.0'
|
|
16
|
+
__version_tuple__ = version_tuple = (0, 2, 0)
|
ezmsg/event/message.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from numbers import Number
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class EventMessage:
|
|
7
|
+
offset: float
|
|
8
|
+
"""The temporal offset at which the event occurred. This is a float in seconds. The reference point is
|
|
9
|
+
unspecified and depends on the clock the application uses. Most applications will use time.time."""
|
|
10
|
+
|
|
11
|
+
ch_idx: int
|
|
12
|
+
|
|
13
|
+
sub_idx: int = 0
|
|
14
|
+
"""The sub-index of the channel. For Blackrock multi-unit data: 0=unsorted, 1-5 sorted unit, >5=noise"""
|
|
15
|
+
|
|
16
|
+
value: Number = 1
|
|
17
|
+
"""The value of the event. This can be any number, but is usually an integer, and is often 1 for spikes."""
|
ezmsg/event/peak.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Detects peaks in a signal.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
import typing
|
|
7
|
+
|
|
8
|
+
import ezmsg.core as ez
|
|
9
|
+
from ezmsg.sigproc.base import GenAxisArray
|
|
10
|
+
from ezmsg.util.messages.axisarray import AxisArray
|
|
11
|
+
from ezmsg.util.generator import consumer
|
|
12
|
+
from ezmsg.sigproc.scaler import scaler_np
|
|
13
|
+
import numpy as np
|
|
14
|
+
import numpy.typing as npt
|
|
15
|
+
import sparse
|
|
16
|
+
|
|
17
|
+
from .message import EventMessage
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@consumer
|
|
21
|
+
def threshold_crossing(
|
|
22
|
+
threshold: float = -3.5,
|
|
23
|
+
max_peak_dur: float = 0.002,
|
|
24
|
+
refrac_dur: float = 0.001,
|
|
25
|
+
align_on_peak: bool = False,
|
|
26
|
+
return_peak_val: bool = False,
|
|
27
|
+
auto_scale_tau: float = 0.0,
|
|
28
|
+
) -> typing.Generator[
|
|
29
|
+
typing.Union[typing.List[EventMessage], AxisArray], AxisArray, None
|
|
30
|
+
]:
|
|
31
|
+
"""
|
|
32
|
+
Detect threshold crossing events.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
threshold: the value the signal must cross before the peak is found.
|
|
36
|
+
max_peak_dur: The maximum duration of a peak in seconds.
|
|
37
|
+
refrac_dur: The minimum duration between peaks in seconds. If 0 (default), no refractory period is enforced.
|
|
38
|
+
align_on_peak: If False (default), the returned sample index indicates the first sample across threshold.
|
|
39
|
+
If True, the sample index indicates the sample with the largest deviation after threshold crossing.
|
|
40
|
+
return_peak_val: If True then the peak value is included in the EventMessage or sparse matrix payload.
|
|
41
|
+
auto_scale_tau: If > 0, the data will be passed through a standard scaler prior to thresholding.
|
|
42
|
+
|
|
43
|
+
Note: If either align_on_peak or return_peak_val are True then it is necessary to find the actual peak and not
|
|
44
|
+
just the threshold crossing. This will drastically increase the computational demand. It is recommended to
|
|
45
|
+
tune max_peak_dur to a minimal-yet-reasonable value to limit the search space.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
A primed generator object that yields a list of :obj:`EventMessage` objects for every
|
|
49
|
+
:obj:`AxisArray` it receives via `send`.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
msg_out = AxisArray(np.array([]), dims=[""])
|
|
53
|
+
|
|
54
|
+
# Initialize state variables
|
|
55
|
+
sample_shape: typing.Optional[typing.Tuple[int, ...]] = None
|
|
56
|
+
fs: typing.Optional[float] = None
|
|
57
|
+
max_width: int = 0
|
|
58
|
+
min_width: int = 1 # Consider making this a parameter.
|
|
59
|
+
refrac_width: int = 0
|
|
60
|
+
|
|
61
|
+
scaler: typing.Optional[typing.Generator[AxisArray, AxisArray, None]] = None
|
|
62
|
+
# adaptive z-scoring.
|
|
63
|
+
# TODO: This sample-by-sample adaptation is probably overkill. ezmsg-sigproc should add chunk-wise scaler updating.
|
|
64
|
+
|
|
65
|
+
_overs: typing.Optional[npt.NDArray] = (
|
|
66
|
+
None # (n_feats, <=max_width) int == -1 or +1
|
|
67
|
+
)
|
|
68
|
+
# Trailing buffer to track whether the previous sample(s) were past threshold.
|
|
69
|
+
|
|
70
|
+
_data: typing.Optional[npt.NDArray] = None # (n_feats, <=max_width) in_dtype
|
|
71
|
+
# Trailing buffer in case peak spans sample chunks. Only used if align_on_peak or return_peak_val.
|
|
72
|
+
|
|
73
|
+
_data_raw: typing.Optional[npt.NDArray] = None # (n_feats, <=max_width) in_dtype
|
|
74
|
+
# Only used if return_peak_val and scaler is not None
|
|
75
|
+
|
|
76
|
+
_elapsed: typing.Optional[npt.NDArray] = None # (n_feats,) int
|
|
77
|
+
# Number of samples since last event. Used to enforce refractory period across iterations.
|
|
78
|
+
#
|
|
79
|
+
# _n_skip: typing.Optional[npt.NDArray] = None # (n_feats,) int
|
|
80
|
+
|
|
81
|
+
while True:
|
|
82
|
+
msg_in: AxisArray = yield msg_out
|
|
83
|
+
|
|
84
|
+
# Extract basic metadata from message
|
|
85
|
+
ax_idx = msg_in.get_axis_idx("time")
|
|
86
|
+
in_sample_shape = msg_in.data.shape[:ax_idx] + msg_in.data.shape[ax_idx + 1 :]
|
|
87
|
+
in_fs = 1 / msg_in.axes["time"].gain
|
|
88
|
+
|
|
89
|
+
# If metadata has changed substantially, then reset state variables
|
|
90
|
+
b_reset = sample_shape is None or sample_shape != in_sample_shape
|
|
91
|
+
b_reset = b_reset or fs != in_fs
|
|
92
|
+
if b_reset:
|
|
93
|
+
sample_shape = in_sample_shape
|
|
94
|
+
fs = in_fs
|
|
95
|
+
max_width = int(max_peak_dur * fs)
|
|
96
|
+
refrac_width = int(refrac_dur * fs)
|
|
97
|
+
if auto_scale_tau > 0:
|
|
98
|
+
scaler = scaler_np(time_constant=auto_scale_tau, axis="time")
|
|
99
|
+
_overs = None
|
|
100
|
+
_data = None
|
|
101
|
+
_data_raw = None
|
|
102
|
+
n_flat_feats = np.prod(sample_shape)
|
|
103
|
+
_elapsed = np.zeros((n_flat_feats,), dtype=int) + (refrac_width + 1)
|
|
104
|
+
# _n_skip = np.zeros((n_flat_feats,), dtype=int)
|
|
105
|
+
# TODO: Support > 2 dim output with pydata.sparse
|
|
106
|
+
other_dim = "*".join([_ for _ in msg_in.dims if _ != "time"])
|
|
107
|
+
out_axes = (
|
|
108
|
+
msg_in.axes.copy()
|
|
109
|
+
if msg_in.data.ndim == 2
|
|
110
|
+
else {"time": msg_in.axes["time"]}
|
|
111
|
+
)
|
|
112
|
+
template = AxisArray(
|
|
113
|
+
sparse.SparseArray((0, 0)), dims=[other_dim, "time"], axes=out_axes
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Optionally scale data
|
|
117
|
+
data_raw: typing.Optional[npt.NDArray] = None
|
|
118
|
+
if scaler is not None:
|
|
119
|
+
if return_peak_val:
|
|
120
|
+
data_raw = msg_in.data.copy()
|
|
121
|
+
msg_in = scaler.send(msg_in)
|
|
122
|
+
|
|
123
|
+
data = msg_in.data
|
|
124
|
+
|
|
125
|
+
# Put the time axis in the 0th dim.
|
|
126
|
+
if ax_idx != data.ndim - 1:
|
|
127
|
+
data = np.moveaxis(data, ax_idx, -1)
|
|
128
|
+
if data_raw is not None:
|
|
129
|
+
data_raw = np.moveaxis(data_raw, ax_idx, -1)
|
|
130
|
+
|
|
131
|
+
# Flatten the feature dimensions
|
|
132
|
+
if data.ndim > 2:
|
|
133
|
+
data = data.reshape((-1, data.shape[-1]))
|
|
134
|
+
if data_raw is not None:
|
|
135
|
+
data_raw = data_raw.reshape((-1, data_raw.shape[-1]))
|
|
136
|
+
|
|
137
|
+
# Check each sample relative to threshold.
|
|
138
|
+
overs = data >= threshold if threshold >= 0 else data <= threshold
|
|
139
|
+
|
|
140
|
+
# Prepend our variables with previous iteration's values. We always expect at least 1 sample to carry over.
|
|
141
|
+
overs = np.concatenate(
|
|
142
|
+
(_overs if _overs is not None else overs[..., :1], overs), axis=-1
|
|
143
|
+
)
|
|
144
|
+
# If we need to identify _where_ the peak was then we must prepend previous data values.
|
|
145
|
+
if align_on_peak or return_peak_val:
|
|
146
|
+
data = np.concatenate(
|
|
147
|
+
(_data if _data is not None else data[..., :1], data), axis=-1
|
|
148
|
+
)
|
|
149
|
+
# If we're doing z-scoring but we need the actual peak value then we must prepend previous RAW values too.
|
|
150
|
+
if return_peak_val and scaler is not None:
|
|
151
|
+
data_raw = np.concatenate(
|
|
152
|
+
(_data_raw if _data_raw is not None else data_raw[..., :1], data_raw),
|
|
153
|
+
axis=-1,
|
|
154
|
+
)
|
|
155
|
+
# We will modify _overs later, so for now we take note of how many samples were prepended.
|
|
156
|
+
n_prepended = 1 if _overs is None else _overs.shape[-1]
|
|
157
|
+
|
|
158
|
+
# Find threshold crossing where sample k is over and sample k-1 is not over.
|
|
159
|
+
b_cross_over = np.logical_and(~overs[..., :-1], overs[..., 1:])
|
|
160
|
+
|
|
161
|
+
feat_idx, samp_idx = np.where(b_cross_over)
|
|
162
|
+
# Because we looked at samples [1:]...
|
|
163
|
+
samp_idx += 1
|
|
164
|
+
# Note: There is an assumption that the 0th sample only serves as a reference and is not part of the output;
|
|
165
|
+
# this will be trimmed at the very end. For now the offset is useful for bookkeeping (peak finding, etc.).
|
|
166
|
+
|
|
167
|
+
# Optionally drop crossings during refractory period
|
|
168
|
+
if refrac_width > 2 and len(samp_idx) > 0:
|
|
169
|
+
# TODO: Consider putting this into its own unit. The downside to moving it out is that some of the remaining
|
|
170
|
+
# computation in this function is unnecessary for to-be-dropped events.
|
|
171
|
+
uq_feats, feat_splits = np.unique(feat_idx, return_index=True)
|
|
172
|
+
ieis = np.diff(np.hstack(([samp_idx[0] + 1], samp_idx)))
|
|
173
|
+
# Reset elapsed time at feature boundaries.
|
|
174
|
+
ieis[feat_splits] = samp_idx[feat_splits] + _elapsed[uq_feats]
|
|
175
|
+
b_drop = ieis <= refrac_width
|
|
176
|
+
drop_idx = np.where(b_drop)[0]
|
|
177
|
+
final_drop = []
|
|
178
|
+
while len(drop_idx) > 0:
|
|
179
|
+
d_idx = drop_idx[0]
|
|
180
|
+
# Update next iei so its interval refers to the event before the to-be-dropped event.
|
|
181
|
+
# but only if the next iei belongs to the same feature.
|
|
182
|
+
if ((d_idx + 1) < len(ieis)) and (d_idx + 1) not in feat_splits:
|
|
183
|
+
ieis[d_idx + 1] += ieis[d_idx]
|
|
184
|
+
# We will later remove this event from samp_idx and feat_idx
|
|
185
|
+
final_drop.append(d_idx)
|
|
186
|
+
# Remove the dropped event from drop_idx.
|
|
187
|
+
drop_idx = drop_idx[1:]
|
|
188
|
+
|
|
189
|
+
# If the next event is now outside the refractory period then it will not be dropped.
|
|
190
|
+
# TODO: Maybe we don't want this feature? e.g., if the 3rd in a triplet is not within the refractory
|
|
191
|
+
# period of the first, but it is within the refractory period of the 2nd which is now dropped,
|
|
192
|
+
# maybe the 3rd event should still be dropped? If so then we can refactor above to drop all at once.
|
|
193
|
+
if len(drop_idx) > 0 and ieis[drop_idx[0]] > refrac_width:
|
|
194
|
+
drop_idx = drop_idx[1:]
|
|
195
|
+
|
|
196
|
+
samp_idx = np.delete(samp_idx, final_drop)
|
|
197
|
+
feat_idx = np.delete(feat_idx, final_drop)
|
|
198
|
+
|
|
199
|
+
hold_idx = overs.shape[-1] - 1
|
|
200
|
+
if len(samp_idx) == 0:
|
|
201
|
+
# No events.
|
|
202
|
+
result_val = np.ones(
|
|
203
|
+
samp_idx.shape, dtype=data.dtype if return_peak_val else bool
|
|
204
|
+
)
|
|
205
|
+
elif not (min_width > 1 or align_on_peak or return_peak_val):
|
|
206
|
+
# No postprocessing required.
|
|
207
|
+
result_val = np.ones(samp_idx.shape, dtype=bool)
|
|
208
|
+
else:
|
|
209
|
+
# Do postprocessing of events.
|
|
210
|
+
|
|
211
|
+
# We extract max_width-length vectors of `overs` values for each event.
|
|
212
|
+
# Pad using last sample until last crossover has at least max_width following samples.
|
|
213
|
+
n_pad = max(0, max(samp_idx) + max_width - overs.shape[-1])
|
|
214
|
+
pad_width = ((0, 0),) * (overs.ndim - 1) + ((0, n_pad),)
|
|
215
|
+
overs_padded = np.pad(overs, pad_width, mode="edge")
|
|
216
|
+
|
|
217
|
+
# Multi-index to extract the vectors for each event
|
|
218
|
+
s_idx = np.arange(max_width)[None, :] + samp_idx[:, None]
|
|
219
|
+
ep_overs = overs_padded[feat_idx[:, None], s_idx] # (n_events, max_width)
|
|
220
|
+
|
|
221
|
+
# Find the event lengths: i.e., the first non-over-threshold value for each event.
|
|
222
|
+
# Warning: Values are invalid for events that don't cross back.
|
|
223
|
+
ev_len = ep_overs[..., 1:].argmin(axis=-1)
|
|
224
|
+
ev_len += 1
|
|
225
|
+
|
|
226
|
+
# Identify peaks that successfully cross back
|
|
227
|
+
b_ev_crossback = np.any(~ep_overs[..., 1:], axis=-1)
|
|
228
|
+
|
|
229
|
+
if min_width > 1:
|
|
230
|
+
# Drop events that have crossed back but fail min_width
|
|
231
|
+
b_short = np.logical_and(b_ev_crossback, ev_len < min_width)
|
|
232
|
+
b_long = ~b_short
|
|
233
|
+
samp_idx = samp_idx[b_long]
|
|
234
|
+
feat_idx = feat_idx[b_long]
|
|
235
|
+
ev_len = ev_len[b_long]
|
|
236
|
+
b_ev_crossback = b_ev_crossback[b_long]
|
|
237
|
+
|
|
238
|
+
# We are returning a sparse array and unfinished peaks must be buffered for the next iteration.
|
|
239
|
+
# Find the earliest unfinished event. If none, we still buffer the final sample.
|
|
240
|
+
b_unf = ~b_ev_crossback
|
|
241
|
+
hold_idx = samp_idx[b_unf].min() if np.any(b_unf) else hold_idx
|
|
242
|
+
|
|
243
|
+
# Trim events that are past the hold_idx. They will be processed next iteration.
|
|
244
|
+
b_pass_ev = samp_idx < hold_idx
|
|
245
|
+
samp_idx = samp_idx[b_pass_ev]
|
|
246
|
+
feat_idx = feat_idx[b_pass_ev]
|
|
247
|
+
ev_len = ev_len[b_pass_ev]
|
|
248
|
+
|
|
249
|
+
if np.any(b_unf):
|
|
250
|
+
# Must hold back at least 1 sample before start of unfinished events so we can re-detect.
|
|
251
|
+
hold_idx = max(hold_idx - 1, 0)
|
|
252
|
+
|
|
253
|
+
if not return_peak_val:
|
|
254
|
+
result_val = np.ones(samp_idx.shape, dtype=bool)
|
|
255
|
+
|
|
256
|
+
# For remaining _finished_ peaks, get the peak location -- for alignment or if returning its value.
|
|
257
|
+
if align_on_peak or return_peak_val:
|
|
258
|
+
# We process peaks in batches based on their length, otherwise short peaks could give
|
|
259
|
+
# incorrect argmax results.
|
|
260
|
+
# TODO: Check performance of using a masked array instead. Might take longer to create the mask.
|
|
261
|
+
pk_offset = np.zeros_like(ev_len)
|
|
262
|
+
uq_lens, len_grps = np.unique(ev_len, return_inverse=True)
|
|
263
|
+
for len_idx, ep_len in enumerate(uq_lens):
|
|
264
|
+
b_grp = len_grps == len_idx
|
|
265
|
+
ep_resamp = np.arange(ep_len)[None, :] + samp_idx[b_grp, None]
|
|
266
|
+
eps = data[feat_idx[b_grp, None], ep_resamp]
|
|
267
|
+
if threshold >= 0:
|
|
268
|
+
pk_offset[b_grp] = np.argmax(eps, axis=1)
|
|
269
|
+
else:
|
|
270
|
+
pk_offset[b_grp] = np.argmin(eps, axis=1)
|
|
271
|
+
|
|
272
|
+
# Now that we have the offset to the peak for each event, we can find the peak value and/or align.
|
|
273
|
+
if return_peak_val:
|
|
274
|
+
if scaler is None:
|
|
275
|
+
result_val = data[feat_idx, samp_idx + pk_offset]
|
|
276
|
+
else:
|
|
277
|
+
result_val = data_raw[feat_idx, samp_idx + pk_offset]
|
|
278
|
+
if align_on_peak:
|
|
279
|
+
samp_idx += pk_offset
|
|
280
|
+
|
|
281
|
+
# Save data for next iteration
|
|
282
|
+
_overs = overs[..., hold_idx:]
|
|
283
|
+
if align_on_peak or return_peak_val:
|
|
284
|
+
_data = data[..., hold_idx:]
|
|
285
|
+
if return_peak_val and scaler is not None:
|
|
286
|
+
_data_raw = data_raw[..., hold_idx:]
|
|
287
|
+
_elapsed += hold_idx
|
|
288
|
+
_elapsed[feat_idx] = hold_idx - samp_idx
|
|
289
|
+
# ^ multiple-write to same index is fine because last value is desired.
|
|
290
|
+
|
|
291
|
+
# Prepare sparse matrix output
|
|
292
|
+
# Note: The first of the "held" samples is part of this iteration's return.
|
|
293
|
+
# Likewise, the first prepended sample was part of the previous iteration's return.
|
|
294
|
+
n_out_samps = hold_idx
|
|
295
|
+
t0 = msg_in.axes["time"].offset - (n_prepended - 1) * msg_in.axes["time"].gain
|
|
296
|
+
samp_idx -= 1 # Discard first prepended sample.
|
|
297
|
+
result = sparse.COO(
|
|
298
|
+
(feat_idx, samp_idx),
|
|
299
|
+
data=result_val,
|
|
300
|
+
shape=data.shape[:-1] + (n_out_samps,),
|
|
301
|
+
)
|
|
302
|
+
msg_out = replace(
|
|
303
|
+
template,
|
|
304
|
+
data=result,
|
|
305
|
+
axes={**template.axes, "time": replace(template.axes["time"], offset=t0)},
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class ThresholdSettings(ez.Settings):
|
|
310
|
+
threshold: float = -3.5
|
|
311
|
+
max_peak_dur: float = 0.002
|
|
312
|
+
refrac_dur: float = 0.001
|
|
313
|
+
align_on_peak: bool = False
|
|
314
|
+
return_peak_val: bool = False
|
|
315
|
+
auto_scale_tau: float = 0.0
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class ThresholdCrossing(GenAxisArray):
|
|
319
|
+
SETTINGS = ThresholdSettings
|
|
320
|
+
|
|
321
|
+
def construct_generator(self):
|
|
322
|
+
self.STATE.gen = threshold_crossing(
|
|
323
|
+
threshold=self.SETTINGS.threshold,
|
|
324
|
+
max_peak_dur=self.SETTINGS.max_peak_dur,
|
|
325
|
+
refrac_dur=self.SETTINGS.refrac_dur,
|
|
326
|
+
align_on_peak=self.SETTINGS.align_on_peak,
|
|
327
|
+
return_peak_val=self.SETTINGS.return_peak_val,
|
|
328
|
+
auto_scale_tau=self.SETTINGS.auto_scale_tau,
|
|
329
|
+
)
|
ezmsg/event/rate.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Count number of events in a given time window. Optionally, divide by window duration to get rate.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
import typing
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import ezmsg.core as ez
|
|
10
|
+
from ezmsg.util.messages.axisarray import AxisArray
|
|
11
|
+
from ezmsg.util.generator import consumer
|
|
12
|
+
from ezmsg.sigproc.base import GenAxisArray
|
|
13
|
+
|
|
14
|
+
from .window import windowing
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@consumer
|
|
18
|
+
def event_rate(
|
|
19
|
+
bin_duration: float = 0.05,
|
|
20
|
+
) -> typing.Generator[AxisArray, AxisArray, None]:
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
bin_duration:
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
A primed generator object that yields an :obj:`AxisArray` object of event rates for every
|
|
29
|
+
:obj:`AxisArray` of sparse events it receives via `send`.
|
|
30
|
+
"""
|
|
31
|
+
msg_out = AxisArray(np.array([]), dims=[""])
|
|
32
|
+
|
|
33
|
+
win_proc = windowing(
|
|
34
|
+
axis="time",
|
|
35
|
+
newaxis="win",
|
|
36
|
+
window_dur=bin_duration,
|
|
37
|
+
window_shift=bin_duration,
|
|
38
|
+
zero_pad_until="none",
|
|
39
|
+
)
|
|
40
|
+
out_dims: typing.Optional[typing.List[str]] = None
|
|
41
|
+
out_axes: typing.Optional[typing.Dict[str, AxisArray.Axis]] = None
|
|
42
|
+
|
|
43
|
+
while True:
|
|
44
|
+
msg_in: AxisArray = yield msg_out
|
|
45
|
+
|
|
46
|
+
win_msg = win_proc.send(msg_in)
|
|
47
|
+
|
|
48
|
+
b_reset = out_dims is None
|
|
49
|
+
if b_reset:
|
|
50
|
+
# Fixup `dims`
|
|
51
|
+
out_dims = list(win_msg.dims)
|
|
52
|
+
out_dims.remove("time")
|
|
53
|
+
out_dims[out_dims.index("win")] = "time"
|
|
54
|
+
# Fixup axes
|
|
55
|
+
out_axes = {k: v for k, v in win_msg.axes.items() if k != "time"}
|
|
56
|
+
|
|
57
|
+
# Sum over time
|
|
58
|
+
time_ax = win_msg.get_axis_idx("time")
|
|
59
|
+
counts_per_bin = np.sum(win_msg.data, axis=time_ax)
|
|
60
|
+
# Scale by 1 / bin_duration to get rates
|
|
61
|
+
rates_per_bin = counts_per_bin / bin_duration
|
|
62
|
+
# Densify
|
|
63
|
+
rates_per_bin = rates_per_bin.todense()
|
|
64
|
+
msg_out = replace(
|
|
65
|
+
win_msg,
|
|
66
|
+
data=rates_per_bin,
|
|
67
|
+
dims=out_dims,
|
|
68
|
+
axes={**out_axes, "time": win_msg.axes["win"]},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class EventRateSettings(ez.Settings):
|
|
73
|
+
bin_duration: float = 0.05
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class EventRate(GenAxisArray):
|
|
77
|
+
SETTINGS = EventRateSettings
|
|
78
|
+
|
|
79
|
+
def construct_generator(self):
|
|
80
|
+
self.STATE.gen = event_rate(
|
|
81
|
+
bin_duration=self.SETTINGS.bin_duration,
|
|
82
|
+
)
|
ezmsg/event/sparse.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from dataclasses import replace
|
|
2
|
+
import typing
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import ezmsg.core as ez
|
|
6
|
+
from ezmsg.sigproc.base import GenAxisArray
|
|
7
|
+
from ezmsg.util.generator import consumer
|
|
8
|
+
from ezmsg.util.messages.axisarray import AxisArray
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@consumer
|
|
12
|
+
def to_dense() -> typing.Generator[AxisArray, AxisArray, None]:
|
|
13
|
+
msg_out = AxisArray(np.array([]), dims=[""])
|
|
14
|
+
while True:
|
|
15
|
+
msg_in: AxisArray = yield msg_out
|
|
16
|
+
if hasattr(msg_in.data, "todense"):
|
|
17
|
+
msg_out = replace(msg_in, data=msg_in.data.todense())
|
|
18
|
+
else:
|
|
19
|
+
msg_out = msg_in
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DensifySettings(ez.Settings):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Densify(GenAxisArray):
|
|
27
|
+
""":obj:`Unit` for :obj:`bandpower`."""
|
|
28
|
+
|
|
29
|
+
SETTINGS = DensifySettings
|
|
30
|
+
|
|
31
|
+
def construct_generator(self):
|
|
32
|
+
self.STATE.gen = to_dense()
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import sparse
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def sliding_win_oneaxis(
|
|
5
|
+
s: sparse.SparseArray, nwin: int, axis: int, step: int = 1
|
|
6
|
+
) -> sparse.SparseArray:
|
|
7
|
+
if -s.ndim <= axis < 0:
|
|
8
|
+
axis = s.ndim + axis
|
|
9
|
+
targ_slices = [slice(_, _ + nwin) for _ in range(0, s.shape[axis] - nwin + 1, step)]
|
|
10
|
+
s = s.reshape(s.shape[:axis] + (1,) + s.shape[axis:])
|
|
11
|
+
full_slices = (slice(None),) * s.ndim
|
|
12
|
+
full_slices = [
|
|
13
|
+
full_slices[: axis + 1] + (sl,) + full_slices[axis + 2 :] for sl in targ_slices
|
|
14
|
+
]
|
|
15
|
+
result = sparse.concatenate([s[_] for _ in full_slices], axis=axis)
|
|
16
|
+
# TODO: Profile this approach vs working with coords and values only.
|
|
17
|
+
return result
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import numpy.typing as npt
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def generate_events(
|
|
6
|
+
fs: float,
|
|
7
|
+
dur: float,
|
|
8
|
+
n_chans: int,
|
|
9
|
+
rate_range: tuple[float, float],
|
|
10
|
+
chunk_dur: float,
|
|
11
|
+
) -> list[npt.NDArray]:
|
|
12
|
+
n_times = int(fs * dur)
|
|
13
|
+
frates = np.random.uniform(rate_range[0], rate_range[1], n_chans)
|
|
14
|
+
frates[:3] = np.random.uniform(150, 200, 3) # Boost rate of first 3 chans.
|
|
15
|
+
chunk_len = int(fs * chunk_dur)
|
|
16
|
+
|
|
17
|
+
# Create a list of spike times for each channel
|
|
18
|
+
rng = np.random.default_rng()
|
|
19
|
+
spike_offsets = []
|
|
20
|
+
for ch_ix, fr in enumerate(frates):
|
|
21
|
+
lam, size = fs / fr, int(fr * dur)
|
|
22
|
+
isi = rng.poisson(lam=lam, size=size)
|
|
23
|
+
spike_samp_inds = np.cumsum(isi)
|
|
24
|
+
spike_samp_inds = spike_samp_inds[spike_samp_inds < n_times]
|
|
25
|
+
|
|
26
|
+
# Add some special cases
|
|
27
|
+
if ch_ix == 0:
|
|
28
|
+
# -- Refractory within chunk --
|
|
29
|
+
# In channel 0, we replace the first event with a triplet; events 2-3 will be eliminated by refractory check
|
|
30
|
+
spike_samp_inds = spike_samp_inds[spike_samp_inds > 30]
|
|
31
|
+
spike_samp_inds = np.hstack(([1, 4, 6], spike_samp_inds))
|
|
32
|
+
elif ch_ix in [1, 2]:
|
|
33
|
+
# -- Unfinished events at chunk boundaries --
|
|
34
|
+
# Drop spike samples within 34 samples of the end of the 0th chunk
|
|
35
|
+
b_drop = np.logical_and(
|
|
36
|
+
spike_samp_inds >= chunk_len - 34, spike_samp_inds < chunk_len
|
|
37
|
+
)
|
|
38
|
+
spike_samp_inds = spike_samp_inds[~b_drop]
|
|
39
|
+
if ch_ix == 1:
|
|
40
|
+
# In channel 1, we add a spike that is in the very last sample of the 0th chunk.
|
|
41
|
+
# It will be detected while processing the 1th chunk.
|
|
42
|
+
spike_samp_inds = np.insert(
|
|
43
|
+
spike_samp_inds,
|
|
44
|
+
np.searchsorted(spike_samp_inds, chunk_len),
|
|
45
|
+
chunk_len - 1,
|
|
46
|
+
)
|
|
47
|
+
elif ch_ix == 2:
|
|
48
|
+
# In channel 2, we make a long event at the end of the 0th chunk.
|
|
49
|
+
# It will be detected while processing the 1th chunk.
|
|
50
|
+
spike_samp_inds = np.insert(
|
|
51
|
+
spike_samp_inds,
|
|
52
|
+
np.searchsorted(spike_samp_inds, chunk_len - 10),
|
|
53
|
+
np.arange(chunk_len - 10, chunk_len),
|
|
54
|
+
)
|
|
55
|
+
elif ch_ix == 3:
|
|
56
|
+
# -- Refractoriness across chunk boundaries --
|
|
57
|
+
# In channel 3, we add a spike 2 samples before the end of 1th chunk, and another within its
|
|
58
|
+
# refractory period at the beginning of 2th chunk.
|
|
59
|
+
ins_ev_start = 2 * chunk_len - 2
|
|
60
|
+
# Clear events that are within target period.
|
|
61
|
+
b_drop = np.logical_and(
|
|
62
|
+
spike_samp_inds >= ins_ev_start - 30,
|
|
63
|
+
spike_samp_inds < ins_ev_start + 30,
|
|
64
|
+
)
|
|
65
|
+
spike_samp_inds = spike_samp_inds[~b_drop]
|
|
66
|
+
# Add the two events; one 2 samples before the chunk boundary and another 10 samples later.
|
|
67
|
+
spike_samp_inds = np.insert(
|
|
68
|
+
spike_samp_inds,
|
|
69
|
+
np.searchsorted(spike_samp_inds, ins_ev_start),
|
|
70
|
+
[ins_ev_start, ins_ev_start + 10],
|
|
71
|
+
)
|
|
72
|
+
# Note: We must also drop events in other channels near the end of chunk 2 to make sure
|
|
73
|
+
# they don't cause the event in channel 3 to be held back to the next iteration.
|
|
74
|
+
elif ch_ix == 4:
|
|
75
|
+
# -- Spike in first sample of non-first chunk --
|
|
76
|
+
# In channel 4, we add a spike at the very beginning of chunk 1th chunk after making sure 0th was empty.
|
|
77
|
+
spike_samp_inds = spike_samp_inds[spike_samp_inds > chunk_len]
|
|
78
|
+
spike_samp_inds = np.insert(
|
|
79
|
+
spike_samp_inds, np.searchsorted(spike_samp_inds, chunk_len), chunk_len
|
|
80
|
+
)
|
|
81
|
+
spike_offsets.append(spike_samp_inds)
|
|
82
|
+
|
|
83
|
+
# Clear all events that occur in 4th - 5th chunks to test flow logic.
|
|
84
|
+
# Additionally clear events in the last sample so we don't have lingering events.
|
|
85
|
+
for ch_ix, so_arr in enumerate(spike_offsets):
|
|
86
|
+
b_drop = np.logical_and(so_arr >= chunk_len * 3, so_arr < chunk_len * 5)
|
|
87
|
+
b_drop = np.logical_or(b_drop, so_arr == n_times - 1)
|
|
88
|
+
if ch_ix != 3:
|
|
89
|
+
# See above for special case in channel 3
|
|
90
|
+
b_drop = np.logical_or(
|
|
91
|
+
b_drop,
|
|
92
|
+
np.logical_and(so_arr >= 2 * chunk_len - 30, so_arr < 2 * chunk_len),
|
|
93
|
+
)
|
|
94
|
+
spike_offsets[ch_ix] = so_arr[~b_drop]
|
|
95
|
+
|
|
96
|
+
return spike_offsets
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def generate_white_noise_with_events(
|
|
100
|
+
fs: float,
|
|
101
|
+
dur: float,
|
|
102
|
+
n_chans: int,
|
|
103
|
+
rate_range: tuple[float, float],
|
|
104
|
+
chunk_dur: float,
|
|
105
|
+
threshold: float,
|
|
106
|
+
) -> npt.NDArray:
|
|
107
|
+
n_times = int(fs * dur)
|
|
108
|
+
spike_offsets = generate_events(fs, dur, n_chans, rate_range, chunk_dur)
|
|
109
|
+
|
|
110
|
+
rng = np.random.default_rng()
|
|
111
|
+
mixed = rng.normal(size=(n_times, n_chans), loc=0, scale=0.1)
|
|
112
|
+
mixed = np.clip(mixed, -np.abs(threshold), np.abs(threshold))
|
|
113
|
+
for ch_ix, ch_spk_offs in enumerate(spike_offsets):
|
|
114
|
+
mixed[ch_spk_offs, ch_ix] = threshold + np.random.random(
|
|
115
|
+
size=(len(ch_spk_offs),)
|
|
116
|
+
)
|
|
117
|
+
return mixed
|
ezmsg/event/window.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
from dataclasses import replace
|
|
2
|
+
import typing
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import sparse
|
|
6
|
+
import ezmsg.core as ez
|
|
7
|
+
from ezmsg.sigproc.base import GenAxisArray
|
|
8
|
+
from ezmsg.util.generator import consumer
|
|
9
|
+
from ezmsg.util.messages.axisarray import (
|
|
10
|
+
AxisArray,
|
|
11
|
+
slice_along_axis,
|
|
12
|
+
sliding_win_oneaxis,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from .util.array import sliding_win_oneaxis as sparse_sliding_win
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@consumer
|
|
19
|
+
def windowing(
|
|
20
|
+
axis: typing.Optional[str] = None,
|
|
21
|
+
newaxis: str = "win",
|
|
22
|
+
window_dur: typing.Optional[float] = None,
|
|
23
|
+
window_shift: typing.Optional[float] = None,
|
|
24
|
+
zero_pad_until: str = "input",
|
|
25
|
+
) -> typing.Generator[AxisArray, AxisArray, None]:
|
|
26
|
+
"""
|
|
27
|
+
Construct a generator that yields windows of data from an input :obj:`AxisArray`.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
axis: The axis along which to segment windows.
|
|
31
|
+
If None, defaults to the first dimension of the first seen AxisArray.
|
|
32
|
+
newaxis: New axis on which windows are delimited, immediately
|
|
33
|
+
preceding the target windowed axis. The data length along newaxis may be 0 if
|
|
34
|
+
this most recent push did not provide enough data for a new window.
|
|
35
|
+
If window_shift is None then the newaxis length will always be 1.
|
|
36
|
+
window_dur: The duration of the window in seconds.
|
|
37
|
+
If None, the function acts as a passthrough and all other parameters are ignored.
|
|
38
|
+
window_shift: The shift of the window in seconds.
|
|
39
|
+
If None (default), windowing operates in "1:1 mode", where each input yields exactly one most-recent window.
|
|
40
|
+
zero_pad_until: Determines how the function initializes the buffer.
|
|
41
|
+
Can be one of "input" (default), "full", "shift", or "none". If `window_shift` is None then this field is
|
|
42
|
+
ignored and "input" is always used.
|
|
43
|
+
|
|
44
|
+
- "input" (default) initializes the buffer with the input then prepends with zeros to the window size.
|
|
45
|
+
The first input will always yield at least one output.
|
|
46
|
+
- "shift" fills the buffer until `window_shift`.
|
|
47
|
+
No outputs will be yielded until at least `window_shift` data has been seen.
|
|
48
|
+
- "none" does not pad the buffer. No outputs will be yielded until at least `window_dur` data has been seen.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
A (primed) generator that accepts .send(an AxisArray object) and yields a list of windowed
|
|
52
|
+
AxisArray objects. The list will always be length-1 if `newaxis` is not None or `window_shift` is None.
|
|
53
|
+
"""
|
|
54
|
+
# Check arguments
|
|
55
|
+
if newaxis is None:
|
|
56
|
+
ez.logger.warning("`newaxis` must not be None. Setting to 'win'.")
|
|
57
|
+
newaxis = "win"
|
|
58
|
+
if window_shift is None and zero_pad_until != "input":
|
|
59
|
+
ez.logger.warning(
|
|
60
|
+
"`zero_pad_until` must be 'input' if `window_shift` is None. "
|
|
61
|
+
f"Ignoring received argument value: {zero_pad_until}"
|
|
62
|
+
)
|
|
63
|
+
zero_pad_until = "input"
|
|
64
|
+
elif window_shift is not None and zero_pad_until == "input":
|
|
65
|
+
ez.logger.warning(
|
|
66
|
+
"windowing is non-deterministic with `zero_pad_until='input'` as it depends on the size "
|
|
67
|
+
"of the first input. We recommend using 'shift' when `window_shift` is float-valued."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
msg_out = AxisArray(np.array([]), dims=[""])
|
|
71
|
+
|
|
72
|
+
# State variables
|
|
73
|
+
buffer: typing.Optional[sparse.SparseArray] = None
|
|
74
|
+
window_samples: typing.Optional[int] = None
|
|
75
|
+
window_shift_samples: typing.Optional[int] = None
|
|
76
|
+
shift_deficit: int = 0
|
|
77
|
+
b_1to1 = window_shift is None
|
|
78
|
+
newaxis_warned: bool = b_1to1
|
|
79
|
+
out_newaxis: typing.Optional[AxisArray.Axis] = None
|
|
80
|
+
out_dims: typing.Optional[typing.List[str]] = None
|
|
81
|
+
|
|
82
|
+
check_inputs = {"samp_shape": None, "fs": None, "key": None}
|
|
83
|
+
|
|
84
|
+
while True:
|
|
85
|
+
msg_in: AxisArray = yield msg_out
|
|
86
|
+
|
|
87
|
+
if window_dur is None:
|
|
88
|
+
msg_out = msg_in
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
axis = axis or msg_in.dims[0]
|
|
92
|
+
axis_idx = msg_in.get_axis_idx(axis)
|
|
93
|
+
axis_info = msg_in.get_axis(axis)
|
|
94
|
+
fs = 1.0 / axis_info.gain
|
|
95
|
+
|
|
96
|
+
if not newaxis_warned and newaxis in msg_in.dims:
|
|
97
|
+
ez.logger.warning(
|
|
98
|
+
f"newaxis {newaxis} present in input dims. Using {newaxis}_win instead"
|
|
99
|
+
)
|
|
100
|
+
newaxis_warned = True
|
|
101
|
+
newaxis = f"{newaxis}_win"
|
|
102
|
+
|
|
103
|
+
samp_shape = msg_in.data.shape[:axis_idx] + msg_in.data.shape[axis_idx + 1 :]
|
|
104
|
+
|
|
105
|
+
# If buffer unset or input stats changed, create a new buffer
|
|
106
|
+
b_reset = buffer is None
|
|
107
|
+
b_reset = b_reset or samp_shape != check_inputs["samp_shape"]
|
|
108
|
+
b_reset = b_reset or fs != check_inputs["fs"]
|
|
109
|
+
b_reset = b_reset or msg_in.key != check_inputs["key"]
|
|
110
|
+
if b_reset:
|
|
111
|
+
# Update check variables
|
|
112
|
+
check_inputs["samp_shape"] = samp_shape
|
|
113
|
+
check_inputs["fs"] = fs
|
|
114
|
+
check_inputs["key"] = msg_in.key
|
|
115
|
+
|
|
116
|
+
window_samples = int(window_dur * fs)
|
|
117
|
+
if not b_1to1:
|
|
118
|
+
window_shift_samples = int(window_shift * fs)
|
|
119
|
+
if zero_pad_until == "none":
|
|
120
|
+
req_samples = window_samples
|
|
121
|
+
elif zero_pad_until == "shift" and not b_1to1:
|
|
122
|
+
req_samples = window_shift_samples
|
|
123
|
+
else: # i.e. zero_pad_until == "input"
|
|
124
|
+
req_samples = msg_in.data.shape[axis_idx]
|
|
125
|
+
n_zero = max(0, window_samples - req_samples)
|
|
126
|
+
buffer = sparse.zeros(
|
|
127
|
+
msg_in.data.shape[:axis_idx]
|
|
128
|
+
+ (n_zero,)
|
|
129
|
+
+ msg_in.data.shape[axis_idx + 1 :]
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Add new data to buffer.
|
|
133
|
+
buffer = sparse.concatenate((buffer, msg_in.data), axis=axis_idx)
|
|
134
|
+
|
|
135
|
+
# Create a vector of buffer timestamps to track axis `offset` in output(s)
|
|
136
|
+
buffer_offset = np.arange(buffer.shape[axis_idx]).astype(float)
|
|
137
|
+
# Adjust so first _new_ sample at index 0
|
|
138
|
+
buffer_offset -= buffer_offset[-msg_in.data.shape[axis_idx]]
|
|
139
|
+
# Convert form indices to 'units' (probably seconds).
|
|
140
|
+
buffer_offset *= axis_info.gain
|
|
141
|
+
buffer_offset += axis_info.offset
|
|
142
|
+
|
|
143
|
+
if not b_1to1 and shift_deficit > 0:
|
|
144
|
+
n_skip = min(buffer.shape[axis_idx], shift_deficit)
|
|
145
|
+
if n_skip > 0:
|
|
146
|
+
buffer = slice_along_axis(buffer, slice(n_skip, None), axis_idx)
|
|
147
|
+
buffer_offset = buffer_offset[n_skip:]
|
|
148
|
+
shift_deficit -= n_skip
|
|
149
|
+
|
|
150
|
+
# Prepare reusable parts of output
|
|
151
|
+
if out_newaxis is None:
|
|
152
|
+
out_dims = msg_in.dims[:axis_idx] + [newaxis] + msg_in.dims[axis_idx:]
|
|
153
|
+
out_newaxis = replace(
|
|
154
|
+
axis_info,
|
|
155
|
+
gain=0.0 if b_1to1 else axis_info.gain * window_shift_samples,
|
|
156
|
+
offset=0.0, # offset modified per-msg below
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Generate outputs.
|
|
160
|
+
# Preliminary copy of axes without the axes that we are modifying.
|
|
161
|
+
out_axes = {k: v for k, v in msg_in.axes.items() if k not in [newaxis, axis]}
|
|
162
|
+
|
|
163
|
+
# Update targeted (windowed) axis so that its offset is relative to the new axis
|
|
164
|
+
# TODO: If we have `anchor_newest=True` then offset should be -win_dur
|
|
165
|
+
out_axes[axis] = replace(axis_info, offset=0.0)
|
|
166
|
+
|
|
167
|
+
# How we update .data and .axes[newaxis] depends on the windowing mode.
|
|
168
|
+
if b_1to1:
|
|
169
|
+
# one-to-one mode -- Each send yields exactly one window containing only the most recent samples.
|
|
170
|
+
buffer = slice_along_axis(buffer, slice(-window_samples, None), axis_idx)
|
|
171
|
+
out_dat = buffer.reshape(
|
|
172
|
+
buffer.shape[:axis_idx] + (1,) + buffer.shape[axis_idx:]
|
|
173
|
+
)
|
|
174
|
+
out_newaxis = replace(out_newaxis, offset=buffer_offset[-window_samples])
|
|
175
|
+
elif buffer.shape[axis_idx] >= window_samples:
|
|
176
|
+
# Deterministic window shifts.
|
|
177
|
+
out_dat = sparse_sliding_win(
|
|
178
|
+
buffer, window_samples, axis_idx, step=window_shift_samples
|
|
179
|
+
)
|
|
180
|
+
offset_view = sliding_win_oneaxis(buffer_offset, window_samples, 0)[
|
|
181
|
+
::window_shift_samples
|
|
182
|
+
]
|
|
183
|
+
out_newaxis = replace(out_newaxis, offset=offset_view[0, 0])
|
|
184
|
+
|
|
185
|
+
# Drop expired beginning of buffer and update shift_deficit
|
|
186
|
+
multi_shift = window_shift_samples * out_dat.shape[axis_idx]
|
|
187
|
+
shift_deficit = max(0, multi_shift - buffer.shape[axis_idx])
|
|
188
|
+
buffer = slice_along_axis(buffer, slice(multi_shift, None), axis_idx)
|
|
189
|
+
else:
|
|
190
|
+
# Not enough data to make a new window. Return empty data.
|
|
191
|
+
empty_data_shape = (
|
|
192
|
+
msg_in.data.shape[:axis_idx]
|
|
193
|
+
+ (0, window_samples)
|
|
194
|
+
+ msg_in.data.shape[axis_idx + 1 :]
|
|
195
|
+
)
|
|
196
|
+
out_dat = sparse.zeros(empty_data_shape, dtype=msg_in.data.dtype)
|
|
197
|
+
# out_newaxis will have first timestamp in input... but mostly meaningless because output is size-zero.
|
|
198
|
+
out_newaxis = replace(out_newaxis, offset=axis_info.offset)
|
|
199
|
+
|
|
200
|
+
msg_out = replace(
|
|
201
|
+
msg_in, data=out_dat, dims=out_dims, axes={**out_axes, newaxis: out_newaxis}
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class WindowSettings(ez.Settings):
|
|
206
|
+
axis: typing.Optional[str] = None
|
|
207
|
+
newaxis: typing.Optional[str] = None # new axis for output. No new axes if None
|
|
208
|
+
window_dur: typing.Optional[float] = None # Sec. passthrough if None
|
|
209
|
+
window_shift: typing.Optional[float] = None # Sec. Use "1:1 mode" if None
|
|
210
|
+
zero_pad_until: str = "full" # "full", "shift", "input", "none"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class Window(GenAxisArray):
|
|
214
|
+
""":obj:`Unit` for :obj:`bandpower`."""
|
|
215
|
+
|
|
216
|
+
SETTINGS = WindowSettings
|
|
217
|
+
|
|
218
|
+
def construct_generator(self):
|
|
219
|
+
self.STATE.gen = windowing(
|
|
220
|
+
axis=self.SETTINGS.axis,
|
|
221
|
+
newaxis=self.SETTINGS.newaxis,
|
|
222
|
+
window_dur=self.SETTINGS.window_dur,
|
|
223
|
+
window_shift=self.SETTINGS.window_shift,
|
|
224
|
+
zero_pad_until=self.SETTINGS.zero_pad_until,
|
|
225
|
+
)
|
ezmsg/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: ezmsg-event
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Namespace package for ezmsg to work with signal events like neural spikes and heartbeats
|
|
5
|
+
Author-email: Chadwick Boulay <chadwick.boulay@gmail.com>
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: ezmsg-sigproc>=1.5.0
|
|
8
|
+
Requires-Dist: numpy>=1.26
|
|
9
|
+
Requires-Dist: sparse>=0.15.4
|
|
10
|
+
Provides-Extra: test
|
|
11
|
+
Requires-Dist: pytest>=8.3.3; extra == 'test'
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# ezmsg-event
|
|
15
|
+
|
|
16
|
+
ezmsg namespace package for working with signal events like neural spikes and heartbeats
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ezmsg/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
ezmsg/event/__init__.py,sha256=8K4IcOA3-pfzadoM6s2Sfg5460KlJUocGgyTJTJl96U,52
|
|
3
|
+
ezmsg/event/__version__.py,sha256=H-qsvrxCpdhaQzyddR-yajEqI71hPxLa4KxzpP3uS1g,411
|
|
4
|
+
ezmsg/event/message.py,sha256=IXjmuWECZaU0NY0vb52hWbJvTP-d2NaKurBg4nOg7ZM,606
|
|
5
|
+
ezmsg/event/peak.py,sha256=EHENdpa9huKJIBiUHuzFLbq2HptY1RKgE96eNnkOe2w,15065
|
|
6
|
+
ezmsg/event/rate.py,sha256=uyj7M6R7_pJSGo4_GY74snrM_8eXYnvVtUjwrUScRgU,2224
|
|
7
|
+
ezmsg/event/sparse.py,sha256=FaRM4YLpFLqkLU0tNjrLvjh4Om9EzPs8rOpW8ajVc48,790
|
|
8
|
+
ezmsg/event/window.py,sha256=A8B412jczoxqEQa95d12_fU2pzeal-a-mW2NakP2qmA,9827
|
|
9
|
+
ezmsg/event/util/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
ezmsg/event/util/array.py,sha256=ZBqaf4jmsXSb56D9Hea7cWD9qJvszfv3x9X3NIUB1f8,653
|
|
11
|
+
ezmsg/event/util/simulate.py,sha256=-fBfFeEktjAn-mtNR_6E0QjijHMQTg08nn1q2B-ywqE,5127
|
|
12
|
+
ezmsg_event-0.2.0.dist-info/METADATA,sha256=ir4Vx3O6CV6w6s_Lf84hyn-nrNVngYcNsaDEJuFEyqA,540
|
|
13
|
+
ezmsg_event-0.2.0.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87
|
|
14
|
+
ezmsg_event-0.2.0.dist-info/licenses/LICENSE,sha256=KImy0vSiKLkW28zWqI8rIaLwvjGKxOjE5kZmVxZDdy4,1066
|
|
15
|
+
ezmsg_event-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 ezmsg-org
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|