nxs-analysis-tools 0.1.13__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.
@@ -0,0 +1,1758 @@
1
+ """
2
+ Tools for generating single crystal pair distribution functions.
3
+ """
4
+ import time
5
+ import os
6
+ import gc
7
+ import math
8
+ import warnings
9
+ from scipy.ndimage import rotate, affine_transform
10
+ import scipy
11
+ import matplotlib.pyplot as plt
12
+ from matplotlib.transforms import Affine2D
13
+ from nexusformat.nexus import nxsave, NXroot, NXentry, NXdata, NXfield
14
+ import numpy as np
15
+ from astropy.convolution import Kernel, convolve_fft
16
+ import pyfftw
17
+ from .datareduction import plot_slice, reciprocal_lattice_params, Padder, \
18
+ array_to_nxdata
19
+ from .lineartransformations import ShearTransformer
20
+
21
+ __all__ = ['Symmetrizer2D', 'Symmetrizer3D', 'Puncher', 'Interpolator',
22
+ 'fourier_transform_nxdata', 'Gaussian3DKernel', 'DeltaPDF',
23
+ 'generate_gaussian'
24
+ ]
25
+
26
+
27
+ class Symmetrizer2D:
28
+ """
29
+ A class for symmetrizing 2D datasets.
30
+
31
+ The `Symmetrizer2D` class provides functionality to apply symmetry
32
+ operations such as rotation and mirroring to 2D datasets.
33
+
34
+ Attributes
35
+ ----------
36
+ mirror_axis : int or None
37
+ The axis along which mirroring is performed. Default is None, meaning
38
+ no mirroring is applied.
39
+ symmetrized : NXdata or None
40
+ The symmetrized dataset after applying the symmetrization operations.
41
+ Default is None until symmetrization is performed.
42
+ rotations : int or None
43
+ The number of rotations needed to reconstruct the full dataset from
44
+ a single wedge. Default is None until parameters are set.
45
+ transform : Affine2D or None
46
+ The transformation matrix used for skewing and scaling the dataset.
47
+ Default is None until parameters are set.
48
+ mirror : bool or None
49
+ Indicates whether mirroring is performed during symmetrization.
50
+ Default is None until parameters are set.
51
+ skew_angle : float or None
52
+ The skew angle (in degrees) between the principal axes of the plane
53
+ to be symmetrized. Default is None until parameters are set.
54
+ theta_max : float or None
55
+ The maximum angle (in degrees) for symmetrization. Default is None
56
+ until parameters are set.
57
+ theta_min : float or None
58
+ The minimum angle (in degrees) for symmetrization. Default is None
59
+ until parameters are set.
60
+ wedge : NXdata or None
61
+ The dataset wedge used in the symmetrization process. Default is
62
+ None until symmetrization is performed.
63
+ symmetrization_mask : NXdata or None
64
+ The mask used for selecting the region of the dataset to be symmetrized.
65
+ Default is None until symmetrization is performed.
66
+
67
+ Methods
68
+ -------
69
+ __init__(**kwargs):
70
+ Initializes the Symmetrizer2D object and optionally sets the parameters
71
+ using `set_parameters`.
72
+ set_parameters(theta_min, theta_max, lattice_angle=90, mirror=True, mirror_axis=0):
73
+ Sets the parameters for the symmetrization operation, including angle limits,
74
+ lattice angle, and mirroring options.
75
+ symmetrize_2d(data):
76
+ Symmetrizes a 2D dataset based on the set parameters.
77
+ test(data, **kwargs):
78
+ Performs a test visualization of the symmetrization process, displaying the
79
+ original data, mask, wedge, and symmetrized result.
80
+ """
81
+ symmetrization_mask: NXdata
82
+
83
+ def __init__(self, **kwargs):
84
+ """
85
+ Initializes the Symmetrizer2D object.
86
+
87
+ Parameters
88
+ ----------
89
+ **kwargs : dict, optional
90
+ Keyword arguments that can be passed to the `set_parameters` method to
91
+ set the symmetrization parameters during initialization.
92
+ """
93
+ self.mirror_axis = None
94
+ self.symmetrized = None
95
+ self.rotations = None
96
+ self.transform = None
97
+ self.mirror = None
98
+ self.skew_angle = None
99
+ self.theta_max = None
100
+ self.theta_min = None
101
+ self.wedge = None
102
+ if kwargs:
103
+ self.set_parameters(**kwargs)
104
+
105
+ def set_parameters(self, theta_min, theta_max, lattice_angle=90, mirror=True, mirror_axis=None):
106
+ """
107
+ Sets the parameters for the symmetrization operation, and calculates the
108
+ required transformations and rotations.
109
+
110
+ Parameters
111
+ ----------
112
+ theta_min : float
113
+ The minimum angle in degrees for symmetrization.
114
+ theta_max : float
115
+ The maximum angle in degrees for symmetrization.
116
+ lattice_angle : float, optional
117
+ The angle in degrees between the two principal axes of the plane to be
118
+ symmetrized (default: 90).
119
+ mirror : bool, optional
120
+ If True, perform mirroring during symmetrization (default: True).
121
+ mirror_axis : int, optional
122
+ The axis along which to perform mirroring (default: 0).
123
+ """
124
+ self.theta_min = theta_min
125
+ self.theta_max = theta_max
126
+ self.skew_angle = lattice_angle
127
+ self.mirror = mirror
128
+ if mirror:
129
+ if mirror_axis is None:
130
+ self.mirror_axis = 0
131
+ warnings.warn(
132
+ "mirror_axis not specified. Defaulting to 0. "
133
+ "Set mirror_axis explicitly when using mirror=True.",
134
+ UserWarning,
135
+ stacklevel=2
136
+ )
137
+ else:
138
+ self.mirror_axis = mirror_axis
139
+
140
+ self.transformer = ShearTransformer(lattice_angle)
141
+ self.transform = self.transformer.t
142
+
143
+ # Calculate number of rotations needed to reconstruct the dataset
144
+ if mirror:
145
+ rotations = abs(int(360 / (theta_max - theta_min) / 2))
146
+ else:
147
+ rotations = abs(int(360 / (theta_max - theta_min)))
148
+ self.rotations = rotations
149
+
150
+ self.symmetrization_mask = None
151
+
152
+ self.wedges = None
153
+
154
+ self.symmetrized = None
155
+
156
+ def symmetrize_2d(self, data):
157
+ """
158
+ Symmetrizes a 2D dataset based on the set parameters, applying padding
159
+ to prevent rotation cutoff and handling overlapping pixels.
160
+
161
+ Parameters
162
+ ----------
163
+ data : NXdata
164
+ The input 2D dataset to be symmetrized.
165
+
166
+ Returns
167
+ -------
168
+ symmetrized : NXdata
169
+ The symmetrized 2D dataset.
170
+ """
171
+ theta_min = self.theta_min
172
+ theta_max = self.theta_max
173
+ mirror = self.mirror
174
+ mirror_axis = self.mirror_axis
175
+ rotations = self.rotations
176
+
177
+ # Pad the dataset so that rotations don't get cutoff if they extend
178
+ # past the extent of the dataset
179
+ p = Padder(data)
180
+ padding = tuple(len(axis) for axis in data.nxaxes)
181
+ data_padded = p.pad(padding)
182
+
183
+ # Define axes that span the plane to be transformed
184
+ q1 = data_padded.nxaxes[0]
185
+ q2 = data_padded.nxaxes[1]
186
+
187
+ # Calculate the angle in radians
188
+ theta = np.arctan2(q1.reshape((-1, 1)), q2.reshape((1, -1)))
189
+ theta = np.mod(theta, 2 * np.pi)
190
+
191
+ # Convert min/max to radians and map to [0, 2pi)
192
+ theta_min_rad = np.deg2rad(theta_min % 360)
193
+ theta_max_rad = np.deg2rad(theta_max % 360)
194
+
195
+ # Handle wraparound cases
196
+ if theta_min_rad <= theta_max_rad:
197
+ symmetrization_mask = (theta >= theta_min_rad) & (theta <= theta_max_rad)
198
+ else:
199
+ symmetrization_mask = (theta >= theta_min_rad) | (theta <= theta_max_rad)
200
+
201
+ # Bring mask from skewed basis to data array basis
202
+ mask = array_to_nxdata(self.transformer.invert(symmetrization_mask), data_padded)
203
+
204
+ # Save mask for user interaction
205
+ self.symmetrization_mask = p.unpad(mask)
206
+
207
+ # Perform masking
208
+ wedge = mask * data_padded
209
+
210
+ # Save wedge for user interaction
211
+ self.wedge = p.unpad(wedge)
212
+
213
+ # Convert wedge back to array for further transformations
214
+ wedge = wedge[data.nxsignal.nxname].nxdata
215
+
216
+ # Bring wedge from data array basis to skewed basis for reconstruction
217
+ wedge = self.transformer.apply(wedge)
218
+
219
+ # Apply additional scaling before rotations
220
+ scale = wedge.shape[0]/wedge.shape[1]
221
+ wedge = affine_transform(wedge,
222
+ Affine2D().scale(scale, 1).get_matrix()[:2, :2],
223
+ offset=[(1 - scale) * wedge.shape[0] / 2, 0],
224
+ order=0,
225
+ )
226
+
227
+ # Reconstruct full dataset from wedge
228
+ reconstructed = np.zeros(wedge.shape)
229
+
230
+ for _ in range(0, rotations):
231
+ reconstructed += wedge
232
+ wedge = rotate(wedge, 360 / rotations, reshape=False, order=0)
233
+
234
+ if mirror:
235
+ reconstructed = np.where(reconstructed == 0,
236
+ reconstructed + np.flip(reconstructed, axis=mirror_axis),
237
+ reconstructed)
238
+
239
+
240
+ # Undo scaling transformation
241
+ reconstructed = affine_transform(reconstructed,
242
+ Affine2D().scale(
243
+ scale, 1
244
+ ).inverted().get_matrix()[:2, :2],
245
+ offset=[-(1 - scale) * wedge.shape[
246
+ 0] / 2 / scale, 0],
247
+ order=0,
248
+ )
249
+
250
+ reconstructed = self.transformer.invert(reconstructed)
251
+
252
+ reconstructed = p.unpad(reconstructed)
253
+
254
+ # Fix any overlapping pixels by truncating counts to max
255
+ reconstructed[reconstructed > data.nxsignal.nxdata.max()] \
256
+ = data.nxsignal.nxdata.max()
257
+
258
+ symmetrized = NXdata(NXfield(reconstructed, name=data.nxsignal.nxname),
259
+ (data.nxaxes[0],
260
+ data.nxaxes[1]))
261
+
262
+ return symmetrized
263
+
264
+ def test(self, data, **kwargs):
265
+ """
266
+ Performs a test visualization of the symmetrization process to help assess
267
+ the effect of the parameters.
268
+
269
+ Parameters
270
+ ----------
271
+ data : ndarray
272
+ The input 2D dataset to be used for the test visualization.
273
+ **kwargs : dict
274
+ Additional keyword arguments to be passed to the plot_slice function.
275
+
276
+ Returns
277
+ -------
278
+ fig : Figure
279
+ The matplotlib Figure object that contains the test visualization plot.
280
+ axesarr : ndarray
281
+ The numpy array of Axes objects representing the subplots in the test
282
+ visualization.
283
+
284
+ Notes
285
+ -----
286
+ This method uses the `symmetrize_2d` method to perform the symmetrization on
287
+ the input data and visualize the process.
288
+
289
+ The test visualization plot includes the following subplots:
290
+ - Subplot 1: The original dataset.
291
+ - Subplot 2: The symmetrization mask.
292
+ - Subplot 3: The wedge slice used for reconstruction of the full symmetrized dataset.
293
+ - Subplot 4: The symmetrized dataset.
294
+
295
+ Example
296
+ -------
297
+ >>> s = Symmetrizer2D()
298
+ >>> s.set_parameters(theta_min, theta_max, skew_angle, mirror)
299
+ >>> s.test(data)
300
+ """
301
+ s = self
302
+ symm_test = s.symmetrize_2d(data)
303
+ fig, axesarr = plt.subplots(2, 2, figsize=(10, 8))
304
+ axes = axesarr.reshape(-1)
305
+
306
+ # Plot the data
307
+ plot_slice(data, skew_angle=s.skew_angle, ax=axes[0], title='data', **kwargs)
308
+
309
+ # Filter kwargs to exclude 'vmin' and 'vmax'
310
+ filtered_kwargs = {key: value for key, value in kwargs.items() if key not in ('vmin', 'vmax')}
311
+ # Plot the mask
312
+ plot_slice(s.symmetrization_mask, skew_angle=s.skew_angle, ax=axes[1], title='mask', **filtered_kwargs)
313
+
314
+ # Plot the wedge
315
+ plot_slice(s.wedge, skew_angle=s.skew_angle, ax=axes[2], title='wedge', **kwargs)
316
+
317
+ # Plot the symmetrized data
318
+ plot_slice(symm_test, skew_angle=s.skew_angle, ax=axes[3], title='symmetrized', **kwargs)
319
+ plt.subplots_adjust(wspace=0.4)
320
+ plt.show()
321
+ return fig, axesarr
322
+
323
+ class Symmetrizer3D:
324
+ """
325
+ A class to symmetrize 3D datasets by performing sequential 2D symmetrization on
326
+ different planes.
327
+
328
+ This class applies 2D symmetrization on the three principal planes of a 3D dataset,
329
+ effectively enhancing the symmetry of the data across all axes.
330
+ """
331
+
332
+ def __init__(self, data=None):
333
+ """
334
+ Initialize the Symmetrizer3D object with an optional 3D dataset.
335
+
336
+ If data is provided, the corresponding q-vectors and planes are automatically
337
+ set up for symmetrization.
338
+
339
+ Parameters
340
+ ----------
341
+ data : NXdata, optional
342
+ The input 3D dataset to be symmetrized.
343
+ """
344
+
345
+ if data is None:
346
+ raise ValueError("Symmetrizer3D requires a 3D NXdata object for initialization.")
347
+
348
+ self.a, self.b, self.c, self.al, self.be, self.ga = [None] * 6
349
+ self.a_star, self.b_star, self.c_star, self.al_star, self.be_star, self.ga_star = [None] * 6
350
+ self.lattice_params = None
351
+ self.reciprocal_lattice_params = None
352
+ self.symmetrized = None
353
+ self.data = data
354
+ self.plane1symmetrizer = Symmetrizer2D()
355
+ self.plane2symmetrizer = Symmetrizer2D()
356
+ self.plane3symmetrizer = Symmetrizer2D()
357
+
358
+ if data is not None:
359
+ self.q1 = data.nxaxes[0]
360
+ self.q2 = data.nxaxes[1]
361
+ self.q3 = data.nxaxes[2]
362
+ self.plane1 = self.q1.nxname + self.q2.nxname
363
+ self.plane2 = self.q1.nxname + self.q3.nxname
364
+ self.plane3 = self.q2.nxname + self.q3.nxname
365
+
366
+ print("Plane 1: " + self.plane1)
367
+ print("Plane 2: " + self.plane2)
368
+ print("Plane 3: " + self.plane3)
369
+
370
+ def set_data(self, data):
371
+ """
372
+ Sets the 3D dataset to be symmetrized and updates the corresponding q-vectors and planes.
373
+
374
+ Parameters
375
+ ----------
376
+ data : NXdata
377
+ The input 3D dataset to be symmetrized.
378
+ """
379
+ self.data = data
380
+ self.q1 = data.nxaxes[0]
381
+ self.q2 = data.nxaxes[1]
382
+ self.q3 = data.nxaxes[2]
383
+ self.plane1 = self.q1.nxname + self.q2.nxname
384
+ self.plane2 = self.q1.nxname + self.q3.nxname
385
+ self.plane3 = self.q2.nxname + self.q3.nxname
386
+
387
+ print("Plane 1: " + self.plane1)
388
+ print("Plane 2: " + self.plane2)
389
+ print("Plane 3: " + self.plane3)
390
+
391
+ def set_lattice_params(self, lattice_params):
392
+ """
393
+ Sets the lattice parameters and calculates the reciprocal lattice parameters.
394
+
395
+ Parameters
396
+ ----------
397
+ lattice_params : tuple of float
398
+ The lattice parameters (a, b, c, alpha, beta, gamma) in real space.
399
+ """
400
+ self.a, self.b, self.c, self.al, self.be, self.ga = lattice_params
401
+ self.lattice_params = lattice_params
402
+ self.reciprocal_lattice_params = reciprocal_lattice_params(lattice_params)
403
+ self.a_star, self.b_star, self.c_star, \
404
+ self.al_star, self.be_star, self.ga_star = self.reciprocal_lattice_params
405
+
406
+ def symmetrize(self, positive_values=True):
407
+ """
408
+ Symmetrize the 3D dataset by sequentially applying 2D symmetrization
409
+ on the three principal planes.
410
+
411
+ This method performs symmetrization along the (q1-q2), (q1-q3),
412
+ and (q2-q3) planes, ensuring that the dataset maintains expected
413
+ symmetry properties. Optionally, negative values resulting from the
414
+ symmetrization process can be set to zero.
415
+
416
+ Parameters
417
+ ----------
418
+ positive_values : bool, optional
419
+ If True, sets negative symmetrized values to zero (default is True).
420
+
421
+ Returns
422
+ -------
423
+ NXdata
424
+ The symmetrized 3D dataset stored in the `symmetrized` attribute.
425
+
426
+ Notes
427
+ -----
428
+ - Symmetrization is performed sequentially across three principal
429
+ planes using corresponding 2D symmetrization methods.
430
+ - The process prints progress updates and timing information.
431
+ - If `theta_max` is not set for a particular plane symmetrizer,
432
+ that plane is skipped.
433
+ """
434
+
435
+ starttime = time.time()
436
+ data = self.data
437
+ q1, q2, q3 = self.q1, self.q2, self.q3
438
+ out_array = np.zeros(data.nxsignal.shape)
439
+
440
+ if self.plane1symmetrizer.theta_max is not None:
441
+ print('Symmetrizing ' + self.plane1 + ' planes...')
442
+ for k, value in enumerate(q3):
443
+ print(f'Symmetrizing {q3.nxname}={value:.02f}.'
444
+ f'..', end='\r')
445
+ data_symmetrized = self.plane1symmetrizer.symmetrize_2d(data[:, :, k])
446
+ out_array[:, :, k] = data_symmetrized[data.nxsignal.nxname].nxdata
447
+ print('\nSymmetrized ' + self.plane1 + ' planes.')
448
+
449
+ if self.plane2symmetrizer.theta_max is not None:
450
+ print('Symmetrizing ' + self.plane2 + ' planes...')
451
+ for j, value in enumerate(q2):
452
+ print(f'Symmetrizing {q2.nxname}={value:.02f}...', end='\r')
453
+ data_symmetrized = self.plane2symmetrizer.symmetrize_2d(
454
+ NXdata(NXfield(out_array[:, j, :], name=data.nxsignal.nxname), (q1, q3))
455
+ )
456
+ out_array[:, j, :] = data_symmetrized[data.nxsignal.nxname].nxdata
457
+ print('\nSymmetrized ' + self.plane2 + ' planes.')
458
+
459
+ if self.plane3symmetrizer.theta_max is not None:
460
+ print('Symmetrizing ' + self.plane3 + ' planes...')
461
+ for i, value in enumerate(q1):
462
+ print(f'Symmetrizing {q1.nxname}={value:.02f}...', end='\r')
463
+ data_symmetrized = self.plane3symmetrizer.symmetrize_2d(
464
+ NXdata(NXfield(out_array[i, :, :], name=data.nxsignal.nxname), (q2, q3))
465
+ )
466
+ out_array[i, :, :] = data_symmetrized[data.nxsignal.nxname].nxdata
467
+ print('\nSymmetrized ' + self.plane3 + ' planes.')
468
+
469
+ if positive_values:
470
+ out_array[out_array < 0] = 0
471
+
472
+ stoptime = time.time()
473
+ print(f"\nSymmetrization finished in {((stoptime - starttime) / 60):.02f} minutes.")
474
+
475
+ self.symmetrized = NXdata(NXfield(out_array, name=data.nxsignal.nxname),
476
+ tuple(axis for axis in data.nxaxes))
477
+
478
+ return self.symmetrized
479
+
480
+ def save(self, fout_name=None):
481
+ """
482
+ Save the symmetrized dataset to a NeXus file.
483
+
484
+ Parameters
485
+ ----------
486
+ fout_name : str, optional
487
+ The name of the output file. If not provided,
488
+ the default name 'symmetrized.nxs' will be used.
489
+ """
490
+ print("Saving file...")
491
+
492
+ f = NXroot()
493
+ f['entry'] = NXentry()
494
+ f['entry']['data'] = self.symmetrized
495
+ if fout_name is None:
496
+ fout_name = 'symmetrized.nxs'
497
+ nxsave(fout_name, f)
498
+ print("Output file saved to: " + os.path.join(os.getcwd(), fout_name))
499
+
500
+
501
+ def generate_gaussian(H, K, L, amp, stddev, lattice_params, coeffs=None, center=None):
502
+ """
503
+ Generate a 3D Gaussian distribution.
504
+
505
+ This function creates a 3D Gaussian distribution in reciprocal space based
506
+ on the specified parameters.
507
+
508
+ Parameters
509
+ ----------
510
+ H, K, L : ndarray
511
+ Arrays specifying the values of H, K, and L coordinates in reciprocal space.
512
+ amp : float
513
+ Amplitude of the Gaussian distribution.
514
+ stddev : float
515
+ Standard deviation of the Gaussian distribution.
516
+ lattice_params : tuple
517
+ Tuple of lattice parameters (a, b, c, alpha, beta, gamma) for the
518
+ reciprocal lattice.
519
+ coeffs : list, optional
520
+ Coefficients for the Gaussian expression, including cross-terms between axes.
521
+ Default is [1, 0, 1, 0, 1, 0],
522
+ corresponding to (1*H**2 + 0*H*K + 1*K**2 + 0*K*L + 1*L**2 + 0*L*H).
523
+ center : tuple
524
+ Tuple of coordinates for the center of the Gaussian. Default is (0,0,0).
525
+
526
+ Returns
527
+ -------
528
+ gaussian : ndarray
529
+ 3D Gaussian distribution array.
530
+ """
531
+ if center is None:
532
+ center=(0,0,0)
533
+ if coeffs is None:
534
+ coeffs = [1, 0, 1, 0, 1, 0]
535
+ a, b, c, al, be, ga = lattice_params
536
+ a_, b_, c_, _, _, _ = reciprocal_lattice_params((a, b, c, al, be, ga))
537
+ H = H-center[0]
538
+ K = K-center[1]
539
+ L = L-center[2]
540
+ H, K, L = np.meshgrid(H, K, L, indexing='ij')
541
+ gaussian = amp * np.exp(-(coeffs[0] * H ** 2 +
542
+ coeffs[1] * (b_ * a_ / (a_ ** 2)) * H * K +
543
+ coeffs[2] * (b_ / a_) ** 2 * K ** 2 +
544
+ coeffs[3] * (b_ * c_ / (a_ ** 2)) * K * L +
545
+ coeffs[4] * (c_ / a_) ** 2 * L ** 2 +
546
+ coeffs[5] * (c_ * a_ / (a_ ** 2)) * L * H) / (2 * stddev ** 2))
547
+ if gaussian.ndim == 3:
548
+ gaussian = gaussian.transpose(1, 0, 2)
549
+ elif gaussian.ndim == 2:
550
+ gaussian = gaussian.transpose()
551
+ return gaussian.transpose(1, 0, 2)
552
+
553
+ class Puncher:
554
+ """
555
+ A class for applying masks to 3D datasets, typically for data processing in reciprocal space.
556
+
557
+ This class provides methods for setting data, applying masks, and generating
558
+ masks based on various criteria. It can be used to "punch" or modify datasets
559
+ by setting specific regions to NaN according to the mask.
560
+
561
+ Attributes
562
+ ----------
563
+ punched : NXdata, optional
564
+ The dataset with regions modified (punched) based on the mask.
565
+ data : NXdata, optional
566
+ The input dataset to be processed.
567
+ HH, KK, LL : ndarray
568
+ Meshgrid arrays representing the H, K, and L coordinates in reciprocal space.
569
+ mask : ndarray, optional
570
+ The mask used for identifying and modifying specific regions in the dataset.
571
+ reciprocal_lattice_params : tuple, optional
572
+ The reciprocal lattice parameters derived from the lattice parameters.
573
+ lattice_params : tuple, optional
574
+ The lattice parameters (a, b, c, alpha, beta, gamma).
575
+ a, b, c, al, be, ga : float
576
+ Individual lattice parameters.
577
+ a_star, b_star, c_star, al_star, be_star, ga_star : float
578
+ Individual reciprocal lattice parameters.
579
+
580
+ Methods
581
+ -------
582
+ set_data(data)
583
+ Sets the dataset to be processed and initializes the coordinate arrays and mask.
584
+ set_lattice_params(lattice_params)
585
+ Sets the lattice parameters and computes the reciprocal lattice parameters.
586
+ add_mask(maskaddition)
587
+ Adds regions to the current mask using a logical OR operation.
588
+ subtract_mask(masksubtraction)
589
+ Removes regions from the current mask using a logical AND NOT operation.
590
+ generate_bragg_mask(punch_radius, coeffs=None, thresh=None)
591
+ Generates a mask for Bragg peaks based on a Gaussian distribution in
592
+ reciprocal space.
593
+ generate_intensity_mask(thresh, radius, verbose=True)
594
+ Generates a mask based on intensity thresholds, including a spherical
595
+ region around high-intensity points.
596
+ generate_mask_at_coord(coordinate, punch_radius, coeffs=None, thresh=None)
597
+ Generates a mask centered at a specific coordinate in reciprocal space
598
+ with a specified radius.
599
+ punch()
600
+ Applies the mask to the dataset, setting masked regions to NaN.
601
+ """
602
+
603
+ def __init__(self):
604
+ """
605
+ Initialize the Puncher object.
606
+
607
+ This method sets up the initial state of the Puncher instance, including
608
+ attributes for storing the dataset, lattice parameters, and masks.
609
+ It prepares the object for further data processing and masking operations.
610
+
611
+ Attributes
612
+ ----------
613
+ punched : NXdata, optional
614
+ The dataset with modified (punched) regions, initialized as None.
615
+ data : NXdata, optional
616
+ The input dataset to be processed, initialized as None.
617
+ HH, KK, LL : ndarray, optional
618
+ Arrays representing the H, K, and L coordinates in reciprocal space,
619
+ initialized as None.
620
+ mask : ndarray, optional
621
+ The mask for identifying and modifying specific regions in the dataset,
622
+ initialized as None.
623
+ reciprocal_lattice_params : tuple, optional
624
+ The reciprocal lattice parameters, initialized as None.
625
+ lattice_params : tuple, optional
626
+ The lattice parameters (a, b, c, alpha, beta, gamma),
627
+ initialized as None.
628
+ a, b, c, al, be, ga : float
629
+ Individual lattice parameters, initialized as None.
630
+ a_star, b_star, c_star, al_star, be_star, ga_star : float
631
+ Individual reciprocal lattice parameters, initialized as None.
632
+ """
633
+ self.punched = None
634
+ self.data = None
635
+ self.HH, self.KK, self.LL = [None] * 3
636
+ self.mask = None
637
+ self.reciprocal_lattice_params = None
638
+ self.lattice_params = None
639
+ self.a, self.b, self.c, self.al, self.be, self.ga = [None] * 6
640
+ self.a_star, self.b_star, self.c_star, self.al_star, self.be_star, self.ga_star = [None] * 6
641
+
642
+ def set_data(self, data):
643
+ """
644
+ Set the 3D dataset and initialize the mask if not already set.
645
+
646
+ Parameters
647
+ ----------
648
+ data : NXdata
649
+ The dataset to be processed.
650
+
651
+ Notes
652
+ -----
653
+ This method also sets up the H, K, and L coordinate grids for the dataset.
654
+ """
655
+ self.data = data
656
+ if self.mask is None:
657
+ self.mask = np.zeros(data.nxsignal.nxdata.shape)
658
+ self.HH, self.KK, self.LL = np.meshgrid(data.nxaxes[0],
659
+ data.nxaxes[1],
660
+ data.nxaxes[2],
661
+ indexing='ij')
662
+
663
+ def set_lattice_params(self, lattice_params):
664
+ """
665
+ Set the lattice parameters and compute the reciprocal lattice parameters.
666
+
667
+ Parameters
668
+ ----------
669
+ lattice_params : tuple
670
+ Tuple of lattice parameters (a, b, c, alpha, beta, gamma).
671
+ """
672
+ self.a, self.b, self.c, self.al, self.be, self.ga = lattice_params
673
+ self.lattice_params = lattice_params
674
+ self.reciprocal_lattice_params = reciprocal_lattice_params(lattice_params)
675
+ self.a_star, self.b_star, self.c_star, \
676
+ self.al_star, self.be_star, self.ga_star = self.reciprocal_lattice_params
677
+
678
+ def add_mask(self, maskaddition):
679
+ """
680
+ Add regions to the current mask using a logical OR operation.
681
+
682
+ Parameters
683
+ ----------
684
+ maskaddition : ndarray
685
+ The mask to be added.
686
+ """
687
+ self.mask = np.logical_or(self.mask, maskaddition)
688
+
689
+ def subtract_mask(self, masksubtraction):
690
+ """
691
+ Remove regions from the current mask using a logical AND NOT operation.
692
+
693
+ Parameters
694
+ ----------
695
+ masksubtraction : ndarray
696
+ The mask to be subtracted.
697
+ """
698
+ self.mask = np.logical_and(self.mask, np.logical_not(masksubtraction))
699
+
700
+ def generate_bragg_mask(self, punch_radius, coeffs=None, thresh=None):
701
+ """
702
+ Generate a mask for Bragg peaks.
703
+
704
+ Parameters
705
+ ----------
706
+ punch_radius : float
707
+ Radius for the Bragg peak mask.
708
+ coeffs : list, optional
709
+ Coefficients for the expression of the sphere to be removed around
710
+ each Bragg position, corresponding to coefficients for H, HK, K, KL, L, and LH terms.
711
+ Default is [1, 0, 1, 0, 1, 0].
712
+ thresh : float, optional
713
+ Intensity threshold for applying the mask.
714
+
715
+ Returns
716
+ -------
717
+ mask : ndarray
718
+ Boolean mask identifying the Bragg peaks.
719
+ """
720
+ if coeffs is None:
721
+ coeffs = [1, 0, 1, 0, 1, 0]
722
+ data = self.data
723
+ H, K, L = self.HH, self.KK, self.LL
724
+ a_, b_, c_, _, _, _ = self.reciprocal_lattice_params
725
+
726
+ mask = (coeffs[0] * (H - np.rint(H)) ** 2 +
727
+ coeffs[1] * (b_ * a_ / (a_ ** 2)) * (H - np.rint(H)) * (K - np.rint(K)) +
728
+ coeffs[2] * (b_ / a_) ** 2 * (K - np.rint(K)) ** 2 +
729
+ coeffs[3] * (b_ * c_ / (a_ ** 2)) * (K - np.rint(K)) * (L - np.rint(L)) +
730
+ coeffs[4] * (c_ / a_) ** 2 * (L - np.rint(L)) ** 2 +
731
+ coeffs[5] * (c_ * a_ / (a_ ** 2)) * (L - np.rint(L)) * (H - np.rint(H))) \
732
+ < punch_radius ** 2
733
+
734
+ if thresh:
735
+ mask = np.logical_and(mask, data.nxsignal > thresh)
736
+
737
+ return mask
738
+
739
+ def generate_intensity_mask(self, thresh, radius, verbose=True):
740
+ """
741
+ Generate a mask based on intensity thresholds.
742
+
743
+ Parameters
744
+ ----------
745
+ thresh : float
746
+ Intensity threshold for creating the mask.
747
+ radius : int
748
+ Radius around high-intensity points to include in the mask.
749
+ verbose : bool, optional
750
+ Whether to print progress information.
751
+
752
+ Returns
753
+ -------
754
+ mask : ndarray
755
+ Boolean mask highlighting regions with high intensity.
756
+ """
757
+ data = self.data
758
+ counts = data.nxsignal.nxdata
759
+ mask = np.zeros(counts.shape)
760
+
761
+ print(f"Shape of data is {counts.shape}") if verbose else None
762
+ for i in range(counts.shape[0]):
763
+ for j in range(counts.shape[1]):
764
+ for k in range(counts.shape[2]):
765
+ if counts[i, j, k] > thresh:
766
+ # Set the pixels within the sphere to NaN
767
+ for x in range(max(i - radius, 0),
768
+ min(i + radius + 1, counts.shape[0])):
769
+ for y in range(max(j - radius, 0),
770
+ min(j + radius + 1, counts.shape[1])):
771
+ for z in range(max(k - radius, 0),
772
+ min(k + radius + 1, counts.shape[2])):
773
+ mask[x, y, z] = 1
774
+ print(f"Found high intensity at ({i}, {j}, {k}).\t\t", end='\r') \
775
+ if verbose else None
776
+ print("\nDone.")
777
+ return mask
778
+
779
+ def generate_mask_at_coord(self, coordinate, punch_radius, coeffs=None, thresh=None):
780
+ """
781
+ Generate a mask centered at a specific coordinate.
782
+
783
+ Parameters
784
+ ----------
785
+ coordinate : tuple of float
786
+ Center coordinate (H, K, L) for the mask.
787
+ punch_radius : float
788
+ Radius for the mask.
789
+ coeffs : list, optional
790
+ Coefficients for the expression of the sphere to be removed around
791
+ each Bragg position,
792
+ corresponding to coefficients for H, HK, K, KL, L, and LH terms.
793
+ Default is [1, 0, 1, 0, 1, 0].
794
+ thresh : float, optional
795
+ Intensity threshold for applying the mask.
796
+
797
+ Returns
798
+ -------
799
+ mask : ndarray
800
+ Boolean mask for the specified coordinate.
801
+ """
802
+ if coeffs is None:
803
+ coeffs = [1, 0, 1, 0, 1, 0]
804
+ data = self.data
805
+ H, K, L = self.HH, self.KK, self.LL
806
+ a_, b_, c_, _, _, _ = self.reciprocal_lattice_params
807
+ centerH, centerK, centerL = coordinate
808
+ mask = (coeffs[0] * (H - centerH) ** 2 +
809
+ coeffs[1] * (b_ * a_ / (a_ ** 2)) * (H - centerH) * (K - centerK) +
810
+ coeffs[2] * (b_ / a_) ** 2 * (K - centerK) ** 2 +
811
+ coeffs[3] * (b_ * c_ / (a_ ** 2)) * (K - centerK) * (L - centerL) +
812
+ coeffs[4] * (c_ / a_) ** 2 * (L - centerL) ** 2 +
813
+ coeffs[5] * (c_ * a_ / (a_ ** 2)) * (L - centerL) * (H - centerH)) \
814
+ < punch_radius ** 2
815
+
816
+ if thresh:
817
+ mask = np.logical_and(mask, data.nxsignal > thresh)
818
+
819
+ return mask
820
+
821
+ def punch(self):
822
+ """
823
+ Apply the mask to the dataset, setting masked regions to NaN.
824
+
825
+ This method creates a new dataset where the masked regions are set to
826
+ NaN, effectively "punching" those regions.
827
+ """
828
+ data = self.data
829
+ self.punched = NXdata(NXfield(
830
+ np.where(self.mask, np.nan, data.nxsignal.nxdata),
831
+ name=data.nxsignal.nxname),
832
+ (data.nxaxes[0], data.nxaxes[1], data.nxaxes[2])
833
+ )
834
+
835
+
836
+ def _round_up_to_odd_integer(value):
837
+ """
838
+ Round up a given number to the nearest odd integer.
839
+
840
+ This function takes a floating-point value and rounds it up to the smallest
841
+ odd integer that is greater than or equal to the given value.
842
+
843
+ Parameters
844
+ ----------
845
+ value : float
846
+ The input floating-point number to be rounded up.
847
+
848
+ Returns
849
+ -------
850
+ int
851
+ The nearest odd integer greater than or equal to the input value.
852
+
853
+ Examples
854
+ --------
855
+ >>> _round_up_to_odd_integer(4.2)
856
+ 5
857
+
858
+ >>> _round_up_to_odd_integer(5.0)
859
+ 5
860
+
861
+ >>> _round_up_to_odd_integer(6.7)
862
+ 7
863
+ """
864
+ i = int(math.ceil(value))
865
+ if i % 2 == 0:
866
+ return i + 1
867
+
868
+ return i
869
+
870
+ class Gaussian2DKernel(Kernel):
871
+ """
872
+ Initialize a 2D Gaussian kernel.
873
+
874
+ This constructor creates a 2D Gaussian kernel with the specified
875
+ standard deviation and size. The Gaussian kernel is generated based on
876
+ the provided coefficients and is then normalized.
877
+
878
+ Parameters
879
+ ----------
880
+ stddev : float
881
+ The standard deviation of the Gaussian distribution, which controls
882
+ the width of the kernel.
883
+
884
+ size : tuple of int
885
+ The dimensions of the kernel, given as (x_dim, y_dim).
886
+
887
+ coeffs : list of float, optional
888
+ Coefficients for the Gaussian expression.
889
+ The default is [1, 0, 1] corresponding to the Gaussian form:
890
+ (1 * X^2 + 0 * X * Y + 1 * Y^2).
891
+
892
+ Raises
893
+ ------
894
+ ValueError
895
+ If the dimensions in `size` are not positive integers.
896
+
897
+ Notes
898
+ -----
899
+ The kernel is generated over a grid that spans twice the size of
900
+ each dimension, and the resulting array is normalized.
901
+ """
902
+ _separable = True
903
+ _is_bool = False
904
+
905
+ def __init__(self, stddev, size, coeffs=None):
906
+ if not coeffs:
907
+ coeffs = [1, 0, 1]
908
+ x_dim, y_dim = size
909
+ x = np.linspace(-x_dim, x_dim, int(x_dim) + 1)
910
+ y = np.linspace(-y_dim, y_dim, int(y_dim) + 1)
911
+ X, Y = np.meshgrid(x, y)
912
+ array = np.exp(-(coeffs[0] * X ** 2 +
913
+ coeffs[1] * X * Y +
914
+ coeffs[2] * Y ** 2) / (2 * stddev ** 2)
915
+ )
916
+ self._default_size = _round_up_to_odd_integer(stddev)
917
+ super().__init__(array)
918
+ self.normalize()
919
+ self._truncation = np.abs(1. - self._array.sum())
920
+
921
+ class Gaussian3DKernel(Kernel):
922
+ """
923
+ Initialize a 3D Gaussian kernel.
924
+
925
+ This constructor creates a 3D Gaussian kernel with the specified
926
+ standard deviation and size. The Gaussian kernel is generated based on
927
+ the provided coefficients and is then normalized.
928
+
929
+ Parameters
930
+ ----------
931
+ stddev : float
932
+ The standard deviation of the Gaussian distribution, which controls
933
+ the width of the kernel.
934
+
935
+ size : tuple of int
936
+ The dimensions of the kernel, given as (x_dim, y_dim, z_dim).
937
+
938
+ coeffs : list of float, optional
939
+ Coefficients for the Gaussian expression.
940
+ The default is [1, 0, 1, 0, 1, 0], corresponding to the Gaussian form:
941
+ (1 * X^2 + 0 * X * Y + 1 * Y^2 + 0 * Y * Z + 1 * Z^2 + 0 * Z * X).
942
+
943
+ Raises
944
+ ------
945
+ ValueError
946
+ If the dimensions in `size` are not positive integers.
947
+
948
+ Notes
949
+ -----
950
+ The kernel is generated over a grid that spans twice the size of
951
+ each dimension, and the resulting array is normalized.
952
+ """
953
+ _separable = True
954
+ _is_bool = False
955
+
956
+ def __init__(self, stddev, size, coeffs=None):
957
+ if not coeffs:
958
+ coeffs = [1, 0, 1, 0, 1, 0]
959
+ x_dim, y_dim, z_dim = size
960
+ x = np.linspace(-x_dim, x_dim, int(x_dim) + 1)
961
+ y = np.linspace(-y_dim, y_dim, int(y_dim) + 1)
962
+ z = np.linspace(-z_dim, z_dim, int(z_dim) + 1)
963
+ X, Y, Z = np.meshgrid(x, y, z)
964
+ array = np.exp(-(coeffs[0] * X ** 2 +
965
+ coeffs[1] * X * Y +
966
+ coeffs[2] * Y ** 2 +
967
+ coeffs[3] * Y * Z +
968
+ coeffs[4] * Z ** 2 +
969
+ coeffs[5] * Z * X) / (2 * stddev ** 2)
970
+ )
971
+ self._default_size = _round_up_to_odd_integer(stddev)
972
+ super().__init__(array)
973
+ self.normalize()
974
+ self._truncation = np.abs(1. - self._array.sum())
975
+
976
+
977
+ class Interpolator:
978
+ """
979
+ A class to perform data interpolation using convolution with a specified
980
+ kernel.
981
+
982
+ Attributes
983
+ ----------
984
+ interp_time : float or None
985
+ Time taken for the last interpolation operation. Defaults to None.
986
+
987
+ window : ndarray or None
988
+ Window function to be applied to the interpolated data. Defaults to None.
989
+
990
+ interpolated : ndarray or None
991
+ The result of the interpolation operation. Defaults to None.
992
+
993
+ data : NXdata or None
994
+ The dataset to be interpolated. Defaults to None.
995
+
996
+ kernel : ndarray or None
997
+ The kernel used for convolution during interpolation. Defaults to None.
998
+
999
+ tapered : ndarray or None
1000
+ The interpolated data after applying the window function. Defaults to None.
1001
+ """
1002
+
1003
+ def __init__(self):
1004
+ """
1005
+ Initialize an Interpolator object.
1006
+
1007
+ Sets up an instance of the Interpolator class with the
1008
+ following attributes initialized to None:
1009
+
1010
+ - interp_time
1011
+ - window
1012
+ - interpolated
1013
+ - data
1014
+ - kernel
1015
+ - tapered
1016
+
1017
+ """
1018
+ self.interp_time = None
1019
+ self.window = None
1020
+ self.interpolated = None
1021
+ self.data = None
1022
+ self.kernel = None
1023
+ self.tapered = None
1024
+
1025
+ def set_data(self, data):
1026
+ """
1027
+ Set the dataset to be interpolated.
1028
+
1029
+ Parameters
1030
+ ----------
1031
+ data : NXdata
1032
+ The dataset containing the data to be interpolated.
1033
+ """
1034
+ self.data = data
1035
+ self.interpolated = data
1036
+ self.tapered = data
1037
+
1038
+ def set_kernel(self, kernel):
1039
+ """
1040
+ Set the kernel to be used for interpolation.
1041
+
1042
+ Parameters
1043
+ ----------
1044
+ kernel : ndarray
1045
+ The kernel to be used for convolution during interpolation.
1046
+ """
1047
+ self.kernel = kernel
1048
+
1049
+ def interpolate(self, verbose=True, positive_values=True):
1050
+ """
1051
+ Perform interpolation on the dataset using the specified kernel.
1052
+
1053
+ This method convolves the dataset with a kernel using `convolve_fft`
1054
+ to perform interpolation. The resulting interpolated data is stored
1055
+ in the `interpolated` attribute.
1056
+
1057
+ Parameters
1058
+ ----------
1059
+ verbose : bool, optional
1060
+ If True, prints progress messages and timing information
1061
+ (default is True).
1062
+ positive_values : bool, optional
1063
+ If True, sets negative interpolated values to zero
1064
+ (default is True).
1065
+
1066
+ Notes
1067
+ -----
1068
+ - The convolution operation is performed in Fourier space.
1069
+ - If a previous interpolation time is recorded, it is displayed
1070
+ before starting a new interpolation.
1071
+
1072
+ Returns
1073
+ -------
1074
+ None
1075
+ """
1076
+ start = time.time()
1077
+
1078
+ if self.interp_time and verbose:
1079
+ print(f"Last interpolation took {self.interp_time / 60:.2f} minutes.")
1080
+
1081
+ print("Running interpolation...") if verbose else None
1082
+ result = np.real(
1083
+ convolve_fft(self.data.nxsignal.nxdata,
1084
+ self.kernel, allow_huge=True, return_fft=False))
1085
+ print("Interpolation finished.") if verbose else None
1086
+
1087
+ end = time.time()
1088
+ interp_time = end - start
1089
+
1090
+ print(f'Interpolation took {interp_time / 60:.2f} minutes.') if verbose else None
1091
+
1092
+ if positive_values:
1093
+ result[result < 0] = 0
1094
+ self.interpolated = array_to_nxdata(result, self.data)
1095
+
1096
+ def set_tukey_window(self, tukey_alphas=(1.0, 1.0, 1.0)):
1097
+ """
1098
+ Set a Tukey window function for data tapering.
1099
+
1100
+ Parameters
1101
+ ----------
1102
+ tukey_alphas : tuple of floats, optional
1103
+ The alpha parameters for the Tukey window in each
1104
+ dimension (H, K, L). Default is (1.0, 1.0, 1.0).
1105
+
1106
+ Notes
1107
+ -----
1108
+ The window function is generated based on the size of the dataset in each dimension.
1109
+ """
1110
+ data = self.data
1111
+ tukey_H = np.tile(
1112
+ scipy.signal.windows.tukey(len(data.nxaxes[0]), alpha=tukey_alphas[0])[:, None, None],
1113
+ (1, len(data.nxaxes[1]), len(data.nxaxes[2]))
1114
+ )
1115
+ tukey_K = np.tile(
1116
+ scipy.signal.windows.tukey(len(data.nxaxes[1]), alpha=tukey_alphas[1])[None, :, None],
1117
+ (len(data.nxaxes[0]), 1, len(data.nxaxes[2]))
1118
+ )
1119
+ window = tukey_H * tukey_K
1120
+
1121
+ del tukey_H, tukey_K
1122
+ gc.collect()
1123
+
1124
+ tukey_L = np.tile(
1125
+ scipy.signal.windows.tukey(len(data.nxaxes[2]), alpha=tukey_alphas[2])[None, None, :],
1126
+ (len(data.nxaxes[0]), len(data.nxaxes[1]), 1))
1127
+ window = window * tukey_L
1128
+
1129
+ self.window = window
1130
+
1131
+ def set_hexagonal_tukey_window(self, tukey_alphas=(1.0, 1.0, 1.0, 1.0)):
1132
+ """
1133
+ Set a hexagonal Tukey window function for data tapering.
1134
+
1135
+ Parameters
1136
+ ----------
1137
+ tukey_alphas : tuple of floats, optional
1138
+ The alpha parameters for the Tukey window in each dimension and
1139
+ for the hexagonal truncation (H, HK, K, L).
1140
+ Default is (1.0, 1.0, 1.0, 1.0).
1141
+
1142
+ Notes
1143
+ -----
1144
+ The hexagonal Tukey window is applied to the dataset in a manner that
1145
+ preserves hexagonal symmetry.
1146
+
1147
+ """
1148
+ data = self.data
1149
+ H_ = data.nxaxes[0]
1150
+ K_ = data.nxaxes[1]
1151
+ L_ = data.nxaxes[2]
1152
+
1153
+ tukey_H = np.tile(
1154
+ scipy.signal.windows.tukey(len(data.nxaxes[0]), alpha=tukey_alphas[0])[:, None, None],
1155
+ (1, len(data.nxaxes[1]), len(data.nxaxes[2]))
1156
+ )
1157
+ tukey_K = np.tile(
1158
+ scipy.signal.windows.tukey(len(data.nxaxes[1]), alpha=tukey_alphas[1])[None, :, None],
1159
+ (len(data.nxaxes[0]), 1, len(data.nxaxes[2]))
1160
+ )
1161
+ window = tukey_H * tukey_K
1162
+
1163
+ del tukey_H, tukey_K
1164
+ gc.collect()
1165
+
1166
+ truncation = int((len(H_) - int(len(H_) * np.sqrt(2) / 2)) / 2)
1167
+
1168
+ tukey_HK = scipy.ndimage.rotate(
1169
+ np.tile(
1170
+ np.concatenate(
1171
+ (np.zeros(truncation)[:, None, None],
1172
+ scipy.signal.windows.tukey(len(H_) - 2 * truncation,
1173
+ alpha=tukey_alphas[2])[:, None, None],
1174
+ np.zeros(truncation)[:, None, None])),
1175
+ (1, len(K_), len(L_))
1176
+ ),
1177
+ angle=45, reshape=False, mode='nearest',
1178
+ )[0:len(H_), 0:len(K_), :]
1179
+ tukey_HK = np.nan_to_num(tukey_HK)
1180
+ window = window * tukey_HK
1181
+
1182
+ del tukey_HK
1183
+ gc.collect()
1184
+
1185
+ tukey_L = np.tile(
1186
+ scipy.signal.windows.tukey(len(data.nxaxes[2]), alpha=tukey_alphas[3])[None, None, :],
1187
+ (len(data.nxaxes[0]), len(data.nxaxes[1]), 1)
1188
+ )
1189
+ window = window * tukey_L
1190
+
1191
+ del tukey_L
1192
+ gc.collect()
1193
+
1194
+ self.window = window
1195
+
1196
+ def set_ellipsoidal_tukey_window(self, tukey_alpha=1.0, coeffs=None):
1197
+ """
1198
+ Set an ellipsoidal Tukey window function for data tapering.
1199
+
1200
+ The Tukey window smoothly tapers the data to zero near the edges of the
1201
+ elliptical region defined by quadratic form coefficients. This helps reduce
1202
+ artifacts in Fourier transforms and other operations sensitive to boundary effects.
1203
+
1204
+ Parameters
1205
+ ----------
1206
+ tukey_alpha : float, optional
1207
+ Tapering parameter for the Tukey window, between 0 and 1.
1208
+ - `tukey_alpha = 0` results in a ellipsoidal window (no tapering).
1209
+ - `tukey_alpha = 1` results in a full cosine taper.
1210
+ Default is 1.0.
1211
+
1212
+ coeffs : tuple of float, optional
1213
+ Coefficients `(c0, c1, c2, c3, c4, c5)` defining the ellipsoidal
1214
+ quadratic form:
1215
+ R^2 = c0*H^2 + c1*H*K + c2*K^2 + c3*K*L + c4*L^2 + c5*L*H
1216
+ If None, coefficients are automatically set to match the edges of the
1217
+ reciprocal space axes (H, K, L), which should be appropriate in cases
1218
+ where H, K, and L are orthogonal.
1219
+
1220
+ Notes
1221
+ -----
1222
+ - The maximum allowed radius `Qmax` is determined from the minimum radius
1223
+ value along the edges of reciprocal space.
1224
+ - The Tukey window is applied radially as a function of the distance `R`
1225
+ from the center, defined by the ellipsoidal quadratic form.
1226
+
1227
+ Sets
1228
+ ----
1229
+ self.window : ndarray
1230
+ A 3D array of the same shape as the data, containing the Tukey window
1231
+ values between 0 and 1.
1232
+ """
1233
+
1234
+ # Initialize axes
1235
+ H,K,L = [axis for axis in self.data.nxaxes]
1236
+
1237
+ # Initialize coeffs (default to window reaching edge of array)
1238
+ smallest_extent = np.min([H.max(), K.max(), L.max()])
1239
+ c = coeffs if coeffs is not None else ((smallest_extent / H.max()) ** 2,
1240
+ 0,
1241
+ (smallest_extent / K.max()) ** 2,
1242
+ 0,
1243
+ (smallest_extent / L.max()) ** 2,
1244
+ 0
1245
+ )
1246
+
1247
+ # Create meshgrid
1248
+ HH, KK, LL = np.meshgrid(H,K,L, indexing='ij')
1249
+
1250
+ # Create radius array
1251
+ RR = np.sqrt(
1252
+ c[0] * HH ** 2 +
1253
+ c[1] * HH * KK +
1254
+ c[2] * KK ** 2 +
1255
+ c[3] * KK * LL +
1256
+ c[4] * LL ** 2 +
1257
+ c[5] * LL * HH
1258
+ )
1259
+
1260
+ # Check the edges of reciprocal space to verify Qmax
1261
+ # Create list of pixels where H = H.max() or K = K.max() or L = L.max()
1262
+ edges = np.where(np.logical_or(np.logical_or(HH == H.max(), KK == K.max()), LL == L.max()), RR, RR.max())
1263
+ Qmax = edges.min()
1264
+ alpha = tukey_alpha
1265
+ period = (Qmax * alpha) / np.pi
1266
+
1267
+ window = np.where(RR > Qmax * (1 - alpha), (np.cos((RR - Qmax * (1 - alpha)) / period) + 1) / 2, 1)
1268
+ window = np.where(RR > Qmax, 0, window)
1269
+
1270
+ self.window = window
1271
+
1272
+ def set_window(self, window):
1273
+ """
1274
+ Set a custom window function for data tapering.
1275
+
1276
+ Parameters
1277
+ ----------
1278
+ window : ndarray
1279
+ A custom window function to be applied to the interpolated data.
1280
+ """
1281
+ self.window = window
1282
+
1283
+ def apply_window(self):
1284
+ """
1285
+ Apply the window function to the interpolated data.
1286
+
1287
+ The window function, if set, is applied to the `interpolated` data
1288
+ to produce the `tapered` result.
1289
+
1290
+ Returns
1291
+ -------
1292
+ None
1293
+ """
1294
+ self.tapered = self.interpolated * self.window
1295
+
1296
+
1297
+ def fourier_transform_nxdata(data, is_2d=False):
1298
+ """
1299
+ Perform a 3D Fourier Transform on the given NXdata object.
1300
+
1301
+ This function applies an inverse Fourier Transform to the input data
1302
+ using the `pyfftw` library to optimize performance. The result is a
1303
+ transformed array with spatial frequency components calculated along
1304
+ each axis.
1305
+
1306
+ Parameters
1307
+ ----------
1308
+ data : NXdata
1309
+ An NXdata object containing the data to be transformed. It should
1310
+ include the `signal` field for the data and `axes` fields
1311
+ specifying the coordinate axes.
1312
+
1313
+ is_2d : bool
1314
+ If true, skip FFT on out-of-plane direction and only do FFT
1315
+ on axes 0 and 1. Default False.
1316
+
1317
+ Returns
1318
+ -------
1319
+ NXdata
1320
+ A new NXdata object containing the Fourier Transformed data. The
1321
+ result includes:
1322
+
1323
+ - `dPDF`: The transformed data array.
1324
+ - `x`, `y`, `z`: Arrays representing the real-space components along each axis.
1325
+
1326
+ Notes
1327
+ -----
1328
+ - The FFT is performed in two stages: first along the last dimension of the input array and then along the first two dimensions.
1329
+ - The function uses `pyfftw` for efficient computation of the Fourier Transform.
1330
+ - The output frequency components are computed based on the step sizes of the original data axes.
1331
+
1332
+ """
1333
+ start = time.time()
1334
+ print("Starting FFT.")
1335
+
1336
+ padded = data.nxsignal.nxdata
1337
+
1338
+ fft_array = np.zeros(padded.shape)
1339
+
1340
+ print("FFT on axes 1,2")
1341
+
1342
+ for k in range(0, padded.shape[2]):
1343
+ fft_array[:, :, k] = np.real(
1344
+ np.fft.fftshift(
1345
+ pyfftw.interfaces.numpy_fft.ifftn(np.fft.fftshift(padded[:, :, k]),
1346
+ planner_effort='FFTW_MEASURE'))
1347
+ )
1348
+ print(f'k={k} ', end='\r')
1349
+
1350
+ if not is_2d:
1351
+ print("FFT on axis 3")
1352
+ for i in range(0, padded.shape[0]):
1353
+ for j in range(0, padded.shape[1]):
1354
+ f_slice = fft_array[i, j, :]
1355
+ print(f'i={i} ', end='\r')
1356
+ fft_array[i, j, :] = np.real(
1357
+ np.fft.fftshift(
1358
+ pyfftw.interfaces.numpy_fft.ifftn(np.fft.fftshift(f_slice),
1359
+ planner_effort='FFTW_MEASURE')
1360
+ )
1361
+ )
1362
+
1363
+ end = time.time()
1364
+ print("FFT complete.")
1365
+ print('FFT took ' + str(end - start) + ' seconds.')
1366
+
1367
+ H_step = data.nxaxes[0].nxdata[1] - data.nxaxes[0].nxdata[0]
1368
+ K_step = data.nxaxes[1].nxdata[1] - data.nxaxes[1].nxdata[0]
1369
+ L_step = data.nxaxes[2].nxdata[1] - data.nxaxes[2].nxdata[0]
1370
+
1371
+ fft = NXdata(NXfield(fft_array, name='dPDF'),
1372
+ (NXfield(np.linspace(-0.5 / H_step, 0.5 / H_step, padded.shape[0]), name='x'),
1373
+ NXfield(np.linspace(-0.5 / K_step, 0.5 / K_step, padded.shape[1]), name='y'),
1374
+ NXfield(np.linspace(-0.5 / L_step, 0.5 / L_step, padded.shape[2]), name='z'),
1375
+ )
1376
+ )
1377
+ return fft
1378
+
1379
+
1380
+ class DeltaPDF:
1381
+ """
1382
+ A class for processing and analyzing 3D diffraction data using various\
1383
+ operations, including masking, interpolation, padding, and Fourier
1384
+ transformation.
1385
+
1386
+ Attributes
1387
+ ----------
1388
+ fft : NXdata or None
1389
+ The Fourier transformed data.
1390
+ data : NXdata or None
1391
+ The input diffraction data.
1392
+ lattice_params : tuple or None
1393
+ Lattice parameters (a, b, c, al, be, ga).
1394
+ reciprocal_lattice_params : tuple or None
1395
+ Reciprocal lattice parameters (a*, b*, c*, al*, be*, ga*).
1396
+ puncher : Puncher
1397
+ An instance of the Puncher class for generating masks and punching
1398
+ the data.
1399
+ interpolator : Interpolator
1400
+ An instance of the Interpolator class for interpolating and applying
1401
+ windows to the data.
1402
+ padder : Padder
1403
+ An instance of the Padder class for padding the data.
1404
+ mask : ndarray or None
1405
+ The mask used for data processing.
1406
+ kernel : Kernel or None
1407
+ The kernel used for interpolation.
1408
+ window : ndarray or None
1409
+ The window applied to the interpolated data.
1410
+ padded : ndarray or None
1411
+ The padded data.
1412
+ tapered : ndarray or None
1413
+ The data after applying the window.
1414
+ interpolated : NXdata or None
1415
+ The interpolated data.
1416
+ punched : NXdata or None
1417
+ The punched data.
1418
+
1419
+ """
1420
+
1421
+ def __init__(self):
1422
+ """
1423
+ Initialize a DeltaPDF object with default attributes.
1424
+ """
1425
+ self.reciprocal_lattice_params = None
1426
+ self.fft = None
1427
+ self.data = None
1428
+ self.lattice_params = None
1429
+ self.puncher = Puncher()
1430
+ self.interpolator = Interpolator()
1431
+ self.padder = Padder()
1432
+ self.mask = None
1433
+ self.kernel = None
1434
+ self.window = None
1435
+ self.padded = None
1436
+ self.tapered = None
1437
+ self.interpolated = None
1438
+ self.punched = None
1439
+
1440
+ def set_data(self, data):
1441
+ """
1442
+ Set the input diffraction data and update the Puncher and Interpolator
1443
+ with the data.
1444
+
1445
+ Parameters
1446
+ ----------
1447
+ data : NXdata
1448
+ The diffraction data to be processed.
1449
+ """
1450
+ self.data = data
1451
+ self.puncher.set_data(data)
1452
+ self.interpolator.set_data(data)
1453
+ self.padder.set_data(data)
1454
+ self.tapered = data
1455
+ self.padded = data
1456
+ self.interpolated = data
1457
+ self.punched = data
1458
+
1459
+ def set_lattice_params(self, lattice_params):
1460
+ """
1461
+ Sets the lattice parameters and calculates the reciprocal lattice
1462
+ parameters.
1463
+
1464
+ Parameters
1465
+ ----------
1466
+ lattice_params : tuple of float
1467
+ The lattice parameters (a, b, c, alpha, beta, gamma) in real space.
1468
+ """
1469
+ self.lattice_params = lattice_params
1470
+ self.puncher.set_lattice_params(lattice_params)
1471
+ self.reciprocal_lattice_params = self.puncher.reciprocal_lattice_params
1472
+
1473
+ def add_mask(self, maskaddition):
1474
+ """
1475
+ Add regions to the current mask using a logical OR operation.
1476
+
1477
+ Parameters
1478
+ ----------
1479
+ maskaddition : ndarray
1480
+ The mask to be added.
1481
+ """
1482
+ self.puncher.add_mask(maskaddition)
1483
+ self.mask = self.puncher.mask
1484
+
1485
+ def subtract_mask(self, masksubtraction):
1486
+ """
1487
+ Remove regions from the current mask using a logical AND NOT operation.
1488
+
1489
+ Parameters
1490
+ ----------
1491
+ masksubtraction : ndarray
1492
+ The mask to be subtracted.
1493
+ """
1494
+ self.puncher.subtract_mask(masksubtraction)
1495
+ self.mask = self.puncher.mask
1496
+
1497
+ def generate_bragg_mask(self, punch_radius, coeffs=None, thresh=None):
1498
+ """
1499
+ Generate a mask for Bragg peaks.
1500
+
1501
+ Parameters
1502
+ ----------
1503
+ punch_radius : float
1504
+ Radius for the Bragg peak mask.
1505
+ coeffs : list, optional
1506
+ Coefficients for the expression of the sphere to be removed
1507
+ around each Bragg position, corresponding to coefficients
1508
+ for H, HK, K, KL, L, and LH terms. Default is [1, 0, 1, 0, 1, 0].
1509
+ thresh : float, optional
1510
+ Intensity threshold for applying the mask.
1511
+
1512
+ Returns
1513
+ -------
1514
+ mask : ndarray
1515
+ Boolean mask identifying the Bragg peaks.
1516
+ """
1517
+ return self.puncher.generate_bragg_mask(punch_radius, coeffs, thresh)
1518
+
1519
+ def generate_intensity_mask(self, thresh, radius, verbose=True):
1520
+ """
1521
+ Generate a mask based on intensity thresholds.
1522
+
1523
+ Parameters
1524
+ ----------
1525
+ thresh : float
1526
+ Intensity threshold for creating the mask.
1527
+ radius : int
1528
+ Radius around high-intensity points to include in the mask.
1529
+ verbose : bool, optional
1530
+ Whether to print progress information.
1531
+
1532
+ Returns
1533
+ -------
1534
+ mask : ndarray
1535
+ Boolean mask highlighting regions with high intensity.
1536
+ """
1537
+ return self.puncher.generate_intensity_mask(thresh, radius, verbose)
1538
+
1539
+ def generate_mask_at_coord(self, coordinate, punch_radius, coeffs=None, thresh=None):
1540
+ """
1541
+ Generate a mask centered at a specific coordinate.
1542
+
1543
+ Parameters
1544
+ ----------
1545
+ coordinate : tuple of float
1546
+ Center coordinate (H, K, L) for the mask.
1547
+ punch_radius : float
1548
+ Radius for the mask.
1549
+ coeffs : list, optional
1550
+ Coefficients for the expression of the sphere to be removed around
1551
+ each Bragg position, corresponding to coefficients for
1552
+ H, HK, K, KL, L, and LH terms. Default is [1, 0, 1, 0, 1, 0].
1553
+ thresh : float, optional
1554
+ Intensity threshold for applying the mask.
1555
+
1556
+ Returns
1557
+ -------
1558
+ mask : ndarray
1559
+ Boolean mask for the specified coordinate.
1560
+ """
1561
+ return self.puncher.generate_mask_at_coord(coordinate, punch_radius, coeffs, thresh)
1562
+
1563
+ def punch(self):
1564
+ """
1565
+ Apply the mask to the dataset, setting masked regions to NaN.
1566
+
1567
+ This method creates a new dataset where the masked regions are set to
1568
+ NaN, effectively "punching" those regions.
1569
+ """
1570
+ self.puncher.punch()
1571
+ self.punched = self.puncher.punched
1572
+ self.interpolator.set_data(self.punched)
1573
+
1574
+ def set_kernel(self, kernel):
1575
+ """
1576
+ Set the kernel to be used for interpolation.
1577
+
1578
+ Parameters
1579
+ ----------
1580
+ kernel : ndarray
1581
+ The kernel to be used for convolution during interpolation.
1582
+ """
1583
+ self.interpolator.set_kernel(kernel)
1584
+ self.kernel = kernel
1585
+
1586
+ def interpolate(self, verbose=True, positive_values=True):
1587
+ """
1588
+ Perform interpolation on the dataset using the specified kernel.
1589
+
1590
+ This method convolves the dataset with a kernel using `convolve_fft`
1591
+ to perform interpolation. The resulting interpolated data is stored
1592
+ in the `interpolated` attribute.
1593
+
1594
+ Parameters
1595
+ ----------
1596
+ verbose : bool, optional
1597
+ If True, prints progress messages and timing information
1598
+ (default is True).
1599
+ positive_values : bool, optional
1600
+ If True, sets negative interpolated values to zero
1601
+ (default is True).
1602
+
1603
+ Notes
1604
+ -----
1605
+ - The convolution operation is performed in Fourier space.
1606
+ - If a previous interpolation time is recorded, it is displayed
1607
+ before starting a new interpolation.
1608
+
1609
+ Returns
1610
+ -------
1611
+ None
1612
+ """
1613
+ self.interpolator.interpolate(verbose, positive_values)
1614
+ self.interpolated = self.interpolator.interpolated
1615
+
1616
+ def set_tukey_window(self, tukey_alphas=(1.0, 1.0, 1.0)):
1617
+ """
1618
+ Set a Tukey window function for data tapering.
1619
+
1620
+ Parameters
1621
+ ----------
1622
+ tukey_alphas : tuple of floats, optional
1623
+ The alpha parameters for the Tukey window in each dimension
1624
+ (H, K, L). Default is (1.0, 1.0, 1.0).
1625
+
1626
+ Notes
1627
+ -----
1628
+ The window function is generated based on the size of the dataset
1629
+ in each dimension.
1630
+ """
1631
+ self.interpolator.set_tukey_window(tukey_alphas)
1632
+ self.window = self.interpolator.window
1633
+
1634
+ def set_hexagonal_tukey_window(self, tukey_alphas=(1.0, 1.0, 1.0, 1.0)):
1635
+ """
1636
+ Set a hexagonal Tukey window function for data tapering.
1637
+
1638
+ Parameters
1639
+ ----------
1640
+ tukey_alphas : tuple of floats, optional
1641
+ The alpha parameters for the Tukey window in each dimension and
1642
+ for the hexagonal truncation (H, HK, K, L). Default is (1.0, 1.0, 1.0, 1.0).
1643
+
1644
+ Notes
1645
+ -----
1646
+ The hexagonal Tukey window is applied to the dataset in a manner that
1647
+ preserves hexagonal symmetry.
1648
+ """
1649
+ self.interpolator.set_hexagonal_tukey_window(tukey_alphas)
1650
+ self.window = self.interpolator.window
1651
+
1652
+ def set_ellipsoidal_tukey_window(self, tukey_alpha=1.0, coeffs=None):
1653
+ """
1654
+ Set an ellipsoidal Tukey window function for data tapering.
1655
+
1656
+ The Tukey window smoothly tapers the data to zero near the edges of the
1657
+ elliptical region defined by quadratic form coefficients. This helps reduce
1658
+ artifacts in Fourier transforms and other operations sensitive to boundary effects.
1659
+
1660
+ Parameters
1661
+ ----------
1662
+ tukey_alpha : float, optional
1663
+ Tapering parameter for the Tukey window, between 0 and 1.
1664
+ - `tukey_alpha = 0` results in a ellipsoidal window (no tapering).
1665
+ - `tukey_alpha = 1` results in a full cosine taper.
1666
+ Default is 1.0.
1667
+
1668
+ coeffs : tuple of float, optional
1669
+ Coefficients `(c0, c1, c2, c3, c4, c5)` defining the ellipsoidal
1670
+ quadratic form:
1671
+ R^2 = c0*H^2 + c1*H*K + c2*K^2 + c3*K*L + c4*L^2 + c5*L*H
1672
+ If None, coefficients are automatically set to match the edges of the
1673
+ reciprocal space axes (H, K, L), which should be appropriate in cases
1674
+ where H, K, and L are orthogonal.
1675
+
1676
+ Notes
1677
+ -----
1678
+ - The maximum allowed radius `Qmax` is determined from the minimum radius
1679
+ value along the edges of reciprocal space.
1680
+ - The Tukey window is applied radially as a function of the distance `R`
1681
+ from the center, defined by the ellipsoidal quadratic form.
1682
+ """
1683
+ self.interpolator.set_ellipsoidal_tukey_window(tukey_alpha=tukey_alpha, coeffs=coeffs)
1684
+ self.window = self.interpolator.window
1685
+
1686
+
1687
+ def set_window(self, window):
1688
+ """
1689
+ Set a custom window function for data tapering.
1690
+
1691
+ Parameters
1692
+ ----------
1693
+ window : ndarray
1694
+ A custom window function to be applied to the interpolated data.
1695
+ """
1696
+ self.interpolator.set_window(window)
1697
+
1698
+ def apply_window(self):
1699
+ """
1700
+ Apply the window function to the interpolated data.
1701
+
1702
+ The window function, if set, is applied to the `interpolated` data to
1703
+ produce the `tapered` result.
1704
+
1705
+ Returns
1706
+ -------
1707
+ None
1708
+ """
1709
+ self.interpolator.apply_window()
1710
+ self.tapered = self.interpolator.tapered
1711
+ self.padder.set_data(self.tapered)
1712
+
1713
+ def pad(self, padding):
1714
+ """
1715
+ Symmetrically pads the data with zero values.
1716
+
1717
+ Parameters
1718
+ ----------
1719
+ padding : tuple
1720
+ The number of zero-value pixels to add along each edge of the array.
1721
+
1722
+ Returns
1723
+ -------
1724
+ NXdata
1725
+ The padded data with symmetric zero padding.
1726
+ """
1727
+ self.padded = self.padder.pad(padding)
1728
+
1729
+ def perform_fft(self, is_2d=False):
1730
+ """
1731
+ Perform a 3D Fourier Transform on the padded data.
1732
+
1733
+ This method applies an inverse Fourier Transform to the padded data
1734
+ using `pyfftw` for optimized performance. The result is stored in
1735
+ the `fft` attribute as an NXdata object containing the transformed
1736
+ spatial frequency components.
1737
+
1738
+ Parameters
1739
+ ----------
1740
+ is_2d : bool, optional
1741
+ If True, performs the FFT only along the first two axes,
1742
+ skipping the out-of-plane direction (default is False).
1743
+
1744
+ Returns
1745
+ -------
1746
+ None
1747
+
1748
+ Notes
1749
+ -----
1750
+ - Calls `fourier_transform_nxdata` to perform the transformation.
1751
+ - The FFT is computed in two stages: first along the last dimension,
1752
+ then along the first two dimensions.
1753
+ - The output includes frequency components computed from the step
1754
+ sizes of the original data axes.
1755
+
1756
+ """
1757
+
1758
+ self.fft = fourier_transform_nxdata(self.padded, is_2d=is_2d)