Skip to content

Commit efbc778

Browse files
stephprincerly
andauthored
Add support for EventDetection with channel index (#2091)
* validate source_idx inputs for EventDetection * add tests for EventDetection schema updates * add 2D source index example for EventDetection * fix formatting and comments * update comments * update CHANGELOG * point to event detection schema branch * Update schema submodule --------- Co-authored-by: rly <[email protected]>
1 parent 87dcadc commit efbc778

File tree

5 files changed

+95
-5
lines changed

5 files changed

+95
-5
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
- Added new `ElectrodesTable` neurodata type. @mavaylon1 [#1890](https://github.com/NeurodataWithoutBorders/pynwb/pull/1890)
1212
- Formally defined and renamed `ElectrodeTable` as the `ElectrodesTable` neurodata type. @mavaylon1 [#1890](https://github.com/NeurodataWithoutBorders/pynwb/pull/1890)
1313
- Formally defined bands within `DecompositionSeries` as the neurodatatype `FrequencyBandsTable`. @mavaylon1 @rly [#2063](https://github.com/NeurodataWithoutBorders/pynwb/pull/2063)
14+
- Added support for 2D `EventDetection.source_index` to indicate [time_index, channel_index]. @stephprince [#2091](https://github.com/NeurodataWithoutBorders/pynwb/pull/2091)
15+
- Made `EventDetection.times` optional. @stephprince [#2091](https://github.com/NeurodataWithoutBorders/pynwb/pull/2091)
1416
- Automatically add timezone information to timestamps reference time if no timezone information is specified. @stephprince [#2056](https://github.com/NeurodataWithoutBorders/pynwb/pull/2056)
1517
- Added option to disable typemap caching and updated type map cache location. @stephprince [#2057](https://github.com/NeurodataWithoutBorders/pynwb/pull/2057)
1618
- Added dictionary-like operations directly on `ProcessingModule` objects (e.g., `len(processing_module)`). @bendichter [#2020](https://github.com/NeurodataWithoutBorders/pynwb/pull/2020)

docs/gallery/domain/ecephys.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,7 @@
381381
name="threshold_events",
382382
detection_method="thresholding, 1.5 * std",
383383
source_electricalseries=raw_electrical_series,
384-
source_idx=[1000, 2000, 3000],
384+
source_idx=[[1000, 0], [2000, 4], [3000, 8]], # indicates the time and channel indices
385385
times=[.033, .066, .099],
386386
)
387387

src/pynwb/ecephys.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -220,15 +220,30 @@ class EventDetection(NWBDataInterface):
220220
{'name': 'source_electricalseries', 'type': ElectricalSeries, 'doc': 'The source electrophysiology data'},
221221
{'name': 'source_idx', 'type': ('array_data', 'data'),
222222
'doc': 'Indices (zero-based) into source ElectricalSeries::data array corresponding '
223-
'to time of event. Module description should define what is meant by time of event '
224-
'(e.g., .25msec before action potential peak, zero-crossing time, etc). '
223+
'to time of event or time and channel of event. For 1D arrays, specifies the time '
224+
'index for each event. For 2D arrays with shape (num_events, 2), specifies '
225+
'[time_index, channel_index] for each event. Module description should define what is meant '
226+
'by time of event (e.g., .25msec before action potential peak, zero-crossing time, etc). '
225227
'The index points to each event from the raw data'},
226-
{'name': 'times', 'type': ('array_data', 'data'), 'doc': 'Timestamps of events, in Seconds'},
228+
{'name': 'times', 'type': ('array_data', 'data'), 'doc': 'Timestamps of events, in Seconds',
229+
'default': None},
227230
{'name': 'name', 'type': str, 'doc': 'the name of this container', 'default': 'EventDetection'},
228231
allow_positional=AllowPositional.WARNING,)
229232
def __init__(self, **kwargs):
230233
args_to_set = popargs_to_dict(('detection_method', 'source_electricalseries', 'source_idx', 'times'), kwargs)
231234
super().__init__(**kwargs)
235+
236+
# Validate source_idx shape
237+
source_idx = args_to_set['source_idx']
238+
source_idx_shape = get_data_shape(source_idx, strict_no_data_load=True)
239+
if source_idx_shape is not None:
240+
if len(source_idx_shape) == 2 and source_idx_shape[1] != 2:
241+
raise ValueError(f"EventDetection source_idx: 2D source_idx must have shape (num_events, 2) "
242+
f"for [time_index, channel_index], but got shape {source_idx_shape}")
243+
elif len(source_idx_shape) > 2:
244+
raise ValueError(f"EventDetection source_idx: source_idx must be 1D or 2D array, "
245+
f"but got {len(source_idx_shape)}D array with shape {source_idx_shape}")
246+
232247
for key, val in args_to_set.items():
233248
setattr(self, key, val)
234249
self.unit = 'seconds' # fixed value

tests/unit/test_ecephys.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,79 @@ def test_init(self):
290290
self.assertEqual(eD.times, (0.1, 0.2, 0.3))
291291
self.assertEqual(eD.unit, 'seconds')
292292

293+
def test_init_2d_source_idx(self):
294+
"""Test EventDetection with 2D source_idx containing time and channel indices"""
295+
data = np.random.rand(10, 2) # 10 time points, 2 channels
296+
ts = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
297+
_, region = self._create_table_and_region()
298+
eS = ElectricalSeries(name='test_eS', data=data, electrodes=region, timestamps=ts)
299+
300+
# 2D source_idx with shape (num_events, 2) for [time_index, channel_index]
301+
source_idx_2d = np.array([[1, 0], [2, 1], [3, 0],]) # 3 events
302+
times = (0.1, 0.2, 0.3)
303+
304+
eD = EventDetection(detection_method='threshold detection',
305+
source_electricalseries=eS,
306+
source_idx=source_idx_2d,
307+
times=times)
308+
309+
self.assertEqual(eD.detection_method, 'threshold detection')
310+
self.assertEqual(eD.source_electricalseries, eS)
311+
np.testing.assert_array_equal(eD.source_idx, source_idx_2d)
312+
self.assertEqual(eD.times, times)
313+
self.assertEqual(eD.unit, 'seconds')
314+
315+
def test_init_optional_times(self):
316+
"""Test EventDetection with optional times parameter (times=None)"""
317+
data = list(range(10))
318+
ts = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
319+
_, region = self._create_table_and_region()
320+
eS = ElectricalSeries(name='test_eS', data=data, electrodes=region, timestamps=ts)
321+
322+
eD = EventDetection(detection_method='detection_method',
323+
source_electricalseries=eS,
324+
source_idx=(1, 2, 3))
325+
326+
self.assertEqual(eD.detection_method, 'detection_method')
327+
self.assertEqual(eD.source_electricalseries, eS)
328+
self.assertEqual(eD.source_idx, (1, 2, 3))
329+
self.assertIsNone(eD.times)
330+
331+
def test_invalid_2d_source_idx_shape(self):
332+
"""Test error handling for invalid 2D source_idx shapes"""
333+
data = list(range(10))
334+
ts = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
335+
_, region = self._create_table_and_region()
336+
eS = ElectricalSeries(name='test_eS', data=data, electrodes=region, timestamps=ts)
337+
338+
# Test with invalid 2D shape (num_events, 3) - should be (num_events, 2)
339+
invalid_source_idx = np.array([[1, 0, 5], [2, 1, 6]])
340+
341+
msg = (f"EventDetection source_idx: 2D source_idx must have shape (num_events, 2) "
342+
f"for [time_index, channel_index], but got shape {invalid_source_idx.shape}")
343+
with self.assertRaisesWith(ValueError, msg):
344+
EventDetection(detection_method='detection_method',
345+
source_electricalseries=eS,
346+
source_idx=invalid_source_idx,
347+
times=(0.1, 0.2))
348+
349+
def test_invalid_3d_source_idx(self):
350+
"""Test error handling for 3D source_idx arrays"""
351+
data = list(range(10))
352+
ts = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
353+
_, region = self._create_table_and_region()
354+
eS = ElectricalSeries(name='test_eS', data=data, electrodes=region, timestamps=ts)
355+
356+
# test with 3D array - should raise ValueError
357+
invalid_source_idx = np.array([[[1, 0], [2, 1]], [[3, 0], [4, 1]]])
358+
359+
msg = (f"EventDetection source_idx: source_idx must be 1D or 2D array, "
360+
f"but got {len(invalid_source_idx.shape)}D array with shape {invalid_source_idx.shape}")
361+
with self.assertRaisesWith(ValueError, msg):
362+
EventDetection(detection_method='detection_method',
363+
source_electricalseries=eS,
364+
source_idx=invalid_source_idx,
365+
times=(0.1, 0.2))
293366

294367
class EventWaveformConstructor(TestCase):
295368

0 commit comments

Comments
 (0)