nxs-analysis-tools 0.0.47__py3-none-any.whl → 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.

Potentially problematic release.


This version of nxs-analysis-tools might be problematic. Click here for more details.

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