Repository: mne-tools/mne-python

Issue:
plot_evoked(gfp=True) with single-channel data behaves incorrectly
#### Problem
Plotting the GFP of data with only a single sensor via `plot_evoked(gfp=True)` produces a GFP, although the GFP – describing the spatial standard deviation – should not exist.

#### Steps to reproduce
```python
import os
import mne
sample_data_folder = mne.datasets.sample.data_path()
sample_data_raw_file = os.path.join(sample_data_folder, 'MEG', 'sample',
                                    'sample_audvis_raw.fif')
raw = mne.io.read_raw_fif(sample_data_raw_file, verbose=False)
events = mne.find_events(raw, stim_channel='STI 014')
event_dict = {'auditory/left': 1, 'auditory/right': 2, 'visual/left': 3,
              'visual/right': 4}
epochs = mne.Epochs(raw, events, tmin=-0.3, tmax=0.7, event_id=event_dict,
                    preload=True)
evoked = epochs['auditory/left'].average()

evoked.pick('EEG 001').plot(gfp=True)
```

#### Expected results
- either no GFP trace, or
- a GFP constantly at zero, or
- a warning, or
- an error message

#### Actual results
GFP is displayed as the absolute value of the sensor signal.
<img width="440" alt="Screen Shot 2021-01-22 at 16 27 48" src="https://user-images.githubusercontent.com/2046265/105510306-c69b0a80-5cce-11eb-90e5-3c628b4e2f62.png">


#### Additional information
Using `master`.


Discussion:

Resolving patch:
diff --git a/mne/viz/evoked.py b/mne/viz/evoked.py
--- a/mne/viz/evoked.py
+++ b/mne/viz/evoked.py
@@ -266,8 +266,8 @@ def _plot_evoked(evoked, picks, exclude, unit, show, ylim, proj, xlim, hline,
     if axes is not None and proj == 'interactive':
         raise RuntimeError('Currently only single axis figures are supported'
                            ' for interactive SSP selection.')
-    if isinstance(gfp, str) and gfp != 'only':
-        raise ValueError('gfp must be boolean or "only". Got %s' % gfp)
+
+    _check_option('gfp', gfp, [True, False, 'only'])
 
     scalings = _handle_default('scalings', scalings)
     titles = _handle_default('titles', titles)
@@ -428,7 +428,7 @@ def _plot_lines(data, info, picks, fig, axes, spatial_colors, unit, units,
             # Set amplitude scaling
             D = this_scaling * data[idx, :]
             _check_if_nan(D)
-            gfp_only = (isinstance(gfp, str) and gfp == 'only')
+            gfp_only = gfp == 'only'
             if not gfp_only:
                 chs = [info['chs'][i] for i in idx]
                 locs3d = np.array([ch['loc'][:3] for ch in chs])
@@ -473,10 +473,17 @@ def _plot_lines(data, info, picks, fig, axes, spatial_colors, unit, units,
                                 linewidth=0.5)[0])
                     line_list[-1].set_pickradius(3.)
 
-            if gfp:  # 'only' or boolean True
+            if gfp:
+                if gfp in [True, 'only']:
+                    if this_type == 'eeg':
+                        this_gfp = D.std(axis=0, ddof=0)
+                        label = 'GFP'
+                    else:
+                        this_gfp = np.sqrt((D ** 2).mean(axis=0))
+                        label = 'RMS'
+
                 gfp_color = 3 * (0.,) if spatial_colors is True else (0., 1.,
                                                                       0.)
-                this_gfp = np.sqrt((D * D).mean(axis=0))
                 this_ylim = ax.get_ylim() if (ylim is None or this_type not in
                                               ylim.keys()) else ylim[this_type]
                 if gfp_only:
@@ -490,7 +497,7 @@ def _plot_lines(data, info, picks, fig, axes, spatial_colors, unit, units,
                                          zorder=3, alpha=line_alpha)[0])
                 ax.text(times[0] + 0.01 * (times[-1] - times[0]),
                         this_gfp[0] + 0.05 * np.diff(ax.get_ylim())[0],
-                        'GFP', zorder=4, color=gfp_color,
+                        label, zorder=4, color=gfp_color,
                         path_effects=gfp_path_effects)
             for ii, line in zip(idx, line_list):
                 if ii in bad_ch_idx:
@@ -674,8 +681,23 @@ def plot_evoked(evoked, picks=None, exclude='bads', unit=True, show=True,
         the same length as the number of channel types. If instance of
         Axes, there must be only one channel type plotted.
     gfp : bool | 'only'
-        Plot GFP in green if True or "only". If "only", then the individual
-        channel traces will not be shown.
+        Plot the global field power (GFP) or the root mean square (RMS) of the
+        data. For MEG data, this will plot the RMS. For EEG, it plots GFP,
+        i.e. the standard deviation of the signal across channels. The GFP is
+        equivalent to the RMS of an average-referenced signal.
+
+        - ``True``
+            Plot GFP or RMS (for EEG and MEG, respectively) and traces for all
+            channels.
+        - ``'only'``
+            Plot GFP or RMS (for EEG and MEG, respectively), and omit the
+            traces for individual channels.
+
+        The color of the GFP/RMS trace will be green if
+        ``spatial_colors=False``, and black otherwise.
+
+        .. versionchanged:: 0.23
+           Plot GFP for EEG instead of RMS. Label RMS traces correctly as such.
     window_title : str | None
         The title to put at the top of the figure.
     spatial_colors : bool
diff --git a/tutorials/evoked/plot_20_visualize_evoked.py b/tutorials/evoked/plot_20_visualize_evoked.py
--- a/tutorials/evoked/plot_20_visualize_evoked.py
+++ b/tutorials/evoked/plot_20_visualize_evoked.py
@@ -75,7 +75,12 @@
 # select channels to plot by name, index, or type. In the next plot we'll show
 # only magnetometer channels, and also color-code the channel traces by their
 # location by passing ``spatial_colors=True``. Finally, we'll superimpose a
-# trace of the :term:`global field power <GFP>` across channels:
+# trace of the root mean square (RMS) of the signal across channels by
+# passing ``gfp=True``. This parameter is called ``gfp`` for historical
+# reasons and behaves correctly for all supported channel types: for MEG data,
+# it will plot the RMS; while for EEG, it would plot the
+# :term:`global field power <GFP>` (an average-referenced RMS), hence its
+# name:
 
 evks['aud/left'].plot(picks='mag', spatial_colors=True, gfp=True)
 
diff --git a/tutorials/evoked/plot_eeg_erp.py b/tutorials/evoked/plot_eeg_erp.py
--- a/tutorials/evoked/plot_eeg_erp.py
+++ b/tutorials/evoked/plot_eeg_erp.py
@@ -10,6 +10,8 @@
 
 """
 
+import matplotlib.pyplot as plt
+
 import mne
 from mne.datasets import sample
 from mne.channels import combine_channels
@@ -108,6 +110,45 @@
 evoked_custom.plot(titles=dict(eeg=title), time_unit='s')
 evoked_custom.plot_topomap(times=[0.1], size=3., title=title, time_unit='s')
 
+###############################################################################
+# Global field power (GFP)
+# ------------------------
+#
+# Global field power :footcite:`Lehmann1980,Lehmann1984,Murray2008` is,
+# generally speaking, a measure of agreement of the signals picked up by all
+# sensors across the entire scalp: if all sensors have the same value at a
+# given time point, the GFP will be zero at that time point; if the signals
+# differ, the GFP will be non-zero at that time point. GFP
+# peaks may reflect "interesting" brain activity, warranting further
+# investigation. Mathematically, the GFP is the population standard
+# deviation across all sensors, calculated separately for every time point.
+#
+# You can plot the GFP using `evoked.plot(gfp=True) <mne.Evoked.plot>`. The GFP
+# trace will be black if ``spatial_colors=True`` and green otherwise. The EEG
+# reference will not affect the GFP:
+
+for evk in (evoked_car, evoked_no_ref):
+    evk.plot(gfp=True, spatial_colors=True, ylim=dict(eeg=[-10, 10]))
+
+###############################################################################
+# To plot the GFP by itself you can pass ``gfp='only'`` (this makes it easier
+# to read off the GFP data values, because the scale is aligned):
+
+evoked_car.plot(gfp='only')
+
+###############################################################################
+# As stated above, the GFP is the population standard deviation of the signal
+# across channels. To compute it manually, we can leverage
+# the fact that `evoked.data <mne.Evoked.data>` is a NumPy array:
+
+gfp = evoked_car.data.std(axis=0, ddof=0)
+
+# Reproducing the plot style from above:
+fig, ax = plt.subplots()
+ax.plot(evoked_car.times, gfp * 1e6, color='lime')
+ax.fill_between(evoked_car.times, gfp * 1e6, color='lime', alpha=0.2)
+ax.set(xlabel='Time (s)', ylabel='GFP (µV)', title='EEG')
+
 ###############################################################################
 # Evoked response averaged across channels by ROI
 # -----------------------------------------------
@@ -200,3 +241,9 @@
 # Besides for explicit access, this can be used for example to set titles.
 for cond in all_evokeds:
     all_evokeds[cond].plot_joint(title=cond, **joint_kwargs)
+
+
+##############################################################################
+# References
+# ----------
+# .. footbibliography::


Test patch:
diff --git a/mne/viz/tests/test_evoked.py b/mne/viz/tests/test_evoked.py
--- a/mne/viz/tests/test_evoked.py
+++ b/mne/viz/tests/test_evoked.py
@@ -39,7 +39,8 @@
 
 # Use a subset of channels for plotting speed
 # make sure we have a magnetometer and a pair of grad pairs for topomap.
-default_picks = (0, 1, 2, 3, 4, 6, 7, 61, 122, 183, 244, 305)
+default_picks = (0, 1, 2, 3, 4, 6, 7, 61, 122, 183, 244, 305,
+                 315, 316, 317, 318)  # EEG channels
 sel = (0, 7)
 
 
@@ -50,7 +51,10 @@ def _get_epochs(picks=default_picks):
     events = read_events(event_name)
     epochs = Epochs(raw, events[:5], event_id, tmin, tmax, picks=picks,
                     decim=10, verbose='error')
-    epochs.info['bads'] = [epochs.ch_names[-1]]
+    epochs.info['bads'] = [
+        epochs.ch_names[-5],  # MEG
+        epochs.ch_names[-1]   # EEG
+    ]
     epochs.info.normalize_proj()
     return epochs
 
@@ -72,7 +76,8 @@ def test_plot_evoked_cov():
     evoked = _get_epochs().average()
     cov = read_cov(cov_fname)
     cov['projs'] = []  # avoid warnings
-    evoked.plot(noise_cov=cov, time_unit='s')
+    with pytest.warns(RuntimeWarning, match='No average EEG reference'):
+        evoked.plot(noise_cov=cov, time_unit='s')
     with pytest.raises(TypeError, match='Covariance'):
         evoked.plot(noise_cov=1., time_unit='s')
     with pytest.raises(IOError, match='No such file'):
@@ -96,7 +101,7 @@ def test_plot_evoked():
     fig = evoked.plot(proj=True, hline=[1], exclude=[], window_title='foo',
                       time_unit='s')
     amplitudes = _get_amplitudes(fig)
-    assert len(amplitudes) == 12
+    assert len(amplitudes) == len(default_picks)
     assert evoked.proj is False
     # Test a click
     ax = fig.get_axes()[0]
@@ -122,9 +127,26 @@ def test_plot_evoked():
                   proj='interactive', axes='foo', time_unit='s')
     plt.close('all')
 
-    # test GFP only
-    evoked.plot(gfp='only', time_unit='s')
-    pytest.raises(ValueError, evoked.plot, gfp='foo', time_unit='s')
+    # test `gfp='only'`: GFP (EEG) and RMS (MEG)
+    fig, ax = plt.subplots(3)
+    evoked.plot(gfp='only', time_unit='s', axes=ax)
+
+    assert len(ax[0].lines) == len(ax[1].lines) == len(ax[2].lines) == 1
+
+    assert ax[0].get_title() == 'EEG (3 channels)'
+    assert ax[0].texts[0].get_text() == 'GFP'
+
+    assert ax[1].get_title() == 'Gradiometers (9 channels)'
+    assert ax[1].texts[0].get_text() == 'RMS'
+
+    assert ax[2].get_title() == 'Magnetometers (2 channels)'
+    assert ax[1].texts[0].get_text() == 'RMS'
+
+    plt.close('all')
+
+    # Test invalid `gfp`
+    with pytest.raises(ValueError):
+        evoked.plot(gfp='foo', time_unit='s')
 
     # plot with bad channels excluded, spatial_colors, zorder & pos. layout
     evoked.rename_channels({'MEG 0133': 'MEG 0000'})
@@ -165,9 +187,9 @@ def _get_amplitudes(fig):
 
 
 @pytest.mark.parametrize('picks, rlims, avg_proj', [
-    (default_picks, (0.59, 0.61), False),  # MEG
-    (np.arange(340, 360), (0.49, 0.51), True),  # EEG
-    (np.arange(340, 360), (0.78, 0.80), False),  # EEG
+    (default_picks[:-4], (0.59, 0.61), False),  # MEG
+    (np.arange(340, 360), (0.56, 0.57), True),  # EEG
+    (np.arange(340, 360), (0.79, 0.81), False),  # EEG
 ])
 def test_plot_evoked_reconstruct(picks, rlims, avg_proj):
     """Test proj="reconstruct"."""
@@ -266,23 +288,25 @@ def test_plot_white():
     cov['method'] = 'empirical'
     cov['projs'] = []  # avoid warnings
     evoked = _get_epochs().average()
+    evoked.set_eeg_reference('average')  # Avoid warnings
+
     # test rank param.
-    evoked.plot_white(cov, rank={'mag': 101, 'grad': 201}, time_unit='s')
+    evoked.plot_white(cov, rank={'mag': 101, 'grad': 201, 'eeg': 10},
+                      time_unit='s')
     fig = evoked.plot_white(cov, rank={'mag': 101}, time_unit='s')  # test rank
     evoked.plot_white(cov, rank={'grad': 201}, time_unit='s', axes=fig.axes)
-    with pytest.raises(ValueError, match=r'must have shape \(3,\), got \(2,'):
+    with pytest.raises(ValueError, match=r'must have shape \(4,\), got \(2,'):
         evoked.plot_white(cov, axes=fig.axes[:2])
     with pytest.raises(ValueError, match='When not using SSS'):
         evoked.plot_white(cov, rank={'meg': 306})
     evoked.plot_white([cov, cov], time_unit='s')
     plt.close('all')
 
-    assert 'eeg' not in evoked
     fig = plot_evoked_white(evoked, [cov, cov])
-    assert len(fig.axes) == 2 * 2
-    axes = np.array(fig.axes).reshape(2, 2)
+    assert len(fig.axes) == 3 * 2
+    axes = np.array(fig.axes).reshape(3, 2)
     plot_evoked_white(evoked, [cov, cov], axes=axes)
-    with pytest.raises(ValueError, match=r'have shape \(2, 2\), got'):
+    with pytest.raises(ValueError, match=r'have shape \(3, 2\), got'):
         plot_evoked_white(evoked, [cov, cov], axes=axes[:, :1])
 
     # Hack to test plotting of maxfiltered data
@@ -301,7 +325,7 @@ def test_plot_compare_evokeds():
     evoked = _get_epochs().average()
     # test defaults
     figs = plot_compare_evokeds(evoked)
-    assert len(figs) == 2
+    assert len(figs) == 3
     # test picks, combine, and vlines (1-channel pick also shows sensor inset)
     picks = ['MEG 0113', 'mag'] + 2 * [['MEG 0113', 'MEG 0112']] + [[0, 1]]
     vlines = [[0.1, 0.2], []] + 3 * ['auto']