Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for NWB schema 2.7.0 #1820

Merged
merged 19 commits into from
Mar 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
## PyNWB 2.7.0 (Upcoming)

### Enhancements and minor changes
- 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)
- Support `stimulus_template` as optional predefined column in `IntracellularStimuliTable`. @stephprince [#1815](https://github.com/NeurodataWithoutBorders/pynwb/pull/1815)
- Support `NWBDataInterface` and `DynamicTable` in `NWBFile.stimulus`. @rly [#1842](https://github.com/NeurodataWithoutBorders/pynwb/pull/1842)
- Added support for python 3.12 and upgraded dependency versions. This also includes infrastructure updates for developers. @mavaylon1 [#1853](https://github.com/NeurodataWithoutBorders/pynwb/pull/1853)

### Bug fixes
Expand Down
2 changes: 1 addition & 1 deletion docs/gallery/domain/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@
description="The images presented to the subject as stimuli",
)

nwbfile.add_stimulus(timeseries=optical_series)
nwbfile.add_stimulus(stimulus=optical_series)

####################
# ImageSeries: Storing series of images as acquisition
Expand Down
54 changes: 54 additions & 0 deletions docs/gallery/domain/plot_icephys.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@

# Import additional core datatypes used in the example
from pynwb.core import DynamicTable, VectorData
from pynwb.base import TimeSeriesReference, TimeSeriesReferenceVectorData

# Import icephys TimeSeries types used
from pynwb.icephys import VoltageClampSeries, VoltageClampStimulusSeries
Expand Down Expand Up @@ -457,6 +458,59 @@
category="electrodes",
)

#####################################################################
# Adding stimulus templates
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
#
# One predefined subcategory column is the ``stimulus_template`` column in the stimuli table. This column is
# used to store template waveforms of stimuli in addition to the actual recorded stimulus that is stored in the
# ``stimulus`` column. The ``stimulus_template`` column contains an idealized version of the template waveform used as
# the stimulus. This can be useful as a noiseless version of the stimulus for data analysis or to validate that the
# recorded stimulus matches the expected waveform of the template. Similar to the ``stimulus`` and ``response``
# columns, we can specify a relevant time range.

stimulus_template = VoltageClampStimulusSeries(
name="ccst",
data=[0, 1, 2, 3, 4],
starting_time=0.0,
rate=10e3,
electrode=electrode,
gain=0.02,
)
nwbfile.add_stimulus_template(stimulus_template)

nwbfile.intracellular_recordings.add_column(
name="stimulus_template",
data=[TimeSeriesReference(0, 5, stimulus_template), # (start_index, index_count, stimulus_template)
TimeSeriesReference(1, 3, stimulus_template),
TimeSeriesReference.empty(stimulus_template)], # if there was no data for that recording, use empty reference
description="Column storing the reference to the stimulus template for the recording (rows).",
category="stimuli",
col_cls=TimeSeriesReferenceVectorData
)

# we can also add stimulus template data as follows
rowindex = nwbfile.add_intracellular_recording(
electrode=electrode,
stimulus=stimulus,
stimulus_template=stimulus_template, # the full time range of the stimulus template will be used unless specified
recording_tag='A4',
recording_lab_data={'location': 'Isengard'},
electrode_metadata={'voltage_threshold': 0.14},
id=13,
)

#####################################################################
# .. note:: If a stimulus template column exists but there is no stimulus template data for that recording, then
# :py:meth:`~pynwb.file.NWBFile.add_intracellular_recording` will internally set the stimulus template to the
# provided stimulus or response TimeSeries and the start_index and index_count for the missing parameter are
# set to -1. The missing values will be represented via masked numpy arrays.

#####################################################################
# .. note:: Since stimulus templates are often reused across many recordings, the timestamps in the templates are not
# usually aligned with the recording nor with the reference time of the file. The timestamps often start
# at 0 and are relative to the time of the application of the stimulus.

#####################################################################
# Add a simultaneous recording
# ---------------------------------
Expand Down
5 changes: 2 additions & 3 deletions docs/gallery/general/plot_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,7 @@
:py:class:`~pynwb.ophys.ImageSegmentation`,
:py:class:`~pynwb.ophys.MotionCorrection`.

* **Others:** :py:class:`~pynwb.retinotopy.ImagingRetinotopy`,
:py:class:`~pynwb.base.Images`.
* **Others:** :py:class:`~pynwb.base.Images`.

* **TimeSeries:** Any :ref:`timeseries_overview` is also a subclass of :py:class:`~pynwb.core.NWBDataInterface`
and can be used anywhere :py:class:`~pynwb.core.NWBDataInterface` is allowed.
Expand Down Expand Up @@ -372,7 +371,7 @@
# Processing modules can be thought of as folders within the file for storing the related processed data.
#
# .. tip:: Use the NWB schema module names as processing module names where appropriate.
# These are: ``"behavior"``, ``"ecephys"``, ``"icephys"``, ``"ophys"``, ``"ogen"``, ``"retinotopy"``, and ``"misc"``.
# These are: ``"behavior"``, ``"ecephys"``, ``"icephys"``, ``"ophys"``, ``"ogen"``, and ``"misc"``.
#
# Let's assume that the subject's position was computed from a video tracking algorithm,
# so it would be classified as processed data.
Expand Down
7 changes: 7 additions & 0 deletions docs/gallery/general/plot_read_basics.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,3 +283,10 @@
# object and accessing its attributes, but it may be useful to explore the data in a
# more interactive, visual way. See :ref:`analysistools-explore` for an updated list of programs for
# exploring NWB files.

####################
# Close the open NWB file
# -----------------------
# It is good practice, especially on Windows, to close any files that you have opened.

io.close()
1 change: 0 additions & 1 deletion docs/source/api_docs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ API Documentation
Intracellular Electrophysiology <pynwb.icephys>
Optophysiology <pynwb.ophys>
Optogenetics <pynwb.ogen>
Retinotopy <pynwb.retinotopy>
General Imaging <pynwb.image>
Behavior <pynwb.behavior>
NWB Base Classes <pynwb.base>
Expand Down
9 changes: 8 additions & 1 deletion docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,12 @@ def __call__(self, filename):
# directories to ignore when looking for source files.
exclude_patterns = ['_build', 'test.py']

# List of patterns, relative to source directory, of modules to be
# excluded by apidoc when generating rst files.
apidoc_exclude = [
"../../src/pynwb/retinotopy.py",
]

# The reST default role (used for this markup: `text`) to use for all documents.
# default_role = None

Expand Down Expand Up @@ -410,7 +416,8 @@ def run_apidoc(_):
out_dir = os.path.dirname(__file__)
src_dir = os.path.join(out_dir, '../../src')
sys.path.append(src_dir)
apidoc_main(['-f', '-e', '--no-toc', '-o', out_dir, src_dir])
apidoc_exclude_abs = [os.path.join(out_dir, f) for f in apidoc_exclude]
apidoc_main(['-f', '-e', '--no-toc', '-o', out_dir, src_dir, *apidoc_exclude_abs])


from abc import abstractproperty
Expand Down
1 change: 0 additions & 1 deletion src/pynwb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,6 @@ def export(self, **kwargs):
from . import misc # noqa: F401,E402
from . import ogen # noqa: F401,E402
from . import ophys # noqa: F401,E402
from . import retinotopy # noqa: F401,E402
from . import legacy # noqa: F401,E402
from hdmf.data_utils import DataChunkIterator # noqa: F401,E402
from hdmf.backends.hdf5 import H5DataIO # noqa: F401,E402
Expand Down
20 changes: 20 additions & 0 deletions src/pynwb/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,26 @@ def data(self):
# load the data from the timeseries
return self.timeseries.data[self.idx_start: (self.idx_start + self.count)]

@classmethod
@docval({'name': 'timeseries', 'type': TimeSeries, 'doc': 'the timeseries object to reference.'})
def empty(cls, timeseries):
"""
Creates an empty TimeSeriesReference object to represent missing data.

When missing data needs to be represented, NWB defines ``None`` for the complex data type ``(idx_start,
count, TimeSeries)`` as (-1, -1, TimeSeries) for storage. The exact timeseries object will technically not
matter since the empty reference is a way of indicating a NaN value in a
:py:class:`~pynwb.base.TimeSeriesReferenceVectorData` column.

An example where this functionality is used is :py:class:`~pynwb.icephys.IntracellularRecordingsTable`
where only one of stimulus or response data was recorded. In such cases, the timeseries object for the
empty stimulus :py:class:`~pynwb.base.TimeSeriesReference` could be set to the response series, or vice versa.

:returns: Returns :py:class:`~pynwb.base.TimeSeriesReference`
"""

return cls(-1, -1, timeseries)


@register_class('TimeSeriesReferenceVectorData', CORE_NAMESPACE)
class TimeSeriesReferenceVectorData(VectorData):
Expand Down
30 changes: 23 additions & 7 deletions src/pynwb/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ class NWBFile(MultiContainerInterface, HERDManager):
{
'attr': 'stimulus',
'add': '_add_stimulus_internal',
'type': TimeSeries,
'type': (NWBDataInterface, DynamicTable),
'get': 'get_stimulus'
},
{
Expand Down Expand Up @@ -356,7 +356,8 @@ class NWBFile(MultiContainerInterface, HERDManager):
{'name': 'analysis', 'type': (list, tuple),
'doc': 'result of analysis', 'default': None},
{'name': 'stimulus', 'type': (list, tuple),
'doc': 'Stimulus TimeSeries objects belonging to this NWBFile', 'default': None},
'doc': 'Stimulus TimeSeries, DynamicTable, or NWBDataInterface objects belonging to this NWBFile',
'default': None},
{'name': 'stimulus_template', 'type': (list, tuple),
'doc': 'Stimulus template TimeSeries objects belonging to this NWBFile', 'default': None},
{'name': 'epochs', 'type': TimeIntervals,
Expand Down Expand Up @@ -856,14 +857,29 @@ def add_acquisition(self, **kwargs):
if use_sweep_table:
self._update_sweep_table(nwbdata)

@docval({'name': 'timeseries', 'type': TimeSeries},
{'name': 'use_sweep_table', 'type': bool, 'default': False, 'doc': 'Use the deprecated SweepTable'})
@docval({'name': 'stimulus', 'type': (TimeSeries, DynamicTable, NWBDataInterface), 'default': None,
'doc': 'The stimulus presentation data to add to this NWBFile.'},
{'name': 'use_sweep_table', 'type': bool, 'default': False, 'doc': 'Use the deprecated SweepTable'},
{'name': 'timeseries', 'type': TimeSeries, 'default': None,
'doc': 'The "timeseries" keyword argument is deprecated. Use the "nwbdata" argument instead.'},)
def add_stimulus(self, **kwargs):
timeseries = popargs('timeseries', kwargs)
self._add_stimulus_internal(timeseries)
stimulus, timeseries = popargs('stimulus', 'timeseries', kwargs)
if stimulus is None and timeseries is None:
raise ValueError(
"The 'stimulus' keyword argument is required. The 'timeseries' keyword argument can be "
"provided for backwards compatibility but is deprecated in favor of 'stimulus' and will be "
"removed in PyNWB 3.0."
)
# TODO remove this support in PyNWB 3.0
if timeseries is not None:
warn("The 'timeseries' keyword argument is deprecated and will be removed in PyNWB 3.0. "
"Use the 'stimulus' argument instead.", DeprecationWarning)
if stimulus is None:
stimulus = timeseries
self._add_stimulus_internal(stimulus)
use_sweep_table = popargs('use_sweep_table', kwargs)
if use_sweep_table:
self._update_sweep_table(timeseries)
self._update_sweep_table(stimulus)

@docval({'name': 'timeseries', 'type': (TimeSeries, Images)},
{'name': 'use_sweep_table', 'type': bool, 'default': False, 'doc': 'Use the deprecated SweepTable'})
Expand Down
30 changes: 30 additions & 0 deletions src/pynwb/icephys.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,12 @@ class IntracellularStimuliTable(DynamicTable):
'index': False,
'table': False,
'class': TimeSeriesReferenceVectorData},
{'name': 'stimulus_template',
'description': 'Column storing the reference to the stimulus template for the recording (rows)',
'required': False,
'index': False,
'table': False,
'class': TimeSeriesReferenceVectorData},
)

@docval(*get_docval(DynamicTable.__init__, 'id', 'columns', 'colnames'))
Expand Down Expand Up @@ -518,6 +524,13 @@ def __init__(self, **kwargs):
{'name': 'stimulus', 'type': TimeSeries,
'doc': 'The TimeSeries (usually a PatchClampSeries) with the stimulus',
'default': None},
{'name': 'stimulus_template_start_index', 'type': int, 'doc': 'Start index of the stimulus template',
'default': None},
{'name': 'stimulus_template_index_count', 'type': int, 'doc': 'Stop index of the stimulus template',
'default': None},
{'name': 'stimulus_template', 'type': TimeSeries,
'doc': 'The TimeSeries (usually a PatchClampSeries) with the stimulus template waveforms',
'default': None},
{'name': 'response_start_index', 'type': int, 'doc': 'Start index of the response', 'default': None},
{'name': 'response_index_count', 'type': int, 'doc': 'Stop index of the response', 'default': None},
{'name': 'response', 'type': TimeSeries,
Expand Down Expand Up @@ -553,6 +566,11 @@ def add_recording(self, **kwargs):
'response',
kwargs)
electrode = popargs('electrode', kwargs)
stimulus_template_start_index, stimulus_template_index_count, stimulus_template = popargs(
'stimulus_template_start_index',
'stimulus_template_index_count',
'stimulus_template',
kwargs)

# if electrode is not provided, take from stimulus or response object
if electrode is None:
Expand All @@ -572,6 +590,15 @@ def add_recording(self, **kwargs):
response_start_index, response_index_count = self.__compute_index(response_start_index,
response_index_count,
response, 'response')
stimulus_template_start_index, stimulus_template_index_count = self.__compute_index(
stimulus_template_start_index,
stimulus_template_index_count,
stimulus_template, 'stimulus_template')

# if stimulus template is already a column in the stimuli table, but stimulus_template was None
if 'stimulus_template' in self.category_tables['stimuli'].colnames and stimulus_template is None:
stimulus_template = stimulus if stimulus is not None else response # set to stimulus if it was provided

# If either stimulus or response are None, then set them to the same TimeSeries to keep the I/O happy
response = response if response is not None else stimulus
stimulus_provided_is_not_none = stimulus is not None # Store if stimulus is None for error checks later
Expand Down Expand Up @@ -612,6 +639,9 @@ def add_recording(self, **kwargs):
stimuli = {}
stimuli['stimulus'] = TimeSeriesReferenceVectorData.TIME_SERIES_REFERENCE_TUPLE(
stimulus_start_index, stimulus_index_count, stimulus)
if stimulus_template is not None:
stimuli['stimulus_template'] = TimeSeriesReferenceVectorData.TIME_SERIES_REFERENCE_TUPLE(
stimulus_template_start_index, stimulus_template_index_count, stimulus_template)

# Compile the responses table data
responses = copy(popargs('response_metadata', kwargs))
Expand Down
1 change: 0 additions & 1 deletion src/pynwb/io/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,3 @@
from . import misc as __misc
from . import ogen as __ogen
from . import ophys as __ophys
from . import retinotopy as __retinotopy
6 changes: 5 additions & 1 deletion src/pynwb/io/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ def __init__(self, spec):
self.unmap(stimulus_spec)
self.unmap(stimulus_spec.get_group('presentation'))
self.unmap(stimulus_spec.get_group('templates'))
self.map_spec('stimulus', stimulus_spec.get_group('presentation').get_neurodata_type('TimeSeries'))
# map "stimulus" to NWBDataInterface and DynamicTable and unmap the spec for TimeSeries because it is
# included in the mapping to NWBDataInterface
self.unmap(stimulus_spec.get_group('presentation').get_neurodata_type('TimeSeries'))
self.map_spec('stimulus', stimulus_spec.get_group('presentation').get_neurodata_type('NWBDataInterface'))
self.map_spec('stimulus', stimulus_spec.get_group('presentation').get_neurodata_type('DynamicTable'))
self.map_spec('stimulus_template', stimulus_spec.get_group('templates').get_neurodata_type('TimeSeries'))
self.map_spec('stimulus_template', stimulus_spec.get_group('templates').get_neurodata_type('Images'))

Expand Down
1 change: 0 additions & 1 deletion src/pynwb/legacy/io/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,3 @@
from . import misc as __misc
from . import ogen as __ogen
from . import ophys as __ophys
from . import retinotopy as __retinotopy
5 changes: 3 additions & 2 deletions src/pynwb/ogen.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,9 @@ class OptogeneticSeries(TimeSeries):
__nwbfields__ = ('site',)

@docval(*get_docval(TimeSeries.__init__, 'name'), # required
{'name': 'data', 'type': ('array_data', 'data', TimeSeries), 'shape': (None, ), # required
'doc': 'The data values over time. Must be 1D.'},
{'name': 'data', 'type': ('array_data', 'data', TimeSeries), # required
'shape': [(None, ), (None, None)],
'doc': 'The data values over time.'},
{'name': 'site', 'type': OptogeneticStimulusSite, # required
'doc': 'The site to which this stimulus was applied.'},
*get_docval(TimeSeries.__init__, 'resolution', 'conversion', 'timestamps', 'starting_time', 'rate',
Expand Down
Loading
Loading