diff --git a/doc/documentation.rst b/doc/documentation.rst index 4fc83b269e3..8011d771194 100644 --- a/doc/documentation.rst +++ b/doc/documentation.rst @@ -115,6 +115,7 @@ There are also **examples**, which contain a short use-case to highlight MNE-fun auto_tutorials/plot_object_evoked.rst auto_tutorials/plot_object_source_estimate.rst auto_tutorials/plot_info.rst + auto_tutorials/plot_object_annotations.rst .. raw:: html diff --git a/doc/glossary.rst b/doc/glossary.rst index 4b1f1c013a9..9a8335b073b 100644 --- a/doc/glossary.rst +++ b/doc/glossary.rst @@ -21,13 +21,14 @@ MNE-Python core terminology and general concepts annotations - One annotation is defined by an onset, a duration and a string + An annotation is defined by an onset, a duration, and a string description. It can contain information about the experiments, but also details on signals marked by a human: bad data segments, sleep scores, sleep events (spindles, K-complex) etc. An :class:`Annotations` object is a container of multiple annotations. See :class:`Annotations` page for the API of the corresponding - object class. + object class and :ref:`sphx_glr_auto_tutorials_plot_object_annotations.py` + for a tutorial on how to manipulate such objects. channels Channels refer to MEG sensors, EEG electrodes or any extra electrode diff --git a/doc/whats_new.rst b/doc/whats_new.rst index ad39f268976..690c813f3fe 100644 --- a/doc/whats_new.rst +++ b/doc/whats_new.rst @@ -19,6 +19,8 @@ Current Changelog ~~~~~~~~~ +- New tutorial in the documentation regarding :class:`mne.Annotations` by `Joan Massich`_ and `Alex Gramfort`_ + - Add :meth:`mne.Epochs.shift_time` that shifts the time axis of :class:`mne.Epochs` by `Thomas Hartmann`_ - Add :func:`mne.viz.plot_arrowmap` computes arrowmaps using Hosaka-Cohen transformation from magnetometer or gradiometer data, these arrows represents an estimation of the current flow underneath the MEG sensors by `Sheraz Khan`_ diff --git a/mne/annotations.py b/mne/annotations.py index ddd28026b57..3b4e393e0b5 100644 --- a/mne/annotations.py +++ b/mne/annotations.py @@ -50,14 +50,16 @@ class Annotations(object): Array of strings containing description for each annotation. If a string, all the annotations are given the same description. To reject epochs, use description starting with keyword 'bad'. See example above. - orig_time : float | int | instance of datetime | array of int | None + orig_time : float | int | instance of datetime | array of int | None | str A POSIX Timestamp, datetime or an array containing the timestamp as the first element and microseconds as the second element. Determines the starting time of annotation acquisition. If None (default), starting time is determined from beginning of raw data acquisition. In general, ``raw.info['meas_date']`` (or None) can be used for syncing the annotations with raw data if their acquisiton is started at the - same time. + same time. If it is a string, it should conform to the ISO8601 format. + More precisely to this '%Y-%m-%d %H:%M:%S.%f' particular case of the + ISO8601 format where the delimiter between date and time is ' '. Notes ----- @@ -143,12 +145,7 @@ class Annotations(object): def __init__(self, onset, duration, description, orig_time=None): # noqa: D102 if orig_time is not None: - if isinstance(orig_time, datetime): - orig_time = float(time.mktime(orig_time.timetuple())) - elif not np.isscalar(orig_time): - orig_time = orig_time[0] + orig_time[1] / 1000000. - else: # isscalar - orig_time = float(orig_time) # np.int not serializable + orig_time = _handle_meas_date(orig_time) self.orig_time = orig_time onset = np.array(onset, dtype=float) @@ -180,8 +177,12 @@ def __repr__(self): for kind in kinds] kinds = ', '.join(kinds[:3]) + ('' if len(kinds) <= 3 else '...') kinds = (': ' if len(kinds) > 0 else '') + kinds - return ('' - % (len(self.onset), _pl(len(self.onset)), kinds)) + if self.orig_time is None: + orig = 'orig_time : None' + else: + orig = 'orig_time : %s' % datetime.utcfromtimestamp(self.orig_time) + return ('' + % (len(self.onset), _pl(len(self.onset)), kinds, orig)) def __len__(self): """Return the number of annotations.""" @@ -358,15 +359,35 @@ def _combine_annotations(one, two, one_n_samples, one_first_samp, def _handle_meas_date(meas_date): - """Convert meas_date to seconds.""" + """Convert meas_date to seconds. + + If `meas_date` is a string, it should conform to the ISO8601 format. + More precisely to this '%Y-%m-%d %H:%M:%S.%f' particular case of the + ISO8601 format where the delimiter between date and time is ' '. + + Otherwise, this function returns 0. Note that ISO8601 allows for ' ' or 'T' + as delimiters between date and time. + """ if meas_date is None: meas_date = 0 + elif isinstance(meas_date, string_types): + ACCEPTED_ISO8601 = '%Y-%m-%d %H:%M:%S.%f' + try: + meas_date = datetime.strptime(meas_date, ACCEPTED_ISO8601) + except ValueError: + meas_date = 0 + else: + unix_ref_time = datetime.utcfromtimestamp(0) + meas_date = (meas_date - unix_ref_time).total_seconds() + meas_date = round(meas_date, 6) # round that 6th decimal + elif isinstance(meas_date, datetime): + meas_date = float(time.mktime(meas_date.timetuple())) elif not np.isscalar(meas_date): if len(meas_date) > 1: meas_date = meas_date[0] + meas_date[1] / 1000000. else: meas_date = meas_date[0] - return meas_date + return float(meas_date) def _sync_onset(raw, onset, inverse=False): diff --git a/mne/tests/test_annotations.py b/mne/tests/test_annotations.py index 3e45d4a0868..1a639b74a1f 100644 --- a/mne/tests/test_annotations.py +++ b/mne/tests/test_annotations.py @@ -635,4 +635,19 @@ def _constant_id(*args, **kwargs): assert event_id == expected_event_id +@pytest.mark.parametrize('meas_date, out', [ + pytest.param('toto', 0, id='invalid string'), + pytest.param(None, 0, id='None'), + pytest.param(42, 42.0, id='Scalar'), + pytest.param(3.14, 3.14, id='Float'), + pytest.param((3, 140000), 3.14, id='Scalar touple'), + pytest.param('2002-12-03 19:01:11.720100', 1038942071.7201, + id='valid iso8601 string'), + pytest.param('2002-12-03T19:01:11.720100', 0, + id='invalid iso8601 string')]) +def test_handle_meas_date(meas_date, out): + """Test meas date formats.""" + assert _handle_meas_date(meas_date) == out + + run_tests_if_main() diff --git a/tutorials/plot_object_annotations.py b/tutorials/plot_object_annotations.py new file mode 100644 index 00000000000..02801cf4e00 --- /dev/null +++ b/tutorials/plot_object_annotations.py @@ -0,0 +1,170 @@ +""" +The **events** and :class:`Annotations ` data structures +========================================================================= + +Events and :class:`Annotations ` are quite similar. +This tutorial highlights their differences and similarities, and tries to shed +some light on which one is preferred to use in different situations when using +MNE. + +Here are the definitions from the :ref:`glossary`. + + events + Events correspond to specific time points in raw data; e.g., triggers, + experimental condition events, etc. MNE represents events with integers + that are stored in numpy arrays of shape (n_events, 3). Such arrays are + classically obtained from a trigger channel, also referred to as stim + channel. + + annotations + An annotation is defined by an onset, a duration, and a string + description. It can contain information about the experiments, but + also details on signals marked by a human: bad data segments, + sleep scores, sleep events (spindles, K-complex) etc. + +Both events and :class:`Annotations ` can be seen as triplets +where the first element answers to **when** something happens and the last +element refers to **what** it is. +The main difference is that events represent the onset in samples taking into +account the first sample value +(:attr:`raw.first_samp `), and the description is +an integer value. +In contrast, :class:`Annotations ` represents the +``onset`` in seconds (relative to the reference ``orig_time``), +and the ``description`` is an arbitrary string. +There is no correspondence between the second element of events and +:class:`Annotations `. +For events, the second element corresponds to the previous value on the +stimulus channel from which events are extracted. In practice, the second +element is therefore in most cases zero. +The second element of :class:`Annotations ` is a float +indicating its duration in seconds. + +See :ref:`sphx_glr_auto_examples_io_plot_read_events.py` +for a complete example of how to read, select, and visualize **events**; +and :ref:`sphx_glr_auto_tutorials_plot_artifacts_correction_rejection.py` to +learn how :class:`Annotations ` are used to mark bad segments +of data. + +An example of events and annotations +------------------------------------ + +The following example shows the recorded events in `sample_audvis_raw.fif` and +marks bad segments due to eye blinks. +""" + +import os.path as op +import numpy as np + +import mne + +# Load the data +data_path = mne.datasets.sample.data_path() +fname = op.join(data_path, 'MEG', 'sample', 'sample_audvis_raw.fif') +raw = mne.io.read_raw_fif(fname) + +# Plot the events +events = mne.find_events(raw) + +# Specify event_id dictionary based on the experiment +event_id = {'Auditory/Left': 1, 'Auditory/Right': 2, + 'Visual/Left': 3, 'Visual/Right': 4, + 'smiley': 5, 'button': 32} +color = {1: 'green', 2: 'yellow', 3: 'red', 4: 'c', 5: 'black', 32: 'blue'} + +mne.viz.plot_events(events, raw.info['sfreq'], raw.first_samp, color=color, + event_id=event_id) + +# Create some annotations specifying onset, duration and description +annotated_blink_raw = raw.copy() +eog_events = mne.preprocessing.find_eog_events(raw) +n_blinks = len(eog_events) +# Center to cover the whole blink with full duration of 0.5s: +onset = eog_events[:, 0] / raw.info['sfreq'] - 0.25 +duration = np.repeat(0.5, n_blinks) +description = ['bad blink'] * n_blinks +annot = mne.Annotations(onset, duration, description, + orig_time=raw.info['meas_date']) +annotated_blink_raw.set_annotations(annot) + +annotated_blink_raw.plot() # plot the annotated raw + + +############################################################################### +# Working with Annotations +# ------------------------ +# +# An important element of :class:`Annotations ` is +# ``orig_time`` which is the time reference for the ``onset``. +# It is key to understand that when calling +# :func:`raw.set_annotations `, the given +# annotations are copied and transformed so that +# :class:`raw.annotations.orig_time ` +# matches the recording time of the raw object. +# Refer to the documentation of :class:`Annotations ` to see +# the expected behavior depending on ``meas_date`` and ``orig_time``. +# Where ``meas_date`` is the recording time stored in +# :class:`Info `. +# You can find more information about :class:`Info ` in +# :ref:`sphx_glr_auto_tutorials_plot_info.py`. +# +# We'll now manipulate some simulated annotations. +# The first annotations has ``orig_time`` set to ``None`` while the +# second is set to a chosen POSIX timestamp for illustration purposes. + +############################################################################### + +# Create an annotation object without orig_time +annot_none = mne.Annotations(onset=[0, 2, 9], duration=[0.5, 4, 0], + description=['foo', 'bar', 'foo'], + orig_time=None) +print(annot_none) + +# Create an annotation object with orig_time +orig_time = '2002-12-03 19:01:31.676071' +annot_orig = mne.Annotations(onset=[22, 24, 31], duration=[0.5, 4, 0], + description=['foo', 'bar', 'foo'], + orig_time=orig_time) +print(annot_orig) + +############################################################################### +# Now we create two raw objects, set the annotations and plot them to compare +# them. + +# Create two cropped copies of raw with the two previous annotations +raw_a = raw.copy().crop(tmax=12).set_annotations(annot_none) +raw_b = raw.copy().crop(tmax=12).set_annotations(annot_orig) + +# Plot the raw objects +raw_a.plot() +raw_b.plot() + +# Show the annotations in the raw objects +print(raw_a.annotations) +print(raw_b.annotations) + +# Show that the onsets are the same +np.set_printoptions(precision=6) +print(raw_a.annotations.onset) +print(raw_b.annotations.onset) + +############################################################################### +# +# Notice that for the case where ``orig_time`` is ``None``, +# one assumes that the orig_time is the time of the first sample of data. + +raw_delta = (1 / raw.info['sfreq']) +print('raw.first_sample is {}'.format(raw.first_samp * raw_delta)) +print('annot_none.onset[0] is {}'.format(annot_none.onset[0])) +print('raw_a.annotations.onset[0] is {}'.format(raw_a.annotations.onset[0])) + +############################################################################### +# +# It is possible to concatenate two annotations with the + operator like for +# lists if both share the same ``orig_time`` + +annot = mne.Annotations(onset=[10], duration=[0.5], + description=['foobar'], + orig_time=orig_time) +annot = annot_orig + annot # concatenation +print(annot)