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:
hoechenberger: @larsoner i don't know when I'll will have time to test this, don't wait for me 🥹
larsoner: On `main`:
```
import mne
raw = mne.io.read_raw_fif(mne.datasets.sample.data_path() / "MEG" / "sample" / "sample_audvis_filt-0-40_raw.fif")
raw.pick("eeg", exclude=()).load_data()
raw.plot_psd(exclude=())
```
gives:

![Screenshot from 2023-10-26 11-51-49](https://github.com/mne-tools/mne-python/assets/2365790/002014d0-1186-42a1-9028-7971733d9e29)

On this PR it's:

![Screenshot from 2023-10-26 11-55-43](https://github.com/mne-tools/mne-python/assets/2365790/0967cbd0-57b9-4252-8774-c8565a4927c7)

There is a bug on `main` where `bad_ch_idx=[]` so the dashed-ness was never applied. But I like that in this PR that is applied *and* it's gray -- I think it works well.

With the gray we could consider getting rid of the dashing which makes the bad one easier to resolve:

![Screenshot from 2023-10-26 11-56-49](https://github.com/mne-tools/mne-python/assets/2365790/bb934895-c8ea-4c0b-a6c8-15811011bbf5)


hoechenberger: I like the last figure you posted!
larsoner: Ready for review/merge from my end.

When I modified the Report tutorial with dashing turned off it [looked like this](https://output.circle-artifacts.com/output/job/1b0c282a-66cf-4018-a4bc-ad73c72fca8e/artifacts/0/html/auto_tutorials/intro/report_epochs.html#Epochs_from__epochs_). So I decided in the end to keep it to make it [slightly more differentiable](https://output.circle-artifacts.com/output/job/e48355d8-33c3-46b4-8337-81cbc2b3ae13/artifacts/0/html/auto_tutorials/intro/report_epochs.html#Epochs_from__epochs_).


Patch:
diff --git a/doc/changes/devel.rst b/doc/changes/devel.rst
index 5457e844f..c52705a9a 100644
--- a/doc/changes/devel.rst
+++ b/doc/changes/devel.rst
@@ -38,6 +38,7 @@ Enhancements
 - Add support for writing forward solutions to HDF5 and convenience function :meth:`mne.Forward.save` (:gh:`12036` by `Eric Larson`_)
 - Refactored internals of :func:`mne.read_annotations` (:gh:`11964` by `Paul Roujansky`_)
 - Add support for drawing MEG sensors in :ref:`mne coreg` (:gh:`12098` by `Eric Larson`_)
+- Bad channels are now colored gray in addition to being dashed when spatial colors are used in :func:`mne.viz.plot_evoked` and related functions (:gh:`12142` by `Eric Larson`_)
 - By default MNE-Python creates matplotlib figures with ``layout='constrained'`` rather than the default ``layout='tight'`` (:gh:`12050`, :gh:`12103` by `Mathieu Scheltienne`_ and `Eric Larson`_)
 - Enhance :func:`~mne.viz.plot_evoked_field` with a GUI that has controls for time, colormap, and contour lines (:gh:`11942` by `Marijn van Vliet`_)
 - Add :class:`mne.viz.ui_events.UIEvent` linking for interactive colorbars, allowing users to link figures and change the colormap and limits interactively. This supports :func:`~mne.viz.plot_evoked_topomap`, :func:`~mne.viz.plot_ica_components`, :func:`~mne.viz.plot_tfr_topomap`, :func:`~mne.viz.plot_projs_topomap`, :meth:`~mne.Evoked.plot_image`, and :meth:`~mne.Epochs.plot_image` (:gh:`12057` by `Santeri Ruuskanen`_)
@@ -71,6 +72,7 @@ Bugs
 - Fix :func:`~mne.viz.plot_volume_source_estimates` with :class:`~mne.VolSourceEstimate` which include a list of vertices (:gh:`12025` by `Mathieu Scheltienne`_)
 - Add support for non-ASCII characters in Annotations, Evoked comments, etc when saving to FIFF format (:gh:`12080` by `Daniel McCloy`_)
 - Correctly handle passing ``"eyegaze"`` or ``"pupil"`` to :meth:`mne.io.Raw.pick` (:gh:`12019` by `Scott Huberty`_)
+- Fix bug with :func:`mne.time_frequency.Spectrum.plot` and related functions where bad channels were not marked (:gh:`12142` by `Eric Larson`_)
 - Fix bug with :func:`~mne.viz.plot_raw` where changing ``MNE_BROWSER_BACKEND`` via :func:`~mne.set_config` would have no effect within a Python session (:gh:`12078` by `Santeri Ruuskanen`_)
 - Improve handling of ``method`` argument in the channel interpolation function to support :class:`str` and raise helpful error messages (:gh:`12113` by `Mathieu Scheltienne`_)
 - Fix combination of ``DIN`` event channels into a single synthetic trigger channel ``STI 014`` by the MFF reader of :func:`mne.io.read_raw_egi` (:gh:`12122` by `Mathieu Scheltienne`_)
diff --git a/mne/viz/evoked.py b/mne/viz/evoked.py
index 6abcbcc0d..1c6712a6b 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
diff --git a/mne/viz/utils.py b/mne/viz/utils.py
index c81bdf354..e9c36281b 100644
--- 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
index a7d3b02b2..951c82a5e 100644
--- a/tutorials/intro/70_report.py
+++ b/tutorials/intro/70_report.py
@@ -17,8 +17,7 @@ Compared to a Jupyter notebook, :class:`mne.Report` is easier to deploy, as the
 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
index 7aaa5b40e..96fe89a2e 100644
--- 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
index c089b064d..51b83f222 100644
--- a/mne/viz/tests/test_evoked.py
+++ b/mne/viz/tests/test_evoked.py
@@ -16,6 +16,7 @@ import numpy as np
 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]