diff --git a/CHANGELOG.md b/CHANGELOG.md index e52e1cbf0..1deae50b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,10 @@ ### Fixed - Fixed incorrect warning for path not ending in `.nwb` when no path argument was provided. @t-b [#2130](https://github.com/NeurodataWithoutBorders/pynwb/pull/2130) +- Fixed inability to read files created with extensions that had schema conflicts with the DeviceModel type introduced in NWB Schema 2.9.0. @stephprince [#2132](https://github.com/NeurodataWithoutBorders/pynwb/pull/2132) -### Documentation and tutorial enhancements -- Change UI of assistant to be an accordion that is always visible. [#2124](https://github.com/NeurodataWithoutBorders/pynwb/pull/2124) +### Changed +- Change UI of documentation assistant to be an accordion that is always visible. @bendichter [#2124](https://github.com/NeurodataWithoutBorders/pynwb/pull/2124) ## PyNWB 3.1.2 (August 13, 2025) diff --git a/src/pynwb/io/__init__.py b/src/pynwb/io/__init__.py index e0de46b87..ff892970b 100644 --- a/src/pynwb/io/__init__.py +++ b/src/pynwb/io/__init__.py @@ -1,5 +1,6 @@ from . import base as __base from . import core as __core +from . import device as __device from . import file as __file from . import behavior as __behavior from . import ecephys as __ecephys diff --git a/src/pynwb/io/device.py b/src/pynwb/io/device.py new file mode 100644 index 000000000..e6f534fba --- /dev/null +++ b/src/pynwb/io/device.py @@ -0,0 +1,64 @@ +from warnings import warn + +from .. import register_map +from ..device import Device, DeviceModel +from .core import NWBContainerMapper + + +@register_map(Device) +class DeviceMapper(NWBContainerMapper): + """ + Custom mapper for Device objects to handle known schema conflicts between core schema and extensions. + + This mapper detects when extensions define Device.model as a string attribute instead of + a link to DeviceModel, or when extensions define their own DeviceModel type. + """ + + @NWBContainerMapper.constructor_arg("model") + def model_carg(self, builder, manager): + """ + Handle different model mapping strategies based on detected schema conflicts. + + Args: + builder: The GroupBuilder for the Device + manager: The BuildManager + + Returns: + The appropriate model object or value based on the mapping strategy + """ + model_builder = builder.get('model') + if isinstance(model_builder, str): + warn(f'Device.model was detected as a string. Remapping "{model_builder}" to a DeviceModel', + stacklevel=3) + + # replace the model string with a DeviceModel object using the model name and device attributes + device_model_attributes = dict(name=model_builder, + description=builder.attributes.get('description'), + manufacturer=builder.attributes.get('manufacturer', ''), + model_number=builder.attributes.get('model_number')) + model = DeviceModel(**device_model_attributes) + + return model + + return None + + + def __new_container__(self, cls, container_source, parent, object_id, **kwargs): + model = kwargs.get('model', None) + + if model is None or isinstance(model, DeviceModel): + device_obj = super().__new_container__(cls, container_source, parent, object_id, **kwargs) + else: + # create device object without model + kwargs.pop('model') + device_obj = super().__new_container__(cls, container_source, parent, object_id, **kwargs) + + # add the conflicting Device.model object as a new attribute on Device + # e.g. Device.model in the file -> Device.ndx_optogenetics_model in the python object + warn(f'The model attribute of the Device "{device_obj.name}" was detected as a non-DeviceModel ' + f'object. Data associated with this object can be accessed at ' + f'"nwbfile.devices["{device_obj.name}"].{model.namespace.replace("-", "_")}_model"', + stacklevel=2) + setattr(device_obj, f"{model.namespace.replace('-', '_')}_model", model) + + return device_obj diff --git a/tests/back_compat/3.0.0_fiber_photometry_extension.nwb b/tests/back_compat/3.0.0_fiber_photometry_extension.nwb new file mode 100644 index 000000000..c796430f2 Binary files /dev/null and b/tests/back_compat/3.0.0_fiber_photometry_extension.nwb differ diff --git a/tests/back_compat/3.0.0_ophys_devices_extension.nwb b/tests/back_compat/3.0.0_ophys_devices_extension.nwb new file mode 100644 index 000000000..028978d5f Binary files /dev/null and b/tests/back_compat/3.0.0_ophys_devices_extension.nwb differ diff --git a/tests/back_compat/3.0.0_optogenetics_extension.nwb b/tests/back_compat/3.0.0_optogenetics_extension.nwb new file mode 100644 index 000000000..de1c08f6e Binary files /dev/null and b/tests/back_compat/3.0.0_optogenetics_extension.nwb differ diff --git a/tests/back_compat/test_read.py b/tests/back_compat/test_read.py index 2d05c54b7..7049bfbd0 100644 --- a/tests/back_compat/test_read.py +++ b/tests/back_compat/test_read.py @@ -4,6 +4,7 @@ import warnings from pynwb import NWBHDF5IO, validate, TimeSeries +from pynwb.device import DeviceModel from pynwb.ecephys import ElectrodesTable from pynwb.image import ImageSeries from pynwb.misc import FrequencyBandsTable @@ -166,4 +167,54 @@ def test_read_bands_table_as_neurodata_type(self): 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 + assert isinstance(read_nwbfile.processing['test_mod']['LFPSpectralAnalysis'].bands, FrequencyBandsTable) + + def test_read_device_model_str_attribute(self): + """Test that a Device.model written as a string attribute is read and remapped to a DeviceModel object""" + f = Path(__file__).parent / '3.0.0_fiber_photometry_extension.nwb' + with self.get_io(f) as io: + # assert warning is issued to inform user the attribute is being remapped + with self.assertWarnsWith(UserWarning, + 'Device.model was detected as a string. ' \ + 'Remapping "dichroic mirror model" to a DeviceModel'): + read_nwbfile = io.read() + + # assert data was remapped correctly + device = read_nwbfile.devices['dichroic_mirror_1'] + self.assertIsInstance(device.model, DeviceModel) + self.assertEqual(device.model.name, 'dichroic mirror model') + self.assertEqual(device.model.description, 'Dichroic mirror for green indicator') + self.assertEqual(device.model.manufacturer, '') + self.assertEqual(device.model.model_number, None) + + def test_read_device_model_link_to_other_object(self): + """Test that a Device.model written as a link to another object is read and remapped to a new attribute""" + f = Path(__file__).parent / '3.0.0_optogenetics_extension.nwb' + with self.get_io(f) as io: + # assert warning is issued to inform user where old data is being remapped + with self.assertWarnsWith(UserWarning, + 'The model attribute of the Device "Lambda" was detected as a non-DeviceModel ' + 'object. Data associated with this object can be accessed at ' + '\"nwbfile.devices["Lambda"].ndx_optogenetics_model\"'): + read_nwbfile = io.read() + + # assert data was remapped correctly + device = read_nwbfile.devices['Lambda'] + self.assertIsNone(device.model) + self.assertIsNotNone(device.ndx_optogenetics_model) + self.assertEqual(device.ndx_optogenetics_model.name, 'Lambda Model') + self.assertEqual(device.ndx_optogenetics_model.description, 'Lambda fiber (tapered fiber) from Optogenix.') + self.assertEqual(device.ndx_optogenetics_model.numerical_aperture, 0.39) + + def test_read_device_model_link_to_extension_device_model(self): + """Test that a Device.model written as a link to an extension DeviceModel object is read successfully""" + f = Path(__file__).parent / '3.0.0_ophys_devices_extension.nwb' + with self.get_io(f) as io: + read_nwbfile = io.read() + + # assert model is read correct + band_optical_filter = read_nwbfile.devices['band_optical_filter'] + self.assertIsInstance(band_optical_filter.model, DeviceModel) + self.assertEqual(band_optical_filter.model.name, 'band_optical_filter_model') + self.assertEqual(band_optical_filter.model.description, 'Band optical filter model for green indicator') + self.assertEqual(band_optical_filter.model.bandwidth_in_nm, 30.0)