Skip to content

Commit c9ea3f2

Browse files
oruebelrly
andauthored
Automatically migrate TimeIntervals.timeseries to use TimeSeriesReferenceVectorData (#1390)
Co-authored-by: Ryan Ly <[email protected]>
1 parent 3b886ca commit c9ea3f2

File tree

12 files changed

+480
-13
lines changed

12 files changed

+480
-13
lines changed

CHANGELOG.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,21 @@
44

55
### Breaking changes:
66
- Restrict `SpatialSeries.data` to have no more than 3 columns (#1455)
7+
- Updated ``TimeIntervals`` to use the new ``TimeSeriesReferenceVectorData`` type. This does not alter the overall
8+
structure of ``TimeIntervals`` in a major way aside from changing the value of the ``neurodata_type`` attribute of the
9+
``TimeIntervals.timeseries`` column from ``VectorData`` to ``TimeSeriesReferenceVectorData``. This change facilitates
10+
creating common functionality around ``TimeSeriesReferenceVectorData``. For NWB files with version 2.4.0 and earlier,
11+
the ``TimeIntervals.timeseries`` column is automatically migrated on read in the ``TimeIntervalsMap``
12+
object mapper class to use the ``TimeSeriesReferenceVectorData`` container class, so that users are presented a
13+
consistent API for existing and new files. This change affects all existing ``TimeIntervals`` tables
14+
e.g., ``NBWFile.epochs``, ``NWBFile.trials``, and ``NWBFile.invalid_times``. While this is technically a breaking
15+
change, the impact user codes should be minimal as this change primarily adds functionality while the overall
16+
behavior of the API is largely consistent with existing behavior. @oruebel, @rly (#1390)
717

818
### Documentation and tutorial enhancements:
19+
- Added tutorial on annotating data via ``TimeIntervals``. @oruebel (#1390)
920
- Add copy button to code blocks @weiglszonja (#1460)
10-
- Create behavioral tutorial @weiglszonja (#1464)
21+
- Create behavioral tutorial @weiglszonja (#1464)
1122
- Enhance display of icephys pandas tutorial by using ``dataframe_image`` to render and display large tables as images. @oruebel (#1469)
1223
- Create tutorial about reading and exploring an existing `NWBFile` @weiglszonja (#1453)
1324

docs/gallery/general/file.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,10 @@
486486
#
487487
# .. _basic_trials:
488488
#
489+
# The following provides a brief introduction to managing annotations in time via
490+
# :py:class:`~pynwb.epoch.TimeIntervals`. See the :ref:`time_intervals` tutorial
491+
# for a more detailed introduction to :py:class:`~pynwb.epoch.TimeIntervals`.
492+
#
489493
# Trials
490494
# ^^^^^^
491495
#
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
"""
2+
.. _time_intervals:
3+
4+
Annotating Time Intervals
5+
=========================
6+
7+
Annotating events in time is a common need in neuroscience, e.g. to describes epochs, trials, and
8+
invalid times during an experimental session. NWB supports annotation of time intervals via the
9+
:py:class:`~pynwb.epoch.TimeIntervals` type. The :py:class:`~pynwb.epoch.TimeIntervals` type is
10+
a :py:class:`~hdmf.common.table.DynamicTable` with the following columns:
11+
12+
1. :py:meth:`~pynwb.epoch.TimeIntervals.start_time` and :py:meth:`~pynwb.epoch.TimeIntervals.stop_time`
13+
describe the start and stop times of intervals as floating point offsets in seconds relative to the
14+
:py:meth:`~pynwb.file.NWBFile.timestamps_reference_time` of the file. In addition,
15+
2. :py:class:`~pynwb.epoch.TimeIntervals.tags` is an optional, indexed column used to associate user-defined string
16+
tags with intervals (0 or more tags per time interval)
17+
3. :py:class:`~pynwb.epoch.TimeIntervals.timeseries` is an optional, indexed
18+
:py:class:`~pynwb.base.TimeSeriesReferenceVectorData` column to map intervals directly to ranges in select,
19+
relevant :py:class:`~pynwb.base.TimeSeries` (0 or more per time interval)
20+
4. as a :py:class:`~hdmf.common.table.DynamicTable` user may add additional columns to
21+
:py:meth:`~pynwb.epoch.TimeIntervals` via :py:class:`~hdmf.common.table.DynamicTable.add_column`
22+
23+
24+
.. hint:: :py:meth:`~pynwb.epoch.TimeIntervals` is intended for storing general annotations of time ranges.
25+
Depending on the application (e.g., when intervals are generated by data acquisition or automatic
26+
data processing), it can be useful to describe intervals (or instantaneous events) in time
27+
as :py:class:`~pynwb.base.TimeSeries`. NWB provides several types for this purposes, e.g.,
28+
:py:class:`~pynwb.misc.IntervalSeries`, :py:class:`~pynwb.behavior.BehavioralEpochs`,
29+
:py:class:`~pynwb.behavior.BehavioralEvents`, :py:class:`~pynwb.ecephys.EventDetection`, or
30+
:py:class:`~pynwb.ecephys.SpikeEventSeries`.
31+
32+
"""
33+
34+
####################
35+
# Setup: Creating an example NWB file for the tutorial
36+
# ----------------------------------------------------
37+
#
38+
39+
# sphinx_gallery_thumbnail_path = 'figures/gallery_thumbnails_timeintervals.png'
40+
from datetime import datetime
41+
from dateutil.tz import tzlocal
42+
from pynwb import NWBFile
43+
from pynwb import TimeSeries
44+
import numpy as np
45+
46+
# create the NWBFile
47+
nwbfile = NWBFile(session_description='NWBFile to illustrate TimeIntervals basics',
48+
identifier='NWB123',
49+
session_start_time=datetime(2017, 4, 3, 11, tzinfo=tzlocal()),
50+
file_create_date=datetime(2017, 4, 15, 12, tzinfo=tzlocal()))
51+
# create some example TimeSeries
52+
test_ts = TimeSeries(name='series1',
53+
data=np.arange(1000),
54+
unit='m',
55+
timestamps=np.linspace(0.5, 601, 1000))
56+
rate_ts = TimeSeries(name='series2',
57+
data=np.arange(600),
58+
unit='V',
59+
starting_time=0.0, rate=1.0)
60+
# Add the TimeSeries to the file
61+
nwbfile.add_acquisition(test_ts)
62+
nwbfile.add_acquisition(rate_ts)
63+
64+
####################
65+
# Adding Time Intervals to a NWBFile
66+
# ----------------------------------
67+
#
68+
# NWB provides a set of pre-defined :py:class:`~pynwb.epoch.TimeIntervals`
69+
# tables for :py:meth:`~pynwb.file.NWBFile.epochs`, :py:meth:`~pynwb.file.NWBFile.trials`, and
70+
# :py:meth:`~pynwb.file.NWBFile.invalid_times`.
71+
#
72+
# Trials
73+
# ^^^^^^
74+
#
75+
# Trials can be added to an NWB file using the methods :py:meth:`~pynwb.file.NWBFile.add_trial`
76+
# By default, NWBFile only requires trial :py:meth:`~pynwb.file.NWBFile.add_trial.start_time`
77+
# and :py:meth:`~pynwb.file.NWBFile.add_trial.end_time`. The :py:meth:`~pynwb.file.NWBFile.add_trial.tags`
78+
# and :py:meth:`~pynwb.file.NWBFile.add_trial.timeseries` are optional. For
79+
# :py:meth:`~pynwb.file.NWBFile.add_trial.timeseries` we only need to supply the :py:class:`~pynwb.base.TimeSeries`.
80+
# PyNWB automatically calculates the corresponding index range (described by ``idx_start`` and ``count``) for
81+
# the supplied :py:class:`~pynwb.base.TimeSeries based on the given ``start_time`` and ``stop_time`` and
82+
# the :py:meth:`~pynwb.base.TimeSeries.timestamps` (or :py:class:`~pynwb.base.TimeSeries.starting_time`
83+
# and :py:meth:`~pynwb.base.TimeSeries.rate`) of the given :py:class:`~pynwb.base.TimeSeries`.
84+
#
85+
# Additional columns can be added using :py:meth:`~pynwb.file.NWBFile.add_trial_column`. This method takes a name
86+
# for the column and a description of what the column stores. You do not need to supply data
87+
# type, as this will inferred. Once all columns have been added, trial data can be populated using
88+
# :py:meth:`~pynwb.file.NWBFile.add_trial`.
89+
#
90+
# Lets add an additional column and some trial data with tags and timeseries references.
91+
92+
nwbfile.add_trial_column(name='stim', description='the visual stimuli during the trial')
93+
94+
nwbfile.add_trial(start_time=0.0, stop_time=2.0, stim='dog',
95+
tags=['animal'], timeseries=[test_ts, rate_ts])
96+
nwbfile.add_trial(start_time=3.0, stop_time=5.0, stim='mountain',
97+
tags=['landscape'], timeseries=[test_ts, rate_ts])
98+
nwbfile.add_trial(start_time=6.0, stop_time=8.0, stim='desert',
99+
tags=['landscape'], timeseries=[test_ts, rate_ts])
100+
nwbfile.add_trial(start_time=9.0, stop_time=11.0, stim='tree',
101+
tags=['landscape', 'plant'], timeseries=[test_ts, rate_ts])
102+
nwbfile.add_trial(start_time=12.0, stop_time=14.0, stim='bird',
103+
tags=['animal'], timeseries=[test_ts, rate_ts])
104+
nwbfile.add_trial(start_time=15.0, stop_time=17.0, stim='flower',
105+
tags=['animal'], timeseries=[test_ts, rate_ts])
106+
107+
####################
108+
# Epochs
109+
# ^^^^^^
110+
#
111+
# Similarly, epochs can be added to an NWB file using the method :py:meth:`~pynwb.file.NWBFile.add_epoch` and
112+
# :py:meth:`~pynwb.file.NWBFile.add_epoch_column`.
113+
114+
nwbfile.add_epoch(2.0, 4.0, ['first', 'example'], [test_ts, ])
115+
nwbfile.add_epoch(6.0, 8.0, ['second', 'example'], [test_ts, ])
116+
117+
####################
118+
# Invalid Times
119+
# ^^^^^^^^^^^^^
120+
#
121+
# Similarly, invalid times can be added using the method :py:meth:`~pynwb.file.NWBFile.add_invalid_time_interval` and
122+
# :py:meth:`~pynwb.file.NWBFile.add_invalid_times_column`.
123+
124+
nwbfile.add_epoch(2.0, 4.0, ['first', 'example'], [test_ts, ])
125+
nwbfile.add_epoch(6.0, 8.0, ['second', 'example'], [test_ts, ])
126+
127+
####################
128+
# Custom Time Intervals
129+
# ^^^^^^^^^^^^^^^^^^^^^
130+
#
131+
# To define custom, experiment-specific :py:class:`~pynwb.epoch.TimeIntervals` we can wither add them
132+
# either: 1) when creating the :py:class:`~pynwb.file.NWBFile` by defining the
133+
# :py:meth:`~pynwb.file.NWBFile.__init__.intervals` constructor argument or 2) via the
134+
# :py:meth:`~pynwb.file.NWBFile.add_time_intervals` or :py:meth:`~pynwb.file.NWBFile.create_time_intervals`
135+
# after the :py:class:`~pynwb.file.NWBFile` has been created.
136+
#
137+
138+
from pynwb.epoch import TimeIntervals
139+
140+
sleep_stages = TimeIntervals(
141+
name="sleep_stages",
142+
description="intervals for each sleep stage as determined by EEG",
143+
)
144+
145+
sleep_stages.add_column(name="stage", description="stage of sleep")
146+
sleep_stages.add_column(name="confidence", description="confidence in stage (0-1)")
147+
148+
sleep_stages.add_row(start_time=0.3, stop_time=0.5, stage=1, confidence=.5)
149+
sleep_stages.add_row(start_time=0.7, stop_time=0.9, stage=2, confidence=.99)
150+
sleep_stages.add_row(start_time=1.3, stop_time=3.0, stage=3, confidence=0.7)
151+
152+
_ = nwbfile.add_time_intervals(sleep_stages)
153+
154+
155+
####################
156+
# Accessing Time Intervals
157+
# ------------------------
158+
#
159+
# We can access the predefined :py:class:`~pynwb.epoch.TimeIntervals` tables via the corresponding
160+
# :py:meth:`~pynwb.file.NWBFile.epochs`, :py:meth:`~pynwb.file.NWBFile.trials`, and
161+
# :py:meth:`~pynwb.file.NWBFile.invalid_times` properties and for custom :py:class:`~pynwb.epoch.TimeIntervals`
162+
# via the :py:meth:`~pynwb.file.NWBFile.get_time_intervals` method. E.g.:
163+
164+
_ = nwbfile.intervals
165+
_ = nwbfile.get_time_intervals('sleep_stages')
166+
167+
168+
####################
169+
# Like any :py:class:`~hdmf.common.table.DynamicTable`, we can conveniently convert any
170+
# :py:class:`~pynwb.epoch.TimeIntervals` table to a ``pandas.DataFrame`` via
171+
# :py:meth:`~hdmf.common.table.DynamicTable.to_dataframe`, such as:
172+
173+
nwbfile.trials.to_dataframe()
174+
175+
####################
176+
# This approach makes it easy to query the data to, e.g., locate all time intervals within a certain time range
177+
178+
trials_df = nwbfile.trials.to_dataframe()
179+
trials_df.query('(start_time > 2.0) & (stop_time < 9.0)')
180+
181+
####################
182+
# Accessing referenced TimeSeries
183+
# -------------------------------
184+
#
185+
# As mentioned earlier, the ``timeseries`` column is defined by a :py:class:`~pynwb.base.TimeSeriesReferenceVectorData`
186+
# which stores references to the corresponding ranges in :py:class:`~pynwb.base.TimeSeries`. Individual references
187+
# to :py:class:`~pynwb.base.TimeSeries` are described via :py:class:`~pynwb.base.TimeSeriesReference` tuples
188+
# with the :py:class:`~pynwb.base.TimeSeriesReference.idx_start`, :py:class:`~pynwb.base.TimeSeriesReference.count`,
189+
# and :py:class:`~pynwb.base.TimeSeriesReference.timeseries`.
190+
# Using :py:class:`~pynwb.base.TimeSeriesReference` we can easily access the relevant
191+
# :py:meth:`~pynwb.base.TimeSeriesReference.data` and :py:meth:`~pynwb.base.TimeSeriesReference.timestamps`
192+
# for the corresponding time range from the :py:class:`~pynwb.base.TimeSeries`.
193+
194+
# Get a single example TimeSeriesReference from the trials table
195+
example_tsr = nwbfile.trials['timeseries'][0][0]
196+
197+
# Get the data values from the timeseries. This is a shorthand for:
198+
# _ = example_tsr.timeseries.data[example_tsr.idx_start: (example_tsr.idx_start + example_tsr.count)]
199+
_ = example_tsr.data
200+
201+
# Get the timestamps. Timestamps are either loaded from the TimeSeries or
202+
# computed from the starting_time and rate
203+
example_tsr.timestamps
204+
205+
####################
206+
# Using :py:class:`~pynwb.base.TimeSeriesReference.isvalid` we can further check if the reference is valid.
207+
# A :py:class:`~pynwb.base.TimeSeriesReference` is defined as invalid if both
208+
# :py:class:`~pynwb.base.TimeSeriesReference.idx_start`, :py:class:`~pynwb.base.TimeSeriesReference.count` are
209+
# set to ``-1``. :py:class:`~pynwb.base.TimeSeriesReference.isvalid` further also checks that the indicated
210+
# index range and types are valid, raising ``IndexError`` and ``TypeError`` respectively, if bad
211+
# :py:class:`~pynwb.base.TimeSeriesReference.idx_start`, :py:class:`~pynwb.base.TimeSeriesReference.count` or
212+
# :py:class:`~pynwb.base.TimeSeriesReference.timeseries` are found.
213+
214+
example_tsr.isvalid()
215+
216+
####################
217+
# Adding TimeSeries references to other tables
218+
# --------------------------------------------
219+
#
220+
# Since :py:class:`~pynwb.base.TimeSeriesReferenceVectorData` is a regular :py:class:`~hdmf.common.table.VectoData`
221+
# type, we can use it to add references to intervals in :py:class:`~pynwb.base.TimeSeries` to any
222+
# :py:class:`~hdmf.common.table.DynamicTable`. In the :py:class:`~pynwb.icephys.IntracellularRecordingTable`, e.g.,
223+
# it is used to reference the recording of the stimulus and response associated with a particular intracellular
224+
# electrophysiology recording.
225+
#
226+
227+
228+
####################
229+
# Reading/Writing TimeIntervals to file
230+
# -------------------------------------
231+
#
232+
# Reading and writing the data is as usual:
233+
234+
from pynwb import NWBHDF5IO
235+
# write the file
236+
with NWBHDF5IO('example_timeintervals_file.nwb', 'w') as io:
237+
io.write(nwbfile)
238+
# read the file
239+
io = NWBHDF5IO('example_timeintervals_file.nwb', 'r')
240+
nwbfile_in = io.read()
241+
# plot the sleep stages TimeIntervals table
242+
nwbfile.get_time_intervals('sleep_stages').to_dataframe()
-27.8 KB
Binary file not shown.
70.9 KB
Loading

src/pynwb/base.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -485,18 +485,38 @@ class TimeSeriesReferenceVectorData(VectorData):
485485
*get_docval(VectorData.__init__, 'data'))
486486
def __init__(self, **kwargs):
487487
call_docval_func(super().__init__, kwargs)
488+
# CAUTION: Define any logic specific for init in the self._init_internal function, not here!
489+
self._init_internal()
488490

489-
@docval({'name': 'val', 'type': TIME_SERIES_REFERENCE_TUPLE, 'doc': 'the value to add to this column'})
491+
def _init_internal(self):
492+
"""
493+
Called from __init__ to perform initialization specific to this class. This is done
494+
here due to the :py:class:`~pynwb.io.epoch.TimeIntervalsMap` having to migrate legacy VectorData
495+
to TimeSeriesReferenceVectorData. In this way, if dedicated logic init logic needs
496+
to be added to this class then we have a place for it without having to also
497+
update :py:class:`~pynwb.io.epoch.TimeIntervalsMap` (which would likely get forgotten)
498+
"""
499+
pass
500+
501+
@docval({'name': 'val', 'type': (TIME_SERIES_REFERENCE_TUPLE, tuple),
502+
'doc': 'the value to add to this column. If this is a regular tuple then it '
503+
'must be convertible to a TimeSeriesReference'})
490504
def add_row(self, **kwargs):
491505
"""Append a data value to this column."""
492506
val = getargs('val', kwargs)
507+
if not (isinstance(val, self.TIME_SERIES_REFERENCE_TUPLE)):
508+
val = self.TIME_SERIES_REFERENCE_TUPLE(*val)
493509
val.check_types()
494510
super().append(val)
495511

496-
@docval({'name': 'arg', 'type': TIME_SERIES_REFERENCE_TUPLE, 'doc': 'the value to append to this column'})
512+
@docval({'name': 'arg', 'type': (TIME_SERIES_REFERENCE_TUPLE, tuple),
513+
'doc': 'the value to append to this column. If this is a regular tuple then it '
514+
'must be convertible to a TimeSeriesReference'})
497515
def append(self, **kwargs):
498516
"""Append a data value to this column."""
499517
arg = getargs('arg', kwargs)
518+
if not (isinstance(arg, self.TIME_SERIES_REFERENCE_TUPLE)):
519+
arg = self.TIME_SERIES_REFERENCE_TUPLE(*arg)
500520
arg.check_types()
501521
super().append(arg)
502522

@@ -535,7 +555,7 @@ def get(self, key, **kwargs):
535555
return self.TIME_SERIES_REFERENCE_TUPLE(*vals)
536556
else: # key selected multiple rows
537557
# When loading from HDF5 we get an np.ndarray otherwise we get list-of-list. This
538-
# makes the values consistent and tranforms the data to use our namedtuple type
558+
# makes the values consistent and transforms the data to use our namedtuple type
539559
re = [self.TIME_SERIES_REFERENCE_NONE_TYPE
540560
if (v[0] < 0 or v[1] < 0) else self.TIME_SERIES_REFERENCE_TUPLE(*v)
541561
for v in vals]

src/pynwb/epoch.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from hdmf.data_utils import DataIO
55

66
from . import register_class, CORE_NAMESPACE
7-
from .base import TimeSeries
7+
from .base import TimeSeries, TimeSeriesReferenceVectorData, TimeSeriesReference
88
from hdmf.common import DynamicTable
99

1010

@@ -20,7 +20,8 @@ class TimeIntervals(DynamicTable):
2020
{'name': 'start_time', 'description': 'Start time of epoch, in seconds', 'required': True},
2121
{'name': 'stop_time', 'description': 'Stop time of epoch, in seconds', 'required': True},
2222
{'name': 'tags', 'description': 'user-defined tags', 'index': True},
23-
{'name': 'timeseries', 'description': 'index into a TimeSeries object', 'index': True}
23+
{'name': 'timeseries', 'description': 'index into a TimeSeries object',
24+
'index': True, 'class': TimeSeriesReferenceVectorData}
2425
)
2526

2627
@docval({'name': 'name', 'type': str, 'doc': 'name of this TimeIntervals'}, # required
@@ -51,7 +52,7 @@ def add_interval(self, **kwargs):
5152
tmp = list()
5253
for ts in timeseries:
5354
idx_start, count = self.__calculate_idx_count(start_time, stop_time, ts)
54-
tmp.append((idx_start, count, ts))
55+
tmp.append(TimeSeriesReference(idx_start, count, ts))
5556
timeseries = tmp
5657
rkwargs['timeseries'] = timeseries
5758
return super(TimeIntervals, self).add_row(**rkwargs)

0 commit comments

Comments
 (0)