4242from easyreflectometry .data .measurement import extract_orso_title
4343from easyreflectometry .data .measurement import load as load_measurement_file
4444from 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
4547from easyreflectometry .fitting import MultiFitter
48+ from easyreflectometry .fitting import PreparedFit
4649from easyreflectometry .inequality_constraints import InequalityEvaluation
4750from easyreflectometry .inequality_constraints import InequalitySpec
4851from easyreflectometry .inequality_constraints import build_constraints_factory
9699# roughness leaves a small erf tail everywhere.
97100MAGNETIC_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