Repository: mne-tools/mne-python

Issue:
DOC: Clarify source space functions
We should make it clear that `mne.setup_source_space` sets up a standard-pipeline surface-based `SourceSpaces` object, and setup cross-references to `mne.setup_volume_source_space`



Discussion:

Resolving patch:
diff --git a/mne/label.py b/mne/label.py
--- a/mne/label.py
+++ b/mne/label.py
@@ -17,7 +17,7 @@
 from .fixes import digitize, in1d
 from .utils import (get_subjects_dir, _check_subject, logger, verbose, warn,
                     _check_copy_dep)
-from .source_estimate import (morph_data, SourceEstimate,
+from .source_estimate import (morph_data, SourceEstimate, _center_of_mass,
                               spatial_src_connectivity)
 from .source_space import add_source_space_distances
 from .surface import read_surface, fast_cross_3d, mesh_edges, mesh_dist
@@ -698,6 +698,61 @@ def get_tris(self, tris, vertices=None):
 
         return label_tris
 
+    def center_of_mass(self, subject=None, restrict_vertices=False,
+                       subjects_dir=None, surf='sphere'):
+        """Compute the center of mass of the label
+
+        This function computes the spatial center of mass on the surface
+        as in [1]_.
+
+        Parameters
+        ----------
+        subject : string | None
+            The subject the stc is defined for.
+        restrict_vertices : bool | array of int | instance of SourceSpaces
+            If True, returned vertex will be one from stc. Otherwise, it could
+            be any vertex from surf. If an array of int, the returned vertex
+            will come from that array. If instance of SourceSpaces (as of
+            0.13), the returned vertex will be from the given source space.
+            For most accuruate estimates, do not restrict vertices.
+        subjects_dir : str, or None
+            Path to the SUBJECTS_DIR. If None, the path is obtained by using
+            the environment variable SUBJECTS_DIR.
+        surf : str
+            The surface to use for Euclidean distance center of mass
+            finding. The default here is "sphere", which finds the center
+            of mass on the spherical surface to help avoid potential issues
+            with cortical folding.
+
+        Returns
+        -------
+        vertex : int
+            Vertex of the spatial center of mass for the inferred hemisphere,
+            with each vertex weighted by its label value.
+
+        See Also
+        --------
+        SourceEstimate.center_of_mass
+        vertex_to_mni
+
+        Notes
+        -----
+        .. versionadded: 0.13
+
+        References
+        ----------
+        .. [1] Larson and Lee, "The cortical dynamics underlying effective
+               switching of auditory spatial attention", NeuroImage 2012.
+        """
+        if not isinstance(surf, string_types):
+            raise TypeError('surf must be a string, got %s' % (type(surf),))
+        subject = _check_subject(self.subject, subject)
+        if np.any(self.values < 0):
+            raise ValueError('Cannot compute COM with negative values')
+        vertex = _center_of_mass(self.vertices, self.values, self.hemi, surf,
+                                 subject, subjects_dir, restrict_vertices)
+        return vertex
+
 
 class BiHemiLabel(object):
     """A freesurfer/MNE label with vertices in both hemispheres
diff --git a/mne/source_estimate.py b/mne/source_estimate.py
--- a/mne/source_estimate.py
+++ b/mne/source_estimate.py
@@ -6,7 +6,7 @@
 # License: BSD (3-clause)
 
 import copy
-import os
+import os.path as op
 from math import ceil
 import warnings
 
@@ -20,14 +20,15 @@
 from .surface import (read_surface, _get_ico_surface, read_morph_map,
                       _compute_nearest, mesh_edges)
 from .source_space import (_ensure_src, _get_morph_src_reordering,
-                           _ensure_src_subject)
+                           _ensure_src_subject, SourceSpaces)
 from .utils import (get_subjects_dir, _check_subject, logger, verbose,
                     _time_mask, warn as warn_)
 from .viz import plot_source_estimates
 from .fixes import in1d, sparse_block_diag
 from .io.base import ToDataFrameMixin, TimeMixin
-from .externals.six.moves import zip
+
 from .externals.six import string_types
+from .externals.six.moves import zip
 from .externals.h5io import read_hdf5, write_hdf5
 
 
@@ -247,7 +248,7 @@ def read_source_estimate(fname, subject=None):
 
     # make sure corresponding file(s) can be found
     ftype = None
-    if os.path.exists(fname):
+    if op.exists(fname):
         if fname.endswith('-vl.stc') or fname.endswith('-vol.stc') or \
                 fname.endswith('-vl.w') or fname.endswith('-vol.w'):
             ftype = 'volume'
@@ -276,11 +277,11 @@ def read_source_estimate(fname, subject=None):
             raise RuntimeError('Unknown extension for file %s' % fname_arg)
 
     if ftype is not 'volume':
-        stc_exist = [os.path.exists(f)
+        stc_exist = [op.exists(f)
                      for f in [fname + '-rh.stc', fname + '-lh.stc']]
-        w_exist = [os.path.exists(f)
+        w_exist = [op.exists(f)
                    for f in [fname + '-rh.w', fname + '-lh.w']]
-        h5_exist = os.path.exists(fname + '-stc.h5')
+        h5_exist = op.exists(fname + '-stc.h5')
         if all(stc_exist) and (ftype is not 'w'):
             ftype = 'surface'
         elif all(w_exist):
@@ -910,6 +911,29 @@ def transform(self, func, idx=None, tmin=None, tmax=None, copy=False):
         return stcs
 
 
+def _center_of_mass(vertices, values, hemi, surf, subject, subjects_dir,
+                    restrict_vertices):
+    """Helper to find the center of mass on a surface"""
+    subjects_dir = get_subjects_dir(subjects_dir, raise_error=True)
+    surf = read_surface(op.join(subjects_dir, subject, 'surf',
+                                hemi + '.' + surf))
+    if restrict_vertices is True:
+        restrict_vertices = vertices
+    elif restrict_vertices is False:
+        restrict_vertices = np.arange(surf[0].shape[0])
+    elif isinstance(restrict_vertices, SourceSpaces):
+        idx = 1 if restrict_vertices.kind == 'surface' and hemi == 'rh' else 0
+        restrict_vertices = restrict_vertices[idx]['vertno']
+    else:
+        restrict_vertices = np.array(restrict_vertices, int)
+    pos = surf[0][vertices, :].T
+    c_o_m = np.sum(pos * values, axis=1) / np.sum(values)
+    vertex = np.argmin(np.sqrt(np.mean((surf[0][restrict_vertices, :] -
+                                        c_o_m) ** 2, axis=1)))
+    vertex = restrict_vertices[vertex]
+    return vertex
+
+
 class SourceEstimate(_BaseSourceEstimate):
     """Container for surface source estimates
 
@@ -1194,15 +1218,19 @@ def extract_label_time_course(self, labels, src, mode='mean_flip',
         return label_tc
 
     def center_of_mass(self, subject=None, hemi=None, restrict_vertices=False,
-                       subjects_dir=None):
-        """Return the vertex on a given surface that is at the center of mass
-        of  the activity in stc. Note that all activity must occur in a single
-        hemisphere, otherwise an error is returned. The "mass" of each point in
-        space for computing the spatial center of mass is computed by summing
-        across time, and vice-versa for each point in time in computing the
-        temporal center of mass. This is useful for quantifying spatio-temporal
-        cluster locations, especially when combined with the function
-        mne.source_space.vertex_to_mni().
+                       subjects_dir=None, surf='sphere'):
+        """Compute the center of mass of activity
+
+        This function computes the spatial center of mass on the surface
+        as well as the temporal center of mass as in [1]_.
+
+        .. note:: All activity must occur in a single hemisphere, otherwise
+                  an error is raised. The "mass" of each point in space for
+                  computing the spatial center of mass is computed by summing
+                  across time, and vice-versa for each point in time in
+                  computing the temporal center of mass. This is useful for
+                  quantifying spatio-temporal cluster locations, especially
+                  when combined with :func:`mne.source_space.vertex_to_mni`.
 
         Parameters
         ----------
@@ -1213,14 +1241,25 @@ def center_of_mass(self, subject=None, hemi=None, restrict_vertices=False,
             hemisphere. If None, one of the hemispheres must be all zeroes,
             and the center of mass will be calculated for the other
             hemisphere (useful for getting COM for clusters).
-        restrict_vertices : bool, or array of int
+        restrict_vertices : bool | array of int | instance of SourceSpaces
             If True, returned vertex will be one from stc. Otherwise, it could
             be any vertex from surf. If an array of int, the returned vertex
-            will come from that array. For most accuruate estimates, do not
-            restrict vertices.
+            will come from that array. If instance of SourceSpaces (as of
+            0.13), the returned vertex will be from the given source space.
+            For most accuruate estimates, do not restrict vertices.
         subjects_dir : str, or None
             Path to the SUBJECTS_DIR. If None, the path is obtained by using
             the environment variable SUBJECTS_DIR.
+        surf : str
+            The surface to use for Euclidean distance center of mass
+            finding. The default here is "sphere", which finds the center
+            of mass on the spherical surface to help avoid potential issues
+            with cortical folding.
+
+        See Also
+        --------
+        Label.center_of_mass
+        vertex_to_mni
 
         Returns
         -------
@@ -1235,12 +1274,16 @@ def center_of_mass(self, subject=None, hemi=None, restrict_vertices=False,
             Time of the temporal center of mass (weighted by the sum across
             source vertices).
 
-        References:
-            Used in Larson and Lee, "The cortical dynamics underlying effective
-            switching of auditory spatial attention", NeuroImage 2012.
+        References
+        ----------
+        .. [1] Larson and Lee, "The cortical dynamics underlying effective
+               switching of auditory spatial attention", NeuroImage 2012.
         """
+        if not isinstance(surf, string_types):
+            raise TypeError('surf must be a string, got %s' % (type(surf),))
         subject = _check_subject(self.subject, subject)
-
+        if np.any(self.data < 0):
+            raise ValueError('Cannot compute COM with negative values')
         values = np.sum(self.data, axis=1)  # sum across time
         vert_inds = [np.arange(len(self.vertices[0])),
                      np.arange(len(self.vertices[1])) + len(self.vertices[0])]
@@ -1252,34 +1295,13 @@ def center_of_mass(self, subject=None, hemi=None, restrict_vertices=False,
             hemi = hemi[0]
         if hemi not in [0, 1]:
             raise ValueError('hemi must be 0 or 1')
-
-        subjects_dir = get_subjects_dir(subjects_dir, raise_error=True)
-
-        values = values[vert_inds[hemi]]
-
-        hemis = ['lh', 'rh']
-        surf = os.path.join(subjects_dir, subject, 'surf',
-                            hemis[hemi] + '.sphere')
-
-        if isinstance(surf, string_types):  # read in surface
-            surf = read_surface(surf)
-
-        if restrict_vertices is False:
-            restrict_vertices = np.arange(surf[0].shape[0])
-        elif restrict_vertices is True:
-            restrict_vertices = self.vertices[hemi]
-
-        if np.any(self.data < 0):
-            raise ValueError('Cannot compute COM with negative values')
-
-        pos = surf[0][self.vertices[hemi], :].T
-        c_o_m = np.sum(pos * values, axis=1) / np.sum(values)
-
-        # Find the vertex closest to the COM
-        vertex = np.argmin(np.sqrt(np.mean((surf[0][restrict_vertices, :] -
-                                            c_o_m) ** 2, axis=1)))
-        vertex = restrict_vertices[vertex]
-
+        vertices = self.vertices[hemi]
+        values = values[vert_inds[hemi]]  # left or right
+        del vert_inds
+        vertex = _center_of_mass(
+            vertices, values, hemi=['lh', 'rh'][hemi], surf=surf,
+            subject=subject, subjects_dir=subjects_dir,
+            restrict_vertices=restrict_vertices)
         # do time center of mass by using the values across space
         masses = np.sum(self.data, axis=0).astype(float)
         t_ind = np.sum(masses * np.arange(self.shape[1])) / np.sum(masses)
@@ -1993,8 +2015,8 @@ def _morph_mult(data, e, use_sparse, idx_use_data, idx_use_out=None):
 
 
 def _get_subject_sphere_tris(subject, subjects_dir):
-    spheres = [os.path.join(subjects_dir, subject, 'surf',
-                            xh + '.sphere.reg') for xh in ['lh', 'rh']]
+    spheres = [op.join(subjects_dir, subject, 'surf',
+                       xh + '.sphere.reg') for xh in ['lh', 'rh']]
     tris = [read_surface(s)[1] for s in spheres]
     return tris
 
@@ -2246,8 +2268,8 @@ def grade_to_vertices(subject, grade, subjects_dir=None, n_jobs=1,
         return [np.arange(10242), np.arange(10242)]
     subjects_dir = get_subjects_dir(subjects_dir, raise_error=True)
 
-    spheres_to = [os.path.join(subjects_dir, subject, 'surf',
-                               xh + '.sphere.reg') for xh in ['lh', 'rh']]
+    spheres_to = [op.join(subjects_dir, subject, 'surf',
+                          xh + '.sphere.reg') for xh in ['lh', 'rh']]
     lhs, rhs = [read_surface(s)[0] for s in spheres_to]
 
     if grade is not None:  # fill a subset of vertices
diff --git a/mne/source_space.py b/mne/source_space.py
--- a/mne/source_space.py
+++ b/mne/source_space.py
@@ -53,6 +53,13 @@ def _get_lut_id(lut, label, use_lut):
     return lut['id'][mask]
 
 
+_src_kind_dict = {
+    'vol': 'volume',
+    'surf': 'surface',
+    'discrete': 'discrete',
+}
+
+
 class SourceSpaces(list):
     """Represent a list of source space
 
@@ -84,24 +91,26 @@ def __repr__(self):
         ss_repr = []
         for ss in self:
             ss_type = ss['type']
+            r = _src_kind_dict[ss_type]
             if ss_type == 'vol':
                 if 'seg_name' in ss:
-                    r = ("'vol' (%s), n_used=%i"
-                         % (ss['seg_name'], ss['nuse']))
+                    r += " (%s)" % (ss['seg_name'],)
                 else:
-                    r = ("'vol', shape=%s, n_used=%i"
-                         % (repr(ss['shape']), ss['nuse']))
+                    r += ", shape=%s" % (ss['shape'],)
             elif ss_type == 'surf':
-                r = "'surf', n_vertices=%i, n_used=%i" % (ss['np'], ss['nuse'])
-            else:
-                r = "%r" % ss_type
-            coord_frame = ss['coord_frame']
-            if isinstance(coord_frame, np.ndarray):
-                coord_frame = coord_frame[0]
-            r += ', coordinate_frame=%s' % _coord_frame_name(coord_frame)
+                r += (" (%s), n_vertices=%i" % (_get_hemi(ss)[0], ss['np']))
+            r += (', n_used=%i, coordinate_frame=%s'
+                  % (ss['nuse'], _coord_frame_name(int(ss['coord_frame']))))
             ss_repr.append('<%s>' % r)
-        ss_repr = ', '.join(ss_repr)
-        return "<SourceSpaces: [{ss}]>".format(ss=ss_repr)
+        return "<SourceSpaces: [%s]>" % ', '.join(ss_repr)
+
+    @property
+    def kind(self):
+        """The kind of source space (surface, volume, discrete)"""
+        ss_types = list(set([ss['type'] for ss in self]))
+        if len(ss_types) != 1:
+            return 'combined'
+        return _src_kind_dict[ss_types[0]]
 
     def __add__(self, other):
         return SourceSpaces(list.__add__(self, other))
@@ -1174,7 +1183,7 @@ def _read_talxfm(subject, subjects_dir, mode=None, verbose=None):
 def setup_source_space(subject, fname=True, spacing='oct6', surface='white',
                        overwrite=False, subjects_dir=None, add_dist=True,
                        n_jobs=1, verbose=None):
-    """Setup a source space with subsampling
+    """Setup a bilater hemisphere surface-based source space with subsampling
 
     Parameters
     ----------
@@ -1206,6 +1215,10 @@ def setup_source_space(subject, fname=True, spacing='oct6', surface='white',
     -------
     src : list
         The source space for each hemisphere.
+
+    See Also
+    --------
+    setup_volume_source_space
     """
     cmd = ('setup_source_space(%s, fname=%s, spacing=%s, surface=%s, '
            'overwrite=%s, subjects_dir=%s, add_dist=%s, verbose=%s)'
@@ -1364,7 +1377,7 @@ def setup_volume_source_space(subject, fname=None, pos=5.0, mri=None,
     surface : str | dict | None
         Define source space bounds using a FreeSurfer surface file. Can
         also be a dictionary with entries `'rr'` and `'tris'`, such as
-        those returned by `read_surface()`.
+        those returned by :func:`mne.read_surface`.
     mindist : float
         Exclude points closer than this distance (mm) to the bounding surface.
     exclude : float
@@ -1389,6 +1402,10 @@ def setup_volume_source_space(subject, fname=None, pos=5.0, mri=None,
         compatibility reasons, as most functions expect source spaces
         to be provided as lists).
 
+    See Also
+    --------
+    setup_source_space
+
     Notes
     -----
     To create a discrete source space, `pos` must be a dict, 'mri' must be


Test patch:
diff --git a/mne/tests/test_label.py b/mne/tests/test_label.py
--- a/mne/tests/test_label.py
+++ b/mne/tests/test_label.py
@@ -775,6 +775,7 @@ def test_grow_labels():
 
 @testing.requires_testing_data
 def test_label_sign_flip():
+    """Test label sign flip computation"""
     src = read_source_spaces(src_fname)
     label = Label(vertices=src[0]['vertno'][:5], hemi='lh')
     src[0]['nn'][label.vertices] = np.array(
@@ -791,4 +792,55 @@ def test_label_sign_flip():
                               len(idx))
 
 
+@testing.requires_testing_data
+def test_label_center_of_mass():
+    """Test computing the center of mass of a label"""
+    stc = read_source_estimate(stc_fname)
+    stc.lh_data[:] = 0
+    vertex_stc = stc.center_of_mass('sample', subjects_dir=subjects_dir)[0]
+    assert_equal(vertex_stc, 124791)
+    label = Label(stc.vertices[1], pos=None, values=stc.rh_data.mean(axis=1),
+                  hemi='rh', subject='sample')
+    vertex_label = label.center_of_mass(subjects_dir=subjects_dir)
+    assert_equal(vertex_label, vertex_stc)
+
+    labels = read_labels_from_annot('sample', parc='aparc.a2009s',
+                                    subjects_dir=subjects_dir)
+    src = read_source_spaces(src_fname)
+    # Try a couple of random ones, one from left and one from right
+    # Visually verified in about the right place using mne_analyze
+    for label, expected in zip([labels[2], labels[3], labels[-5]],
+                               [141162, 145221, 55979]):
+        label.values[:] = -1
+        assert_raises(ValueError, label.center_of_mass,
+                      subjects_dir=subjects_dir)
+        label.values[:] = 1
+        assert_equal(label.center_of_mass(subjects_dir=subjects_dir), expected)
+        assert_equal(label.center_of_mass(subjects_dir=subjects_dir,
+                                          restrict_vertices=label.vertices),
+                     expected)
+        # restrict to source space
+        idx = 0 if label.hemi == 'lh' else 1
+        # this simple nearest version is not equivalent, but is probably
+        # close enough for many labels (including the test ones):
+        pos = label.pos[np.where(label.vertices == expected)[0][0]]
+        pos = (src[idx]['rr'][src[idx]['vertno']] - pos)
+        pos = np.argmin(np.sum(pos * pos, axis=1))
+        src_expected = src[idx]['vertno'][pos]
+        # see if we actually get the same one
+        src_restrict = np.intersect1d(label.vertices, src[idx]['vertno'])
+        assert_equal(label.center_of_mass(subjects_dir=subjects_dir,
+                                          restrict_vertices=src_restrict),
+                     src_expected)
+        assert_equal(label.center_of_mass(subjects_dir=subjects_dir,
+                                          restrict_vertices=src),
+                     src_expected)
+    # degenerate cases
+    assert_raises(ValueError, label.center_of_mass, subjects_dir=subjects_dir,
+                  restrict_vertices='foo')
+    assert_raises(TypeError, label.center_of_mass, subjects_dir=subjects_dir,
+                  surf=1)
+    assert_raises(IOError, label.center_of_mass, subjects_dir=subjects_dir,
+                  surf='foo')
+
 run_tests_if_main()
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
@@ -271,8 +271,7 @@ def test_stc_arithmetic():
 @slow_test
 @testing.requires_testing_data
 def test_stc_methods():
-    """Test stc methods lh_data, rh_data, bin(), center_of_mass(), resample()
-    """
+    """Test stc methods lh_data, rh_data, bin, center_of_mass, resample"""
     stc = read_source_estimate(fname_stc)
 
     # lh_data / rh_data
@@ -286,6 +285,8 @@ def test_stc_methods():
     assert a[0] == bin.data[0, 0]
 
     assert_raises(ValueError, stc.center_of_mass, 'sample')
+    assert_raises(TypeError, stc.center_of_mass, 'sample',
+                  subjects_dir=subjects_dir, surf=1)
     stc.lh_data[:] = 0
     vertex, hemi, t = stc.center_of_mass('sample', subjects_dir=subjects_dir)
     assert_true(hemi == 1)
diff --git a/mne/tests/test_source_space.py b/mne/tests/test_source_space.py
--- a/mne/tests/test_source_space.py
+++ b/mne/tests/test_source_space.py
@@ -205,6 +205,8 @@ def test_discrete_source_space():
         # now do MRI
         assert_raises(ValueError, setup_volume_source_space, 'sample',
                       pos=pos_dict, mri=fname_mri)
+        assert_equal(repr(src_new), repr(src_c))
+        assert_equal(src_new.kind, 'discrete')
     finally:
         if op.isfile(temp_name):
             os.remove(temp_name)
@@ -233,6 +235,8 @@ def test_volume_source_space():
     assert_raises(IOError, setup_volume_source_space, 'sample', temp_name,
                   pos=7.0, bem=None, surface='foo',  # bad surf
                   mri=fname_mri, subjects_dir=subjects_dir)
+    assert_equal(repr(src), repr(src_new))
+    assert_equal(src.kind, 'volume')
 
 
 @testing.requires_testing_data
@@ -254,6 +258,7 @@ def test_other_volume_source_spaces():
                                         mri=fname_mri,
                                         subjects_dir=subjects_dir)
     _compare_source_spaces(src, src_new, mode='approx')
+    assert_true('volume, shape' in repr(src))
     del src
     del src_new
     assert_raises(ValueError, setup_volume_source_space, 'sample', temp_name,
@@ -341,6 +346,8 @@ def test_setup_source_space():
                                      subjects_dir=subjects_dir, add_dist=False,
                                      overwrite=True)
     _compare_source_spaces(src, src_new, mode='approx')
+    assert_equal(repr(src), repr(src_new))
+    assert_equal(repr(src).count('surface ('), 2)
     assert_array_equal(src[0]['vertno'], np.arange(10242))
     assert_array_equal(src[1]['vertno'], np.arange(10242))
 
@@ -531,6 +538,8 @@ def test_combine_source_spaces():
     src.save(src_out_name)
     src_from_file = read_source_spaces(src_out_name)
     _compare_source_spaces(src, src_from_file, mode='approx')
+    assert_equal(repr(src), repr(src_from_file))
+    assert_equal(src.kind, 'combined')
 
     # test that all source spaces are in MRI coordinates
     coord_frames = np.array([s['coord_frame'] for s in src])