diff --git a/CHANGELOG.md b/CHANGELOG.md index 1878dc452..606d6abe2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,19 @@ ## Upcoming ### Breaking changes - - 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) ### Enhancements and minor changes +- Added support for NWB Schema 2.9.0. + - 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) + - Added new `ElectrodesTable` neurodata type. @mavaylon1 [#1890](https://github.com/NeurodataWithoutBorders/pynwb/pull/1890) + - Formally defined and renamed `ElectrodeTable` as the `ElectrodesTable` neurodata type. @mavaylon1 [#1890](https://github.com/NeurodataWithoutBorders/pynwb/pull/1890) + - Formally defined bands within `DecompositionSeries` as the neurodatatype `FrequencyBandsTable`. @mavaylon1 @rly [#2063](https://github.com/NeurodataWithoutBorders/pynwb/pull/2063) + - Added new `DeviceModel` neurodata type to store device model information. @rly [#2088](https://github.com/NeurodataWithoutBorders/pynwb/pull/2088) + - Deprecated `Device.model_name`, `Device.model_number`, and `Device.manufacturer` fields in favor of `DeviceModel`. @rly [#2088](https://github.com/NeurodataWithoutBorders/pynwb/pull/2088) + - Added support for 2D `EventDetection.source_index` to indicate [time_index, channel_index]. @stephprince [#2091](https://github.com/NeurodataWithoutBorders/pynwb/pull/2091) + - Made `EventDetection.times` optional. @stephprince [#2091](https://github.com/NeurodataWithoutBorders/pynwb/pull/2091) + - Deprecated `EventDetection.times`. @stephprince [#2101](https://github.com/NeurodataWithoutBorders/pynwb/pull/2101) - Automatically add timezone information to timestamps reference time if no timezone information is specified. @stephprince [#2056](https://github.com/NeurodataWithoutBorders/pynwb/pull/2056) - Added option to disable typemap caching and updated type map cache location. @stephprince [#2057](https://github.com/NeurodataWithoutBorders/pynwb/pull/2057) - 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 @@ - Fixed missing `IndexSeries.indexed_images`. @rly [#2074](https://github.com/NeurodataWithoutBorders/pynwb/pull/2074) - Fixed missing `__nwbfields__` and `_fieldsname` for `NWBData` and its subclasses. @rly [#2082](https://github.com/NeurodataWithoutBorders/pynwb/pull/2082) - Fixed caching of the type map when using HDMF 4.1.0. @rly [#2087](https://github.com/NeurodataWithoutBorders/pynwb/pull/2087) -- 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/) +- 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/) ### Documentation and tutorial enhancements - Added NWB AI assistant to the home page of the documentation. @magland [#2076](https://github.com/NeurodataWithoutBorders/pynwb/pull/2076) diff --git a/docs/gallery/domain/ecephys.py b/docs/gallery/domain/ecephys.py index 11e39b44a..14cf93d5f 100644 --- a/docs/gallery/domain/ecephys.py +++ b/docs/gallery/domain/ecephys.py @@ -80,15 +80,21 @@ # The electrodes table references a required :py:class:`~pynwb.ecephys.ElectrodeGroup`, which is used to represent a # group of electrodes. Before creating an :py:class:`~pynwb.ecephys.ElectrodeGroup`, you must define a # :py:class:`~pynwb.device.Device` object using the method :py:meth:`.NWBFile.create_device`. The fields -# ``description``, ``manufacturer``, ``model_number``, ``model_name``, and ``serial_number`` are optional, but -# recommended. +# ``description``, ``serial_number``, and ``model`` are optional, but recommended. The +# :py:class:`~pynwb.device.DeviceModel` object stores information about the device model, which can be useful +# when searching a set of NWB files or a data archive for all files that use a specific device model +# (e.g., Neuropixels probe). +device_model = nwbfile.create_device_model( + name="Neurovoxels 0.99", + manufacturer="Array Technologies", + model_number="PRB_1_4_0480_123", + description="A 12-channel array with 4 shanks and 3 channels per shank", +) device = nwbfile.create_device( name="array", description="A 12-channel array with 4 shanks and 3 channels per shank", - manufacturer="Array Technologies", - model_number="PRB_1_4_0480_123", - model_name="Neurovoxels 0.99", serial_number="1234567890", + model=device_model, ) ####################### @@ -238,7 +244,7 @@ lfp = LFP(electrical_series=lfp_electrical_series) #################### -# LFP refers to data that has been low-pass filtered, typically below 300 Hz. This data may also be downsampled. +# LFP refers to data that has been low-pass filtered, typically below 300 Hz. This data may also be downsampled. # Because it is filtered and potentially resampled, it is categorized as processed data. # # Create a processing module named ``"ecephys"`` and add the :py:class:`~pynwb.ecephys.LFP` object to it. @@ -252,7 +258,7 @@ ####################### # If your data is filtered for frequency ranges other than LFP — such as Gamma or Theta — you should store it in an -# :py:class:`~pynwb.ecephys.ElectricalSeries` and encapsulate it within a +# :py:class:`~pynwb.ecephys.ElectricalSeries` and encapsulate it within a # :py:class:`~pynwb.ecephys.FilteredEphys` object. from pynwb.ecephys import FilteredEphys @@ -272,21 +278,21 @@ ecephys_module.add(filtered_ephys) ################################ -# In some cases, you may want to further process the LFP data and decompose the signal into different frequency bands +# In some cases, you may want to further process the LFP data and decompose the signal into different frequency bands # to use for other downstream analyses. You can store the processed data from these spectral analyses using a # :py:class:`~pynwb.misc.DecompositionSeries` object. This object allows you to include metadata about the frequency -# bands and metric used (e.g., power, phase, amplitude), as well as link the decomposed data to the original +# bands and metric used (e.g., power, phase, amplitude), as well as link the decomposed data to the original # :py:class:`~pynwb.base.TimeSeries` signal the data was derived from. ####################### -# .. note:: When adding data to :py:class:`~pynwb.misc.DecompositionSeries`, the ``data`` argument is assumed to be +# .. note:: When adding data to :py:class:`~pynwb.misc.DecompositionSeries`, the ``data`` argument is assumed to be # 3D where the first dimension is time, the second dimension is channels, and the third dimension is bands. -bands = dict(theta=(4.0, 12.0), - beta=(12.0, 30.0), +bands = dict(theta=(4.0, 12.0), + beta=(12.0, 30.0), gamma=(30.0, 80.0)) # in Hz -phase_data = np.random.randn(50, 12, len(bands)) # 50 samples, 12 channels, 3 frequency bands +phase_data = np.random.randn(50, 12, len(bands)) # 50 samples, 12 channels, 3 frequency bands decomp_series = DecompositionSeries( name="theta", @@ -353,7 +359,7 @@ # While the :py:class:`~pynwb.misc.Units` table is used to store spike times and waveform data for # spike-sorted, single-unit activity, you may also want to store spike times and waveform snippets of # unsorted spiking activity (e.g., multi-unit activity detected via threshold crossings during data acquisition). -# This information can be stored using :py:class:`~pynwb.ecephys.SpikeEventSeries` objects. +# This information can be stored using :py:class:`~pynwb.ecephys.SpikeEventSeries` objects. spike_snippets = np.random.rand(40, 3, 30) # 40 events, 3 channels, 30 samples per event shank0 = nwbfile.create_electrode_table_region( @@ -381,7 +387,7 @@ name="threshold_events", detection_method="thresholding, 1.5 * std", source_electricalseries=raw_electrical_series, - source_idx=[1000, 2000, 3000], + source_idx=[[1000, 0], [2000, 4], [3000, 8]], # indicates the time and channel indices times=[.033, .066, .099], ) diff --git a/src/pynwb/base.py b/src/pynwb/base.py index cd982e239..f357d0d58 100644 --- a/src/pynwb/base.py +++ b/src/pynwb/base.py @@ -1,5 +1,6 @@ from warnings import warn from collections.abc import Iterable +from abc import ABC from typing import NamedTuple import numpy as np @@ -15,6 +16,8 @@ __all__ = [ 'ProcessingModule', 'TimeSeries', + 'BaseImage', + 'ExternalImage', 'Image', 'ImageReferences', 'Images', @@ -402,7 +405,7 @@ def get_data_in_units(self): .. math:: out = data * conversion + offset - If the field 'channel_conversion' is present, the conversion factor for each channel is additionally applied + If the field 'channel_conversion' is present, the conversion factor for each channel is additionally applied to each channel: .. math:: @@ -422,26 +425,80 @@ def get_data_in_units(self): return np.asarray(self.data) * scale_factor + self.offset +@register_class('BaseImage', CORE_NAMESPACE) +class BaseImage(NWBData, ABC): + """ + An abstract base type for image data. Parent type for Image and ExternalImage types. + """ + __nwbfields__ = ('description', ) + + @docval({'name': 'name', 'type': str, 'doc': 'The name of the image'}, + {'name': 'data', 'type': None, 'doc': 'The image data (any type) as specified by a subclass.'}, + {'name': 'description', 'type': str, 'doc': 'Description of the image', 'default': None}, + allow_positional=AllowPositional.WARNING,) + def __init__(self, **kwargs): + description = kwargs.pop('description') + super().__init__(**kwargs) + + self.description = description + + @register_class('Image', CORE_NAMESPACE) -class Image(NWBData): +class Image(BaseImage): """ - Abstract image class. It is recommended to instead use pynwb.image.GrayscaleImage or pynwb.image.RGPImage where - appropriate. + A base type for storing image data directly. Shape can be 2-D (x, y), or 3-D where the + third dimension can have three or four elements, e.g. (x, y, (r, g, b)) or + (x, y, (r, g, b, a)). """ - __nwbfields__ = ('data', 'resolution', 'description') + __nwbfields__ = ('resolution', ) - @docval({'name': 'name', 'type': str, 'doc': 'The name of this image'}, - {'name': 'data', 'type': ('array_data', 'data'), 'doc': 'data of image. Dimensions: x, y [, r,g,b[,a]]', + @docval(*get_docval(BaseImage.__init__, 'name'), + {'name': 'data', 'type': ('array_data', 'data'), + 'doc': 'Data of image. Shape can be (x, y), (x, y, 3), or (x, y, 4).', 'shape': ((None, None), (None, None, 3), (None, None, 4))}, {'name': 'resolution', 'type': float, 'doc': 'pixels / cm', 'default': None}, - {'name': 'description', 'type': str, 'doc': 'description of image', 'default': None}, + *get_docval(BaseImage.__init__, 'description'), allow_positional=AllowPositional.WARNING,) def __init__(self, **kwargs): - args_to_set = popargs_to_dict(("resolution", "description"), kwargs) + resolution = kwargs.pop('resolution') super().__init__(**kwargs) - for key, val in args_to_set.items(): - setattr(self, key, val) + self.resolution = resolution + + +@register_class('ExternalImage', CORE_NAMESPACE) +class ExternalImage(BaseImage): + """ + A type for referencing an external image file. + """ + __nwbfields__ = ('image_format', 'image_mode') + + @docval(*get_docval(BaseImage.__init__, 'name'), + {'name': 'data', 'type': str, 'doc': 'Path or URL to the external image file.'}, + *get_docval(BaseImage.__init__, 'description'), + { + 'name': 'image_format', + 'type': str, + 'doc': ( + 'Common name of the image file format. Only widely readable, open file formats are allowed.' + 'Allowed values are "PNG", "JPEG", and "GIF".' + ), + 'enum': ['PNG', 'JPEG', 'GIF'], + }, + { + 'name': 'image_mode', + 'type': str, + 'doc': 'Image mode (color mode) of the image, e.g., "RGB", "RGBA", "grayscale", and "LA".', + 'default': None, + }, + allow_positional=AllowPositional.WARNING,) + def __init__(self, **kwargs): + image_format = kwargs.pop('image_format') + image_mode = kwargs.pop('image_mode') + super().__init__(**kwargs) + + self.image_format = image_format + self.image_mode = image_mode @register_class('ImageReferences', CORE_NAMESPACE) @@ -455,11 +512,9 @@ class ImageReferences(NWBData): {'name': 'data', 'type': 'array_data', 'doc': 'The images in order.'}, allow_positional=AllowPositional.WARNING,) def __init__(self, **kwargs): - # NOTE we do not use the docval shape validator here because it will recognize a list of P MxN images as - # having shape (P, M, N) - # check type and dimensionality for image in kwargs['data']: - assert isinstance(image, Image), "Images used in ImageReferences must have type Image, not %s" % type(image) + assert isinstance(image, BaseImage), \ + "Images used in ImageReferences must have type BaseImage, not %s" % type(image) super().__init__(**kwargs) @@ -476,7 +531,7 @@ class Images(MultiContainerInterface): __clsconf__ = { 'attr': 'images', 'add': 'add_image', - 'type': Image, + 'type': BaseImage, 'get': 'get_image', 'create': 'create_image' } @@ -485,7 +540,7 @@ class Images(MultiContainerInterface): {'name': 'images', 'type': 'array_data', 'doc': 'image objects', 'default': None}, {'name': 'description', 'type': str, 'doc': 'description of images', 'default': 'no description'}, {'name': 'order_of_images', 'type': ImageReferences, - 'doc': 'Ordered dataset of references to Image objects stored in the parent group.', 'default': None}, + 'doc': 'Ordered dataset of references to BaseImage objects stored in the parent group.', 'default': None}, allow_positional=AllowPositional.WARNING,) def __init__(self, **kwargs): diff --git a/src/pynwb/device.py b/src/pynwb/device.py index d4169557e..3fbabf88a 100644 --- a/src/pynwb/device.py +++ b/src/pynwb/device.py @@ -1,14 +1,16 @@ from hdmf.utils import docval, popargs, AllowPositional +import warnings from . import register_class, CORE_NAMESPACE from .core import NWBContainer -__all__ = ['Device'] +__all__ = ['Device', 'DeviceModel'] @register_class('Device', CORE_NAMESPACE) class Device(NWBContainer): """ Metadata about a data acquisition device, e.g., recording system, electrode, microscope. + Link to a DeviceModel.model to represent information about the model of the device. """ __nwbfields__ = ( @@ -18,6 +20,7 @@ class Device(NWBContainer): 'model_number', 'model_name', 'serial_number', + 'model', ) @docval( @@ -27,26 +30,86 @@ class Device(NWBContainer): "with the device, the names and versions of those can be added to `NWBFile.was_generated_by`."), 'default': None}, {'name': 'manufacturer', 'type': str, - 'doc': ("The name of the manufacturer of the device, e.g., Imec, Plexon, Thorlabs."), + 'doc': ("DEPRECATED. The name of the manufacturer of the device, e.g., Imec, Plexon, Thorlabs. " + "Instead of using this field, store the value in DeviceModel.manufacturer and link to that " + "DeviceModel from this Device."), 'default': None}, {'name': 'model_number', 'type': str, - 'doc': ('The model number (or part/product number) of the device, e.g., PRB_1_4_0480_1, ' - 'PLX-VP-32-15SE(75)-(260-80)(460-10)-300-(1)CON/32m-V, BERGAMO.'), + 'doc': ('DEPRECATED. The model number (or part/product number) of the device, e.g., PRB_1_4_0480_1, ' + 'PLX-VP-32-15SE(75)-(260-80)(460-10)-300-(1)CON/32m-V, BERGAMO. ' + 'Instead of using this field, store the value in DeviceModel.model_number and link to that ' + 'DeviceModel from this Device. '), 'default': None}, {'name': 'model_name', 'type': str, - 'doc': ('The model name of the device, e.g., Neuropixels 1.0, V-Probe, Bergamo III.'), - 'default': None}, - {'name': 'serial_number', 'type': str, - 'doc': 'The serial number of the device.', + 'doc': ('DEPRECATED. The model name of the device, e.g., Neuropixels 1.0, V-Probe, Bergamo III. ' + 'Instead of using this field, storing the value in DeviceModel.name and link to that ' + 'DeviceModel from this Device.'), 'default': None}, + {'name': 'serial_number', 'type': str, 'doc': 'The serial number of the device.', 'default': None}, + {'name': 'model', 'type': 'DeviceModel', 'doc': 'The model of the device.', 'default': None}, allow_positional=AllowPositional.WARNING, ) def __init__(self, **kwargs): - description, manufacturer, model_number, model_name, serial_number = popargs( - 'description', 'manufacturer', 'model_number', 'model_name', 'serial_number', kwargs) + description, manufacturer, model_number, model_name, serial_number, model = popargs( + 'description', 'manufacturer', 'model_number', 'model_name', 'serial_number', 'model', kwargs) + if model_number is not None: + warnings.warn( + "The 'model_number' field is deprecated. Instead, use DeviceModel.model_number and link to that " + "DeviceModel from this Device.", + DeprecationWarning, + stacklevel=2 + ) + if manufacturer is not None: + warnings.warn( + "The 'manufacturer' field is deprecated. Instead, use DeviceModel.manufacturer and link to that " + "DeviceModel from this Device.", + DeprecationWarning, + stacklevel=2 + ) + if model_name is not None: + warnings.warn( + "The 'model_name' field is deprecated. Instead, use DeviceModel.name and link to that " + "DeviceModel from this Device.", + DeprecationWarning, + stacklevel=2 + ) super().__init__(**kwargs) self.description = description self.manufacturer = manufacturer self.model_number = model_number self.model_name = model_name self.serial_number = serial_number + self.model = model + + +@register_class('DeviceModel', CORE_NAMESPACE) +class DeviceModel(NWBContainer): + """ + Model properties of a data acquisition device, e.g., recording system, electrode, microscope. + """ + + __nwbfields__ = ( + 'manufacturer', + 'model_number', + 'description', + ) + + @docval( + {'name': 'name', 'type': str, 'doc': 'The name of this device model'}, + {'name': 'manufacturer', 'type': str, + 'doc': ("The name of the manufacturer of the device, e.g., Imec, Plexon, Thorlabs.")}, + {'name': 'model_number', 'type': str, + 'doc': ('The model number (or part/product number) of the device, e.g., PRB_1_4_0480_1, ' + 'PLX-VP-32-15SE(75)-(260-80)(460-10)-300-(1)CON/32m-V, BERGAMO.'), + 'default': None}, + {'name': 'description', 'type': str, + 'doc': ("Description of the device model as free-form text."), + 'default': None}, + allow_positional=AllowPositional.ERROR, + ) + def __init__(self, **kwargs): + manufacturer, model_number, description = popargs('manufacturer', 'model_number', 'description', kwargs) + super().__init__(**kwargs) + self.manufacturer = manufacturer + self.model_number = model_number + self.description = description diff --git a/src/pynwb/ecephys.py b/src/pynwb/ecephys.py index 9f3c6e4b2..1c781de8a 100644 --- a/src/pynwb/ecephys.py +++ b/src/pynwb/ecephys.py @@ -2,7 +2,7 @@ import numpy as np from collections.abc import Iterable -from hdmf.common import DynamicTableRegion +from hdmf.common import DynamicTableRegion, DynamicTable from hdmf.data_utils import assertEqualShape from hdmf.utils import docval, popargs, get_docval, popargs_to_dict, get_data_shape, AllowPositional @@ -18,7 +18,8 @@ 'EventDetection', 'LFP', 'FilteredEphys', - 'FeatureExtraction' + 'FeatureExtraction', + 'ElectrodesTable', ] @@ -67,6 +68,42 @@ def __init__(self, **kwargs): setattr(self, key, val) +@register_class('ElectrodesTable', CORE_NAMESPACE) +class ElectrodesTable(DynamicTable): + """A table of all electrodes (i.e. channels) used for recording. Introduced in NWB 3.0.0. Replaces the "electrodes" + table (neurodata_type_inc DynamicTable, no neurodata_type_def) that is part of NWBFile.""" + + __columns__ = ( + {'name': 'location', 'description': 'Location of the electrode (channel).', 'required': True}, + {'name': 'group', 'description': 'Reference to the ElectrodeGroup.', 'required': True}, + {'name': 'group_name', 'description': 'Name of the ElectrodeGroup.', 'required': False}, + {'name': 'x', 'description': 'x coordinate of the channel location in the brain.', 'required': False}, + {'name': 'y', 'description': 'y coordinate of the channel location in the brain.', 'required': False}, + {'name': 'z', 'description': 'z coordinate of the channel location in the brain.', 'required': False}, + {'name': 'imp', 'description': 'Impedance of the channel, in ohms.', 'required': False}, + {'name': 'filtering', 'description': 'Description of hardware filtering.', 'required': False}, + {'name': 'rel_x', 'description': 'x coordinate in electrode group.', 'required': False}, + {'name': 'rel_y', 'description': 'xy coordinate in electrode group.', 'required': False}, + {'name': 'rel_z', 'description': 'z coordinate in electrode group.', 'required': False}, + {'name': 'reference', 'description': ('Description of the reference electrode and/or reference scheme used ' + 'for this electrode.'), 'required': False} + ) + + @docval(*get_docval(DynamicTable.__init__, 'id', 'columns', 'colnames')) + def __init__(self, **kwargs): + kwargs['name'] = 'electrodes' + kwargs['description'] = 'metadata about extracellular electrodes' + super().__init__(**kwargs) + + def copy(self): + """ + Return a copy of this ElectrodesTable. + This is useful for linking. + """ + kwargs = dict(id=self.id, columns=self.columns, colnames=self.colnames) + return self.__class__(**kwargs) + + @register_class('ElectricalSeries', CORE_NAMESPACE) class ElectricalSeries(TimeSeries): """ @@ -155,12 +192,12 @@ def __init__(self, **kwargs): # case where the data is a AbstractDataChunkIterator data_shape = get_data_shape(kwargs['data'], strict_no_data_load=True) timestamps_shape = get_data_shape(kwargs['timestamps'], strict_no_data_load=True) - if (data_shape is not None and - timestamps_shape is not None and - len(data_shape) > 0 and + if (data_shape is not None and + timestamps_shape is not None and + len(data_shape) > 0 and len(timestamps_shape) > 0): - if (data_shape[0] != timestamps_shape[0] and - data_shape[0] is not None and + if (data_shape[0] != timestamps_shape[0] and + data_shape[0] is not None and timestamps_shape[0] is not None): raise ValueError('Must provide the same number of timestamps and spike events') super().__init__(**kwargs) @@ -183,15 +220,37 @@ class EventDetection(NWBDataInterface): {'name': 'source_electricalseries', 'type': ElectricalSeries, 'doc': 'The source electrophysiology data'}, {'name': 'source_idx', 'type': ('array_data', 'data'), 'doc': 'Indices (zero-based) into source ElectricalSeries::data array corresponding ' - 'to time of event. Module description should define what is meant by time of event ' - '(e.g., .25msec before action potential peak, zero-crossing time, etc). ' + 'to time of event or time and channel of event. For 1D arrays, specifies the time ' + 'index for each event. For 2D arrays with shape (num_events, 2), specifies ' + '[time_index, channel_index] for each event. Module description should define what is meant ' + 'by time of event (e.g., .25msec before action potential peak, zero-crossing time, etc). ' 'The index points to each event from the raw data'}, - {'name': 'times', 'type': ('array_data', 'data'), 'doc': 'Timestamps of events, in Seconds'}, + {'name': 'times', 'type': ('array_data', 'data'), 'doc': 'DEPRECATED. Timestamps of events, in Seconds', + 'default': None}, {'name': 'name', 'type': str, 'doc': 'the name of this container', 'default': 'EventDetection'}, allow_positional=AllowPositional.WARNING,) def __init__(self, **kwargs): args_to_set = popargs_to_dict(('detection_method', 'source_electricalseries', 'source_idx', 'times'), kwargs) super().__init__(**kwargs) + + if args_to_set['times'] is not None: + warnings.warn( + "The 'times' argument is deprecated and will be removed in a future version. " \ + "Use 'source_idx' instead to specify the time of events.", + DeprecationWarning, + ) + + # Validate source_idx shape + source_idx = args_to_set['source_idx'] + source_idx_shape = get_data_shape(source_idx, strict_no_data_load=True) + if source_idx_shape is not None: + if len(source_idx_shape) == 2 and source_idx_shape[1] != 2: + raise ValueError(f"EventDetection source_idx: 2D source_idx must have shape (num_events, 2) " + f"for [time_index, channel_index], but got shape {source_idx_shape}") + elif len(source_idx_shape) > 2: + raise ValueError(f"EventDetection source_idx: source_idx must be 1D or 2D array, " + f"but got {len(source_idx_shape)}D array with shape {source_idx_shape}") + for key, val in args_to_set.items(): setattr(self, key, val) self.unit = 'seconds' # fixed value diff --git a/src/pynwb/file.py b/src/pynwb/file.py index dcc9ad458..c2fd317ca 100644 --- a/src/pynwb/file.py +++ b/src/pynwb/file.py @@ -13,9 +13,9 @@ from . import register_class, CORE_NAMESPACE from .base import TimeSeries, ProcessingModule -from .device import Device +from .device import Device, DeviceModel from .epoch import TimeIntervals -from .ecephys import ElectrodeGroup +from .ecephys import ElectrodeGroup, ElectrodesTable from .icephys import (IntracellularElectrode, SweepTable, PatchClampSeries, IntracellularRecordingsTable, SimultaneousRecordingsTable, SequentialRecordingsTable, RepetitionsTable, ExperimentalConditionsTable) @@ -208,6 +208,13 @@ class NWBFile(MultiContainerInterface, HERDManager): 'create': 'create_device', 'get': 'get_device' }, + { + 'attr': 'device_models', + 'add': 'add_device_model', + 'type': DeviceModel, + 'create': 'create_device_model', + 'get': 'get_device_model' + }, { 'attr': 'electrode_groups', 'add': 'add_electrode_group', @@ -385,12 +392,12 @@ class NWBFile(MultiContainerInterface, HERDManager): {'name': 'lab_meta_data', 'type': (list, tuple), 'default': None, 'doc': 'an extension that contains lab-specific meta-data'}, {'name': 'electrodes', 'type': DynamicTable, - 'doc': 'the ElectrodeTable that belongs to this NWBFile', 'default': None}, + 'doc': 'the ElectrodesTable that belongs to this NWBFile', 'default': None}, {'name': 'electrode_groups', 'type': Iterable, 'doc': 'the ElectrodeGroups that belong to this NWBFile', 'default': None}, {'name': 'ic_electrodes', 'type': (list, tuple), 'doc': 'DEPRECATED use icephys_electrodes parameter instead. ' - 'IntracellularElectrodes that belong to this NWBFile', 'default': None}, + 'IntracellularElectrodes that belong to this NWBFile', 'default': None}, # TODO remove this arg in PyNWB 4.0 {'name': 'sweep_table', 'type': SweepTable, 'doc': '[DEPRECATED] Use IntracellularRecordingsTable instead. ' @@ -401,6 +408,8 @@ class NWBFile(MultiContainerInterface, HERDManager): 'doc': 'OptogeneticStimulusSites that belong to this NWBFile', 'default': None}, {'name': 'devices', 'type': (list, tuple), 'doc': 'Device objects belonging to this NWBFile', 'default': None}, + {'name': 'device_models', 'type': (list, tuple), + 'doc': ' Device models used in this NWBFile', 'default': None}, {'name': 'subject', 'type': Subject, 'doc': 'subject metadata', 'default': None}, {'name': 'scratch', 'type': (list, tuple), @@ -439,6 +448,7 @@ def __init__(self, **kwargs): 'electrodes', 'electrode_groups', 'devices', + 'device_models', 'imaging_planes', 'ogen_sites', 'intervals', @@ -611,7 +621,7 @@ def add_epoch(self, **kwargs): def __check_electrodes(self): if self.electrodes is None: - self.electrodes = ElectrodeTable() + self.electrodes = ElectrodesTable() @docval(*get_docval(DynamicTable.add_column), allow_extra=True) def add_electrode_column(self, **kwargs): @@ -666,31 +676,15 @@ def add_electrode(self, **kwargs): # are not allowed if not d['location']: raise ValueError("The 'location' argument is required when creating an electrode.") - if not kwargs['group']: + if not d['group']: raise ValueError("The 'group' argument is required when creating an electrode.") if d.get('group_name', None) is None: d['group_name'] = d['group'].name - new_cols = [('x', 'the x coordinate of the position (+x is posterior)'), - ('y', 'the y coordinate of the position (+y is inferior)'), - ('z', 'the z coordinate of the position (+z is right)'), - ('imp', 'the impedance of the electrode, in ohms'), - ('filtering', 'description of hardware filtering, including the filter name and frequency cutoffs'), - ('rel_x', 'the x coordinate within the electrode group'), - ('rel_y', 'the y coordinate within the electrode group'), - ('rel_z', 'the z coordinate within the electrode group'), - ('reference', 'Description of the reference electrode and/or reference scheme used for this \ - electrode, e.g.,"stainless steel skull screw" or "online common average referencing".') - ] - - # add column if the arg is supplied and column does not yet exist - # do not pass arg to add_row if arg is not supplied - for col_name, col_doc in new_cols: - if kwargs[col_name] is not None: - if col_name not in self.electrodes: - self.electrodes.add_column(col_name, col_doc) - else: - d.pop(col_name) # remove args from d if not set + # remove keys that are None + for key in list(d.keys()): + if d[key] is None: + d.pop(key) self.electrodes.add_row(**d) @@ -705,7 +699,7 @@ def create_electrode_table_region(self, **kwargs): for idx in region: if idx < 0 or idx >= len(self.electrodes): raise IndexError('The index ' + str(idx) + - ' is out of range for the ElectrodeTable of length ' + ' is out of range for the ElectrodesTable of length ' + str(len(self.electrodes))) desc = getargs('description', kwargs) name = getargs('name', kwargs) @@ -787,13 +781,13 @@ def add_invalid_time_interval(self, **kwargs): self.__check_invalid_times() self.invalid_times.add_interval(**kwargs) - @docval({'name': 'electrode_table', 'type': DynamicTable, 'doc': 'the ElectrodeTable for this file'}) + @docval({'name': 'electrode_table', 'type': ElectrodesTable, 'doc': 'the ElectrodesTable for this file'}) def set_electrode_table(self, **kwargs): """ - Set the electrode table of this NWBFile to an existing ElectrodeTable + Set the electrode table of this NWBFile to an existing ElectrodesTable """ if self.electrodes is not None: - msg = 'ElectrodeTable already exists, cannot overwrite' + msg = 'ElectrodesTable already exists, cannot overwrite' raise ValueError(msg) electrode_table = getargs('electrode_table', kwargs) self.electrodes = electrode_table @@ -804,7 +798,7 @@ def _check_sweep_table(self): """ if self.sweep_table is None: if self._in_construct_mode: - # Construct the SweepTable without triggering errors in construct mode because + # Construct the SweepTable without triggering errors in construct mode because # SweepTable has been deprecated sweep_table = SweepTable.__new__(SweepTable, parent=self, in_construct_mode=True) sweep_table.__init__(name='sweep_table') @@ -1146,19 +1140,16 @@ def _tablefunc(table_name, description, columns): return t -def ElectrodeTable(name='electrodes', - description='metadata about extracellular electrodes'): - return _tablefunc(name, description, - [('location', 'the location of channel within the subject e.g. brain region'), - ('group', 'a reference to the ElectrodeGroup this electrode is a part of'), - ('group_name', 'the name of the ElectrodeGroup this electrode is a part of') - ] - ) - - def TrialTable(name='trials', description='metadata about experimental trials'): return _tablefunc(name, description, ['start_time', 'stop_time']) def InvalidTimesTable(name='invalid_times', description='time intervals to be removed from analysis'): return _tablefunc(name, description, ['start_time', 'stop_time']) + + +def ElectrodeTable(name='electrodes', + description='metadata about extracellular electrodes'): + warn("The ElectrodeTable convenience function is deprecated. Please create a new instance of " + "the ElectrodesTable class instead.", DeprecationWarning) + return ElectrodesTable() \ No newline at end of file diff --git a/src/pynwb/io/base.py b/src/pynwb/io/base.py index db9c259ef..409c98d64 100644 --- a/src/pynwb/io/base.py +++ b/src/pynwb/io/base.py @@ -2,7 +2,7 @@ from .core import NWBContainerMapper from .. import register_map -from ..base import TimeSeries, ProcessingModule +from ..base import TimeSeries, ProcessingModule, Images @register_map(ProcessingModule) @@ -125,3 +125,13 @@ def unit_carg(self, builder, manager): if unit_value is None: return timeseries_cls.DEFAULT_UNIT return unit_value + + +@register_map(Images) +class ImagesMap(NWBContainerMapper): + + def __init__(self, spec): + super().__init__(spec) + + # Map the images attribute to the BaseImage neurodata type + self.map_spec('images', spec.get_neurodata_type('BaseImage')) diff --git a/src/pynwb/io/file.py b/src/pynwb/io/file.py index 53d257a05..d74c66be1 100644 --- a/src/pynwb/io/file.py +++ b/src/pynwb/io/file.py @@ -114,6 +114,10 @@ def __init__(self, spec): self.unmap(device_spec) self.map_spec('devices', device_spec.get_neurodata_type('Device')) + device_model_spec = general_spec.get_group('devices').get_group('models') + self.unmap(device_model_spec) + self.map_spec('device_models', device_model_spec.get_neurodata_type('DeviceModel')) + self.map_spec('lab_meta_data', general_spec.get_neurodata_type('LabMetaData')) proc_spec = self.spec.get_group('processing') @@ -181,6 +185,24 @@ def scratch(self, builder, manager): ret.append(manager.construct(d)) return tuple(ret) if len(ret) > 0 else None + @ObjectMapper.constructor_arg('electrodes') + def electrodes(self, builder, manager): + try: + electrodes_builder = builder['general']['extracellular_ephys']['electrodes'] + except KeyError: + # Note: This is here because the ObjectMapper pulls argname from docval and checks to see + # if there is an override even if the file doesn't have what is looking for. In this case, + # electrodes for NWBFile. + electrodes_builder = None + if (electrodes_builder is not None and electrodes_builder.attributes['neurodata_type'] != 'ElectrodesTable'): + electrodes_builder.attributes['neurodata_type'] = 'ElectrodesTable' + electrodes_builder.attributes['namespace'] = 'core' + manager.clear_cache() + new_container = manager.construct(electrodes_builder) + return new_container + else: + return None + @ObjectMapper.constructor_arg('session_start_time') def dateconversion(self, builder, manager): """Set the constructor arg for 'session_start_time' to a datetime object. diff --git a/src/pynwb/io/icephys.py b/src/pynwb/io/icephys.py index b95deff70..3515d85d8 100644 --- a/src/pynwb/io/icephys.py +++ b/src/pynwb/io/icephys.py @@ -26,11 +26,6 @@ def __init__(self, spec): @register_map(IntracellularRecordingsTable) class IntracellularRecordingsTableMap(AlignedDynamicTableMap): - """ - Customize the mapping for AlignedDynamicTable - """ - def __init__(self, spec): - super().__init__(spec) @DynamicTableMap.object_attr('electrodes') def electrodes(self, container, manager): diff --git a/src/pynwb/io/misc.py b/src/pynwb/io/misc.py index 0fd4afabc..1ee8d6280 100644 --- a/src/pynwb/io/misc.py +++ b/src/pynwb/io/misc.py @@ -1,15 +1,26 @@ from hdmf.common.io.table import DynamicTableMap +from .base import TimeSeriesMap from .. import register_map -from pynwb.misc import Units +from pynwb.misc import Units, DecompositionSeries + + +@register_map(DecompositionSeries) +class DecompositionSeriesMap(TimeSeriesMap): + + @TimeSeriesMap.constructor_arg('bands') + def bands(self, builder, manager): + if builder.groups['bands'].attributes['neurodata_type'] != 'FrequencyBandsTable': + builder.groups['bands'].attributes['neurodata_type'] = 'FrequencyBandsTable' + builder.groups['bands'].attributes['namespace'] = 'core' + manager.clear_cache() + new_container = manager.construct(builder.groups['bands']) + return new_container @register_map(Units) class UnitsMap(DynamicTableMap): - def __init__(self, spec): - super().__init__(spec) - @DynamicTableMap.constructor_arg('resolution') def resolution_carg(self, builder, manager): if 'spike_times' in builder: diff --git a/src/pynwb/misc.py b/src/pynwb/misc.py index 5a9b83712..f04ec4c2d 100644 --- a/src/pynwb/misc.py +++ b/src/pynwb/misc.py @@ -253,13 +253,43 @@ def get_unit_obs_intervals(self, **kwargs): index = getargs('index', kwargs) return np.asarray(self['obs_intervals'][index]) +@register_class('FrequencyBandsTable', CORE_NAMESPACE) +class FrequencyBandsTable(DynamicTable): + """ + Table for describing the bands that DecompositionSeries was generated from. + """ + __columns__ = ( + {'name': 'band_name', 'description': 'Name of the band, e.g. theta.', 'required': True}, + {'name': 'band_limits', 'description': 'Low and high limit of each band in Hz.', 'required': True}, + {'name': 'band_mean', 'description': 'The mean Gaussian filters, in Hz.', 'required': False}, + {'name': 'band_stdev', 'description': 'The standard deviation Gaussian filters, in Hz.', 'required': False} + ) + + @docval(*get_docval(DynamicTable.__init__, 'id', 'columns', 'colnames')) + def __init__(self, **kwargs): + kwargs['name'] = 'bands' + kwargs['description'] = 'Table for describing the bands that DecompositionSeries was generated from.' + super().__init__(**kwargs) + + @docval( + {'name': 'band_name', 'type': str, 'doc': 'Name of the band, e.g. theta.'}, + {'name': 'band_limits', 'type': ('array_data', 'data'), 'shape': (2, ), + 'doc': 'Low and high limit of each band in Hz.'}, + {'name': 'band_mean', 'type': float, 'doc': 'The mean Gaussian filters, in Hz.', + 'default': None}, + {'name': 'band_stdev', 'type': float, 'doc': 'The standard deviation Gaussian filters, in Hz.', + 'default': None}, + allow_extra=True + ) + def add_band(self, **kwargs): + super().add_row(**kwargs) + @register_class('DecompositionSeries', CORE_NAMESPACE) class DecompositionSeries(TimeSeries): """ Stores product of spectral analysis """ - __nwbfields__ = ('metric', {'name': 'source_timeseries', 'child': False, 'doc': 'the input TimeSeries from this analysis'}, {'name': 'source_channels', 'child': True, 'doc': 'the channels that provided the source data'}, @@ -278,7 +308,7 @@ class DecompositionSeries(TimeSeries): {'name': 'metric', 'type': str, # required 'doc': "metric of analysis. recommended - 'phase', 'amplitude', 'power'"}, {'name': 'unit', 'type': str, 'doc': 'SI unit of measurement', 'default': 'no unit'}, - {'name': 'bands', 'type': DynamicTable, + {'name': 'bands', 'type': FrequencyBandsTable, 'doc': 'a table for describing the frequency bands that the signal was decomposed into', 'default': None}, {'name': 'source_timeseries', 'type': TimeSeries, 'doc': 'the input TimeSeries from this analysis', 'default': None}, @@ -302,39 +332,19 @@ def __init__(self, **kwargs): "corresponding source_channels. (Optional)") self.metric = metric if bands is None: - bands = DynamicTable( - name="bands", - description="data about the frequency bands that the signal was decomposed into" - ) + bands = FrequencyBandsTable() self.bands = bands - def __check_column(self, name, desc): - if name not in self.bands.colnames: - self.bands.add_column(name, desc) - - @docval({'name': 'band_name', 'type': str, 'doc': 'the name of the frequency band', - 'default': None}, - {'name': 'band_limits', 'type': ('array_data', 'data'), 'default': None, - 'doc': 'low and high frequencies of bandpass filter in Hz'}, - {'name': 'band_mean', 'type': float, 'doc': 'the mean of Gaussian filters in Hz', - 'default': None}, - {'name': 'band_stdev', 'type': float, 'doc': 'the standard deviation of Gaussian filters in Hz', - 'default': None}, - allow_extra=True) + @docval( + {'name': 'band_name', 'type': str, 'doc': 'the name of the frequency band'}, + {'name': 'band_limits', 'type': ('array_data', 'data'), + 'doc': 'low and high frequencies of bandpass filter in Hz'}, + {'name': 'band_mean', 'type': float, 'doc': 'the mean of Gaussian filters in Hz', + 'default': None}, + {'name': 'band_stdev', 'type': float, 'doc': 'the standard deviation of Gaussian filters in Hz', + 'default': None}, + allow_extra=True + ) def add_band(self, **kwargs): - """ - Add ROI data to this - """ - band_name, band_limits, band_mean, band_stdev = getargs('band_name', 'band_limits', 'band_mean', 'band_stdev', - kwargs) - if band_name is not None: - self.__check_column('band_name', "the name of the frequency band (recommended: 'alpha', 'beta', 'gamma', " - "'delta', 'high gamma'") - if band_name is not None: - self.__check_column('band_limits', 'low and high frequencies of bandpass filter in Hz') - if band_mean is not None: - self.__check_column('band_mean', 'the mean of Gaussian filters in Hz') - if band_stdev is not None: - self.__check_column('band_stdev', 'the standard deviation of Gaussian filters in Hz') - - self.bands.add_row({k: v for k, v in kwargs.items() if v is not None}) + """Add a frequency band to the bands table of this DecompositionSeries.""" + self.bands.add_band(**kwargs) diff --git a/src/pynwb/nwb-schema b/src/pynwb/nwb-schema index 54f4980e3..ade50ef33 160000 --- a/src/pynwb/nwb-schema +++ b/src/pynwb/nwb-schema @@ -1 +1 @@ -Subproject commit 54f4980e3be54f2c05582371d25b76074e160d40 +Subproject commit ade50ef33446beb3c7df4c6f1072ae0e821b5115 diff --git a/src/pynwb/testing/make_test_files.py b/src/pynwb/testing/make_test_files.py index 2311989ca..51e7eeafc 100644 --- a/src/pynwb/testing/make_test_files.py +++ b/src/pynwb/testing/make_test_files.py @@ -4,6 +4,7 @@ from pynwb import NWBFile, NWBHDF5IO, __version__, TimeSeries, get_class, load_namespaces from pynwb.file import Subject from pynwb.image import ImageSeries +from pynwb.misc import DecompositionSeries from pynwb.spec import NWBNamespaceBuilder, export_spec, NWBGroupSpec, NWBAttributeSpec @@ -214,6 +215,59 @@ def _make_subject_without_age_reference(): test_name = 'subject_no_age__reference' _write(test_name, nwbfile) +def _make_electrodes_dynamic_table(): + """Create a test file where electrodes is a dynamic table and not its own type.""" + nwbfile = NWBFile(session_description='ADDME', + identifier='ADDME', + session_start_time=datetime.now().astimezone()) + device = nwbfile.create_device(name="array", description="an array") + nwbfile.add_electrode_column(name="label", description="label of electrode") + + for i in range(4): + electrode_group = nwbfile.create_electrode_group( + name=f"shank{i}", + description=f"electrode group for shank {i}", + device=device, + location="brain area", + ) + for j in range(3): + nwbfile.add_electrode( + group=electrode_group, + location="brain area", + label=f"shank{i}electrode{j}", + ) + + test_name = 'electrodes_dynamic_table' + _write(test_name, nwbfile) + +def _make_bands_dynamic_table(): + """Create a test file where electrodes is a dynamic table and not its own type.""" + nwbfile = NWBFile(session_description='ADDME', + identifier='ADDME', + session_start_time=datetime.now().astimezone()) + mod = nwbfile.create_processing_module(name="test_mod", description="a test module") + ts = TimeSeries( + name='dummy timeseries', + description='desc', + data=np.ones((3, 3)), + unit='Volts', + timestamps=np.ones((3,)) + ) + ds = DecompositionSeries( + name='LFPSpectralAnalysis', + description='my description', + data=np.ones((3, 3, 3)), + timestamps=[1., 2., 3.], + source_timeseries=ts, + metric='amplitude' + ) + for band_name in ['alpha', 'beta', 'gamma']: + ds.add_band(band_name=band_name, band_limits=np.array([1., 1.]), band_mean=1., band_stdev=1.) + mod.add(ts) + mod.add(ds) + + test_name = 'decompositionseries_bands_dynamic_table' + _write(test_name, nwbfile) if __name__ == '__main__': # install these versions of PyNWB and run this script to generate new files @@ -242,3 +296,7 @@ def _make_subject_without_age_reference(): if __version__ == "2.2.0": _make_subject_without_age_reference() + + if __version__ == "3.0.0": + _make_electrodes_dynamic_table() + _make_bands_dynamic_table() \ No newline at end of file diff --git a/src/pynwb/testing/mock/device.py b/src/pynwb/testing/mock/device.py index 06ac628e8..4499cc01d 100644 --- a/src/pynwb/testing/mock/device.py +++ b/src/pynwb/testing/mock/device.py @@ -1,7 +1,7 @@ from typing import Optional from ... import NWBFile -from ...device import Device +from ...device import Device, DeviceModel from .utils import name_generator @@ -22,3 +22,23 @@ def mock_Device( nwbfile.add_device(device) return device + + +def mock_DeviceModel( + name: Optional[str] = None, + manufacturer: str = None, + model_number: Optional[str] = None, + description: str = "description", + nwbfile: Optional[NWBFile] = None, +) -> DeviceModel: + device = DeviceModel( + name=name or name_generator("DeviceModel"), + manufacturer=manufacturer, + model_number=model_number, + description=description, + ) + + if nwbfile is not None: + nwbfile.add_device_model(device) + + return device diff --git a/src/pynwb/testing/mock/ecephys.py b/src/pynwb/testing/mock/ecephys.py index 315eb3d9c..5d3399e6d 100644 --- a/src/pynwb/testing/mock/ecephys.py +++ b/src/pynwb/testing/mock/ecephys.py @@ -1,12 +1,13 @@ from typing import Optional +import warnings import numpy as np from hdmf.common.table import DynamicTableRegion, DynamicTable from ...device import Device -from ...file import ElectrodeTable, NWBFile -from ...ecephys import ElectricalSeries, ElectrodeGroup, SpikeEventSeries +from ...file import NWBFile +from ...ecephys import ElectricalSeries, ElectrodeGroup, SpikeEventSeries, ElectrodesTable from .device import mock_Device from .utils import name_generator from ...misc import Units @@ -35,10 +36,10 @@ def mock_ElectrodeGroup( return electrode_group -def mock_ElectrodeTable( +def mock_ElectrodesTable( n_rows: int = 5, group: Optional[ElectrodeGroup] = None, nwbfile: Optional[NWBFile] = None -) -> DynamicTable: - electrodes_table = ElectrodeTable() +) -> ElectrodesTable: + electrodes_table = ElectrodesTable() group = group if group is not None else mock_ElectrodeGroup(nwbfile=nwbfile) for i in range(n_rows): electrodes_table.add_row( @@ -53,11 +54,18 @@ def mock_ElectrodeTable( return electrodes_table +def mock_ElectrodeTable( + n_rows: int = 5, group: Optional[ElectrodeGroup] = None, nwbfile: Optional[NWBFile] = None +) -> ElectrodesTable: + warnings.warn("mock_ElectrodeTable() is deprecated. Use mock_ElectrodesTable() instead.", DeprecationWarning) + return mock_ElectrodesTable(n_rows=n_rows, group=group, nwbfile=nwbfile) + + def mock_electrodes( n_electrodes: int = 5, table: Optional[DynamicTable] = None, nwbfile: Optional[NWBFile] = None ) -> DynamicTableRegion: - table = table or mock_ElectrodeTable(n_rows=5, nwbfile=nwbfile) + table = table or mock_ElectrodesTable(n_rows=5, nwbfile=nwbfile) return DynamicTableRegion( name="electrodes", data=list(range(n_electrodes)), @@ -80,7 +88,7 @@ def mock_ElectricalSeries( conversion: float = 1.0, offset: float = 0., ) -> ElectricalSeries: - + # Set a default rate if timestamps are not provided rate = 30_000.0 if (timestamps is None and rate is None) else rate n_electrodes = data.shape[1] if data is not None else 5 diff --git a/tests/back_compat/3.0.0_decompositionseries_bands_dynamic_table.nwb b/tests/back_compat/3.0.0_decompositionseries_bands_dynamic_table.nwb new file mode 100644 index 000000000..4c9c6f45e Binary files /dev/null and b/tests/back_compat/3.0.0_decompositionseries_bands_dynamic_table.nwb differ diff --git a/tests/back_compat/3.0.0_electrodes_dynamic_table.nwb b/tests/back_compat/3.0.0_electrodes_dynamic_table.nwb new file mode 100644 index 000000000..5c2dbfe5b Binary files /dev/null and b/tests/back_compat/3.0.0_electrodes_dynamic_table.nwb differ diff --git a/tests/back_compat/test_read.py b/tests/back_compat/test_read.py index 1349797be..c7fbf8b8b 100644 --- a/tests/back_compat/test_read.py +++ b/tests/back_compat/test_read.py @@ -3,7 +3,9 @@ import warnings from pynwb import NWBHDF5IO, validate, TimeSeries +from pynwb.ecephys import ElectrodesTable from pynwb.image import ImageSeries +from pynwb.misc import FrequencyBandsTable from pynwb.testing import TestCase @@ -130,3 +132,17 @@ def test_read_subject_no_age__reference(self): with self.get_io(f) as io: read_nwbfile = io.read() self.assertIsNone(read_nwbfile.subject.age__reference) + + def test_read_electrodes_table_as_neurodata_type(self): + """Test that an "electrodes" table written as a DynamicTable is read as an ElectrodesTable""" + f = Path(__file__).parent / '3.0.0_electrodes_dynamic_table.nwb' + with self.get_io(f) as io: + read_nwbfile = io.read() + assert isinstance(read_nwbfile.electrodes, ElectrodesTable) + + def test_read_bands_table_as_neurodata_type(self): + """Test that a "bands" table written as a DynamicTable is read as an FrequencyBandsTable""" + 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 diff --git a/tests/integration/hdf5/test_base.py b/tests/integration/hdf5/test_base.py index 60f8510ff..3e8238b61 100644 --- a/tests/integration/hdf5/test_base.py +++ b/tests/integration/hdf5/test_base.py @@ -3,7 +3,7 @@ from dateutil.tz import tzlocal from pynwb import TimeSeries, NWBFile, NWBHDF5IO -from pynwb.base import Images, Image, ImageReferences +from pynwb.base import Images, Image, ImageReferences, ExternalImage from pynwb.testing import AcquisitionH5IOMixin, TestCase, remove_test_file @@ -78,3 +78,54 @@ def setUpContainer(self): images = Images(name='images_name', images=[image1, image2], order_of_images=image_references) return images + + +class TestExternalImageInImagesIO(AcquisitionH5IOMixin, TestCase): + + def setUpContainer(self): + """ Return the test Images with ExternalImage to read/write """ + ext_img1 = ExternalImage(name='test_external_image1', data='path/to/image1.jpg', image_format='JPEG') + ext_img2 = ExternalImage(name='test_external_image2.jpg', data='path/to/image2.jpg', + description='An external image', image_format="JPEG", image_mode='RGB') + + # Create an Images container with the ExternalImage objects + images = Images(name='test_images', images=[ext_img1, ext_img2]) + + return images + + +class TestExternalImageWithReferencesIO(AcquisitionH5IOMixin, TestCase): + + def setUpContainer(self): + """ Return the test Images with ExternalImage and ImageReferences to read/write """ + ext_img1 = ExternalImage(name="test_external_image1", data="path/to/image1.jpg", image_format="JPEG") + ext_img2 = ExternalImage(name="test_external_image2", data="path/to/image2.png", image_format="PNG") + ext_img3 = ExternalImage(name="test_external_image3", data="path/to/image3.gif", image_format="GIF") + + # Create ImageReferences with the ExternalImage objects + image_references = ImageReferences(name="order_of_images", data=[ext_img3, ext_img2, ext_img1]) + + # Create an Images container with the ExternalImage objects and ImageReferences + images = Images(name="test_images", images=[ext_img1, ext_img2, ext_img3], order_of_images=image_references) + + return images + + +class TestMixedImagesIO(AcquisitionH5IOMixin, TestCase): + """Test reading and writing Images container with both Image and ExternalImage objects.""" + + def setUpContainer(self): + """Return the test Images with both Image and ExternalImage to read/write.""" + # Create a regular Image + image1 = Image(name='test_image', data=np.ones((10, 10))) + + # Create an ExternalImage + ext_img = ExternalImage(name='test_external_image', data='path/to/image.jpg', image_format='JPEG') + + # Create ImageReferences with both types of images + image_references = ImageReferences(name='order_of_images', data=[ext_img, image1]) + + # Create an Images container with both types of images and ImageReferences + images = Images(name='mixed_images', images=[image1, ext_img], order_of_images=image_references) + + return images diff --git a/tests/integration/hdf5/test_device.py b/tests/integration/hdf5/test_device.py index 820edbd53..6d0c19aa4 100644 --- a/tests/integration/hdf5/test_device.py +++ b/tests/integration/hdf5/test_device.py @@ -1,4 +1,4 @@ -from pynwb.device import Device +from pynwb.device import Device, DeviceModel from pynwb.testing import NWBH5IOMixin, TestCase @@ -6,19 +6,46 @@ class TestDeviceIO(NWBH5IOMixin, TestCase): def setUpContainer(self): """ Return the test Device to read/write """ - return Device( - name='device_name', - description='description', + device_model = DeviceModel( + name='device_model_name', manufacturer='manufacturer', model_number='model_number', - model_name='model_name', + description='description', + ) + device = Device( + name='device_name', + description='description', serial_number='serial_number', + model=device_model, ) + return device def addContainer(self, nwbfile): """ Add the test Device to the given NWBFile """ nwbfile.add_device(self.container) + nwbfile.add_device_model(self.container.model) def getContainer(self, nwbfile): """ Return the test Device from the given NWBFile """ return nwbfile.get_device(self.container.name) + + +class TestDeviceModelIO(NWBH5IOMixin, TestCase): + + def setUpContainer(self): + """ Return the test DeviceModel to read/write """ + device_model = DeviceModel( + name='device_model_name', + manufacturer='manufacturer', + model_number='model_number', + description='description', + ) + return device_model + + def addContainer(self, nwbfile): + """ Add the test DeviceModel to the given NWBFile """ + nwbfile.add_device_model(self.container) + + def getContainer(self, nwbfile): + """ Return the test DeviceModel from the given NWBFile """ + return nwbfile.get_device_model(self.container.name) diff --git a/tests/integration/hdf5/test_ecephys.py b/tests/integration/hdf5/test_ecephys.py index 9bfdf3086..1b0872fc0 100644 --- a/tests/integration/hdf5/test_ecephys.py +++ b/tests/integration/hdf5/test_ecephys.py @@ -13,9 +13,9 @@ SpikeEventSeries, EventDetection, FeatureExtraction, + ElectrodesTable, ) from pynwb.device import Device -from pynwb.file import ElectrodeTable as get_electrode_table from pynwb.testing import NWBH5IOMixin, AcquisitionH5IOMixin, NWBH5IOFlexMixin, TestCase @@ -43,7 +43,7 @@ def getContainer(self, nwbfile): def setup_electrode_table(): - table = get_electrode_table() + table = ElectrodesTable() dev1 = Device(name='dev1') group = ElectrodeGroup( name='tetrode1', @@ -175,10 +175,10 @@ def setUpContainer(self): # raise error on write error_msg = "The Clustering neurodata type is deprecated. Use pynwb.misc.Units or NWBFile.units instead" kwargs = dict(description="A fake Clustering interface", - num=[0, 1, 2, 0, 1, 2], - peak_over_rms=[100., 101., 102.], + num=[0, 1, 2, 0, 1, 2], + peak_over_rms=[100., 101., 102.], times=[float(i) for i in range(10, 61, 10)]) - + # create object with deprecated argument with self.assertRaisesWith(ValueError, error_msg): Clustering(**kwargs) @@ -187,7 +187,7 @@ def setUpContainer(self): # no warning should be raised obj = Clustering.__new__(Clustering, in_construct_mode=True) obj.__init__(**kwargs) - + return obj def roundtripContainer(self, cache_spec=False): @@ -247,7 +247,7 @@ def setUpContainer(self): with self.assertRaisesWith(ValueError, msg): cw = ClusterWaveforms(self.clustering, 'filtering', means, stdevs) - # create object in construct mode, modeling the behavior of the ObjectMapper on read + # create object in construct mode, modeling the behavior of the ObjectMapper on read # no warning should be raised cw = ClusterWaveforms.__new__(ClusterWaveforms, container_source=None, @@ -326,7 +326,6 @@ def addContainer(self): detection_method='detection_method', source_electricalseries=eS, source_idx=(1, 2, 3), - times=(0.1, 0.2, 0.3) ) self.nwbfile.add_acquisition(eS) diff --git a/tests/integration/hdf5/test_image.py b/tests/integration/hdf5/test_image.py index a39922951..198ccc2d1 100644 --- a/tests/integration/hdf5/test_image.py +++ b/tests/integration/hdf5/test_image.py @@ -39,7 +39,7 @@ def setUpContainer(self): iS = IndexSeries( name='test_iS', - data=[1, 2, 3], + data=np.uint([1, 2, 3]), unit='N/A', indexed_images=self.images, timestamps=[0.1, 0.2, 0.3] diff --git a/tests/integration/hdf5/test_misc.py b/tests/integration/hdf5/test_misc.py index fe0ded502..ab9744c16 100644 --- a/tests/integration/hdf5/test_misc.py +++ b/tests/integration/hdf5/test_misc.py @@ -1,12 +1,11 @@ import numpy as np -from hdmf.common import DynamicTable, VectorData, DynamicTableRegion +from hdmf.common import VectorData, DynamicTableRegion from pynwb import TimeSeries -from pynwb.misc import Units, DecompositionSeries +from pynwb.misc import Units, DecompositionSeries, FrequencyBandsTable from pynwb.testing import NWBH5IOMixin, AcquisitionH5IOMixin, TestCase -from pynwb.ecephys import ElectrodeGroup +from pynwb.ecephys import ElectrodeGroup, ElectrodesTable from pynwb.device import Device -from pynwb.file import ElectrodeTable as get_electrode_table class TestUnitsIO(AcquisitionH5IOMixin, TestCase): @@ -116,9 +115,7 @@ def setUpContainer(self): unit='flibs', timestamps=np.ones((3,)), ) - bands = DynamicTable( - name='bands', - description='band info for LFPSpectralAnalysis', + bands = FrequencyBandsTable( columns=[ VectorData(name='band_name', description='name of bands', data=['alpha', 'beta', 'gamma']), VectorData(name='band_limits', description='low and high cutoffs in Hz', data=np.ones((3, 2))), @@ -158,7 +155,7 @@ class TestDecompositionSeriesWithSourceChannelsIO(AcquisitionH5IOMixin, TestCase @staticmethod def make_electrode_table(self): """ Make an electrode table, electrode group, and device """ - self.table = get_electrode_table() + self.table = ElectrodesTable() self.dev1 = Device(name='dev1') self.group = ElectrodeGroup( name='tetrode1', @@ -180,9 +177,7 @@ def setUpContainer(self): ) data = np.random.randn(100, 2, 30) timestamps = np.arange(100)/100 - bands = DynamicTable( - name='bands', - description='band info for LFPSpectralAnalysis', + bands = FrequencyBandsTable( columns=[ VectorData(name='band_name', description='name of bands', data=['alpha', 'beta', 'gamma']), VectorData(name='band_limits', description='low and high cutoffs in Hz', data=np.ones((3, 2))), diff --git a/tests/unit/test_device.py b/tests/unit/test_device.py index 305577b53..91af24d81 100644 --- a/tests/unit/test_device.py +++ b/tests/unit/test_device.py @@ -1,22 +1,79 @@ -from pynwb.device import Device +from pynwb.device import Device, DeviceModel from pynwb.testing import TestCase class TestDevice(TestCase): def test_init(self): + device_model = DeviceModel( + name='device_model_name', + manufacturer='manufacturer', + model_number='model_number', + description='description', + ) device = Device( name='device_name', description='description', - manufacturer='manufacturer', - model_number='model_number', - model_name='model_name', serial_number='serial_number', + model=device_model, ) self.assertEqual(device.name, 'device_name') self.assertEqual(device.description, 'description') + self.assertEqual(device.serial_number, 'serial_number') + self.assertIs(device.model, device_model) + + def test_deprecated_manufacturer(self): + msg = ( + "The 'manufacturer' field is deprecated. Instead, use DeviceModel.manufacturer and link to that " + "DeviceModel from this Device." + ) + with self.assertWarnsWith(DeprecationWarning, msg): + device = Device( + name='device_name', + description='description', + manufacturer='manufacturer', + ) self.assertEqual(device.manufacturer, 'manufacturer') + + def test_deprecated_model_number(self): + msg = ( + "The 'model_number' field is deprecated. Instead, use DeviceModel.model_number and link to that " + "DeviceModel from this Device." + ) + with self.assertWarnsWith(DeprecationWarning, msg): + device = Device( + name='device_name', + description='description', + model_number='model_number', + ) self.assertEqual(device.model_number, 'model_number') + + def test_deprecated_model_name(self): + msg = ( + "The 'model_name' field is deprecated. Instead, use DeviceModel.name and link to that " + "DeviceModel from this Device." + ) + with self.assertWarnsWith(DeprecationWarning, msg): + device = Device( + name='device_name', + description='description', + model_name='model_name', + ) self.assertEqual(device.model_name, 'model_name') - self.assertEqual(device.serial_number, 'serial_number') + + +class TestDeviceModel(TestCase): + + def test_init(self): + device_model = DeviceModel( + name='device_model_name', + manufacturer='manufacturer', + model_number='model_number', + description='description', + ) + + self.assertEqual(device_model.name, 'device_model_name') + self.assertEqual(device_model.manufacturer, 'manufacturer') + self.assertEqual(device_model.model_number, 'model_number') + self.assertEqual(device_model.description, 'description') diff --git a/tests/unit/test_ecephys.py b/tests/unit/test_ecephys.py index 9ed263d05..8ee4abac4 100644 --- a/tests/unit/test_ecephys.py +++ b/tests/unit/test_ecephys.py @@ -14,9 +14,10 @@ FilteredEphys, FeatureExtraction, ElectrodeGroup, + ElectrodesTable ) -from pynwb.device import Device from pynwb.file import ElectrodeTable +from pynwb.device import Device from pynwb.testing import TestCase from pynwb.testing.mock.ecephys import mock_ElectricalSeries @@ -25,11 +26,11 @@ def make_electrode_table(): - table = ElectrodeTable() + table = ElectrodesTable() dev1 = Device(name='dev1') - group = ElectrodeGroup(name='tetrode1', - description='tetrode description', - location='tetrode location', + group = ElectrodeGroup(name='tetrode1', + description='tetrode description', + location='tetrode location', device=dev1) table.add_row(location='CA1', group=group, group_name='tetrode1') table.add_row(location='CA1', group=group, group_name='tetrode1') @@ -74,7 +75,7 @@ def test_init(self): def test_link(self): table, region = self._create_table_and_region() - ts1 = ElectricalSeries(name='test_ts1', data=[0, 1, 2, 3, 4, 5], electrodes=region, + ts1 = ElectricalSeries(name='test_ts1', data=[0, 1, 2, 3, 4, 5], electrodes=region, timestamps=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5]) ts2 = ElectricalSeries(name='test_ts2', data=ts1, electrodes=region, timestamps=ts1) ts3 = ElectricalSeries(name='test_ts3', data=ts2, electrodes=region, timestamps=ts2) @@ -87,7 +88,7 @@ def test_invalid_data_shape(self): table, region = self._create_table_and_region() with self.assertRaisesWith(ValueError, ("ElectricalSeries.__init__: incorrect shape for 'data' (got '(2, 2, 2, " "2)', expected '((None,), (None, None), (None, None, None))')")): - ElectricalSeries(name='test_ts1', data=np.ones((2, 2, 2, 2)), electrodes=region, + ElectricalSeries(name='test_ts1', data=np.ones((2, 2, 2, 2)), electrodes=region, timestamps=[0.0, 0.1, 0.2, 0.3, 0.4, 0.5]) def test_dimensions_warning(self): @@ -211,9 +212,9 @@ def test_init(self): def test_init_position_array(self): position = np.array((1, 2, 3), dtype=np.dtype([('x', float), ('y', float), ('z', float)])) dev1 = Device(name='dev1') - group = ElectrodeGroup(name='elec1', - description='electrode description', - location='electrode location', + group = ElectrodeGroup(name='elec1', + description='electrode description', + location='electrode location', device=dev1, position=position) self.assertEqual(group.name, 'elec1') @@ -279,16 +280,98 @@ def test_init(self): ts = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0] table, region = self._create_table_and_region() eS = ElectricalSeries(name='test_eS', data=data, electrodes=region, timestamps=ts) - eD = EventDetection(detection_method='detection_method', - source_electricalseries=eS, - source_idx=(1, 2, 3), - times=(0.1, 0.2, 0.3)) + eD = EventDetection(detection_method='detection_method', + source_electricalseries=eS, + source_idx=(1, 2, 3)) self.assertEqual(eD.detection_method, 'detection_method') self.assertEqual(eD.source_electricalseries, eS) self.assertEqual(eD.source_idx, (1, 2, 3)) - self.assertEqual(eD.times, (0.1, 0.2, 0.3)) self.assertEqual(eD.unit, 'seconds') + def test_init_2d_source_idx(self): + """Test EventDetection with 2D source_idx containing time and channel indices""" + data = np.random.rand(10, 2) # 10 time points, 2 channels + ts = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0] + _, region = self._create_table_and_region() + eS = ElectricalSeries(name='test_eS', data=data, electrodes=region, timestamps=ts) + + # 2D source_idx with shape (num_events, 2) for [time_index, channel_index] + source_idx_2d = np.array([[1, 0], [2, 1], [3, 0],]) # 3 events + + eD = EventDetection(detection_method='threshold detection', + source_electricalseries=eS, + source_idx=source_idx_2d) + + self.assertEqual(eD.detection_method, 'threshold detection') + self.assertEqual(eD.source_electricalseries, eS) + np.testing.assert_array_equal(eD.source_idx, source_idx_2d) + self.assertEqual(eD.unit, 'seconds') + + def test_init_optional_times(self): + """Test EventDetection with optional times parameter (times=None)""" + data = list(range(10)) + ts = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0] + _, region = self._create_table_and_region() + eS = ElectricalSeries(name='test_eS', data=data, electrodes=region, timestamps=ts) + + eD = EventDetection(detection_method='detection_method', + source_electricalseries=eS, + source_idx=(1, 2, 3)) + + self.assertEqual(eD.detection_method, 'detection_method') + self.assertEqual(eD.source_electricalseries, eS) + self.assertEqual(eD.source_idx, (1, 2, 3)) + self.assertIsNone(eD.times) + + def test_times_deprecation_warning(self): + """Test that using times parameter raises deprecation warning""" + data = list(range(10)) + ts = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0] + _, region = self._create_table_and_region() + eS = ElectricalSeries(name='test_eS', data=data, electrodes=region, timestamps=ts) + + # Test that deprecation warning is raised when times is provided + msg = ("The 'times' argument is deprecated and will be removed in a future version. " + "Use 'source_idx' instead to specify the time of events.") + with self.assertWarnsWith(DeprecationWarning, msg): + EventDetection(detection_method='test_method', + source_electricalseries=eS, + source_idx=(1, 2, 3), + times=(0.1, 0.2, 0.3)) + + def test_invalid_2d_source_idx_shape(self): + """Test error handling for invalid 2D source_idx shapes""" + data = list(range(10)) + ts = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0] + _, region = self._create_table_and_region() + eS = ElectricalSeries(name='test_eS', data=data, electrodes=region, timestamps=ts) + + # Test with invalid 2D shape (num_events, 3) - should be (num_events, 2) + invalid_source_idx = np.array([[1, 0, 5], [2, 1, 6]]) + + msg = (f"EventDetection source_idx: 2D source_idx must have shape (num_events, 2) " + f"for [time_index, channel_index], but got shape {invalid_source_idx.shape}") + with self.assertRaisesWith(ValueError, msg): + EventDetection(detection_method='detection_method', + source_electricalseries=eS, + source_idx=invalid_source_idx) + + def test_invalid_3d_source_idx(self): + """Test error handling for 3D source_idx arrays""" + data = list(range(10)) + ts = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0] + _, region = self._create_table_and_region() + eS = ElectricalSeries(name='test_eS', data=data, electrodes=region, timestamps=ts) + + # test with 3D array - should raise ValueError + invalid_source_idx = np.array([[[1, 0], [2, 1]], [[3, 0], [4, 1]]]) + + msg = (f"EventDetection source_idx: source_idx must be 1D or 2D array, " + f"but got {len(invalid_source_idx.shape)}D array with shape {invalid_source_idx.shape}") + with self.assertRaisesWith(ValueError, msg): + EventDetection(detection_method='detection_method', + source_electricalseries=eS, + source_idx=invalid_source_idx) class EventWaveformConstructor(TestCase): @@ -305,14 +388,14 @@ def test_init(self): peak_over_rms = [5.3, 6.3] error_msg = "The Clustering neurodata type is deprecated. Use pynwb.misc.Units or NWBFile.units instead" - kwargs = dict(description='description', + kwargs = dict(description='description', num=num, peak_over_rms=peak_over_rms, times=times) with self.assertRaisesWith(ValueError, error_msg): cc = Clustering(**kwargs) - - # create object in construct mode, modeling the behavior of the ObjectMapper on read + + # create object in construct mode, modeling the behavior of the ObjectMapper on read # no error or warning should be raised cc = Clustering.__new__(Clustering, in_construct_mode=True) cc.__init__(**kwargs) @@ -330,7 +413,7 @@ def test_init(self): num = [3, 4] peak_over_rms = [5.3, 6.3] - # create object in construct mode, modeling the behavior of the ObjectMapper on read + # create object in construct mode, modeling the behavior of the ObjectMapper on read cc = Clustering.__new__(Clustering, container_source=None, parent=None, @@ -343,7 +426,7 @@ def test_init(self): with self.assertRaisesWith(ValueError, error_msg): cw = ClusterWaveforms(cc, 'filtering', means, stdevs) - # create object in construct mode, modeling the behavior of the ObjectMapper on read + # create object in construct mode, modeling the behavior of the ObjectMapper on read # no error or warning should be raised cw = ClusterWaveforms.__new__(ClusterWaveforms, container_source=None, @@ -371,7 +454,7 @@ def _create_table_and_region(self): def test_init(self): _, region = self._create_table_and_region() - eS = ElectricalSeries(name='test_eS', data=[0, 1, 2, 3], + eS = ElectricalSeries(name='test_eS', data=[0, 1, 2, 3], electrodes=region, timestamps=[0.1, 0.2, 0.3, 0.4]) msg = ( "The linked table for DynamicTableRegion 'electrodes' does not share " @@ -385,9 +468,9 @@ def test_init(self): def test_add_electrical_series(self): lfp = LFP() table, region = self._create_table_and_region() - eS = ElectricalSeries(name='test_eS', - data=[0, 1, 2, 3], - electrodes=region, + eS = ElectricalSeries(name='test_eS', + data=[0, 1, 2, 3], + electrodes=region, timestamps=[0.1, 0.2, 0.3, 0.4]) pm = ProcessingModule(name='test_module', description='a test module') pm.add(table) @@ -410,9 +493,9 @@ def _create_table_and_region(self): def test_init(self): _, region = self._create_table_and_region() - eS = ElectricalSeries(name='test_eS', - data=[0, 1, 2, 3], - electrodes=region, + eS = ElectricalSeries(name='test_eS', + data=[0, 1, 2, 3], + electrodes=region, timestamps=[0.1, 0.2, 0.3, 0.4]) msg = ( "The linked table for DynamicTableRegion 'electrodes' does not share " @@ -425,9 +508,9 @@ def test_init(self): def test_add_electrical_series(self): table, region = self._create_table_and_region() - eS = ElectricalSeries(name='test_eS', - data=[0, 1, 2, 3], - electrodes=region, + eS = ElectricalSeries(name='test_eS', + data=[0, 1, 2, 3], + electrodes=region, timestamps=[0.1, 0.2, 0.3, 0.4]) pm = ProcessingModule(name='test_module', description='a test module') fe = FilteredEphys() @@ -455,9 +538,9 @@ def test_init(self): table, region = self._create_table_and_region() description = ['desc1', 'desc2', 'desc3'] features = [[[0, 1, 2], [3, 4, 5]], [[6, 7, 8], [9, 10, 11]]] - fe = FeatureExtraction(electrodes=region, - description=description, - times=event_times, + fe = FeatureExtraction(electrodes=region, + description=description, + times=event_times, features=features) self.assertEqual(fe.description, description) self.assertEqual(fe.times, event_times) @@ -469,9 +552,9 @@ def test_invalid_init_mismatched_event_times(self): description = ['desc1', 'desc2', 'desc3'] features = [[[0, 1, 2], [3, 4, 5]]] with self.assertRaises(ValueError): - FeatureExtraction(electrodes=region, - description=description, - times=event_times, + FeatureExtraction(electrodes=region, + description=description, + times=event_times, features=features) def test_invalid_init_mismatched_electrodes(self): @@ -481,9 +564,9 @@ def test_invalid_init_mismatched_electrodes(self): description = ['desc1', 'desc2', 'desc3'] features = [[[0, 1, 2], [3, 4, 5]]] with self.assertRaises(ValueError): - FeatureExtraction(electrodes=region, - description=description, - times=event_times, + FeatureExtraction(electrodes=region, + description=description, + times=event_times, features=features) def test_invalid_init_mismatched_description(self): @@ -492,9 +575,9 @@ def test_invalid_init_mismatched_description(self): description = ['desc1', 'desc2', 'desc3', 'desc4'] # Need 3 descriptions but give 4 features = [[[0, 1, 2], [3, 4, 5]]] with self.assertRaises(ValueError): - FeatureExtraction(electrodes=region, - description=description, - times=event_times, + FeatureExtraction(electrodes=region, + description=description, + times=event_times, features=features) def test_invalid_init_mismatched_description2(self): @@ -503,7 +586,15 @@ def test_invalid_init_mismatched_description2(self): description = ['desc1', 'desc2', 'desc3'] features = [[0, 1, 2], [3, 4, 5]] # Need 3D feature array but give only 2D array with self.assertRaises(ValueError): - FeatureExtraction(electrodes=region, - description=description, - times=event_times, + FeatureExtraction(electrodes=region, + description=description, + times=event_times, features=features) + +class ElectrodesTableConstructor(TestCase): + + def test_backwards_compatibility(self): + warn_msg = ("The ElectrodeTable convenience function is deprecated. Please create a new instance of " + "the ElectrodesTable class instead.") + with self.assertWarnsWith(DeprecationWarning, warn_msg): + ElectrodeTable() diff --git a/tests/unit/test_external_image.py b/tests/unit/test_external_image.py new file mode 100644 index 000000000..0b11d3306 --- /dev/null +++ b/tests/unit/test_external_image.py @@ -0,0 +1,74 @@ +from pynwb.base import ExternalImage, Images, ImageReferences +from pynwb.testing import TestCase + + +class TestExternalImage(TestCase): + """Test the ExternalImage class.""" + + def test_init(self): + """Test creating an ExternalImage.""" + file_path = "path/to/image.jpg" + ext_img = ExternalImage(name="test_external_image", data=file_path, image_format="JPEG") + + self.assertEqual(ext_img.name, "test_external_image") + self.assertEqual(ext_img.data, file_path) + self.assertEqual(ext_img.image_format, "JPEG") + self.assertIsNone(ext_img.description) + + def test_init_with_fields(self): + """Test creating an ExternalImage with a description, image format, and image mode.""" + file_path = "path/to/image.jpg" + description = "An external image" + ext_img = ExternalImage(name="test_external_image", data=file_path, description=description, + image_format="JPEG", image_mode='RGB') + + self.assertEqual(ext_img.name, "test_external_image") + self.assertEqual(ext_img.data, file_path) + self.assertEqual(ext_img.description, description) + self.assertEqual(ext_img.image_format, "JPEG") + self.assertEqual(ext_img.image_mode, 'RGB') + + def test_init_invalid_image_format(self): + with self.assertRaises(ValueError): + ExternalImage(name="test_external_image", data="path/to/image.jpg", image_format="INVALID_FORMAT") + + def test_url_as_file_path(self): + """Test creating an ExternalImage with a URL as the file path.""" + file_path = "https://example.com/image.jpg" + ext_img = ExternalImage(name="test_external_image", data=file_path, image_format="JPEG") + + self.assertEqual(ext_img.name, "test_external_image") + self.assertEqual(ext_img.data, file_path) + + def test_in_images_container(self): + """Test adding an ExternalImage to an Images container.""" + ext_img1 = ExternalImage(name="test_external_image1", data="path/to/image1.jpg", image_format="JPEG") + ext_img2 = ExternalImage(name="test_external_image2", data="path/to/image2.png", image_format="PNG") + ext_img3 = ExternalImage(name="test_external_image3", data="path/to/image3.gif", image_format="GIF") + + # Create an Images container with the ExternalImage objects + images = Images(name="test_images", images=[ext_img1, ext_img2, ext_img3]) + + # Check that the ExternalImage objects are in the Images container + self.assertIn("test_external_image1", images.images) + self.assertIn("test_external_image2", images.images) + self.assertIs(images.images["test_external_image1"], ext_img1) + self.assertIs(images.images["test_external_image2"], ext_img2) + self.assertIs(images.images["test_external_image3"], ext_img3) + + def test_with_image_references(self): + """Test using ExternalImage with ImageReferences.""" + ext_img1 = ExternalImage(name="test_external_image1", data="path/to/image1.jpg", image_format="JPEG") + ext_img2 = ExternalImage(name="test_external_image2", data="path/to/image2.png", image_format="PNG") + ext_img3 = ExternalImage(name="test_external_image3", data="path/to/image3.gif", image_format="GIF") + + # Create ImageReferences with the ExternalImage objects + image_references = ImageReferences(name="order_of_images", data=[ext_img3, ext_img2, ext_img1]) + + # Create an Images container with the ExternalImage objects and ImageReferences + images = Images(name="test_images", images=[ext_img1, ext_img2, ext_img3], order_of_images=image_references) + + # Check that the order in ImageReferences is correct + self.assertIs(images.order_of_images[0], ext_img3) + self.assertIs(images.order_of_images[1], ext_img2) + self.assertIs(images.order_of_images[2], ext_img1) diff --git a/tests/unit/test_file.py b/tests/unit/test_file.py index 7d66926cd..5338f6669 100644 --- a/tests/unit/test_file.py +++ b/tests/unit/test_file.py @@ -9,9 +9,9 @@ from hdmf.utils import docval, get_docval, popargs from pynwb import NWBFile, TimeSeries, NWBHDF5IO from pynwb.base import Image, Images -from pynwb.file import Subject, ElectrodeTable, _add_missing_timezone +from pynwb.file import Subject, _add_missing_timezone from pynwb.epoch import TimeIntervals -from pynwb.ecephys import ElectricalSeries +from pynwb.ecephys import ElectricalSeries, ElectrodesTable from pynwb.testing import TestCase, remove_test_file @@ -261,7 +261,7 @@ def test_add_acquisition_invalid_name(self): self.nwbfile.get_acquisition("TEST_TS") def test_set_electrode_table(self): - table = ElectrodeTable() + table = ElectrodesTable() dev1 = self.nwbfile.create_device('dev1') group = self.nwbfile.create_electrode_group('tetrode1', 'tetrode description', 'tetrode location', dev1) diff --git a/tests/unit/test_misc.py b/tests/unit/test_misc.py index 173ac9fdf..9b4e372b7 100644 --- a/tests/unit/test_misc.py +++ b/tests/unit/test_misc.py @@ -1,11 +1,13 @@ import numpy as np -from hdmf.common import DynamicTable, VectorData, DynamicTableRegion +from hdmf.common import VectorData, DynamicTableRegion -from pynwb.misc import AnnotationSeries, AbstractFeatureSeries, IntervalSeries, Units, DecompositionSeries -from pynwb.file import TimeSeries, ElectrodeTable as get_electrode_table +from pynwb.misc import ( + AnnotationSeries, AbstractFeatureSeries, IntervalSeries, Units, DecompositionSeries, FrequencyBandsTable +) +from pynwb.file import TimeSeries from pynwb.device import Device -from pynwb.ecephys import ElectrodeGroup +from pynwb.ecephys import ElectrodeGroup, ElectrodesTable from pynwb.testing import TestCase @@ -18,9 +20,9 @@ def test_init(self): class AbstractFeatureSeriesConstructor(TestCase): def test_init(self): - aFS = AbstractFeatureSeries(name='test_aFS', - feature_units=['feature units'], - features=['features'], + aFS = AbstractFeatureSeries(name='test_aFS', + feature_units=['feature units'], + features=['features'], timestamps=list()) self.assertEqual(aFS.name, 'test_aFS') self.assertEqual(aFS.feature_units, ['feature units']) @@ -29,21 +31,56 @@ def test_init(self): aFS.add_features(2.0, [1.]) +class FrequencyBandsTableConstructor(TestCase): + def setUp(self): + self.bands = FrequencyBandsTable( + columns=[ + VectorData(name='band_name', description='name of bands', data=['alpha', 'beta', 'gamma']), + VectorData(name='band_limits', description='low and high cutoffs in Hz', data=np.ones((3, 2))), + VectorData(name='band_mean', description='mean gaussian filters in Hz', data=np.ones((3,))), + VectorData( + name='band_stdev', + description='standard deviation of gaussian filters in Hz', + data=np.ones((3,)) + ), + ], + ) + + def test_init(self): + self.assertEqual(self.bands['band_name'].data, ['alpha', 'beta', 'gamma']) + np.testing.assert_equal(self.bands['band_limits'].data, np.ones((3, 2))) + np.testing.assert_equal(self.bands['band_mean'].data, np.ones((3,))) + np.testing.assert_equal(self.bands['band_stdev'].data, np.ones((3,))) + + def test_add_row(self): + self.bands.add_band(band_name='band_name1', band_limits=np.array([1., 2.]), band_mean=1., band_stdev=1.) + self.bands.add_band(band_name='band_name2', band_limits=(3., 4.), band_mean=1., band_stdev=1.) + self.bands.add_band(band_name='band_name3', band_limits=[5., 6.], band_mean=1., band_stdev=1.) + np.testing.assert_equal( + self.bands['band_limits'].data, + [[1., 1.], [1., 1.], [1., 1.], [1., 2.,], [3., 4.], [5., 6.]] + ) + np.testing.assert_equal(self.bands['band_mean'].data, np.ones((6,))) + np.testing.assert_equal(self.bands['band_stdev'].data, np.ones((6,))) + + class DecompositionSeriesConstructor(TestCase): def test_init(self): timeseries = TimeSeries(name='dummy timeseries', description='desc', data=np.ones((3, 3)), unit='Volts', timestamps=[1., 2., 3.]) - bands = DynamicTable(name='bands', description='band info for LFPSpectralAnalysis', columns=[ - VectorData(name='band_name', description='name of bands', data=['alpha', 'beta', 'gamma']), - VectorData(name='band_limits', description='low and high cutoffs in Hz', data=np.ones((3, 2))), - VectorData(name='band_mean', description='mean gaussian filters in Hz', data=np.ones((3,))), - VectorData( - name='band_stdev', - description='standard deviation of gaussian filters in Hz', - data=np.ones((3,)) - ), - ]) + bands = FrequencyBandsTable( + columns=[ + VectorData(name='band_name', description='name of bands', data=['alpha', 'beta', 'gamma']), + VectorData(name='band_limits', description='low and high cutoffs in Hz', data=np.ones((3, 2))), + VectorData(name='band_mean', description='mean gaussian filters in Hz', data=np.ones((3,))), + VectorData( + name='band_stdev', + description='standard deviation of gaussian filters in Hz', + data=np.ones((3,)) + ), + ], + ) spec_anal = DecompositionSeries(name='LFPSpectralAnalysis', description='my description', data=np.ones((3, 3, 3)), @@ -74,21 +111,20 @@ def test_init_delayed_bands(self): source_timeseries=timeseries, metric='amplitude') for band_name in ['alpha', 'beta', 'gamma']: - spec_anal.add_band(band_name=band_name, band_limits=(1., 1.), band_mean=1., band_stdev=1.) - + spec_anal.add_band(band_name=band_name, band_limits=np.array([1., 1.]), band_mean=1., band_stdev=1.) self.assertEqual(spec_anal.name, 'LFPSpectralAnalysis') self.assertEqual(spec_anal.description, 'my description') np.testing.assert_equal(spec_anal.data, np.ones((3, 3, 3))) np.testing.assert_equal(spec_anal.timestamps, [1., 2., 3.]) - self.assertEqual(spec_anal.bands['band_name'].data, ['alpha', 'beta', 'gamma']) - np.testing.assert_equal(spec_anal.bands['band_limits'].data, np.ones((3, 2))) self.assertEqual(spec_anal.source_timeseries, timeseries) self.assertEqual(spec_anal.metric, 'amplitude') + self.assertEqual(spec_anal.bands['band_name'].data, ['alpha', 'beta', 'gamma']) + np.testing.assert_equal(spec_anal.bands['band_limits'].data, [np.array([1., 1.]) for _ in range(3)]) @staticmethod def make_electrode_table(self): """ Make an electrode table, electrode group, and device """ - self.table = get_electrode_table() + self.table = ElectrodesTable() self.dev1 = Device(name='dev1') self.group = ElectrodeGroup(name='tetrode1', description='tetrode description', @@ -251,9 +287,9 @@ def test_times_and_intervals(self): def test_electrode_group(self): ut = Units() device = Device(name='test_device') - electrode_group = ElectrodeGroup(name='test_electrode_group', - description='description', - location='location', + electrode_group = ElectrodeGroup(name='test_electrode_group', + description='description', + location='location', device=device) ut.add_unit(electrode_group=electrode_group) self.assertEqual(ut['electrode_group'][0], electrode_group) diff --git a/tests/unit/test_mock.py b/tests/unit/test_mock.py index b0adad269..136e28d82 100644 --- a/tests/unit/test_mock.py +++ b/tests/unit/test_mock.py @@ -32,7 +32,7 @@ from pynwb.testing.mock.ecephys import ( mock_ElectrodeGroup, - mock_ElectrodeTable, + mock_ElectrodesTable, mock_ElectricalSeries, mock_SpikeEventSeries, mock_Units, @@ -70,7 +70,7 @@ mock_CompassDirection, mock_SpatialSeries, mock_ElectrodeGroup, - mock_ElectrodeTable, + mock_ElectrodesTable, mock_ElectricalSeries, mock_SpikeEventSeries, mock_Subject, @@ -116,12 +116,11 @@ def test_mock_write(mock_function, tmp_path): def test_name_generator(): - name_generator_registry.clear() # reset registry - assert name_generator("TimeSeries") == "TimeSeries" assert name_generator("TimeSeries") == "TimeSeries2" + @pytest.mark.parametrize("mask_type", ["image_mask", "pixel_mask", "voxel_mask"]) def test_mock_PlaneSegmentation_mask_type(mask_type): plane_segmentation = mock_PlaneSegmentation(mask_type=mask_type)