diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d92bb03b..71a1b71d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,12 @@ # PyNWB Changelog -## PyNWB 3.1.1 (July 14, 2025) +## PyNWB 3.1.1 (July 16, 2025) ### Bug fixes - +- Fixed reading and exporting of files written with NWB Schema < 2.9.0 that contained a reference to the electrodes table. @rly [#2112](https://github.com/NeurodataWithoutBorders/pynwb/pull/2112) - Skip streaming tests gracefully if offline. @rly [#2113](https://github.com/NeurodataWithoutBorders/pynwb/pull/2113) + ## PyNWB 3.1.0 (July 8, 2025) ### Breaking changes @@ -41,7 +42,7 @@ - Fixed caching of the type map when using HDMF 4.1.0. @rly [#2087](https://github.com/NeurodataWithoutBorders/pynwb/pull/2087) - Removed use of complex numbers in scratch tutorial because of incompatibilities with HDMF 4.1.0. @stephprince [#2090](https://github.com/NeurodataWithoutBorders/pynwb/pull/2090/) - Made `ImagingPlane.description` optional to conform with the NWB Schema. @rly [#2051](https://github.com/NeurodataWithoutBorders/pynwb/pull/2051) - + ### Documentation and tutorial enhancements - Added NWB AI assistant to the home page of the documentation. @magland [#2076](https://github.com/NeurodataWithoutBorders/pynwb/pull/2076) diff --git a/src/pynwb/io/epoch.py b/src/pynwb/io/epoch.py index e5c35f17f..e82a2de95 100644 --- a/src/pynwb/io/epoch.py +++ b/src/pynwb/io/epoch.py @@ -14,6 +14,9 @@ class TimeIntervalsMap(DynamicTableMap): def columns_carg(self, builder, manager): # handle the case when a TimeIntervals is read with a non-TimeSeriesReferenceVectorData "timeseries" column # this is the case for NWB schema v2.4 and earlier, where the timeseries column was a regular VectorData. + # NOTE: the below machinery might be movable into the NWBFileMap.construct override which might better + # handle the rare edge case where the "timeseries" column is referenced in another field elsewhere in the file. + timeseries_builder = builder.get('timeseries') # handle the case when the TimeIntervals has a "timeseries" column that is a link (e.g., it exists in diff --git a/src/pynwb/io/file.py b/src/pynwb/io/file.py index d74c66be1..7a35eca16 100644 --- a/src/pynwb/io/file.py +++ b/src/pynwb/io/file.py @@ -1,6 +1,8 @@ from dateutil.parser import parse as dateutil_parse +import typing -from hdmf.build import ObjectMapper +from hdmf.build import ObjectMapper, Builder, GroupBuilder +from hdmf.utils import docval, get_docval from .. import register_map from ..file import NWBFile, Subject @@ -130,6 +132,34 @@ def __init__(self, spec): self.map_spec('scratch_containers', scratch_spec.get_neurodata_type('NWBContainer')) self.map_spec('scratch_containers', scratch_spec.get_neurodata_type('DynamicTable')) + @docval(*get_docval(ObjectMapper.construct)) + def construct(self, **kwargs): + nwbfile_builder = kwargs["builder"] + electrodes_builder = nwbfile_builder.get("general", dict()).get("extracellular_ephys", dict()).get("electrodes") + if (electrodes_builder is not None and electrodes_builder.attributes['neurodata_type'] != 'ElectrodesTable'): + electrodes_builder.attributes['neurodata_type'] = 'ElectrodesTable' + electrodes_builder.attributes['namespace'] = 'core' + + def apply_to_child_builders(builder: Builder, funcs: list[typing.Callable]): + # iterate recursively through each builder (which is just a dict of dicts) and make migration changes + for bchild_value in builder.values(): + if isinstance(bchild_value, Builder): + for func in funcs: + func(bchild_value) + apply_to_child_builders(bchild_value, funcs) + + def update_builder_frequency_bands_table(builder: Builder): + if (isinstance(builder, GroupBuilder) and + builder.attributes.get('namespace') == 'core' and + builder.attributes.get('neurodata_type') == 'DecompositionSeries' and + builder.groups['bands'].attributes['neurodata_type'] == 'DynamicTable'): + builder.groups['bands'].attributes['neurodata_type'] = 'FrequencyBandsTable' + builder.groups['bands'].attributes['namespace'] = 'core' + + apply_to_child_builders(nwbfile_builder, [update_builder_frequency_bands_table]) + + return super().construct(**kwargs) + @ObjectMapper.object_attr('scratch_datas') def scratch_datas(self, container, manager): """Set the value for the 'scratch_datas' spec on NWBFile to a list of ScratchData objects. @@ -185,24 +215,6 @@ 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' - manager.clear_cache() - new_container = manager.construct(electrodes_builder) - 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/io/misc.py b/src/pynwb/io/misc.py index 1ee8d6280..20ec69261 100644 --- a/src/pynwb/io/misc.py +++ b/src/pynwb/io/misc.py @@ -1,21 +1,7 @@ from hdmf.common.io.table import DynamicTableMap -from .base import TimeSeriesMap from .. import register_map -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 +from pynwb.misc import Units @register_map(Units) diff --git a/src/pynwb/testing/make_test_files.py b/src/pynwb/testing/make_test_files.py index 51e7eeafc..11cca3488 100644 --- a/src/pynwb/testing/make_test_files.py +++ b/src/pynwb/testing/make_test_files.py @@ -2,6 +2,7 @@ import numpy as np from pathlib import Path from pynwb import NWBFile, NWBHDF5IO, __version__, TimeSeries, get_class, load_namespaces +from pynwb.ecephys import ElectricalSeries from pynwb.file import Subject from pynwb.image import ImageSeries from pynwb.misc import DecompositionSeries @@ -237,6 +238,20 @@ def _make_electrodes_dynamic_table(): label=f"shank{i}electrode{j}", ) + nwbfile.add_unit( + spike_times=[0.1, 0.2, 0.3], + electrodes=[0, 1], + ) + + eseries = ElectricalSeries( + name="ElectricalSeries", + description="Test electrodes reference", + data=[[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]], + timestamps=[0.1, 0.2, 0.3], + electrodes=nwbfile.create_electrode_table_region(region=[2, 3], description="electrodes table indices 2 and 3"), + ) + nwbfile.add_acquisition(eseries) + test_name = 'electrodes_dynamic_table' _write(test_name, nwbfile) diff --git a/tests/back_compat/3.0.0_electrodes_dynamic_table.nwb b/tests/back_compat/3.0.0_electrodes_dynamic_table.nwb index 5c2dbfe5b..928fab97b 100644 Binary files a/tests/back_compat/3.0.0_electrodes_dynamic_table.nwb and b/tests/back_compat/3.0.0_electrodes_dynamic_table.nwb differ diff --git a/tests/back_compat/test_read.py b/tests/back_compat/test_read.py index c7fbf8b8b..7ebf3a4ea 100644 --- a/tests/back_compat/test_read.py +++ b/tests/back_compat/test_read.py @@ -1,5 +1,6 @@ import numpy as np from pathlib import Path +import tempfile import warnings from pynwb import NWBHDF5IO, validate, TimeSeries @@ -140,6 +141,26 @@ def test_read_electrodes_table_as_neurodata_type(self): read_nwbfile = io.read() assert isinstance(read_nwbfile.electrodes, ElectrodesTable) + # test that references to the electrodes table are also ElectrodesTable + assert read_nwbfile.units.electrodes.table is read_nwbfile.electrodes + assert read_nwbfile.acquisition["ElectricalSeries"].electrodes.table is read_nwbfile.electrodes + + # test that export writes the correct builders + temp_dir = tempfile.TemporaryDirectory() + export_file = Path(temp_dir.name) / "3.0.0_electrodes_dynamic_table_export.nwb" + with NWBHDF5IO(export_file, 'w') as export_io: + export_io.export(io) + + with self.get_io(export_file) as read_export_io: + read_export_nwbfile = read_export_io.read() + assert isinstance(read_export_nwbfile.electrodes, ElectrodesTable) + + # test that references to the electrodes table are also ElectrodesTable + units_table_ref = read_export_nwbfile.units.electrodes.table + assert units_table_ref is read_export_nwbfile.electrodes + eseries_table_ref = read_export_nwbfile.acquisition["ElectricalSeries"].electrodes.table + assert eseries_table_ref is read_export_nwbfile.electrodes + 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'