From 5d9fd640506c17f9409d0879d6f08526f48bd4c2 Mon Sep 17 00:00:00 2001 From: Sparky <59151395+Sparks29032@users.noreply.github.com> Date: Wed, 9 Jul 2025 16:20:02 -0500 Subject: [PATCH 01/23] Update numerical file comparisons in tests (#231) * Update numerical file comparisons in tests * News * Dictionary numeric comparison * Renaming for clarity --- news/update_tests.rst | 23 +++++++++++++++++ tests/test_morphio.py | 58 +++++++++++++++++++------------------------ tests/test_morphpy.py | 18 ++++++++++++-- 3 files changed, 65 insertions(+), 34 deletions(-) create mode 100644 news/update_tests.rst diff --git a/news/update_tests.rst b/news/update_tests.rst new file mode 100644 index 00000000..790d30b1 --- /dev/null +++ b/news/update_tests.rst @@ -0,0 +1,23 @@ +**Added:** + +* + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/tests/test_morphio.py b/tests/test_morphio.py index b16d414a..08699dcb 100644 --- a/tests/test_morphio.py +++ b/tests/test_morphio.py @@ -50,6 +50,20 @@ def isfloat(s): return False +def are_files_same(file1, file2): + """Assert that two files have (approximately) the same numerical + values.""" + for f1_row, f2_row in zip(file1, file2): + f1_arr = f1_row.split() + f2_arr = f2_row.split() + assert len(f1_arr) == len(f2_arr) + for idx, _ in enumerate(f1_arr): + if isfloat(f1_arr[idx]) and isfloat(f2_arr[idx]): + assert np.isclose(float(f1_arr[idx]), float(f2_arr[idx])) + else: + assert f1_arr[idx] == f2_arr[idx] + + class TestApp: @pytest.fixture def setup(self): @@ -106,9 +120,9 @@ def test_morph_outputs(self, setup, tmp_path): for file in common: with open(tmp_succinct.joinpath(file)) as gf: with open(test_saving_succinct.joinpath(file)) as tf: - generated = filter(ignore_path, gf) - target = filter(ignore_path, tf) - assert all(x == y for x, y in zip(generated, target)) + actual = filter(ignore_path, gf) + expected = filter(ignore_path, tf) + are_files_same(actual, expected) # Save multiple verbose morphs tmp_verbose = tmp_path.joinpath("verbose") @@ -147,9 +161,9 @@ def test_morph_outputs(self, setup, tmp_path): for file in common: with open(tmp_verbose.joinpath(file)) as gf: with open(test_saving_verbose.joinpath(file)) as tf: - generated = filter(ignore_path, gf) - target = filter(ignore_path, tf) - assert all(x == y for x, y in zip(generated, target)) + actual = filter(ignore_path, gf) + expected = filter(ignore_path, tf) + are_files_same(actual, expected) def test_morphsqueeze_outputs(self, setup, tmp_path): # The file squeeze_morph has a squeeze and stretch applied @@ -182,19 +196,9 @@ def test_morphsqueeze_outputs(self, setup, tmp_path): # Check squeeze morph generates the correct output with open(sqr) as mf: with open(target_file) as tf: - morphed = filter(ignore_path, mf) - target = filter(ignore_path, tf) - for m, t in zip(morphed, target): - m_row = m.split() - t_row = t.split() - assert len(m_row) == len(t_row) - for idx, _ in enumerate(m_row): - if isfloat(m_row[idx]) and isfloat(t_row[idx]): - assert np.isclose( - float(m_row[idx]), float(t_row[idx]) - ) - else: - assert m_row[idx] == t_row[idx] + actual = filter(ignore_path, mf) + expected = filter(ignore_path, tf) + are_files_same(actual, expected) def test_morphfuncy_outputs(self, tmp_path): def quadratic(x, y, a0, a1, a2): @@ -215,16 +219,6 @@ def quadratic(x, y, a0, a1, a2): with open(testdata_dir.joinpath("funcy_target.cgr")) as tf: with open(tmp_path.joinpath("funcy_target.cgr")) as gf: - generated = filter(ignore_path, gf) - target = filter(ignore_path, tf) - for m, t in zip(generated, target): - m_row = m.split() - t_row = t.split() - assert len(m_row) == len(t_row) - for idx, _ in enumerate(m_row): - if isfloat(m_row[idx]) and isfloat(t_row[idx]): - assert np.isclose( - float(m_row[idx]), float(t_row[idx]) - ) - else: - assert m_row[idx] == t_row[idx] + actual = filter(ignore_path, gf) + expected = filter(ignore_path, tf) + are_files_same(actual, expected) diff --git a/tests/test_morphpy.py b/tests/test_morphpy.py index 848fe1a9..0a288271 100644 --- a/tests/test_morphpy.py +++ b/tests/test_morphpy.py @@ -91,7 +91,14 @@ class Chain: assert np.allclose( [rw], [self.morphapp_results[target_file.name]["Rw"]] ) - assert morph_results == self.morphapp_results + # Check values in dictionaries are approximately equal + for file in morph_results.keys(): + morph_params = morph_results[file] + morphapp_params = self.morphapp_results[file] + for key in morph_params.keys(): + assert morph_params[key] == pytest.approx( + morphapp_params[key], abs=1e-08 + ) def test_morphpy(self, setup_morph): morph_results = {} @@ -113,7 +120,14 @@ class Chain: assert np.allclose( [rw], [self.morphapp_results[target_file.name]["Rw"]] ) - assert morph_results == self.morphapp_results + # Check values in dictionaries are approximately equal + for file in morph_results.keys(): + morph_params = morph_results[file] + morphapp_params = self.morphapp_results[file] + for key in morph_params.keys(): + assert morph_params[key] == pytest.approx( + morphapp_params[key], abs=1e-08 + ) def test_morphfuncy(self, setup_morph): def gaussian(x, mu, sigma): From f8ee98a08bc89805cc10a16d1fde45bc59cf2444 Mon Sep 17 00:00:00 2001 From: Sparky <59151395+Sparks29032@users.noreply.github.com> Date: Wed, 9 Jul 2025 16:27:56 -0500 Subject: [PATCH 02/23] Put the `MorphRGrid` at the end of the morphing chain (#232) * Update numerical file comparisons in tests * News * Dictionary numeric comparison * Renaming for clarity * Change order in which r-grid is compared * News * Remove undeveloped option --- news/config_order.rst | 23 +++++++++++++++++++++++ src/diffpy/morph/morphapp.py | 9 ++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 news/config_order.rst diff --git a/news/config_order.rst b/news/config_order.rst new file mode 100644 index 00000000..718a0f61 --- /dev/null +++ b/news/config_order.rst @@ -0,0 +1,23 @@ +**Added:** + +* + +**Changed:** + +* The interpolation of the morphed/objective function onto the target function grid is now done at the end of the morphing chain. Prior, it was done before. This change is desirable as the target function grid may be much smaller/larger than that of the objective, but a morph (e.g. stretch) accounts for that difference. Then, we ensure the morph is done before we regrid for comparison. + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/src/diffpy/morph/morphapp.py b/src/diffpy/morph/morphapp.py index 70737bf2..43f71ce1 100755 --- a/src/diffpy/morph/morphapp.py +++ b/src/diffpy/morph/morphapp.py @@ -506,8 +506,6 @@ def single_morph( # Set up the morphs chain = morphs.MorphChain(config) - # Add the r-range morph, we will remove it when saving and plotting - chain.append(morphs.MorphRGrid()) refpars = [] # Python-Specific Morphs @@ -632,6 +630,10 @@ def single_morph( refpars.append("qdamp") config["qdamp"] = opts.qdamp + # Add the r-range morph, we will remove it when saving and plotting + mrg = morphs.MorphRGrid() + chain.append(mrg) + # Now remove non-refinable parameters if opts.exclude is not None: refpars = list(set(refpars) - set(opts.exclude)) @@ -674,7 +676,8 @@ def single_morph( rw = tools.getRw(chain) pcc = tools.get_pearson(chain) # Replace the MorphRGrid with Morph identity - chain[0] = morphs.Morph() + # This removes the r-range morph as mentioned above + mrg = morphs.Morph() chain(x_morph, y_morph, x_target, y_target) # FOR FUTURE MAINTAINERS From 742643ce23c950f950c92f646f389c9250efc1b0 Mon Sep 17 00:00:00 2001 From: Sparky <59151395+Sparks29032@users.noreply.github.com> Date: Wed, 9 Jul 2025 21:15:21 -0500 Subject: [PATCH 03/23] Bug fix for `--smear-pdf` (#234) * Bug fix for smear-pdf * News --- news/smear_pdf_bug.rst | 23 +++++++++++++++++++ .../morph/morph_helpers/transformrdftopdf.py | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 news/smear_pdf_bug.rst diff --git a/news/smear_pdf_bug.rst b/news/smear_pdf_bug.rst new file mode 100644 index 00000000..1b897755 --- /dev/null +++ b/news/smear_pdf_bug.rst @@ -0,0 +1,23 @@ +**Added:** + +* + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* When transforming from the RDF to PDF in the smear-pdf morph, we incorrectly referenced the target grid zero point, when we should be referencing the morph grid zero point. This would lead to the morph PDF being set to zero on a point corresponding to when the target PDF grid value is zero. The correct behavior is for the morph PDF to be set to zero when the morph PDF grid value is zero. This has been fixed. + +**Security:** + +* diff --git a/src/diffpy/morph/morph_helpers/transformrdftopdf.py b/src/diffpy/morph/morph_helpers/transformrdftopdf.py index 495c7dda..54656ccf 100644 --- a/src/diffpy/morph/morph_helpers/transformrdftopdf.py +++ b/src/diffpy/morph/morph_helpers/transformrdftopdf.py @@ -57,7 +57,7 @@ def morph(self, x_morph, y_morph, x_target, y_target): self.y_morph_out = ( self.y_morph_in / self.x_morph_in + morph_baseline ) - self.y_morph_out[self.x_target_in == 0] = 0 + self.y_morph_out[self.x_morph_in == 0] = 0 return self.xyallout From 322b75e0450b120cf90b75afaee26e4570867769 Mon Sep 17 00:00:00 2001 From: Sparky <59151395+Sparks29032@users.noreply.github.com> Date: Thu, 10 Jul 2025 14:54:10 -0500 Subject: [PATCH 04/23] Restructure morphpy for easier maintainence (#235) --- news/restructure_morphpy.rst | 23 ++++++++ src/diffpy/morph/morphpy.py | 111 +++++++++++++++++++---------------- tests/test_morphpy.py | 48 ++++++++++++++- 3 files changed, 129 insertions(+), 53 deletions(-) create mode 100644 news/restructure_morphpy.rst diff --git a/news/restructure_morphpy.rst b/news/restructure_morphpy.rst new file mode 100644 index 00000000..401c25d2 --- /dev/null +++ b/news/restructure_morphpy.rst @@ -0,0 +1,23 @@ +**Added:** + +* + +**Changed:** + +* For diffpy.morph developers: both morphpy functions now have shared code in a separate function for easier maintenance. + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/src/diffpy/morph/morphpy.py b/src/diffpy/morph/morphpy.py index 945ad749..7240f9f8 100644 --- a/src/diffpy/morph/morphpy.py +++ b/src/diffpy/morph/morphpy.py @@ -19,6 +19,57 @@ def get_args(parser, params, kwargs): return opts, pargs +def __get_morph_opts__(parser, scale, stretch, smear, plot, **kwargs): + # Check for Python-specific options + python_morphs = ["funcy"] + pymorphs = {} + for pmorph in python_morphs: + if pmorph in kwargs: + pmorph_value = kwargs.pop(pmorph) + pymorphs.update({pmorph: pmorph_value}) + + # Special handling of parameters with dashes + kwargs_copy = kwargs.copy() + kwargs = {} + for key in kwargs_copy.keys(): + new_key = key + if "_" in key: + new_key = key.replace("_", "-") + kwargs.update({new_key: kwargs_copy[key]}) + + # Special handling of store_true and store_false parameters + opts_storing_values = [ + "verbose", + "pearson", + "addpearson", + "apply", + "reverse", + ] + opts_to_ignore = ["multiple-morphs", "multiple-targets"] + for opt in opts_storing_values: + if opt in kwargs: + # Remove if user sets false in params + if not kwargs[opt]: + kwargs.pop(opt) + for opt in opts_to_ignore: + if opt in kwargs: + kwargs.pop(opt) + + # Wrap the CLI + params = { + "scale": scale, + "stretch": stretch, + "smear": smear, + "noplot": True if not plot else None, + } + opts, _ = get_args(parser, params, kwargs) + + if not len(pymorphs) > 0: + pymorphs = None + + return opts, pymorphs + + # Take in file names as input. def morph( morph_file, @@ -58,38 +109,12 @@ def morph( Function after morph where morph_table[:,0] is the abscissa and morph_table[:,1] is the ordinate. """ - - # Check for Python-specific morphs - python_morphs = ["funcy"] - pymorphs = {} - for pmorph in python_morphs: - if pmorph in kwargs: - pmorph_value = kwargs.pop(pmorph) - pymorphs.update({pmorph: pmorph_value}) - - # Special handling of parameters with dashes - kwargs_copy = kwargs.copy() - kwargs = {} - for key in kwargs_copy.keys(): - new_key = key - if "_" in key: - new_key = key.replace("_", "-") - kwargs.update({new_key: kwargs_copy[key]}) - - # Wrap the CLI - parser = create_option_parser() - params = { - "scale": scale, - "stretch": stretch, - "smear": smear, - "noplot": True if not plot else None, - } - opts, _ = get_args(parser, params, kwargs) - pargs = [morph_file, target_file] + parser = create_option_parser() + opts, pymorphs = __get_morph_opts__( + parser, scale, stretch, smear, plot, **kwargs + ) - if not len(pymorphs) > 0: - pymorphs = None return single_morph( parser, opts, @@ -139,36 +164,18 @@ def morph_arrays( Function after morph where morph_table[:,0] is the abscissa and morph_table[:,1] is the ordinate. """ - # Check for Python-specific morphs - python_morphs = ["funcy"] - pymorphs = {} - for pmorph in python_morphs: - if pmorph in kwargs: - pmorph_value = kwargs.pop(pmorph) - pymorphs.update({pmorph: pmorph_value}) - - # Wrap the CLI - parser = create_option_parser() - params = { - "scale": scale, - "stretch": stretch, - "smear": smear, - "noplot": True if not plot else None, - } - opts, _ = get_args(parser, params, kwargs) - morph_table = np.array(morph_table) target_table = np.array(target_table) - x_morph = morph_table[:, 0] y_morph = morph_table[:, 1] x_target = target_table[:, 0] y_target = target_table[:, 1] - pargs = ["Morph", "Target", x_morph, y_morph, x_target, y_target] + parser = create_option_parser() + opts, pymorphs = __get_morph_opts__( + parser, scale, stretch, smear, plot, **kwargs + ) - if not len(pymorphs) > 0: - pymorphs = None return single_morph( parser, opts, diff --git a/tests/test_morphpy.py b/tests/test_morphpy.py index 0a288271..b599d9e4 100644 --- a/tests/test_morphpy.py +++ b/tests/test_morphpy.py @@ -6,7 +6,7 @@ import pytest from diffpy.morph.morphapp import create_option_parser, single_morph -from diffpy.morph.morphpy import morph, morph_arrays +from diffpy.morph.morphpy import __get_morph_opts__, morph, morph_arrays from diffpy.morph.tools import getRw thisfile = locals().get("__file__", "file.py") @@ -68,6 +68,52 @@ def setup_morph(self): ) return + def test_morph_opts(self, setup_morph): + kwargs = { + "verbose": False, + "pearson": False, + "addpearson": False, + "apply": False, + "reverse": False, + "multiple_morphs": False, + "multiple_targets": False, + } + kwargs_copy = kwargs.copy() + opts, _ = __get_morph_opts__( + self.parser, scale=1, stretch=0, smear=0, plot=False, **kwargs_copy + ) + # Special set true/false operations should be removed + # when their input value is False + for opt in kwargs: + if opt == "apply": + assert getattr(opts, "refine") + else: + assert getattr(opts, opt) is None or not getattr(opts, opt) + + kwargs = { + "verbose": True, + "pearson": True, + "addpearson": True, + "apply": True, + "reverse": True, + "multiple_morphs": True, + "multiple_targets": True, + } + kwargs_copy = kwargs.copy() + opts, _ = __get_morph_opts__( + self.parser, scale=1, stretch=0, smear=0, plot=False, **kwargs_copy + ) + for opt in kwargs: + if opt == "apply": + assert not getattr(opts, "refine") + # These options are not enabled in morphpy + elif opt == "multiple_morphs" or opt == "multiple_targets": + assert getattr(opts, opt) is None or not getattr(opts, opt) + # Special set true/false operations should NOT be removed + # when their input value is True + else: + assert getattr(opts, opt) + def test_morph(self, setup_morph): morph_results = {} morph_file = self.testfiles[0] From c4eba1e1ec87c149f750c4d815098a3dab00518f Mon Sep 17 00:00:00 2001 From: Sparky <59151395+Sparks29032@users.noreply.github.com> Date: Sat, 12 Jul 2025 10:18:13 -0500 Subject: [PATCH 05/23] Add option to save difference curve (#237) * Add save diff * Add documentation * Name change diff->get-diff --- docs/source/morphpy.rst | 8 +++- news/save_diff.rst | 23 +++++++++++ src/diffpy/morph/morph_io.py | 18 ++++----- src/diffpy/morph/morphapp.py | 54 ++++++++++++++++++------- src/diffpy/morph/morphpy.py | 2 + tests/test_morphio.py | 77 ++++++++++++++++++++++++++++++++++++ tests/test_morphpy.py | 2 + 7 files changed, 159 insertions(+), 25 deletions(-) create mode 100644 news/save_diff.rst diff --git a/docs/source/morphpy.rst b/docs/source/morphpy.rst index d3d3af57..34050fdf 100644 --- a/docs/source/morphpy.rst +++ b/docs/source/morphpy.rst @@ -38,7 +38,7 @@ Python Morphing Functions * ``morph_info`` contains all morphs as keys (e.g. ``"scale"``, ``"stretch"``, ``"smear"``) with the optimized morphing parameters found by ``diffpy.morph`` as values. ``morph_info`` also contains - the Rw and Pearson correlation coefficients found post-morphing. Try printing ``print(morph_info)`` + the Rw and Pearson correlation coefficients found post-morphing. Try printing ``print(morph_info)`` and compare the values stored in this dictionary to those given by the CLI output! * ``morph_table`` is a two-column array of the morphed function interpolated onto the grid of the target function (e.g. in our example, it returns the contents of `darkSub_rh20_C_01.gr` after @@ -74,6 +74,10 @@ General Parameters save: str or path Save the morphed function to a the file passed to save. Use '-' for stdout. +get_diff: bool + Return the difference function (morphed function minus target function) instead of + the morphed function (default). When save is enabled, the difference function + is saved instead of the morphed function. verbose: bool Print additional header details to saved files. These include details about the morph inputs and outputs. @@ -240,4 +244,4 @@ As you can see, the fitted scale and offset values match the ones used to generate the target (scale=20 & offset=0.8). This example shows how ``MorphFuncy`` can be used to fit and apply custom transformations. Now it's your turn to experiment with other custom functions that may be useful -for analyzing your data. +for analyzing your data. diff --git a/news/save_diff.rst b/news/save_diff.rst new file mode 100644 index 00000000..5ddb57fd --- /dev/null +++ b/news/save_diff.rst @@ -0,0 +1,23 @@ +**Added:** + +* There is now an option to save the difference curve. This is computed on the common interval between the two curves. + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/src/diffpy/morph/morph_io.py b/src/diffpy/morph/morph_io.py index 4433fc79..0c35a3f3 100644 --- a/src/diffpy/morph/morph_io.py +++ b/src/diffpy/morph/morph_io.py @@ -47,7 +47,7 @@ def single_morph_output( save_file Name of file to print to. If None (default) print to terminal. morph_file - Name of the morphed PDF file. Required when printing to a + Name of the morphed function file. Required when printing to a non-terminal file. param xy_out: list List of the form [x_morph_out, y_morph_out]. x_morph_out is a List of @@ -144,7 +144,7 @@ def single_morph_output( def create_morphs_directory(save_directory): - """Create a directory for saving multiple morphed PDFs. + """Create a directory for saving multiple morphed functions. Takes in a user-given path to a directory save_directory and create a subdirectory named Morphs. diffpy.morph will save all morphs into the @@ -183,7 +183,7 @@ def get_multisave_names(target_list: list, save_names_file=None, mm=False): Parameters ---------- target_list: list - Target (or Morph if mm enabled) PDFs used for each morph. + Target (or Morph if mm enabled) functions used for each morph. save_names_file Name of file to import save names dictionary from (default None). mm: bool @@ -192,8 +192,8 @@ def get_multisave_names(target_list: list, save_names_file=None, mm=False): Returns ------- dict - The names to save each morph as. Keys are the target PDF file names - used to produce that morph. + The names to save each morph as. Keys are the target function file + names used to produce that morph. """ # Dictionary storing save file names @@ -252,20 +252,20 @@ def multiple_morph_output( morph_results: dict Resulting data after morphing. target_files: list - PDF files that acted as targets to morphs. + Files that acted as targets to morphs. save_directory Name of directory to save morphs in. field Name of field if data was sorted by a particular field. Otherwise, leave blank. field_list: list - List of field values for each target PDF. + List of field values for each target function. Generated by diffpy.morph.tools.field_sort(). morph_file - Name of the morphed PDF file. + Name of the morphed function file. Required to give summary data after saving to a directory. target_directory - Name of the directory containing the target PDF files. + Name of the directory containing the target function files. Required to give summary data after saving to a directory. verbose: bool Print additional summary details when True (default False). diff --git a/src/diffpy/morph/morphapp.py b/src/diffpy/morph/morphapp.py index 43f71ce1..3647614a 100755 --- a/src/diffpy/morph/morphapp.py +++ b/src/diffpy/morph/morphapp.py @@ -81,14 +81,27 @@ def custom_error(self, msg): metavar="NAME", dest="slocation", help=( - "Save the manipulated PDF to a file named NAME. " + "Save the manipulated function to a file named NAME. " "Use '-' for stdout.\n" "When --multiple- is enabled, " - "save each manipulated PDF as a file in a directory named NAME;\n" - "you can specify names for each saved PDF file using " + "save each manipulated function as a file in a directory " + "named NAME;\n" + "you can specify names for each saved function file using " "--save-names-file." ), ) + parser.add_option( + "--diff", + "--get-diff", + dest="get_diff", + action="store_true", + help=( + "Save the difference curve rather than the manipulated function.\n" + "This is computed as manipulated function minus target function.\n" + "The difference curve is computed on the interval shared by the " + "grid of the objective and target function." + ), + ) parser.add_option( "-v", "--verbose", @@ -99,12 +112,12 @@ def custom_error(self, msg): parser.add_option( "--rmin", type="float", - help="Minimum r-value to use for PDF comparisons.", + help="Minimum r-value (abscissa) to use for function comparisons.", ) parser.add_option( "--rmax", type="float", - help="Maximum r-value to use for PDF comparisons.", + help="Maximum r-value (abscissa) to use for function comparisons.", ) parser.add_option( "--tolerance", @@ -419,9 +432,9 @@ def custom_error(self, msg): "using a serial file NAMESFILE. The format of NAMESFILE should be " "as follows: each target PDF is an entry in NAMESFILE. For each " "entry, there should be a key {__save_morph_as__} whose value " - "specifies the name to save the manipulated PDF as. An example " - ".json serial file is included in the tutorial directory " - "on the package GitHub repository." + "specifies the name to save the manipulated function as." + "An example .json serial file is included in the tutorial " + "directory on the package GitHub repository." ), ) group.add_option( @@ -492,10 +505,7 @@ def single_morph( smear_in = "None" hshift_in = "None" vshift_in = "None" - config = {} - config["rmin"] = opts.rmin - config["rmax"] = opts.rmax - config["rstep"] = None + config = {"rmin": opts.rmin, "rmax": opts.rmax, "rstep": None} if ( opts.rmin is not None and opts.rmax is not None @@ -708,13 +718,29 @@ def single_morph( morph_results.update({"Pearson": pcc}) # Print summary to terminal and save morph to file if requested + xy_save = [chain.x_morph_out, chain.y_morph_out] + if opts.get_diff is not None: + diff_chain = morphs.MorphChain( + {"rmin": None, "rmax": None, "rstep": None} + ) + diff_chain.append(morphs.MorphRGrid()) + diff_chain( + chain.x_morph_out, + chain.y_morph_out, + chain.x_target_in, + chain.y_target_in, + ) + xy_save = [ + diff_chain.x_morph_out, + diff_chain.y_morph_out - diff_chain.y_target_out, + ] try: io.single_morph_output( morph_inputs, morph_results, save_file=opts.slocation, morph_file=pargs[0], - xy_out=[chain.x_morph_out, chain.y_morph_out], + xy_out=xy_save, verbose=opts.verbose, stdout_flag=stdout_flag, ) @@ -753,7 +779,7 @@ def single_morph( # Return different things depending on whether it is python interfaced if python_wrap: morph_info = morph_results - morph_table = numpy.array([chain.x_morph_out, chain.y_morph_out]).T + morph_table = numpy.array(xy_save).T return morph_info, morph_table else: return morph_results diff --git a/src/diffpy/morph/morphpy.py b/src/diffpy/morph/morphpy.py index 7240f9f8..974573fa 100644 --- a/src/diffpy/morph/morphpy.py +++ b/src/diffpy/morph/morphpy.py @@ -44,6 +44,8 @@ def __get_morph_opts__(parser, scale, stretch, smear, plot, **kwargs): "addpearson", "apply", "reverse", + "diff", + "get-diff", ] opts_to_ignore = ["multiple-morphs", "multiple-targets"] for opt in opts_storing_values: diff --git a/tests/test_morphio.py b/tests/test_morphio.py index 08699dcb..c86b66c6 100644 --- a/tests/test_morphio.py +++ b/tests/test_morphio.py @@ -11,6 +11,7 @@ single_morph, ) from diffpy.morph.morphpy import morph_arrays +from diffpy.utils.parsers.loaddata import loadData # Support Python 2 try: @@ -64,6 +65,29 @@ def are_files_same(file1, file2): assert f1_arr[idx] == f2_arr[idx] +def are_diffs_right(file1, file2, diff_file): + """Assert that diff_file ordinate data is approximately file1 + ordinate data minus file2 ordinate data.""" + f1_data = loadData(file1) + f2_data = loadData(file2) + diff_data = loadData(diff_file) + + rmin = max(min(f1_data[:, 0]), min(f1_data[:, 1])) + rmax = min(max(f2_data[:, 0]), max(f2_data[:, 1])) + rnumsteps = max( + len(f1_data[:, 0][(rmin <= f1_data[:, 0]) & (f1_data[:, 0] <= rmax)]), + len(f2_data[:, 0][(rmin <= f2_data[:, 0]) & (f2_data[:, 0] <= rmax)]), + ) + + share_grid = np.linspace(rmin, rmax, rnumsteps) + f1_interp = np.interp(share_grid, f1_data[:, 0], f1_data[:, 1]) + f2_interp = np.interp(share_grid, f2_data[:, 0], f2_data[:, 1]) + diff_interp = np.interp(share_grid, diff_data[:, 0], diff_data[:, 1]) + + for idx, diff in enumerate(diff_interp): + assert np.isclose(f1_interp[idx] - f2_interp[idx], diff) + + class TestApp: @pytest.fixture def setup(self): @@ -165,6 +189,59 @@ def test_morph_outputs(self, setup, tmp_path): expected = filter(ignore_path, tf) are_files_same(actual, expected) + # Similar format as test_morph_outputs + def test_morph_diff_outputs(self, setup, tmp_path): + morph_file = self.testfiles[0] + target_file = self.testfiles[-1] + + # Save multiple diff morphs + tmp_diff = tmp_path.joinpath("diff") + tmp_diff_name = tmp_diff.resolve().as_posix() + + (opts, pargs) = self.parser.parse_args( + [ + "--multiple-targets", + "--sort-by", + "temperature", + "-s", + tmp_diff_name, + "-n", + "--save-names-file", + tssf, + "--diff", + ] + ) + pargs = [morph_file, testsequence_dir] + multiple_targets(self.parser, opts, pargs, stdout_flag=False) + + # Save a single diff morph + diff_name = "single_diff_morph.cgr" + diff_file = tmp_diff.joinpath(diff_name) + df_name = diff_file.resolve().as_posix() + (opts, pargs) = self.parser.parse_args(["-s", df_name, "-n", "--diff"]) + pargs = [morph_file, target_file] + single_morph(self.parser, opts, pargs, stdout_flag=False) + + # Check that the saved diff matches the morph minus target + # Morphs are saved in testdata/testsequence/testsaving/succinct + # Targets are stored in testdata/testsequence + + # Single morph diff + morphed_file = test_saving_succinct / diff_name.replace( + "diff", "succinct" + ) + are_diffs_right(morphed_file, target_file, diff_file) + + # Multiple morphs diff + diff_files = list((tmp_diff / "Morphs").iterdir()) + morphed_files = list((test_saving_succinct / "Morphs").iterdir()) + target_files = self.testfiles[1:] + diff_files.sort() + morphed_files.sort() + target_files.sort() + for idx, diff_file in enumerate(diff_files): + are_diffs_right(morphed_files[idx], target_files[idx], diff_file) + def test_morphsqueeze_outputs(self, setup, tmp_path): # The file squeeze_morph has a squeeze and stretch applied morph_file = testdata_dir / "squeeze_morph.cgr" diff --git a/tests/test_morphpy.py b/tests/test_morphpy.py index b599d9e4..643216e2 100644 --- a/tests/test_morphpy.py +++ b/tests/test_morphpy.py @@ -75,6 +75,7 @@ def test_morph_opts(self, setup_morph): "addpearson": False, "apply": False, "reverse": False, + "get_diff": False, "multiple_morphs": False, "multiple_targets": False, } @@ -96,6 +97,7 @@ def test_morph_opts(self, setup_morph): "addpearson": True, "apply": True, "reverse": True, + "get_diff": True, "multiple_morphs": True, "multiple_targets": True, } From 1f99f5edb548f82a470bab3c4ac488eb4b7ea0e1 Mon Sep 17 00:00:00 2001 From: Sparky <59151395+Sparks29032@users.noreply.github.com> Date: Sun, 13 Jul 2025 13:47:19 -0500 Subject: [PATCH 06/23] Move epsilon parameter in `MorphRGrid` (#242) * Move epsilon * news --- news/epsilon.rst | 23 +++++++++++++++++++++++ src/diffpy/morph/morphs/morphrgrid.py | 5 ++--- 2 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 news/epsilon.rst diff --git a/news/epsilon.rst b/news/epsilon.rst new file mode 100644 index 00000000..f19b1999 --- /dev/null +++ b/news/epsilon.rst @@ -0,0 +1,23 @@ +**Added:** + +* + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* MorphRGrid can now handle grids denser than 1e-08. Previously, it was possible that multiple points on the intersection range could be excluded. + +**Security:** + +* diff --git a/src/diffpy/morph/morphs/morphrgrid.py b/src/diffpy/morph/morphs/morphrgrid.py index fadce9f7..b6f3e540 100644 --- a/src/diffpy/morph/morphs/morphrgrid.py +++ b/src/diffpy/morph/morphs/morphrgrid.py @@ -19,9 +19,6 @@ from diffpy.morph.morphs.morph import LABEL_GR, LABEL_RA, Morph -# roundoff tolerance for selecting bounds on arrays. -epsilon = 1e-8 - class MorphRGrid(Morph): """Resample to specified r-grid. @@ -71,6 +68,8 @@ def morph(self, x_morph, y_morph, x_target, y_target): self.rmax = rmaxinc if self.rstep is None or self.rstep < rstepinc: self.rstep = rstepinc + # roundoff tolerance for selecting bounds on arrays. + epsilon = self.rstep / 2 # Make sure that rmax is exclusive self.x_morph_out = numpy.arange( self.rmin, self.rmax - epsilon, self.rstep From 979409957c4018540660bfb4b3b12dced069df23 Mon Sep 17 00:00:00 2001 From: Sparky <59151395+Sparks29032@users.noreply.github.com> Date: Tue, 15 Jul 2025 10:50:07 -0500 Subject: [PATCH 07/23] Change kwargs input from dict to any (#244) * Change kwargs input from dict to any * News * Explicitly list --- news/kwargs.rst | 23 +++++++++++++++++++++++ src/diffpy/morph/morphpy.py | 4 ++-- 2 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 news/kwargs.rst diff --git a/news/kwargs.rst b/news/kwargs.rst new file mode 100644 index 00000000..052ffdab --- /dev/null +++ b/news/kwargs.rst @@ -0,0 +1,23 @@ +**Added:** + +* + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* Docstring for kwargs updated from dict to Any since various input types from kwargs are possible (e.g. a list/tuple for squeeze, a tuple for funcy, a float for smear-pdf). This should remove the warning that inputs are not dicts. + +**Security:** + +* diff --git a/src/diffpy/morph/morphpy.py b/src/diffpy/morph/morphpy.py index 974573fa..80e792ff 100644 --- a/src/diffpy/morph/morphpy.py +++ b/src/diffpy/morph/morphpy.py @@ -100,7 +100,7 @@ def morph( plot: bool Show a plot of the morphed and target functions as well as the difference curve (default: False). - kwargs: dict + kwargs: str, float, list, tuple, bool See the diffpy.morph website for full list of options. Returns ------- @@ -155,7 +155,7 @@ def morph_arrays( plot: bool Show a plot of the morphed and target functions as well as the difference curve (default: False). - kwargs: dict + kwargs: str, float, list, tuple, bool See the diffpy.morph website for full list of options. Returns ------- From af8047246784607c8f10fdb7bb1b07fef6672c5a Mon Sep 17 00:00:00 2001 From: Sparky <59151395+Sparks29032@users.noreply.github.com> Date: Thu, 17 Jul 2025 12:11:45 -0500 Subject: [PATCH 08/23] Update exclude morphs option (#246) * Update exclude * News --- docs/source/morphpy.rst | 5 +++-- news/exclude.rst | 23 ++++++++++++++++++++ src/diffpy/morph/morphpy.py | 9 ++++++-- tests/test_morphpy.py | 42 +++++++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 news/exclude.rst diff --git a/docs/source/morphpy.rst b/docs/source/morphpy.rst index 34050fdf..8031b3bf 100644 --- a/docs/source/morphpy.rst +++ b/docs/source/morphpy.rst @@ -102,8 +102,9 @@ excluded with the apply or exclude parameters. apply: bool Apply morphs but do not refine. -exclude: str - Exclude a manipulation from refinement by name. +exclude: list of str + Exclude a manipulations from refinement by name + (e.g. exclude=["scale", "stretch"] excludes the scale and stretch morphs). scale: float Apply scale factor. This multiplies the function ordinate by scale. stretch: float diff --git a/news/exclude.rst b/news/exclude.rst new file mode 100644 index 00000000..dd0173bd --- /dev/null +++ b/news/exclude.rst @@ -0,0 +1,23 @@ +**Added:** + +* + +**Changed:** + +* Exclude option in morphpy now takes in a list of morphs to exclude rather than excluding a single morph. + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/src/diffpy/morph/morphpy.py b/src/diffpy/morph/morphpy.py index 80e792ff..f6238155 100644 --- a/src/diffpy/morph/morphpy.py +++ b/src/diffpy/morph/morphpy.py @@ -13,8 +13,13 @@ def get_args(parser, params, kwargs): inputs.append(f"{value}") for key, value in kwargs.items(): key = key.replace("_", "-") - inputs.append(f"--{key}") - inputs.append(f"{value}") + if key == "exclude": + for param in value: + inputs.append(f"--{key}") + inputs.append(f"{param}") + else: + inputs.append(f"--{key}") + inputs.append(f"{value}") (opts, pargs) = parser.parse_args(inputs) return opts, pargs diff --git a/tests/test_morphpy.py b/tests/test_morphpy.py index 643216e2..4308ca8b 100644 --- a/tests/test_morphpy.py +++ b/tests/test_morphpy.py @@ -148,6 +148,48 @@ class Chain: morphapp_params[key], abs=1e-08 ) + def test_exclude(self, setup_morph): + morph_file = self.testfiles[0] + target_file = self.testfiles[-1] + morph_info, _ = morph( + morph_file, + target_file, + scale=1, + stretch=0, + exclude=["scale", "stretch"], + sort_by="temperature", + ) + + # Nothing should be refined + assert pytest.approx(morph_info["scale"]) == 1 + assert pytest.approx(morph_info["stretch"]) == 0 + + morph_info, _ = morph( + morph_file, + target_file, + scale=1, + stretch=0, + exclude=["scale"], + sort_by="temperature", + ) + + # Stretch only should be refined + assert pytest.approx(morph_info["scale"]) == 1 + assert pytest.approx(morph_info["stretch"]) != 0 + + morph_info, _ = morph( + morph_file, + target_file, + scale=1, + stretch=0, + exclude=["stretch"], + sort_by="temperature", + ) + + # Scale only should be refined + assert pytest.approx(morph_info["scale"]) != 1 + assert pytest.approx(morph_info["stretch"]) == 0 + def test_morphpy(self, setup_morph): morph_results = {} morph_file = self.testfiles[0] From 6fdc0afbcd6b37f80288de375f9ee52d2ae0b438 Mon Sep 17 00:00:00 2001 From: Sparky <59151395+Sparks29032@users.noreply.github.com> Date: Thu, 31 Jul 2025 19:50:58 -0500 Subject: [PATCH 09/23] Morphfuncxy (#239) * Add morphfuncxy (scrappy) * Modifications * Add morph funcx (scrappy) * Add funcxy, funcx * Fix docstrings for morphfuncx,y * Update funcx * funcx docstring * News * Fix changing r-grid issue in refine * Add padding as well * Move epsilon * Morph rgrid change * Merge * RMS averaging * Final testing suite * Finish documentation * Doc formatting check --- docs/source/morphpy.rst | 329 +++++++++++++++++++++++-- news/morphfuncxy.rst | 24 ++ src/diffpy/morph/morph_api.py | 2 +- src/diffpy/morph/morph_io.py | 53 ++-- src/diffpy/morph/morphapp.py | 35 ++- src/diffpy/morph/morphpy.py | 2 +- src/diffpy/morph/morphs/__init__.py | 4 + src/diffpy/morph/morphs/morphfuncx.py | 87 +++++++ src/diffpy/morph/morphs/morphfuncxy.py | 85 +++++++ src/diffpy/morph/morphs/morphfuncy.py | 7 +- src/diffpy/morph/morphs/morphrgrid.py | 33 ++- src/diffpy/morph/refine.py | 30 ++- tests/test_morph_func.py | 2 +- tests/test_morphfuncx.py | 68 +++++ tests/test_morphfuncxy.py | 71 ++++++ tests/test_morphfuncy.py | 79 +++--- tests/test_morphpy.py | 62 +++++ tests/test_refine.py | 67 +++++ 18 files changed, 942 insertions(+), 98 deletions(-) create mode 100644 news/morphfuncxy.rst create mode 100644 src/diffpy/morph/morphs/morphfuncx.py create mode 100644 src/diffpy/morph/morphs/morphfuncxy.py create mode 100644 tests/test_morphfuncx.py create mode 100644 tests/test_morphfuncxy.py diff --git a/docs/source/morphpy.rst b/docs/source/morphpy.rst index 8031b3bf..1fd4f1c0 100644 --- a/docs/source/morphpy.rst +++ b/docs/source/morphpy.rst @@ -106,19 +106,26 @@ exclude: list of str Exclude a manipulations from refinement by name (e.g. exclude=["scale", "stretch"] excludes the scale and stretch morphs). scale: float - Apply scale factor. This multiplies the function ordinate by scale. + Apply scale factor. + + This multiplies the function ordinate by scale. stretch: float - Stretch function grid by a fraction stretch. Specifically, this multiplies the function grid by 1+stretch. + Stretch function grid by a fraction stretch. + + This multiplies the function grid by 1+stretch. squeeze: list of float Squeeze function grid given a polynomial - p(x) = squeeze[0]+squeeze[1]*x+...+squeeze[n]*x^n. n is dependent on the number + p(x) = squeeze[0]+squeeze[1]*x+...+squeeze[n]*x^n. + + n is dependent on the number of values in the user-inputted comma-separated list. The morph transforms the function grid from x to x+p(x). When this parameter is given, hshift is disabled. When n>1, stretch is disabled. smear: float - Smear the peaks with a Gaussian of width smear. This - is done by convolving the function with a Gaussian + Smear the peaks with a Gaussian of width smear. + + This is done by convolving the function with a Gaussian with standard deviation smear. If both smear and smear_pdf are used, only smear_pdf will be applied. @@ -129,6 +136,7 @@ smear_pdf: float applied. slope: float Slope of the baseline used in converting from PDF to RDF. + This is used with the option smear_pdf. The slope will be estimated if not provided. hshift: float @@ -139,39 +147,99 @@ qdamp: float Dampen PDF by a factor qdamp. radius: float Apply characteristic function of sphere with radius - given by parameter radius. If pradius is also specified, instead apply + given by parameter radius. + + If pradius is also specified, instead apply characteristic function of spheroid with equatorial radius radius and polar radius pradius. pradius: float Apply characteristic function of spheroid with equatorial radius given by above parameter radius and polar radius pradius. + If only pradius is specified, instead apply characteristic function of sphere with radius pradius. iradius: float Apply inverse characteristic function of sphere with - radius iradius. If ipradius is also specified, instead + radius iradius. + + If ipradius is also specified, instead apply inverse characteristic function of spheroid with equatorial radius iradius and polar radius ipradius. ipradius: float Apply inverse characteristic function of spheroid with equatorial radius iradius and polar radius ipradius. + If only ipradius is specified, instead apply inverse characteristic function of sphere with radius ipradius. funcy: tuple (function, dict) + Apply a function to the y-axis of the (two-column) data. + This morph applies the function funcy[0] with parameters given in funcy[1]. - The function funcy[0] must be a function of both the abscissa and ordinate - (e.g. take in at least two inputs with as many additional parameters as needed). + The function funcy[0] take in as parameters both the abscissa and ordinate + (i.e. take in at least two inputs with as many additional parameters as needed). + The y-axis values of the data are then replaced by the return value of funcy[0]. + For example, let's start with a two-column table with abscissa x and ordinate y. let us say we want to apply the function :: def linear(x, y, a, b, c): return a * x + b * y + c - This function takes in both the abscissa and ordinate on top of three additional - parameters a, b, and c. To use the funcy parameter with initial guesses - a=1.0, b=2.0, c=3.0, we would pass ``funcy=(linear, {a: 1.0, b: 2.0, c: 3.0})``. - For an example use-case, see the Python-Specific Morphs section below. + This example function above takes in both the abscissa and ordinate on top of + three additional parameters a, b, and c. + To use the funcy parameter with parameter values a=1.0, b=2.0, and c=3.0, + we would pass ``funcy=(linear, {"a": 1.0, "b": 2.0, "c": 3.0})``. + For an explicit example, see the Python-Specific Morphs section below. +funcx: tuple (function, dict) + Apply a function to the x-axis of the (two-column) data. + + This morph works fundamentally differently from the other grid morphs + (e.g. stretch and squeeze) as it directly modifies the grid of the + morph function. + The other morphs maintain the original grid and apply the morphs by interpolating + the function ***. + + This morph applies the function funcx[0] with parameters given in funcx[1]. + The function funcx[0] take in as parameters both the abscissa and ordinate + (i.e. take in at least two inputs with as many additional parameters as needed). + The x-axis values of the data are then replaced by the return value of funcx[0]. + Note that diffpy.morph requires the x-axis be monotonic increasing + (i.e. for i < j, x[i] < x[j]): as such, + if funcx[0] is not a monotonic increasing function of the provided x-axis data, + the error ``x must be a strictly increasing sequence`` will be thrown. + + For example, let's start with a two-column table with abscissa x and ordinate y. + let us say we want to apply the function :: + + def exponential(x, y, amp, decay): + return abs(amp) * (1 - 2**(-decay * x)) + This example function above takes in both the abscissa and ordinate on top of + three additional parameters amp and decay. + (Even though the ordinate is not used in the function, + it is still required that the function take in both acscissa and ordinate.) + To use the funcx parameter with parameter values amp=1.0 and decay=2.0, + we would pass ``funcx=(exponential, {"amp": 1.0, "decay:: 2.0})``. + For an explicit example, see the Python-Specific Morphs section below. +funcxy: tuple (function, dict) + Apply a function the (two-column) data. + + This morph applies the function funcxy[0] with parameters given in funcxy[1]. + The function funcxy[0] take in as parameters both the abscissa and ordinate + (i.e. take in at least two inputs with as many additional parameters as needed). + The two columns of the data are then replaced by the two return values of funcxy[0]. + + For example, let's start with a two-column table with abscissa x and ordinate y. + let us say we want to apply the function :: + + def shift(x, y, hshift, vshift): + return x + hshift, y + vshift + + This example function above takes in both the abscissa and ordinate on top of + two additional parameters hshift and vshift. + To use the funcy parameter with parameter values hshift=1.0 and vshift=2.0, + we would pass ``funcy=(shift, {"hshift": 1.0, "vshift": 1.0})``. + For an example use-case, see the Python-Specific Morphs section below. Python-Specific Morphs ====================== @@ -179,17 +247,25 @@ Python-Specific Morphs Some morphs in ``diffpy.morph`` are supported only in Python. Here, we detail how they are used and how to call them. -MorphFuncy: Applying custom functions +MorphFunc: Applying custom functions ------------------------------------- +In these tutorial, we walk through how to use the ``MorphFunc`` morphs +(``MorphFuncy``, ``MorphFuncx``, ``MorphFuncxy``) +with some example transformations. + +Unlike other morphs that can be run from the command line, +``MorphFunc`` moprhs require a Python function and is therefore +intended to be used through Python scripting. + +MorphFuncy: +^^^^^^^^^^^ + The ``MorphFuncy`` morph allows users to apply a custom Python function to the y-axis values of a dataset, enabling flexible and user-defined transformations. -In this tutorial, we walk through how to use ``MorphFuncy`` with an example -transformation. Unlike other morphs that can be run from the command line, -``MorphFuncy`` requires a Python function and is therefore intended to be used -through Python scripting. +Let's try out this morph! 1. Import the necessary modules into your Python script: @@ -230,7 +306,7 @@ through Python scripting. .. code-block:: python - morph_params, morph_table = morph_arrays(np.array([x_morph, y_morph]).T,np.array([x_target, y_target]).T, + morph_params, morph_table = morph_arrays(np.array([x_morph, y_morph]).T, np.array([x_target, y_target]).T, funcy=(linear_function,{'scale': 1.2, 'offset': 0.1})) 5. Extract the fitted parameters from the result: @@ -246,3 +322,218 @@ to generate the target (scale=20 & offset=0.8). This example shows how ``MorphFuncy`` can be used to fit and apply custom transformations. Now it's your turn to experiment with other custom functions that may be useful for analyzing your data. + +MorphFuncx: +^^^^^^^^^^^ + +The ``MorphFuncx`` morph allows users to apply a custom Python function +to the x-axis values of a dataset, similar to the ``MorphFuncy`` morph. + +One caveat to this morph is that the x-axis values must remain monotonic +increasing, so it is possible to run into errors when applying this morph. +For example, if your initial grid is ``[-1, 0, 1]``, and your function is +``lambda x, y: x**2``, the grid after the function is applied will be +``[1, 0, 1]``, which is no longer monotonic increasing. +In this case, the error ``x must be a strictly increasing sequence`` +will be thrown. + +Let's try out this morph! + + 1. Import the necessary modules into your Python script: + + .. code-block:: python + + from diffpy.morph.morphpy import morph_arrays + import numpy as np + + 2. Define a custom Python function to apply a transformation to the data. + The function must take ``x`` and ``y`` (1D arrays of the same length) + along with named parameters, and return a transformed ``x`` array of the + same length. Recall that this function must maintain the monotonic + increasing nature of the ``x`` array. + + For this example, we will use a simple exponential function transformation that + greatly modifies the input: + + .. code-block:: python + + def exp_function(x, y, scale, rate): + return np.abs(scale) * np.exp(np.abs(rate) * x) + + Notice that, though the function only uses the ``x`` input, + the function signature takes in both ``x`` and ``y``. + + 3. Like in the previous example, we will use a sine function for the morph + data and generate the target data by applying the decay transfomration + with a known scale and rate: + + .. code-block:: python + + x_morph = np.linspace(0, 10, 1001) + y_morph = np.sin(x_morph) + x_target = x_target = 20 * np.exp(0.8 * x_morph) + y_target = y_morph.copy() + + 4. Setup and run the morph using the ``morph_arrays(...)``. + ``morph_arrays`` expects the morph and target data as **2D arrays** in + *two-column* format ``[[x0, y0], [x1, y1], ...]``. This will apply + the user-defined function and refine the parameters to best align the + morph data with the target data. This includes both the transformation + parameters (our initial guess) and the transformation function itself: + + .. code-block:: python + + morph_params, morph_table = morph_arrays(np.array([x_morph, y_morph]).T, np.array([x_target, y_target]).T, + funcx=(decay_function, {'scale': 1.2, 'rate': 1.0})) + + 5. Extract the fitted parameters from the result: + + .. code-block:: python + + fitted_params = morph_params["funcx"] + print(f"Fitted scale: {fitted_params['scale']}") + print(f"Fitted rate: {fitted_params['rate']}") + +Again, we should see that the fitted scale and offset values match the ones used +to generate the target (scale=20 & rate=0.8). + +For fun, you can plot the original function to the morphed function to see +how much the + +MorphFuncxy: +^^^^^^^^^^^^ +The ``MorphFuncxy`` morph allows users to apply a custom Python function +to a dataset that modifies both the ``x`` and ``y`` column values. +This is equivalent to applying a ``MorphFuncx`` and ``MorphFuncy`` +simultaneously. + +This morph is useful when you want to apply operations that modify both +the grid and function value. A PDF-specific example includes computing +PDFs from 1D diffraction data (see paragraph at the end of this section). + +For this tutorial, we will go through two examples. One simple one +involving shifting a function in the ``x`` and ``y`` directions, and +another involving a Fourier transform. + + 1. Let's start by taking a simple ``sine`` function. + + .. code-block:: python + + import numpy as np + morph_x = np.linspace(0, 10, 101) + morph_y = np.sin(morph_x) + morph_table = np.array([morph_x, morph_y]).T + + 2. Then, let our target function be that same ``sine`` function shifted + to the right by ``0.3`` and up by ``0.7``. + + .. code-block:: python + + target_x = morph_x + 0.3 + target_y = morph_y + 0.7 + target_table = np.array([target_x, target_y]).T + + 3. While we could use the ``hshift`` and ``vshift`` morphs, + this would require us to refine over two separate morph + operations. We can instead perform these morphs simultaneously + by defining a function: + + .. code-block:: python + + def shift(x, y, hshift, vshift): + return x + hshift, y + vshift + + 4. Now, let's try finding the optimal shift parameters using the ``MorphFuncxy`` morph. + We can try an initial guess of ``hshift=0.0`` and ``vshift=0.0``. + + .. code-block:: python + + from diffpy.morph.morphpy import morph_arrays + initial_guesses = {"hshift": 0.0, "vshift": 0.0} + info, table = morph_arrays(morph_table, target_table, funcxy=(shift, initial_guesses)) + + 5. Finally, to see the refined ``hshift`` and ``vshift`` parameters, we extract them from ``info``. + + .. code-block:: python + + print(f"Refined hshift: {info["funcxy"]["hshift"]}") + print(f"Refined vshift: {info["funcxy"]["vshift"]}") + +Now for an example involving a Fourier transform. + + 1. Let's say you measured a signal of the form :math:`f(x)=\exp\{\cos(\pi x)\}`. + Unfortunately, your measurement was taken against a noisy sinusoidal + background of the form :math:`n(x)=A\sin(Bx)`, where ``A``, ``B`` are unknown. + For our example, let's say (unknown to us) that ``A=2`` and ``B=1.7``. + + .. code-block:: python + + import numpy as np + n = 201 + dx = 0.01 + measured_x = np.linspace(0, 2, n) + + def signal(x): + return np.exp(np.cos(np.pi * x)) + + def noise(x, A, B): + return A * np.sin(B * x) + + measured_f = signal(measured_x) + noise(measured_x, 2, 1.7) + morph_table = np.array([measured_x, measured_f]).T + + 2. Your colleague remembers they previously computed the Fourier transform + of the function and has sent that to you. + + .. code-block:: python + + # We only consider the region where the grid is positive for simplicity + target_x = np.fft.fftfreq(n, dx)[:n//2] + target_f = np.real(np.fft.fft(signal(measured_x))[:n//2]) + target_table = np.array([target_x, target_f]).T + + 3. We can now write a noise subtraction function that takes in our measured + signal and guesses for parameters ``A``, ``B``, and computes the Fourier + transform post-noise-subtraction. + + .. code-block:: python + + def noise_subtracted_ft(x, y, A, B): + n = 201 + dx = 0.01 + background_subtracted_y = y - noise(x, A, B) + + ft_x = np.fft.fftfreq(n, dx)[:n//2] + ft_f = np.real(np.fft.fft(background_subtracted_y)[:n//2]) + + return ft_x, ft_f + + 4. Finally, we can provide initial guesses of ``A=0`` and ``B=1`` to the + ``MorphFuncxy`` morph and see what refined values we get. + + .. code-block:: python + + from diffpy.morph.morphpy import morph_arrays + initial_guesses = {"A": 0, "B": 1} + info, table = morph_arrays(morph_table, target_table, funcxy=(background_subtracted_ft, initial_guesses)) + + 5. Print these values to see if they match with the true values of + of ``A=2.0`` and ``B=1.7``! + + .. code-block:: python + + print(f"Refined A: {info["funcxy"]["A"]}") + print(f"Refined B: {info["funcxy"]["B"]}") + +You can also use this morph to help find optimal parameters +(e.g. ``rpoly``, ``qmin``, ``qmax``, ``bgscale``) for computing +PDFs of materials with known structures. +One does this by setting the ``MorphFuncxy`` function to a PDF +computing function such as +`PDFgetx3 `_. +The input (morphed) 1D function should be the 1D diffraction data +one wishes to compute the PDF of and the target 1D function +can be the PDF of a target material with similar geometry. +More information about this will be released in the ``diffpy.morph`` +manuscript, and we plan to integrate this feature automatically into +``PDFgetx3`` soon. diff --git a/news/morphfuncxy.rst b/news/morphfuncxy.rst new file mode 100644 index 00000000..ef89e304 --- /dev/null +++ b/news/morphfuncxy.rst @@ -0,0 +1,24 @@ +**Added:** + +* morphfuncx added: apply a function to the grid of your morphed function; this function should maintain the monotonic increasing nature of the grid +* morphfuncxy added: apply a general function which can modify both the ordinate and abscissa; useful when applying fourier transform or integration functions + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/src/diffpy/morph/morph_api.py b/src/diffpy/morph/morph_api.py index f4b06a72..e13f4d0f 100644 --- a/src/diffpy/morph/morph_api.py +++ b/src/diffpy/morph/morph_api.py @@ -209,7 +209,7 @@ def morph( refpars.append("baselineslope") elif k == "funcy": morph_inst = morph_cls() - morph_inst.function = rv_cfg.get("function", None) + morph_inst.function = rv_cfg.get("funcy_function", None) if morph_inst.function is None: raise ValueError( "Must provide a 'function' when using 'parameters'" diff --git a/src/diffpy/morph/morph_io.py b/src/diffpy/morph/morph_io.py index 0c35a3f3..fd016afd 100644 --- a/src/diffpy/morph/morph_io.py +++ b/src/diffpy/morph/morph_io.py @@ -79,32 +79,41 @@ def single_morph_output( rw_pos + idx, (f"squeeze a{idx}", sq_dict[f"a{idx}"]) ) mr_copy = dict(morph_results_list) - funcy_function = None - if "function" in mr_copy: - funcy_function = mr_copy.pop("function") - print(funcy_function) - if "funcy" in mr_copy: - fy_dict = mr_copy.pop("funcy") - rw_pos = list(mr_copy.keys()).index("Rw") - morph_results_list = list(mr_copy.items()) - for idx, key in enumerate(fy_dict): - morph_results_list.insert( - rw_pos + idx, (f"funcy {key}", fy_dict[key]) - ) - mr_copy = dict(morph_results_list) + + # Handle special inputs (functional remove) + func_dicts = { + "funcxy": [None, None], + "funcx": [None, None], + "funcy": [None, None], + } + for func in func_dicts.keys(): + if f"{func}_function" in mr_copy: + func_dicts[func][0] = mr_copy.pop(f"{func}_function") + if func in mr_copy: + func_dicts[func][1] = mr_copy.pop(func) + rw_pos = list(mr_copy.keys()).index("Rw") + morph_results_list = list(mr_copy.items()) + for idx, key in enumerate(func_dicts[func][1]): + morph_results_list.insert( + rw_pos + idx, (f"{func} {key}", func_dicts[func][1][key]) + ) + mr_copy = dict(morph_results_list) + # Normal inputs morphs_out += "\n".join( f"# {key} = {mr_copy[key]:.6f}" for key in mr_copy.keys() ) - # Special inputs (functional) - if funcy_function is not None: - morphs_in += '# funcy function =\n"""\n' - f_code, _ = inspect.getsourcelines(funcy_function) - n_leading = len(f_code[0]) - len(f_code[0].lstrip()) - for idx, f_line in enumerate(f_code): - f_code[idx] = f_line[n_leading:] - morphs_in += "".join(f_code) - morphs_in += '"""\n' + + # Handle special inputs (functional add) + for func in func_dicts.keys(): + if func_dicts[func][0] is not None: + morphs_in += f'# {func} function =\n"""\n' + f_code, _ = inspect.getsourcelines(func_dicts[func][0]) + n_leading = len(f_code[0]) - len(f_code[0].lstrip()) + for idx, f_line in enumerate(f_code): + f_code[idx] = f_line[n_leading:] + morphs_in += "".join(f_code) + morphs_in += '"""\n' # Printing to terminal if stdout_flag: diff --git a/src/diffpy/morph/morphapp.py b/src/diffpy/morph/morphapp.py index 3647614a..9981e3a3 100755 --- a/src/diffpy/morph/morphapp.py +++ b/src/diffpy/morph/morphapp.py @@ -520,12 +520,26 @@ def single_morph( # Python-Specific Morphs if pymorphs is not None: - # funcy value is a tuple (function,{param_dict}) + # funcxy/funcx/funcy value is a tuple (function,{param_dict}) + if "funcxy" in pymorphs: + mfxy_function = pymorphs["funcxy"][0] + mfxy_params = pymorphs["funcxy"][1] + chain.append(morphs.MorphFuncxy()) + config["funcxy_function"] = mfxy_function + config["funcxy"] = mfxy_params + refpars.append("funcxy") + if "funcx" in pymorphs: + mfx_function = pymorphs["funcx"][0] + mfx_params = pymorphs["funcx"][1] + chain.append(morphs.MorphFuncx()) + config["funcx_function"] = mfx_function + config["funcx"] = mfx_params + refpars.append("funcx") if "funcy" in pymorphs: mfy_function = pymorphs["funcy"][0] mfy_params = pymorphs["funcy"][1] chain.append(morphs.MorphFuncy()) - config["function"] = mfy_function + config["funcy_function"] = mfy_function config["funcy"] = mfy_params refpars.append("funcy") @@ -692,6 +706,9 @@ def single_morph( # FOR FUTURE MAINTAINERS # Any new morph should have their input morph parameters updated here + # You should also update the IO in morph_io + # if you think there requires special handling + # Input morph parameters morph_inputs = { "scale": scale_in, @@ -705,11 +722,25 @@ def single_morph( for idx, _ in enumerate(squeeze_dict): morph_inputs.update({f"squeeze a{idx}": squeeze_dict[f"a{idx}"]}) if pymorphs is not None: + if "funcxy" in pymorphs: + for funcxy_param in pymorphs["funcxy"][1].keys(): + morph_inputs.update( + { + f"funcxy {funcxy_param}": pymorphs["funcxy"][1][ + funcxy_param + ] + } + ) if "funcy" in pymorphs: for funcy_param in pymorphs["funcy"][1].keys(): morph_inputs.update( {f"funcy {funcy_param}": pymorphs["funcy"][1][funcy_param]} ) + if "funcx" in pymorphs: + for funcy_param in pymorphs["funcx"][1].keys(): + morph_inputs.update( + {f"funcx {funcy_param}": pymorphs["funcx"][1][funcy_param]} + ) # Output morph parameters morph_results = dict(config.items()) diff --git a/src/diffpy/morph/morphpy.py b/src/diffpy/morph/morphpy.py index f6238155..bac6eee8 100644 --- a/src/diffpy/morph/morphpy.py +++ b/src/diffpy/morph/morphpy.py @@ -26,7 +26,7 @@ def get_args(parser, params, kwargs): def __get_morph_opts__(parser, scale, stretch, smear, plot, **kwargs): # Check for Python-specific options - python_morphs = ["funcy"] + python_morphs = ["funcy", "funcx", "funcxy"] pymorphs = {} for pmorph in python_morphs: if pmorph in kwargs: diff --git a/src/diffpy/morph/morphs/__init__.py b/src/diffpy/morph/morphs/__init__.py index a1cbdc88..e66f8e3d 100644 --- a/src/diffpy/morph/morphs/__init__.py +++ b/src/diffpy/morph/morphs/__init__.py @@ -17,6 +17,8 @@ from diffpy.morph.morphs.morph import Morph # noqa: F401 from diffpy.morph.morphs.morphchain import MorphChain # noqa: F401 +from diffpy.morph.morphs.morphfuncx import MorphFuncx +from diffpy.morph.morphs.morphfuncxy import MorphFuncxy from diffpy.morph.morphs.morphfuncy import MorphFuncy from diffpy.morph.morphs.morphishape import MorphISphere, MorphISpheroid from diffpy.morph.morphs.morphresolution import MorphResolutionDamping @@ -42,6 +44,8 @@ MorphShift, MorphSqueeze, MorphFuncy, + MorphFuncx, + MorphFuncxy, ] # End of file diff --git a/src/diffpy/morph/morphs/morphfuncx.py b/src/diffpy/morph/morphs/morphfuncx.py new file mode 100644 index 00000000..d2c6edc1 --- /dev/null +++ b/src/diffpy/morph/morphs/morphfuncx.py @@ -0,0 +1,87 @@ +"""Class MorphFuncx -- apply a user-supplied python function to the +x-axis.""" + +from diffpy.morph.morphs.morph import LABEL_GR, LABEL_RA, Morph + + +class MorphFuncx(Morph): + """Apply a custom function to the x-axis (grid) of the morph + function. + + General morph function that applies a user-supplied function to the + x-coordinates of morph data to make it align with a target. + + Notice: the function provided must preserve the monotonic + increase of the grid. + I.e. the function f applied on the grid x must ensure for all + indices i>> from diffpy.morph.morphs.morphfuncx import MorphFuncx + + Define or import the user-supplied transformation function: + + >>> import numpy as np + >>> def exp_function(x, y, scale, rate): + >>> return abs(scale) * np.exp(rate * x) + + Note that this transformation is monotonic increasing, so will preserve + the monotonic increasing nature of the provided grid. + + Provide initial guess for parameters: + + >>> parameters = {'scale': 1, 'rate': 1} + + Run the funcy morph given input morph array (x_morph, y_morph)and target + array (x_target, y_target): + + >>> morph = MorphFuncx() + >>> morph.funcx_function = exp_function + >>> morph.funcx = parameters + >>> x_morph_out, y_morph_out, x_target_out, y_target_out = + ... morph.morph(x_morph, y_morph, x_target, y_target) + + To access parameters from the morph instance: + + >>> x_morph_in = morph.x_morph_in + >>> y_morph_in = morph.y_morph_in + >>> x_target_in = morph.x_target_in + >>> y_target_in = morph.y_target_in + >>> parameters_out = morph.funcx + """ + + # Define input output types + summary = "Apply a Python function to the x-axis data" + xinlabel = LABEL_RA + yinlabel = LABEL_GR + xoutlabel = LABEL_RA + youtlabel = LABEL_GR + parnames = ["funcx_function", "funcx"] + + def morph(self, x_morph, y_morph, x_target, y_target): + """Apply the user-supplied Python function to the x-coordinates + of the morph data.""" + Morph.morph(self, x_morph, y_morph, x_target, y_target) + self.x_morph_out = self.funcx_function( + self.x_morph_in, self.y_morph_in, **self.funcx + ) + return self.xyallout diff --git a/src/diffpy/morph/morphs/morphfuncxy.py b/src/diffpy/morph/morphs/morphfuncxy.py new file mode 100644 index 00000000..1a802bc7 --- /dev/null +++ b/src/diffpy/morph/morphs/morphfuncxy.py @@ -0,0 +1,85 @@ +"""Class MorphFuncxy -- apply a user-supplied python function to both +the x and y axes.""" + +from diffpy.morph.morphs.morph import LABEL_GR, LABEL_RA, Morph + + +class MorphFuncxy(Morph): + """Apply a custom function to the morph function. + + General morph function that applies a user-supplied function to the + morph data to make it align with a target. + + This function may modify both the grid (x-axis) and function (y-axis) + of the morph data. + + The user-provided function must return a two-column 1D function. + + Configuration Variables + ----------------------- + function: callable + The user-supplied function that applies a transformation to the + grid (x-axis) and morph function (y-axis). + + parameters: dict + A dictionary of parameters to pass to the function. + + Returns + ------- + A tuple (x_morph_out, y_morph_out, x_target_out, y_target_out) + where the target values remain the same and the morph data is + transformed according to the user-specified function and parameters + The morphed data is returned on the same grid as the unmorphed data + + Example (EDIT) + ------- + Import the funcxy morph function: + + >>> from diffpy.morph.morphs.morphfuncxy import MorphFuncxy + + Define or import the user-supplied transformation function: + + >>> import numpy as np + >>> def shift_function(x, y, hshift, vshift): + >>> return x + hshift, y + vshift + + Provide initial guess for parameters: + + >>> parameters = {'hshift': 1, 'vshift': 1} + + Run the funcy morph given input morph array (x_morph, y_morph)and target + array (x_target, y_target): + + >>> morph = MorphFuncxy() + >>> morph.function = shift_function + >>> morph.funcy = parameters + >>> x_morph_out, y_morph_out, x_target_out, y_target_out = + ... morph.morph(x_morph, y_morph, x_target, y_target) + + To access parameters from the morph instance: + + >>> x_morph_in = morph.x_morph_in + >>> y_morph_in = morph.y_morph_in + >>> x_target_in = morph.x_target_in + >>> y_target_in = morph.y_target_in + >>> parameters_out = morph.funcxy + """ + + # Define input output types + summary = ( + "Apply a Python function to the data (y-axis) and data grid (x-axis)" + ) + xinlabel = LABEL_RA + yinlabel = LABEL_GR + xoutlabel = LABEL_RA + youtlabel = LABEL_GR + parnames = ["funcxy_function", "funcxy"] + + def morph(self, x_morph, y_morph, x_target, y_target): + """Apply the user-supplied Python function to the y-coordinates + of the morph data.""" + Morph.morph(self, x_morph, y_morph, x_target, y_target) + self.x_morph_out, self.y_morph_out = self.funcxy_function( + self.x_morph_in, self.y_morph_in, **self.funcxy + ) + return self.xyallout diff --git a/src/diffpy/morph/morphs/morphfuncy.py b/src/diffpy/morph/morphs/morphfuncy.py index 41716e7e..176a0241 100644 --- a/src/diffpy/morph/morphs/morphfuncy.py +++ b/src/diffpy/morph/morphs/morphfuncy.py @@ -34,6 +34,7 @@ class MorphFuncy(Morph): Define or import the user-supplied transformation function: + >>> import numpy as np >>> def sine_function(x, y, amplitude, frequency): >>> return amplitude * np.sin(frequency * x) * y @@ -45,7 +46,7 @@ class MorphFuncy(Morph): array (x_target, y_target): >>> morph = MorphFuncy() - >>> morph.function = sine_function + >>> morph.funcy_function = sine_function >>> morph.funcy = parameters >>> x_morph_out, y_morph_out, x_target_out, y_target_out = ... morph.morph(x_morph, y_morph, x_target, y_target) @@ -65,13 +66,13 @@ class MorphFuncy(Morph): yinlabel = LABEL_GR xoutlabel = LABEL_RA youtlabel = LABEL_GR - parnames = ["function", "funcy"] + parnames = ["funcy_function", "funcy"] def morph(self, x_morph, y_morph, x_target, y_target): """Apply the user-supplied Python function to the y-coordinates of the morph data.""" Morph.morph(self, x_morph, y_morph, x_target, y_target) - self.y_morph_out = self.function( + self.y_morph_out = self.funcy_function( self.x_morph_in, self.y_morph_in, **self.funcy ) return self.xyallout diff --git a/src/diffpy/morph/morphs/morphrgrid.py b/src/diffpy/morph/morphs/morphrgrid.py index b6f3e540..d0c6ce1b 100644 --- a/src/diffpy/morph/morphs/morphrgrid.py +++ b/src/diffpy/morph/morphs/morphrgrid.py @@ -51,22 +51,39 @@ class MorphRGrid(Morph): youtlabel = LABEL_GR parnames = ["rmin", "rmax", "rstep"] + # Define rmin rmax holders for adaptive x-grid refinement + # Without these, the program r-grid can only decrease in interval size + rmin_origin = None + rmax_origin = None + rstep_origin = None + def morph(self, x_morph, y_morph, x_target, y_target): """Resample arrays onto specified grid.""" + if self.rmin is not None: + self.rmin_origin = self.rmin + if self.rmax is not None: + self.rmax_origin = self.rmax + if self.rstep is not None: + self.rstep_origin = self.rstep + Morph.morph(self, x_morph, y_morph, x_target, y_target) - rmininc = max(self.x_target_in[0], self.x_morph_in[0]) - r_step_target = self.x_target_in[1] - self.x_target_in[0] - r_step_morph = self.x_morph_in[1] - self.x_morph_in[0] + rmininc = max(min(self.x_target_in), min(self.x_morph_in)) + r_step_target = (max(self.x_target_in) - min(self.x_target_in)) / ( + len(self.x_target_in) - 1 + ) + r_step_morph = (max(self.x_morph_in) - min(self.x_morph_in)) / ( + len(self.x_morph_in) - 1 + ) rstepinc = max(r_step_target, r_step_morph) rmaxinc = min( - self.x_target_in[-1] + r_step_target, - self.x_morph_in[-1] + r_step_morph, + max(self.x_target_in) + r_step_target, + max(self.x_morph_in) + r_step_morph, ) - if self.rmin is None or self.rmin < rmininc: + if self.rmin_origin is None or self.rmin_origin < rmininc: self.rmin = rmininc - if self.rmax is None or self.rmax > rmaxinc: + if self.rmax_origin is None or self.rmax_origin > rmaxinc: self.rmax = rmaxinc - if self.rstep is None or self.rstep < rstepinc: + if self.rstep_origin is None or self.rstep_origin < rstepinc: self.rstep = rstepinc # roundoff tolerance for selecting bounds on arrays. epsilon = self.rstep / 2 diff --git a/src/diffpy/morph/refine.py b/src/diffpy/morph/refine.py index c0c5eb60..688e06e0 100644 --- a/src/diffpy/morph/refine.py +++ b/src/diffpy/morph/refine.py @@ -15,7 +15,7 @@ """refine -- Refine a morph or morph chain """ -from numpy import concatenate, dot, exp, ones_like +from numpy import array, concatenate, dot, exp, ones_like from scipy.optimize import leastsq from scipy.stats import pearsonr @@ -54,6 +54,10 @@ def __init__( self.pars = [] self.residual = self._residual self.flat_to_grouped = {} + + # Padding required for the residual vector to ensure constant length + # across the entire morph process + self.res_length = None return def _update_chain(self, pvals): @@ -79,6 +83,26 @@ def _residual(self, pvals): self.x_morph, self.y_morph, self.x_target, self.y_target ) rvec = _y_target - _y_morph + + # If first time computing residual + if self.res_length is None: + self.res_length = len(rvec) + # Ensure residual length is constant + else: + # Padding + if len(rvec) < self.res_length: + diff_length = self.res_length - len(rvec) + rvec = list(rvec) + rvec.extend([0] * diff_length) + rvec = array(rvec) + # Removal + # For removal, pass the average RMS + # This is fast and easy to compute + # For sufficiently functions, this approximation becomes exact + elif len(rvec) > self.res_length: + avg_rms = sum(rvec**2) / len(rvec) + rvec = array([avg_rms for _ in range(self.res_length)]) + return rvec def _pearson(self, pvals): @@ -147,8 +171,8 @@ def refine(self, *args, **kw): sol, cov_sol, infodict, emesg, ier = leastsq( self.residual, - initial, - full_output=1, + array(initial), + full_output=True, ftol=self.tolerance, xtol=self.tolerance, ) diff --git a/tests/test_morph_func.py b/tests/test_morph_func.py index cb0442d1..2fba0a33 100644 --- a/tests/test_morph_func.py +++ b/tests/test_morph_func.py @@ -145,7 +145,7 @@ def linear_function(x, y, scale, offset): x_target = x_morph.copy() y_target = np.sin(x_target) * 2 * x_target + 0.4 cfg = morph_default_config(funcy={"scale": 1.2, "offset": 0.1}) - cfg["function"] = linear_function + cfg["funcy_function"] = linear_function morph_rv = morph(x_morph, y_morph, x_target, y_target, **cfg) morphed_cfg = morph_rv["morphed_config"] x_morph_out, y_morph_out, x_target_out, y_target_out = morph_rv[ diff --git a/tests/test_morphfuncx.py b/tests/test_morphfuncx.py new file mode 100644 index 00000000..48444db6 --- /dev/null +++ b/tests/test_morphfuncx.py @@ -0,0 +1,68 @@ +import numpy as np +import pytest + +from diffpy.morph.morphs.morphfuncx import MorphFuncx + + +def x_exponential_function(x, y, x_amplitude, x_rate): + return x_amplitude * np.exp(x_rate * x) + + +def x_linear_function(x, y, x_slope, x_intercept): + return x_slope * x + x_intercept + + +def x_cubic_function(x, y, x_amplitude, x_shift): + return x_amplitude * (x - x_shift) ** 3 + + +def x_arctan_function(x, y, x_amplitude, x_frequency): + return x_amplitude * np.arctan(x_frequency * x) + + +funcx_test_suite = [ + ( + x_exponential_function, + {"x_amplitude": 2, "x_rate": 5}, + lambda x, y: 2 * np.exp(5 * x), + ), + ( + x_linear_function, + {"x_slope": 5, "x_intercept": 0.1}, + lambda x, y: 5 * x + 0.1, + ), + ( + x_cubic_function, + {"x_amplitude": 2, "x_shift": 5}, + lambda x, y: 2 * (x - 5) ** 3, + ), + ( + x_arctan_function, + {"x_amplitude": 4, "x_frequency": 2}, + lambda x, y: 4 * np.arctan(2 * x), + ), +] + + +@pytest.mark.parametrize( + "function, parameters, expected_function", + funcx_test_suite, +) +def test_funcy(function, parameters, expected_function): + x_morph = np.linspace(0, 10, 101) + y_morph = np.sin(x_morph) + x_target = x_morph.copy() + y_target = y_morph.copy() + x_morph_expected = expected_function(x_morph, y_morph) + y_morph_expected = y_morph + morph = MorphFuncx() + morph.funcx_function = function + morph.funcx = parameters + x_morph_actual, y_morph_actual, x_target_actual, y_target_actual = ( + morph.morph(x_morph, y_morph, x_target, y_target) + ) + + assert np.allclose(y_morph_actual, y_morph_expected) + assert np.allclose(x_morph_actual, x_morph_expected) + assert np.allclose(x_target_actual, x_target) + assert np.allclose(y_target_actual, y_target) diff --git a/tests/test_morphfuncxy.py b/tests/test_morphfuncxy.py new file mode 100644 index 00000000..1de86874 --- /dev/null +++ b/tests/test_morphfuncxy.py @@ -0,0 +1,71 @@ +import numpy as np +import pytest + +from diffpy.morph.morphs.morphfuncxy import MorphFuncxy + +from .test_morphfuncx import funcx_test_suite +from .test_morphfuncy import funcy_test_suite + +funcxy_test_suite = [] +for entry_y in funcy_test_suite: + for entry_x in funcx_test_suite: + funcxy_test_suite.append( + ( + entry_x[0], + entry_y[0], + entry_x[1], + entry_y[1], + entry_x[2], + entry_y[2], + ) + ) + + +# FIXME: +@pytest.mark.parametrize( + "funcx_func, funcy_func, funcx_params, funcy_params, " + "funcx_lambda, funcy_lambda", + funcxy_test_suite, +) +def test_funcy( + funcx_func, + funcy_func, + funcx_params, + funcy_params, + funcx_lambda, + funcy_lambda, +): + x_morph = np.linspace(0, 10, 101) + y_morph = np.sin(x_morph) + x_target = x_morph.copy() + y_target = y_morph.copy() + x_morph_expected = funcx_lambda(x_morph, y_morph) + y_morph_expected = funcy_lambda(x_morph, y_morph) + + funcxy_params = {} + funcxy_params.update(funcx_params) + funcxy_params.update(funcy_params) + + def funcxy_func(x, y, **funcxy_params): + funcx_params = {} + funcy_params = {} + for param in funcxy_params.keys(): + if param[:2] == "x_": + funcx_params.update({param: funcxy_params[param]}) + elif param[:2] == "y_": + funcy_params.update({param: funcxy_params[param]}) + return funcx_func(x, y, **funcx_params), funcy_func( + x, y, **funcy_params + ) + + morph = MorphFuncxy() + morph.funcxy_function = funcxy_func + morph.funcxy = funcxy_params + x_morph_actual, y_morph_actual, x_target_actual, y_target_actual = ( + morph.morph(x_morph, y_morph, x_target, y_target) + ) + + assert np.allclose(y_morph_actual, y_morph_expected) + assert np.allclose(x_morph_actual, x_morph_expected) + assert np.allclose(x_target_actual, x_target) + assert np.allclose(y_target_actual, y_target) diff --git a/tests/test_morphfuncy.py b/tests/test_morphfuncy.py index 31749fd6..a7753260 100644 --- a/tests/test_morphfuncy.py +++ b/tests/test_morphfuncy.py @@ -4,55 +4,58 @@ from diffpy.morph.morphs.morphfuncy import MorphFuncy -def sine_function(x, y, amplitude, frequency): - return amplitude * np.sin(frequency * x) * y +def y_sine_function(x, y, y_amplitude, y_frequency): + return y_amplitude * np.sin(y_frequency * x) * y -def exponential_decay_function(x, y, amplitude, decay_rate): - return amplitude * np.exp(-decay_rate * x) * y +def y_exponential_decay_function(x, y, y_amplitude, y_decay_rate): + return y_amplitude * np.exp(-y_decay_rate * x) * y -def gaussian_function(x, y, amplitude, mean, sigma): - return amplitude * np.exp(-((x - mean) ** 2) / (2 * sigma**2)) * y +def y_gaussian_function(x, y, y_amplitude, y_mean, y_sigma): + return y_amplitude * np.exp(-((x - y_mean) ** 2) / (2 * y_sigma**2)) * y -def polynomial_function(x, y, a, b, c): - return (a * x**2 + b * x + c) * y +def y_polynomial_function(x, y, y_a, y_b, y_c): + return (y_a * x**2 + y_b * x + y_c) * y -def logarithmic_function(x, y, scale): - return scale * np.log(1 + x) * y +def y_logarithmic_function(x, y, y_scale): + return y_scale * np.log(1 + x) * y + + +funcy_test_suite = [ + ( + y_sine_function, + {"y_amplitude": 2, "y_frequency": 5}, + lambda x, y: 2 * np.sin(5 * x) * y, + ), + ( + y_exponential_decay_function, + {"y_amplitude": 5, "y_decay_rate": 0.1}, + lambda x, y: 5 * np.exp(-0.1 * x) * y, + ), + ( + y_gaussian_function, + {"y_amplitude": 1, "y_mean": 5, "y_sigma": 1}, + lambda x, y: np.exp(-((x - 5) ** 2) / (2 * 1**2)) * y, + ), + ( + y_polynomial_function, + {"y_a": 1, "y_b": 2, "y_c": 0}, + lambda x, y: (x**2 + 2 * x) * y, + ), + ( + y_logarithmic_function, + {"y_scale": 0.5}, + lambda x, y: 0.5 * np.log(1 + x) * y, + ), +] @pytest.mark.parametrize( "function, parameters, expected_function", - [ - ( - sine_function, - {"amplitude": 2, "frequency": 5}, - lambda x, y: 2 * np.sin(5 * x) * y, - ), - ( - exponential_decay_function, - {"amplitude": 5, "decay_rate": 0.1}, - lambda x, y: 5 * np.exp(-0.1 * x) * y, - ), - ( - gaussian_function, - {"amplitude": 1, "mean": 5, "sigma": 1}, - lambda x, y: np.exp(-((x - 5) ** 2) / (2 * 1**2)) * y, - ), - ( - polynomial_function, - {"a": 1, "b": 2, "c": 0}, - lambda x, y: (x**2 + 2 * x) * y, - ), - ( - logarithmic_function, - {"scale": 0.5}, - lambda x, y: 0.5 * np.log(1 + x) * y, - ), - ], + funcy_test_suite, ) def test_funcy(function, parameters, expected_function): x_morph = np.linspace(0, 10, 101) @@ -62,7 +65,7 @@ def test_funcy(function, parameters, expected_function): x_morph_expected = x_morph y_morph_expected = expected_function(x_morph, y_morph) morph = MorphFuncy() - morph.function = function + morph.funcy_function = function morph.funcy = parameters x_morph_actual, y_morph_actual, x_target_actual, y_target_actual = ( morph.morph(x_morph, y_morph, x_target, y_target) diff --git a/tests/test_morphpy.py b/tests/test_morphpy.py index 4308ca8b..621f844d 100644 --- a/tests/test_morphpy.py +++ b/tests/test_morphpy.py @@ -249,6 +249,68 @@ def gaussian_like_function(x, y, mu): assert pytest.approx(abs(morph_info["smear"])) == 4.0 assert pytest.approx(morph_info["funcy"]["mu"]) == 50.0 + # FIXME: + def test_morphfuncx(self, setup_morph): + def gaussian(x, mu, sigma): + return np.exp(-((x - mu) ** 2) / (2 * sigma**2)) / ( + sigma * np.sqrt(2 * np.pi) + ) + + def gaussian_like_function(x, y, mu): + return gaussian((x + y) / 2, mu, 3) + + morph_r = np.linspace(0, 100, 1001) + morph_gr = np.linspace(0, 100, 1001) + + target_r = np.linspace(0, 100, 1001) + target_gr = 0.5 * gaussian(target_r, 50, 5) + 0.05 + + morph_info, _ = morph_arrays( + np.array([morph_r, morph_gr]).T, + np.array([target_r, target_gr]).T, + scale=1, + smear=3.75, + vshift=0.01, + funcy=(gaussian_like_function, {"mu": 47.5}), + tolerance=1e-12, + ) + + assert pytest.approx(morph_info["scale"]) == 0.5 + assert pytest.approx(morph_info["vshift"]) == 0.05 + assert pytest.approx(abs(morph_info["smear"])) == 4.0 + assert pytest.approx(morph_info["funcy"]["mu"]) == 50.0 + + # FIXME: + def test_morphfuncxy(self, setup_morph): + def gaussian(x, mu, sigma): + return np.exp(-((x - mu) ** 2) / (2 * sigma**2)) / ( + sigma * np.sqrt(2 * np.pi) + ) + + def gaussian_like_function(x, y, mu): + return gaussian((x + y) / 2, mu, 3) + + morph_r = np.linspace(0, 100, 1001) + morph_gr = np.linspace(0, 100, 1001) + + target_r = np.linspace(0, 100, 1001) + target_gr = 0.5 * gaussian(target_r, 50, 5) + 0.05 + + morph_info, _ = morph_arrays( + np.array([morph_r, morph_gr]).T, + np.array([target_r, target_gr]).T, + scale=1, + smear=3.75, + vshift=0.01, + funcy=(gaussian_like_function, {"mu": 47.5}), + tolerance=1e-12, + ) + + assert pytest.approx(morph_info["scale"]) == 0.5 + assert pytest.approx(morph_info["vshift"]) == 0.05 + assert pytest.approx(abs(morph_info["smear"])) == 4.0 + assert pytest.approx(morph_info["funcy"]["mu"]) == 50.0 + def test_morphpy_outputs(self, tmp_path): r = np.linspace(0, 1, 11) gr = np.linspace(0, 1, 11) diff --git a/tests/test_refine.py b/tests/test_refine.py index d05a3db2..4cf1bc7b 100644 --- a/tests/test_refine.py +++ b/tests/test_refine.py @@ -9,6 +9,8 @@ from diffpy.morph.morph_helpers.transformpdftordf import TransformXtalPDFtoRDF from diffpy.morph.morph_helpers.transformrdftopdf import TransformXtalRDFtoPDF from diffpy.morph.morphs.morphchain import MorphChain +from diffpy.morph.morphs.morphfuncx import MorphFuncx +from diffpy.morph.morphs.morphrgrid import MorphRGrid from diffpy.morph.morphs.morphscale import MorphScale from diffpy.morph.morphs.morphsmear import MorphSmear from diffpy.morph.morphs.morphstretch import MorphStretch @@ -114,6 +116,71 @@ def test_refine_tolerance(self, setup): assert not pytest.approx(config["scale"], stol, stol) == 3.0 return + def test_refine_grid_change(self): + err = 1e-08 + + # First test what occurs when the grid overlap increases + # As we shift, the overlap number increases + # In this case, overlap goes from 41 -> 51 + exp_hshift = 1 + grid1 = numpy.linspace(0, 5, 51) + grid2 = numpy.linspace(0 + exp_hshift, 5 + exp_hshift, 51) + func1 = numpy.zeros(grid1.shape) + func1[(1 < grid1) & (grid1 < 4)] = 1 + func2 = numpy.zeros(grid2.shape) + func2[(1 + exp_hshift < grid2) & (grid2 < 4 + exp_hshift)] = 1 + + def shift(x, y, hshift): + return x + hshift + + config = { + "funcx_function": shift, + "funcx": {"hshift": 0}, + "rmin": 0, + "rmax": 7, + "rstep": 0.01, + } + + mfuncx = MorphFuncx(config) + mrgrid = MorphRGrid(config) + chain = MorphChain(config, mfuncx, mrgrid) + refiner = Refiner(chain, grid1, func1, grid2, func2) + refpars = ["funcx"] + res = refiner.refine(*refpars) + + assert res < err + + # Second test when the grid overlap decreases + # As we stretch, the grid spacing increases + # Thus, the overlap number decreases + # For this test, overlap goes from 12 -> 10 + grid1 = numpy.linspace(0, 4, 41) + grid2 = numpy.linspace(2, 4, 21) + func1 = numpy.zeros(grid1.shape) + func1[grid1 <= 2] = 1 + func1[2 < grid1] = 2 + func2 = numpy.zeros(grid2.shape) + 1 + + def stretch(x, y, stretch): + return x * (1 + stretch) + + config = { + "funcx_function": stretch, + "funcx": {"stretch": 0.7}, + "rmin": 0, + "rmax": 4, + "rstep": 0.01, + } + + mfuncx = MorphFuncx(config) + mrgrid = MorphRGrid(config) + chain = MorphChain(config, mfuncx, mrgrid) + refiner = Refiner(chain, grid1, func1, grid2, func2) + refpars = ["funcx"] + res = refiner.refine(*refpars) + + assert res < err + # End of class TestRefine From d8bb5acb127f0868741d6f0d31acf9d04f78bf67 Mon Sep 17 00:00:00 2001 From: Yuchen Xiao <112262226+ycexiao@users.noreply.github.com> Date: Tue, 19 Aug 2025 09:47:48 -0400 Subject: [PATCH 10/23] docs: fix rst markup (#247) --- docs/source/tutorials.rst | 10 +++++----- news/rst-markup.rst | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) create mode 100644 news/rst-markup.rst diff --git a/docs/source/tutorials.rst b/docs/source/tutorials.rst index 01ba3455..2fccec18 100644 --- a/docs/source/tutorials.rst +++ b/docs/source/tutorials.rst @@ -67,7 +67,7 @@ selected directory and plot resulting :math:`R_w` values from each morph. 4. Between 192K and 198K, the Rw has a sharp increase, indicating that we may have a phase change. To confirm, let us now apply morphs - onto `` SrFe2As2_150K.gr`` with all other files in + onto ``SrFe2As2_150K.gr`` with all other files in ``morphsequence`` as targets :: diffpy.morph --scale=1 --stretch=0 SrFe2As2_150K.gr . --multiple-targets --sort-by=temperature @@ -191,10 +191,10 @@ Currently, the supported nanoparticle shapes include: spheres and spheroids. * Within the ``additionalData`` directory, ``cd`` into the ``morphShape`` subdirectory. Inside, you will find a sample Ni bulk - material PDF ``Ni_bulk.gr``. This PDF is from `"Atomic Pair - Distribution Function Analysis: - A primer" `_. + material PDF ``Ni_bulk.gr``. This PDF is from + `"Atomic Pair Distribution Function Analysis: A primer" + `_. There are also multiple ``.cgr`` files with calculated Ni nanoparticle PDFs. * Let us apply various shape effect morphs on the bulk material to diff --git a/news/rst-markup.rst b/news/rst-markup.rst new file mode 100644 index 00000000..2ceaf1e2 --- /dev/null +++ b/news/rst-markup.rst @@ -0,0 +1,23 @@ +**Added:** + +* No news added: Fix rst markup in the documentation. + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* From 3908d63f9fefb99ddc8ab7c8cd94f7a22703f06e Mon Sep 17 00:00:00 2001 From: Yuchen Xiao <112262226+ycexiao@users.noreply.github.com> Date: Fri, 22 Aug 2025 21:24:41 -0400 Subject: [PATCH 11/23] raise `ValueError` if the the number of regression points is less than the number of parameters in `_residual` (#249) * raise `ValueError` if the the number of regression points is less than the number of parameters in `_residual` * test: add test for not enough shared grid points the bad case. * chore: fix typo * chore: more concise error message * chore: fix pre-commit * fix: use a custom error to stop `leatsq` * Revert "fix: use a custom error to stop `leatsq`" This reverts commit 91fa347f8e783bdb3be5423eabdfd7b234ff63ff. * chore: fix the code indentation. --- news/enough-regression-points.rst | 23 +++++++++++++ src/diffpy/morph/refine.py | 13 ++++++- tests/test_refine.py | 56 +++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 news/enough-regression-points.rst diff --git a/news/enough-regression-points.rst b/news/enough-regression-points.rst new file mode 100644 index 00000000..8ff9e7c6 --- /dev/null +++ b/news/enough-regression-points.rst @@ -0,0 +1,23 @@ +**Added:** + +* Raise ``ValueError`` if the number of shared grid points between morphed and target functions is less than the number of parameters. + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/src/diffpy/morph/refine.py b/src/diffpy/morph/refine.py index 688e06e0..8f1390d9 100644 --- a/src/diffpy/morph/refine.py +++ b/src/diffpy/morph/refine.py @@ -83,7 +83,15 @@ def _residual(self, pvals): self.x_morph, self.y_morph, self.x_target, self.y_target ) rvec = _y_target - _y_morph - + if len(rvec) < len(pvals): + raise ValueError( + f"\nNumber of parameters (currently {len(pvals)}) cannot " + "exceed the number of shared grid points " + f"(currently {len(rvec)}). " + "Please reduce the number of morphing parameters or " + "provide new morphing and target functions with more " + "shared grid points." + ) # If first time computing residual if self.res_length is None: self.res_length = len(rvec) @@ -145,6 +153,9 @@ def refine(self, *args, **kw): ------ ValueError Exception raised if a minimum cannot be found. + ValueError + If the number of shared grid points between morphed function and + target function is smaller than the number of parameters. """ self.pars = args or self.chain.config.keys() diff --git a/tests/test_refine.py b/tests/test_refine.py index 4cf1bc7b..76263bfa 100644 --- a/tests/test_refine.py +++ b/tests/test_refine.py @@ -181,6 +181,62 @@ def stretch(x, y, stretch): assert res < err + def test_refine_grid_bad(self, user_filesystem): + grid = numpy.arange(2) + func = numpy.sin(grid) + grid1, func1, grid2, func2 = grid, func, grid, func + config = { + "stretch": 0.005, + "scale": 1.0, + "smear": 0, + } + chain = MorphChain(config) + refiner = Refiner(chain, grid1, func1, grid2, func2) + refpars = ["stretch", "scale", "smear"] + expected_error_message = ( + "\nNumber of parameters (currently 3) cannot " + "exceed the number of shared grid points " + "(currently 2). " + "Please reduce the number of morphing parameters or " + "provide new morphing and target functions with more " + "shared grid points." + ) + with pytest.raises( + ValueError, + ) as error: + refiner.refine(*refpars) + actual_error_message = str(error.value) + assert actual_error_message == expected_error_message + + # call from command line + import subprocess + + data_dir_path = user_filesystem / "cwd_dir" + morph_file = data_dir_path / "morph_data" + morph_data_text = [ + str(grid1[i]) + " " + str(func1[i]) for i in range(len(grid1)) + ] + morph_data_text = "\n".join(morph_data_text) + morph_file.write_text(morph_data_text) + target_file = data_dir_path / "target_data" + target_data_text = [ + str(grid2[i]) + " " + str(func2[i]) for i in range(len(grid2)) + ] + target_data_text = "\n".join(target_data_text) + target_file.write_text(target_data_text) + run_cmd = ["diffpy.morph"] + for key, value in config.items(): + run_cmd.append(f"--{key}") + run_cmd.append(f"{value}") + run_cmd.extend([str(morph_file), str(target_file)]) + run_cmd.append("-n") + result = subprocess.run(run_cmd, capture_output=True, text=True) + expected_error_message = ( + "diffpy.morph: error: " + expected_error_message + ) + actual_error_message = result.stderr.strip() + assert actual_error_message == expected_error_message + # End of class TestRefine From 3c52108eedda5d8ad1bbc390755ca918c568aa60 Mon Sep 17 00:00:00 2001 From: Yuchen Xiao <112262226+ycexiao@users.noreply.github.com> Date: Tue, 26 Aug 2025 16:46:51 -0400 Subject: [PATCH 12/23] feat: add warning for extrapolation in `morphsqueeze` (#250) * feat: add warning for extrapolation in `morphsqueeze` * [pre-commit.ci] auto fixes from pre-commit hooks * chore: add news * test: add test for warning extrapolation in `morphsqueeze` * chore: update warning message for extrapolation in `morphsqueeze.py` * test: add CLI test for extrapolation warning --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- news/extrapolate-warning.rst | 23 +++++++ src/diffpy/morph/morphs/morphsqueeze.py | 32 ++++++++++ tests/test_morphsqueeze.py | 82 +++++++++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 news/extrapolate-warning.rst diff --git a/news/extrapolate-warning.rst b/news/extrapolate-warning.rst new file mode 100644 index 00000000..c669afc9 --- /dev/null +++ b/news/extrapolate-warning.rst @@ -0,0 +1,23 @@ +**Added:** + +* No news added: Add warning for extrapolation in morphsqueeze.py. + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/src/diffpy/morph/morphs/morphsqueeze.py b/src/diffpy/morph/morphs/morphsqueeze.py index bc0e4d49..d57957ac 100644 --- a/src/diffpy/morph/morphs/morphsqueeze.py +++ b/src/diffpy/morph/morphs/morphsqueeze.py @@ -1,6 +1,8 @@ """Class MorphSqueeze -- Apply a polynomial to squeeze the morph function.""" +import warnings + import numpy as np from numpy.polynomial import Polynomial from scipy.interpolate import CubicSpline @@ -8,6 +10,13 @@ from diffpy.morph.morphs.morph import LABEL_GR, LABEL_RA, Morph +def custom_formatwarning(msg, *args, **kwargs): + return f"{msg}\n" + + +warnings.formatwarning = custom_formatwarning + + class MorphSqueeze(Morph): """Squeeze the morph function. @@ -85,4 +94,27 @@ def morph(self, x_morph, y_morph, x_target, y_target): high_extrap = np.where(self.x_morph_in > x_squeezed[-1])[0] self.extrap_index_low = low_extrap[-1] if low_extrap.size else None self.extrap_index_high = high_extrap[0] if high_extrap.size else None + below_extrap = min(x_morph) < min(x_squeezed) + above_extrap = max(x_morph) > max(x_squeezed) + if below_extrap or above_extrap: + if not above_extrap: + wmsg = ( + "Warning: points with grid value below " + f"{min(x_squeezed)} will be extrapolated." + ) + elif not below_extrap: + wmsg = ( + "Warning: points with grid value above " + f"{max(x_squeezed)} will be extrapolated." + ) + else: + wmsg = ( + "Warning: points with grid value below " + f"{min(x_squeezed)} and above {max(x_squeezed)} will be " + "extrapolated." + ) + warnings.warn( + wmsg, + UserWarning, + ) return self.xyallout diff --git a/tests/test_morphsqueeze.py b/tests/test_morphsqueeze.py index e5ce2a56..cc741bc1 100644 --- a/tests/test_morphsqueeze.py +++ b/tests/test_morphsqueeze.py @@ -1,3 +1,5 @@ +import subprocess + import numpy as np import pytest from numpy.polynomial import Polynomial @@ -85,3 +87,83 @@ def test_morphsqueeze(x_morph, x_target, squeeze_coeffs): assert np.allclose(x_morph_actual, x_morph_expected) assert np.allclose(x_target_actual, x_target) assert np.allclose(y_target_actual, y_target) + + +@pytest.mark.parametrize( + "squeeze_coeffs, wmsg_gen", + [ + # extrapolate below + ( + {"a0": 0.01}, + lambda x: ( + "Warning: points with grid value below " + f"{x[0]} will be extrapolated." + ), + ), + # extrapolate above + ( + {"a0": -0.01}, + lambda x: ( + "Warning: points with grid value above " + f"{x[1]} will be extrapolated." + ), + ), + # extrapolate below and above + ( + {"a0": 0.01, "a1": -0.002}, + lambda x: ( + "Warning: points with grid value below " + f"{x[0]} and above {x[1]} will be " + "extrapolated." + ), + ), + ], +) +def test_morphsqueeze_extrapolate(user_filesystem, squeeze_coeffs, wmsg_gen): + x_morph = np.linspace(0, 10, 101) + y_morph = np.sin(x_morph) + x_target = x_morph + y_target = y_morph + morph = MorphSqueeze() + morph.squeeze = squeeze_coeffs + coeffs = [squeeze_coeffs[f"a{i}"] for i in range(len(squeeze_coeffs))] + squeeze_polynomial = Polynomial(coeffs) + x_squeezed = x_morph + squeeze_polynomial(x_morph) + with pytest.warns() as w: + x_morph_actual, y_morph_actual, x_target_actual, y_target_actual = ( + morph(x_morph, y_morph, x_target, y_target) + ) + assert len(w) == 1 + assert w[0].category is UserWarning + actual_wmsg = str(w[0].message) + expected_wmsg = wmsg_gen([min(x_squeezed), max(x_squeezed)]) + assert actual_wmsg == expected_wmsg + + # CLI test + morph_file, target_file = create_morph_data_file( + user_filesystem / "cwd_dir", x_morph, y_morph, x_target, y_target + ) + run_cmd = ["diffpy.morph"] + run_cmd.extend(["--squeeze=" + ",".join(map(str, coeffs))]) + run_cmd.extend([str(morph_file), str(target_file)]) + run_cmd.append("-n") + result = subprocess.run(run_cmd, capture_output=True, text=True) + assert expected_wmsg in result.stderr + + +def create_morph_data_file( + data_dir_path, x_morph, y_morph, x_target, y_target +): + morph_file = data_dir_path / "morph_data" + morph_data_text = [ + str(x_morph[i]) + " " + str(y_morph[i]) for i in range(len(x_morph)) + ] + morph_data_text = "\n".join(morph_data_text) + morph_file.write_text(morph_data_text) + target_file = data_dir_path / "target_data" + target_data_text = [ + str(x_target[i]) + " " + str(y_target[i]) for i in range(len(x_target)) + ] + target_data_text = "\n".join(target_data_text) + target_file.write_text(target_data_text) + return morph_file, target_file From fc50526a8721da12a5d7f45988a81105d92df2b0 Mon Sep 17 00:00:00 2001 From: Sparky <59151395+Sparks29032@users.noreply.github.com> Date: Fri, 29 Aug 2025 14:13:46 -0700 Subject: [PATCH 13/23] Fix diffpy.utils minimum version --- requirements/conda.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/conda.txt b/requirements/conda.txt index e67cf6e2..378eecb5 100644 --- a/requirements/conda.txt +++ b/requirements/conda.txt @@ -1,5 +1,5 @@ numpy scipy -diffpy.utils +diffpy.utils>=3.6.1 matplotlib-base bg-mpl-stylesheets From 9b4458196022c7553bdadc72e84a7b5ce91db177 Mon Sep 17 00:00:00 2001 From: Sparky <59151395+Sparks29032@users.noreply.github.com> Date: Tue, 2 Sep 2025 14:25:23 -0700 Subject: [PATCH 14/23] Remove subprocess calls from tests (#251) --- news/no-subprocess.rst | 23 +++++++++++++++++++++++ tests/test_morphsqueeze.py | 26 +++++++++++++++++--------- tests/test_refine.py | 21 ++++++++++----------- 3 files changed, 50 insertions(+), 20 deletions(-) create mode 100644 news/no-subprocess.rst diff --git a/news/no-subprocess.rst b/news/no-subprocess.rst new file mode 100644 index 00000000..90f37de5 --- /dev/null +++ b/news/no-subprocess.rst @@ -0,0 +1,23 @@ +**Added:** + +* + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* Removed subprocess calls in test functions. diff --git a/tests/test_morphsqueeze.py b/tests/test_morphsqueeze.py index cc741bc1..a4b2d6be 100644 --- a/tests/test_morphsqueeze.py +++ b/tests/test_morphsqueeze.py @@ -1,9 +1,8 @@ -import subprocess - import numpy as np import pytest from numpy.polynomial import Polynomial +from diffpy.morph.morphapp import create_option_parser, single_morph from diffpy.morph.morphs.morphsqueeze import MorphSqueeze squeeze_coeffs_dic = [ @@ -119,7 +118,9 @@ def test_morphsqueeze(x_morph, x_target, squeeze_coeffs): ), ], ) -def test_morphsqueeze_extrapolate(user_filesystem, squeeze_coeffs, wmsg_gen): +def test_morphsqueeze_extrapolate( + user_filesystem, capsys, squeeze_coeffs, wmsg_gen +): x_morph = np.linspace(0, 10, 101) y_morph = np.sin(x_morph) x_target = x_morph @@ -143,12 +144,19 @@ def test_morphsqueeze_extrapolate(user_filesystem, squeeze_coeffs, wmsg_gen): morph_file, target_file = create_morph_data_file( user_filesystem / "cwd_dir", x_morph, y_morph, x_target, y_target ) - run_cmd = ["diffpy.morph"] - run_cmd.extend(["--squeeze=" + ",".join(map(str, coeffs))]) - run_cmd.extend([str(morph_file), str(target_file)]) - run_cmd.append("-n") - result = subprocess.run(run_cmd, capture_output=True, text=True) - assert expected_wmsg in result.stderr + + parser = create_option_parser() + (opts, pargs) = parser.parse_args( + [ + "--squeeze", + ",".join(map(str, coeffs)), + f"{morph_file.as_posix()}", + f"{target_file.as_posix()}", + "-n", + ] + ) + with pytest.warns(UserWarning, match=expected_wmsg): + single_morph(parser, opts, pargs, stdout_flag=False) def create_morph_data_file( diff --git a/tests/test_refine.py b/tests/test_refine.py index 76263bfa..2c10c196 100644 --- a/tests/test_refine.py +++ b/tests/test_refine.py @@ -8,6 +8,7 @@ from diffpy.morph.morph_helpers.transformpdftordf import TransformXtalPDFtoRDF from diffpy.morph.morph_helpers.transformrdftopdf import TransformXtalRDFtoPDF +from diffpy.morph.morphapp import create_option_parser, single_morph from diffpy.morph.morphs.morphchain import MorphChain from diffpy.morph.morphs.morphfuncx import MorphFuncx from diffpy.morph.morphs.morphrgrid import MorphRGrid @@ -181,7 +182,7 @@ def stretch(x, y, stretch): assert res < err - def test_refine_grid_bad(self, user_filesystem): + def test_refine_grid_bad(self, user_filesystem, capsys): grid = numpy.arange(2) func = numpy.sin(grid) grid1, func1, grid2, func2 = grid, func, grid, func @@ -208,9 +209,7 @@ def test_refine_grid_bad(self, user_filesystem): actual_error_message = str(error.value) assert actual_error_message == expected_error_message - # call from command line - import subprocess - + # Test from command line data_dir_path = user_filesystem / "cwd_dir" morph_file = data_dir_path / "morph_data" morph_data_text = [ @@ -224,18 +223,18 @@ def test_refine_grid_bad(self, user_filesystem): ] target_data_text = "\n".join(target_data_text) target_file.write_text(target_data_text) - run_cmd = ["diffpy.morph"] + run_cmd = [] for key, value in config.items(): run_cmd.append(f"--{key}") run_cmd.append(f"{value}") run_cmd.extend([str(morph_file), str(target_file)]) run_cmd.append("-n") - result = subprocess.run(run_cmd, capture_output=True, text=True) - expected_error_message = ( - "diffpy.morph: error: " + expected_error_message - ) - actual_error_message = result.stderr.strip() - assert actual_error_message == expected_error_message + parser = create_option_parser() + (opts, pargs) = parser.parse_args(run_cmd) + with pytest.raises(SystemExit): + single_morph(parser, opts, pargs, stdout_flag=False) + _, err = capsys.readouterr() + assert expected_error_message in actual_error_message # End of class TestRefine From dce54cf83435df6169a32f827867c4cbaa4a5b02 Mon Sep 17 00:00:00 2001 From: Sparky <59151395+Sparks29032@users.noreply.github.com> Date: Tue, 2 Sep 2025 17:41:40 -0700 Subject: [PATCH 15/23] Reduce warnings (#252) --- news/reduce-warns.rst | 23 +++++++++++++ src/diffpy/morph/morph_io.py | 39 ++++++++++++++++++++++ src/diffpy/morph/morphapp.py | 7 +++- src/diffpy/morph/morphs/morphsqueeze.py | 44 ++++++------------------- tests/test_morphsqueeze.py | 13 +++++--- 5 files changed, 87 insertions(+), 39 deletions(-) create mode 100644 news/reduce-warns.rst diff --git a/news/reduce-warns.rst b/news/reduce-warns.rst new file mode 100644 index 00000000..d8c99b5a --- /dev/null +++ b/news/reduce-warns.rst @@ -0,0 +1,23 @@ +**Added:** + +* + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* Only give user extrapolation warning after refinement. + +**Security:** + +* diff --git a/src/diffpy/morph/morph_io.py b/src/diffpy/morph/morph_io.py index fd016afd..713b5729 100644 --- a/src/diffpy/morph/morph_io.py +++ b/src/diffpy/morph/morph_io.py @@ -18,6 +18,7 @@ import inspect import sys +import warnings from pathlib import Path import numpy @@ -26,6 +27,13 @@ from diffpy.morph import __save_morph_as__ +def custom_formatwarning(msg, *args, **kwargs): + return f"{msg}\n" + + +warnings.formatwarning = custom_formatwarning + + def single_morph_output( morph_inputs, morph_results, @@ -398,3 +406,34 @@ def tabulate_results(multiple_morph_results): } ) return tabulated_results + + +def handle_warnings(squeeze_morph): + if squeeze_morph is not None: + eil = squeeze_morph.extrap_index_low + eih = squeeze_morph.extrap_index_high + + if eil is not None or eih is not None: + if eih is None: + wmsg = ( + "Warning: points with grid value below " + f"{squeeze_morph.squeeze_cutoff_low} " + f"will be extrapolated." + ) + elif eil is None: + wmsg = ( + "Warning: points with grid value above " + f"{squeeze_morph.squeeze_cutoff_high} " + f"will be extrapolated." + ) + else: + wmsg = ( + "Warning: points with grid value below " + f"{squeeze_morph.squeeze_cutoff_low} and above " + f"{squeeze_morph.squeeze_cutoff_high} " + f"will be extrapolated." + ) + warnings.warn( + wmsg, + UserWarning, + ) diff --git a/src/diffpy/morph/morphapp.py b/src/diffpy/morph/morphapp.py index 9981e3a3..37a028ba 100755 --- a/src/diffpy/morph/morphapp.py +++ b/src/diffpy/morph/morphapp.py @@ -546,6 +546,7 @@ def single_morph( # Squeeze squeeze_poly_deg = -1 squeeze_dict_in = {} + squeeze_morph = None if opts.squeeze is not None: # Handles both list and csv input if ( @@ -570,7 +571,8 @@ def single_morph( except ValueError: parser.error(f"{coeff} could not be converted to float.") squeeze_poly_deg = len(squeeze_dict_in.keys()) - chain.append(morphs.MorphSqueeze()) + squeeze_morph = morphs.MorphSqueeze() + chain.append(squeeze_morph) config["squeeze"] = squeeze_dict_in # config["extrap_index_low"] = None # config["extrap_index_high"] = None @@ -696,6 +698,9 @@ def single_morph( else: chain(x_morph, y_morph, x_target, y_target) + # THROW ANY WARNINGS HERE + io.handle_warnings(squeeze_morph) + # Get Rw for the morph range rw = tools.getRw(chain) pcc = tools.get_pearson(chain) diff --git a/src/diffpy/morph/morphs/morphsqueeze.py b/src/diffpy/morph/morphs/morphsqueeze.py index d57957ac..401d3340 100644 --- a/src/diffpy/morph/morphs/morphsqueeze.py +++ b/src/diffpy/morph/morphs/morphsqueeze.py @@ -1,8 +1,6 @@ """Class MorphSqueeze -- Apply a polynomial to squeeze the morph function.""" -import warnings - import numpy as np from numpy.polynomial import Polynomial from scipy.interpolate import CubicSpline @@ -10,13 +8,6 @@ from diffpy.morph.morphs.morph import LABEL_GR, LABEL_RA, Morph -def custom_formatwarning(msg, *args, **kwargs): - return f"{msg}\n" - - -warnings.formatwarning = custom_formatwarning - - class MorphSqueeze(Morph): """Squeeze the morph function. @@ -75,6 +66,11 @@ class MorphSqueeze(Morph): # extrap_index_high: first index after interpolation region extrap_index_low = None extrap_index_high = None + squeeze_cutoff_low = None + squeeze_cutoff_high = None + + def __init__(self, config=None): + super().__init__(config) def morph(self, x_morph, y_morph, x_target, y_target): """Apply a polynomial to squeeze the morph function. @@ -87,34 +83,14 @@ def morph(self, x_morph, y_morph, x_target, y_target): coeffs = [self.squeeze[f"a{i}"] for i in range(len(self.squeeze))] squeeze_polynomial = Polynomial(coeffs) x_squeezed = self.x_morph_in + squeeze_polynomial(self.x_morph_in) + self.squeeze_cutoff_low = min(x_squeezed) + self.squeeze_cutoff_high = max(x_squeezed) self.y_morph_out = CubicSpline(x_squeezed, self.y_morph_in)( self.x_morph_in ) - low_extrap = np.where(self.x_morph_in < x_squeezed[0])[0] - high_extrap = np.where(self.x_morph_in > x_squeezed[-1])[0] + low_extrap = np.where(self.x_morph_in < self.squeeze_cutoff_low)[0] + high_extrap = np.where(self.x_morph_in > self.squeeze_cutoff_high)[0] self.extrap_index_low = low_extrap[-1] if low_extrap.size else None self.extrap_index_high = high_extrap[0] if high_extrap.size else None - below_extrap = min(x_morph) < min(x_squeezed) - above_extrap = max(x_morph) > max(x_squeezed) - if below_extrap or above_extrap: - if not above_extrap: - wmsg = ( - "Warning: points with grid value below " - f"{min(x_squeezed)} will be extrapolated." - ) - elif not below_extrap: - wmsg = ( - "Warning: points with grid value above " - f"{max(x_squeezed)} will be extrapolated." - ) - else: - wmsg = ( - "Warning: points with grid value below " - f"{min(x_squeezed)} and above {max(x_squeezed)} will be " - "extrapolated." - ) - warnings.warn( - wmsg, - UserWarning, - ) + return self.xyallout diff --git a/tests/test_morphsqueeze.py b/tests/test_morphsqueeze.py index a4b2d6be..6ab8cb06 100644 --- a/tests/test_morphsqueeze.py +++ b/tests/test_morphsqueeze.py @@ -2,6 +2,7 @@ import pytest from numpy.polynomial import Polynomial +import diffpy.morph.morphpy as morphpy from diffpy.morph.morphapp import create_option_parser, single_morph from diffpy.morph.morphs.morphsqueeze import MorphSqueeze @@ -123,16 +124,19 @@ def test_morphsqueeze_extrapolate( ): x_morph = np.linspace(0, 10, 101) y_morph = np.sin(x_morph) - x_target = x_morph - y_target = y_morph + x_target = x_morph.copy() + y_target = y_morph.copy() morph = MorphSqueeze() morph.squeeze = squeeze_coeffs coeffs = [squeeze_coeffs[f"a{i}"] for i in range(len(squeeze_coeffs))] squeeze_polynomial = Polynomial(coeffs) x_squeezed = x_morph + squeeze_polynomial(x_morph) with pytest.warns() as w: - x_morph_actual, y_morph_actual, x_target_actual, y_target_actual = ( - morph(x_morph, y_morph, x_target, y_target) + morphpy.morph_arrays( + np.array([x_morph, y_morph]).T, + np.array([x_target, y_target]).T, + squeeze=coeffs, + apply=True, ) assert len(w) == 1 assert w[0].category is UserWarning @@ -152,6 +156,7 @@ def test_morphsqueeze_extrapolate( ",".join(map(str, coeffs)), f"{morph_file.as_posix()}", f"{target_file.as_posix()}", + "--apply", "-n", ] ) From ad976f6d7d4e59d284ed5a92d4ce32488cd46248 Mon Sep 17 00:00:00 2001 From: Sparky <59151395+Sparks29032@users.noreply.github.com> Date: Fri, 12 Sep 2025 03:38:58 -0700 Subject: [PATCH 16/23] Update code of conduct (#254) * Style update * News * Update news item to reflect file name change --------- Co-authored-by: Simon Billinge --- CODE_OF_CONDUCT.rst => CODE-OF-CONDUCT.rst | 0 README.rst | 2 +- news/coc.rst | 23 ++++++++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) rename CODE_OF_CONDUCT.rst => CODE-OF-CONDUCT.rst (100%) create mode 100644 news/coc.rst diff --git a/CODE_OF_CONDUCT.rst b/CODE-OF-CONDUCT.rst similarity index 100% rename from CODE_OF_CONDUCT.rst rename to CODE-OF-CONDUCT.rst diff --git a/README.rst b/README.rst index 0260c281..87c60770 100644 --- a/README.rst +++ b/README.rst @@ -191,7 +191,7 @@ trying to commit again. Improvements and fixes are always appreciated. -Before contributing, please read our `Code of Conduct `_. +Before contributing, please read our `Code of Conduct `_. Contact ------- diff --git a/news/coc.rst b/news/coc.rst new file mode 100644 index 00000000..be44844e --- /dev/null +++ b/news/coc.rst @@ -0,0 +1,23 @@ +**Added:** + +* no news needed: file name change chore + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* From 2e5391d2443463b0123cc692952e289f26d3c10a Mon Sep 17 00:00:00 2001 From: Yuchen Xiao <112262226+ycexiao@users.noreply.github.com> Date: Sat, 13 Sep 2025 17:01:30 -0400 Subject: [PATCH 17/23] feat: add `set_extrapolation_info` function in `morph.Morph` (#255) * feat: add `checkExtrapolation` function in `morph.Morph` * refactor: rename the function to `set_extrapolation_info` --- news/extrap-warnings.rst | 23 ++++++++++ src/diffpy/morph/morph_io.py | 52 ++++++++++++---------- src/diffpy/morph/morphapp.py | 5 ++- src/diffpy/morph/morphs/morph.py | 35 +++++++++++++-- src/diffpy/morph/morphs/morphshift.py | 1 + src/diffpy/morph/morphs/morphsqueeze.py | 8 +--- tests/test_morphsqueeze.py | 59 ++++++++++++++----------- 7 files changed, 124 insertions(+), 59 deletions(-) create mode 100644 news/extrap-warnings.rst diff --git a/news/extrap-warnings.rst b/news/extrap-warnings.rst new file mode 100644 index 00000000..2ed88c6c --- /dev/null +++ b/news/extrap-warnings.rst @@ -0,0 +1,23 @@ +**Added:** + +* Enable ``diffpy.morph`` to detect extrapolation. + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/src/diffpy/morph/morph_io.py b/src/diffpy/morph/morph_io.py index 713b5729..84d5c15a 100644 --- a/src/diffpy/morph/morph_io.py +++ b/src/diffpy/morph/morph_io.py @@ -410,29 +410,35 @@ def tabulate_results(multiple_morph_results): def handle_warnings(squeeze_morph): if squeeze_morph is not None: - eil = squeeze_morph.extrap_index_low - eih = squeeze_morph.extrap_index_high - - if eil is not None or eih is not None: - if eih is None: - wmsg = ( - "Warning: points with grid value below " - f"{squeeze_morph.squeeze_cutoff_low} " - f"will be extrapolated." - ) - elif eil is None: - wmsg = ( - "Warning: points with grid value above " - f"{squeeze_morph.squeeze_cutoff_high} " - f"will be extrapolated." - ) - else: - wmsg = ( - "Warning: points with grid value below " - f"{squeeze_morph.squeeze_cutoff_low} and above " - f"{squeeze_morph.squeeze_cutoff_high} " - f"will be extrapolated." - ) + extrapolation_info = squeeze_morph.extrapolation_info + is_extrap_low = extrapolation_info["is_extrap_low"] + is_extrap_high = extrapolation_info["is_extrap_high"] + cutoff_low = extrapolation_info["cutoff_low"] + cutoff_high = extrapolation_info["cutoff_high"] + + if is_extrap_low and is_extrap_high: + wmsg = ( + "Warning: points with grid value below " + f"{cutoff_low} and above " + f"{cutoff_high} " + f"are extrapolated." + ) + elif is_extrap_low: + wmsg = ( + "Warning: points with grid value below " + f"{cutoff_low} " + f"are extrapolated." + ) + elif is_extrap_high: + wmsg = ( + "Warning: points with grid value above " + f"{cutoff_high} " + f"are extrapolated." + ) + else: + wmsg = None + + if wmsg: warnings.warn( wmsg, UserWarning, diff --git a/src/diffpy/morph/morphapp.py b/src/diffpy/morph/morphapp.py index 37a028ba..27799d0f 100755 --- a/src/diffpy/morph/morphapp.py +++ b/src/diffpy/morph/morphapp.py @@ -610,10 +610,12 @@ def single_morph( config["smear"] = smear_in # Shift # Only enable hshift is squeeze is not enabled + shift_morph = None if ( opts.hshift is not None and squeeze_poly_deg < 0 ) or opts.vshift is not None: - chain.append(morphs.MorphShift()) + shift_morph = morphs.MorphShift() + chain.append(shift_morph) if opts.hshift is not None and squeeze_poly_deg < 0: hshift_in = opts.hshift config["hshift"] = hshift_in @@ -700,6 +702,7 @@ def single_morph( # THROW ANY WARNINGS HERE io.handle_warnings(squeeze_morph) + io.handle_warnings(shift_morph) # Get Rw for the morph range rw = tools.getRw(chain) diff --git a/src/diffpy/morph/morphs/morph.py b/src/diffpy/morph/morphs/morph.py index 5f6c2ccc..7ffccd15 100644 --- a/src/diffpy/morph/morphs/morph.py +++ b/src/diffpy/morph/morphs/morph.py @@ -12,9 +12,8 @@ # See LICENSE.txt for license information. # ############################################################################## -"""Morph -- base class for defining a morph. -""" - +"""Morph -- base class for defining a morph.""" +import numpy LABEL_RA = "r (A)" # r-grid LABEL_GR = "G (1/A^2)" # PDF G(r) @@ -246,6 +245,36 @@ def plotOutputs(self, xylabels=True, **plotargs): ylabel(self.youtlabel) return rv + def set_extrapolation_info(self, x_true, x_extrapolate): + """Set extrapolation information of the concerned morphing + process. + + Parameters + ---------- + x_true : array + original x values + x_extrapolate : array + x values after a morphing process + """ + + cutoff_low = min(x_true) + extrap_low_x = numpy.where(x_extrapolate < cutoff_low)[0] + is_extrap_low = False if len(extrap_low_x) == 0 else True + cutoff_high = max(x_true) + extrap_high_x = numpy.where(x_extrapolate > cutoff_high)[0] + is_extrap_high = False if len(extrap_high_x) == 0 else True + extrap_index_low = extrap_low_x[-1] if is_extrap_low else 0 + extrap_index_high = extrap_high_x[0] if is_extrap_high else -1 + extrapolation_info = { + "is_extrap_low": is_extrap_low, + "cutoff_low": cutoff_low, + "extrap_index_low": extrap_index_low, + "is_extrap_high": is_extrap_high, + "cutoff_high": cutoff_high, + "extrap_index_high": extrap_index_high, + } + self.extrapolation_info = extrapolation_info + def __getattr__(self, name): """Obtain the value from self.config, when normal lookup fails. diff --git a/src/diffpy/morph/morphs/morphshift.py b/src/diffpy/morph/morphs/morphshift.py index b4f6c9f8..abe5f063 100644 --- a/src/diffpy/morph/morphs/morphshift.py +++ b/src/diffpy/morph/morphs/morphshift.py @@ -57,6 +57,7 @@ def morph(self, x_morph, y_morph, x_target, y_target): r = self.x_morph_in - hshift self.y_morph_out = numpy.interp(r, self.x_morph_in, self.y_morph_in) self.y_morph_out += vshift + self.set_extrapolation_info(self.x_morph_in, r) return self.xyallout diff --git a/src/diffpy/morph/morphs/morphsqueeze.py b/src/diffpy/morph/morphs/morphsqueeze.py index 401d3340..3d0250da 100644 --- a/src/diffpy/morph/morphs/morphsqueeze.py +++ b/src/diffpy/morph/morphs/morphsqueeze.py @@ -1,7 +1,6 @@ """Class MorphSqueeze -- Apply a polynomial to squeeze the morph function.""" -import numpy as np from numpy.polynomial import Polynomial from scipy.interpolate import CubicSpline @@ -83,14 +82,9 @@ def morph(self, x_morph, y_morph, x_target, y_target): coeffs = [self.squeeze[f"a{i}"] for i in range(len(self.squeeze))] squeeze_polynomial = Polynomial(coeffs) x_squeezed = self.x_morph_in + squeeze_polynomial(self.x_morph_in) - self.squeeze_cutoff_low = min(x_squeezed) - self.squeeze_cutoff_high = max(x_squeezed) self.y_morph_out = CubicSpline(x_squeezed, self.y_morph_in)( self.x_morph_in ) - low_extrap = np.where(self.x_morph_in < self.squeeze_cutoff_low)[0] - high_extrap = np.where(self.x_morph_in > self.squeeze_cutoff_high)[0] - self.extrap_index_low = low_extrap[-1] if low_extrap.size else None - self.extrap_index_high = high_extrap[0] if high_extrap.size else None + self.set_extrapolation_info(x_squeezed, self.x_morph_in) return self.xyallout diff --git a/tests/test_morphsqueeze.py b/tests/test_morphsqueeze.py index 6ab8cb06..79130238 100644 --- a/tests/test_morphsqueeze.py +++ b/tests/test_morphsqueeze.py @@ -46,47 +46,56 @@ @pytest.mark.parametrize("squeeze_coeffs", squeeze_coeffs_dic) def test_morphsqueeze(x_morph, x_target, squeeze_coeffs): y_target = np.sin(x_target) + y_morph = np.sin(x_morph) + # expected output + y_morph_expected = y_morph + x_morph_expected = x_morph + x_target_expected = x_target + y_target_expected = y_target + # actual output coeffs = [squeeze_coeffs[f"a{i}"] for i in range(len(squeeze_coeffs))] squeeze_polynomial = Polynomial(coeffs) x_squeezed = x_morph + squeeze_polynomial(x_morph) y_morph = np.sin(x_squeezed) - low_extrap = np.where(x_morph < x_squeezed[0])[0] - high_extrap = np.where(x_morph > x_squeezed[-1])[0] - extrap_index_low_expected = low_extrap[-1] if low_extrap.size else None - extrap_index_high_expected = high_extrap[0] if high_extrap.size else None - x_morph_expected = x_morph - y_morph_expected = np.sin(x_morph) morph = MorphSqueeze() morph.squeeze = squeeze_coeffs x_morph_actual, y_morph_actual, x_target_actual, y_target_actual = morph( x_morph, y_morph, x_target, y_target ) - extrap_index_low = morph.extrap_index_low - extrap_index_high = morph.extrap_index_high - if extrap_index_low is None: - extrap_index_low = 0 - elif extrap_index_high is None: - extrap_index_high = -1 + + extrap_low = np.where(x_morph < min(x_squeezed))[0] + extrap_high = np.where(x_morph > max(x_squeezed))[0] + extrap_index_low_expected = extrap_low[-1] if extrap_low.size else 0 + extrap_index_high_expected = extrap_high[0] if extrap_high.size else -1 + + extrapolation_info = morph.extrapolation_info + extrap_index_low_actual = extrapolation_info["extrap_index_low"] + extrap_index_high_actual = extrapolation_info["extrap_index_high"] + assert np.allclose( - y_morph_actual[extrap_index_low + 1 : extrap_index_high], - y_morph_expected[extrap_index_low + 1 : extrap_index_high], + y_morph_actual[ + extrap_index_low_expected + 1 : extrap_index_high_expected + ], + y_morph_expected[ + extrap_index_low_expected + 1 : extrap_index_high_expected + ], atol=1e-6, ) assert np.allclose( - y_morph_actual[:extrap_index_low], - y_morph_expected[:extrap_index_low], + y_morph_actual[:extrap_index_low_expected], + y_morph_expected[:extrap_index_low_expected], atol=1e-3, ) assert np.allclose( - y_morph_actual[extrap_index_high:], - y_morph_expected[extrap_index_high:], + y_morph_actual[extrap_index_high_expected:], + y_morph_expected[extrap_index_high_expected:], atol=1e-3, ) - assert morph.extrap_index_low == extrap_index_low_expected - assert morph.extrap_index_high == extrap_index_high_expected assert np.allclose(x_morph_actual, x_morph_expected) - assert np.allclose(x_target_actual, x_target) - assert np.allclose(y_target_actual, y_target) + assert np.allclose(x_target_actual, x_target_expected) + assert np.allclose(y_target_actual, y_target_expected) + assert extrap_index_low_actual == extrap_index_low_expected + assert extrap_index_high_actual == extrap_index_high_expected @pytest.mark.parametrize( @@ -97,7 +106,7 @@ def test_morphsqueeze(x_morph, x_target, squeeze_coeffs): {"a0": 0.01}, lambda x: ( "Warning: points with grid value below " - f"{x[0]} will be extrapolated." + f"{x[0]} are extrapolated." ), ), # extrapolate above @@ -105,7 +114,7 @@ def test_morphsqueeze(x_morph, x_target, squeeze_coeffs): {"a0": -0.01}, lambda x: ( "Warning: points with grid value above " - f"{x[1]} will be extrapolated." + f"{x[1]} are extrapolated." ), ), # extrapolate below and above @@ -113,7 +122,7 @@ def test_morphsqueeze(x_morph, x_target, squeeze_coeffs): {"a0": 0.01, "a1": -0.002}, lambda x: ( "Warning: points with grid value below " - f"{x[0]} and above {x[1]} will be " + f"{x[0]} and above {x[1]} are " "extrapolated." ), ), From ddfa6feafbffd4ca6438d097af54410ea61e3f64 Mon Sep 17 00:00:00 2001 From: Yuchen Xiao <112262226+ycexiao@users.noreply.github.com> Date: Fri, 19 Sep 2025 14:40:44 -0400 Subject: [PATCH 18/23] feat: detect extrapolation for `morphstretch` (#258) * feat: detect extrapolation for `morphstretch` * fix: set `stretch_morph` to None when it is excluded * chore: add extension to temporary morph file --- news/stretch-extrapolation.rst | 23 ++++++++++ src/diffpy/morph/morphapp.py | 7 ++- src/diffpy/morph/morphs/morphstretch.py | 1 + tests/helper.py | 16 +++++++ tests/test_morphshift.py | 61 +++++++++++++++++++++++++ tests/test_morphsqueeze.py | 23 +--------- tests/test_morphstretch.py | 61 +++++++++++++++++++++++++ 7 files changed, 170 insertions(+), 22 deletions(-) create mode 100644 news/stretch-extrapolation.rst create mode 100644 tests/helper.py diff --git a/news/stretch-extrapolation.rst b/news/stretch-extrapolation.rst new file mode 100644 index 00000000..1992487c --- /dev/null +++ b/news/stretch-extrapolation.rst @@ -0,0 +1,23 @@ +**Added:** + +* No news added: Add warning for extrapolation in morphstretch and tests for extrapolations. + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/src/diffpy/morph/morphapp.py b/src/diffpy/morph/morphapp.py index 27799d0f..43976984 100755 --- a/src/diffpy/morph/morphapp.py +++ b/src/diffpy/morph/morphapp.py @@ -585,9 +585,11 @@ def single_morph( refpars.append("scale") # Stretch # Only enable stretch if squeeze is lower than degree 1 + stretch_morph = None if opts.stretch is not None and squeeze_poly_deg < 1: + stretch_morph = morphs.MorphStretch() + chain.append(stretch_morph) stretch_in = opts.stretch - chain.append(morphs.MorphStretch()) config["stretch"] = stretch_in refpars.append("stretch") # Smear @@ -665,6 +667,8 @@ def single_morph( # Now remove non-refinable parameters if opts.exclude is not None: refpars = list(set(refpars) - set(opts.exclude)) + if "stretch" in opts.exclude: + stretch_morph = None # Refine or execute the morph refiner = refine.Refiner( @@ -703,6 +707,7 @@ def single_morph( # THROW ANY WARNINGS HERE io.handle_warnings(squeeze_morph) io.handle_warnings(shift_morph) + io.handle_warnings(stretch_morph) # Get Rw for the morph range rw = tools.getRw(chain) diff --git a/src/diffpy/morph/morphs/morphstretch.py b/src/diffpy/morph/morphs/morphstretch.py index 78c25c8b..21f50a68 100644 --- a/src/diffpy/morph/morphs/morphstretch.py +++ b/src/diffpy/morph/morphs/morphstretch.py @@ -48,6 +48,7 @@ def morph(self, x_morph, y_morph, x_target, y_target): r = self.x_morph_in / (1.0 + self.stretch) self.y_morph_out = numpy.interp(r, self.x_morph_in, self.y_morph_in) + self.set_extrapolation_info(self.x_morph_in, r) return self.xyallout diff --git a/tests/helper.py b/tests/helper.py new file mode 100644 index 00000000..e21bdcee --- /dev/null +++ b/tests/helper.py @@ -0,0 +1,16 @@ +def create_morph_data_file( + data_dir_path, x_morph, y_morph, x_target, y_target +): + morph_file = data_dir_path / "morph_data.txt" + morph_data_text = [ + str(x_morph[i]) + " " + str(y_morph[i]) for i in range(len(x_morph)) + ] + morph_data_text = "\n".join(morph_data_text) + morph_file.write_text(morph_data_text) + target_file = data_dir_path / "target_data.txt" + target_data_text = [ + str(x_target[i]) + " " + str(y_target[i]) for i in range(len(x_target)) + ] + target_data_text = "\n".join(target_data_text) + target_file.write_text(target_data_text) + return morph_file, target_file diff --git a/tests/test_morphshift.py b/tests/test_morphshift.py index c01f2c64..1ae2e0f7 100644 --- a/tests/test_morphshift.py +++ b/tests/test_morphshift.py @@ -6,7 +6,10 @@ import numpy import pytest +import diffpy.morph.morphpy as morphpy +from diffpy.morph.morphapp import create_option_parser, single_morph from diffpy.morph.morphs.morphshift import MorphShift +from tests.helper import create_morph_data_file # useful variables thisfile = locals().get("__file__", "file.py") @@ -44,3 +47,61 @@ def test_morph(self, setup): assert numpy.allclose(self.x_target, x_target) assert numpy.allclose(self.y_target, y_target) return + + +@pytest.mark.parametrize( + "hshift, wmsg_gen", + [ + # extrapolate below + ( + 0.01, + lambda x: ( + "Warning: points with grid value below " + f"{x[0]} are extrapolated." + ), + ), + # extrapolate above + ( + -0.01, + lambda x: ( + "Warning: points with grid value above " + f"{x[1]} are extrapolated." + ), + ), + ], +) +def test_morphshift_extrapolate(user_filesystem, capsys, hshift, wmsg_gen): + x_morph = numpy.linspace(0, 10, 101) + y_morph = numpy.sin(x_morph) + x_target = x_morph.copy() + y_target = y_morph.copy() + with pytest.warns() as w: + morphpy.morph_arrays( + numpy.array([x_morph, y_morph]).T, + numpy.array([x_target, y_target]).T, + hshift=hshift, + apply=True, + ) + assert len(w) == 1 + assert w[0].category is UserWarning + actual_wmsg = str(w[0].message) + expected_wmsg = wmsg_gen([min(x_morph), max(x_morph)]) + assert actual_wmsg == expected_wmsg + + # CLI test + morph_file, target_file = create_morph_data_file( + user_filesystem / "cwd_dir", x_morph, y_morph, x_target, y_target + ) + + parser = create_option_parser() + (opts, pargs) = parser.parse_args( + [ + f"--hshift={hshift}", + f"{morph_file.as_posix()}", + f"{target_file.as_posix()}", + "--apply", + "-n", + ] + ) + with pytest.warns(UserWarning, match=expected_wmsg): + single_morph(parser, opts, pargs, stdout_flag=False) diff --git a/tests/test_morphsqueeze.py b/tests/test_morphsqueeze.py index 79130238..07b99372 100644 --- a/tests/test_morphsqueeze.py +++ b/tests/test_morphsqueeze.py @@ -5,6 +5,7 @@ import diffpy.morph.morphpy as morphpy from diffpy.morph.morphapp import create_option_parser, single_morph from diffpy.morph.morphs.morphsqueeze import MorphSqueeze +from tests.helper import create_morph_data_file squeeze_coeffs_dic = [ # The order of coefficients is {a0, a1, a2, ..., an} @@ -128,9 +129,7 @@ def test_morphsqueeze(x_morph, x_target, squeeze_coeffs): ), ], ) -def test_morphsqueeze_extrapolate( - user_filesystem, capsys, squeeze_coeffs, wmsg_gen -): +def test_morphsqueeze_extrapolate(user_filesystem, squeeze_coeffs, wmsg_gen): x_morph = np.linspace(0, 10, 101) y_morph = np.sin(x_morph) x_target = x_morph.copy() @@ -171,21 +170,3 @@ def test_morphsqueeze_extrapolate( ) with pytest.warns(UserWarning, match=expected_wmsg): single_morph(parser, opts, pargs, stdout_flag=False) - - -def create_morph_data_file( - data_dir_path, x_morph, y_morph, x_target, y_target -): - morph_file = data_dir_path / "morph_data" - morph_data_text = [ - str(x_morph[i]) + " " + str(y_morph[i]) for i in range(len(x_morph)) - ] - morph_data_text = "\n".join(morph_data_text) - morph_file.write_text(morph_data_text) - target_file = data_dir_path / "target_data" - target_data_text = [ - str(x_target[i]) + " " + str(y_target[i]) for i in range(len(x_target)) - ] - target_data_text = "\n".join(target_data_text) - target_file.write_text(target_data_text) - return morph_file, target_file diff --git a/tests/test_morphstretch.py b/tests/test_morphstretch.py index 8e97bb1f..311c92e4 100644 --- a/tests/test_morphstretch.py +++ b/tests/test_morphstretch.py @@ -6,7 +6,10 @@ import numpy import pytest +import diffpy.morph.morphpy as morphpy +from diffpy.morph.morphapp import create_option_parser, single_morph from diffpy.morph.morphs.morphstretch import MorphStretch +from tests.helper import create_morph_data_file # useful variables thisfile = locals().get("__file__", "file.py") @@ -70,3 +73,61 @@ def heaviside(x, lb, ub): y[x < lb] = 0.0 y[x > ub] = 0.0 return y + + +@pytest.mark.parametrize( + "stretch, wmsg_gen", + [ + # extrapolate below + ( + 0.01, + lambda x: ( + "Warning: points with grid value below " + f"{x[0]} are extrapolated." + ), + ), + # extrapolate above + ( + -0.01, + lambda x: ( + "Warning: points with grid value above " + f"{x[1]} are extrapolated." + ), + ), + ], +) +def test_morphshift_extrapolate(user_filesystem, stretch, wmsg_gen): + x_morph = numpy.linspace(1, 10, 101) + y_morph = numpy.sin(x_morph) + x_target = x_morph.copy() + y_target = y_morph.copy() + with pytest.warns() as w: + morphpy.morph_arrays( + numpy.array([x_morph, y_morph]).T, + numpy.array([x_target, y_target]).T, + stretch=stretch, + apply=True, + ) + assert len(w) == 1 + assert w[0].category is UserWarning + actual_wmsg = str(w[0].message) + expected_wmsg = wmsg_gen([min(x_morph), max(x_morph)]) + assert actual_wmsg == expected_wmsg + + # CLI test + morph_file, target_file = create_morph_data_file( + user_filesystem / "cwd_dir", x_morph, y_morph, x_target, y_target + ) + + parser = create_option_parser() + (opts, pargs) = parser.parse_args( + [ + f"--stretch={stretch}", + f"{morph_file.as_posix()}", + f"{target_file.as_posix()}", + "--apply", + "-n", + ] + ) + with pytest.warns(UserWarning, match=expected_wmsg): + single_morph(parser, opts, pargs, stdout_flag=False) From 1238d8226b770aa60c6b6ffd53a2ad19a2911419 Mon Sep 17 00:00:00 2001 From: Sparky <59151395+Sparks29032@users.noreply.github.com> Date: Mon, 22 Sep 2025 16:46:27 -0700 Subject: [PATCH 19/23] Add citation file --- CITATION.cff | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 CITATION.cff diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 00000000..6375531f --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,61 @@ +# This CITATION.cff file was generated with cffinit. +# Visit https://bit.ly/cffinit to generate yours today! + +cff-version: 1.2.0 +title: diffpy.morph +message: >- + If you use this software, please cite the manuscript + associated with this repository (will be included here + when published). +type: software +authors: + - given-names: Andrew + family-names: Yang + email: ayang2@caltech.edu + affiliation: Caltech + orcid: 'https://orcid.org/0000-0003-0553-715X' + - given-names: Christopher + family-names: Farrow + orcid: 'https://orcid.org/0000-0001-5768-6654' + - given-names: Chia-Hao + family-names: Liu + affiliation: Columbia University + orcid: 'https://orcid.org/0000-0002-3216-0354' + - given-names: Luis + family-names: Kitsu + orcid: 'https://orcid.org/0000-0002-9292-4416' + affiliation: University of Colorado Boulder + email: Luis.Kitsu@echemes.ethz.ch + - given-names: Simon + family-names: Billinge + email: sb2896@columbia.edu + affiliation: Columbia University + orcid: 'https://orcid.org/0000-0002-9734-4998' +abstract: >- + diffpy.morph is an open-source and free-to-use Python + package that increases the insight researchers can obtain + when comparing experimentally measured 1D functions such + as diffraction patterns and atomic distribution functions + (PDFs). For example, it is often difficult to identify + whether or not structural changes have occurred between + two diffraction patterns or PDFs of the same material + measured at different temperatures due to the presence of + benign thermal effects such as lattice expansion and + increased atomic motion. These contribute large signals in + the difference curves and Rw factors when comparing two + curves, which can hide small but significant structural + changes. diffpy.morph does its best to correct for these + benign effects by applying simple transformations, or + “morphs”, to a diffraction pattern or PDF prior to + comparison. Other morphs are also possible such as + corrections for nanoparticle shape effects on the PDF. + diffpy.morph is model-independent and also could be used + on other non-diffraction spectra, though + + it has not been extensively tested beyond powder + diffraction patterns and the PDF. +keywords: + - diffpy + - pdf + - data interpretation +license: BSD-3-Clause From 21db0c3ad29d17f5f4a8e2a286549aec0053c60d Mon Sep 17 00:00:00 2001 From: Sparky <59151395+Sparks29032@users.noreply.github.com> Date: Mon, 22 Sep 2025 16:47:45 -0700 Subject: [PATCH 20/23] Update CITATION.cff --- CITATION.cff | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 6375531f..6a33b4f5 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -50,8 +50,7 @@ abstract: >- comparison. Other morphs are also possible such as corrections for nanoparticle shape effects on the PDF. diffpy.morph is model-independent and also could be used - on other non-diffraction spectra, though - + on other non-diffraction spectra, though it has not been extensively tested beyond powder diffraction patterns and the PDF. keywords: From eab82b33e244af64f81a42606cbc56081f9342a4 Mon Sep 17 00:00:00 2001 From: Sparky <59151395+Sparks29032@users.noreply.github.com> Date: Mon, 22 Sep 2025 16:48:04 -0700 Subject: [PATCH 21/23] Update CITATION.cff --- CITATION.cff | 3 --- 1 file changed, 3 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 6a33b4f5..9e2c37aa 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,6 +1,3 @@ -# This CITATION.cff file was generated with cffinit. -# Visit https://bit.ly/cffinit to generate yours today! - cff-version: 1.2.0 title: diffpy.morph message: >- From de41950c9ddfc8ac2922555e36aca04222d214ed Mon Sep 17 00:00:00 2001 From: Sparky <59151395+Sparks29032@users.noreply.github.com> Date: Tue, 23 Sep 2025 18:15:04 -0700 Subject: [PATCH 22/23] Add citation file (#260) * Add citation * Add blank news file * Update citation.rst --- CITATION.cff | 16 ++++++++-------- news/citation.rst | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+), 8 deletions(-) create mode 100644 news/citation.rst diff --git a/CITATION.cff b/CITATION.cff index 9e2c37aa..bfdaca5a 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -10,24 +10,24 @@ authors: family-names: Yang email: ayang2@caltech.edu affiliation: Caltech - orcid: 'https://orcid.org/0000-0003-0553-715X' + orcid: "https://orcid.org/0000-0003-0553-715X" - given-names: Christopher family-names: Farrow - orcid: 'https://orcid.org/0000-0001-5768-6654' + orcid: "https://orcid.org/0000-0001-5768-6654" - given-names: Chia-Hao family-names: Liu affiliation: Columbia University - orcid: 'https://orcid.org/0000-0002-3216-0354' + orcid: "https://orcid.org/0000-0002-3216-0354" - given-names: Luis family-names: Kitsu - orcid: 'https://orcid.org/0000-0002-9292-4416' + orcid: "https://orcid.org/0000-0002-9292-4416" affiliation: University of Colorado Boulder email: Luis.Kitsu@echemes.ethz.ch - given-names: Simon family-names: Billinge email: sb2896@columbia.edu affiliation: Columbia University - orcid: 'https://orcid.org/0000-0002-9734-4998' + orcid: "https://orcid.org/0000-0002-9734-4998" abstract: >- diffpy.morph is an open-source and free-to-use Python package that increases the insight researchers can obtain @@ -47,9 +47,9 @@ abstract: >- comparison. Other morphs are also possible such as corrections for nanoparticle shape effects on the PDF. diffpy.morph is model-independent and also could be used - on other non-diffraction spectra, though - it has not been extensively tested beyond powder - diffraction patterns and the PDF. + on other non-diffraction spectra, though it has not been + extensively tested beyond powder diffraction patterns + and the PDF. keywords: - diffpy - pdf diff --git a/news/citation.rst b/news/citation.rst new file mode 100644 index 00000000..4b69694c --- /dev/null +++ b/news/citation.rst @@ -0,0 +1,23 @@ +**Added:** + +* no news: style changes + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* From 4e9ef91b6ee64c8316d662121cd142c0951cd94d Mon Sep 17 00:00:00 2001 From: Sparky <59151395+Sparks29032@users.noreply.github.com> Date: Thu, 25 Sep 2025 11:19:35 -0700 Subject: [PATCH 23/23] Refactor away from PDF language (#261) * Refactor away from pdf language * [pre-commit.ci] auto fixes from pre-commit hooks * Add news --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- README.rst | 29 ++++---- TUTORIAL.rst | 8 +-- news/refactor_pdf.rst | 25 +++++++ src/diffpy/morph/morph_api.py | 30 ++++----- src/diffpy/morph/morphapp.py | 34 +++++----- src/diffpy/morph/morphs/morphrgrid.py | 66 +++++++++---------- src/diffpy/morph/plot.py | 4 +- tests/test_morphapp.py | 10 +-- tests/test_morphchain.py | 12 ++-- tests/test_morphio.py | 12 ++-- tests/test_morphrgrid.py | 66 +++++++++---------- tests/test_refine.py | 12 ++-- tests/testdata/funcy_target.cgr | 6 +- tests/testdata/squeeze_target.cgr | 6 +- tests/testdata/testsequence/a_210K.gr | 6 +- tests/testdata/testsequence/b_204K.gr | 6 +- tests/testdata/testsequence/c_198K.gr | 6 +- tests/testdata/testsequence/d_192K.gr | 6 +- tests/testdata/testsequence/e_186K.gr | 6 +- tests/testdata/testsequence/f_180K.gr | 6 +- tests/testdata/testsequence/g_174K.gr | 6 +- .../verbose/Morph_Reference_Table.txt | 36 +++++----- .../testsaving/verbose/Morphs/mwt_a.cgr | 6 +- .../testsaving/verbose/Morphs/mwt_b.cgr | 6 +- .../testsaving/verbose/Morphs/mwt_c.cgr | 6 +- .../testsaving/verbose/Morphs/mwt_d.cgr | 6 +- .../testsaving/verbose/Morphs/mwt_e.cgr | 6 +- .../testsaving/verbose/Morphs/mwt_f.cgr | 6 +- .../verbose/single_verbose_morph.cgr | 6 +- tests/testdata/testsequence_serialfile.json | 42 ++++++------ 30 files changed, 255 insertions(+), 227 deletions(-) create mode 100644 news/refactor_pdf.rst diff --git a/README.rst b/README.rst index 87c60770..9f69ba7f 100644 --- a/README.rst +++ b/README.rst @@ -35,34 +35,35 @@ .. |Tracking| image:: https://img.shields.io/badge/issue_tracking-github-blue :target: https://github.com/diffpy/diffpy.morph/issues -Python package for manipulating and comparing PDF profiles +Python package for manipulating and comparing diffraction data ``diffpy.morph`` is a Python software package designed to increase the insight -researchers can obtain from measured atomic pair distribution functions +researchers can obtain from measured diffraction data +and atomic pair distribution functions (PDFs) in a model-independent way. The program was designed to help a researcher answer the question: "Has my material undergone a phase transition between these two measurements?" -One approach is to compare the two PDFs in a plot and view the difference -curve underneath. However, significant signal can be seen in the -difference curve from benign effects such as thermal expansion (peak -shifts) and increased thermal motion (peak broadening) or a change in +One approach is to compare the two diffraction patterns in a plot +and view the difference curve underneath. However, significant signal can +be seen in the difference curve from benign effects such as thermal expansion +(peak shifts) and increased thermal motion (peak broadening) or a change in scale due to differences in incident flux, for example. ``diffpy.morph`` will do its best to correct for these benign effects before computing and -plotting the difference curve. One measured PDF (typically that collected -at higher temperature) is identified as the target PDF and the second -PDF is then morphed by "stretching" (changing the r-axis to simulate a +plotting the difference curve. One measured function (typically that collected +at higher temperature) is identified as the target function and the second +function is then morphed by "stretching" (changing the r-axis to simulate a uniform lattice expansion), "smearing" (broadening peaks through a uniform convolution to simulate increased thermal motion), and "scaling" (self-explanatory). ``diffpy.morph`` will vary the amplitude of the morphing transformations to obtain the best fit between the morphed and the target -PDFs, then plot them on top of each other with the difference plotted +functions, then plot them on top of each other with the difference plotted below. There are also a few other morphing transformations in the program. -Finally, we note that ``diffpy.morph`` should work on other spectra that are not -PDFs, though it has not been extensively tested beyond the PDF. +Finally, we note that ``diffpy.morph`` should work on other spectra, +though it has not been extensively tested beyond spectral data and the PDF. For more information about the diffpy.morph library, please consult our `online documentation `_. @@ -153,9 +154,9 @@ If installed correctly, this last command should return the version of ``diffpy.morph`` that you have installed on your system. To begin using ``diffpy.morph``, run a command like :: - diffpy.morph + diffpy.morph -where both PDFs file are text files which contain PDF data, such as ``.gr`` +where both files are text files which contain two-column data, such as ``.gr`` or ``.cgr`` files that are produced by ``PDFgetX2``, ``PDFgetX3``, or ``PDFgui``. File extensions other than ``.gr`` or ``.cgr``, but with the same content structure, also work with ``diffpy.morph``. diff --git a/TUTORIAL.rst b/TUTORIAL.rst index 433c301a..86b8a06d 100644 --- a/TUTORIAL.rst +++ b/TUTORIAL.rst @@ -132,10 +132,10 @@ Basic diffpy.morph Workflow superficial and in most cases can be ignored. We see that this has had hardly any effect on our PDF. To see - an effect, we restrict the ``rmin`` and ``rmax`` values to + an effect, we restrict the ``xmin`` and ``xmax`` values to reflect relevant data range by typing :: - diffpy.morph --scale=0.8 --smear=0.5 --rmin=1.5 --rmax=30 darkSub_rh20_C_01.gr darkSub_rh20_C_44.gr + diffpy.morph --scale=0.8 --smear=0.5 --xmin=1.5 --xmax=30 darkSub_rh20_C_01.gr darkSub_rh20_C_44.gr Now, we see that the difference Rw = 0.204 and that the optimized ``smear=-0.084138``. @@ -151,7 +151,7 @@ Basic diffpy.morph Workflow 8. Finally, we will examine the stretch factor. Provide an initial guess by typing :: - diffpy.morph --scale=0.8 --smear=-0.08 --stretch=0.5 --rmin=1.5 --rmax=30 -a darkSub_rh20_C_01.gr darkSub_rh20_C_44.gr + diffpy.morph --scale=0.8 --smear=-0.08 --stretch=0.5 --xmin=1.5 --xmax=30 -a darkSub_rh20_C_01.gr darkSub_rh20_C_44.gr And noting that the difference has increased. Before continuing, see if you can see which direction (higher or lower) our initial @@ -160,7 +160,7 @@ Basic diffpy.morph Workflow If you cannot, type :: - diffpy.morph --scale=0.8 --smear=-0.08 --stretch=0.005 --rmin=1.5 --rmax=30 -a darkSub_rh20_C_01.gr darkSub_rh20_C_44.gr + diffpy.morph --scale=0.8 --smear=-0.08 --stretch=0.005 --xmin=1.5 --xmax=30 -a darkSub_rh20_C_01.gr darkSub_rh20_C_44.gr to observe decreased difference and then remove ``-a`` to see the optimized ``--stretch=0.001762``. We have now reached diff --git a/news/refactor_pdf.rst b/news/refactor_pdf.rst new file mode 100644 index 00000000..4d24450b --- /dev/null +++ b/news/refactor_pdf.rst @@ -0,0 +1,25 @@ +**Added:** + +* + +**Changed:** + +* Changed tutorial language away from PDF-specific names +* Names of rmin, rmax, rstep renamed to xmin, xmax, xstep +* Removed PDF-specific labels from plotting functions + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/src/diffpy/morph/morph_api.py b/src/diffpy/morph/morph_api.py index e13f4d0f..25374cce 100644 --- a/src/diffpy/morph/morph_api.py +++ b/src/diffpy/morph/morph_api.py @@ -86,9 +86,9 @@ def morph( y_morph, x_target, y_target, - rmin=None, - rmax=None, - rstep=None, + xmin=None, + xmax=None, + xstep=None, pearson=False, add_pearson=False, fixed_operations=None, @@ -112,12 +112,12 @@ def morph( y_morph: numpy.array An array of target y values, i.e., those will be kept constant by morphing. - rmin: float, optional - A value to specify lower r-limit of morph operations. - rmax: float, optional - A value to specify upper r-limit of morph operations. - rstep: float, optional - A value to specify rstep of morph operations. + xmin: float, optional + A value to specify lower x-limit of morph operations. + xmax: float, optional + A value to specify upper x-limit of morph operations. + xstep: float, optional + A value to specify xstep of morph operations. pearson: Bool, optional Option to include Pearson coefficient as a minimizing target during morphing. Default to False. @@ -191,9 +191,9 @@ def morph( for k, v in rv_cfg.items() if (v is not None) and k in _morph_step_dict ] - rv_cfg["rmin"] = rmin - rv_cfg["rmax"] = rmax - rv_cfg["rstep"] = rstep + rv_cfg["xmin"] = xmin + rv_cfg["xmax"] = xmax + rv_cfg["xstep"] = xstep # configure smear, guess baselineslope when it is not provided if rv_cfg.get("smear") is not None and rv_cfg.get("baselineslope") is None: rv_cfg["baselineslope"] = -0.5 @@ -295,9 +295,9 @@ def plot_morph(chain, ax=None, **kwargs): rdat, grdat = chain.xy_target_out l_list = ax.plot(rfit, grfit, label="morph", **kwargs) l_list += ax.plot(rdat, grdat, label="target", **kwargs) - ax.set_xlim([chain.config["rmin"], chain.config["rmax"]]) + ax.set_xlim([chain.config["xmin"], chain.config["xmax"]]) ax.legend() - ax.set_xlabel(r"r ($\mathrm{\AA}$)") - ax.set_ylabel(r"G ($\mathrm{\AA}^{-2}$)") + # ax.set_xlabel(r"r ($\mathrm{\AA}$)") + # ax.set_ylabel(r"G ($\mathrm{\AA}^{-2}$)") return l_list diff --git a/src/diffpy/morph/morphapp.py b/src/diffpy/morph/morphapp.py index 43976984..327ed2da 100755 --- a/src/diffpy/morph/morphapp.py +++ b/src/diffpy/morph/morphapp.py @@ -110,14 +110,16 @@ def custom_error(self, msg): help="Print additional header details to saved files.", ) parser.add_option( - "--rmin", + "--xmin", type="float", - help="Minimum r-value (abscissa) to use for function comparisons.", + metavar="XMIN", + help="Minimum x-value (abscissa) to use for function comparisons.", ) parser.add_option( - "--rmax", + "--xmax", type="float", - help="Maximum r-value (abscissa) to use for function comparisons.", + metavar="XMAX", + help="Maximum x-value (abscissa) to use for function comparisons.", ) parser.add_option( "--tolerance", @@ -343,12 +345,12 @@ def custom_error(self, msg): group.add_option( "--pmin", type="float", - help="Minimum r-value to plot. Defaults to RMIN.", + help="Minimum x-value to plot. Defaults to XMIN.", ) group.add_option( "--pmax", type="float", - help="Maximum r-value to plot. Defaults to RMAX.", + help="Maximum x-value to plot. Defaults to XMAX.", ) group.add_option( "--maglim", @@ -505,13 +507,13 @@ def single_morph( smear_in = "None" hshift_in = "None" vshift_in = "None" - config = {"rmin": opts.rmin, "rmax": opts.rmax, "rstep": None} + config = {"xmin": opts.xmin, "xmax": opts.xmax, "xstep": None} if ( - opts.rmin is not None - and opts.rmax is not None - and opts.rmax <= opts.rmin + opts.xmin is not None + and opts.xmax is not None + and opts.xmax <= opts.xmin ): - e = "rmin must be less than rmax" + e = "xmin must be less than xmax" parser.custom_error(e) # Set up the morphs @@ -765,7 +767,7 @@ def single_morph( xy_save = [chain.x_morph_out, chain.y_morph_out] if opts.get_diff is not None: diff_chain = morphs.MorphChain( - {"rmin": None, "rmax": None, "rstep": None} + {"xmin": None, "xmax": None, "xstep": None} ) diff_chain.append(morphs.MorphRGrid()) diff_chain( @@ -804,16 +806,16 @@ def single_morph( labels[0] = opts.tlabel # Plot extent defaults to calculation extent - pmin = opts.pmin if opts.pmin is not None else opts.rmin - pmax = opts.pmax if opts.pmax is not None else opts.rmax + pmin = opts.pmin if opts.pmin is not None else opts.xmin + pmax = opts.pmax if opts.pmax is not None else opts.xmax maglim = opts.maglim mag = opts.mag l_width = opts.lwidth plot.compare_funcs( pairlist, labels, - rmin=pmin, - rmax=pmax, + xmin=pmin, + xmax=pmax, maglim=maglim, mag=mag, rw=rw, diff --git a/src/diffpy/morph/morphs/morphrgrid.py b/src/diffpy/morph/morphs/morphrgrid.py index d0c6ce1b..9b20cf67 100644 --- a/src/diffpy/morph/morphs/morphrgrid.py +++ b/src/diffpy/morph/morphs/morphrgrid.py @@ -28,12 +28,12 @@ class MorphRGrid(Morph): Configuration Variables ----------------------- - rmin - The lower-bound on the r-range. - rmax - The upper-bound on the r-range (exclusive within tolerance of 1e-8). - rstep - The r-spacing. + xmin + The lower-bound on the x-range. + xmax + The upper-bound on the x-range (exclusive within tolerance of 1e-8). + xstep + The x-spacing. Notes ----- @@ -49,47 +49,47 @@ class MorphRGrid(Morph): yinlabel = LABEL_GR xoutlabel = LABEL_RA youtlabel = LABEL_GR - parnames = ["rmin", "rmax", "rstep"] + parnames = ["xmin", "xmax", "xstep"] - # Define rmin rmax holders for adaptive x-grid refinement + # Define xmin xmax holders for adaptive x-grid refinement # Without these, the program r-grid can only decrease in interval size - rmin_origin = None - rmax_origin = None - rstep_origin = None + xmin_origin = None + xmax_origin = None + xstep_origin = None def morph(self, x_morph, y_morph, x_target, y_target): """Resample arrays onto specified grid.""" - if self.rmin is not None: - self.rmin_origin = self.rmin - if self.rmax is not None: - self.rmax_origin = self.rmax - if self.rstep is not None: - self.rstep_origin = self.rstep + if self.xmin is not None: + self.xmin_origin = self.xmin + if self.xmax is not None: + self.xmax_origin = self.xmax + if self.xstep is not None: + self.xstep_origin = self.xstep Morph.morph(self, x_morph, y_morph, x_target, y_target) - rmininc = max(min(self.x_target_in), min(self.x_morph_in)) - r_step_target = (max(self.x_target_in) - min(self.x_target_in)) / ( + xmininc = max(min(self.x_target_in), min(self.x_morph_in)) + x_step_target = (max(self.x_target_in) - min(self.x_target_in)) / ( len(self.x_target_in) - 1 ) - r_step_morph = (max(self.x_morph_in) - min(self.x_morph_in)) / ( + x_step_morph = (max(self.x_morph_in) - min(self.x_morph_in)) / ( len(self.x_morph_in) - 1 ) - rstepinc = max(r_step_target, r_step_morph) - rmaxinc = min( - max(self.x_target_in) + r_step_target, - max(self.x_morph_in) + r_step_morph, + xstepinc = max(x_step_target, x_step_morph) + xmaxinc = min( + max(self.x_target_in) + x_step_target, + max(self.x_morph_in) + x_step_morph, ) - if self.rmin_origin is None or self.rmin_origin < rmininc: - self.rmin = rmininc - if self.rmax_origin is None or self.rmax_origin > rmaxinc: - self.rmax = rmaxinc - if self.rstep_origin is None or self.rstep_origin < rstepinc: - self.rstep = rstepinc + if self.xmin_origin is None or self.xmin_origin < xmininc: + self.xmin = xmininc + if self.xmax_origin is None or self.xmax_origin > xmaxinc: + self.xmax = xmaxinc + if self.xstep_origin is None or self.xstep_origin < xstepinc: + self.xstep = xstepinc # roundoff tolerance for selecting bounds on arrays. - epsilon = self.rstep / 2 - # Make sure that rmax is exclusive + epsilon = self.xstep / 2 + # Make sure that xmax is exclusive self.x_morph_out = numpy.arange( - self.rmin, self.rmax - epsilon, self.rstep + self.xmin, self.xmax - epsilon, self.xstep ) self.y_morph_out = numpy.interp( self.x_morph_out, self.x_morph_in, self.y_morph_in diff --git a/src/diffpy/morph/plot.py b/src/diffpy/morph/plot.py index d0ca14b0..22eb46b4 100644 --- a/src/diffpy/morph/plot.py +++ b/src/diffpy/morph/plot.py @@ -212,8 +212,8 @@ def compare_funcs( plt.ylim(ymin, ymax) # Make labels and legends - plt.xlabel(r"r ($\mathrm{\AA})$") - plt.ylabel(r"G $(\mathrm{\AA}^{-1})$") + # plt.xlabel(r"r ($\mathrm{\AA})$") + # plt.ylabel(r"G $(\mathrm{\AA}^{-1})$") if legend: plt.legend( bbox_to_anchor=(0.005, 1.02, 0.99, 0.10), diff --git a/tests/test_morphapp.py b/tests/test_morphapp.py index 379d9f22..97682aec 100644 --- a/tests/test_morphapp.py +++ b/tests/test_morphapp.py @@ -52,8 +52,8 @@ def test_parser_numerical(self, setup_parser): # Check values parsed correctly n_names = [ - "--rmin", - "--rmax", + "--xmin", + "--xmax", "--scale", "--smear", "--stretch", @@ -134,14 +134,14 @@ def test_parser_systemexits(self, capsys, setup_parser): in err ) - # Make sure rmax greater than rmin + # Make sure xmax greater than xmin (opts, pargs) = self.parser.parse_args( - [f"{nickel_PDF}", f"{nickel_PDF}", "--rmin", "10", "--rmax", "1"] + [f"{nickel_PDF}", f"{nickel_PDF}", "--xmin", "10", "--xmax", "1"] ) with pytest.raises(SystemExit): single_morph(self.parser, opts, pargs, stdout_flag=False) _, err = capsys.readouterr() - assert "rmin must be less than rmax" in err + assert "xmin must be less than xmax" in err # ###Tests exclusive to multiple morphs### # Make sure we save to a directory that exists diff --git a/tests/test_morphchain.py b/tests/test_morphchain.py index 721beae0..004b5f2b 100644 --- a/tests/test_morphchain.py +++ b/tests/test_morphchain.py @@ -30,9 +30,9 @@ def test_morph(self, setup): """Check MorphChain.morph()""" # Define the morphs config = { - "rmin": 1, - "rmax": 6, - "rstep": 0.1, + "xmin": 1, + "xmax": 6, + "xstep": 0.1, "scale": 3.0, } @@ -48,8 +48,8 @@ def test_morph(self, setup): pytest.approx(x_morph[0], 1.0) pytest.approx(x_morph[-1], 4.9) pytest.approx(x_morph[1] - x_morph[0], 0.1) - pytest.approx(x_morph[0], mgrid.rmin) - pytest.approx(x_morph[-1], mgrid.rmax - mgrid.rstep) - pytest.approx(x_morph[1] - x_morph[0], mgrid.rstep) + pytest.approx(x_morph[0], mgrid.xmin) + pytest.approx(x_morph[-1], mgrid.xmax - mgrid.xstep) + pytest.approx(x_morph[1] - x_morph[0], mgrid.xstep) assert numpy.allclose(y_morph, y_target) return diff --git a/tests/test_morphio.py b/tests/test_morphio.py index c86b66c6..2eed0135 100644 --- a/tests/test_morphio.py +++ b/tests/test_morphio.py @@ -72,14 +72,14 @@ def are_diffs_right(file1, file2, diff_file): f2_data = loadData(file2) diff_data = loadData(diff_file) - rmin = max(min(f1_data[:, 0]), min(f1_data[:, 1])) - rmax = min(max(f2_data[:, 0]), max(f2_data[:, 1])) - rnumsteps = max( - len(f1_data[:, 0][(rmin <= f1_data[:, 0]) & (f1_data[:, 0] <= rmax)]), - len(f2_data[:, 0][(rmin <= f2_data[:, 0]) & (f2_data[:, 0] <= rmax)]), + xmin = max(min(f1_data[:, 0]), min(f1_data[:, 1])) + xmax = min(max(f2_data[:, 0]), max(f2_data[:, 1])) + xnumsteps = max( + len(f1_data[:, 0][(xmin <= f1_data[:, 0]) & (f1_data[:, 0] <= xmax)]), + len(f2_data[:, 0][(xmin <= f2_data[:, 0]) & (f2_data[:, 0] <= xmax)]), ) - share_grid = np.linspace(rmin, rmax, rnumsteps) + share_grid = np.linspace(xmin, xmax, xnumsteps) f1_interp = np.interp(share_grid, f1_data[:, 0], f1_data[:, 1]) f2_interp = np.interp(share_grid, f2_data[:, 0], f2_data[:, 1]) diff_interp = np.interp(share_grid, diff_data[:, 0], diff_data[:, 1]) diff --git a/tests/test_morphrgrid.py b/tests/test_morphrgrid.py index 866c6332..8d85a2c2 100644 --- a/tests/test_morphrgrid.py +++ b/tests/test_morphrgrid.py @@ -27,9 +27,9 @@ def setup(self): def _runTests(self, xyallout, morph): x_morph, y_morph, x_target, y_target = xyallout assert (x_morph == x_target).all() - pytest.approx(x_morph[0], morph.rmin) - pytest.approx(x_morph[-1], morph.rmax - morph.rstep) - pytest.approx(x_morph[1] - x_morph[0], morph.rstep) + pytest.approx(x_morph[0], morph.xmin) + pytest.approx(x_morph[-1], morph.xmax - morph.xstep) + pytest.approx(x_morph[1] - x_morph[0], morph.xstep) pytest.approx(len(y_morph), len(y_target)) return @@ -37,70 +37,70 @@ def testRangeInBounds(self, setup): """Selected range is within input bounds.""" config = { - "rmin": 1.0, - "rmax": 2.0, - "rstep": 0.1, + "xmin": 1.0, + "xmax": 2.0, + "xstep": 0.1, } morph = MorphRGrid(config) xyallout = morph( self.x_morph, self.y_morph, self.x_target, self.y_target ) - pytest.approx(config["rmin"], morph.rmin) - pytest.approx(config["rmax"], morph.rmax) - pytest.approx(config["rstep"], morph.rstep) + pytest.approx(config["xmin"], morph.xmin) + pytest.approx(config["xmax"], morph.xmax) + pytest.approx(config["xstep"], morph.xstep) self._runTests(xyallout, morph) return - def testRmaxOut(self, setup): - """Selected rmax is outside of input bounds.""" + def testxmaxOut(self, setup): + """Selected xmax is outside of input bounds.""" config = { - "rmin": 1.0, - "rmax": 15.0, - "rstep": 0.1, + "xmin": 1.0, + "xmax": 15.0, + "xstep": 0.1, } morph = MorphRGrid(config) xyallout = morph( self.x_morph, self.y_morph, self.x_target, self.y_target ) - pytest.approx(config["rmin"], morph.rmin) - pytest.approx(5, morph.rmax) - pytest.approx(config["rstep"], morph.rstep) + pytest.approx(config["xmin"], morph.xmin) + pytest.approx(5, morph.xmax) + pytest.approx(config["xstep"], morph.xstep) self._runTests(xyallout, morph) return - def testRminOut(self, setup): - """Selected rmin is outside of input bounds.""" + def testxminOut(self, setup): + """Selected xmin is outside of input bounds.""" config = { - "rmin": 0.0, - "rmax": 2.0, - "rstep": 0.01, + "xmin": 0.0, + "xmax": 2.0, + "xstep": 0.01, } morph = MorphRGrid(config) xyallout = morph( self.x_morph, self.y_morph, self.x_target, self.y_target ) - pytest.approx(1.0, morph.rmin) - pytest.approx(config["rmax"], morph.rmax) - pytest.approx(config["rstep"], morph.rstep) + pytest.approx(1.0, morph.xmin) + pytest.approx(config["xmax"], morph.xmax) + pytest.approx(config["xstep"], morph.xstep) self._runTests(xyallout, morph) return - def testRstepOut(self, setup): - """Selected rstep is outside of input bounds.""" + def testxstepOut(self, setup): + """Selected xstep is outside of input bounds.""" config = { - "rmin": 1.0, - "rmax": 2.0, - "rstep": 0.001, + "xmin": 1.0, + "xmax": 2.0, + "xstep": 0.001, } morph = MorphRGrid(config) xyallout = morph( self.x_morph, self.y_morph, self.x_target, self.y_target ) - pytest.approx(config["rmin"], morph.rmin) - pytest.approx(config["rmax"], morph.rmax) - pytest.approx(0.01, morph.rstep) + pytest.approx(config["xmin"], morph.xmin) + pytest.approx(config["xmax"], morph.xmax) + pytest.approx(0.01, morph.xstep) self._runTests(xyallout, morph) return diff --git a/tests/test_refine.py b/tests/test_refine.py index 2c10c196..b64bccd1 100644 --- a/tests/test_refine.py +++ b/tests/test_refine.py @@ -137,9 +137,9 @@ def shift(x, y, hshift): config = { "funcx_function": shift, "funcx": {"hshift": 0}, - "rmin": 0, - "rmax": 7, - "rstep": 0.01, + "xmin": 0, + "xmax": 7, + "xstep": 0.01, } mfuncx = MorphFuncx(config) @@ -168,9 +168,9 @@ def stretch(x, y, stretch): config = { "funcx_function": stretch, "funcx": {"stretch": 0.7}, - "rmin": 0, - "rmax": 4, - "rstep": 0.01, + "xmin": 0, + "xmax": 4, + "xstep": 0.01, } mfuncx = MorphFuncx(config) diff --git a/tests/testdata/funcy_target.cgr b/tests/testdata/funcy_target.cgr index 5744e86a..fa36dc6b 100644 --- a/tests/testdata/funcy_target.cgr +++ b/tests/testdata/funcy_target.cgr @@ -20,9 +20,9 @@ def quadratic(x, y, a0, a1, a2): """ # Optimized morphing parameters: -# rmin = 0.000000 -# rmax = 10.100000 -# rstep = 0.100000 +# xmin = 0.000000 +# xmax = 10.100000 +# xstep = 0.100000 # squeeze a0 = 0.000000 # squeeze a1 = 0.000000 # squeeze a2 = 0.000000 diff --git a/tests/testdata/squeeze_target.cgr b/tests/testdata/squeeze_target.cgr index 5a7c4017..1474409a 100644 --- a/tests/testdata/squeeze_target.cgr +++ b/tests/testdata/squeeze_target.cgr @@ -13,9 +13,9 @@ # squeeze a3 = 0.0001 # Optimized morphing parameters: -# rmin = 0.000000 -# rmax = 10.100000 -# rstep = 0.100000 +# xmin = 0.000000 +# xmax = 10.100000 +# xstep = 0.100000 # scale = 0.500000 # squeeze a0 = 0.000000 # squeeze a1 = 0.010000 diff --git a/tests/testdata/testsequence/a_210K.gr b/tests/testdata/testsequence/a_210K.gr index a9932c9b..8b73d5bc 100644 --- a/tests/testdata/testsequence/a_210K.gr +++ b/tests/testdata/testsequence/a_210K.gr @@ -12,9 +12,9 @@ outputtype = gr qmaxinst = 25.0 qmin = 0.4 qmax = 25.0 -rmax = 100.0 -rmin = 0.0 -rstep = 0.01 +xmax = 100.0 +xmin = 0.0 +xstep = 0.01 rpoly = 0.7 [Misc] diff --git a/tests/testdata/testsequence/b_204K.gr b/tests/testdata/testsequence/b_204K.gr index bfce0df5..75584763 100644 --- a/tests/testdata/testsequence/b_204K.gr +++ b/tests/testdata/testsequence/b_204K.gr @@ -12,9 +12,9 @@ outputtype = gr qmaxinst = 25.0 qmin = 0.4 qmax = 25.0 -rmax = 100.0 -rmin = 0.0 -rstep = 0.01 +xmax = 100.0 +xmin = 0.0 +xstep = 0.01 rpoly = 0.7 [Misc] diff --git a/tests/testdata/testsequence/c_198K.gr b/tests/testdata/testsequence/c_198K.gr index 9f884651..a8fda3b3 100644 --- a/tests/testdata/testsequence/c_198K.gr +++ b/tests/testdata/testsequence/c_198K.gr @@ -12,9 +12,9 @@ outputtype = gr qmaxinst = 25.0 qmin = 0.4 qmax = 25.0 -rmax = 100.0 -rmin = 0.0 -rstep = 0.01 +xmax = 100.0 +xmin = 0.0 +xstep = 0.01 rpoly = 0.7 [Misc] diff --git a/tests/testdata/testsequence/d_192K.gr b/tests/testdata/testsequence/d_192K.gr index bcbe7330..38083694 100644 --- a/tests/testdata/testsequence/d_192K.gr +++ b/tests/testdata/testsequence/d_192K.gr @@ -12,9 +12,9 @@ outputtype = gr qmaxinst = 25.0 qmin = 0.4 qmax = 25.0 -rmax = 100.0 -rmin = 0.0 -rstep = 0.01 +xmax = 100.0 +xmin = 0.0 +xstep = 0.01 rpoly = 0.7 [Misc] diff --git a/tests/testdata/testsequence/e_186K.gr b/tests/testdata/testsequence/e_186K.gr index 0bcb4646..bde9e2a9 100644 --- a/tests/testdata/testsequence/e_186K.gr +++ b/tests/testdata/testsequence/e_186K.gr @@ -12,9 +12,9 @@ outputtype = gr qmaxinst = 25.0 qmin = 0.4 qmax = 25.0 -rmax = 100.0 -rmin = 0.0 -rstep = 0.01 +xmax = 100.0 +xmin = 0.0 +xstep = 0.01 rpoly = 0.7 [Misc] diff --git a/tests/testdata/testsequence/f_180K.gr b/tests/testdata/testsequence/f_180K.gr index e0800537..af5b64f8 100644 --- a/tests/testdata/testsequence/f_180K.gr +++ b/tests/testdata/testsequence/f_180K.gr @@ -12,9 +12,9 @@ outputtype = gr qmaxinst = 25.0 qmin = 0.4 qmax = 25.0 -rmax = 100.0 -rmin = 0.0 -rstep = 0.01 +xmax = 100.0 +xmin = 0.0 +xstep = 0.01 rpoly = 0.7 [Misc] diff --git a/tests/testdata/testsequence/g_174K.gr b/tests/testdata/testsequence/g_174K.gr index 88a2ad0b..9deb50ca 100644 --- a/tests/testdata/testsequence/g_174K.gr +++ b/tests/testdata/testsequence/g_174K.gr @@ -12,9 +12,9 @@ outputtype = gr qmaxinst = 25.0 qmin = 0.4 qmax = 25.0 -rmax = 100.0 -rmin = 0.0 -rstep = 0.01 +xmax = 100.0 +xmin = 0.0 +xstep = 0.01 rpoly = 0.7 [Misc] diff --git a/tests/testdata/testsequence/testsaving/verbose/Morph_Reference_Table.txt b/tests/testdata/testsequence/testsaving/verbose/Morph_Reference_Table.txt index c849e35f..83445d1e 100644 --- a/tests/testdata/testsequence/testsaving/verbose/Morph_Reference_Table.txt +++ b/tests/testdata/testsequence/testsaving/verbose/Morph_Reference_Table.txt @@ -11,49 +11,49 @@ # Target: f_180K.gr # Optimized morphing parameters: -# rmin = 0.000000 -# rmax = 100.010000 -# rstep = 0.010000 +# xmin = 0.000000 +# xmax = 100.010000 +# xstep = 0.010000 # Rw = 0.020141 # Pearson = 0.999810 # Target: e_186K.gr # Optimized morphing parameters: -# rmin = 0.000000 -# rmax = 100.010000 -# rstep = 0.010000 +# xmin = 0.000000 +# xmax = 100.010000 +# xstep = 0.010000 # Rw = 0.034859 # Pearson = 0.999424 # Target: d_192K.gr # Optimized morphing parameters: -# rmin = 0.000000 -# rmax = 100.010000 -# rstep = 0.010000 +# xmin = 0.000000 +# xmax = 100.010000 +# xstep = 0.010000 # Rw = 0.062392 # Pearson = 0.998077 # Target: c_198K.gr # Optimized morphing parameters: -# rmin = 0.000000 -# rmax = 100.010000 -# rstep = 0.010000 +# xmin = 0.000000 +# xmax = 100.010000 +# xstep = 0.010000 # Rw = 0.105918 # Pearson = 0.994409 # Target: b_204K.gr # Optimized morphing parameters: -# rmin = 0.000000 -# rmax = 100.010000 -# rstep = 0.010000 +# xmin = 0.000000 +# xmax = 100.010000 +# xstep = 0.010000 # Rw = 0.117595 # Pearson = 0.993160 # Target: a_210K.gr # Optimized morphing parameters: -# rmin = 0.000000 -# rmax = 100.010000 -# rstep = 0.010000 +# xmin = 0.000000 +# xmax = 100.010000 +# xstep = 0.010000 # Rw = 0.127100 # Pearson = 0.992111 diff --git a/tests/testdata/testsequence/testsaving/verbose/Morphs/mwt_a.cgr b/tests/testdata/testsequence/testsaving/verbose/Morphs/mwt_a.cgr index d4ab20ff..95cba1f9 100644 --- a/tests/testdata/testsequence/testsaving/verbose/Morphs/mwt_a.cgr +++ b/tests/testdata/testsequence/testsaving/verbose/Morphs/mwt_a.cgr @@ -9,9 +9,9 @@ # vshift = None # Optimized morphing parameters: -# rmin = 0.000000 -# rmax = 100.010000 -# rstep = 0.010000 +# xmin = 0.000000 +# xmax = 100.010000 +# xstep = 0.010000 # Rw = 0.127100 # Pearson = 0.992111 diff --git a/tests/testdata/testsequence/testsaving/verbose/Morphs/mwt_b.cgr b/tests/testdata/testsequence/testsaving/verbose/Morphs/mwt_b.cgr index fd039d17..ef8cf023 100644 --- a/tests/testdata/testsequence/testsaving/verbose/Morphs/mwt_b.cgr +++ b/tests/testdata/testsequence/testsaving/verbose/Morphs/mwt_b.cgr @@ -9,9 +9,9 @@ # vshift = None # Optimized morphing parameters: -# rmin = 0.000000 -# rmax = 100.010000 -# rstep = 0.010000 +# xmin = 0.000000 +# xmax = 100.010000 +# xstep = 0.010000 # Rw = 0.117595 # Pearson = 0.993160 diff --git a/tests/testdata/testsequence/testsaving/verbose/Morphs/mwt_c.cgr b/tests/testdata/testsequence/testsaving/verbose/Morphs/mwt_c.cgr index 0321f80e..27ebcd00 100644 --- a/tests/testdata/testsequence/testsaving/verbose/Morphs/mwt_c.cgr +++ b/tests/testdata/testsequence/testsaving/verbose/Morphs/mwt_c.cgr @@ -9,9 +9,9 @@ # vshift = None # Optimized morphing parameters: -# rmin = 0.000000 -# rmax = 100.010000 -# rstep = 0.010000 +# xmin = 0.000000 +# xmax = 100.010000 +# xstep = 0.010000 # Rw = 0.105918 # Pearson = 0.994409 diff --git a/tests/testdata/testsequence/testsaving/verbose/Morphs/mwt_d.cgr b/tests/testdata/testsequence/testsaving/verbose/Morphs/mwt_d.cgr index fa694073..8a30b321 100644 --- a/tests/testdata/testsequence/testsaving/verbose/Morphs/mwt_d.cgr +++ b/tests/testdata/testsequence/testsaving/verbose/Morphs/mwt_d.cgr @@ -9,9 +9,9 @@ # vshift = None # Optimized morphing parameters: -# rmin = 0.000000 -# rmax = 100.010000 -# rstep = 0.010000 +# xmin = 0.000000 +# xmax = 100.010000 +# xstep = 0.010000 # Rw = 0.062392 # Pearson = 0.998077 diff --git a/tests/testdata/testsequence/testsaving/verbose/Morphs/mwt_e.cgr b/tests/testdata/testsequence/testsaving/verbose/Morphs/mwt_e.cgr index 5ea8d806..1ad1a45f 100644 --- a/tests/testdata/testsequence/testsaving/verbose/Morphs/mwt_e.cgr +++ b/tests/testdata/testsequence/testsaving/verbose/Morphs/mwt_e.cgr @@ -9,9 +9,9 @@ # vshift = None # Optimized morphing parameters: -# rmin = 0.000000 -# rmax = 100.010000 -# rstep = 0.010000 +# xmin = 0.000000 +# xmax = 100.010000 +# xstep = 0.010000 # Rw = 0.034859 # Pearson = 0.999424 diff --git a/tests/testdata/testsequence/testsaving/verbose/Morphs/mwt_f.cgr b/tests/testdata/testsequence/testsaving/verbose/Morphs/mwt_f.cgr index 1cf81646..32ce202b 100644 --- a/tests/testdata/testsequence/testsaving/verbose/Morphs/mwt_f.cgr +++ b/tests/testdata/testsequence/testsaving/verbose/Morphs/mwt_f.cgr @@ -9,9 +9,9 @@ # vshift = None # Optimized morphing parameters: -# rmin = 0.000000 -# rmax = 100.010000 -# rstep = 0.010000 +# xmin = 0.000000 +# xmax = 100.010000 +# xstep = 0.010000 # Rw = 0.020141 # Pearson = 0.999810 diff --git a/tests/testdata/testsequence/testsaving/verbose/single_verbose_morph.cgr b/tests/testdata/testsequence/testsaving/verbose/single_verbose_morph.cgr index d4ab20ff..95cba1f9 100644 --- a/tests/testdata/testsequence/testsaving/verbose/single_verbose_morph.cgr +++ b/tests/testdata/testsequence/testsaving/verbose/single_verbose_morph.cgr @@ -9,9 +9,9 @@ # vshift = None # Optimized morphing parameters: -# rmin = 0.000000 -# rmax = 100.010000 -# rstep = 0.010000 +# xmin = 0.000000 +# xmax = 100.010000 +# xstep = 0.010000 # Rw = 0.127100 # Pearson = 0.992111 diff --git a/tests/testdata/testsequence_serialfile.json b/tests/testdata/testsequence_serialfile.json index bc49ce93..834f3c1e 100644 --- a/tests/testdata/testsequence_serialfile.json +++ b/tests/testdata/testsequence_serialfile.json @@ -13,9 +13,9 @@ "qmaxinst": 25.0, "qmin": 0.4, "qmax": 25.0, - "rmax": 100.0, - "rmin": 0.0, - "rstep": 0.01, + "xmax": 100.0, + "xmin": 0.0, + "xstep": 0.01, "rpoly": 0.7 }, "b_204K.gr": { @@ -32,9 +32,9 @@ "qmaxinst": 25.0, "qmin": 0.4, "qmax": 25.0, - "rmax": 100.0, - "rmin": 0.0, - "rstep": 0.01, + "xmax": 100.0, + "xmin": 0.0, + "xstep": 0.01, "rpoly": 0.7 }, "c_198K.gr": { @@ -51,9 +51,9 @@ "qmaxinst": 25.0, "qmin": 0.4, "qmax": 25.0, - "rmax": 100.0, - "rmin": 0.0, - "rstep": 0.01, + "xmax": 100.0, + "xmin": 0.0, + "xstep": 0.01, "rpoly": 0.7 }, "d_192K.gr": { @@ -70,9 +70,9 @@ "qmaxinst": 25.0, "qmin": 0.4, "qmax": 25.0, - "rmax": 100.0, - "rmin": 0.0, - "rstep": 0.01, + "xmax": 100.0, + "xmin": 0.0, + "xstep": 0.01, "rpoly": 0.7 }, "e_186K.gr": { @@ -89,9 +89,9 @@ "qmaxinst": 25.0, "qmin": 0.4, "qmax": 25.0, - "rmax": 100.0, - "rmin": 0.0, - "rstep": 0.01, + "xmax": 100.0, + "xmin": 0.0, + "xstep": 0.01, "rpoly": 0.7 }, "f_180K.gr": { @@ -108,9 +108,9 @@ "qmaxinst": 25.0, "qmin": 0.4, "qmax": 25.0, - "rmax": 100.0, - "rmin": 0.0, - "rstep": 0.01, + "xmax": 100.0, + "xmin": 0.0, + "xstep": 0.01, "rpoly": 0.7 }, "g_174K.gr": { @@ -127,9 +127,9 @@ "qmaxinst": 25.0, "qmin": 0.4, "qmax": 25.0, - "rmax": 100.0, - "rmin": 0.0, - "rstep": 0.01, + "xmax": 100.0, + "xmin": 0.0, + "xstep": 0.01, "rpoly": 0.7 } }