Skip to content

Commit 618df86

Browse files
authored
Merge pull request #2092 from NeurodataWithoutBorders/nwb-schema-2.9.0
[WIP] Support NWB Schema 2.9.0
2 parents cba0ae8 + 7d7667a commit 618df86

29 files changed

+919
-257
lines changed

CHANGELOG.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,19 @@
33
## Upcoming
44

55
### Breaking changes
6-
76
- Removed unused functions `prepend_string` and `_not_parent` in `core.py`, `_not_parent` in `file.py`, and `NWBBaseTypeMapper.get_nwb_file` in `io/core.py` @oruebel [#2036](https://github.com/NeurodataWithoutBorders/pynwb/pull/2036)
87

98
### Enhancements and minor changes
9+
- Added support for NWB Schema 2.9.0.
10+
- Added `BaseImage` and `ExternalImage` as new neurodata types. The first so both `Image` and `ExternalImage` can inherit from it. The second to store external images. @rly [#2079](https://github.com/NeurodataWithoutBorders/pynwb/pull/2079)
11+
- Added new `ElectrodesTable` neurodata type. @mavaylon1 [#1890](https://github.com/NeurodataWithoutBorders/pynwb/pull/1890)
12+
- Formally defined and renamed `ElectrodeTable` as the `ElectrodesTable` neurodata type. @mavaylon1 [#1890](https://github.com/NeurodataWithoutBorders/pynwb/pull/1890)
13+
- 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)
16+
- Added support for 2D `EventDetection.source_index` to indicate [time_index, channel_index]. @stephprince [#2091](https://github.com/NeurodataWithoutBorders/pynwb/pull/2091)
17+
- Made `EventDetection.times` optional. @stephprince [#2091](https://github.com/NeurodataWithoutBorders/pynwb/pull/2091)
18+
- Deprecated `EventDetection.times`. @stephprince [#2101](https://github.com/NeurodataWithoutBorders/pynwb/pull/2101)
1019
- Automatically add timezone information to timestamps reference time if no timezone information is specified. @stephprince [#2056](https://github.com/NeurodataWithoutBorders/pynwb/pull/2056)
1120
- Added option to disable typemap caching and updated type map cache location. @stephprince [#2057](https://github.com/NeurodataWithoutBorders/pynwb/pull/2057)
1221
- Added dictionary-like operations directly on `ProcessingModule` objects (e.g., `len(processing_module)`). @bendichter [#2020](https://github.com/NeurodataWithoutBorders/pynwb/pull/2020)
@@ -24,7 +33,7 @@
2433
- Fixed missing `IndexSeries.indexed_images`. @rly [#2074](https://github.com/NeurodataWithoutBorders/pynwb/pull/2074)
2534
- Fixed missing `__nwbfields__` and `_fieldsname` for `NWBData` and its subclasses. @rly [#2082](https://github.com/NeurodataWithoutBorders/pynwb/pull/2082)
2635
- Fixed caching of the type map when using HDMF 4.1.0. @rly [#2087](https://github.com/NeurodataWithoutBorders/pynwb/pull/2087)
27-
- 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/)
36+
- 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/)
2837

2938
### Documentation and tutorial enhancements
3039
- Added NWB AI assistant to the home page of the documentation. @magland [#2076](https://github.com/NeurodataWithoutBorders/pynwb/pull/2076)

docs/gallery/domain/ecephys.py

Lines changed: 21 additions & 15 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(
@@ -381,7 +387,7 @@
381387
name="threshold_events",
382388
detection_method="thresholding, 1.5 * std",
383389
source_electricalseries=raw_electrical_series,
384-
source_idx=[1000, 2000, 3000],
390+
source_idx=[[1000, 0], [2000, 4], [3000, 8]], # indicates the time and channel indices
385391
times=[.033, .066, .099],
386392
)
387393

src/pynwb/base.py

Lines changed: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from warnings import warn
22
from collections.abc import Iterable
3+
from abc import ABC
34
from typing import NamedTuple
45

56
import numpy as np
@@ -15,6 +16,8 @@
1516
__all__ = [
1617
'ProcessingModule',
1718
'TimeSeries',
19+
'BaseImage',
20+
'ExternalImage',
1821
'Image',
1922
'ImageReferences',
2023
'Images',
@@ -402,7 +405,7 @@ def get_data_in_units(self):
402405
.. math::
403406
out = data * conversion + offset
404407
405-
If the field 'channel_conversion' is present, the conversion factor for each channel is additionally applied
408+
If the field 'channel_conversion' is present, the conversion factor for each channel is additionally applied
406409
to each channel:
407410
408411
.. math::
@@ -422,26 +425,80 @@ def get_data_in_units(self):
422425
return np.asarray(self.data) * scale_factor + self.offset
423426

424427

428+
@register_class('BaseImage', CORE_NAMESPACE)
429+
class BaseImage(NWBData, ABC):
430+
"""
431+
An abstract base type for image data. Parent type for Image and ExternalImage types.
432+
"""
433+
__nwbfields__ = ('description', )
434+
435+
@docval({'name': 'name', 'type': str, 'doc': 'The name of the image'},
436+
{'name': 'data', 'type': None, 'doc': 'The image data (any type) as specified by a subclass.'},
437+
{'name': 'description', 'type': str, 'doc': 'Description of the image', 'default': None},
438+
allow_positional=AllowPositional.WARNING,)
439+
def __init__(self, **kwargs):
440+
description = kwargs.pop('description')
441+
super().__init__(**kwargs)
442+
443+
self.description = description
444+
445+
425446
@register_class('Image', CORE_NAMESPACE)
426-
class Image(NWBData):
447+
class Image(BaseImage):
427448
"""
428-
Abstract image class. It is recommended to instead use pynwb.image.GrayscaleImage or pynwb.image.RGPImage where
429-
appropriate.
449+
A base type for storing image data directly. Shape can be 2-D (x, y), or 3-D where the
450+
third dimension can have three or four elements, e.g. (x, y, (r, g, b)) or
451+
(x, y, (r, g, b, a)).
430452
"""
431-
__nwbfields__ = ('data', 'resolution', 'description')
453+
__nwbfields__ = ('resolution', )
432454

433-
@docval({'name': 'name', 'type': str, 'doc': 'The name of this image'},
434-
{'name': 'data', 'type': ('array_data', 'data'), 'doc': 'data of image. Dimensions: x, y [, r,g,b[,a]]',
455+
@docval(*get_docval(BaseImage.__init__, 'name'),
456+
{'name': 'data', 'type': ('array_data', 'data'),
457+
'doc': 'Data of image. Shape can be (x, y), (x, y, 3), or (x, y, 4).',
435458
'shape': ((None, None), (None, None, 3), (None, None, 4))},
436459
{'name': 'resolution', 'type': float, 'doc': 'pixels / cm', 'default': None},
437-
{'name': 'description', 'type': str, 'doc': 'description of image', 'default': None},
460+
*get_docval(BaseImage.__init__, 'description'),
438461
allow_positional=AllowPositional.WARNING,)
439462
def __init__(self, **kwargs):
440-
args_to_set = popargs_to_dict(("resolution", "description"), kwargs)
463+
resolution = kwargs.pop('resolution')
441464
super().__init__(**kwargs)
442465

443-
for key, val in args_to_set.items():
444-
setattr(self, key, val)
466+
self.resolution = resolution
467+
468+
469+
@register_class('ExternalImage', CORE_NAMESPACE)
470+
class ExternalImage(BaseImage):
471+
"""
472+
A type for referencing an external image file.
473+
"""
474+
__nwbfields__ = ('image_format', 'image_mode')
475+
476+
@docval(*get_docval(BaseImage.__init__, 'name'),
477+
{'name': 'data', 'type': str, 'doc': 'Path or URL to the external image file.'},
478+
*get_docval(BaseImage.__init__, 'description'),
479+
{
480+
'name': 'image_format',
481+
'type': str,
482+
'doc': (
483+
'Common name of the image file format. Only widely readable, open file formats are allowed.'
484+
'Allowed values are "PNG", "JPEG", and "GIF".'
485+
),
486+
'enum': ['PNG', 'JPEG', 'GIF'],
487+
},
488+
{
489+
'name': 'image_mode',
490+
'type': str,
491+
'doc': 'Image mode (color mode) of the image, e.g., "RGB", "RGBA", "grayscale", and "LA".',
492+
'default': None,
493+
},
494+
allow_positional=AllowPositional.WARNING,)
495+
def __init__(self, **kwargs):
496+
image_format = kwargs.pop('image_format')
497+
image_mode = kwargs.pop('image_mode')
498+
super().__init__(**kwargs)
499+
500+
self.image_format = image_format
501+
self.image_mode = image_mode
445502

446503

447504
@register_class('ImageReferences', CORE_NAMESPACE)
@@ -455,11 +512,9 @@ class ImageReferences(NWBData):
455512
{'name': 'data', 'type': 'array_data', 'doc': 'The images in order.'},
456513
allow_positional=AllowPositional.WARNING,)
457514
def __init__(self, **kwargs):
458-
# NOTE we do not use the docval shape validator here because it will recognize a list of P MxN images as
459-
# having shape (P, M, N)
460-
# check type and dimensionality
461515
for image in kwargs['data']:
462-
assert isinstance(image, Image), "Images used in ImageReferences must have type Image, not %s" % type(image)
516+
assert isinstance(image, BaseImage), \
517+
"Images used in ImageReferences must have type BaseImage, not %s" % type(image)
463518
super().__init__(**kwargs)
464519

465520

@@ -476,7 +531,7 @@ class Images(MultiContainerInterface):
476531
__clsconf__ = {
477532
'attr': 'images',
478533
'add': 'add_image',
479-
'type': Image,
534+
'type': BaseImage,
480535
'get': 'get_image',
481536
'create': 'create_image'
482537
}
@@ -485,7 +540,7 @@ class Images(MultiContainerInterface):
485540
{'name': 'images', 'type': 'array_data', 'doc': 'image objects', 'default': None},
486541
{'name': 'description', 'type': str, 'doc': 'description of images', 'default': 'no description'},
487542
{'name': 'order_of_images', 'type': ImageReferences,
488-
'doc': 'Ordered dataset of references to Image objects stored in the parent group.', 'default': None},
543+
'doc': 'Ordered dataset of references to BaseImage objects stored in the parent group.', 'default': None},
489544
allow_positional=AllowPositional.WARNING,)
490545
def __init__(self, **kwargs):
491546

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

0 commit comments

Comments
 (0)