1212import shutil
1313from collections import OrderedDict
1414from copy import deepcopy
15- from functools import partial
15+ from functools import cache , partial
1616from pathlib import Path
1717
1818import numpy as np
@@ -791,23 +791,44 @@ def _one_step(mu, u):
791791
792792def _fwd_eeg_fit_berg_scherg (m , nterms , nfit ):
793793 """Fit the Berg-Scherg equivalent spherical model dipole parameters."""
794+ # The fit depends only on the relative radii and conductivities of the
795+ # layers, not the absolute head radius or origin, so cache on those
796+ # Using the exact relative-radius ratio also keeps scipy's COBYLA out of a
797+ # near-degenerate regime where it fails to converge
798+ rel_rads = tuple (float (layer ["rel_rad" ]) for layer in m ["layers" ])
799+ sigmas = tuple (float (layer ["sigma" ]) for layer in m ["layers" ])
800+ mu , lambda_ , rv = _fit_berg_scherg_cached (rel_rads , sigmas , nterms , nfit )
801+
802+ m ["mu" ] = np .array (mu )
803+ # This division takes into account the actual conductivities
804+ m ["lambda" ] = np .array (lambda_ ) / m ["layers" ][- 1 ]["sigma" ]
805+ m ["nfit" ] = nfit
806+ return rv
807+
808+
809+ @cache
810+ def _fit_berg_scherg_cached (rel_rads , sigmas , nterms , nfit ):
811+ """Fit Berg-Scherg params (pure function of relative radii and sigmas)."""
794812 assert nfit >= 2
813+ # Only rel_rad and sigma are read to compute the coefficients and weighting.
814+ m = dict (layers = [dict (rel_rad = r , sigma = s ) for r , s in zip (rel_rads , sigmas )])
795815 u = dict (nfit = nfit , nterms = nterms )
796816
797817 # (1) Calculate the coefficients of the true expansion
798818 u ["fn" ] = _fwd_eeg_get_multi_sphere_model_coeffs (m , nterms + 1 )
799819
800- # (2) Calculate the weighting
801- f = min ([layer ["rad" ] for layer in m ["layers" ]]) / max (
802- [layer ["rad" ] for layer in m ["layers" ]]
803- )
820+ # (2) Calculate the weighting from the relative-radius ratio
821+ f = min (rel_rads ) / max (rel_rads )
804822
805823 # correct weighting
806824 k = np .arange (1 , nterms + 1 )
807825 u ["w" ] = np .sqrt ((2.0 * k + 1 ) * (3.0 * k + 1.0 ) / k ) * np .power (f , (k - 1.0 ))
808826 u ["w" ][- 1 ] = 0
809827
810- # Do the nonlinear minimization, constraining mu to the interval [-1, +1]
828+ # rhobeg (initial trust-region radius) is ~half the (-1, 1) variable range
829+ # rhoend (final radius) sets the resolution of mu. The dipoles sit at radii
830+ # proportional to mu (order 1) while the fit's residual var is ~1e-4, so resolving
831+ # below that gains nothing and can prevent convergence
811832 mu_0 = np .zeros (3 )
812833 fun = partial (_one_step , u = u )
813834 catol = 1e-6
@@ -816,18 +837,13 @@ def _fwd_eeg_fit_berg_scherg(m, nterms, nfit):
816837 def cons (x ):
817838 return max_ - np .abs (x )
818839
819- mu = fmin_cobyla (fun , mu_0 , [cons ], rhobeg = 0.5 , rhoend = 1e-5 , catol = catol )
840+ mu = fmin_cobyla (fun , mu_0 , [cons ], rhobeg = 0.5 , rhoend = 1e-4 , catol = catol )
820841
821842 # (6) Do the final step: calculation of the linear parameters
822843 rv , lambda_ = _compute_linear_parameters (mu , u )
823844 order = np .argsort (mu )[::- 1 ]
824845 mu , lambda_ = mu [order ], lambda_ [order ] # sort: largest mu first
825-
826- m ["mu" ] = mu
827- # This division takes into account the actual conductivities
828- m ["lambda" ] = lambda_ / m ["layers" ][- 1 ]["sigma" ]
829- m ["nfit" ] = nfit
830- return rv
846+ return tuple (mu ), tuple (lambda_ ), rv
831847
832848
833849@verbose
0 commit comments