Repository: mne-tools/mne-bids

Issue:
write_raw_bids_: annotations are extracted despite event_data passed
Not sure whether this is one bug, two bugs, or just an unclear bit in the documentation...

#### Describe the bug

In the documentation of `write_raw_bids` it says as description of the `event_data` argument that:
>If None, events will be inferred from the the raw object’s mne.Annotations using mne.events_from_annotations.

Maybe I am misunderstanding, but I read this as mne.Annotations are *only* extracted if None is passed to event_data. However, despite me setting event_data with an MNE events array, `write_raw_bids` is still trying to extract events from annotations. Because of that, `write_raw_bids` fails with this error: 

```
Traceback (most recent call last):
  File "makeBids.py", line 76, in <module>
    mb.write_raw_bids(raw, bids_basename,event_id = event_id[task], events_data = events,overwrite=True)
  File "/home/ede/tools/miniconda3/envs/coconut/lib/python3.6/site-packages/mne_bids/write.py", line 1108, in write_raw_bids
    verbose=False)
  File "/home/ede/tools/miniconda3/envs/coconut/lib/python3.6/site-packages/mne_bids/utils.py", line 234, in _read_events
    verbose=verbose)
  File "<decorator-gen-44>", line 20, in events_from_annotations
  File "/home/ede/tools/miniconda3/envs/coconut/lib/python3.6/site-packages/mne/annotations.py", line 1008, in events_from_annotations
    annotations.description, event_id=event_id, regexp=regexp)
  File "/home/ede/tools/miniconda3/envs/coconut/lib/python3.6/site-packages/mne/annotations.py", line 865, in _select_annotations_based_on_description
    raise ValueError('Could not find any of the events you specified.')
ValueError: Could not find any of the events you specified.
```

The annotations I have in my raw file are `BAD_` and `EDGE_` markers of where I concatenated two raw files (and therefore nothing I would want to be extracted as files). According to this [closed issue](https://github.com/mne-tools/mne-python/pull/6333) in MNE-python, these event should be ignored anyway. To avoid this error, I had to add the `regexp=None` argument to the call of [mne.events_from_annotations](https://github.com/mne-tools/mne-bids/blob/2a035c77a8a2d2708efa17144bd341acb96af121/mne_bids/utils.py#L233) in `utils._read_events()`. Even though mne still tried to extract annotations, `BAD_` and `EDGE_` are being ignored.


#### Steps to reproduce
a call like this: 
`write_raw_bids(raw, bids_basename,event_id = event_id, events_data = events)`
on a raw file that has annotations in it (which are not listed in the `event_id` dict.)

#### Expected results
write_raw_bids should extract events and make an event.tsv


#### Actual results
crash with the error message from above. 



Discussion:
Thank you for reporting this issue!

The problem is that we currently pass `event_id` to `events_from_annotations()`, which we really shouldn't:
https://github.com/mne-tools/mne-bids/blob/2a035c77a8a2d2708efa17144bd341acb96af121/mne_bids/utils.py#L233-L234

But if we don't we'd have to ensure we don't get overlapping event IDs in case users pass `event_data` and `event_id` to `write_raw_bids` too, while Annotations are present.

@agramfort Would the clean way to go here to move from our current approach – read events from an events file or an event dict and merge those with the events produced by `events_from_annotations` – to instead do the following:

- use events from events file or event dict
- convert these events to Annotations
- THEN extract **all** events via `events_from_annotations`?

We'd have to be super careful about `raw.first_time` when creating the Annotations, I'm afraid?
yes I agree we need a different strategy.

what you suggest would work yes.

@eort to fix your pb for now you can do

raw.set_annotations(None)

before calling write_raw_bids

>

@agramfort 
> what you suggest would work yes.


Ok I will file a PR sometime today. I'll need help dealing with the `orig_time` param of `Annotations` / its interaction (?) with `Raw.first_time`, I never really understood this part I'm afraid. – We can discuss during the PR review ;)

Resolving patch:
diff --git a/mne_bids/read.py b/mne_bids/read.py
--- a/mne_bids/read.py
+++ b/mne_bids/read.py
@@ -12,7 +12,7 @@
 
 import numpy as np
 import mne
-from mne import io
+from mne import io, read_events, events_from_annotations
 from mne.utils import has_nibabel, logger, warn
 from mne.coreg import fit_matched_points
 from mne.transforms import apply_trans
@@ -63,6 +63,97 @@ def _read_raw(raw_fpath, electrode=None, hsp=None, hpi=None,
     return raw
 
 
+def _read_events(events_data, event_id, raw, verbose=None):
+    """Retrieve events (for use in *_events.tsv) from FIFF/array & Annotations.
+
+    Parameters
+    ----------
+    events_data : str | array | None
+        If a string, a path to an events file. If an array, an MNE events array
+        (shape n_events, 3). If None, events will be generated from
+        ``raw.annotations``.
+    event_id : dict | None
+        The event id dict used to create a 'trial_type' column in events.tsv,
+        mapping a description key to an integer valued event code.
+    raw : instance of Raw
+        The data as MNE-Python Raw object.
+    verbose : bool | str | int | None
+        If not None, override default verbose level (see :func:`mne.verbose`).
+
+    Returns
+    -------
+    all_events : array, shape = (n_events, 3)
+        The first column contains the event time in samples and the third
+        column contains the event id. The second column is ignored for now but
+        typically contains the value of the trigger channel either immediately
+        before the event or immediately after.
+    all_dur : array, shape (n_events,)
+        The event durations in seconds.
+    all_desc : dict
+        A dictionary with the keys corresponding to the event descriptions and
+        the values to the event IDs.
+
+    """
+    # get events from events_data
+    if isinstance(events_data, str):
+        events = read_events(events_data, verbose=verbose).astype(int)
+    elif isinstance(events_data, np.ndarray):
+        if events_data.ndim != 2:
+            raise ValueError('Events must have two dimensions, '
+                             f'found {events_data.ndim}')
+        if events_data.shape[1] != 3:
+            raise ValueError('Events must have second dimension of length 3, '
+                             f'found {events_data.shape[1]}')
+        events = events_data
+    else:
+        events = np.empty(shape=(0, 3), dtype=int)
+
+    if events.size > 0:
+        # Only keep events for which we have an ID <> description mapping.
+        ids_without_desc = set(events[:, 2]) - set(event_id.values())
+        if ids_without_desc:
+            raise ValueError(
+                f'No description was specified for the following event(s): '
+                f'{", ".join([str(x) for x in sorted(ids_without_desc)])}. '
+                f'Please add them to the event_id dictionary, or drop them '
+                f'from the events_data array.'
+            )
+        del ids_without_desc
+        mask = [e in list(event_id.values()) for e in events[:, 2]]
+        events = events[mask]
+
+        # Append events to raw.annotations. All event onsets are relative to
+        # measurement beginning.
+        id_to_desc_map = dict(zip(event_id.values(), event_id.keys()))
+        # We don't pass `first_samp`, as set_annotations() below will take
+        # care of this shift automatically.
+        new_annotations = mne.annotations_from_events(
+            events=events, sfreq=raw.info['sfreq'], event_desc=id_to_desc_map,
+            orig_time=raw.annotations.orig_time, verbose=verbose)
+
+        raw = raw.copy()  # Don't alter the original.
+        annotations = raw.annotations.copy()
+
+        # We use `+=` here because `Annotations.__iadd__()` does the right
+        # thing and also performs a sanity check on `Annotations.orig_time`.
+        annotations += new_annotations
+        raw.set_annotations(annotations)
+        del id_to_desc_map, annotations, new_annotations
+
+    # Now convert the Annotations to events.
+    all_events, all_desc = events_from_annotations(
+        raw,
+        regexp=None,  # Include `BAD_` and `EDGE_` Annotations, too.
+        verbose=verbose
+    )
+    all_dur = raw.annotations.duration
+    if all_events.size == 0:
+        warn('No events found or provided. Please add annotations to the raw '
+             'data, or provide the events_data and event_id parameters.')
+
+    return all_events, all_dur, all_desc
+
+
 def _handle_participants_reading(participants_fname, raw,
                                  subject, verbose=None):
     participants_tsv = _from_tsv(participants_fname)
diff --git a/mne_bids/utils.py b/mne_bids/utils.py
--- a/mne_bids/utils.py
+++ b/mne_bids/utils.py
@@ -14,7 +14,6 @@
 from os import path as op
 
 import numpy as np
-from mne import read_events, events_from_annotations
 from mne.channels import make_standard_montage
 from mne.io.constants import FIFF
 from mne.io.kit.kit import get_kit_info
@@ -189,68 +188,6 @@ def _check_key_val(key, val):
     return key, val
 
 
-def _read_events(events_data, event_id, raw, verbose=None):
-    """Read in events data.
-
-    Parameters
-    ----------
-    events_data : str | array | None
-        The events file. If a string, a path to the events file. If an array,
-        the MNE events array (shape n_events, 3). If None, events will be
-        inferred from the stim channel using `find_events`.
-    event_id : dict
-        The event id dict used to create a 'trial_type' column in events.tsv,
-        mapping a description key to an integer valued event code.
-    raw : instance of Raw
-        The data as MNE-Python Raw object.
-    verbose : bool | str | int | None
-        If not None, override default verbose level (see :func:`mne.verbose`).
-
-    Returns
-    -------
-    events : array, shape = (n_events, 3)
-        The first column contains the event time in samples and the third
-        column contains the event id. The second column is ignored for now but
-        typically contains the value of the trigger channel either immediately
-        before the event or immediately after.
-
-    """
-    # get events from events_data
-    if isinstance(events_data, str):
-        events = read_events(events_data, verbose=verbose).astype(int)
-    elif isinstance(events_data, np.ndarray):
-        if events_data.ndim != 2:
-            raise ValueError('Events must have two dimensions, '
-                             f'found {events_data.ndim}')
-        if events_data.shape[1] != 3:
-            raise ValueError('Events must have second dimension of length 3, '
-                             f'found {events_data.shape[1]}')
-        events = events_data
-    else:
-        events = np.empty(shape=(0, 3), dtype=int)
-
-    # get events from annotations
-    events_annot, event_id_annot = events_from_annotations(raw, event_id,
-                                                           verbose=verbose)
-    if event_id is None:
-        events = events_annot
-        event_id = event_id_annot
-    else:
-        events = np.concatenate((events, events_annot), axis=0)
-        # Sort rows by onset sample.
-        # See https://stackoverflow.com/a/2828121/1944216
-        events = events[events[:, 0].argsort()]
-        event_id.update(event_id_annot)
-
-    if events.size == 0:
-        warn('No events found or provided. Please make sure to'
-             ' set channel type using raw.set_channel_types'
-             ' or provide events_data.')
-        events = None
-
-    return events, event_id
-
-
 def _get_mrk_meas_date(mrk):
     """Find the measurement date from a KIT marker file."""
     info = get_kit_info(mrk, False)[0]
diff --git a/mne_bids/write.py b/mne_bids/write.py
--- a/mne_bids/write.py
+++ b/mne_bids/write.py
@@ -35,8 +35,7 @@
 from mne_bids.pick import coil_type
 from mne_bids.dig import _write_dig_bids, _coordsystem_json
 from mne_bids.utils import (_write_json, _write_tsv, _write_text,
-                            _read_events, _age_on_date,
-                            _infer_eeg_placement_scheme,
+                            _age_on_date, _infer_eeg_placement_scheme,
                             _handle_datatype, _get_ch_type_mapping,
                             _check_anonymize, _stamp_to_dt)
 from mne_bids import read_raw_bids
@@ -45,7 +44,8 @@
                                 copyfile_ctf, copyfile_bti, copyfile_kit)
 from mne_bids.tsv_handler import (_from_tsv, _drop, _contains_row,
                                   _combine_rows)
-from mne_bids.read import _get_bads_from_tsv_data, _find_matching_sidecar
+from mne_bids.read import (_get_bads_from_tsv_data, _find_matching_sidecar,
+                           _read_events)
 
 from mne_bids.config import (ORIENTATION, UNITS, MANUFACTURERS,
                              IGNORED_CHANNELS, ALLOWED_DATATYPE_EXTENSIONS,
@@ -138,7 +138,7 @@ def _channels_tsv(raw, fname, overwrite=False, verbose=True):
     _write_tsv(fname, ch_data, overwrite, verbose)
 
 
-def _events_tsv(events, raw, fname, trial_type, overwrite=False,
+def _events_tsv(events, durations, raw, fname, trial_type, overwrite=False,
                 verbose=True):
     """Create an events.tsv file and save it.
 
@@ -154,6 +154,8 @@ def _events_tsv(events, raw, fname, trial_type, overwrite=False,
         column contains the event id. The second column is ignored for now but
         typically contains the value of the trigger channel either immediately
         before the event or immediately after.
+    durations : array, shape (n_events,)
+        The event durations in seconds.
     raw : instance of Raw
         The data as MNE-Python Raw object.
     fname : str | BIDSPath
@@ -180,7 +182,7 @@ def _events_tsv(events, raw, fname, trial_type, overwrite=False,
 
     # Onset column needs to be specified in seconds
     data = OrderedDict([('onset', events[:, 0] / sfreq),
-                        ('duration', np.zeros(events.shape[0])),
+                        ('duration', durations),
                         ('trial_type', None),
                         ('value', events[:, 2]),
                         ('sample', events[:, 0])])
@@ -858,13 +860,36 @@ def write_raw_bids(raw, bids_path, events_data=None,
         object, as well as the extension. Data with MEG and other
         electrophysiology data in the same file will be stored as ``'meg'``.
     events_data : path-like | array | None
+        Use this parameter to specify events to write to the ``*_events.tsv``
+        sidecar file, additionally to the object's `mne.Annotations` (which
+        are always written).
         If a path, specifies the location of an MNE events file.
         If an array, the MNE events array (shape: ``(n_events, 3)``).
-        If ``None``, events will be inferred from the the raw object's
-        `mne.Annotations` using `mne.events_from_annotations`.
+        If a path or an array and ``raw.annotations`` exist, the union of
+        ``event_data`` and ``raw.annotations`` will be written.
+        Corresponding descriptions for all event IDs (listed in the third
+        column of the MNE events array) must be specified via the ``event_id``
+        parameter; otherwise, an exception is raised.
+        If ``None``, events will only be inferred from the the raw object's
+        `mne.Annotations`.
+
+        .. note::
+           If ``not None``, writes the union of ``events_data`` and
+           ``raw.annotations``. If you wish to **only** write
+           ``raw.annotations``, pass ``events_data=None``. If you want to
+           **exclude** the events in ``raw.annotations`` from being written,
+           call ``raw.set_annotations(None)`` before invoking this function.
+
+        .. note::
+           Descriptions of all event IDs must be specified via the ``event_id``
+           parameter.
+
     event_id : dict | None
-        The event ID dictionary used to create a `trial_type` column in
-        ``*_events.tsv``.
+        Descriptions of all event IDs, if you passed ``events_data``.
+        The descriptions will be written to the ``trial_type`` column in
+        ``*_events.tsv``. The dictionary keys correspond to the event
+        descriptions and the values to the event IDs. You must specify a
+        description for all event IDs in ``events_data``.
     anonymize : dict | None
         If `None` (default), no anonymization is performed.
         If a dictionary, data will be anonymized depending on the dictionary
@@ -1098,11 +1123,17 @@ def write_raw_bids(raw, bids_path, events_data=None,
         logger.warning(f'Writing of electrodes.tsv is not supported '
                        f'for data type "{bids_path.datatype}". Skipping ...')
 
-    events, event_id = _read_events(events_data, event_id, raw,
-                                    verbose=False)
-    if events is not None and len(events) > 0 and not emptyroom:
-        _events_tsv(events, raw, events_path.fpath, event_id,
-                    overwrite, verbose)
+    # Write events.
+    if not emptyroom:
+        events_array, event_dur, event_desc_id_map = _read_events(
+            events_data, event_id, raw, verbose=False
+        )
+        if events_array.size != 0:
+            _events_tsv(events=events_array, durations=event_dur, raw=raw,
+                        fname=events_path.fpath, trial_type=event_desc_id_map,
+                        overwrite=overwrite, verbose=verbose)
+        # Kepp events_array around for BrainVision writing below.
+        del event_desc_id_map, events_data, event_id, event_dur
 
     make_dataset_description(bids_path.root, name=" ", overwrite=overwrite,
                              verbose=verbose)
@@ -1142,7 +1173,8 @@ def write_raw_bids(raw, bids_path, events_data=None,
             if verbose:
                 warn('Converting data files to BrainVision format')
             bids_path.update(suffix=bids_path.datatype, extension='.vhdr')
-            _write_raw_brainvision(raw, bids_path.fpath, events)
+            # XXX Should we write durations here too?
+            _write_raw_brainvision(raw, bids_path.fpath, events=events_array)
     elif ext == '.fif':
         _write_raw_fif(raw, bids_path)
     # CTF data is saved and renamed in a directory
@@ -1514,12 +1546,13 @@ def mark_bad_channels(ch_names, descriptions=None, *, bids_path,
     # Update info['bads']
     bads = _get_bads_from_tsv_data(tsv_data)
     raw.info['bads'] = bads
-    # XXX (How) will this handle split files?
     if isinstance(raw, mne.io.brainvision.brainvision.RawBrainVision):
-        events, event_id = _read_events(events_data=None, event_id=None,
-                                        raw=raw, verbose=False)
+        # XXX We should write durations too, this is supported by pybv.
+        events, _, _ = _read_events(events_data=None, event_id=None,
+                                    raw=raw, verbose=False)
         _write_raw_brainvision(raw, bids_path.fpath, events)
     elif isinstance(raw, mne.io.RawFIF):
+        # XXX (How) will this handle split files?
         raw.save(raw.filenames[0], overwrite=True, verbose=False)
     else:
         raise RuntimeError('Can only mark bad channels for '


Test patch:
diff --git a/mne_bids/tests/test_path.py b/mne_bids/tests/test_path.py
--- a/mne_bids/tests/test_path.py
+++ b/mne_bids/tests/test_path.py
@@ -63,11 +63,16 @@ def return_bids_test_dir(tmpdir_factory):
 
     raw = mne.io.read_raw_fif(raw_fname)
     raw.info['line_freq'] = 60
+
+    # Drop unknown events.
+    events = mne.read_events(events_fname)
+    events = events[events[:, 2] != 0]
+
     bids_path.update(root=bids_root)
     # Write multiple runs for test_purposes
     for run_idx in [run, '02']:
         name = bids_path.copy().update(run=run_idx)
-        write_raw_bids(raw, name, events_data=events_fname,
+        write_raw_bids(raw, name, events_data=events,
                        event_id=event_id, overwrite=True)
 
     write_meg_calibration(cal_fname, bids_path=bids_path)
diff --git a/mne_bids/tests/test_read.py b/mne_bids/tests/test_read.py
--- a/mne_bids/tests/test_read.py
+++ b/mne_bids/tests/test_read.py
@@ -202,12 +202,16 @@ def test_get_head_mri_trans():
     events_fname = op.join(data_path, 'MEG', 'sample',
                            'sample_audvis_trunc_raw-eve.fif')
 
+    # Drop unknown events.
+    events = mne.read_events(events_fname)
+    events = events[events[:, 2] != 0]
+
     # Write it to BIDS
     raw = _read_raw_fif(raw_fname)
     bids_root = _TempDir()
     bids_path = _bids_path.copy().update(root=bids_root)
-    write_raw_bids(raw, bids_path, events_data=events_fname,
-                   event_id=event_id, overwrite=False)
+    write_raw_bids(raw, bids_path, events_data=events, event_id=event_id,
+                   overwrite=False)
 
     # We cannot recover trans, if no MRI has yet been written
     with pytest.raises(RuntimeError):
diff --git a/mne_bids/tests/test_write.py b/mne_bids/tests/test_write.py
--- a/mne_bids/tests/test_write.py
+++ b/mne_bids/tests/test_write.py
@@ -69,7 +69,6 @@
     meas_date_set_to_none="ignore:.*'meas_date' set to None:RuntimeWarning:"
                           "mne",
     nasion_not_found='ignore:.*nasion not found:RuntimeWarning:mne',
-    annotations_omitted='ignore:Omitted .* annot.*:RuntimeWarning:mne',
 )
 
 
@@ -273,7 +272,6 @@ def test_create_fif(_bids_validate):
 
 
 @requires_version('pybv', '0.2.0')
-@pytest.mark.filterwarnings(warning_str['annotations_omitted'])
 @pytest.mark.filterwarnings(warning_str['channel_unit_changed'])
 def test_fif(_bids_validate):
     """Test functionality of the write_raw_bids conversion for fif."""
@@ -288,6 +286,10 @@ def test_fif(_bids_validate):
     events_fname = op.join(data_path, 'MEG', 'sample',
                            'sample_audvis_trunc_raw-eve.fif')
 
+    # Drop unknown events.
+    events = mne.read_events(events_fname)
+    events = events[events[:, 2] != 0]
+
     raw = _read_raw_fif(raw_fname)
     # add data in as a montage for MEG
     ch_names = raw.ch_names
@@ -297,8 +299,8 @@ def test_fif(_bids_validate):
                                                 coord_frame='head')
     raw.set_montage(meg_montage)
 
-    write_raw_bids(raw, bids_path, events_data=events_fname,
-                   event_id=event_id, overwrite=False)
+    write_raw_bids(raw, bids_path, events_data=events, event_id=event_id,
+                   overwrite=False)
 
     # Read the file back in to check that the data has come through cleanly.
     # Events and bad channel information was read through JSON sidecar files.
@@ -340,7 +342,7 @@ def test_fif(_bids_validate):
     with pytest.warns(RuntimeWarning,
                       match='Converting data files to BrainVision format'):
         write_raw_bids(raw2, bids_path,
-                       events_data=events_fname, event_id=event_id,
+                       events_data=events, event_id=event_id,
                        verbose=True, overwrite=False)
     bids_dir = op.join(bids_root, 'sub-%s' % subject_id,
                        'ses-%s' % session_id, 'eeg')
@@ -394,7 +396,7 @@ def test_fif(_bids_validate):
     raw = _read_raw_fif(raw_fname)
     raw.info['subject_info'] = {'his_id': subject_id2,
                                 'birthday': (1993, 1, 26), 'sex': 1, 'hand': 2}
-    write_raw_bids(raw, bids_path, events_data=events_fname,
+    write_raw_bids(raw, bids_path, events_data=events,
                    event_id=event_id, overwrite=True)
     # assert age of participant is correct
     participants_tsv = op.join(bids_root, 'participants.tsv')
@@ -448,7 +450,7 @@ def test_fif(_bids_validate):
     # try and write preloaded data
     raw = _read_raw_fif(raw_fname, preload=True)
     with pytest.raises(ValueError, match='preloaded'):
-        write_raw_bids(raw, bids_path, events_data=events_fname,
+        write_raw_bids(raw, bids_path, events_data=events,
                        event_id=event_id, overwrite=False)
 
     # test anonymize
@@ -467,7 +469,7 @@ def test_fif(_bids_validate):
     bids_path2 = bids_path.copy().update(subject=subject_id2)
     raw = _read_raw_fif(raw_fname2)
     bids_output_path = write_raw_bids(raw, bids_path2,
-                                      events_data=events_fname,
+                                      events_data=events,
                                       event_id=event_id, overwrite=False)
 
     # check that the overwrite parameters work correctly for the participant
@@ -478,8 +480,7 @@ def test_fif(_bids_validate):
                                 'birthday': (1994, 1, 26), 'sex': 2, 'hand': 1}
     with pytest.raises(FileExistsError, match="already exists"):  # noqa: F821
         write_raw_bids(raw, bids_path2,
-                       events_data=events_fname, event_id=event_id,
-                       overwrite=False)
+                       events_data=events, event_id=event_id, overwrite=False)
 
     # assert README has references in it
     with open(readme, 'r') as fid:
@@ -491,8 +492,8 @@ def test_fif(_bids_validate):
         assert REFERENCES['ieeg'] not in text
 
     # now force the overwrite
-    write_raw_bids(raw, bids_path2, events_data=events_fname,
-                   event_id=event_id, overwrite=True)
+    write_raw_bids(raw, bids_path2, events_data=events, event_id=event_id,
+                   overwrite=True)
 
     with open(readme, 'r') as fid:
         text = fid.read()
@@ -555,37 +556,34 @@ def test_fif_anonymize(_bids_validate):
     events_fname = op.join(data_path, 'MEG', 'sample',
                            'sample_audvis_trunc_raw-eve.fif')
 
+    # Drop unknown events.
+    events = mne.read_events(events_fname)
+    events = events[events[:, 2] != 0]
+
     # test keyword mne-bids anonymize
     raw = _read_raw_fif(raw_fname)
     with pytest.raises(ValueError, match='`daysback` argument required'):
-        write_raw_bids(raw, bids_path, events_data=events_fname,
-                       event_id=event_id,
-                       anonymize=dict(),
-                       overwrite=True)
+        write_raw_bids(raw, bids_path, events_data=events, event_id=event_id,
+                       anonymize=dict(), overwrite=True)
 
     bids_root = _TempDir()
     bids_path.update(root=bids_root)
     raw = _read_raw_fif(raw_fname)
     with pytest.warns(RuntimeWarning, match='daysback` is too small'):
-        write_raw_bids(raw, bids_path, events_data=events_fname,
-                       event_id=event_id,
-                       anonymize=dict(daysback=400),
-                       overwrite=False)
+        write_raw_bids(raw, bids_path, events_data=events, event_id=event_id,
+                       anonymize=dict(daysback=400), overwrite=False)
 
     bids_root = _TempDir()
     bids_path.update(root=bids_root)
     raw = _read_raw_fif(raw_fname)
     with pytest.raises(ValueError, match='`daysback` exceeds maximum value'):
-        write_raw_bids(raw, bids_path, events_data=events_fname,
-                       event_id=event_id,
-                       anonymize=dict(daysback=40000),
-                       overwrite=False)
+        write_raw_bids(raw, bids_path, events_data=events, event_id=event_id,
+                       anonymize=dict(daysback=40000), overwrite=False)
 
     bids_root = _TempDir()
     bids_path.update(root=bids_root)
     raw = _read_raw_fif(raw_fname)
-    write_raw_bids(raw, bids_path, events_data=events_fname,
-                   event_id=event_id,
+    write_raw_bids(raw, bids_path, events_data=events, event_id=event_id,
                    anonymize=dict(daysback=30000, keep_his=True),
                    overwrite=False)
     scans_tsv = BIDSPath(
@@ -613,7 +611,7 @@ def test_kit(_bids_validate):
     hpi_post_fname = op.join(data_path, 'test_mrk_post.sqd')
     electrode_fname = op.join(data_path, 'test.elp')
     headshape_fname = op.join(data_path, 'test.hsp')
-    event_id = dict(cond=1)
+    event_id = dict(cond=128)
 
     kit_bids_path = _bids_path.copy().update(acquisition=None,
                                              root=bids_root,
@@ -1199,10 +1197,14 @@ def test_write_anat(_bids_validate):
     events_fname = op.join(data_path, 'MEG', 'sample',
                            'sample_audvis_trunc_raw-eve.fif')
 
+    # Drop unknown events.
+    events = mne.read_events(events_fname)
+    events = events[events[:, 2] != 0]
+
     raw = _read_raw_fif(raw_fname)
     bids_path = _bids_path.copy().update(root=bids_root)
-    write_raw_bids(raw, bids_path, events_data=events_fname,
-                   event_id=event_id, overwrite=False)
+    write_raw_bids(raw, bids_path, events_data=events, event_id=event_id,
+                   overwrite=False)
 
     # Write some MRI data and supply a `trans`
     trans_fname = raw_fname.replace('_raw.fif', '-trans.fif')
@@ -1523,6 +1525,10 @@ def test_write_does_not_alter_events_inplace():
 
     raw = _read_raw_fif(raw_fname)
     events = mne.read_events(events_fname)
+    # Drop unknown events.
+    events = mne.read_events(events_fname)
+    events = events[events[:, 2] != 0]
+
     events_orig = events.copy()
     event_id = {'Auditory/Left': 1, 'Auditory/Right': 2, 'Visual/Left': 3,
                 'Visual/Right': 4, 'Smiley': 5, 'Button': 32}
@@ -1590,9 +1596,14 @@ def test_mark_bad_channels(_bids_validate,
                 'Visual/Right': 4, 'Smiley': 5, 'Button': 32}
     events_fname = op.join(data_path, 'MEG', 'sample',
                            'sample_audvis_trunc_raw-eve.fif')
+
+    # Drop unknown events.
+    events = mne.read_events(events_fname)
+    events = events[events[:, 2] != 0]
+
     raw = _read_raw_fif(raw_fname, verbose=False)
     raw.info['bads'] = []
-    write_raw_bids(raw, bids_path=bids_path, events_data=events_fname,
+    write_raw_bids(raw, bids_path=bids_path, events_data=events,
                    event_id=event_id, verbose=False)
 
     channels_fname = _find_matching_sidecar(bids_path, suffix='channels',
@@ -1805,3 +1816,105 @@ def test_write_meg_crosstalk(_bids_validate):
                                         datatype='eeg')
     with pytest.raises(ValueError, match='Can only write .* for MEG'):
         write_meg_crosstalk(crosstalk_fname, bids_path)
+
+
+@pytest.mark.parametrize(
+    'bad_segments',
+    [False, 'add', 'only']
+)
+@pytest.mark.filterwarnings(warning_str['channel_unit_changed'])
+def test_annotations(_bids_validate, bad_segments):
+    """Test that Annotations are stored as events."""
+    bids_root = _TempDir()
+    bids_path = _bids_path.copy().update(root=bids_root, datatype='meg')
+    data_path = testing.data_path()
+    raw_fname = op.join(data_path, 'MEG', 'sample',
+                        'sample_audvis_trunc_raw.fif')
+    events_fname = op.join(data_path, 'MEG', 'sample',
+                           'sample_audvis_trunc_raw-eve.fif')
+
+    events = mne.read_events(events_fname)
+    event_id = {'Auditory/Left': 1, 'Auditory/Right': 2, 'Visual/Left': 3,
+                'Visual/Right': 4, 'Smiley': 5, 'Button': 32}
+    event_desc = dict(zip(event_id.values(), event_id.keys()))
+
+    raw = _read_raw_fif(raw_fname)
+    annotations = mne.annotations_from_events(
+        events=events, sfreq=raw.info['sfreq'], event_desc=event_desc,
+        orig_time=raw.info['meas_date']
+    )
+    if bad_segments:
+        bad_annots = mne.Annotations(
+            # Try to avoid rounding errors.
+            onset=(annotations.onset[0] + 1 / raw.info['sfreq'] * 600,
+                   annotations.onset[0] + 1 / raw.info['sfreq'] * 3000),
+            duration=(1 / raw.info['sfreq'] * 750,
+                      1 / raw.info['sfreq'] * 550),
+            description=('BAD_segment', 'BAD_segment'),
+            orig_time=annotations.orig_time)
+
+        if bad_segments == 'add':
+            annotations += bad_annots
+        elif bad_segments == 'only':
+            annotations = bad_annots
+        else:
+            raise ValueError('Unknown `bad_segments` test parameter passed.')
+        del bad_annots
+
+    raw.set_annotations(annotations)
+    write_raw_bids(raw, bids_path, events_data=None, event_id=None,
+                   overwrite=False)
+
+    annotations_read = read_raw_bids(bids_path=bids_path).annotations
+    assert_array_almost_equal(annotations.onset, annotations_read.onset)
+    assert_array_almost_equal(annotations.duration, annotations_read.duration)
+    assert_array_equal(annotations.description, annotations_read.description)
+    assert annotations.orig_time == annotations_read.orig_time
+    _bids_validate(bids_root)
+
+
+@pytest.mark.parametrize(
+    'drop_undescribed_events',
+    [True, False]
+)
+@pytest.mark.filterwarnings(warning_str['channel_unit_changed'])
+def test_undescribed_events(_bids_validate, drop_undescribed_events):
+    """Test we're behaving correctly if event descriptions are missing."""
+    bids_root = _TempDir()
+    bids_path = _bids_path.copy().update(root=bids_root, datatype='meg')
+    data_path = testing.data_path()
+    raw_fname = op.join(data_path, 'MEG', 'sample',
+                        'sample_audvis_trunc_raw.fif')
+    events_fname = op.join(data_path, 'MEG', 'sample',
+                           'sample_audvis_trunc_raw-eve.fif')
+
+    events = mne.read_events(events_fname)
+    if drop_undescribed_events:
+        mask = events[:, 2] != 0
+        assert sum(mask) > 0  # Make sure we're actually about to drop sth.!
+        events = events[mask]
+        del mask
+
+    event_id = {'Auditory/Left': 1, 'Auditory/Right': 2, 'Visual/Left': 3,
+                'Visual/Right': 4, 'Smiley': 5, 'Button': 32}
+
+    raw = _read_raw_fif(raw_fname)
+    raw.set_annotations(None)  # Make sure it's clean.
+    kwargs = dict(raw=raw, bids_path=bids_path, events_data=events,
+                  event_id=event_id, overwrite=False)
+
+    if not drop_undescribed_events:
+        with pytest.raises(ValueError, match='No description was specified'):
+            write_raw_bids(**kwargs)
+        return
+    else:
+        write_raw_bids(**kwargs)
+
+    raw_read = read_raw_bids(bids_path=bids_path)
+    events_read, event_id_read = mne.events_from_annotations(
+        raw=raw_read, event_id=event_id, regexp=None
+    )
+
+    assert_array_equal(events, events_read)
+    assert event_id == event_id_read
+    _bids_validate(bids_root)