Skip to content

Commit 801f92e

Browse files
stephprincerly
andauthored
Handle Device conflicts between core schema and extensions (#2132)
* device model mapper - wip * add device mapper for schema conflicts * update warning stack levels * update device mapping warnings for clarity * add extension back compatibility test files * add tests for reading old extension files with nwb schema 2.9.0 * update CHANGELOG * fix test name * bump hdmf minimum requirement * update requirements.txt * Update dandi download URL for testing data * Update src/pynwb/io/device.py Co-authored-by: Ryan Ly <[email protected]> * Update src/pynwb/io/device.py Co-authored-by: Ryan Ly <[email protected]> * update test warning assertion --------- Co-authored-by: Ryan Ly <[email protected]>
1 parent 6ff7e44 commit 801f92e

File tree

10 files changed

+130
-6
lines changed

10 files changed

+130
-6
lines changed

.github/workflows/run_inspector_tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ jobs:
3636
python -m pip install pytest
3737
python -m pip install ".[dandi]" # this might install a pinned version of pynwb instead of the current one
3838
# Download testing data and set config path
39-
dandi download "https://gui-staging.dandiarchive.org/#/dandiset/204919"
39+
dandi download "https://sandbox.dandiarchive.org/dandiset/204919"
4040
cd ..
4141
python -m pip uninstall -y pynwb # uninstall the pinned version of pynwb
4242
python -m pip install . # reinstall current branch of pynwb

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77

88
### Fixed
99
- 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)
10+
- 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)
1011
- Fixed issue with setting `neurodata_type_inc` when reading NWB files with cached schema versions less than 2.2.0. @rly [#2135](https://github.com/NeurodataWithoutBorders/pynwb/pull/2135)
1112
- Fixed import structure test. @rly [#2136](https://github.com/NeurodataWithoutBorders/pynwb/pull/2136)
1213

13-
### Documentation and tutorial enhancements
14-
- Change UI of assistant to be an accordion that is always visible. [#2124](https://github.com/NeurodataWithoutBorders/pynwb/pull/2124)
14+
### Changed
15+
- Change UI of documentation assistant to be an accordion that is always visible. @bendichter [#2124](https://github.com/NeurodataWithoutBorders/pynwb/pull/2124)
1516

1617

1718
## PyNWB 3.1.2 (August 13, 2025)

requirements-min.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# minimum versions of package dependencies for installing PyNWB
22
h5py==3.2.0
3-
hdmf==4.1.0
3+
hdmf==4.1.1
44
numpy==1.24.0
55
pandas==1.2.0
66
python-dateutil==2.8.2

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# pinned dependencies to reproduce an entire development environment to use PyNWB
22
h5py==3.12.1
3-
hdmf==4.1.0
3+
hdmf==4.1.1
44
numpy==2.1.1; python_version > "3.9" # numpy 2.1+ is not compatible with py3.9
55
numpy==2.0.2; python_version == "3.9"
66
pandas==2.2.3

src/pynwb/io/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from . import base as __base
22
from . import core as __core
3+
from . import device as __device
34
from . import file as __file
45
from . import behavior as __behavior
56
from . import ecephys as __ecephys

src/pynwb/io/device.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
from warnings import warn
2+
3+
from .. import register_map
4+
from ..device import Device, DeviceModel
5+
from .core import NWBContainerMapper
6+
7+
8+
@register_map(Device)
9+
class DeviceMapper(NWBContainerMapper):
10+
"""
11+
Custom mapper for Device objects to handle known schema conflicts between core schema and extensions.
12+
13+
This mapper detects when extensions define Device.model as a string attribute instead of
14+
a link to DeviceModel, or when extensions define their own DeviceModel type.
15+
"""
16+
17+
@NWBContainerMapper.constructor_arg("model")
18+
def model_carg(self, builder, manager):
19+
"""
20+
Handle different model mapping strategies based on detected schema conflicts.
21+
22+
Args:
23+
builder: The GroupBuilder for the Device
24+
manager: The BuildManager
25+
26+
Returns:
27+
The appropriate model object or value based on the mapping strategy
28+
"""
29+
model_builder = builder.get('model')
30+
if isinstance(model_builder, str):
31+
warn(
32+
'Device.model was detected as a string, but NWB 2.9 specifies Device.model as a link to a DeviceModel. '
33+
f'Remapping "{model_builder}" to a new DeviceModel.',
34+
stacklevel=3)
35+
36+
# replace the model string with a DeviceModel object using the model name and device attributes
37+
device_model_attributes = dict(name=model_builder,
38+
description=builder.attributes.get('description'),
39+
manufacturer=builder.attributes.get('manufacturer', ''),
40+
model_number=builder.attributes.get('model_number'))
41+
model = DeviceModel(**device_model_attributes)
42+
43+
return model
44+
45+
return None
46+
47+
48+
def __new_container__(self, cls, container_source, parent, object_id, **kwargs):
49+
# Override ObjectMapper.__new_container__ to handle the case where the Device.model argument
50+
# is not a DeviceModel, which can happen in extensions written to be compatible with NWB<2.9.
51+
# The original Device.model object will be accessible under a new attribute name based on the
52+
# extension namespace.
53+
model = kwargs.get('model', None)
54+
55+
if model is None or isinstance(model, DeviceModel):
56+
device_obj = super().__new_container__(cls, container_source, parent, object_id, **kwargs)
57+
else:
58+
# create device object without model
59+
kwargs.pop('model')
60+
device_obj = super().__new_container__(cls, container_source, parent, object_id, **kwargs)
61+
62+
# add the conflicting Device.model object as a new attribute on Device
63+
# e.g. Device.model in the file -> Device.ndx_optogenetics_model in the python object
64+
warn(f'The model attribute of the Device "{device_obj.name}" was detected as a non-DeviceModel '
65+
f'object. Data associated with this object can be accessed at '
66+
f'"nwbfile.devices["{device_obj.name}"].{model.namespace.replace("-", "_")}_model"',
67+
stacklevel=2)
68+
setattr(device_obj, f"{model.namespace.replace('-', '_')}_model", model)
69+
70+
return device_obj
233 KB
Binary file not shown.
221 KB
Binary file not shown.
223 KB
Binary file not shown.

tests/back_compat/test_read.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import warnings
55

66
from pynwb import NWBHDF5IO, validate, TimeSeries
7+
from pynwb.device import DeviceModel
78
from pynwb.ecephys import ElectrodesTable
89
from pynwb.image import ImageSeries
910
from pynwb.misc import FrequencyBandsTable
@@ -166,4 +167,55 @@ def test_read_bands_table_as_neurodata_type(self):
166167
f = Path(__file__).parent / '3.0.0_decompositionseries_bands_dynamic_table.nwb'
167168
with self.get_io(f) as io:
168169
read_nwbfile = io.read()
169-
assert isinstance(read_nwbfile.processing['test_mod']['LFPSpectralAnalysis'].bands, FrequencyBandsTable)
170+
assert isinstance(read_nwbfile.processing['test_mod']['LFPSpectralAnalysis'].bands, FrequencyBandsTable)
171+
172+
def test_read_device_model_str_attribute(self):
173+
"""Test that a Device.model written as a string attribute is read and remapped to a DeviceModel object"""
174+
f = Path(__file__).parent / '3.0.0_fiber_photometry_extension.nwb'
175+
with self.get_io(f) as io:
176+
# assert warning is issued to inform user the attribute is being remapped
177+
with self.assertWarnsWith(UserWarning,
178+
'Device.model was detected as a string, ' \
179+
'but NWB 2.9 specifies Device.model as a link to a DeviceModel. '
180+
'Remapping "dichroic mirror model" to a new DeviceModel.'):
181+
read_nwbfile = io.read()
182+
183+
# assert data was remapped correctly
184+
device = read_nwbfile.devices['dichroic_mirror_1']
185+
self.assertIsInstance(device.model, DeviceModel)
186+
self.assertEqual(device.model.name, 'dichroic mirror model')
187+
self.assertEqual(device.model.description, 'Dichroic mirror for green indicator')
188+
self.assertEqual(device.model.manufacturer, '')
189+
self.assertEqual(device.model.model_number, None)
190+
191+
def test_read_device_model_link_to_other_object(self):
192+
"""Test that a Device.model written as a link to another object is read and remapped to a new attribute"""
193+
f = Path(__file__).parent / '3.0.0_optogenetics_extension.nwb'
194+
with self.get_io(f) as io:
195+
# assert warning is issued to inform user where old data is being remapped
196+
with self.assertWarnsWith(UserWarning,
197+
'The model attribute of the Device "Lambda" was detected as a non-DeviceModel '
198+
'object. Data associated with this object can be accessed at '
199+
'\"nwbfile.devices["Lambda"].ndx_optogenetics_model\"'):
200+
read_nwbfile = io.read()
201+
202+
# assert data was remapped correctly
203+
device = read_nwbfile.devices['Lambda']
204+
self.assertIsNone(device.model)
205+
self.assertIsNotNone(device.ndx_optogenetics_model)
206+
self.assertEqual(device.ndx_optogenetics_model.name, 'Lambda Model')
207+
self.assertEqual(device.ndx_optogenetics_model.description, 'Lambda fiber (tapered fiber) from Optogenix.')
208+
self.assertEqual(device.ndx_optogenetics_model.numerical_aperture, 0.39)
209+
210+
def test_read_device_model_link_to_extension_device_model(self):
211+
"""Test that a Device.model written as a link to an extension DeviceModel object is read successfully"""
212+
f = Path(__file__).parent / '3.0.0_ophys_devices_extension.nwb'
213+
with self.get_io(f) as io:
214+
read_nwbfile = io.read()
215+
216+
# assert model is read correct
217+
band_optical_filter = read_nwbfile.devices['band_optical_filter']
218+
self.assertIsInstance(band_optical_filter.model, DeviceModel)
219+
self.assertEqual(band_optical_filter.model.name, 'band_optical_filter_model')
220+
self.assertEqual(band_optical_filter.model.description, 'Band optical filter model for green indicator')
221+
self.assertEqual(band_optical_filter.model.bandwidth_in_nm, 30.0)

0 commit comments

Comments
 (0)