diff --git a/news/init-w-recipe.rst b/news/init-w-recipe.rst new file mode 100644 index 00000000..073926e7 --- /dev/null +++ b/news/init-w-recipe.rst @@ -0,0 +1,23 @@ +**Added:** + +* Added initialize_recipe_from_recipe to ``FitRecipe``. + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/src/diffpy/srfit/fitbase/fitrecipe.py b/src/diffpy/srfit/fitbase/fitrecipe.py index c28acc3e..72946e40 100644 --- a/src/diffpy/srfit/fitbase/fitrecipe.py +++ b/src/diffpy/srfit/fitbase/fitrecipe.py @@ -1140,6 +1140,50 @@ def getBounds2(self): """ return self.get_bounds_array() + def initialize_recipe_with_recipe(self, recipe_object): + """Initialize a FitRecipe with another FitRecipe. + + This is used to initialize a FitRecipe with the contribution(s), + parameters, constraints and restraints of another FitRecipe. + If a duplicate contribution, parameter, constraint, or restraint + is added to the FitRecipe you are initializing, the value from the + added object will be used. + + Parameters + ---------- + recipe_object : FitRecipe + The FitRecipe to initialize with. + + Raises + ------ + ValueError + If the object passed is not a FitRecipe. + """ + if not isinstance(recipe_object, FitRecipe): + raise ValueError( + "The input recipe_object must be a FitRecipe, " + f"but got {type(recipe_object)}." + ) + + for contrib_object in recipe_object._contributions.values(): + if contrib_object not in self._contributions.values(): + self.add_contribution(contrib_object) + + for param_name, param_object in recipe_object._parameters.items(): + if param_name not in self._parameters: + self._parameters.update({param_name: param_object}) + + for ( + parameter_object, + constraint_object, + ) in recipe_object._constraints.items(): + if parameter_object not in self._constraints: + self._constraints.update({parameter_object: constraint_object}) + + for restraint in recipe_object._restraints: + if restraint not in self._restraints: + self._restraints.add(restraint) + def set_plot_defaults(self, **kwargs): """Set default plotting options for all future plots. diff --git a/tests/conftest.py b/tests/conftest.py index 9e5332bd..0417ad17 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -146,7 +146,7 @@ def _capturestdout(f, *args, **kwargs): return _capturestdout -@pytest.fixture(scope="session") +@pytest.fixture() def build_recipe_one_contribution(): "helper to build a simple recipe" profile = Profile() @@ -164,32 +164,45 @@ def build_recipe_one_contribution(): return recipe -@pytest.fixture(scope="session") +@pytest.fixture() def build_recipe_two_contributions(): - "helper to build a recipe with two contributions" + """Helper to build a recipe with two physically related contributions.""" profile1 = Profile() - x = linspace(0, pi, 10) - y1 = sin(x) + x = linspace(0, pi, 50) + y1 = sin(x) # amplitude=1, freq=1 profile1.set_observed_profile(x, y1) + contribution1 = FitContribution("c1") contribution1.set_profile(profile1) contribution1.set_equation("A*sin(k*x + c)") profile2 = Profile() - y2 = 0.5 * sin(2 * x) + y2 = 0.5 * sin(2 * x) # amplitude=0.5, freq=2 profile2.set_observed_profile(x, y2) + contribution2 = FitContribution("c2") contribution2.set_profile(profile2) contribution2.set_equation("B*sin(m*x + d)") + recipe = FitRecipe() recipe.add_contribution(contribution1) recipe.add_contribution(contribution2) - recipe.add_variable(contribution1.A, 1) - recipe.add_variable(contribution1.k, 1) - recipe.add_variable(contribution1.c, 1) - recipe.add_variable(contribution2.B, 0.5) - recipe.add_variable(contribution2.m, 2) - recipe.add_variable(contribution2.d, 0) + + # Add variables with reasonable initial guesses + recipe.add_variable(contribution1.A, 0.8) + recipe.add_variable(contribution1.k, 1.0) + recipe.add_variable(contribution1.c, 0.1) + + recipe.add_variable(contribution2.B, 0.4) + recipe.add_variable(contribution2.m, 2.0) + recipe.add_variable(contribution2.d, 0.1) + + # ---- Meaningful constraints ---- + recipe.constrain(contribution2.m, "2*k") + recipe.constrain(contribution2.d, contribution1.c) + recipe.constrain(contribution2.B, "0.5*A") + recipe.restrain(contribution1.A, 0.5, 1.5) + recipe.restrain(contribution1.k, 0.8, 1.2) return recipe diff --git a/tests/test_fitrecipe.py b/tests/test_fitrecipe.py index 48e0bf15..39af10e8 100644 --- a/tests/test_fitrecipe.py +++ b/tests/test_fitrecipe.py @@ -18,10 +18,12 @@ import matplotlib import matplotlib.pyplot as plt +import numpy as np import pytest from numpy import array_equal, dot, linspace, pi, sin from scipy.optimize import leastsq +from diffpy.srfit.fitbase import FitResults from diffpy.srfit.fitbase.fitcontribution import FitContribution from diffpy.srfit.fitbase.fitrecipe import FitRecipe from diffpy.srfit.fitbase.parameter import Parameter @@ -462,6 +464,76 @@ def optimize_recipe(recipe): leastsq(residuals, values) +def test_initialize_recipe_from_recipe(build_recipe_two_contributions): + # Case: User initializes a FitRecipe from a previously optimized fit + # expected: recipe is initialized with everything: + # contributions, profiles (contained in contributions), + # variables, restraints, and constraints + recipe1 = build_recipe_two_contributions + optimize_recipe(recipe1) + expected_parameters_dict = recipe1._parameters + expected_constraints_dict = recipe1._constraints + expected_restraints_set = recipe1._restraints + expected_contributions_dict = recipe1._contributions + expected_profiles_list = [] + for con_name, contribution in expected_contributions_dict.items(): + expected_profile = contribution.profile + expected_profiles_list.append(expected_profile) + + recipe2 = FitRecipe() + recipe2.initialize_recipe_with_recipe(recipe1) + actual_parameters_dict = recipe2._parameters + actual_constraints_dict = recipe2._constraints + actual_restraints_set = recipe2._restraints + actual_contributions_dict = recipe2._contributions + actual_profiles_list = [] + for con_name, contribution in actual_contributions_dict.items(): + actual_profile = contribution.profile + actual_profiles_list.append(actual_profile) + + assert expected_parameters_dict == actual_parameters_dict + assert expected_constraints_dict == actual_constraints_dict + assert expected_restraints_set == actual_restraints_set + assert expected_contributions_dict == actual_contributions_dict + assert expected_profiles_list == actual_profiles_list + + # Check to see if the refined values and variable names are + # the same in the results objects for each recipe + results1 = FitResults(recipe1) + # round to account for small numerical differences + expected_values = np.round(results1.varvals, 7) + expected_names = results1.varnames + + optimize_recipe(recipe2) + results2 = FitResults(recipe2) + # round to account for small numerical differences + actual_values = np.round(results2.varvals, 7) + actual_names = results2.varnames + + assert sorted(expected_names) == sorted(actual_names) + assert sorted(list(expected_values)) == sorted(list(actual_values)) + + +def test_initialize_recipe_from_recipe_bad(build_recipe_two_contributions): + # Case: User tries to initialize a FitRecipe from a non recipe object + # expected: raised ValueError with message + recipe_bad = 12345 # not a FitRecipe object + recipe2 = FitRecipe() + msg = ( + "The input recipe_object must be a FitRecipe, " + "but got ." + ) + with pytest.raises(ValueError, match=msg): + recipe2.initialize_recipe_with_recipe(recipe_bad) + + +# def test_initialize_recipe_from_results(build_recipe_one_contribution): +# # Case: User initializes a FitRecipe from a FitResults object or +# # results file +# # expected: recipe is initialized with variables from previous fit +# assert False + + def get_labels_and_linecount(ax): """Helper to get line labels and count from a matplotlib Axes.""" labels = [