From ba09e11f9779ea9c4b07c86350f3f146609d85a0 Mon Sep 17 00:00:00 2001 From: Marie Backman Date: Thu, 8 Feb 2024 10:09:02 -0500 Subject: [PATCH] Export direct beam data (#75) * Add right-click option "Export data" to reduction table and direct beam table to save text file with: - TOF - wavelength - counts normalized by proton charge - error in counts normalized by proton charge - counts - error in counts - size of ROI --- .../interfaces/data_handling/data_set.py | 65 ++++++++++++-- .../interfaces/event_handlers/main_handler.py | 90 +++++++++++++++++-- reflectivity_ui/interfaces/main_window.py | 16 +++- reflectivity_ui/ui/ui_main_window.ui | 39 ++++++++ test/ui/test_main_window.py | 28 ++++++ .../interfaces/data_handling/test_data_set.py | 55 ++++++++++++ .../event_handlers/test_main_handler.py | 80 +++++++++++++++++ 7 files changed, 362 insertions(+), 11 deletions(-) create mode 100644 test/unit/reflectivity_ui/interfaces/data_handling/test_data_set.py diff --git a/reflectivity_ui/interfaces/data_handling/data_set.py b/reflectivity_ui/interfaces/data_handling/data_set.py index b2d76b19..bf245f06 100644 --- a/reflectivity_ui/interfaces/data_handling/data_set.py +++ b/reflectivity_ui/interfaces/data_handling/data_set.py @@ -88,15 +88,15 @@ def getIxyt(nxs_data): sz_x_axis = int(nxs_data.getInstrument().getNumberParameter("number-of-x-pixels")[0]) # 304 _y_axis = np.zeros((sz_x_axis, sz_y_axis, nbr_tof - 1)) - # _y_error_axis = np.zeros((sz_x_axis, sz_y_axis, nbr_tof-1)) + _y_error_axis = np.zeros((sz_x_axis, sz_y_axis, nbr_tof - 1)) for x in range(sz_x_axis): for y in range(sz_y_axis): _index = int(sz_y_axis * x + y) - _tmp_data = nxs_data.readY(_index)[:] - _y_axis[x, y, :] = _tmp_data + _y_axis[x, y, :] = nxs_data.readY(_index)[:] + _y_error_axis[x, y, :] = nxs_data.readE(_index)[:] - return _y_axis + return _y_axis, _y_error_axis class NexusData(object): @@ -478,6 +478,7 @@ def __init__(self, name, configuration, entry_name="entry", workspace=None): self.data = None self.xydata = None self.xtofdata = None + self.raw_error = None self.meta_data_roi_peak = None self.meta_data_roi_bck = None @@ -725,13 +726,14 @@ def prepare_plot_data(self): t_0 = time.time() binning_ws = api.CreateWorkspace(DataX=self.tof_edges, DataY=np.zeros(len(self.tof_edges) - 1)) data_rebinned = api.RebinToWorkspace(WorkspaceToRebin=workspace, WorkspaceToMatch=binning_ws) - Ixyt = getIxyt(data_rebinned) + Ixyt, Ixyt_error = getIxyt(data_rebinned) # Create projections for the 2D datasets Ixy = Ixyt.sum(axis=2) Ixt = Ixyt.sum(axis=1) # Store the data self.data = Ixyt.astype(float) # 3D dataset + self.raw_error = Ixyt_error.astype(float) # 3D dataset self.xydata = Ixy.transpose().astype(float) # 2D dataset self.xtofdata = Ixt.astype(float) # 2D dataset logging.info("Plot data generated: %s sec", time.time() - t_0) @@ -785,6 +787,59 @@ def get_counts_vs_TOF(self): return (summed_raw / math.fabs(size_roi) - bck) / self.proton_charge + def get_tof_counts_table(self): + """ + Get a table of TOF vs counts in the region-of-interest (ROI) + + The table columns are: + - TOF + - wavelength + - counts normalized by proton charge + - error in counts normalized by proton charge + - counts + - error in counts + - size of the ROI + """ + self.prepare_plot_data() + # Calculate ROI intensities and normalize by number of points + data_roi = self.data[ + self.configuration.peak_roi[0] : self.configuration.peak_roi[1], + self.configuration.low_res_roi[0] : self.configuration.low_res_roi[1], + :, + ] + counts_roi = data_roi.sum(axis=(0, 1)) + raw_error_roi = self.raw_error[ + self.configuration.peak_roi[0] : self.configuration.peak_roi[1], + self.configuration.low_res_roi[0] : self.configuration.low_res_roi[1], + :, + ] + counts_roi_error = np.linalg.norm(raw_error_roi, axis=(0, 1)) # square root of sum of squares + if self.proton_charge > 0.0: + counts_roi_normalized = counts_roi / self.proton_charge + counts_roi_normalized_error = counts_roi_error / self.proton_charge + else: + counts_roi_normalized = counts_roi + counts_roi_normalized_error = counts_roi_error + size_roi = len(counts_roi) * [ + float( + (self.configuration.low_res_roi[1] - self.configuration.low_res_roi[0]) + * (self.configuration.peak_roi[1] - self.configuration.peak_roi[0]) + ) + ] + data_table = np.vstack( + ( + self.tof, + self.wavelength, + counts_roi_normalized, + counts_roi_normalized_error, + counts_roi, + counts_roi_error, + size_roi, + ) + ).T + header = "tof wavelength counts_normalized counts_normalized_error counts counts_error size_roi" + return data_table, header + def get_background_vs_TOF(self): """ Returns the background counts vs TOF diff --git a/reflectivity_ui/interfaces/event_handlers/main_handler.py b/reflectivity_ui/interfaces/event_handlers/main_handler.py index c6cc1341..900ac6f3 100644 --- a/reflectivity_ui/interfaces/event_handlers/main_handler.py +++ b/reflectivity_ui/interfaces/event_handlers/main_handler.py @@ -4,15 +4,16 @@ """ Manage file-related and UI events """ - +import numpy as np # package imports +from reflectivity_ui.interfaces.configuration import Configuration from reflectivity_ui.interfaces.data_handling.data_manipulation import NormalizeToUnityQCutoffError -from .status_bar_handler import StatusBarHandler -from ..configuration import Configuration -from .progress_reporter import ProgressReporter -from .widgets import AcceptRejectDialog +from reflectivity_ui.interfaces.data_handling.data_set import NexusData, CrossSectionData from reflectivity_ui.interfaces.data_handling.filepath import FilePath, RunNumbers +from reflectivity_ui.interfaces.event_handlers.progress_reporter import ProgressReporter +from reflectivity_ui.interfaces.event_handlers.status_bar_handler import StatusBarHandler +from reflectivity_ui.interfaces.event_handlers.widgets import AcceptRejectDialog from reflectivity_ui.config import Settings # 3rd-party imports @@ -970,6 +971,68 @@ def active_data_changed(self): item.setBackground(QtGui.QColor(255, 255, 255)) self.main_window.auto_change_active = False + def reduction_table_right_click(self, pos, is_reduction_table=True): + """ + Handle right-click on the reduction table. + :param QPoint pos: mouse position + :param bool is_reduction_table: True if the reduction table is active, False if the direct beam table is active + """ + if is_reduction_table: + table_widget = self.ui.reductionTable + data_table = self._data_manager.reduction_list + else: + table_widget = self.ui.normalizeTable + data_table = self._data_manager.direct_beam_list + + def _export_data(_pos): + """callback function to right-click action: Export data""" + row = table_widget.rowAt(pos.y()) + if 0 <= row < len(data_table): + nexus_data = data_table[row] + self.save_run_data(nexus_data) + + reduction_table_menu = QtWidgets.QMenu(table_widget) + export_data_action = QtWidgets.QAction("Export data") + export_data_action.triggered.connect(lambda: _export_data(pos)) + reduction_table_menu.addAction(export_data_action) + reduction_table_menu.exec_(table_widget.mapToGlobal(pos)) + + def save_run_data(self, nexus_data: NexusData): + """ + Save run data to file + :param NexusData nexus_data: run data object + """ + path = QtWidgets.QFileDialog.getExistingDirectory(self.main_window, "Select directory") + if not path: + return + # ask user for base name for files (one file for each cross-section, e.g. "REF_M_1234_data_Off-Off.dat") + default_basename = f"REF_M_{nexus_data.number}_data" + while True: # to ask again for new basename if the user does not want to overwrite existing files + basename, ok = QtWidgets.QInputDialog.getText( + self.main_window, "Base name", "Save file base name:", text=default_basename + ) + if not (ok and basename): + # user cancels + return + save_filepaths = {} + existing_filenames = [] + for xs in nexus_data.cross_sections.keys(): + filename = f"{basename}_{xs}.dat" + filepath = os.path.join(path, filename) + save_filepaths[xs] = filepath + if os.path.isfile(filepath): + existing_filenames.append(filename) + newline = "\n" + if len(existing_filenames) == 0 or self.ask_question( + f"Overwrite existing file(s):\n{newline.join(existing_filenames)}?" + ): + break + # save one file per cross-section + for xs, filepath in save_filepaths.items(): + cross_section = nexus_data.cross_sections[xs] + data_to_save, header = cross_section.get_tof_counts_table() + np.savetxt(filepath, data_to_save, header=header) + def compute_offspec_on_change(self, force=False): """ Compute off-specular as needed @@ -1411,6 +1474,23 @@ def report_message(self, message, informative_message=None, detailed_message=Non msg.setStandardButtons(QtWidgets.QMessageBox.Ok) msg.exec_() + def ask_question(self, message): + """ + Display a popup dialog with a message and choices "Ok" and "Cancel" + :param str message: question to ask + :returns: bool + """ + ret = QtWidgets.QMessageBox.warning( + self.main_window, + "Warning", + message, + buttons=QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel, + defaultButton=QtWidgets.QMessageBox.Ok, + ) + if ret == QtWidgets.QMessageBox.Cancel: + return False + return True + def show_results(self): """ Pop up the result viewer diff --git a/reflectivity_ui/interfaces/main_window.py b/reflectivity_ui/interfaces/main_window.py index bbee4a76..864b5dab 100644 --- a/reflectivity_ui/interfaces/main_window.py +++ b/reflectivity_ui/interfaces/main_window.py @@ -5,7 +5,6 @@ Main application window """ - # package imports from .data_manager import DataManager from .plotting import PlotManager @@ -47,6 +46,7 @@ def __init__(self): QtWidgets.QMainWindow.__init__(self) # Initialize the UI widgets + self.reduction_table_menu = None self.ui = load_ui("ui_main_window.ui", baseinstance=self) version = reflectivity_ui.__version__ if reflectivity_ui.__version__.lower() != "unknown" else "" self.setWindowTitle(f"QuickNXS Magnetic Reflectivity {version}") @@ -304,6 +304,20 @@ def reduction_cell_activated(self, row, col): self.file_loaded() self.file_handler.active_data_changed() + def reduction_table_right_click(self, pos): + """ + Handle right-click on the reduction table. + :param QPoint pos: mouse position + """ + self.file_handler.reduction_table_right_click(pos, True) + + def direct_beam_table_right_click(self, pos): + """ + Handle right-click on the direct beam table. + :param QPoint pos: mouse position + """ + self.file_handler.reduction_table_right_click(pos, False) + def direct_beam_cell_activated(self, row, col): """ Select a data set when the user double-clicks on a run number (col 0). diff --git a/reflectivity_ui/ui/ui_main_window.ui b/reflectivity_ui/ui/ui_main_window.ui index f4f57efa..4fd998ca 100644 --- a/reflectivity_ui/ui/ui_main_window.ui +++ b/reflectivity_ui/ui/ui_main_window.ui @@ -2644,6 +2644,9 @@ 0 + + Qt::CustomContextMenu + true @@ -2810,6 +2813,9 @@ QAbstractItemView::NoEditTriggers + + Qt::CustomContextMenu + true @@ -6440,6 +6446,38 @@ + + reductionTable + customContextMenuRequested(QPoint) + MainWindow + reduction_table_right_click(QPoint) + + + 1508 + 1348 + + + 778 + 551 + + + + + normalizeTable + customContextMenuRequested(QPoint) + MainWindow + direct_beam_table_right_click(QPoint) + + + 1508 + 1348 + + + 778 + 551 + + + file_open_dialog() @@ -6490,5 +6528,6 @@ hide_sidebar() hide_run_data() hide_data_table() + reduction_table_right_click() diff --git a/test/ui/test_main_window.py b/test/ui/test_main_window.py index 51a17bd4..6c9fb51a 100644 --- a/test/ui/test_main_window.py +++ b/test/ui/test_main_window.py @@ -1,4 +1,5 @@ # local imports +from reflectivity_ui.interfaces.configuration import Configuration from reflectivity_ui.interfaces.data_handling.data_set import CrossSectionData, NexusData from reflectivity_ui.interfaces.main_window import MainWindow from test import SNS_REFM_MOUNTED @@ -6,6 +7,8 @@ # third party imports import pytest +from qtpy import QtCore, QtWidgets + # standard library imports @@ -61,6 +64,31 @@ def test_active_channel(self, mocker, qtbot): # check the current channel name displayed in the UI assert channel1.name in window_main.ui.currentChannel.text() + @pytest.mark.parametrize("table_widget", ["reductionTable", "normalizeTable"]) + def test_reduction_table_right_click(self, table_widget, qtbot, mocker): + mock_save_run_data = mocker.patch( + "reflectivity_ui.interfaces.event_handlers.main_handler.MainHandler.save_run_data" + ) + window_main = MainWindow() + qtbot.addWidget(window_main) + window_main.data_manager.reduction_list = [NexusData("filepath", Configuration())] + window_main.data_manager.direct_beam_list = [NexusData("filepath", Configuration())] + table = getattr(window_main.ui, table_widget) + table.insertRow(0) + + def handle_menu(): + """Press Enter on item in menu and check that the function was called""" + menu = table.findChild(QtWidgets.QMenu) + action = menu.actions()[0] + assert action.text() == "Export data" + qtbot.keyClick(menu, QtCore.Qt.Key_Down) + qtbot.keyClick(menu, QtCore.Qt.Key_Enter) + mock_save_run_data.assert_called_once() + + QtCore.QTimer.singleShot(200, handle_menu) + pos = QtCore.QPoint() + table.customContextMenuRequested.emit(pos) + if __name__ == "__main__": pytest.main([__file__]) diff --git a/test/unit/reflectivity_ui/interfaces/data_handling/test_data_set.py b/test/unit/reflectivity_ui/interfaces/data_handling/test_data_set.py new file mode 100644 index 00000000..f746cf35 --- /dev/null +++ b/test/unit/reflectivity_ui/interfaces/data_handling/test_data_set.py @@ -0,0 +1,55 @@ +import numpy as np +import pytest + +from reflectivity_ui.interfaces.configuration import Configuration +from reflectivity_ui.interfaces.data_handling.data_set import CrossSectionData + + +def _get_cross_section_data(): + """Get instance of CrossSectionData for testing""" + config = Configuration() + config.peak_position = 3 + config.peak_width = 1 + config.low_res_position = 2 + config.low_res_width = 2 + xs = CrossSectionData("On_Off", config) + pixel_counts = [[0, 0, 1, 1, 0, 0], [0, 1, 3, 4, 1, 0], [0, 1, 2, 4, 1, 0], [0, 0, 1, 1, 0, 0]] + pixel_counts = np.array(pixel_counts).astype(float) + xs.data = np.array([pixel_counts, pixel_counts, pixel_counts]).T + xs.raw_error = np.ones_like(xs.data) + xs.tof_edges = np.array([0.1, 0.2, 0.3, 0.4]) + xs.proton_charge = 8.03e5 + xs.dist_mod_det = 2.96 + return xs + + +class TestCrossSectionData(object): + def test_r_scaling_factor(self): + config = Configuration() + xs = CrossSectionData("On_Off", config) + xs._r = 0.8 + xs._dr = 0.1 + setattr(xs.configuration, "scaling_factor", 0.35) + setattr(xs.configuration, "scaling_error", 0.01) + + assert xs.r == pytest.approx(0.28, rel=1e-6) + assert xs.dr == pytest.approx(0.035902646, rel=1e-6) + + def test_get_tof_counts_table(self, mocker): + """Test of method get_tof_counts_table""" + mocker.patch("reflectivity_ui.interfaces.data_handling.data_set.CrossSectionData.prepare_plot_data") + rel_tol = 1e-6 + xs = _get_cross_section_data() + data_table, header = xs.get_tof_counts_table() + assert len(data_table) == 3 + assert data_table[0][0] == pytest.approx(0.15, rel_tol) # tof + assert data_table[0][1] == pytest.approx(2.0046381e-4, rel_tol) # wavelength + assert data_table[0][2] == pytest.approx(13.0 / xs.proton_charge, rel_tol) # counts normalized + assert data_table[0][3] == pytest.approx(2.0 / xs.proton_charge, rel_tol) # counts normalized error + assert data_table[0][4] == pytest.approx(13.0, rel_tol) # counts + assert data_table[0][5] == pytest.approx(2.0, rel_tol) # counts error + assert data_table[0][6] == 4 # size of ROI + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/test/unit/reflectivity_ui/interfaces/event_handlers/test_main_handler.py b/test/unit/reflectivity_ui/interfaces/event_handlers/test_main_handler.py index f22caf65..bea181fd 100644 --- a/test/unit/reflectivity_ui/interfaces/event_handlers/test_main_handler.py +++ b/test/unit/reflectivity_ui/interfaces/event_handlers/test_main_handler.py @@ -1,5 +1,10 @@ # package imports +import numpy as np +from qtpy import QtWidgets, QtCore + +from reflectivity_ui.interfaces.configuration import Configuration from reflectivity_ui.interfaces.data_handling.data_manipulation import NormalizeToUnityQCutoffError +from reflectivity_ui.interfaces.data_handling.data_set import NexusData, CrossSectionData from reflectivity_ui.interfaces.main_window import MainWindow from reflectivity_ui.interfaces.event_handlers.main_handler import MainHandler @@ -79,5 +84,80 @@ def test_stitch_reflectivity_errors(self, mocker, error_type, error_msg): assert error_msg in mock_report_message.call_args[0][0] +def test_save_run_data(tmp_path, qtbot, mocker): + """Test of method save_run_data""" + mocker.patch( + "reflectivity_ui.interfaces.event_handlers.main_handler.QtWidgets.QFileDialog.getExistingDirectory", + return_value=tmp_path, + ) + mocker.patch( + "reflectivity_ui.interfaces.event_handlers.main_handler.QtWidgets.QInputDialog.getText", + return_value=("test_save_run_data", True), + ) + header = "col1 col2" + mocker.patch( + "reflectivity_ui.interfaces.data_handling.data_set.CrossSectionData.get_tof_counts_table", + return_value=(np.ones((5, 5)), header), + ) + mocker.patch( + "reflectivity_ui.interfaces.event_handlers.main_handler.MainHandler.ask_question", side_effect=[False, True] + ) + + main_window = MainWindow() + handler = MainHandler(main_window) + qtbot.addWidget(main_window) + + nexus_data = _get_nexus_data() + # test save files + handler.save_run_data(nexus_data) + # test save and overwrite existing files + handler.save_run_data(nexus_data) + + for xs in nexus_data.cross_sections.keys(): + save_file_path = tmp_path / f"test_save_run_data_{xs}.dat" + assert os.path.exists(save_file_path) + with open(save_file_path) as f: + first_line = f.readline() + assert header in first_line + second_line = f.readline() + assert len(second_line.split()) == 5 + + +def test_ask_question(qtbot): + """Test of helper function ask_question""" + main_window = MainWindow() + handler = MainHandler(main_window) + qtbot.addWidget(main_window) + + def dialog_click_button(button_type): + # click button in the popup dialog + dialog = QApplication.activeModalWidget() + button = dialog.button(button_type) + qtbot.mouseClick(button, QtCore.Qt.LeftButton, delay=1) + + QTimer.singleShot(200, lambda: dialog_click_button(QtWidgets.QMessageBox.Ok)) + answer = handler.ask_question("OK or Cancel?") + assert answer is True + + QTimer.singleShot(200, lambda: dialog_click_button(QtWidgets.QMessageBox.Cancel)) + answer = handler.ask_question("OK or Cancel?") + assert answer is False + + +def _get_nexus_data(): + """Data for testing""" + config = Configuration() + nexus_data = NexusData("file/path", config) + off_off = CrossSectionData("Off_Off", config) + off_off.tof_edges = np.arange(0.1, 0.4, 20) + off_off.dist_mod_det = 1.0 + on_off = CrossSectionData("On_Off", config) + on_off.tof_edges = np.arange(0.1, 0.4, 20) + on_off.dist_mod_det = 1.0 + nexus_data.cross_sections["Off_Off"] = off_off + nexus_data.cross_sections["On_Off"] = on_off + return nexus_data + + if __name__ == "__main__": pytest.main([__file__])