Skip to content

Commit a18d3f8

Browse files
committed
Refactor H5Dialog to handle file paths and populate tree structure; add io module for HDF5 file handling; implement ReadWorker for threaded data reading; enhance MainWindow to load intensity data asynchronously; create user-defined file parser template.
1 parent 2f96ddd commit a18d3f8

7 files changed

Lines changed: 554 additions & 343 deletions

File tree

plaid/data_containers.py

Lines changed: 25 additions & 236 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,10 @@
1212
import numpy as np
1313
from PyQt6.QtWidgets import QInputDialog, QMessageBox
1414
import h5py as h5
15-
from plaid.nexus import (get_nx_default, get_nx_signal, get_nx_signal_errors, get_nx_axes,
16-
get_nx_energy, get_nx_monitor, get_instrument_name, get_source_name,
17-
get_nx_sample, get_nx_transformations, get_translations_from_nx_transformations)
15+
from plaid.nexus import (get_nx_monitor, get_nx_sample, get_nx_transformations,
16+
get_translations_from_nx_transformations)
1817
from plaid.misc import q_to_tth, tth_to_q, get_map_shape_and_indices, average_blocks
19-
from plaid.dialogs import H5Dialog
20-
21-
22-
18+
from plaid.io import export_xy
2319

2420
class AzintData():
2521
"""
@@ -30,7 +26,6 @@ class AzintData():
3026
Attributes:
3127
- x: The radial axis data (2theta or q).
3228
- I: The intensity data.
33-
- y_avg: The average intensity data.
3429
- is_q: A boolean indicating if the radial axis is in q or 2theta.
3530
- E: The energy data, if available.
3631
- I0: The I0 data, if available.
@@ -47,7 +42,7 @@ def __init__(self, parent=None,fnames=None):
4742
self.x = None
4843
self.I = None
4944
self.I_error = None
50-
self.y_avg = None
45+
#self.y_avg = None
5146
self.is_q = False
5247
self.E = None
5348
self.I0 = None
@@ -64,84 +59,16 @@ def __init__(self, parent=None,fnames=None):
6459

6560
#self.aux_data = {} # {alias: np.array}
6661

67-
68-
def load(self, look_for_I0=True):
69-
"""
70-
Determine the file type and load the data with the appropriate function.
71-
The load function should take a file name as input and return the x, I, I_error, is_q, and E values.
72-
If the I_error or energy are not available in the file, the load function should return None for both.
73-
Parameters:
74-
- look_for_I0: If True, attempts to load I0 data from a nxmonitor dataset in the file(s).
75-
Returns:
76-
- True if the data was loaded successfully, False otherwise.
77-
"""
78-
messages = [] # messages to return to caller. A workaround for threading issues with QMessageBox
79-
if not all(fname.endswith('.h5') for fname in self.fnames):
80-
messages.append((QMessageBox.critical,"Error", "File(s) are not HDF5 files."))
81-
print("File(s) are not HDF5 files.")
82-
return False, messages
83-
84-
if self._load_func is None:
85-
# Determine the load function based on the first file
86-
self._determine_load_func(self.fnames[0])
87-
if self._load_func is None:
88-
messages.append((QMessageBox.critical,"Error", "No valid load function found. Please provide a valid azimuthal integration file."))
89-
print("No valid load function found. Please provide a valid azimuthal integration file.")
90-
return False, messages
91-
92-
x = None
93-
I = np.array([[],[]])
94-
I_error = np.array([[],[]])
95-
_shapes = []
96-
for fname in self.fnames:
97-
x_, I_, I_error_, is_q, E = self._load_func(fname)
98-
if x_ is None or I_ is None:
99-
messages.append((QMessageBox.critical,"Error", f"Error loading data from {fname}."))
100-
print(f"Error loading data from {fname}.")
101-
return False, messages
102-
if x is not None and x_.shape != x.shape:
103-
print(f"Error: Inconsistent x shapes in {fname}.")
104-
messages.append((QMessageBox.critical,"Error", f"Inconsistent x shapes in {fname}."))
105-
#QMessageBox.critical(self.parent, "Error", f"Inconsistent x shapes in {fname}.")
106-
return False, messages
107-
x = x_
108-
I = np.append(I, I_, axis=0) if I.size else I_
109-
_shapes.append(I_.shape)
110-
if I_error_ is not None:
111-
I_error = np.append(I_error, I_error_, axis=0) if I_error.size else I_error_
112-
if I_error.size == 0:
113-
I_error = None
114-
#I = np.array(I)
115-
self.x = x
116-
self.I = I
117-
self.I_error = I_error
62+
def set_secondary_data(self, data_dict):
63+
"""Set the "secondary" azimuthal integration data from a dictionary."""
64+
is_q = data_dict["q"] is not None
11865
self.is_q = is_q
119-
self.E = E
120-
self.y_avg = I.mean(axis=0)
121-
self.shape = I.shape
122-
self._shapes = _shapes
123-
124-
if look_for_I0 and self._load_func == self._load_azint:
125-
# If the data is loaded from a nxazint file, attempts to load
126-
# the I0 data from a nxmonitor dataset in the file. Give the user
127-
# the option ignore the I0 data
128-
if self.load_I0_from_nxmonitor():
129-
# reply = QMessageBox.question(None, "NXmonitor data found",
130-
# "I0 data loaded from nxmonitor dataset. Do you want to use it?",
131-
# QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
132-
# QMessageBox.StandardButton.Yes)
133-
# if reply == QMessageBox.StandardButton.No:
134-
# self.I0 = None
135-
messages.append((QMessageBox.question,"NXmonitor data found",
136-
"I0 data loaded from nxmonitor dataset. Do you want to use it?",
137-
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
138-
QMessageBox.StandardButton.Yes))
139-
if self._load_func == self._load_azint:
140-
# If the data is loaded from a nxazint file, attempts to load
141-
# the map shape and pixel indices from a nxtransformations group in the file.
142-
self.load_map_shape_and_indices()
143-
144-
return True, messages
66+
self.x = data_dict["q"] if is_q else data_dict["tth"]
67+
self.E = data_dict.get("energy", None)
68+
self.instrument_name = data_dict.get("instrument_name", None)
69+
self.source_name = data_dict.get("source_name", None)
70+
self.map_shape = data_dict.get("map_shape", None)
71+
self.map_indices = data_dict.get("map_indices", None)
14572

14673
def load_I0_from_nxmonitor(self):
14774
"""
@@ -203,7 +130,7 @@ def reduce_data(self, reduction_factor=2, axes=(0,)):
203130
self.I_error = average_blocks(self.I_error, reduction_factor=reduction_factor, axes=axes)
204131
if self.I0 is not None:
205132
self.I0 = average_blocks(self.I0, reduction_factor=reduction_factor, axes=axes)
206-
self.y_avg = self.I.mean(axis=0) if self.I is not None else None
133+
#self.y_avg = self.I.mean(axis=0) if self.I is not None else None
207134
self.shape = self.I.shape if self.I is not None else None
208135
self.map_shape, self.map_indices = None, None # Invalidate map shape and indices after reduction
209136
self.reduction_factor *= reduction_factor
@@ -328,17 +255,6 @@ def set_I0(self, I0):
328255
print("I0 data must be a numpy array or a list/tuple.")
329256
return
330257

331-
# # check if the I0 data are close to unity
332-
# # otherwise, normalize it and print a warning
333-
# if self.I0.min() <= 0 or self.I0.max() < 0.5 or self.I0.max() > 2:
334-
# message = ("Warning: I0 data should be close to unity and >0. Normalizing it.")
335-
# QMessageBox.warning(self.parent, "I0 Data Warning", message)
336-
337-
# print(f"I0 [{self.I0.min():.2e}, {self.I0.max():.2e}] normalized to [{self.I0.min()/self.I0.max():.2f}, 1.00]")
338-
# self.I0 = self.I0 / np.max(self.I0)
339-
# self.I0[self.I0<=0] = 1 # Set any zero values to 1 to avoid division by zero
340-
341-
342258
if self.I is None:
343259
# Don't normalize (yet)
344260
return
@@ -366,7 +282,7 @@ def export_pattern(self, fname, index, is_Q=False, I0_normalized=True, kwargs={}
366282
print("Error retrieving data for export.")
367283
return False
368284

369-
self._export_xy(fname,x,y,y_e, kwargs)
285+
export_xy(fname,x,y,y_e, kwargs)
370286
return True
371287

372288
def export_average_pattern(self, fname, is_Q=False, I0_normalized=True, bgr_subtracted=True, kwargs={}):
@@ -389,7 +305,7 @@ def export_average_pattern(self, fname, is_Q=False, I0_normalized=True, bgr_subt
389305
print("Error retrieving data for export.")
390306
return False
391307

392-
self._export_xy(fname,x,y,y_e, kwargs)
308+
export_xy(fname,x,y,y_e, kwargs)
393309
return True
394310

395311
def get_info_string(self):
@@ -415,147 +331,20 @@ def get_info_string(self):
415331
name += f"reduced x{self.reduction_factor}"
416332
return name
417333

418-
def _determine_load_func(self, fname):
419-
"""Determine the appropriate load function based on the file structure."""
420-
with h5.File(fname, 'r') as f:
421-
if 'entry/data' in f:
422-
self._load_func = self._load_azint
423-
elif 'entry/data1d' in f:
424-
self._load_func = self._load_azint_old
425-
elif 'entry/dataxrd1d' in f:
426-
self._load_func = self._load_DM_map
427-
elif 'I' in f:
428-
self._load_func = self._load_DM_old
429-
else:
430-
# Attempt to load using the H5Dialog if no specific structure is found
431-
self._load_func = self._load_dialog
432-
#print("File type not recognized. Please provide a valid azimuthal integration file.")
433-
#self._load_func = None
434-
435-
def _load_azint(self, fname):
436-
"""Load azimuthal integration data from a nxazint HDF5 file."""
437-
with h5.File(fname, 'r') as f:
438-
default = get_nx_default(f)
439-
if default is None:
440-
print(f"File {fname} does not contain a valid azimuthal integration dataset.")
441-
return None, None, None, None
442-
signal = get_nx_signal(default)
443-
signal_errors = get_nx_signal_errors(default)
444-
axis = get_nx_axes(default)[-1] # Get the last axis, which is usually the radial axis
445-
if signal is None or axis is None:
446-
print(f"File {fname} does not contain a valid azimuthal integration dataset.")
447-
return None, None, None, None
448-
x = axis[:]
449-
is_Q = 'q' in axis.attrs['long_name'].lower() if 'long_name' in axis.attrs else False
450-
I = signal[:]
451-
I_error = signal_errors[:] if signal_errors is not None else None
452-
E = get_nx_energy(f)
453-
454-
# get the instrument and source names if available
455-
self.instrument_name = get_instrument_name(f)
456-
self.source_name = get_source_name(f)
457-
return x, I, I_error, is_Q, E
458-
# with h5.File(fname, 'r') as f:
459-
# data_group = f['entry/data']
460-
# x = data_group['radial_axis'][:]
461-
# I = data_group['I'][:]
462-
# is_q = 'q' in data_group['radial_axis'].attrs['long_name'].lower()
463-
464-
# if 'entry/instrument/monochromator/energy' in f:
465-
# E = f['entry/instrument/monochromator/energy'][()]
466-
# elif 'entry/instrument/monochromator/wavelength' in f:
467-
# wavelength = f['entry/instrument/monochromator/wavelength'][()]
468-
# E = 12.398 / wavelength # Convert wavelength to energy in keV
469-
# else:
470-
# E = None
471-
# return x, I, is_q, E
472-
473-
def _load_azint_old(self, fname):
474-
"""Load azimuthal integration data from an old (DanMAX) nxazint HDF5 file."""
475-
with h5.File(fname, 'r') as f:
476-
data_group = f['entry/data1d']
477-
if '2th' in data_group:
478-
x = data_group['2th'][:]
479-
is_q = False
480-
elif 'q' in data_group:
481-
x = data_group['q'][:]
482-
is_q = True
483-
I = data_group['I'][:]
484-
return x, I, None, is_q, None
485-
486-
def _load_DM_old(self, fname):
487-
"""Load azimuthal integration data from an old DanMAX HDF5 file."""
488-
with h5.File(fname, 'r') as f:
489-
if '2th' in f:
490-
x = f['2th'][:]
491-
is_q = False
492-
elif 'q' in f:
493-
x = f['q'][:]
494-
is_q = True
495-
I = f['I'][:]
496-
return x, I, None, is_q, None
497-
498-
def _load_DM_map(self, fname):
499-
"""Load azimuthal integration data from a DanMAX map HDF5 file."""
500-
with h5.File(fname, 'r') as f:
501-
data_group = f['entry/dataxrd1d']
502-
if 'tth' in data_group:
503-
x = data_group['tth'][:]
504-
is_q = False
505-
elif 'q' in data_group:
506-
x = data_group['q'][:]
507-
is_q = True
508-
I = data_group['xrd'][:] # [radial bins, fast_axis, slow_axis]
509-
self.map_shape = I.shape[1:] # (fast_axis, slow_axis)
510-
# self.map_indices = np.arange(I.shape[1]*I.shape[2])
511-
self.map_indices = list(range(I.shape[1]*I.shape[2]))
512-
# transpose and reshape I
513-
I = np.transpose(I, (1,2,0)).reshape(-1, I.shape[0]) # [num_patterns, radial bins]
514-
# in the case of xrd-ct data, an extra first column might be present as absorption data
515-
# in that case, remove it
516-
if I.shape[1] - x.shape[0] == 1:
517-
I = I[:, 1:] # Remove first column if x has one less element than I
518-
return x, I, None, is_q, None
519-
520-
def _load_dialog(self, fname):
521-
"""
522-
Load azimuthal integration data from an h5 file dialog.
523-
This function is used as a last resort if no other load function is found.
524-
"""
525-
dialog = H5Dialog(self.parent, fname)
526-
if not dialog.exec_1d_2d_pair():
527-
return None, None, None, None, None
528-
529-
selected = dialog.get_selected_items() # list of tuples with (alias, full_path, shape)
530-
axis = [item for item in selected if not "×" in item[2]][0]
531-
signal = [item for item in selected if "×" in item[2]][0]
532-
# Check if the shape of the axis and signal match
533-
if not axis[2] in signal[2].split("×")[1]:
534-
QMessageBox.critical(self.parent, "Error", f"Error: The shape of the axis {axis[2]} does not match the shape of the signal {signal[2]}.")
535-
return None, None, None, None
536-
with h5.File(fname, 'r') as f:
537-
x = f[axis[1]][:]
538-
I = f[signal[1]][:]
539-
# attempt to guess if the axis is q or 2theta
540-
is_q = 'q' in axis[0].lower() or 'q' in f[axis[1]].attrs.get('long_name', '').lower()
541-
return x, I, None, is_q, None
542-
543-
def _export_xy(self, fname, x, y, y_e=None, kwargs={}):
544-
"""
545-
Export the azimuthal integration data to a text file.
546-
kwargs are passed to np.savetxt.
547-
"""
548-
if y_e is None:
549-
np.savetxt(fname, np.column_stack((x, y)),comments='#',**kwargs)
550-
else:
551-
np.savetxt(fname, np.column_stack((x, y, y_e)),comments='#',**kwargs)
552-
return True
553-
554334
class AuxData:
555335
"""A class to hold auxiliary data for azimuthal integration."""
556336
def __init__(self,parent=None):
557337
self._parent = parent
558338
self.I0 = None
339+
self._E = None
340+
341+
def set_energy(self, E):
342+
"""Set energy"""
343+
self._E = E
344+
345+
def get_energy(self):
346+
"""Get energy"""
347+
return self._E
559348

560349
def set_I0(self, I0):
561350
"""Set I0"""

plaid/dialogs.py

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,11 @@ def __init__(self, parent=None, file_path=None, mode='any'):
6464
self.file_tree.setSortingEnabled(False)
6565
self.layout().addWidget(self.file_tree,2)
6666
self.file_tree.itemDoubleClicked.connect(self.item_double_clicked)
67-
self._populate_tree(file_path)
67+
if isinstance(file_path, str):
68+
with h5.File(file_path, 'r') as file:
69+
self._populate_tree(file)
70+
else:
71+
self._populate_tree(file_path)
6872
self.file_tree.header().setSectionResizeMode(1,self.file_tree.header().ResizeMode.Fixed)
6973
self.file_tree.header().setStretchLastSection(False)
7074

@@ -225,21 +229,21 @@ def selection_finished(self):
225229
self.selected_items = None
226230
self.accept()
227231

228-
def _populate_tree(self, file_path):
232+
def _populate_tree(self, f):
229233
"""
230234
Populate the tree with the content of the HDF5 file as a tree structure
231235
with two columns: the item name and its shape.
232236
"""
233-
with h5.File(file_path, 'r') as f:
234-
for key in f.keys():
235-
shape = f[key].shape if hasattr(f[key], 'shape') and len(f[key].shape) else ""
236-
item = QTreeWidgetItem([key, self._shape_to_str(shape)])
237-
self.file_tree.addTopLevelItem(item)
238-
if isinstance(f[key], h5.Group):
239-
has_child_with_shape = self._populate_item(item, f[key])
240-
#if not has_child_with_shape:
241-
# If no child has a shape, set the item to a lighter color
242-
# item.setForeground(0, pg.mkColor("#AAAAAA"))
237+
# with h5.File(file_path, 'r') as f:
238+
for key in f.keys():
239+
shape = f[key].shape if hasattr(f[key], 'shape') and len(f[key].shape) else ""
240+
item = QTreeWidgetItem([key, self._shape_to_str(shape)])
241+
self.file_tree.addTopLevelItem(item)
242+
if isinstance(f[key], h5.Group):
243+
has_child_with_shape = self._populate_item(item, f[key])
244+
#if not has_child_with_shape:
245+
# If no child has a shape, set the item to a lighter color
246+
# item.setForeground(0, pg.mkColor("#AAAAAA"))
243247

244248
def _populate_item(self, parent_item, group):
245249
"""

0 commit comments

Comments
 (0)