Repository: mne-tools/mne-python

Issue:
mistake in ECoG tutorial
Hello, 

In this tutorial:
https://mne.tools/stable/auto_tutorials/clinical/30_ecog.html

it is said: "We can project gamma power from the sensor data to the nearest locations on the pial surface and visualize that:"

However, to my understanding:
```
src = mne.read_source_spaces(
    op.join(subjects_dir, 'fsaverage', 'bem', 'fsaverage-ico-5-src.fif'))
```
reads out sources located on the white matter surface, not the pial surface.

If we want sources on the pial surface, I believe one should first do:
```
src = mne.setup_source_space('fsaverage', subjects_dir=subjects_dir,
                              spacing='ico5', surface='pial')
```

Indeed,
```
src = mne.read_source_spaces(
    op.join(subjects_dir, 'fsaverage', 'bem', 'fsaverage-ico-5-src.fif'))
```
and
```
src = mne.setup_source_space('fsaverage', subjects_dir=subjects_dir,
                              spacing='ico5', surface='white')
```
give the same result.




Discussion:
indeed ! either we fix the text or we fix the code

@alexrockhill wdyt?
I think we should add a "surface" option that defaults to `'pial'` and just use the vertices (not positions) from the source space unless surface=None
> I think we should add a "surface" option that defaults to `'pial'` and just use the vertices (not positions) from the source space unless surface=None

Sounds reasonable to me.
@chapochn do you want to open a PR to add this behavior? I think we can consider it a bugfix so no need for a deprecation cycle.

Resolving patch:
diff --git a/mne/source_estimate.py b/mne/source_estimate.py
--- a/mne/source_estimate.py
+++ b/mne/source_estimate.py
@@ -3231,7 +3231,7 @@ def extract_label_time_course(stcs, labels, src, mode='auto',
 @verbose
 def stc_near_sensors(evoked, trans, subject, distance=0.01, mode='sum',
                      project=True, subjects_dir=None, src=None, picks=None,
-                     verbose=None):
+                     surface='pial', verbose=None):
     """Create a STC from ECoG, sEEG and DBS sensor data.
 
     Parameters
@@ -3261,10 +3261,16 @@ def stc_near_sensors(evoked, trans, subject, distance=0.01, mode='sum',
         The source space.
 
         .. warning:: If a surface source space is used, make sure that
-                     ``surf='pial'`` was used during construction.
+                     ``surface='pial'`` was used during construction,
+                     or that you set ``surface='pial'`` here.
     %(picks_base)s good sEEG, ECoG, and DBS channels.
 
         .. versionadded:: 0.24
+    surface : str | None
+        The surface to use if ``src=None``. Default is the pial surface.
+        If None, the source space surface will be used.
+
+        .. versionadded:: 0.24.1
     %(verbose)s
 
     Returns
@@ -3345,17 +3351,27 @@ def stc_near_sensors(evoked, trans, subject, distance=0.01, mode='sum',
 
     subject = _check_subject(None, subject, raise_error=False)
     subjects_dir = get_subjects_dir(subjects_dir, raise_error=True)
+    if surface is not None:
+        surf_rr = [read_surface(op.join(subjects_dir, subject, 'surf',
+                                        f'{hemi}.{surface}'))[0] / 1000.
+                   for hemi in ('lh', 'rh')]
     if src is None:  # fake a full surface one
-        rrs = [read_surface(op.join(subjects_dir, subject,
-                                    'surf', f'{hemi}.pial'))[0]
-               for hemi in ('lh', 'rh')]
+        _validate_type(surface, str, 'surface', 'when src is None')
         src = SourceSpaces([
-            dict(rr=rr / 1000., vertno=np.arange(len(rr)), type='surf',
+            dict(rr=rr, vertno=np.arange(len(rr)), type='surf',
                  coord_frame=FIFF.FIFFV_COORD_MRI)
-            for rr in rrs])
-        del rrs
+            for rr in surf_rr])
+        rrs = np.concatenate([s_rr[s['vertno']] for s_rr, s in
+                              zip(surf_rr, src)])
         keep_all = False
     else:
+        if surface is None:
+            rrs = np.concatenate([s['rr'][s['vertno']] for s in src])
+            if src[0]['coord_frame'] == FIFF.FIFFV_COORD_HEAD:
+                rrs = apply_trans(trans, rrs)
+        else:
+            rrs = np.concatenate([s_rr[s['vertno']] for s_rr, s in
+                                  zip(surf_rr, src)])
         keep_all = True
     # ensure it's a usable one
     klass = dict(
@@ -3365,9 +3381,6 @@ def stc_near_sensors(evoked, trans, subject, distance=0.01, mode='sum',
     )
     _check_option('src.kind', src.kind, sorted(klass.keys()))
     klass = klass[src.kind]
-    rrs = np.concatenate([s['rr'][s['vertno']] for s in src])
-    if src[0]['coord_frame'] == FIFF.FIFFV_COORD_HEAD:
-        rrs = apply_trans(trans, rrs)
     # projection will only occur with surfaces
     logger.info(
         f'Projecting data from {len(pos)} sensor{_pl(pos)} onto {len(rrs)} '
@@ -3408,18 +3421,18 @@ def stc_near_sensors(evoked, trans, subject, distance=0.01, mode='sum',
              f'{", ".join(evoked.ch_names[mi] for mi in missing)}')
 
     nz_data = w @ evoked.data
-    if not keep_all:
-        assert src.kind == 'surface'
-        data = nz_data
-        offset = len(src[0]['vertno'])
-        vertices = [vertices[vertices < offset],
-                    vertices[vertices >= offset] - offset]
-    else:
+    if keep_all:
         data = np.zeros(
             (sum(len(s['vertno']) for s in src), len(evoked.times)),
             dtype=nz_data.dtype)
         data[vertices] = nz_data
         vertices = [s['vertno'].copy() for s in src]
+    else:
+        assert src.kind == 'surface'
+        data = nz_data
+        offset = len(src[0]['vertno'])
+        vertices = [vertices[vertices < offset],
+                    vertices[vertices >= offset] - offset]
 
     return klass(data, vertices, evoked.times[0], 1. / evoked.info['sfreq'],
                  subject=subject, verbose=verbose)
diff --git a/tutorials/clinical/30_ecog.py b/tutorials/clinical/30_ecog.py
--- a/tutorials/clinical/30_ecog.py
+++ b/tutorials/clinical/30_ecog.py
@@ -184,8 +184,8 @@
 src = mne.read_source_spaces(
     op.join(subjects_dir, 'fsaverage', 'bem', 'fsaverage-ico-5-src.fif'))
 stc = mne.stc_near_sensors(gamma_power_t, trans='fsaverage',
-                           subject='fsaverage', src=src,
-                           mode='nearest', subjects_dir=subjects_dir,
+                           subject='fsaverage', subjects_dir=subjects_dir,
+                           src=src, surface='pial', mode='nearest',
                            distance=0.02)
 vmin, vmid, vmax = np.percentile(gamma_power_t.data, [10, 25, 90])
 clim = dict(kind='value', lims=[vmin, vmid, vmax])


Test patch:
diff --git a/mne/tests/test_source_estimate.py b/mne/tests/test_source_estimate.py
--- a/mne/tests/test_source_estimate.py
+++ b/mne/tests/test_source_estimate.py
@@ -1611,9 +1611,9 @@ def test_stc_near_sensors(tmp_path):
     # and now with volumetric projection
     src = read_source_spaces(fname_vsrc)
     with catch_logging() as log:
-        stc_vol = stc_near_sensors(evoked, trans, 'sample', src=src,
-                                   subjects_dir=subjects_dir, verbose=True,
-                                   distance=0.033)
+        stc_vol = stc_near_sensors(
+            evoked, trans, 'sample', src=src, surface=None,
+            subjects_dir=subjects_dir, distance=0.033, verbose=True)
     assert isinstance(stc_vol, VolSourceEstimate)
     log = log.getvalue()
     assert '4157 volume vertices' in log
@@ -1627,7 +1627,7 @@ def test_stc_near_sensors_picks():
     src = mne.read_source_spaces(fname_src_fs)
     kwargs = dict(
         evoked=evoked, subject='fsaverage', trans='fsaverage',
-        subjects_dir=subjects_dir, src=src, project=True)
+        subjects_dir=subjects_dir, src=src, surface=None, project=True)
     with pytest.raises(ValueError, match='No appropriate channels'):
         stc_near_sensors(**kwargs)
     picks = np.arange(len(info['ch_names']))