Repository: mne-tools/mne-python

Issue:
correct the API usage of mask in mne.find_events to be consistent with mne-c, and more user-friendly
while I was addressing a comment on the listserv, I noticed that although the masking is correct per its instructions, it is not consistent with the mne-c way of handling the trigger mask. I also take blame because I added the feature. in essence, I inverted the meaning of the ones and zeros, which makes constructing the mask much more difficult.
 this is how it should be:

> --digtrigmask <number>
> Mask to be applied to the trigger channel values before considering
> them. This option is useful if one wants to set some bits in a don’t
> care state. For example, some finger response pads keep the trigger
> lines high if not in use, i.e., a finger is not in place. Yet, it is convenient
> to keep these devices permanently connected to the acquisition
> system. The number can be given in decimal or hexadecimal format
> (beginning with 0x or 0X). For example, the value 255 (0xFF)
> means that only the lowest order byte (usually trigger lines 1 - 8 or
> bits 0 - 7) will be considered.

I propose that we make it our mask option consistent with this. it makes its use case more user-friendly, and fixes the inconsistency issue. 

a real example, a user's triggers ranged from 0-32 and are only described on the first six bits of the trigger channels. in order to isolate these values, we need to mask the upper bits. the current implementation requires `mask=2**17 -32` whereas the mne-c way, and the way I propose would have it be `mask=32`.

What are your thoughts?
cc: @agramfort, @Eric89GXL



Discussion:
fine with me if we don't break people's code without proper deprecation
cycle

I think there will need to be a deprecation cycle, but I think it's worth it to get equivalent to MNE-C.

We could introduce a `kwarg` like `kind='and' | 'and not'` (or some better names) for the kind of masking to do. For one cycle it can be `kind=None` which wraps to the existing `'and not'` behavior, changing to `kind='and'` for 0.14.


Resolving patch:
diff --git a/mne/event.py b/mne/event.py
--- a/mne/event.py
+++ b/mne/event.py
@@ -188,7 +188,8 @@ def _read_events_fif(fid, tree):
     return event_list, mappings
 
 
-def read_events(filename, include=None, exclude=None, mask=0):
+def read_events(filename, include=None, exclude=None, mask=None,
+                mask_type=None):
     """Reads events from fif or text file
 
     Parameters
@@ -209,7 +210,12 @@ def read_events(filename, include=None, exclude=None, mask=0):
         the exclude parameter is ignored.
     mask : int | None
         The value of the digital mask to apply to the stim channel values.
-        The default value is 0. ``None`` skips masking.
+        If None (default), no masking is performed.
+    mask_type: 'and' | 'not_and'
+        The type of operation between the mask and the trigger.
+        Choose 'and' for MNE-C masking behavior.
+
+        .. versionadded:: 0.13
 
     Returns
     -------
@@ -225,9 +231,8 @@ def read_events(filename, include=None, exclude=None, mask=0):
     This function will discard the offset line (i.e., first line with zero
     event number) if it is present in a text file.
 
-    Working with downsampled data: Events that were computed before the data
-    was decimated are no longer valid. Please recompute your events after
-    decimation.
+    For more information on ``mask`` and ``mask_type``, see
+    :func:`mne.find_events`.
     """
     check_fname(filename, 'events', ('.eve', '-eve.fif', '-eve.fif.gz',
                                      '-eve.lst', '-eve.txt'))
@@ -265,7 +270,7 @@ def read_events(filename, include=None, exclude=None, mask=0):
     event_list = pick_events(event_list, include, exclude)
     unmasked_len = event_list.shape[0]
     if mask is not None:
-        event_list = _mask_trigs(event_list, mask)
+        event_list = _mask_trigs(event_list, mask, mask_type)
         masked_len = event_list.shape[0]
         if masked_len < unmasked_len:
             warn('{0} of {1} events masked'.format(unmasked_len - masked_len,
@@ -415,7 +420,7 @@ def find_stim_steps(raw, pad_start=None, pad_stop=None, merge=0,
 
 def _find_events(data, first_samp, verbose=None, output='onset',
                  consecutive='increasing', min_samples=0, mask=0,
-                 uint_cast=False):
+                 uint_cast=False, mask_type=None):
     """Helper function for find events"""
     if min_samples > 0:
         merge = int(min_samples // 1)
@@ -435,7 +440,7 @@ def _find_events(data, first_samp, verbose=None, output='onset',
         data = np.abs(data)  # make sure trig channel is positive
 
     events = _find_stim_steps(data, first_samp, pad_stop=0, merge=merge)
-    events = _mask_trigs(events, mask)
+    events = _mask_trigs(events, mask, mask_type)
 
     # Determine event onsets and offsets
     if consecutive == 'increasing':
@@ -487,7 +492,8 @@ def _find_events(data, first_samp, verbose=None, output='onset',
 @verbose
 def find_events(raw, stim_channel=None, output='onset',
                 consecutive='increasing', min_duration=0,
-                shortest_event=2, mask=0, uint_cast=False, verbose=None):
+                shortest_event=2, mask=None, uint_cast=False,
+                mask_type=None, verbose=None):
     """Find events from raw file
 
     Parameters
@@ -516,9 +522,9 @@ def find_events(raw, stim_channel=None, output='onset',
     shortest_event : int
         Minimum number of samples an event must last (default is 2). If the
         duration is less than this an exception will be raised.
-    mask : int
+    mask : int | None
         The value of the digital mask to apply to the stim channel values.
-        The default value is 0.
+        If None (default), no masking is performed.
     uint_cast : bool
         If True (default False), do a cast to ``uint16`` on the channel
         data. This can be used to fix a bug with STI101 and STI014 in
@@ -528,6 +534,12 @@ def find_events(raw, stim_channel=None, output='onset',
 
         .. versionadded:: 0.12
 
+    mask_type: 'and' | 'not_and'
+        The type of operation between the mask and the trigger.
+        Choose 'and' for MNE-C masking behavior.
+
+        .. versionadded:: 0.13
+
     verbose : bool, str, int, or None
         If not None, override default verbose level (see mne.verbose).
 
@@ -541,9 +553,24 @@ def find_events(raw, stim_channel=None, output='onset',
         the second column contains the value of the stim channel after the
         event offset.
 
+    See Also
+    --------
+    find_stim_steps : Find all the steps in the stim channel.
+    read_events : Read events from disk.
+    write_events : Write events to disk.
+
+    Notes
+    -----
+    .. warning:: If you are working with downsampled data, events computed
+                 before decimation are no longer valid. Please recompute
+                 your events after decimation, but note this reduces the
+                 precision of event timing.
+
     Examples
     --------
-    Consider data with a stim channel that looks like: [0, 32, 32, 33, 32, 0]
+    Consider data with a stim channel that looks like::
+
+        [0, 32, 32, 33, 32, 0]
 
     By default, find_events returns all samples at which the value of the
     stim channel increases::
@@ -593,18 +620,24 @@ def find_events(raw, stim_channel=None, output='onset',
         ...                   min_duration=0.002))
         [[ 1  0 32]]
 
-    For the digital mask, it will take the binary representation of the
-    digital mask, e.g. 5 -> '00000101', and will block the values
-    where mask is one, e.g.::
+    For the digital mask, if mask_type is set to 'and' it will take the
+    binary representation of the digital mask, e.g. 5 -> '00000101', and will
+    allow the values to pass where mask is one, e.g.::
+
+              7 '0000111' <- trigger value
+             37 '0100101' <- mask
+         ----------------
+              5 '0000101'
+
+    For the digital mask, if mask_type is set to 'not_and' it will take the
+    binary representation of the digital mask, e.g. 5 -> '00000101', and will
+    block the values where mask is one, e.g.::
 
               7 '0000111' <- trigger value
              37 '0100101' <- mask
          ----------------
               2 '0000010'
 
-    See Also
-    --------
-    find_stim_steps : Find all the steps in the stim channel.
     """
     min_samples = min_duration * raw.info['sfreq']
 
@@ -618,7 +651,7 @@ def find_events(raw, stim_channel=None, output='onset',
 
     events = _find_events(data, raw.first_samp, verbose=verbose, output=output,
                           consecutive=consecutive, min_samples=min_samples,
-                          mask=mask, uint_cast=uint_cast)
+                          mask=mask, uint_cast=uint_cast, mask_type=mask_type)
 
     # add safety check for spurious events (for ex. from neuromag syst.) by
     # checking the number of low sample events
@@ -633,17 +666,28 @@ def find_events(raw, stim_channel=None, output='onset',
     return events
 
 
-def _mask_trigs(events, mask):
+def _mask_trigs(events, mask, mask_type):
     """Helper function for masking digital trigger values"""
-    if not isinstance(mask, int):
-        raise TypeError('You provided a(n) %s. Mask must be an int.'
-                        % type(mask))
+    if mask is not None:
+        if not isinstance(mask, int):
+            raise TypeError('You provided a(n) %s.' % type(mask) +
+                            'Mask must be an int or None.')
     n_events = len(events)
     if n_events == 0:
         return events.copy()
 
-    mask = np.bitwise_not(mask)
-    events[:, 1:] = np.bitwise_and(events[:, 1:], mask)
+    if mask is not None:
+        if mask_type is None:
+            warn("The default setting will change from 'not_and' "
+                 "to 'and' in v0.14.", DeprecationWarning)
+            mask_type = 'not_and'
+        if mask_type == 'not_and':
+            mask = np.bitwise_not(mask)
+        elif mask_type != 'and':
+            if mask_type is not None:
+                raise ValueError("'mask_type' should be either 'and'"
+                                 " or 'not_and', instead of '%s'" % mask_type)
+        events[:, 1:] = np.bitwise_and(events[:, 1:], mask)
     events = events[events[:, 1] != events[:, 2]]
 
     return events
diff --git a/mne/realtime/epochs.py b/mne/realtime/epochs.py
--- a/mne/realtime/epochs.py
+++ b/mne/realtime/epochs.py
@@ -107,13 +107,12 @@ class RtEpochs(_BaseEpochs):
     find_events : dict
         The arguments to the real-time `find_events` method as a dictionary.
         If `find_events` is None, then default values are used.
-        Valid keys are 'output' | 'consecutive' | 'min_duration' | 'mask'.
         Example (also default values)::
 
             find_events = dict(output='onset', consecutive='increasing',
-                               min_duration=0, mask=0)
+                               min_duration=0, mask=0, mask_type='not_and')
 
-        See mne.find_events for detailed explanation of these options.
+        See :func:`mne.find_events` for detailed explanation of these options.
     verbose : bool, str, int, or None
         If not None, override default verbose level (see mne.verbose).
         Defaults to client.verbose.
@@ -169,7 +168,8 @@ def __init__(self, client, event_id, tmin, tmax, stim_channel='STI 014',
         # find_events default options
         self._find_events_kwargs = dict(output='onset',
                                         consecutive='increasing',
-                                        min_duration=0, mask=0)
+                                        min_duration=0, mask=0,
+                                        mask_type='not_and')
         # update default options if dictionary is provided
         if find_events is not None:
             self._find_events_kwargs.update(find_events)


Test patch:
diff --git a/mne/tests/test_event.py b/mne/tests/test_event.py
--- a/mne/tests/test_event.py
+++ b/mne/tests/test_event.py
@@ -119,7 +119,7 @@ def test_io_events():
     assert_array_almost_equal(events, events2)
     with warnings.catch_warnings(record=True) as w:
         warnings.simplefilter('always')
-        events2 = read_events(fname_txt_mpr)
+        events2 = read_events(fname_txt_mpr, mask=0, mask_type='not_and')
         assert_true(sum('first row of' in str(ww.message) for ww in w) == 1)
     assert_array_almost_equal(events, events2)
 
@@ -203,6 +203,8 @@ def test_find_events():
     # 1 == '0b1', 2 == '0b10', 3 == '0b11', 4 == '0b100'
 
     assert_raises(TypeError, find_events, raw, mask="0")
+    assert_raises(ValueError, find_events, raw, mask=0, mask_type='blah')
+    # testing mask_type. default = 'not_and'
     assert_array_equal(find_events(raw, shortest_event=1, mask=1),
                        [[2, 0, 2], [4, 2, 4]])
     assert_array_equal(find_events(raw, shortest_event=1, mask=2),
@@ -211,6 +213,19 @@ def test_find_events():
                        [[4, 0, 4]])
     assert_array_equal(find_events(raw, shortest_event=1, mask=4),
                        [[1, 0, 1], [2, 1, 2], [3, 2, 3]])
+    # testing with mask_type = 'and'
+    assert_array_equal(find_events(raw, shortest_event=1, mask=1,
+                       mask_type='and'),
+                       [[1, 0, 1], [3, 0, 1]])
+    assert_array_equal(find_events(raw, shortest_event=1, mask=2,
+                       mask_type='and'),
+                       [[2, 0, 2]])
+    assert_array_equal(find_events(raw, shortest_event=1, mask=3,
+                       mask_type='and'),
+                       [[1, 0, 1], [2, 1, 2], [3, 2, 3]])
+    assert_array_equal(find_events(raw, shortest_event=1, mask=4,
+                       mask_type='and'),
+                       [[4, 0, 4]])
 
     # test empty events channel
     raw._data[stim_channel_idx, :] = 0