diff --git a/doc/source/examples/diffractionobjectsexample.rst b/doc/source/examples/diffractionobjectsexample.rst new file mode 100644 index 00000000..f3114ab0 --- /dev/null +++ b/doc/source/examples/diffractionobjectsexample.rst @@ -0,0 +1,34 @@ +.. _Diffraction Objects Example: + +:tocdepth: -1 + +Diffraction Objects Example +########################### + +This example will demonstrate how to use the ``DiffractionObject`` class in the +``diffpy.utils.scattering_objects.diffraction_objects`` module to process and analyze diffraction data. + +1) Assuming we have created a ``DiffractionObject`` called my_diffraction_pattern from a measured diffraction pattern, + and we have specified the wavelength (see Section ??, to be added), + we can use the ``q_to_tth`` and ``tth_to_q`` functions to convert between q and two-theta. :: + + # Example: convert q to tth + my_diffraction_pattern.on_q = [[0, 0.2, 0.4, 0.6, 0.8, 1], [1, 2, 3, 4, 5, 6]] + my_diffraction_pattern.q_to_tth() + + This function will convert your provided q array and return a two theta array in degrees. + To load the converted array, you can either call ``test.q_to_tth()`` or ``test.on_q[0]``. :: + + # Example: convert tth to q + from diffpy.utils.scattering_objects.diffraction_objects import DiffractionObject + my_diffraction_pattern.on_tth = [[0, 30, 60, 90, 120, 180], [1, 2, 3, 4, 5, 6]] + my_diffraction_pattern.tth_to_q() + + Similarly, to load the converted array, you can either call ``test.tth_to_q()`` or ``test.on_tth[0]``. + +2) Both functions require a wavelength to perform conversions. Without a wavelength, they will return empty arrays. + Therefore, we strongly encourage you to specify a wavelength when using these functions. :: + + # Example: without wavelength specified + my_diffraction_pattern.on_q = [[0, 0.2, 0.4, 0.6, 0.8, 1], [1, 2, 3, 4, 5, 6]] + my_diffraction_pattern.q_to_tth() # returns an empty array diff --git a/doc/source/utilities/diffractionobjectsutility.rst b/doc/source/utilities/diffractionobjectsutility.rst new file mode 100644 index 00000000..d388e856 --- /dev/null +++ b/doc/source/utilities/diffractionobjectsutility.rst @@ -0,0 +1,13 @@ +.. _Diffraction Objects Utility: + +Diffraction Objects Utility +=========================== + +The ``diffpy.utils.scattering_objects.diffraction_objects`` module provides functions +for managing and analyzing diffraction data, including angle-space conversions +and interactions between diffraction data. + +These functions help developers standardize diffraction data and update the arrays +in the associated ``DiffractionObject``, enabling easier analysis and further processing. + +For a more in-depth tutorial for how to use these functions, click :ref:`here `. diff --git a/news/tth-q.rst b/news/tth-q.rst new file mode 100644 index 00000000..c512bf9b --- /dev/null +++ b/news/tth-q.rst @@ -0,0 +1,23 @@ +**Added:** + +* functionality to raise useful warning and error messages during angular conversion between two theta and q + +**Changed:** + +* + +**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 926ed8b2..2017b926 100644 --- a/src/diffpy/utils/scattering_objects/diffraction_objects.py +++ b/src/diffpy/utils/scattering_objects/diffraction_objects.py @@ -17,6 +17,23 @@ "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. @@ -763,25 +780,35 @@ def q_to_tth(self): 2\theta_n = 2 \arcsin\left(\frac{\lambda q}{4 \pi}\right) + Function adapted from scikit-beam. Thanks to those developers + Parameters ---------- q : array - An array of :math:`q` values + The 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 + 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): @@ -800,25 +827,33 @@ def tth_to_q(self): 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 - An array of :math:`2\theta` values in units of degrees + The 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 + 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) diff --git a/tests/diffpy/utils/scattering_objects/test_diffraction_objects.py b/tests/diffpy/utils/scattering_objects/test_diffraction_objects.py index b2225db8..3bdc22d4 100644 --- a/tests/diffpy/utils/scattering_objects/test_diffraction_objects.py +++ b/tests/diffpy/utils/scattering_objects/test_diffraction_objects.py @@ -4,7 +4,7 @@ import pytest from freezegun import freeze_time -from diffpy.utils.scattering_objects.diffraction_objects import DiffractionObject +from diffpy.utils.scattering_objects.diffraction_objects import DiffractionObject, wavelength_warning_emsg params = [ ( # Default @@ -231,6 +231,155 @@ def test_diffraction_objects_equality(inputs1, inputs2, expected): assert (diffraction_object1 == diffraction_object2) == expected +def _test_valid_diffraction_objects(actual_diffraction_object, function, expected_array): + if actual_diffraction_object.wavelength is None: + with pytest.warns(UserWarning) as warn_record: + getattr(actual_diffraction_object, function)() + assert str(warn_record[0].message) == wavelength_warning_emsg + actual_array = getattr(actual_diffraction_object, function)() + 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)