From d6b320d43596b5f076bc015e9e6b59516227dcbc Mon Sep 17 00:00:00 2001 From: Yuchen Ethan Xiao Date: Mon, 18 Aug 2025 16:15:52 -0400 Subject: [PATCH 01/10] raise `ValueError` if `x_squeezed` is not strictly increasing --- news/strictly-increasing-squeeze.rst | 23 +++++++++++++++++++++++ src/diffpy/morph/morphs/morphsqueeze.py | 7 +++++++ 2 files changed, 30 insertions(+) create mode 100644 news/strictly-increasing-squeeze.rst diff --git a/news/strictly-increasing-squeeze.rst b/news/strictly-increasing-squeeze.rst new file mode 100644 index 0000000..f14fabf --- /dev/null +++ b/news/strictly-increasing-squeeze.rst @@ -0,0 +1,23 @@ +**Added:** + +* Raise ``ValueError`` if ``x_squeezed`` is not strictly increasing. + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/src/diffpy/morph/morphs/morphsqueeze.py b/src/diffpy/morph/morphs/morphsqueeze.py index bc0e4d4..4000423 100644 --- a/src/diffpy/morph/morphs/morphsqueeze.py +++ b/src/diffpy/morph/morphs/morphsqueeze.py @@ -78,6 +78,13 @@ 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) + strictly_increasing_x = (np.diff(x_squeezed) > 0).all() + if not strictly_increasing_x: + raise ValueError( + "Computed squeezed x is not strictly increasing. " + "Please change the input x_morph or the squeeze " + "coefficients." + ) self.y_morph_out = CubicSpline(x_squeezed, self.y_morph_in)( self.x_morph_in ) From add0413994326e2e5493a796aabc8ed8e60afbbf Mon Sep 17 00:00:00 2001 From: Yuchen Ethan Xiao Date: Thu, 11 Sep 2025 15:58:10 -0400 Subject: [PATCH 02/10] chore: update error message --- src/diffpy/morph/morphs/morphsqueeze.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/diffpy/morph/morphs/morphsqueeze.py b/src/diffpy/morph/morphs/morphsqueeze.py index 6126068..a6b0aac 100644 --- a/src/diffpy/morph/morphs/morphsqueeze.py +++ b/src/diffpy/morph/morphs/morphsqueeze.py @@ -87,7 +87,8 @@ def morph(self, x_morph, y_morph, x_target, y_target): if not strictly_increasing_x: raise ValueError( "Computed squeezed x is not strictly increasing. " - "Please change the input x_morph or the squeeze " + "The squeezed morph is only intended for small polynomial " + "stretches. Please decrease the magnitude of the polynomial " "coefficients." ) From c9ce9541983c7f738b775354bd5ffce30d83d200 Mon Sep 17 00:00:00 2001 From: Yuchen Ethan Xiao Date: Wed, 17 Sep 2025 15:25:17 -0400 Subject: [PATCH 03/10] feat: add `--check-increase` option --- news/sort-squeezed-x.rst | 23 +++++++ src/diffpy/morph/morph_io.py | 24 ++++++- src/diffpy/morph/morphapp.py | 17 ++++- src/diffpy/morph/morphpy.py | 1 + src/diffpy/morph/morphs/morphsqueeze.py | 74 ++++++++++++++++++++- tests/test_morphsqueeze.py | 85 +++++++++++++++++++++++++ 6 files changed, 217 insertions(+), 7 deletions(-) create mode 100644 news/sort-squeezed-x.rst diff --git a/news/sort-squeezed-x.rst b/news/sort-squeezed-x.rst new file mode 100644 index 0000000..9fe0f2a --- /dev/null +++ b/news/sort-squeezed-x.rst @@ -0,0 +1,23 @@ +**Added:** + +* Add ``--check-increase`` option for squeeze morph. + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/src/diffpy/morph/morph_io.py b/src/diffpy/morph/morph_io.py index 84d5c15..7727042 100644 --- a/src/diffpy/morph/morph_io.py +++ b/src/diffpy/morph/morph_io.py @@ -408,7 +408,7 @@ def tabulate_results(multiple_morph_results): return tabulated_results -def handle_warnings(squeeze_morph): +def handle_extrapolation_warnings(squeeze_morph): if squeeze_morph is not None: extrapolation_info = squeeze_morph.extrapolation_info is_extrap_low = extrapolation_info["is_extrap_low"] @@ -443,3 +443,25 @@ def handle_warnings(squeeze_morph): wmsg, UserWarning, ) + + +def handle_check_increase_warning(squeeze_morph): + if squeeze_morph is not None: + if squeeze_morph.squeeze_info["monotonic"]: + wmsg = None + else: + overlapping_regions = squeeze_morph.squeeze_info[ + "overlapping_regions" + ] + wmsg = ( + "Warning: The squeeze morph has interpolated your morphed " + "function from a non-monotonically increasing grid. " + "This can result in strange behavior in the regions " + f"{overlapping_regions}. To disable this setting, " + "please enable --check-increasing." + ) + if wmsg: + warnings.warn( + wmsg, + UserWarning, + ) diff --git a/src/diffpy/morph/morphapp.py b/src/diffpy/morph/morphapp.py index 27799d0..b33223e 100755 --- a/src/diffpy/morph/morphapp.py +++ b/src/diffpy/morph/morphapp.py @@ -207,6 +207,16 @@ def custom_error(self, msg): "See online documentation for more information." ), ) + group.add_option( + "--check-increase", + action="store_true", + dest="check_increase", + help=( + "Disable squeeze morph to interpolat morphed function " + "from a non-monotonically increasing grid." + ), + ) + group.add_option( "--smear", type="float", @@ -571,7 +581,7 @@ def single_morph( except ValueError: parser.error(f"{coeff} could not be converted to float.") squeeze_poly_deg = len(squeeze_dict_in.keys()) - squeeze_morph = morphs.MorphSqueeze() + squeeze_morph = morphs.MorphSqueeze(check_increase=opts.check_increase) chain.append(squeeze_morph) config["squeeze"] = squeeze_dict_in # config["extrap_index_low"] = None @@ -701,8 +711,9 @@ def single_morph( chain(x_morph, y_morph, x_target, y_target) # THROW ANY WARNINGS HERE - io.handle_warnings(squeeze_morph) - io.handle_warnings(shift_morph) + io.handle_extrapolation_warnings(squeeze_morph) + io.handle_check_increase_warning(squeeze_morph) + io.handle_extrapolation_warnings(shift_morph) # Get Rw for the morph range rw = tools.getRw(chain) diff --git a/src/diffpy/morph/morphpy.py b/src/diffpy/morph/morphpy.py index bac6eee..3889f82 100644 --- a/src/diffpy/morph/morphpy.py +++ b/src/diffpy/morph/morphpy.py @@ -51,6 +51,7 @@ def __get_morph_opts__(parser, scale, stretch, smear, plot, **kwargs): "reverse", "diff", "get-diff", + "check-increase", ] opts_to_ignore = ["multiple-morphs", "multiple-targets"] for opt in opts_storing_values: diff --git a/src/diffpy/morph/morphs/morphsqueeze.py b/src/diffpy/morph/morphs/morphsqueeze.py index 3d0250d..f198bd1 100644 --- a/src/diffpy/morph/morphs/morphsqueeze.py +++ b/src/diffpy/morph/morphs/morphsqueeze.py @@ -1,6 +1,7 @@ """Class MorphSqueeze -- Apply a polynomial to squeeze the morph function.""" +import numpy from numpy.polynomial import Polynomial from scipy.interpolate import CubicSpline @@ -68,8 +69,68 @@ class MorphSqueeze(Morph): squeeze_cutoff_low = None squeeze_cutoff_high = None - def __init__(self, config=None): + def __init__(self, config=None, check_increase=False): super().__init__(config) + self.check_increase = check_increase + + def _set_squeeze_info(self, x, x_sorted): + self.squeeze_info = {"monotonic": True, "overlapping_regions": None} + if list(x) != list(x_sorted): + if self.check_increase: + raise ValueError( + "Squeezed grid is not strictly increasing." + "Please (1) decrease the order of your polynomial and " + "(2) ensure that the initial polynomial morph result in " + "good agreement between your reference and " + "objective functions." + ) + else: + overlapping_regions = self._get_overlapping_regions(x) + self.squeeze_info["monotonic"] = False + self.squeeze_info["overlapping_regions"] = overlapping_regions + + def _sort_squeeze(self, x, y): + """Sort x,y according to the value of x.""" + xy = list(zip(x, y)) + xy_sorted = sorted(xy, key=lambda pair: pair[0]) + x_sorted, y_sorted = list(zip(*xy_sorted)) + return x_sorted, y_sorted + + def _get_overlapping_regions(self, x): + diffx = numpy.diff(x) + monotomic_regions = [] + monotomic_signs = [numpy.sign(diffx[0])] + current_region = [x[0], x[1]] + for i in range(1, len(diffx)): + if numpy.sign(diffx[i]) == monotomic_signs[-1]: + current_region.append(x[i + 1]) + else: + monotomic_regions.append(current_region) + monotomic_signs.append(diffx[i]) + current_region = [x[i + 1]] + monotomic_regions.append(current_region) + overlapping_regions_sign = -1 if x[0] < x[-1] else 1 + overlapping_regions_x = [ + monotomic_regions[i] + for i in range(len(monotomic_regions)) + if monotomic_signs[i] == overlapping_regions_sign + ] + overlapping_regions = [ + (min(region), max(region)) for region in overlapping_regions_x + ] + return overlapping_regions + + def _handle_duplicates(self, x, y): + """Remove duplicated x and use the mean value of y corresponded + to the duplicated x.""" + unq_x, unq_inv = numpy.unique(x, return_inverse=True) + if len(unq_x) == len(x): + return x, y + else: + y_avg = numpy.zeros_like(unq_x) + for i in range(len(unq_x)): + y_avg[i] = numpy.array(y)[unq_inv == i].mean() + return unq_x, y_avg def morph(self, x_morph, y_morph, x_target, y_target): """Apply a polynomial to squeeze the morph function. @@ -82,9 +143,16 @@ 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.y_morph_out = CubicSpline(x_squeezed, self.y_morph_in)( + x_squeezed_sorted, y_morph_sorted = self._sort_squeeze( + x_squeezed, self.y_morph_in + ) + self._set_squeeze_info(x_squeezed_sorted, x_squeezed) + x_squeezed_sorted, y_morph_sorted = self._handle_duplicates( + x_squeezed_sorted, y_morph_sorted + ) + self.y_morph_out = CubicSpline(x_squeezed_sorted, y_morph_sorted)( self.x_morph_in ) - self.set_extrapolation_info(x_squeezed, self.x_morph_in) + self.set_extrapolation_info(x_squeezed_sorted, self.x_morph_in) return self.xyallout diff --git a/tests/test_morphsqueeze.py b/tests/test_morphsqueeze.py index 7913023..db4cccd 100644 --- a/tests/test_morphsqueeze.py +++ b/tests/test_morphsqueeze.py @@ -173,6 +173,91 @@ def test_morphsqueeze_extrapolate( single_morph(parser, opts, pargs, stdout_flag=False) +@pytest.mark.parametrize( + "squeeze_coeffs, x_morph", + [ + ({"a0": -1, "a1": -1, "a2": 2}, np.linspace(-1, 1, 101)), + ], +) +def test_sort_squeeze_bad(user_filesystem, squeeze_coeffs, x_morph): + # call in .py without --check-increase + x_target = x_morph + y_target = np.sin(x_target) + 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) + morph = MorphSqueeze() + morph.squeeze = squeeze_coeffs + with pytest.warns() as w: + 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 + actual_wmsg = str(w[0].message) + expected_wmsg = ( + "Warning: The squeeze morph has interpolated your morphed " + "function from a non-monotonically increasing grid. " + ) + assert expected_wmsg in actual_wmsg + + # call in .py with --check-increase + with pytest.raises(ValueError) as excinfo: + morphpy.morph_arrays( + np.array([x_morph, y_morph]).T, + np.array([x_target, y_target]).T, + squeeze=coeffs, + check_increase=True, + apply=True, + ) + actual_emsg = str(excinfo.value) + expected_emsg = "Squeezed grid is not strictly increasing." + assert expected_emsg in actual_emsg + + # call in CLI without --check-increase + 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( + [ + "--squeeze", + ",".join(map(str, coeffs)), + f"{morph_file.as_posix()}", + f"{target_file.as_posix()}", + "--apply", + "-n", + ] + ) + with pytest.warns(UserWarning) as w: + single_morph(parser, opts, pargs, stdout_flag=False) + assert len(w) == 1 + actual_wmsg = str(w[0].message) + assert expected_wmsg in actual_wmsg + + # call in CLI with --check-increase + parser = create_option_parser() + (opts, pargs) = parser.parse_args( + [ + "--squeeze", + ",".join(map(str, coeffs)), + f"{morph_file.as_posix()}", + f"{target_file.as_posix()}", + "--apply", + "-n", + "--check-increase", + ] + ) + with pytest.raises(ValueError) as excinfo: + single_morph(parser, opts, pargs, stdout_flag=False) + actual_emsg = str(excinfo.value) + assert expected_emsg in actual_emsg + + def create_morph_data_file( data_dir_path, x_morph, y_morph, x_target, y_target ): From 99afb1578cc00d09fbe3326ab12368cc9a83d75f Mon Sep 17 00:00:00 2001 From: Yuchen Ethan Xiao Date: Wed, 17 Sep 2025 18:33:11 -0400 Subject: [PATCH 04/10] chore: update error message --- src/diffpy/morph/morphs/morphsqueeze.py | 25 ++++++++++++++++++++----- tests/test_morphsqueeze.py | 5 ++++- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/diffpy/morph/morphs/morphsqueeze.py b/src/diffpy/morph/morphs/morphsqueeze.py index f198bd1..e35f81f 100644 --- a/src/diffpy/morph/morphs/morphsqueeze.py +++ b/src/diffpy/morph/morphs/morphsqueeze.py @@ -78,11 +78,26 @@ def _set_squeeze_info(self, x, x_sorted): if list(x) != list(x_sorted): if self.check_increase: raise ValueError( - "Squeezed grid is not strictly increasing." - "Please (1) decrease the order of your polynomial and " - "(2) ensure that the initial polynomial morph result in " - "good agreement between your reference and " - "objective functions." + "Error: The polynomial applied by the squeeze morph has " + "resulted in a grid that is no longer strictly increasing" + ", likely due to a convergence issue. A strictly " + "increasing grid is required for diffpy.morph to compute " + "the morphed function through cubic spline interpolation. " + "Here are some suggested methods to resolve this:\n" + "(1) Please decrease the order of your polynomial and " + "try again.\n" + "(2) If you are using initial guesses of all 0, please " + "ensure your objective function only requires a small " + "polynomial squeeze to match your reference. (In other " + "words, there is good agreement between the two functions" + ".)\n" + "(3) If you expect a large polynomial squeeze to be needed" + ", please ensure your initial parameters for the " + "polynomial morph result in good agreement between your " + "reference and objective functions. One way to obtain " + "such parameters is to first apply a --hshift and " + "--stretch morph. Then, use the hshift parameter for a0 " + "and stretch parameter for a1." ) else: overlapping_regions = self._get_overlapping_regions(x) diff --git a/tests/test_morphsqueeze.py b/tests/test_morphsqueeze.py index db4cccd..ada1e5e 100644 --- a/tests/test_morphsqueeze.py +++ b/tests/test_morphsqueeze.py @@ -215,7 +215,10 @@ def test_sort_squeeze_bad(user_filesystem, squeeze_coeffs, x_morph): apply=True, ) actual_emsg = str(excinfo.value) - expected_emsg = "Squeezed grid is not strictly increasing." + expected_emsg = ( + "Error: The polynomial applied by the squeeze morph has " + "resulted in a grid that is no longer strictly increasing" + ) assert expected_emsg in actual_emsg # call in CLI without --check-increase From 852e2de012a44b8abb72c3293fe5ec2fcb593530 Mon Sep 17 00:00:00 2001 From: Yuchen Ethan Xiao Date: Wed, 17 Sep 2025 18:50:26 -0400 Subject: [PATCH 05/10] fix: display proper warning message --- src/diffpy/morph/morphs/morphsqueeze.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/diffpy/morph/morphs/morphsqueeze.py b/src/diffpy/morph/morphs/morphsqueeze.py index e35f81f..30230d3 100644 --- a/src/diffpy/morph/morphs/morphsqueeze.py +++ b/src/diffpy/morph/morphs/morphsqueeze.py @@ -161,7 +161,7 @@ def morph(self, x_morph, y_morph, x_target, y_target): x_squeezed_sorted, y_morph_sorted = self._sort_squeeze( x_squeezed, self.y_morph_in ) - self._set_squeeze_info(x_squeezed_sorted, x_squeezed) + self._set_squeeze_info(x_squeezed, x_squeezed_sorted) x_squeezed_sorted, y_morph_sorted = self._handle_duplicates( x_squeezed_sorted, y_morph_sorted ) From bc4a7bef15c774f4031010d28b97d3ef5ae309d6 Mon Sep 17 00:00:00 2001 From: Yuchen Ethan Xiao Date: Wed, 17 Sep 2025 19:10:26 -0400 Subject: [PATCH 06/10] fix: display correct overlapping regions --- src/diffpy/morph/morphs/morphsqueeze.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/diffpy/morph/morphs/morphsqueeze.py b/src/diffpy/morph/morphs/morphsqueeze.py index 30230d3..d64539f 100644 --- a/src/diffpy/morph/morphs/morphsqueeze.py +++ b/src/diffpy/morph/morphs/morphsqueeze.py @@ -121,7 +121,7 @@ def _get_overlapping_regions(self, x): current_region.append(x[i + 1]) else: monotomic_regions.append(current_region) - monotomic_signs.append(diffx[i]) + monotomic_signs.append(numpy.sign(diffx[i])) current_region = [x[i + 1]] monotomic_regions.append(current_region) overlapping_regions_sign = -1 if x[0] < x[-1] else 1 From aa0f1d8f85073311988b136d5d4d11416c1cd2da Mon Sep 17 00:00:00 2001 From: Yuchen Ethan Xiao Date: Wed, 17 Sep 2025 20:36:02 -0400 Subject: [PATCH 07/10] test: refactor and add test for ``get_overlapping_regions`` --- src/diffpy/morph/morphs/morphsqueeze.py | 40 +++++++++++++------------ tests/test_morphsqueeze.py | 20 +++++++++++++ 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/src/diffpy/morph/morphs/morphsqueeze.py b/src/diffpy/morph/morphs/morphsqueeze.py index d64539f..6529989 100644 --- a/src/diffpy/morph/morphs/morphsqueeze.py +++ b/src/diffpy/morph/morphs/morphsqueeze.py @@ -100,7 +100,7 @@ def _set_squeeze_info(self, x, x_sorted): "and stretch parameter for a1." ) else: - overlapping_regions = self._get_overlapping_regions(x) + overlapping_regions = self.get_overlapping_regions(x) self.squeeze_info["monotonic"] = False self.squeeze_info["overlapping_regions"] = overlapping_regions @@ -111,27 +111,29 @@ def _sort_squeeze(self, x, y): x_sorted, y_sorted = list(zip(*xy_sorted)) return x_sorted, y_sorted - def _get_overlapping_regions(self, x): + def get_overlapping_regions(self, x): diffx = numpy.diff(x) - monotomic_regions = [] - monotomic_signs = [numpy.sign(diffx[0])] - current_region = [x[0], x[1]] - for i in range(1, len(diffx)): - if numpy.sign(diffx[i]) == monotomic_signs[-1]: - current_region.append(x[i + 1]) - else: - monotomic_regions.append(current_region) - monotomic_signs.append(numpy.sign(diffx[i])) - current_region = [x[i + 1]] - monotomic_regions.append(current_region) + diffx_sign = numpy.sign(diffx) + local_min_or_max_index = ( + numpy.where(numpy.diff(diffx_sign) != 0)[0] + 1 + ) + monotonic_regions_x = numpy.concatenate( + ( + [x[0]], + numpy.repeat( + numpy.array(x)[local_min_or_max_index], 2 + ).tolist()[:-1], + ) + ).reshape(-1, 2) + monotinic_regions_sign = diffx_sign[local_min_or_max_index - 1] + overlapping_regions_sign = -1 if x[0] < x[-1] else 1 - overlapping_regions_x = [ - monotomic_regions[i] - for i in range(len(monotomic_regions)) - if monotomic_signs[i] == overlapping_regions_sign - ] + overlapping_regions_index = numpy.where( + monotinic_regions_sign == overlapping_regions_sign + )[0] + overlapping_regions = monotonic_regions_x[overlapping_regions_index] overlapping_regions = [ - (min(region), max(region)) for region in overlapping_regions_x + sorted(region) for region in overlapping_regions ] return overlapping_regions diff --git a/tests/test_morphsqueeze.py b/tests/test_morphsqueeze.py index ada1e5e..a6c2dfa 100644 --- a/tests/test_morphsqueeze.py +++ b/tests/test_morphsqueeze.py @@ -261,6 +261,26 @@ def test_sort_squeeze_bad(user_filesystem, squeeze_coeffs, x_morph): assert expected_emsg in actual_emsg +@pytest.mark.parametrize( + "turning_points, expected_overlapping_regions", + [ + # x[-1] > x[0], monotonically decreasing regions are overlapping + ([0, 10, 7, 12], [[7, 10]]), + # x[-1] < x[0], monotonically increasing regions are overlapping + ([0, 5, 2, 4, -10], [[0, 5], [2, 4]]) + ], +) +def test_get_overlapping_regions(turning_points, expected_overlapping_regions): + morph = MorphSqueeze() + regions = ( + np.linspace(turning_points[i], turning_points[i + 1], 20) + for i in range(len(turning_points) - 1) + ) + x_value = np.concatenate(list(regions)) + actual_overlaping_regions = morph.get_overlapping_regions(x_value) + assert expected_overlapping_regions == actual_overlaping_regions + + def create_morph_data_file( data_dir_path, x_morph, y_morph, x_target, y_target ): From 390439a37d323745440a3e963dce854bb5a61ed3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 18 Sep 2025 00:40:27 +0000 Subject: [PATCH 08/10] [pre-commit.ci] auto fixes from pre-commit hooks --- tests/test_morphsqueeze.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_morphsqueeze.py b/tests/test_morphsqueeze.py index a6c2dfa..3993921 100644 --- a/tests/test_morphsqueeze.py +++ b/tests/test_morphsqueeze.py @@ -267,7 +267,7 @@ def test_sort_squeeze_bad(user_filesystem, squeeze_coeffs, x_morph): # x[-1] > x[0], monotonically decreasing regions are overlapping ([0, 10, 7, 12], [[7, 10]]), # x[-1] < x[0], monotonically increasing regions are overlapping - ([0, 5, 2, 4, -10], [[0, 5], [2, 4]]) + ([0, 5, 2, 4, -10], [[0, 5], [2, 4]]), ], ) def test_get_overlapping_regions(turning_points, expected_overlapping_regions): From 241b56debdf430096b69ce10ae7e15fbba8ac50b Mon Sep 17 00:00:00 2001 From: Yuchen Ethan Xiao Date: Wed, 17 Sep 2025 22:46:44 -0400 Subject: [PATCH 09/10] test: add convergence test for non-strctily-increasing squeeze morph --- src/diffpy/morph/morphs/morphsqueeze.py | 9 ++-- tests/test_morphsqueeze.py | 56 ++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/diffpy/morph/morphs/morphsqueeze.py b/src/diffpy/morph/morphs/morphsqueeze.py index 6529989..a3b3f24 100644 --- a/src/diffpy/morph/morphs/morphsqueeze.py +++ b/src/diffpy/morph/morphs/morphsqueeze.py @@ -100,9 +100,12 @@ def _set_squeeze_info(self, x, x_sorted): "and stretch parameter for a1." ) else: - overlapping_regions = self.get_overlapping_regions(x) - self.squeeze_info["monotonic"] = False - self.squeeze_info["overlapping_regions"] = overlapping_regions + if list(x) != list(x_sorted[::-1]): + overlapping_regions = self.get_overlapping_regions(x) + self.squeeze_info["monotonic"] = False + self.squeeze_info["overlapping_regions"] = ( + overlapping_regions + ) def _sort_squeeze(self, x, y): """Sort x,y according to the value of x.""" diff --git a/tests/test_morphsqueeze.py b/tests/test_morphsqueeze.py index a6c2dfa..da13bae 100644 --- a/tests/test_morphsqueeze.py +++ b/tests/test_morphsqueeze.py @@ -173,10 +173,64 @@ def test_morphsqueeze_extrapolate( single_morph(parser, opts, pargs, stdout_flag=False) +@pytest.mark.parametrize( + "squeeze_coeffs, x_morph", + [ + ({"a0": 0.01, "a1": -0.99, "a2": 0.01}, np.linspace(-1, 1, 101)), + ], +) +def test_sort_squeeze(user_filesystem, squeeze_coeffs, x_morph): + x_target = x_morph + y_target = np.sin(x_target) + 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) + # non-strictly-monotonic + assert not np.all(np.diff(np.sign(np.diff(x_squeezed))) == 0) + # outcome converges when --check-increase is not used + y_morph = np.sin(x_squeezed) + morph = MorphSqueeze() + morph.squeeze = squeeze_coeffs + with pytest.warns() as w: + moreph_results = morphpy.morph_arrays( + np.array([x_morph, y_morph]).T, + np.array([x_target, y_target]).T, + squeeze=[0.01, -0.99, 0.01], + ) + assert w[0].category is UserWarning + actual_wmsg = " ".join([str(w[i].message) for i in range(len(w))]) + expected_wmsg = ( + "Warning: The squeeze morph has interpolated your morphed " + "function from a non-monotonically increasing grid. " + ) + assert expected_wmsg in actual_wmsg + expected_coeffs = coeffs + actual_coeffs = [ + moreph_results[0]["squeeze"][f"a{i}"] + for i in range(len(moreph_results[0]["squeeze"])) + ] + # program exits when --check-increase is used + assert np.allclose(actual_coeffs, expected_coeffs, rtol=1e-2) + with pytest.raises(SystemExit) as excinfo: + morphpy.morph_arrays( + np.array([x_morph, y_morph]).T, + np.array([x_target, y_target]).T, + squeeze=[0.01, -1, 0.01], + check_increase=True, + ) + actual_emsg = str(excinfo.value) + expected_emsg = "2" + assert expected_emsg == actual_emsg + + @pytest.mark.parametrize( "squeeze_coeffs, x_morph", [ ({"a0": -1, "a1": -1, "a2": 2}, np.linspace(-1, 1, 101)), + ( + {"a0": -1, "a1": -1, "a2": 0, "a3": 0, "a4": 2}, + np.linspace(-1, 1, 101), + ), ], ) def test_sort_squeeze_bad(user_filesystem, squeeze_coeffs, x_morph): @@ -267,7 +321,7 @@ def test_sort_squeeze_bad(user_filesystem, squeeze_coeffs, x_morph): # x[-1] > x[0], monotonically decreasing regions are overlapping ([0, 10, 7, 12], [[7, 10]]), # x[-1] < x[0], monotonically increasing regions are overlapping - ([0, 5, 2, 4, -10], [[0, 5], [2, 4]]) + ([0, 5, 2, 4, -10], [[0, 5], [2, 4]]), ], ) def test_get_overlapping_regions(turning_points, expected_overlapping_regions): From bfc91867daf8273327e8dfd35825e137a75377e5 Mon Sep 17 00:00:00 2001 From: Yuchen Ethan Xiao Date: Sun, 12 Oct 2025 17:44:33 -0400 Subject: [PATCH 10/10] chore: clean the test cases --- src/diffpy/morph/morph_io.py | 9 +-- src/diffpy/morph/morphs/morphsqueeze.py | 41 ++----------- tests/test_morphsqueeze.py | 78 ++++++++++++++----------- 3 files changed, 52 insertions(+), 76 deletions(-) diff --git a/src/diffpy/morph/morph_io.py b/src/diffpy/morph/morph_io.py index 7727042..fbe06ec 100644 --- a/src/diffpy/morph/morph_io.py +++ b/src/diffpy/morph/morph_io.py @@ -447,17 +447,14 @@ def handle_extrapolation_warnings(squeeze_morph): def handle_check_increase_warning(squeeze_morph): if squeeze_morph is not None: - if squeeze_morph.squeeze_info["monotonic"]: + if squeeze_morph.strictly_increasing: wmsg = None else: - overlapping_regions = squeeze_morph.squeeze_info[ - "overlapping_regions" - ] wmsg = ( "Warning: The squeeze morph has interpolated your morphed " "function from a non-monotonically increasing grid. " - "This can result in strange behavior in the regions " - f"{overlapping_regions}. To disable this setting, " + "This can result in strange behavior in the non-uniqe " + "grid regions. To disable this setting, " "please enable --check-increasing." ) if wmsg: diff --git a/src/diffpy/morph/morphs/morphsqueeze.py b/src/diffpy/morph/morphs/morphsqueeze.py index a3b3f24..2bfbf90 100644 --- a/src/diffpy/morph/morphs/morphsqueeze.py +++ b/src/diffpy/morph/morphs/morphsqueeze.py @@ -73,8 +73,7 @@ def __init__(self, config=None, check_increase=False): super().__init__(config) self.check_increase = check_increase - def _set_squeeze_info(self, x, x_sorted): - self.squeeze_info = {"monotonic": True, "overlapping_regions": None} + def _ensure_strictly_increase(self, x, x_sorted): if list(x) != list(x_sorted): if self.check_increase: raise ValueError( @@ -100,12 +99,9 @@ def _set_squeeze_info(self, x, x_sorted): "and stretch parameter for a1." ) else: - if list(x) != list(x_sorted[::-1]): - overlapping_regions = self.get_overlapping_regions(x) - self.squeeze_info["monotonic"] = False - self.squeeze_info["overlapping_regions"] = ( - overlapping_regions - ) + self.strictly_increasing = False + else: + self.strictly_increasing = True def _sort_squeeze(self, x, y): """Sort x,y according to the value of x.""" @@ -114,32 +110,6 @@ def _sort_squeeze(self, x, y): x_sorted, y_sorted = list(zip(*xy_sorted)) return x_sorted, y_sorted - def get_overlapping_regions(self, x): - diffx = numpy.diff(x) - diffx_sign = numpy.sign(diffx) - local_min_or_max_index = ( - numpy.where(numpy.diff(diffx_sign) != 0)[0] + 1 - ) - monotonic_regions_x = numpy.concatenate( - ( - [x[0]], - numpy.repeat( - numpy.array(x)[local_min_or_max_index], 2 - ).tolist()[:-1], - ) - ).reshape(-1, 2) - monotinic_regions_sign = diffx_sign[local_min_or_max_index - 1] - - overlapping_regions_sign = -1 if x[0] < x[-1] else 1 - overlapping_regions_index = numpy.where( - monotinic_regions_sign == overlapping_regions_sign - )[0] - overlapping_regions = monotonic_regions_x[overlapping_regions_index] - overlapping_regions = [ - sorted(region) for region in overlapping_regions - ] - return overlapping_regions - def _handle_duplicates(self, x, y): """Remove duplicated x and use the mean value of y corresponded to the duplicated x.""" @@ -159,14 +129,13 @@ def morph(self, x_morph, y_morph, x_target, y_target): data. """ Morph.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) x_squeezed_sorted, y_morph_sorted = self._sort_squeeze( x_squeezed, self.y_morph_in ) - self._set_squeeze_info(x_squeezed, x_squeezed_sorted) + self._ensure_strictly_increase(x_squeezed, x_squeezed_sorted) x_squeezed_sorted, y_morph_sorted = self._handle_duplicates( x_squeezed_sorted, y_morph_sorted ) diff --git a/tests/test_morphsqueeze.py b/tests/test_morphsqueeze.py index da13bae..6e05aa4 100644 --- a/tests/test_morphsqueeze.py +++ b/tests/test_morphsqueeze.py @@ -176,26 +176,38 @@ def test_morphsqueeze_extrapolate( @pytest.mark.parametrize( "squeeze_coeffs, x_morph", [ - ({"a0": 0.01, "a1": -0.99, "a2": 0.01}, np.linspace(-1, 1, 101)), + ({"a0": 0.01, "a1": 0.01, "a2": -0.1}, np.linspace(0, 10, 101)), ], ) -def test_sort_squeeze(user_filesystem, squeeze_coeffs, x_morph): +def test_non_strictly_increasing_squeeze(squeeze_coeffs, x_morph): x_target = x_morph y_target = np.sin(x_target) 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) - # non-strictly-monotonic - assert not np.all(np.diff(np.sign(np.diff(x_squeezed))) == 0) - # outcome converges when --check-increase is not used + # non-strictly-increasing + assert not np.all(np.sign(np.diff(x_squeezed)) > 0) y_morph = np.sin(x_squeezed) - morph = MorphSqueeze() - morph.squeeze = squeeze_coeffs + # all zero initial guess + morph_results = morphpy.morph_arrays( + np.array([x_morph, y_morph]).T, + np.array([x_target, y_target]).T, + squeeze=[0, 0, 0], + apply=True, + ) + _, y_morph_actual = morph_results[1].T # noqa: F841 + y_morph_expected = np.sin(x_morph) # noqa: F841 + # squeeze morph extrapolates. + # Need to extract extrap_index from morph_results to examine + # the convergence. + # assert np.allclose(y_morph_actual, y_morph_expected, atol=1e-3) + # Raise warning when called without --check-increase with pytest.warns() as w: - moreph_results = morphpy.morph_arrays( + morph_results = morphpy.morph_arrays( np.array([x_morph, y_morph]).T, np.array([x_target, y_target]).T, - squeeze=[0.01, -0.99, 0.01], + squeeze=[0.01, 0.01, -0.1], + apply=True, ) assert w[0].category is UserWarning actual_wmsg = " ".join([str(w[i].message) for i in range(len(w))]) @@ -204,18 +216,18 @@ def test_sort_squeeze(user_filesystem, squeeze_coeffs, x_morph): "function from a non-monotonically increasing grid. " ) assert expected_wmsg in actual_wmsg - expected_coeffs = coeffs - actual_coeffs = [ - moreph_results[0]["squeeze"][f"a{i}"] - for i in range(len(moreph_results[0]["squeeze"])) - ] - # program exits when --check-increase is used - assert np.allclose(actual_coeffs, expected_coeffs, rtol=1e-2) + _, y_morph_actual = morph_results[1].T # noqa: F841 + y_morph_expected = np.sin(x_morph) # noqa: F841 + # squeeze morph extrapolates. + # Need to extract extrap_index from morph_results to examine + # the convergence. + # assert np.allclose(y_morph_actual, y_morph_expected, atol=1e-3) + # System exits when called with --check-increase with pytest.raises(SystemExit) as excinfo: morphpy.morph_arrays( np.array([x_morph, y_morph]).T, np.array([x_target, y_target]).T, - squeeze=[0.01, -1, 0.01], + squeeze=[0.01, 0.009, -0.1], check_increase=True, ) actual_emsg = str(excinfo.value) @@ -315,24 +327,22 @@ def test_sort_squeeze_bad(user_filesystem, squeeze_coeffs, x_morph): assert expected_emsg in actual_emsg -@pytest.mark.parametrize( - "turning_points, expected_overlapping_regions", - [ - # x[-1] > x[0], monotonically decreasing regions are overlapping - ([0, 10, 7, 12], [[7, 10]]), - # x[-1] < x[0], monotonically increasing regions are overlapping - ([0, 5, 2, 4, -10], [[0, 5], [2, 4]]), - ], -) -def test_get_overlapping_regions(turning_points, expected_overlapping_regions): +def test_handle_duplicates(): + unq_x = np.linspace(0, 11, 10) + iter = 10 morph = MorphSqueeze() - regions = ( - np.linspace(turning_points[i], turning_points[i + 1], 20) - for i in range(len(turning_points) - 1) - ) - x_value = np.concatenate(list(regions)) - actual_overlaping_regions = morph.get_overlapping_regions(x_value) - assert expected_overlapping_regions == actual_overlaping_regions + for i in range(iter): + actual_x = np.random.choice(unq_x, size=20) + actual_y = np.sin(actual_x) + actual_handled_x, actual_handled_y = morph._handle_duplicates( + actual_x, actual_y + ) + expected_handled_x = np.unique(actual_x) + expected_handled_y = np.array( + [actual_y[actual_x == x].mean() for x in expected_handled_x] + ) + assert np.allclose(actual_handled_x, expected_handled_x) + assert np.allclose(actual_handled_y, expected_handled_y) def create_morph_data_file(