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/broadening.py
ADDED
|
@@ -0,0 +1,1155 @@
|
|
|
1
|
+
"""Peak widths: profile fitting of reflections and the instrumental resolution.
|
|
2
|
+
|
|
3
|
+
The widths reported by :func:`~xrdkit.peaks.find_peaks` are read straight off
|
|
4
|
+
the counts at half the prominence. They carry no error bar, and they include
|
|
5
|
+
whatever of the K alpha 2 satellite lies inside the half maximum, which is all
|
|
6
|
+
of it at low angle and a changing fraction of it once the pair starts to
|
|
7
|
+
separate. That is good enough to find peaks, but not to measure broadening.
|
|
8
|
+
|
|
9
|
+
:func:`fit_profile` fits a reflection instead as a K alpha 1 and K alpha 2
|
|
10
|
+
doublet of split pseudo-Voigt lines on a linear background, and reports the
|
|
11
|
+
width of the K alpha 1 line alone with its esd. The split lets the low angle
|
|
12
|
+
half be wider than the high angle half, which is what axial divergence does to
|
|
13
|
+
the low angle reflections; a symmetric line misses their tops and so misreads
|
|
14
|
+
their widths.
|
|
15
|
+
|
|
16
|
+
:func:`fit_caglioti` fits the Caglioti relation to the widths from a standard,
|
|
17
|
+
which gives the instrumental FWHM at any angle. Sample widths must be measured
|
|
18
|
+
with the same :func:`fit_profile` before the instrumental width is taken out of
|
|
19
|
+
them, so that the satellite is handled the same way on both sides.
|
|
20
|
+
|
|
21
|
+
:func:`fit_breadth_models` asks what the corrected breadths follow with angle:
|
|
22
|
+
crystallite size, strain, or a spread of specimen heights across the surface,
|
|
23
|
+
:func:`height_spread_breadth`, which the LaB6 standard would not share.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
from scipy import optimize, special
|
|
32
|
+
|
|
33
|
+
from xrdkit.peaks import KALPHA2_RATIO
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"BreadthModelFit",
|
|
37
|
+
"BreadthModels",
|
|
38
|
+
"BroadeningCorrection",
|
|
39
|
+
"Caglioti",
|
|
40
|
+
"ProfileFit",
|
|
41
|
+
"correct_broadening",
|
|
42
|
+
"doublet_gaps",
|
|
43
|
+
"fit_breadth_models",
|
|
44
|
+
"fit_caglioti",
|
|
45
|
+
"fit_profile",
|
|
46
|
+
"height_spread_breadth",
|
|
47
|
+
"integral_breadth",
|
|
48
|
+
"kalpha2_position",
|
|
49
|
+
"pseudo_voigt",
|
|
50
|
+
"pseudo_voigt_components",
|
|
51
|
+
"pseudo_voigt_from_components",
|
|
52
|
+
"split_pseudo_voigt",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
# Integrated intensity of K alpha 2 over K alpha 1, as the Aeris records it.
|
|
56
|
+
KALPHA2_INTENSITY_RATIO = 0.5
|
|
57
|
+
|
|
58
|
+
# Half the fitting window beyond the doublet, as a multiple of the starting
|
|
59
|
+
# width, so that the background either side is well sampled.
|
|
60
|
+
WINDOW_FWHM = 10.0
|
|
61
|
+
|
|
62
|
+
# The data must run at least this many fitted widths below K alpha 1 and beyond
|
|
63
|
+
# K alpha 2 for the fit to count as whole; less and the tails, and with them the
|
|
64
|
+
# background, are not seen.
|
|
65
|
+
MIN_MARGIN_FWHM = 3.0
|
|
66
|
+
|
|
67
|
+
# Each end of the window, as a fraction of its points, gives the starting
|
|
68
|
+
# background.
|
|
69
|
+
BACKGROUND_FRACTION = 0.1
|
|
70
|
+
|
|
71
|
+
# Parameters of a split pseudo-Voigt doublet on a linear background, in the
|
|
72
|
+
# order fit_profile hands them to the optimiser.
|
|
73
|
+
PROFILE_PARAMETERS = (
|
|
74
|
+
"two_theta",
|
|
75
|
+
"fwhm",
|
|
76
|
+
"eta",
|
|
77
|
+
"asymmetry",
|
|
78
|
+
"area",
|
|
79
|
+
"background",
|
|
80
|
+
"slope",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Bounds on the asymmetry, short of the +-1 at which one half width vanishes.
|
|
84
|
+
MAX_ASYMMETRY = 0.95
|
|
85
|
+
|
|
86
|
+
# The Caglioti relation has three parameters; one more point is the least that
|
|
87
|
+
# leaves a residual to scale the esds by.
|
|
88
|
+
CAGLIOTI_PARAMETERS = 3
|
|
89
|
+
MIN_CAGLIOTI_POINTS = CAGLIOTI_PARAMETERS + 1
|
|
90
|
+
|
|
91
|
+
FOUR_LN2 = 4.0 * np.log(2.0)
|
|
92
|
+
|
|
93
|
+
# Thompson, Cox and Hastings, J. Appl. Cryst. 20 (1987) 79: the FWHM of a
|
|
94
|
+
# Voigt as the fifth root of a quintic in its Gaussian and Lorentzian FWHM,
|
|
95
|
+
# coefficients of G^5, G^4 L, ... L^5, and the pseudo-Voigt mixing parameter
|
|
96
|
+
# that matches it as a cubic in L / FWHM, coefficients of the first to third
|
|
97
|
+
# powers.
|
|
98
|
+
TCH_FWHM = (1.0, 2.69269, 2.42843, 4.47163, 0.07842, 1.0)
|
|
99
|
+
TCH_ETA = (1.36603, -0.47719, 0.11116)
|
|
100
|
+
|
|
101
|
+
# A sample width must exceed the instrumental width by this many combined
|
|
102
|
+
# esds to count as resolved.
|
|
103
|
+
DEFAULT_SIGNIFICANCE = 2.0
|
|
104
|
+
|
|
105
|
+
# Relative step of the numerical derivatives used to propagate esds through
|
|
106
|
+
# the correction.
|
|
107
|
+
DERIVATIVE_STEP = 1e-6
|
|
108
|
+
|
|
109
|
+
# fit_breadth_models: the fewest breadths that leave the two parameter model a
|
|
110
|
+
# residual, and the Scherrer constant of an integral breadth.
|
|
111
|
+
MIN_BREADTH_MODEL_POINTS = 3
|
|
112
|
+
BREADTH_MODEL_K = 1.0
|
|
113
|
+
|
|
114
|
+
# The squared terms of the size plus height fit start at least this far above
|
|
115
|
+
# zero, as a fraction of the largest squared breadth. A free fit that beats the
|
|
116
|
+
# better one term fit by less than QUADRATURE_BOUND_CHI_SQUARED in chi squared,
|
|
117
|
+
# far below the one or so a real improvement needs, has only crept towards a
|
|
118
|
+
# bound from inside, where the esd of the vanishing term runs away; the fit is
|
|
119
|
+
# then taken as on that bound.
|
|
120
|
+
QUADRATURE_START_FLOOR = 1e-6
|
|
121
|
+
QUADRATURE_BOUND_CHI_SQUARED = 1e-3
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def pseudo_voigt(
|
|
125
|
+
two_theta: np.ndarray, centre: float, fwhm: float, eta: float
|
|
126
|
+
) -> np.ndarray:
|
|
127
|
+
"""A pseudo-Voigt line of unit area.
|
|
128
|
+
|
|
129
|
+
The Lorentzian and Gaussian components share one FWHM and are mixed as
|
|
130
|
+
``eta`` L + (1 - ``eta``) G, so ``eta`` runs from 0 for a pure Gaussian to
|
|
131
|
+
1 for a pure Lorentzian.
|
|
132
|
+
"""
|
|
133
|
+
x = (np.asarray(two_theta, dtype=float) - centre) / fwhm
|
|
134
|
+
gaussian = np.sqrt(FOUR_LN2 / np.pi) / fwhm * np.exp(-FOUR_LN2 * x**2)
|
|
135
|
+
lorentzian = 2.0 / (np.pi * fwhm) / (1.0 + 4.0 * x**2)
|
|
136
|
+
return eta * lorentzian + (1.0 - eta) * gaussian
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def split_pseudo_voigt(
|
|
140
|
+
two_theta: np.ndarray, centre: float, fwhm: float, eta: float, asymmetry: float
|
|
141
|
+
) -> np.ndarray:
|
|
142
|
+
"""A pseudo-Voigt line of unit area with different half widths either side.
|
|
143
|
+
|
|
144
|
+
The half width below ``centre`` is ``fwhm`` (1 - ``asymmetry``) / 2 and the
|
|
145
|
+
half width above it ``fwhm`` (1 + ``asymmetry``) / 2, so ``fwhm`` is still
|
|
146
|
+
the full width at half maximum and a negative ``asymmetry`` gives the low
|
|
147
|
+
angle tail that axial divergence puts on low angle reflections. The two
|
|
148
|
+
halves meet at the same height, and zero asymmetry is :func:`pseudo_voigt`.
|
|
149
|
+
"""
|
|
150
|
+
two_theta = np.asarray(two_theta, dtype=float)
|
|
151
|
+
below = fwhm * (1.0 - asymmetry)
|
|
152
|
+
above = fwhm * (1.0 + asymmetry)
|
|
153
|
+
# Each half is half of a symmetric line of its own width, scaled so the
|
|
154
|
+
# halves join at the peak and the whole integrates to one.
|
|
155
|
+
return np.where(
|
|
156
|
+
two_theta < centre,
|
|
157
|
+
below / fwhm * pseudo_voigt(two_theta, centre, below, eta),
|
|
158
|
+
above / fwhm * pseudo_voigt(two_theta, centre, above, eta),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _satellite(two_theta: float | np.ndarray, wavelength_ratio: float):
|
|
163
|
+
"""The K alpha 2 position of a K alpha 1 line at ``two_theta``, in degrees."""
|
|
164
|
+
sin_theta = wavelength_ratio * np.sin(np.radians(np.asarray(two_theta) / 2.0))
|
|
165
|
+
return 2.0 * np.degrees(np.arcsin(np.clip(sin_theta, -1.0, 1.0)))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def kalpha2_position(
|
|
169
|
+
two_theta: float, wavelength_ratio: float = KALPHA2_RATIO
|
|
170
|
+
) -> float:
|
|
171
|
+
"""Where the K alpha 2 line of a K alpha 1 line at ``two_theta`` falls.
|
|
172
|
+
|
|
173
|
+
The satellite diffracts at the same d spacing at the longer wavelength, so
|
|
174
|
+
it always lies to high angle, by more the higher the angle.
|
|
175
|
+
"""
|
|
176
|
+
return float(_satellite(two_theta, wavelength_ratio))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass
|
|
180
|
+
class ProfileFit:
|
|
181
|
+
"""A reflection fitted as a split pseudo-Voigt K alpha doublet.
|
|
182
|
+
|
|
183
|
+
``two_theta`` and ``fwhm`` belong to the K alpha 1 line; the satellite sits
|
|
184
|
+
at ``kalpha2_two_theta`` with the same width and shape. ``area`` is the
|
|
185
|
+
integrated intensity of K alpha 1 above the background, in counts times
|
|
186
|
+
degrees. Esds are scaled by the reduced chi squared; the esd of a parameter
|
|
187
|
+
held fixed is ``None``.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
two_theta: float
|
|
191
|
+
esd_two_theta: float
|
|
192
|
+
fwhm: float
|
|
193
|
+
esd_fwhm: float
|
|
194
|
+
eta: float
|
|
195
|
+
esd_eta: float
|
|
196
|
+
asymmetry: float
|
|
197
|
+
esd_asymmetry: float | None
|
|
198
|
+
area: float
|
|
199
|
+
esd_area: float
|
|
200
|
+
background: float
|
|
201
|
+
slope: float
|
|
202
|
+
kalpha2_two_theta: float
|
|
203
|
+
window: tuple[float, float]
|
|
204
|
+
n_points: int
|
|
205
|
+
reduced_chi_squared: float
|
|
206
|
+
# Weighted profile R factor, as a fraction.
|
|
207
|
+
r_wp: float
|
|
208
|
+
converged: bool
|
|
209
|
+
# Whether the data stop short of MIN_MARGIN_FWHM widths beyond the doublet
|
|
210
|
+
# at either end, so that a tail and the background there go unseen.
|
|
211
|
+
truncated: bool
|
|
212
|
+
# The fitted curve over the window, for plotting and inspection.
|
|
213
|
+
fitted: np.ndarray
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def fit_profile(
|
|
217
|
+
two_theta: np.ndarray,
|
|
218
|
+
intensity: np.ndarray,
|
|
219
|
+
centre: float,
|
|
220
|
+
fwhm_guess: float,
|
|
221
|
+
window: tuple[float, float] | None = None,
|
|
222
|
+
fit_asymmetry: bool = True,
|
|
223
|
+
wavelength_ratio: float = KALPHA2_RATIO,
|
|
224
|
+
intensity_ratio: float = KALPHA2_INTENSITY_RATIO,
|
|
225
|
+
) -> ProfileFit:
|
|
226
|
+
"""Fit one reflection as a K alpha 1 and K alpha 2 split pseudo-Voigt doublet.
|
|
227
|
+
|
|
228
|
+
The satellite is tied to its parent: it sits where the parent's d spacing
|
|
229
|
+
puts the longer wavelength, carries ``intensity_ratio`` of its area, and
|
|
230
|
+
shares its width, mixing parameter and asymmetry. Both lines stand on one
|
|
231
|
+
linear background. The free parameters are therefore the K alpha 1
|
|
232
|
+
position, FWHM, mixing parameter, asymmetry and area, and the background
|
|
233
|
+
level and slope. Counts are weighted as Poisson, 1 / max(counts, 1).
|
|
234
|
+
|
|
235
|
+
The same function, with the same settings, has to measure the standard and
|
|
236
|
+
the samples, so that an instrumental width is only ever compared with a
|
|
237
|
+
sample width that treats the doublet and the asymmetry the same way.
|
|
238
|
+
|
|
239
|
+
Parameters
|
|
240
|
+
----------
|
|
241
|
+
two_theta, intensity
|
|
242
|
+
The pattern, in degrees and counts.
|
|
243
|
+
centre
|
|
244
|
+
Starting K alpha 1 position, in degrees.
|
|
245
|
+
fwhm_guess
|
|
246
|
+
Starting FWHM, in degrees. A width read off the raw doublet, such as
|
|
247
|
+
``Peak.fwhm``, is close enough.
|
|
248
|
+
window
|
|
249
|
+
``(low, high)`` range to fit, in degrees. By default it runs
|
|
250
|
+
``WINDOW_FWHM`` starting widths below ``centre`` and the same beyond
|
|
251
|
+
the satellite, cut at the ends of the data.
|
|
252
|
+
fit_asymmetry
|
|
253
|
+
Refine the asymmetry of :func:`split_pseudo_voigt`. When ``False`` it
|
|
254
|
+
is held at zero and the lines are symmetric pseudo-Voigts.
|
|
255
|
+
wavelength_ratio
|
|
256
|
+
K alpha 2 over K alpha 1 wavelength.
|
|
257
|
+
intensity_ratio
|
|
258
|
+
K alpha 2 over K alpha 1 integrated intensity. Zero fits a single line.
|
|
259
|
+
|
|
260
|
+
Returns
|
|
261
|
+
-------
|
|
262
|
+
ProfileFit
|
|
263
|
+
The fitted parameters, their esds and the quality of the fit.
|
|
264
|
+
|
|
265
|
+
Raises
|
|
266
|
+
------
|
|
267
|
+
ValueError
|
|
268
|
+
If ``fwhm_guess`` is not positive, or the window holds no more points
|
|
269
|
+
than there are parameters.
|
|
270
|
+
"""
|
|
271
|
+
if fwhm_guess <= 0.0:
|
|
272
|
+
raise ValueError(f"fwhm_guess must be positive, got {fwhm_guess}")
|
|
273
|
+
|
|
274
|
+
two_theta = np.asarray(two_theta, dtype=float)
|
|
275
|
+
intensity = np.asarray(intensity, dtype=float)
|
|
276
|
+
if window is None:
|
|
277
|
+
margin = WINDOW_FWHM * fwhm_guess
|
|
278
|
+
window = (
|
|
279
|
+
centre - margin,
|
|
280
|
+
float(_satellite(centre, wavelength_ratio)) + margin,
|
|
281
|
+
)
|
|
282
|
+
low, high = window
|
|
283
|
+
selected = (two_theta >= low) & (two_theta <= high)
|
|
284
|
+
x = two_theta[selected]
|
|
285
|
+
y = intensity[selected]
|
|
286
|
+
|
|
287
|
+
free = np.ones(len(PROFILE_PARAMETERS), dtype=bool)
|
|
288
|
+
free[PROFILE_PARAMETERS.index("asymmetry")] = fit_asymmetry
|
|
289
|
+
n_parameters = int(free.sum())
|
|
290
|
+
if x.size <= n_parameters:
|
|
291
|
+
raise ValueError(
|
|
292
|
+
f"window {low:.3f}-{high:.3f} holds {x.size} points, "
|
|
293
|
+
f"need more than {n_parameters}"
|
|
294
|
+
)
|
|
295
|
+
middle = 0.5 * (x[0] + x[-1])
|
|
296
|
+
sigma = np.sqrt(np.maximum(y, 1.0))
|
|
297
|
+
|
|
298
|
+
def model(parameters: np.ndarray) -> np.ndarray:
|
|
299
|
+
position, fwhm, eta, asymmetry, area, background, slope = parameters
|
|
300
|
+
satellite = _satellite(position, wavelength_ratio)
|
|
301
|
+
peak = split_pseudo_voigt(x, position, fwhm, eta, asymmetry)
|
|
302
|
+
peak += intensity_ratio * split_pseudo_voigt(x, satellite, fwhm, eta, asymmetry)
|
|
303
|
+
return background + slope * (x - middle) + area * peak
|
|
304
|
+
|
|
305
|
+
# Starting background: a straight line through the two ends of the window.
|
|
306
|
+
edge = max(2, int(BACKGROUND_FRACTION * x.size))
|
|
307
|
+
left = float(np.mean(y[:edge]))
|
|
308
|
+
right = float(np.mean(y[-edge:]))
|
|
309
|
+
slope0 = (right - left) / (np.mean(x[-edge:]) - np.mean(x[:edge]))
|
|
310
|
+
background0 = 0.5 * (left + right)
|
|
311
|
+
height = max(float(np.max(y)) - background0, 1.0)
|
|
312
|
+
# A pseudo-Voigt of eta one half is about 1.3 times its height by its FWHM.
|
|
313
|
+
area0 = 1.3 * height * fwhm_guess / (1.0 + intensity_ratio)
|
|
314
|
+
|
|
315
|
+
step = float(np.median(np.diff(x)))
|
|
316
|
+
lower = np.array([x[0], step / 2.0, 0.0, -MAX_ASYMMETRY, 0.0, -np.inf, -np.inf])
|
|
317
|
+
upper = np.array([x[-1], x[-1] - x[0], 1.0, MAX_ASYMMETRY, np.inf, np.inf, np.inf])
|
|
318
|
+
start = np.array([centre, fwhm_guess, 0.5, 0.0, area0, background0, slope0])
|
|
319
|
+
start = np.clip(start, lower, upper)
|
|
320
|
+
|
|
321
|
+
def residual(varying: np.ndarray) -> np.ndarray:
|
|
322
|
+
parameters = start.copy()
|
|
323
|
+
parameters[free] = varying
|
|
324
|
+
return (y - model(parameters)) / sigma
|
|
325
|
+
|
|
326
|
+
result = optimize.least_squares(
|
|
327
|
+
residual, start[free], bounds=(lower[free], upper[free]), x_scale="jac"
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
parameters = start.copy()
|
|
331
|
+
parameters[free] = result.x
|
|
332
|
+
residuals = np.asarray(result.fun, dtype=float)
|
|
333
|
+
reduced_chi_squared = float(np.sum(residuals**2) / (x.size - n_parameters))
|
|
334
|
+
jacobian = np.asarray(result.jac, dtype=float)
|
|
335
|
+
covariance = np.linalg.pinv(jacobian.T @ jacobian) * reduced_chi_squared
|
|
336
|
+
deviations = np.sqrt(np.abs(np.diag(covariance)))
|
|
337
|
+
esd = {
|
|
338
|
+
name: float(value)
|
|
339
|
+
for name, value in zip(np.array(PROFILE_PARAMETERS)[free], deviations)
|
|
340
|
+
}
|
|
341
|
+
position, fwhm, eta, asymmetry, area, background, slope = (
|
|
342
|
+
float(value) for value in parameters
|
|
343
|
+
)
|
|
344
|
+
satellite = float(_satellite(position, wavelength_ratio))
|
|
345
|
+
margin = MIN_MARGIN_FWHM * fwhm
|
|
346
|
+
r_wp = float(np.sqrt(np.sum(residuals**2) / np.sum((y / sigma) ** 2)))
|
|
347
|
+
|
|
348
|
+
return ProfileFit(
|
|
349
|
+
two_theta=position,
|
|
350
|
+
esd_two_theta=esd["two_theta"],
|
|
351
|
+
fwhm=fwhm,
|
|
352
|
+
esd_fwhm=esd["fwhm"],
|
|
353
|
+
eta=eta,
|
|
354
|
+
esd_eta=esd["eta"],
|
|
355
|
+
asymmetry=asymmetry,
|
|
356
|
+
esd_asymmetry=esd.get("asymmetry"),
|
|
357
|
+
area=area,
|
|
358
|
+
esd_area=esd["area"],
|
|
359
|
+
background=background,
|
|
360
|
+
slope=slope,
|
|
361
|
+
kalpha2_two_theta=satellite,
|
|
362
|
+
window=(float(x[0]), float(x[-1])),
|
|
363
|
+
n_points=int(x.size),
|
|
364
|
+
reduced_chi_squared=reduced_chi_squared,
|
|
365
|
+
r_wp=r_wp,
|
|
366
|
+
converged=bool(result.success),
|
|
367
|
+
truncated=bool(position - x[0] < margin or x[-1] - satellite < margin),
|
|
368
|
+
fitted=model(parameters),
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _tan_theta(two_theta: float | np.ndarray) -> np.ndarray:
|
|
373
|
+
"""tan(theta) of ``two_theta`` in degrees, which must lie in (0, 180)."""
|
|
374
|
+
two_theta = np.asarray(two_theta, dtype=float)
|
|
375
|
+
if np.any((two_theta <= 0.0) | (two_theta >= 180.0)):
|
|
376
|
+
raise ValueError("two_theta must lie strictly between 0 and 180 degrees")
|
|
377
|
+
return np.tan(np.radians(two_theta / 2.0))
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@dataclass
|
|
381
|
+
class Caglioti:
|
|
382
|
+
"""The Caglioti resolution function, FWHM^2 = U tan^2 + V tan + W.
|
|
383
|
+
|
|
384
|
+
U, V and W are in degrees squared of 2theta, theta being half the
|
|
385
|
+
diffraction angle. ``covariance`` is the 3 by 3 covariance of (U, V, W),
|
|
386
|
+
scaled by the reduced chi squared of the fit; ``rms`` is the root mean
|
|
387
|
+
square of the FWHM residuals, in degrees.
|
|
388
|
+
"""
|
|
389
|
+
|
|
390
|
+
u: float
|
|
391
|
+
v: float
|
|
392
|
+
w: float
|
|
393
|
+
esd_u: float
|
|
394
|
+
esd_v: float
|
|
395
|
+
esd_w: float
|
|
396
|
+
n_peaks: int
|
|
397
|
+
rms: float
|
|
398
|
+
covariance: np.ndarray
|
|
399
|
+
|
|
400
|
+
def _squared(self, two_theta: float | np.ndarray) -> np.ndarray:
|
|
401
|
+
tan_theta = _tan_theta(two_theta)
|
|
402
|
+
return self.u * tan_theta**2 + self.v * tan_theta + self.w
|
|
403
|
+
|
|
404
|
+
def fwhm(self, two_theta: float | np.ndarray) -> float | np.ndarray:
|
|
405
|
+
"""The FWHM at ``two_theta``, in degrees.
|
|
406
|
+
|
|
407
|
+
Where the quadratic dips below zero, which a fitted V < 0 can do outside
|
|
408
|
+
the range of the data, the width is taken as zero rather than the
|
|
409
|
+
square root of a negative number.
|
|
410
|
+
|
|
411
|
+
Raises
|
|
412
|
+
------
|
|
413
|
+
ValueError
|
|
414
|
+
If any ``two_theta`` lies outside (0, 180) degrees.
|
|
415
|
+
"""
|
|
416
|
+
width = np.sqrt(np.clip(self._squared(two_theta), 0.0, None))
|
|
417
|
+
return float(width) if width.ndim == 0 else width
|
|
418
|
+
|
|
419
|
+
def fwhm_esd(self, two_theta: float | np.ndarray) -> float | np.ndarray:
|
|
420
|
+
"""The esd of :meth:`fwhm` at ``two_theta``, from the covariance.
|
|
421
|
+
|
|
422
|
+
``nan`` where the width is zero, since the esd of a square root is not
|
|
423
|
+
defined there.
|
|
424
|
+
"""
|
|
425
|
+
tan_theta = _tan_theta(two_theta)
|
|
426
|
+
gradient = np.stack([tan_theta**2, tan_theta, np.ones_like(tan_theta)])
|
|
427
|
+
variance = np.einsum("i...,ij,j...->...", gradient, self.covariance, gradient)
|
|
428
|
+
width = np.asarray(self.fwhm(two_theta), dtype=float)
|
|
429
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
430
|
+
esd = np.where(
|
|
431
|
+
width > 0.0, np.sqrt(np.abs(variance)) / (2.0 * width), np.nan
|
|
432
|
+
)
|
|
433
|
+
return float(esd) if esd.ndim == 0 else esd
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def fit_caglioti(
|
|
437
|
+
two_theta: np.ndarray,
|
|
438
|
+
fwhm: np.ndarray,
|
|
439
|
+
weights: np.ndarray | None = None,
|
|
440
|
+
) -> Caglioti:
|
|
441
|
+
"""Fit FWHM^2 = U tan^2(theta) + V tan(theta) + W by weighted least squares.
|
|
442
|
+
|
|
443
|
+
The relation is linear in U, V and W, so this is a single linear solve.
|
|
444
|
+
The covariance is scaled by the reduced chi squared, as
|
|
445
|
+
:func:`~xrdkit.lattice.refine_lattice` scales its own, so the esds reflect
|
|
446
|
+
how well the relation actually describes the widths.
|
|
447
|
+
|
|
448
|
+
Parameters
|
|
449
|
+
----------
|
|
450
|
+
two_theta
|
|
451
|
+
Peak positions, in degrees.
|
|
452
|
+
fwhm
|
|
453
|
+
Peak widths, in degrees.
|
|
454
|
+
weights
|
|
455
|
+
Weight of each point in the sum of squared FWHM^2 residuals. For a
|
|
456
|
+
width with esd s this is 1 / (2 FWHM s)^2. Equal weights by default.
|
|
457
|
+
|
|
458
|
+
Raises
|
|
459
|
+
------
|
|
460
|
+
ValueError
|
|
461
|
+
If the arrays differ in length, there are fewer than four points, a
|
|
462
|
+
position lies outside (0, 180) degrees, a width is not positive, or a
|
|
463
|
+
weight is negative or not finite.
|
|
464
|
+
"""
|
|
465
|
+
two_theta = np.asarray(two_theta, dtype=float).ravel()
|
|
466
|
+
fwhm = np.asarray(fwhm, dtype=float).ravel()
|
|
467
|
+
weights = (
|
|
468
|
+
np.ones_like(fwhm) if weights is None else np.asarray(weights, float).ravel()
|
|
469
|
+
)
|
|
470
|
+
if not two_theta.size == fwhm.size == weights.size:
|
|
471
|
+
raise ValueError(
|
|
472
|
+
f"two_theta, fwhm and weights differ in length: {two_theta.size}, "
|
|
473
|
+
f"{fwhm.size}, {weights.size}"
|
|
474
|
+
)
|
|
475
|
+
if two_theta.size < MIN_CAGLIOTI_POINTS:
|
|
476
|
+
raise ValueError(
|
|
477
|
+
f"Need at least {MIN_CAGLIOTI_POINTS} widths to fit U, V and W, "
|
|
478
|
+
f"got {two_theta.size}"
|
|
479
|
+
)
|
|
480
|
+
if np.any(~np.isfinite(fwhm) | (fwhm <= 0.0)):
|
|
481
|
+
raise ValueError("Every fwhm must be positive")
|
|
482
|
+
if np.any(~np.isfinite(weights) | (weights < 0.0)):
|
|
483
|
+
raise ValueError("Every weight must be finite and not negative")
|
|
484
|
+
|
|
485
|
+
tan_theta = _tan_theta(two_theta)
|
|
486
|
+
design = np.column_stack([tan_theta**2, tan_theta, np.ones_like(tan_theta)])
|
|
487
|
+
root = np.sqrt(weights)
|
|
488
|
+
parameters, *_ = np.linalg.lstsq(design * root[:, None], fwhm**2 * root, rcond=None)
|
|
489
|
+
|
|
490
|
+
weighted = (fwhm**2 - design @ parameters) * root
|
|
491
|
+
degrees_of_freedom = two_theta.size - CAGLIOTI_PARAMETERS
|
|
492
|
+
reduced_chi_squared = float(np.sum(weighted**2) / degrees_of_freedom)
|
|
493
|
+
normal = design.T @ (design * weights[:, None])
|
|
494
|
+
covariance = np.linalg.pinv(normal) * reduced_chi_squared
|
|
495
|
+
esd = np.sqrt(np.abs(np.diag(covariance)))
|
|
496
|
+
|
|
497
|
+
u, v, w = (float(p) for p in parameters)
|
|
498
|
+
fit = Caglioti(
|
|
499
|
+
u=u,
|
|
500
|
+
v=v,
|
|
501
|
+
w=w,
|
|
502
|
+
esd_u=float(esd[0]),
|
|
503
|
+
esd_v=float(esd[1]),
|
|
504
|
+
esd_w=float(esd[2]),
|
|
505
|
+
n_peaks=int(two_theta.size),
|
|
506
|
+
rms=0.0,
|
|
507
|
+
covariance=covariance,
|
|
508
|
+
)
|
|
509
|
+
fit.rms = float(np.sqrt(np.mean((fwhm - fit.fwhm(two_theta)) ** 2)))
|
|
510
|
+
return fit
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def doublet_gaps(
|
|
514
|
+
two_theta: float,
|
|
515
|
+
others: list[float] | np.ndarray,
|
|
516
|
+
wavelength_ratio: float = KALPHA2_RATIO,
|
|
517
|
+
) -> tuple[float, float]:
|
|
518
|
+
"""The clear space either side of a K alpha doublet, in degrees.
|
|
519
|
+
|
|
520
|
+
``others`` are the K alpha 1 positions of every other reflection or peak
|
|
521
|
+
that might lie nearby; each brings its own K alpha 2 line too. The first
|
|
522
|
+
gap runs down from the K alpha 1 line at ``two_theta`` to the nearest line
|
|
523
|
+
below it, the second up from its K alpha 2 line to the nearest line above.
|
|
524
|
+
A line falling between the two members of the doublet closes both gaps to
|
|
525
|
+
zero, and a side with no line at all is infinitely clear.
|
|
526
|
+
"""
|
|
527
|
+
satellite = float(_satellite(two_theta, wavelength_ratio))
|
|
528
|
+
others = np.asarray(others, dtype=float).ravel()
|
|
529
|
+
lines = np.concatenate([others, _satellite(others, wavelength_ratio)])
|
|
530
|
+
if np.any((lines >= two_theta) & (lines <= satellite)):
|
|
531
|
+
return 0.0, 0.0
|
|
532
|
+
below = lines[lines < two_theta]
|
|
533
|
+
above = lines[lines > satellite]
|
|
534
|
+
return (
|
|
535
|
+
float(two_theta - below.max()) if below.size else float("inf"),
|
|
536
|
+
float(above.min() - satellite) if above.size else float("inf"),
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def pseudo_voigt_from_components(
|
|
541
|
+
fwhm_gaussian: float, fwhm_lorentzian: float
|
|
542
|
+
) -> tuple[float, float]:
|
|
543
|
+
"""The FWHM and mixing parameter of the pseudo-Voigt matching a Voigt.
|
|
544
|
+
|
|
545
|
+
Uses the Thompson, Cox and Hastings approximation, from the FWHM of the
|
|
546
|
+
Gaussian and Lorentzian the Voigt convolves.
|
|
547
|
+
|
|
548
|
+
Raises
|
|
549
|
+
------
|
|
550
|
+
ValueError
|
|
551
|
+
If either width is negative, or both are zero.
|
|
552
|
+
"""
|
|
553
|
+
if fwhm_gaussian < 0.0 or fwhm_lorentzian < 0.0:
|
|
554
|
+
raise ValueError(
|
|
555
|
+
f"Component widths cannot be negative, got {fwhm_gaussian} "
|
|
556
|
+
f"and {fwhm_lorentzian}"
|
|
557
|
+
)
|
|
558
|
+
if fwhm_gaussian == 0.0 and fwhm_lorentzian == 0.0:
|
|
559
|
+
raise ValueError("At least one component width must be positive")
|
|
560
|
+
fwhm = _tch_fwhm(fwhm_gaussian, fwhm_lorentzian)
|
|
561
|
+
# The cubic reaches 1 at a pure Lorentzian only to rounding.
|
|
562
|
+
return fwhm, min(_tch_eta(fwhm_lorentzian / fwhm), 1.0)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def pseudo_voigt_components(fwhm: float, eta: float) -> tuple[float, float]:
|
|
566
|
+
"""The Gaussian and Lorentzian FWHM of the Voigt a pseudo-Voigt matches.
|
|
567
|
+
|
|
568
|
+
The inverse of :func:`pseudo_voigt_from_components`: the Thompson, Cox and
|
|
569
|
+
Hastings cubic is solved for the Lorentzian fraction of the width, and the
|
|
570
|
+
quintic then for the Gaussian width that makes up the rest.
|
|
571
|
+
|
|
572
|
+
Returns
|
|
573
|
+
-------
|
|
574
|
+
tuple[float, float]
|
|
575
|
+
The Gaussian and Lorentzian FWHM, in the units of ``fwhm``.
|
|
576
|
+
|
|
577
|
+
Raises
|
|
578
|
+
------
|
|
579
|
+
ValueError
|
|
580
|
+
If ``fwhm`` is not positive or ``eta`` lies outside 0 to 1.
|
|
581
|
+
"""
|
|
582
|
+
if not fwhm > 0.0:
|
|
583
|
+
raise ValueError(f"fwhm must be positive, got {fwhm}")
|
|
584
|
+
if not 0.0 <= eta <= 1.0:
|
|
585
|
+
raise ValueError(f"eta must lie between 0 and 1, got {eta}")
|
|
586
|
+
|
|
587
|
+
# The cubic rises steadily from 0 to 1 across the unit interval, so it has
|
|
588
|
+
# exactly one root there; the ends are settled directly so that rounding in
|
|
589
|
+
# the coefficients cannot push the root out of the bracket.
|
|
590
|
+
if eta <= 0.0:
|
|
591
|
+
fraction = 0.0
|
|
592
|
+
elif eta >= _tch_eta(1.0):
|
|
593
|
+
fraction = 1.0
|
|
594
|
+
else:
|
|
595
|
+
fraction = optimize.brentq(
|
|
596
|
+
lambda ratio: _tch_eta(ratio) - eta, 0.0, 1.0, xtol=1e-15
|
|
597
|
+
)
|
|
598
|
+
lorentzian = fraction * fwhm
|
|
599
|
+
if fraction >= 1.0:
|
|
600
|
+
return 0.0, fwhm
|
|
601
|
+
|
|
602
|
+
# The quintic grows with the Gaussian width, from the Lorentzian width
|
|
603
|
+
# alone at zero up past fwhm, so again there is one root in the bracket.
|
|
604
|
+
# Where the Lorentzian is so small that rounding leaves the top of the
|
|
605
|
+
# bracket short of fwhm, the profile is Gaussian to working precision.
|
|
606
|
+
def shortfall(width: float) -> float:
|
|
607
|
+
return _tch_fwhm(width, lorentzian) - fwhm
|
|
608
|
+
|
|
609
|
+
if shortfall(fwhm) <= 0.0:
|
|
610
|
+
return fwhm, lorentzian
|
|
611
|
+
gaussian = optimize.brentq(shortfall, 0.0, fwhm, xtol=1e-15)
|
|
612
|
+
return float(gaussian), float(lorentzian)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def integral_breadth(fwhm_gaussian: float, fwhm_lorentzian: float) -> float:
|
|
616
|
+
"""The integral breadth of a Voigt, from its component FWHM.
|
|
617
|
+
|
|
618
|
+
Area over height, exact for the Voigt: beta = beta_G / erfcx(k), with
|
|
619
|
+
k = beta_L / (sqrt(pi) beta_G), beta_G = (FWHM_G / 2) sqrt(pi / ln 2) and
|
|
620
|
+
beta_L = (pi / 2) FWHM_L.
|
|
621
|
+
"""
|
|
622
|
+
beta_gaussian = 0.5 * fwhm_gaussian * np.sqrt(np.pi / np.log(2.0))
|
|
623
|
+
beta_lorentzian = 0.5 * np.pi * fwhm_lorentzian
|
|
624
|
+
if beta_gaussian == 0.0:
|
|
625
|
+
return float(beta_lorentzian)
|
|
626
|
+
k = beta_lorentzian / (np.sqrt(np.pi) * beta_gaussian)
|
|
627
|
+
return float(beta_gaussian / special.erfcx(k))
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _tch_fwhm(gaussian: float, lorentzian: float) -> float:
|
|
631
|
+
powers = [
|
|
632
|
+
gaussian ** (5 - order) * lorentzian**order for order in range(len(TCH_FWHM))
|
|
633
|
+
]
|
|
634
|
+
return float(np.dot(TCH_FWHM, powers) ** 0.2)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _tch_eta(ratio: float) -> float:
|
|
638
|
+
return float(sum(c * ratio ** (order + 1) for order, c in enumerate(TCH_ETA)))
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
@dataclass
|
|
642
|
+
class BroadeningCorrection:
|
|
643
|
+
"""The sample breadth of a reflection, with the instrument taken out.
|
|
644
|
+
|
|
645
|
+
Widths are in the units given, degrees of 2theta in practice. The profile
|
|
646
|
+
is split into Gaussian and Lorentzian parts by Thompson, Cox and Hastings,
|
|
647
|
+
the instrumental Lorentzian taken off the observed one linearly and the
|
|
648
|
+
Gaussian in quadrature, and the two recombined into ``fwhm`` and ``eta``.
|
|
649
|
+
``integral_breadth`` is that of the corrected Voigt.
|
|
650
|
+
|
|
651
|
+
``fwhm_linear`` and ``fwhm_quadrature`` are the simple estimates for
|
|
652
|
+
comparison: the observed minus the instrumental FWHM, as if both were
|
|
653
|
+
Lorentzian, and the difference of their squares, as if both were Gaussian.
|
|
654
|
+
|
|
655
|
+
A sample component that comes out below zero, which noise can do to the
|
|
656
|
+
Gaussian part of a size broadened peak for instance, is set to zero and
|
|
657
|
+
flagged; its esd is then that of the difference it was clipped from.
|
|
658
|
+
|
|
659
|
+
When ``unresolved``, the observed width is within ``significance`` combined
|
|
660
|
+
esds of the instrumental one, so the sample breadth is not measurable and
|
|
661
|
+
every sample quantity is ``None``. ``excess`` is the observed minus the
|
|
662
|
+
instrumental FWHM over their combined esd either way.
|
|
663
|
+
"""
|
|
664
|
+
|
|
665
|
+
fwhm: float | None
|
|
666
|
+
esd_fwhm: float | None
|
|
667
|
+
eta: float | None
|
|
668
|
+
esd_eta: float | None
|
|
669
|
+
fwhm_gaussian: float | None
|
|
670
|
+
esd_fwhm_gaussian: float | None
|
|
671
|
+
fwhm_lorentzian: float | None
|
|
672
|
+
esd_fwhm_lorentzian: float | None
|
|
673
|
+
integral_breadth: float | None
|
|
674
|
+
esd_integral_breadth: float | None
|
|
675
|
+
fwhm_linear: float | None
|
|
676
|
+
esd_fwhm_linear: float | None
|
|
677
|
+
fwhm_quadrature: float | None
|
|
678
|
+
esd_fwhm_quadrature: float | None
|
|
679
|
+
gaussian_clipped: bool
|
|
680
|
+
lorentzian_clipped: bool
|
|
681
|
+
unresolved: bool
|
|
682
|
+
excess: float
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _quadrature_esd(value: float, esd_squared: float) -> float:
|
|
686
|
+
"""The esd of sqrt(D) from the esd of D.
|
|
687
|
+
|
|
688
|
+
Linear propagation gives esd(D) / (2 sqrt(D)), which runs away as D goes to
|
|
689
|
+
zero; it is capped at sqrt(esd(D)), the width at which D would stand one
|
|
690
|
+
esd above zero, so a width at or near zero keeps a finite, honest esd.
|
|
691
|
+
"""
|
|
692
|
+
if value <= 0.0:
|
|
693
|
+
return float(np.sqrt(esd_squared))
|
|
694
|
+
return float(min(esd_squared / (2.0 * value), np.sqrt(esd_squared)))
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _corrected(inputs: np.ndarray) -> np.ndarray:
|
|
698
|
+
"""Sample (FWHM, eta, G, L, beta, raw L difference, raw G^2 difference)."""
|
|
699
|
+
fwhm_obs, eta_obs, fwhm_inst, eta_inst = inputs
|
|
700
|
+
gaussian_obs, lorentzian_obs = pseudo_voigt_components(fwhm_obs, eta_obs)
|
|
701
|
+
gaussian_inst, lorentzian_inst = pseudo_voigt_components(fwhm_inst, eta_inst)
|
|
702
|
+
lorentzian_difference = lorentzian_obs - lorentzian_inst
|
|
703
|
+
squared_difference = gaussian_obs**2 - gaussian_inst**2
|
|
704
|
+
gaussian = np.sqrt(max(squared_difference, 0.0))
|
|
705
|
+
lorentzian = max(lorentzian_difference, 0.0)
|
|
706
|
+
fwhm, eta = pseudo_voigt_from_components(gaussian, lorentzian)
|
|
707
|
+
return np.array(
|
|
708
|
+
[
|
|
709
|
+
fwhm,
|
|
710
|
+
eta,
|
|
711
|
+
gaussian,
|
|
712
|
+
lorentzian,
|
|
713
|
+
integral_breadth(gaussian, lorentzian),
|
|
714
|
+
lorentzian_difference,
|
|
715
|
+
squared_difference,
|
|
716
|
+
]
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _jacobian(function, inputs: np.ndarray) -> np.ndarray:
|
|
721
|
+
"""Numerical derivatives of ``function`` at ``inputs``, one column each.
|
|
722
|
+
|
|
723
|
+
Central differences, except that a mixing parameter at 0 or 1 is stepped
|
|
724
|
+
inwards only, since the components are not defined beyond it.
|
|
725
|
+
"""
|
|
726
|
+
centre = function(inputs)
|
|
727
|
+
columns = []
|
|
728
|
+
for index, value in enumerate(inputs):
|
|
729
|
+
step = DERIVATIVE_STEP * max(abs(value), 1.0e-3)
|
|
730
|
+
is_eta = index in (1, 3)
|
|
731
|
+
up = inputs.copy()
|
|
732
|
+
down = inputs.copy()
|
|
733
|
+
if is_eta and value + step > 1.0:
|
|
734
|
+
down[index] -= step
|
|
735
|
+
columns.append((centre - function(down)) / step)
|
|
736
|
+
elif is_eta and value - step < 0.0:
|
|
737
|
+
up[index] += step
|
|
738
|
+
columns.append((function(up) - centre) / step)
|
|
739
|
+
else:
|
|
740
|
+
up[index] += step
|
|
741
|
+
down[index] -= step
|
|
742
|
+
columns.append((function(up) - function(down)) / (2.0 * step))
|
|
743
|
+
return np.column_stack(columns)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def correct_broadening(
|
|
747
|
+
fwhm_obs: float,
|
|
748
|
+
eta_obs: float,
|
|
749
|
+
fwhm_inst: float,
|
|
750
|
+
eta_inst: float,
|
|
751
|
+
esd_fwhm_obs: float = 0.0,
|
|
752
|
+
esd_eta_obs: float = 0.0,
|
|
753
|
+
esd_fwhm_inst: float = 0.0,
|
|
754
|
+
esd_eta_inst: float = 0.0,
|
|
755
|
+
significance: float = DEFAULT_SIGNIFICANCE,
|
|
756
|
+
) -> BroadeningCorrection:
|
|
757
|
+
"""Take the instrumental broadening out of an observed pseudo-Voigt.
|
|
758
|
+
|
|
759
|
+
Both profiles are split into their Gaussian and Lorentzian FWHM with
|
|
760
|
+
:func:`pseudo_voigt_components`. Lorentzians convolve by adding widths and
|
|
761
|
+
Gaussians by adding squares, so the sample Lorentzian is the observed one
|
|
762
|
+
less the instrumental one, and the sample Gaussian the root of the
|
|
763
|
+
difference of squares. The two are recombined with
|
|
764
|
+
:func:`pseudo_voigt_from_components`.
|
|
765
|
+
|
|
766
|
+
Esds are propagated linearly through numerical derivatives, treating the
|
|
767
|
+
four inputs as independent. The observed width and mixing parameter of a
|
|
768
|
+
fit are in fact correlated, so the esds are indicative rather than exact.
|
|
769
|
+
|
|
770
|
+
Parameters
|
|
771
|
+
----------
|
|
772
|
+
fwhm_obs, eta_obs
|
|
773
|
+
The observed FWHM and mixing parameter.
|
|
774
|
+
fwhm_inst, eta_inst
|
|
775
|
+
The instrumental FWHM and mixing parameter at the same angle.
|
|
776
|
+
esd_fwhm_obs, esd_eta_obs, esd_fwhm_inst, esd_eta_inst
|
|
777
|
+
Their esds; zero by default.
|
|
778
|
+
significance
|
|
779
|
+
How many combined esds the observed width must clear the instrumental
|
|
780
|
+
one by for the sample breadth to be resolved.
|
|
781
|
+
|
|
782
|
+
Raises
|
|
783
|
+
------
|
|
784
|
+
ValueError
|
|
785
|
+
If a width is not positive, a mixing parameter lies outside 0 to 1, or
|
|
786
|
+
an esd is negative.
|
|
787
|
+
"""
|
|
788
|
+
for name, width in (("fwhm_obs", fwhm_obs), ("fwhm_inst", fwhm_inst)):
|
|
789
|
+
if not width > 0.0:
|
|
790
|
+
raise ValueError(f"{name} must be positive, got {width}")
|
|
791
|
+
for name, eta in (("eta_obs", eta_obs), ("eta_inst", eta_inst)):
|
|
792
|
+
if not 0.0 <= eta <= 1.0:
|
|
793
|
+
raise ValueError(f"{name} must lie between 0 and 1, got {eta}")
|
|
794
|
+
esds = np.array([esd_fwhm_obs, esd_eta_obs, esd_fwhm_inst, esd_eta_inst])
|
|
795
|
+
if np.any(~np.isfinite(esds) | (esds < 0.0)):
|
|
796
|
+
raise ValueError("Every esd must be finite and not negative")
|
|
797
|
+
|
|
798
|
+
combined = float(np.hypot(esd_fwhm_obs, esd_fwhm_inst))
|
|
799
|
+
difference = fwhm_obs - fwhm_inst
|
|
800
|
+
# With no esds at all, any positive difference counts as resolved.
|
|
801
|
+
excess = (
|
|
802
|
+
difference / combined
|
|
803
|
+
if combined > 0.0
|
|
804
|
+
else float(np.copysign(np.inf, difference))
|
|
805
|
+
)
|
|
806
|
+
if difference <= 0.0 or difference < significance * combined:
|
|
807
|
+
return BroadeningCorrection(
|
|
808
|
+
*([None] * 14),
|
|
809
|
+
gaussian_clipped=False,
|
|
810
|
+
lorentzian_clipped=False,
|
|
811
|
+
unresolved=True,
|
|
812
|
+
excess=float(excess),
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
inputs = np.array([fwhm_obs, eta_obs, fwhm_inst, eta_inst], dtype=float)
|
|
816
|
+
values = _corrected(inputs)
|
|
817
|
+
jacobian = _jacobian(_corrected, inputs)
|
|
818
|
+
propagated = np.sqrt((jacobian**2) @ esds**2)
|
|
819
|
+
fwhm, eta, gaussian, lorentzian, breadth, lorentzian_difference, squared = values
|
|
820
|
+
|
|
821
|
+
gaussian_clipped = bool(squared < 0.0)
|
|
822
|
+
lorentzian_clipped = bool(lorentzian_difference < 0.0)
|
|
823
|
+
esd_lorentzian = float(propagated[5])
|
|
824
|
+
esd_gaussian = _quadrature_esd(float(gaussian), float(propagated[6]))
|
|
825
|
+
|
|
826
|
+
linear = difference
|
|
827
|
+
esd_linear = combined
|
|
828
|
+
quadrature_squared = fwhm_obs**2 - fwhm_inst**2
|
|
829
|
+
quadrature = float(np.sqrt(quadrature_squared))
|
|
830
|
+
esd_quadrature_squared = 2.0 * float(
|
|
831
|
+
np.hypot(fwhm_obs * esd_fwhm_obs, fwhm_inst * esd_fwhm_inst)
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
return BroadeningCorrection(
|
|
835
|
+
fwhm=float(fwhm),
|
|
836
|
+
esd_fwhm=float(propagated[0]),
|
|
837
|
+
eta=float(eta),
|
|
838
|
+
esd_eta=float(propagated[1]),
|
|
839
|
+
fwhm_gaussian=float(gaussian),
|
|
840
|
+
esd_fwhm_gaussian=esd_gaussian,
|
|
841
|
+
fwhm_lorentzian=float(lorentzian),
|
|
842
|
+
esd_fwhm_lorentzian=esd_lorentzian,
|
|
843
|
+
integral_breadth=float(breadth),
|
|
844
|
+
esd_integral_breadth=float(propagated[4]),
|
|
845
|
+
fwhm_linear=float(linear),
|
|
846
|
+
esd_fwhm_linear=esd_linear,
|
|
847
|
+
fwhm_quadrature=quadrature,
|
|
848
|
+
esd_fwhm_quadrature=_quadrature_esd(quadrature, esd_quadrature_squared),
|
|
849
|
+
gaussian_clipped=gaussian_clipped,
|
|
850
|
+
lorentzian_clipped=lorentzian_clipped,
|
|
851
|
+
unresolved=False,
|
|
852
|
+
excess=float(excess),
|
|
853
|
+
)
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _cos_theta(two_theta: float | np.ndarray) -> np.ndarray:
|
|
857
|
+
"""cos(theta) of ``two_theta`` in degrees, which must lie in (0, 180)."""
|
|
858
|
+
two_theta = np.asarray(two_theta, dtype=float)
|
|
859
|
+
if np.any(~np.isfinite(two_theta) | (two_theta <= 0.0) | (two_theta >= 180.0)):
|
|
860
|
+
raise ValueError("two_theta must lie strictly between 0 and 180 degrees")
|
|
861
|
+
return np.cos(np.radians(two_theta / 2.0))
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def height_spread_breadth(
|
|
865
|
+
two_theta: float | np.ndarray, delta_s_mm: float, radius_mm: float
|
|
866
|
+
) -> float | np.ndarray:
|
|
867
|
+
"""The breadth a spread of specimen heights gives a line, in degrees.
|
|
868
|
+
|
|
869
|
+
A specimen displaced by s shifts a line by -2 s cos(theta) / R radians of
|
|
870
|
+
2theta. A surface whose heights spread uniformly over ``delta_s_mm``
|
|
871
|
+
spreads the shift over 2 delta_s cos(theta) / R radians, and that is both
|
|
872
|
+
the FWHM and the integral breadth of the broadening it adds. Unlike size
|
|
873
|
+
or strain broadening it narrows as the angle rises. The result is in
|
|
874
|
+
degrees, like every other width here.
|
|
875
|
+
|
|
876
|
+
Parameters
|
|
877
|
+
----------
|
|
878
|
+
two_theta
|
|
879
|
+
Position, in degrees.
|
|
880
|
+
delta_s_mm
|
|
881
|
+
Full spread of heights across the irradiated surface, in mm.
|
|
882
|
+
radius_mm
|
|
883
|
+
Goniometer radius, in mm.
|
|
884
|
+
|
|
885
|
+
Raises
|
|
886
|
+
------
|
|
887
|
+
ValueError
|
|
888
|
+
If ``delta_s_mm`` is negative, ``radius_mm`` is not positive or a
|
|
889
|
+
position lies outside (0, 180) degrees.
|
|
890
|
+
"""
|
|
891
|
+
if not delta_s_mm >= 0.0:
|
|
892
|
+
raise ValueError(f"delta_s_mm cannot be negative, got {delta_s_mm}")
|
|
893
|
+
if not radius_mm > 0.0:
|
|
894
|
+
raise ValueError(f"radius_mm must be positive, got {radius_mm}")
|
|
895
|
+
breadth = np.degrees(2.0 * delta_s_mm * _cos_theta(two_theta) / radius_mm)
|
|
896
|
+
return float(breadth) if breadth.ndim == 0 else breadth
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
@dataclass
|
|
900
|
+
class BreadthModelFit:
|
|
901
|
+
"""One model of sample breadth against angle, fitted by weighted least squares.
|
|
902
|
+
|
|
903
|
+
The model is sqrt((K lambda / (D cos))^2 + (2 delta_s cos / R)^2)
|
|
904
|
+
+ 4 epsilon tan, with whichever of the three terms the model has.
|
|
905
|
+
``size`` is in the units of the wavelength and ``delta_s`` in mm; each is
|
|
906
|
+
``None`` when the model lacks it or it fits at zero, a size that fits at
|
|
907
|
+
zero breadth being infinite. Esds are scaled by the reduced chi squared.
|
|
908
|
+
``at_bound`` marks a two parameter fit with one term held at zero, where
|
|
909
|
+
the fit has in effect fallen back to a one parameter model.
|
|
910
|
+
``normalised_residuals`` are the observed less the fitted breadths over
|
|
911
|
+
their esds.
|
|
912
|
+
"""
|
|
913
|
+
|
|
914
|
+
model: str
|
|
915
|
+
size: float | None
|
|
916
|
+
esd_size: float | None
|
|
917
|
+
strain: float | None
|
|
918
|
+
esd_strain: float | None
|
|
919
|
+
delta_s: float | None
|
|
920
|
+
esd_delta_s: float | None
|
|
921
|
+
chi_squared: float
|
|
922
|
+
degrees_of_freedom: int
|
|
923
|
+
reduced_chi_squared: float
|
|
924
|
+
at_bound: bool
|
|
925
|
+
normalised_residuals: np.ndarray
|
|
926
|
+
# The terms in radians: K lambda / D, epsilon and 2 delta_s / R.
|
|
927
|
+
size_term: float
|
|
928
|
+
strain_term: float
|
|
929
|
+
height_term: float
|
|
930
|
+
|
|
931
|
+
def breadth(self, two_theta: float | np.ndarray) -> float | np.ndarray:
|
|
932
|
+
"""The fitted breadth at ``two_theta``, in degrees."""
|
|
933
|
+
cos_theta = _cos_theta(two_theta)
|
|
934
|
+
tan_theta = np.sqrt(1.0 - cos_theta**2) / cos_theta
|
|
935
|
+
breadth = np.degrees(
|
|
936
|
+
np.hypot(self.size_term / cos_theta, self.height_term * cos_theta)
|
|
937
|
+
+ 4.0 * self.strain_term * tan_theta
|
|
938
|
+
)
|
|
939
|
+
return float(breadth) if breadth.ndim == 0 else breadth
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
@dataclass
|
|
943
|
+
class BreadthModels:
|
|
944
|
+
"""The four fits of :func:`fit_breadth_models`, by model name."""
|
|
945
|
+
|
|
946
|
+
fits: dict[str, BreadthModelFit]
|
|
947
|
+
|
|
948
|
+
@property
|
|
949
|
+
def preferred(self) -> BreadthModelFit:
|
|
950
|
+
"""The fit with the lowest reduced chi squared."""
|
|
951
|
+
return min(self.fits.values(), key=lambda fit: fit.reduced_chi_squared)
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def _breadth_model_fit(
|
|
955
|
+
model: str,
|
|
956
|
+
terms: dict[str, tuple[float, float]],
|
|
957
|
+
residuals: np.ndarray,
|
|
958
|
+
n_parameters: int,
|
|
959
|
+
at_bound: bool,
|
|
960
|
+
k_lambda: float,
|
|
961
|
+
radius_mm: float,
|
|
962
|
+
) -> BreadthModelFit:
|
|
963
|
+
"""Turn fitted terms in radians into a size, a strain and a height spread.
|
|
964
|
+
|
|
965
|
+
``terms`` maps "size", "strain" and "height" to (term, esd) for the terms
|
|
966
|
+
the model has; ``residuals`` are normalised by the breadth esds.
|
|
967
|
+
"""
|
|
968
|
+
chi_squared = float(np.sum(residuals**2))
|
|
969
|
+
dof = residuals.size - n_parameters
|
|
970
|
+
size = esd_size = strain = esd_strain = delta_s = esd_delta_s = None
|
|
971
|
+
size_term, esd_size_term = terms.get("size", (0.0, 0.0))
|
|
972
|
+
strain_term, esd_strain_term = terms.get("strain", (0.0, 0.0))
|
|
973
|
+
height_term, esd_height_term = terms.get("height", (0.0, 0.0))
|
|
974
|
+
if size_term > 0.0:
|
|
975
|
+
size = k_lambda / size_term
|
|
976
|
+
esd_size = size * esd_size_term / size_term
|
|
977
|
+
if "strain" in terms:
|
|
978
|
+
strain, esd_strain = strain_term, esd_strain_term
|
|
979
|
+
if height_term > 0.0:
|
|
980
|
+
delta_s = height_term * radius_mm / 2.0
|
|
981
|
+
esd_delta_s = esd_height_term * radius_mm / 2.0
|
|
982
|
+
return BreadthModelFit(
|
|
983
|
+
model=model,
|
|
984
|
+
size=size,
|
|
985
|
+
esd_size=esd_size,
|
|
986
|
+
strain=strain,
|
|
987
|
+
esd_strain=esd_strain,
|
|
988
|
+
delta_s=delta_s,
|
|
989
|
+
esd_delta_s=esd_delta_s,
|
|
990
|
+
chi_squared=chi_squared,
|
|
991
|
+
degrees_of_freedom=dof,
|
|
992
|
+
reduced_chi_squared=chi_squared / dof,
|
|
993
|
+
at_bound=at_bound,
|
|
994
|
+
normalised_residuals=residuals,
|
|
995
|
+
size_term=max(size_term, 0.0),
|
|
996
|
+
strain_term=strain_term,
|
|
997
|
+
height_term=max(height_term, 0.0),
|
|
998
|
+
)
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def fit_breadth_models(
|
|
1002
|
+
two_theta: np.ndarray,
|
|
1003
|
+
breadth: np.ndarray,
|
|
1004
|
+
esd: np.ndarray,
|
|
1005
|
+
wavelength: float,
|
|
1006
|
+
radius_mm: float,
|
|
1007
|
+
k: float = BREADTH_MODEL_K,
|
|
1008
|
+
) -> BreadthModels:
|
|
1009
|
+
"""Fit sample breadths with size, strain and height spread models.
|
|
1010
|
+
|
|
1011
|
+
Three one parameter models, each a breadth proportional to its own
|
|
1012
|
+
angular dependence and fitted linearly:
|
|
1013
|
+
|
|
1014
|
+
``size``
|
|
1015
|
+
beta = K lambda / (D cos(theta)).
|
|
1016
|
+
``strain``
|
|
1017
|
+
beta = 4 epsilon tan(theta).
|
|
1018
|
+
``height``
|
|
1019
|
+
beta = 2 delta_s cos(theta) / R, as :func:`height_spread_breadth`.
|
|
1020
|
+
|
|
1021
|
+
and one with two, ``size+height``, the size and height breadths added in
|
|
1022
|
+
quadrature. That one is fitted in the squares of the two terms,
|
|
1023
|
+
P = (K lambda / D)^2 and Q = (2 delta_s / R)^2, each held at zero or
|
|
1024
|
+
above, which keeps the derivatives finite when either term vanishes.
|
|
1025
|
+
Every fit weights by the breadth esds and is judged by its reduced chi
|
|
1026
|
+
squared on the breadths themselves, so the four compare directly.
|
|
1027
|
+
|
|
1028
|
+
Size and height breadths go as 1 / cos(theta) and cos(theta), both close
|
|
1029
|
+
to flat over a narrow range of angle, so the two parameter fit is strongly
|
|
1030
|
+
correlated and its esds are large unless the angles span widely.
|
|
1031
|
+
|
|
1032
|
+
Parameters
|
|
1033
|
+
----------
|
|
1034
|
+
two_theta
|
|
1035
|
+
Positions, in degrees.
|
|
1036
|
+
breadth, esd
|
|
1037
|
+
Sample breadths with the instrument taken out, and their esds, in
|
|
1038
|
+
degrees of 2theta; every esd must be positive.
|
|
1039
|
+
wavelength
|
|
1040
|
+
Wavelength, which sets the units of the size.
|
|
1041
|
+
radius_mm
|
|
1042
|
+
Goniometer radius, in mm.
|
|
1043
|
+
k
|
|
1044
|
+
Scherrer constant, 1 for integral breadths by default and 0.9 for a
|
|
1045
|
+
FWHM.
|
|
1046
|
+
|
|
1047
|
+
Raises
|
|
1048
|
+
------
|
|
1049
|
+
ValueError
|
|
1050
|
+
If the arrays differ in length, there are fewer than three breadths, a
|
|
1051
|
+
position lies outside (0, 180) degrees, a breadth is not finite, an
|
|
1052
|
+
esd is not positive, or the wavelength, radius or ``k`` is not
|
|
1053
|
+
positive.
|
|
1054
|
+
"""
|
|
1055
|
+
two_theta = np.asarray(two_theta, dtype=float).ravel()
|
|
1056
|
+
beta = np.radians(np.asarray(breadth, dtype=float).ravel())
|
|
1057
|
+
sigma = np.radians(np.asarray(esd, dtype=float).ravel())
|
|
1058
|
+
if not two_theta.size == beta.size == sigma.size:
|
|
1059
|
+
raise ValueError(
|
|
1060
|
+
f"two_theta, breadth and esd differ in length: {two_theta.size}, "
|
|
1061
|
+
f"{beta.size}, {sigma.size}"
|
|
1062
|
+
)
|
|
1063
|
+
if two_theta.size < MIN_BREADTH_MODEL_POINTS:
|
|
1064
|
+
raise ValueError(
|
|
1065
|
+
f"Need at least {MIN_BREADTH_MODEL_POINTS} breadths, got {two_theta.size}"
|
|
1066
|
+
)
|
|
1067
|
+
if not np.all(np.isfinite(beta)):
|
|
1068
|
+
raise ValueError("Every breadth must be finite")
|
|
1069
|
+
if np.any(~np.isfinite(sigma) | (sigma <= 0.0)):
|
|
1070
|
+
raise ValueError("Every esd must be positive")
|
|
1071
|
+
for name, value in (("wavelength", wavelength), ("radius_mm", radius_mm), ("k", k)):
|
|
1072
|
+
if not value > 0.0:
|
|
1073
|
+
raise ValueError(f"{name} must be positive, got {value}")
|
|
1074
|
+
|
|
1075
|
+
cos_theta = _cos_theta(two_theta)
|
|
1076
|
+
tan_theta = np.sqrt(1.0 - cos_theta**2) / cos_theta
|
|
1077
|
+
weights = 1.0 / sigma**2
|
|
1078
|
+
k_lambda = k * wavelength
|
|
1079
|
+
|
|
1080
|
+
fits: dict[str, BreadthModelFit] = {}
|
|
1081
|
+
single_terms: dict[str, tuple[float, float]] = {}
|
|
1082
|
+
for name, shape in (
|
|
1083
|
+
("size", 1.0 / cos_theta),
|
|
1084
|
+
("strain", 4.0 * tan_theta),
|
|
1085
|
+
("height", cos_theta),
|
|
1086
|
+
):
|
|
1087
|
+
normal = float(np.sum(weights * shape**2))
|
|
1088
|
+
term = float(np.sum(weights * shape * beta)) / normal
|
|
1089
|
+
residuals = (beta - term * shape) / sigma
|
|
1090
|
+
reduced = float(np.sum(residuals**2)) / (beta.size - 1)
|
|
1091
|
+
esd_term = float(np.sqrt(reduced / normal))
|
|
1092
|
+
single_terms[name] = (term, esd_term)
|
|
1093
|
+
fits[name] = _breadth_model_fit(
|
|
1094
|
+
name, {name: (term, esd_term)}, residuals, 1, False, k_lambda, radius_mm
|
|
1095
|
+
)
|
|
1096
|
+
|
|
1097
|
+
# Size and height in quadrature, in P and Q, started from a linear fit of
|
|
1098
|
+
# the squared breadths and polished on the breadths themselves.
|
|
1099
|
+
design = np.column_stack([1.0 / cos_theta**2, cos_theta**2])
|
|
1100
|
+
root_weights = 1.0 / (2.0 * np.maximum(np.abs(beta), sigma) * sigma)
|
|
1101
|
+
start, *_ = np.linalg.lstsq(
|
|
1102
|
+
design * root_weights[:, None], beta**2 * root_weights, rcond=None
|
|
1103
|
+
)
|
|
1104
|
+
scale = float(np.max(beta**2))
|
|
1105
|
+
start = np.clip(start, 0.0, None) + QUADRATURE_START_FLOOR * scale
|
|
1106
|
+
|
|
1107
|
+
def residual(parameters: np.ndarray) -> np.ndarray:
|
|
1108
|
+
return (beta - np.sqrt(design @ parameters)) / sigma
|
|
1109
|
+
|
|
1110
|
+
result = optimize.least_squares(
|
|
1111
|
+
residual, start, bounds=(0.0, np.inf), x_scale=scale, xtol=1e-15, ftol=1e-15
|
|
1112
|
+
)
|
|
1113
|
+
residuals = np.asarray(result.fun, dtype=float)
|
|
1114
|
+
# With one term at zero the fit is the other term alone, which the one
|
|
1115
|
+
# parameter fits have already found exactly. The optimiser only approaches
|
|
1116
|
+
# a bound from inside, so when either of those does as well, to within
|
|
1117
|
+
# QUADRATURE_BOUND_CHI_SQUARED, the best fit lies on that bound and is
|
|
1118
|
+
# taken from it.
|
|
1119
|
+
boundary = min((fits["size"], fits["height"]), key=lambda fit: fit.chi_squared)
|
|
1120
|
+
free_chi_squared = float(np.sum(residuals**2))
|
|
1121
|
+
if boundary.chi_squared <= free_chi_squared + QUADRATURE_BOUND_CHI_SQUARED:
|
|
1122
|
+
name = boundary.model
|
|
1123
|
+
term, esd_term = single_terms[name]
|
|
1124
|
+
# Its esd rescaled to the one fewer degree of freedom of this model.
|
|
1125
|
+
dof_ratio = (beta.size - 1) / (beta.size - 2)
|
|
1126
|
+
fits["size+height"] = _breadth_model_fit(
|
|
1127
|
+
"size+height",
|
|
1128
|
+
{name: (term, esd_term * np.sqrt(dof_ratio))},
|
|
1129
|
+
boundary.normalised_residuals,
|
|
1130
|
+
2,
|
|
1131
|
+
True,
|
|
1132
|
+
k_lambda,
|
|
1133
|
+
radius_mm,
|
|
1134
|
+
)
|
|
1135
|
+
return BreadthModels(fits)
|
|
1136
|
+
|
|
1137
|
+
reduced = float(np.sum(residuals**2)) / (beta.size - 2)
|
|
1138
|
+
jacobian = np.asarray(result.jac, dtype=float)
|
|
1139
|
+
covariance = np.linalg.pinv(jacobian.T @ jacobian) * reduced
|
|
1140
|
+
# The esd of a square root, from the esd of its square.
|
|
1141
|
+
roots = np.sqrt(result.x)
|
|
1142
|
+
esd_roots = np.sqrt(np.abs(np.diag(covariance))) / (2.0 * roots)
|
|
1143
|
+
fits["size+height"] = _breadth_model_fit(
|
|
1144
|
+
"size+height",
|
|
1145
|
+
{
|
|
1146
|
+
"size": (float(roots[0]), float(esd_roots[0])),
|
|
1147
|
+
"height": (float(roots[1]), float(esd_roots[1])),
|
|
1148
|
+
},
|
|
1149
|
+
residuals,
|
|
1150
|
+
2,
|
|
1151
|
+
False,
|
|
1152
|
+
k_lambda,
|
|
1153
|
+
radius_mm,
|
|
1154
|
+
)
|
|
1155
|
+
return BreadthModels(fits)
|