diff --git a/README.rst b/README.rst index c553270e9..f5f29a007 100644 --- a/README.rst +++ b/README.rst @@ -93,7 +93,7 @@ You are under no obligation whatsoever to provide any bug fixes, patches, or upg COPYRIGHT ========= -"pynwb" Copyright (c) 2017-2019, The Regents of the University of California, through Lawrence Berkeley National Laboratory (subject to receipt of any required approvals from the U.S. Dept. of Energy). All rights reserved. +"pynwb" Copyright (c) 2017-2020, The Regents of the University of California, through Lawrence Berkeley National Laboratory (subject to receipt of any required approvals from the U.S. Dept. of Energy). All rights reserved. If you have questions about your rights to use or distribute this software, please contact Berkeley Lab's Innovation & Partnerships Office at IPO@lbl.gov. NOTICE. This Software was developed under funding from the U.S. Department of Energy and the U.S. Government consequently retains certain rights. As such, the U.S. Government has been granted for itself and others acting on its behalf a paid-up, nonexclusive, irrevocable, worldwide license in the Software to reproduce, distribute copies to the public, prepare derivative works, and perform publicly and display publicly, and to permit other to do so. diff --git a/requirements-min.txt b/requirements-min.txt index d462a1954..430c39948 100644 --- a/requirements-min.txt +++ b/requirements-min.txt @@ -1,6 +1,6 @@ # these minimum requirements specify '==' for testing; setup.py replaces '==' with '>=' h5py==2.9 # support for setting attrs to lists of utf-8 added in 2.9 -hdmf==1.5.4,<2 +hdmf==1.6.1,<2 numpy==1.16 pandas==0.23 python-dateutil==2.7 diff --git a/requirements.txt b/requirements.txt index 3d8d7d180..1eb5d4751 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ h5py==2.10.0 -hdmf==1.5.4 +hdmf==1.6.1 numpy==1.18.1 pandas==0.25.3 -python-dateutil==2.8.0 +python-dateutil==2.8.1 diff --git a/src/pynwb/__init__.py b/src/pynwb/__init__.py index ec6e9a186..57af58dfa 100644 --- a/src/pynwb/__init__.py +++ b/src/pynwb/__init__.py @@ -168,8 +168,8 @@ def get_class(**kwargs): specification. If you want to define a custom mapping, you should not use this function and you should define the class manually. - Examples - -------- + Examples: + Generating and registering an extension is as simple as:: MyClass = get_class('MyClass', 'ndx-my-extension') diff --git a/src/pynwb/base.py b/src/pynwb/base.py index b7e7f069e..b45406acb 100644 --- a/src/pynwb/base.py +++ b/src/pynwb/base.py @@ -232,10 +232,14 @@ def time_unit(self): @register_class('Image', CORE_NAMESPACE) class Image(NWBData): + """ + Abstract image class. It is recommended to instead use pynwb.image.GrayscaleImage or pynwb.image.RGPImage where + appropriate. + """ __nwbfields__ = ('data', 'resolution', 'description') @docval({'name': 'name', 'type': str, 'doc': 'The name of this TimeSeries dataset'}, - {'name': 'data', 'type': ('array_data', 'data'), 'doc': 'data of image', + {'name': 'data', 'type': ('array_data', 'data'), 'doc': 'data of image. Dimensions: x, y [, r,g,b[,a]]', '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}) diff --git a/src/pynwb/core.py b/src/pynwb/core.py index 0ee83a3cd..0d1c36ba3 100644 --- a/src/pynwb/core.py +++ b/src/pynwb/core.py @@ -5,7 +5,8 @@ from hdmf.utils import docval, getargs, ExtenderMeta, call_docval_func, popargs, get_docval, fmt_docval_args from hdmf import Container, Data, DataRegion, get_region_slicer from hdmf.container import AbstractContainer -from hdmf.common import DynamicTable, DynamicTableRegion # NOQA +from hdmf.common import DynamicTable, DynamicTableRegion # noqa: F401 +from hdmf.common import VectorData, VectorIndex, ElementIdentifiers # noqa: F401 from . import CORE_NAMESPACE, register_class diff --git a/src/pynwb/ecephys.py b/src/pynwb/ecephys.py index 7281e40ac..c0e002030 100644 --- a/src/pynwb/ecephys.py +++ b/src/pynwb/ecephys.py @@ -18,18 +18,25 @@ class ElectrodeGroup(NWBContainer): __nwbfields__ = ('name', 'description', 'location', - 'device') + 'device', + 'position') @docval({'name': 'name', 'type': str, 'doc': 'the name of this electrode'}, {'name': 'description', 'type': str, 'doc': 'description of this electrode group'}, {'name': 'location', 'type': str, 'doc': 'description of location of this electrode group'}, - {'name': 'device', 'type': Device, 'doc': 'the device that was used to record from this electrode group'}) + {'name': 'device', 'type': Device, 'doc': 'the device that was used to record from this electrode group'}, + {'name': 'position', 'type': 'array_data', + 'doc': 'stereotaxic position of this electrode group (x, y, z)', 'default': None}) def __init__(self, **kwargs): call_docval_func(super(ElectrodeGroup, self).__init__, kwargs) - description, location, device = popargs("description", "location", "device", kwargs) + description, location, device, position = popargs('description', 'location', 'device', 'position', kwargs) self.description = description self.location = location self.device = device + if position and len(position) != 3: + raise Exception('ElectrodeGroup position argument must have three elements: x, y, z, but received: %s' + % position) + self.position = position @register_class('ElectricalSeries', CORE_NAMESPACE) diff --git a/src/pynwb/file.py b/src/pynwb/file.py index 9fc5f21e0..90ceaee24 100644 --- a/src/pynwb/file.py +++ b/src/pynwb/file.py @@ -493,14 +493,19 @@ def add_electrode_column(self, **kwargs): self.__check_electrodes() call_docval_func(self.electrodes.add_column, kwargs) - @docval({'name': 'x', 'type': 'float', 'doc': 'the x coordinate of the position'}, - {'name': 'y', 'type': 'float', 'doc': 'the y coordinate of the position'}, - {'name': 'z', 'type': 'float', 'doc': 'the z coordinate of the position'}, + @docval({'name': 'x', 'type': 'float', 'doc': 'the x coordinate of the position (+x is posterior)'}, + {'name': 'y', 'type': 'float', 'doc': 'the y coordinate of the position (+y is inferior)'}, + {'name': 'z', 'type': 'float', 'doc': 'the z coordinate of the position (+z is right)'}, {'name': 'imp', 'type': 'float', 'doc': 'the impedance of the electrode'}, {'name': 'location', 'type': str, 'doc': 'the location of electrode within the subject e.g. brain region'}, {'name': 'filtering', 'type': str, 'doc': 'description of hardware filtering'}, {'name': 'group', 'type': ElectrodeGroup, 'doc': 'the ElectrodeGroup object to add to this NWBFile'}, {'name': 'id', 'type': int, 'doc': 'a unique identifier for the electrode', 'default': None}, + {'name': 'rel_x', 'type': 'float', 'doc': 'the x coordinate within the electrode group', 'default': None}, + {'name': 'rel_y', 'type': 'float', 'doc': 'the y coordinate within the electrode group', 'default': None}, + {'name': 'rel_z', 'type': 'float', 'doc': 'the z coordinate within the electrode group', 'default': None}, + {'name': 'reference', 'type': str, 'doc': 'Description of the reference used for this electrode.', + 'default': None}, allow_extra=True) def add_electrode(self, **kwargs): """ @@ -515,6 +520,17 @@ def add_electrode(self, **kwargs): d = _copy.copy(kwargs['data']) if kwargs.get('data') is not None else kwargs if d.get('group_name', None) is None: d['group_name'] = d['group'].name + + new_cols = [('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 used for this electrode.')] + for col_name, col_doc in new_cols: + if kwargs[col_name] is not None and 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 + call_docval_func(self.electrodes.add_row, d) @docval({'name': 'region', 'type': (slice, list, tuple), 'doc': 'the indices of the table'}, @@ -757,7 +773,8 @@ def ElectrodeTable(name='electrodes', ('location', 'the location of channel within the subject e.g. brain region'), ('filtering', 'description of hardware filtering'), ('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')] + ('group_name', 'the name of the ElectrodeGroup this electrode is a part of') + ] ) diff --git a/src/pynwb/image.py b/src/pynwb/image.py index c42f7a0b7..7beb50471 100644 --- a/src/pynwb/image.py +++ b/src/pynwb/image.py @@ -21,7 +21,8 @@ class ImageSeries(TimeSeries): @docval(*get_docval(TimeSeries.__init__, 'name'), # required {'name': 'data', 'type': ('array_data', 'data', TimeSeries), 'shape': ([None] * 3, [None] * 4), - 'doc': 'The data this TimeSeries dataset stores. Can also store binary data e.g. image frames', + 'doc': 'The data this TimeSeries dataset stores. Can also store binary data e.g. image frames. ' + 'dimensions: time, x, y [, z]', 'default': None}, *get_docval(TimeSeries.__init__, 'unit'), {'name': 'format', 'type': str, @@ -130,7 +131,9 @@ class OpticalSeries(ImageSeries): 'field_of_view', 'orientation') - @docval(*get_docval(ImageSeries.__init__, 'name', 'data'), # required + @docval(*get_docval(ImageSeries.__init__, 'name'), + {'name': 'data', 'type': ('array_data', 'data'), 'shape': ([None] * 3, [None, None, None, 3]), + 'doc': 'Images presented to subject, either grayscale or RGB'}, *get_docval(ImageSeries.__init__, 'unit', 'format'), {'name': 'distance', 'type': 'float', 'doc': 'Distance from camera/monitor to target/eye.'}, # required {'name': 'field_of_view', 'type': ('array_data', 'data', 'TimeSeries'), 'shape': ((2, ), (3, )), # required diff --git a/src/pynwb/io/core.py b/src/pynwb/io/core.py index 91687843c..7e493f68a 100644 --- a/src/pynwb/io/core.py +++ b/src/pynwb/io/core.py @@ -1,9 +1,14 @@ from hdmf.build import ObjectMapper, RegionBuilder +from hdmf.common import VectorData +from hdmf.utils import getargs, docval +from hdmf.spec import AttributeSpec +from hdmf.build import BuildManager from .. import register_map from pynwb.file import NWBFile from pynwb.core import NWBData, NWBContainer +from pynwb.misc import Units class NWBBaseTypeMapper(ObjectMapper): @@ -19,7 +24,6 @@ def get_nwb_file(container): @register_map(NWBContainer) class NWBContainerMapper(NWBBaseTypeMapper): - pass @@ -46,3 +50,27 @@ def carg_region(self, builder, manager): if not isinstance(builder.data, RegionBuilder): raise ValueError("'builder' must be a RegionBuilder") return builder.data.region + + +@register_map(VectorData) +class VectorDataMap(ObjectMapper): + + @docval({"name": "spec", "type": AttributeSpec, "doc": "the spec to get the attribute value for"}, + {"name": "container", "type": VectorData, "doc": "the container to get the attribute value from"}, + {"name": "manager", "type": BuildManager, "doc": "the BuildManager used for managing this build"}, + returns='the value of the attribute') + def get_attr_value(self, **kwargs): + ''' Get the value of the attribute corresponding to this spec from the given container ''' + spec, container, manager = getargs('spec', 'container', 'manager', kwargs) + + # handle custom mapping of container Units.waveform_rate -> spec Units.waveform_mean.sampling_rate + if isinstance(container.parent, Units): + if container.name == 'waveform_mean' or container.name == 'waveform_sd': + if spec.name == 'sampling_rate': + return container.parent.waveform_rate + if spec.name == 'unit': + return container.parent.waveform_unit + if container.name == 'spike_times': + if spec.name == 'resolution': + return container.parent.resolution + return super().get_attr_value(**kwargs) diff --git a/src/pynwb/io/misc.py b/src/pynwb/io/misc.py index 3161cfefa..90df76655 100644 --- a/src/pynwb/io/misc.py +++ b/src/pynwb/io/misc.py @@ -7,6 +7,38 @@ @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: + return builder['spike_times'].attributes.get('resolution') + return None + + @DynamicTableMap.constructor_arg('waveform_rate') + def waveform_rate_carg(self, builder, manager): + return self._get_waveform_stat(builder, 'sampling_rate') + + @DynamicTableMap.constructor_arg('waveform_unit') + def waveform_unit_carg(self, builder, manager): + return self._get_waveform_stat(builder, 'unit') + + def _get_waveform_stat(self, builder, attribute): + if 'waveform_mean' not in builder and 'waveform_sd' not in builder: + return None + mean_stat = None + sd_stat = None + if 'waveform_mean' in builder: + mean_stat = builder['waveform_mean'].attributes.get(attribute) + if 'waveform_sd' in builder: + sd_stat = builder['waveform_sd'].attributes.get(attribute) + if mean_stat is not None and sd_stat is not None: + if mean_stat != sd_stat: + # throw warning + pass + return mean_stat + @DynamicTableMap.object_attr("electrodes") def electrodes_column(self, container, manager): ret = container.get('electrodes') diff --git a/src/pynwb/io/ophys.py b/src/pynwb/io/ophys.py index ad1924ccd..a5b1b9d18 100644 --- a/src/pynwb/io/ophys.py +++ b/src/pynwb/io/ophys.py @@ -21,5 +21,10 @@ class ImagingPlaneMap(NWBContainerMapper): def __init__(self, spec): super(ImagingPlaneMap, self).__init__(spec) manifold_spec = self.spec.get_dataset('manifold') + origin_coords_spec = self.spec.get_dataset('origin_coords') + grid_spacing_spec = self.spec.get_dataset('grid_spacing') self.map_spec('unit', manifold_spec.get_attribute('unit')) self.map_spec('conversion', manifold_spec.get_attribute('conversion')) + self.map_spec('origin_coords_unit', origin_coords_spec.get_attribute('unit')) + self.map_spec('grid_spacing_unit', grid_spacing_spec.get_attribute('unit')) + self.map_spec('optical_channel', self.spec.get_neurodata_type('OpticalChannel')) diff --git a/src/pynwb/io/retinotopy.py b/src/pynwb/io/retinotopy.py index e69de29bb..0288b1b51 100644 --- a/src/pynwb/io/retinotopy.py +++ b/src/pynwb/io/retinotopy.py @@ -0,0 +1,14 @@ +# from hdmf.build import ObjectMapper +# from .. import register_map +# from pynwb.retinotopy import ImagingRetinotopy +# +# +# @register_map(ImagingRetinotopy) +# class ImagingRetinotopyMap(ObjectMapper): +# +# def __init__(self, spec): +# super().__init__(spec) +# +# datasets = ['sign_map', 'axis_1_phase_map'] +# for dataset_name in datasets: +# self.map_spec(dataset_name, spec.get_dataset(dataset_name)) diff --git a/src/pynwb/misc.py b/src/pynwb/misc.py index 462383e5e..7f917f475 100644 --- a/src/pynwb/misc.py +++ b/src/pynwb/misc.py @@ -131,6 +131,12 @@ class Units(DynamicTable): Event times of observed units (e.g. cell, synapse, etc.). """ + __fields__ = ( + 'waveform_rate', + 'waveform_unit', + 'resolution' + ) + __columns__ = ( {'name': 'spike_times', 'description': 'the spike times for each unit', 'index': True}, {'name': 'obs_intervals', 'description': 'the observation intervals for each unit', @@ -146,7 +152,14 @@ class Units(DynamicTable): *get_docval(DynamicTable.__init__, 'id', 'columns', 'colnames'), {'name': 'description', 'type': str, 'doc': 'a description of what is in this table', 'default': None}, {'name': 'electrode_table', 'type': DynamicTable, - 'doc': 'the table that the *electrodes* column indexes', 'default': None}) + 'doc': 'the table that the *electrodes* column indexes', 'default': None}, + {'name': 'waveform_rate', 'type': 'float', + 'doc': 'Sampling rate of the waveform means', 'default': None}, + {'name': 'waveform_unit', 'type': str, + 'doc': 'Unit of measurement of the waveform means', 'default': 'volts'}, + {'name': 'resolution', 'type': 'float', + 'doc': 'The smallest possible difference between two spike times', 'default': None}, + ) def __init__(self, **kwargs): if kwargs.get('description', None) is None: kwargs['description'] = "data on spiking units" @@ -154,6 +167,9 @@ def __init__(self, **kwargs): if 'spike_times' not in self.colnames: self.__has_spike_times = False self.__electrode_table = getargs('electrode_table', kwargs) + self.waveform_rate = getargs('waveform_rate', kwargs) + self.waveform_unit = getargs('waveform_unit', kwargs) + self.resolution = getargs('resolution', kwargs) @docval({'name': 'spike_times', 'type': 'array_data', 'doc': 'the spike times for each unit', 'default': None, 'shape': (None,)}, diff --git a/src/pynwb/nwb-schema b/src/pynwb/nwb-schema index 3939e3525..7931e59ad 160000 --- a/src/pynwb/nwb-schema +++ b/src/pynwb/nwb-schema @@ -1 +1 @@ -Subproject commit 3939e3525c89a15ab81f26871db844c0d3f062c2 +Subproject commit 7931e59ad1e97433ce4a450fa5bc2dab81af6f8d diff --git a/src/pynwb/ophys.py b/src/pynwb/ophys.py index 881d3d1d8..ab87caab1 100644 --- a/src/pynwb/ophys.py +++ b/src/pynwb/ophys.py @@ -1,4 +1,6 @@ from collections.abc import Iterable +import numpy as np +import warnings from hdmf.utils import docval, getargs, popargs, call_docval_func, get_docval @@ -8,7 +10,6 @@ from .core import NWBContainer, MultiContainerInterface, NWBDataInterface from hdmf.common import DynamicTable, DynamicTableRegion from .device import Device -import numpy as np @register_class('OpticalChannel', CORE_NAMESPACE) @@ -46,31 +47,51 @@ class ImagingPlane(NWBContainer): @docval(*get_docval(NWBContainer.__init__, 'name'), # required {'name': 'optical_channel', 'type': (list, OpticalChannel), # required - 'doc': 'One of possibly many groups storing channelspecific data.'}, + 'doc': 'One of possibly many groups storing channel-specific data.'}, {'name': 'description', 'type': str, 'doc': 'Description of this ImagingPlane.'}, # required {'name': 'device', 'type': Device, 'doc': 'the device that was used to record'}, # required {'name': 'excitation_lambda', 'type': 'float', 'doc': 'Excitation wavelength in nm.'}, # required {'name': 'imaging_rate', 'type': 'float', 'doc': 'Rate images are acquired, in Hz.'}, # required {'name': 'indicator', 'type': str, 'doc': 'Calcium indicator'}, # required {'name': 'location', 'type': str, 'doc': 'Location of image plane.'}, # required - {'name': 'manifold', 'type': Iterable, - 'doc': 'Physical position of each pixel. size=("height", "width", "xyz").', + {'name': 'manifold', 'type': 'array_data', + 'doc': ('DEPRECATED: Physical position of each pixel. size=("height", "width", "xyz"). ' + 'Deprecated in favor of origin_coords and grid_spacing.'), 'default': None}, {'name': 'conversion', 'type': 'float', - 'doc': 'Multiplier to get from stored values to specified unit (e.g., 1e-3 for millimeters)', + 'doc': ('DEPRECATED: Multiplier to get from stored values to specified unit (e.g., 1e-3 for millimeters) ' + 'Deprecated in favor of origin_coords and grid_spacing.'), 'default': 1.0}, - {'name': 'unit', 'type': str, 'doc': 'Base unit that coordinates are stored in (e.g., Meters).', + {'name': 'unit', 'type': str, + 'doc': 'DEPRECATED: Base unit that coordinates are stored in (e.g., Meters). ' + 'Deprecated in favor of origin_coords_unit and grid_spacing_unit.', 'default': 'meters'}, {'name': 'reference_frame', 'type': str, 'doc': 'Describes position and reference frame of manifold based on position of first element ' 'in manifold.', - 'default': None}) + 'default': None}, + {'name': 'origin_coords', 'type': 'array_data', + 'doc': 'Physical location of the first element of the imaging plane (0, 0) for 2-D data or (0, 0, 0) for ' + '3-D data. See also reference_frame for what the physical location is relative to (e.g., bregma).', + 'default': None}, + {'name': 'origin_coords_unit', 'type': str, + 'doc': "Measurement units for origin_coords. The default value is 'meters'.", + 'default': 'meters'}, + {'name': 'grid_spacing', 'type': 'array_data', + 'doc': "Space between pixels in (x, y) or voxels in (x, y, z) directions, in the specified unit. Assumes " + "imaging plane is a regular grid. See also reference_frame to interpret the grid.", + 'default': None}, + {'name': 'grid_spacing_unit', 'type': str, + 'doc': "Measurement units for grid_spacing. The default value is 'meters'.", + 'default': 'meters'}) def __init__(self, **kwargs): optical_channel, description, device, excitation_lambda, imaging_rate, \ - indicator, location, manifold, conversion, unit, reference_frame = popargs( + indicator, location, manifold, conversion, unit, reference_frame, origin_coords, origin_coords_unit, \ + grid_spacing, grid_spacing_unit = popargs( 'optical_channel', 'description', 'device', 'excitation_lambda', 'imaging_rate', 'indicator', 'location', 'manifold', 'conversion', - 'unit', 'reference_frame', kwargs) + 'unit', 'reference_frame', 'origin_coords', 'origin_coords_unit', 'grid_spacing', 'grid_spacing_unit', + kwargs) call_docval_func(super(ImagingPlane, self).__init__, kwargs) self.optical_channel = optical_channel if isinstance(optical_channel, list) else [optical_channel] self.description = description @@ -79,10 +100,23 @@ def __init__(self, **kwargs): self.imaging_rate = imaging_rate self.indicator = indicator self.location = location + if manifold is not None: + warnings.warn("The 'manifold' argument is deprecated in favor of 'origin_coords' and 'grid_spacing'.", + DeprecationWarning) + if conversion != 1.0: + warnings.warn("The 'conversion' argument is deprecated in favor of 'origin_coords' and 'grid_spacing'.", + DeprecationWarning) + if unit != 'meters': + warnings.warn("The 'unit' argument is deprecated in favor of 'origin_coords_unit' and 'grid_spacing_unit'.", + DeprecationWarning) self.manifold = manifold self.conversion = conversion self.unit = unit self.reference_frame = reference_frame + self.origin_coords = origin_coords + self.origin_coords_unit = origin_coords_unit + self.grid_spacing = grid_spacing + self.grid_spacing_unit = grid_spacing_unit @register_class('TwoPhotonSeries', CORE_NAMESPACE) @@ -128,9 +162,9 @@ class CorrectedImageStack(NWBDataInterface): assumed to be 2-D (has only x & y dimensions). """ - __nwbfields__ = ('corrected', - 'original', - 'xy_translation') + __nwbfields__ = ({'name': 'corrected', 'child': True}, + {'name': 'xy_translation', 'child': True}, + 'original') @docval({'name': 'name', 'type': str, 'doc': 'The name of this CorrectedImageStack container', 'default': 'CorrectedImageStack'}, diff --git a/src/pynwb/retinotopy.py b/src/pynwb/retinotopy.py index dc0358c59..4671cf40a 100644 --- a/src/pynwb/retinotopy.py +++ b/src/pynwb/retinotopy.py @@ -1,86 +1,112 @@ from collections.abc import Iterable +import warnings -from hdmf.utils import docval, popargs, call_docval_func +from hdmf.utils import docval, popargs, call_docval_func, get_docval from . import register_class, CORE_NAMESPACE -from .core import NWBContainer, NWBDataInterface +from .core import NWBDataInterface, NWBData -class AImage(NWBContainer): - """ +class RetinotopyImage(NWBData): + """Gray-scale anatomical image of cortical surface. Array structure: [rows][columns] """ - __nwbfields__ = ('data', - 'bits_per_pixel', + __nwbfields__ = ('bits_per_pixel', 'dimension', 'format', - 'field_of_view', - 'focal_depth') + 'field_of_view') - @docval({'name': 'name', 'type': str, 'doc': 'the name of this axis map'}, + @docval({'name': 'name', 'type': str, 'doc': 'Name of this retinotopy image'}, {'name': 'data', 'type': Iterable, 'doc': 'Data field.'}, {'name': 'bits_per_pixel', 'type': int, 'doc': 'Number of bits used to represent each value. This is necessary to determine maximum ' '(white) pixel value.'}, - {'name': 'dimension', 'type': Iterable, 'doc': 'Number of rows and columns in the image.'}, + {'name': 'dimension', 'type': Iterable, 'shape': (2, ), 'doc': 'Number of rows and columns in the image.'}, {'name': 'format', 'type': Iterable, 'doc': 'Format of image. Right now only "raw" supported.'}, - {'name': 'field_of_view', 'type': Iterable, 'doc': 'Size of viewing area, in meters.'}, - {'name': 'focal_depth', 'type': 'float', 'doc': 'Focal depth offset, in meters.'}) + {'name': 'field_of_view', 'type': Iterable, 'shape': (2, ), 'doc': 'Size of viewing area, in meters.'}) def __init__(self, **kwargs): - data, bits_per_pixel, dimension, format, field_of_view = popargs( - 'data', 'bits_per_pixel', 'dimension', 'format', 'field_of_view', kwargs) - call_docval_func(super(AImage, self).__init__, kwargs) - self.data = data + bits_per_pixel, dimension, format, field_of_view = popargs( + 'bits_per_pixel', 'dimension', 'format', 'field_of_view', kwargs) + call_docval_func(super().__init__, kwargs) self.bits_per_pixel = bits_per_pixel - self.dimension = format + self.dimension = dimension + self.format = format self.field_of_view = field_of_view -class AxisMap(NWBContainer): +class FocalDepthImage(RetinotopyImage): + """Gray-scale image taken with same settings/parameters (e.g., focal depth, + wavelength) as data collection. Array format: [rows][columns]. """ + + __nwbfields__ = ('focal_depth', ) + + @docval(*get_docval(RetinotopyImage.__init__), + {'name': 'focal_depth', 'type': 'float', 'doc': 'Focal depth offset, in meters.'}) + def __init__(self, **kwargs): + focal_depth = popargs('focal_depth', kwargs) + call_docval_func(super().__init__, kwargs) + self.focal_depth = focal_depth + + +class RetinotopyMap(NWBData): + """Abstract two-dimensional map of responses to stimuli along a single response axis (e.g., altitude) """ - __nwbfields__ = ('data', - 'field_of_view', - 'unit', + __nwbfields__ = ('field_of_view', 'dimension') @docval({'name': 'name', 'type': str, 'doc': 'the name of this axis map'}, {'name': 'data', 'type': Iterable, 'shape': (None, None), 'doc': 'data field.'}, - {'name': 'field_of_view', 'type': Iterable, 'doc': 'Size of viewing area, in meters.'}, - {'name': 'unit', 'type': str, 'doc': 'Unit that axis data is stored in (e.g., degrees)'}, - {'name': 'dimension', 'type': Iterable, 'shape': (None, ), + {'name': 'field_of_view', 'type': Iterable, 'shape': (2, ), 'doc': 'Size of viewing area, in meters.'}, + {'name': 'dimension', 'type': Iterable, 'shape': (2, ), 'doc': 'Number of rows and columns in the image'}) def __init__(self, **kwargs): - data, field_of_view, unit, dimension = popargs('data', 'field_of_view', 'unit', 'dimension', kwargs) - call_docval_func(super(AxisMap, self).__init__, kwargs) - self.data = data + field_of_view, dimension = popargs('field_of_view', 'dimension', kwargs) + call_docval_func(super().__init__, kwargs) self.field_of_view = field_of_view - self.unit = unit self.dimension = dimension +class AxisMap(RetinotopyMap): + """Abstract two-dimensional map of responses to stimuli along a single response axis (e.g., altitude) with unit + """ + + __nwbfields__ = ('unit', ) + + @docval(*get_docval(RetinotopyMap.__init__, 'name', 'data', 'field_of_view'), + {'name': 'unit', 'type': str, 'doc': 'Unit that axis data is stored in (e.g., degrees)'}, + *get_docval(RetinotopyMap.__init__, 'dimension')) + def __init__(self, **kwargs): + unit = popargs('unit', kwargs) + call_docval_func(super().__init__, kwargs) + self.unit = unit + + @register_class('ImagingRetinotopy', CORE_NAMESPACE) class ImagingRetinotopy(NWBDataInterface): """ Intrinsic signal optical imaging or widefield imaging for measuring retinotopy. Stores orthogonal maps (e.g., altitude/azimuth; radius/theta) of responses to specific stimuli and a combined polarity map from which to identify visual areas. + This group does not store the raw responses imaged during retinotopic mapping or the + stimuli presented, but rather the resulting phase and power maps after applying a Fourier + transform on the averaged responses. Note: for data consistency, all images and arrays are stored in the format [row][column] and [row, col], which equates to [y][x]. Field of view and dimension arrays may appear backward (i.e., y before x). """ - __nwbfields__ = ('sign_map', - 'axis_1_phase_map', - 'axis_1_power_map', - 'axis_2_phase_map', - 'axis_2_power_map', - 'axis_descriptions', - 'focal_depth_image', - 'vasculature_image',) + __nwbfields__ = ({'name': 'sign_map', 'child': True}, + {'name': 'axis_1_phase_map', 'child': True}, + {'name': 'axis_1_power_map', 'child': True}, + {'name': 'axis_2_phase_map', 'child': True}, + {'name': 'axis_2_power_map', 'child': True}, + {'name': 'focal_depth_image', 'child': True}, + {'name': 'vasculature_image', 'child': True}, + 'axis_descriptions') - @docval({'name': 'sign_map', 'type': AxisMap, + @docval({'name': 'sign_map', 'type': RetinotopyMap, 'doc': 'Sine of the angle between the direction of the gradient in axis_1 and axis_2.'}, {'name': 'axis_1_phase_map', 'type': AxisMap, 'doc': 'Phase response to stimulus on the first measured axis.'}, @@ -92,13 +118,13 @@ class ImagingRetinotopy(NWBDataInterface): {'name': 'axis_2_power_map', 'type': AxisMap, 'doc': 'Power response on the second measured axis. Response is scaled so 0.0 is no ' 'power in the response and 1.0 is maximum relative power.'}, - {'name': 'axis_descriptions', 'type': Iterable, + {'name': 'axis_descriptions', 'type': Iterable, 'shape': (2, ), 'doc': 'Two-element array describing the contents of the two response axis fields. ' 'Description should be something like ["altitude", "azimuth"] or ["radius", "theta"].'}, - {'name': 'focal_depth_image', 'type': AImage, + {'name': 'focal_depth_image', 'type': FocalDepthImage, 'doc': 'Gray-scale image taken with same settings/parameters (e.g., focal depth, wavelength) ' 'as data collection. Array format: [rows][columns].'}, - {'name': 'vasculature_image', 'type': AImage, + {'name': 'vasculature_image', 'type': RetinotopyImage, 'doc': 'Gray-scale anatomical image of cortical surface. Array structure: [rows][columns].'}, {'name': 'name', 'type': str, 'doc': 'the name of this container', 'default': 'ImagingRetinotopy'}) def __init__(self, **kwargs): @@ -106,7 +132,9 @@ def __init__(self, **kwargs): focal_depth_image, sign_map, vasculature_image = popargs( 'axis_1_phase_map', 'axis_1_power_map', 'axis_2_phase_map', 'axis_2_power_map', 'axis_descriptions', 'focal_depth_image', 'sign_map', 'vasculature_image', kwargs) - call_docval_func(super(ImagingRetinotopy, self).__init__, kwargs) + call_docval_func(super().__init__, kwargs) + warnings.warn("The ImagingRetinotopy class currently cannot be written to or read from a file. " + "This is a known bug and will be fixed in a future release of PyNWB.") self.axis_1_phase_map = axis_1_phase_map self.axis_1_power_map = axis_1_power_map self.axis_2_phase_map = axis_2_phase_map diff --git a/src/pynwb/testing/testh5io.py b/src/pynwb/testing/testh5io.py index 9db958fbd..ba13a9729 100644 --- a/src/pynwb/testing/testh5io.py +++ b/src/pynwb/testing/testh5io.py @@ -18,14 +18,15 @@ class NWBH5IOMixin(metaclass=ABCMeta): The abstract methods setUpContainer, addContainer, and getContainer needs to be implemented by classes that include this mixin. - Example: - class TestMyContainerIO(NWBH5IOMixin, TestCase): - def setUpContainer(self): - # return a test Container to read/write - def addContainer(self, nwbfile): - # add the test Container to an NWB file - def getContainer(self, nwbfile): - # return the test Container from an NWB file + Example:: + + class TestMyContainerIO(NWBH5IOMixin, TestCase): + def setUpContainer(self): + # return a test Container to read/write + def addContainer(self, nwbfile): + # add the test Container to an NWB file + def getContainer(self, nwbfile): + # return the test Container from an NWB file This code is adapted from hdmf.testing.H5RoundTripMixin. """ @@ -128,10 +129,11 @@ class AcquisitionH5IOMixin(NWBH5IOMixin): The abstract method setUpContainer needs to be implemented by classes that include this mixin. - Example: - class TestMyContainerIO(NWBH5IOMixin, TestCase): - def setUpContainer(self): - # return a test Container to read/write + Example:: + + class TestMyContainerIO(NWBH5IOMixin, TestCase): + def setUpContainer(self): + # return a test Container to read/write This code is adapted from hdmf.testing.H5RoundTripMixin. """ diff --git a/tests/integration/hdf5/test_icephys.py b/tests/integration/hdf5/test_icephys.py index b23e4a338..649014941 100644 --- a/tests/integration/hdf5/test_icephys.py +++ b/tests/integration/hdf5/test_icephys.py @@ -1,3 +1,5 @@ +import numpy as np + from pynwb import NWBFile from pynwb.icephys import (IntracellularElectrode, PatchClampSeries, CurrentClampStimulusSeries, SweepTable, VoltageClampStimulusSeries, CurrentClampSeries, @@ -123,7 +125,7 @@ def setUpContainer(self): device=self.device) self.pcs = PatchClampSeries(name="pcs", data=[1, 2, 3, 4, 5], unit='A', starting_time=123.6, rate=10e3, electrode=self.elec, gain=0.126, - stimulus_description="gotcha ya!", sweep_number=4711) + stimulus_description="gotcha ya!", sweep_number=np.uint(4711)) return SweepTable(name='sweep_table') def addContainer(self, nwbfile): @@ -166,13 +168,13 @@ def setUpContainer(self): device=self.device) self.pcs1 = PatchClampSeries(name="pcs1", data=[1, 2, 3, 4, 5], unit='A', starting_time=123.6, rate=10e3, electrode=self.elec, gain=0.126, - stimulus_description="gotcha ya!", sweep_number=4711) + stimulus_description="gotcha ya!", sweep_number=np.uint(4711)) self.pcs2a = PatchClampSeries(name="pcs2a", data=[1, 2, 3, 4, 5], unit='A', starting_time=123.6, rate=10e3, electrode=self.elec, gain=0.126, - stimulus_description="gotcha ya!", sweep_number=4712) + stimulus_description="gotcha ya!", sweep_number=np.uint(4712)) self.pcs2b = PatchClampSeries(name="pcs2b", data=[1, 2, 3, 4, 5], unit='A', starting_time=123.6, rate=10e3, electrode=self.elec, gain=0.126, - stimulus_description="gotcha ya!", sweep_number=4712) + stimulus_description="gotcha ya!", sweep_number=np.uint(4712)) return SweepTable(name='sweep_table') diff --git a/tests/integration/hdf5/test_misc.py b/tests/integration/hdf5/test_misc.py index 74eb0e973..66c221001 100644 --- a/tests/integration/hdf5/test_misc.py +++ b/tests/integration/hdf5/test_misc.py @@ -12,8 +12,12 @@ class TestUnitsIO(AcquisitionH5IOMixin, TestCase): def setUpContainer(self): """ Return the test Units to read/write """ ut = Units(name='UnitsTest', description='a simple table for testing Units') - ut.add_unit(spike_times=[0, 1, 2], obs_intervals=[[0, 1], [2, 3]]) - ut.add_unit(spike_times=[3, 4, 5], obs_intervals=[[2, 5], [6, 7]]) + ut.add_unit(spike_times=[0, 1, 2], obs_intervals=[[0, 1], [2, 3]], + waveform_mean=[1., 2., 3.], waveform_sd=[4., 5., 6.]) + ut.add_unit(spike_times=[3, 4, 5], obs_intervals=[[2, 5], [6, 7]], + waveform_mean=[1., 2., 3.], waveform_sd=[4., 5., 6.]) + ut.waveform_rate = 40000. + ut.resolution = 1/40000 return ut def test_get_spike_times(self): diff --git a/tests/integration/hdf5/test_retinotopy.py b/tests/integration/hdf5/test_retinotopy.py new file mode 100644 index 000000000..423b4d6c3 --- /dev/null +++ b/tests/integration/hdf5/test_retinotopy.py @@ -0,0 +1,34 @@ +# import numpy as np +# +# from pynwb.retinotopy import ImagingRetinotopy, AxisMap, RetinotopyImage, FocalDepthImage, RetinotopyMap +# from pynwb.testing import AcquisitionH5IOMixin, TestCase +# +# +# class TestImagingRetinotopy(AcquisitionH5IOMixin, TestCase): +# +# def setUpContainer(self): +# """ Return the test ImagingRetinotopy to read/write """ +# data = np.ones((2, 2)) +# field_of_view = [1, 2] +# dimension = [1, 2] +# sign_map = RetinotopyMap('sign_map', data, field_of_view, dimension) +# axis_1_phase_map = AxisMap('axis_1_phase_map', data, field_of_view, 'unit', dimension) +# axis_1_power_map = AxisMap('axis_1_power_map', data, field_of_view, 'unit', dimension) +# axis_2_phase_map = AxisMap('axis_2_phase_map', data, field_of_view, 'unit', dimension) +# axis_2_power_map = AxisMap('axis_2_power_map', data, field_of_view, 'unit', dimension) +# axis_descriptions = ['altitude', 'azimuth'] +# +# data = [[1, 1], [1, 1]] +# bits_per_pixel = 8 +# dimension = [3, 4] +# format = 'raw' +# field_of_view = [1, 2] +# focal_depth = 1.0 +# focal_depth_image = FocalDepthImage('focal_depth_image', data, bits_per_pixel, dimension, format, +# field_of_view, focal_depth) +# vasculature_image = RetinotopyImage('vasculature_image', np.uint16(data), bits_per_pixel, dimension, format, +# field_of_view) +# +# return ImagingRetinotopy(sign_map, axis_1_phase_map, axis_1_power_map, axis_2_phase_map, +# axis_2_power_map, axis_descriptions, focal_depth_image, +# vasculature_image) diff --git a/tests/unit/test_ecephys.py b/tests/unit/test_ecephys.py index 9dc90570f..d8b2682b3 100644 --- a/tests/unit/test_ecephys.py +++ b/tests/unit/test_ecephys.py @@ -79,12 +79,27 @@ def test_no_rate(self): class ElectrodeGroupConstructor(TestCase): def test_init(self): + dev1 = Device('dev1') + group = ElectrodeGroup('elec1', 'electrode description', 'electrode location', dev1, (1, 2, 3)) + self.assertEqual(group.name, 'elec1') + self.assertEqual(group.description, 'electrode description') + self.assertEqual(group.location, 'electrode location') + self.assertEqual(group.device, dev1) + self.assertEqual(group.position, (1, 2, 3)) + + def test_init_position_none(self): dev1 = Device('dev1') group = ElectrodeGroup('elec1', 'electrode description', 'electrode location', dev1) self.assertEqual(group.name, 'elec1') self.assertEqual(group.description, 'electrode description') self.assertEqual(group.location, 'electrode location') self.assertEqual(group.device, dev1) + self.assertIsNone(group.position) + + def test_init_position_bad(self): + dev1 = Device('dev1') + with self.assertRaises(TypeError): + ElectrodeGroup('elec1', 'electrode description', 'electrode location', dev1, (1, 2)) class EventDetectionConstructor(TestCase): diff --git a/tests/unit/test_file.py b/tests/unit/test_file.py index 2bd222585..638e5e3b8 100644 --- a/tests/unit/test_file.py +++ b/tests/unit/test_file.py @@ -298,6 +298,7 @@ def test_print_units(self): ) description: Autogenerated by NWBFile id: id + waveform_unit: volts """ expected = expected % id(self.nwbfile.units) self.assertEqual(str(self.nwbfile.units), expected) diff --git a/tests/unit/test_misc.py b/tests/unit/test_misc.py index fbc0b2d12..82d58dd9c 100644 --- a/tests/unit/test_misc.py +++ b/tests/unit/test_misc.py @@ -176,3 +176,8 @@ def test_electrode_group(self): electrode_group = ElectrodeGroup('test_electrode_group', 'description', 'location', device) ut.add_unit(electrode_group=electrode_group) self.assertEqual(ut['electrode_group'][0], electrode_group) + + def test_waveform_attrs(self): + ut = Units(waveform_rate=40000.) + self.assertEqual(ut.waveform_rate, 40000.) + self.assertEqual(ut.waveform_unit, 'volts') diff --git a/tests/unit/test_ophys.py b/tests/unit/test_ophys.py index 670d87ea4..60950fe3a 100644 --- a/tests/unit/test_ophys.py +++ b/tests/unit/test_ophys.py @@ -21,8 +21,7 @@ def CreatePlaneSegmentation(): oc = OpticalChannel('test_optical_channel', 'description', 500.) device = Device(name='device_name') ip = ImagingPlane('test_imaging_plane', oc, 'description', device, 600., - 300., 'indicator', 'location', (1, 2, 1, 2, 3), 4.0, - 'unit', 'reference_frame') + 300., 'indicator', 'location', reference_frame='reference_frame') pS = PlaneSegmentation('description', ip, 'test_name', iSS) pS.add_roi(pixel_mask=pix_mask[0:3], image_mask=img_mask[0]) @@ -38,17 +37,20 @@ def test_init(self): device = Device(name='device_name') ip = ImagingPlane('test_imaging_plane', oc, 'description', device, 600., - 300., 'indicator', 'location', (50, 100, 3), 4.0, 'unit', 'reference_frame') + 300., 'indicator', 'location', reference_frame='reference_frame', + origin_coords=[10, 20], origin_coords_unit='oc_unit', + grid_spacing=[1, 2, 3], grid_spacing_unit='gs_unit') self.assertEqual(ip.optical_channel[0], oc) self.assertEqual(ip.device, device) self.assertEqual(ip.excitation_lambda, 600.) self.assertEqual(ip.imaging_rate, 300.) self.assertEqual(ip.indicator, 'indicator') self.assertEqual(ip.location, 'location') - self.assertEqual(ip.manifold, (50, 100, 3)) - self.assertEqual(ip.conversion, 4.0) - self.assertEqual(ip.unit, 'unit') self.assertEqual(ip.reference_frame, 'reference_frame') + self.assertEqual(ip.origin_coords, [10, 20]) + self.assertEqual(ip.origin_coords_unit, 'oc_unit') + self.assertEqual(ip.grid_spacing, [1, 2, 3]) + self.assertEqual(ip.grid_spacing_unit, 'gs_unit') tPS = TwoPhotonSeries('test_tPS', unit='unit', field_of_view=[2., 3.], imaging_plane=ip, pmt_gain=1.0, scan_line_rate=2.0, external_file=['external_file'], @@ -68,12 +70,48 @@ def test_args(self): oc = OpticalChannel('test_name', 'description', 500.) device = Device(name='device_name') ip = ImagingPlane('test_imaging_plane', oc, 'description', device, 600., - 300., 'indicator', 'location', (50, 100, 3), 4.0, 'unit', 'reference_frame') + 300., 'indicator', 'location', reference_frame='reference_frame') with self.assertRaises(ValueError): # no data or external file TwoPhotonSeries('test_tPS', unit='unit', field_of_view=[2., 3.], imaging_plane=ip, pmt_gain=1.0, scan_line_rate=2.0, starting_frame=[1, 2, 3], format='tiff', timestamps=[1., 2.]) + def test_manifold_deprecated(self): + oc = OpticalChannel('test_name', 'description', 500.) + self.assertEqual(oc.description, 'description') + self.assertEqual(oc.emission_lambda, 500.) + + device = Device(name='device_name') + + msg = "The 'manifold' argument is deprecated in favor of 'origin_coords' and 'grid_spacing'." + with self.assertWarnsWith(DeprecationWarning, msg): + ImagingPlane('test_imaging_plane', oc, 'description', device, 600., 300., 'indicator', 'location', + (1, 1, (2, 2, 2))) + + def test_conversion_deprecated(self): + oc = OpticalChannel('test_name', 'description', 500.) + self.assertEqual(oc.description, 'description') + self.assertEqual(oc.emission_lambda, 500.) + + device = Device(name='device_name') + + msg = "The 'conversion' argument is deprecated in favor of 'origin_coords' and 'grid_spacing'." + with self.assertWarnsWith(DeprecationWarning, msg): + ImagingPlane('test_imaging_plane', oc, 'description', device, 600., 300., 'indicator', 'location', + None, 2.0) + + def test_unit_deprecated(self): + oc = OpticalChannel('test_name', 'description', 500.) + self.assertEqual(oc.description, 'description') + self.assertEqual(oc.emission_lambda, 500.) + + device = Device(name='device_name') + + msg = "The 'unit' argument is deprecated in favor of 'origin_coords_unit' and 'grid_spacing_unit'." + with self.assertWarnsWith(DeprecationWarning, msg): + ImagingPlane('test_imaging_plane', oc, 'description', device, 600., 300., 'indicator', 'location', + None, 1.0, 'my_unit') + class MotionCorrectionConstructor(TestCase): def test_init(self): @@ -153,7 +191,7 @@ def getBoilerPlateObjects(self): device = Device(name='device_name') oc = OpticalChannel('test_optical_channel', 'description', 500.) ip = ImagingPlane('test_imaging_plane', oc, 'description', device, 600., - 300., 'indicator', 'location', (1, 2, 1, 2, 3), 4.0, 'unit', 'reference_frame') + 300., 'indicator', 'location', reference_frame='reference_frame') return iSS, ip def create_basic_plane_segmentation(self): diff --git a/tests/unit/test_retinotopy.py b/tests/unit/test_retinotopy.py index 121402fff..57942d274 100644 --- a/tests/unit/test_retinotopy.py +++ b/tests/unit/test_retinotopy.py @@ -1,40 +1,174 @@ import numpy as np -from pynwb.retinotopy import ImagingRetinotopy, AxisMap, AImage +from pynwb.retinotopy import ImagingRetinotopy, AxisMap, RetinotopyImage, FocalDepthImage, RetinotopyMap from pynwb.testing import TestCase class ImageRetinotopyConstructor(TestCase): - def test_init(self): + def setUp(self): data = np.ones((2, 2)) + field_of_view = [1, 2] + dimension = [1, 2] + self.sign_map = RetinotopyMap('sign_map', data, field_of_view, dimension) + self.axis_1_phase_map = AxisMap('axis_1_phase_map', data, field_of_view, 'unit', dimension) + self.axis_1_power_map = AxisMap('axis_1_power_map', data, field_of_view, 'unit', dimension) + self.axis_2_phase_map = AxisMap('axis_2_phase_map', data, field_of_view, 'unit', dimension) + self.axis_2_power_map = AxisMap('axis_2_power_map', data, field_of_view, 'unit', dimension) + self.axis_descriptions = ['altitude', 'azimuth'] + + data = [[1, 1], [1, 1]] + bits_per_pixel = 8 + dimension = [3, 4] + format = 'raw' + field_of_view = [1, 2] + focal_depth = 1.0 + self.focal_depth_image = FocalDepthImage('focal_depth_image', data, bits_per_pixel, dimension, format, + field_of_view, focal_depth) + self.vasculature_image = RetinotopyImage('vasculature_image', np.uint16(data), bits_per_pixel, dimension, + format, field_of_view) + + def test_init(self): + """Test that ImagingRetinotopy constructor sets properties correctly.""" + msg = ('The ImagingRetinotopy class currently cannot be written to or read from a file. This is a known bug ' + 'and will be fixed in a future release of PyNWB.') + with self.assertWarnsWith(UserWarning, msg): + ir = ImagingRetinotopy(self.sign_map, self.axis_1_phase_map, self.axis_1_power_map, self.axis_2_phase_map, + self.axis_2_power_map, self.axis_descriptions, self.focal_depth_image, + self.vasculature_image) + self.assertEqual(ir.sign_map, self.sign_map) + self.assertEqual(ir.axis_1_phase_map, self.axis_1_phase_map) + self.assertEqual(ir.axis_1_power_map, self.axis_1_power_map) + self.assertEqual(ir.axis_2_phase_map, self.axis_2_phase_map) + self.assertEqual(ir.axis_2_power_map, self.axis_2_power_map) + self.assertEqual(ir.axis_descriptions, self.axis_descriptions) + self.assertEqual(ir.focal_depth_image, self.focal_depth_image) + self.assertEqual(ir.vasculature_image, self.vasculature_image) + + def test_init_axis_descriptions_wrong_shape(self): + """Test that creating a ImagingRetinotopy with a axis descriptions argument that is not 2 elements raises an + error. + """ + self.axis_descriptions = ['altitude', 'azimuth', 'extra'] + + msg = "ImagingRetinotopy.__init__: incorrect shape for 'axis_descriptions' (got '(3,)', expected '(2,)')" + with self.assertRaisesWith(ValueError, msg): + ImagingRetinotopy(self.sign_map, self.axis_1_phase_map, self.axis_1_power_map, self.axis_2_phase_map, + self.axis_2_power_map, self.axis_descriptions, self.focal_depth_image, + self.vasculature_image) + + +class RetinotopyImageConstructor(TestCase): + + def test_init(self): + """Test that RetinotopyImage constructor sets properties correctly.""" + data = [[1, 1], [1, 1]] + bits_per_pixel = 8 + dimension = [3, 4] + format = 'raw' + field_of_view = [1, 2] + image = RetinotopyImage('vasculature_image', data, bits_per_pixel, dimension, format, field_of_view) + + self.assertEqual(image.name, 'vasculature_image') + self.assertEqual(image.data, data) + self.assertEqual(image.bits_per_pixel, bits_per_pixel) + self.assertEqual(image.dimension, dimension) + self.assertEqual(image.format, format) + self.assertEqual(image.field_of_view, field_of_view) + + def test_init_dimension_wrong_shape(self): + """Test that creating a RetinotopyImage with a dimension argument that is not 2 elements raises an error.""" + data = [[1, 1], [1, 1]] + bits_per_pixel = 8 + dimension = [3, 4, 5] + format = 'raw' + field_of_view = [1, 2] + + msg = "RetinotopyImage.__init__: incorrect shape for 'dimension' (got '(3,)', expected '(2,)')" + with self.assertRaisesWith(ValueError, msg): + RetinotopyImage('vasculature_image', data, bits_per_pixel, dimension, format, field_of_view) + + def test_init_fov_wrong_shape(self): + """Test that creating a RetinotopyImage with a field of view argument that is not 2 elements raises an error.""" + data = [[1, 1], [1, 1]] + bits_per_pixel = 8 + dimension = [3, 4] + format = 'raw' field_of_view = [1, 2, 3] + + msg = "RetinotopyImage.__init__: incorrect shape for 'field_of_view' (got '(3,)', expected '(2,)')" + with self.assertRaisesWith(ValueError, msg): + RetinotopyImage('vasculature_image', data, bits_per_pixel, dimension, format, field_of_view) + + +class RetinotopyMapConstructor(TestCase): + + def test_init(self): + """Test that RetinotopyMap constructor sets properties correctly.""" + data = np.ones((2, 2)) + field_of_view = [1, 2] + dimension = [1, 2] + map = RetinotopyMap('sign_map', data, field_of_view, dimension) + + self.assertEqual(map.name, 'sign_map') + np.testing.assert_array_equal(map.data, data) + self.assertEqual(map.field_of_view, field_of_view) + self.assertEqual(map.dimension, dimension) + + +class AxisMapConstructor(TestCase): + + def test_init(self): + """Test that AxisMap constructor sets properties correctly.""" + data = np.ones((2, 2)) + field_of_view = [1, 2] + dimension = [1, 2] + map = AxisMap('axis_1_phase', data, field_of_view, 'unit', dimension) + + self.assertEqual(map.name, 'axis_1_phase') + np.testing.assert_array_equal(map.data, data) + self.assertEqual(map.field_of_view, field_of_view) + self.assertEqual(map.dimension, dimension) + self.assertEqual(map.unit, 'unit') + + def test_init_dimension_wrong_shape(self): + """Test that creating an AxisMap with a dimension argument that is not 2 elements raises an error.""" + data = np.ones((2, 2)) + field_of_view = [1, 2] dimension = [1, 2, 3] - sign_map = AxisMap('sign_map', data, field_of_view, 'unit', dimension) - axis_1_phase_map = AxisMap('axis_1_phase', data, field_of_view, 'unit', dimension) - axis_1_power_map = AxisMap('axis_1_power', data, field_of_view, 'unit', dimension) - axis_2_phase_map = AxisMap('axis_2_phase', data, field_of_view, 'unit', dimension) - axis_2_power_map = AxisMap('axis_2_power', data, field_of_view, 'unit', dimension) - axis_descriptions = ['altitude', 'azimuth'] - - data = list() + + msg = "AxisMap.__init__: incorrect shape for 'dimension' (got '(3,)', expected '(2,)')" + with self.assertRaisesWith(ValueError, msg): + AxisMap('axis_1_phase', data, field_of_view, 'unit', dimension) + + def test_init_fov_wrong_shape(self): + """Test that creating an AxisMap with a dimension argument that is not 2 elements raises an error.""" + data = np.ones((2, 2)) + field_of_view = [1, 2, 3] + dimension = [1, 2] + + msg = "AxisMap.__init__: incorrect shape for 'field_of_view' (got '(3,)', expected '(2,)')" + with self.assertRaisesWith(ValueError, msg): + AxisMap('axis_1_phase', data, field_of_view, 'unit', dimension) + + +class FocalDepthImageConstructor(TestCase): + + def test_init(self): + """Test that FocalDepthImage constructor sets properties correctly.""" + data = [[1, 1], [1, 1]] bits_per_pixel = 8 dimension = [3, 4] format = 'raw' - field_of_view = [1, 2, 3] + field_of_view = [1, 2] focal_depth = 1.0 - focal_depth_image = AImage('focal_depth_image', data, bits_per_pixel, - dimension, format, field_of_view, focal_depth) - vasculature_image = AImage('vasculature', data, bits_per_pixel, - dimension, format, field_of_view, focal_depth) - - ir = ImagingRetinotopy(sign_map, axis_1_phase_map, axis_1_power_map, axis_2_phase_map, - axis_2_power_map, axis_descriptions, focal_depth_image, vasculature_image) - self.assertEqual(ir.sign_map, sign_map) - self.assertEqual(ir.axis_1_phase_map, axis_1_phase_map) - self.assertEqual(ir.axis_1_power_map, axis_1_power_map) - self.assertEqual(ir.axis_2_phase_map, axis_2_phase_map) - self.assertEqual(ir.axis_2_power_map, axis_2_power_map) - self.assertEqual(ir.axis_descriptions, axis_descriptions) - self.assertEqual(ir.focal_depth_image, focal_depth_image) - self.assertEqual(ir.vasculature_image, vasculature_image) + image = FocalDepthImage('focal_depth_image', data, bits_per_pixel, dimension, format, field_of_view, + focal_depth) + + self.assertEqual(image.name, 'focal_depth_image') + self.assertEqual(image.data, data) + self.assertEqual(image.bits_per_pixel, bits_per_pixel) + self.assertEqual(image.dimension, dimension) + self.assertEqual(image.format, format) + self.assertEqual(image.field_of_view, field_of_view) + self.assertEqual(image.focal_depth, focal_depth) diff --git a/tests/validation/test_validate.py b/tests/validation/test_validate.py new file mode 100644 index 000000000..cf946aa0d --- /dev/null +++ b/tests/validation/test_validate.py @@ -0,0 +1,138 @@ +import subprocess +import re + +from pynwb.testing import TestCase +from pynwb import validate, NWBHDF5IO + + +class TestValidateScript(TestCase): + + # 1.0.2_nwbfile.nwb has no cached specifications + # 1.0.3_nwbfile.nwb has cached "core" specification + # 1.1.2_nwbfile.nwb has cached "core" and "hdmf-common" specifications + + def test_validate_file_no_cache(self): + """Test that validating a file with no cached spec against the core namespace succeeds.""" + result = subprocess.run("python -m pynwb.validate tests/back_compat/1.0.2_nwbfile.nwb", + capture_output=True) + + stderr_regex = re.compile( + r".*UserWarning: No cached namespaces found in tests/back_compat/1\.0\.2_nwbfile\.nwb\s*" + r"warnings.warn\(msg\)\s*" + r"The file tests/back_compat/1\.0\.2_nwbfile\.nwb has no cached namespace information\. " + r"Falling back to pynwb namespace information\.\s*" + ) + self.assertRegex(result.stderr.decode('utf-8'), stderr_regex) + + stdout_regex = re.compile( + r"Validating tests/back_compat/1\.0\.2_nwbfile\.nwb against pynwb namespace information using namespace " + r"'core'\.\s* - no errors found\.\s*") + self.assertRegex(result.stdout.decode('utf-8'), stdout_regex) + + def test_validate_file_no_cache_bad_ns(self): + """Test that validating a file with no cached spec against a specified, unknown namespace fails.""" + result = subprocess.run("python -m pynwb.validate tests/back_compat/1.0.2_nwbfile.nwb --ns notfound", + capture_output=True) + + stderr_regex = re.compile( + r".*UserWarning: No cached namespaces found in tests/back_compat/1\.0\.2_nwbfile\.nwb\s*" + r"warnings.warn\(msg\)\s*" + r"The file tests/back_compat/1\.0\.2_nwbfile\.nwb has no cached namespace information\. " + r"Falling back to pynwb namespace information\.\s*" + r"The namespace 'notfound' could not be found in pynwb namespace information\.\s*" + ) + self.assertRegex(result.stderr.decode('utf-8'), stderr_regex) + + self.assertEqual(result.stdout.decode('utf-8'), '') + + def test_validate_file_cached(self): + """Test that validating a file with cached spec against its cached namespace succeeds.""" + result = subprocess.run("python -m pynwb.validate tests/back_compat/1.1.2_nwbfile.nwb", + capture_output=True) + + self.assertEqual(result.stderr.decode('utf-8'), '') + + stdout_regex = re.compile( + r"Validating tests/back_compat/1\.1\.2_nwbfile\.nwb against cached namespace information using namespace " + r"'core'\.\s* - no errors found\.\s*") + self.assertRegex(result.stdout.decode('utf-8'), stdout_regex) + + def test_validate_file_cached_bad_ns(self): + """Test that validating a file with cached spec against a specified, unknown namespace fails.""" + result = subprocess.run("python -m pynwb.validate tests/back_compat/1.1.2_nwbfile.nwb --ns notfound", + capture_output=True) + + stderr_regex = re.compile( + r"The namespace 'notfound' could not be found in cached namespace information\.\s*" + ) + self.assertRegex(result.stderr.decode('utf-8'), stderr_regex) + + self.assertEqual(result.stdout.decode('utf-8'), '') + + def test_validate_file_cached_hdmf_common(self): + """Test that validating a file with cached spec against the hdmf-common namespace fails.""" + result = subprocess.run("python -m pynwb.validate tests/back_compat/1.1.2_nwbfile.nwb --ns hdmf-common", + capture_output=True) + + stderr_regex = re.compile( + r".*ValueError: data type \'NWBFile\' not found in namespace hdmf-common.\s*", + re.DOTALL + ) + self.assertRegex(result.stderr.decode('utf-8'), stderr_regex) + + stdout_regex = re.compile( + r"Validating tests/back_compat/1.1.2_nwbfile.nwb against cached namespace information using namespace " + r"'hdmf-common'.\s*" + ) + self.assertRegex(result.stdout.decode('utf-8'), stdout_regex) + + def test_validate_file_cached_ignore(self): + """Test that validating a file with cached spec against the core namespace succeeds.""" + result = subprocess.run("python -m pynwb.validate tests/back_compat/1.1.2_nwbfile.nwb --no-cached-namespace", + capture_output=True) + + self.assertEqual(result.stderr.decode('utf-8'), '') + + stdout_regex = re.compile( + r"Validating tests/back_compat/1\.1\.2_nwbfile\.nwb against pynwb namespace information using namespace " + r"'core'\.\s* - no errors found\.\s*") + self.assertRegex(result.stdout.decode('utf-8'), stdout_regex) + + +class TestValidateFunction(TestCase): + + # 1.0.2_nwbfile.nwb has no cached specifications + # 1.0.3_nwbfile.nwb has cached "core" specification + # 1.1.2_nwbfile.nwb has cached "core" and "hdmf-common" specifications + + def test_validate_file_no_cache(self): + """Test that validating a file with no cached spec against the core namespace succeeds.""" + with NWBHDF5IO('tests/back_compat/1.0.2_nwbfile.nwb', 'r') as io: + errors = validate(io) + self.assertEqual(errors, []) + + def test_validate_file_no_cache_bad_ns(self): + """Test that validating a file with no cached spec against a specified, unknown namespace fails.""" + with NWBHDF5IO('tests/back_compat/1.0.2_nwbfile.nwb', 'r') as io: + with self.assertRaisesWith(KeyError, "\"'notfound' not a namespace\""): + validate(io, 'notfound') + + def test_validate_file_cached(self): + """Test that validating a file with cached spec against its cached namespace succeeds.""" + with NWBHDF5IO('tests/back_compat/1.1.2_nwbfile.nwb', 'r') as io: + errors = validate(io) + self.assertEqual(errors, []) + + def test_validate_file_cached_bad_ns(self): + """Test that validating a file with cached spec against a specified, unknown namespace fails.""" + with NWBHDF5IO('tests/back_compat/1.1.2_nwbfile.nwb', 'r') as io: + with self.assertRaisesWith(KeyError, "\"'notfound' not a namespace\""): + validate(io, 'notfound') + + def test_validate_file_cached_hdmf_common(self): + """Test that validating a file with cached spec against the hdmf-common namespace fails.""" + with NWBHDF5IO('tests/back_compat/1.1.2_nwbfile.nwb', 'r') as io: + # TODO this error should not be different from the error when using the validate script above + msg = "builder must have data type defined with attribute 'data_type'" + with self.assertRaisesWith(ValueError, msg): + validate(io, 'hdmf-common')