From f3d3e951d72477bba011f9ac030a39f3a068640f Mon Sep 17 00:00:00 2001 From: Teemu Taivainen Date: Tue, 15 Sep 2026 15:53:14 +0300 Subject: [PATCH 01/10] Allow user specified source covariance --- mne/minimum_norm/inverse.py | 70 ++++++++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 4 deletions(-) diff --git a/mne/minimum_norm/inverse.py b/mne/minimum_norm/inverse.py index ee8815ce1e9..3074bf80f4f 100644 --- a/mne/minimum_norm/inverse.py +++ b/mne/minimum_norm/inverse.py @@ -6,6 +6,7 @@ from math import sqrt import numpy as np +from numpy.typing import ArrayLike, NDArray from scipy import linalg from .._fiff.constants import FIFF @@ -1709,6 +1710,7 @@ def _prepare_forward( combine_xyz, allow_fixed_depth, limit, + source_cov=None, ): """Prepare a gain matrix and noise covariance for localization.""" # Steps (according to MNE-C, we change the order of various steps @@ -1845,12 +1847,21 @@ def _prepare_forward( gain = np.dot(whitener, forward["sol"]["data"]) logger.info("Creating the source covariance matrix") - source_std = np.ones(gain.shape[1], dtype=gain.dtype) + if source_cov is not None: + logger.info(" Using user-specified source covariance matrix as a base") + source_variances = _handle_source_cov( + source_cov=source_cov, + fixed_inverse=fixed_inverse, + n_sources=forward["nsource"], + ) + else: + source_variances = np.ones(gain.shape[1], dtype=gain.dtype) + if depth_prior is not None: - source_std *= depth_prior + source_variances *= depth_prior if orient_prior is not None: - source_std *= orient_prior - np.sqrt(source_std, out=source_std) + source_variances *= orient_prior + source_std = np.sqrt(source_variances) # variances --> standard deviations gain *= source_std # Adjusting Source Covariance matrix to make trace of G*R*G' equal # to number of sensors. @@ -1874,6 +1885,50 @@ def _prepare_forward( ) +def _handle_source_cov( + source_cov: ArrayLike, fixed_inverse: bool, n_sources: int +) -> NDArray[np.float64]: + """Check that source_cov is compatible with the gain and reshape if necessary. + + Always returns a copy with data type float64. + """ + # Make a copy and ensure data type float64. + source_cov = np.array(source_cov, dtype=np.float64, copy=True) + # Ensure 1D array. + if source_cov.ndim > 1: + source_cov = source_cov.squeeze() + if source_cov.ndim != 1: + raise ValueError( + f"source_cov must be a 1D array of variances, got shape {source_cov.shape}" + ) + if np.any(source_cov < 0): + raise ValueError("source_cov must contain non-negative variance values.") + + if source_cov.shape[0] == n_sources: + if fixed_inverse: + return source_cov + logger.info( + " Repeated each element of a fixed-orientation source " + "covariance matrix into the free-orientation one" + ) + return np.repeat(source_cov, 3) + + if source_cov.shape[0] == 3 * n_sources: + if fixed_inverse: + logger.info( + " Picked every third element from a free-orientation source " + "covariance matrix into the fixed-orientation one" + ) + return source_cov[2::3] + return source_cov + + raise ValueError( + f"source_cov length {source_cov.shape[0]} is not compatible with " + f"the number of sources ({n_sources}). Allowed lengths are " + f"{n_sources} or 3 * {n_sources}." + ) + + @verbose def make_inverse_operator( info, @@ -1884,6 +1939,7 @@ def make_inverse_operator( fixed="auto", rank=None, use_cps=True, + source_cov=None, verbose=None, ): """Assemble inverse operator. @@ -1910,6 +1966,11 @@ def make_inverse_operator( is used. %(rank_none)s %(use_cps)s + source_cov : array-like of float, shape (n_sources,) or (n_sources * 3,) | None + Diagonal source covariance matrix (source variances) to use as a base. Final + source covariance matrix will be computed as the product of this base and + the depth and orientation priors determined by parameters ``depth``, ``loose``, and + ``fixed``. If None (default), a uniform source covariance matrix is used as a base. %(verbose)s Returns @@ -1981,6 +2042,7 @@ def make_inverse_operator( rank, pca="white", use_cps=use_cps, + source_cov=source_cov, **depth, ) # no need to copy any attributes of forward here because there is From 3ab503c803b7520224f991b96153f5dd29f6b7d0 Mon Sep 17 00:00:00 2001 From: Teemu Taivainen Date: Thu, 17 Sep 2026 16:07:55 +0300 Subject: [PATCH 02/10] Add tests --- mne/minimum_norm/tests/test_inverse.py | 116 ++++++++++++++++++++++++- 1 file changed, 114 insertions(+), 2 deletions(-) diff --git a/mne/minimum_norm/tests/test_inverse.py b/mne/minimum_norm/tests/test_inverse.py index b659d4f35c8..8501ac22c4d 100644 --- a/mne/minimum_norm/tests/test_inverse.py +++ b/mne/minimum_norm/tests/test_inverse.py @@ -58,6 +58,7 @@ read_inverse_operator, write_inverse_operator, ) +from mne.minimum_norm.inverse import _handle_source_cov from mne.source_estimate import VolSourceEstimate, read_source_estimate from mne.source_space._source_space import _get_src_nn from mne.surface import _normal_orth @@ -861,7 +862,7 @@ def test_make_inverse_operator_fixed(evoked, noise_cov): fixed=True, ) - # now compare to C solution + # now test source_cov parameter and compare to C solution # note that the forward solution must not be surface-oriented # to get equivalence (surf_ori=True changes the normals) with catch_logging() as log: @@ -879,7 +880,47 @@ def test_make_inverse_operator_fixed(evoked, noise_cov): assert "EEG channels: 0" in repr(inv_op) assert "MEG channels: 305" in repr(inv_op) assert "Fixed" in repr(inv_op) - del fwd_fixed + + # Test that uniform source_cov gives same result as no source_cov (default). + # Pass free orientation length source_cov to also test picking to fixed orientation. + inv_op_source_cov_ones = make_inverse_operator( + evoked.info, + fwd, + noise_cov, + depth=0.0, + fixed=True, + use_cps=False, + source_cov=np.ones(3 * fwd["nsource"]), + ) + _compare_inverses_approx( + inv_op, inv_op_source_cov_ones, evoked, rtol=1e-5, atol=1e-8 + ) + # Test that non-uniform source covariance input correctly scales the final source + # covariance matrix. + source_cov_input = np.ones(fwd["nsource"]) + source_cov_input[0] = 4.0 + inv_op_scaled = make_inverse_operator( + evoked.info, + fwd, + noise_cov, + depth=0.0, + fixed=True, + use_cps=False, + source_cov=source_cov_input, + ) + final_source_cov = inv_op_scaled["source_cov"]["data"] + # No depth or orientation weighting, result should be proportional to the input + # source covariance. + assert_allclose(final_source_cov[0] / final_source_cov[1], 4.0) + assert_allclose(final_source_cov[1:] / final_source_cov[1], 1.0) + + del ( + fwd_fixed, + inv_op_source_cov_ones, + inv_op_scaled, + ) + + # Compare to C solution. inverse_operator_nodepth = read_inverse_operator(fname_inv_fixed_nodepth) # XXX We should have this but we don't (MNE-C doesn't restrict info): # assert 'EEG channels: 0' in repr(inverse_operator_nodepth) @@ -932,6 +973,77 @@ def test_make_inverse_operator_free(evoked, noise_cov): stc_surf = apply_inverse(evoked, inv_surf, pick_ori=pick_ori) assert_allclose(stc_surf.data, stc.data, atol=1e-2) + # Test that uniform source_cov gives same result as no source_cov (default). + # Pass fixed orientation length source_cov to also test repeating to the free + # orientation length. + inv_op_source_cov_ones = make_inverse_operator( + evoked.info, + fwd, + noise_cov, + depth=None, + loose=1.0, + source_cov=np.ones(fwd["nsource"]), + ) + _compare_inverses_approx(inv, inv_op_source_cov_ones, evoked, rtol=1e-5, atol=1e-8) + # Test that non-uniform source covariance input correctly scales the final source + # covariance matrix. + source_cov_input = np.ones(fwd["nsource"]) + source_cov_input[-1] = 5.0 + inv_op_scaled = make_inverse_operator( + evoked.info, + fwd, + noise_cov, + depth=None, + loose=1.0, + source_cov=source_cov_input, + ) + # Last source (all three orientations) should be scaled by 5x. + final_source_cov = inv_op_scaled["source_cov"]["data"] + assert_allclose(final_source_cov[-3:] / final_source_cov[-4], 5.0) + assert_allclose(final_source_cov[:-3] / final_source_cov[-4], 1.0) + + +def test_handle_source_cov(): + """Test the private _handle_source_cov helper.""" + n_sources = 5 + source_cov = np.arange(1, n_sources + 1, dtype=float) + + # Matching length is returned unchanged, as an independent copy. + out = _handle_source_cov(source_cov, fixed_inverse=True, n_sources=n_sources) + assert_allclose(out, source_cov) + out[0] = -1 + assert source_cov[0] == 1 + + # Free-orientation-shaped input is picked down for a fixed-orientation inverse. + free_shaped = np.repeat(source_cov, 3) + out = _handle_source_cov(free_shaped, fixed_inverse=True, n_sources=n_sources) + assert_allclose(out, source_cov) + + # Fixed-orientation-shaped input is repeated for a free-orientation inverse. + out = _handle_source_cov(source_cov, fixed_inverse=False, n_sources=n_sources) + assert_allclose(out, np.repeat(source_cov, 3)) + + # int dtype and plain list input are coerced to float64. + for arraylike in (list(source_cov), source_cov.astype(int)): + out = _handle_source_cov(arraylike, fixed_inverse=True, n_sources=n_sources) + assert out.dtype == np.float64 + + # A length-1 input is not squeezed away to a 0D array. + out = _handle_source_cov(np.array([2.5]), fixed_inverse=True, n_sources=1) + assert out.shape == (1,) + + # Invalid inputs. + with pytest.raises(ValueError, match="not compatible with the number of sources"): + _handle_source_cov( + np.ones(n_sources + 1), fixed_inverse=True, n_sources=n_sources + ) + with pytest.raises(ValueError, match="non-negative"): + _handle_source_cov(-source_cov, fixed_inverse=True, n_sources=n_sources) + with pytest.raises(ValueError, match="1D array of variances"): + _handle_source_cov( + np.ones((2, n_sources)), fixed_inverse=True, n_sources=n_sources + ) + @pytest.mark.slowtest def test_make_inverse_operator_vector(evoked, noise_cov): From dc980861748e2a20f8d70cffe7c38ae86bab9cd7 Mon Sep 17 00:00:00 2001 From: Teemu Taivainen Date: Fri, 18 Sep 2026 15:59:42 +0300 Subject: [PATCH 03/10] Validate input variances better --- mne/minimum_norm/inverse.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mne/minimum_norm/inverse.py b/mne/minimum_norm/inverse.py index 3074bf80f4f..be5bac3032e 100644 --- a/mne/minimum_norm/inverse.py +++ b/mne/minimum_norm/inverse.py @@ -1901,8 +1901,8 @@ def _handle_source_cov( raise ValueError( f"source_cov must be a 1D array of variances, got shape {source_cov.shape}" ) - if np.any(source_cov < 0): - raise ValueError("source_cov must contain non-negative variance values.") + if not np.all(np.isfinite(source_cov)) or np.any(source_cov <= 0): + raise ValueError("source_cov must contain finite, positive variance values.") if source_cov.shape[0] == n_sources: if fixed_inverse: @@ -1970,7 +1970,8 @@ def make_inverse_operator( Diagonal source covariance matrix (source variances) to use as a base. Final source covariance matrix will be computed as the product of this base and the depth and orientation priors determined by parameters ``depth``, ``loose``, and - ``fixed``. If None (default), a uniform source covariance matrix is used as a base. + ``fixed``. Values must be finite and positive. If None (default), a uniform + source covariance matrix is used as a base. %(verbose)s Returns From 15cfc38ac080b906099283cf3c24df757e5af0b4 Mon Sep 17 00:00:00 2001 From: Teemu Taivainen Date: Fri, 18 Sep 2026 16:01:36 +0300 Subject: [PATCH 04/10] Update input validation test --- mne/minimum_norm/tests/test_inverse.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/mne/minimum_norm/tests/test_inverse.py b/mne/minimum_norm/tests/test_inverse.py index 8501ac22c4d..1735d22876a 100644 --- a/mne/minimum_norm/tests/test_inverse.py +++ b/mne/minimum_norm/tests/test_inverse.py @@ -1037,8 +1037,18 @@ def test_handle_source_cov(): _handle_source_cov( np.ones(n_sources + 1), fixed_inverse=True, n_sources=n_sources ) - with pytest.raises(ValueError, match="non-negative"): + with pytest.raises(ValueError, match="finite, positive"): _handle_source_cov(-source_cov, fixed_inverse=True, n_sources=n_sources) + with pytest.raises(ValueError, match="finite, positive"): + _handle_source_cov(np.zeros(n_sources), fixed_inverse=True, n_sources=n_sources) + with pytest.raises(ValueError, match="finite, positive"): + source_cov_nan = source_cov.copy() + source_cov_nan[0] = np.nan + _handle_source_cov(source_cov_nan, fixed_inverse=True, n_sources=n_sources) + with pytest.raises(ValueError, match="finite, positive"): + source_cov_inf = source_cov.copy() + source_cov_inf[0] = np.inf + _handle_source_cov(source_cov_inf, fixed_inverse=True, n_sources=n_sources) with pytest.raises(ValueError, match="1D array of variances"): _handle_source_cov( np.ones((2, n_sources)), fixed_inverse=True, n_sources=n_sources From 7cf109b75829945061dd210ccec1d9abbe1764e0 Mon Sep 17 00:00:00 2001 From: Teemu Taivainen Date: Fri, 18 Sep 2026 16:30:48 +0300 Subject: [PATCH 05/10] Add to changelog --- doc/changes/dev/14315.newfeature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 doc/changes/dev/14315.newfeature.rst diff --git a/doc/changes/dev/14315.newfeature.rst b/doc/changes/dev/14315.newfeature.rst new file mode 100644 index 00000000000..56caad89889 --- /dev/null +++ b/doc/changes/dev/14315.newfeature.rst @@ -0,0 +1 @@ +Add parameter ``source_cov`` for :func:`mne.minimum_norm.make_inverse_operator`, allowing use of a custom (diagonal) source covariance matrix for the inverse solution, by `Teemu Taivainen`_. \ No newline at end of file From f99f76e43f23fa1e5d795ac63a552495dbd613e9 Mon Sep 17 00:00:00 2001 From: Teemu Taivainen Date: Fri, 18 Sep 2026 16:58:25 +0300 Subject: [PATCH 06/10] Make docstring nicer --- mne/minimum_norm/inverse.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/mne/minimum_norm/inverse.py b/mne/minimum_norm/inverse.py index be5bac3032e..47333086c06 100644 --- a/mne/minimum_norm/inverse.py +++ b/mne/minimum_norm/inverse.py @@ -1966,12 +1966,12 @@ def make_inverse_operator( is used. %(rank_none)s %(use_cps)s - source_cov : array-like of float, shape (n_sources,) or (n_sources * 3,) | None - Diagonal source covariance matrix (source variances) to use as a base. Final - source covariance matrix will be computed as the product of this base and - the depth and orientation priors determined by parameters ``depth``, ``loose``, and - ``fixed``. Values must be finite and positive. If None (default), a uniform - source covariance matrix is used as a base. + source_cov : array-like of float | None + Diagonal source covariance matrix (source variances) to use as a base. + Allowed shapes are (n_sources,) and (n_sources * 3,). Final source covariance matrix + will be computed as the product of this base and the depth and orientation priors + determined by parameters ``depth``, ``loose``, and ``fixed``. If None (default), + a uniform source covariance matrix (array of ones) is used as a base. %(verbose)s Returns From 86256383f5229c20efd6dd535501463772e9da3a Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Fri, 18 Sep 2026 11:19:17 -0400 Subject: [PATCH 07/10] Simplify and condense --- doc/changes/dev/14315.newfeature.rst | 2 +- mne/minimum_norm/inverse.py | 86 ++++----------- mne/minimum_norm/tests/test_inverse.py | 139 ++++++------------------- 3 files changed, 53 insertions(+), 174 deletions(-) diff --git a/doc/changes/dev/14315.newfeature.rst b/doc/changes/dev/14315.newfeature.rst index 56caad89889..a08776f43f5 100644 --- a/doc/changes/dev/14315.newfeature.rst +++ b/doc/changes/dev/14315.newfeature.rst @@ -1 +1 @@ -Add parameter ``source_cov`` for :func:`mne.minimum_norm.make_inverse_operator`, allowing use of a custom (diagonal) source covariance matrix for the inverse solution, by `Teemu Taivainen`_. \ No newline at end of file +Add parameter ``source_cov`` for :func:`mne.minimum_norm.make_inverse_operator`, allowing use of a custom (diagonal) source covariance matrix for the inverse solution, by `Teemu Taivainen`_. diff --git a/mne/minimum_norm/inverse.py b/mne/minimum_norm/inverse.py index 47333086c06..535782f1498 100644 --- a/mne/minimum_norm/inverse.py +++ b/mne/minimum_norm/inverse.py @@ -6,7 +6,6 @@ from math import sqrt import numpy as np -from numpy.typing import ArrayLike, NDArray from scipy import linalg from .._fiff.constants import FIFF @@ -1847,21 +1846,24 @@ def _prepare_forward( gain = np.dot(whitener, forward["sol"]["data"]) logger.info("Creating the source covariance matrix") + source_std = np.ones(gain.shape[1], dtype=gain.dtype) if source_cov is not None: - logger.info(" Using user-specified source covariance matrix as a base") - source_variances = _handle_source_cov( - source_cov=source_cov, - fixed_inverse=fixed_inverse, - n_sources=forward["nsource"], - ) - else: - source_variances = np.ones(gain.shape[1], dtype=gain.dtype) - + source_cov = np.asarray(source_cov, dtype=np.float64) + n_sources = forward["nsource"] + if source_cov.shape != (n_sources,): + raise ValueError( + f"source_cov must have shape ({n_sources},), got {source_cov.shape}" + ) + if not (np.isfinite(source_cov).all() and (source_cov > 0).all()): + raise ValueError("source_cov must contain finite, positive variances") + logger.info(" Using user-specified source variances") + # one variance per source location, applied to all orientations + source_std *= np.repeat(source_cov, gain.shape[1] // n_sources) if depth_prior is not None: - source_variances *= depth_prior + source_std *= depth_prior if orient_prior is not None: - source_variances *= orient_prior - source_std = np.sqrt(source_variances) # variances --> standard deviations + source_std *= orient_prior + np.sqrt(source_std, out=source_std) gain *= source_std # Adjusting Source Covariance matrix to make trace of G*R*G' equal # to number of sensors. @@ -1885,50 +1887,6 @@ def _prepare_forward( ) -def _handle_source_cov( - source_cov: ArrayLike, fixed_inverse: bool, n_sources: int -) -> NDArray[np.float64]: - """Check that source_cov is compatible with the gain and reshape if necessary. - - Always returns a copy with data type float64. - """ - # Make a copy and ensure data type float64. - source_cov = np.array(source_cov, dtype=np.float64, copy=True) - # Ensure 1D array. - if source_cov.ndim > 1: - source_cov = source_cov.squeeze() - if source_cov.ndim != 1: - raise ValueError( - f"source_cov must be a 1D array of variances, got shape {source_cov.shape}" - ) - if not np.all(np.isfinite(source_cov)) or np.any(source_cov <= 0): - raise ValueError("source_cov must contain finite, positive variance values.") - - if source_cov.shape[0] == n_sources: - if fixed_inverse: - return source_cov - logger.info( - " Repeated each element of a fixed-orientation source " - "covariance matrix into the free-orientation one" - ) - return np.repeat(source_cov, 3) - - if source_cov.shape[0] == 3 * n_sources: - if fixed_inverse: - logger.info( - " Picked every third element from a free-orientation source " - "covariance matrix into the fixed-orientation one" - ) - return source_cov[2::3] - return source_cov - - raise ValueError( - f"source_cov length {source_cov.shape[0]} is not compatible with " - f"the number of sources ({n_sources}). Allowed lengths are " - f"{n_sources} or 3 * {n_sources}." - ) - - @verbose def make_inverse_operator( info, @@ -1966,12 +1924,14 @@ def make_inverse_operator( is used. %(rank_none)s %(use_cps)s - source_cov : array-like of float | None - Diagonal source covariance matrix (source variances) to use as a base. - Allowed shapes are (n_sources,) and (n_sources * 3,). Final source covariance matrix - will be computed as the product of this base and the depth and orientation priors - determined by parameters ``depth``, ``loose``, and ``fixed``. If None (default), - a uniform source covariance matrix (array of ones) is used as a base. + source_cov : array-like, shape (n_sources,) | None + Prior variance of each source location, i.e., the diagonal of a custom + source covariance matrix. It is applied to all orientations of a source + and multiplied by the depth and orientation priors determined by + ``depth``, ``loose``, and ``fixed``. If None (default), all sources get + the same prior variance. + + .. versionadded:: 1.14 %(verbose)s Returns diff --git a/mne/minimum_norm/tests/test_inverse.py b/mne/minimum_norm/tests/test_inverse.py index 1735d22876a..eba97c20020 100644 --- a/mne/minimum_norm/tests/test_inverse.py +++ b/mne/minimum_norm/tests/test_inverse.py @@ -58,7 +58,6 @@ read_inverse_operator, write_inverse_operator, ) -from mne.minimum_norm.inverse import _handle_source_cov from mne.source_estimate import VolSourceEstimate, read_source_estimate from mne.source_space._source_space import _get_src_nn from mne.surface import _normal_orth @@ -862,7 +861,7 @@ def test_make_inverse_operator_fixed(evoked, noise_cov): fixed=True, ) - # now test source_cov parameter and compare to C solution + # now compare to C solution # note that the forward solution must not be surface-oriented # to get equivalence (surf_ori=True changes the normals) with catch_logging() as log: @@ -881,46 +880,28 @@ def test_make_inverse_operator_fixed(evoked, noise_cov): assert "MEG channels: 305" in repr(inv_op) assert "Fixed" in repr(inv_op) - # Test that uniform source_cov gives same result as no source_cov (default). - # Pass free orientation length source_cov to also test picking to fixed orientation. - inv_op_source_cov_ones = make_inverse_operator( - evoked.info, - fwd, - noise_cov, - depth=0.0, - fixed=True, - use_cps=False, - source_cov=np.ones(3 * fwd["nsource"]), + # uniform source_cov should be equivalent to the default (None) + kwargs = dict(depth=0.0, fixed=True, use_cps=False) + inv_op_ones = make_inverse_operator( + evoked.info, fwd, noise_cov, source_cov=np.ones(fwd["nsource"]), **kwargs ) - _compare_inverses_approx( - inv_op, inv_op_source_cov_ones, evoked, rtol=1e-5, atol=1e-8 - ) - # Test that non-uniform source covariance input correctly scales the final source - # covariance matrix. - source_cov_input = np.ones(fwd["nsource"]) - source_cov_input[0] = 4.0 + _compare_inverses_approx(inv_op, inv_op_ones, evoked, rtol=1e-5, atol=1e-8) + # non-uniform source_cov should scale the final source covariance + source_cov = np.ones(fwd["nsource"]) + source_cov[0] = 4.0 inv_op_scaled = make_inverse_operator( - evoked.info, - fwd, - noise_cov, - depth=0.0, - fixed=True, - use_cps=False, - source_cov=source_cov_input, - ) - final_source_cov = inv_op_scaled["source_cov"]["data"] - # No depth or orientation weighting, result should be proportional to the input - # source covariance. - assert_allclose(final_source_cov[0] / final_source_cov[1], 4.0) - assert_allclose(final_source_cov[1:] / final_source_cov[1], 1.0) - - del ( - fwd_fixed, - inv_op_source_cov_ones, - inv_op_scaled, + evoked.info, fwd, noise_cov, source_cov=source_cov, **kwargs ) + got = inv_op_scaled["source_cov"]["data"] + assert_allclose(got / got[1], source_cov) + with pytest.raises(ValueError, match="must have shape"): + make_inverse_operator(evoked.info, fwd, noise_cov, source_cov=[1.0], **kwargs) + with pytest.raises(ValueError, match="finite, positive"): + make_inverse_operator( + evoked.info, fwd, noise_cov, source_cov=-source_cov, **kwargs + ) + del fwd_fixed, inv_op_ones, inv_op_scaled, source_cov, kwargs - # Compare to C solution. inverse_operator_nodepth = read_inverse_operator(fname_inv_fixed_nodepth) # XXX We should have this but we don't (MNE-C doesn't restrict info): # assert 'EEG channels: 0' in repr(inverse_operator_nodepth) @@ -973,10 +954,8 @@ def test_make_inverse_operator_free(evoked, noise_cov): stc_surf = apply_inverse(evoked, inv_surf, pick_ori=pick_ori) assert_allclose(stc_surf.data, stc.data, atol=1e-2) - # Test that uniform source_cov gives same result as no source_cov (default). - # Pass fixed orientation length source_cov to also test repeating to the free - # orientation length. - inv_op_source_cov_ones = make_inverse_operator( + # uniform source_cov should be equivalent to the default (None) + inv_ones = make_inverse_operator( evoked.info, fwd, noise_cov, @@ -984,75 +963,15 @@ def test_make_inverse_operator_free(evoked, noise_cov): loose=1.0, source_cov=np.ones(fwd["nsource"]), ) - _compare_inverses_approx(inv, inv_op_source_cov_ones, evoked, rtol=1e-5, atol=1e-8) - # Test that non-uniform source covariance input correctly scales the final source - # covariance matrix. - source_cov_input = np.ones(fwd["nsource"]) - source_cov_input[-1] = 5.0 - inv_op_scaled = make_inverse_operator( - evoked.info, - fwd, - noise_cov, - depth=None, - loose=1.0, - source_cov=source_cov_input, + _compare_inverses_approx(inv, inv_ones, evoked, rtol=1e-5, atol=1e-8) + # non-uniform source_cov should scale all three orientations of a source + source_cov = np.ones(fwd["nsource"]) + source_cov[-1] = 5.0 + inv_scaled = make_inverse_operator( + evoked.info, fwd, noise_cov, depth=None, loose=1.0, source_cov=source_cov ) - # Last source (all three orientations) should be scaled by 5x. - final_source_cov = inv_op_scaled["source_cov"]["data"] - assert_allclose(final_source_cov[-3:] / final_source_cov[-4], 5.0) - assert_allclose(final_source_cov[:-3] / final_source_cov[-4], 1.0) - - -def test_handle_source_cov(): - """Test the private _handle_source_cov helper.""" - n_sources = 5 - source_cov = np.arange(1, n_sources + 1, dtype=float) - - # Matching length is returned unchanged, as an independent copy. - out = _handle_source_cov(source_cov, fixed_inverse=True, n_sources=n_sources) - assert_allclose(out, source_cov) - out[0] = -1 - assert source_cov[0] == 1 - - # Free-orientation-shaped input is picked down for a fixed-orientation inverse. - free_shaped = np.repeat(source_cov, 3) - out = _handle_source_cov(free_shaped, fixed_inverse=True, n_sources=n_sources) - assert_allclose(out, source_cov) - - # Fixed-orientation-shaped input is repeated for a free-orientation inverse. - out = _handle_source_cov(source_cov, fixed_inverse=False, n_sources=n_sources) - assert_allclose(out, np.repeat(source_cov, 3)) - - # int dtype and plain list input are coerced to float64. - for arraylike in (list(source_cov), source_cov.astype(int)): - out = _handle_source_cov(arraylike, fixed_inverse=True, n_sources=n_sources) - assert out.dtype == np.float64 - - # A length-1 input is not squeezed away to a 0D array. - out = _handle_source_cov(np.array([2.5]), fixed_inverse=True, n_sources=1) - assert out.shape == (1,) - - # Invalid inputs. - with pytest.raises(ValueError, match="not compatible with the number of sources"): - _handle_source_cov( - np.ones(n_sources + 1), fixed_inverse=True, n_sources=n_sources - ) - with pytest.raises(ValueError, match="finite, positive"): - _handle_source_cov(-source_cov, fixed_inverse=True, n_sources=n_sources) - with pytest.raises(ValueError, match="finite, positive"): - _handle_source_cov(np.zeros(n_sources), fixed_inverse=True, n_sources=n_sources) - with pytest.raises(ValueError, match="finite, positive"): - source_cov_nan = source_cov.copy() - source_cov_nan[0] = np.nan - _handle_source_cov(source_cov_nan, fixed_inverse=True, n_sources=n_sources) - with pytest.raises(ValueError, match="finite, positive"): - source_cov_inf = source_cov.copy() - source_cov_inf[0] = np.inf - _handle_source_cov(source_cov_inf, fixed_inverse=True, n_sources=n_sources) - with pytest.raises(ValueError, match="1D array of variances"): - _handle_source_cov( - np.ones((2, n_sources)), fixed_inverse=True, n_sources=n_sources - ) + got = inv_scaled["source_cov"]["data"] + assert_allclose(got / got[0], np.repeat(source_cov, 3)) @pytest.mark.slowtest From a56e491e81ce0bb11922eea4434668e61519f37b Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 21 Sep 2026 12:15:53 -0400 Subject: [PATCH 08/10] FIX: Add blame --- .git-blame-ignore-revs | 1 + 1 file changed, 1 insertion(+) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 054b0c65924..86938254a5c 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -14,3 +14,4 @@ e39995d9be6fc831c7a4a59f09b7a7c0a41ae315 # 12588, percent formatting b8b168088cb474f27833f5f9db9d60abe00dca83 # 12779, PR JSONs ee64eba6f345e895e3d5e7d2804fa6aa2dac2e6d # 12781, Header unification 362f9330925fb79a6adc19a42243672676dec63e # 12799, UP038 +c166d8c191d07b0efa7d6772bb0fffcf4cdb9252 # 14316, Fully populate docstrings \ No newline at end of file From 06dd8c9f12cc934385b8c05baacbe4a60e6b35b0 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 21 Sep 2026 12:27:34 -0400 Subject: [PATCH 09/10] FIX: More --- .git-blame-ignore-revs | 2 +- doc/sphinxext/credit_tools.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 86938254a5c..15bb7033067 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -14,4 +14,4 @@ e39995d9be6fc831c7a4a59f09b7a7c0a41ae315 # 12588, percent formatting b8b168088cb474f27833f5f9db9d60abe00dca83 # 12779, PR JSONs ee64eba6f345e895e3d5e7d2804fa6aa2dac2e6d # 12781, Header unification 362f9330925fb79a6adc19a42243672676dec63e # 12799, UP038 -c166d8c191d07b0efa7d6772bb0fffcf4cdb9252 # 14316, Fully populate docstrings \ No newline at end of file +c166d8c191d07b0efa7d6772bb0fffcf4cdb9252 # 14316, Fully populate docstrings diff --git a/doc/sphinxext/credit_tools.py b/doc/sphinxext/credit_tools.py index d171c0eefce..716e1979c1e 100644 --- a/doc/sphinxext/credit_tools.py +++ b/doc/sphinxext/credit_tools.py @@ -521,9 +521,11 @@ def generate_credit_rst( logger.info(f"{pr.ljust(5)} @ {commits[(name, pr)]:5d} by {name}") logger.info("\nIgnored commits:") - for pr, files in ignores.items(): # should have found one of each + # should have found one of each, except PRs whose JSON isn't fetched yet + last_pr = max(int(pr) for _, pr in commits) + for pr, files in ignores.items(): logger.info(f"ignored {len(files):3d} files for {pr}") - assert len(files) >= 1, (pr, files) + assert len(files) >= 1 or int(pr) > last_pr, (pr, files) mod_stats, link_overrides, mod_file_map = _aggregate_module_stats(stats) _write_credit_rst(mod_stats, link_overrides, mod_file_map, urls) From e169692167414fd47f2e19ec80435d2711b5f270 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 21 Sep 2026 12:38:47 -0400 Subject: [PATCH 10/10] FIX: Skip --- mne/viz/backends/_jupyterlite.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mne/viz/backends/_jupyterlite.py b/mne/viz/backends/_jupyterlite.py index 39651556525..73516996994 100644 --- a/mne/viz/backends/_jupyterlite.py +++ b/mne/viz/backends/_jupyterlite.py @@ -31,7 +31,8 @@ _orig = dict() # the functions patched below that are imported lazily, by name -def setup_notebook(data_path="/tmp/mne_data"): +# /tmp here is the Pyodide in-browser virtual filesystem, not a shared host dir +def setup_notebook(data_path="/tmp/mne_data"): # nosec B108 """Patch MNE for the browser kernel; the docs' first cell calls this. Parameters