Repository: mne-tools/mne-python

Issue:
Should PSD plots in the report contain bad channels by default?
Since we switched from plot_psd() to compute_psd().plot(), PSD plots in the Report contain bad channels. Is this a default we want to keep? I was using MNE-BIDS-Pipeline and triple-checked my data until I realized what's going on. (I thought something was wrong with my BIDS conversion) 
So even if we keep this behavior for Report, we should reconsider it for MNE-BIDS-Pipeline (and transfer this issue if needed)


Discussion:
One argument for excluding bads is that the PSDs are added as sub-sections for the corresponding Raw and Epochs instances, where the data traces are currently not corresponding to the PSD since the former do not contain bads ... sorry about the phrasing, I'm a bit in a rush right now 🥵
> Since we switched from plot_psd() to compute_psd().plot(), PSD plots in the Report contain bad channels

actually the change was here: https://github.com/mne-tools/mne-python/pull/11680 and the motivation was a dev-meeting discussion where I think @agramfort made the point that PSDs are often used for data quality checks and thus showing bads is a useful default to have.

> Is this a default we want to keep?

I think so. And after #12006 it should now be possible to specify `exclude="bads"` either when creating the spectrum or when plotting.
Yes, I'm using the PSD plots myself for data quality checks and I can see that for interactive use, showing the bads by default can make sense.

But for the reports, the data has usually already been inspected or processed by the time it gets added to the report. Now the issue is that the bad channels are not visually distinct from the non-bads, and since the PSD plot in the report is not interactive, there is no way to figure out (post-hoc, when looking at the report) whether a channel with an odd PSD has actually been marked as bad or not.

This is especially problematic when using MNE-BIDS-Pipeline, where one cannot be sure which channels actually entered analysis…

The PSD plots in Report I'm talking about are auto-generated when calling`Report.add_raw(..., psd=True)` and `Report.add_epochs(..., psd=True)`; that is, they are generated automatically and added below `raw.plot()` and `epochs.plot_image()` – both of which ignore bads by default, which makes sense. Now, the PSD plots don't correspond to those figures anymore, because a different set of channels is included.

Proposals:

- in Report, don't show bads in PSD by default for `add_raw()` and `add_epochs()`, or
- at least in MNE-BIDS-Pipeline reports, don't show bads in PSD by default or
- plot PSD traces of bad channels in a different color so they're easy to spot


> * in Report, don't show bads in PSD by default for `add_raw()` and `add_epochs()`

This is the approach I would take. Sorry, I think I misunderstood your earlier post as suggesting to change the method default, not just the default within report contexts 
Changing the `report.add_raw/epochs(..., psd=True)` behavior sounds reasonable to me. But I think really some variant of this would be the best solution:

> plot PSD traces of bad channels in a different color so they're easy to spot

Given that we use `spatial_colors` (and that this is useful) I'm not totally sure what would work. One option would be to use middle gray since it would stand out as the only `saturation=0` color. (And in cases where spatial colors are not used then we can use red as usual, which I think is what `evoked.plot(spatial_colors=False)` does for example.) Maybe we should look to see how this looks with `evoked.plot()` butterfly for example where `spatial_colors=True`. I don't actually know what we do for a bad color there (?) but gray might be easy enough to spot as bad in the middle of the channel rainbow.
@larsoner
If finding a suitable color turns out to be a problem, we could consider selecting a different line style (dashed?) maybe?
I thought about this but it can be really hard to follow dotted or dashed lines when the lines are squiggly, and evoked/psd lines can be quite squiggly
In that case – maybe a very red red? :) I don't think we use that as a spatial color, do we?
> In that case – maybe a very red red? :)

We use a whole bunch of colors, including red. :( I think it would be hard to find a high-saturation color that stands out, hence the idea to use a color with saturation zero.

I quickly modified `mne/viz/evoked.py` to try middle gray. Looks like we might already be setting the line style in `evoked.plot` to be an acceptable dashing, too (I didn't set it), but I think the middle gray also helps distinguish it further:

<details>
<summary>Patch</summary>

```
diff --git a/mne/viz/evoked.py b/mne/viz/evoked.py
index 6abcbcc0d1..1c6712a6be 100644
--- a/mne/viz/evoked.py
+++ b/mne/viz/evoked.py
@@ -679,15 +679,17 @@ def _plot_lines(
                     _handle_spatial_colors(
                         colors, info, idx, this_type, psd, ax, sphere
                     )
+                    bad_color = (0.5, 0.5, 0.5)
                 else:
                     if isinstance(_spat_col, (tuple, str)):
                         col = [_spat_col]
                     else:
                         col = ["k"]
+                    bad_color = "r"
                     colors = col * len(idx)
-                    for i in bad_ch_idx:
-                        if i in idx:
-                            colors[idx.index(i)] = "r"
+                for i in bad_ch_idx:
+                    if i in idx:
+                        colors[idx.index(i)] = bad_color
 
                 if zorder == "std":
                     # find the channels with the least activity

```

</details>

```
import mne
evoked = mne.read_evokeds(mne.datasets.sample.data_path() / "MEG" / "sample" / "sample_audvis-ave.fif", baseline=(None, 0))[0]
evoked.pick("eeg", exclude=())
evoked.plot(spatial_colors=True, exclude=())
```
![Screenshot from 2023-10-26 09-26-04](https://github.com/mne-tools/mne-python/assets/2365790/a34e8b69-83cb-4e6a-9b4a-3aceb853e6a7)

Zoomed:

![Screenshot from 2023-10-26 09-27-57](https://github.com/mne-tools/mne-python/assets/2365790/302e5bca-d5f4-4f8f-a25a-220e405ca7a3)

And here is spatial_colors=False:

![Screenshot from 2023-10-26 09-24-36](https://github.com/mne-tools/mne-python/assets/2365790/599a8c9f-c395-4015-b384-1ea615d11e94)
> I think it would be hard to find a high-saturation color that stands out, hence the idea to use a color with saturation zero.

Thinking about it again, it doesn't need to stand out – because figuring "oh, that's the channel I already marked as bad!" only becomes relevant if that channel has an odd (very high or low power) spectrum which clearly separates it from the other traces anyway

Otherwise you wouldn't really care, I suppose
Sorry I mean "stands out" as in "is easy to tell that it's different/separate from the good, spatial-color-mapped channels", not "pops out as prominent". Just that we need them to be differentiable in some way

Resolving patch:
diff --git a/mne/viz/evoked.py b/mne/viz/evoked.py
--- a/mne/viz/evoked.py
+++ b/mne/viz/evoked.py
@@ -679,15 +679,17 @@ def _plot_lines(
                     _handle_spatial_colors(
                         colors, info, idx, this_type, psd, ax, sphere
                     )
+                    bad_color = (0.5, 0.5, 0.5)
                 else:
                     if isinstance(_spat_col, (tuple, str)):
                         col = [_spat_col]
                     else:
                         col = ["k"]
+                    bad_color = "r"
                     colors = col * len(idx)
-                    for i in bad_ch_idx:
-                        if i in idx:
-                            colors[idx.index(i)] = "r"
+                for i in bad_ch_idx:
+                    if i in idx:
+                        colors[idx.index(i)] = bad_color
 
                 if zorder == "std":
                     # find the channels with the least activity
diff --git a/mne/viz/utils.py b/mne/viz/utils.py
--- a/mne/viz/utils.py
+++ b/mne/viz/utils.py
@@ -2506,6 +2506,7 @@ def _plot_psd(
     if not average:
         picks = np.concatenate(picks_list)
         info = pick_info(inst.info, sel=picks, copy=True)
+        bad_ch_idx = [info["ch_names"].index(ch) for ch in info["bads"]]
         types = np.array(info.get_channel_types())
         ch_types_used = list()
         for this_type in _VALID_CHANNEL_TYPES:
@@ -2538,7 +2539,7 @@ def _plot_psd(
             xlim=(freqs[0], freqs[-1]),
             ylim=None,
             times=freqs,
-            bad_ch_idx=[],
+            bad_ch_idx=bad_ch_idx,
             titles=titles,
             ch_types_used=ch_types_used,
             selectable=True,
diff --git a/tutorials/intro/70_report.py b/tutorials/intro/70_report.py
--- a/tutorials/intro/70_report.py
+++ b/tutorials/intro/70_report.py
@@ -17,8 +17,7 @@
 HTML pages it generates are self-contained and do not require a running Python
 environment. However, it is less flexible as you can't change code and re-run
 something directly within the browser. This tutorial covers the basics of
-building a report. As usual, we will start by importing the modules and data we
-need:
+building a report. As usual, we will start by importing the modules and data we need:
 """
 
 # %%


Test patch:
diff --git a/mne/time_frequency/tests/test_spectrum.py b/mne/time_frequency/tests/test_spectrum.py
--- a/mne/time_frequency/tests/test_spectrum.py
+++ b/mne/time_frequency/tests/test_spectrum.py
@@ -1,9 +1,9 @@
 from contextlib import nullcontext
 from functools import partial
 
-import matplotlib.pyplot as plt
 import numpy as np
 import pytest
+from matplotlib.colors import same_color
 from numpy.testing import assert_allclose, assert_array_equal
 
 from mne import Annotations, create_info, make_fixed_length_epochs
@@ -449,8 +449,16 @@ def test_plot_spectrum(kind, array, request):
         data, freqs = spectrum.get_data(return_freqs=True)
         Klass = SpectrumArray if kind == "raw" else EpochsSpectrumArray
         spectrum = Klass(data=data, info=spectrum.info, freqs=freqs)
+    spectrum.info["bads"] = spectrum.ch_names[:1]  # one grad channel
     spectrum.plot(average=True, amplitude=True, spatial_colors=True)
-    spectrum.plot(average=False, amplitude=False, spatial_colors=False)
+    spectrum.plot(average=True, amplitude=False, spatial_colors=False)
+    n_grad = sum(ch_type == "grad" for ch_type in spectrum.get_channel_types())
+    for amp, sc in ((True, True), (False, False)):
+        fig = spectrum.plot(average=False, amplitude=amp, spatial_colors=sc, exclude=())
+        lines = fig.axes[0].lines[2:]  # grads, ignore two vlines
+        assert len(lines) == n_grad
+        bad_color = "0.5" if sc else "r"
+        n_bad = sum(same_color(line.get_color(), bad_color) for line in lines)
+        assert n_bad == 1
     spectrum.plot_topo()
     spectrum.plot_topomap()
-    plt.close("all")
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
@@ -16,6 +16,7 @@
 import pytest
 from matplotlib import gridspec
 from matplotlib.collections import PolyCollection
+from matplotlib.colors import same_color
 from mpl_toolkits.axes_grid1.parasite_axes import HostAxes  # spatial_colors
 from numpy.testing import assert_allclose
 
@@ -134,6 +135,12 @@ def test_plot_evoked():
     amplitudes = _get_amplitudes(fig)
     assert len(amplitudes) == len(default_picks)
     assert evoked.proj is False
+    assert evoked.info["bads"] == ["MEG 2641", "EEG 004"]
+    eeg_lines = fig.axes[2].lines
+    n_eeg = sum(ch_type == "eeg" for ch_type in evoked.get_channel_types())
+    assert len(eeg_lines) == n_eeg == 4
+    n_bad = sum(same_color(line.get_color(), "0.5") for line in eeg_lines)
+    assert n_bad == 1
     # Test a click
     ax = fig.get_axes()[0]
     line = ax.lines[0]