From 114cb65046dea151d9e49394250f79a28d12b2a7 Mon Sep 17 00:00:00 2001 From: Simon Billinge Date: Sun, 1 Dec 2024 08:44:59 -0500 Subject: [PATCH 1/2] refactor the transforms out of DiffractionObjects to make them more wiedely available in general --- news/private_f.rst | 24 ++ .../scattering_objects/diffraction_objects.py | 230 ++---------------- src/diffpy/utils/transforms.py | 122 ++++++++++ .../test_diffraction_objects.py | 151 +----------- tests/test_transforms.py | 99 ++++++++ 5 files changed, 271 insertions(+), 355 deletions(-) create mode 100644 news/private_f.rst create mode 100644 src/diffpy/utils/transforms.py create mode 100644 tests/test_transforms.py diff --git a/news/private_f.rst b/news/private_f.rst new file mode 100644 index 00000000..b67df7b7 --- /dev/null +++ b/news/private_f.rst @@ -0,0 +1,24 @@ +**Added:** + +* + +**Changed:** + +* refactor `q_to_tth()` and `tth_to_q()` into `diffpy.utils.transforms` to make them available outside + DiffractionObject + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/src/diffpy/utils/scattering_objects/diffraction_objects.py b/src/diffpy/utils/scattering_objects/diffraction_objects.py index 2017b926..4351c537 100644 --- a/src/diffpy/utils/scattering_objects/diffraction_objects.py +++ b/src/diffpy/utils/scattering_objects/diffraction_objects.py @@ -5,6 +5,7 @@ import numpy as np from diffpy.utils.tools import get_package_info +from diffpy.utils.transforms import q_to_tth, tth_to_q QQUANTITIES = ["q"] ANGLEQUANTITIES = ["angle", "tth", "twotheta", "2theta"] @@ -17,23 +18,6 @@ "and specifying how to handle the mismatch." ) -wavelength_warning_emsg = ( - "INFO: no wavelength has been specified. You can continue " - "to use the DiffractionObject but some of its powerful features " - "will not be available. To specify a wavelength, set " - "diffraction_object.wavelength = [number], " - "where diffraction_object is the variable name of you Diffraction Object, " - "and number is the wavelength in angstroms." -) - -length_mismatch_emsg = "Please ensure {array_name} array and intensity array are of the same length." -non_numeric_value_emsg = "Invalid value found in {array_name} array. Please ensure all values are numeric." -invalid_tth_emsg = "Two theta exceeds 180 degrees. Please check the input values for errors." -invalid_q_or_wavelength_emsg = ( - "The supplied q-array and wavelength will result in an impossible two-theta. " - "Please check these values and re-instantiate the DiffractionObject with correct values." -) - class Diffraction_object: """A class to represent and manipulate data associated with diffraction experiments. @@ -54,9 +38,9 @@ def __init__(self, name="", wavelength=None): self.name = name self.wavelength = wavelength self.scat_quantity = "" - self.on_q = [np.empty(0), np.empty(0)] - self.on_tth = [np.empty(0), np.empty(0)] - self.on_d = [np.empty(0), np.empty(0)] + self.on_q = np.array([np.empty(0), np.empty(0)]) + self.on_tth = np.array([np.empty(0), np.empty(0)]) + self.on_d = np.array([np.empty(0), np.empty(0)]) self._all_arrays = [self.on_q, self.on_tth] self.metadata = {} @@ -311,97 +295,19 @@ def insert_scattering_quantity( if wavelength is not None: self.wavelength = wavelength if xtype.lower() in QQUANTITIES: - self.on_q = [np.array(xarray), np.array(yarray)] + self.on_q = np.array([xarray, yarray]) elif xtype.lower() in ANGLEQUANTITIES: - self.on_tth = [np.array(xarray), np.array(yarray)] + self.on_tth = np.array([xarray, yarray]) elif xtype.lower() in DQUANTITIES: - self.on_tth = [np.array(xarray), np.array(yarray)] + self.on_tth = np.array([xarray, yarray]) self.set_all_arrays() - def q_to_tth(self): - r""" - Helper function to convert q to two-theta. - - By definition the relationship is: - - .. math:: - - \sin\left(\frac{2\theta}{2}\right) = \frac{\lambda q}{4 \pi} - - thus - - .. math:: - - 2\theta_n = 2 \arcsin\left(\frac{\lambda q}{4 \pi}\right) - - Parameters - ---------- - q : array - An array of :math:`q` values - - wavelength : float - Wavelength of the incoming x-rays - - Function adapted from scikit-beam. Thanks to those developers - - Returns - ------- - two_theta : array - An array of :math:`2\theta` values in radians - """ - q = self.on_q[0] - q = np.asarray(q) - wavelength = float(self.wavelength) - pre_factor = wavelength / (4 * np.pi) - return np.rad2deg(2.0 * np.arcsin(q * pre_factor)) - - def tth_to_q(self): - r""" - Helper function to convert two-theta to q - - By definition the relationship is - - .. math:: - - \sin\left(\frac{2\theta}{2}\right) = \frac{\lambda q}{4 \pi} - - thus - - .. math:: - - q = \frac{4 \pi \sin\left(\frac{2\theta}{2}\right)}{\lambda} - - - - Parameters - ---------- - two_theta : array - An array of :math:`2\theta` values in units of degrees - - wavelength : float - Wavelength of the incoming x-rays - - Function adapted from scikit-beam. Thanks to those developers. - - Returns - ------- - q : array - An array of :math:`q` values in the inverse of the units - of ``wavelength`` - """ - two_theta = np.asarray(np.deg2rad(self.on_tth[0])) - wavelength = float(self.wavelength) - pre_factor = (4 * np.pi) / wavelength - return pre_factor * np.sin(two_theta / 2) - def set_all_arrays(self): master_array, xtype = self._get_original_array() if xtype == "q": - self.on_tth[0] = self.q_to_tth() - self.on_tth[1] = master_array[1] - if xtype == "tth": - self.on_q[0] = self.tth_to_q() - self.on_q[1] = master_array[1] + self.on_tth = q_to_tth(self.on_q, self.wavelength) + elif xtype == "tth": + self.on_q = tth_to_q(self.on_tth, self.wavelength) self.tthmin = self.on_tth[0][0] self.tthmax = self.on_tth[0][-1] self.qmin = self.on_q[0][0] @@ -500,9 +406,9 @@ def __init__(self, name="", wavelength=None): self.name = name self.wavelength = wavelength self.scat_quantity = "" - self.on_q = [np.empty(0), np.empty(0)] - self.on_tth = [np.empty(0), np.empty(0)] - self.on_d = [np.empty(0), np.empty(0)] + self.on_q = np.empty((2, 0), dtype=np.float64) + self.on_tth = np.empty((2, 0), dtype=np.float64) + self.on_d = np.empty((2, 0), dtype=np.float64) self._all_arrays = [self.on_q, self.on_tth] self.metadata = {} @@ -757,115 +663,19 @@ def insert_scattering_quantity( if wavelength is not None: self.wavelength = wavelength if xtype.lower() in QQUANTITIES: - self.on_q = [np.array(xarray), np.array(yarray)] + self.on_q = np.array([xarray, yarray]) elif xtype.lower() in ANGLEQUANTITIES: - self.on_tth = [np.array(xarray), np.array(yarray)] - elif xtype.lower() in DQUANTITIES: - self.on_tth = [np.array(xarray), np.array(yarray)] + self.on_tth = np.array([xarray, yarray]) + elif xtype.lower() in DQUANTITIES: # Fixme when d is implemented. This here as a placeholder + self.on_tth = np.array([xarray, yarray]) self.set_all_arrays() - def q_to_tth(self): - r""" - Helper function to convert q to two-theta. - - By definition the relationship is: - - .. math:: - - \sin\left(\frac{2\theta}{2}\right) = \frac{\lambda q}{4 \pi} - - thus - - .. math:: - - 2\theta_n = 2 \arcsin\left(\frac{\lambda q}{4 \pi}\right) - - Function adapted from scikit-beam. Thanks to those developers - - Parameters - ---------- - q : array - The array of :math:`q` values - - wavelength : float - Wavelength of the incoming x-rays - - Returns - ------- - two_theta : array - The array of :math:`2\theta` values in radians - """ - for i, value in enumerate(self.on_q[0]): - if not isinstance(value, (int, float)): - raise TypeError(non_numeric_value_emsg.format(array_name="q")) - if len(self.on_q[0]) != len(self.on_q[1]): - raise RuntimeError(length_mismatch_emsg.format(array_name="q")) - if self.wavelength is None: - warnings.warn(wavelength_warning_emsg, UserWarning) - return np.empty(0) - q = self.on_q[0] - q = np.asarray(q) - wavelength = float(self.wavelength) - pre_factor = wavelength / (4 * np.pi) - if np.any(np.abs(q * pre_factor) > 1): - raise ValueError(invalid_q_or_wavelength_emsg) - return np.rad2deg(2.0 * np.arcsin(q * pre_factor)) - - def tth_to_q(self): - r""" - Helper function to convert two-theta to q - - By definition the relationship is - - .. math:: - - \sin\left(\frac{2\theta}{2}\right) = \frac{\lambda q}{4 \pi} - - thus - - .. math:: - - q = \frac{4 \pi \sin\left(\frac{2\theta}{2}\right)}{\lambda} - - Function adapted from scikit-beam. Thanks to those developers. - - Parameters - ---------- - two_theta : array - The array of :math:`2\theta` values in units of degrees - - wavelength : float - Wavelength of the incoming x-rays - - Returns - ------- - q : array - The array of :math:`q` values in the inverse of the units - of ``wavelength`` - """ - for i, value in enumerate(self.on_tth[0]): - if not isinstance(value, (int, float)): - raise TypeError(non_numeric_value_emsg.format(array_name="two theta")) - if len(self.on_tth[0]) != len(self.on_tth[1]): - raise RuntimeError(length_mismatch_emsg.format(array_name="two theta")) - two_theta = np.asarray(np.deg2rad(self.on_tth[0])) - if np.any(two_theta > np.pi): - raise ValueError(invalid_tth_emsg) - if self.wavelength is None: - warnings.warn(wavelength_warning_emsg, UserWarning) - return np.empty(0) - wavelength = float(self.wavelength) - pre_factor = (4 * np.pi) / wavelength - return pre_factor * np.sin(two_theta / 2) - def set_all_arrays(self): master_array, xtype = self._get_original_array() if xtype == "q": - self.on_tth[0] = self.q_to_tth() - self.on_tth[1] = master_array[1] - if xtype == "tth": - self.on_q[0] = self.tth_to_q() - self.on_q[1] = master_array[1] + self.on_tth = q_to_tth(self.on_q, self.wavelength) + elif xtype == "tth": + self.on_q = tth_to_q(self.on_tth, self.wavelength) self.tthmin = self.on_tth[0][0] self.tthmax = self.on_tth[0][-1] self.qmin = self.on_q[0][0] diff --git a/src/diffpy/utils/transforms.py b/src/diffpy/utils/transforms.py new file mode 100644 index 00000000..08be3786 --- /dev/null +++ b/src/diffpy/utils/transforms.py @@ -0,0 +1,122 @@ +import warnings +from copy import copy + +import numpy as np + +wavelength_warning_emsg = ( + "INFO: no wavelength has been specified. You can continue " + "to use the DiffractionObject but some of its powerful features " + "will not be available. To specify a wavelength, set " + "diffraction_object.wavelength = [number], " + "where diffraction_object is the variable name of you Diffraction Object, " + "and number is the wavelength in angstroms." +) +invalid_tth_emsg = "Two theta exceeds 180 degrees. Please check the input values for errors." +invalid_q_or_wavelength_emsg = ( + "The supplied q-array and wavelength will result in an impossible two-theta. " + "Please check these values and re-instantiate the DiffractionObject with correct values." +) + + +def _validate_inputs(on_q, wavelength): + if wavelength is None: + warnings.warn(wavelength_warning_emsg, UserWarning) + return np.empty(0) + pre_factor = wavelength / (4 * np.pi) + if np.any(np.abs(on_q[0] * pre_factor) > 1.0): + raise ValueError(invalid_q_or_wavelength_emsg) + + +def q_to_tth(on_q, wavelength): + r""" + Helper function to convert q to two-theta. + + If wavelength is missing, returns x-values that are integer indexes + + By definition the relationship is: + + .. math:: + + \sin\left(\frac{2\theta}{2}\right) = \frac{\lambda q}{4 \pi} + + thus + + .. math:: + + 2\theta_n = 2 \arcsin\left(\frac{\lambda q}{4 \pi}\right) + + Parameters + ---------- + on_q : 2D array + The array of :math:`q` values and :math: 'i' intensity values, np.array([[qs], [is]]). + This is the same format as, and so can accept, diffpy.utils.DiffractionOject.on_q + The units of q must be reciprocal of the units of wavelength. + + wavelength : float + Wavelength of the incoming x-rays/neutrons/electrons + + Returns + ------- + on_tth : 2D array + The array of :math:`2\theta` values in degrees and :math: 'i' intensity values unchanged, + np.array([[tths], [is]]). + This is the correct format for loading into diffpy.utils.DiffractionOject.on_tth + """ + _validate_inputs(on_q, wavelength) + on_q.astype(np.float64) + on_tth = copy(on_q) # initialize output array of same shape + if wavelength is not None: + on_tth[0] = np.rad2deg(2.0 * np.arcsin(on_q[0] * wavelength / (4 * np.pi))) + else: # return intensities vs. an x-array that is just the index + for i, _ in enumerate(on_q[0]): + on_tth[0][i] = i + return on_tth + + +def tth_to_q(on_tth, wavelength): + r""" + Helper function to convert two-theta to q on independent variable axis. + + If wavelength is missing, returns independent variable axis as integer indexes. + + By definition the relationship is: + + .. math:: + + \sin\left(\frac{2\theta}{2}\right) = \frac{\lambda q}{4 \pi} + + thus + + .. math:: + + q = \frac{4 \pi \sin\left(\frac{2\theta}{2}\right)}{\lambda} + + Parameters + ---------- + on_tth : 2D array + The array of :math:`2\theta` values and :math: 'i' intensity values, np.array([[tths], [is]]). + This is the same format as, and so can accept, diffpy.utils.DiffractionOject.on_tth + The units of tth are expected in degrees. + + wavelength : float + Wavelength of the incoming x-rays/neutrons/electrons + + Returns + ------- + on_q : 2D array + The array of :math:`q` values and :math: 'i' intensity values unchanged, + np.array([[qs], [is]]). + The units for the q-values are the inverse of the units of the provided wavelength. + This is the correct format for loading into diffpy.utils.DiffractionOject.on_q + """ + on_tth.astype(np.float64) + if np.any(np.deg2rad(on_tth[0]) > np.pi): + raise ValueError(invalid_tth_emsg) + on_q = copy(on_tth) + if wavelength is not None: + pre_factor = (4.0 * np.pi) / wavelength + on_q[0] = pre_factor * np.sin(np.deg2rad(on_tth[0] / 2)) + else: # return intensities vs. an x-array that is just the index + for i, _ in enumerate(on_q[0]): + on_q[0][i] = i + return on_q diff --git a/tests/diffpy/utils/scattering_objects/test_diffraction_objects.py b/tests/diffpy/utils/scattering_objects/test_diffraction_objects.py index 3bdc22d4..ada14ff1 100644 --- a/tests/diffpy/utils/scattering_objects/test_diffraction_objects.py +++ b/tests/diffpy/utils/scattering_objects/test_diffraction_objects.py @@ -4,7 +4,8 @@ import pytest from freezegun import freeze_time -from diffpy.utils.scattering_objects.diffraction_objects import DiffractionObject, wavelength_warning_emsg +from diffpy.utils.scattering_objects.diffraction_objects import DiffractionObject +from diffpy.utils.transforms import wavelength_warning_emsg params = [ ( # Default @@ -240,146 +241,6 @@ def _test_valid_diffraction_objects(actual_diffraction_object, function, expecte return np.allclose(actual_array, expected_array) -params_q_to_tth = [ - # UC1: User specified empty q values (without wavelength) - ([None, [], []], [[]]), - # UC2: User specified empty q values (with wavelength) - ([4 * np.pi, [], []], [[]]), - # UC3: User specified valid q values (without wavelength) - ([None, [0, 0.2, 0.4, 0.6, 0.8, 1], [1, 2, 3, 4, 5, 6]], [[]]), - # UC4: User specified valid q values (with wavelength) - # expected tth values are 2*arcsin(q) in degrees - ( - [4 * np.pi, [0, 0.2, 0.4, 0.6, 0.8, 1], [1, 2, 3, 4, 5, 6]], - [[0, 23.07392, 47.15636, 73.73980, 106.26020, 180]], - ), -] - - -@pytest.mark.parametrize("inputs, expected", params_q_to_tth) -def test_q_to_tth(inputs, expected): - actual = DiffractionObject(wavelength=inputs[0]) - actual.on_q = [inputs[1], inputs[2]] - expected_tth = expected[0] - assert _test_valid_diffraction_objects(actual, "q_to_tth", expected_tth) - - -params_q_to_tth_bad = [ - # UC1: user specified invalid q values that result in tth > 180 degrees - ( - [4 * np.pi, [0.2, 0.4, 0.6, 0.8, 1, 1.2], [1, 2, 3, 4, 5, 6]], - [ - ValueError, - "The supplied q-array and wavelength will result in an impossible two-theta. " - "Please check these values and re-instantiate the DiffractionObject with correct values.", - ], - ), - # UC2: user specified a wrong wavelength that result in tth > 180 degrees - ( - [100, [0, 0.2, 0.4, 0.6, 0.8, 1], [1, 2, 3, 4, 5, 6]], - [ - ValueError, - "The supplied q-array and wavelength will result in an impossible two-theta. " - "Please check these values and re-instantiate the DiffractionObject with correct values.", - ], - ), - # UC3: user specified a q array that does not match the length of intensity array (without wavelength) - ( - [None, [0, 0.2, 0.4, 0.6, 0.8, 1], [1, 2, 3, 4, 5]], - [RuntimeError, "Please ensure q array and intensity array are of the same length."], - ), - # UC4: user specified a q array that does not match the length of intensity array (with wavelength) - ( - [4 * np.pi, [0, 0.2, 0.4, 0.6, 0.8, 1], [1, 2, 3, 4, 5]], - [RuntimeError, "Please ensure q array and intensity array are of the same length."], - ), - # UC5: user specified a non-numeric value in q array (without wavelength) - ( - [None, [0, 0.2, 0.4, 0.6, 0.8, "invalid"], [1, 2, 3, 4, 5, 6]], - [TypeError, "Invalid value found in q array. Please ensure all values are numeric."], - ), - # UC5: user specified a non-numeric value in q array (with wavelength) - ( - [4 * np.pi, [0, 0.2, 0.4, 0.6, 0.8, "invalid"], [1, 2, 3, 4, 5, 6]], - [TypeError, "Invalid value found in q array. Please ensure all values are numeric."], - ), -] - - -@pytest.mark.parametrize("inputs, expected", params_q_to_tth_bad) -def test_q_to_tth_bad(inputs, expected): - actual = DiffractionObject(wavelength=inputs[0]) - actual.on_q = [inputs[1], inputs[2]] - with pytest.raises(expected[0], match=expected[1]): - actual.q_to_tth() - - -params_tth_to_q = [ - # UC1: User specified empty tth values (without wavelength) - ([None, [], []], [[]]), - # UC2: User specified empty tth values (with wavelength) - ([4 * np.pi, [], []], [[]]), - # UC3: User specified valid tth values between 0-180 degrees (without wavelength) - ( - [None, [0, 30, 60, 90, 120, 180], [1, 2, 3, 4, 5, 6]], - [[]], - ), - # UC4: User specified valid tth values between 0-180 degrees (with wavelength) - # expected q vales are sin15, sin30, sin45, sin60, sin90 - ([4 * np.pi, [0, 30, 60, 90, 120, 180], [1, 2, 3, 4, 5, 6]], [[0, 0.258819, 0.5, 0.707107, 0.866025, 1]]), -] - - -@pytest.mark.parametrize("inputs, expected", params_tth_to_q) -def test_tth_to_q(inputs, expected): - actual = DiffractionObject(wavelength=inputs[0]) - actual.on_tth = [inputs[1], inputs[2]] - expected_q = expected[0] - assert _test_valid_diffraction_objects(actual, "tth_to_q", expected_q) - - -params_tth_to_q_bad = [ - # UC1: user specified an invalid tth value of > 180 degrees (without wavelength) - ( - [None, [0, 30, 60, 90, 120, 181], [1, 2, 3, 4, 5, 6]], - [ValueError, "Two theta exceeds 180 degrees. Please check the input values for errors."], - ), - # UC2: user specified an invalid tth value of > 180 degrees (with wavelength) - ( - [4 * np.pi, [0, 30, 60, 90, 120, 181], [1, 2, 3, 4, 5, 6]], - [ValueError, "Two theta exceeds 180 degrees. Please check the input values for errors."], - ), - # UC3: user specified a two theta array that does not match the length of intensity array (without wavelength) - ( - [None, [0, 30, 60, 90, 120], [1, 2, 3, 4, 5, 6]], - [RuntimeError, "Please ensure two theta array and intensity array are of the same length."], - ), - # UC4: user specified a two theta array that does not match the length of intensity array (with wavelength) - ( - [4 * np.pi, [0, 30, 60, 90, 120], [1, 2, 3, 4, 5, 6]], - [RuntimeError, "Please ensure two theta array and intensity array are of the same length."], - ), - # UC5: user specified a non-numeric value in two theta array (without wavelength) - ( - [None, [0, 30, 60, 90, 120, "invalid"], [1, 2, 3, 4, 5, 6]], - [TypeError, "Invalid value found in two theta array. Please ensure all values are numeric."], - ), - # UC6: user specified a non-numeric value in two theta array (with wavelength) - ( - [4 * np.pi, [0, 30, 60, 90, 120, "invalid"], [1, 2, 3, 4, 5, 6]], - [TypeError, "Invalid value found in two theta array. Please ensure all values are numeric."], - ), -] - - -@pytest.mark.parametrize("inputs, expected", params_tth_to_q_bad) -def test_tth_to_q_bad(inputs, expected): - actual = DiffractionObject(wavelength=inputs[0]) - actual.on_tth = [inputs[1], inputs[2]] - with pytest.raises(expected[0], match=expected[1]): - actual.tth_to_q() - - def test_dump(tmp_path, mocker): x, y = np.linspace(0, 5, 6), np.linspace(0, 5, 6) directory = Path(tmp_path) @@ -389,14 +250,14 @@ def test_dump(tmp_path, mocker): test.name = "test" test.scat_quantity = "x-ray" test.insert_scattering_quantity( - x, y, "q", metadata={"thing1": 1, "thing2": "thing2", "package_info": {"package2": "3.4.5"}} + np.array(x), + np.array(y), + "q", + metadata={"thing1": 1, "thing2": "thing2", "package_info": {"package2": "3.4.5"}}, ) - mocker.patch("importlib.metadata.version", return_value="3.3.0") - with freeze_time("2012-01-14"): test.dump(file, "q") - with open(file, "r") as f: actual = f.read() expected = ( diff --git a/tests/test_transforms.py b/tests/test_transforms.py new file mode 100644 index 00000000..d5c5ccfa --- /dev/null +++ b/tests/test_transforms.py @@ -0,0 +1,99 @@ +import numpy as np +import pytest + +from diffpy.utils.transforms import q_to_tth, tth_to_q + +params_q_to_tth = [ + # UC1: Empty q values, no wavelength, return empty arrays + ([None, np.empty((2, 0))], np.empty((2, 0))), + # UC2: Empty q values, wavelength specified, return empty arrays + ([4 * np.pi, np.empty((2, 0))], np.empty((2, 0))), + # UC3: User specified valid q values, no wavelength, return empty arrays + ( + [None, np.array([[0, 0.2, 0.4, 0.6, 0.8, 1], [1, 2, 3, 4, 5, 6]])], + np.array([[0, 1, 2, 3, 4, 5], [1, 2, 3, 4, 5, 6]]), + ), + # UC4: User specified valid q values (with wavelength) + # expected tth values are 2*arcsin(q) in degrees + ([4 * np.pi, np.array([[0, 1 / np.sqrt(2), 1.0], [1, 2, 3]])], np.array([[0, 90.0, 180.0], [1, 2, 3]])), +] + + +@pytest.mark.parametrize("inputs, expected", params_q_to_tth) +def test_q_to_tth(inputs, expected): + actual = q_to_tth(inputs[1], inputs[0]) + assert np.allclose(expected[0], actual[0]) + assert np.allclose(expected[1], actual[1]) + + +params_q_to_tth_bad = [ + # UC1: user specified invalid q values that result in tth > 180 degrees + ( + [4 * np.pi, np.array([[0.2, 0.4, 0.6, 0.8, 1, 1.2], [1, 2, 3, 4, 5, 6]])], + [ + ValueError, + "The supplied q-array and wavelength will result in an impossible two-theta. " + "Please check these values and re-instantiate the DiffractionObject with correct values.", + ], + ), + # UC2: user specified a wrong wavelength that result in tth > 180 degrees + ( + [100, np.array([[0, 0.2, 0.4, 0.6, 0.8, 1], [1, 2, 3, 4, 5, 6]])], + [ + ValueError, + "The supplied q-array and wavelength will result in an impossible two-theta. " + "Please check these values and re-instantiate the DiffractionObject with correct values.", + ], + ), +] + + +@pytest.mark.parametrize("inputs, expected", params_q_to_tth_bad) +def test_q_to_tth_bad(inputs, expected): + with pytest.raises(expected[0], match=expected[1]): + q_to_tth(inputs[1], inputs[0]) + + +params_tth_to_q = [ + # UC0: User specified empty tth values (without wavelength) + ([None, np.array([[], []])], np.array([[], []])), + # UC1: User specified empty tth values (with wavelength) + ([4 * np.pi, np.array([[], []])], np.array([[], []])), + # UC2: User specified valid tth values between 0-180 degrees (without wavelength) + ( + [None, np.array([[0, 30, 60, 90, 120, 180], [1, 2, 3, 4, 5, 6]])], + np.array([[0, 1, 2, 3, 4, 5], [1, 2, 3, 4, 5, 6]]), + ), + # UC3: User specified valid tth values between 0-180 degrees (with wavelength) + # expected q vales are sin15, sin30, sin45, sin60, sin90 + ( + [4 * np.pi, np.array([[0, 30.0, 60.0, 90.0, 120.0, 180.0], [1, 2, 3, 4, 5, 6]])], + np.array([[0, 0.258819, 0.5, 0.707107, 0.866025, 1], [1, 2, 3, 4, 5, 6]]), + ), +] + + +@pytest.mark.parametrize("inputs, expected", params_tth_to_q) +def test_tth_to_q(inputs, expected): + actual = tth_to_q(inputs[1], inputs[0]) + assert np.allclose(actual, expected) + + +params_tth_to_q_bad = [ + # UC0: user specified an invalid tth value of > 180 degrees (without wavelength) + ( + [None, np.array([[0, 30, 60, 90, 120, 181], [1, 2, 3, 4, 5, 6]])], + [ValueError, "Two theta exceeds 180 degrees. Please check the input values for errors."], + ), + # UC1: user specified an invalid tth value of > 180 degrees (with wavelength) + ( + [4 * np.pi, np.array([[0, 30, 60, 90, 120, 181], [1, 2, 3, 4, 5, 6]])], + [ValueError, "Two theta exceeds 180 degrees. Please check the input values for errors."], + ), +] + + +@pytest.mark.parametrize("inputs, expected", params_tth_to_q_bad) +def test_tth_to_q_bad(inputs, expected): + with pytest.raises(expected[0], match=expected[1]): + tth_to_q(inputs[1], inputs[0]) From e55e44d44929620747eeef1941b6702482c4ad07 Mon Sep 17 00:00:00 2001 From: Simon Billinge Date: Mon, 2 Dec 2024 14:25:18 -0500 Subject: [PATCH 2/2] public functions now operate on 1D x-arrays only for ease of user use --- src/diffpy/utils/transforms.py | 49 +++++++++++++++++----------------- tests/test_transforms.py | 33 +++++++++++------------ 2 files changed, 40 insertions(+), 42 deletions(-) diff --git a/src/diffpy/utils/transforms.py b/src/diffpy/utils/transforms.py index 08be3786..773aa607 100644 --- a/src/diffpy/utils/transforms.py +++ b/src/diffpy/utils/transforms.py @@ -18,16 +18,16 @@ ) -def _validate_inputs(on_q, wavelength): +def _validate_inputs(q, wavelength): if wavelength is None: warnings.warn(wavelength_warning_emsg, UserWarning) return np.empty(0) pre_factor = wavelength / (4 * np.pi) - if np.any(np.abs(on_q[0] * pre_factor) > 1.0): + if np.any(np.abs(q * pre_factor) > 1.0): raise ValueError(invalid_q_or_wavelength_emsg) -def q_to_tth(on_q, wavelength): +def q_to_tth(q, wavelength): r""" Helper function to convert q to two-theta. @@ -47,9 +47,8 @@ def q_to_tth(on_q, wavelength): Parameters ---------- - on_q : 2D array - The array of :math:`q` values and :math: 'i' intensity values, np.array([[qs], [is]]). - This is the same format as, and so can accept, diffpy.utils.DiffractionOject.on_q + q : 1D array + The array of :math:`q` values numpy.array([qs]). The units of q must be reciprocal of the units of wavelength. wavelength : float @@ -57,24 +56,24 @@ def q_to_tth(on_q, wavelength): Returns ------- - on_tth : 2D array - The array of :math:`2\theta` values in degrees and :math: 'i' intensity values unchanged, - np.array([[tths], [is]]). + tth : 1D array + The array of :math:`2\theta` values in degrees numpy.array([tths]). This is the correct format for loading into diffpy.utils.DiffractionOject.on_tth """ - _validate_inputs(on_q, wavelength) - on_q.astype(np.float64) - on_tth = copy(on_q) # initialize output array of same shape + _validate_inputs(q, wavelength) + q.astype(np.float64) + tth = copy(q) # initialize output array of same shape if wavelength is not None: - on_tth[0] = np.rad2deg(2.0 * np.arcsin(on_q[0] * wavelength / (4 * np.pi))) + tth = np.rad2deg(2.0 * np.arcsin(q * wavelength / (4 * np.pi))) else: # return intensities vs. an x-array that is just the index - for i, _ in enumerate(on_q[0]): - on_tth[0][i] = i - return on_tth + for i, _ in enumerate(q): + tth[i] = i + return tth -def tth_to_q(on_tth, wavelength): +def tth_to_q(tth, wavelength): r""" + Helper function to convert two-theta to q on independent variable axis. If wavelength is missing, returns independent variable axis as integer indexes. @@ -93,7 +92,7 @@ def tth_to_q(on_tth, wavelength): Parameters ---------- - on_tth : 2D array + tth : 2D array The array of :math:`2\theta` values and :math: 'i' intensity values, np.array([[tths], [is]]). This is the same format as, and so can accept, diffpy.utils.DiffractionOject.on_tth The units of tth are expected in degrees. @@ -109,14 +108,14 @@ def tth_to_q(on_tth, wavelength): The units for the q-values are the inverse of the units of the provided wavelength. This is the correct format for loading into diffpy.utils.DiffractionOject.on_q """ - on_tth.astype(np.float64) - if np.any(np.deg2rad(on_tth[0]) > np.pi): + tth.astype(np.float64) + if np.any(np.deg2rad(tth) > np.pi): raise ValueError(invalid_tth_emsg) - on_q = copy(on_tth) + q = copy(tth) if wavelength is not None: pre_factor = (4.0 * np.pi) / wavelength - on_q[0] = pre_factor * np.sin(np.deg2rad(on_tth[0] / 2)) + q = pre_factor * np.sin(np.deg2rad(tth / 2)) else: # return intensities vs. an x-array that is just the index - for i, _ in enumerate(on_q[0]): - on_q[0][i] = i - return on_q + for i, _ in enumerate(q): + q[i] = i + return q diff --git a/tests/test_transforms.py b/tests/test_transforms.py index d5c5ccfa..7e3eae3f 100644 --- a/tests/test_transforms.py +++ b/tests/test_transforms.py @@ -5,31 +5,30 @@ params_q_to_tth = [ # UC1: Empty q values, no wavelength, return empty arrays - ([None, np.empty((2, 0))], np.empty((2, 0))), + ([None, np.empty((1,))], np.empty((1,))), # UC2: Empty q values, wavelength specified, return empty arrays - ([4 * np.pi, np.empty((2, 0))], np.empty((2, 0))), + ([4 * np.pi, np.empty((1,))], np.empty((1,))), # UC3: User specified valid q values, no wavelength, return empty arrays ( - [None, np.array([[0, 0.2, 0.4, 0.6, 0.8, 1], [1, 2, 3, 4, 5, 6]])], - np.array([[0, 1, 2, 3, 4, 5], [1, 2, 3, 4, 5, 6]]), + [None, np.array([0, 0.2, 0.4, 0.6, 0.8, 1])], + np.array([0, 1, 2, 3, 4, 5]), ), # UC4: User specified valid q values (with wavelength) # expected tth values are 2*arcsin(q) in degrees - ([4 * np.pi, np.array([[0, 1 / np.sqrt(2), 1.0], [1, 2, 3]])], np.array([[0, 90.0, 180.0], [1, 2, 3]])), + ([4 * np.pi, np.array([0, 1 / np.sqrt(2), 1.0])], np.array([0, 90.0, 180.0])), ] @pytest.mark.parametrize("inputs, expected", params_q_to_tth) def test_q_to_tth(inputs, expected): actual = q_to_tth(inputs[1], inputs[0]) - assert np.allclose(expected[0], actual[0]) - assert np.allclose(expected[1], actual[1]) + assert np.allclose(expected, actual) params_q_to_tth_bad = [ # UC1: user specified invalid q values that result in tth > 180 degrees ( - [4 * np.pi, np.array([[0.2, 0.4, 0.6, 0.8, 1, 1.2], [1, 2, 3, 4, 5, 6]])], + [4 * np.pi, np.array([0.2, 0.4, 0.6, 0.8, 1, 1.2])], [ ValueError, "The supplied q-array and wavelength will result in an impossible two-theta. " @@ -38,7 +37,7 @@ def test_q_to_tth(inputs, expected): ), # UC2: user specified a wrong wavelength that result in tth > 180 degrees ( - [100, np.array([[0, 0.2, 0.4, 0.6, 0.8, 1], [1, 2, 3, 4, 5, 6]])], + [100, np.array([0, 0.2, 0.4, 0.6, 0.8, 1])], [ ValueError, "The supplied q-array and wavelength will result in an impossible two-theta. " @@ -56,19 +55,19 @@ def test_q_to_tth_bad(inputs, expected): params_tth_to_q = [ # UC0: User specified empty tth values (without wavelength) - ([None, np.array([[], []])], np.array([[], []])), + ([None, np.array([])], np.array([])), # UC1: User specified empty tth values (with wavelength) - ([4 * np.pi, np.array([[], []])], np.array([[], []])), + ([4 * np.pi, np.array([])], np.array([])), # UC2: User specified valid tth values between 0-180 degrees (without wavelength) ( - [None, np.array([[0, 30, 60, 90, 120, 180], [1, 2, 3, 4, 5, 6]])], - np.array([[0, 1, 2, 3, 4, 5], [1, 2, 3, 4, 5, 6]]), + [None, np.array([0, 30, 60, 90, 120, 180])], + np.array([0, 1, 2, 3, 4, 5]), ), # UC3: User specified valid tth values between 0-180 degrees (with wavelength) # expected q vales are sin15, sin30, sin45, sin60, sin90 ( - [4 * np.pi, np.array([[0, 30.0, 60.0, 90.0, 120.0, 180.0], [1, 2, 3, 4, 5, 6]])], - np.array([[0, 0.258819, 0.5, 0.707107, 0.866025, 1], [1, 2, 3, 4, 5, 6]]), + [4 * np.pi, np.array([0, 30.0, 60.0, 90.0, 120.0, 180.0])], + np.array([0, 0.258819, 0.5, 0.707107, 0.866025, 1]), ), ] @@ -82,12 +81,12 @@ def test_tth_to_q(inputs, expected): params_tth_to_q_bad = [ # UC0: user specified an invalid tth value of > 180 degrees (without wavelength) ( - [None, np.array([[0, 30, 60, 90, 120, 181], [1, 2, 3, 4, 5, 6]])], + [None, np.array([0, 30, 60, 90, 120, 181])], [ValueError, "Two theta exceeds 180 degrees. Please check the input values for errors."], ), # UC1: user specified an invalid tth value of > 180 degrees (with wavelength) ( - [4 * np.pi, np.array([[0, 30, 60, 90, 120, 181], [1, 2, 3, 4, 5, 6]])], + [4 * np.pi, np.array([0, 30, 60, 90, 120, 181])], [ValueError, "Two theta exceeds 180 degrees. Please check the input values for errors."], ), ]