From 32ba6674d9881355e8cbf863f01aff914a073a31 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Thu, 29 Jan 2026 18:33:48 -0500 Subject: [PATCH 01/16] add plot_recipe to FitRecipe --- src/diffpy/srfit/fitbase/fitrecipe.py | 219 ++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) diff --git a/src/diffpy/srfit/fitbase/fitrecipe.py b/src/diffpy/srfit/fitbase/fitrecipe.py index ffc1d595..72558ac9 100644 --- a/src/diffpy/srfit/fitbase/fitrecipe.py +++ b/src/diffpy/srfit/fitbase/fitrecipe.py @@ -36,6 +36,7 @@ from collections import OrderedDict +import matplotlib.pyplot as plt import six from numpy import array, concatenate, dot, sqrt @@ -871,6 +872,224 @@ def getBounds2(self): ub = array([b[1] for b in bounds]) return lb, ub + def plot_recipe( + self, + show_data=True, + show_fit=True, + show_diff=True, + offset_scale=1.0, + figsize=(8, 6), + data_style="o", + fit_style="-", + diff_style="-", + data_color=None, + fit_color=None, + diff_color=None, + data_label="Observed", + fit_label="Calculated", + diff_label="Difference", + xlabel=None, + ylabel=None, + title=None, + legend=True, + legend_loc="best", + grid=False, + markersize=None, + linewidth=None, + alpha=1.0, + show=True, + ax=None, + return_fig=False, + ): + """Plot the fit recipe data, calculated fit, and difference curve. + + If the recipe has multiple contributions, a separate + plot is created for each contribution. + + Parameters + ---------- + show_data : bool, optional + If True, plot the observed data points. Default is True. + show_fit : bool, optional + If True, plot the calculated fit curve. Default is True. + show_diff : bool, optional + If True, plot the difference curve (observed - calculated). + Default is True. + offset_scale : float, optional + Scaling factor for the difference curve offset. The difference + curve is offset below the data by + (min_y - 0.1*range) * offset_scale. Default is 1.0. + figsize : tuple, optional + Figure size as (width, height) in inches. Default is (8, 6). + data_style : str, optional + Matplotlib line/marker style for data points. Default is "o". + fit_style : str, optional + Matplotlib line style for calculated fit. Default is "-". + diff_style : str, optional + Matplotlib line style for difference curve. Default is "-". + data_color : str or None, optional + Color for data points. If None, uses default matplotlib colors. + fit_color : str or None, optional + Color for fit curve. If None, uses default matplotlib colors. + diff_color : str or None, optional + Color for difference curve. If None, uses default matplotlib + colors. + data_label : str, optional + Legend label for observed data. Default is "Observed". + fit_label : str, optional + Legend label for calculated fit. Default is "Calculated". + diff_label : str, optional + Legend label for difference curve. Default is "Difference". + xlabel : str, optional + Label for x-axis. + ylabel : str, optional + Label for y-axis. + title : str or None, optional + Plot title. Default is no title. + legend : bool, optional + If True, show legend. Default is True. + legend_loc : str, optional + Legend location. Default is "best". + grid : bool, optional + If True, show grid. Default is False. + markersize : float, optional + Size of data point markers. + linewidth : float, optional + Width of fit and difference lines. + alpha : float, optional + Transparency of all plot elements (0=transparent, 1=opaque). + Default is 1.0. + show : bool, optional + If True, display the plot using plt.show(). Default is True. + ax : matplotlib.axes.Axes or None, optional + Axes object to plot on. If None, creates new figure. + Default is None. + return_fig : bool, optional + If True, return the figure and axes objects. Default is False. + + Returns + ------- + fig, axes : tuple of (mpl.figure.Figure, list of mpl.axes.Axes) + Only returned if return_fig=True. Returns the figure object + and a list of axes objects (one per contribution). + + Examples + -------- + Plot everything with default settings: + + >>> recipe.plot_recipe() + + Plot only data and fit (no difference curve): + + >>> recipe.plot_recipe(show_diff=False) + + Plot only data to check before refinement: + + >>> recipe.plot_recipe(show_fit=False, show_diff=False) + + Get figure object for further customization: + + >>> fig, axes = recipe.plot_recipe(show=False, return_fig=True) + >>> axes[0].set_yscale('log') + >>> plt.savefig('my_fit.png', dpi=300) + """ + if not any([show_data, show_fit, show_diff]): + raise ValueError( + "At least one of show_data, show_fit, " + "or show_diff must be True" + ) + + if not self._contributions: + raise ValueError( + "No contributions found in recipe. " + "Add contributions before plotting." + ) + + figures = [] + axes_list = [] + + for name, contrib in self._contributions.items(): + profile = contrib.profile + x = profile.x + yobs = profile.y + ycalc = profile.ycalc + if ycalc is None: + if show_fit or show_diff: + print( + f"Contribution '{name}' has no calculated values " + "(ycalc is None). " + "Only observed data will be plotted." + ) + show_fit = False + show_diff = False + else: + diff = yobs - ycalc + y_min = min(yobs.min(), ycalc.min()) + y_max = max(yobs.max(), ycalc.max()) + y_range = y_max - y_min + base_offset = y_min - 0.1 * y_range + offset = base_offset * offset_scale + + if ax is None: + fig = plt.figure(figsize=figsize) + current_ax = fig.add_subplot(111) + else: + current_ax = ax + fig = current_ax.figure + if show_data: + current_ax.plot( + x, + yobs, + data_style, + label=data_label, + color=data_color, + markersize=markersize, + alpha=alpha, + ) + if show_fit: + current_ax.plot( + x, + ycalc, + fit_style, + label=fit_label, + color=fit_color, + linewidth=linewidth, + alpha=alpha, + ) + if show_diff: + current_ax.plot( + x, + diff + offset, + diff_style, + label=diff_label, + color=diff_color, + linewidth=linewidth, + alpha=alpha, + ) + current_ax.axhline( + offset, + color="black", + ) + current_ax.set_xlabel(xlabel) + current_ax.set_ylabel(ylabel) + + if title is not None: + current_ax.set_title(title) + if legend: + current_ax.legend(loc=legend_loc, frameon=True) + if grid: + current_ax.grid(True) + fig.tight_layout() + figures.append(fig) + axes_list.append(current_ax) + if show and ax is None: + plt.show() + if return_fig: + if len(figures) == 1: + return figures[0], axes_list[0] + else: + return figures, axes_list + def boundsToRestraints(self, sig=1, scaled=False): """Turn all bounded parameters into restraints. From 63ce227eaae138340ccb6ef9b01450ca2acfa0df Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Thu, 29 Jan 2026 18:34:00 -0500 Subject: [PATCH 02/16] add plot_recipe tests --- tests/test_fitrecipe.py | 206 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) diff --git a/tests/test_fitrecipe.py b/tests/test_fitrecipe.py index 1a2b2368..02c5408b 100644 --- a/tests/test_fitrecipe.py +++ b/tests/test_fitrecipe.py @@ -16,13 +16,19 @@ import unittest +import matplotlib +import matplotlib.pyplot as plt +import pytest from numpy import array_equal, dot, linspace, pi, sin +from scipy.optimize import leastsq from diffpy.srfit.fitbase.fitcontribution import FitContribution from diffpy.srfit.fitbase.fitrecipe import FitRecipe from diffpy.srfit.fitbase.parameter import Parameter from diffpy.srfit.fitbase.profile import Profile +matplotlib.use("Agg") + class TestFitRecipe(unittest.TestCase): @@ -284,5 +290,205 @@ def testPrintFitHook(capturestdout): return +def build_recipe_one_contribution(): + "helper to build a simple recipe" + profile = Profile() + x = linspace(0, pi, 10) + y = sin(x) + profile.setObservedProfile(x, y) + contribution = FitContribution("c1") + contribution.setProfile(profile) + contribution.setEquation("A*sin(k*x + c)") + recipe = FitRecipe() + recipe.addContribution(contribution) + recipe.addVar(contribution.A, 1) + recipe.addVar(contribution.k, 1) + recipe.addVar(contribution.c, 1) + return recipe + + +def build_recipe_two_contributions(): + "helper to build a recipe with two contributions" + profile1 = Profile() + x = linspace(0, pi, 10) + y1 = sin(x) + profile1.setObservedProfile(x, y1) + contribution1 = FitContribution("c1") + contribution1.setProfile(profile1) + contribution1.setEquation("A*sin(k*x + c)") + + profile2 = Profile() + y2 = 0.5 * sin(2 * x) + profile2.setObservedProfile(x, y2) + contribution2 = FitContribution("c2") + contribution2.setProfile(profile2) + contribution2.setEquation("B*sin(m*x + d)") + recipe = FitRecipe() + recipe.addContribution(contribution1) + recipe.addContribution(contribution2) + recipe.addVar(contribution1.A, 1) + recipe.addVar(contribution1.k, 1) + recipe.addVar(contribution1.c, 1) + recipe.addVar(contribution2.B, 0.5) + recipe.addVar(contribution2.m, 2) + recipe.addVar(contribution2.d, 0) + + return recipe + + +def optimize_recipe(recipe): + recipe.fithooks[0].verbose = 0 + residuals = recipe.residual + values = recipe.values + leastsq(residuals, values) + + +def test_plot_recipe_bad_display(): + recipe = build_recipe_one_contribution() + # Case: All plots are disabled + # expected: raised ValueError with message + plt.close("all") + msg = "At least one of show_data, show_fit, or show_diff must be True" + with pytest.raises(ValueError, match=msg): + recipe.plot_recipe( + show_data=False, + show_diff=False, + show_fit=False, + ) + + +def test_plot_recipe_no_contribution(): + recipe = FitRecipe() + # Case: No contributions in recipe + # expected: raised ValueError with message + plt.close("all") + msg = ( + "No contributions found in recipe. " + "Add contributions before plotting." + ) + with pytest.raises(ValueError, match=msg): + recipe.plot_recipe() + + +def test_plot_recipe_before_refinement(capsys): + # Case: User tries to plot recipe before refinement + # expected: Data plotted without fit line or difference curve + # and warning message printed + recipe = build_recipe_one_contribution() + plt.close("all") + before = set(plt.get_fignums()) + recipe.plot_recipe(show=False) + after = set(plt.get_fignums()) + new_figs = after - before + captured = capsys.readouterr() + actual = captured.out.strip() + expected = ( + "Contribution 'c1' has no calculated values (ycalc is None). " + "Only observed data will be plotted." + ) + assert len(new_figs) == 1 + assert actual == expected + + +def test_plot_recipe_after_refinement(): + # Case: User refines recipe and then plots + # expected: Plot generates with no problem + recipe = build_recipe_one_contribution() + optimize_recipe(recipe) + plt.close("all") + before = set(plt.get_fignums()) + recipe.plot_recipe(show=False) + after = set(plt.get_fignums()) + new_figs = after - before + assert len(new_figs) == 1 + + +def test_plot_recipe_two_contributions(): + # Case: Two contributions in recipe + # expected: two figures created + recipe = build_recipe_two_contributions() + optimize_recipe(recipe) + plt.close("all") + before = set(plt.get_fignums()) + recipe.plot_recipe(show=False) + after = set(plt.get_fignums()) + new_figs = after - before + assert len(new_figs) == 2 + + +def test_plot_recipe_on_existing_plot(): + # Case: User passes axes to plot_recipe to plot on existing figure + # expected: User modifications are present in the final figure + recipe = build_recipe_one_contribution() + optimize_recipe(recipe) + plt.close("all") + fig, ax = plt.subplots() + ax.set_title("User Title") + recipe.plot_recipe(ax=ax, show=False) + assert ax.get_title() == "User Title" + + +def test_plot_recipe_add_new_data(): + # Case: User wants to add data to figure generated by plot_recipe + # Expected: New data is added to existing figure (check with labels) + recipe = build_recipe_one_contribution() + optimize_recipe(recipe) + plt.close("all") + before = set(plt.get_fignums()) + figure, ax = recipe.plot_recipe(return_fig=True, show=False) + after = set(plt.get_fignums()) + new_figs = after - before + # add new data to existing plot + ax.plot([0, pi], [0, 0], label="New Data") + ax.legend() + legend = ax.get_legend() + # get sorted list of legend labels for comparison + actual_labels = sorted([t.get_text() for t in legend.get_texts()]) + expected_labels = sorted( + ["Observed", "Calculated", "Difference", "New Data"] + ) + assert len(new_figs) == 1 + assert actual_labels == expected_labels + + +def test_plot_recipe_add_new_data_two_figs(): + # Case: User wants to add data to figure generated by plot_recipe + # with two contributions + # Expected: New data is added to existing figure (check with labels) + recipe = build_recipe_two_contributions() + optimize_recipe(recipe) + plt.close("all") + before = set(plt.get_fignums()) + figure, axes = recipe.plot_recipe(return_fig=True, show=False) + after = set(plt.get_fignums()) + new_figs = after - before + # add new data to existing plots + for ax in axes: + ax.plot([0, pi], [0, 0], label="New Data") + ax.legend() + legend = ax.get_legend() + # get sorted list of legend labels for comparison + actual_labels = sorted([t.get_text() for t in legend.get_texts()]) + expected_labels = sorted( + ["Observed", "Calculated", "Difference", "New Data"] + ) + assert actual_labels == expected_labels + assert len(new_figs) == 1 + + +def test_plot_recipe_set_title(): + # Case: User sets title via plot_recipe + # Expected: Title is set correctly + recipe = build_recipe_one_contribution() + optimize_recipe(recipe) + plt.close("all") + expected_title = "Custom Recipe Title" + figure, ax = recipe.plot_recipe( + title=expected_title, return_fig=True, show=False + ) + actual_title = ax.get_title() + assert actual_title == expected_title + + if __name__ == "__main__": unittest.main() From 91922911d403ed01924b625d65677241803753de Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Thu, 29 Jan 2026 18:34:59 -0500 Subject: [PATCH 03/16] news --- news/plot-recipe.rst | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 news/plot-recipe.rst diff --git a/news/plot-recipe.rst b/news/plot-recipe.rst new file mode 100644 index 00000000..347b958b --- /dev/null +++ b/news/plot-recipe.rst @@ -0,0 +1,23 @@ +**Added:** + +* Add ``plot_recipe`` method to ``FitRecipe``. + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* From a1123a6d06223c566ead5704ca0f58c46a40320a Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Thu, 29 Jan 2026 18:36:41 -0500 Subject: [PATCH 04/16] more verbose test --- tests/test_fitrecipe.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_fitrecipe.py b/tests/test_fitrecipe.py index 02c5408b..991349fc 100644 --- a/tests/test_fitrecipe.py +++ b/tests/test_fitrecipe.py @@ -423,9 +423,10 @@ def test_plot_recipe_on_existing_plot(): optimize_recipe(recipe) plt.close("all") fig, ax = plt.subplots() - ax.set_title("User Title") + expected_title = "User Title" + actual_title = ax.set_title(expected_title) recipe.plot_recipe(ax=ax, show=False) - assert ax.get_title() == "User Title" + assert actual_title == expected_title def test_plot_recipe_add_new_data(): From 78dbba7231ebd5a095dd6746825792b77e4fbc45 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Thu, 29 Jan 2026 18:45:50 -0500 Subject: [PATCH 05/16] make tests stronger --- tests/test_fitrecipe.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_fitrecipe.py b/tests/test_fitrecipe.py index 991349fc..0e7fe129 100644 --- a/tests/test_fitrecipe.py +++ b/tests/test_fitrecipe.py @@ -423,10 +423,14 @@ def test_plot_recipe_on_existing_plot(): optimize_recipe(recipe) plt.close("all") fig, ax = plt.subplots() - expected_title = "User Title" - actual_title = ax.set_title(expected_title) + ax.set_title("User Title") + ax.plot([0, 1], [0, 1], label="New Data") recipe.plot_recipe(ax=ax, show=False) + actual_title = ax.get_title() + expected_title = "User Title" assert actual_title == expected_title + labels = [label.get_label() for label in ax.get_lines()] + assert "New Data" in labels def test_plot_recipe_add_new_data(): @@ -474,7 +478,7 @@ def test_plot_recipe_add_new_data_two_figs(): ["Observed", "Calculated", "Difference", "New Data"] ) assert actual_labels == expected_labels - assert len(new_figs) == 1 + assert len(new_figs) == 2 def test_plot_recipe_set_title(): From 0437b21602c7746682f63a3ad9eb25da86484261 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Fri, 30 Jan 2026 09:31:56 -0500 Subject: [PATCH 06/16] change show_data to show_observed --- src/diffpy/srfit/fitbase/fitrecipe.py | 10 +++++----- tests/test_fitrecipe.py | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/diffpy/srfit/fitbase/fitrecipe.py b/src/diffpy/srfit/fitbase/fitrecipe.py index 72558ac9..fb67ecdc 100644 --- a/src/diffpy/srfit/fitbase/fitrecipe.py +++ b/src/diffpy/srfit/fitbase/fitrecipe.py @@ -874,7 +874,7 @@ def getBounds2(self): def plot_recipe( self, - show_data=True, + show_observed=True, show_fit=True, show_diff=True, offset_scale=1.0, @@ -908,7 +908,7 @@ def plot_recipe( Parameters ---------- - show_data : bool, optional + show_observed : bool, optional If True, plot the observed data points. Default is True. show_fit : bool, optional If True, plot the calculated fit curve. Default is True. @@ -993,9 +993,9 @@ def plot_recipe( >>> axes[0].set_yscale('log') >>> plt.savefig('my_fit.png', dpi=300) """ - if not any([show_data, show_fit, show_diff]): + if not any([show_observed, show_fit, show_diff]): raise ValueError( - "At least one of show_data, show_fit, " + "At least one of show_observed, show_fit, " "or show_diff must be True" ) @@ -1036,7 +1036,7 @@ def plot_recipe( else: current_ax = ax fig = current_ax.figure - if show_data: + if show_observed: current_ax.plot( x, yobs, diff --git a/tests/test_fitrecipe.py b/tests/test_fitrecipe.py index 0e7fe129..d248ba39 100644 --- a/tests/test_fitrecipe.py +++ b/tests/test_fitrecipe.py @@ -348,10 +348,10 @@ def test_plot_recipe_bad_display(): # Case: All plots are disabled # expected: raised ValueError with message plt.close("all") - msg = "At least one of show_data, show_fit, or show_diff must be True" + msg = "At least one of show_observed, show_fit, or show_diff must be True" with pytest.raises(ValueError, match=msg): recipe.plot_recipe( - show_data=False, + show_observed=False, show_diff=False, show_fit=False, ) From b6dcdc1608f0006197d023785457219e66e27073 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Fri, 30 Jan 2026 09:54:19 -0500 Subject: [PATCH 07/16] add check for number of lines being plotted and the labels --- tests/test_fitrecipe.py | 76 ++++++++++++++++++++++++++++++----------- 1 file changed, 56 insertions(+), 20 deletions(-) diff --git a/tests/test_fitrecipe.py b/tests/test_fitrecipe.py index d248ba39..f7af61c0 100644 --- a/tests/test_fitrecipe.py +++ b/tests/test_fitrecipe.py @@ -343,6 +343,23 @@ def optimize_recipe(recipe): leastsq(residuals, values) +def get_labels_and_linecount(ax): + """Helper to get line labels and count from a matplotlib Axes.""" + labels = [ + line.get_label() + for line in ax.get_lines() + if not line.get_label().startswith("_") + ] + line_count = len( + [ + line + for line in ax.get_lines() + if not line.get_label().startswith("_") + ] + ) + return labels, line_count + + def test_plot_recipe_bad_display(): recipe = build_recipe_one_contribution() # Case: All plots are disabled @@ -377,7 +394,10 @@ def test_plot_recipe_before_refinement(capsys): recipe = build_recipe_one_contribution() plt.close("all") before = set(plt.get_fignums()) - recipe.plot_recipe(show=False) + # include fit_label="nothing" to make sure fit line is not plotted + fig, ax = recipe.plot_recipe( + show=False, data_label="my data", fit_label="nothing", return_fig=True + ) after = set(plt.get_fignums()) new_figs = after - before captured = capsys.readouterr() @@ -386,6 +406,12 @@ def test_plot_recipe_before_refinement(capsys): "Contribution 'c1' has no calculated values (ycalc is None). " "Only observed data will be plotted." ) + # get labels from the plotted line + actual_label, actual_line_count = get_labels_and_linecount(ax) + expected_line_count = 1 + expected_label = ["my data"] + assert actual_line_count == expected_line_count + assert actual_label == expected_label assert len(new_figs) == 1 assert actual == expected @@ -397,9 +423,14 @@ def test_plot_recipe_after_refinement(): optimize_recipe(recipe) plt.close("all") before = set(plt.get_fignums()) - recipe.plot_recipe(show=False) + fig, ax = recipe.plot_recipe(show=False, return_fig=True) after = set(plt.get_fignums()) new_figs = after - before + actual_label, actual_line_count = get_labels_and_linecount(ax) + expected_label = ["Observed", "Calculated", "Difference"] + expected_line_count = 3 + assert actual_line_count == expected_line_count + assert actual_label == expected_label assert len(new_figs) == 1 @@ -410,7 +441,13 @@ def test_plot_recipe_two_contributions(): optimize_recipe(recipe) plt.close("all") before = set(plt.get_fignums()) - recipe.plot_recipe(show=False) + figs, axes = recipe.plot_recipe(show=False, return_fig=True) + for ax in axes: + actual_label, actual_line_count = get_labels_and_linecount(ax) + expected_label = ["Observed", "Calculated", "Difference"] + expected_line_count = 3 + assert actual_line_count == expected_line_count + assert actual_label == expected_label after = set(plt.get_fignums()) new_figs = after - before assert len(new_figs) == 2 @@ -428,9 +465,12 @@ def test_plot_recipe_on_existing_plot(): recipe.plot_recipe(ax=ax, show=False) actual_title = ax.get_title() expected_title = "User Title" + actual_labels, actual_line_count = get_labels_and_linecount(ax) + expected_line_count = 4 + expected_labels = ["Calculated", "Difference", "New Data", "Observed"] + assert actual_line_count == expected_line_count + assert sorted(actual_labels) == sorted(expected_labels) assert actual_title == expected_title - labels = [label.get_label() for label in ax.get_lines()] - assert "New Data" in labels def test_plot_recipe_add_new_data(): @@ -440,20 +480,18 @@ def test_plot_recipe_add_new_data(): optimize_recipe(recipe) plt.close("all") before = set(plt.get_fignums()) - figure, ax = recipe.plot_recipe(return_fig=True, show=False) + fig, ax = recipe.plot_recipe(return_fig=True, show=False) after = set(plt.get_fignums()) new_figs = after - before # add new data to existing plot ax.plot([0, pi], [0, 0], label="New Data") ax.legend() - legend = ax.get_legend() - # get sorted list of legend labels for comparison - actual_labels = sorted([t.get_text() for t in legend.get_texts()]) - expected_labels = sorted( - ["Observed", "Calculated", "Difference", "New Data"] - ) + actual_labels, actual_line_count = get_labels_and_linecount(ax) + expected_labels = ["Observed", "Calculated", "Difference", "New Data"] + expected_line_count = 4 assert len(new_figs) == 1 - assert actual_labels == expected_labels + assert actual_line_count == expected_line_count + assert sorted(actual_labels) == sorted(expected_labels) def test_plot_recipe_add_new_data_two_figs(): @@ -471,13 +509,11 @@ def test_plot_recipe_add_new_data_two_figs(): for ax in axes: ax.plot([0, pi], [0, 0], label="New Data") ax.legend() - legend = ax.get_legend() - # get sorted list of legend labels for comparison - actual_labels = sorted([t.get_text() for t in legend.get_texts()]) - expected_labels = sorted( - ["Observed", "Calculated", "Difference", "New Data"] - ) - assert actual_labels == expected_labels + actual_labels, actual_line_count = get_labels_and_linecount(ax) + expected_labels = ["Observed", "Calculated", "Difference", "New Data"] + expected_line_count = 4 + assert actual_line_count == expected_line_count + assert sorted(actual_labels) == sorted(expected_labels) assert len(new_figs) == 2 From 2c3db7b4d9ac9b9971e794cebf680af9626f6530 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Sat, 31 Jan 2026 12:50:29 -0500 Subject: [PATCH 08/16] use bg stylesheets --- requirements/conda.txt | 1 + requirements/pip.txt | 1 + src/diffpy/srfit/fitbase/fitrecipe.py | 3 +++ 3 files changed, 5 insertions(+) diff --git a/requirements/conda.txt b/requirements/conda.txt index 5306b5d1..f728df41 100644 --- a/requirements/conda.txt +++ b/requirements/conda.txt @@ -1,3 +1,4 @@ matplotlib-base numpy scipy +bg-mpl-stylesheets diff --git a/requirements/pip.txt b/requirements/pip.txt index 74fa65e6..a9d7948e 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -1,3 +1,4 @@ matplotlib numpy scipy +bg-mpl-stylesheets diff --git a/src/diffpy/srfit/fitbase/fitrecipe.py b/src/diffpy/srfit/fitbase/fitrecipe.py index fb67ecdc..f90be968 100644 --- a/src/diffpy/srfit/fitbase/fitrecipe.py +++ b/src/diffpy/srfit/fitbase/fitrecipe.py @@ -38,6 +38,7 @@ import matplotlib.pyplot as plt import six +from bg_mpl_stylesheets.styles import all_styles from numpy import array, concatenate, dot, sqrt from diffpy.srfit.fitbase.fithook import PrintFitHook @@ -46,6 +47,8 @@ from diffpy.srfit.interface import _fitrecipe_interface from diffpy.srfit.util.tagmanager import TagManager +plt.style.use(all_styles["bg-style"]) + class FitRecipe(_fitrecipe_interface, RecipeOrganizer): """FitRecipe class. From b147e68ad5d44857141ef1221f7ca0f70e9b56d9 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Sat, 31 Jan 2026 12:57:33 -0500 Subject: [PATCH 09/16] add 'The' to docstrings --- src/diffpy/srfit/fitbase/fitrecipe.py | 70 ++++++++++++++------------- 1 file changed, 36 insertions(+), 34 deletions(-) diff --git a/src/diffpy/srfit/fitbase/fitrecipe.py b/src/diffpy/srfit/fitbase/fitrecipe.py index f90be968..7e839996 100644 --- a/src/diffpy/srfit/fitbase/fitrecipe.py +++ b/src/diffpy/srfit/fitbase/fitrecipe.py @@ -904,7 +904,8 @@ def plot_recipe( ax=None, return_fig=False, ): - """Plot the fit recipe data, calculated fit, and difference curve. + """The fit recipe data, calculated fit, and difference curve are + plotted. If the recipe has multiple contributions, a separate plot is created for each contribution. @@ -912,85 +913,86 @@ def plot_recipe( Parameters ---------- show_observed : bool, optional - If True, plot the observed data points. Default is True. + The observed data is plotted if True. Default is True. show_fit : bool, optional - If True, plot the calculated fit curve. Default is True. + The fit to the data is plotted if True. Default is True. show_diff : bool, optional - If True, plot the difference curve (observed - calculated). + The difference curve (observed - calculated) is plotted if True. Default is True. offset_scale : float, optional - Scaling factor for the difference curve offset. The difference + The scaling factor for the difference curve offset. The difference curve is offset below the data by (min_y - 0.1*range) * offset_scale. Default is 1.0. figsize : tuple, optional - Figure size as (width, height) in inches. Default is (8, 6). + The figure size as (width, height) in inches. Default is (8, 6). data_style : str, optional - Matplotlib line/marker style for data points. Default is "o". + The matplotlib line/marker style for data points. Default is "o". fit_style : str, optional - Matplotlib line style for calculated fit. Default is "-". + The matplotlib line style for the calculated fit. Default is "-". diff_style : str, optional - Matplotlib line style for difference curve. Default is "-". + The matplotlib line style for the difference curve. Default is "-". data_color : str or None, optional - Color for data points. If None, uses default matplotlib colors. + The color for data points. If None, uses default matplotlib colors. fit_color : str or None, optional - Color for fit curve. If None, uses default matplotlib colors. - diff_color : str or None, optional - Color for difference curve. If None, uses default matplotlib + The color for the fit curve. If None, uses default matplotlib colors. + diff_color : str or None, optional + The color for the difference curve. If None, uses default + matplotlib colors. data_label : str, optional - Legend label for observed data. Default is "Observed". + The legend label for observed data. Default is "Observed". fit_label : str, optional - Legend label for calculated fit. Default is "Calculated". + The legend label for the calculated fit. Default is "Calculated". diff_label : str, optional - Legend label for difference curve. Default is "Difference". + The legend label for the difference curve. Default is "Difference". xlabel : str, optional - Label for x-axis. + The label for the x-axis. ylabel : str, optional - Label for y-axis. + The label for the y-axis. title : str or None, optional - Plot title. Default is no title. + The plot title. Default is no title. legend : bool, optional - If True, show legend. Default is True. + The legend is shown if True. Default is True. legend_loc : str, optional - Legend location. Default is "best". + The legend location. Default is "best". grid : bool, optional - If True, show grid. Default is False. + The grid is shown if True. Default is False. markersize : float, optional - Size of data point markers. + The size of data point markers. linewidth : float, optional - Width of fit and difference lines. + The width of fit and difference lines. alpha : float, optional - Transparency of all plot elements (0=transparent, 1=opaque). + The transparency of all plot elements (0=transparent, 1=opaque). Default is 1.0. show : bool, optional - If True, display the plot using plt.show(). Default is True. + The plot is displayed using plt.show() if True. Default is True. ax : matplotlib.axes.Axes or None, optional - Axes object to plot on. If None, creates new figure. + The axes object to plot on. If None, creates a new figure. Default is None. return_fig : bool, optional - If True, return the figure and axes objects. Default is False. + The figure and axes objects are returned if True. Default is False. Returns ------- fig, axes : tuple of (mpl.figure.Figure, list of mpl.axes.Axes) - Only returned if return_fig=True. Returns the figure object - and a list of axes objects (one per contribution). + The figure object and a list of axes objects (one per contribution) + are returned if return_fig=True. Examples -------- - Plot everything with default settings: + The plot is created with default settings: >>> recipe.plot_recipe() - Plot only data and fit (no difference curve): + The data and fit are plotted (no difference curve): >>> recipe.plot_recipe(show_diff=False) - Plot only data to check before refinement: + The data is plotted to check before refinement: >>> recipe.plot_recipe(show_fit=False, show_diff=False) - Get figure object for further customization: + The figure object is retrieved for further customization: >>> fig, axes = recipe.plot_recipe(show=False, return_fig=True) >>> axes[0].set_yscale('log') From 35bd1146e62a7725a320982932f34271062fe214 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Sat, 31 Jan 2026 13:53:00 -0500 Subject: [PATCH 10/16] add set_plot_defaults function --- src/diffpy/srfit/fitbase/fitrecipe.py | 204 ++++++++++++++++---------- tests/conftest.py | 50 +++++++ tests/test_fitrecipe.py | 129 ++++++++-------- 3 files changed, 247 insertions(+), 136 deletions(-) diff --git a/src/diffpy/srfit/fitbase/fitrecipe.py b/src/diffpy/srfit/fitbase/fitrecipe.py index 7e839996..900140c0 100644 --- a/src/diffpy/srfit/fitbase/fitrecipe.py +++ b/src/diffpy/srfit/fitbase/fitrecipe.py @@ -155,6 +155,32 @@ def __init__(self, name="fit"): self._contributions = OrderedDict() self._manage(self._contributions) + self.plot_options = { + "show_observed": True, + "show_fit": True, + "show_diff": True, + "offset_scale": 1.0, + "figsize": (8, 6), + "data_style": "o", + "fit_style": "-", + "diff_style": "-", + "data_color": None, + "fit_color": None, + "diff_color": None, + "data_label": "Observed", + "fit_label": "Calculated", + "diff_label": "Difference", + "xlabel": None, + "ylabel": None, + "title": None, + "legend": True, + "legend_loc": "best", + "grid": False, + "markersize": None, + "linewidth": None, + "alpha": 1.0, + "show": True, + } return def pushFitHook(self, fithook, index=None): @@ -875,35 +901,30 @@ def getBounds2(self): ub = array([b[1] for b in bounds]) return lb, ub - def plot_recipe( - self, - show_observed=True, - show_fit=True, - show_diff=True, - offset_scale=1.0, - figsize=(8, 6), - data_style="o", - fit_style="-", - diff_style="-", - data_color=None, - fit_color=None, - diff_color=None, - data_label="Observed", - fit_label="Calculated", - diff_label="Difference", - xlabel=None, - ylabel=None, - title=None, - legend=True, - legend_loc="best", - grid=False, - markersize=None, - linewidth=None, - alpha=1.0, - show=True, - ax=None, - return_fig=False, - ): + def set_plot_defaults(self, **kwargs): + """Set default plotting options for all future plots. + + Any keyword argument accepted by plot_recipe() can be set here. + + Examples + -------- + >>> recipe.set_plot_defaults( + ... xlabel='r (Å)', + ... ylabel='G(r) (Å⁻²)', + ... data_color='black', + ... fit_color='red' + ... ) + """ + + for key in kwargs: + if key not in self.plot_options: + print( + f"Warning: '{key}' is not a valid " + "plot_recipe option and will be ignored." + ) + self.plot_options.update(kwargs) + + def plot_recipe(self, ax=None, return_fig=False, **kwargs): """The fit recipe data, calculated fit, and difference curve are plotted. @@ -912,6 +933,17 @@ def plot_recipe( Parameters ---------- + ax : matplotlib.axes.Axes or None, optional + The axes object to plot on. If None, creates a new figure. + Default is None. + return_fig : bool, optional + The figure and axes objects are returned if True. Default is False. + **kwargs : dict + Any plotting option can be passed to override the defaults in + recipe.plot_options. + + Keyword Arguments + ----------------- show_observed : bool, optional The observed data is plotted if True. Default is True. show_fit : bool, optional @@ -980,25 +1012,51 @@ def plot_recipe( Examples -------- - The plot is created with default settings: + Plot with default settings: >>> recipe.plot_recipe() - The data and fit are plotted (no difference curve): + Override defaults for one plot: + + >>> recipe.plot_recipe(show_diff=False, title='My Custom Title') + + Set defaults once, use everywhere: + + >>> recipe.set_plot_defaults(xlabel='r (Å)', ylabel='G(r)') + >>> recipe.plot_recipe() # Uses xlabel and ylabel + >>> recipe.plot_recipe() # Still uses them + + Override a default for one plot: - >>> recipe.plot_recipe(show_diff=False) + >>> recipe.set_plot_defaults(figsize=(10, 7)) + >>> recipe.plot_recipe() # Uses (10, 7) + >>> recipe.plot_recipe(figsize=(12, 8)) # Temporarily uses (12, 8) + >>> recipe.plot_recipe() # Back to (10, 7) - The data is plotted to check before refinement: + Notes + ----- + Default values are taken from recipe.plot_options. You can modify + these defaults in three ways: - >>> recipe.plot_recipe(show_fit=False, show_diff=False) + 1. Using set_plot_defaults(): + recipe.set_plot_defaults(xlabel='r (Å)') - The figure object is retrieved for further customization: + 2. Direct attribute access: + recipe.plot_options['xlabel'] = 'r (Å)' - >>> fig, axes = recipe.plot_recipe(show=False, return_fig=True) - >>> axes[0].set_yscale('log') - >>> plt.savefig('my_fit.png', dpi=300) + 3. Using update(): + recipe.plot_options.update({'xlabel': 'r (Å)', 'ylabel': 'G(r)'}) """ - if not any([show_observed, show_fit, show_diff]): + plot_params = self.plot_options.copy() + plot_params.update(kwargs) + + if not any( + [ + plot_params["show_observed"], + plot_params["show_fit"], + plot_params["show_diff"], + ] + ): raise ValueError( "At least one of show_observed, show_fit, " "or show_diff must be True" @@ -1009,85 +1067,83 @@ def plot_recipe( "No contributions found in recipe. " "Add contributions before plotting." ) - figures = [] axes_list = [] - for name, contrib in self._contributions.items(): profile = contrib.profile x = profile.x yobs = profile.y ycalc = profile.ycalc if ycalc is None: - if show_fit or show_diff: + if plot_params["show_fit"] or plot_params["show_diff"]: print( f"Contribution '{name}' has no calculated values " "(ycalc is None). " "Only observed data will be plotted." ) - show_fit = False - show_diff = False + plot_params["show_fit"] = False + plot_params["show_diff"] = False else: diff = yobs - ycalc y_min = min(yobs.min(), ycalc.min()) y_max = max(yobs.max(), ycalc.max()) y_range = y_max - y_min base_offset = y_min - 0.1 * y_range - offset = base_offset * offset_scale - + offset = base_offset * plot_params["offset_scale"] if ax is None: - fig = plt.figure(figsize=figsize) + fig = plt.figure(figsize=plot_params["figsize"]) current_ax = fig.add_subplot(111) else: current_ax = ax fig = current_ax.figure - if show_observed: + if plot_params["show_observed"]: current_ax.plot( x, yobs, - data_style, - label=data_label, - color=data_color, - markersize=markersize, - alpha=alpha, + plot_params["data_style"], + label=plot_params["data_label"], + color=plot_params["data_color"], + markersize=plot_params["markersize"], + alpha=plot_params["alpha"], ) - if show_fit: + if plot_params["show_fit"]: current_ax.plot( x, ycalc, - fit_style, - label=fit_label, - color=fit_color, - linewidth=linewidth, - alpha=alpha, + plot_params["fit_style"], + label=plot_params["fit_label"], + color=plot_params["fit_color"], + linewidth=plot_params["linewidth"], + alpha=plot_params["alpha"], ) - if show_diff: + if plot_params["show_diff"]: current_ax.plot( x, diff + offset, - diff_style, - label=diff_label, - color=diff_color, - linewidth=linewidth, - alpha=alpha, + plot_params["diff_style"], + label=plot_params["diff_label"], + color=plot_params["diff_color"], + linewidth=plot_params["linewidth"], + alpha=plot_params["alpha"], ) current_ax.axhline( offset, color="black", ) - current_ax.set_xlabel(xlabel) - current_ax.set_ylabel(ylabel) - - if title is not None: - current_ax.set_title(title) - if legend: - current_ax.legend(loc=legend_loc, frameon=True) - if grid: + if plot_params["xlabel"] is not None: + current_ax.set_xlabel(plot_params["xlabel"]) + if plot_params["ylabel"] is not None: + current_ax.set_ylabel(plot_params["ylabel"]) + if plot_params["title"] is not None: + current_ax.set_title(plot_params["title"]) + if plot_params["legend"]: + current_ax.legend(loc=plot_params["legend_loc"], frameon=True) + if plot_params["grid"]: current_ax.grid(True) fig.tight_layout() figures.append(fig) axes_list.append(current_ax) - if show and ax is None: + if plot_params["show"] and ax is None: plt.show() if return_fig: if len(figures) == 1: diff --git a/tests/conftest.py b/tests/conftest.py index 0bb50a2c..85640b41 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,8 +5,10 @@ import pytest import six +from numpy import linspace, pi, sin import diffpy.srfit.equation.literals as literals +from diffpy.srfit.fitbase import FitContribution, FitRecipe, Profile logger = logging.getLogger(__name__) @@ -142,3 +144,51 @@ def _capturestdout(f, *args, **kwargs): return fp.getvalue() return _capturestdout + + +@pytest.fixture(scope="session") +def build_recipe_one_contribution(): + "helper to build a simple recipe" + profile = Profile() + x = linspace(0, pi, 10) + y = sin(x) + profile.setObservedProfile(x, y) + contribution = FitContribution("c1") + contribution.setProfile(profile) + contribution.setEquation("A*sin(k*x + c)") + recipe = FitRecipe() + recipe.addContribution(contribution) + recipe.addVar(contribution.A, 1) + recipe.addVar(contribution.k, 1) + recipe.addVar(contribution.c, 1) + return recipe + + +@pytest.fixture(scope="session") +def build_recipe_two_contributions(): + "helper to build a recipe with two contributions" + profile1 = Profile() + x = linspace(0, pi, 10) + y1 = sin(x) + profile1.setObservedProfile(x, y1) + contribution1 = FitContribution("c1") + contribution1.setProfile(profile1) + contribution1.setEquation("A*sin(k*x + c)") + + profile2 = Profile() + y2 = 0.5 * sin(2 * x) + profile2.setObservedProfile(x, y2) + contribution2 = FitContribution("c2") + contribution2.setProfile(profile2) + contribution2.setEquation("B*sin(m*x + d)") + recipe = FitRecipe() + recipe.addContribution(contribution1) + recipe.addContribution(contribution2) + recipe.addVar(contribution1.A, 1) + recipe.addVar(contribution1.k, 1) + recipe.addVar(contribution1.c, 1) + recipe.addVar(contribution2.B, 0.5) + recipe.addVar(contribution2.m, 2) + recipe.addVar(contribution2.d, 0) + + return recipe diff --git a/tests/test_fitrecipe.py b/tests/test_fitrecipe.py index f7af61c0..3a2cd5f4 100644 --- a/tests/test_fitrecipe.py +++ b/tests/test_fitrecipe.py @@ -290,52 +290,6 @@ def testPrintFitHook(capturestdout): return -def build_recipe_one_contribution(): - "helper to build a simple recipe" - profile = Profile() - x = linspace(0, pi, 10) - y = sin(x) - profile.setObservedProfile(x, y) - contribution = FitContribution("c1") - contribution.setProfile(profile) - contribution.setEquation("A*sin(k*x + c)") - recipe = FitRecipe() - recipe.addContribution(contribution) - recipe.addVar(contribution.A, 1) - recipe.addVar(contribution.k, 1) - recipe.addVar(contribution.c, 1) - return recipe - - -def build_recipe_two_contributions(): - "helper to build a recipe with two contributions" - profile1 = Profile() - x = linspace(0, pi, 10) - y1 = sin(x) - profile1.setObservedProfile(x, y1) - contribution1 = FitContribution("c1") - contribution1.setProfile(profile1) - contribution1.setEquation("A*sin(k*x + c)") - - profile2 = Profile() - y2 = 0.5 * sin(2 * x) - profile2.setObservedProfile(x, y2) - contribution2 = FitContribution("c2") - contribution2.setProfile(profile2) - contribution2.setEquation("B*sin(m*x + d)") - recipe = FitRecipe() - recipe.addContribution(contribution1) - recipe.addContribution(contribution2) - recipe.addVar(contribution1.A, 1) - recipe.addVar(contribution1.k, 1) - recipe.addVar(contribution1.c, 1) - recipe.addVar(contribution2.B, 0.5) - recipe.addVar(contribution2.m, 2) - recipe.addVar(contribution2.d, 0) - - return recipe - - def optimize_recipe(recipe): recipe.fithooks[0].verbose = 0 residuals = recipe.residual @@ -360,8 +314,8 @@ def get_labels_and_linecount(ax): return labels, line_count -def test_plot_recipe_bad_display(): - recipe = build_recipe_one_contribution() +def test_plot_recipe_bad_display(build_recipe_one_contribution): + recipe = build_recipe_one_contribution # Case: All plots are disabled # expected: raised ValueError with message plt.close("all") @@ -387,11 +341,11 @@ def test_plot_recipe_no_contribution(): recipe.plot_recipe() -def test_plot_recipe_before_refinement(capsys): +def test_plot_recipe_before_refinement(capsys, build_recipe_one_contribution): # Case: User tries to plot recipe before refinement # expected: Data plotted without fit line or difference curve # and warning message printed - recipe = build_recipe_one_contribution() + recipe = build_recipe_one_contribution plt.close("all") before = set(plt.get_fignums()) # include fit_label="nothing" to make sure fit line is not plotted @@ -416,10 +370,10 @@ def test_plot_recipe_before_refinement(capsys): assert actual == expected -def test_plot_recipe_after_refinement(): +def test_plot_recipe_after_refinement(build_recipe_one_contribution): # Case: User refines recipe and then plots # expected: Plot generates with no problem - recipe = build_recipe_one_contribution() + recipe = build_recipe_one_contribution optimize_recipe(recipe) plt.close("all") before = set(plt.get_fignums()) @@ -434,10 +388,10 @@ def test_plot_recipe_after_refinement(): assert len(new_figs) == 1 -def test_plot_recipe_two_contributions(): +def test_plot_recipe_two_contributions(build_recipe_two_contributions): # Case: Two contributions in recipe # expected: two figures created - recipe = build_recipe_two_contributions() + recipe = build_recipe_two_contributions optimize_recipe(recipe) plt.close("all") before = set(plt.get_fignums()) @@ -453,10 +407,10 @@ def test_plot_recipe_two_contributions(): assert len(new_figs) == 2 -def test_plot_recipe_on_existing_plot(): +def test_plot_recipe_on_existing_plot(build_recipe_one_contribution): # Case: User passes axes to plot_recipe to plot on existing figure # expected: User modifications are present in the final figure - recipe = build_recipe_one_contribution() + recipe = build_recipe_one_contribution optimize_recipe(recipe) plt.close("all") fig, ax = plt.subplots() @@ -473,10 +427,10 @@ def test_plot_recipe_on_existing_plot(): assert actual_title == expected_title -def test_plot_recipe_add_new_data(): +def test_plot_recipe_add_new_data(build_recipe_one_contribution): # Case: User wants to add data to figure generated by plot_recipe # Expected: New data is added to existing figure (check with labels) - recipe = build_recipe_one_contribution() + recipe = build_recipe_one_contribution optimize_recipe(recipe) plt.close("all") before = set(plt.get_fignums()) @@ -494,11 +448,11 @@ def test_plot_recipe_add_new_data(): assert sorted(actual_labels) == sorted(expected_labels) -def test_plot_recipe_add_new_data_two_figs(): +def test_plot_recipe_add_new_data_two_figs(build_recipe_two_contributions): # Case: User wants to add data to figure generated by plot_recipe # with two contributions # Expected: New data is added to existing figure (check with labels) - recipe = build_recipe_two_contributions() + recipe = build_recipe_two_contributions optimize_recipe(recipe) plt.close("all") before = set(plt.get_fignums()) @@ -517,10 +471,10 @@ def test_plot_recipe_add_new_data_two_figs(): assert len(new_figs) == 2 -def test_plot_recipe_set_title(): +def test_plot_recipe_set_title(build_recipe_one_contribution): # Case: User sets title via plot_recipe # Expected: Title is set correctly - recipe = build_recipe_one_contribution() + recipe = build_recipe_one_contribution optimize_recipe(recipe) plt.close("all") expected_title = "Custom Recipe Title" @@ -531,5 +485,56 @@ def test_plot_recipe_set_title(): assert actual_title == expected_title +def test_plot_recipe_set_defaults(build_recipe_one_contribution): + # Case: user sets default plot options with set_plot_defaults + # Expected: plot_recipe uses the default options for all calls + recipe = build_recipe_one_contribution + optimize_recipe(recipe) + plt.close("all") + # set new defaults + recipe.set_plot_defaults( + show_observed=False, + show_fit=True, + show_diff=False, + data_label="Data Default", + fit_label="Fit Default", + diff_label="Diff Default", + title="Default Title", + ) + # call plot_recipe without any arguments + figure, ax = recipe.plot_recipe(return_fig=True, show=False) + actual_title = ax.get_title() + actual_labels, actual_line_count = get_labels_and_linecount(ax) + expected_title = "Default Title" + expected_labels = ["Fit Default"] + expected_line_count = 1 + assert actual_title == expected_title + assert actual_line_count == expected_line_count + assert actual_labels == expected_labels + + +def test_plot_recipe_set_defaults_bad(capsys, build_recipe_one_contribution): + # Case: user tries to set kwargs that are not valid plot_recipe options + # Expected: Plot is shown and warning is printed + recipe = build_recipe_one_contribution + optimize_recipe(recipe) + plt.close("all") + recipe.set_plot_defaults( + invalid_option="blah", + ) + captured = capsys.readouterr() + actual_msg = captured.out.strip() + expected_msg = ( + "Warning: 'invalid_option' is not a valid " + "plot_recipe option and will be ignored." + ) + assert actual_msg == expected_msg + before = set(plt.get_fignums()) + figure, ax = recipe.plot_recipe(return_fig=True, show=False) + after = set(plt.get_fignums()) + new_figs = after - before + assert len(new_figs) == 1 + + if __name__ == "__main__": unittest.main() From 0a946e5c013016c2426e264a707b489dec311985 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Sat, 31 Jan 2026 15:06:41 -0500 Subject: [PATCH 11/16] replace six.string_types with str --- src/diffpy/srfit/fitbase/fitrecipe.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/diffpy/srfit/fitbase/fitrecipe.py b/src/diffpy/srfit/fitbase/fitrecipe.py index 900140c0..b637e202 100644 --- a/src/diffpy/srfit/fitbase/fitrecipe.py +++ b/src/diffpy/srfit/fitbase/fitrecipe.py @@ -37,7 +37,6 @@ from collections import OrderedDict import matplotlib.pyplot as plt -import six from bg_mpl_stylesheets.styles import all_styles from numpy import array, concatenate, dot, sqrt @@ -671,7 +670,7 @@ def __get_var_and_check(self, var): Returns the variable or None if the variable cannot be found in the _parameters list. """ - if isinstance(var, six.string_types): + if isinstance(var, str): var = self._parameters.get(var) if var not in self._parameters.values(): @@ -691,9 +690,7 @@ def __get_vars_from_args(self, *args, **kw): or if a tag is passed in a keyword. """ # Process args. Each variable is tagged with its name, so this is easy. - strargs = set( - [arg for arg in args if isinstance(arg, six.string_types)] - ) + strargs = set([arg for arg in args if isinstance(arg, str)]) varargs = set(args) - strargs # Check that the tags are valid alltags = set(self._tagmanager.alltags()) @@ -794,7 +791,7 @@ def unconstrain(self, *pars): """ update = False for par in pars: - if isinstance(par, six.string_types): + if isinstance(par, str): name = par par = self.get(name) @@ -845,7 +842,7 @@ def constrain(self, par, con, ns={}): the FitRecipe and that is not defined in ns. Raises ValueError if par is marked as constant. """ - if isinstance(par, six.string_types): + if isinstance(par, str): name = par par = self.get(name) if par is None: From cd422def161c09b50ce679b9bbe36f7fb4567af4 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Sat, 31 Jan 2026 15:38:13 -0500 Subject: [PATCH 12/16] add xlims option --- src/diffpy/srfit/fitbase/fitrecipe.py | 39 ++++++++---- tests/test_fitrecipe.py | 88 +++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 11 deletions(-) diff --git a/src/diffpy/srfit/fitbase/fitrecipe.py b/src/diffpy/srfit/fitbase/fitrecipe.py index b637e202..3a595909 100644 --- a/src/diffpy/srfit/fitbase/fitrecipe.py +++ b/src/diffpy/srfit/fitbase/fitrecipe.py @@ -159,6 +159,8 @@ def __init__(self, name="fit"): "show_fit": True, "show_diff": True, "offset_scale": 1.0, + "xmin": None, + "xmax": None, "figsize": (8, 6), "data_style": "o", "fit_style": "-", @@ -922,8 +924,8 @@ def set_plot_defaults(self, **kwargs): self.plot_options.update(kwargs) def plot_recipe(self, ax=None, return_fig=False, **kwargs): - """The fit recipe data, calculated fit, and difference curve are - plotted. + """Plot the observed, fit, and difference curves for each contribution + of the fit recipe. If the recipe has multiple contributions, a separate plot is created for each contribution. @@ -937,7 +939,7 @@ def plot_recipe(self, ax=None, return_fig=False, **kwargs): The figure and axes objects are returned if True. Default is False. **kwargs : dict Any plotting option can be passed to override the defaults in - recipe.plot_options. + recipe.plot_options. See below for options. Keyword Arguments ----------------- @@ -952,21 +954,29 @@ def plot_recipe(self, ax=None, return_fig=False, **kwargs): The scaling factor for the difference curve offset. The difference curve is offset below the data by (min_y - 0.1*range) * offset_scale. Default is 1.0. + xmin : float or None, optional + The minimum x value to plot. If None, uses the minimum x value + of the data. Default is None. + xmax : float or None, optional + The maximum x value to plot. If None, uses the maximum x value + of the data. Default is None. figsize : tuple, optional - The figure size as (width, height) in inches. Default is (8, 6). + The figure size as (width, height). Default is (8, 6). data_style : str, optional The matplotlib line/marker style for data points. Default is "o". fit_style : str, optional - The matplotlib line style for the calculated fit. Default is "-". + The matplotlib line/marker style for the calculated fit. + Default is "-". diff_style : str, optional - The matplotlib line style for the difference curve. Default is "-". + The matplotlib line/marker style for the difference curve. + Default is "-". data_color : str or None, optional - The color for data points. If None, uses default matplotlib colors. + The color for data plot. If None, uses default matplotlib colors. fit_color : str or None, optional - The color for the fit curve. If None, uses default matplotlib + The color for the fit plot. If None, uses default matplotlib colors. diff_color : str or None, optional - The color for the difference curve. If None, uses default + The color for the difference plot. If None, uses default matplotlib colors. data_label : str, optional The legend label for observed data. Default is "Observed". @@ -994,7 +1004,7 @@ def plot_recipe(self, ax=None, return_fig=False, **kwargs): The transparency of all plot elements (0=transparent, 1=opaque). Default is 1.0. show : bool, optional - The plot is displayed using plt.show() if True. Default is True. + The plot is displayed using `plt.show()` if True. Default is True. ax : matplotlib.axes.Axes or None, optional The axes object to plot on. If None, creates a new figure. Default is None. @@ -1032,7 +1042,7 @@ def plot_recipe(self, ax=None, return_fig=False, **kwargs): Notes ----- - Default values are taken from recipe.plot_options. You can modify + The default values are taken from recipe.plot_options. You can modify these defaults in three ways: 1. Using set_plot_defaults(): @@ -1137,6 +1147,13 @@ def plot_recipe(self, ax=None, return_fig=False, **kwargs): current_ax.legend(loc=plot_params["legend_loc"], frameon=True) if plot_params["grid"]: current_ax.grid(True) + if ( + plot_params["xmin"] is not None + or plot_params["xmax"] is not None + ): + current_ax.set_xlim( + left=plot_params["xmin"], right=plot_params["xmax"] + ) fig.tight_layout() figures.append(fig) axes_list.append(current_ax) diff --git a/tests/test_fitrecipe.py b/tests/test_fitrecipe.py index 3a2cd5f4..f3412ab0 100644 --- a/tests/test_fitrecipe.py +++ b/tests/test_fitrecipe.py @@ -536,5 +536,93 @@ def test_plot_recipe_set_defaults_bad(capsys, build_recipe_one_contribution): assert len(new_figs) == 1 +def test_plot_recipe_reset_all_defaults(build_recipe_one_contribution): + expected_defaults = { + "show_observed": True, + "show_fit": True, + "show_diff": True, + "offset_scale": 0.5, + "xmin": 1, + "xmax": 10, + "figsize": (9, 10), + "data_style": "-", + "fit_style": "o", + "diff_style": "o", + "data_color": "blue", + "fit_color": "purple", + "diff_color": "orange", + "data_label": "my data", + "fit_label": "my fit", + "diff_label": "my diff", + "xlabel": "my x label", + "ylabel": "my y label", + "title": "my title", + "legend": False, + "legend_loc": "upper right", + "markersize": 5, + "linewidth": 3, + "alpha": 0.5, + "show": True, + } + + recipe = build_recipe_one_contribution + optimize_recipe(recipe) + plt.close("all") + + recipe.set_plot_defaults(**expected_defaults) + fig, ax = recipe.plot_recipe(return_fig=True, show=False) + + actual_title = ax.get_title() + actual_xlabel = ax.get_xlabel() + actual_ylabel = ax.get_ylabel() + + expected_title = expected_defaults["title"] + expected_xlabel = expected_defaults["xlabel"] + expected_ylabel = expected_defaults["ylabel"] + + assert actual_title == expected_title + assert actual_xlabel == expected_xlabel + assert actual_ylabel == expected_ylabel + + actual_labels, actual_line_count = get_labels_and_linecount(ax) + + expected_labels = [ + expected_defaults["data_label"], + expected_defaults["fit_label"], + expected_defaults["diff_label"], + ] + expected_line_count = 3 + + assert actual_line_count == expected_line_count + assert actual_labels == expected_labels + + lines_by_label = {line.get_label(): line for line in ax.get_lines()} + + data_line = lines_by_label[expected_defaults["data_label"]] + fit_line = lines_by_label[expected_defaults["fit_label"]] + diff_line = lines_by_label[expected_defaults["diff_label"]] + + assert data_line.get_color() == expected_defaults["data_color"] + assert fit_line.get_color() == expected_defaults["fit_color"] + assert diff_line.get_color() == expected_defaults["diff_color"] + + assert data_line.get_linestyle() == expected_defaults["data_style"] + assert fit_line.get_marker() == expected_defaults["fit_style"] + assert diff_line.get_marker() == expected_defaults["diff_style"] + + assert data_line.get_markersize() == expected_defaults["markersize"] + assert data_line.get_alpha() == expected_defaults["alpha"] + + actual_xlim = ax.get_xlim() + expected_xlim = (expected_defaults["xmin"], expected_defaults["xmax"]) + assert actual_xlim == expected_xlim + + # no legend + actual_legend = ax.get_legend() is not None + expected_legend = expected_defaults["legend"] + + assert actual_legend == expected_legend + + if __name__ == "__main__": unittest.main() From b4478688dc0d55f8857f46af305608bc891ac3b9 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Sat, 31 Jan 2026 18:23:51 -0500 Subject: [PATCH 13/16] move docstring to set_plot_defaults --- src/diffpy/srfit/fitbase/fitrecipe.py | 74 ++++++++++++++------------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/src/diffpy/srfit/fitbase/fitrecipe.py b/src/diffpy/srfit/fitbase/fitrecipe.py index 3a595909..a56db546 100644 --- a/src/diffpy/srfit/fitbase/fitrecipe.py +++ b/src/diffpy/srfit/fitbase/fitrecipe.py @@ -905,44 +905,8 @@ def set_plot_defaults(self, **kwargs): Any keyword argument accepted by plot_recipe() can be set here. - Examples - -------- - >>> recipe.set_plot_defaults( - ... xlabel='r (Å)', - ... ylabel='G(r) (Å⁻²)', - ... data_color='black', - ... fit_color='red' - ... ) - """ - - for key in kwargs: - if key not in self.plot_options: - print( - f"Warning: '{key}' is not a valid " - "plot_recipe option and will be ignored." - ) - self.plot_options.update(kwargs) - - def plot_recipe(self, ax=None, return_fig=False, **kwargs): - """Plot the observed, fit, and difference curves for each contribution - of the fit recipe. - - If the recipe has multiple contributions, a separate - plot is created for each contribution. - Parameters ---------- - ax : matplotlib.axes.Axes or None, optional - The axes object to plot on. If None, creates a new figure. - Default is None. - return_fig : bool, optional - The figure and axes objects are returned if True. Default is False. - **kwargs : dict - Any plotting option can be passed to override the defaults in - recipe.plot_options. See below for options. - - Keyword Arguments - ----------------- show_observed : bool, optional The observed data is plotted if True. Default is True. show_fit : bool, optional @@ -1011,6 +975,44 @@ def plot_recipe(self, ax=None, return_fig=False, **kwargs): return_fig : bool, optional The figure and axes objects are returned if True. Default is False. + Examples + -------- + >>> recipe.set_plot_defaults( + xlabel='r (Å)', + ylabel='G(r) (Å⁻²)', + data_color='black', + fit_color='red' + ) + """ + + for key in kwargs: + if key not in self.plot_options: + print( + f"Warning: '{key}' is not a valid " + "plot_recipe option and will be ignored." + ) + self.plot_options.update(kwargs) + + def plot_recipe(self, ax=None, return_fig=False, **kwargs): + """Plot the observed, fit, and difference curves for each contribution + of the fit recipe. + + If the recipe has multiple contributions, a separate + plot is created for each contribution. + + Parameters + ---------- + ax : matplotlib.axes.Axes or None, optional + The axes object to plot on. If None, creates a new figure. + Default is None. + return_fig : bool, optional + The figure and axes objects are returned if True. Default is False. + **kwargs : dict + Any plotting option can be passed to override the defaults in + `FitRecipe().plot_options`. See the + `FitRecipe().set_plot_defaults()` method for available + keyword arguments. + Returns ------- fig, axes : tuple of (mpl.figure.Figure, list of mpl.axes.Axes) From 13a5f9cb4b39dd60e553aecfe73dd5e8794a17c3 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Sat, 31 Jan 2026 19:30:55 -0500 Subject: [PATCH 14/16] get axes labels from gr files function --- src/diffpy/srfit/fitbase/fitrecipe.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/diffpy/srfit/fitbase/fitrecipe.py b/src/diffpy/srfit/fitbase/fitrecipe.py index a56db546..4d7be2c9 100644 --- a/src/diffpy/srfit/fitbase/fitrecipe.py +++ b/src/diffpy/srfit/fitbase/fitrecipe.py @@ -993,6 +993,20 @@ def set_plot_defaults(self, **kwargs): ) self.plot_options.update(kwargs) + def _set_axes_labels_from_metadata(self, meta, plot_params): + """Set axes labels based on filename suffix in profile metadata if not + already set.""" + if isinstance(meta, dict): + filename = meta.get("filename") + if filename: + suffix = filename.rsplit(".", 1)[-1].lower() + if "gr" in suffix: + if plot_params.get("xlabel") is None: + plot_params["xlabel"] = r"r ($\mathrm{\AA}$)" + if plot_params.get("ylabel") is None: + plot_params["ylabel"] = r"G ($\mathrm{\AA}^{-2}$)" + return + def plot_recipe(self, ax=None, return_fig=False, **kwargs): """Plot the observed, fit, and difference curves for each contribution of the fit recipe. @@ -1139,6 +1153,9 @@ def plot_recipe(self, ax=None, return_fig=False, **kwargs): offset, color="black", ) + meta = getattr(profile, "meta", None) + if meta: + self._set_axes_labels_from_metadata(meta, plot_params) if plot_params["xlabel"] is not None: current_ax.set_xlabel(plot_params["xlabel"]) if plot_params["ylabel"] is not None: From 1b32ce65f9069b777916837daa181397a094a849 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Sat, 31 Jan 2026 19:31:52 -0500 Subject: [PATCH 15/16] tests for axes label inference --- tests/conftest.py | 16 +++++++++++ tests/test_fitrecipe.py | 60 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 85640b41..73bf0556 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -192,3 +192,19 @@ def build_recipe_two_contributions(): recipe.addVar(contribution2.d, 0) return recipe + + +@pytest.fixture +def temp_data_files(tmp_path): + """ + Temporary directory containing: + - data_with_meta.gr + - data_without_meta.dat + Each file contains a single line of data. + """ + file_with_meta = tmp_path / "gr_file.gr" + file_with_meta.write_text("1.0 2.0\n" "1.1 2.1\n" "1.2 2.2\n") + + dat_file = tmp_path / "dat_file.dat" + dat_file.write_text("1.0 2.0\n" "1.1 2.1\n" "1.2 2.2\n") + yield tmp_path diff --git a/tests/test_fitrecipe.py b/tests/test_fitrecipe.py index f3412ab0..15802f51 100644 --- a/tests/test_fitrecipe.py +++ b/tests/test_fitrecipe.py @@ -26,6 +26,7 @@ from diffpy.srfit.fitbase.fitrecipe import FitRecipe from diffpy.srfit.fitbase.parameter import Parameter from diffpy.srfit.fitbase.profile import Profile +from diffpy.srfit.pdf import PDFParser matplotlib.use("Agg") @@ -314,6 +315,24 @@ def get_labels_and_linecount(ax): return labels, line_count +def build_recipe_from_datafile(datafile): + """Helper to build a FitRecipe from a datafile using PDFParser and + PDFGenerator.""" + profile = Profile() + parser = PDFParser() + parser.parseFile(str(datafile)) + profile.loadParsedData(parser) + + contribution = FitContribution("c") + contribution.setProfile(profile) + contribution.setEquation("m*x + b") + recipe = FitRecipe() + recipe.addContribution(contribution) + recipe.addVar(contribution.m, 1) + recipe.addVar(contribution.b, 0) + return recipe + + def test_plot_recipe_bad_display(build_recipe_one_contribution): recipe = build_recipe_one_contribution # Case: All plots are disabled @@ -536,6 +555,47 @@ def test_plot_recipe_set_defaults_bad(capsys, build_recipe_one_contribution): assert len(new_figs) == 1 +@pytest.mark.parametrize( + "input,expected", + [ + # case1: .gr file + # expected: labels are inferred from file + ("gr_file.gr", [r"r ($\mathrm{\AA}$)", r"G ($\mathrm{\AA}^{-2}$)"]), + # case2: .dat file + # expected: default empty labels + ("dat_file.dat", ["", ""]), + ], +) +def test_plot_recipe_labels_from_gr_file(temp_data_files, input, expected): + gr_file = temp_data_files / input + recipe = build_recipe_from_datafile(gr_file) + optimize_recipe(recipe) + plt.close("all") + fig, ax = recipe.plot_recipe(return_fig=True, show=False) + actual_xlabel = ax.get_xlabel() + actual_ylabel = ax.get_ylabel() + expected_xlabel = expected[0] + expected_ylabel = expected[1] + assert actual_xlabel == expected_xlabel + assert actual_ylabel == expected_ylabel + + +def test_plot_recipe_labels_from_gr_file_overwrite(temp_data_files): + gr_file = temp_data_files / "gr_file.gr" + recipe = build_recipe_from_datafile(gr_file) + optimize_recipe(recipe) + plt.close("all") + fig, ax = recipe.plot_recipe( + return_fig=True, show=False, xlabel="My X", ylabel="My Y" + ) + actual_xlabel = ax.get_xlabel() + actual_ylabel = ax.get_ylabel() + expected_xlabel = "My X" + expected_ylabel = "My Y" + assert actual_xlabel == expected_xlabel + assert actual_ylabel == expected_ylabel + + def test_plot_recipe_reset_all_defaults(build_recipe_one_contribution): expected_defaults = { "show_observed": True, From 65a4c85ad093f16abe254e7dec3ee2a902ff54e7 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Sat, 31 Jan 2026 19:34:00 -0500 Subject: [PATCH 16/16] add cgr case --- tests/conftest.py | 3 +++ tests/test_fitrecipe.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 73bf0556..250bccf3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -207,4 +207,7 @@ def temp_data_files(tmp_path): dat_file = tmp_path / "dat_file.dat" dat_file.write_text("1.0 2.0\n" "1.1 2.1\n" "1.2 2.2\n") + + cgr_file = tmp_path / "cgr_file.cgr" + cgr_file.write_text("1.0 2.0\n" "1.1 2.1\n" "1.2 2.2\n") yield tmp_path diff --git a/tests/test_fitrecipe.py b/tests/test_fitrecipe.py index 15802f51..7b40f229 100644 --- a/tests/test_fitrecipe.py +++ b/tests/test_fitrecipe.py @@ -564,6 +564,9 @@ def test_plot_recipe_set_defaults_bad(capsys, build_recipe_one_contribution): # case2: .dat file # expected: default empty labels ("dat_file.dat", ["", ""]), + # case3: .cgr file + # expected: labels are inferred from file + ("cgr_file.cgr", [r"r ($\mathrm{\AA}$)", r"G ($\mathrm{\AA}^{-2}$)"]), ], ) def test_plot_recipe_labels_from_gr_file(temp_data_files, input, expected):