Repository: mne-tools/mne-python

Issue:
API: mne.preprocessing.mark_flat --> mne.preprocessing.annotate_flat ?
With the *relatively* new Annotations in MNE, there are now (at least) two functions that work on raw data and return annotations:

- [mne.preprocessing.annotate_muscle_z_score](https://mne.tools/dev/generated/mne.preprocessing.annotate_muscle_zscore.html?highlight=annotate#mne.preprocessing.annotate_muscle_zscore)
- [mne.preprocessing.annotate_movement](https://mne.tools/dev/generated/mne.preprocessing.annotate_movement.html?highlight=annotate#mne.preprocessing.annotate_movement)

There is an older function operating in a similar style: [`mne.preprocessing.mark_flat`](https://mne.tools/stable/generated/mne.preprocessing.mark_flat.html#mne.preprocessing.mark_flat)

Would it make sense to change this to `mne.preprocessing.anotate_flat` to have a set of functions with a consistent API working on raw data and providing annotations?




Discussion:
+100

>

That function might mark bad channels and it might annotate, depending on the data and what percentage of time a given channel is flat. So maybe annotate_mark_flat?
> So maybe annotate_mark_flat?

this is in fact the only function with `mark_` in its name (although there is the MNE-C function mne_mark_bad_channels). So unclear to me that we need the longer, more complicated name.  It doesn't seem conceptually weird to me that a temporal-span annotation function might "give up" and just mark the whole channel in some cases. +1 for `annotate_flat`
> +1 for `annotate_flat`

+1
I just realized all of these functions return Annotations, but do not add them to the passed Raw instance. To me, this behavior seems to kind of contradict the function name? i.e. when something is called "annotate", I'd expect it to actually annotate… 🧐

Edit: To clarify, I meant the proposed `annotate_muscle_z_score` and `annotate_movement`. Not the propsed `annotate_flat`, which currently operates in-place. In any case, this would be quite some API inconsistency there.

I know MNE methods should generally work in-place, but in the case of artifact processing I'd want an option to double-check the automatically generated annotations/bad channels. Therefore, I'd prefer such methods to return annotations and a list of bad channels.
> but in the case of artifact processing I'd want an option to double-check the automatically generated annotations/bad channels. Therefore, I'd prefer such methods to return annotations and a list of bad channels.

… which is already the case for `annotate_muscle_z_score` and `annotate_movement`; and if we rename `mark_flat` anyway, we could also change its behavior accordingly.

Still, my issue remains: if I say "annotate something", I expect "something" to be annotated. Just like when I call `list.remove()`, I expect something to be removed from the list, without requiring an additional step from my end. – If, however, the function were called e.g. `gen_movement_annotations`, I would expect it to return annotations (and not necessarily to work in-place).
What about `str.upper()` then?
> What about `str.upper()` then?

"upper" is not a verb in its imperative form, so to me the behavior is unclear unless I check the docs.
(besides, strings are immutable; so the comparison is a little off anyway… but this is an implementation detail)
You could interpret it as a verb though - uppercase that string for me. And yes, strings are immutable, but I wanted to point out that even in the built-in types this is not consistent.

Regarding your expectation, if you annotate something, you could also get back the annotated object (instead of modifying in-place).
> Regarding your expectation, if you annotate something, you could also get back the annotated object

Yes, in addition to the in-place modification. Otherwise it would be, "generate an annotation please" and not "please annotate".

Edit: I misread this as "get back the **Annotations** object"
Yes, I would be fine with returning the annotated object too, and leaving the input unchanged. 👍
(I've edited my answer above)
In [compute_proj_ecg](https://mne.tools/stable/generated/mne.preprocessing.compute_proj_ecg.html#mne.preprocessing.compute_proj_ecg) and [compute_proj_eog](https://mne.tools/stable/generated/mne.preprocessing.compute_proj_eog.html#mne.preprocessing.compute_proj_eog)
we  do as the function name indicates: compute the projs, and return them. The verb, here, makes total sense. OTOH, annotate_*, without actually annotating, does not make sense to me.
> I just realized all of these functions return Annotations, but do not add them to the passed Raw instance. To me, this behavior seems to kind of contradict the function name? i.e. when something is called "annotate", I'd expect it to actually annotate

@hoechenberger what is the concrete proposal here?  Is it to rename the existing `annotate_*` functions to `make_*_annotations`, or to keep their names and change their behavior?  If it's the latter, please open a separate issue, and we can (I think?) move forward here with renaming `mark_flat` to `annotate_flat`.

If your proposal is to keep the behavior and change the names, then we should probably do it here all at once.  But that said, I'm not bothered by the `annotate_*` naming. To me "annotate" can mean either "make notes" or "attach notes". If you think about a literary work, annotating it does not necessarily mean writing in the margins of a physical book; a separate document with page and paragraph numbers attached to each note still counts as annotation IMO. That seems quite analogous to our `Annotations` object (which incidentally, a user might simply want to save to disk in tabular format --- returning the modified `Raw` is not helpful in that case).

@drammock 
> @hoechenberger what is the concrete proposal here? Is it to rename the existing `annotate_*` functions to `make_*_annotations`, or to keep their names and change their behavior?

Since my interpretation of "to annotate" seems to be a bit of an edge case, I'm okay with leaving these functions as is (I very much **would** prefer to rename them to `make_*_annotations`, personally.)

But we should make sure that the current `mark_flat`, if renamed to `annotate_flat`, matches the behavior of the existing `annotate_*` functions, namely, stop modifying the input in-place, and return Annotations instead.
> But we should make sure that the current `mark_flat`, if renamed to `annotate_flat`, matches the behavior of the existing `annotate_*` functions, namely, stop modifying the input in-place, and return Annotations instead.

Agreed; I think that was part of @sappelhoff's original proposal.
Sounds reasonable to me
(if someone calls `mark_flat` in 0.21 it should do the same thing as in 0.20 but give dep warning, `annotate_flat` should do whatever we want `annotate_*` functions to do)
@sappelhoff will you be able to take a stab at this one in the next couple of weeks?
Offering my help here too 👍 
> Offering my help here too +1

In that case, I'd be happy if you took over @hoechenberger :-)
+1 to add this to the next release

>


Resolving patch:
diff --git a/mne/preprocessing/__init__.py b/mne/preprocessing/__init__.py
--- a/mne/preprocessing/__init__.py
+++ b/mne/preprocessing/__init__.py
@@ -7,7 +7,7 @@
 #
 # License: BSD (3-clause)
 
-from .flat import mark_flat
+from .flat import annotate_flat, mark_flat
 from .maxfilter import apply_maxfilter
 from .ssp import compute_proj_ecg, compute_proj_eog
 from .eog import find_eog_events, create_eog_epochs
diff --git a/mne/preprocessing/flat.py b/mne/preprocessing/flat.py
--- a/mne/preprocessing/flat.py
+++ b/mne/preprocessing/flat.py
@@ -4,14 +4,15 @@
 
 import numpy as np
 
-from ..annotations import _annotations_starts_stops
+from ..annotations import _annotations_starts_stops, Annotations
 from ..io import BaseRaw
 from ..io.pick import _picks_to_idx
 from ..utils import (_validate_type, verbose, logger, _pl,
-                     _mask_to_onsets_offsets, ProgressBar)
+                     _mask_to_onsets_offsets, ProgressBar, deprecated)
 
 
-@verbose
+@deprecated('mark_flat is deprecated in 0.21 and will be removed in 0.22, use '
+            'annotate_flat instead')
 def mark_flat(raw, bad_percent=5., min_duration=0.005, picks=None,
               verbose=None):
     r"""Mark flat segments of raw data using annotations or in info['bads'].
@@ -51,6 +52,56 @@ def mark_flat(raw, bad_percent=5., min_duration=0.005, picks=None,
     time points), which is typically not an efficient way to read
     raw data from disk.
 
+    .. versionadded:: 0.18
+    """
+    annot, bads = annotate_flat(raw, bad_percent, min_duration, picks, verbose)
+    raw.set_annotations(raw.annotations + annot)
+    raw.info['bads'].extend(bads)
+    return raw
+
+
+@verbose
+def annotate_flat(raw, bad_percent=5., min_duration=0.005, picks=None,
+                  verbose=None):
+    """Annotate flat segments of raw data (or add to a bad channel list).
+
+    Parameters
+    ----------
+    raw : instance of Raw
+        The raw data.
+    bad_percent : float
+        The percentage of the time a channel can be bad.
+        Below this percentage, temporal bad marking (:class:`~mne.Annotations`)
+        will be used. Above this percentage, spatial bad marking
+        (:class:`info['bads'] <mne.Info>`) will be used.
+        Defaults to 5 (5%%).
+    min_duration : float
+        The minimum duration (sec) to consider as actually flat.
+        For some systems with low bit data representations, adjacent
+        time samples with exactly the same value are not totally uncommon.
+        Defaults to 0.005 (5 ms).
+    %(picks_good_data)s
+    %(verbose)s
+
+    Returns
+    -------
+    annot : instance of Annotations
+        The annotated bad segments.
+    bads : list
+        The channels detected as bad.
+
+    Notes
+    -----
+    This function is useful both for removing short segments of data where
+    the acquisition system clipped (i.e., hit the ADC limit of the hardware)
+    and for automatically identifying channels that were flat for a large
+    proportion of a given recording.
+
+    This function may perform much faster if data are loaded
+    in memory, as it loads data one channel at a time (across all
+    time points), which is typically not an efficient way to read
+    raw data from disk.
+
     .. versionadded:: 0.18
     """
     _validate_type(raw, BaseRaw, 'raw')
@@ -94,11 +145,10 @@ def mark_flat(raw, bad_percent=5., min_duration=0.005, picks=None,
                 % (100 * any_flat[idx].mean(), len(starts), _pl(starts),
                    len(bads), len(picks), _pl(bads),
                    (': %s' % (bads,)) if bads else ''))
-    add_bads = [bad for bad in bads if bad not in raw.info['bads']]
-    raw.info['bads'] = list(raw.info['bads']) + add_bads
-    if len(starts) > 0:
-        starts, stops = np.array(starts), np.array(stops)
-        onsets = (starts + raw.first_samp) / raw.info['sfreq']
-        durations = (stops - starts) / raw.info['sfreq']
-        raw.annotations.append(onsets, durations, ['BAD_flat'] * len(onsets))
-    return raw
+    bads = [bad for bad in bads if bad not in raw.info['bads']]
+    starts, stops = np.array(starts), np.array(stops)
+    onsets = (starts + raw.first_samp) / raw.info['sfreq']
+    durations = (stops - starts) / raw.info['sfreq']
+    annot = Annotations(onsets, durations, ['BAD_flat'] * len(onsets),
+                        orig_time=raw.annotations.orig_time)
+    return annot, bads
diff --git a/mne/preprocessing/maxwell.py b/mne/preprocessing/maxwell.py
--- a/mne/preprocessing/maxwell.py
+++ b/mne/preprocessing/maxwell.py
@@ -105,7 +105,7 @@ def maxwell_filter(raw, origin='auto', int_order=8, ext_order=3,
 
     See Also
     --------
-    mne.preprocessing.mark_flat
+    mne.preprocessing.annotate_flat
     mne.preprocessing.find_bad_channels_maxwell
     mne.chpi.filter_chpi
     mne.chpi.read_head_pos
@@ -2029,7 +2029,7 @@ def find_bad_channels_maxwell(
 
     See Also
     --------
-    mark_flat
+    annotate_flat
     maxwell_filter
 
     Notes


Test patch:
diff --git a/mne/preprocessing/tests/test_flat.py b/mne/preprocessing/tests/test_flat.py
--- a/mne/preprocessing/tests/test_flat.py
+++ b/mne/preprocessing/tests/test_flat.py
@@ -9,14 +9,15 @@
 from mne import create_info
 from mne.datasets import testing
 from mne.io import RawArray, read_raw_fif
-from mne.preprocessing import mark_flat
+from mne.preprocessing import annotate_flat, mark_flat
+from mne.tests.test_annotations import _assert_annotations_equal
 
 data_path = testing.data_path(download=False)
 skip_fname = op.join(data_path, 'misc', 'intervalrecording_raw.fif')
 
 
 @pytest.mark.parametrize('first_samp', (0, 10000))
-def test_mark_flat(first_samp):
+def test_annotate_flat(first_samp):
     """Test marking flat segments."""
     # Test if ECG analysis will work on data that is not preloaded
     n_ch, n_times = 11, 1000
@@ -38,12 +39,17 @@ def test_mark_flat(first_samp):
             (dict(bad_percent=100.), [], 0),
             (dict(bad_percent=99.9), [raw.ch_names[0]], n_times),
             (dict(), [raw.ch_names[0]], n_times)]:  # default (1)
-        raw_time = mark_flat(raw_0.copy(), verbose='debug', **kwargs)
-        want_bads = raw.info['bads'] + bads
-        assert raw_time.info['bads'] == want_bads
+        raw_time = raw_0.copy()
+        annot, got_bads = annotate_flat(raw_0, verbose='debug', **kwargs)
+        assert got_bads == bads
+        raw_time.set_annotations(raw_time.annotations + annot)
+        raw_time.info['bads'] += got_bads
         n_good_times = raw_time.get_data(reject_by_annotation='omit').shape[1]
         assert n_good_times == want_times
-
+        with pytest.deprecated_call(match='annotate_flat'):
+            raw_time_2 = mark_flat(raw_0.copy(), verbose='debug', **kwargs)
+        _assert_annotations_equal(raw_time_2.annotations, raw_time.annotations)
+        assert raw_time_2.info['bads'] == raw_time.info['bads']
     #
     # Now make a channel flat for 20% of the time points
     #
@@ -57,26 +63,27 @@ def test_mark_flat(first_samp):
             (dict(bad_percent=threshold), [], n_good_times),
             (dict(bad_percent=threshold - 1e-5), [raw.ch_names[0]], n_times),
             (dict(), [raw.ch_names[0]], n_times)]:
-        raw_time = mark_flat(raw_0.copy(), verbose='debug', **kwargs)
-        want_bads = raw.info['bads'] + bads
-        assert raw_time.info['bads'] == want_bads
+        annot, got_bads = annotate_flat(raw_0, verbose='debug', **kwargs)
+        assert got_bads == bads
+        raw_time = raw_0.copy()
+        raw_time.set_annotations(raw_time.annotations + annot)
+        raw_time.info['bads'] += got_bads
         n_good_times = raw_time.get_data(reject_by_annotation='omit').shape[1]
         assert n_good_times == want_times
 
     with pytest.raises(TypeError, match='must be an instance of BaseRaw'):
-        mark_flat(0.)
+        annotate_flat(0.)
     with pytest.raises(ValueError, match='not convert string to float'):
-        mark_flat(raw, 'x')
+        annotate_flat(raw, 'x')
 
 
 @testing.requires_testing_data
 def test_flat_acq_skip():
     """Test that acquisition skips are handled properly."""
     raw = read_raw_fif(skip_fname).load_data()
-    n_annot = len(raw.annotations)
-    mark_flat(raw)
-    assert len(raw.annotations) == n_annot
-    assert raw.info['bads'] == [  # MaxFilter finds the same 21 channels
+    annot, bads = annotate_flat(raw)
+    assert len(annot) == 0
+    assert bads == [  # MaxFilter finds the same 21 channels
         'MEG%04d' % (int(num),) for num in
         '141 331 421 431 611 641 1011 1021 1031 1241 1421 '
         '1741 1841 2011 2131 2141 2241 2531 2541 2611 2621'.split()]