Skip to content

q_to_tth & tth_to_q #178

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Nov 28, 2024
34 changes: 34 additions & 0 deletions doc/source/examples/diffractionobjectsexample.rst
Original file line number Diff line number Diff line change
@@ -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]``. ::
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is confusing. Doesn't this function do it in place and just set the tth array? In this respect, shouldn't this just be a private function and not used by the user at all? In other words, I would load my diffraction data into the object and the object automatically populates all the different arrays. So to get my data on q if I loaded it on tth I would just do my_diffraction_data.on_q

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a private function for now (I didn't see the "_" in the function name)? We need to call insert_scattering_quantity in order for it to populate on all arrays automatically. If they just do my_diffraction_pattern = DiffractionObjects() and my_diffraction_pattern.on_q = ... it is not automatically populated to tth.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but if they do that they are just instantiating an empty DO so there is nothing to populate anywhere..... So that is desired behavior.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The question is what behavior do we want? I want to be able to get my intensity data on all the different x-grids, but I can do that by typing my_pattern.on_q orwhatever. What is the UC where I would want to run that function?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. Does this sound better?

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. For example, my_diffraction_pattern.q_to_tth() converts q to two-theta, while my_diffraction_pattern.tth_to_q() converts two-theta to q. The converted array can be accessed using by calling my_diffraction_pattern.on_q[0] for q, or my_diffraction_pattern.on_tth[0] for two-theta.


# 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
13 changes: 13 additions & 0 deletions doc/source/utilities/diffractionobjectsutility.rst
Original file line number Diff line number Diff line change
@@ -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 <Diffraction Objects Example>`.
23 changes: 23 additions & 0 deletions news/tth-q.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
**Added:**

* functionality to raise useful warning and error messages during angular conversion between two theta and q

**Changed:**

* <news item>

**Deprecated:**

* <news item>

**Removed:**

* <news item>

**Fixed:**

* <news item>

**Security:**

* <news item>
53 changes: 44 additions & 9 deletions src/diffpy/utils/scattering_objects/diffraction_objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as above, I think this isn't doing quite what we want.

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)
Expand Down
151 changes: 150 additions & 1 deletion tests/diffpy/utils/scattering_objects/test_diffraction_objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. "
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above. Also since we are reusing the error message do we want to minimize word by defining it in a variable once and reusing the variable in multiple tests?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I will store them in diffraction_objects.py file

"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)
Expand Down
Loading