Skip to content

Commit 4497299

Browse files
committed
🚀 Functions for handling radial diffraction units
xtl.math.crystallography - New functions for converting between 2theta, q and d - New functions for converting between deg/rad, A/nm and A^-1/nm^-1 - Two new dictionaries `radial_converters` and `unit_converters` for easily loading and organizing all converter functions xtl.common.labels - New `Label` dataclass for storing a simple text value with additional representations xtl.units.crystallography.radial - New `RadialUnitType` enum for storing all valid types of radial units - New `RadialUnit` dataclass for representing radial units - New `RadialValue` dataclass for representing a value in radial units - Interconversion between different radial units is possible with the `.to()` method
1 parent 29d71d4 commit 4497299

File tree

3 files changed

+233
-0
lines changed

3 files changed

+233
-0
lines changed

src/xtl/common/labels.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from dataclasses import dataclass
2+
from typing import Optional
3+
4+
5+
@dataclass
6+
class Label:
7+
value: str
8+
description: Optional[str] = None
9+
repr: Optional[str] = None
10+
latex: Optional[str] = None

src/xtl/math/crystallography.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from typing_extensions import Literal
44

5+
import numpy as np
6+
57
from .trig import sin, asin, sin_d, cos_d
68

79

@@ -31,6 +33,56 @@ def ttheta_to_d_spacing(ttheta, wavelength, mode: Literal['d', 'r'] = 'd'):
3133
return wavelength / (2 * sin(ttheta / 2, mode))
3234

3335

36+
# Conversion functions (assuming standard units, aka deg, A, 1/A)
37+
tth_to_d = lambda x, w: w / (2 * np.sin(np.radians(x / 2)))
38+
d_to_tth = lambda x, w: 2 * np.degrees(np.arcsin(w / (2 * x)))
39+
d_to_q = lambda x, w: 2 * np.pi / x
40+
q_to_d = lambda x, w: 2 * np.pi / x
41+
tth_to_q = lambda x, w: d_to_q(tth_to_d(x, w), w)
42+
q_to_tth = lambda x, w: d_to_tth(q_to_d(x, w), w)
43+
radial_converters = {
44+
'2theta': {
45+
'd': tth_to_d,
46+
'q': tth_to_q
47+
},
48+
'd': {
49+
'2theta': d_to_tth,
50+
'q': d_to_q
51+
},
52+
'q': {
53+
'2theta': q_to_tth,
54+
'd': q_to_d
55+
}
56+
}
57+
58+
deg_to_rad = lambda x: np.radians(x)
59+
rad_to_deg = lambda x: np.degrees(x)
60+
nm_to_A = lambda x: x * 10.
61+
A_to_nm = lambda x: x / 10.
62+
inv_nm_to_inv_A = lambda x: x / 10.
63+
inv_A_to_inv_nm = lambda x: x * 10.
64+
unit_converters = {
65+
'degrees': {
66+
'radians': deg_to_rad
67+
},
68+
'radians': {
69+
'degrees': rad_to_deg
70+
},
71+
'nm': {
72+
'A': nm_to_A
73+
},
74+
'A': {
75+
'nm': A_to_nm
76+
},
77+
'1/nm': {
78+
'1/A': inv_nm_to_inv_A
79+
},
80+
'1/A': {
81+
'1/nm': inv_A_to_inv_nm
82+
}
83+
}
84+
85+
3486
# Reference
3587
# Ladd, M., Palmer, R., 2012. Structure determination by x-ray crystallography: analysis by x-rays and neutrons.
3688
def d_hkl(hkl, cell):
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
from dataclasses import dataclass
2+
from enum import Enum
3+
from typing import Callable, Optional
4+
5+
from xtl.common.labels import Label
6+
from xtl.math.crystallography import radial_converters, unit_converters
7+
8+
9+
class RadialUnitType(Enum):
10+
TWOTHETA_DEG = '2th_deg' # 2theta in degrees
11+
TWOTHETA_RAD = '2th_rad' # 2theta in radians
12+
Q_NM = 'q_nm^-1' # q in 1/nm
13+
Q_A = 'q_A^-1' # q in 1/A
14+
D_NM = 'd_nm' # d in nm
15+
D_A = 'd_A' # d in A
16+
17+
18+
@dataclass
19+
class RadialUnit:
20+
name: Label
21+
unit: Label
22+
23+
@property
24+
def repr(self):
25+
return f'{self.name.repr}_{self.unit.repr}'
26+
27+
@property
28+
def latex(self):
29+
return f'{self.name.latex} ({self.unit.latex})'
30+
31+
@property
32+
def type(self):
33+
return RadialUnitType(self.repr)
34+
35+
@classmethod
36+
def ttheta_deg(cls):
37+
return cls(name=Label(value='2theta', repr='2th', latex='2\u03b8'),
38+
unit=Label(value='degrees', repr='deg', latex='\u00b0'))
39+
40+
@classmethod
41+
def ttheta_rad(cls):
42+
return cls(name=Label(value='2theta', repr='2th', latex='2\u03b8'),
43+
unit=Label(value='radians', repr='rad', latex='rad'))
44+
45+
@classmethod
46+
def q_nm(cls):
47+
return cls(name=Label(value='q', repr='q', latex='q'),
48+
unit=Label(value='1/nm', repr='nm^-1', latex='nm\u207B\u00B9'))
49+
50+
@classmethod
51+
def q_A(cls):
52+
return cls(name=Label(value='q', repr='q', latex='q'),
53+
unit=Label(value='1/A', repr='A^-1', latex='\u212b\u207B\u00B9'))
54+
55+
@classmethod
56+
def d_nm(cls):
57+
return cls(name=Label(value='d', repr='d', latex='d'),
58+
unit=Label(value='nm', repr='nm', latex='nm'))
59+
60+
@classmethod
61+
def d_A(cls):
62+
return cls(name=Label(value='d', repr='d', latex='d'),
63+
unit=Label(value='A', repr='A', latex='\u212b'))
64+
65+
@classmethod
66+
def from_type(cls, r: RadialUnitType | str):
67+
if isinstance(r, str):
68+
r = RadialUnitType(r)
69+
if not isinstance(r, RadialUnitType):
70+
raise TypeError(f'Expected {RadialUnitType.__class__.__name__} or str, got {type(r)}')
71+
72+
if r == RadialUnitType.TWOTHETA_DEG:
73+
return cls.ttheta_deg()
74+
elif r == RadialUnitType.TWOTHETA_RAD:
75+
return cls.ttheta_rad()
76+
elif r == RadialUnitType.Q_NM:
77+
return cls.q_nm()
78+
elif r == RadialUnitType.Q_A:
79+
return cls.q_A()
80+
elif r == RadialUnitType.D_NM:
81+
return cls.d_nm()
82+
elif r == RadialUnitType.D_A:
83+
return cls.d_A()
84+
else:
85+
raise ValueError(f'Unknown radial units: {r!r}')
86+
87+
88+
@dataclass
89+
class RadialValue:
90+
value: float | int
91+
type: RadialUnitType | str
92+
93+
def __post_init__(self):
94+
r = RadialUnitType(self.type)
95+
self._radial: RadialUnit = RadialUnit.from_type(r)
96+
97+
self._std_units = {
98+
'2theta': RadialUnit.ttheta_deg(),
99+
'd': RadialUnit.d_A(),
100+
'q': RadialUnit.q_A()
101+
}
102+
self._supported_unit_types = list(self._std_units.keys())
103+
self._supported_units = ['deg', 'rad', 'A', 'nm', 'A^-1', 'nm^-1']
104+
105+
@property
106+
def name(self):
107+
return self._radial.name
108+
109+
@property
110+
def units(self):
111+
return self._radial.unit
112+
113+
def to(self, units: RadialUnit | RadialUnitType | str, wavelength: Optional[float] = None) -> 'RadialValue':
114+
# Typecast to RadialUnit
115+
if isinstance(units, RadialUnitType) or isinstance(units, str):
116+
new = RadialUnit.from_type(units)
117+
else:
118+
new = units
119+
# Check if units is a RadialUnit
120+
if not isinstance(new, RadialUnit):
121+
raise TypeError(f'Expected {RadialUnit.__class__.__name__} or str, got {type(new)}')
122+
123+
# Check if units are supported
124+
new: RadialUnit
125+
if new.name.value not in self._supported_unit_types:
126+
raise ValueError(f'Unsupported radial units: {new.name.value!r}, choose one from: {",".join(self._supported_unit_types)}')
127+
if new.unit.repr not in self._supported_units:
128+
raise ValueError(f'Unsupported units: {new.unit.repr!r}, choose one from: {",".join(self._supported_units)}')
129+
130+
# Check if wavelength is required for conversion
131+
if sorted([self.name.value, new.name.value]) in [['2theta', 'd'], ['2theta', 'q']]:
132+
if wavelength is None:
133+
raise ValueError(f'Wavelength is required to convert from {self.name.value} to {new.name.value}')
134+
135+
f = self._conversion_function(self._radial, new)
136+
new_value = f(self.value, wavelength)
137+
return RadialValue(new_value, new.repr)
138+
139+
def _conversion_function(self, r0: RadialUnit, r1: RadialUnit) -> Callable:
140+
"""
141+
Returns a number to multiply r0 to get r1.
142+
"""
143+
u0, u1 = r0.unit.value, r1.unit.value
144+
t0, t1 = r0.name.value, r1.name.value
145+
146+
# Check if units are the same
147+
if u0 == u1:
148+
return lambda x, w: x
149+
150+
# Check if unit types are the same
151+
if t0 == t1:
152+
return lambda x, w: unit_converters[u0][u1](x)
153+
154+
# Get factor f0 to convert r0 to standard units (2th, A, 1/A)
155+
f0 = self._conversion_function(r0, self._std_units[t0])
156+
157+
# Get factor f1 to convert standard units (2th, A, 1/A) to r1 units
158+
f1 = self._conversion_function(self._std_units[t1], r1)
159+
160+
# Get converter that assumes standard units
161+
converter = radial_converters[t0][t1]
162+
163+
return lambda x, w: f1(converter(f0(x, w), w), w)
164+
165+
def __repr__(self):
166+
return f'{self.value} {self.units.repr}'
167+
168+
def __rich_repr__(self):
169+
yield 'value', self.value
170+
yield 'units', self.units.repr
171+
yield 'type', self._radial.type

0 commit comments

Comments
 (0)