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/indexing.py
ADDED
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
"""Indexing of diffraction peaks against a tetragonal cell."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
from dataclasses import dataclass, field, replace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from xrdkit.peaks import Peak
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"TTB_CELL",
|
|
15
|
+
"CellFit",
|
|
16
|
+
"IndexedPeak",
|
|
17
|
+
"Reflection",
|
|
18
|
+
"TetragonalCell",
|
|
19
|
+
"ZeroSearch",
|
|
20
|
+
"estimate_zero_offset",
|
|
21
|
+
"generate_reflections",
|
|
22
|
+
"index_and_refine",
|
|
23
|
+
"index_peaks",
|
|
24
|
+
"indexed_to_csv",
|
|
25
|
+
"indexing_summary",
|
|
26
|
+
"refine_cell",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
# Maximum difference between corrected and calculated 2theta, in degrees.
|
|
30
|
+
DEFAULT_TOLERANCE = 0.05
|
|
31
|
+
|
|
32
|
+
# Zero point correction subtracted from every observed position, in degrees.
|
|
33
|
+
DEFAULT_ZERO_OFFSET = 0.0
|
|
34
|
+
|
|
35
|
+
DEFAULT_SPACE_GROUP = "P4bm"
|
|
36
|
+
|
|
37
|
+
# Range and step of the automatic zero offset search, in degrees. Wide enough
|
|
38
|
+
# for a pellet standing proud of its holder, fine enough to land inside the
|
|
39
|
+
# fine tolerance.
|
|
40
|
+
DEFAULT_ZERO_SEARCH = (-0.4, 0.4)
|
|
41
|
+
DEFAULT_ZERO_SEARCH_STEP = 0.01
|
|
42
|
+
|
|
43
|
+
# Every trial offset is given a cell of its own, seeded from the peaks below
|
|
44
|
+
# this position at this tolerance. Holding one cell for the whole search does
|
|
45
|
+
# not work: a cell that is a per cent out shifts the low angle peaks by about
|
|
46
|
+
# as much as a zero offset does, so the trial that wins is whichever one best
|
|
47
|
+
# papers over the cell error rather than the one that is right.
|
|
48
|
+
ZERO_SEED_TOLERANCE = 0.4
|
|
49
|
+
ZERO_SEED_TWO_THETA_MAX = 35.0
|
|
50
|
+
|
|
51
|
+
# Space groups whose reflection conditions this module knows about.
|
|
52
|
+
SUPPORTED_SPACE_GROUPS = ("P4bm",)
|
|
53
|
+
|
|
54
|
+
CSV_COLUMNS = (
|
|
55
|
+
"two_theta",
|
|
56
|
+
"corrected_two_theta",
|
|
57
|
+
"d_spacing",
|
|
58
|
+
"relative_intensity",
|
|
59
|
+
"h",
|
|
60
|
+
"k",
|
|
61
|
+
"l",
|
|
62
|
+
"calculated_two_theta",
|
|
63
|
+
"difference",
|
|
64
|
+
"n_candidates",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Decimal places per column when writing CSV; hkl and counts stay integers.
|
|
68
|
+
CSV_DECIMALS = {
|
|
69
|
+
"two_theta": 3,
|
|
70
|
+
"corrected_two_theta": 3,
|
|
71
|
+
"d_spacing": 4,
|
|
72
|
+
"relative_intensity": 2,
|
|
73
|
+
"calculated_two_theta": 3,
|
|
74
|
+
"difference": 3,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class TetragonalCell:
|
|
80
|
+
"""A tetragonal unit cell, in angstroms."""
|
|
81
|
+
|
|
82
|
+
a: float
|
|
83
|
+
c: float
|
|
84
|
+
|
|
85
|
+
def d_spacing(self, h: int, k: int, l: int) -> float:
|
|
86
|
+
"""Return the d spacing of ``(h k l)`` from 1/d^2 = (h^2+k^2)/a^2 + l^2/c^2.
|
|
87
|
+
|
|
88
|
+
Raises
|
|
89
|
+
------
|
|
90
|
+
ValueError
|
|
91
|
+
If ``(h k l)`` is ``(0 0 0)``, which has no d spacing.
|
|
92
|
+
"""
|
|
93
|
+
if h == 0 and k == 0 and l == 0:
|
|
94
|
+
raise ValueError("(000) has no d spacing")
|
|
95
|
+
inverse_squared = (h**2 + k**2) / self.a**2 + l**2 / self.c**2
|
|
96
|
+
return float(1.0 / np.sqrt(inverse_squared))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# Provisional starting cell for the tungsten bronze phase; a later refinement
|
|
100
|
+
# stage is expected to replace these values.
|
|
101
|
+
TTB_CELL = TetragonalCell(a=12.45, c=3.94)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class Reflection:
|
|
106
|
+
"""A calculated reflection of a cell at a given wavelength."""
|
|
107
|
+
|
|
108
|
+
h: int
|
|
109
|
+
k: int
|
|
110
|
+
l: int
|
|
111
|
+
d_spacing: float
|
|
112
|
+
two_theta: float
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def hkl(self) -> tuple[int, int, int]:
|
|
116
|
+
"""The Miller indices as a tuple."""
|
|
117
|
+
return (self.h, self.k, self.l)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class IndexedPeak:
|
|
122
|
+
"""An observed peak with its assignment against a calculated reflection."""
|
|
123
|
+
|
|
124
|
+
peak: Peak
|
|
125
|
+
corrected_two_theta: float
|
|
126
|
+
reflection: Reflection | None
|
|
127
|
+
difference: float
|
|
128
|
+
candidates: list[Reflection] = field(default_factory=list)
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def is_indexed(self) -> bool:
|
|
132
|
+
"""Whether a reflection was assigned."""
|
|
133
|
+
return self.reflection is not None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _is_allowed(h: int, k: int, l: int, space_group: str | None) -> bool:
|
|
137
|
+
"""Whether ``(h k l)`` survives the reflection conditions of ``space_group``.
|
|
138
|
+
|
|
139
|
+
``None`` applies no conditions. For P4bm the zonal conditions 0kl with k even
|
|
140
|
+
and h0l with h even also cover the axial 0k0 and h00 cases, which are just
|
|
141
|
+
those zones with the third index zero.
|
|
142
|
+
|
|
143
|
+
Raises
|
|
144
|
+
------
|
|
145
|
+
ValueError
|
|
146
|
+
If ``space_group`` is not one this module knows.
|
|
147
|
+
"""
|
|
148
|
+
if space_group is None:
|
|
149
|
+
return True
|
|
150
|
+
if space_group not in SUPPORTED_SPACE_GROUPS:
|
|
151
|
+
raise ValueError(
|
|
152
|
+
f"Unknown space group {space_group!r}; "
|
|
153
|
+
f"expected None or one of {SUPPORTED_SPACE_GROUPS}"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# P4bm: 0kl present only for k even, h0l only for h even. 00l satisfies both
|
|
157
|
+
# trivially, and general reflections meet neither.
|
|
158
|
+
if h == 0:
|
|
159
|
+
return k % 2 == 0
|
|
160
|
+
if k == 0:
|
|
161
|
+
return h % 2 == 0
|
|
162
|
+
return True
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def generate_reflections(
|
|
166
|
+
cell: TetragonalCell,
|
|
167
|
+
wavelength: float,
|
|
168
|
+
two_theta_max: float,
|
|
169
|
+
two_theta_min: float = 0.0,
|
|
170
|
+
space_group: str | None = DEFAULT_SPACE_GROUP,
|
|
171
|
+
) -> list[Reflection]:
|
|
172
|
+
"""Enumerate the allowed reflections of ``cell`` in a 2theta window.
|
|
173
|
+
|
|
174
|
+
Only ``h >= k >= 0`` and ``l >= 0`` are enumerated, so each family that is
|
|
175
|
+
equivalent under 4/mmm Laue symmetry appears exactly once.
|
|
176
|
+
|
|
177
|
+
Parameters
|
|
178
|
+
----------
|
|
179
|
+
cell
|
|
180
|
+
The cell to calculate d spacings from.
|
|
181
|
+
wavelength
|
|
182
|
+
Radiation wavelength in angstroms.
|
|
183
|
+
two_theta_max
|
|
184
|
+
Upper limit of the window, in degrees. Must be above ``two_theta_min``
|
|
185
|
+
and below 180.
|
|
186
|
+
two_theta_min
|
|
187
|
+
Lower limit of the window, in degrees.
|
|
188
|
+
space_group
|
|
189
|
+
Space group whose reflection conditions to apply, or ``None`` for none.
|
|
190
|
+
|
|
191
|
+
Returns
|
|
192
|
+
-------
|
|
193
|
+
list[Reflection]
|
|
194
|
+
Reflections inside the window, sorted by 2theta.
|
|
195
|
+
|
|
196
|
+
Raises
|
|
197
|
+
------
|
|
198
|
+
ValueError
|
|
199
|
+
If the window is empty or outside 0 to 180 degrees, or the space group
|
|
200
|
+
is not known.
|
|
201
|
+
"""
|
|
202
|
+
if not 0.0 <= two_theta_min < two_theta_max < 180.0:
|
|
203
|
+
raise ValueError(
|
|
204
|
+
f"Need 0 <= two_theta_min < two_theta_max < 180, got "
|
|
205
|
+
f"{two_theta_min} and {two_theta_max}"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# The smallest d spacing that can diffract within the window fixes how far
|
|
209
|
+
# the indices need to run, so the limits follow from the cell rather than
|
|
210
|
+
# from an arbitrary cut-off.
|
|
211
|
+
d_min = wavelength / (2.0 * np.sin(np.radians(two_theta_max / 2.0)))
|
|
212
|
+
# Along a*, 1/d = h/a, so h can reach a/d_min before falling below d_min.
|
|
213
|
+
h_max = int(np.floor(cell.a / d_min))
|
|
214
|
+
l_max = int(np.floor(cell.c / d_min))
|
|
215
|
+
|
|
216
|
+
reflections = []
|
|
217
|
+
for h in range(h_max + 1):
|
|
218
|
+
for k in range(h + 1):
|
|
219
|
+
for l in range(l_max + 1):
|
|
220
|
+
if h == 0 and k == 0 and l == 0:
|
|
221
|
+
continue
|
|
222
|
+
if not _is_allowed(h, k, l, space_group):
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
d = cell.d_spacing(h, k, l)
|
|
226
|
+
sin_theta = wavelength / (2.0 * d)
|
|
227
|
+
if sin_theta > 1.0:
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
two_theta = 2.0 * float(np.degrees(np.arcsin(sin_theta)))
|
|
231
|
+
if two_theta_min <= two_theta <= two_theta_max:
|
|
232
|
+
reflections.append(
|
|
233
|
+
Reflection(h=h, k=k, l=l, d_spacing=d, two_theta=two_theta)
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
return sorted(reflections, key=lambda reflection: reflection.two_theta)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def estimate_zero_offset(
|
|
240
|
+
peaks: list[Peak],
|
|
241
|
+
cell: TetragonalCell,
|
|
242
|
+
wavelength: float,
|
|
243
|
+
search: tuple[float, float] = DEFAULT_ZERO_SEARCH,
|
|
244
|
+
step: float = DEFAULT_ZERO_SEARCH_STEP,
|
|
245
|
+
tolerance: float = DEFAULT_TOLERANCE,
|
|
246
|
+
two_theta_max: float | None = None,
|
|
247
|
+
space_group: str | None = DEFAULT_SPACE_GROUP,
|
|
248
|
+
) -> ZeroSearch:
|
|
249
|
+
"""Find the zero offset that indexes the most peaks unambiguously.
|
|
250
|
+
|
|
251
|
+
A specimen that sits proud of its holder moves every reflection by close to
|
|
252
|
+
a constant, which no cell can absorb: indexed against an uncorrected cell
|
|
253
|
+
such a pattern loses most of its peaks. Trying offsets across ``search`` and
|
|
254
|
+
keeping the one that leaves the most peaks with exactly one candidate
|
|
255
|
+
recovers the shift without anyone having to measure it by hand. Ties go to
|
|
256
|
+
the offset whose matched peaks sit closest to their calculated positions.
|
|
257
|
+
|
|
258
|
+
Each trial gets a cell refined for it, from the low angle peaks, rather than
|
|
259
|
+
sharing one. This matters: a constant offset and a cell that is a per cent
|
|
260
|
+
out shift the low angle peaks by much the same amount, so a search that
|
|
261
|
+
holds the cell fixed returns whichever offset best hides the error in that
|
|
262
|
+
cell. Letting the cell move with the offset asks the question that was meant
|
|
263
|
+
instead, which is how much of the pattern each offset can account for.
|
|
264
|
+
|
|
265
|
+
Parameters
|
|
266
|
+
----------
|
|
267
|
+
peaks
|
|
268
|
+
Observed peaks to search on.
|
|
269
|
+
cell
|
|
270
|
+
Cell each trial's own refinement starts from.
|
|
271
|
+
wavelength
|
|
272
|
+
Radiation wavelength in angstroms.
|
|
273
|
+
search
|
|
274
|
+
``(low, high)`` bounds of the offsets tried, in degrees.
|
|
275
|
+
step
|
|
276
|
+
Spacing of the offsets tried, in degrees.
|
|
277
|
+
tolerance
|
|
278
|
+
Tolerance the trials are scored at, in degrees.
|
|
279
|
+
two_theta_max
|
|
280
|
+
Score on the peaks below this position only. All of them by default,
|
|
281
|
+
since the whole range is what tells an offset from a cell.
|
|
282
|
+
space_group
|
|
283
|
+
Space group whose reflection conditions to apply, or ``None`` for none.
|
|
284
|
+
|
|
285
|
+
Returns
|
|
286
|
+
-------
|
|
287
|
+
ZeroSearch
|
|
288
|
+
The best offset, how many peaks it indexed unambiguously, their root
|
|
289
|
+
mean square difference, and the whole profile that was tried.
|
|
290
|
+
|
|
291
|
+
Raises
|
|
292
|
+
------
|
|
293
|
+
ValueError
|
|
294
|
+
If ``search`` is not a rising pair, ``step`` is not positive, or there
|
|
295
|
+
are no peaks to search on.
|
|
296
|
+
"""
|
|
297
|
+
low, high = search
|
|
298
|
+
if not low < high:
|
|
299
|
+
raise ValueError(f"Need search low < high, got {search}")
|
|
300
|
+
if step <= 0.0:
|
|
301
|
+
raise ValueError(f"Need a positive step, got {step}")
|
|
302
|
+
|
|
303
|
+
candidates = [
|
|
304
|
+
peak
|
|
305
|
+
for peak in peaks
|
|
306
|
+
if two_theta_max is None or peak.two_theta <= two_theta_max
|
|
307
|
+
]
|
|
308
|
+
if not candidates:
|
|
309
|
+
raise ValueError(
|
|
310
|
+
f"No peak at or below {two_theta_max} degrees to search a zero offset on"
|
|
311
|
+
)
|
|
312
|
+
seed_peaks = [
|
|
313
|
+
peak for peak in candidates if peak.two_theta <= ZERO_SEED_TWO_THETA_MAX
|
|
314
|
+
]
|
|
315
|
+
|
|
316
|
+
trials = np.arange(low, high + step / 2.0, step)
|
|
317
|
+
counts = np.zeros(trials.size, dtype=int)
|
|
318
|
+
deviations = np.full(trials.size, np.inf)
|
|
319
|
+
|
|
320
|
+
for index, offset in enumerate(trials):
|
|
321
|
+
trial_cell = _seed_cell(
|
|
322
|
+
seed_peaks, cell, wavelength, float(offset), space_group
|
|
323
|
+
)
|
|
324
|
+
indexed = index_peaks(
|
|
325
|
+
candidates, trial_cell, wavelength, tolerance, float(offset), space_group
|
|
326
|
+
)
|
|
327
|
+
differences = [
|
|
328
|
+
entry.difference
|
|
329
|
+
for entry in indexed
|
|
330
|
+
if entry.is_indexed and len(entry.candidates) == 1
|
|
331
|
+
]
|
|
332
|
+
counts[index] = len(differences)
|
|
333
|
+
if differences:
|
|
334
|
+
deviations[index] = float(np.sqrt(np.mean(np.square(differences))))
|
|
335
|
+
|
|
336
|
+
# Most peaks wins; a tie is broken on how well those peaks actually sit.
|
|
337
|
+
best = int(np.lexsort((deviations, -counts))[0])
|
|
338
|
+
return ZeroSearch(
|
|
339
|
+
offset=float(trials[best]),
|
|
340
|
+
n_indexed=int(counts[best]),
|
|
341
|
+
rms=float(deviations[best]),
|
|
342
|
+
offsets=trials,
|
|
343
|
+
counts=counts,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def index_peaks(
|
|
348
|
+
peaks: list[Peak],
|
|
349
|
+
cell: TetragonalCell,
|
|
350
|
+
wavelength: float,
|
|
351
|
+
tolerance: float = DEFAULT_TOLERANCE,
|
|
352
|
+
zero_offset: float = DEFAULT_ZERO_OFFSET,
|
|
353
|
+
space_group: str | None = DEFAULT_SPACE_GROUP,
|
|
354
|
+
) -> list[IndexedPeak]:
|
|
355
|
+
"""Assign reflections of ``cell`` to ``peaks``, in peak order.
|
|
356
|
+
|
|
357
|
+
Each observed position has ``zero_offset`` subtracted from it before it is
|
|
358
|
+
compared with the calculated positions.
|
|
359
|
+
|
|
360
|
+
Parameters
|
|
361
|
+
----------
|
|
362
|
+
peaks
|
|
363
|
+
Observed peaks to index.
|
|
364
|
+
cell
|
|
365
|
+
The cell to index against.
|
|
366
|
+
wavelength
|
|
367
|
+
Radiation wavelength in angstroms.
|
|
368
|
+
tolerance
|
|
369
|
+
Largest allowed difference between corrected and calculated 2theta,
|
|
370
|
+
in degrees.
|
|
371
|
+
zero_offset
|
|
372
|
+
Zero point correction, in degrees, subtracted from each observed peak.
|
|
373
|
+
space_group
|
|
374
|
+
Space group whose reflection conditions to apply, or ``None`` for none.
|
|
375
|
+
|
|
376
|
+
Returns
|
|
377
|
+
-------
|
|
378
|
+
list[IndexedPeak]
|
|
379
|
+
One entry per peak, in the order given. Peaks with no reflection within
|
|
380
|
+
``tolerance`` keep ``reflection=None`` and a difference of ``nan``.
|
|
381
|
+
"""
|
|
382
|
+
if not peaks:
|
|
383
|
+
return []
|
|
384
|
+
|
|
385
|
+
corrected = [peak.two_theta - zero_offset for peak in peaks]
|
|
386
|
+
# Widen the calculated window by the tolerance so peaks at either end can
|
|
387
|
+
# still find a partner just outside the observed range.
|
|
388
|
+
window_min = max(0.0, min(corrected) - tolerance)
|
|
389
|
+
window_max = min(179.0, max(corrected) + tolerance)
|
|
390
|
+
reflections = generate_reflections(
|
|
391
|
+
cell, wavelength, window_max, window_min, space_group
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
indexed = []
|
|
395
|
+
for peak, position in zip(peaks, corrected):
|
|
396
|
+
candidates = [
|
|
397
|
+
reflection
|
|
398
|
+
for reflection in reflections
|
|
399
|
+
if abs(position - reflection.two_theta) <= tolerance
|
|
400
|
+
]
|
|
401
|
+
# Closest first; ties break on hkl so the choice is reproducible.
|
|
402
|
+
candidates.sort(
|
|
403
|
+
key=lambda reflection: (
|
|
404
|
+
abs(position - reflection.two_theta),
|
|
405
|
+
reflection.hkl,
|
|
406
|
+
)
|
|
407
|
+
)
|
|
408
|
+
best = candidates[0] if candidates else None
|
|
409
|
+
indexed.append(
|
|
410
|
+
IndexedPeak(
|
|
411
|
+
peak=peak,
|
|
412
|
+
corrected_two_theta=position,
|
|
413
|
+
reflection=best,
|
|
414
|
+
difference=(
|
|
415
|
+
position - best.two_theta if best is not None else float("nan")
|
|
416
|
+
),
|
|
417
|
+
candidates=candidates,
|
|
418
|
+
)
|
|
419
|
+
)
|
|
420
|
+
return indexed
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def indexing_summary(indexed: list[IndexedPeak]) -> dict[str, float]:
|
|
424
|
+
"""Summarise an indexing run.
|
|
425
|
+
|
|
426
|
+
Returns
|
|
427
|
+
-------
|
|
428
|
+
dict
|
|
429
|
+
``n_peaks``, ``n_indexed``, ``n_unindexed``, ``n_ambiguous`` (peaks with
|
|
430
|
+
more than one candidate within tolerance) and ``rms_difference``, the
|
|
431
|
+
root mean square of the differences of the indexed peaks in degrees.
|
|
432
|
+
The root mean square is ``nan`` when nothing was indexed.
|
|
433
|
+
"""
|
|
434
|
+
differences = [entry.difference for entry in indexed if entry.is_indexed]
|
|
435
|
+
rms = (
|
|
436
|
+
float(np.sqrt(np.mean(np.square(differences)))) if differences else float("nan")
|
|
437
|
+
)
|
|
438
|
+
return {
|
|
439
|
+
"n_peaks": len(indexed),
|
|
440
|
+
"n_indexed": len(differences),
|
|
441
|
+
"n_unindexed": len(indexed) - len(differences),
|
|
442
|
+
"n_ambiguous": sum(1 for entry in indexed if len(entry.candidates) > 1),
|
|
443
|
+
"rms_difference": rms,
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def indexed_to_csv(indexed: list[IndexedPeak], path: str | Path) -> Path:
|
|
448
|
+
"""Write ``indexed`` to ``path`` as CSV with a header, returning the path.
|
|
449
|
+
|
|
450
|
+
Unindexed peaks leave the hkl, calculated position and difference blank.
|
|
451
|
+
"""
|
|
452
|
+
path = Path(path)
|
|
453
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
454
|
+
|
|
455
|
+
with path.open("w", newline="", encoding="utf-8") as handle:
|
|
456
|
+
writer = csv.writer(handle)
|
|
457
|
+
writer.writerow(CSV_COLUMNS)
|
|
458
|
+
for entry in indexed:
|
|
459
|
+
reflection = entry.reflection
|
|
460
|
+
values = {
|
|
461
|
+
"two_theta": entry.peak.two_theta,
|
|
462
|
+
"corrected_two_theta": entry.corrected_two_theta,
|
|
463
|
+
"d_spacing": entry.peak.d_spacing,
|
|
464
|
+
"relative_intensity": entry.peak.relative_intensity,
|
|
465
|
+
"h": reflection.h if reflection else "",
|
|
466
|
+
"k": reflection.k if reflection else "",
|
|
467
|
+
"l": reflection.l if reflection else "",
|
|
468
|
+
"calculated_two_theta": reflection.two_theta if reflection else "",
|
|
469
|
+
"difference": entry.difference if reflection else "",
|
|
470
|
+
"n_candidates": len(entry.candidates),
|
|
471
|
+
}
|
|
472
|
+
writer.writerow(
|
|
473
|
+
[
|
|
474
|
+
f"{values[column]:.{CSV_DECIMALS[column]}f}"
|
|
475
|
+
if column in CSV_DECIMALS and values[column] != ""
|
|
476
|
+
else values[column]
|
|
477
|
+
for column in CSV_COLUMNS
|
|
478
|
+
]
|
|
479
|
+
)
|
|
480
|
+
return path
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
@dataclass
|
|
484
|
+
class CellFit:
|
|
485
|
+
"""The outcome of a least squares cell refinement."""
|
|
486
|
+
|
|
487
|
+
cell: TetragonalCell
|
|
488
|
+
n_peaks: int
|
|
489
|
+
rms_two_theta: float
|
|
490
|
+
c_fitted: bool
|
|
491
|
+
# Zero offset the indexing was run with, in degrees. Zero unless
|
|
492
|
+
# index_and_refine searched for one.
|
|
493
|
+
zero_offset: float = 0.0
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@dataclass
|
|
497
|
+
class ZeroSearch:
|
|
498
|
+
"""The outcome of a scan over trial zero offsets."""
|
|
499
|
+
|
|
500
|
+
offset: float
|
|
501
|
+
n_indexed: int
|
|
502
|
+
rms: float
|
|
503
|
+
offsets: np.ndarray
|
|
504
|
+
counts: np.ndarray
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _two_theta(d_spacing: float, wavelength: float) -> float:
|
|
508
|
+
"""Return the 2theta of a d spacing, or ``nan`` if it cannot diffract."""
|
|
509
|
+
sin_theta = wavelength / (2.0 * d_spacing)
|
|
510
|
+
if sin_theta > 1.0:
|
|
511
|
+
return float("nan")
|
|
512
|
+
return 2.0 * float(np.degrees(np.arcsin(sin_theta)))
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def refine_cell(
|
|
516
|
+
indexed: list[IndexedPeak],
|
|
517
|
+
wavelength: float | None = None,
|
|
518
|
+
start_cell: TetragonalCell | None = None,
|
|
519
|
+
) -> CellFit:
|
|
520
|
+
"""Refine a tetragonal cell from indexed peaks by linear least squares.
|
|
521
|
+
|
|
522
|
+
For a tetragonal cell 1/d^2 = (h^2 + k^2) A + l^2 C with A = 1/a^2 and
|
|
523
|
+
C = 1/c^2, so A and C are the coefficients of an ordinary linear least
|
|
524
|
+
squares problem in the observed 1/d^2. The observed d spacings are
|
|
525
|
+
recomputed from ``corrected_two_theta`` rather than taken from the peaks, so
|
|
526
|
+
that any zero point correction already applied is carried through.
|
|
527
|
+
|
|
528
|
+
Parameters
|
|
529
|
+
----------
|
|
530
|
+
indexed
|
|
531
|
+
Result of :func:`index_peaks`; only entries with an assignment are used.
|
|
532
|
+
wavelength
|
|
533
|
+
Radiation wavelength in angstroms. :class:`IndexedPeak` does not carry
|
|
534
|
+
one, so it must be given here.
|
|
535
|
+
start_cell
|
|
536
|
+
Cell the indexing started from. Only needed when no peak has ``l != 0``,
|
|
537
|
+
in which case its ``c`` is kept unchanged.
|
|
538
|
+
|
|
539
|
+
Returns
|
|
540
|
+
-------
|
|
541
|
+
CellFit
|
|
542
|
+
The fitted cell, the number of peaks used, the root mean square
|
|
543
|
+
difference in degrees between their corrected and recalculated 2theta,
|
|
544
|
+
and whether ``c`` was fitted.
|
|
545
|
+
|
|
546
|
+
Raises
|
|
547
|
+
------
|
|
548
|
+
ValueError
|
|
549
|
+
If ``wavelength`` is None, if fewer than three indexed peaks are
|
|
550
|
+
available, if ``c`` cannot be fitted and no ``start_cell`` is given, or
|
|
551
|
+
if the fit returns a coefficient that is not a positive number.
|
|
552
|
+
"""
|
|
553
|
+
if wavelength is None:
|
|
554
|
+
raise ValueError(
|
|
555
|
+
"refine_cell needs a wavelength; IndexedPeak does not carry one"
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
used = [entry for entry in indexed if entry.is_indexed]
|
|
559
|
+
if len(used) < 3:
|
|
560
|
+
raise ValueError(
|
|
561
|
+
f"Need at least 3 indexed peaks to refine a cell, got {len(used)}"
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
positions = np.array([entry.corrected_two_theta for entry in used], dtype=float)
|
|
565
|
+
d_observed = wavelength / (2.0 * np.sin(np.radians(positions / 2.0)))
|
|
566
|
+
inverse_squared = 1.0 / d_observed**2
|
|
567
|
+
|
|
568
|
+
hk = np.array(
|
|
569
|
+
[entry.reflection.h**2 + entry.reflection.k**2 for entry in used], dtype=float
|
|
570
|
+
)
|
|
571
|
+
ll = np.array([entry.reflection.l**2 for entry in used], dtype=float)
|
|
572
|
+
|
|
573
|
+
c_fitted = bool(np.any(ll > 0.0))
|
|
574
|
+
if c_fitted:
|
|
575
|
+
design = np.column_stack((hk, ll))
|
|
576
|
+
else:
|
|
577
|
+
if start_cell is None:
|
|
578
|
+
raise ValueError(
|
|
579
|
+
"No peak with l != 0, so c cannot be fitted; pass start_cell to "
|
|
580
|
+
"keep its c"
|
|
581
|
+
)
|
|
582
|
+
design = hk.reshape(-1, 1)
|
|
583
|
+
|
|
584
|
+
solution, *_ = np.linalg.lstsq(design, inverse_squared, rcond=None)
|
|
585
|
+
|
|
586
|
+
a_coefficient = float(solution[0])
|
|
587
|
+
if not a_coefficient > 0.0:
|
|
588
|
+
raise ValueError(
|
|
589
|
+
f"Refinement gave a non-positive 1/a^2 of {a_coefficient}; the "
|
|
590
|
+
"assignments are probably wrong"
|
|
591
|
+
)
|
|
592
|
+
a = 1.0 / np.sqrt(a_coefficient)
|
|
593
|
+
|
|
594
|
+
if c_fitted:
|
|
595
|
+
c_coefficient = float(solution[1])
|
|
596
|
+
if not c_coefficient > 0.0:
|
|
597
|
+
raise ValueError(
|
|
598
|
+
f"Refinement gave a non-positive 1/c^2 of {c_coefficient}; the "
|
|
599
|
+
"assignments are probably wrong"
|
|
600
|
+
)
|
|
601
|
+
c = 1.0 / np.sqrt(c_coefficient)
|
|
602
|
+
else:
|
|
603
|
+
c = start_cell.c
|
|
604
|
+
|
|
605
|
+
cell = TetragonalCell(a=float(a), c=float(c))
|
|
606
|
+
recalculated = np.array(
|
|
607
|
+
[
|
|
608
|
+
_two_theta(cell.d_spacing(*entry.reflection.hkl), wavelength)
|
|
609
|
+
for entry in used
|
|
610
|
+
],
|
|
611
|
+
dtype=float,
|
|
612
|
+
)
|
|
613
|
+
rms = float(np.sqrt(np.mean(np.square(positions - recalculated))))
|
|
614
|
+
|
|
615
|
+
return CellFit(cell=cell, n_peaks=len(used), rms_two_theta=rms, c_fitted=c_fitted)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _seed_cell(
|
|
619
|
+
peaks: list[Peak],
|
|
620
|
+
cell: TetragonalCell,
|
|
621
|
+
wavelength: float,
|
|
622
|
+
zero_offset: float,
|
|
623
|
+
space_group: str | None,
|
|
624
|
+
) -> TetragonalCell:
|
|
625
|
+
"""Refine ``cell`` on the low angle peaks at one trial offset.
|
|
626
|
+
|
|
627
|
+
Falls back to the cell it was given whenever there is too little to refine
|
|
628
|
+
on, which leaves that trial to be judged on the starting cell rather than
|
|
629
|
+
dropped.
|
|
630
|
+
"""
|
|
631
|
+
indexed = index_peaks(
|
|
632
|
+
peaks, cell, wavelength, ZERO_SEED_TOLERANCE, zero_offset, space_group
|
|
633
|
+
)
|
|
634
|
+
unambiguous = [entry for entry in indexed if len(entry.candidates) == 1]
|
|
635
|
+
try:
|
|
636
|
+
return refine_cell(unambiguous, wavelength, cell).cell
|
|
637
|
+
except ValueError:
|
|
638
|
+
return cell
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def index_and_refine(
|
|
642
|
+
peaks: list[Peak],
|
|
643
|
+
start_cell: TetragonalCell,
|
|
644
|
+
wavelength: float,
|
|
645
|
+
zero_offset: float = DEFAULT_ZERO_OFFSET,
|
|
646
|
+
coarse_tolerance: float = 0.4,
|
|
647
|
+
coarse_two_theta_max: float = 35.0,
|
|
648
|
+
fine_tolerance: float = DEFAULT_TOLERANCE,
|
|
649
|
+
space_group: str | None = DEFAULT_SPACE_GROUP,
|
|
650
|
+
n_cycles: int = 2,
|
|
651
|
+
search_zero: bool = True,
|
|
652
|
+
) -> tuple[list[IndexedPeak], CellFit]:
|
|
653
|
+
"""Index and refine in cycles, starting coarse and low angle.
|
|
654
|
+
|
|
655
|
+
The first cycle indexes only the peaks below ``coarse_two_theta_max`` with
|
|
656
|
+
``coarse_tolerance``, where a cell that is still some way off can be trusted
|
|
657
|
+
to put reflections near the right peaks. Every later cycle indexes the whole
|
|
658
|
+
list with ``fine_tolerance`` against the cell of the previous cycle. Each
|
|
659
|
+
cycle refines on the peaks that matched exactly one reflection, so an
|
|
660
|
+
ambiguous peak never chooses between two candidates on the strength of a
|
|
661
|
+
cell that is not yet converged.
|
|
662
|
+
|
|
663
|
+
Parameters
|
|
664
|
+
----------
|
|
665
|
+
peaks
|
|
666
|
+
Observed peaks to index.
|
|
667
|
+
start_cell
|
|
668
|
+
Cell the first cycle indexes against.
|
|
669
|
+
wavelength
|
|
670
|
+
Radiation wavelength in angstroms.
|
|
671
|
+
zero_offset
|
|
672
|
+
Zero point correction, in degrees, subtracted from each observed peak.
|
|
673
|
+
Giving one turns the search off, since it is then already known.
|
|
674
|
+
coarse_tolerance
|
|
675
|
+
Tolerance of the first cycle, in degrees.
|
|
676
|
+
coarse_two_theta_max
|
|
677
|
+
Upper limit of the first cycle, in corrected degrees.
|
|
678
|
+
fine_tolerance
|
|
679
|
+
Tolerance of the later cycles and of the returned indexing, in degrees.
|
|
680
|
+
space_group
|
|
681
|
+
Space group whose reflection conditions to apply, or ``None`` for none.
|
|
682
|
+
n_cycles
|
|
683
|
+
Number of refinement cycles, at least one.
|
|
684
|
+
search_zero
|
|
685
|
+
Look for the zero offset with :func:`estimate_zero_offset` before
|
|
686
|
+
indexing, starting from ``start_cell`` and scoring at the fine
|
|
687
|
+
tolerance. Only done when ``zero_offset`` is left at zero.
|
|
688
|
+
|
|
689
|
+
Returns
|
|
690
|
+
-------
|
|
691
|
+
tuple[list[IndexedPeak], CellFit]
|
|
692
|
+
The whole peak list indexed against the final cell with
|
|
693
|
+
``fine_tolerance``, and the fit of the last cycle. The fit carries the
|
|
694
|
+
offset everything was indexed with in its ``zero_offset``.
|
|
695
|
+
|
|
696
|
+
Raises
|
|
697
|
+
------
|
|
698
|
+
ValueError
|
|
699
|
+
If ``n_cycles`` is below one, if no peak falls below
|
|
700
|
+
``coarse_two_theta_max``, or if a cycle has too few peaks to refine on.
|
|
701
|
+
"""
|
|
702
|
+
if n_cycles < 1:
|
|
703
|
+
raise ValueError(f"Need at least one cycle, got {n_cycles}")
|
|
704
|
+
|
|
705
|
+
if search_zero and zero_offset == 0.0 and peaks:
|
|
706
|
+
# Searched on the same window and tolerance the first cycle will use,
|
|
707
|
+
# so the offset that wins is the one that cycle can act on.
|
|
708
|
+
# Scored over the whole pattern at the fine tolerance: the spread in
|
|
709
|
+
# 2theta is exactly what separates a constant offset from a cell error,
|
|
710
|
+
# so confining the search to the coarse window throws that away.
|
|
711
|
+
zero_offset = estimate_zero_offset(
|
|
712
|
+
peaks,
|
|
713
|
+
start_cell,
|
|
714
|
+
wavelength,
|
|
715
|
+
tolerance=fine_tolerance,
|
|
716
|
+
space_group=space_group,
|
|
717
|
+
).offset
|
|
718
|
+
|
|
719
|
+
coarse_peaks = [
|
|
720
|
+
peak for peak in peaks if peak.two_theta - zero_offset <= coarse_two_theta_max
|
|
721
|
+
]
|
|
722
|
+
if not coarse_peaks:
|
|
723
|
+
raise ValueError(
|
|
724
|
+
f"No peak below {coarse_two_theta_max} degrees to start the refinement from"
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
cell = start_cell
|
|
728
|
+
fit = None
|
|
729
|
+
for cycle in range(n_cycles):
|
|
730
|
+
if cycle == 0:
|
|
731
|
+
candidates = index_peaks(
|
|
732
|
+
coarse_peaks,
|
|
733
|
+
cell,
|
|
734
|
+
wavelength,
|
|
735
|
+
coarse_tolerance,
|
|
736
|
+
zero_offset,
|
|
737
|
+
space_group,
|
|
738
|
+
)
|
|
739
|
+
else:
|
|
740
|
+
candidates = index_peaks(
|
|
741
|
+
peaks, cell, wavelength, fine_tolerance, zero_offset, space_group
|
|
742
|
+
)
|
|
743
|
+
unambiguous = [entry for entry in candidates if len(entry.candidates) == 1]
|
|
744
|
+
fit = refine_cell(unambiguous, wavelength, cell)
|
|
745
|
+
cell = fit.cell
|
|
746
|
+
|
|
747
|
+
final = index_peaks(
|
|
748
|
+
peaks, cell, wavelength, fine_tolerance, zero_offset, space_group
|
|
749
|
+
)
|
|
750
|
+
return final, replace(fit, zero_offset=zero_offset)
|