Repository: mne-tools/mne-python

Issue:
BUG: BrainVision reads more events than it says it does.
According to the docstring, the BrainVision reader will only read "Stimulus" events, which are typically in the shape `r"(S\d{1,3})"`, so an S and then 1 to 3 digits. All other events will have to be specified using the `event_id` dictionary:
https://github.com/mne-tools/mne-python/blob/beda451422b4d8016643438ce7e998c76cbd2f59/mne/io/brainvision/brainvision.py#L233-L239

However, the BrainVision reader parses all events that end with a number, due to this regexp:
https://github.com/mne-tools/mne-python/blob/beda451422b4d8016643438ce7e998c76cbd2f59/mne/io/brainvision/brainvision.py#L325

This leads to issues when e.g., a "Comment" event is in the markers such as `Mk9=Comment,Discontinuit? [B] 4156 ms,136544,1,0` as in #5356. See [regexp101](https://regex101.com/r/VAqMnU/1) to play around with it.

That comment will then be automatically added to the events. Crucially, its trigger value will be the number that was found, in this case `4156`. That number originally referred to milliseconds. In this particular case the error is not too bad and will just lead to a nonsensical event value. However imagine that it was `254ms`: as BrainVision uses trigger values from 0 to 255, the 254 falls in the range of sensible event values and thus has the potency to be falsely added as a "true event" when it really is just a misread comment.


Discussion:
do you plan to send PR?

Yes, but first we need to discuss how to fix this issue. I could replace the present `r'[A-Za-z]*\s*?(\d+)'` with `r'S(\d{1,3})'` to truly match only the S### events as advertised by the docstring. However a lot of our tests are actually working with the buggy behavior. So it would involve fixing a lot of tests as well.

I'd be up for that but perhaps somebody has objections or a better idea.
How about a new kwarg `event_parsing='whatever' | 'full'` where `whatever` is some term that accurately describes what we currently do, and `full` is what you propose to change it to?
@larsoner thanks for the suggestion. However, what we currently do is really not sensible and I would like to change it, I am just looking for the best way to do it.

The arguably most common event in BrainVision files next to stimulus events marked as `S###` are response events marked as `R###`. These are also the most common events in our test files. I suggest to replace the current, faulty regexp in our code with [`[SR]([\s\d]{2}\d{1})`](https://regex101.com/r/UYbiJj/1), which will match any S### or R###.

Then I would adjust the docstrings to explicitly mention that.

What would remain is fixing the tests, because currently, we have an "optical" event in the test files with the marker `0  1`. The tests are tailored such that this event is read by default. But this event has a very idiosyncratic marker which we should not read by default (instead only when specified in `event_id`)

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
@@ -60,7 +60,7 @@ class RawBrainVision(BaseRaw):
     response_trig_shift : int | None
         An integer that will be added to all response triggers when reading
         events (stimulus triggers will be unaffected). This parameter was
-        deprecated in version 0.17 and will be removed in 0.19. Use
+        deprecated in version 0.17 and will be removed in 0.18. 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.
@@ -69,8 +69,9 @@ class RawBrainVision(BaseRaw):
         follow the normal Brainvision trigger format ('S###').
         If dict, the keys will be mapped to trigger values on the stimulus
         channel. Example: {'SyncStatus': 1; 'Pulse Artifact': 3}. If None
-        or an empty dict (default), only stimulus events are added to the
-        stimulus channel. Keys are case sensitive.
+        or an empty dict (default), only stimulus and response events are added
+        to the stimulus channel. Keys are case sensitive. "New Segment" markers
+        are always dropped.
     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).
@@ -234,8 +235,9 @@ def _read_vmrk_events(fname, event_id=None, trig_shift_by_type=None):
         follow the normal Brainvision trigger format ('S###').
         If dict, the keys will be mapped to trigger values on the stimulus
         channel. Example: {'SyncStatus': 1; 'Pulse Artifact': 3}. If None
-        or an empty dict (default), only stimulus events are added to the
-        stimulus channel. Keys are case sensitive.
+        or an empty dict (default), only stimulus and response events are added
+        to the stimulus channel. Keys are case sensitive. "New Segment" markers
+        are always dropped.
     response_trig_shift : int | None
         Integer to shift response triggers by. None ignores response triggers.
 
@@ -321,27 +323,32 @@ def _read_vmrk_events(fname, event_id=None, trig_shift_by_type=None):
             trigger = event_id[mdesc]
         else:
             try:
-                trigger = int(re.findall(r'[A-Za-z]*\s*?(\d+)', mdesc)[0])
+                trigger = int(re.findall(r'S([\s\d]{2}\d{1})', mdesc)[0])
             except IndexError:
                 trigger = None
-            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
+        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:
+                # The trigger has been deliberately shifted to None. Do not
+                # add this to "dropped" so we do not warn about something
+                # that was done deliberately. Just continue with next item.
+                trigger = None
+                continue
         # FIXME: ideally, we would not use the middle column of the events
         # array to store the duration. A better solution would be using
         # annotations.
         if trigger:
             events.append((onset, duration, trigger))
         else:
+            # Markers with no description are not regarded as dropped but
+            # instead are simply ignored. For example, the "New Segment"
+            # markers are ignored, because they usually look like:
+            # Mk1=New Segment,,1,1,0
             if len(mdesc) > 0:
                 dropped.append(mdesc)
 
-    # Drop all markers with no description from the dropped markers
-    # For example, the "New Segment" markers will be dropped from the list
-    # of dropped markers because they usually look like: Mk1=New Segment,,1,1,0
     if len(dropped) > 0:
         dropped = list(set(dropped))
         examples = ", ".join(dropped[:5])
@@ -869,8 +876,9 @@ def read_raw_brainvision(vhdr_fname, montage=None,
         follow the normal Brainvision trigger format ('S###').
         If dict, the keys will be mapped to trigger values on the stimulus
         channel. Example: {'SyncStatus': 1; 'Pulse Artifact': 3}. If None
-        or an empty dict (default), only stimulus events are added to the
-        stimulus channel. Keys are case sensitive.
+        or an empty dict (default), only stimulus and response events are added
+        to the stimulus channel. Keys are case sensitive. "New Segment" markers
+        are always dropped.
     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).


Test patch:
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
@@ -62,7 +62,7 @@
 montage = op.join(data_dir, 'test.hpts')
 eeg_bin = op.join(data_dir, 'test_bin_raw.fif')
 eog = ['HL', 'HR', 'Vb']
-event_id = {'Sync On': 5}
+event_id = {'Sync On': 5, 'O  1': 1, 'R255': 255}
 
 
 def test_vmrk_meas_date():
@@ -349,6 +349,7 @@ def test_brainvision_data():
 
     # test loading v2
     read_raw_brainvision(vhdr_v2_path, eog=eog, preload=True,
+                         event_id=event_id,
                          trig_shift_by_type={'response': 1000},
                          verbose='error')
     # For the nanovolt unit test we use the same data file with a different
@@ -466,11 +467,13 @@ def test_events():
                                 [7629, 1, 5],
                                 [7699, 1, 2001]])
 
+    # Check that we warn if a trigger is dropped
+    with pytest.warns(RuntimeWarning, match='to parse triggers'):
+        raw = read_raw_brainvision(vhdr_path)
     # check that events are read and stim channel is synthesized correctly and
     # response triggers are ignored.
-    with pytest.warns(RuntimeWarning, match='to parse triggers'):
-        raw = read_raw_brainvision(vhdr_path, eog=eog,
-                                   trig_shift_by_type={'response': None})
+    raw = read_raw_brainvision(vhdr_path, eog=eog, event_id=event_id,
+                               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],
@@ -506,7 +509,7 @@ def test_events():
     with pytest.warns(RuntimeWarning, match='channel types to misc'):
         raw = read_raw_brainvision(vhdr_v2_path)
     events = raw._get_brainvision_events()
-    assert events.shape == (11, 3)  # shape of events without the comment
+    assert events.shape == (10, 3)  # shape of events without comment/response
 
     # with event_id specified, get that comment and assert it's there
     tmp_event_id = {'comment using [square] brackets': 999}
@@ -514,14 +517,13 @@ def test_events():
         raw = read_raw_brainvision(vhdr_v2_path, event_id=tmp_event_id)
     events = raw._get_brainvision_events()
     assert 999 in events[:, -1]
-    assert events.shape == (12, 3)
+    assert events.shape == (11, 3)  # shape of events without response
 
     # check that events are read properly when event_id is specified for
     # auxiliary events
-    with pytest.warns(RuntimeWarning, match='dropped'):
-        raw = read_raw_brainvision(vhdr_path, eog=eog, preload=True,
-                                   trig_shift_by_type={'response': None},
-                                   event_id=event_id)
+    raw = read_raw_brainvision(vhdr_path, eog=eog, preload=True,
+                               trig_shift_by_type={'response': None},
+                               event_id=event_id)
     events = raw._get_brainvision_events()
     assert_array_equal(events, [[486, 0, 253],
                                 [496, 1, 255],