xrdkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xrdkit/__init__.py +226 -0
- xrdkit/broadening.py +1155 -0
- xrdkit/config.py +660 -0
- xrdkit/density.py +144 -0
- xrdkit/gsas2.py +915 -0
- xrdkit/gsas2_driver.py +2551 -0
- xrdkit/indexing.py +750 -0
- xrdkit/io.py +115 -0
- xrdkit/lattice.py +233 -0
- xrdkit/peaks.py +340 -0
- xrdkit/phases.py +494 -0
- xrdkit/plotting.py +1072 -0
- xrdkit/py.typed +0 -0
- xrdkit/sizestrain.py +539 -0
- xrdkit/structure.py +470 -0
- xrdkit-0.1.0.dist-info/METADATA +229 -0
- xrdkit-0.1.0.dist-info/RECORD +19 -0
- xrdkit-0.1.0.dist-info/WHEEL +4 -0
- xrdkit-0.1.0.dist-info/licenses/LICENSE +21 -0
xrdkit/io.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Readers for X-ray diffraction data files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
__all__ = ["XRDScan", "read_xrdml"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class XRDScan:
|
|
16
|
+
"""A single powder diffraction scan."""
|
|
17
|
+
|
|
18
|
+
two_theta: np.ndarray
|
|
19
|
+
intensity: np.ndarray
|
|
20
|
+
wavelength: float
|
|
21
|
+
start_angle: float
|
|
22
|
+
end_angle: float
|
|
23
|
+
step_size: float
|
|
24
|
+
time_per_step: float
|
|
25
|
+
sample_id: str
|
|
26
|
+
source_path: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _namespace(root: ET.Element) -> str:
|
|
30
|
+
"""Return the default namespace of ``root`` as a ``{uri}`` prefix."""
|
|
31
|
+
if root.tag.startswith("{"):
|
|
32
|
+
return root.tag[: root.tag.index("}") + 1]
|
|
33
|
+
return ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _text(element: ET.Element | None) -> str:
|
|
37
|
+
if element is None or element.text is None:
|
|
38
|
+
return ""
|
|
39
|
+
return element.text.strip()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def read_xrdml(path: str | Path) -> XRDScan:
|
|
43
|
+
"""Read a PANalytical ``.xrdml`` file into an :class:`XRDScan`.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
path
|
|
48
|
+
Path to the ``.xrdml`` file.
|
|
49
|
+
|
|
50
|
+
Raises
|
|
51
|
+
------
|
|
52
|
+
ValueError
|
|
53
|
+
If the file contains no intensity data.
|
|
54
|
+
"""
|
|
55
|
+
path = Path(path)
|
|
56
|
+
root = ET.parse(path).getroot()
|
|
57
|
+
ns = _namespace(root)
|
|
58
|
+
|
|
59
|
+
scan = root.find(f".//{ns}scan")
|
|
60
|
+
if scan is None:
|
|
61
|
+
raise ValueError(f"No scan found in {path}")
|
|
62
|
+
|
|
63
|
+
data_points = scan.find(f"{ns}dataPoints")
|
|
64
|
+
if data_points is None:
|
|
65
|
+
raise ValueError(f"No dataPoints element found in {path}")
|
|
66
|
+
|
|
67
|
+
# Intensities live in <counts> or <intensities> depending on the writer.
|
|
68
|
+
counts_element = data_points.find(f"{ns}counts")
|
|
69
|
+
if counts_element is None:
|
|
70
|
+
counts_element = data_points.find(f"{ns}intensities")
|
|
71
|
+
raw_counts = _text(counts_element)
|
|
72
|
+
if not raw_counts:
|
|
73
|
+
raise ValueError(f"No intensity data found in {path}")
|
|
74
|
+
|
|
75
|
+
intensity = np.array(raw_counts.split(), dtype=float)
|
|
76
|
+
if intensity.size == 0:
|
|
77
|
+
raise ValueError(f"No intensity data found in {path}")
|
|
78
|
+
|
|
79
|
+
two_theta_positions = None
|
|
80
|
+
for positions in data_points.findall(f"{ns}positions"):
|
|
81
|
+
if positions.get("axis") == "2Theta":
|
|
82
|
+
two_theta_positions = positions
|
|
83
|
+
break
|
|
84
|
+
if two_theta_positions is None:
|
|
85
|
+
raise ValueError(f"No 2Theta axis found in {path}")
|
|
86
|
+
|
|
87
|
+
start_angle = float(_text(two_theta_positions.find(f"{ns}startPosition")))
|
|
88
|
+
end_angle = float(_text(two_theta_positions.find(f"{ns}endPosition")))
|
|
89
|
+
|
|
90
|
+
n_points = intensity.size
|
|
91
|
+
two_theta = np.linspace(start_angle, end_angle, n_points)
|
|
92
|
+
step_size = (end_angle - start_angle) / (n_points - 1) if n_points > 1 else 0.0
|
|
93
|
+
|
|
94
|
+
common_time = _text(data_points.find(f"{ns}commonCountingTime"))
|
|
95
|
+
if common_time:
|
|
96
|
+
time_per_step = float(common_time)
|
|
97
|
+
else:
|
|
98
|
+
per_point = _text(data_points.find(f"{ns}countingTimes"))
|
|
99
|
+
time_per_step = float(per_point.split()[0]) if per_point else 0.0
|
|
100
|
+
|
|
101
|
+
wavelength = float(_text(root.find(f".//{ns}usedWavelength/{ns}kAlpha1")))
|
|
102
|
+
|
|
103
|
+
sample_id = _text(root.find(f".//{ns}sample/{ns}id"))
|
|
104
|
+
|
|
105
|
+
return XRDScan(
|
|
106
|
+
two_theta=two_theta,
|
|
107
|
+
intensity=intensity,
|
|
108
|
+
wavelength=wavelength,
|
|
109
|
+
start_angle=start_angle,
|
|
110
|
+
end_angle=end_angle,
|
|
111
|
+
step_size=step_size,
|
|
112
|
+
time_per_step=time_per_step,
|
|
113
|
+
sample_id=sample_id,
|
|
114
|
+
source_path=str(path),
|
|
115
|
+
)
|
xrdkit/lattice.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Lattice parameter refinement against indexed peaks, with systematic errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from scipy import optimize
|
|
9
|
+
|
|
10
|
+
from xrdkit.indexing import IndexedPeak, TetragonalCell
|
|
11
|
+
|
|
12
|
+
__all__ = ["LatticeFit", "lattice_fit_to_dict", "refine_lattice"]
|
|
13
|
+
|
|
14
|
+
# A peak needs this many more than the parameters before a fit means anything.
|
|
15
|
+
MIN_EXTRA_PEAKS = 3
|
|
16
|
+
|
|
17
|
+
# Keeps the arcsine in range while the optimiser tries a cell that cannot
|
|
18
|
+
# diffract at all; the residual there is large either way.
|
|
19
|
+
MAX_SIN_THETA = 1.0 - 1e-9
|
|
20
|
+
|
|
21
|
+
# Order of the parameter vector the model is written in.
|
|
22
|
+
PARAMETER_NAMES = ("a", "c", "zero", "displacement")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class LatticeFit:
|
|
27
|
+
"""The outcome of a lattice parameter refinement.
|
|
28
|
+
|
|
29
|
+
``zero`` is the zero point error in degrees, added to every calculated
|
|
30
|
+
position. ``displacement`` is the specimen displacement in millimetres,
|
|
31
|
+
positive below the focusing circle, or ``None`` when it was not refined.
|
|
32
|
+
An estimated standard deviation of a parameter that was held fixed is
|
|
33
|
+
``None``.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
a: float
|
|
37
|
+
c: float
|
|
38
|
+
esd_a: float
|
|
39
|
+
esd_c: float
|
|
40
|
+
zero: float
|
|
41
|
+
esd_zero: float | None
|
|
42
|
+
displacement: float | None
|
|
43
|
+
esd_displacement: float | None
|
|
44
|
+
radius_mm: float | None
|
|
45
|
+
n_peaks: int
|
|
46
|
+
rms_two_theta: float
|
|
47
|
+
residuals: np.ndarray
|
|
48
|
+
hkl: list[tuple[int, int, int]]
|
|
49
|
+
converged: bool
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def cell(self) -> TetragonalCell:
|
|
53
|
+
"""The refined cell."""
|
|
54
|
+
return TetragonalCell(a=self.a, c=self.c)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _two_theta(
|
|
58
|
+
a: float, c: float, hkl: np.ndarray, wavelength: float
|
|
59
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
60
|
+
"""Return the Bragg 2theta of ``hkl`` for a tetragonal cell, and its theta.
|
|
61
|
+
|
|
62
|
+
Both in degrees and radians respectively, from 1/d^2 = (h^2+k^2)/a^2 +
|
|
63
|
+
l^2/c^2 and sin(theta) = lambda / 2d.
|
|
64
|
+
"""
|
|
65
|
+
hk, ll = hkl[:, 0] ** 2 + hkl[:, 1] ** 2, hkl[:, 2] ** 2
|
|
66
|
+
inverse_squared = hk / a**2 + ll / c**2
|
|
67
|
+
d_spacing = 1.0 / np.sqrt(inverse_squared)
|
|
68
|
+
sin_theta = np.clip(wavelength / (2.0 * d_spacing), -MAX_SIN_THETA, MAX_SIN_THETA)
|
|
69
|
+
theta = np.arcsin(sin_theta)
|
|
70
|
+
return 2.0 * np.degrees(theta), theta
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _model(
|
|
74
|
+
parameters: np.ndarray, hkl: np.ndarray, wavelength: float, radius_mm: float | None
|
|
75
|
+
) -> np.ndarray:
|
|
76
|
+
"""Calculated 2theta for ``parameters`` = (a, c, zero, displacement).
|
|
77
|
+
|
|
78
|
+
The specimen displacement shifts a reflection by -2 s cos(theta) / R
|
|
79
|
+
radians, which is the usual flat plate term: a specimen sitting below the
|
|
80
|
+
focusing circle moves every peak to low angle, most of all at low angle.
|
|
81
|
+
"""
|
|
82
|
+
a, c, zero, displacement = parameters
|
|
83
|
+
calculated, theta = _two_theta(a, c, hkl, wavelength)
|
|
84
|
+
shift = zero
|
|
85
|
+
if radius_mm is not None:
|
|
86
|
+
shift = shift + np.degrees(-2.0 * displacement * np.cos(theta) / radius_mm)
|
|
87
|
+
return calculated + shift
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def refine_lattice(
|
|
91
|
+
indexed: list[IndexedPeak],
|
|
92
|
+
wavelength: float,
|
|
93
|
+
start_cell: TetragonalCell,
|
|
94
|
+
fit_zero: bool = True,
|
|
95
|
+
fit_displacement: bool = False,
|
|
96
|
+
radius_mm: float | None = None,
|
|
97
|
+
start_zero: float = 0.0,
|
|
98
|
+
) -> LatticeFit:
|
|
99
|
+
"""Refine a tetragonal cell, and its systematic errors, by least squares.
|
|
100
|
+
|
|
101
|
+
Each peak is modelled as its Bragg position for the cell plus a zero point
|
|
102
|
+
error plus a specimen displacement term, and the observed positions are
|
|
103
|
+
taken as measured: ``peak.two_theta``, not ``corrected_two_theta``, since
|
|
104
|
+
the zero point is what is being refined rather than assumed.
|
|
105
|
+
|
|
106
|
+
Only peaks that matched exactly one reflection are used, so an assignment
|
|
107
|
+
that was a choice between rivals cannot pull the cell.
|
|
108
|
+
|
|
109
|
+
A zero point error and a specimen displacement are not independent over a
|
|
110
|
+
short angular range, since one is constant and the other follows
|
|
111
|
+
cos(theta); refining both needs peaks spread widely enough in 2theta to
|
|
112
|
+
tell them apart.
|
|
113
|
+
|
|
114
|
+
Parameters
|
|
115
|
+
----------
|
|
116
|
+
indexed
|
|
117
|
+
Result of :func:`~xrdkit.indexing.index_peaks`, or the first element of
|
|
118
|
+
:func:`~xrdkit.indexing.index_and_refine`.
|
|
119
|
+
wavelength
|
|
120
|
+
Radiation wavelength in angstroms.
|
|
121
|
+
start_cell
|
|
122
|
+
Cell to start from.
|
|
123
|
+
fit_zero
|
|
124
|
+
Refine the zero point error. Held at zero when ``False``.
|
|
125
|
+
fit_displacement
|
|
126
|
+
Refine the specimen displacement. Held at zero when ``False``.
|
|
127
|
+
radius_mm
|
|
128
|
+
Goniometer radius in millimetres. Needed only to refine a displacement.
|
|
129
|
+
start_zero
|
|
130
|
+
Zero point error to start from, in degrees. Only a starting value: it
|
|
131
|
+
is refined like any other when ``fit_zero``, and held there when not.
|
|
132
|
+
|
|
133
|
+
Returns
|
|
134
|
+
-------
|
|
135
|
+
LatticeFit
|
|
136
|
+
The refined parameters, their estimated standard deviations, and the
|
|
137
|
+
residuals of the peaks used.
|
|
138
|
+
|
|
139
|
+
Raises
|
|
140
|
+
------
|
|
141
|
+
ValueError
|
|
142
|
+
If ``fit_displacement`` is asked for without a ``radius_mm``, or fewer
|
|
143
|
+
than three peaks more than there are parameters are available.
|
|
144
|
+
"""
|
|
145
|
+
if fit_displacement and radius_mm is None:
|
|
146
|
+
raise ValueError(
|
|
147
|
+
"refine_lattice needs radius_mm to refine a specimen displacement"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
used = [
|
|
151
|
+
entry for entry in indexed if entry.is_indexed and len(entry.candidates) == 1
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
free = np.array([True, True, bool(fit_zero), bool(fit_displacement)])
|
|
155
|
+
n_parameters = int(free.sum())
|
|
156
|
+
if len(used) < n_parameters + MIN_EXTRA_PEAKS:
|
|
157
|
+
raise ValueError(
|
|
158
|
+
f"Need at least {n_parameters + MIN_EXTRA_PEAKS} peaks with a single "
|
|
159
|
+
f"candidate to refine {n_parameters} parameters, got {len(used)}"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
observed = np.array([entry.peak.two_theta for entry in used], dtype=float)
|
|
163
|
+
hkl = np.array([entry.reflection.hkl for entry in used], dtype=float)
|
|
164
|
+
hkl_tuples = [entry.reflection.hkl for entry in used]
|
|
165
|
+
|
|
166
|
+
# The whole vector is carried about so the model stays readable; only the
|
|
167
|
+
# free entries are handed to the optimiser.
|
|
168
|
+
start = np.array([start_cell.a, start_cell.c, start_zero, 0.0], dtype=float)
|
|
169
|
+
# A displacement term of zero leaves the model unchanged, so the radius is
|
|
170
|
+
# only consulted when one is being refined.
|
|
171
|
+
radius = radius_mm if fit_displacement else None
|
|
172
|
+
|
|
173
|
+
def residual(varying: np.ndarray) -> np.ndarray:
|
|
174
|
+
parameters = start.copy()
|
|
175
|
+
parameters[free] = varying
|
|
176
|
+
return observed - _model(parameters, hkl, wavelength, radius)
|
|
177
|
+
|
|
178
|
+
result = optimize.least_squares(residual, start[free])
|
|
179
|
+
|
|
180
|
+
parameters = start.copy()
|
|
181
|
+
parameters[free] = result.x
|
|
182
|
+
a, c, zero, displacement = parameters
|
|
183
|
+
|
|
184
|
+
residuals = np.asarray(result.fun, dtype=float)
|
|
185
|
+
degrees_of_freedom = len(used) - n_parameters
|
|
186
|
+
# Scaling by the reduced chi squared turns the curvature of the surface
|
|
187
|
+
# into an error bar that reflects how well the model actually fits.
|
|
188
|
+
reduced_chi_squared = float(np.sum(residuals**2) / degrees_of_freedom)
|
|
189
|
+
jacobian = np.asarray(result.jac, dtype=float)
|
|
190
|
+
covariance = np.linalg.pinv(jacobian.T @ jacobian) * reduced_chi_squared
|
|
191
|
+
deviations = np.sqrt(np.abs(np.diag(covariance)))
|
|
192
|
+
|
|
193
|
+
esd = dict(zip(np.array(PARAMETER_NAMES)[free], deviations))
|
|
194
|
+
|
|
195
|
+
return LatticeFit(
|
|
196
|
+
a=float(a),
|
|
197
|
+
c=float(c),
|
|
198
|
+
esd_a=float(esd["a"]),
|
|
199
|
+
esd_c=float(esd["c"]),
|
|
200
|
+
zero=float(zero),
|
|
201
|
+
esd_zero=float(esd["zero"]) if fit_zero else None,
|
|
202
|
+
displacement=float(displacement) if fit_displacement else None,
|
|
203
|
+
esd_displacement=float(esd["displacement"]) if fit_displacement else None,
|
|
204
|
+
radius_mm=radius_mm,
|
|
205
|
+
n_peaks=len(used),
|
|
206
|
+
rms_two_theta=float(np.sqrt(np.mean(residuals**2))),
|
|
207
|
+
residuals=residuals,
|
|
208
|
+
hkl=hkl_tuples,
|
|
209
|
+
converged=bool(result.success),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def lattice_fit_to_dict(fit: LatticeFit) -> dict[str, float | int | bool | None]:
|
|
214
|
+
"""Flatten ``fit`` to the scalar fields, ready for a CSV row.
|
|
215
|
+
|
|
216
|
+
The residuals and the hkl are left out, being one row each rather than one
|
|
217
|
+
value.
|
|
218
|
+
"""
|
|
219
|
+
return {
|
|
220
|
+
"a": fit.a,
|
|
221
|
+
"esd_a": fit.esd_a,
|
|
222
|
+
"c": fit.c,
|
|
223
|
+
"esd_c": fit.esd_c,
|
|
224
|
+
"c_over_a": fit.c / fit.a,
|
|
225
|
+
"zero": fit.zero,
|
|
226
|
+
"esd_zero": fit.esd_zero,
|
|
227
|
+
"displacement": fit.displacement,
|
|
228
|
+
"esd_displacement": fit.esd_displacement,
|
|
229
|
+
"radius_mm": fit.radius_mm,
|
|
230
|
+
"n_peaks": fit.n_peaks,
|
|
231
|
+
"rms_two_theta": fit.rms_two_theta,
|
|
232
|
+
"converged": fit.converged,
|
|
233
|
+
}
|
xrdkit/peaks.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Peak finding for X-ray diffraction patterns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
from dataclasses import asdict, dataclass, replace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from scipy import signal
|
|
11
|
+
|
|
12
|
+
from xrdkit.io import XRDScan
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Peak",
|
|
16
|
+
"exclude_kalpha2",
|
|
17
|
+
"find_peaks",
|
|
18
|
+
"flag_kalpha2",
|
|
19
|
+
"peaks_to_csv",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
# Minimum peak prominence, as a fraction of the strongest intensity in range.
|
|
23
|
+
DEFAULT_MIN_PROMINENCE = 0.02
|
|
24
|
+
|
|
25
|
+
# Minimum separation between peaks, in degrees.
|
|
26
|
+
DEFAULT_MIN_DISTANCE = 0.15
|
|
27
|
+
|
|
28
|
+
# scipy measures the width at this fraction of the prominence below the peak,
|
|
29
|
+
# so 0.5 gives the full width at half maximum.
|
|
30
|
+
HALF_PROMINENCE = 0.5
|
|
31
|
+
|
|
32
|
+
# Copper K alpha 1 and K alpha 2 wavelengths, in angstroms. A satellite
|
|
33
|
+
# diffracts at the same d spacing as its parent, so Bragg's law puts it at
|
|
34
|
+
# 2theta_2 = 2 arcsin(KALPHA2_RATIO sin(theta_1)), always to high angle.
|
|
35
|
+
KALPHA1_WAVELENGTH = 1.54056
|
|
36
|
+
KALPHA2_WAVELENGTH = 1.54439
|
|
37
|
+
KALPHA2_RATIO = KALPHA2_WAVELENGTH / KALPHA1_WAVELENGTH
|
|
38
|
+
|
|
39
|
+
# Largest gap between a peak and the satellite position predicted for it, in
|
|
40
|
+
# degrees.
|
|
41
|
+
DEFAULT_KALPHA2_TOLERANCE = 0.03
|
|
42
|
+
|
|
43
|
+
# A K alpha 2 satellite carries about half the intensity of its parent, but a
|
|
44
|
+
# satellite resolved on the tail of its parent measures well above that, so the
|
|
45
|
+
# ceiling is set high enough to admit the partly overlapped ones.
|
|
46
|
+
DEFAULT_KALPHA2_INTENSITY_RATIO = (0.25, 0.90)
|
|
47
|
+
|
|
48
|
+
CSV_COLUMNS = (
|
|
49
|
+
"two_theta",
|
|
50
|
+
"intensity",
|
|
51
|
+
"prominence",
|
|
52
|
+
"fwhm",
|
|
53
|
+
"d_spacing",
|
|
54
|
+
"relative_intensity",
|
|
55
|
+
"kalpha2_of",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Decimal places per column when writing CSV; counts keep one place.
|
|
59
|
+
CSV_DECIMALS = {
|
|
60
|
+
"two_theta": 3,
|
|
61
|
+
"intensity": 1,
|
|
62
|
+
"prominence": 1,
|
|
63
|
+
"fwhm": 3,
|
|
64
|
+
"d_spacing": 4,
|
|
65
|
+
"relative_intensity": 2,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class Peak:
|
|
71
|
+
"""A single reflection located in a diffraction pattern."""
|
|
72
|
+
|
|
73
|
+
two_theta: float
|
|
74
|
+
intensity: float
|
|
75
|
+
prominence: float
|
|
76
|
+
fwhm: float
|
|
77
|
+
d_spacing: float
|
|
78
|
+
relative_intensity: float
|
|
79
|
+
# Index in the peak list of the K alpha 1 parent this peak is a satellite
|
|
80
|
+
# of, or None if it is not judged to be one.
|
|
81
|
+
kalpha2_of: int | None = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _step_size(scan: XRDScan) -> float:
|
|
85
|
+
"""Return the 2theta step of ``scan``, falling back to the axis spacing."""
|
|
86
|
+
if scan.step_size > 0.0:
|
|
87
|
+
return float(scan.step_size)
|
|
88
|
+
if scan.two_theta.size > 1:
|
|
89
|
+
return float(np.median(np.diff(scan.two_theta)))
|
|
90
|
+
raise ValueError("Cannot determine the 2theta step of a single point scan")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _d_spacing(two_theta: float, wavelength: float) -> float:
|
|
94
|
+
"""Return the d spacing in angstroms from Bragg's law."""
|
|
95
|
+
theta = np.radians(two_theta / 2.0)
|
|
96
|
+
sin_theta = float(np.sin(theta))
|
|
97
|
+
if sin_theta <= 0.0:
|
|
98
|
+
return float("nan")
|
|
99
|
+
return wavelength / (2.0 * sin_theta)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _refine_position(
|
|
103
|
+
two_theta: np.ndarray, intensity: np.ndarray, index: int, step: float
|
|
104
|
+
) -> float:
|
|
105
|
+
"""Refine a peak position by the vertex of a parabola through three points.
|
|
106
|
+
|
|
107
|
+
Falls back to the grid position when the peak sits on the first or last
|
|
108
|
+
point, when the three points are collinear, or when the vertex lands outside
|
|
109
|
+
the neighbouring points, since none of those give a meaningful vertex.
|
|
110
|
+
"""
|
|
111
|
+
grid_position = float(two_theta[index])
|
|
112
|
+
if index <= 0 or index >= intensity.size - 1:
|
|
113
|
+
return grid_position
|
|
114
|
+
|
|
115
|
+
left, centre, right = (float(value) for value in intensity[index - 1 : index + 2])
|
|
116
|
+
curvature = left - 2.0 * centre + right
|
|
117
|
+
if curvature == 0.0:
|
|
118
|
+
return grid_position
|
|
119
|
+
|
|
120
|
+
shift = 0.5 * (left - right) / curvature
|
|
121
|
+
if not -1.0 < shift < 1.0:
|
|
122
|
+
return grid_position
|
|
123
|
+
return grid_position + shift * step
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def find_peaks(
|
|
127
|
+
scan: XRDScan,
|
|
128
|
+
min_prominence: float = DEFAULT_MIN_PROMINENCE,
|
|
129
|
+
min_distance: float = DEFAULT_MIN_DISTANCE,
|
|
130
|
+
two_theta_range: tuple[float, float] | None = None,
|
|
131
|
+
flag_satellites: bool = True,
|
|
132
|
+
) -> list[Peak]:
|
|
133
|
+
"""Locate reflections in ``scan``, sorted by 2theta.
|
|
134
|
+
|
|
135
|
+
Parameters
|
|
136
|
+
----------
|
|
137
|
+
scan
|
|
138
|
+
The scan to search.
|
|
139
|
+
min_prominence
|
|
140
|
+
Minimum prominence a peak must have, as a fraction of the strongest
|
|
141
|
+
intensity within the searched range.
|
|
142
|
+
min_distance
|
|
143
|
+
Minimum separation between peaks, in degrees.
|
|
144
|
+
two_theta_range
|
|
145
|
+
Optional ``(low, high)`` window, in degrees, to restrict the search to.
|
|
146
|
+
flag_satellites
|
|
147
|
+
Whether to run :func:`flag_kalpha2` over the result, which sets
|
|
148
|
+
``kalpha2_of`` on the peaks that look like K alpha 2 satellites.
|
|
149
|
+
|
|
150
|
+
Returns
|
|
151
|
+
-------
|
|
152
|
+
list[Peak]
|
|
153
|
+
Peaks ordered by 2theta. ``relative_intensity`` is a percentage of the
|
|
154
|
+
strongest peak found, which is therefore always 100. Satellites are
|
|
155
|
+
still returned; they are flagged rather than dropped, so that
|
|
156
|
+
:func:`exclude_kalpha2` can be applied when it suits.
|
|
157
|
+
|
|
158
|
+
Raises
|
|
159
|
+
------
|
|
160
|
+
ValueError
|
|
161
|
+
If ``two_theta_range`` selects no data, or the step cannot be determined.
|
|
162
|
+
"""
|
|
163
|
+
two_theta = np.asarray(scan.two_theta, dtype=float)
|
|
164
|
+
intensity = np.asarray(scan.intensity, dtype=float)
|
|
165
|
+
|
|
166
|
+
if two_theta_range is not None:
|
|
167
|
+
low, high = two_theta_range
|
|
168
|
+
selected = (two_theta >= low) & (two_theta <= high)
|
|
169
|
+
if not np.any(selected):
|
|
170
|
+
raise ValueError(
|
|
171
|
+
f"two_theta_range {two_theta_range} selects no points of "
|
|
172
|
+
f"{two_theta[0]:.3f}-{two_theta[-1]:.3f} degrees"
|
|
173
|
+
)
|
|
174
|
+
two_theta = two_theta[selected]
|
|
175
|
+
intensity = intensity[selected]
|
|
176
|
+
|
|
177
|
+
if intensity.size == 0:
|
|
178
|
+
return []
|
|
179
|
+
|
|
180
|
+
step = _step_size(scan)
|
|
181
|
+
prominence = min_prominence * float(np.max(intensity))
|
|
182
|
+
# scipy counts separation in points, and needs at least one.
|
|
183
|
+
distance = max(1, round(min_distance / step))
|
|
184
|
+
|
|
185
|
+
indices, properties = signal.find_peaks(
|
|
186
|
+
intensity, prominence=prominence, distance=distance
|
|
187
|
+
)
|
|
188
|
+
if indices.size == 0:
|
|
189
|
+
return []
|
|
190
|
+
|
|
191
|
+
widths = signal.peak_widths(intensity, indices, rel_height=HALF_PROMINENCE)[0]
|
|
192
|
+
|
|
193
|
+
heights = intensity[indices]
|
|
194
|
+
strongest = float(np.max(heights))
|
|
195
|
+
|
|
196
|
+
peaks = []
|
|
197
|
+
for index, height, peak_prominence, width in zip(
|
|
198
|
+
indices, heights, properties["prominences"], widths
|
|
199
|
+
):
|
|
200
|
+
position = _refine_position(two_theta, intensity, int(index), step)
|
|
201
|
+
peaks.append(
|
|
202
|
+
Peak(
|
|
203
|
+
two_theta=position,
|
|
204
|
+
intensity=float(height),
|
|
205
|
+
prominence=float(peak_prominence),
|
|
206
|
+
fwhm=float(width) * step,
|
|
207
|
+
d_spacing=_d_spacing(position, scan.wavelength),
|
|
208
|
+
relative_intensity=100.0 * float(height) / strongest,
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
peaks.sort(key=lambda peak: peak.two_theta)
|
|
212
|
+
if flag_satellites:
|
|
213
|
+
flag_kalpha2(peaks)
|
|
214
|
+
return peaks
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _satellite_position(two_theta: float, wavelength_ratio: float) -> float:
|
|
218
|
+
"""Return where the K alpha 2 satellite of a peak at ``two_theta`` falls.
|
|
219
|
+
|
|
220
|
+
``nan`` when the scaled sine passes 1, which is beyond the reach of the
|
|
221
|
+
longer wavelength and so has no satellite.
|
|
222
|
+
"""
|
|
223
|
+
sin_theta = wavelength_ratio * np.sin(np.radians(two_theta / 2.0))
|
|
224
|
+
if sin_theta > 1.0:
|
|
225
|
+
return float("nan")
|
|
226
|
+
return 2.0 * float(np.degrees(np.arcsin(sin_theta)))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def flag_kalpha2(
|
|
230
|
+
peaks: list[Peak],
|
|
231
|
+
tolerance: float = DEFAULT_KALPHA2_TOLERANCE,
|
|
232
|
+
intensity_ratio: tuple[float, float] = DEFAULT_KALPHA2_INTENSITY_RATIO,
|
|
233
|
+
wavelength_ratio: float = KALPHA2_RATIO,
|
|
234
|
+
) -> list[Peak]:
|
|
235
|
+
"""Flag the peaks that look like K alpha 2 satellites, in place.
|
|
236
|
+
|
|
237
|
+
A satellite sits at the position the parent's d spacing would give at the
|
|
238
|
+
longer K alpha 2 wavelength, and carries roughly half the parent's
|
|
239
|
+
intensity. Every stronger peak is tested as a possible parent and the
|
|
240
|
+
closest one that satisfies both conditions wins, so a peak that happens to
|
|
241
|
+
fall near two predicted positions is attributed to the better match.
|
|
242
|
+
|
|
243
|
+
Only resolved satellites can be caught this way. Below about 50 degrees the
|
|
244
|
+
pair is not separated enough for the peak finder to report two peaks, so
|
|
245
|
+
there is nothing to flag. A satellite that is only just resolved, still
|
|
246
|
+
sitting on the tail of its parent, measures high because its height is taken
|
|
247
|
+
above a baseline the parent has raised, which is why the default ceiling on
|
|
248
|
+
the intensity ratio is well above the half that clean separation would give.
|
|
249
|
+
|
|
250
|
+
Parameters
|
|
251
|
+
----------
|
|
252
|
+
peaks
|
|
253
|
+
Peaks to flag, as returned by :func:`find_peaks`. Modified in place.
|
|
254
|
+
tolerance
|
|
255
|
+
Largest allowed gap between a peak and a predicted satellite position,
|
|
256
|
+
in degrees.
|
|
257
|
+
intensity_ratio
|
|
258
|
+
Allowed ``(low, high)`` range of the peak's intensity over its parent's.
|
|
259
|
+
wavelength_ratio
|
|
260
|
+
K alpha 2 over K alpha 1, 1.002486 for copper.
|
|
261
|
+
|
|
262
|
+
Returns
|
|
263
|
+
-------
|
|
264
|
+
list[Peak]
|
|
265
|
+
The same list, for chaining.
|
|
266
|
+
|
|
267
|
+
Raises
|
|
268
|
+
------
|
|
269
|
+
ValueError
|
|
270
|
+
If ``intensity_ratio`` is not a rising pair of positive numbers.
|
|
271
|
+
"""
|
|
272
|
+
low, high = intensity_ratio
|
|
273
|
+
if not 0.0 < low < high:
|
|
274
|
+
raise ValueError(
|
|
275
|
+
f"Need 0 < low < high for intensity_ratio, got {intensity_ratio}"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# Predicting once per peak keeps this a single pass over the pairs.
|
|
279
|
+
predicted = [
|
|
280
|
+
_satellite_position(peak.two_theta, wavelength_ratio) for peak in peaks
|
|
281
|
+
]
|
|
282
|
+
|
|
283
|
+
for index, peak in enumerate(peaks):
|
|
284
|
+
best_parent = None
|
|
285
|
+
best_gap = float("inf")
|
|
286
|
+
for parent_index, parent in enumerate(peaks):
|
|
287
|
+
if parent_index == index or parent.intensity <= peak.intensity:
|
|
288
|
+
continue
|
|
289
|
+
if not low <= peak.intensity / parent.intensity <= high:
|
|
290
|
+
continue
|
|
291
|
+
gap = abs(peak.two_theta - predicted[parent_index])
|
|
292
|
+
# A tie keeps the earlier parent, which is the lower angle one.
|
|
293
|
+
if gap <= tolerance and gap < best_gap:
|
|
294
|
+
best_parent = parent_index
|
|
295
|
+
best_gap = gap
|
|
296
|
+
peak.kalpha2_of = best_parent
|
|
297
|
+
|
|
298
|
+
return peaks
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def exclude_kalpha2(peaks: list[Peak]) -> list[Peak]:
|
|
302
|
+
"""Return a new list without the flagged satellites.
|
|
303
|
+
|
|
304
|
+
The survivors are copied, not shared, and their ``relative_intensity`` is
|
|
305
|
+
recomputed against the strongest of them, so it again reaches 100.
|
|
306
|
+
"""
|
|
307
|
+
kept = [replace(peak) for peak in peaks if peak.kalpha2_of is None]
|
|
308
|
+
if not kept:
|
|
309
|
+
return []
|
|
310
|
+
|
|
311
|
+
strongest = max(peak.intensity for peak in kept)
|
|
312
|
+
for peak in kept:
|
|
313
|
+
peak.relative_intensity = 100.0 * peak.intensity / strongest
|
|
314
|
+
return kept
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def peaks_to_csv(peaks: list[Peak], path: str | Path) -> Path:
|
|
318
|
+
"""Write ``peaks`` to ``path`` as CSV with a header, returning the path.
|
|
319
|
+
|
|
320
|
+
Positions and widths are written to 3 decimal places and d spacings to 4;
|
|
321
|
+
counts keep one place and relative intensities two. ``kalpha2_of`` is the
|
|
322
|
+
parent's index, or blank for a peak that is not a satellite.
|
|
323
|
+
"""
|
|
324
|
+
path = Path(path)
|
|
325
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
326
|
+
|
|
327
|
+
with path.open("w", newline="", encoding="utf-8") as handle:
|
|
328
|
+
writer = csv.writer(handle)
|
|
329
|
+
writer.writerow(CSV_COLUMNS)
|
|
330
|
+
for peak in peaks:
|
|
331
|
+
values = asdict(peak)
|
|
332
|
+
writer.writerow(
|
|
333
|
+
[
|
|
334
|
+
f"{values[column]:.{CSV_DECIMALS[column]}f}"
|
|
335
|
+
if column in CSV_DECIMALS
|
|
336
|
+
else ("" if values[column] is None else values[column])
|
|
337
|
+
for column in CSV_COLUMNS
|
|
338
|
+
]
|
|
339
|
+
)
|
|
340
|
+
return path
|