Repository: mne-tools/mne-python

Issue:
Size of resolution matrix (make_inverse_resolution_matrix) inconsistent with documentation
#### Describe the bug
It is unclear what the correct way to compute the resolution matrix is when using `mne.minimum_norm.make_inverse_resolution_matrix` with an inverse operator that has loose orientations. The function `_get_matrix_from_inverse_operator` is called (in line 59) to return a matrix that can be multiplied with the leadfield, and the documentation there states:

> For loose orientation constraint, the CTFs are computed for the normal component (pick_ori=‘normal’).

However, it does not seem like `pick_ori='normal'` is actually used. 

#### Steps to reproduce
```
import mne 

data_path = mne.datasets.sample.data_path()
fname_inv = data_path + '/MEG/sample/sample_audvis-eeg-oct-6-eeg-inv.fif' # free source orientation
inverse_operator = mne.minimum_norm.read_inverse_operator(fname_inv)

fname_fwd = data_path + '/MEG/sample/sample_audvis-eeg-oct-6-fwd.fif'
forward = mne.read_forward_solution(fname_fwd)

method = "sLORETA" 
snr = 3.
lambda2 = 1. / snr ** 2

res_matrix = mne.minimum_norm.make_inverse_resolution_matrix(forward,inverse_operator,method=method,lambda2=lambda2)
```

#### Expected results
I expect the res_matrix to have dimensions nsources x nsources, where nsources is the number of sources in the source space. 

#### Actual results
The res_matrix has dimensions (3*nsources) x (3*nsources). 

#### Additional information
Output of `mne.sys_info()`: 
```
Qt WebEngine seems to be initialized from a plugin. Please set Qt::AA_ShareOpenGLContexts using QCoreApplication::setAttribute before constructing QGuiApplication.
Platform:      Linux-5.11.0-44-generic-x86_64-with-debian-bullseye-sid
Python:        3.7.9 (default, Aug 31 2020, 12:42:55)  [GCC 7.3.0]
Executable:    /home/katharina/snap/anaconda3/envs/py37cmp-gui/bin/python
CPU:           x86_64: 16 cores
Memory:        31.3 GB

mne:           0.20.7
numpy:         1.18.5 {blas=mkl_rt, lapack=mkl_rt}
scipy:         1.5.2
matplotlib:    3.2.2 {backend=Qt5Agg}

sklearn:       1.0.1
numba:         Not found
nibabel:       3.2.1
cupy:          Not found
pandas:        1.3.4
dipy:          1.1.1
mayavi:        Not found
pyvista:       Not found
vtk:           Not found
```



Discussion:

Resolving patch:
diff --git a/mne/minimum_norm/resolution_matrix.py b/mne/minimum_norm/resolution_matrix.py
--- a/mne/minimum_norm/resolution_matrix.py
+++ b/mne/minimum_norm/resolution_matrix.py
@@ -7,7 +7,8 @@
 
 import numpy as np
 
-from .. import pick_channels_forward, EvokedArray, SourceEstimate
+from .. import (pick_channels_forward, EvokedArray, SourceEstimate,
+                VectorSourceEstimate)
 from ..io.constants import FIFF
 from ..utils import logger, verbose
 from ..forward.forward import convert_forward_solution
@@ -64,13 +65,13 @@ def make_inverse_resolution_matrix(forward, inverse_operator, method='dSPM',
 
 
 @verbose
-def _get_psf_ctf(resmat, src, idx, func, mode, n_comp, norm, return_pca_vars,
-                 verbose=None):
+def _get_psf_ctf(resmat, src, idx, *, func, mode, n_comp, norm,
+                 return_pca_vars, vector=False, verbose=None):
     """Get point-spread (PSFs) or cross-talk (CTFs) functions.
 
     Parameters
     ----------
-    resmat : array, shape (n_dipoles, n_dipoles)
+    resmat : array, shape (n_orient_inv * n_dipoles, n_orient_fwd * n_dipoles)
         Forward Operator.
     src : Source Space
         Source space used to compute resolution matrix.
@@ -81,6 +82,7 @@ def _get_psf_ctf(resmat, src, idx, func, mode, n_comp, norm, return_pca_vars,
     %(n_comp_pctf_n)s
     %(norm_pctf)s
     %(return_pca_vars_pctf)s
+    %(vector_pctf)s
     %(verbose)s
 
     Returns
@@ -97,20 +99,44 @@ def _get_psf_ctf(resmat, src, idx, func, mode, n_comp, norm, return_pca_vars,
 
     # get relevant vertices in source space
     verts_all = _vertices_for_get_psf_ctf(idx, src)
-    # vertices used in forward and inverse operator
     vertno_lh = src[0]['vertno']
     vertno_rh = src[1]['vertno']
     vertno = [vertno_lh, vertno_rh]
 
+    n_verts = len(vertno[0]) + len(vertno[1])
+
+    n_r, n_c = resmat.shape
+    if (((n_verts != n_r) and (n_r / 3 != n_verts))
+            or ((n_verts != n_c) and (n_c / 3 != n_verts))):
+        msg = ('Number of vertices (%d) and corresponding dimension of'
+               'resolution matrix ((%d, %d) do not match' %
+               (n_verts, n_r, n_c))
+        raise ValueError(msg)
+
     # the following will operate on columns of funcs
     if func == 'ctf':
         resmat = resmat.T
+        n_r, n_c = n_c, n_r
+
     # Functions and variances per label
     stcs = []
     pca_vars = []
 
+    # if 3 orientations per vertex, redefine indices to columns of resolution
+    # matrix
+    if n_verts != n_c:
+        # change indices to three indices per vertex
+        for [i, verts] in enumerate(verts_all):
+            verts_vec = np.empty(3 * len(verts), dtype=int)
+            for [j, v] in enumerate(verts):
+                verts_vec[3 * j: 3 * j + 3] = \
+                    3 * verts[j] + np.array([0, 1, 2])
+            verts_all[i] = verts_vec  # use these as indices
+
     for verts in verts_all:
         # get relevant PSFs or CTFs for specified vertices
+        if type(verts) is int:
+            verts = [verts]  # to keep array dimensions
         funcs = resmat[:, verts]
 
         # normalise PSFs/CTFs if requested
@@ -123,8 +149,21 @@ def _get_psf_ctf(resmat, src, idx, func, mode, n_comp, norm, return_pca_vars,
             funcs, pca_var = _summarise_psf_ctf(funcs, mode, n_comp,
                                                 return_pca_vars)
 
-        # convert to source estimate
-        stc = SourceEstimate(funcs, vertno, tmin=0., tstep=1.)
+        if not vector:  # if one value per vertex requested
+            if n_verts != n_r:  # if 3 orientations per vertex, combine
+                funcs_int = np.empty([int(n_r / 3), funcs.shape[1]])
+                for i in np.arange(0, n_verts):
+                    funcs_vert = funcs[3 * i:3 * i + 3, :]
+                    funcs_int[i, :] = np.sqrt((funcs_vert ** 2).sum(axis=0))
+                stc = SourceEstimate(funcs_int, vertno, tmin=0., tstep=1.)
+            else:  # use as is
+                stc = SourceEstimate(funcs, vertno, tmin=0., tstep=1.)
+        else:  # STC with orientations
+            # convert to vector source estimate
+            m, n = int(funcs.shape[0] / 3), int(funcs.shape[1])
+            data = funcs.reshape(m, 3, n)
+            stc = VectorSourceEstimate(data, vertno, tmin=0., tstep=1.)
+
         stcs.append(stc)
         pca_vars.append(pca_var)
 
@@ -196,7 +235,6 @@ def _normalise_psf_ctf(funcs, norm):
 
 def _summarise_psf_ctf(funcs, mode, n_comp, return_pca_vars):
     """Summarise PSFs/CTFs across vertices."""
-    from scipy import linalg
     s_var = None  # only computed for return_pca_vars=True
 
     if mode == 'maxval':  # pick PSF/CTF with maximum absolute value
@@ -205,7 +243,7 @@ def _summarise_psf_ctf(funcs, mode, n_comp, return_pca_vars):
             sortidx = np.argsort(absvals)
             maxidx = sortidx[-n_comp:]
         else:  # faster if only one required
-            maxidx = absvals.argmax()
+            maxidx = [absvals.argmax()]
         funcs = funcs[:, maxidx]
 
     elif mode == 'maxnorm':  # pick PSF/CTF with maximum norm
@@ -214,19 +252,22 @@ def _summarise_psf_ctf(funcs, mode, n_comp, return_pca_vars):
             sortidx = np.argsort(norms)
             maxidx = sortidx[-n_comp:]
         else:  # faster if only one required
-            maxidx = norms.argmax()
+            maxidx = [norms.argmax()]
         funcs = funcs[:, maxidx]
 
     elif mode == 'sum':  # sum across PSFs/CTFs
-        funcs = np.sum(funcs, axis=1)
+        funcs = np.sum(funcs, axis=1, keepdims=True)
 
     elif mode == 'mean':  # mean of PSFs/CTFs
-        funcs = np.mean(funcs, axis=1)
+        funcs = np.mean(funcs, axis=1, keepdims=True)
 
     elif mode == 'pca':  # SVD across PSFs/CTFs
         # compute SVD of PSFs/CTFs across vertices
-        u, s, _ = linalg.svd(funcs, full_matrices=False)
-        funcs = u[:, :n_comp]
+        u, s, _ = np.linalg.svd(funcs, full_matrices=False, compute_uv=True)
+        if n_comp > 1:
+            funcs = u[:, :n_comp]
+        else:
+            funcs = u[:, 0, np.newaxis]
         # if explained variances for SVD components requested
         if return_pca_vars:
             # explained variance of individual SVD components
@@ -237,8 +278,8 @@ def _summarise_psf_ctf(funcs, mode, n_comp, return_pca_vars):
 
 
 @verbose
-def get_point_spread(resmat, src, idx, mode=None, n_comp=1, norm=False,
-                     return_pca_vars=False, verbose=None):
+def get_point_spread(resmat, src, idx, mode=None, *, n_comp=1, norm=False,
+                     return_pca_vars=False, vector=False, verbose=None):
     """Get point-spread (PSFs) functions for vertices.
 
     Parameters
@@ -252,6 +293,7 @@ def get_point_spread(resmat, src, idx, mode=None, n_comp=1, norm=False,
     %(n_comp_pctf_n)s
     %(norm_pctf)s
     %(return_pca_vars_pctf)s
+    %(vector_pctf)s
     %(verbose)s
 
     Returns
@@ -260,12 +302,13 @@ def get_point_spread(resmat, src, idx, mode=None, n_comp=1, norm=False,
     %(pca_vars_pctf)s
     """
     return _get_psf_ctf(resmat, src, idx, func='psf', mode=mode, n_comp=n_comp,
-                        norm=norm, return_pca_vars=return_pca_vars)
+                        norm=norm, return_pca_vars=return_pca_vars,
+                        vector=vector)
 
 
 @verbose
-def get_cross_talk(resmat, src, idx, mode=None, n_comp=1, norm=False,
-                   return_pca_vars=False, verbose=None):
+def get_cross_talk(resmat, src, idx, mode=None, *, n_comp=1, norm=False,
+                   return_pca_vars=False, vector=False, verbose=None):
     """Get cross-talk (CTFs) function for vertices.
 
     Parameters
@@ -279,6 +322,7 @@ def get_cross_talk(resmat, src, idx, mode=None, n_comp=1, norm=False,
     %(n_comp_pctf_n)s
     %(norm_pctf)s
     %(return_pca_vars_pctf)s
+    %(vector_pctf)s
     %(verbose)s
 
     Returns
@@ -287,7 +331,8 @@ def get_cross_talk(resmat, src, idx, mode=None, n_comp=1, norm=False,
     %(pca_vars_pctf)s
     """
     return _get_psf_ctf(resmat, src, idx, func='ctf', mode=mode, n_comp=n_comp,
-                        norm=norm, return_pca_vars=return_pca_vars)
+                        norm=norm, return_pca_vars=return_pca_vars,
+                        vector=vector)
 
 
 def _convert_forward_match_inv(fwd, inv):
diff --git a/mne/utils/docs.py b/mne/utils/docs.py
--- a/mne/utils/docs.py
+++ b/mne/utils/docs.py
@@ -3144,10 +3144,18 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75):
 
 docdict['stcs_pctf'] = """
 stcs : instance of SourceEstimate | list of instances of SourceEstimate
-    PSFs or CTFs as STC objects.
-    All PSFs/CTFs will be returned as successive samples in STC objects,
-    in the order they are specified in idx. STCs for different labels will
-    be returned as a list.
+    The PSFs or CTFs as STC objects. All PSFs/CTFs will be returned as
+    successive samples in STC objects, in the order they are specified
+    in idx. STCs for different labels willbe returned as a list.
+    If resmat was computed with n_orient_inv==3 for CTFs or
+    n_orient_fwd==3 for PSFs then 3 functions per vertex will be returned
+    as successive samples (i.e. one function per orientation).
+    If vector=False (default) and resmat was computed with
+    n_orient_inv==3 for PSFs or n_orient_fwd==3 for CTFs, then the three
+    values per vertex will be combined into one intensity value per
+    vertex in a SourceEstimate object. If vector=True, PSFs or CTFs
+    with 3 values per vertex (one per orientation) will be returned in
+    a VectorSourceEstimate object.
 """
 
 docdict['std_err_by_event_type_returns'] = """
@@ -3496,6 +3504,16 @@ def _reflow_param_docstring(docstring, has_first_line=True, width=75):
 # %%
 # V
 
+docdict['vector_pctf'] = """
+vector : bool
+    Whether to return PSF/CTF as vector source estimate (3 values per
+    location) or source estimate object (1 intensity value per location).
+    Only allowed to be True if corresponding dimension of resolution matrix
+    is 3 * n_dipoles. Defaults to False.
+
+    .. versionadded:: 1.2
+"""
+
 docdict['verbose'] = """
 verbose : bool | str | int | None
     Control verbosity of the logging output. If ``None``, use the default


Test patch:
diff --git a/mne/minimum_norm/tests/test_resolution_matrix.py b/mne/minimum_norm/tests/test_resolution_matrix.py
--- a/mne/minimum_norm/tests/test_resolution_matrix.py
+++ b/mne/minimum_norm/tests/test_resolution_matrix.py
@@ -122,8 +122,19 @@ def test_resolution_matrix():
                     norm=norm, return_pca_vars=False)
                 # for MNE, PSF/CTFs for same vertices should be the same
                 assert_array_almost_equal(stc_psf.data, stc_ctf.data)
+                # with free orientations and vector source estimates
+                stc_psf_free = get_point_spread(
+                    rm_mne_free, forward_fxd['src'], idx, mode=mode,
+                    n_comp=n_comp, norm=norm, return_pca_vars=False,
+                    vector=True)
+                stc_ctf_free = get_cross_talk(
+                    rm_mne_free, forward_fxd['src'], idx, mode=mode,
+                    n_comp=n_comp, norm=norm, return_pca_vars=False,
+                    vector=True)
+                assert_array_almost_equal(stc_psf_free.data, stc_ctf_free.data)
 
     # check SVD variances
+    n_comp = 3
     stc_psf, s_vars_psf = get_point_spread(
         rm_mne, forward_fxd['src'], idx, mode=mode, n_comp=n_comp,
         norm='norm', return_pca_vars=True)
@@ -157,6 +168,13 @@ def test_resolution_matrix():
                                    norm='max')
     # For MNE, PSF and CTF for same vertices should be the same
     assert_array_almost_equal(stc_psf_label.data, stc_ctf_label.data)
+    # same with free orientations and vector source estimates
+    stc_psf_label_free = get_point_spread(
+        rm_mne_free, forward_fxd['src'], label, norm='norm', vector=True)
+    stc_ctf_label_free = get_cross_talk(
+        rm_mne_free, forward_fxd['src'], label, norm='norm', vector=True)
+    assert_array_almost_equal(stc_psf_label_free.data, stc_ctf_label_free.data)
+
     # test multiple labels
     stc_psf_label2 = get_point_spread(rm_mne, forward_fxd['src'], label2,
                                       norm='max')