Skip to content

Commit 2febb08

Browse files
authored
added fit configuration owner #427 (#429)
* added fit configuration owner #427 * PR review fixes
1 parent 64b4f45 commit 2febb08

8 files changed

Lines changed: 1787 additions & 313 deletions

File tree

‎src/easyreflectometry/fit_settings.py‎

Lines changed: 399 additions & 0 deletions
Large diffs are not rendered by default.

‎src/easyreflectometry/fitting.py‎

Lines changed: 445 additions & 264 deletions
Large diffs are not rendered by default.

‎src/easyreflectometry/project.py‎

Lines changed: 81 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@
4242
from easyreflectometry.data.measurement import extract_orso_title
4343
from easyreflectometry.data.measurement import load as load_measurement_file
4444
from easyreflectometry.data.measurement import load_data_from_orso_file
45+
from easyreflectometry.fit_settings import DEFAULT_MINIMIZER # noqa: F401 (re-exported)
46+
from easyreflectometry.fit_settings import FitSettings
4547
from easyreflectometry.fitting import MultiFitter
48+
from easyreflectometry.fitting import PreparedFit
4649
from easyreflectometry.inequality_constraints import InequalityEvaluation
4750
from easyreflectometry.inequality_constraints import InequalitySpec
4851
from easyreflectometry.inequality_constraints import build_constraints_factory
@@ -96,8 +99,6 @@
9699
# roughness leaves a small erf tail everywhere.
97100
MAGNETIC_MOMENT_FLOOR_FRACTION = 0.01
98101

99-
DEFAULT_MINIMIZER = AvailableMinimizers.LMFit_leastsq
100-
101102
#: Properties not descended into when *generating* structural parameter
102103
#: paths: non-structural objects and convenience aliases of ``layers[i]``
103104
#: (so a layer parameter is always addressed as ``.../layers/<i>/...``).
@@ -125,7 +126,9 @@ def __init__(self):
125126
self._calculator = CalculatorFactory()
126127
self._experiments: Dict[DataGroup] = {}
127128
self._fitter: MultiFitter = None
128-
self._minimizer_selection: AvailableMinimizers = DEFAULT_MINIMIZER
129+
self._fit_settings = FitSettings()
130+
#: Warnings from the last :meth:`from_dict`, for the user; cleared by each load.
131+
self.load_report: list[str] = []
129132
self._colors: list[str] = None
130133
self._report = None
131134
self._q_min: float = None
@@ -350,6 +353,7 @@ def models(self) -> ModelCollection:
350353
def models(self, models: ModelCollection) -> None:
351354
"""Models function."""
352355
self._replace_collection(models, self._models)
356+
self._invalidate_fitter()
353357
# Use setter to update indicies for current model, assembly and layer
354358
self.current_model_index = 0
355359
# Only track materials not already in the project's material collection
@@ -363,19 +367,73 @@ def models(self, models: ModelCollection) -> None:
363367

364368
@property
365369
def fitter(self) -> MultiFitter:
366-
"""Fitter function."""
370+
"""The fitter of the current model, configured from :attr:`fit_settings`.
371+
372+
Every hand-out writes the project's settings onto it, so configure the
373+
fit through :attr:`fit_settings` (or :attr:`minimizer`), not through
374+
the returned fitter's core fitter.
375+
"""
367376
if len(self._models):
368377
if (self._fitter is None) or (self._fitter_model_index != self._current_model_index):
369378
self._fitter = MultiFitter(self._models[self._current_model_index])
370-
self._fitter.easy_science_multi_fitter.switch_minimizer(self._minimizer_selection)
379+
self._fitter.settings = self._fit_settings
371380
self._fitter_model_index = self._current_model_index
372381
# Fits run through this fitter pick up the project's inequality
373382
# constraints automatically (resolved at fit time). A weak
374383
# reference avoids a project -> fitter -> project cycle that
375384
# would keep a discarded project (and its unique names) alive.
376385
self._fitter.constraints_factory_provider = _weak_constraints_provider(self)
386+
self._fit_settings.configure(self._fitter.easy_science_multi_fitter)
377387
return self._fitter
378388

389+
def _invalidate_fitter(self) -> None:
390+
"""Drop the cached fitter; the next :attr:`fitter` access builds a new one."""
391+
self._fitter = None
392+
self._fitter_model_index = None
393+
394+
@property
395+
def fit_settings(self) -> FitSettings:
396+
"""The project's fit configuration; edits apply to the next fit prepared."""
397+
return self._fit_settings
398+
399+
@fit_settings.setter
400+
def fit_settings(self, settings: FitSettings) -> None:
401+
settings.validate()
402+
self._fit_settings = settings
403+
if self._fitter is not None:
404+
self._fitter.settings = settings
405+
406+
def prepare_fit(self, experiments: Optional[list] = None, *, objective: Optional[str] = None) -> PreparedFit:
407+
"""Prepare a fit of the experiments with a snapshot of :attr:`fit_settings`.
408+
409+
The run is not executed: call :meth:`PreparedFit.execute`, or run its
410+
``core_fitter`` in a worker thread, then hand the results to
411+
``project.fitter.record_fit_results(results, prepared.finalize(results))``.
412+
Inequality constraints are resolved when it executes.
413+
414+
Parameters
415+
----------
416+
experiments : list, optional
417+
The experiments to fit, in order. By default, all loaded ones.
418+
objective : str, optional
419+
Zero-variance objective overriding the settings' one.
420+
421+
Returns
422+
-------
423+
PreparedFit
424+
The prepared run.
425+
426+
Raises
427+
------
428+
FitPreconditionError
429+
If a free parameter's bounds are unusable for the minimizer.
430+
"""
431+
if experiments is None:
432+
experiments = [self._experiments[key] for key in sorted(self._experiments)]
433+
fitter = MultiFitter.for_experiments(experiments, constraints_factory_provider=_weak_constraints_provider(self))
434+
fitter.settings = self._fit_settings
435+
return fitter.prepare(objective=objective)
436+
379437
# ----- structural parameter paths -----
380438

381439
@staticmethod
@@ -685,8 +743,7 @@ def calculator(self, calculator: str) -> None:
685743
model.generate_bindings()
686744

687745
# The cached fitter holds fit functions bound to the previous backend.
688-
self._fitter = None
689-
self._fitter_model_index = None
746+
self._invalidate_fitter()
690747

691748
@property
692749
def calculator_supports_magnetism(self) -> bool:
@@ -726,25 +783,17 @@ def models_have_magnetism(self) -> bool:
726783

727784
@property
728785
def minimizer(self) -> AvailableMinimizers:
729-
"""Minimizer function."""
730-
if self._fitter is not None:
731-
return self._fitter.easy_science_multi_fitter.minimizer.enum
732-
return self._minimizer_selection
786+
"""The selected minimizer; an alias for ``fit_settings.minimizer``."""
787+
return self._fit_settings.minimizer
733788

734789
@minimizer.setter
735790
def minimizer(self, minimizer: AvailableMinimizers) -> None:
736-
"""Minimizer function."""
737-
old_name = getattr(self._minimizer_selection, 'name', str(self._minimizer_selection))
738-
new_name = getattr(minimizer, 'name', str(minimizer))
739-
logger.info(
740-
'Minimizer changed from %s to %s (fitter active: %s)',
741-
old_name,
742-
new_name,
743-
self._fitter is not None,
744-
)
745-
self._minimizer_selection = minimizer
746-
if self._fitter is not None:
747-
self._fitter.easy_science_multi_fitter.switch_minimizer(minimizer)
791+
"""Select the minimizer; it applies to the next fit."""
792+
if not isinstance(minimizer, AvailableMinimizers):
793+
raise ValueError(f'minimizer must be an AvailableMinimizers member, got {minimizer!r}.')
794+
previous = self._fit_settings.minimizer
795+
self._fit_settings.minimizer = minimizer
796+
logger.info('Minimizer changed from %s to %s', previous.name, self._fit_settings.minimizer.name)
748797

749798
@property
750799
def experiments(self) -> Dict[int, Union[DataSet1D, PolarizedDataSet]]:
@@ -1968,6 +2017,7 @@ def remove_model_at_index(self, index: int) -> None:
19682017

19692018
# Remove the model from the collection
19702019
self._models.pop(index)
2020+
self._invalidate_fitter()
19712021

19722022
# Remove experiment mapped to the removed model index.
19732023
if index in self._experiments:
@@ -2149,10 +2199,9 @@ def as_dict(self, include_materials_not_in_model=False):
21492199
self._as_dict_add_materials_not_in_model_dict(project_dict)
21502200
if self._with_experiments:
21512201
self._as_dict_add_experiments(project_dict)
2152-
if self.fitter is not None:
2153-
project_dict['fitter_minimizer'] = self.fitter.easy_science_multi_fitter.minimizer.name
2154-
elif self._minimizer_selection is not None:
2155-
project_dict['fitter_minimizer'] = self._minimizer_selection.name
2202+
project_dict['fit_settings'] = self._fit_settings.to_dict()
2203+
# Kept for readers that predate `fit_settings`.
2204+
project_dict['fitter_minimizer'] = project_dict['fit_settings']['minimizer']
21562205
if self._calculator is not None:
21572206
project_dict['calculator'] = self._calculator.current_interface_name
21582207
if self._colors is not None:
@@ -2283,10 +2332,11 @@ def from_dict(self, project_dict: dict):
22832332
self._replace_collection(self._get_materials_in_models(), self._materials)
22842333
if 'materials_not_in_model' in keys:
22852334
self._materials.extend(MaterialCollection.from_dict(project_dict['materials_not_in_model']))
2286-
if 'fitter_minimizer' in keys:
2287-
self.minimizer = AvailableMinimizers[project_dict['fitter_minimizer']]
2288-
else:
2289-
self._fitter = None
2335+
# Settings are replaced wholesale, never merged with the previous project's.
2336+
# A file predating `fit_settings` only stored `fitter_minimizer`.
2337+
legacy = {'minimizer': project_dict.get('fitter_minimizer')}
2338+
self._fit_settings, self.load_report = FitSettings.from_dict(project_dict.get('fit_settings', legacy))
2339+
self._invalidate_fitter()
22902340
if 'experiments' in keys:
22912341
self._experiments = self._from_dict_extract_experiments(project_dict)
22922342
else:

0 commit comments

Comments
 (0)