Repository: mne-tools/mne-python

Issue:
Bug with transformation from cartesian to sphere to cartesian coordinates
I am not sure it's a bug, I feel like I'm missing something obvious.. Following #11079 I've been looking at the code of `interpolate_bridged_electrodes` and I ran into a weird case. I'm testing code to add virtual channels at the average location between N bridged electrodes on this recording:

![Screenshot 2022-08-25 at 11 48 24](https://user-images.githubusercontent.com/73893616/186633401-8c5c0920-911b-4e43-8c88-5722e47e3c24.png)

I'm not going to provide that code snippet for now, I'll wait for opinions on #11079, but it creates 3 virtual channels. One at the center of the triangle (AF3, F3, F1), one very close to PO5... and a last one, between T8 and TP8 on the wrong side of the head!!

-------------

Now, consider this example, with code very similar to `interpolate_bridged_electrodes`:

```
import numpy as np
from mne import create_info
from mne.transforms import _cart_to_sph, _sph_to_cart


info = create_info(["T7", "TP7", "T8", "TP8"], sfreq=1024, ch_types="eeg")
info.set_montage("standard_1020")
montage = info.get_montage()
pos = montage.get_positions()
assert pos["coord_frame"] == "head"

ch_pos = pos["ch_pos"]

# create virtual channel midway between T7 and TP7
ch_names = ["T7", "TP7"]
sphere_positions = np.zeros((len(ch_names), 3))
for i, ch_name in enumerate(ch_names):
    sphere_positions[i, :] = _cart_to_sph(ch_pos[ch_name])
pos_virtual = _sph_to_cart(np.average(sphere_positions, axis=0))
```

`ch_pos` reads as:

```
OrderedDict([('T7',  array([-0.08598208,  0.01487164,  0.03117337])),
             ('TP7', array([-0.08675728, -0.01495098,  0.03515866])),
             ('T8',  array([0.08326137, 0.01525819, 0.03097297])),
             ('TP8', array([ 0.08362232, -0.01508567,  0.0350577 ]))])
```

Note that T7 and TP7, with odd numbers, have a **negative** x coordinate.

`pos_virtual` reads as `array([[ 8.76692427e-02, -2.68577709e-05,  3.31532296e-02]])`, with a **positive** x coordinate.

-----------

And to make it more explicit, here is the next couples of lines to get to a plot, using very similar code to `interpolate_bridged_electrodes`:

```
import numpy as np
from mne import create_info
from mne.io import RawArray
from mne.transforms import _cart_to_sph, _sph_to_cart


info = create_info(["T7", "TP7", "T8", "TP8"], sfreq=1024, ch_types="eeg")
info.set_montage("standard_1020")
montage = info.get_montage()
pos = montage.get_positions()
assert pos['coord_frame'] == "head"

ch_pos = pos["ch_pos"]

# create virtual channel midway between T7 and TP7
ch_names = ["T7", "TP7"]
sphere_positions = np.zeros((len(ch_names), 3))
for i, ch_name in enumerate(ch_names):
    sphere_positions[i, :] = _cart_to_sph(ch_pos[ch_name])
pos_virtual = _sph_to_cart(np.average(sphere_positions, axis=0))

# create virtual channel info
virtual_info = create_info(
    ch_names=["vT7-TP7"],
    sfreq=info["sfreq"],
    ch_types="eeg",
)
virtual_info["chs"][0]["loc"][:3] = pos_virtual

# create a RawArray to concatenate both info
raw = RawArray(np.random.randn(4, 1024), info)
raw_virtual = RawArray(np.random.randn(1, 1024), virtual_info)
raw.add_channels([raw_virtual], force_update_info=True)

# plot montage
raw.get_montage().plot()
```

![Screenshot 2022-08-25 at 12 00 53](https://user-images.githubusercontent.com/73893616/186636074-1e9d4ae7-70d5-48ce-8406-496a215f3c12.png)

Interestingly, changing `ch_names` to `["T8", "TP8"]` does create the correct virtual channel between T8 and TP8.


Discussion:
To me you shouldn't really need to worry about this --  you shouldn't use 2D-projected locations for meaningful data analysis (just for viz!) but rather stick with the original 3D positions.

But in any case... keep in mind in spherical coordinates, `np.average` will not be the right thing to do as it doesn't take into account wrapping. An electrode at -179deg and +179 deg are 2deg apart and all the way to the left, but you average them together and get 0 deg (on the right).
Yes, the average feels wrong here, but that's what `interpolate_bridged_electrodes` is doing at the moment.

The 2D projection I show at the end is just to illustrate; the issue is really about the 3D position. The location of the virtual channel between T7 and TP7 (both with negative `x` coordinates, left of the head) has a positive `x` coordinate (right of the head), probably because of the `average` as you mention.
If you know from the top of your head the right way to compute the average location of N electrodes in spherical coordinates, that would be great. I'm not used to working in the spherical coordinate system and it will take me a bit of time to wrap my head around it.
I'll add the fix as part of the PR for #11079.
My first thought was to average in cartesian, but this alone would bias the radius. So then if you convert all the old ones to spherical, compute the average *radius* only, then convert the new location to spherical, replace its radius, and convert back, it should work.

You could also use something like https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.circmean.html to just the azimuth and use the arithmetic/standard mean on the elevation and radius. That's just one idea from a quick search. Another idea is to use the average position on sphere, which would lead you to https://en.wikipedia.org/wiki/Spherical_mean, etc. That's probably the most principled way
Thanks a lot, more than enough material for me to work this one out quickly.

Resolving patch:
diff --git a/mne/preprocessing/interpolate.py b/mne/preprocessing/interpolate.py
--- a/mne/preprocessing/interpolate.py
+++ b/mne/preprocessing/interpolate.py
@@ -3,9 +3,10 @@
 # Authors: Alexandre Gramfort <alexandre.gramfort@inria.fr>
 
 from itertools import chain
+
 import numpy as np
 
-from ..utils import _validate_type
+from ..utils import _validate_type, _ensure_int
 from ..io import BaseRaw, RawArray
 from ..io.meas_info import create_info
 from ..epochs import BaseEpochs, EpochsArray
@@ -72,7 +73,7 @@ def equalize_bads(insts, interp_thresh=1., copy=True):
     return insts
 
 
-def interpolate_bridged_electrodes(inst, bridged_idx):
+def interpolate_bridged_electrodes(inst, bridged_idx, bad_limit=4):
     """Interpolate bridged electrode pairs.
 
     Because bridged electrodes contain brain signal, it's just that the
@@ -88,6 +89,12 @@ def interpolate_bridged_electrodes(inst, bridged_idx):
     bridged_idx : list of tuple
         The indices of channels marked as bridged with each bridged
         pair stored as a tuple.
+    bad_limit : int
+        The maximum number of electrodes that can be bridged together
+        (included) and interpolated. Above this number, an error will be
+        raised.
+
+        .. versionadded:: 1.2
 
     Returns
     -------
@@ -98,7 +105,15 @@ def interpolate_bridged_electrodes(inst, bridged_idx):
     --------
     mne.preprocessing.compute_bridged_electrodes
     """
+    from scipy.sparse.csgraph import connected_components
+
     _validate_type(inst, (BaseRaw, BaseEpochs, Evoked))
+    bad_limit = _ensure_int(bad_limit, "bad_limit")
+    if bad_limit <= 0:
+        raise ValueError(
+            "Argument 'bad_limit' should be a strictly positive "
+            f"integer. Provided {bad_limit} is invalid."
+        )
     montage = inst.get_montage()
     if montage is None:
         raise RuntimeError('No channel positions found in ``inst``')
@@ -106,40 +121,70 @@ def interpolate_bridged_electrodes(inst, bridged_idx):
     if pos['coord_frame'] != 'head':
         raise RuntimeError('Montage channel positions must be in ``head``'
                            'got {}'.format(pos['coord_frame']))
-    ch_pos = pos['ch_pos']
     # store bads orig to put back at the end
     bads_orig = inst.info['bads']
     inst.info['bads'] = list()
 
+    # look for group of bad channels
+    nodes = sorted(set(chain(*bridged_idx)))
+    G_dense = np.zeros((len(nodes), len(nodes)))
+    # fill the edges with a weight of 1
+    for bridge in bridged_idx:
+        idx0 = np.searchsorted(nodes, bridge[0])
+        idx1 = np.searchsorted(nodes, bridge[1])
+        G_dense[idx0, idx1] = 1
+        G_dense[idx1, idx0] = 1
+    # look for connected components
+    _, labels = connected_components(G_dense, directed=False)
+    groups_idx = [
+        [nodes[j] for j in np.where(labels == k)[0]] for k in set(labels)
+    ]
+    groups_names = [
+        [inst.info.ch_names[k] for k in group_idx] for group_idx in groups_idx
+    ]
+
+    # warn for all bridged areas that include too many electrodes
+    for group_names in groups_names:
+        if len(group_names) > bad_limit:
+            raise RuntimeError(
+                f"The channels {', '.join(group_names)} are bridged together "
+                "and form a large area of bridged electrodes. Interpolation "
+                "might be inaccurate."
+            )
+
     # make virtual channels
     virtual_chs = dict()
     bads = set()
-    data = inst.get_data()
-    for idx0, idx1 in bridged_idx:
-        ch0 = inst.ch_names[idx0]
-        ch1 = inst.ch_names[idx1]
-        bads = bads.union([ch0, ch1])
-        # compute midway position in spherical coordinates in "head"
-        # (more accurate than cutting though the scalp by using cartesian)
-        pos0 = _cart_to_sph(ch_pos[ch0])
-        pos1 = _cart_to_sph(ch_pos[ch1])
-        pos_virtual = _sph_to_cart((pos0 + pos1) / 2)
+    for k, group_idx in enumerate(groups_idx):
+        group_names = [inst.info.ch_names[k] for k in group_idx]
+        bads = bads.union(group_names)
+        # compute centroid position in spherical "head" coordinates
+        pos_virtual = _find_centroid_sphere(pos['ch_pos'], group_names)
+        # create the virtual channel info and set the position
         virtual_info = create_info(
-            [f'{ch0}-{ch1} virtual'], inst.info['sfreq'], 'eeg')
+            [f'virtual {k+1}'], inst.info['sfreq'], 'eeg'
+        )
         virtual_info['chs'][0]['loc'][:3] = pos_virtual
+        # create virtual channel
+        data = inst.get_data(picks=group_names)
         if isinstance(inst, BaseRaw):
+            data = np.average(data, axis=0).reshape(1, -1)
             virtual_ch = RawArray(
-                (data[idx0:idx0 + 1] + data[idx1:idx1 + 1]) / 2,
-                virtual_info, first_samp=inst.first_samp)
+                data, virtual_info, first_samp=inst.first_samp
+            )
         elif isinstance(inst, BaseEpochs):
-            virtual_ch = EpochsArray(
-                (data[:, idx0:idx0 + 1] + data[:, idx1:idx1 + 1]) / 2,
-                virtual_info, tmin=inst.tmin)
+            data = np.average(data, axis=1).reshape(len(data), 1, -1)
+            virtual_ch = EpochsArray(data, virtual_info, tmin=inst.tmin)
         else:  # evoked
+            data = np.average(data, axis=0).reshape(1, -1)
             virtual_ch = EvokedArray(
-                (data[idx0:idx0 + 1] + data[idx1:idx1 + 1]) / 2,
-                virtual_info, tmin=inst.tmin, nave=inst.nave, kind=inst.kind)
-        virtual_chs[f'{ch0}-{ch1} virtual'] = virtual_ch
+                np.average(data, axis=0).reshape(1, -1),
+                virtual_info,
+                tmin=inst.tmin,
+                nave=inst.nave,
+                kind=inst.kind,
+            )
+        virtual_chs[f'virtual {k+1}'] = virtual_ch
 
     # add the virtual channels
     inst.add_channels(list(virtual_chs.values()), force_update_info=True)
@@ -153,3 +198,40 @@ def interpolate_bridged_electrodes(inst, bridged_idx):
 
     inst.info['bads'] = bads_orig
     return inst
+
+
+def _find_centroid_sphere(ch_pos, group_names):
+    """Compute the centroid position between N electrodes.
+
+    The centroid should be determined in spherical "head" coordinates which is
+    more accurante than cutting through the scalp by averaging in cartesian
+    coordinates.
+
+    A simple way is to average the location in cartesian coordinate, convert
+    to spehrical coordinate and replace the radius with the average radius of
+    the N points in spherical coordinates.
+
+    Parameters
+    ----------
+    ch_pos : OrderedDict
+        The position of all channels in cartesian coordinates.
+    group_names : list | tuple
+        The name of the N electrodes used to determine the centroid.
+
+    Returns
+    -------
+    pos_centroid : array of shape (3,)
+        The position of the centroid in cartesian coordinates.
+    """
+    cartesian_positions = np.array([
+        ch_pos[ch_name] for ch_name in group_names]
+    )
+    sphere_positions = _cart_to_sph(cartesian_positions)
+    cartesian_pos_centroid = np.average(cartesian_positions, axis=0)
+    sphere_pos_centroid = _cart_to_sph(cartesian_pos_centroid)
+    # average the radius and overwrite it
+    avg_radius = np.average(sphere_positions, axis=0)[0]
+    sphere_pos_centroid[0, 0] = avg_radius
+    # convert back to cartesian
+    pos_centroid = _sph_to_cart(sphere_pos_centroid)[0, :]
+    return pos_centroid


Test patch:
diff --git a/mne/preprocessing/tests/test_interpolate.py b/mne/preprocessing/tests/test_interpolate.py
--- a/mne/preprocessing/tests/test_interpolate.py
+++ b/mne/preprocessing/tests/test_interpolate.py
@@ -1,10 +1,14 @@
+import itertools
 import os.path as op
-import numpy as np
 
+import numpy as np
 import pytest
 
+from mne import create_info, io, pick_types, read_events, Epochs
+from mne.channels import make_standard_montage
 from mne.preprocessing import equalize_bads, interpolate_bridged_electrodes
-from mne import io, pick_types, read_events, Epochs
+from mne.preprocessing.interpolate import _find_centroid_sphere
+from mne.transforms import _cart_to_sph
 
 base_dir = op.join(op.dirname(__file__), '..', '..', 'io', 'tests', 'data')
 raw_fname = op.join(base_dir, 'test_raw.fif')
@@ -80,3 +84,101 @@ def test_interpolate_bridged_electrodes():
         assert inst.info['bads'] == bads_orig
         # check closer to regular interpolation than original data
         assert 1e-6 < np.mean(np.abs(data_interp - data_interp_reg)) < 5.4e-5
+
+    for inst in (raw, epochs, evoked):
+        idx0 = inst.ch_names.index('EEG 001')
+        idx1 = inst.ch_names.index('EEG 002')
+        idx2 = inst.ch_names.index('EEG 003')
+        ch_names_orig = inst.ch_names.copy()
+        bads_orig = inst.info['bads'].copy()
+        inst2 = inst.copy()
+        inst2.info['bads'] = ['EEG 001', 'EEG 002', 'EEG 003']
+        inst2.interpolate_bads()
+        data_interp_reg = inst2.get_data(
+            picks=['EEG 001', 'EEG 002', 'EEG 003']
+        )
+        inst = interpolate_bridged_electrodes(
+            inst, [(idx0, idx1), (idx0, idx2), (idx1, idx2)]
+        )
+        data_interp = inst.get_data(picks=['EEG 001', 'EEG 002', 'EEG 003'])
+        assert not any(['virtual' in ch for ch in inst.ch_names])
+        assert inst.ch_names == ch_names_orig
+        assert inst.info['bads'] == bads_orig
+        # check closer to regular interpolation than original data
+        assert 1e-6 < np.mean(np.abs(data_interp - data_interp_reg)) < 5.4e-5
+
+    # test bad_limit
+    montage = make_standard_montage("standard_1020")
+    ch_names = [ch for ch in montage.ch_names
+                if ch not in ["P7", "P8", "T3", "T4", "T5", "T4", "T6"]]
+    info = create_info(ch_names, sfreq=1024, ch_types="eeg")
+    data = np.random.randn(len(ch_names), 1024)
+    data[:5, :] = np.ones((5, 1024))
+    raw = io.RawArray(data, info)
+    raw.set_montage("standard_1020")
+    bridged_idx = list(itertools.combinations(range(5), 2))
+    with pytest.raises(
+        RuntimeError,
+        match="The channels Fp1, Fpz, Fp2, AF9, AF7 are bridged "
+        "together and form a large area of bridged electrodes."
+    ):
+        interpolate_bridged_electrodes(raw, bridged_idx, bad_limit=4)
+    # increase the limit to prevent raising
+    interpolate_bridged_electrodes(raw, bridged_idx, bad_limit=5)
+    # invalid argument
+    with pytest.raises(
+        ValueError,
+        match="Argument 'bad_limit' should be a strictly positive integer."
+    ):
+        interpolate_bridged_electrodes(raw, bridged_idx, bad_limit=-4)
+
+
+def test_find_centroid():
+    """Test that the centroid is correct."""
+    montage = make_standard_montage("standard_1020")
+    ch_names = [ch for ch in montage.ch_names
+                if ch not in ["P7", "P8", "T3", "T4", "T5", "T4", "T6"]]
+    info = create_info(ch_names, sfreq=1024, ch_types="eeg")
+    info.set_montage(montage)
+    montage = info.get_montage()
+    pos = montage.get_positions()
+    assert pos["coord_frame"] == "head"
+
+    # look for centroid between T7 and TP7, an average in spehrical coordinate
+    # fails and places the average on the wrong side of the head between T8 and
+    # TP8
+    ch_names = ["T7", "TP7"]
+    pos_centroid = _find_centroid_sphere(pos["ch_pos"], ch_names)
+    _check_centroid_position(pos, ch_names, pos_centroid)
+
+    # check other positions
+    pairs = [("CPz", "CP2"), ("CPz", "Cz"), ("Fpz", "AFz"), ("AF7", "F7"),
+             ("O1", "O2"), ("M2", "A2"), ("P5", "P9")]
+    for ch_names in pairs:
+        pos_centroid = _find_centroid_sphere(pos["ch_pos"], ch_names)
+        _check_centroid_position(pos, ch_names, pos_centroid)
+    triplets = [("CPz", "Cz", "FCz"), ("AF9", "Fpz", "AF10"),
+                ("FT10", "FT8", "T10")]
+    for ch_names in triplets:
+        pos_centroid = _find_centroid_sphere(pos["ch_pos"], ch_names)
+        _check_centroid_position(pos, ch_names, pos_centroid)
+
+
+def _check_centroid_position(pos, ch_names, pos_centroid):
+    """Check the centroid distance.
+
+    The cartesian average should be distanced from pos_centroid by the
+    difference between the radii.
+    """
+    radii = list()
+    cartesian_positions = np.zeros((len(ch_names), 3))
+    for i, ch in enumerate(ch_names):
+        radii.append(_cart_to_sph(pos["ch_pos"][ch])[0, 0])
+        cartesian_positions[i, :] = pos["ch_pos"][ch]
+    avg_radius = np.average(radii)
+    avg_cartesian_position = np.average(cartesian_positions, axis=0)
+    avg_cartesian_position_radius = _cart_to_sph(avg_cartesian_position)[0, 0]
+    radius_diff = np.abs(avg_radius - avg_cartesian_position_radius)
+    # distance
+    distance = np.linalg.norm(pos_centroid - avg_cartesian_position)
+    assert np.isclose(radius_diff, distance, atol=1e-6)