diff --git a/CHANGELOG.md b/CHANGELOG.md index e5909f577..9962dfb32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## PyNWB 2.8.3 (Upcoming) +### Enhancements +- Formally defined and renamed `ElectrodeTable` as the `ElectrodesTable` neurodata_type. @mavaylon1 [#1890](https://github.com/NeurodataWithoutBorders/pynwb/pull/1890) + ### Performance - Cache global type map to speed import 3X. @sneakers-the-rat [#1931](https://github.com/NeurodataWithoutBorders/pynwb/pull/1931) diff --git a/src/pynwb/ecephys.py b/src/pynwb/ecephys.py index 3187cca4a..aa13d9d54 100644 --- a/src/pynwb/ecephys.py +++ b/src/pynwb/ecephys.py @@ -1,7 +1,7 @@ import warnings from collections.abc import Iterable -from hdmf.common import DynamicTableRegion +from hdmf.common import DynamicTableRegion, DynamicTable, VectorData from hdmf.data_utils import DataChunkIterator, assertEqualShape from hdmf.utils import docval, popargs, get_docval, popargs_to_dict, get_data_shape @@ -37,6 +37,60 @@ def __init__(self, **kwargs): setattr(self, key, val) +@register_class('ElectrodesTable', CORE_NAMESPACE) +class ElectrodesTable(DynamicTable): + """A table of all electrodes (i.e. channels) used for recording. Introduced in NWB 3.0.0. Replaces the "electrodes" + table (neurodata_type_inc DynamicTable, no neurodata_type_def) that is part of NWBFile.""" + + __columns__ = ( + {'name': 'location', 'description': 'Location of the electrode (channel).', 'required': True}, + {'name': 'group', 'description': 'Reference to the ElectrodeGroup.', 'required': True}, + {'name': 'group_name', 'description': 'Name of the ElectrodeGroup.', 'required': False }) + + @docval({'name': 'x', 'type': VectorData, 'doc':'x coordinate of the channel location in the brain', + 'default': None}, + {'name': 'y', 'type': VectorData, 'doc':'y coordinate of the channel location in the brain', + 'default': None}, + {'name': 'z', 'type': VectorData, 'doc':'z coordinate of the channel location in the brain', + 'default': None}, + {'name': 'imp', 'type': VectorData, 'doc':'Impedance of the channel, in ohms.', 'default': None}, + {'name': 'filtering', 'type': VectorData, 'doc':'Description of hardware filtering.', 'default': None}, + {'name': 'rel_x', 'type': VectorData, 'doc':'x coordinate in electrode group', 'default': None}, + {'name': 'rel_y', 'type': VectorData, 'doc':'xy coordinate in electrode group', 'default': None}, + {'name': 'rel_z', 'type': VectorData, 'doc':'z coordinate in electrode group', 'default': None}, + {'name': 'reference', 'type': VectorData, 'default': None, + 'doc':'Description of the reference electrode and/or reference scheme used for this electrode'}, + *get_docval(DynamicTable.__init__, 'id', 'columns', 'colnames')) + def __init__(self, **kwargs): + kwargs['name'] = 'electrodes' + kwargs['description'] = 'metadata about extracellular electrodes' + + # optional fields + keys_to_set = ( + 'x', + 'y', + 'z', + 'imp', + 'filtering', + 'rel_x', + 'rel_y', + 'rel_z', + 'reference') + args_to_set = popargs_to_dict(keys_to_set, kwargs) + for key, val in args_to_set.items(): + setattr(self, key, val) + + super().__init__(**kwargs) + + def copy(self): + """ + Return a copy of this ElectrodesTable. + This is useful for linking. + """ + kwargs = dict(id=self.id, columns=self.columns, colnames=self.colnames) + return self.__class__(**kwargs) + + @register_class('ElectricalSeries', CORE_NAMESPACE) class ElectricalSeries(TimeSeries): """ diff --git a/src/pynwb/file.py b/src/pynwb/file.py index 7621df2ac..39a55b128 100644 --- a/src/pynwb/file.py +++ b/src/pynwb/file.py @@ -15,7 +15,7 @@ from .base import TimeSeries, ProcessingModule from .device import Device from .epoch import TimeIntervals -from .ecephys import ElectrodeGroup +from .ecephys import ElectrodeGroup, ElectrodesTable from .icephys import (IntracellularElectrode, SweepTable, PatchClampSeries, IntracellularRecordingsTable, SimultaneousRecordingsTable, SequentialRecordingsTable, RepetitionsTable, ExperimentalConditionsTable) @@ -374,7 +374,7 @@ class NWBFile(MultiContainerInterface, HERDManager): {'name': 'lab_meta_data', 'type': (list, tuple), 'default': None, 'doc': 'an extension that contains lab-specific meta-data'}, {'name': 'electrodes', 'type': DynamicTable, - 'doc': 'the ElectrodeTable that belongs to this NWBFile', 'default': None}, + 'doc': 'the ElectrodesTable that belongs to this NWBFile', 'default': None}, {'name': 'electrode_groups', 'type': Iterable, 'doc': 'the ElectrodeGroups that belong to this NWBFile', 'default': None}, {'name': 'ic_electrodes', 'type': (list, tuple), @@ -641,7 +641,7 @@ def add_epoch(self, **kwargs): def __check_electrodes(self): if self.electrodes is None: - self.electrodes = ElectrodeTable() + self.electrodes = ElectrodesTable() @docval(*get_docval(DynamicTable.add_column), allow_extra=True) def add_electrode_column(self, **kwargs): @@ -735,7 +735,7 @@ def create_electrode_table_region(self, **kwargs): for idx in region: if idx < 0 or idx >= len(self.electrodes): raise IndexError('The index ' + str(idx) + - ' is out of range for the ElectrodeTable of length ' + ' is out of range for the ElectrodesTable of length ' + str(len(self.electrodes))) desc = getargs('description', kwargs) name = getargs('name', kwargs) @@ -817,13 +817,13 @@ def add_invalid_time_interval(self, **kwargs): self.__check_invalid_times() self.invalid_times.add_interval(**kwargs) - @docval({'name': 'electrode_table', 'type': DynamicTable, 'doc': 'the ElectrodeTable for this file'}) + @docval({'name': 'electrode_table', 'type': ElectrodesTable, 'doc': 'the ElectrodesTable for this file'}) def set_electrode_table(self, **kwargs): """ - Set the electrode table of this NWBFile to an existing ElectrodeTable + Set the electrode table of this NWBFile to an existing ElectrodesTable """ if self.electrodes is not None: - msg = 'ElectrodeTable already exists, cannot overwrite' + msg = 'ElectrodesTable already exists, cannot overwrite' raise ValueError(msg) electrode_table = getargs('electrode_table', kwargs) self.electrodes = electrode_table @@ -1176,16 +1176,6 @@ def _tablefunc(table_name, description, columns): return t -def ElectrodeTable(name='electrodes', - description='metadata about extracellular electrodes'): - return _tablefunc(name, description, - [('location', 'the location of channel within the subject e.g. brain region'), - ('group', 'a reference to the ElectrodeGroup this electrode is a part of'), - ('group_name', 'the name of the ElectrodeGroup this electrode is a part of') - ] - ) - - def TrialTable(name='trials', description='metadata about experimental trials'): return _tablefunc(name, description, ['start_time', 'stop_time']) diff --git a/src/pynwb/io/file.py b/src/pynwb/io/file.py index 90e8f36b7..821fcf0d0 100644 --- a/src/pynwb/io/file.py +++ b/src/pynwb/io/file.py @@ -180,6 +180,24 @@ def scratch(self, builder, manager): ret.append(manager.construct(d)) return tuple(ret) if len(ret) > 0 else None + @ObjectMapper.constructor_arg('electrodes') + def electrodes(self, builder, manager): + try: + electrodes_builder = builder['general']['extracellular_ephys']['electrodes'] + except KeyError: + # Note: This is here because the ObjectMapper pulls argname from docval and checks to see + # if there is an override even if the file doesn't have what is looking for. In this case, + # electrodes for NWBFile. + electrodes_builder = None + if (electrodes_builder is not None and electrodes_builder.attributes['neurodata_type'] != 'ElectrodesTable'): + electrodes_builder.attributes['neurodata_type'] = 'ElectrodesTable' + electrodes_builder.attributes['namespace'] = 'core' + + new_container = manager.construct(electrodes_builder, True) + return new_container + else: + return None + @ObjectMapper.constructor_arg('session_start_time') def dateconversion(self, builder, manager): """Set the constructor arg for 'session_start_time' to a datetime object. diff --git a/src/pynwb/nwb-schema b/src/pynwb/nwb-schema index d65d42257..78df82c4b 160000 --- a/src/pynwb/nwb-schema +++ b/src/pynwb/nwb-schema @@ -1 +1 @@ -Subproject commit d65d42257003543c569ea7ac0cd6d7aee01c88d6 +Subproject commit 78df82c4bdc6f094a6dfb97fb85d9ae396087d91 diff --git a/src/pynwb/testing/mock/ecephys.py b/src/pynwb/testing/mock/ecephys.py index 0669e7493..fd634fb38 100644 --- a/src/pynwb/testing/mock/ecephys.py +++ b/src/pynwb/testing/mock/ecephys.py @@ -5,8 +5,8 @@ from hdmf.common.table import DynamicTableRegion, DynamicTable from ...device import Device -from ...file import ElectrodeTable, NWBFile -from ...ecephys import ElectricalSeries, ElectrodeGroup, SpikeEventSeries +from ...file import NWBFile +from ...ecephys import ElectricalSeries, ElectrodeGroup, SpikeEventSeries, ElectrodesTable from .device import mock_Device from .utils import name_generator from ...misc import Units @@ -35,10 +35,10 @@ def mock_ElectrodeGroup( return electrode_group -def mock_ElectrodeTable( +def mock_ElectrodesTable( n_rows: int = 5, group: Optional[ElectrodeGroup] = None, nwbfile: Optional[NWBFile] = None ) -> DynamicTable: - electrodes_table = ElectrodeTable() + electrodes_table = ElectrodesTable() group = group if group is not None else mock_ElectrodeGroup(nwbfile=nwbfile) for i in range(n_rows): electrodes_table.add_row( @@ -57,7 +57,7 @@ def mock_electrodes( n_electrodes: int = 5, table: Optional[DynamicTable] = None, nwbfile: Optional[NWBFile] = None ) -> DynamicTableRegion: - table = table or mock_ElectrodeTable(n_rows=5, nwbfile=nwbfile) + table = table or mock_ElectrodesTable(n_rows=5, nwbfile=nwbfile) return DynamicTableRegion( name="electrodes", data=list(range(n_electrodes)), @@ -80,7 +80,7 @@ def mock_ElectricalSeries( conversion: float = 1.0, offset: float = 0., ) -> ElectricalSeries: - + # Set a default rate if timestamps are not provided rate = 30_000.0 if (timestamps is None and rate is None) else rate diff --git a/tests/back_compat/2.6.0_DynamicTableElectrodes.nwb b/tests/back_compat/2.6.0_DynamicTableElectrodes.nwb new file mode 100644 index 000000000..e0f259403 Binary files /dev/null and b/tests/back_compat/2.6.0_DynamicTableElectrodes.nwb differ diff --git a/tests/integration/hdf5/test_ecephys.py b/tests/integration/hdf5/test_ecephys.py index ff67d27c9..2cdd15db0 100644 --- a/tests/integration/hdf5/test_ecephys.py +++ b/tests/integration/hdf5/test_ecephys.py @@ -14,7 +14,7 @@ FeatureExtraction, ) from pynwb.device import Device -from pynwb.file import ElectrodeTable as get_electrode_table +from pynwb.ecephys import ElectrodesTable as get_electrode_table from pynwb.testing import NWBH5IOMixin, AcquisitionH5IOMixin, NWBH5IOFlexMixin, TestCase diff --git a/tests/integration/hdf5/test_misc.py b/tests/integration/hdf5/test_misc.py index cd9ab1706..7ca29aa52 100644 --- a/tests/integration/hdf5/test_misc.py +++ b/tests/integration/hdf5/test_misc.py @@ -6,7 +6,7 @@ from pynwb.testing import NWBH5IOMixin, AcquisitionH5IOMixin, TestCase from pynwb.ecephys import ElectrodeGroup from pynwb.device import Device -from pynwb.file import ElectrodeTable as get_electrode_table +from pynwb.ecephys import ElectrodesTable as get_electrode_table class TestUnitsIO(AcquisitionH5IOMixin, TestCase): diff --git a/tests/integration/hdf5/test_nwbfile.py b/tests/integration/hdf5/test_nwbfile.py index e164ec649..081cd7ea7 100644 --- a/tests/integration/hdf5/test_nwbfile.py +++ b/tests/integration/hdf5/test_nwbfile.py @@ -508,6 +508,7 @@ def getContainer(self, nwbfile): def test_roundtrip(self): super().test_roundtrip() + # When comparing the pandas dataframes for the row we drop the 'group' column since the # ElectrodeGroup object after reading will naturally have a different address pd.testing.assert_frame_equal(self.read_container[0].drop('group', axis=1), diff --git a/tests/unit/test_ecephys.py b/tests/unit/test_ecephys.py index dc194af2a..ddd5706e1 100644 --- a/tests/unit/test_ecephys.py +++ b/tests/unit/test_ecephys.py @@ -14,9 +14,9 @@ FilteredEphys, FeatureExtraction, ElectrodeGroup, + ElectrodesTable ) from pynwb.device import Device -from pynwb.file import ElectrodeTable from pynwb.testing import TestCase from pynwb.testing.mock.ecephys import mock_ElectricalSeries @@ -24,7 +24,7 @@ def make_electrode_table(): - table = ElectrodeTable() + table = ElectrodesTable() dev1 = Device('dev1') group = ElectrodeGroup('tetrode1', 'tetrode description', 'tetrode location', dev1) table.add_row(location='CA1', group=group, group_name='tetrode1') diff --git a/tests/unit/test_file.py b/tests/unit/test_file.py index 8cb3415d9..43eb6f692 100644 --- a/tests/unit/test_file.py +++ b/tests/unit/test_file.py @@ -9,9 +9,9 @@ from hdmf.utils import docval, get_docval, popargs from pynwb import NWBFile, TimeSeries, NWBHDF5IO from pynwb.base import Image, Images -from pynwb.file import Subject, ElectrodeTable, _add_missing_timezone +from pynwb.file import Subject, _add_missing_timezone from pynwb.epoch import TimeIntervals -from pynwb.ecephys import ElectricalSeries +from pynwb.ecephys import ElectricalSeries, ElectrodesTable from pynwb.testing import TestCase, remove_test_file @@ -245,7 +245,7 @@ def test_add_acquisition_invalid_name(self): self.nwbfile.get_acquisition("TEST_TS") def test_set_electrode_table(self): - table = ElectrodeTable() + table = ElectrodesTable() dev1 = self.nwbfile.create_device('dev1') group = self.nwbfile.create_electrode_group('tetrode1', 'tetrode description', 'tetrode location', dev1) diff --git a/tests/unit/test_misc.py b/tests/unit/test_misc.py index 9350d1d2e..6ea8d24ba 100644 --- a/tests/unit/test_misc.py +++ b/tests/unit/test_misc.py @@ -3,9 +3,9 @@ from hdmf.common import DynamicTable, VectorData, DynamicTableRegion from pynwb.misc import AnnotationSeries, AbstractFeatureSeries, IntervalSeries, Units, DecompositionSeries -from pynwb.file import TimeSeries, ElectrodeTable as get_electrode_table +from pynwb.file import TimeSeries from pynwb.device import Device -from pynwb.ecephys import ElectrodeGroup +from pynwb.ecephys import ElectrodeGroup, ElectrodesTable as get_electrode_table from pynwb.testing import TestCase diff --git a/tests/unit/test_mock.py b/tests/unit/test_mock.py index 2ce777b65..2fd034a25 100644 --- a/tests/unit/test_mock.py +++ b/tests/unit/test_mock.py @@ -32,7 +32,7 @@ from pynwb.testing.mock.ecephys import ( mock_ElectrodeGroup, - mock_ElectrodeTable, + mock_ElectrodesTable, mock_ElectricalSeries, mock_SpikeEventSeries, mock_Units, @@ -70,7 +70,7 @@ mock_CompassDirection, mock_SpatialSeries, mock_ElectrodeGroup, - mock_ElectrodeTable, + mock_ElectrodesTable, mock_ElectricalSeries, mock_SpikeEventSeries, mock_Subject, @@ -121,4 +121,3 @@ def test_name_generator(): assert name_generator("TimeSeries") == "TimeSeries" assert name_generator("TimeSeries") == "TimeSeries2" -