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