diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e47d051f..4892f297f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,13 @@ - 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) - Added new `ElectrodesTable` neurodata type. @mavaylon1 [#1890](https://github.com/NeurodataWithoutBorders/pynwb/pull/1890) - Formally defined and renamed `ElectrodeTable` as the `ElectrodesTable` neurodata type. @mavaylon1 [#1890](https://github.com/NeurodataWithoutBorders/pynwb/pull/1890) + - Formally defined bands within `DecompositionSeries` as the neurodatatype `FrequencyBandsTable`. @mavaylon1 @rly [#2063](https://github.com/NeurodataWithoutBorders/pynwb/pull/2063) - Automatically add timezone information to timestamps reference time if no timezone information is specified. @stephprince [#2056](https://github.com/NeurodataWithoutBorders/pynwb/pull/2056) - Added option to disable typemap caching and updated type map cache location. @stephprince [#2057](https://github.com/NeurodataWithoutBorders/pynwb/pull/2057) - Added dictionary-like operations directly on `ProcessingModule` objects (e.g., `len(processing_module)`). @bendichter [#2020](https://github.com/NeurodataWithoutBorders/pynwb/pull/2020) - 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) - Added mask_type option to `mock_PlaneSegmentation`. @pauladkisson [#2067](https://github.com/NeurodataWithoutBorders/pynwb/pull/2067) +- Updated minimum HDMF version to 4.1.0. @mavaylon1 @rly [#2063](https://github.com/NeurodataWithoutBorders/pynwb/pull/2063) ### Bug fixes - Fixed `add_data_interface` functionality that was mistakenly removed in PyNWB 3.0. @stephprince [#2052](https://github.com/NeurodataWithoutBorders/pynwb/pull/2052) diff --git a/environment-ros3.yml b/environment-ros3.yml index 8d946726b..eb080d0fb 100644 --- a/environment-ros3.yml +++ b/environment-ros3.yml @@ -6,7 +6,7 @@ channels: dependencies: - python==3.13 - h5py==3.12.1 - - hdmf==3.14.5 + - hdmf==4.1.0 - matplotlib==3.9.2 - numpy==2.1.3 - pandas==2.2.3 diff --git a/pyproject.toml b/pyproject.toml index 10e4c739d..17d4b38ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ classifiers = [ ] dependencies = [ "h5py>=3.2.0", - "hdmf>=3.14.5,<5", + "hdmf>=4.1.0,<6", "numpy>=1.24.0", "pandas>=1.2.0", "python-dateutil>=2.8.2", diff --git a/requirements-min.txt b/requirements-min.txt index a9f3bcb71..ac0bcfe1b 100644 --- a/requirements-min.txt +++ b/requirements-min.txt @@ -1,6 +1,6 @@ # minimum versions of package dependencies for installing PyNWB h5py==3.2.0 -hdmf==3.14.5 +hdmf==4.1.0 numpy==1.24.0 pandas==1.2.0 python-dateutil==2.8.2 diff --git a/requirements.txt b/requirements.txt index 16936de2e..5edb7d071 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ # pinned dependencies to reproduce an entire development environment to use PyNWB h5py==3.12.1 -hdmf==3.14.5 +hdmf==4.1.0 numpy==2.1.1; python_version > "3.9" # numpy 2.1+ is not compatible with py3.9 numpy==2.0.2; python_version == "3.9" pandas==2.2.3 diff --git a/src/pynwb/io/icephys.py b/src/pynwb/io/icephys.py index b95deff70..3515d85d8 100644 --- a/src/pynwb/io/icephys.py +++ b/src/pynwb/io/icephys.py @@ -26,11 +26,6 @@ def __init__(self, spec): @register_map(IntracellularRecordingsTable) class IntracellularRecordingsTableMap(AlignedDynamicTableMap): - """ - Customize the mapping for AlignedDynamicTable - """ - def __init__(self, spec): - super().__init__(spec) @DynamicTableMap.object_attr('electrodes') def electrodes(self, container, manager): diff --git a/src/pynwb/io/misc.py b/src/pynwb/io/misc.py index 0fd4afabc..1ee8d6280 100644 --- a/src/pynwb/io/misc.py +++ b/src/pynwb/io/misc.py @@ -1,15 +1,26 @@ from hdmf.common.io.table import DynamicTableMap +from .base import TimeSeriesMap from .. import register_map -from pynwb.misc import Units +from pynwb.misc import Units, DecompositionSeries + + +@register_map(DecompositionSeries) +class DecompositionSeriesMap(TimeSeriesMap): + + @TimeSeriesMap.constructor_arg('bands') + def bands(self, builder, manager): + if builder.groups['bands'].attributes['neurodata_type'] != 'FrequencyBandsTable': + builder.groups['bands'].attributes['neurodata_type'] = 'FrequencyBandsTable' + builder.groups['bands'].attributes['namespace'] = 'core' + manager.clear_cache() + new_container = manager.construct(builder.groups['bands']) + return new_container @register_map(Units) class UnitsMap(DynamicTableMap): - def __init__(self, spec): - super().__init__(spec) - @DynamicTableMap.constructor_arg('resolution') def resolution_carg(self, builder, manager): if 'spike_times' in builder: diff --git a/src/pynwb/misc.py b/src/pynwb/misc.py index e9141b9ae..8a7278aa8 100644 --- a/src/pynwb/misc.py +++ b/src/pynwb/misc.py @@ -253,13 +253,43 @@ def get_unit_obs_intervals(self, **kwargs): index = getargs('index', kwargs) return np.asarray(self['obs_intervals'][index]) +@register_class('FrequencyBandsTable', CORE_NAMESPACE) +class FrequencyBandsTable(DynamicTable): + """ + Table for describing the bands that DecompositionSeries was generated from. + """ + __columns__ = ( + {'name': 'band_name', 'description': 'Name of the band, e.g. theta.', 'required': True}, + {'name': 'band_limits', 'description': 'Low and high limit of each band in Hz.', 'required': True}, + {'name': 'band_mean', 'description': 'The mean Gaussian filters, in Hz.', 'required': False}, + {'name': 'band_stdev', 'description': 'The standard deviation Gaussian filters, in Hz.', 'required': False} + ) + + @docval(*get_docval(DynamicTable.__init__, 'id', 'columns', 'colnames')) + def __init__(self, **kwargs): + kwargs['name'] = 'bands' + kwargs['description'] = 'Table for describing the bands that DecompositionSeries was generated from.' + super().__init__(**kwargs) + + @docval( + {'name': 'band_name', 'type': str, 'doc': 'Name of the band, e.g. theta.'}, + {'name': 'band_limits', 'type': ('array_data', 'data'), 'shape': (2, ), + 'doc': 'Low and high limit of each band in Hz.'}, + {'name': 'band_mean', 'type': float, 'doc': 'The mean Gaussian filters, in Hz.', + 'default': None}, + {'name': 'band_stdev', 'type': float, 'doc': 'The standard deviation Gaussian filters, in Hz.', + 'default': None}, + allow_extra=True + ) + def add_band(self, **kwargs): + super().add_row(**kwargs) + @register_class('DecompositionSeries', CORE_NAMESPACE) class DecompositionSeries(TimeSeries): """ Stores product of spectral analysis """ - __nwbfields__ = ('metric', {'name': 'source_timeseries', 'child': False, 'doc': 'the input TimeSeries from this analysis'}, {'name': 'source_channels', 'child': True, 'doc': 'the channels that provided the source data'}, @@ -278,7 +308,7 @@ class DecompositionSeries(TimeSeries): {'name': 'metric', 'type': str, # required 'doc': "metric of analysis. recommended - 'phase', 'amplitude', 'power'"}, {'name': 'unit', 'type': str, 'doc': 'SI unit of measurement', 'default': 'no unit'}, - {'name': 'bands', 'type': DynamicTable, + {'name': 'bands', 'type': FrequencyBandsTable, 'doc': 'a table for describing the frequency bands that the signal was decomposed into', 'default': None}, {'name': 'source_timeseries', 'type': TimeSeries, 'doc': 'the input TimeSeries from this analysis', 'default': None}, @@ -302,39 +332,19 @@ def __init__(self, **kwargs): "corresponding source_channels. (Optional)") self.metric = metric if bands is None: - bands = DynamicTable( - name="bands", - description="data about the frequency bands that the signal was decomposed into" - ) + bands = FrequencyBandsTable() self.bands = bands - def __check_column(self, name, desc): - if name not in self.bands.colnames: - self.bands.add_column(name, desc) - - @docval({'name': 'band_name', 'type': str, 'doc': 'the name of the frequency band', - 'default': None}, - {'name': 'band_limits', 'type': ('array_data', 'data'), 'default': None, - 'doc': 'low and high frequencies of bandpass filter in Hz'}, - {'name': 'band_mean', 'type': float, 'doc': 'the mean of Gaussian filters in Hz', - 'default': None}, - {'name': 'band_stdev', 'type': float, 'doc': 'the standard deviation of Gaussian filters in Hz', - 'default': None}, - allow_extra=True) + @docval( + {'name': 'band_name', 'type': str, 'doc': 'the name of the frequency band'}, + {'name': 'band_limits', 'type': ('array_data', 'data'), + 'doc': 'low and high frequencies of bandpass filter in Hz'}, + {'name': 'band_mean', 'type': float, 'doc': 'the mean of Gaussian filters in Hz', + 'default': None}, + {'name': 'band_stdev', 'type': float, 'doc': 'the standard deviation of Gaussian filters in Hz', + 'default': None}, + allow_extra=True + ) def add_band(self, **kwargs): - """ - Add ROI data to this - """ - band_name, band_limits, band_mean, band_stdev = getargs('band_name', 'band_limits', 'band_mean', 'band_stdev', - kwargs) - if band_name is not None: - self.__check_column('band_name', "the name of the frequency band (recommended: 'alpha', 'beta', 'gamma', " - "'delta', 'high gamma'") - if band_name is not None: - self.__check_column('band_limits', 'low and high frequencies of bandpass filter in Hz') - if band_mean is not None: - self.__check_column('band_mean', 'the mean of Gaussian filters in Hz') - if band_stdev is not None: - self.__check_column('band_stdev', 'the standard deviation of Gaussian filters in Hz') - - self.bands.add_row({k: v for k, v in kwargs.items() if v is not None}) + """Add a frequency band to the bands table of this DecompositionSeries.""" + self.bands.add_band(**kwargs) diff --git a/src/pynwb/nwb-schema b/src/pynwb/nwb-schema index a8127bba9..362c09585 160000 --- a/src/pynwb/nwb-schema +++ b/src/pynwb/nwb-schema @@ -1 +1 @@ -Subproject commit a8127bba98c8df2c4aa10eca0cbbc08c2721b820 +Subproject commit 362c0958528868ac323b1d986ecba5050d0cbd36 diff --git a/src/pynwb/testing/make_test_files.py b/src/pynwb/testing/make_test_files.py index 073cf0372..06219a1b7 100644 --- a/src/pynwb/testing/make_test_files.py +++ b/src/pynwb/testing/make_test_files.py @@ -4,6 +4,7 @@ from pynwb import NWBFile, NWBHDF5IO, __version__, TimeSeries, get_class, load_namespaces from pynwb.file import Subject from pynwb.image import ImageSeries +from pynwb.misc import DecompositionSeries from pynwb.spec import NWBNamespaceBuilder, export_spec, NWBGroupSpec, NWBAttributeSpec @@ -235,10 +236,39 @@ def _make_electrodes_dynamic_table(): location="brain area", label=f"shank{i}electrode{j}", ) - + test_name = 'electrodes_dynamic_table' _write(test_name, nwbfile) +def _make_bands_dynamic_table(): + """Create a test file where electrodes is a dynamic table and not its own type.""" + nwbfile = NWBFile(session_description='ADDME', + identifier='ADDME', + session_start_time=datetime.now().astimezone()) + mod = nwbfile.create_processing_module(name="test_mod", description="a test module") + ts = TimeSeries( + name='dummy timeseries', + description='desc', + data=np.ones((3, 3)), + unit='Volts', + timestamps=np.ones((3,)) + ) + ds = DecompositionSeries( + name='LFPSpectralAnalysis', + description='my description', + data=np.ones((3, 3, 3)), + timestamps=[1., 2., 3.], + source_timeseries=ts, + metric='amplitude' + ) + for band_name in ['alpha', 'beta', 'gamma']: + ds.add_band(band_name=band_name, band_limits=np.array([1., 1.]), band_mean=1., band_stdev=1.) + mod.add(ts) + mod.add(ds) + + test_name = 'decompositionseries_bands_dynamic_table' + _write(test_name, nwbfile) + if __name__ == '__main__': # install these versions of PyNWB and run this script to generate new files # python src/pynwb/testing/make_test_files.py @@ -268,4 +298,5 @@ def _make_electrodes_dynamic_table(): _make_subject_without_age_reference() if __version__ == "3.0.0": - _make_electrodes_dynamic_table() \ No newline at end of file + _make_electrodes_dynamic_table() + _make_bands_dynamic_table() \ No newline at end of file diff --git a/tests/back_compat/3.0.0_decompositionseries_bands_dynamic_table.nwb b/tests/back_compat/3.0.0_decompositionseries_bands_dynamic_table.nwb new file mode 100644 index 000000000..4c9c6f45e Binary files /dev/null and b/tests/back_compat/3.0.0_decompositionseries_bands_dynamic_table.nwb differ diff --git a/tests/back_compat/test_read.py b/tests/back_compat/test_read.py index 4540f4874..c96bce40a 100644 --- a/tests/back_compat/test_read.py +++ b/tests/back_compat/test_read.py @@ -5,6 +5,7 @@ from pynwb import NWBHDF5IO, validate, TimeSeries from pynwb.ecephys import ElectrodesTable from pynwb.image import ImageSeries +from pynwb.misc import FrequencyBandsTable from pynwb.testing import TestCase @@ -138,9 +139,16 @@ def test_read_subject_no_age__reference(self): read_nwbfile = io.read() self.assertIsNone(read_nwbfile.subject.age__reference) - def test_read_electrodes_table_as_dynamic_table(self): + def test_read_electrodes_table_as_neurodata_type(self): """Test that an "electrodes" table written as a DynamicTable is read as an ElectrodesTable""" f = Path(__file__).parent / '3.0.0_electrodes_dynamic_table.nwb' with self.get_io(f) as io: read_nwbfile = io.read() assert isinstance(read_nwbfile.electrodes, ElectrodesTable) + + def test_read_bands_table_as_neurodata_type(self): + """Test that a "bands" table written as a DynamicTable is read as an FrequencyBandsTable""" + f = Path(__file__).parent / '3.0.0_decompositionseries_bands_dynamic_table.nwb' + with self.get_io(f) as io: + read_nwbfile = io.read() + assert isinstance(read_nwbfile.processing['test_mod']['LFPSpectralAnalysis'].bands, FrequencyBandsTable) \ No newline at end of file diff --git a/tests/integration/hdf5/test_misc.py b/tests/integration/hdf5/test_misc.py index 534175386..ab9744c16 100644 --- a/tests/integration/hdf5/test_misc.py +++ b/tests/integration/hdf5/test_misc.py @@ -1,8 +1,8 @@ import numpy as np -from hdmf.common import DynamicTable, VectorData, DynamicTableRegion +from hdmf.common import VectorData, DynamicTableRegion from pynwb import TimeSeries -from pynwb.misc import Units, DecompositionSeries +from pynwb.misc import Units, DecompositionSeries, FrequencyBandsTable from pynwb.testing import NWBH5IOMixin, AcquisitionH5IOMixin, TestCase from pynwb.ecephys import ElectrodeGroup, ElectrodesTable from pynwb.device import Device @@ -115,9 +115,7 @@ def setUpContainer(self): unit='flibs', timestamps=np.ones((3,)), ) - bands = DynamicTable( - name='bands', - description='band info for LFPSpectralAnalysis', + bands = FrequencyBandsTable( columns=[ VectorData(name='band_name', description='name of bands', data=['alpha', 'beta', 'gamma']), VectorData(name='band_limits', description='low and high cutoffs in Hz', data=np.ones((3, 2))), @@ -179,9 +177,7 @@ def setUpContainer(self): ) data = np.random.randn(100, 2, 30) timestamps = np.arange(100)/100 - bands = DynamicTable( - name='bands', - description='band info for LFPSpectralAnalysis', + bands = FrequencyBandsTable( columns=[ VectorData(name='band_name', description='name of bands', data=['alpha', 'beta', 'gamma']), VectorData(name='band_limits', description='low and high cutoffs in Hz', data=np.ones((3, 2))), diff --git a/tests/unit/test_misc.py b/tests/unit/test_misc.py index 342a86e98..9b4e372b7 100644 --- a/tests/unit/test_misc.py +++ b/tests/unit/test_misc.py @@ -1,8 +1,10 @@ import numpy as np -from hdmf.common import DynamicTable, VectorData, DynamicTableRegion +from hdmf.common import VectorData, DynamicTableRegion -from pynwb.misc import AnnotationSeries, AbstractFeatureSeries, IntervalSeries, Units, DecompositionSeries +from pynwb.misc import ( + AnnotationSeries, AbstractFeatureSeries, IntervalSeries, Units, DecompositionSeries, FrequencyBandsTable +) from pynwb.file import TimeSeries from pynwb.device import Device from pynwb.ecephys import ElectrodeGroup, ElectrodesTable @@ -29,21 +31,56 @@ def test_init(self): aFS.add_features(2.0, [1.]) +class FrequencyBandsTableConstructor(TestCase): + def setUp(self): + self.bands = FrequencyBandsTable( + columns=[ + VectorData(name='band_name', description='name of bands', data=['alpha', 'beta', 'gamma']), + VectorData(name='band_limits', description='low and high cutoffs in Hz', data=np.ones((3, 2))), + VectorData(name='band_mean', description='mean gaussian filters in Hz', data=np.ones((3,))), + VectorData( + name='band_stdev', + description='standard deviation of gaussian filters in Hz', + data=np.ones((3,)) + ), + ], + ) + + def test_init(self): + self.assertEqual(self.bands['band_name'].data, ['alpha', 'beta', 'gamma']) + np.testing.assert_equal(self.bands['band_limits'].data, np.ones((3, 2))) + np.testing.assert_equal(self.bands['band_mean'].data, np.ones((3,))) + np.testing.assert_equal(self.bands['band_stdev'].data, np.ones((3,))) + + def test_add_row(self): + self.bands.add_band(band_name='band_name1', band_limits=np.array([1., 2.]), band_mean=1., band_stdev=1.) + self.bands.add_band(band_name='band_name2', band_limits=(3., 4.), band_mean=1., band_stdev=1.) + self.bands.add_band(band_name='band_name3', band_limits=[5., 6.], band_mean=1., band_stdev=1.) + np.testing.assert_equal( + self.bands['band_limits'].data, + [[1., 1.], [1., 1.], [1., 1.], [1., 2.,], [3., 4.], [5., 6.]] + ) + np.testing.assert_equal(self.bands['band_mean'].data, np.ones((6,))) + np.testing.assert_equal(self.bands['band_stdev'].data, np.ones((6,))) + + class DecompositionSeriesConstructor(TestCase): def test_init(self): timeseries = TimeSeries(name='dummy timeseries', description='desc', data=np.ones((3, 3)), unit='Volts', timestamps=[1., 2., 3.]) - bands = DynamicTable(name='bands', description='band info for LFPSpectralAnalysis', columns=[ - VectorData(name='band_name', description='name of bands', data=['alpha', 'beta', 'gamma']), - VectorData(name='band_limits', description='low and high cutoffs in Hz', data=np.ones((3, 2))), - VectorData(name='band_mean', description='mean gaussian filters in Hz', data=np.ones((3,))), - VectorData( - name='band_stdev', - description='standard deviation of gaussian filters in Hz', - data=np.ones((3,)) - ), - ]) + bands = FrequencyBandsTable( + columns=[ + VectorData(name='band_name', description='name of bands', data=['alpha', 'beta', 'gamma']), + VectorData(name='band_limits', description='low and high cutoffs in Hz', data=np.ones((3, 2))), + VectorData(name='band_mean', description='mean gaussian filters in Hz', data=np.ones((3,))), + VectorData( + name='band_stdev', + description='standard deviation of gaussian filters in Hz', + data=np.ones((3,)) + ), + ], + ) spec_anal = DecompositionSeries(name='LFPSpectralAnalysis', description='my description', data=np.ones((3, 3, 3)), @@ -74,16 +111,15 @@ def test_init_delayed_bands(self): source_timeseries=timeseries, metric='amplitude') for band_name in ['alpha', 'beta', 'gamma']: - spec_anal.add_band(band_name=band_name, band_limits=(1., 1.), band_mean=1., band_stdev=1.) - + spec_anal.add_band(band_name=band_name, band_limits=np.array([1., 1.]), band_mean=1., band_stdev=1.) self.assertEqual(spec_anal.name, 'LFPSpectralAnalysis') self.assertEqual(spec_anal.description, 'my description') np.testing.assert_equal(spec_anal.data, np.ones((3, 3, 3))) np.testing.assert_equal(spec_anal.timestamps, [1., 2., 3.]) - self.assertEqual(spec_anal.bands['band_name'].data, ['alpha', 'beta', 'gamma']) - np.testing.assert_equal(spec_anal.bands['band_limits'].data, np.ones((3, 2))) self.assertEqual(spec_anal.source_timeseries, timeseries) self.assertEqual(spec_anal.metric, 'amplitude') + self.assertEqual(spec_anal.bands['band_name'].data, ['alpha', 'beta', 'gamma']) + np.testing.assert_equal(spec_anal.bands['band_limits'].data, [np.array([1., 1.]) for _ in range(3)]) @staticmethod def make_electrode_table(self):