1212
1313from .._fiff .constants import FIFF
1414from .._fiff .meas_info import _simplify_info
15- from .._fiff .pick import pick_info , pick_types
15+ from .._fiff .pick import pick_channels_forward , pick_info , pick_types
1616from .._fiff .proj import _has_eeg_average_ref_proj , make_projector
1717from ..bem import _check_origin
18- from ..cov import make_ad_hoc_cov
18+ from ..cov import Covariance , make_ad_hoc_cov
1919from ..epochs import BaseEpochs , EpochsArray
2020from ..evoked import Evoked , EvokedArray
21- from ..fixes import _safe_svd
21+ from ..rank import _compute_rank_int
2222from ..surface import get_head_surf , get_meg_helmet_surf
2323from ..transforms import _find_trans , transform_surface_to
24- from ..utils import _check_fname , _check_option , _pl , _reg_pinv , logger , verbose
24+ from ..utils import (
25+ _check_fname ,
26+ _check_option ,
27+ _pl ,
28+ _reg_pinv ,
29+ logger ,
30+ verbose ,
31+ )
2532from ._lead_dots import _do_cross_dots , _do_self_dots , _do_surface_dots , _get_legen_fun
2633from ._make_forward import _create_eeg_els , _create_meg_coils , _read_coil_defs
2734
2835
2936def _setup_dots (mode , info , coils , ch_type ):
3037 """Set up dot products."""
3138 int_rad = 0.06
32- noise = make_ad_hoc_cov (info , dict ( mag = 20e-15 , grad = 5e-13 , eeg = 1e-6 ) )
39+ noise = _make_field_mapping_noise (info )
3340 # "fast" uses a coarser (n_coeff=50) Legendre series than "accurate" (n_coeff=100)
3441 n_coeff = 50 if mode == "fast" else 100
3542 leg_fun , n_fact = _get_legen_fun (ch_type , False , n_coeff )
3643 return int_rad , noise , leg_fun , n_fact
3744
3845
39- def _compute_mapping_matrix (fmd , info ):
46+ def _make_field_mapping_noise (info ):
47+ """Create the ad hoc noise covariance used for field mapping."""
48+ return make_ad_hoc_cov (info , dict (mag = 20e-15 , grad = 5e-13 , eeg = 1e-6 ))
49+
50+
51+ def _compute_mapping_matrix (fmd , info , * , rank = None ):
4052 """Do the hairy computations."""
4153 logger .info (" Preparing the mapping matrix..." )
4254 # assemble a projector and apply it to the data
@@ -52,12 +64,26 @@ def _compute_mapping_matrix(fmd, info):
5264 whitener = np .diag (1.0 / np .sqrt (noise_cov ["data" ].ravel ()))
5365 whitened_dots = np .dot (whitener .T , np .dot (proj_dots , whitener ))
5466
55- # SVD is numerically better than the eigenvalue composition even if
56- # mat is supposed to be symmetric and positive definite
67+ # whitened_dots is symmetric and positive semi-definite, so _reg_pinv (which
68+ # requires square Hermitian input) can do the truncated pseudoinversion
5769 if fmd .get ("pinv_method" , "tsvd" ) == "tsvd" :
58- inv , fmd ["nest" ] = _pinv_trunc (whitened_dots , fmd ["miss" ])
70+ n = len (whitened_dots )
71+ if rank is None :
72+ # truncate at most "miss" fraction of the singular value energy
73+ s = np .linalg .svd (whitened_dots , compute_uv = False , hermitian = True )
74+ varexp = np .cumsum (s )
75+ varexp /= varexp [- 1 ]
76+ rank = np .where (varexp >= 1.0 - fmd ["miss" ])[0 ][0 ] + 1
77+ logger .info (
78+ f" Truncating at { rank } /{ n } components to omit less than "
79+ f"{ fmd ['miss' ]:g} ({ 1.0 - varexp [rank - 1 ]:0.2g} )"
80+ )
81+ else :
82+ logger .info (f" Truncating at { rank } /{ n } components" )
83+ inv , _ , fmd ["nest" ] = _reg_pinv (whitened_dots , reg = 0 , rank = rank )
5984 else :
6085 assert fmd ["pinv_method" ] == "tikhonov" , fmd ["pinv_method" ]
86+ assert rank is None , rank # only the tsvd path supports an explicit rank
6187 inv , fmd ["nest" ] = _pinv_tikhonov (whitened_dots , fmd ["miss" ])
6288
6389 # Sandwich with the whitener
@@ -81,26 +107,6 @@ def _compute_mapping_matrix(fmd, info):
81107 return mapping_mat
82108
83109
84- def _pinv_trunc (x , miss ):
85- """Compute pseudoinverse, truncating at most "miss" fraction of varexp."""
86- u , s , v = _safe_svd (x , full_matrices = False )
87-
88- # Eigenvalue truncation
89- varexp = np .cumsum (s )
90- varexp /= varexp [- 1 ]
91- n = np .where (varexp >= (1.0 - miss ))[0 ][0 ] + 1
92- logger .info (
93- " Truncating at %d/%d components to omit less than %g (%0.2g)" ,
94- n ,
95- len (s ),
96- miss ,
97- 1.0 - varexp [n - 1 ],
98- )
99- s = 1.0 / s [:n ]
100- inv = ((u [:, :n ] * s ) @ v [:n ]).T
101- return inv , n
102-
103-
104110def _pinv_tikhonov (x , reg ):
105111 # _reg_pinv requires square Hermitian, which we have here
106112 inv , _ , n = _reg_pinv (x , reg = reg , rank = None )
@@ -110,7 +116,9 @@ def _pinv_tikhonov(x, reg):
110116 return inv , n
111117
112118
113- def _map_meg_or_eeg_channels (info_from , info_to , mode , * , origin , miss = None ):
119+ def _map_meg_or_eeg_channels (
120+ info_from , info_to , mode , * , origin , miss = None , forward = None , rank = None
121+ ):
114122 """Find mapping from one set of channels to another.
115123
116124 Parameters
@@ -127,14 +135,18 @@ def _map_meg_or_eeg_channels(info_from, info_to, mode, *, origin, miss=None):
127135 Origin of the sphere in the head coordinate frame and in meters.
128136 Can be ``'auto'``, which means a head-digitization-based origin
129137 fit.
138+ forward : instance of Forward | None
139+ Forward model used instead of geometry-based field interpolation.
140+ rank : None | 'info' | dict
141+ Rank specification for Forward-based reconstruction, where ``None``
142+ estimates it from the projected field covariance. Must be ``None`` for
143+ the geometry-based path, which uses its own truncation instead.
130144
131145 Returns
132146 -------
133147 mapping : array, shape (n_to, n_from)
134148 A mapping matrix.
135149 """
136- assert origin is not None # should be assured elsewhere
137-
138150 # no need to apply trans because both from and to coils are in device
139151 # coordinates
140152 info_kinds = set (ch ["kind" ] for ch in info_to ["chs" ])
@@ -150,6 +162,34 @@ def _map_meg_or_eeg_channels(info_from, info_to, mode, *, origin, miss=None):
150162 )
151163 kind = "eeg" if info_kinds [0 ] == FIFF .FIFFV_EEG_CH else "meg"
152164
165+ if forward is not None :
166+ forward = pick_channels_forward (
167+ forward , include = info_from ["ch_names" ], ordered = True
168+ )
169+ assert forward ["sol" ]["row_names" ] == info_from ["ch_names" ]
170+ lead_field = forward ["sol" ]["data" ]
171+ # Form the sensor-space field covariance from the Forward gain matrix.
172+ # As with any Gram representation, very weak modes can be numerically unstable.
173+ dots = lead_field @ lead_field .T
174+ field_cov = Covariance (
175+ dots ,
176+ info_from ["ch_names" ],
177+ info_from ["bads" ],
178+ info_from ["projs" ],
179+ nfree = 1 ,
180+ )
181+ rank_int = _compute_rank_int (field_cov , rank = rank , info = info_from )
182+ fmd = dict (
183+ kind = kind ,
184+ ch_names = info_from ["ch_names" ],
185+ noise = _make_field_mapping_noise (info_from ),
186+ self_dots = dots ,
187+ surface_dots = dots ,
188+ )
189+ return _compute_mapping_matrix (fmd , info_from , rank = rank_int )
190+
191+ assert origin is not None # should be assured elsewhere
192+
153193 #
154194 # Step 1. Prepare the coil definitions
155195 #
0 commit comments