Repository: mne-tools/mne-python

Issue:
Selection of ICA components when plotting properties
https://github.com/mne-tools/mne-python/blob/45d87fb0e5c34156c9568e04cd6ceef81140bed4/mne/viz/ica.py#L422-L423

Shouldn't this be `limit=min(5, ica.n_components_) if picks is None else ica.n_components_`?
Consider an ICA fitted with 55 components on 57 EEG channels:

```
ica.plot_properties(raw, picks=55)  # max is 54 since it picks on components
```

will currently fail with `IndexError: index 55 is out of bounds for axis 1 with size 55`, a bit after the previous code snippet, here: https://github.com/mne-tools/mne-python/blob/45d87fb0e5c34156c9568e04cd6ceef81140bed4/mne/viz/ica.py#L459

Overall, I think `limit` should be updated and the validity of the provided `picks` should be asserted at the beginning of this function, with an error message more informative. 

EDIT: Also, the use of `picks = _picks_to_idx(ica.info, picks, 'all')[:limit]` doesn't feel right as `_picks_to_idx` selects on channels and not components.


Discussion:
sounds legit

> Message ID: ***@***.***>
>

Before opening a PR, I rapidly checked the code that uses `_fast_plot_ica_properties`:
- `ICA.plot_ica_properties`, which is also used by `ica.plot_components(inst=...)`, as expected
- in `mne.viz._figure.py`, in `BrowserBase._create_ica_properties_fig` 

https://github.com/mne-tools/mne-python/blob/cee4e7d11116bd4e0e219fe611c0121cafd2394d/mne/viz/_figure.py#L475-L498

I'm a bit concern by this second one and by what type might be stored in `self.mne.ica._ica_names`.. `BrowserBase._create_ica_properties_fig` is called in `_create_ch_context_fig`:

https://github.com/mne-tools/mne-python/blob/cee4e7d11116bd4e0e219fe611c0121cafd2394d/mne/viz/_figure.py#L437-L448

which is called by the matplotlib pick event in `MNEBrowseFigure`.

I don't get in which context this chain of functions is called, the only browser I can think of related to the ICA is `ica.plot_sources(inst)`, and I don't get where the `plot_properties` comes up in this one. I'm not very familiar with the plotting API, any idea?
I didn't want to have to follow our code so I tried trusting our tests by adding a `raise RuntimeError` early in `_fast_plot_ica_properties`, it looks like both failure modes are thankfully visible:
```
__________________________________________________________________________ test_plot_ica_properties __________________________________________________________________________
mne/viz/tests/test_ica.py:144: in test_plot_ica_properties
    ica.plot_properties(raw, picks=0, verbose='debug', **topoargs)
mne/preprocessing/ica.py:2205: in plot_properties
    return plot_ica_properties(self, inst, picks=picks, axes=axes,
<decorator-gen-164>:10: in plot_ica_properties
    ???
mne/viz/ica.py:392: in plot_ica_properties
    return _fast_plot_ica_properties(ica, inst, picks=picks, axes=axes, dB=dB,
mne/viz/ica.py:410: in _fast_plot_ica_properties
    raise RuntimeError
E   RuntimeError
_____________________________________________________________________ test_plot_ica_sources[matplotlib] ______________________________________________________________________
mne/viz/tests/test_ica.py:279: in test_plot_ica_sources
    fig._click_ch_name(ch_index=0, button=3)
mne/viz/_mpl_figure.py:2005: in _click_ch_name
    _click_ch_name(self, ch_index, button)
mne/utils/_testing.py:435: in _click_ch_name
    _fake_click(fig, fig.mne.ax_main, (x, y), xform='pix',
mne/viz/utils.py:788: in _fake_click
    fig.canvas.callbacks.process(
../matplotlib/lib/matplotlib/cbook/__init__.py:307: in process
    func(*args, **kwargs)
../matplotlib/lib/matplotlib/figure.py:2506: in pick
    super().pick(mouseevent)
../matplotlib/lib/matplotlib/artist.py:525: in pick
    a.pick(mouseevent)
../matplotlib/lib/matplotlib/artist.py:525: in pick
    a.pick(mouseevent)
../matplotlib/lib/matplotlib/artist.py:525: in pick
    a.pick(mouseevent)
../matplotlib/lib/matplotlib/artist.py:525: in pick
    a.pick(mouseevent)
../matplotlib/lib/matplotlib/artist.py:511: in pick
    mouseevent, self, **prop)._process()
../matplotlib/lib/matplotlib/backend_bases.py:1231: in _process
    self.canvas.callbacks.process(self.name, self)
../matplotlib/lib/matplotlib/cbook/__init__.py:307: in process
    func(*args, **kwargs)
mne/viz/_mpl_figure.py:779: in _pick
    self._create_ch_context_fig(ind)
mne/viz/_mpl_figure.py:782: in _create_ch_context_fig
    fig = super()._create_ch_context_fig(idx)
mne/viz/_figure.py:444: in _create_ch_context_fig
    fig = self._create_ica_properties_fig(pick)
mne/viz/_figure.py:494: in _create_ica_properties_fig
    _fast_plot_ica_properties(
mne/viz/ica.py:410: in _fast_plot_ica_properties
    raise RuntimeError
E   RuntimeError
```
> I didn't want to have to follow our code

*aside* if even @larsoner feels this way, it seems we are ripe for a refactor / code readability improvements (var names, comments, etc) 😆 
> aside if even @larsoner feels this way, it seems we are ripe for a refactor / code readability improvements (var names, comments, etc) 😆

That's a big tell ahah

Thank you @larsoner, I did not have time to look at it yet, but that's a great starting point! I'll try to dig in this week or next week.

Resolving patch:
diff --git a/mne/io/pick.py b/mne/io/pick.py
--- a/mne/io/pick.py
+++ b/mne/io/pick.py
@@ -970,8 +970,15 @@ def _pick_data_or_ica(info, exclude=()):
 
 
 def _picks_to_idx(info, picks, none='data', exclude='bads', allow_empty=False,
-                  with_ref_meg=True, return_kind=False):
-    """Convert and check pick validity."""
+                  with_ref_meg=True, return_kind=False, picks_on="channels"):
+    """Convert and check pick validity.
+
+    Parameters
+    ----------
+    picks_on : str
+        'channels' (default) for error messages about selection of channels.
+        'components' for error messages about selection of components.
+    """
     from .meas_info import Info
     picked_ch_type_or_generic = False
     #
@@ -1017,8 +1024,12 @@ def _picks_to_idx(info, picks, none='data', exclude='bads', allow_empty=False,
             picked_ch_type_or_generic = picks[1]
             picks = picks[0]
     if picks.dtype.kind not in ['i', 'u']:
-        raise TypeError('picks must be a list of int or list of str, got '
-                        'a data type of %s' % (picks.dtype,))
+        extra_ch = " or list of str (names)" if picks_on == "channels" else ""
+        msg = (
+            f"picks must be a list of int (indices){extra_ch}. "
+            f"The provided data type {picks.dtype} is invalid."
+        )
+        raise TypeError(msg)
     del extra_repr
     picks = picks.astype(int)
 
@@ -1026,14 +1037,14 @@ def _picks_to_idx(info, picks, none='data', exclude='bads', allow_empty=False,
     # ensure we have (optionally non-empty) ndarray of valid int
     #
     if len(picks) == 0 and not allow_empty:
-        raise ValueError('No appropriate channels found for the given picks '
-                         '(%r)' % (orig_picks,))
+        raise ValueError('No appropriate %s found for the given picks '
+                         '(%r)' % (picks_on, orig_picks))
     if (picks < -n_chan).any():
         raise ValueError('All picks must be >= %d, got %r'
                          % (-n_chan, orig_picks))
     if (picks >= n_chan).any():
-        raise ValueError('All picks must be < n_channels (%d), got %r'
-                         % (n_chan, orig_picks))
+        raise ValueError('All picks must be < n_%s (%d), got %r'
+                         % (picks_on, n_chan, orig_picks))
     picks %= n_chan  # ensure positive
     if return_kind:
         return picks, picked_ch_type_or_generic
diff --git a/mne/viz/ica.py b/mne/viz/ica.py
--- a/mne/viz/ica.py
+++ b/mne/viz/ica.py
@@ -95,7 +95,7 @@ def plot_ica_sources(ica, inst, picks=None, start=None,
     from ..epochs import BaseEpochs
 
     exclude = ica.exclude
-    picks = _picks_to_idx(ica.n_components_, picks, 'all')
+    picks = _picks_to_idx(ica.n_components_, picks, picks_on="components")
 
     if isinstance(inst, (BaseRaw, BaseEpochs)):
         fig = _plot_sources(ica, inst, picks, exclude, start=start, stop=stop,
@@ -327,10 +327,12 @@ def plot_ica_properties(ica, inst, picks=None, axes=None, dB=True,
         .. note::
            You can interactively cycle through topographic maps for different
            channel types by pressing :kbd:`T`.
-    picks : str | list | slice | None
-        Components to include. Slices and lists of integers will be interpreted
-        as component indices. ``None`` (default) will use the first five
-        components. Each component will be plotted in a separate figure.
+    picks : int | list of int | slice | None
+        Indices of the independent components (ICs) to visualize.
+        If an integer, represents the index of the IC to pick.
+        Multiple ICs can be selected using a list of int or a slice.
+        The indices are 0-indexed, so ``picks=1`` will pick the second
+        IC: ``ICA001``. ``None`` will pick the first 5 components.
     axes : list of Axes | None
         List of five matplotlib axes to use in plotting: [topomap_axis,
         image_axis, erp_axis, spectrum_axis, variance_axis]. If None a new
@@ -418,9 +420,11 @@ def _fast_plot_ica_properties(ica, inst, picks=None, axes=None, dB=True,
         plot_std = True
     num_std = float(plot_std)
 
-    # if no picks given - plot the first 5 components
-    limit = min(5, ica.n_components_) if picks is None else len(ica.ch_names)
-    picks = _picks_to_idx(ica.info, picks, 'all')[:limit]
+    limit = min(5, ica.n_components_) if picks is None else ica.n_components_
+    picks = _picks_to_idx(
+        ica.n_components_, picks, picks_on="components"
+    )[:limit]
+
     if axes is None:
         fig, axes = _create_properties_layout(figsize=figsize)
     else:
diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py
--- a/mne/viz/topomap.py
+++ b/mne/viz/topomap.py
@@ -1213,7 +1213,7 @@ def plot_ica_components(ica, picks=None, ch_type=None, res=64,
             figs.append(fig)
         return figs
     else:
-        picks = _picks_to_idx(ica.info, picks)
+        picks = _picks_to_idx(ica.n_components_, picks, picks_on="components")
     ch_type = _get_ch_type(ica, ch_type)
 
     cmap = _setup_cmap(cmap, n_axes=len(picks))


Test patch:
diff --git a/mne/io/tests/test_pick.py b/mne/io/tests/test_pick.py
--- a/mne/io/tests/test_pick.py
+++ b/mne/io/tests/test_pick.py
@@ -484,7 +484,7 @@ def test_picks_to_idx():
     # Array and list
     assert_array_equal(picks, _picks_to_idx(info, picks))
     assert_array_equal(picks, _picks_to_idx(info, list(picks)))
-    with pytest.raises(TypeError, match='data type of float64'):
+    with pytest.raises(TypeError, match='data type float64 is invalid'):
         _picks_to_idx(info, 1.)
     # None
     assert_array_equal(picks, _picks_to_idx(info, None))