nxs-analysis-tools 0.0.47__py3-none-any.whl → 0.1.2__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,1187 +1,1360 @@
1
- """
2
- Reduces scattering data into 2D and 1D datasets.
3
- """
4
- import os
5
- import numpy as np
6
- import matplotlib.pyplot as plt
7
- from matplotlib.transforms import Affine2D
8
- from matplotlib.markers import MarkerStyle
9
- from matplotlib.ticker import MultipleLocator
10
- from matplotlib import colors
11
- from matplotlib import patches
12
- from IPython.display import display, Markdown
13
- from nexusformat.nexus import NXfield, NXdata, nxload, NeXusError, NXroot, NXentry, nxsave
14
- from scipy import ndimage
15
-
16
- # Specify items on which users are allowed to perform standalone imports
17
- __all__ = ['load_data', 'load_transform', 'plot_slice', 'Scissors',
18
- 'reciprocal_lattice_params', 'rotate_data', 'rotate_data_2D'
19
- 'array_to_nxdata', 'Padder']
20
-
21
-
22
- def load_data(path, print_tree=True):
23
- """
24
- Load data from a NeXus file at a specified path. It is assumed that the data follows the CHESS
25
- file structure (i.e., root/entry/data/counts, etc.).
26
-
27
- Parameters
28
- ----------
29
- path : str
30
- The path to the NeXus data file.
31
-
32
- print_tree : bool, optional
33
- Whether to print the data tree upon loading. Default True.
34
-
35
- Returns
36
- -------
37
- data : nxdata object
38
- The loaded data stored in a nxdata object.
39
-
40
- """
41
-
42
- g = nxload(path)
43
- try:
44
- print(g.entry.data.tree) if print_tree else None
45
- except NeXusError:
46
- pass
47
-
48
- return g.entry.data
49
-
50
-
51
- def load_transform(path, print_tree=True):
52
- """
53
- Load data obtained from nxrefine output from a specified path.
54
-
55
- Parameters
56
- ----------
57
- path : str
58
- The path to the transform data file.
59
-
60
- print_tree : bool, optional
61
- Whether to print the data tree upon loading. Default True.
62
-
63
- Returns
64
- -------
65
- data : nxdata object
66
- The loaded data stored in a nxdata object.
67
- """
68
-
69
- g = nxload(path)
70
-
71
- data = NXdata(NXfield(g.entry.transform.data.nxdata.transpose(2, 1, 0), name='counts'),
72
- (g.entry.transform.Qh, g.entry.transform.Qk, g.entry.transform.Ql))
73
-
74
- print(data.tree) if print_tree else None
75
-
76
- return data
77
-
78
-
79
- def array_to_nxdata(array, data_template, signal_name=None):
80
- """
81
- Create an NXdata object from an input array and an NXdata template,
82
- with an optional signal name.
83
-
84
- Parameters
85
- ----------
86
- array : array-like
87
- The data array to be included in the NXdata object.
88
-
89
- data_template : NXdata
90
- An NXdata object serving as a template, which provides information
91
- about axes and other metadata.
92
-
93
- signal_name : str, optional
94
- The name of the signal within the NXdata object. If not provided,
95
- the signal name is inherited from the data_template.
96
-
97
- Returns
98
- -------
99
- NXdata
100
- An NXdata object containing the input data array and associated axes
101
- based on the template.
102
- """
103
- d = data_template
104
- if signal_name is None:
105
- signal_name = d.signal
106
- return NXdata(NXfield(array, name=signal_name),
107
- tuple(d[d.axes[i]] for i in range(len(d.axes))))
108
-
109
-
110
- def plot_slice(data, X=None, Y=None, sum_axis=None, transpose=False, vmin=None, vmax=None,
111
- skew_angle=90, ax=None, xlim=None, ylim=None,
112
- xticks=None, yticks=None, cbar=True, logscale=False,
113
- symlogscale=False, cmap='viridis', linthresh=1,
114
- title=None, mdheading=None, cbartitle=None,
115
- **kwargs):
116
- """
117
- Plot a 2D slice of the provided dataset, with optional transformations
118
- and customizations.
119
-
120
- Parameters
121
- ----------
122
- data : :class:`nexusformat.nexus.NXdata` or ndarray
123
- The dataset to plot. Can be an `NXdata` object or a `numpy` array.
124
-
125
- X : NXfield, optional
126
- The X axis values. If None, a default range from 0 to the number of
127
- columns in `data` is used.
128
-
129
- Y : NXfield, optional
130
- The Y axis values. If None, a default range from 0 to the number of
131
- rows in `data` is used.
132
-
133
- sum_axis : int, optional
134
- If the input data is 3D, this specifies the axis to sum over in order
135
- to reduce the data to 2D for plotting. Required if `data` has three dimensions.
136
-
137
- transpose : bool, optional
138
- If True, transpose the dataset and its axes before plotting.
139
- Default is False.
140
-
141
- vmin : float, optional
142
- The minimum value for the color scale. If not provided, the minimum
143
- value of the dataset is used.
144
-
145
- vmax : float, optional
146
- The maximum value for the color scale. If not provided, the maximum
147
- value of the dataset is used.
148
-
149
- skew_angle : float, optional
150
- The angle in degrees to shear the plot. Default is 90 degrees (no skew).
151
-
152
- ax : matplotlib.axes.Axes, optional
153
- The `matplotlib` axis to plot on. If None, a new figure and axis will
154
- be created.
155
-
156
- xlim : tuple, optional
157
- The limits for the x-axis. If None, the limits are set automatically
158
- based on the data.
159
-
160
- ylim : tuple, optional
161
- The limits for the y-axis. If None, the limits are set automatically
162
- based on the data.
163
-
164
- xticks : float or list of float, optional
165
- The major tick interval or specific tick locations for the x-axis.
166
- Default is to use a minor tick interval of 1.
167
-
168
- yticks : float or list of float, optional
169
- The major tick interval or specific tick locations for the y-axis.
170
- Default is to use a minor tick interval of 1.
171
-
172
- cbar : bool, optional
173
- Whether to include a colorbar. Default is True.
174
-
175
- logscale : bool, optional
176
- Whether to use a logarithmic color scale. Default is False.
177
-
178
- symlogscale : bool, optional
179
- Whether to use a symmetrical logarithmic color scale. Default is False.
180
-
181
- cmap : str or Colormap, optional
182
- The colormap to use for the plot. Default is 'viridis'.
183
-
184
- linthresh : float, optional
185
- The linear threshold for symmetrical logarithmic scaling. Default is 1.
186
-
187
- title : str, optional
188
- The title for the plot. If None, no title is set.
189
-
190
- mdheading : str, optional
191
- A Markdown heading to display above the plot. If 'None' or not provided,
192
- no heading is displayed.
193
-
194
- cbartitle : str, optional
195
- The title for the colorbar. If None, the colorbar label will be set to
196
- the name of the signal.
197
-
198
- **kwargs
199
- Additional keyword arguments passed to `pcolormesh`.
200
-
201
- Returns
202
- -------
203
- p : :class:`matplotlib.collections.QuadMesh`
204
- The `matplotlib` QuadMesh object representing the plotted data.
205
- """
206
- is_array = False
207
- is_nxdata = False
208
-
209
- if isinstance(data, np.ndarray):
210
- is_array = True
211
- elif isinstance(data, (NXdata, NXfield)):
212
- is_nxdata = True
213
- else:
214
- raise TypeError(f"Unexpected data type: {type(data)}. "
215
- f"Supported types are np.ndarray and NXdata.")
216
-
217
- # If three-dimensional, demand sum_axis to reduce to two dimensions.
218
- if is_array and len(data.shape) == 3:
219
- assert sum_axis is not None, "sum_axis must be specified when data is a 3D array"
220
-
221
- data = data.sum(axis=sum_axis)
222
-
223
- if is_nxdata and len(data.shape) == 3:
224
- assert sum_axis is not None, "sum_axis must be specified when data is a 3D array"
225
-
226
- arr = data.nxsignal.nxdata
227
- arr = arr.sum(axis=sum_axis)
228
-
229
- # Create a 2D template from the original nxdata
230
- slice_obj = [slice(None)] * len(data.shape)
231
- slice_obj[sum_axis] = 0
232
-
233
- # Use the 2D template to create a new nxdata
234
- data = array_to_nxdata(arr, data[slice_obj])
235
-
236
- if is_array:
237
- if X is None:
238
- X = NXfield(np.linspace(0, data.shape[0], data.shape[0]), name='x')
239
- if Y is None:
240
- Y = NXfield(np.linspace(0, data.shape[1], data.shape[1]), name='y')
241
- if transpose:
242
- X, Y = Y, X
243
- data = data.transpose()
244
- data = NXdata(NXfield(data, name='value'), (X, Y))
245
- data_arr = data[data.signal].nxdata.transpose()
246
- elif is_nxdata:
247
- if X is None:
248
- X = data[data.axes[0]]
249
- if Y is None:
250
- Y = data[data.axes[1]]
251
- if transpose:
252
- X, Y = Y, X
253
- data = data.transpose()
254
- data_arr = data[data.signal].nxdata.transpose()
255
-
256
- # Display Markdown heading
257
- if mdheading is None:
258
- pass
259
- elif mdheading == "None":
260
- display(Markdown('### Figure'))
261
- else:
262
- display(Markdown('### Figure - ' + mdheading))
263
-
264
- # Inherit axes if user provides some
265
- if ax is not None:
266
- fig = ax.get_figure()
267
- # Otherwise set up some default axes
268
- else:
269
- fig = plt.figure()
270
- ax = fig.add_axes([0, 0, 1, 1])
271
-
272
- # If limits not provided, use extrema
273
- if vmin is None:
274
- vmin = data_arr.min()
275
- if vmax is None:
276
- vmax = data_arr.max()
277
-
278
- # Set norm (linear scale, logscale, or symlogscale)
279
- norm = colors.Normalize(vmin=vmin, vmax=vmax) # Default: linear scale
280
-
281
- if symlogscale:
282
- norm = colors.SymLogNorm(linthresh=linthresh, vmin=-1 * vmax, vmax=vmax)
283
- elif logscale:
284
- norm = colors.LogNorm(vmin=vmin, vmax=vmax)
285
-
286
- # Plot data
287
- p = ax.pcolormesh(X.nxdata, Y.nxdata, data_arr, shading='auto', norm=norm, cmap=cmap, **kwargs)
288
-
289
- ## Transform data to new coordinate system if necessary
290
- # Correct skew angle
291
- skew_angle_adj = 90 - skew_angle
292
- # Create blank 2D affine transformation
293
- t = Affine2D()
294
- # Scale y-axis to preserve norm while shearing
295
- t += Affine2D().scale(1, np.cos(skew_angle_adj * np.pi / 180))
296
- # Shear along x-axis
297
- t += Affine2D().skew_deg(skew_angle_adj, 0)
298
- # Return to original y-axis scaling
299
- t += Affine2D().scale(1, np.cos(skew_angle_adj * np.pi / 180)).inverted()
300
- ## Correct for x-displacement after shearing
301
- # If ylims provided, use those
302
- if ylim is not None:
303
- # Set ylims
304
- ax.set(ylim=ylim)
305
- ymin, ymax = ylim
306
- # Else, use current ylims
307
- else:
308
- ymin, ymax = ax.get_ylim()
309
- # Use ylims to calculate translation (necessary to display axes in correct position)
310
- p.set_transform(t
311
- + Affine2D().translate(-ymin * np.sin(skew_angle_adj * np.pi / 180), 0)
312
- + ax.transData)
313
-
314
- # Set x limits
315
- if xlim is not None:
316
- xmin, xmax = xlim
317
- else:
318
- xmin, xmax = ax.get_xlim()
319
- if skew_angle <= 90:
320
- ax.set(xlim=(xmin, xmax + (ymax - ymin) / np.tan((90 - skew_angle_adj) * np.pi / 180)))
321
- else:
322
- ax.set(xlim=(xmin - (ymax - ymin) / np.tan((skew_angle_adj - 90) * np.pi / 180), xmax))
323
-
324
- # Correct aspect ratio for the x/y axes after transformation
325
- ax.set(aspect=np.cos(skew_angle_adj * np.pi / 180))
326
-
327
- # Add tick marks all around
328
- ax.tick_params(direction='in', top=True, right=True, which='both')
329
-
330
- # Set tick locations
331
- if xticks is None:
332
- # Add default minor ticks
333
- ax.xaxis.set_minor_locator(MultipleLocator(1))
334
- else:
335
- # Otherwise use user provided values
336
- ax.xaxis.set_major_locator(MultipleLocator(xticks))
337
- ax.xaxis.set_minor_locator(MultipleLocator(1))
338
- if yticks is None:
339
- # Add default minor ticks
340
- ax.yaxis.set_minor_locator(MultipleLocator(1))
341
- else:
342
- # Otherwise use user provided values
343
- ax.yaxis.set_major_locator(MultipleLocator(yticks))
344
- ax.yaxis.set_minor_locator(MultipleLocator(1))
345
-
346
- # Apply transform to tick marks
347
- for i in range(0, len(ax.xaxis.get_ticklines())):
348
- # Tick marker
349
- m = MarkerStyle(3)
350
- line = ax.xaxis.get_majorticklines()[i]
351
- if i % 2:
352
- # Top ticks (translation here makes their direction="in")
353
- m._transform.set(Affine2D().translate(0, -1) + Affine2D().skew_deg(skew_angle_adj, 0))
354
- # This first method shifts the top ticks horizontally to match the skew angle.
355
- # This does not look good in all cases.
356
- # line.set_transform(Affine2D().translate((ymax-ymin)*np.sin(skew_angle*np.pi/180),0) +
357
- # line.get_transform())
358
- # This second method skews the tick marks in place and
359
- # can sometimes lead to them being misaligned.
360
- line.set_transform(line.get_transform()) # This does nothing
361
- else:
362
- # Bottom ticks
363
- m._transform.set(Affine2D().skew_deg(skew_angle_adj, 0))
364
-
365
- line.set_marker(m)
366
-
367
- for i in range(0, len(ax.xaxis.get_minorticklines())):
368
- m = MarkerStyle(2)
369
- line = ax.xaxis.get_minorticklines()[i]
370
- if i % 2:
371
- m._transform.set(Affine2D().translate(0, -1) + Affine2D().skew_deg(skew_angle_adj, 0))
372
- else:
373
- m._transform.set(Affine2D().skew_deg(skew_angle_adj, 0))
374
-
375
- line.set_marker(m)
376
-
377
- if cbar:
378
- colorbar = fig.colorbar(p)
379
- if cbartitle is None:
380
- colorbar.set_label(data.signal)
381
-
382
- ax.set(
383
- xlabel=X.nxname,
384
- ylabel=Y.nxname,
385
- )
386
-
387
- if title is not None:
388
- ax.set_title(title)
389
-
390
- # Return the quadmesh object
391
- return p
392
-
393
-
394
- class Scissors:
395
- """
396
- Scissors class provides functionality for reducing data to a 1D linecut using an integration
397
- window.
398
-
399
- Attributes
400
- ----------
401
- data : :class:`nexusformat.nexus.NXdata` or None
402
- Input :class:`nexusformat.nexus.NXdata`.
403
- center : tuple or None
404
- Central coordinate around which to perform the linecut.
405
- window : tuple or None
406
- Extents of the window for integration along each axis.
407
- axis : int or None
408
- Axis along which to perform the integration.
409
- integration_volume : :class:`nexusformat.nexus.NXdata` or None
410
- Data array after applying the integration window.
411
- integrated_axes : tuple or None
412
- Indices of axes that were integrated.
413
- linecut : :class:`nexusformat.nexus.NXdata` or None
414
- 1D linecut data after integration.
415
- integration_window : tuple or None
416
- Slice object representing the integration window in the data array.
417
-
418
- Methods
419
- -------
420
- set_data(data)
421
- Set the input :class:`nexusformat.nexus.NXdata`.
422
- get_data()
423
- Get the input :class:`nexusformat.nexus.NXdata`.
424
- set_center(center)
425
- Set the central coordinate for the linecut.
426
- set_window(window, axis=None, verbose=False)
427
- Set the extents of the integration window.
428
- get_window()
429
- Get the extents of the integration window.
430
- cut_data(center=None, window=None, axis=None, verbose=False)
431
- Reduce data to a 1D linecut using the integration window.
432
- highlight_integration_window(data=None, label=None, highlight_color='red', **kwargs)
433
- Plot the integration window highlighted on a 2D heatmap of the full dataset.
434
- plot_integration_window(**kwargs)
435
- Plot a 2D heatmap of the integration window data.
436
- """
437
-
438
- def __init__(self, data=None, center=None, window=None, axis=None):
439
- """
440
- Initializes a Scissors object.
441
-
442
- Parameters
443
- ----------
444
- data : :class:`nexusformat.nexus.NXdata` or None, optional
445
- Input NXdata. Default is None.
446
- center : tuple or None, optional
447
- Central coordinate around which to perform the linecut. Default is None.
448
- window : tuple or None, optional
449
- Extents of the window for integration along each axis. Default is None.
450
- axis : int or None, optional
451
- Axis along which to perform the integration. Default is None.
452
- """
453
-
454
- self.data = data
455
- self.center = tuple(float(i) for i in center) if center is not None else None
456
- self.window = tuple(float(i) for i in window) if window is not None else None
457
- self.axis = axis
458
-
459
- self.integration_volume = None
460
- self.integrated_axes = None
461
- self.linecut = None
462
- self.integration_window = None
463
-
464
- def set_data(self, data):
465
- """
466
- Set the input NXdata.
467
-
468
- Parameters
469
- ----------
470
- data : :class:`nexusformat.nexus.NXdata`
471
- Input data array.
472
- """
473
- self.data = data
474
-
475
- def get_data(self):
476
- """
477
- Get the input data array.
478
-
479
- Returns
480
- -------
481
- ndarray or None
482
- Input data array.
483
- """
484
- return self.data
485
-
486
- def set_center(self, center):
487
- """
488
- Set the central coordinate for the linecut.
489
-
490
- Parameters
491
- ----------
492
- center : tuple
493
- Central coordinate around which to perform the linecut.
494
- """
495
- self.center = tuple(float(i) for i in center) if center is not None else None
496
-
497
- def set_window(self, window, axis=None, verbose=False):
498
- """
499
- Set the extents of the integration window.
500
-
501
- Parameters
502
- ----------
503
- window : tuple
504
- Extents of the window for integration along each axis.
505
- axis : int or None, optional
506
- The axis along which to perform the linecut. If not specified, the value from the
507
- object's attribute will be used.
508
- verbose : bool, optional
509
- Enables printout of linecut axis and integrated axes. Default False.
510
-
511
- """
512
- self.window = tuple(float(i) for i in window) if window is not None else None
513
-
514
- # Determine the axis for integration
515
- self.axis = window.index(max(window)) if axis is None else axis
516
-
517
- # Determine the integrated axes (axes other than the integration axis)
518
- self.integrated_axes = tuple(i for i in range(self.data.ndim) if i != self.axis)
519
-
520
- if verbose:
521
- print("Linecut axis: " + str(self.data.axes[self.axis]))
522
- print("Integrated axes: " + str([self.data.axes[axis]
523
- for axis in self.integrated_axes]))
524
-
525
- def get_window(self):
526
- """
527
- Get the extents of the integration window.
528
-
529
- Returns
530
- -------
531
- tuple or None
532
- Extents of the integration window.
533
- """
534
- return self.window
535
-
536
- def cut_data(self, center=None, window=None, axis=None, verbose=False):
537
- """
538
- Reduces data to a 1D linecut with integration extents specified by the
539
- window about a central coordinate.
540
-
541
- Parameters
542
- ----------
543
- center : float or None, optional
544
- Central coordinate for the linecut. If not specified, the value from the object's
545
- attribute will be used.
546
- window : tuple or None, optional
547
- Integration window extents around the central coordinate. If not specified, the value
548
- from the object's attribute will be used.
549
- axis : int or None, optional
550
- The axis along which to perform the linecut. If not specified, the value from the
551
- object's attribute will be used.
552
- verbose : bool
553
- Enables printout of linecut axis and integrated axes. Default False.
554
-
555
- Returns
556
- -------
557
- integrated_data : :class:`nexusformat.nexus.NXdata`
558
- 1D linecut data after integration.
559
-
560
- """
561
-
562
- # Extract necessary attributes from the object
563
- data = self.data
564
- center = center if center is not None else self.center
565
- self.set_center(center)
566
- window = window if window is not None else self.window
567
- self.set_window(window, axis, verbose)
568
-
569
- # Convert the center to a tuple of floats
570
- center = tuple(float(c) for c in center)
571
-
572
- # Calculate the start and stop indices for slicing the data
573
- start = np.subtract(center, window)
574
- stop = np.add(center, window)
575
- slice_obj = tuple(slice(s, e) for s, e in zip(start, stop))
576
- self.integration_window = slice_obj
577
-
578
- # Perform the data cut
579
- self.integration_volume = data[slice_obj]
580
- self.integration_volume.nxname = data.nxname
581
-
582
- # Perform integration along the integrated axes
583
- integrated_data = np.sum(self.integration_volume[self.integration_volume.signal].nxdata,
584
- axis=self.integrated_axes)
585
-
586
- # Create an NXdata object for the linecut data
587
- self.linecut = NXdata(NXfield(integrated_data, name=self.integration_volume.signal),
588
- self.integration_volume[self.integration_volume.axes[self.axis]])
589
- self.linecut.nxname = self.integration_volume.nxname
590
-
591
- return self.linecut
592
-
593
- def highlight_integration_window(self, data=None, width=None, height=None, label=None, highlight_color='red',
594
- **kwargs):
595
- """
596
- Plots the integration window highlighted on the three principal 2D cross-sections of a 3D dataset.
597
-
598
- Parameters
599
- ----------
600
- data : array-like, optional
601
- The 3D dataset to visualize. If not provided, uses `self.data`.
602
- width : float, optional
603
- Width of the visible x-axis range in each subplot. Used to zoom in on the integration region.
604
- height : float, optional
605
- Height of the visible y-axis range in each subplot. Used to zoom in on the integration region.
606
- label : str, optional
607
- Label for the rectangle patch marking the integration window, used in the legend.
608
- highlight_color : str, optional
609
- Color of the rectangle edges highlighting the integration window. Default is 'red'.
610
- **kwargs : dict, optional
611
- Additional keyword arguments passed to `plot_slice` for customizing the plot (e.g., colormap, vmin, vmax).
612
-
613
- Returns
614
- -------
615
- p1, p2, p3 : matplotlib.collections.QuadMesh
616
- The plotted QuadMesh objects for the three cross-sections:
617
- XY at fixed Z, XZ at fixed Y, and YZ at fixed X.
618
-
619
- """
620
- data = self.data if data is None else data
621
- center = self.center
622
- window = self.window
623
-
624
- # Create a figure and subplots
625
- fig, axes = plt.subplots(1, 3, figsize=(15, 4))
626
-
627
- # Plot cross-section 1
628
- slice_obj = [slice(None)] * data.ndim
629
- slice_obj[2] = center[2]
630
-
631
- p1 = plot_slice(data[slice_obj],
632
- X=data[data.axes[0]],
633
- Y=data[data.axes[1]],
634
- ax=axes[0],
635
- **kwargs)
636
- ax = axes[0]
637
- rect_diffuse = patches.Rectangle(
638
- (center[0] - window[0],
639
- center[1] - window[1]),
640
- 2 * window[0], 2 * window[1],
641
- linewidth=1, edgecolor=highlight_color,
642
- facecolor='none', transform=p1.get_transform(), label=label,
643
- )
644
- ax.add_patch(rect_diffuse)
645
-
646
- if 'xlim' not in kwargs and width is not None:
647
- ax.set(xlim=(center[0] - width / 2, center[0] + width / 2))
648
- if 'ylim' not in kwargs and height is not None:
649
- ax.set(ylim=(center[1] - height / 2, center[1] + height / 2))
650
-
651
- # Plot cross-section 2
652
- slice_obj = [slice(None)] * data.ndim
653
- slice_obj[1] = center[1]
654
-
655
- p2 = plot_slice(data[slice_obj],
656
- X=data[data.axes[0]],
657
- Y=data[data.axes[2]],
658
- ax=axes[1],
659
- **kwargs)
660
- ax = axes[1]
661
- rect_diffuse = patches.Rectangle(
662
- (center[0] - window[0],
663
- center[2] - window[2]),
664
- 2 * window[0], 2 * window[2],
665
- linewidth=1, edgecolor=highlight_color,
666
- facecolor='none', transform=p2.get_transform(), label=label,
667
- )
668
- ax.add_patch(rect_diffuse)
669
-
670
- if 'xlim' not in kwargs and width is not None:
671
- ax.set(xlim=(center[0] - width / 2, center[0] + width / 2))
672
- if 'ylim' not in kwargs and height is not None:
673
- ax.set(ylim=(center[2] - height / 2, center[2] + height / 2))
674
-
675
- # Plot cross-section 3
676
- slice_obj = [slice(None)] * data.ndim
677
- slice_obj[0] = center[0]
678
-
679
- p3 = plot_slice(data[slice_obj],
680
- X=data[data.axes[1]],
681
- Y=data[data.axes[2]],
682
- ax=axes[2],
683
- **kwargs)
684
- ax = axes[2]
685
- rect_diffuse = patches.Rectangle(
686
- (center[1] - window[1],
687
- center[2] - window[2]),
688
- 2 * window[1], 2 * window[2],
689
- linewidth=1, edgecolor=highlight_color,
690
- facecolor='none', transform=p3.get_transform(), label=label,
691
- )
692
- ax.add_patch(rect_diffuse)
693
-
694
- if 'xlim' not in kwargs and width is not None:
695
- ax.set(xlim=(center[1] - width / 2, center[1] + width / 2))
696
- if 'ylim' not in kwargs and height is not None:
697
- ax.set(ylim=(center[2] - height / 2, center[2] + height / 2))
698
-
699
- # Adjust subplot padding
700
- fig.subplots_adjust(wspace=0.5)
701
-
702
- if label is not None:
703
- [ax.legend() for ax in axes]
704
-
705
- plt.show()
706
-
707
- return p1, p2, p3
708
-
709
- def plot_integration_window(self, **kwargs):
710
- """
711
- Plots the three principal cross-sections of the integration volume on a single figure.
712
-
713
- Parameters
714
- ----------
715
- **kwargs : keyword arguments, optional
716
- Additional keyword arguments to customize the plot.
717
- """
718
- data = self.integration_volume
719
- center = self.center
720
-
721
- fig, axes = plt.subplots(1, 3, figsize=(15, 4))
722
-
723
- # Plot cross-section 1
724
- slice_obj = [slice(None)] * data.ndim
725
- slice_obj[2] = center[2]
726
- p1 = plot_slice(data[slice_obj],
727
- X=data[data.axes[0]],
728
- Y=data[data.axes[1]],
729
- ax=axes[0],
730
- **kwargs)
731
- axes[0].set_aspect(len(data[data.axes[0]].nxdata) / len(data[data.axes[1]].nxdata))
732
-
733
- # Plot cross section 2
734
- slice_obj = [slice(None)] * data.ndim
735
- slice_obj[1] = center[1]
736
- p3 = plot_slice(data[slice_obj],
737
- X=data[data.axes[0]],
738
- Y=data[data.axes[2]],
739
- ax=axes[1],
740
- **kwargs)
741
- axes[1].set_aspect(len(data[data.axes[0]].nxdata) / len(data[data.axes[2]].nxdata))
742
-
743
- # Plot cross-section 3
744
- slice_obj = [slice(None)] * data.ndim
745
- slice_obj[0] = center[0]
746
- p2 = plot_slice(data[slice_obj],
747
- X=data[data.axes[1]],
748
- Y=data[data.axes[2]],
749
- ax=axes[2],
750
- **kwargs)
751
- axes[2].set_aspect(len(data[data.axes[1]].nxdata) / len(data[data.axes[2]].nxdata))
752
-
753
- # Adjust subplot padding
754
- fig.subplots_adjust(wspace=0.3)
755
-
756
- plt.show()
757
-
758
- return p1, p2, p3
759
-
760
-
761
- def reciprocal_lattice_params(lattice_params):
762
- """
763
- Calculate the reciprocal lattice parameters from the given direct lattice parameters.
764
-
765
- Parameters
766
- ----------
767
- lattice_params : tuple
768
- A tuple containing the direct lattice parameters (a, b, c, alpha, beta, gamma), where
769
- a, b, and c are the magnitudes of the lattice vectors, and alpha, beta, and gamma are the
770
- angles between them in degrees.
771
-
772
- Returns
773
- -------
774
- tuple
775
- A tuple containing the reciprocal lattice parameters (a*, b*, c*, alpha*, beta*, gamma*),
776
- where a*, b*, and c* are the magnitudes of the reciprocal lattice vectors, and alpha*,
777
- beta*, and gamma* are the angles between them in degrees.
778
- """
779
- a_mag, b_mag, c_mag, alpha, beta, gamma = lattice_params
780
- # Convert angles to radians
781
- alpha = np.deg2rad(alpha)
782
- beta = np.deg2rad(beta)
783
- gamma = np.deg2rad(gamma)
784
-
785
- # Calculate unit cell volume
786
- V = a_mag * b_mag * c_mag * np.sqrt(
787
- 1 - np.cos(alpha) ** 2 - np.cos(beta) ** 2 - np.cos(gamma) ** 2
788
- + 2 * np.cos(alpha) * np.cos(beta) * np.cos(gamma)
789
- )
790
-
791
- # Calculate reciprocal lattice parameters
792
- a_star = (b_mag * c_mag * np.sin(alpha)) / V
793
- b_star = (a_mag * c_mag * np.sin(beta)) / V
794
- c_star = (a_mag * b_mag * np.sin(gamma)) / V
795
- alpha_star = np.rad2deg(np.arccos((np.cos(beta) * np.cos(gamma) - np.cos(alpha))
796
- / (np.sin(beta) * np.sin(gamma))))
797
- beta_star = np.rad2deg(np.arccos((np.cos(alpha) * np.cos(gamma) - np.cos(beta))
798
- / (np.sin(alpha) * np.sin(gamma))))
799
- gamma_star = np.rad2deg(np.arccos((np.cos(alpha) * np.cos(beta) - np.cos(gamma))
800
- / (np.sin(alpha) * np.sin(beta))))
801
-
802
- return a_star, b_star, c_star, alpha_star, beta_star, gamma_star
803
-
804
-
805
- def rotate_data(data, lattice_angle, rotation_angle, rotation_axis, printout=False):
806
- """
807
- Rotates 3D data around a specified axis.
808
-
809
- Parameters
810
- ----------
811
- data : :class:`nexusformat.nexus.NXdata`
812
- Input data.
813
- lattice_angle : float
814
- Angle between the two in-plane lattice axes in degrees.
815
- rotation_angle : float
816
- Angle of rotation in degrees.
817
- rotation_axis : int
818
- Axis of rotation (0, 1, or 2).
819
- printout : bool, optional
820
- Enables printout of rotation progress. If set to True, information
821
- about each rotation slice will be printed to the console, indicating
822
- the axis being rotated and the corresponding coordinate value.
823
- Defaults to False.
824
-
825
-
826
- Returns
827
- -------
828
- rotated_data : :class:`nexusformat.nexus.NXdata`
829
- Rotated data as an NXdata object.
830
- """
831
- # Define output array
832
- output_array = np.zeros(data[data.signal].shape)
833
-
834
- # Define transformation
835
- skew_angle_adj = 90 - lattice_angle
836
- t = Affine2D()
837
- # Scale y-axis to preserve norm while shearing
838
- t += Affine2D().scale(1, np.cos(skew_angle_adj * np.pi / 180))
839
- # Shear along x-axis
840
- t += Affine2D().skew_deg(skew_angle_adj, 0)
841
- # Return to original y-axis scaling
842
- t += Affine2D().scale(1, np.cos(skew_angle_adj * np.pi / 180)).inverted()
843
-
844
- for i in range(len(data[data.axes[rotation_axis]])):
845
- if printout:
846
- print(f'\rRotating {data.axes[rotation_axis]}'
847
- f'={data[data.axes[rotation_axis]][i]}... ',
848
- end='', flush=True)
849
- # Identify current slice
850
- if rotation_axis == 0:
851
- sliced_data = data[i, :, :]
852
- elif rotation_axis == 1:
853
- sliced_data = data[:, i, :]
854
- elif rotation_axis == 2:
855
- sliced_data = data[:, :, i]
856
- else:
857
- sliced_data = None
858
-
859
- p = Padder(sliced_data)
860
- padding = tuple(len(sliced_data[axis]) for axis in sliced_data.axes)
861
- counts = p.pad(padding)
862
- counts = p.padded[p.padded.signal]
863
-
864
- counts_skewed = ndimage.affine_transform(counts,
865
- t.inverted().get_matrix()[:2, :2],
866
- offset=[counts.shape[0] / 2
867
- * np.sin(skew_angle_adj * np.pi / 180),
868
- 0],
869
- order=0,
870
- )
871
- scale1 = np.cos(skew_angle_adj * np.pi / 180)
872
- counts_scaled1 = ndimage.affine_transform(counts_skewed,
873
- Affine2D().scale(scale1, 1).get_matrix()[:2, :2],
874
- offset=[(1 - scale1) * counts.shape[0] / 2, 0],
875
- order=0,
876
- )
877
- scale2 = counts.shape[0] / counts.shape[1]
878
- counts_scaled2 = ndimage.affine_transform(counts_scaled1,
879
- Affine2D().scale(scale2, 1).get_matrix()[:2, :2],
880
- offset=[(1 - scale2) * counts.shape[0] / 2, 0],
881
- order=0,
882
- )
883
-
884
- counts_rotated = ndimage.rotate(counts_scaled2, rotation_angle, reshape=False, order=0)
885
-
886
- counts_unscaled2 = ndimage.affine_transform(counts_rotated,
887
- Affine2D().scale(
888
- scale2, 1
889
- ).inverted().get_matrix()[:2, :2],
890
- offset=[-(1 - scale2) * counts.shape[
891
- 0] / 2 / scale2, 0],
892
- order=0,
893
- )
894
-
895
- counts_unscaled1 = ndimage.affine_transform(counts_unscaled2,
896
- Affine2D().scale(
897
- scale1, 1
898
- ).inverted().get_matrix()[:2, :2],
899
- offset=[-(1 - scale1) * counts.shape[
900
- 0] / 2 / scale1, 0],
901
- order=0,
902
- )
903
-
904
- counts_unskewed = ndimage.affine_transform(counts_unscaled1,
905
- t.get_matrix()[:2, :2],
906
- offset=[
907
- (-counts.shape[0] / 2
908
- * np.sin(skew_angle_adj * np.pi / 180)),
909
- 0],
910
- order=0,
911
- )
912
-
913
- counts_unpadded = p.unpad(counts_unskewed)
914
-
915
- # Write current slice
916
- if rotation_axis == 0:
917
- output_array[i, :, :] = counts_unpadded
918
- elif rotation_axis == 1:
919
- output_array[:, i, :] = counts_unpadded
920
- elif rotation_axis == 2:
921
- output_array[:, :, i] = counts_unpadded
922
- print('\nDone.')
923
- return NXdata(NXfield(output_array, name=p.padded.signal),
924
- (data[data.axes[0]], data[data.axes[1]], data[data.axes[2]]))
925
-
926
-
927
- def rotate_data_2D(data, lattice_angle, rotation_angle):
928
- """
929
- Rotates 2D data.
930
-
931
- Parameters
932
- ----------
933
- data : :class:`nexusformat.nexus.NXdata`
934
- Input data.
935
- lattice_angle : float
936
- Angle between the two in-plane lattice axes in degrees.
937
- rotation_angle : float
938
- Angle of rotation in degrees.
939
-
940
-
941
- Returns
942
- -------
943
- rotated_data : :class:`nexusformat.nexus.NXdata`
944
- Rotated data as an NXdata object.
945
- """
946
-
947
- # Define transformation
948
- skew_angle_adj = 90 - lattice_angle
949
- t = Affine2D()
950
- # Scale y-axis to preserve norm while shearing
951
- t += Affine2D().scale(1, np.cos(skew_angle_adj * np.pi / 180))
952
- # Shear along x-axis
953
- t += Affine2D().skew_deg(skew_angle_adj, 0)
954
- # Return to original y-axis scaling
955
- t += Affine2D().scale(1, np.cos(skew_angle_adj * np.pi / 180)).inverted()
956
-
957
- p = Padder(data)
958
- padding = tuple(len(data[axis]) for axis in data.axes)
959
- counts = p.pad(padding)
960
- counts = p.padded[p.padded.signal]
961
-
962
- counts_skewed = ndimage.affine_transform(counts,
963
- t.inverted().get_matrix()[:2, :2],
964
- offset=[counts.shape[0] / 2
965
- * np.sin(skew_angle_adj * np.pi / 180), 0],
966
- order=0,
967
- )
968
- scale1 = np.cos(skew_angle_adj * np.pi / 180)
969
- counts_scaled1 = ndimage.affine_transform(counts_skewed,
970
- Affine2D().scale(scale1, 1).get_matrix()[:2, :2],
971
- offset=[(1 - scale1) * counts.shape[0] / 2, 0],
972
- order=0,
973
- )
974
- scale2 = counts.shape[0] / counts.shape[1]
975
- counts_scaled2 = ndimage.affine_transform(counts_scaled1,
976
- Affine2D().scale(scale2, 1).get_matrix()[:2, :2],
977
- offset=[(1 - scale2) * counts.shape[0] / 2, 0],
978
- order=0,
979
- )
980
-
981
- counts_rotated = ndimage.rotate(counts_scaled2, rotation_angle, reshape=False, order=0)
982
-
983
- counts_unscaled2 = ndimage.affine_transform(counts_rotated,
984
- Affine2D().scale(
985
- scale2, 1
986
- ).inverted().get_matrix()[:2, :2],
987
- offset=[-(1 - scale2) * counts.shape[
988
- 0] / 2 / scale2, 0],
989
- order=0,
990
- )
991
-
992
- counts_unscaled1 = ndimage.affine_transform(counts_unscaled2,
993
- Affine2D().scale(
994
- scale1, 1
995
- ).inverted().get_matrix()[:2, :2],
996
- offset=[-(1 - scale1) * counts.shape[
997
- 0] / 2 / scale1, 0],
998
- order=0,
999
- )
1000
-
1001
- counts_unskewed = ndimage.affine_transform(counts_unscaled1,
1002
- t.get_matrix()[:2, :2],
1003
- offset=[
1004
- (-counts.shape[0] / 2
1005
- * np.sin(skew_angle_adj * np.pi / 180)),
1006
- 0],
1007
- order=0,
1008
- )
1009
-
1010
- counts_unpadded = p.unpad(counts_unskewed)
1011
-
1012
- print('\nDone.')
1013
- return NXdata(NXfield(counts_unpadded, name=p.padded.signal),
1014
- (data[data.axes[0]], data[data.axes[1]]))
1015
-
1016
-
1017
- class Padder:
1018
- """
1019
- A class to symmetrically pad and unpad datasets with a region of zeros.
1020
-
1021
- Attributes
1022
- ----------
1023
- data : NXdata or None
1024
- The input data to be padded.
1025
- padded : NXdata or None
1026
- The padded data with symmetric zero padding.
1027
- padding : tuple or None
1028
- The number of zero-value pixels added along each edge of the array.
1029
- steps : tuple or None
1030
- The step sizes along each axis of the dataset.
1031
- maxes : tuple or None
1032
- The maximum values along each axis of the dataset.
1033
-
1034
- Methods
1035
- -------
1036
- set_data(data)
1037
- Set the input data for padding.
1038
- pad(padding)
1039
- Symmetrically pads the data with zero values.
1040
- save(fout_name=None)
1041
- Saves the padded dataset to a .nxs file.
1042
- unpad(data)
1043
- Removes the padded region from the data.
1044
- """
1045
-
1046
- def __init__(self, data=None):
1047
- """
1048
- Initialize the Padder object.
1049
-
1050
- Parameters
1051
- ----------
1052
- data : NXdata, optional
1053
- The input data to be padded. If provided, the `set_data` method
1054
- is called to set the data.
1055
- """
1056
- self.padded = None
1057
- self.padding = None
1058
- if data is not None:
1059
- self.set_data(data)
1060
-
1061
- def set_data(self, data):
1062
- """
1063
- Set the input data for padding.
1064
-
1065
- Parameters
1066
- ----------
1067
- data : NXdata
1068
- The input data to be padded.
1069
- """
1070
- self.data = data
1071
-
1072
- self.steps = tuple((data[axis].nxdata[1] - data[axis].nxdata[0])
1073
- for axis in data.axes)
1074
-
1075
- # Absolute value of the maximum value; assumes the domain of the input
1076
- # is symmetric (eg, -H_min = H_max)
1077
- self.maxes = tuple(data[axis].nxdata.max() for axis in data.axes)
1078
-
1079
- def pad(self, padding):
1080
- """
1081
- Symmetrically pads the data with zero values.
1082
-
1083
- Parameters
1084
- ----------
1085
- padding : tuple
1086
- The number of zero-value pixels to add along each edge of the array.
1087
-
1088
- Returns
1089
- -------
1090
- NXdata
1091
- The padded data with symmetric zero padding.
1092
- """
1093
- data = self.data
1094
- self.padding = padding
1095
-
1096
- padded_shape = tuple(data[data.signal].nxdata.shape[i]
1097
- + self.padding[i] * 2 for i in range(data.ndim))
1098
-
1099
- # Create padded dataset
1100
- padded = np.zeros(padded_shape)
1101
-
1102
- slice_obj = [slice(None)] * data.ndim
1103
- for i, _ in enumerate(slice_obj):
1104
- slice_obj[i] = slice(self.padding[i], -self.padding[i], None)
1105
- slice_obj = tuple(slice_obj)
1106
- padded[slice_obj] = data[data.signal].nxdata
1107
-
1108
- padmaxes = tuple(self.maxes[i] + self.padding[i] * self.steps[i]
1109
- for i in range(data.ndim))
1110
-
1111
- padded = NXdata(NXfield(padded, name=data.signal),
1112
- tuple(NXfield(np.linspace(-padmaxes[i], padmaxes[i], padded_shape[i]),
1113
- name=data.axes[i])
1114
- for i in range(data.ndim)))
1115
-
1116
- self.padded = padded
1117
- return padded
1118
-
1119
- def save(self, fout_name=None):
1120
- """
1121
- Saves the padded dataset to a .nxs file.
1122
-
1123
- Parameters
1124
- ----------
1125
- fout_name : str, optional
1126
- The output file name. Default is padded_(Hpadding)_(Kpadding)_(Lpadding).nxs
1127
- """
1128
- padH, padK, padL = self.padding
1129
-
1130
- # Save padded dataset
1131
- print("Saving padded dataset...")
1132
- f = NXroot()
1133
- f['entry'] = NXentry()
1134
- f['entry']['data'] = self.padded
1135
- if fout_name is None:
1136
- fout_name = 'padded_' + str(padH) + '_' + str(padK) + '_' + str(padL) + '.nxs'
1137
- nxsave(fout_name, f)
1138
- print("Output file saved to: " + os.path.join(os.getcwd(), fout_name))
1139
-
1140
- def unpad(self, data):
1141
- """
1142
- Removes the padded region from the data.
1143
-
1144
- Parameters
1145
- ----------
1146
- data : ndarray or NXdata
1147
- The padded data from which to remove the padding.
1148
-
1149
- Returns
1150
- -------
1151
- ndarray or NXdata
1152
- The unpadded data, with the symmetric padding region removed.
1153
- """
1154
- slice_obj = [slice(None)] * data.ndim
1155
- for i in range(data.ndim):
1156
- slice_obj[i] = slice(self.padding[i], -self.padding[i], None)
1157
- slice_obj = tuple(slice_obj)
1158
- return data[slice_obj]
1159
-
1160
-
1161
- def load_discus_nxs(path):
1162
- """
1163
- Load .nxs format data from the DISCUS program (by T. Proffen and R. Neder)
1164
- and convert it to the CHESS format.
1165
-
1166
- Parameters
1167
- ----------
1168
- path : str
1169
- The file path to the .nxs file generated by DISCUS.
1170
-
1171
- Returns
1172
- -------
1173
- NXdata
1174
- The data converted to the CHESS format, with axes labeled 'H', 'K', and 'L',
1175
- and the signal labeled 'counts'.
1176
-
1177
- """
1178
- filename = path
1179
- root = nxload(filename)
1180
- hlim, klim, llim = root.lower_limits
1181
- hstep, kstep, lstep = root.step_sizes
1182
- h = NXfield(np.linspace(hlim, -hlim, int(np.abs(hlim * 2) / hstep) + 1), name='H')
1183
- k = NXfield(np.linspace(klim, -klim, int(np.abs(klim * 2) / kstep) + 1), name='K')
1184
- l = NXfield(np.linspace(llim, -llim, int(np.abs(llim * 2) / lstep) + 1), name='L')
1185
- data = NXdata(NXfield(root.data[:, :, :], name='counts'), (h, k, l))
1186
-
1187
- return data
1
+ """
2
+ Reduces scattering data into 2D and 1D datasets.
3
+ """
4
+ import os
5
+ import numpy as np
6
+ import matplotlib.pyplot as plt
7
+ from matplotlib.transforms import Affine2D
8
+ from matplotlib.markers import MarkerStyle
9
+ from matplotlib.ticker import MultipleLocator
10
+ from matplotlib import colors
11
+ from matplotlib import patches
12
+ from IPython.display import display, Markdown
13
+ from nexusformat.nexus import NXfield, NXdata, nxload, NeXusError, NXroot, NXentry, nxsave
14
+ from scipy import ndimage
15
+
16
+ # Specify items on which users are allowed to perform standalone imports
17
+ __all__ = ['load_data', 'load_transform', 'plot_slice', 'Scissors',
18
+ 'reciprocal_lattice_params', 'rotate_data', 'rotate_data_2D',
19
+ 'convert_to_inverse_angstroms', 'array_to_nxdata', 'Padder',
20
+ 'rebin_nxdata', 'rebin_3d', 'rebin_1d']
21
+
22
+
23
+ def load_data(path, print_tree=True):
24
+ """
25
+ Load data from a NeXus file at a specified path. It is assumed that the data follows the CHESS
26
+ file structure (i.e., root/entry/data/counts, etc.).
27
+
28
+ Parameters
29
+ ----------
30
+ path : str
31
+ The path to the NeXus data file.
32
+
33
+ print_tree : bool, optional
34
+ Whether to print the data tree upon loading. Default True.
35
+
36
+ Returns
37
+ -------
38
+ data : nxdata object
39
+ The loaded data stored in a nxdata object.
40
+
41
+ """
42
+
43
+ g = nxload(path)
44
+ try:
45
+ print(g.entry.data.tree) if print_tree else None
46
+ except NeXusError:
47
+ pass
48
+
49
+ return g.entry.data
50
+
51
+
52
+ def load_transform(path, print_tree=True):
53
+ """
54
+ Load transform data from an nxrefine output file.
55
+
56
+ Parameters
57
+ ----------
58
+ path : str
59
+ The path to the transform data file.
60
+
61
+ print_tree : bool, optional
62
+ If True, prints the NeXus data tree upon loading. Default is True.
63
+
64
+ Returns
65
+ -------
66
+ data : NXdata
67
+ The loaded transform data as an NXdata object.
68
+ """
69
+
70
+ g = nxload(path)
71
+
72
+ data = NXdata(NXfield(g.entry.transform.data.nxdata.transpose(2, 1, 0), name='counts'),
73
+ (g.entry.transform.Qh, g.entry.transform.Qk, g.entry.transform.Ql))
74
+
75
+ print(data.tree) if print_tree else None
76
+
77
+ return data
78
+
79
+
80
+ def array_to_nxdata(array, data_template, signal_name=None):
81
+ """
82
+ Create an NXdata object from an input array and an NXdata template,
83
+ with an optional signal name.
84
+
85
+ Parameters
86
+ ----------
87
+ array : array-like
88
+ The data array to be included in the NXdata object.
89
+
90
+ data_template : NXdata
91
+ An NXdata object serving as a template, which provides information
92
+ about axes and other metadata.
93
+
94
+ signal_name : str, optional
95
+ The name of the signal within the NXdata object. If not provided,
96
+ the signal name is inherited from the data_template.
97
+
98
+ Returns
99
+ -------
100
+ NXdata
101
+ An NXdata object containing the input data array and associated axes
102
+ based on the template.
103
+ """
104
+ d = data_template
105
+ if signal_name is None:
106
+ signal_name = d.signal
107
+ return NXdata(NXfield(array, name=signal_name),
108
+ tuple(d[d.axes[i]] for i in range(len(d.axes))))
109
+
110
+
111
+ def rebin_3d(array):
112
+ """
113
+ Rebins a 3D NumPy array by a factor of 2 along each dimension.
114
+
115
+ This function reduces the size of the input array by averaging over non-overlapping
116
+ 2x2x2 blocks. Each dimension of the input array must be divisible by 2.
117
+
118
+ Parameters
119
+ ----------
120
+ array : np.ndarray
121
+ A 3-dimensional NumPy array to be rebinned.
122
+
123
+ Returns
124
+ -------
125
+ np.ndarray
126
+ A rebinned array with shape (N//2, M//2, L//2) if the original shape was (N, M, L).
127
+ """
128
+
129
+ # Ensure the array shape is divisible by 2 in each dimension
130
+ shape = array.shape
131
+ if any(dim % 2 != 0 for dim in shape):
132
+ raise ValueError("Each dimension of the array must be divisible by 2 to rebin.")
133
+
134
+ # Reshape the array to group the data into 2x2x2 blocks
135
+ reshaped = array.reshape(shape[0] // 2, 2, shape[1] // 2, 2, shape[2] // 2, 2)
136
+
137
+ # Average over the 2x2x2 blocks
138
+ rebinned = reshaped.mean(axis=(1, 3, 5))
139
+
140
+ return rebinned
141
+
142
+
143
+ def rebin_1d(array):
144
+ """
145
+ Rebins a 1D NumPy array by a factor of 2.
146
+
147
+ This function reduces the size of the input array by averaging over non-overlapping
148
+ pairs of elements. The input array length must be divisible by 2.
149
+
150
+ Parameters
151
+ ----------
152
+ array : np.ndarray
153
+ A 1-dimensional NumPy array to be rebinned.
154
+
155
+ Returns
156
+ -------
157
+ np.ndarray
158
+ A rebinned array with length N//2 if the original length was N.
159
+ """
160
+
161
+ # Ensure the array length is divisible by 2
162
+ if len(array) % 2 != 0:
163
+ raise ValueError("The length of the array must be divisible by 2 to rebin.")
164
+
165
+ # Reshape the array to group elements into pairs
166
+ reshaped = array.reshape(len(array) // 2, 2)
167
+
168
+ # Average over the pairs
169
+ rebinned = reshaped.mean(axis=1)
170
+
171
+ return rebinned
172
+
173
+
174
+ def rebin_nxdata(data):
175
+ """
176
+ Rebins the signal and axes of an NXdata object by a factor of 2 along each dimension.
177
+
178
+ This function first checks each axis of the input `NXdata` object:
179
+ - If the axis has an odd number of elements, the last element is excluded before rebinning.
180
+ - Then, each axis is rebinned using `rebin_1d`.
181
+
182
+ The signal array is similarly cropped to remove the last element along any dimension
183
+ with an odd shape, and then the data is averaged over 2x2x... blocks using the same
184
+ `rebin_1d` method (assumed to apply across 1D slices).
185
+
186
+ Parameters
187
+ ----------
188
+ data : NXdata
189
+ The NeXus data group containing the signal and axes to be rebinned.
190
+
191
+ Returns
192
+ -------
193
+ NXdata
194
+ A new NXdata object with signal and axes rebinned by a factor of 2 along each dimension.
195
+ """
196
+ # First, rebin axes
197
+ new_axes = []
198
+ for i in range(len(data.shape)):
199
+ if data.shape[i] % 2 == 1:
200
+ new_axes.append(
201
+ NXfield(
202
+ rebin_1d(data.nxaxes[i].nxdata[:-1]),
203
+ name=data.axes[i]
204
+ )
205
+ )
206
+ else:
207
+ new_axes.append(
208
+ NXfield(
209
+ rebin_1d(data.nxaxes[i].nxdata[:]),
210
+ name=data.axes[i]
211
+ )
212
+ )
213
+
214
+ # Second, rebin signal
215
+ data_arr = data.nxsignal.nxdata
216
+
217
+ # Crop the array if the shape is odd in any direction
218
+ slice_obj = []
219
+ for i, dim in enumerate(data_arr.shape):
220
+ if dim % 2 == 1:
221
+ slice_obj.append(slice(0, dim - 1))
222
+ else:
223
+ slice_obj.append(slice(None))
224
+
225
+ data_arr = data_arr[tuple(slice_obj)]
226
+
227
+ # Perform actual rebinning
228
+ data_arr = rebin_3d(data_arr)
229
+
230
+ return NXdata(NXfield(data_arr, name=data.signal),
231
+ tuple([axis for axis in new_axes])
232
+ )
233
+
234
+
235
+ def plot_slice(data, X=None, Y=None, sum_axis=None, transpose=False, vmin=None, vmax=None,
236
+ skew_angle=90, ax=None, xlim=None, ylim=None,
237
+ xticks=None, yticks=None, cbar=True, logscale=False,
238
+ symlogscale=False, cmap='viridis', linthresh=1,
239
+ title=None, mdheading=None, cbartitle=None,
240
+ **kwargs):
241
+ """
242
+ Plot a 2D slice of the provided dataset, with optional transformations
243
+ and customizations.
244
+
245
+ Parameters
246
+ ----------
247
+ data : :class:`nexusformat.nexus.NXdata` or ndarray
248
+ The dataset to plot. Can be an `NXdata` object or a `numpy` array.
249
+
250
+ X : NXfield, optional
251
+ The X axis values. If None, a default range from 0 to the number of
252
+ columns in `data` is used.
253
+
254
+ Y : NXfield, optional
255
+ The Y axis values. If None, a default range from 0 to the number of
256
+ rows in `data` is used.
257
+
258
+ sum_axis : int, optional
259
+ If the input data is 3D, this specifies the axis to sum over in order
260
+ to reduce the data to 2D for plotting. Required if `data` has three dimensions.
261
+
262
+ transpose : bool, optional
263
+ If True, transpose the dataset and its axes before plotting.
264
+ Default is False.
265
+
266
+ vmin : float, optional
267
+ The minimum value for the color scale. If not provided, the minimum
268
+ value of the dataset is used.
269
+
270
+ vmax : float, optional
271
+ The maximum value for the color scale. If not provided, the maximum
272
+ value of the dataset is used.
273
+
274
+ skew_angle : float, optional
275
+ The angle in degrees to shear the plot. Default is 90 degrees (no skew).
276
+
277
+ ax : matplotlib.axes.Axes, optional
278
+ The `matplotlib` axis to plot on. If None, a new figure and axis will
279
+ be created.
280
+
281
+ xlim : tuple, optional
282
+ The limits for the x-axis. If None, the limits are set automatically
283
+ based on the data.
284
+
285
+ ylim : tuple, optional
286
+ The limits for the y-axis. If None, the limits are set automatically
287
+ based on the data.
288
+
289
+ xticks : float or list of float, optional
290
+ The major tick interval or specific tick locations for the x-axis.
291
+ Default is to use a minor tick interval of 1.
292
+
293
+ yticks : float or list of float, optional
294
+ The major tick interval or specific tick locations for the y-axis.
295
+ Default is to use a minor tick interval of 1.
296
+
297
+ cbar : bool, optional
298
+ Whether to include a colorbar. Default is True.
299
+
300
+ logscale : bool, optional
301
+ Whether to use a logarithmic color scale. Default is False.
302
+
303
+ symlogscale : bool, optional
304
+ Whether to use a symmetrical logarithmic color scale. Default is False.
305
+
306
+ cmap : str or Colormap, optional
307
+ The colormap to use for the plot. Default is 'viridis'.
308
+
309
+ linthresh : float, optional
310
+ The linear threshold for symmetrical logarithmic scaling. Default is 1.
311
+
312
+ title : str, optional
313
+ The title for the plot. If None, no title is set.
314
+
315
+ mdheading : str, optional
316
+ A Markdown heading to display above the plot. If 'None' or not provided,
317
+ no heading is displayed.
318
+
319
+ cbartitle : str, optional
320
+ The title for the colorbar. If None, the colorbar label will be set to
321
+ the name of the signal.
322
+
323
+ **kwargs
324
+ Additional keyword arguments passed to `pcolormesh`.
325
+
326
+ Returns
327
+ -------
328
+ p : :class:`matplotlib.collections.QuadMesh`
329
+ The `matplotlib` QuadMesh object representing the plotted data.
330
+ """
331
+ is_array = False
332
+ is_nxdata = False
333
+
334
+ if isinstance(data, np.ndarray):
335
+ is_array = True
336
+ elif isinstance(data, (NXdata, NXfield)):
337
+ is_nxdata = True
338
+ else:
339
+ raise TypeError(f"Unexpected data type: {type(data)}. "
340
+ f"Supported types are np.ndarray and NXdata.")
341
+
342
+ # If three-dimensional, demand sum_axis to reduce to two dimensions.
343
+ if is_array and len(data.shape) == 3:
344
+ assert sum_axis is not None, "sum_axis must be specified when data is a 3D array"
345
+
346
+ data = data.sum(axis=sum_axis)
347
+
348
+ if is_nxdata and len(data.shape) == 3:
349
+ assert sum_axis is not None, "sum_axis must be specified when data is a 3D array"
350
+
351
+ arr = data.nxsignal.nxdata
352
+ arr = arr.sum(axis=sum_axis)
353
+
354
+ # Create a 2D template from the original nxdata
355
+ slice_obj = [slice(None)] * len(data.shape)
356
+ slice_obj[sum_axis] = 0
357
+
358
+ # Use the 2D template to create a new nxdata
359
+ data = array_to_nxdata(arr, data[slice_obj])
360
+
361
+ if is_array:
362
+ if X is None:
363
+ X = NXfield(np.linspace(0, data.shape[0], data.shape[0]), name='x')
364
+ if Y is None:
365
+ Y = NXfield(np.linspace(0, data.shape[1], data.shape[1]), name='y')
366
+ if transpose:
367
+ X, Y = Y, X
368
+ data = data.transpose()
369
+ data = NXdata(NXfield(data, name='value'), (X, Y))
370
+ data_arr = data[data.signal].nxdata.transpose()
371
+ elif is_nxdata:
372
+ if X is None:
373
+ X = data[data.axes[0]]
374
+ if Y is None:
375
+ Y = data[data.axes[1]]
376
+ if transpose:
377
+ X, Y = Y, X
378
+ data = data.transpose()
379
+ data_arr = data[data.signal].nxdata.transpose()
380
+
381
+ # Display Markdown heading
382
+ if mdheading is None:
383
+ pass
384
+ elif mdheading == "None":
385
+ display(Markdown('### Figure'))
386
+ else:
387
+ display(Markdown('### Figure - ' + mdheading))
388
+
389
+ # Inherit axes if user provides some
390
+ if ax is not None:
391
+ fig = ax.get_figure()
392
+ # Otherwise set up some default axes
393
+ else:
394
+ fig = plt.figure()
395
+ ax = fig.add_axes([0, 0, 1, 1])
396
+
397
+ # If limits not provided, use extrema
398
+ if vmin is None:
399
+ vmin = data_arr.min()
400
+ if vmax is None:
401
+ vmax = data_arr.max()
402
+
403
+ # Set norm (linear scale, logscale, or symlogscale)
404
+ norm = colors.Normalize(vmin=vmin, vmax=vmax) # Default: linear scale
405
+
406
+ if symlogscale:
407
+ norm = colors.SymLogNorm(linthresh=linthresh, vmin=-1 * vmax, vmax=vmax)
408
+ elif logscale:
409
+ norm = colors.LogNorm(vmin=vmin, vmax=vmax)
410
+
411
+ # Plot data
412
+ p = ax.pcolormesh(X.nxdata, Y.nxdata, data_arr, shading='auto', norm=norm, cmap=cmap, **kwargs)
413
+
414
+ ## Transform data to new coordinate system if necessary
415
+ # Correct skew angle
416
+ skew_angle_adj = 90 - skew_angle
417
+ # Create blank 2D affine transformation
418
+ t = Affine2D()
419
+ # Scale y-axis to preserve norm while shearing
420
+ t += Affine2D().scale(1, np.cos(skew_angle_adj * np.pi / 180))
421
+ # Shear along x-axis
422
+ t += Affine2D().skew_deg(skew_angle_adj, 0)
423
+ # Return to original y-axis scaling
424
+ t += Affine2D().scale(1, np.cos(skew_angle_adj * np.pi / 180)).inverted()
425
+ ## Correct for x-displacement after shearing
426
+ # If ylims provided, use those
427
+ if ylim is not None:
428
+ # Set ylims
429
+ ax.set(ylim=ylim)
430
+ ymin, ymax = ylim
431
+ # Else, use current ylims
432
+ else:
433
+ ymin, ymax = ax.get_ylim()
434
+ # Use ylims to calculate translation (necessary to display axes in correct position)
435
+ p.set_transform(t
436
+ + Affine2D().translate(-ymin * np.sin(skew_angle_adj * np.pi / 180), 0)
437
+ + ax.transData)
438
+
439
+ # Set x limits
440
+ if xlim is not None:
441
+ xmin, xmax = xlim
442
+ else:
443
+ xmin, xmax = ax.get_xlim()
444
+ if skew_angle <= 90:
445
+ ax.set(xlim=(xmin, xmax + (ymax - ymin) / np.tan((90 - skew_angle_adj) * np.pi / 180)))
446
+ else:
447
+ ax.set(xlim=(xmin - (ymax - ymin) / np.tan((skew_angle_adj - 90) * np.pi / 180), xmax))
448
+
449
+ # Correct aspect ratio for the x/y axes after transformation
450
+ ax.set(aspect=np.cos(skew_angle_adj * np.pi / 180))
451
+
452
+ # Add tick marks all around
453
+ ax.tick_params(direction='in', top=True, right=True, which='both')
454
+
455
+ # Set tick locations
456
+ if xticks is None:
457
+ # Add default minor ticks
458
+ ax.xaxis.set_minor_locator(MultipleLocator(1))
459
+ else:
460
+ # Otherwise use user provided values
461
+ ax.xaxis.set_major_locator(MultipleLocator(xticks))
462
+ ax.xaxis.set_minor_locator(MultipleLocator(1))
463
+ if yticks is None:
464
+ # Add default minor ticks
465
+ ax.yaxis.set_minor_locator(MultipleLocator(1))
466
+ else:
467
+ # Otherwise use user provided values
468
+ ax.yaxis.set_major_locator(MultipleLocator(yticks))
469
+ ax.yaxis.set_minor_locator(MultipleLocator(1))
470
+
471
+ # Apply transform to tick marks
472
+ for i in range(0, len(ax.xaxis.get_ticklines())):
473
+ # Tick marker
474
+ m = MarkerStyle(3)
475
+ line = ax.xaxis.get_majorticklines()[i]
476
+ if i % 2:
477
+ # Top ticks (translation here makes their direction="in")
478
+ m._transform.set(Affine2D().translate(0, -1) + Affine2D().skew_deg(skew_angle_adj, 0))
479
+ # This first method shifts the top ticks horizontally to match the skew angle.
480
+ # This does not look good in all cases.
481
+ # line.set_transform(Affine2D().translate((ymax-ymin)*np.sin(skew_angle*np.pi/180),0) +
482
+ # line.get_transform())
483
+ # This second method skews the tick marks in place and
484
+ # can sometimes lead to them being misaligned.
485
+ line.set_transform(line.get_transform()) # This does nothing
486
+ else:
487
+ # Bottom ticks
488
+ m._transform.set(Affine2D().skew_deg(skew_angle_adj, 0))
489
+
490
+ line.set_marker(m)
491
+
492
+ for i in range(0, len(ax.xaxis.get_minorticklines())):
493
+ m = MarkerStyle(2)
494
+ line = ax.xaxis.get_minorticklines()[i]
495
+ if i % 2:
496
+ m._transform.set(Affine2D().translate(0, -1) + Affine2D().skew_deg(skew_angle_adj, 0))
497
+ else:
498
+ m._transform.set(Affine2D().skew_deg(skew_angle_adj, 0))
499
+
500
+ line.set_marker(m)
501
+
502
+ if cbar:
503
+ colorbar = fig.colorbar(p)
504
+ if cbartitle is None:
505
+ colorbar.set_label(data.signal)
506
+
507
+ ax.set(
508
+ xlabel=X.nxname,
509
+ ylabel=Y.nxname,
510
+ )
511
+
512
+ if title is not None:
513
+ ax.set_title(title)
514
+
515
+ # Return the quadmesh object
516
+ return p
517
+
518
+
519
+ class Scissors:
520
+ """
521
+ Scissors class provides functionality for reducing data to a 1D linecut using an integration
522
+ window.
523
+
524
+ Attributes
525
+ ----------
526
+ data : :class:`nexusformat.nexus.NXdata` or None
527
+ Input :class:`nexusformat.nexus.NXdata`.
528
+ center : tuple or None
529
+ Central coordinate around which to perform the linecut.
530
+ window : tuple or None
531
+ Extents of the window for integration along each axis.
532
+ axis : int or None
533
+ Axis along which to perform the integration.
534
+ integration_volume : :class:`nexusformat.nexus.NXdata` or None
535
+ Data array after applying the integration window.
536
+ integrated_axes : tuple or None
537
+ Indices of axes that were integrated.
538
+ linecut : :class:`nexusformat.nexus.NXdata` or None
539
+ 1D linecut data after integration.
540
+ integration_window : tuple or None
541
+ Slice object representing the integration window in the data array.
542
+
543
+ Methods
544
+ -------
545
+ set_data(data)
546
+ Set the input :class:`nexusformat.nexus.NXdata`.
547
+ get_data()
548
+ Get the input :class:`nexusformat.nexus.NXdata`.
549
+ set_center(center)
550
+ Set the central coordinate for the linecut.
551
+ set_window(window, axis=None, verbose=False)
552
+ Set the extents of the integration window.
553
+ get_window()
554
+ Get the extents of the integration window.
555
+ cut_data(center=None, window=None, axis=None, verbose=False)
556
+ Reduce data to a 1D linecut using the integration window.
557
+ highlight_integration_window(data=None, label=None, highlight_color='red', **kwargs)
558
+ Plot the integration window highlighted on a 2D heatmap of the full dataset.
559
+ plot_integration_window(**kwargs)
560
+ Plot a 2D heatmap of the integration window data.
561
+ """
562
+
563
+ def __init__(self, data=None, center=None, window=None, axis=None):
564
+ """
565
+ Initializes a Scissors object.
566
+
567
+ Parameters
568
+ ----------
569
+ data : :class:`nexusformat.nexus.NXdata` or None, optional
570
+ Input NXdata. Default is None.
571
+ center : tuple or None, optional
572
+ Central coordinate around which to perform the linecut. Default is None.
573
+ window : tuple or None, optional
574
+ Extents of the window for integration along each axis. Default is None.
575
+ axis : int or None, optional
576
+ Axis along which to perform the integration. Default is None.
577
+ """
578
+
579
+ self.data = data
580
+ self.center = tuple(float(i) for i in center) if center is not None else None
581
+ self.window = tuple(float(i) for i in window) if window is not None else None
582
+ self.axis = axis
583
+
584
+ self.integration_volume = None
585
+ self.integrated_axes = None
586
+ self.linecut = None
587
+ self.integration_window = None
588
+
589
+ def set_data(self, data):
590
+ """
591
+ Set the input NXdata.
592
+
593
+ Parameters
594
+ ----------
595
+ data : :class:`nexusformat.nexus.NXdata`
596
+ Input data array.
597
+ """
598
+ self.data = data
599
+
600
+ def get_data(self):
601
+ """
602
+ Get the input data array.
603
+
604
+ Returns
605
+ -------
606
+ ndarray or None
607
+ Input data array.
608
+ """
609
+ return self.data
610
+
611
+ def set_center(self, center):
612
+ """
613
+ Set the central coordinate for the linecut.
614
+
615
+ Parameters
616
+ ----------
617
+ center : tuple
618
+ Central coordinate around which to perform the linecut.
619
+ """
620
+ self.center = tuple(float(i) for i in center) if center is not None else None
621
+
622
+ def set_window(self, window, axis=None, verbose=False):
623
+ """
624
+ Set the extents of the integration window.
625
+
626
+ Parameters
627
+ ----------
628
+ window : tuple
629
+ Extents of the window for integration along each axis.
630
+ axis : int or None, optional
631
+ The axis along which to perform the linecut. If not specified, the value from the
632
+ object's attribute will be used.
633
+ verbose : bool, optional
634
+ Enables printout of linecut axis and integrated axes. Default False.
635
+
636
+ """
637
+ self.window = tuple(float(i) for i in window) if window is not None else None
638
+
639
+ # Determine the axis for integration
640
+ self.axis = window.index(max(window)) if axis is None else axis
641
+
642
+ # Determine the integrated axes (axes other than the integration axis)
643
+ self.integrated_axes = tuple(i for i in range(self.data.ndim) if i != self.axis)
644
+
645
+ if verbose:
646
+ print("Linecut axis: " + str(self.data.axes[self.axis]))
647
+ print("Integrated axes: " + str([self.data.axes[axis]
648
+ for axis in self.integrated_axes]))
649
+
650
+ def get_window(self):
651
+ """
652
+ Get the extents of the integration window.
653
+
654
+ Returns
655
+ -------
656
+ tuple or None
657
+ Extents of the integration window.
658
+ """
659
+ return self.window
660
+
661
+ def cut_data(self, center=None, window=None, axis=None, verbose=False):
662
+ """
663
+ Reduces data to a 1D linecut with integration extents specified by the
664
+ window about a central coordinate.
665
+
666
+ Parameters
667
+ ----------
668
+ center : float or None, optional
669
+ Central coordinate for the linecut. If not specified, the value from the object's
670
+ attribute will be used.
671
+ window : tuple or None, optional
672
+ Integration window extents around the central coordinate. If not specified, the value
673
+ from the object's attribute will be used.
674
+ axis : int or None, optional
675
+ The axis along which to perform the linecut. If not specified, the value from the
676
+ object's attribute will be used.
677
+ verbose : bool
678
+ Enables printout of linecut axis and integrated axes. Default False.
679
+
680
+ Returns
681
+ -------
682
+ integrated_data : :class:`nexusformat.nexus.NXdata`
683
+ 1D linecut data after integration.
684
+
685
+ """
686
+
687
+ # Extract necessary attributes from the object
688
+ data = self.data
689
+ center = center if center is not None else self.center
690
+ self.set_center(center)
691
+ window = window if window is not None else self.window
692
+ self.set_window(window, axis, verbose)
693
+
694
+ # Convert the center to a tuple of floats
695
+ center = tuple(float(c) for c in center)
696
+
697
+ # Calculate the start and stop indices for slicing the data
698
+ start = np.subtract(center, window)
699
+ stop = np.add(center, window)
700
+ slice_obj = tuple(slice(s, e) for s, e in zip(start, stop))
701
+ self.integration_window = slice_obj
702
+
703
+ # Perform the data cut
704
+ self.integration_volume = data[slice_obj]
705
+ self.integration_volume.nxname = data.nxname
706
+
707
+ # Perform integration along the integrated axes
708
+ integrated_data = np.sum(self.integration_volume[self.integration_volume.signal].nxdata,
709
+ axis=self.integrated_axes)
710
+
711
+ # Create an NXdata object for the linecut data
712
+ self.linecut = NXdata(NXfield(integrated_data, name=self.integration_volume.signal),
713
+ self.integration_volume[self.integration_volume.axes[self.axis]])
714
+ self.linecut.nxname = self.integration_volume.nxname
715
+
716
+ return self.linecut
717
+
718
+ def highlight_integration_window(self, data=None, width=None, height=None,
719
+ label=None, highlight_color='red', **kwargs):
720
+ """
721
+ Plots the integration window highlighted on the three principal 2D cross-sections of a 3D dataset.
722
+
723
+ Parameters
724
+ ----------
725
+ data : array-like, optional
726
+ The 3D dataset to visualize. If not provided, uses `self.data`.
727
+ width : float, optional
728
+ Width of the visible x-axis range in each subplot. Used to zoom in on the integration region.
729
+ height : float, optional
730
+ Height of the visible y-axis range in each subplot. Used to zoom in on the integration region.
731
+ label : str, optional
732
+ Label for the rectangle patch marking the integration window, used in the legend.
733
+ highlight_color : str, optional
734
+ Color of the rectangle edges highlighting the integration window. Default is 'red'.
735
+ **kwargs : dict, optional
736
+ Additional keyword arguments passed to `plot_slice` for customizing the plot (e.g., cmap, vmin, vmax).
737
+
738
+ Returns
739
+ -------
740
+ p1, p2, p3 : matplotlib.collections.QuadMesh
741
+ The plotted QuadMesh objects for the three cross-sections:
742
+ XY at fixed Z, XZ at fixed Y, and YZ at fixed X.
743
+
744
+ """
745
+ data = self.data if data is None else data
746
+ center = self.center
747
+ window = self.window
748
+
749
+ # Create a figure and subplots
750
+ fig, axes = plt.subplots(1, 3, figsize=(15, 4))
751
+
752
+ # Plot cross-section 1
753
+ slice_obj = [slice(None)] * data.ndim
754
+ slice_obj[2] = center[2]
755
+
756
+ p1 = plot_slice(data[slice_obj],
757
+ X=data[data.axes[0]],
758
+ Y=data[data.axes[1]],
759
+ ax=axes[0],
760
+ **kwargs)
761
+ ax = axes[0]
762
+ rect_diffuse = patches.Rectangle(
763
+ (center[0] - window[0],
764
+ center[1] - window[1]),
765
+ 2 * window[0], 2 * window[1],
766
+ linewidth=1, edgecolor=highlight_color,
767
+ facecolor='none', transform=p1.get_transform(), label=label,
768
+ )
769
+ ax.add_patch(rect_diffuse)
770
+
771
+ if 'xlim' not in kwargs and width is not None:
772
+ ax.set(xlim=(center[0] - width / 2, center[0] + width / 2))
773
+ if 'ylim' not in kwargs and height is not None:
774
+ ax.set(ylim=(center[1] - height / 2, center[1] + height / 2))
775
+
776
+ # Plot cross-section 2
777
+ slice_obj = [slice(None)] * data.ndim
778
+ slice_obj[1] = center[1]
779
+
780
+ p2 = plot_slice(data[slice_obj],
781
+ X=data[data.axes[0]],
782
+ Y=data[data.axes[2]],
783
+ ax=axes[1],
784
+ **kwargs)
785
+ ax = axes[1]
786
+ rect_diffuse = patches.Rectangle(
787
+ (center[0] - window[0],
788
+ center[2] - window[2]),
789
+ 2 * window[0], 2 * window[2],
790
+ linewidth=1, edgecolor=highlight_color,
791
+ facecolor='none', transform=p2.get_transform(), label=label,
792
+ )
793
+ ax.add_patch(rect_diffuse)
794
+
795
+ if 'xlim' not in kwargs and width is not None:
796
+ ax.set(xlim=(center[0] - width / 2, center[0] + width / 2))
797
+ if 'ylim' not in kwargs and height is not None:
798
+ ax.set(ylim=(center[2] - height / 2, center[2] + height / 2))
799
+
800
+ # Plot cross-section 3
801
+ slice_obj = [slice(None)] * data.ndim
802
+ slice_obj[0] = center[0]
803
+
804
+ p3 = plot_slice(data[slice_obj],
805
+ X=data[data.axes[1]],
806
+ Y=data[data.axes[2]],
807
+ ax=axes[2],
808
+ **kwargs)
809
+ ax = axes[2]
810
+ rect_diffuse = patches.Rectangle(
811
+ (center[1] - window[1],
812
+ center[2] - window[2]),
813
+ 2 * window[1], 2 * window[2],
814
+ linewidth=1, edgecolor=highlight_color,
815
+ facecolor='none', transform=p3.get_transform(), label=label,
816
+ )
817
+ ax.add_patch(rect_diffuse)
818
+
819
+ # If width and height are provided, center the view on the linecut area
820
+ if 'xlim' not in kwargs and width is not None:
821
+ ax.set(xlim=(center[1] - width / 2, center[1] + width / 2))
822
+ if 'ylim' not in kwargs and height is not None:
823
+ ax.set(ylim=(center[2] - height / 2, center[2] + height / 2))
824
+
825
+ # Adjust subplot padding
826
+ fig.subplots_adjust(wspace=0.5)
827
+
828
+ if label is not None:
829
+ [ax.legend() for ax in axes]
830
+
831
+ plt.show()
832
+
833
+ return p1, p2, p3
834
+
835
+ def plot_integration_window(self, **kwargs):
836
+ """
837
+ Plots the three principal cross-sections of the integration volume on a single figure.
838
+
839
+ Parameters
840
+ ----------
841
+ **kwargs : keyword arguments, optional
842
+ Additional keyword arguments to customize the plot.
843
+ """
844
+ data = self.integration_volume
845
+ center = self.center
846
+
847
+ fig, axes = plt.subplots(1, 3, figsize=(15, 4))
848
+
849
+ # Plot cross-section 1
850
+ slice_obj = [slice(None)] * data.ndim
851
+ slice_obj[2] = center[2]
852
+ p1 = plot_slice(data[slice_obj],
853
+ X=data[data.axes[0]],
854
+ Y=data[data.axes[1]],
855
+ ax=axes[0],
856
+ **kwargs)
857
+ axes[0].set_aspect(len(data[data.axes[0]].nxdata) / len(data[data.axes[1]].nxdata))
858
+
859
+ # Plot cross section 2
860
+ slice_obj = [slice(None)] * data.ndim
861
+ slice_obj[1] = center[1]
862
+ p3 = plot_slice(data[slice_obj],
863
+ X=data[data.axes[0]],
864
+ Y=data[data.axes[2]],
865
+ ax=axes[1],
866
+ **kwargs)
867
+ axes[1].set_aspect(len(data[data.axes[0]].nxdata) / len(data[data.axes[2]].nxdata))
868
+
869
+ # Plot cross-section 3
870
+ slice_obj = [slice(None)] * data.ndim
871
+ slice_obj[0] = center[0]
872
+ p2 = plot_slice(data[slice_obj],
873
+ X=data[data.axes[1]],
874
+ Y=data[data.axes[2]],
875
+ ax=axes[2],
876
+ **kwargs)
877
+ axes[2].set_aspect(len(data[data.axes[1]].nxdata) / len(data[data.axes[2]].nxdata))
878
+
879
+ # Adjust subplot padding
880
+ fig.subplots_adjust(wspace=0.3)
881
+
882
+ plt.show()
883
+
884
+ return p1, p2, p3
885
+
886
+
887
+ def reciprocal_lattice_params(lattice_params):
888
+ """
889
+ Calculate the reciprocal lattice parameters from the given direct lattice parameters.
890
+
891
+ Parameters
892
+ ----------
893
+ lattice_params : tuple
894
+ A tuple containing the direct lattice parameters (a, b, c, alpha, beta, gamma), where
895
+ a, b, and c are the magnitudes of the lattice vectors, and alpha, beta, and gamma are the
896
+ angles between them in degrees.
897
+
898
+ Returns
899
+ -------
900
+ tuple
901
+ A tuple containing the reciprocal lattice parameters (a*, b*, c*, alpha*, beta*, gamma*),
902
+ where a*, b*, and c* are the magnitudes of the reciprocal lattice vectors, and alpha*,
903
+ beta*, and gamma* are the angles between them in degrees.
904
+ """
905
+ a_mag, b_mag, c_mag, alpha, beta, gamma = lattice_params
906
+ # Convert angles to radians
907
+ alpha = np.deg2rad(alpha)
908
+ beta = np.deg2rad(beta)
909
+ gamma = np.deg2rad(gamma)
910
+
911
+ # Calculate unit cell volume
912
+ V = a_mag * b_mag * c_mag * np.sqrt(
913
+ 1 - np.cos(alpha) ** 2 - np.cos(beta) ** 2 - np.cos(gamma) ** 2
914
+ + 2 * np.cos(alpha) * np.cos(beta) * np.cos(gamma)
915
+ )
916
+
917
+ # Calculate reciprocal lattice parameters
918
+ a_star = (b_mag * c_mag * np.sin(alpha)) / V
919
+ b_star = (a_mag * c_mag * np.sin(beta)) / V
920
+ c_star = (a_mag * b_mag * np.sin(gamma)) / V
921
+ alpha_star = np.rad2deg(np.arccos((np.cos(beta) * np.cos(gamma) - np.cos(alpha))
922
+ / (np.sin(beta) * np.sin(gamma))))
923
+ beta_star = np.rad2deg(np.arccos((np.cos(alpha) * np.cos(gamma) - np.cos(beta))
924
+ / (np.sin(alpha) * np.sin(gamma))))
925
+ gamma_star = np.rad2deg(np.arccos((np.cos(alpha) * np.cos(beta) - np.cos(gamma))
926
+ / (np.sin(alpha) * np.sin(beta))))
927
+
928
+ return a_star, b_star, c_star, alpha_star, beta_star, gamma_star
929
+
930
+
931
+ def convert_to_inverse_angstroms(data, lattice_params):
932
+ """
933
+ Convert the axes of a 3D NXdata object from reciprocal lattice units (r.l.u.)
934
+ to inverse angstroms using provided lattice parameters.
935
+
936
+ Parameters
937
+ ----------
938
+ data : :class:`nexusformat.nexus.NXdata`
939
+ A 3D NXdata object with axes in reciprocal lattice units.
940
+
941
+ lattice_params : tuple of float
942
+ A tuple containing the real-space lattice parameters
943
+ (a, b, c, alpha, beta, gamma) in angstroms and degrees.
944
+
945
+ Returns
946
+ -------
947
+ NXdata
948
+ A new NXdata object with axes scaled to inverse angstroms.
949
+ """
950
+
951
+ a_, b_, c_, al_, be_, ga_ = reciprocal_lattice_params(lattice_params)
952
+
953
+ new_data = data.nxsignal
954
+ a_star = NXfield(data.nxaxes[0].nxdata * a_, name='a_star')
955
+ b_star = NXfield(data.nxaxes[1].nxdata * b_, name='b_star')
956
+ c_star = NXfield(data.nxaxes[2].nxdata * c_, name='c_star')
957
+
958
+ return NXdata(new_data, (a_star, b_star, c_star))
959
+
960
+
961
+ def rotate_data(data, lattice_angle, rotation_angle, rotation_axis, printout=False):
962
+ """
963
+ Rotates 3D data around a specified axis.
964
+
965
+ Parameters
966
+ ----------
967
+ data : :class:`nexusformat.nexus.NXdata`
968
+ Input data.
969
+ lattice_angle : float
970
+ Angle between the two in-plane lattice axes in degrees.
971
+ rotation_angle : float
972
+ Angle of rotation in degrees.
973
+ rotation_axis : int
974
+ Axis of rotation (0, 1, or 2).
975
+ printout : bool, optional
976
+ Enables printout of rotation progress. If set to True, information
977
+ about each rotation slice will be printed to the console, indicating
978
+ the axis being rotated and the corresponding coordinate value.
979
+ Defaults to False.
980
+
981
+
982
+ Returns
983
+ -------
984
+ rotated_data : :class:`nexusformat.nexus.NXdata`
985
+ Rotated data as an NXdata object.
986
+ """
987
+ # Define output array
988
+ output_array = np.zeros(data[data.signal].shape)
989
+
990
+ # Define shear transformation
991
+ skew_angle_adj = 90 - lattice_angle
992
+ t = Affine2D()
993
+ # Scale y-axis to preserve norm while shearing
994
+ t += Affine2D().scale(1, np.cos(skew_angle_adj * np.pi / 180))
995
+ # Shear along x-axis
996
+ t += Affine2D().skew_deg(skew_angle_adj, 0)
997
+ # Return to original y-axis scaling
998
+ t += Affine2D().scale(1, np.cos(skew_angle_adj * np.pi / 180)).inverted()
999
+
1000
+ # Iterate over all layers perpendicular to the rotation axis
1001
+ for i in range(len(data[data.axes[rotation_axis]])):
1002
+ # Print progress
1003
+ if printout:
1004
+ print(f'\rRotating {data.axes[rotation_axis]}'
1005
+ f'={data[data.axes[rotation_axis]][i]}... ',
1006
+ end='', flush=True)
1007
+
1008
+ # Identify current slice
1009
+ if rotation_axis == 0:
1010
+ sliced_data = data[i, :, :]
1011
+ elif rotation_axis == 1:
1012
+ sliced_data = data[:, i, :]
1013
+ elif rotation_axis == 2:
1014
+ sliced_data = data[:, :, i]
1015
+ else:
1016
+ sliced_data = None
1017
+
1018
+ # Add padding to avoid data cutoff during rotation
1019
+ p = Padder(sliced_data)
1020
+ padding = tuple(len(sliced_data[axis]) for axis in sliced_data.axes)
1021
+ counts = p.pad(padding)
1022
+ counts = p.padded[p.padded.signal]
1023
+
1024
+ # Perform shear operation
1025
+ counts_skewed = ndimage.affine_transform(counts,
1026
+ t.inverted().get_matrix()[:2, :2],
1027
+ offset=[counts.shape[0] / 2
1028
+ * np.sin(skew_angle_adj * np.pi / 180),
1029
+ 0],
1030
+ order=0,
1031
+ )
1032
+ # Scale data based on skew angle
1033
+ scale1 = np.cos(skew_angle_adj * np.pi / 180)
1034
+ counts_scaled1 = ndimage.affine_transform(counts_skewed,
1035
+ Affine2D().scale(scale1, 1).get_matrix()[:2, :2],
1036
+ offset=[(1 - scale1) * counts.shape[0] / 2, 0],
1037
+ order=0,
1038
+ )
1039
+ # Scale data based on ratio of array dimensions
1040
+ scale2 = counts.shape[0] / counts.shape[1]
1041
+ counts_scaled2 = ndimage.affine_transform(counts_scaled1,
1042
+ Affine2D().scale(scale2, 1).get_matrix()[:2, :2],
1043
+ offset=[(1 - scale2) * counts.shape[0] / 2, 0],
1044
+ order=0,
1045
+ )
1046
+
1047
+ # Perform rotation
1048
+ counts_rotated = ndimage.rotate(counts_scaled2, rotation_angle, reshape=False, order=0)
1049
+
1050
+ # Undo scaling 2
1051
+ counts_unscaled2 = ndimage.affine_transform(counts_rotated,
1052
+ Affine2D().scale(
1053
+ scale2, 1
1054
+ ).inverted().get_matrix()[:2, :2],
1055
+ offset=[-(1 - scale2) * counts.shape[
1056
+ 0] / 2 / scale2, 0],
1057
+ order=0,
1058
+ )
1059
+ # Undo scaling 1
1060
+ counts_unscaled1 = ndimage.affine_transform(counts_unscaled2,
1061
+ Affine2D().scale(
1062
+ scale1, 1
1063
+ ).inverted().get_matrix()[:2, :2],
1064
+ offset=[-(1 - scale1) * counts.shape[
1065
+ 0] / 2 / scale1, 0],
1066
+ order=0,
1067
+ )
1068
+ # Undo shear operation
1069
+ counts_unskewed = ndimage.affine_transform(counts_unscaled1,
1070
+ t.get_matrix()[:2, :2],
1071
+ offset=[
1072
+ (-counts.shape[0] / 2
1073
+ * np.sin(skew_angle_adj * np.pi / 180)),
1074
+ 0],
1075
+ order=0,
1076
+ )
1077
+ # Remove padding
1078
+ counts_unpadded = p.unpad(counts_unskewed)
1079
+
1080
+ # Write current slice
1081
+ if rotation_axis == 0:
1082
+ output_array[i, :, :] = counts_unpadded
1083
+ elif rotation_axis == 1:
1084
+ output_array[:, i, :] = counts_unpadded
1085
+ elif rotation_axis == 2:
1086
+ output_array[:, :, i] = counts_unpadded
1087
+ print('\nDone.')
1088
+ return NXdata(NXfield(output_array, name=p.padded.signal),
1089
+ (data[data.axes[0]], data[data.axes[1]], data[data.axes[2]]))
1090
+
1091
+
1092
+ def rotate_data_2D(data, lattice_angle, rotation_angle):
1093
+ """
1094
+ Rotates 2D data.
1095
+
1096
+ Parameters
1097
+ ----------
1098
+ data : :class:`nexusformat.nexus.NXdata`
1099
+ Input data.
1100
+ lattice_angle : float
1101
+ Angle between the two in-plane lattice axes in degrees.
1102
+ rotation_angle : float
1103
+ Angle of rotation in degrees.
1104
+
1105
+
1106
+ Returns
1107
+ -------
1108
+ rotated_data : :class:`nexusformat.nexus.NXdata`
1109
+ Rotated data as an NXdata object.
1110
+ """
1111
+
1112
+ # Define transformation
1113
+ skew_angle_adj = 90 - lattice_angle
1114
+ t = Affine2D()
1115
+ # Scale y-axis to preserve norm while shearing
1116
+ t += Affine2D().scale(1, np.cos(skew_angle_adj * np.pi / 180))
1117
+ # Shear along x-axis
1118
+ t += Affine2D().skew_deg(skew_angle_adj, 0)
1119
+ # Return to original y-axis scaling
1120
+ t += Affine2D().scale(1, np.cos(skew_angle_adj * np.pi / 180)).inverted()
1121
+
1122
+ # Add padding to avoid data cutoff during rotation
1123
+ p = Padder(data)
1124
+ padding = tuple(len(data[axis]) for axis in data.axes)
1125
+ counts = p.pad(padding)
1126
+ counts = p.padded[p.padded.signal]
1127
+
1128
+ # Perform shear operation
1129
+ counts_skewed = ndimage.affine_transform(counts,
1130
+ t.inverted().get_matrix()[:2, :2],
1131
+ offset=[counts.shape[0] / 2
1132
+ * np.sin(skew_angle_adj * np.pi / 180), 0],
1133
+ order=0,
1134
+ )
1135
+ # Scale data based on skew angle
1136
+ scale1 = np.cos(skew_angle_adj * np.pi / 180)
1137
+ counts_scaled1 = ndimage.affine_transform(counts_skewed,
1138
+ Affine2D().scale(scale1, 1).get_matrix()[:2, :2],
1139
+ offset=[(1 - scale1) * counts.shape[0] / 2, 0],
1140
+ order=0,
1141
+ )
1142
+ # Scale data based on ratio of array dimensions
1143
+ scale2 = counts.shape[0] / counts.shape[1]
1144
+ counts_scaled2 = ndimage.affine_transform(counts_scaled1,
1145
+ Affine2D().scale(scale2, 1).get_matrix()[:2, :2],
1146
+ offset=[(1 - scale2) * counts.shape[0] / 2, 0],
1147
+ order=0,
1148
+ )
1149
+ # Perform rotation
1150
+ counts_rotated = ndimage.rotate(counts_scaled2, rotation_angle, reshape=False, order=0)
1151
+
1152
+ # Undo scaling 2
1153
+ counts_unscaled2 = ndimage.affine_transform(counts_rotated,
1154
+ Affine2D().scale(
1155
+ scale2, 1
1156
+ ).inverted().get_matrix()[:2, :2],
1157
+ offset=[-(1 - scale2) * counts.shape[
1158
+ 0] / 2 / scale2, 0],
1159
+ order=0,
1160
+ )
1161
+ # Undo scaling 1
1162
+ counts_unscaled1 = ndimage.affine_transform(counts_unscaled2,
1163
+ Affine2D().scale(
1164
+ scale1, 1
1165
+ ).inverted().get_matrix()[:2, :2],
1166
+ offset=[-(1 - scale1) * counts.shape[
1167
+ 0] / 2 / scale1, 0],
1168
+ order=0,
1169
+ )
1170
+ # Undo shear operation
1171
+ counts_unskewed = ndimage.affine_transform(counts_unscaled1,
1172
+ t.get_matrix()[:2, :2],
1173
+ offset=[
1174
+ (-counts.shape[0] / 2
1175
+ * np.sin(skew_angle_adj * np.pi / 180)),
1176
+ 0],
1177
+ order=0,
1178
+ )
1179
+ # Remove padding
1180
+ counts_unpadded = p.unpad(counts_unskewed)
1181
+
1182
+ print('\nDone.')
1183
+ return NXdata(NXfield(counts_unpadded, name=p.padded.signal),
1184
+ (data[data.axes[0]], data[data.axes[1]]))
1185
+
1186
+
1187
+ class Padder:
1188
+ """
1189
+ A class to symmetrically pad and unpad datasets with a region of zeros.
1190
+
1191
+ Attributes
1192
+ ----------
1193
+ data : NXdata or None
1194
+ The input data to be padded.
1195
+ padded : NXdata or None
1196
+ The padded data with symmetric zero padding.
1197
+ padding : tuple or None
1198
+ The number of zero-value pixels added along each edge of the array.
1199
+ steps : tuple or None
1200
+ The step sizes along each axis of the dataset.
1201
+ maxes : tuple or None
1202
+ The maximum values along each axis of the dataset.
1203
+
1204
+ Methods
1205
+ -------
1206
+ set_data(data)
1207
+ Set the input data for padding.
1208
+ pad(padding)
1209
+ Symmetrically pads the data with zero values.
1210
+ save(fout_name=None)
1211
+ Saves the padded dataset to a .nxs file.
1212
+ unpad(data)
1213
+ Removes the padded region from the data.
1214
+ """
1215
+
1216
+ def __init__(self, data=None):
1217
+ """
1218
+ Initialize the Padder object.
1219
+
1220
+ Parameters
1221
+ ----------
1222
+ data : NXdata, optional
1223
+ The input data to be padded. If provided, the `set_data` method
1224
+ is called to set the data.
1225
+ """
1226
+ self.maxes = None
1227
+ self.steps = None
1228
+ self.data = None
1229
+ self.padded = None
1230
+ self.padding = None
1231
+ if data is not None:
1232
+ self.set_data(data)
1233
+
1234
+ def set_data(self, data):
1235
+ """
1236
+ Set the input data for padding.
1237
+
1238
+ Parameters
1239
+ ----------
1240
+ data : NXdata
1241
+ The input data to be padded.
1242
+ """
1243
+ self.data = data
1244
+
1245
+ self.steps = tuple((data[axis].nxdata[1] - data[axis].nxdata[0])
1246
+ for axis in data.axes)
1247
+
1248
+ # Absolute value of the maximum value; assumes the domain of the input
1249
+ # is symmetric (eg, -H_min = H_max)
1250
+ self.maxes = tuple(data[axis].nxdata.max() for axis in data.axes)
1251
+
1252
+ def pad(self, padding):
1253
+ """
1254
+ Symmetrically pads the data with zero values.
1255
+
1256
+ Parameters
1257
+ ----------
1258
+ padding : tuple
1259
+ The number of zero-value pixels to add along each edge of the array.
1260
+
1261
+ Returns
1262
+ -------
1263
+ NXdata
1264
+ The padded data with symmetric zero padding.
1265
+ """
1266
+ data = self.data
1267
+ self.padding = padding
1268
+
1269
+ padded_shape = tuple(data[data.signal].nxdata.shape[i]
1270
+ + self.padding[i] * 2 for i in range(data.ndim))
1271
+
1272
+ # Create padded dataset
1273
+ padded = np.zeros(padded_shape)
1274
+
1275
+ slice_obj = [slice(None)] * data.ndim
1276
+ for i, _ in enumerate(slice_obj):
1277
+ slice_obj[i] = slice(self.padding[i], -self.padding[i], None)
1278
+ slice_obj = tuple(slice_obj)
1279
+ padded[slice_obj] = data[data.signal].nxdata
1280
+
1281
+ padmaxes = tuple(self.maxes[i] + self.padding[i] * self.steps[i]
1282
+ for i in range(data.ndim))
1283
+
1284
+ padded = NXdata(NXfield(padded, name=data.signal),
1285
+ tuple(NXfield(np.linspace(-padmaxes[i], padmaxes[i], padded_shape[i]),
1286
+ name=data.axes[i])
1287
+ for i in range(data.ndim)))
1288
+
1289
+ self.padded = padded
1290
+ return padded
1291
+
1292
+ def save(self, fout_name=None):
1293
+ """
1294
+ Saves the padded dataset to a .nxs file.
1295
+
1296
+ Parameters
1297
+ ----------
1298
+ fout_name : str, optional
1299
+ The output file name. Default is padded_(Hpadding)_(Kpadding)_(Lpadding).nxs
1300
+ """
1301
+ padH, padK, padL = self.padding
1302
+
1303
+ # Save padded dataset
1304
+ print("Saving padded dataset...")
1305
+ f = NXroot()
1306
+ f['entry'] = NXentry()
1307
+ f['entry']['data'] = self.padded
1308
+ if fout_name is None:
1309
+ fout_name = 'padded_' + str(padH) + '_' + str(padK) + '_' + str(padL) + '.nxs'
1310
+ nxsave(fout_name, f)
1311
+ print("Output file saved to: " + os.path.join(os.getcwd(), fout_name))
1312
+
1313
+ def unpad(self, data):
1314
+ """
1315
+ Removes the padded region from the data.
1316
+
1317
+ Parameters
1318
+ ----------
1319
+ data : ndarray or NXdata
1320
+ The padded data from which to remove the padding.
1321
+
1322
+ Returns
1323
+ -------
1324
+ ndarray or NXdata
1325
+ The unpadded data, with the symmetric padding region removed.
1326
+ """
1327
+ slice_obj = [slice(None)] * data.ndim
1328
+ for i in range(data.ndim):
1329
+ slice_obj[i] = slice(self.padding[i], -self.padding[i], None)
1330
+ slice_obj = tuple(slice_obj)
1331
+ return data[slice_obj]
1332
+
1333
+
1334
+ def load_discus_nxs(path):
1335
+ """
1336
+ Load .nxs format data from the DISCUS program (by T. Proffen and R. Neder)
1337
+ and convert it to the CHESS format.
1338
+
1339
+ Parameters
1340
+ ----------
1341
+ path : str
1342
+ The file path to the .nxs file generated by DISCUS.
1343
+
1344
+ Returns
1345
+ -------
1346
+ NXdata
1347
+ The data converted to the CHESS format, with axes labeled 'H', 'K', and 'L',
1348
+ and the signal labeled 'counts'.
1349
+
1350
+ """
1351
+ filename = path
1352
+ root = nxload(filename)
1353
+ hlim, klim, llim = root.lower_limits
1354
+ hstep, kstep, lstep = root.step_sizes
1355
+ h = NXfield(np.linspace(hlim, -hlim, int(np.abs(hlim * 2) / hstep) + 1), name='H')
1356
+ k = NXfield(np.linspace(klim, -klim, int(np.abs(klim * 2) / kstep) + 1), name='K')
1357
+ l = NXfield(np.linspace(llim, -llim, int(np.abs(llim * 2) / lstep) + 1), name='L')
1358
+ data = NXdata(NXfield(root.data[:, :, :], name='counts'), (h, k, l))
1359
+
1360
+ return data