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