diff --git a/src/easyscience/fitting/minimizers/minimizer_bumps.py b/src/easyscience/fitting/minimizers/minimizer_bumps.py index 4a3e8802..b512d59d 100644 --- a/src/easyscience/fitting/minimizers/minimizer_bumps.py +++ b/src/easyscience/fitting/minimizers/minimizer_bumps.py @@ -2,6 +2,8 @@ # SPDX-License-Identifier: BSD-3-Clause import copy +import functools +import inspect import warnings from typing import Callable from typing import List @@ -10,6 +12,7 @@ from bumps.fitters import FIT_AVAILABLE_IDS from bumps.fitters import FITTERS from bumps.fitters import FitDriver +from bumps.fitters import fit as bumps_fit from bumps.monitor import Monitor from bumps.names import Curve from bumps.names import FitProblem @@ -45,6 +48,19 @@ def __call__(self, history): self.last_step = int(history.step[0]) +class _EvalCounter: + def __init__(self, fn: Callable): + self._fn = fn + self.count = 0 + self.__name__ = getattr(fn, '__name__', self.__class__.__name__) + self.__signature__ = inspect.signature(fn) + functools.update_wrapper(self, fn) + + def __call__(self, *args, **kwargs): + self.count += 1 + return self._fn(*args, **kwargs) + + class _BumpsProgressMonitor(Monitor): def __init__(self, problem, callback, payload_builder): self._problem = problem @@ -90,6 +106,7 @@ def __init__( """ super().__init__(obj=obj, fit_function=fit_function, minimizer_enum=minimizer_enum) self._p_0 = {} + self._eval_counter: _EvalCounter | None = None @staticmethod def all_methods() -> List[str]: @@ -201,10 +218,12 @@ def fit( global_object.stack.enabled = False try: - x_result, fx = driver.fit() - self._set_parameter_fit_result(x_result, driver, stack_status, problem._parameters) + model_results = bumps_fit(problem, **method_dict, **minimizer_kwargs, **kwargs) + self._set_parameter_fit_result(model_results, stack_status, problem._parameters) results = self._gen_fit_results( - x_result, fx, driver, step_counter.last_step, max_evaluations + model_results, + max_evaluations=max_evaluations, + tolerance=tolerance, ) except Exception as e: self._restore_parameter_values() @@ -293,7 +312,8 @@ def _make_model(self, parameters: List[BumpsParameter] | None = None) -> Callabl :return: Callable to make a bumps Curve model :rtype: Callable """ - fit_func = self._generate_fit_function() + fit_func = _EvalCounter(self._generate_fit_function()) + self._eval_counter = fit_func def _outer(obj): def _make_func(x, y, weights): @@ -316,23 +336,23 @@ def _make_func(x, y, weights): def _set_parameter_fit_result( self, - x_result: np.ndarray, - driver: FitDriver, + fit_result, stack_status: bool, par_list: List[BumpsParameter], ): """Update parameters to their final values and assign a std error to them. - :param x_result: Optimized parameter values from FitDriver - :param driver: The FitDriver instance (provides stderr) + :param fit_result: BUMPS OptimizeResult containing best-fit + values and errors :param stack_status: Whether the undo stack was enabled :param par_list: List of BUMPS parameter objects """ from easyscience import global_object pars = self._cached_pars - stderr = driver.stderr() + x_result = np.asarray(fit_result.x) + stderr = np.asarray(fit_result.dx) if stack_status: self._restore_parameter_values() @@ -348,11 +368,9 @@ def _set_parameter_fit_result( def _gen_fit_results( self, - x_result: np.ndarray, - fx: float, - driver: FitDriver, - n_evaluations: int = 0, + fit_results, max_evaluations: int | None = None, + tolerance: float | None = None, **kwargs, ) -> FitResults: """Convert fit results into the unified `FitResults` format. @@ -370,20 +388,27 @@ def _gen_fit_results( for name, value in kwargs.items(): if getattr(results, name, False): setattr(results, name, value) - results.n_evaluations = n_evaluations - # Bumps step counter is 0-indexed, so the last step of a budget of N - # is N-1. We therefore compare with ``max_evaluations - 1``. - if max_evaluations is not None and n_evaluations >= max_evaluations - 1: - results.success = False - results.message = f'Maximum number of evaluations ({max_evaluations}) reached' - warnings.warn( - f'Fit did not converge within the maximum number of evaluations ({max_evaluations}). ' - 'Consider increasing the maximum number of evaluations or adjusting the tolerance.', - UserWarning, + n_evaluations = None if self._eval_counter is None else self._eval_counter.count + # BUMPS exposes `nit` as the last reported optimizer step index rather than the + # total number of objective calls. We keep `n_evaluations` as objective-call + # count for cross-backend consistency with LMFit (`nfev`) and DFO-LS (`nf`). + n_iterations = getattr(fit_results, 'nit', None) + # Convert the zero-based step index into the number of optimizer steps that have + # actually been consumed against the configured BUMPS `steps` budget. + n_steps_used = None if n_iterations is None else n_iterations + 1 + stopped_on_budget = max_evaluations is not None and ( + # For BUMPS, `max_evaluations` is forwarded as `steps`, so budget + # exhaustion must be checked against consumed optimizer steps, not raw + # objective evaluations, which can legitimately exceed the step budget. + (n_steps_used is not None and n_steps_used >= max_evaluations) + or ( + n_iterations is None + and n_evaluations is not None + and n_evaluations >= max_evaluations ) - else: - results.success = True - results.message = 'Optimization terminated successfully' + ) + + results.success = fit_results.success and not stopped_on_budget pars = self._cached_pars item = {} for index, name in enumerate(self._cached_model.pars.keys()): @@ -396,7 +421,32 @@ def _gen_fit_results( results.y_obs = self._cached_model.y results.y_calc = self.evaluate(results.x, minimizer_parameters=results.p) results.y_err = self._cached_model.dy + results.n_evaluations = n_evaluations + results.message = '' + if stopped_on_budget: + results.message = ( + f'Fit stopped: reached maximum optimizer steps ({max_evaluations}); ' + f'objective evaluated {n_evaluations} times' + ) + if stopped_on_budget: + if tolerance is None: + warnings.warn( + f'Fit did not converge within the maximum optimizer steps of {max_evaluations} ' + f'({n_evaluations} objective evaluations). ' + 'Consider increasing the maximum number of evaluations or adjusting the tolerance.', + UserWarning, + ) + else: + warnings.warn( + f'Fit did not reach the desired tolerance of {tolerance} within the maximum optimizer steps of {max_evaluations} ' + f'({n_evaluations} objective evaluations). ' + 'Consider increasing the maximum number of evaluations or adjusting the tolerance.', + UserWarning, + ) + + # results.residual = results.y_obs - results.y_calc + # results.goodness_of_fit = np.sum(results.residual**2) results.minimizer_engine = self.__class__ results.fit_args = None - results.engine_result = driver + results.engine_result = fit_results return results diff --git a/src/easyscience/fitting/minimizers/minimizer_dfo.py b/src/easyscience/fitting/minimizers/minimizer_dfo.py index 259a52ea..2cb2031e 100644 --- a/src/easyscience/fitting/minimizers/minimizer_dfo.py +++ b/src/easyscience/fitting/minimizers/minimizer_dfo.py @@ -364,6 +364,8 @@ def _gen_fit_results(self, fit_results, weights, **kwargs) -> FitResults: # DFO-LS stores fixed exit-code constants on each result object; # EXIT_SUCCESS is 0 and EXIT_MAXFUN_WARNING keeps a different flag value. results.success = fit_results.flag == fit_results.EXIT_SUCCESS + if fit_results.flag == fit_results.EXIT_MAXFUN_WARNING: + warnings.warn(str(fit_results.msg), UserWarning) pars = {} for p_name, par in self._cached_pars.items(): diff --git a/tests/integration/fitting/test_fitter.py b/tests/integration/fitting/test_fitter.py index 63ede513..9f97b54a 100644 --- a/tests/integration/fitting/test_fitter.py +++ b/tests/integration/fitting/test_fitter.py @@ -251,6 +251,35 @@ def test_max_evaluations_populates_fit_result_fields(fit_engine): assert len(result.message) > 0 +@pytest.mark.fast +def test_bumps_max_evaluations_counts_objective_calls() -> None: + """Bumps reports actual objective calls, which can exceed the step budget.""" + ref_sin = AbsSin(0.2, np.pi) + sp_sin = AbsSin(0.354, 3.05) + + x = np.linspace(0, 5, 200) + weights = np.ones_like(x) + y = ref_sin(x) + + sp_sin.offset.fixed = False + sp_sin.phase.fixed = False + + f = Fitter(sp_sin, sp_sin) + try: + f.switch_minimizer(AvailableMinimizers.Bumps) + except AttributeError: + pytest.skip(msg=f'{AvailableMinimizers.Bumps} is not installed') + + f.max_evaluations = 3 + result = f.fit(x=x, y=y, weights=weights) + + assert result.success is False + assert result.n_evaluations is not None + assert result.n_evaluations > f.max_evaluations + assert f'maximum optimizer steps ({f.max_evaluations})' in result.message + assert f'objective evaluated {result.n_evaluations} times' in result.message + + @pytest.mark.fast @pytest.mark.parametrize( 'fit_engine,tolerance', diff --git a/tests/unit/fitting/minimizers/test_minimizer_bumps.py b/tests/unit/fitting/minimizers/test_minimizer_bumps.py index 19dc1579..e0f21451 100644 --- a/tests/unit/fitting/minimizers/test_minimizer_bumps.py +++ b/tests/unit/fitting/minimizers/test_minimizer_bumps.py @@ -53,14 +53,21 @@ def test_fit(self, minimizer: Bumps, monkeypatch) -> None: # Mock FitDriver mock_driver_instance = MagicMock() - mock_driver_instance.fit.return_value = (np.array([42.0]), 0.5) - mock_driver_instance.stderr.return_value = np.array([0.1]) mock_driver_instance.clip = MagicMock() mock_FitDriver = MagicMock(return_value=mock_driver_instance) monkeypatch.setattr( easyscience.fitting.minimizers.minimizer_bumps, 'FitDriver', mock_FitDriver ) + mock_fit_result = MagicMock() + mock_fit_result.x = np.array([42.0]) + mock_fit_result.dx = np.array([0.1]) + monkeypatch.setattr( + easyscience.fitting.minimizers.minimizer_bumps, + 'bumps_fit', + MagicMock(return_value=mock_fit_result), + ) + # Prepare a mock parameter with .name = 'pmock_parm_1' mock_bumps_param = MagicMock() mock_bumps_param.name = 'pmock_parm_1' @@ -84,10 +91,10 @@ def test_fit(self, minimizer: Bumps, monkeypatch) -> None: minimizer._cached_pars_vals = {'mock_parm_1': (1, 0.0)} # Patch _set_parameter_fit_result - def fake_set_parameter_fit_result(x_result, driver, stack_status, par_list): + def fake_set_parameter_fit_result(fit_result, stack_status, par_list): for index, name in enumerate([par.name for par in par_list]): dict_name = name[len('p') :] - minimizer._cached_pars[dict_name].value = x_result[index] + minimizer._cached_pars[dict_name].value = fit_result.x[index] minimizer._set_parameter_fit_result = fake_set_parameter_fit_result @@ -102,8 +109,12 @@ def fake_set_parameter_fit_result(x_result, driver, stack_status, par_list): assert result == 'gen_fit_results' mock_FitDriver.assert_called_once() mock_driver_instance.clip.assert_called_once() - mock_driver_instance.fit.assert_called_once() minimizer._make_model.assert_called_once_with(parameters=None) + minimizer._gen_fit_results.assert_called_once_with( + mock_fit_result, + max_evaluations=None, + tolerance=None, + ) mock_model_function.assert_called_once_with(1.0, 2.0, 1) mock_FitProblem.assert_called_once_with(mock_model) @@ -141,10 +152,13 @@ def test_make_model(self, minimizer: Bumps, monkeypatch) -> None: curve_for_model = model( x=np.array([1, 2]), y=np.array([10, 20]), weights=np.array([100, 200]) ) + wrapped_fit_function = mock_Curve.call_args[0][0] + wrapped_fit_function(np.array([1, 2]), pmock_parm_1=3) # Expect minimizer._generate_fit_function.assert_called_once_with() - assert mock_Curve.call_args[0][0] == mock_fit_function + assert minimizer._eval_counter is wrapped_fit_function + assert minimizer._eval_counter.count == 1 assert all(mock_Curve.call_args[0][1] == np.array([1, 2])) assert all(mock_Curve.call_args[0][2] == np.array([10, 20])) assert curve_for_model == 'curve' @@ -162,9 +176,9 @@ def test_set_parameter_fit_result_no_stack_status(self, minimizer: Bumps): mock_cached_model.pars = {'pa': 0, 'pb': 0} minimizer._cached_model = mock_cached_model - x_result = np.array([1.0, 2.0]) - mock_driver = MagicMock() - mock_driver.stderr.return_value = np.array([0.1, 0.2]) + mock_fit_result = MagicMock() + mock_fit_result.x = np.array([1.0, 2.0]) + mock_fit_result.dx = np.array([0.1, 0.2]) # The new argument: par_list (list of mock parameters) mock_par_a = MagicMock() @@ -174,7 +188,7 @@ def test_set_parameter_fit_result_no_stack_status(self, minimizer: Bumps): par_list = [mock_par_a, mock_par_b] # Then - minimizer._set_parameter_fit_result(x_result, mock_driver, False, par_list) + minimizer._set_parameter_fit_result(mock_fit_result, False, par_list) # Expect assert minimizer._cached_pars['a'].value == 1.0 @@ -190,9 +204,9 @@ def test_gen_fit_results(self, minimizer: Bumps, monkeypatch): easyscience.fitting.minimizers.minimizer_bumps, 'FitResults', mock_FitResults ) - x_result = np.array([1.0, 2.0]) - fx = 0.5 - mock_driver = MagicMock() + mock_fit_result = MagicMock() + mock_fit_result.success = True + mock_fit_result.nit = 2 mock_cached_model = MagicMock() mock_cached_model.x = 'x' @@ -208,29 +222,38 @@ def test_gen_fit_results(self, minimizer: Bumps, monkeypatch): minimizer._cached_pars = {'par_1': mock_cached_par_1, 'par_2': mock_cached_par_2} minimizer._p_0 = 'p_0' + minimizer._eval_counter = MagicMock(count=7) minimizer.evaluate = MagicMock(return_value='evaluate') # Then - domain_fit_results = minimizer._gen_fit_results( - x_result, fx, mock_driver, **{'kwargs_set_key': 'kwargs_set_val'} - ) + with pytest.warns(UserWarning, match='maximum optimizer steps of 3'): + domain_fit_results = minimizer._gen_fit_results( + mock_fit_result, + max_evaluations=3, + **{'kwargs_set_key': 'kwargs_set_val'}, + ) # Expect assert domain_fit_results == mock_domain_fit_results assert domain_fit_results.kwargs_set_key == 'kwargs_set_val' - assert domain_fit_results.success is True + assert domain_fit_results.success == False assert domain_fit_results.y_obs == 'y' assert domain_fit_results.x == 'x' assert domain_fit_results.p == {'ppar_1': 'par_value_1', 'ppar_2': 'par_value_2'} assert domain_fit_results.p0 == 'p_0' assert domain_fit_results.y_calc == 'evaluate' assert domain_fit_results.y_err == 'dy' + assert domain_fit_results.n_evaluations == 7 + assert ( + domain_fit_results.message + == 'Fit stopped: reached maximum optimizer steps (3); objective evaluated 7 times' + ) assert ( str(domain_fit_results.minimizer_engine) == "" ) assert domain_fit_results.fit_args is None - assert domain_fit_results.engine_result == mock_driver + assert domain_fit_results.engine_result == mock_fit_result minimizer.evaluate.assert_called_once_with( 'x', minimizer_parameters={'ppar_1': 'par_value_1', 'ppar_2': 'par_value_2'} ) @@ -265,13 +288,14 @@ def test_gen_fit_results_max_evaluations_boundary( mock_par.value = 1.0 minimizer._cached_pars = {'par_1': mock_par} minimizer._p_0 = 'p_0' + minimizer._eval_counter = MagicMock(count=n_evaluations) minimizer.evaluate = MagicMock(return_value='evaluate') - mock_driver = MagicMock() + mock_fit_result = MagicMock() + mock_fit_result.success = True + mock_fit_result.nit = n_evaluations - minimizer._gen_fit_results( - np.array([1.0]), 0.5, mock_driver, n_evaluations, max_evaluations - ) + minimizer._gen_fit_results(mock_fit_result, max_evaluations=max_evaluations) assert mock_domain_fit_results.success is expected_success @@ -296,14 +320,18 @@ def test_fit_progress_callback(self, minimizer: Bumps, monkeypatch) -> None: progress_callback = MagicMock(return_value=True) mock_driver_instance = MagicMock() - mock_driver_instance.fit.return_value = (np.array([42.0]), 0.5) - mock_driver_instance.stderr.return_value = np.array([0.1]) mock_driver_instance.clip = MagicMock() mock_FitDriver = MagicMock(return_value=mock_driver_instance) monkeypatch.setattr( easyscience.fitting.minimizers.minimizer_bumps, 'FitDriver', mock_FitDriver ) + monkeypatch.setattr( + easyscience.fitting.minimizers.minimizer_bumps, + 'bumps_fit', + MagicMock(return_value=MagicMock(x=np.array([42.0]), dx=np.array([0.1]))), + ) + mock_bumps_param = MagicMock() mock_bumps_param.name = 'pmock_parm_1' mock_FitProblem_instance = MagicMock() @@ -348,14 +376,19 @@ def test_fit_uses_supplied_model_and_optional_kwargs( global_object.stack.enabled = False mock_driver_instance = MagicMock() - mock_driver_instance.fit.return_value = (np.array([3.0]), 0.5) - mock_driver_instance.stderr.return_value = np.array([0.1]) mock_driver_instance.clip = MagicMock() mock_FitDriver = MagicMock(return_value=mock_driver_instance) monkeypatch.setattr( easyscience.fitting.minimizers.minimizer_bumps, 'FitDriver', mock_FitDriver ) + mock_bumps_fit = MagicMock(return_value=MagicMock(x=np.array([3.0]), dx=np.array([0.1]))) + monkeypatch.setattr( + easyscience.fitting.minimizers.minimizer_bumps, + 'bumps_fit', + mock_bumps_fit, + ) + mock_bumps_param = MagicMock() mock_bumps_param.name = 'pmock_parm_1' mock_problem = MagicMock() @@ -397,6 +430,12 @@ def test_fit_uses_supplied_model_and_optional_kwargs( assert fit_driver_kwargs['ftol'] == 0.25 assert fit_driver_kwargs['xtol'] == 0.25 assert fit_driver_kwargs['steps'] == 7 + assert mock_bumps_fit.call_args.kwargs['method'] == 'amoeba' + assert mock_bumps_fit.call_args.kwargs['existing_option'] == 'minimizer' + assert mock_bumps_fit.call_args.kwargs['engine_option'] == 'engine' + assert mock_bumps_fit.call_args.kwargs['ftol'] == 0.25 + assert mock_bumps_fit.call_args.kwargs['xtol'] == 0.25 + assert mock_bumps_fit.call_args.kwargs['steps'] == 7 def test_fit_rejects_non_callable_progress_callback( self, minimizer: Bumps, monkeypatch @@ -610,3 +649,39 @@ def test_fit_exception_restores_values(self, minimizer: Bumps, monkeypatch) -> N minimizer.fit(x=1.0, y=2.0, weights=1) assert parameter.value == 1.0 + + def test_gen_fit_results_uses_nit_for_budget_check(self, minimizer: Bumps, monkeypatch): + mock_domain_fit_results = MagicMock() + mock_FitResults = MagicMock(return_value=mock_domain_fit_results) + monkeypatch.setattr( + easyscience.fitting.minimizers.minimizer_bumps, 'FitResults', mock_FitResults + ) + + mock_fit_result = MagicMock() + mock_fit_result.success = True + mock_fit_result.nit = 99 + + mock_cached_model = MagicMock() + mock_cached_model.x = 'x' + mock_cached_model.y = 'y' + mock_cached_model.dy = 'dy' + mock_cached_model.pars = {'ppar_1': 0} + minimizer._cached_model = mock_cached_model + + mock_cached_par = MagicMock() + mock_cached_par.value = 'par_value_1' + minimizer._cached_pars = {'par_1': mock_cached_par} + + minimizer._p_0 = 'p_0' + minimizer._eval_counter = MagicMock(count=2) + minimizer.evaluate = MagicMock(return_value='evaluate') + + with pytest.warns(UserWarning, match='maximum optimizer steps of 3'): + domain_fit_results = minimizer._gen_fit_results(mock_fit_result, max_evaluations=3) + + assert domain_fit_results.success == False + assert domain_fit_results.n_evaluations == 2 + assert ( + domain_fit_results.message + == 'Fit stopped: reached maximum optimizer steps (3); objective evaluated 2 times' + ) diff --git a/tests/unit/fitting/minimizers/test_minimizer_dfo.py b/tests/unit/fitting/minimizers/test_minimizer_dfo.py index d572f228..a413ce48 100644 --- a/tests/unit/fitting/minimizers/test_minimizer_dfo.py +++ b/tests/unit/fitting/minimizers/test_minimizer_dfo.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +import warnings from unittest.mock import MagicMock import numpy as np @@ -420,8 +421,10 @@ def test_gen_fit_results(self, minimizer: DFO, monkeypatch): == "" ) - def test_gen_fit_results_maxfun_warning_sets_success_false(self, minimizer: DFO, monkeypatch): - """When DFO returns EXIT_MAXFUN_WARNING, _gen_fit_results must set success=False.""" + def test_gen_fit_results_maxfun_warning_sets_success_false_and_warns( + self, minimizer: DFO, monkeypatch + ): + """When DFO returns EXIT_MAXFUN_WARNING, _gen_fit_results must warn and set success=False.""" mock_domain_fit_results = MagicMock() mock_FitResults = MagicMock(return_value=mock_domain_fit_results) monkeypatch.setattr( @@ -453,6 +456,49 @@ def test_gen_fit_results_maxfun_warning_sets_success_false(self, minimizer: DFO, assert domain_fit_results.n_evaluations == 50 assert domain_fit_results.message == 'Objective has been called MAXFUN times' + def test_dfo_fit_allows_maxfun_warning(self, minimizer: DFO, monkeypatch) -> None: + mock_result = MagicMock() + mock_result.EXIT_SUCCESS = 0 + mock_result.EXIT_MAXFUN_WARNING = 1 + mock_result.flag = 1 + + mock_solve = MagicMock(return_value=mock_result) + monkeypatch.setattr( + easyscience.fitting.minimizers.minimizer_dfo.dfols, 'solve', mock_solve + ) + + def test_gen_fit_results_success_does_not_warn(self, minimizer: DFO, monkeypatch): + mock_domain_fit_results = MagicMock() + mock_FitResults = MagicMock(return_value=mock_domain_fit_results) + monkeypatch.setattr( + easyscience.fitting.minimizers.minimizer_dfo, 'FitResults', mock_FitResults + ) + + mock_fit_result = MagicMock() + mock_fit_result.EXIT_SUCCESS = 0 + mock_fit_result.EXIT_MAXFUN_WARNING = 1 + mock_fit_result.flag = 0 + mock_fit_result.nf = 12 + mock_fit_result.msg = 'Success' + + mock_cached_model = MagicMock() + mock_cached_model.x = 'x' + mock_cached_model.y = 'y' + minimizer._cached_model = mock_cached_model + + mock_cached_par_1 = MagicMock() + mock_cached_par_1.value = 'v1' + minimizer._cached_pars = {'par_1': mock_cached_par_1} + minimizer._p_0 = 'p_0' + minimizer.evaluate = MagicMock(return_value='evaluate') + + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter('always') + domain_fit_results = minimizer._gen_fit_results(mock_fit_result, 'weights') + + assert len(record) == 0 + assert domain_fit_results.success == True + def test_dfo_fit_allows_maxfun_warning(self, minimizer: DFO, monkeypatch) -> None: mock_result = MagicMock() mock_result.EXIT_SUCCESS = 0 @@ -588,6 +634,30 @@ def test_fit_generic_exception_resets_parameters_and_raises_fit_error( assert cached_par_2.value == 2.0 assert global_object.stack.enabled is True + def test_fit_fit_error_resets_parameters_and_reraises(self, minimizer: DFO) -> None: + """When _dfo_fit raises FitError, fit() must reset parameter values and re-raise it.""" + from easyscience import global_object + + global_object.stack.enabled = False + + mock_model = MagicMock() + mock_model_function = MagicMock(return_value=mock_model) + minimizer._make_model = MagicMock(return_value=mock_model_function) + minimizer._dfo_fit = MagicMock(side_effect=FitError(RuntimeError('solver failed'))) + + cached_par_1 = MagicMock() + cached_par_1.value = 5.0 + cached_par_2 = MagicMock() + cached_par_2.value = 10.0 + minimizer._cached_pars = {'a': cached_par_1, 'b': cached_par_2} + minimizer._cached_pars_vals = {'a': (1.0, 0.1), 'b': (2.0, 0.2)} + + with pytest.raises(FitError): + minimizer.fit(x=np.array([1.0]), y=np.array([1.0]), weights=np.array([1.0])) + + assert cached_par_1.value == 1.0 + assert cached_par_2.value == 2.0 + def test_dfo_fit_exception(self, minimizer: DFO, monkeypatch): # When pars = {1: MagicMock(Parameter)}