Skip to content

Commit 99cc33d

Browse files
mavaylon1rlystephprince
authored
ElectrodesTable (#1890)
Co-authored-by: rly <[email protected]> Co-authored-by: Steph Prince <[email protected]> Fix missing __nwbfields__ for NWBData (#2082)
1 parent 3de7018 commit 99cc33d

File tree

16 files changed

+226
-136
lines changed

16 files changed

+226
-136
lines changed

CHANGELOG.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,27 @@
33
## Upcoming
44

55
### Breaking changes
6-
76
- Removed unused functions `prepend_string` and `_not_parent` in `core.py`, `_not_parent` in `file.py`, and `NWBBaseTypeMapper.get_nwb_file` in `io/core.py` @oruebel [#2036](https://github.com/NeurodataWithoutBorders/pynwb/pull/2036)
87

98
### Enhancements and minor changes
10-
- Added support for NWB Schema 2.9.0. @rly [#2079](https://github.com/NeurodataWithoutBorders/pynwb/pull/2079)
11-
- Added `BaseImage` and `ExternalImage` as new neurodata types. The first so both `Image` and `ExternalImage` can inherit from it. The second to store external images.
9+
- Added support for NWB Schema 2.9.0.
10+
- Added `BaseImage` and `ExternalImage` as new neurodata types. The first so both `Image` and `ExternalImage` can inherit from it. The second to store external images. @rly [#2079](https://github.com/NeurodataWithoutBorders/pynwb/pull/2079)
11+
- Added new `ElectrodesTable` neurodata type. @mavaylon1 [#1890](https://github.com/NeurodataWithoutBorders/pynwb/pull/1890)
12+
- Formally defined and renamed `ElectrodeTable` as the `ElectrodesTable` neurodata type. @mavaylon1 [#1890](https://github.com/NeurodataWithoutBorders/pynwb/pull/1890)
1213
- Automatically add timezone information to timestamps reference time if no timezone information is specified. @stephprince [#2056](https://github.com/NeurodataWithoutBorders/pynwb/pull/2056)
1314
- Added option to disable typemap caching and updated type map cache location. @stephprince [#2057](https://github.com/NeurodataWithoutBorders/pynwb/pull/2057)
1415
- Added dictionary-like operations directly on `ProcessingModule` objects (e.g., `len(processing_module)`). @bendichter [#2020](https://github.com/NeurodataWithoutBorders/pynwb/pull/2020)
1516
- When an external file is detected when initializing an ImageSeries and no format is provided, automatically set format to "external" instead of raising an error. @stephprince [#2060](https://github.com/NeurodataWithoutBorders/pynwb/pull/2060)
1617
- Added mask_type option to `mock_PlaneSegmentation`. @pauladkisson [#2067](https://github.com/NeurodataWithoutBorders/pynwb/pull/2067)
1718

1819
### Bug fixes
19-
- Fix `add_data_interface` functionality that was mistakenly removed in PyNWB 3.0. @stephprince [#2052](https://github.com/NeurodataWithoutBorders/pynwb/pull/2052)
20+
- Fixed `add_data_interface` functionality that was mistakenly removed in PyNWB 3.0. @stephprince [#2052](https://github.com/NeurodataWithoutBorders/pynwb/pull/2052)
2021
- Fixed bug in `IntracellularRecordingsTable.__init__` were `IntracellularResponsesTable` wasn't created correctly when custom category tables were provided @oruebel. [#2031](https://github.com/NeurodataWithoutBorders/pynwb/pull/2031)
2122
- Fixed shape check in `SpikeEventSeries.__init__` to support `AbstractDataChunkIterator` for timestamps/data. @oruebel [#2031](https://github.com/NeurodataWithoutBorders/pynwb/pull/2031)
2223
- Added unit tests to enhance coverage of `core.py`, `image.py`, `spec.py`, `icephys.py`, `epoch.py` and others. @oruebel [#2031](https://github.com/NeurodataWithoutBorders/pynwb/pull/2031)
23-
- Fix missing `IndexSeries.indexed_images`. @rly [#2074](https://github.com/NeurodataWithoutBorders/pynwb/pull/2074)
24-
24+
- Fixed missing `IndexSeries.indexed_images`. @rly [#2074](https://github.com/NeurodataWithoutBorders/pynwb/pull/2074)
25+
- Fixed missing `__nwbfields__` and `_fieldsname` for `NWBData` and its subclasses. @rly [#2082](https://github.com/NeurodataWithoutBorders/pynwb/pull/2082)
26+
2527
## PyNWB 3.0.0 (February 26, 2025)
2628

2729
### Breaking changes

src/pynwb/core.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ class NWBMixin(AbstractContainer):
2828

2929
_data_type_attr = 'neurodata_type'
3030

31+
_fieldsname = '__nwbfields__'
32+
33+
__nwbfields__ = tuple()
34+
3135
@docval({'name': 'neurodata_type', 'type': str, 'doc': 'the data_type to search for', 'default': None})
3236
def get_ancestor(self, **kwargs):
3337
"""
@@ -72,9 +76,7 @@ def data_type(self):
7276
@register_class('NWBContainer', CORE_NAMESPACE)
7377
class NWBContainer(NWBMixin, Container):
7478

75-
_fieldsname = '__nwbfields__'
76-
77-
__nwbfields__ = tuple()
79+
pass
7880

7981

8082
@register_class('NWBDataInterface', CORE_NAMESPACE)
@@ -110,8 +112,8 @@ def __getitem__(self, args):
110112
def append(self, arg):
111113
"""
112114
Append a single element to the data
113-
114-
Note: The arg to append should be 1 dimension less than the data.
115+
116+
Note: The arg to append should be 1 dimension less than the data.
115117
For example, if the data is a 2D array, arg should be a 1D array.
116118
Appending to scalar data is not supported. To append multiple
117119
elements, use extend.
@@ -172,7 +174,7 @@ def notes(self):
172174
warn(("Use of ScratchData.notes has been deprecated and will be removed in PyNWB 4.0. "
173175
"Use ScratchData.description instead."), DeprecationWarning)
174176
return self.description
175-
177+
176178
@notes.setter
177179
def notes(self, value):
178180
"""

src/pynwb/ecephys.py

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import numpy as np
33
from collections.abc import Iterable
44

5-
from hdmf.common import DynamicTableRegion
5+
from hdmf.common import DynamicTableRegion, DynamicTable
66
from hdmf.data_utils import assertEqualShape
77
from hdmf.utils import docval, popargs, get_docval, popargs_to_dict, get_data_shape, AllowPositional
88

@@ -18,7 +18,8 @@
1818
'EventDetection',
1919
'LFP',
2020
'FilteredEphys',
21-
'FeatureExtraction'
21+
'FeatureExtraction',
22+
'ElectrodesTable',
2223
]
2324

2425

@@ -67,6 +68,42 @@ def __init__(self, **kwargs):
6768
setattr(self, key, val)
6869

6970

71+
@register_class('ElectrodesTable', CORE_NAMESPACE)
72+
class ElectrodesTable(DynamicTable):
73+
"""A table of all electrodes (i.e. channels) used for recording. Introduced in NWB 3.0.0. Replaces the "electrodes"
74+
table (neurodata_type_inc DynamicTable, no neurodata_type_def) that is part of NWBFile."""
75+
76+
__columns__ = (
77+
{'name': 'location', 'description': 'Location of the electrode (channel).', 'required': True},
78+
{'name': 'group', 'description': 'Reference to the ElectrodeGroup.', 'required': True},
79+
{'name': 'group_name', 'description': 'Name of the ElectrodeGroup.', 'required': False},
80+
{'name': 'x', 'description': 'x coordinate of the channel location in the brain.', 'required': False},
81+
{'name': 'y', 'description': 'y coordinate of the channel location in the brain.', 'required': False},
82+
{'name': 'z', 'description': 'z coordinate of the channel location in the brain.', 'required': False},
83+
{'name': 'imp', 'description': 'Impedance of the channel, in ohms.', 'required': False},
84+
{'name': 'filtering', 'description': 'Description of hardware filtering.', 'required': False},
85+
{'name': 'rel_x', 'description': 'x coordinate in electrode group.', 'required': False},
86+
{'name': 'rel_y', 'description': 'xy coordinate in electrode group.', 'required': False},
87+
{'name': 'rel_z', 'description': 'z coordinate in electrode group.', 'required': False},
88+
{'name': 'reference', 'description': ('Description of the reference electrode and/or reference scheme used '
89+
'for this electrode.'), 'required': False}
90+
)
91+
92+
@docval(*get_docval(DynamicTable.__init__, 'id', 'columns', 'colnames'))
93+
def __init__(self, **kwargs):
94+
kwargs['name'] = 'electrodes'
95+
kwargs['description'] = 'metadata about extracellular electrodes'
96+
super().__init__(**kwargs)
97+
98+
def copy(self):
99+
"""
100+
Return a copy of this ElectrodesTable.
101+
This is useful for linking.
102+
"""
103+
kwargs = dict(id=self.id, columns=self.columns, colnames=self.colnames)
104+
return self.__class__(**kwargs)
105+
106+
70107
@register_class('ElectricalSeries', CORE_NAMESPACE)
71108
class ElectricalSeries(TimeSeries):
72109
"""
@@ -155,12 +192,12 @@ def __init__(self, **kwargs):
155192
# case where the data is a AbstractDataChunkIterator
156193
data_shape = get_data_shape(kwargs['data'], strict_no_data_load=True)
157194
timestamps_shape = get_data_shape(kwargs['timestamps'], strict_no_data_load=True)
158-
if (data_shape is not None and
159-
timestamps_shape is not None and
160-
len(data_shape) > 0 and
195+
if (data_shape is not None and
196+
timestamps_shape is not None and
197+
len(data_shape) > 0 and
161198
len(timestamps_shape) > 0):
162-
if (data_shape[0] != timestamps_shape[0] and
163-
data_shape[0] is not None and
199+
if (data_shape[0] != timestamps_shape[0] and
200+
data_shape[0] is not None and
164201
timestamps_shape[0] is not None):
165202
raise ValueError('Must provide the same number of timestamps and spike events')
166203
super().__init__(**kwargs)

src/pynwb/file.py

Lines changed: 21 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from .base import TimeSeries, ProcessingModule
1616
from .device import Device
1717
from .epoch import TimeIntervals
18-
from .ecephys import ElectrodeGroup
18+
from .ecephys import ElectrodeGroup, ElectrodesTable
1919
from .icephys import (IntracellularElectrode, SweepTable, PatchClampSeries, IntracellularRecordingsTable,
2020
SimultaneousRecordingsTable, SequentialRecordingsTable, RepetitionsTable,
2121
ExperimentalConditionsTable)
@@ -385,12 +385,12 @@ class NWBFile(MultiContainerInterface, HERDManager):
385385
{'name': 'lab_meta_data', 'type': (list, tuple), 'default': None,
386386
'doc': 'an extension that contains lab-specific meta-data'},
387387
{'name': 'electrodes', 'type': DynamicTable,
388-
'doc': 'the ElectrodeTable that belongs to this NWBFile', 'default': None},
388+
'doc': 'the ElectrodesTable that belongs to this NWBFile', 'default': None},
389389
{'name': 'electrode_groups', 'type': Iterable,
390390
'doc': 'the ElectrodeGroups that belong to this NWBFile', 'default': None},
391391
{'name': 'ic_electrodes', 'type': (list, tuple),
392392
'doc': 'DEPRECATED use icephys_electrodes parameter instead. '
393-
'IntracellularElectrodes that belong to this NWBFile', 'default': None},
393+
'IntracellularElectrodes that belong to this NWBFile', 'default': None},
394394
# TODO remove this arg in PyNWB 4.0
395395
{'name': 'sweep_table', 'type': SweepTable,
396396
'doc': '[DEPRECATED] Use IntracellularRecordingsTable instead. '
@@ -611,7 +611,7 @@ def add_epoch(self, **kwargs):
611611

612612
def __check_electrodes(self):
613613
if self.electrodes is None:
614-
self.electrodes = ElectrodeTable()
614+
self.electrodes = ElectrodesTable()
615615

616616
@docval(*get_docval(DynamicTable.add_column), allow_extra=True)
617617
def add_electrode_column(self, **kwargs):
@@ -666,31 +666,15 @@ def add_electrode(self, **kwargs):
666666
# are not allowed
667667
if not d['location']:
668668
raise ValueError("The 'location' argument is required when creating an electrode.")
669-
if not kwargs['group']:
669+
if not d['group']:
670670
raise ValueError("The 'group' argument is required when creating an electrode.")
671671
if d.get('group_name', None) is None:
672672
d['group_name'] = d['group'].name
673673

674-
new_cols = [('x', 'the x coordinate of the position (+x is posterior)'),
675-
('y', 'the y coordinate of the position (+y is inferior)'),
676-
('z', 'the z coordinate of the position (+z is right)'),
677-
('imp', 'the impedance of the electrode, in ohms'),
678-
('filtering', 'description of hardware filtering, including the filter name and frequency cutoffs'),
679-
('rel_x', 'the x coordinate within the electrode group'),
680-
('rel_y', 'the y coordinate within the electrode group'),
681-
('rel_z', 'the z coordinate within the electrode group'),
682-
('reference', 'Description of the reference electrode and/or reference scheme used for this \
683-
electrode, e.g.,"stainless steel skull screw" or "online common average referencing".')
684-
]
685-
686-
# add column if the arg is supplied and column does not yet exist
687-
# do not pass arg to add_row if arg is not supplied
688-
for col_name, col_doc in new_cols:
689-
if kwargs[col_name] is not None:
690-
if col_name not in self.electrodes:
691-
self.electrodes.add_column(col_name, col_doc)
692-
else:
693-
d.pop(col_name) # remove args from d if not set
674+
# remove keys that are None
675+
for key in list(d.keys()):
676+
if d[key] is None:
677+
d.pop(key)
694678

695679
self.electrodes.add_row(**d)
696680

@@ -705,7 +689,7 @@ def create_electrode_table_region(self, **kwargs):
705689
for idx in region:
706690
if idx < 0 or idx >= len(self.electrodes):
707691
raise IndexError('The index ' + str(idx) +
708-
' is out of range for the ElectrodeTable of length '
692+
' is out of range for the ElectrodesTable of length '
709693
+ str(len(self.electrodes)))
710694
desc = getargs('description', kwargs)
711695
name = getargs('name', kwargs)
@@ -787,13 +771,13 @@ def add_invalid_time_interval(self, **kwargs):
787771
self.__check_invalid_times()
788772
self.invalid_times.add_interval(**kwargs)
789773

790-
@docval({'name': 'electrode_table', 'type': DynamicTable, 'doc': 'the ElectrodeTable for this file'})
774+
@docval({'name': 'electrode_table', 'type': ElectrodesTable, 'doc': 'the ElectrodesTable for this file'})
791775
def set_electrode_table(self, **kwargs):
792776
"""
793-
Set the electrode table of this NWBFile to an existing ElectrodeTable
777+
Set the electrode table of this NWBFile to an existing ElectrodesTable
794778
"""
795779
if self.electrodes is not None:
796-
msg = 'ElectrodeTable already exists, cannot overwrite'
780+
msg = 'ElectrodesTable already exists, cannot overwrite'
797781
raise ValueError(msg)
798782
electrode_table = getargs('electrode_table', kwargs)
799783
self.electrodes = electrode_table
@@ -804,7 +788,7 @@ def _check_sweep_table(self):
804788
"""
805789
if self.sweep_table is None:
806790
if self._in_construct_mode:
807-
# Construct the SweepTable without triggering errors in construct mode because
791+
# Construct the SweepTable without triggering errors in construct mode because
808792
# SweepTable has been deprecated
809793
sweep_table = SweepTable.__new__(SweepTable, parent=self, in_construct_mode=True)
810794
sweep_table.__init__(name='sweep_table')
@@ -1146,19 +1130,16 @@ def _tablefunc(table_name, description, columns):
11461130
return t
11471131

11481132

1149-
def ElectrodeTable(name='electrodes',
1150-
description='metadata about extracellular electrodes'):
1151-
return _tablefunc(name, description,
1152-
[('location', 'the location of channel within the subject e.g. brain region'),
1153-
('group', 'a reference to the ElectrodeGroup this electrode is a part of'),
1154-
('group_name', 'the name of the ElectrodeGroup this electrode is a part of')
1155-
]
1156-
)
1157-
1158-
11591133
def TrialTable(name='trials', description='metadata about experimental trials'):
11601134
return _tablefunc(name, description, ['start_time', 'stop_time'])
11611135

11621136

11631137
def InvalidTimesTable(name='invalid_times', description='time intervals to be removed from analysis'):
11641138
return _tablefunc(name, description, ['start_time', 'stop_time'])
1139+
1140+
1141+
def ElectrodeTable(name='electrodes',
1142+
description='metadata about extracellular electrodes'):
1143+
warn("The ElectrodeTable convenience function is deprecated. Please create a new instance of "
1144+
"the ElectrodesTable class instead.", DeprecationWarning)
1145+
return ElectrodesTable()

src/pynwb/io/file.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,24 @@ def scratch(self, builder, manager):
181181
ret.append(manager.construct(d))
182182
return tuple(ret) if len(ret) > 0 else None
183183

184+
@ObjectMapper.constructor_arg('electrodes')
185+
def electrodes(self, builder, manager):
186+
try:
187+
electrodes_builder = builder['general']['extracellular_ephys']['electrodes']
188+
except KeyError:
189+
# Note: This is here because the ObjectMapper pulls argname from docval and checks to see
190+
# if there is an override even if the file doesn't have what is looking for. In this case,
191+
# electrodes for NWBFile.
192+
electrodes_builder = None
193+
if (electrodes_builder is not None and electrodes_builder.attributes['neurodata_type'] != 'ElectrodesTable'):
194+
electrodes_builder.attributes['neurodata_type'] = 'ElectrodesTable'
195+
electrodes_builder.attributes['namespace'] = 'core'
196+
manager.clear_cache()
197+
new_container = manager.construct(electrodes_builder)
198+
return new_container
199+
else:
200+
return None
201+
184202
@ObjectMapper.constructor_arg('session_start_time')
185203
def dateconversion(self, builder, manager):
186204
"""Set the constructor arg for 'session_start_time' to a datetime object.

src/pynwb/testing/make_test_files.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,30 @@ def _make_subject_without_age_reference():
214214
test_name = 'subject_no_age__reference'
215215
_write(test_name, nwbfile)
216216

217+
def _make_electrodes_dynamic_table():
218+
"""Create a test file where electrodes is a dynamic table and not its own type."""
219+
nwbfile = NWBFile(session_description='ADDME',
220+
identifier='ADDME',
221+
session_start_time=datetime.now().astimezone())
222+
device = nwbfile.create_device(name="array", description="an array", manufacturer="company")
223+
nwbfile.add_electrode_column(name="label", description="label of electrode")
224+
225+
for i in range(4):
226+
electrode_group = nwbfile.create_electrode_group(
227+
name=f"shank{i}",
228+
description=f"electrode group for shank {i}",
229+
device=device,
230+
location="brain area",
231+
)
232+
for j in range(3):
233+
nwbfile.add_electrode(
234+
group=electrode_group,
235+
location="brain area",
236+
label=f"shank{i}electrode{j}",
237+
)
238+
239+
test_name = 'electrodes_dynamic_table'
240+
_write(test_name, nwbfile)
217241

218242
if __name__ == '__main__':
219243
# install these versions of PyNWB and run this script to generate new files
@@ -242,3 +266,6 @@ def _make_subject_without_age_reference():
242266

243267
if __version__ == "2.2.0":
244268
_make_subject_without_age_reference()
269+
270+
if __version__ == "3.0.0":
271+
_make_electrodes_dynamic_table()

0 commit comments

Comments
 (0)