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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.26.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.