Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/pynwb/io/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
64 changes: 64 additions & 0 deletions src/pynwb/io/device.py
Original file line number Diff line number Diff line change
@@ -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
Binary file not shown.
Binary file not shown.
Binary file not shown.
53 changes: 52 additions & 1 deletion tests/back_compat/test_read.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
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)
Loading