Repository: mne-tools/mne-python

Issue:
BUG: Spectrum should only warn on zero spectrum for non-bads
CircleCI just [hit](https://app.circleci.com/pipelines/github/mne-tools/mne-python/21221/workflows/00fe92c1-8ad8-46c3-b797-d8fda6ab4fce/jobs/60092) this:
```
/home/circleci/project/tutorials/preprocessing/50_artifact_correction_ssp.py failed leaving traceback:
Traceback (most recent call last):
  File "/home/circleci/project/tutorials/preprocessing/50_artifact_correction_ssp.py", line 109, in <module>
    spectrum = empty_room_raw.compute_psd()
  File "<decorator-gen-292>", line 12, in compute_psd
  File "/home/circleci/project/mne/io/base.py", line 2181, in compute_psd
    return Spectrum(
  File "/home/circleci/project/mne/time_frequency/spectrum.py", line 1172, in __init__
    self._check_values()
  File "/home/circleci/project/mne/time_frequency/spectrum.py", line 454, in _check_values
    warn(f'Zero value in spectrum for channel{s} {", ".join(chs)}', UserWarning)
  File "/home/circleci/project/mne/utils/_logging.py", line 414, in warn
    warnings.warn_explicit(
UserWarning: Zero value in spectrum for channel MEG 2443
```
I am pretty sure this channel is marked as bad in the data. It seems like spectrum should perhaps not warn about zero or nan/inf values if a channel is bad.

If we go this route we can undo two `verbose="error"`s introduced in https://github.com/mne-tools/mne-python/pull/12074

Discussion:

Patch:
diff --git a/doc/changes/devel.rst b/doc/changes/devel.rst
index d11bb3ad6..d5089fd95 100644
--- a/doc/changes/devel.rst
+++ b/doc/changes/devel.rst
@@ -47,17 +47,20 @@ Enhancements
 - :func:`~mne.epochs.make_metadata` now accepts ``tmin=None`` and ``tmax=None``, which will bound the time window used for metadata generation by event names (instead of a fixed time). That way, you can now for example generate metadata spanning from one cue or fixation cross to the next, even if trial durations vary throughout the recording (:gh:`12118` by `Richard Höchenberger`_)
 - Add support for passing multiple labels to :func:`mne.minimum_norm.source_induced_power` (:gh:`12026` by `Erica Peterson`_, `Eric Larson`_, and `Daniel McCloy`_ )
 - Added documentation to :meth:`mne.io.Raw.set_montage` and :func:`mne.add_reference_channels` to specify that montages should be set after adding reference channels (:gh:`12160` by `Jacob Woessner`_)
+- Add argument ``splash`` to the function using the ``qt`` browser backend to allow enabling/disabling the splash screen (:gh:`12185` by `Mathieu Scheltienne`_)
 
 Bugs
 ~~~~
-- Fix bug where :func:`mne.io.read_raw_gdf` would fail due to improper usage of ``np.clip`` (:gh:`12168` by :newcontrib:`Rasmus Aagaard`) 
+- Fix bug where :func:`mne.io.read_raw_gdf` would fail due to improper usage of ``np.clip`` (:gh:`12168` by :newcontrib:`Rasmus Aagaard`)
 - Fix bugs with :func:`mne.preprocessing.realign_raw` where the start of ``other`` was incorrectly cropped; and onsets and durations in ``other.annotations`` were left unsynced with the resampled data (:gh:`11950` by :newcontrib:`Qian Chu`)
 - Fix bug where ``encoding`` argument was ignored when reading annotations from an EDF file (:gh:`11958` by :newcontrib:`Andrew Gilbert`)
 - Mark tests ``test_adjacency_matches_ft`` and ``test_fetch_uncompressed_file`` as network tests (:gh:`12041` by :newcontrib:`Maksym Balatsko`)
 - Fix bug with :func:`mne.channels.read_ch_adjacency` (:gh:`11608` by :newcontrib:`Ivan Zubarev`)
 - Fix bugs with saving splits for :class:`~mne.Epochs` (:gh:`11876` by `Dmitrii Altukhov`_)
 - Fix bug with multi-plot 3D rendering where only one plot was updated (:gh:`11896` by `Eric Larson`_)
+- Fix bug where ``verbose`` level was not respected inside parallel jobs (:gh:`12154` by `Eric Larson`_)
 - Fix bug where subject birthdays were not correctly read by :func:`mne.io.read_raw_snirf` (:gh:`11912` by `Eric Larson`_)
+- Fix bug where warnings were emitted when computing spectra for channels marked as bad (:gh:`12186` by `Eric Larson`_)
 - Fix bug with :func:`mne.chpi.compute_head_pos` for CTF data where digitization points were modified in-place, producing an incorrect result during a save-load round-trip (:gh:`11934` by `Eric Larson`_)
 - Fix bug where non-compliant stimulus data streams were not ignored by :func:`mne.io.read_raw_snirf` (:gh:`11915` by `Johann Benerradi`_)
 - Fix bug with ``pca=False`` in :func:`mne.minimum_norm.compute_source_psd` (:gh:`11927` by `Alex Gramfort`_)
@@ -69,7 +72,7 @@ Bugs
 - Fix bug with reported component number and errant reporting of PCA explained variance as ICA explained variance in :meth:`mne.Report.add_ica` (:gh:`12155`, :gh:`12167` by `Eric Larson`_ and `Richard Höchenberger`_)
 - Fix bug with axis clip box boundaries in :func:`mne.viz.plot_evoked_topo` and related functions (:gh:`11999` by `Eric Larson`_)
 - Fix bug with ``subject_info`` when loading data from and exporting to EDF file (:gh:`11952` by `Paul Roujansky`_)
-- Fix bug where :class:`mne.Info` HTML representations listed all channel counts instead of good channel counts under the heading "Good channels" (:gh:`12145` by `Eric Larson`_) 
+- Fix bug where :class:`mne.Info` HTML representations listed all channel counts instead of good channel counts under the heading "Good channels" (:gh:`12145` by `Eric Larson`_)
 - Fix rendering glitches when plotting Neuromag/TRIUX sensors in :func:`mne.viz.plot_alignment` and related functions (:gh:`12098` by `Eric Larson`_)
 - Fix bug with delayed checking of :class:`info["bads"] <mne.Info>` (:gh:`12038` by `Eric Larson`_)
 - Fix bug with :ref:`mne coreg` where points inside the head surface were not shown (:gh:`12147`, :gh:`12164` by `Eric Larson`_)
@@ -85,7 +88,7 @@ Bugs
 - 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`_)
-- Fix bug with :func:`mne.io.read_raw_eeglab` and :func:`mne.read_epochs_eeglab` where automatic fiducial detection would fail for certain files (:gh:`12165` by `Clemens Brunner`_) 
+- Fix bug with :func:`mne.io.read_raw_eeglab` and :func:`mne.read_epochs_eeglab` where automatic fiducial detection would fail for certain files (:gh:`12165` by `Clemens Brunner`_)
 
 API changes
 ~~~~~~~~~~~
diff --git a/mne/epochs.py b/mne/epochs.py
index 510161f99..50ecf10ee 100644
--- a/mne/epochs.py
+++ b/mne/epochs.py
@@ -1299,6 +1299,7 @@ class BaseEpochs(
         *,
         theme=None,
         overview_mode=None,
+        splash=True,
     ):
         return plot_epochs(
             self,
@@ -1324,6 +1325,7 @@ class BaseEpochs(
             use_opengl=use_opengl,
             theme=theme,
             overview_mode=overview_mode,
+            splash=splash,
         )
 
     @copy_function_doc_to_method_doc(plot_topo_image_epochs)
diff --git a/mne/io/base.py b/mne/io/base.py
index 4c1d13ffa..658eb0e4e 100644
--- a/mne/io/base.py
+++ b/mne/io/base.py
@@ -1801,6 +1801,7 @@ class BaseRaw(
         *,
         theme=None,
         overview_mode=None,
+        splash=True,
         verbose=None,
     ):
         return plot_raw(
@@ -1838,6 +1839,7 @@ class BaseRaw(
             use_opengl=use_opengl,
             theme=theme,
             overview_mode=overview_mode,
+            splash=splash,
             verbose=verbose,
         )
 
diff --git a/mne/parallel.py b/mne/parallel.py
index 2598d93ae..bb8a14d38 100644
--- a/mne/parallel.py
+++ b/mne/parallel.py
@@ -14,6 +14,7 @@ from .utils import (
     _validate_type,
     get_config,
     logger,
+    use_log_level,
     verbose,
     warn,
 )
@@ -120,7 +121,12 @@ def parallel_func(
         logger.debug(f"Got {n_jobs} parallel jobs after requesting {n_jobs_orig}")
         if max_jobs is not None:
             n_jobs = min(n_jobs, max(_ensure_int(max_jobs, "max_jobs"), 1))
-        my_func = delayed(func)
+
+        def run_verbose(*args, verbose=logger.level, **kwargs):
+            with use_log_level(verbose=verbose):
+                return func(*args, **kwargs)
+
+        my_func = delayed(run_verbose)
 
     if total is not None:
 
diff --git a/mne/preprocessing/ica.py b/mne/preprocessing/ica.py
index c0139427a..df1952669 100644
--- a/mne/preprocessing/ica.py
+++ b/mne/preprocessing/ica.py
@@ -2552,6 +2552,7 @@ class ICA(ContainsMixin):
         *,
         theme=None,
         overview_mode=None,
+        splash=True,
     ):
         return plot_ica_sources(
             self,
@@ -2569,6 +2570,7 @@ class ICA(ContainsMixin):
             use_opengl=use_opengl,
             theme=theme,
             overview_mode=overview_mode,
+            splash=splash,
         )
 
     @copy_function_doc_to_method_doc(plot_ica_scores)
diff --git a/mne/time_frequency/spectrum.py b/mne/time_frequency/spectrum.py
index 3d6acbc39..c39d5f8a0 100644
--- a/mne/time_frequency/spectrum.py
+++ b/mne/time_frequency/spectrum.py
@@ -439,15 +439,17 @@ class BaseSpectrum(ContainsMixin, UpdateChannelsMixin):
         """Check PSD results for correct shape and bad values."""
         assert len(self._dims) == self._data.ndim, (self._dims, self._data.ndim)
         assert self._data.shape == self._shape
-        # negative values OK if the spectrum is really fourier coefficients
-        if "taper" in self._dims:
-            return
         # TODO: should this be more fine-grained (report "chan X in epoch Y")?
         ch_dim = self._dims.index("channel")
-        dims = np.arange(self._data.ndim).tolist()
+        dims = list(range(self._data.ndim))
         dims.pop(ch_dim)
         # take min() across all but the channel axis
-        bad_value = self._data.min(axis=tuple(dims)) <= 0
+        # (if the abs becomes memory intensive we could iterate over channels)
+        use_data = self._data
+        if use_data.dtype.kind == "c":
+            use_data = np.abs(use_data)
+        bad_value = use_data.min(axis=tuple(dims)) == 0
+        bad_value &= ~np.isin(self.ch_names, self.info["bads"])
         if bad_value.any():
             chs = np.array(self.ch_names)[bad_value].tolist()
             s = _pl(bad_value.sum())
diff --git a/mne/utils/docs.py b/mne/utils/docs.py
index 7875f83fe..573066ca3 100644
--- a/mne/utils/docs.py
+++ b/mne/utils/docs.py
@@ -4201,6 +4201,14 @@ sphere : float | array-like | instance of ConductorModel | None  | 'auto' | 'eeg
     .. versionchanged:: 1.1 Added ``'eeglab'`` option.
 """
 
+docdict[
+    "splash"
+] = """
+splash : bool
+    If True (default), a splash screen is shown during the application startup. Only
+    applicable to the ``qt`` backend.
+"""
+
 docdict[
     "split_naming"
 ] = """
diff --git a/mne/viz/_figure.py b/mne/viz/_figure.py
index f53e079c6..7bd58f4ee 100644
--- a/mne/viz/_figure.py
+++ b/mne/viz/_figure.py
@@ -659,7 +659,7 @@ def _get_browser(show, block, **kwargs):
     figsize = kwargs.setdefault("figsize", _get_figsize_from_config())
     if figsize is None or np.any(np.array(figsize) < 8):
         kwargs["figsize"] = (8, 8)
-    kwargs["splash"] = True if show else False
+    kwargs["splash"] = kwargs.get("splash", True) and show
     if kwargs.get("theme", None) is None:
         kwargs["theme"] = get_config("MNE_BROWSER_THEME", "auto")
     if kwargs.get("overview_mode", None) is None:
diff --git a/mne/viz/epochs.py b/mne/viz/epochs.py
index 613f3a2b6..22d686c9b 100644
--- a/mne/viz/epochs.py
+++ b/mne/viz/epochs.py
@@ -786,6 +786,7 @@ def plot_epochs(
     *,
     theme=None,
     overview_mode=None,
+    splash=True,
 ):
     """Visualize epochs.
 
@@ -881,6 +882,9 @@ def plot_epochs(
     %(overview_mode)s
 
         .. versionadded:: 1.1
+    %(splash)s
+
+        .. versionadded:: 1.6
 
     Returns
     -------
@@ -1086,6 +1090,7 @@ def plot_epochs(
         use_opengl=use_opengl,
         theme=theme,
         overview_mode=overview_mode,
+        splash=splash,
     )
 
     fig = _get_browser(show=show, block=block, **params)
diff --git a/mne/viz/ica.py b/mne/viz/ica.py
index 2b16c5f68..24a9af42b 100644
--- a/mne/viz/ica.py
+++ b/mne/viz/ica.py
@@ -55,6 +55,7 @@ def plot_ica_sources(
     *,
     theme=None,
     overview_mode=None,
+    splash=True,
 ):
     """Plot estimated latent sources given the unmixing matrix.
 
@@ -99,6 +100,9 @@ def plot_ica_sources(
     %(overview_mode)s
 
         .. versionadded:: 1.1
+    %(splash)s
+
+        .. versionadded:: 1.6
 
     Returns
     -------
@@ -139,6 +143,7 @@ def plot_ica_sources(
             use_opengl=use_opengl,
             theme=theme,
             overview_mode=overview_mode,
+            splash=splash,
         )
     elif isinstance(inst, Evoked):
         if start is not None or stop is not None:
@@ -1266,6 +1271,7 @@ def _plot_sources(
     *,
     theme=None,
     overview_mode=None,
+    splash=True,
 ):
     """Plot the ICA components as a RawArray or EpochsArray."""
     from ..epochs import BaseEpochs, EpochsArray
@@ -1410,6 +1416,7 @@ def _plot_sources(
         use_opengl=use_opengl,
         theme=theme,
         overview_mode=overview_mode,
+        splash=splash,
     )
     if is_epo:
         params.update(
diff --git a/mne/viz/raw.py b/mne/viz/raw.py
index ec5c95f57..cdefc285c 100644
--- a/mne/viz/raw.py
+++ b/mne/viz/raw.py
@@ -65,6 +65,7 @@ def plot_raw(
     *,
     theme=None,
     overview_mode=None,
+    splash=True,
     verbose=None,
 ):
     """Plot raw data.
@@ -196,6 +197,9 @@ def plot_raw(
     %(overview_mode)s
 
         .. versionadded:: 1.1
+    %(splash)s
+
+        .. versionadded:: 1.6
     %(verbose)s
 
     Returns
@@ -394,6 +398,7 @@ def plot_raw(
         use_opengl=use_opengl,
         theme=theme,
         overview_mode=overview_mode,
+        splash=splash,
     )
 
     fig = _get_browser(show=show, block=block, **params)
diff --git a/tutorials/preprocessing/50_artifact_correction_ssp.py b/tutorials/preprocessing/50_artifact_correction_ssp.py
index 6adcacfb4..2d18367ef 100644
--- a/tutorials/preprocessing/50_artifact_correction_ssp.py
+++ b/tutorials/preprocessing/50_artifact_correction_ssp.py
@@ -111,7 +111,8 @@ empty_room_raw.del_proj()
 # individual spectrum for each sensor, or an average (with confidence band)
 # across sensors:
 
-spectrum = empty_room_raw.compute_psd(verbose="error")  # ignore zero value warning
+raw.info["bads"] = ["MEG 2443"]
+spectrum = empty_room_raw.compute_psd()
 for average in (False, True):
     spectrum.plot(average=average, dB=False, xscale="log", picks="data", exclude="bads")
 


Test patch:
diff --git a/mne/time_frequency/tests/test_spectrum.py b/mne/time_frequency/tests/test_spectrum.py
index 96fe89a2e..75768aff1 100644
--- a/mne/time_frequency/tests/test_spectrum.py
+++ b/mne/time_frequency/tests/test_spectrum.py
@@ -1,4 +1,3 @@
-from contextlib import nullcontext
 from functools import partial
 
 import numpy as np
@@ -359,7 +358,6 @@ def test_spectrum_complex(method, average):
     assert len(epochs) == 5
     assert len(epochs.times) == 2 * sfreq
     kwargs = dict(output="complex", method=method)
-    ctx = pytest.warns(UserWarning, match="Zero value")
     if method == "welch":
         kwargs["n_fft"] = sfreq
         want_dims = ("epoch", "channel", "freq")
@@ -371,11 +369,9 @@ def test_spectrum_complex(method, average):
     else:
         assert method == "multitaper"
         assert not average
-        ctx = nullcontext()
         want_dims = ("epoch", "channel", "taper", "freq")
         want_shape = (5, 1, 7, sfreq + 1)
-    with ctx:
-        spectrum = epochs.compute_psd(**kwargs)
+    spectrum = epochs.compute_psd(**kwargs)
     idx = np.argmin(np.abs(spectrum.freqs - freq))
     assert spectrum.freqs[idx] == freq
     assert spectrum._dims == want_dims
@@ -389,6 +385,13 @@ def test_spectrum_complex(method, average):
         coef = coef.mean(-1)  # over segments
     coef = coef.item()
     assert_allclose(np.angle(coef), phase, rtol=1e-4)
+    # Now test that it warns appropriately
+    epochs._data[0, 0, :] = 0  # actually zero for one epoch and ch
+    with pytest.warns(UserWarning, match="Zero value.*channel 0"):
+        epochs.compute_psd(**kwargs)
+    # But not if we mark that channel as bad
+    epochs.info["bads"] = epochs.ch_names[:1]
+    epochs.compute_psd(**kwargs)
 
 
 def test_spectrum_kwarg_triaging(raw):
diff --git a/mne/utils/tests/test_logging.py b/mne/utils/tests/test_logging.py
index 2b9b6a8ae..a091bea0f 100644
--- a/mne/utils/tests/test_logging.py
+++ b/mne/utils/tests/test_logging.py
@@ -1,3 +1,4 @@
+import logging
 import os
 import re
 import warnings
@@ -8,6 +9,7 @@ import pytest
 
 from mne import Epochs, create_info, read_evokeds
 from mne.io import RawArray, read_raw_fif
+from mne.parallel import parallel_func
 from mne.utils import (
     _get_call_line,
     catch_logging,
@@ -252,3 +254,23 @@ def test_verbose_strictness():
         o.meth_2(verbose=False)
     log = log.getvalue()
     assert log == ""
+
+
+@pytest.mark.parametrize("n_jobs", (1, 2))
+def test_verbose_threads(n_jobs):
+    """Test that our verbose level propagates to threads."""
+
+    def my_fun():
+        from mne.utils import logger
+
+        return logger.level
+
+    with use_log_level("info"):
+        assert logger.level == logging.INFO
+        with use_log_level("warning"):
+            assert logger.level == logging.WARNING
+            parallel, p_fun, got_jobs = parallel_func(my_fun, n_jobs=n_jobs)
+            assert got_jobs in (1, n_jobs)  # FORCE_SERIAL could be set
+            out = parallel(p_fun() for _ in range(5))
+            want_levels = [logging.WARNING] * 5
+            assert out == want_levels