Skip to content

Commit ddca692

Browse files
rlystephprince
andauthored
Add support for DeviceModel neurodata type (#2088)
Co-authored-by: Steph Prince <[email protected]>
1 parent efbc778 commit ddca692

File tree

12 files changed

+228
-39
lines changed

12 files changed

+228
-39
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
- Added new `ElectrodesTable` neurodata type. @mavaylon1 [#1890](https://github.com/NeurodataWithoutBorders/pynwb/pull/1890)
1212
- Formally defined and renamed `ElectrodeTable` as the `ElectrodesTable` neurodata type. @mavaylon1 [#1890](https://github.com/NeurodataWithoutBorders/pynwb/pull/1890)
1313
- Formally defined bands within `DecompositionSeries` as the neurodatatype `FrequencyBandsTable`. @mavaylon1 @rly [#2063](https://github.com/NeurodataWithoutBorders/pynwb/pull/2063)
14+
- Added new `DeviceModel` neurodata type to store device model information. @rly [#2088](https://github.com/NeurodataWithoutBorders/pynwb/pull/2088)
15+
- Deprecated `Device.model_name`, `Device.model_number`, and `Device.manufacturer` fields in favor of `DeviceModel`. @rly [#2088](https://github.com/NeurodataWithoutBorders/pynwb/pull/2088)
1416
- Added support for 2D `EventDetection.source_index` to indicate [time_index, channel_index]. @stephprince [#2091](https://github.com/NeurodataWithoutBorders/pynwb/pull/2091)
1517
- Made `EventDetection.times` optional. @stephprince [#2091](https://github.com/NeurodataWithoutBorders/pynwb/pull/2091)
1618
- Automatically add timezone information to timestamps reference time if no timezone information is specified. @stephprince [#2056](https://github.com/NeurodataWithoutBorders/pynwb/pull/2056)

docs/gallery/domain/ecephys.py

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -80,15 +80,21 @@
8080
# The electrodes table references a required :py:class:`~pynwb.ecephys.ElectrodeGroup`, which is used to represent a
8181
# group of electrodes. Before creating an :py:class:`~pynwb.ecephys.ElectrodeGroup`, you must define a
8282
# :py:class:`~pynwb.device.Device` object using the method :py:meth:`.NWBFile.create_device`. The fields
83-
# ``description``, ``manufacturer``, ``model_number``, ``model_name``, and ``serial_number`` are optional, but
84-
# recommended.
83+
# ``description``, ``serial_number``, and ``model`` are optional, but recommended. The
84+
# :py:class:`~pynwb.device.DeviceModel` object stores information about the device model, which can be useful
85+
# when searching a set of NWB files or a data archive for all files that use a specific device model
86+
# (e.g., Neuropixels probe).
87+
device_model = nwbfile.create_device_model(
88+
name="Neurovoxels 0.99",
89+
manufacturer="Array Technologies",
90+
model_number="PRB_1_4_0480_123",
91+
description="A 12-channel array with 4 shanks and 3 channels per shank",
92+
)
8593
device = nwbfile.create_device(
8694
name="array",
8795
description="A 12-channel array with 4 shanks and 3 channels per shank",
88-
manufacturer="Array Technologies",
89-
model_number="PRB_1_4_0480_123",
90-
model_name="Neurovoxels 0.99",
9196
serial_number="1234567890",
97+
model=device_model,
9298
)
9399

94100
#######################
@@ -238,7 +244,7 @@
238244
lfp = LFP(electrical_series=lfp_electrical_series)
239245

240246
####################
241-
# LFP refers to data that has been low-pass filtered, typically below 300 Hz. This data may also be downsampled.
247+
# LFP refers to data that has been low-pass filtered, typically below 300 Hz. This data may also be downsampled.
242248
# Because it is filtered and potentially resampled, it is categorized as processed data.
243249
#
244250
# Create a processing module named ``"ecephys"`` and add the :py:class:`~pynwb.ecephys.LFP` object to it.
@@ -252,7 +258,7 @@
252258

253259
#######################
254260
# If your data is filtered for frequency ranges other than LFP — such as Gamma or Theta — you should store it in an
255-
# :py:class:`~pynwb.ecephys.ElectricalSeries` and encapsulate it within a
261+
# :py:class:`~pynwb.ecephys.ElectricalSeries` and encapsulate it within a
256262
# :py:class:`~pynwb.ecephys.FilteredEphys` object.
257263

258264
from pynwb.ecephys import FilteredEphys
@@ -272,21 +278,21 @@
272278
ecephys_module.add(filtered_ephys)
273279

274280
################################
275-
# In some cases, you may want to further process the LFP data and decompose the signal into different frequency bands
281+
# In some cases, you may want to further process the LFP data and decompose the signal into different frequency bands
276282
# to use for other downstream analyses. You can store the processed data from these spectral analyses using a
277283
# :py:class:`~pynwb.misc.DecompositionSeries` object. This object allows you to include metadata about the frequency
278-
# bands and metric used (e.g., power, phase, amplitude), as well as link the decomposed data to the original
284+
# bands and metric used (e.g., power, phase, amplitude), as well as link the decomposed data to the original
279285
# :py:class:`~pynwb.base.TimeSeries` signal the data was derived from.
280286

281287
#######################
282-
# .. note:: When adding data to :py:class:`~pynwb.misc.DecompositionSeries`, the ``data`` argument is assumed to be
288+
# .. note:: When adding data to :py:class:`~pynwb.misc.DecompositionSeries`, the ``data`` argument is assumed to be
283289
# 3D where the first dimension is time, the second dimension is channels, and the third dimension is bands.
284290

285291

286-
bands = dict(theta=(4.0, 12.0),
287-
beta=(12.0, 30.0),
292+
bands = dict(theta=(4.0, 12.0),
293+
beta=(12.0, 30.0),
288294
gamma=(30.0, 80.0)) # in Hz
289-
phase_data = np.random.randn(50, 12, len(bands)) # 50 samples, 12 channels, 3 frequency bands
295+
phase_data = np.random.randn(50, 12, len(bands)) # 50 samples, 12 channels, 3 frequency bands
290296

291297
decomp_series = DecompositionSeries(
292298
name="theta",
@@ -353,7 +359,7 @@
353359
# While the :py:class:`~pynwb.misc.Units` table is used to store spike times and waveform data for
354360
# spike-sorted, single-unit activity, you may also want to store spike times and waveform snippets of
355361
# unsorted spiking activity (e.g., multi-unit activity detected via threshold crossings during data acquisition).
356-
# This information can be stored using :py:class:`~pynwb.ecephys.SpikeEventSeries` objects.
362+
# This information can be stored using :py:class:`~pynwb.ecephys.SpikeEventSeries` objects.
357363

358364
spike_snippets = np.random.rand(40, 3, 30) # 40 events, 3 channels, 30 samples per event
359365
shank0 = nwbfile.create_electrode_table_region(

src/pynwb/device.py

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
from hdmf.utils import docval, popargs, AllowPositional
2+
import warnings
23

34
from . import register_class, CORE_NAMESPACE
45
from .core import NWBContainer
56

6-
__all__ = ['Device']
7+
__all__ = ['Device', 'DeviceModel']
78

89
@register_class('Device', CORE_NAMESPACE)
910
class Device(NWBContainer):
1011
"""
1112
Metadata about a data acquisition device, e.g., recording system, electrode, microscope.
13+
Link to a DeviceModel.model to represent information about the model of the device.
1214
"""
1315

1416
__nwbfields__ = (
@@ -18,6 +20,7 @@ class Device(NWBContainer):
1820
'model_number',
1921
'model_name',
2022
'serial_number',
23+
'model',
2124
)
2225

2326
@docval(
@@ -27,26 +30,86 @@ class Device(NWBContainer):
2730
"with the device, the names and versions of those can be added to `NWBFile.was_generated_by`."),
2831
'default': None},
2932
{'name': 'manufacturer', 'type': str,
30-
'doc': ("The name of the manufacturer of the device, e.g., Imec, Plexon, Thorlabs."),
33+
'doc': ("DEPRECATED. The name of the manufacturer of the device, e.g., Imec, Plexon, Thorlabs. "
34+
"Instead of using this field, store the value in DeviceModel.manufacturer and link to that "
35+
"DeviceModel from this Device."),
3136
'default': None},
3237
{'name': 'model_number', 'type': str,
33-
'doc': ('The model number (or part/product number) of the device, e.g., PRB_1_4_0480_1, '
34-
'PLX-VP-32-15SE(75)-(260-80)(460-10)-300-(1)CON/32m-V, BERGAMO.'),
38+
'doc': ('DEPRECATED. The model number (or part/product number) of the device, e.g., PRB_1_4_0480_1, '
39+
'PLX-VP-32-15SE(75)-(260-80)(460-10)-300-(1)CON/32m-V, BERGAMO. '
40+
'Instead of using this field, store the value in DeviceModel.model_number and link to that '
41+
'DeviceModel from this Device. '),
3542
'default': None},
3643
{'name': 'model_name', 'type': str,
37-
'doc': ('The model name of the device, e.g., Neuropixels 1.0, V-Probe, Bergamo III.'),
38-
'default': None},
39-
{'name': 'serial_number', 'type': str,
40-
'doc': 'The serial number of the device.',
44+
'doc': ('DEPRECATED. The model name of the device, e.g., Neuropixels 1.0, V-Probe, Bergamo III. '
45+
'Instead of using this field, storing the value in DeviceModel.name and link to that '
46+
'DeviceModel from this Device.'),
4147
'default': None},
48+
{'name': 'serial_number', 'type': str, 'doc': 'The serial number of the device.', 'default': None},
49+
{'name': 'model', 'type': 'DeviceModel', 'doc': 'The model of the device.', 'default': None},
4250
allow_positional=AllowPositional.WARNING,
4351
)
4452
def __init__(self, **kwargs):
45-
description, manufacturer, model_number, model_name, serial_number = popargs(
46-
'description', 'manufacturer', 'model_number', 'model_name', 'serial_number', kwargs)
53+
description, manufacturer, model_number, model_name, serial_number, model = popargs(
54+
'description', 'manufacturer', 'model_number', 'model_name', 'serial_number', 'model', kwargs)
55+
if model_number is not None:
56+
warnings.warn(
57+
"The 'model_number' field is deprecated. Instead, use DeviceModel.model_number and link to that "
58+
"DeviceModel from this Device.",
59+
DeprecationWarning,
60+
stacklevel=2
61+
)
62+
if manufacturer is not None:
63+
warnings.warn(
64+
"The 'manufacturer' field is deprecated. Instead, use DeviceModel.manufacturer and link to that "
65+
"DeviceModel from this Device.",
66+
DeprecationWarning,
67+
stacklevel=2
68+
)
69+
if model_name is not None:
70+
warnings.warn(
71+
"The 'model_name' field is deprecated. Instead, use DeviceModel.name and link to that "
72+
"DeviceModel from this Device.",
73+
DeprecationWarning,
74+
stacklevel=2
75+
)
4776
super().__init__(**kwargs)
4877
self.description = description
4978
self.manufacturer = manufacturer
5079
self.model_number = model_number
5180
self.model_name = model_name
5281
self.serial_number = serial_number
82+
self.model = model
83+
84+
85+
@register_class('DeviceModel', CORE_NAMESPACE)
86+
class DeviceModel(NWBContainer):
87+
"""
88+
Model properties of a data acquisition device, e.g., recording system, electrode, microscope.
89+
"""
90+
91+
__nwbfields__ = (
92+
'manufacturer',
93+
'model_number',
94+
'description',
95+
)
96+
97+
@docval(
98+
{'name': 'name', 'type': str, 'doc': 'The name of this device model'},
99+
{'name': 'manufacturer', 'type': str,
100+
'doc': ("The name of the manufacturer of the device, e.g., Imec, Plexon, Thorlabs.")},
101+
{'name': 'model_number', 'type': str,
102+
'doc': ('The model number (or part/product number) of the device, e.g., PRB_1_4_0480_1, '
103+
'PLX-VP-32-15SE(75)-(260-80)(460-10)-300-(1)CON/32m-V, BERGAMO.'),
104+
'default': None},
105+
{'name': 'description', 'type': str,
106+
'doc': ("Description of the device model as free-form text."),
107+
'default': None},
108+
allow_positional=AllowPositional.ERROR,
109+
)
110+
def __init__(self, **kwargs):
111+
manufacturer, model_number, description = popargs('manufacturer', 'model_number', 'description', kwargs)
112+
super().__init__(**kwargs)
113+
self.manufacturer = manufacturer
114+
self.model_number = model_number
115+
self.description = description

src/pynwb/file.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
from . import register_class, CORE_NAMESPACE
1515
from .base import TimeSeries, ProcessingModule
16-
from .device import Device
16+
from .device import Device, DeviceModel
1717
from .epoch import TimeIntervals
1818
from .ecephys import ElectrodeGroup, ElectrodesTable
1919
from .icephys import (IntracellularElectrode, SweepTable, PatchClampSeries, IntracellularRecordingsTable,
@@ -208,6 +208,13 @@ class NWBFile(MultiContainerInterface, HERDManager):
208208
'create': 'create_device',
209209
'get': 'get_device'
210210
},
211+
{
212+
'attr': 'device_models',
213+
'add': 'add_device_model',
214+
'type': DeviceModel,
215+
'create': 'create_device_model',
216+
'get': 'get_device_model'
217+
},
211218
{
212219
'attr': 'electrode_groups',
213220
'add': 'add_electrode_group',
@@ -401,6 +408,8 @@ class NWBFile(MultiContainerInterface, HERDManager):
401408
'doc': 'OptogeneticStimulusSites that belong to this NWBFile', 'default': None},
402409
{'name': 'devices', 'type': (list, tuple),
403410
'doc': 'Device objects belonging to this NWBFile', 'default': None},
411+
{'name': 'device_models', 'type': (list, tuple),
412+
'doc': ' Device models used in this NWBFile', 'default': None},
404413
{'name': 'subject', 'type': Subject,
405414
'doc': 'subject metadata', 'default': None},
406415
{'name': 'scratch', 'type': (list, tuple),
@@ -439,6 +448,7 @@ def __init__(self, **kwargs):
439448
'electrodes',
440449
'electrode_groups',
441450
'devices',
451+
'device_models',
442452
'imaging_planes',
443453
'ogen_sites',
444454
'intervals',

src/pynwb/io/file.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,10 @@ def __init__(self, spec):
114114
self.unmap(device_spec)
115115
self.map_spec('devices', device_spec.get_neurodata_type('Device'))
116116

117+
device_model_spec = general_spec.get_group('devices').get_group('models')
118+
self.unmap(device_model_spec)
119+
self.map_spec('device_models', device_model_spec.get_neurodata_type('DeviceModel'))
120+
117121
self.map_spec('lab_meta_data', general_spec.get_neurodata_type('LabMetaData'))
118122

119123
proc_spec = self.spec.get_group('processing')

src/pynwb/testing/make_test_files.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ def _make_electrodes_dynamic_table():
220220
nwbfile = NWBFile(session_description='ADDME',
221221
identifier='ADDME',
222222
session_start_time=datetime.now().astimezone())
223-
device = nwbfile.create_device(name="array", description="an array", manufacturer="company")
223+
device = nwbfile.create_device(name="array", description="an array")
224224
nwbfile.add_electrode_column(name="label", description="label of electrode")
225225

226226
for i in range(4):

src/pynwb/testing/mock/device.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from typing import Optional
22

33
from ... import NWBFile
4-
from ...device import Device
4+
from ...device import Device, DeviceModel
55

66
from .utils import name_generator
77

@@ -22,3 +22,23 @@ def mock_Device(
2222
nwbfile.add_device(device)
2323

2424
return device
25+
26+
27+
def mock_DeviceModel(
28+
name: Optional[str] = None,
29+
manufacturer: str = None,
30+
model_number: Optional[str] = None,
31+
description: str = "description",
32+
nwbfile: Optional[NWBFile] = None,
33+
) -> DeviceModel:
34+
device = DeviceModel(
35+
name=name or name_generator("DeviceModel"),
36+
manufacturer=manufacturer,
37+
model_number=model_number,
38+
description=description,
39+
)
40+
41+
if nwbfile is not None:
42+
nwbfile.add_device_model(device)
43+
44+
return device
-80 Bytes
Binary file not shown.
Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,51 @@
1-
from pynwb.device import Device
1+
from pynwb.device import Device, DeviceModel
22
from pynwb.testing import NWBH5IOMixin, TestCase
33

44

55
class TestDeviceIO(NWBH5IOMixin, TestCase):
66

77
def setUpContainer(self):
88
""" Return the test Device to read/write """
9-
return Device(
10-
name='device_name',
11-
description='description',
9+
device_model = DeviceModel(
10+
name='device_model_name',
1211
manufacturer='manufacturer',
1312
model_number='model_number',
14-
model_name='model_name',
13+
description='description',
14+
)
15+
device = Device(
16+
name='device_name',
17+
description='description',
1518
serial_number='serial_number',
19+
model=device_model,
1620
)
21+
return device
1722

1823
def addContainer(self, nwbfile):
1924
""" Add the test Device to the given NWBFile """
2025
nwbfile.add_device(self.container)
26+
nwbfile.add_device_model(self.container.model)
2127

2228
def getContainer(self, nwbfile):
2329
""" Return the test Device from the given NWBFile """
2430
return nwbfile.get_device(self.container.name)
31+
32+
33+
class TestDeviceModelIO(NWBH5IOMixin, TestCase):
34+
35+
def setUpContainer(self):
36+
""" Return the test DeviceModel to read/write """
37+
device_model = DeviceModel(
38+
name='device_model_name',
39+
manufacturer='manufacturer',
40+
model_number='model_number',
41+
description='description',
42+
)
43+
return device_model
44+
45+
def addContainer(self, nwbfile):
46+
""" Add the test DeviceModel to the given NWBFile """
47+
nwbfile.add_device_model(self.container)
48+
49+
def getContainer(self, nwbfile):
50+
""" Return the test DeviceModel from the given NWBFile """
51+
return nwbfile.get_device_model(self.container.name)

0 commit comments

Comments
 (0)