Repository: mne-tools/mne-python

Issue:
Missing support for arbitrary BrainVision marker types
The BrainVision ``vmrk`` files allow for arbitrary marker types, but MNE currently only handles ``Response`` markers separately.

For example, we use optical markers to correct for display latencies, which leads to a marker files looking like the following excerpt:

```
Mk79=Stimulus,S224,125503,1,0
Mk80=Optic,O  1,125599,1,0
Mk81=Response,R  1,127066,1,0
```

The documentation allows for arbitrary types (see #5357). Currently only the type ``Response`` is handled  (``response_trig_shift`` parameter, https://github.com/mne-tools/mne-python/blob/master/mne/io/brainvision/brainvision.py#L290) and otherwise the type is ignored, which can lead to different markers getting the same event id.

One option would be to add a new ``trig_shift_by_type`` parameter through which users could supply a dictionary of the form ``{'Stimulus': 0, 'Optic': 1000, 'Response': 2000}`` (and deprecate ``response_trig_shift``).


Discussion:
no objection for the suggested change.

ok, I'll create a PR in the next few days...

Resolving patch:
diff --git a/mne/io/brainvision/brainvision.py b/mne/io/brainvision/brainvision.py
--- a/mne/io/brainvision/brainvision.py
+++ b/mne/io/brainvision/brainvision.py
@@ -14,6 +14,7 @@
 import os
 import os.path as op
 import re
+import warnings
 from datetime import datetime
 from math import modf
 
@@ -60,9 +61,11 @@ class RawBrainVision(BaseRaw):
         If False, data are not read until save.
     response_trig_shift : int | None
         An integer that will be added to all response triggers when reading
-        events (stimulus triggers will be unaffected). If None, response
-        triggers will be ignored. Default is 0 for backwards compatibility, but
-        typically another value or None will be necessary.
+        events (stimulus triggers will be unaffected). This parameter was
+        deprecated in version 0.17 and will be removed in 0.19. Use
+        ``trig_shift_by_type={'response': ...}`` instead. If None, response
+        triggers will be ignored. Default is 0 for backwards compatibility,
+        but typically another value or None will be necessary.
     event_id : dict | None
         The id of special events to consider in addition to those that
         follow the normal Brainvision trigger format ('S###').
@@ -73,6 +76,14 @@ class RawBrainVision(BaseRaw):
     verbose : bool, str, int, or None
         If not None, override default verbose level (see :func:`mne.verbose`
         and :ref:`Logging documentation <tut_logging>` for more).
+    trig_shift_by_type: dict | None
+        The names of marker types to which an offset should be added.
+        If dict, the keys specify marker types (case is ignored), so that the
+        corresponding value (an integer) will be added to the trigger value of
+        all events of this type. If the value for a key is in the dict is None,
+        all markers of this type will be ignored. If None (default), no offset
+        is added, which may lead to different marker types being mapped to the
+        same event id.
 
     See Also
     --------
@@ -84,7 +95,24 @@ class RawBrainVision(BaseRaw):
     def __init__(self, vhdr_fname, montage=None,
                  eog=('HEOGL', 'HEOGR', 'VEOGb'), misc='auto',
                  scale=1., preload=False, response_trig_shift=0,
-                 event_id=None, verbose=None):  # noqa: D107
+                 event_id=None, verbose=None,
+                 trig_shift_by_type=None):  # noqa: D107
+        if response_trig_shift != 0:
+            warnings.warn(
+                "'response_trig_shift' was deprecated in version "
+                "0.17 and will be removed in 0.19. Use "
+                "trig_shift_by_type={{'response': {} }} instead".format(
+                    response_trig_shift), DeprecationWarning)
+            if trig_shift_by_type and 'response' in (
+                    key.lower() for key in trig_shift_by_type):
+                raise ValueError(
+                    'offset for response markers has been specified twice, '
+                    'both using "trig_shift_by_type" and '
+                    '"response_trig_shift"')
+            else:
+                if trig_shift_by_type is None:
+                    trig_shift_by_type = dict()
+                trig_shift_by_type['response'] = response_trig_shift
         # Channel info and events
         logger.info('Extracting parameters from %s...' % vhdr_fname)
         vhdr_fname = op.abspath(vhdr_fname)
@@ -92,7 +120,7 @@ def __init__(self, vhdr_fname, montage=None,
             _get_vhdr_info(vhdr_fname, eog, misc, scale, montage)
         self._order = order
         self._n_samples = n_samples
-        events = _read_vmrk_events(mrk_fname, event_id, response_trig_shift)
+        events = _read_vmrk_events(mrk_fname, event_id, trig_shift_by_type)
         _check_update_montage(info, montage)
         with open(data_filename, 'rb') as f:
             if isinstance(fmt, dict):  # ASCII, this will be slow :(
@@ -198,7 +226,7 @@ def _read_segments_c(raw, data, idx, fi, start, stop, cals, mult):
         _mult_cal_one(data, block, idx, cals, mult)
 
 
-def _read_vmrk_events(fname, event_id=None, response_trig_shift=0):
+def _read_vmrk_events(fname, event_id=None, trig_shift_by_type=None):
     """Read events from a vmrk file.
 
     Parameters
@@ -224,6 +252,23 @@ def _read_vmrk_events(fname, event_id=None, response_trig_shift=0):
     """
     if event_id is None:
         event_id = dict()
+    if trig_shift_by_type is None:
+        trig_shift_by_type = dict()
+    if not isinstance(trig_shift_by_type, dict):
+        raise TypeError("'trig_shift_by_type' must be None or dict")
+    for mrk_type in list(trig_shift_by_type.keys()):
+        cur_shift = trig_shift_by_type[mrk_type]
+        if not isinstance(cur_shift, int) and cur_shift is not None:
+            raise TypeError('shift for type {} must be int or None'.format(
+                mrk_type
+            ))
+        mrk_type_lc = mrk_type.lower()
+        if mrk_type_lc != mrk_type:
+            if mrk_type_lc in trig_shift_by_type:
+                raise ValueError('marker type {} specified twice with'
+                                 'different case'.format(mrk_type_lc))
+            trig_shift_by_type[mrk_type_lc] = cur_shift
+            del trig_shift_by_type[mrk_type]
     # read vmrk file
     with open(fname, 'rb') as fid:
         txt = fid.read()
@@ -233,9 +278,6 @@ def _read_vmrk_events(fname, event_id=None, response_trig_shift=0):
     # same in Latin-1 and UTF-8
     header = txt.decode('ascii', 'ignore').split('\n')[0].strip()
     _check_mrk_version(header)
-    if (response_trig_shift is not None and
-            not isinstance(response_trig_shift, int)):
-        raise TypeError("response_trig_shift must be an integer or None")
 
     # although the markers themselves are guaranteed to be ASCII (they
     # consist of numbers and a few reserved words), we should still
@@ -287,9 +329,10 @@ def _read_vmrk_events(fname, event_id=None, response_trig_shift=0):
                 trigger = int(re.findall(r'[A-Za-z]*\s*?(\d+)', mdesc)[0])
             except IndexError:
                 trigger = None
-            if mtype.lower().startswith('response'):
-                if response_trig_shift is not None:
-                    trigger += response_trig_shift
+            if mtype.lower() in trig_shift_by_type:
+                cur_shift = trig_shift_by_type[mtype.lower()]
+                if cur_shift is not None:
+                    trigger += cur_shift
                 else:
                     trigger = None
         # FIXME: ideally, we would not use the middle column of the events
@@ -783,7 +826,8 @@ def _get_vhdr_info(vhdr_fname, eog, misc, scale, montage):
 def read_raw_brainvision(vhdr_fname, montage=None,
                          eog=('HEOGL', 'HEOGR', 'VEOGb'), misc='auto',
                          scale=1., preload=False, response_trig_shift=0,
-                         event_id=None, verbose=None):
+                         event_id=None, verbose=None,
+                         trig_shift_by_type=None):
     """Reader for Brain Vision EEG file.
 
     Parameters
@@ -811,9 +855,11 @@ def read_raw_brainvision(vhdr_fname, montage=None,
         If False, data are not read until save.
     response_trig_shift : int | None
         An integer that will be added to all response triggers when reading
-        events (stimulus triggers will be unaffected). If None, response
-        triggers will be ignored. Default is 0 for backwards compatibility, but
-        typically another value or None will be necessary.
+        events (stimulus triggers will be unaffected). This parameter was
+        deprecated in version 0.17 and will be removed in 0.19. Use
+        ``trig_shift_by_type={'response': ...}`` instead. If None, response
+        triggers will be ignored. Default is 0 for backwards compatibility,
+        but typically another value or None will be necessary.
     event_id : dict | None
         The id of special events to consider in addition to those that
         follow the normal Brainvision trigger format ('S###').
@@ -824,6 +870,14 @@ def read_raw_brainvision(vhdr_fname, montage=None,
     verbose : bool, str, int, or None
         If not None, override default verbose level (see :func:`mne.verbose`
         and :ref:`Logging documentation <tut_logging>` for more).
+    trig_shift_by_type: dict | None
+        The names of marker types to which an offset should be added.
+        If dict, the keys specify marker types (case is ignored), so that the
+        corresponding value (an integer) will be added to the trigger value of
+        all events of this type. If the value for a key is in the dict is None,
+        all markers of this type will be ignored. If None (default), no offset
+        is added, which may lead to different marker types being mapped to the
+        same event id.
 
     Returns
     -------
@@ -838,4 +892,5 @@ def read_raw_brainvision(vhdr_fname, montage=None,
     return RawBrainVision(vhdr_fname=vhdr_fname, montage=montage, eog=eog,
                           misc=misc, scale=scale, preload=preload,
                           response_trig_shift=response_trig_shift,
-                          event_id=event_id, verbose=verbose)
+                          event_id=event_id, verbose=verbose,
+                          trig_shift_by_type=trig_shift_by_type)


Test patch:
diff --git a/mne/io/brainvision/tests/data/test.vmrk b/mne/io/brainvision/tests/data/test.vmrk
--- a/mne/io/brainvision/tests/data/test.vmrk
+++ b/mne/io/brainvision/tests/data/test.vmrk
@@ -22,3 +22,4 @@ Mk10=Response,R255,6000,1,0
 Mk11=Stimulus,S254,6620,1,0
 Mk12=Stimulus,S255,6630,1,0
 Mk13=SyncStatus,Sync On,7630,1,0
+Mk14=Optic,O  1,7700,1,0
\ No newline at end of file
diff --git a/mne/io/brainvision/tests/test_brainvision.py b/mne/io/brainvision/tests/test_brainvision.py
--- a/mne/io/brainvision/tests/test_brainvision.py
+++ b/mne/io/brainvision/tests/test_brainvision.py
@@ -347,7 +347,8 @@ def test_brainvision_data():
 
     # test loading v2
     read_raw_brainvision(vhdr_v2_path, eog=eog, preload=True,
-                         response_trig_shift=1000, verbose='error')
+                         trig_shift_by_type={'response': 1000},
+                         verbose='error')
     # For the nanovolt unit test we use the same data file with a different
     # header file.
     raw_nV = _test_raw_reader(
@@ -420,14 +421,35 @@ def test_events():
                                 [4945, 1, 255],
                                 [5999, 1, 255],
                                 [6619, 1, 254],
-                                [6629, 1, 255]])
+                                [6629, 1, 255],
+                                [7699, 1, 1]])
 
     # check that events are read and stim channel is synthesized correctly and
-    # response triggers are shifted like they're supposed to be.
+    # response triggers are shifted using the deprecated response_trig_shift.
+    with pytest.warns(DeprecationWarning):
+        raw = read_raw_brainvision(vhdr_path, eog=eog,
+                                   response_trig_shift=1000, event_id=event_id)
+    events = raw._get_brainvision_events()
+    assert_array_equal(events, [[486, 0, 253],
+                                [496, 1, 255],
+                                [1769, 1, 254],
+                                [1779, 1, 255],
+                                [3252, 1, 254],
+                                [3262, 1, 255],
+                                [4935, 1, 253],
+                                [4945, 1, 255],
+                                [5999, 1, 1255],
+                                [6619, 1, 254],
+                                [6629, 1, 255],
+                                [7629, 1, 5],
+                                [7699, 1, 1]])
+
+    # check that trig_shift_by_type works as well
     raw = read_raw_brainvision(vhdr_path, eog=eog,
-                               response_trig_shift=1000, event_id=event_id)
+                               trig_shift_by_type={'response': 1000,
+                                                   'Optic': 2000},
+                               event_id=event_id)
     events = raw._get_brainvision_events()
-    events = events[events[:, 2] != event_id['Sync On']]
     assert_array_equal(events, [[486, 0, 253],
                                 [496, 1, 255],
                                 [1769, 1, 254],
@@ -438,13 +460,15 @@ def test_events():
                                 [4945, 1, 255],
                                 [5999, 1, 1255],
                                 [6619, 1, 254],
-                                [6629, 1, 255]])
+                                [6629, 1, 255],
+                                [7629, 1, 5],
+                                [7699, 1, 2001]])
 
     # check that events are read and stim channel is synthesized correctly and
     # response triggers are ignored.
     with warnings.catch_warnings(record=True):  # ignored events
         raw = read_raw_brainvision(vhdr_path, eog=eog,
-                                   response_trig_shift=None)
+                                   trig_shift_by_type={'response': None})
     events = raw._get_brainvision_events()
     events = events[events[:, 2] != event_id['Sync On']]
     assert_array_equal(events, [[486, 0, 253],
@@ -456,13 +480,30 @@ def test_events():
                                 [4935, 1, 253],
                                 [4945, 1, 255],
                                 [6619, 1, 254],
-                                [6629, 1, 255]])
+                                [6629, 1, 255],
+                                [7699, 1, 1]])
+
+    # Error handling of trig_shift_by_type
+
+    pytest.raises(TypeError, read_raw_brainvision, vhdr_path, eog=eog,
+                  preload=True, trig_shift_by_type=1)
+    pytest.raises(TypeError, read_raw_brainvision, vhdr_path, eog=eog,
+                  preload=True, trig_shift_by_type={'response': 0.1})
+    pytest.raises(TypeError, read_raw_brainvision, vhdr_path, eog=eog,
+                  preload=True, trig_shift_by_type={'response': np.nan})
+    pytest.raises(ValueError, read_raw_brainvision, vhdr_path, eog=eog,
+                  preload=True, trig_shift_by_type={'response': 1000,
+                                                    'Response': 1001})
+    with pytest.warns(DeprecationWarning):
+        pytest.raises(ValueError, read_raw_brainvision, vhdr_path, eog=eog,
+                      preload=True, trig_shift_by_type={'response': 1000},
+                      response_trig_shift=1001)
 
     # check that events are read properly when event_id is specified for
     # auxiliary events
     with warnings.catch_warnings(record=True):  # dropped events
         raw = read_raw_brainvision(vhdr_path, eog=eog, preload=True,
-                                   response_trig_shift=None,
+                                   trig_shift_by_type={'response': None},
                                    event_id=event_id)
     events = raw._get_brainvision_events()
     assert_array_equal(events, [[486, 0, 253],
@@ -475,12 +516,8 @@ def test_events():
                                 [4945, 1, 255],
                                 [6619, 1, 254],
                                 [6629, 1, 255],
-                                [7629, 1, 5]])
-
-    pytest.raises(TypeError, read_raw_brainvision, vhdr_path, eog=eog,
-                  preload=True, response_trig_shift=0.1)
-    pytest.raises(TypeError, read_raw_brainvision, vhdr_path, eog=eog,
-                  preload=True, response_trig_shift=np.nan)
+                                [7629, 1, 5],
+                                [7699, 1, 1]])
 
     # to handle the min duration = 1 of stim trig (re)construction ...
     events = np.array([[486, 1, 253],
@@ -493,11 +530,13 @@ def test_events():
                        [4945, 1, 255],
                        [6619, 1, 254],
                        [6629, 1, 255],
-                       [7629, 1, 5]])
+                       [7629, 1, 5],
+                       [7699, 1, 1]])
 
-    # Test that both response_trig_shit and event_id can be set
+    # Test that both trig_shift_by_type and event_id can be set
     read_raw_brainvision(vhdr_path, eog=eog, preload=False,
-                         response_trig_shift=100, event_id=event_id)
+                         trig_shift_by_type={'response': 100},
+                         event_id=event_id)
     mne_events = find_events(raw, stim_channel='STI 014')
     assert_array_equal(events[:, [0, 2]], mne_events[:, [0, 2]])