Repository: mne-tools/mne-python

Issue:
RFC: better API for plot_topomap bands parameter?
`plot_psds_topomap`, `plot_epochs_psd_topomap`, and `epochs.plot_psd_topomap` have a `bands` parameter that takes a list of tuples as input. The format is:

```
Length-2 tuples specify a single frequency and a subplot title (e.g.,
``(6.5, 'presentation rate')``); length-3 tuples specify lower and
upper band edges and a subplot title.
```

This seems inelegant: input is one or more strings, each associated with 1 or 2 numbers. It is begging to be a `dict`.  Proposed new API:

```
If a :class:`dict`, keys will be used as subplot titles and dict values should be
either single frequency values or length-two :class:`tuples<tuple>` indicating
lower and upper band edges.
```

We could easily, silently support 'lists' or `np.array`s as the dict values too; and also treat length-1 dict values as equivalent to passing a single value:

```py
if not hasattr(value, '__len__') or len(value) == 1:
    # assume single freq value
elif len(value) == 2:
    # lower/upper band edges
else:
    raise ValueError()
```



Discussion:
Agreed a dict is better here, and +1 for keeping undocumented backward compat support for the old mode if it's easy

Resolving patch:
diff --git a/mne/utils/docs.py b/mne/utils/docs.py
--- a/mne/utils/docs.py
+++ b/mne/utils/docs.py
@@ -279,18 +279,26 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75):
 """
 
 docdict['bands_psd_topo'] = """
-bands : list of tuple | None
-    The frequencies or frequency ranges to plot. Length-2 tuples specify
-    a single frequency and a subplot title (e.g.,
-    ``(6.5, 'presentation rate')``); length-3 tuples specify lower and
-    upper band edges and a subplot title. If ``None`` (the default),
-    expands to::
-
-        bands = [(0, 4, 'Delta'), (4, 8, 'Theta'), (8, 12, 'Alpha'),
-                 (12, 30, 'Beta'), (30, 45, 'Gamma')]
-
-    In bands where a single frequency is provided, the topomap will reflect
-    the single frequency bin that is closest to the provided value.
+bands : None | dict | list of tuple
+    The frequencies or frequency ranges to plot. If a :class:`dict`, keys will
+    be used as subplot titles and values should be either a single frequency
+    (e.g., ``{'presentation rate': 6.5}``) or a length-two sequence of lower
+    and upper frequency band edges (e.g., ``{'theta': (4, 8)}``). If a single
+    frequency is provided, the plot will show the frequency bin that is closest
+    to the requested value. If ``None`` (the default), expands to::
+
+        bands = {'Delta (0-4 Hz)': (0, 4), 'Theta (4-8 Hz)': (4, 8),
+                 'Alpha (8-12 Hz)': (8, 12), 'Beta (12-30 Hz)': (12, 30),
+                 'Gamma (30-45 Hz)': (30, 45)}
+
+    .. note::
+       For backwards compatibility, :class:`tuples<tuple>` of length 2 or 3 are
+       also accepted, where the last element of the tuple is the subplot title
+       and the other entries are frequency values (a single value or band
+       edges). New code should use :class:`dict` or ``None``.
+
+    .. versionchanged:: 1.2
+       Allow passing a dict and discourage passing tuples.
 """
 
 docdict['base_estimator'] = """
diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py
--- a/mne/viz/topomap.py
+++ b/mne/viz/topomap.py
@@ -2069,15 +2069,23 @@ def plot_psds_topomap(
         cbar_fmt = '%0.1f' if dB else '%0.3f'
 
     if bands is None:
-        bands = [(0, 4, 'Delta (0-4 Hz)'), (4, 8, 'Theta (4-8 Hz)'),
-                 (8, 12, 'Alpha (8-12 Hz)'), (12, 30, 'Beta (12-30 Hz)'),
-                 (30, 45, 'Gamma (30-45 Hz)')]
-    else:  # upconvert single freqs to band upper/lower edges as needed
-        bin_spacing = np.diff(freqs)[0]
-        bin_edges = np.array([0, bin_spacing]) - bin_spacing / 2
-        bands = [tuple(bin_edges + freqs[np.argmin(np.abs(freqs - band[0]))]) +
-                 (band[1],) if len(band) == 2 else band for band in bands]
-
+        bands = {'Delta (0-4 Hz)': (0, 4), 'Theta (4-8 Hz)': (4, 8),
+                 'Alpha (8-12 Hz)': (8, 12), 'Beta (12-30 Hz)': (12, 30),
+                 'Gamma (30-45 Hz)': (30, 45)}
+    elif not hasattr(bands, 'keys'):
+        # convert legacy list-of-tuple input to a dict
+        bands = {band[-1]: band[:-1] for band in bands}
+        logger.info('converting legacy list-of-tuples input to a dict for the '
+                    '`bands` parameter')
+    # upconvert single freqs to band upper/lower edges as needed
+    bin_spacing = np.diff(freqs)[0]
+    bin_edges = np.array([0, bin_spacing]) - bin_spacing / 2
+    for band, _edges in bands.items():
+        if not hasattr(_edges, '__len__'):
+            _edges = (_edges,)
+        if len(_edges) == 1:
+            bands[band] = tuple(bin_edges
+                                + freqs[np.argmin(np.abs(freqs - _edges[0]))])
     if agg_fun is None:
         agg_fun = np.sum if normalize else np.mean
 
@@ -2097,7 +2105,7 @@ def plot_psds_topomap(
     # handle vmin/vmax
     if vlim == 'joint':
         _freq_masks = [(fmin < freqs) & (freqs < fmax)
-                       for (fmin, fmax, _) in bands]
+                       for (fmin, fmax) in bands.values()]
         _datas = [agg_fun(psds[:, _freq_mask], axis=1)
                   for _freq_mask in _freq_masks]
         _datas = [10 * np.log10(_d) if (dB and not normalize) else _d
@@ -2116,7 +2124,7 @@ def plot_psds_topomap(
         if dB and not normalize:
             unit += ' (dB)'
 
-    for ax, (fmin, fmax, title) in zip(axes, bands):
+    for ax, (title, (fmin, fmax)) in zip(axes, bands.items()):
         freq_mask = (fmin < freqs) & (freqs < fmax)
         if freq_mask.sum() == 0:
             raise RuntimeError('No frequencies in band "%s" (%s, %s)'
diff --git a/tutorials/time-freq/20_sensors_time_frequency.py b/tutorials/time-freq/20_sensors_time_frequency.py
--- a/tutorials/time-freq/20_sensors_time_frequency.py
+++ b/tutorials/time-freq/20_sensors_time_frequency.py
@@ -19,22 +19,20 @@
 # License: BSD-3-Clause
 
 # %%
-import os.path as op
-
-import numpy as np
 import matplotlib.pyplot as plt
+import numpy as np
 
 import mne
-from mne.time_frequency import tfr_morlet, psd_multitaper, psd_welch
 from mne.datasets import somato
+from mne.time_frequency import psd_multitaper, psd_welch, tfr_morlet
 
 # %%
 # Set parameters
 data_path = somato.data_path()
 subject = '01'
 task = 'somato'
-raw_fname = op.join(data_path, 'sub-{}'.format(subject), 'meg',
-                    'sub-{}_task-{}_meg.fif'.format(subject, task))
+raw_fname = (data_path / f'sub-{subject}' / 'meg' /
+             f'sub-{subject}_task-{task}_meg.fif')
 
 # Setup for reading the raw data
 raw = mne.io.read_raw_fif(raw_fname)


Test patch: