diff --git a/CHANGELOG.md b/CHANGELOG.md index e5909f577..2910a19b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,31 @@ # PyNWB Changelog +## PyNWB 3.0.0 (Upcoming) + +### Enhancements and minor changes +- Added support for NWB schema 2.8.0: + - Removed `SpatialSeries.bounds` field that was not functional. This will be fixed in a future release. @rly [#1907](https://github.com/NeurodataWithoutBorders/pynwb/pull/1907), + ## PyNWB 2.8.3 (Upcoming) +### Enhancements and minor changes +* Added `NWBHDF5IO.read_nwb` convenience method to simplify reading an NWB file. @h-mayorquin [#1979](https://github.com/NeurodataWithoutBorders/pynwb/pull/1979) + +### Documentation and tutorial enhancements +- Added documentation example for `SpikeEventSeries`. @stephprince [#1983](https://github.com/NeurodataWithoutBorders/pynwb/pull/1983) +- Added documentation example for `AnnotationSeries`. @stephprince [#1989](https://github.com/NeurodataWithoutBorders/pynwb/pull/1989) + ### Performance - Cache global type map to speed import 3X. @sneakers-the-rat [#1931](https://github.com/NeurodataWithoutBorders/pynwb/pull/1931) +### Bug fixes +- Fixed bug in how `ElectrodeGroup.__init__` validates its `position` argument. @oruebel [#1770](https://github.com/NeurodataWithoutBorders/pynwb/pull/1770) +- Changed `SpatialSeries.reference_frame` from required to optional as specified in the schema. @rly [#1986](https://github.com/NeurodataWithoutBorders/pynwb/pull/1986) + +### Enhancements and minor changes +- Made gain an optional argument for PatchClampSeries to match the schema. @stephprince [#1975](https://github.com/NeurodataWithoutBorders/pynwb/pull/1975) +- Added warning when writing files with `NWBHDF5IO` without the `.nwb` extension. @stephprince [#1978](https://github.com/NeurodataWithoutBorders/pynwb/pull/1978) + ## PyNWB 2.8.2 (September 9, 2024) ### Enhancements and minor changes @@ -43,7 +64,7 @@ ## PyNWB 2.7.0 (May 2, 2024) ### Enhancements and minor changes -- Added `bounds` field to `SpatialSeries` to set optional boundary range (min, max) for each dimension of data. @mavaylon1 [#1869](https://github.com/NeurodataWithoutBorders/pynwb/pull/1869/files) +- Added `bounds` field to `SpatialSeries` to set optional boundary range (min, max) for each dimension of data. @mavaylon1 [#1869](https://github.com/NeurodataWithoutBorders/pynwb/pull/1869) - Added support for NWB schema 2.7.0. See [2.7.0 release notes](https://nwb-schema.readthedocs.io/en/latest/format_release_notes.html) for details - Deprecated `ImagingRetinotopy` neurodata type. @rly [#1813](https://github.com/NeurodataWithoutBorders/pynwb/pull/1813) - Modified `OptogeneticSeries` to allow 2D data, primarily in extensions of `OptogeneticSeries`. @rly [#1812](https://github.com/NeurodataWithoutBorders/pynwb/pull/1812) diff --git a/docs/gallery/advanced_io/plot_editing.py b/docs/gallery/advanced_io/plot_editing.py index cf19bb610..b08b2adfd 100644 --- a/docs/gallery/advanced_io/plot_editing.py +++ b/docs/gallery/advanced_io/plot_editing.py @@ -24,6 +24,9 @@ First, let's create an NWB file with data: """ + +# sphinx_gallery_thumbnail_path = "figures/gallery_thumbnails_editing.png" + from pynwb import NWBHDF5IO, NWBFile, TimeSeries from datetime import datetime from dateutil.tz import tzlocal diff --git a/docs/gallery/domain/ecephys.py b/docs/gallery/domain/ecephys.py index 3f239eb77..48a979272 100644 --- a/docs/gallery/domain/ecephys.py +++ b/docs/gallery/domain/ecephys.py @@ -31,7 +31,7 @@ from dateutil.tz import tzlocal from pynwb import NWBHDF5IO, NWBFile -from pynwb.ecephys import LFP, ElectricalSeries +from pynwb.ecephys import LFP, ElectricalSeries, SpikeEventSeries ####################### # Creating and Writing NWB files @@ -244,8 +244,8 @@ #################### # .. _units_electrode: # -# Spike Times -# ^^^^^^^^^^^ +# Sorted spike times +# ^^^^^^^^^^^^^^^^^^ # # Spike times are stored in the :py:class:`~pynwb.misc.Units` table, which is a subclass of # :py:class:`~hdmf.common.table.DynamicTable`. Adding columns to the :py:class:`~pynwb.misc.Units` table is analogous @@ -272,6 +272,29 @@ nwbfile.units.to_dataframe() +#################### +# Unsorted spike times +# ^^^^^^^^^^^^^^^^^^^^ +# +# 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. + +spike_snippets = np.random.rand(20, 3, 40) # 20 events, 3 channels, 40 samples per event +shank0 = nwbfile.create_electrode_table_region( + region=[0, 1, 2], + description="shank0", +) + + +spike_events = SpikeEventSeries(name='SpikeEvents_Shank0', + description="events detected with 100uV threshold", + data=spike_snippets, + timestamps=np.arange(20), + electrodes=shank0) +nwbfile.add_acquisition(spike_events) + ####################### # Designating electrophysiology data # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -283,17 +306,17 @@ # :py:mod:`API documentation ` and :ref:`basics` for more details on # using these objects. # -# For storing spike data, there are two options. Which one you choose depends on what data you have available. -# If you need to store the complete, continuous raw voltage traces, you should store the traces with +# For storing unsorted spiking data, there are two options. Which one you choose depends on what data you +# have available. If you need to store the complete, continuous raw voltage traces, you should store the traces with # :py:class:`~pynwb.ecephys.ElectricalSeries` objects as :ref:`acquisition ` data, and use # the :py:class:`~pynwb.ecephys.EventDetection` class for identifying the spike events in your raw traces. # If you do not want to store the raw voltage traces and only the waveform 'snippets' surrounding spike events, -# you should use the :py:class:`~pynwb.ecephys.EventWaveform` class, which can store one or more -# :py:class:`~pynwb.ecephys.SpikeEventSeries` objects. +# you should use :py:class:`~pynwb.ecephys.SpikeEventSeries` objects. # # The results of spike sorting (or clustering) should be stored in the top-level :py:class:`~pynwb.misc.Units` table. -# Note that it is not required to store spike waveforms in order to store spike events or mean waveforms--if you only -# want to store the spike times of clustered units you can use only the Units table. +# The :py:class:`~pynwb.misc.Units` table can contain simply the spike times of sorted units, or you can also include +# individual and mean waveform information in some of the optional, predefined :py:class:`~pynwb.misc.Units` table +# columns: ``waveform_mean``, ``waveform_sd``, or ``waveforms``. # # For local field potential data, there are two options. Again, which one you choose depends on what data you # have available. With both options, you should store your traces with :py:class:`~pynwb.ecephys.ElectricalSeries` diff --git a/docs/gallery/domain/plot_behavior.py b/docs/gallery/domain/plot_behavior.py index 35fbae81f..8f341bea1 100644 --- a/docs/gallery/domain/plot_behavior.py +++ b/docs/gallery/domain/plot_behavior.py @@ -105,9 +105,6 @@ # # For position data ``reference_frame`` indicates the zero-position, e.g. # the 0,0 point might be the bottom-left corner of an enclosure, as viewed from the tracking camera. -# In :py:class:`~pynwb.behavior.SpatialSeries`, the ``bounds`` field allows the user to set -# the boundary range, i.e., (min, max), for each dimension of ``data``. The units are the same as in ``data``. -# This field does not enforce a boundary on the dataset itself. timestamps = np.linspace(0, 50) / 200 @@ -115,7 +112,6 @@ name="SpatialSeries", description="Position (x, y) in an open field.", data=position_data, - bounds=[(0,50), (0,50)], timestamps=timestamps, reference_frame="(0,0) is bottom left corner", ) diff --git a/docs/gallery/general/plot_configurator.py b/docs/gallery/general/plot_configurator.py index 0156a8fef..431f5891e 100644 --- a/docs/gallery/general/plot_configurator.py +++ b/docs/gallery/general/plot_configurator.py @@ -8,7 +8,7 @@ Introduction ------------- Users will create a configuration YAML file that outlines the fields (within a neurodata type) -they want to be validated against a set of allowed terms. +they want to be validated against a set of allowed terms. After creating the configuration file, users will need to load the configuration file with the :py:func:`~pynwb.load_type_config` method. With the configuration loaded, every instance of the neurodata @@ -16,10 +16,10 @@ :py:class:`~hdmf.term_set.TermSetWrapper`. This automatic wrapping is what provides the term validation for the field value. For greater control on which datasets and attributes are validated -against which sets of allowed terms, use the +against which sets of allowed terms, use the :py:class:`~hdmf.term_set.TermSetWrapper` on individual datasets and attributes instead. -You can follow the -`TermSet tutorial in the HDMF documentation +You can follow the +`TermSet tutorial in the HDMF documentation `_ for more information. @@ -42,6 +42,8 @@ 3. Each data type will have a list of fields associated with a :py:class:`~hdmf.term_set.TermSet`. The user can use the same or unique TermSet instances for each field. """ +# sphinx_gallery_thumbnail_path = 'figures/gallery_thumbnails_configurator.png' + try: import linkml_runtime # noqa: F401 except ImportError as e: diff --git a/docs/gallery/general/plot_file.py b/docs/gallery/general/plot_file.py index b410d4b69..d80db10ac 100644 --- a/docs/gallery/general/plot_file.py +++ b/docs/gallery/general/plot_file.py @@ -131,6 +131,7 @@ from pynwb import NWBHDF5IO, NWBFile, TimeSeries from pynwb.behavior import Position, SpatialSeries from pynwb.file import Subject +from pynwb.misc import AnnotationSeries #################### # .. _basics_nwbfile: @@ -285,6 +286,27 @@ # or using the method :py:meth:`.NWBFile.get_acquisition`: nwbfile.get_acquisition("test_timeseries") +#################### +# Other Types of Time Series +# ^^^^^^^^^^^^^^^^^^^^^^^^^^ +# +# As mentioned previously, there are many subtypes of :py:class:`~pynwb.base.TimeSeries` that are used to store +# different kinds of data. One example is :py:class:`~pynwb.misc.AnnotationSeries`, a subclass of +# :py:class:`~pynwb.base.TimeSeries` that stores text-based records about the experiment. Similarly to our +# :py:class:`~pynwb.base.TimeSeries` example above, we can create an :py:class:`~pynwb.misc.AnnotationSeries` +# object with text information about a stimulus and add it to the stimulus group in +# the :py:class:`~pynwb.file.NWBFile`. + +annotations = AnnotationSeries(name='airpuffs', + data=['Left Airpuff', 'Right Airpuff', 'Right Airpuff'], + description='Airpuff events delivered to the animal', + timestamps=[1.0, 3.0, 8.0]) + +nwbfile.add_stimulus(annotations) + +#################### +# This approach of creating a :py:class:`~pynwb.base.TimeSeries` object and adding it to the appropriate +# :py:class:`~pynwb.file.NWBFile` group can be used for all subtypes of :py:class:`~pynwb.base.TimeSeries` data. #################### # .. _basic_spatialseries: diff --git a/docs/source/conf.py b/docs/source/conf.py index 4eaf1a19b..eabca22c7 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -244,7 +244,6 @@ def __call__(self, filename): # html_theme = 'default' # html_theme = "sphinxdoc" html_theme = "sphinx_rtd_theme" -html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -260,9 +259,6 @@ def __call__(self, filename): 'css/custom.css', ] -# Add any paths that contain custom themes here, relative to this directory. -# html_theme_path = [] - # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". # html_title = None diff --git a/docs/source/export.rst b/docs/source/export.rst index 218184f9b..5df003387 100644 --- a/docs/source/export.rst +++ b/docs/source/export.rst @@ -53,12 +53,12 @@ on the :py:class:`~pynwb.file.NWBFile` before exporting. How do I create a copy of an NWB file with different data layouts (e.g., applying compression)? --------------------------------------------------------------------------------------------------------- -Use the `h5repack `_ command line tool from the HDF5 Group. +Use the `h5repack `_ command line tool from the HDF5 Group. How do I create a copy of an NWB file with different controls over how links are treated and whether copies are deep or shallow? --------------------------------------------------------------------------------------------------------------------------------- -Use the `h5copy `_ command line tool from the HDF5 Group. +Use the `h5copy `_ command line tool from the HDF5 Group. How do I generate new object IDs for a newly exported NWB file? @@ -99,7 +99,7 @@ For example: export_io.export(src_io=read_io, nwbfile=nwbfile, write_args={'link_data': False}) # copy linked datasets # the written file will contain no links to external datasets -You can also the `h5copy `_ command line tool \ +You can also the `h5copy `_ command line tool \ from the HDF5 Group. diff --git a/docs/source/figures/gallery_thumbnails.pptx b/docs/source/figures/gallery_thumbnails.pptx index 51ebe79d0..765cf2e2a 100644 Binary files a/docs/source/figures/gallery_thumbnails.pptx and b/docs/source/figures/gallery_thumbnails.pptx differ diff --git a/docs/source/figures/gallery_thumbnails_configurator.png b/docs/source/figures/gallery_thumbnails_configurator.png new file mode 100644 index 000000000..9995c387d Binary files /dev/null and b/docs/source/figures/gallery_thumbnails_configurator.png differ diff --git a/docs/source/figures/gallery_thumbnails_editing.png b/docs/source/figures/gallery_thumbnails_editing.png new file mode 100644 index 000000000..54c52fb27 Binary files /dev/null and b/docs/source/figures/gallery_thumbnails_editing.png differ diff --git a/pyproject.toml b/pyproject.toml index f798f2b5a..14a11f5d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ classifiers = [ ] dependencies = [ "h5py>=2.10", - "hdmf>=3.14.3", + "hdmf>=3.14.5", "numpy>=1.18", "pandas>=1.1.5", "python-dateutil>=2.7.3", diff --git a/requirements-min.txt b/requirements-min.txt index eef051b25..feed604bc 100644 --- a/requirements-min.txt +++ b/requirements-min.txt @@ -1,6 +1,6 @@ # minimum versions of package dependencies for installing PyNWB h5py==2.10 # support for selection of datasets with list of indices added in 2.10 -hdmf==3.14.3 +hdmf==3.14.5 numpy==1.18 pandas==1.1.5 python-dateutil==2.7.3 diff --git a/requirements-opt.txt b/requirements-opt.txt index 3badc79c7..da62bc314 100644 --- a/requirements-opt.txt +++ b/requirements-opt.txt @@ -1,3 +1,8 @@ linkml-runtime==1.7.4; python_version >= "3.9" schemasheets==0.2.1; python_version >= "3.9" oaklib==0.5.32; python_version >= "3.9" + +# for streaming tests +fsspec==2024.10.0 +requests==2.32.3 +aiohttp==3.10.10 diff --git a/requirements.txt b/requirements.txt index 6d7a17623..1e7a5e18d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ # pinned dependencies to reproduce an entire development environment to use PyNWB h5py==3.11.0 -hdmf==3.14.3 +hdmf==3.14.5 numpy==2.1.1; python_version > "3.9" # numpy 2.1+ is not compatible with py3.9 numpy==2.0.2; python_version == "3.9" pandas==2.2.2 diff --git a/src/pynwb/__init__.py b/src/pynwb/__init__.py index 1d109abe3..3a4d95e98 100644 --- a/src/pynwb/__init__.py +++ b/src/pynwb/__init__.py @@ -397,6 +397,10 @@ def __init__(self, **kwargs): if mode in io_modes_that_create_file or manager is not None or extensions is not None: load_namespaces = False + if mode in io_modes_that_create_file and not str(path).endswith('.nwb'): + warn(f"The file path provided: {path} does not end in '.nwb'. " + "It is recommended that NWB files using the HDF5 backend use the '.nwb' extension.", UserWarning) + if load_namespaces: tm = get_type_map() super().load_namespaces(tm, path, file=file_obj, driver=driver, aws_region=aws_region) @@ -502,6 +506,34 @@ def export(self, **kwargs): kwargs['container'] = nwbfile super().export(**kwargs) + @staticmethod + @docval({'name': 'path', 'type': (str, Path), 'doc': 'the path to the HDF5 file', 'default': None}, + {'name': 'file', 'type': [h5py.File, 'S3File'], 'doc': 'a pre-existing h5py.File object', 'default': None}, + is_method=False) + def read_nwb(**kwargs): + """ + Helper factory method for reading an NWB file and return the NWBFile object + """ + # Retrieve the filepath + path = popargs('path', kwargs) + file = popargs('file', kwargs) + + path = str(path) if path is not None else None + + # Streaming case + if path is not None and (path.startswith("s3://") or path.startswith("http")): + import fsspec + fsspec_file_system = fsspec.filesystem("http") + ffspec_file = fsspec_file_system.open(path, "rb") + + open_file = h5py.File(ffspec_file, "r") + io = NWBHDF5IO(file=open_file) + nwbfile = io.read() + else: + io = NWBHDF5IO(path=path, file=file, mode="r", load_namespaces=True) + nwbfile = io.read() + + return nwbfile from . import io as __io # noqa: F401,E402 from .core import NWBContainer, NWBData # noqa: F401,E402 diff --git a/src/pynwb/behavior.py b/src/pynwb/behavior.py index 1b3078535..d4d43d515 100644 --- a/src/pynwb/behavior.py +++ b/src/pynwb/behavior.py @@ -26,10 +26,8 @@ class SpatialSeries(TimeSeries): {'name': 'data', 'type': ('array_data', 'data', TimeSeries), 'shape': ((None, ), (None, None)), # required 'doc': ('The data values. Can be 1D or 2D. The first dimension must be time. If 2D, there can be 1, 2, ' 'or 3 columns, which represent x, y, and z.')}, - {'name': 'bounds', 'type': list, 'shape': ((1, 2), (2, 2), (3, 2)), 'default': None, - 'doc': 'The boundary range (min, max) for each dimension of data.'}, - {'name': 'reference_frame', 'type': str, # required - 'doc': 'description defining what the zero-position is'}, + {'name': 'reference_frame', 'type': str, + 'doc': 'description defining what the zero-position is', 'default': None}, {'name': 'unit', 'type': str, 'doc': 'The base unit of measurement (should be SI unit)', 'default': 'meters'}, *get_docval(TimeSeries.__init__, 'conversion', 'resolution', 'timestamps', 'starting_time', 'rate', @@ -38,7 +36,9 @@ def __init__(self, **kwargs): """ Create a SpatialSeries TimeSeries dataset """ - name, data, bounds, reference_frame, unit = popargs('name', 'data', 'bounds', 'reference_frame', 'unit', kwargs) + name, data, reference_frame, unit = popargs( + 'name', 'data', 'reference_frame', 'unit', kwargs + ) super().__init__(name, data, unit, **kwargs) # NWB 2.5 restricts length of second dimension to be <= 3 @@ -49,7 +49,6 @@ def __init__(self, **kwargs): "The second dimension should have length <= 3 to represent at most x, y, z." % (name, str(data_shape))) - self.bounds = bounds self.reference_frame = reference_frame @staticmethod diff --git a/src/pynwb/ecephys.py b/src/pynwb/ecephys.py index 3187cca4a..e6dadbe97 100644 --- a/src/pynwb/ecephys.py +++ b/src/pynwb/ecephys.py @@ -1,4 +1,5 @@ import warnings +import numpy as np from collections.abc import Iterable from hdmf.common import DynamicTableRegion @@ -26,13 +27,31 @@ class ElectrodeGroup(NWBContainer): {'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': 'position', 'type': 'array_data', - 'doc': 'stereotaxic position of this electrode group (x, y, z)', 'default': None}) + 'doc': 'Compound dataset with stereotaxic position of this electrode group (x, y, z). ' + 'The data array must have three elements or the dtype of the ' + 'array must be ``(float, float, float)``', 'default': None}) def __init__(self, **kwargs): args_to_set = popargs_to_dict(('description', 'location', 'device', 'position'), kwargs) super().__init__(**kwargs) - if args_to_set['position'] and len(args_to_set['position']) != 3: - raise ValueError('ElectrodeGroup position argument must have three elements: x, y, z, but received: %s' - % str(args_to_set['position'])) + + # position is a compound dataset, i.e., this must be a scalar with a + # compound data type of three floats or a list/tuple of three entries + position = args_to_set['position'] + if position: + # check position argument is valid + position_dtype_invalid = ( + (hasattr(position, 'dtype') and len(position.dtype) != 3) or + (not hasattr(position, 'dtype') and len(position) != 3) or + (len(np.shape(position)) > 1) + ) + if position_dtype_invalid: + raise ValueError(f"ElectrodeGroup position argument must have three elements: x, y, z," + f"but received: {position}") + + # convert position to scalar with compound data type if needed + if not hasattr(position, 'dtype'): + args_to_set['position'] = np.array(tuple(position), dtype=[('x', float), ('y', float), ('z', float)]) + for key, val in args_to_set.items(): setattr(self, key, val) diff --git a/src/pynwb/icephys.py b/src/pynwb/icephys.py index 3101649fd..ec44a2bd8 100644 --- a/src/pynwb/icephys.py +++ b/src/pynwb/icephys.py @@ -109,7 +109,8 @@ class PatchClampSeries(TimeSeries): 'name': 'gain', 'type': float, 'doc': 'Units: Volt/Amp (v-clamp) or Volt/Volt (c-clamp)', - }, # required + 'default': None, + }, { 'name': 'stimulus_description', 'type': str, @@ -164,7 +165,7 @@ class CurrentClampSeries(PatchClampSeries): 'capacitance_compensation') @docval(*get_docval(PatchClampSeries.__init__, 'name', 'data', 'electrode'), # required - {'name': 'gain', 'type': float, 'doc': 'Units - Volt/Volt'}, + {'name': 'gain', 'type': float, 'doc': 'Units - Volt/Volt', 'default': None}, *get_docval(PatchClampSeries.__init__, 'stimulus_description'), {'name': 'bias_current', 'type': float, 'doc': 'Unit - Amp', 'default': None}, {'name': 'bridge_balance', 'type': float, 'doc': 'Unit - Ohm', 'default': None}, @@ -196,7 +197,7 @@ class IZeroClampSeries(CurrentClampSeries): __nwbfields__ = () @docval(*get_docval(CurrentClampSeries.__init__, 'name', 'data', 'electrode'), # required - {'name': 'gain', 'type': float, 'doc': 'Units: Volt/Volt'}, # required + {'name': 'gain', 'type': float, 'doc': 'Units: Volt/Volt', 'default': None}, {'name': 'stimulus_description', 'type': str, 'doc': ('The stimulus name/protocol. Setting this to a value other than "N/A" is deprecated as of ' 'NWB 2.3.0.'), @@ -238,16 +239,16 @@ class CurrentClampStimulusSeries(PatchClampSeries): __nwbfields__ = () - @docval(*get_docval(PatchClampSeries.__init__, 'name', 'data', 'electrode', 'gain'), # required - *get_docval(PatchClampSeries.__init__, 'stimulus_description', 'resolution', 'conversion', 'timestamps', - 'starting_time', 'rate', 'comments', 'description', 'control', 'control_description', - 'sweep_number', 'offset'), + @docval(*get_docval(PatchClampSeries.__init__, 'name', 'data', 'electrode'), # required + *get_docval(PatchClampSeries.__init__, 'gain', 'stimulus_description', 'resolution', 'conversion', + 'timestamps', 'starting_time', 'rate', 'comments', 'description', 'control', + 'control_description', 'sweep_number', 'offset'), {'name': 'unit', 'type': str, 'doc': "The base unit of measurement (must be 'amperes')", 'default': 'amperes'}) def __init__(self, **kwargs): - name, data, unit, electrode, gain = popargs('name', 'data', 'unit', 'electrode', 'gain', kwargs) + name, data, unit, electrode = popargs('name', 'data', 'unit', 'electrode', kwargs) unit = ensure_unit(self, name, unit, 'amperes', '2.1.0') - super().__init__(name, data, unit, electrode, gain, **kwargs) + super().__init__(name, data, unit, electrode, **kwargs) @register_class('VoltageClampSeries', CORE_NAMESPACE) @@ -267,7 +268,7 @@ class VoltageClampSeries(PatchClampSeries): 'whole_cell_series_resistance_comp') @docval(*get_docval(PatchClampSeries.__init__, 'name', 'data', 'electrode'), # required - {'name': 'gain', 'type': float, 'doc': 'Units - Volt/Amp'}, # required + {'name': 'gain', 'type': float, 'doc': 'Units - Volt/Amp', 'default': None}, *get_docval(PatchClampSeries.__init__, 'stimulus_description'), {'name': 'capacitance_fast', 'type': float, 'doc': 'Unit - Farad', 'default': None}, {'name': 'capacitance_slow', 'type': float, 'doc': 'Unit - Farad', 'default': None}, @@ -307,16 +308,16 @@ class VoltageClampStimulusSeries(PatchClampSeries): __nwbfields__ = () - @docval(*get_docval(PatchClampSeries.__init__, 'name', 'data', 'electrode', 'gain'), # required - *get_docval(PatchClampSeries.__init__, 'stimulus_description', 'resolution', 'conversion', 'timestamps', - 'starting_time', 'rate', 'comments', 'description', 'control', 'control_description', - 'sweep_number', 'offset'), + @docval(*get_docval(PatchClampSeries.__init__, 'name', 'data', 'electrode'), # required + *get_docval(PatchClampSeries.__init__, 'gain', 'stimulus_description', 'resolution', 'conversion', + 'timestamps', 'starting_time', 'rate', 'comments', 'description', 'control', + 'control_description', 'sweep_number', 'offset'), {'name': 'unit', 'type': str, 'doc': "The base unit of measurement (must be 'volts')", 'default': 'volts'}) def __init__(self, **kwargs): - name, data, unit, electrode, gain = popargs('name', 'data', 'unit', 'electrode', 'gain', kwargs) + name, data, unit, electrode = popargs('name', 'data', 'unit', 'electrode', kwargs) unit = ensure_unit(self, name, unit, 'volts', '2.1.0') - super().__init__(name, data, unit, electrode, gain, **kwargs) + super().__init__(name, data, unit, electrode, **kwargs) @register_class('SweepTable', CORE_NAMESPACE) diff --git a/src/pynwb/nwb-schema b/src/pynwb/nwb-schema index d4566d31e..473fcc41e 160000 --- a/src/pynwb/nwb-schema +++ b/src/pynwb/nwb-schema @@ -1 +1 @@ -Subproject commit d4566d31edde93deed75106a0e01750e4e06fb41 +Subproject commit 473fcc41e871288767cfb37d83315cca7469b9d1 diff --git a/tests/integration/hdf5/test_behavior.py b/tests/integration/hdf5/test_behavior.py index d4b230bcc..39770876b 100644 --- a/tests/integration/hdf5/test_behavior.py +++ b/tests/integration/hdf5/test_behavior.py @@ -11,7 +11,17 @@ def setUpContainer(self): return SpatialSeries( name='test_sS', data=np.ones((3, 2)), - bounds=[(-1,1),(-1,1),(-1,1)], reference_frame='reference_frame', timestamps=[1., 2., 3.] ) + + +class TestSpatialSeriesMinIO(AcquisitionH5IOMixin, TestCase): + + def setUpContainer(self): + """ Return the test TimeSeries to read/write """ + return SpatialSeries( + name='test_sS', + data=np.ones((3, 2)), + timestamps=[1., 2., 3.] + ) diff --git a/tests/integration/hdf5/test_ecephys.py b/tests/integration/hdf5/test_ecephys.py index ff67d27c9..c44725277 100644 --- a/tests/integration/hdf5/test_ecephys.py +++ b/tests/integration/hdf5/test_ecephys.py @@ -26,7 +26,8 @@ def setUpContainer(self): eg = ElectrodeGroup(name='elec1', description='a test ElectrodeGroup', location='a nonexistent place', - device=self.dev1) + device=self.dev1, + position=(1., 2., 3.)) return eg def addContainer(self, nwbfile): diff --git a/tests/integration/hdf5/test_file_copy.py b/tests/integration/hdf5/test_file_copy.py index b491586fb..be23f74ca 100644 --- a/tests/integration/hdf5/test_file_copy.py +++ b/tests/integration/hdf5/test_file_copy.py @@ -9,8 +9,8 @@ class TestFileCopy(TestCase): def setUp(self): - self.path1 = "test_a.h5" - self.path2 = "test_b.h5" + self.path1 = "test_a.nwb" + self.path2 = "test_b.nwb" def tearDown(self): if os.path.exists(self.path1): diff --git a/tests/integration/hdf5/test_icephys.py b/tests/integration/hdf5/test_icephys.py index 4fba6d28f..b50e5d9f5 100644 --- a/tests/integration/hdf5/test_icephys.py +++ b/tests/integration/hdf5/test_icephys.py @@ -66,6 +66,27 @@ def addContainer(self, nwbfile): nwbfile.add_device(self.device) super().addContainer(nwbfile) +class TestPatchClampSeriesMin(AcquisitionH5IOMixin, TestCase): + """ Test a PatchClampSeries with minimum required args to read/write """ + + def setUpElectrode(self): + """ Set up the test IntracellularElectrode """ + self.device = Device(name='device_name') + self.elec = IntracellularElectrode(name="elec0", description='a fake electrode object', + device=self.device) + + def setUpContainer(self): + self.setUpElectrode() + return PatchClampSeries(name="pcs", data=[1, 2, 3, 4, 5], unit='A', + starting_time=123.6, rate=10e3, electrode=self.elec) + + def addContainer(self, nwbfile): + """ + Add the test PatchClampSeries as an acquisition and IntracellularElectrode and Device to the given NWBFile + """ + nwbfile.add_icephys_electrode(self.elec) + nwbfile.add_device(self.device) + super().addContainer(nwbfile) class TestCurrentClampStimulusSeries(TestPatchClampSeries): diff --git a/tests/integration/hdf5/test_io.py b/tests/integration/hdf5/test_io.py index e1ce4269b..1e6ed0593 100644 --- a/tests/integration/hdf5/test_io.py +++ b/tests/integration/hdf5/test_io.py @@ -18,6 +18,13 @@ from pynwb.testing.mock.file import mock_NWBFile +import unittest +try: + import fsspec # noqa f401 + HAVE_FSSPEC = True +except ImportError: + HAVE_FSSPEC = False + class TestHDF5Writer(TestCase): _required_tests = ('test_nwbio', 'test_write_clobber', 'test_write_cache_spec', 'test_write_no_cache_spec') @@ -313,7 +320,7 @@ def setUp(self): self.nwbfile = NWBFile(session_description='a', identifier='b', session_start_time=datetime(1970, 1, 1, 12, tzinfo=tzutc())) - self.path = "test_pynwb_io_hdf5_h5dataIO.h5" + self.path = "test_pynwb_io_hdf5_h5dataIO.nwb" def tearDown(self): remove_test_file(self.path) @@ -428,7 +435,7 @@ def setUp(self): self.nwbfile = NWBFile(session_description='a test NWB File', identifier='TEST123', session_start_time=datetime(1970, 1, 1, 12, tzinfo=tzutc())) - self.path = "test_pynwb_io_nwbhdf5.h5" + self.path = "test_pynwb_io_nwbhdf5.nwb" def tearDown(self): remove_test_file(self.path) @@ -532,6 +539,23 @@ def test_round_trip_with_pathlib_path(self): read_file = io.read() self.assertContainerEqual(read_file, self.nwbfile) + def test_warn_for_nwb_extension(self): + """Creating a file with an extension other than .nwb should raise a warning""" + pathlib_path = Path(self.path).with_suffix('.h5') + + with self.assertWarns(UserWarning): + with NWBHDF5IO(pathlib_path, 'w') as io: + io.write(self.nwbfile) + with self.assertWarns(UserWarning): + with NWBHDF5IO(str(pathlib_path), 'w') as io: + io.write(self.nwbfile) + + # should not warn on read or append + with NWBHDF5IO(str(pathlib_path), 'r') as io: + io.read() + with NWBHDF5IO(str(pathlib_path), 'a') as io: + io.read() + def test_can_read_current_nwb_file(self): with NWBHDF5IO(self.path, 'w') as io: io.write(self.nwbfile) @@ -569,3 +593,38 @@ def test_can_read_file_old_version(self): def test_can_read_file_invalid_hdf5_file(self): # current file is not an HDF5 file self.assertFalse(NWBHDF5IO.can_read(__file__)) + + def test_read_nwb_method_path(self): + + # write the example file + with NWBHDF5IO(self.path, 'w') as io: + io.write(self.nwbfile) + + # test that the read_nwb method works + read_nwbfile = NWBHDF5IO.read_nwb(path=self.path) + self.assertContainerEqual(read_nwbfile, self.nwbfile) + + read_nwbfile.get_read_io().close() + + def test_read_nwb_method_file(self): + + # write the example file + with NWBHDF5IO(self.path, 'w') as io: + io.write(self.nwbfile) + + import h5py + + file = h5py.File(self.path, 'r') + + read_nwbfile = NWBHDF5IO.read_nwb(file=file) + self.assertContainerEqual(read_nwbfile, self.nwbfile) + + read_nwbfile.get_read_io().close() + + @unittest.skipIf(not HAVE_FSSPEC, "fsspec library not available") + def test_read_nwb_method_s3_path(self): + s3_test_path = "https://dandiarchive.s3.amazonaws.com/blobs/11e/c89/11ec8933-1456-4942-922b-94e5878bb991" + read_nwbfile = NWBHDF5IO.read_nwb(path=s3_test_path) + assert read_nwbfile.identifier == "3f77c586-6139-4777-a05d-f603e90b1330" + + assert read_nwbfile.subject.subject_id == "1" \ No newline at end of file diff --git a/tests/unit/test_behavior.py b/tests/unit/test_behavior.py index 6bcf1a9eb..37a471688 100644 --- a/tests/unit/test_behavior.py +++ b/tests/unit/test_behavior.py @@ -12,15 +12,21 @@ def test_init(self): sS = SpatialSeries( name='test_sS', data=np.ones((3, 2)), - bounds=[(-1,1),(-1,1),(-1,1)], reference_frame='reference_frame', timestamps=[1., 2., 3.] ) self.assertEqual(sS.name, 'test_sS') self.assertEqual(sS.unit, 'meters') - self.assertEqual(sS.bounds, [(-1,1),(-1,1),(-1,1)]) self.assertEqual(sS.reference_frame, 'reference_frame') + def test_init_minimum(self): + sS = SpatialSeries( + name='test_sS', + data=np.ones((3, 2)), + timestamps=[1., 2., 3.] + ) + assert sS.reference_frame is None + def test_set_unit(self): sS = SpatialSeries( name='test_sS', diff --git a/tests/unit/test_ecephys.py b/tests/unit/test_ecephys.py index dc194af2a..1415c3d30 100644 --- a/tests/unit/test_ecephys.py +++ b/tests/unit/test_ecephys.py @@ -178,16 +178,34 @@ class ElectrodeGroupConstructor(TestCase): def test_init(self): dev1 = Device('dev1') - group = ElectrodeGroup('elec1', 'electrode description', 'electrode location', dev1, (1, 2, 3)) + group = ElectrodeGroup(name='elec1', + description='electrode description', + location='electrode location', + device=dev1, + position=(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)) + self.assertEqual(group.position.tolist(), (1, 2, 3)) + + def test_init_position_array(self): + position = np.array((1, 2, 3), dtype=np.dtype([('x', float), ('y', float), ('z', float)])) + dev1 = Device('dev1') + group = ElectrodeGroup('elec1', 'electrode description', 'electrode location', dev1, + position) + 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, position) def test_init_position_none(self): dev1 = Device('dev1') - group = ElectrodeGroup('elec1', 'electrode description', 'electrode location', dev1) + group = ElectrodeGroup(name='elec1', + description='electrode description', + location='electrode location', + device=dev1) self.assertEqual(group.name, 'elec1') self.assertEqual(group.description, 'electrode description') self.assertEqual(group.location, 'electrode location') @@ -197,7 +215,29 @@ def test_init_position_none(self): def test_init_position_bad(self): dev1 = Device('dev1') with self.assertRaises(ValueError): - ElectrodeGroup('elec1', 'electrode description', 'electrode location', dev1, (1, 2)) + ElectrodeGroup(name='elec1', + description='electrode description', + location='electrode location', + device=dev1, + position=(1, 2)) + with self.assertRaises(ValueError): + ElectrodeGroup(name='elec1', + description='electrode description', + location='electrode location', + device=dev1, + position=[(1, 2), ]) + with self.assertRaises(ValueError): + ElectrodeGroup(name='elec1', + description='electrode description', + location='electrode location', + device=dev1, + position=np.array([(1., 2.)], dtype=np.dtype([('x', float), ('y', float)]))) + with self.assertRaises(ValueError): + ElectrodeGroup(name='elec1', + description='electrode description', + location='electrode location', + device=dev1, + position=[(1, 2, 3), (4, 5, 6), (7, 8, 9)]) class EventDetectionConstructor(TestCase): diff --git a/tests/unit/test_icephys.py b/tests/unit/test_icephys.py index e0e8332f9..5ada7ec12 100644 --- a/tests/unit/test_icephys.py +++ b/tests/unit/test_icephys.py @@ -154,6 +154,13 @@ def test_default(self): self.assertEqual(pCS.electrode, electrode_name) self.assertEqual(pCS.gain, 1.0) + def test_gain_optional(self): + electrode_name = GetElectrode() + + pCS = PatchClampSeries('test_pCS', list(), 'unit', + electrode_name, timestamps=list()) + self.assertIsNone(pCS.gain) + def test_sweepNumber_valid(self): electrode_name = GetElectrode() diff --git a/tests/unit/test_icephys_metadata_tables.py b/tests/unit/test_icephys_metadata_tables.py index b31ee9215..a357f3288 100644 --- a/tests/unit/test_icephys_metadata_tables.py +++ b/tests/unit/test_icephys_metadata_tables.py @@ -82,7 +82,7 @@ def setUp(self): sweep_number=np.uint64(15) ) self.nwbfile.add_acquisition(self.response) - self.path = 'test_icephys_meta_intracellularrecording.h5' + self.path = 'test_icephys_meta_intracellularrecording.nwb' def tearDown(self): remove_test_file(self.path) @@ -1037,7 +1037,7 @@ class NWBFileTests(TestCase): """ def setUp(self): warnings.simplefilter("always") # Trigger all warnings - self.path = 'test_icephys_meta_intracellularrecording.h5' + self.path = 'test_icephys_meta_intracellularrecording.nwb' def tearDown(self): remove_test_file(self.path)