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/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)