diff --git a/docs/release_notes/next/feature-2151-spectrum-viewer-ToF-units b/docs/release_notes/next/feature-2151-spectrum-viewer-ToF-units new file mode 100644 index 00000000000..d4045f15bbb --- /dev/null +++ b/docs/release_notes/next/feature-2151-spectrum-viewer-ToF-units @@ -0,0 +1 @@ +#2151: Neutron wavelength, energy and tof units can be selected in the Spectrum Viewer \ No newline at end of file diff --git a/mantidimaging/core/utility/unit_conversion.py b/mantidimaging/core/utility/unit_conversion.py new file mode 100644 index 00000000000..7c657be7048 --- /dev/null +++ b/mantidimaging/core/utility/unit_conversion.py @@ -0,0 +1,28 @@ +# Copyright (C) 2024 ISIS Rutherford Appleton Laboratory UKRI +# SPDX - License - Identifier: GPL-3.0-or-later +from __future__ import annotations + +import numpy as np + + +class UnitConversion: + # target_to_camera_dist = 56 m taken from https://scripts.iucr.org/cgi-bin/paper?S1600576719001730 + neutron_mass: float = 1.674927211e-27 # [kg] + planck_h: float = 6.62606896e-34 # [JHz-1] + angstrom: float = 1e-10 # [m] + mega_electro_volt: float = 1.60217662e-19 / 1e6 + + def __init__(self, data_to_convert: np.ndarray, target_to_camera_dist: float = 56) -> None: + self.tof_data_to_convert = data_to_convert + self.target_to_camera_dist = target_to_camera_dist + self.velocity = self.target_to_camera_dist / self.tof_data_to_convert + + def tof_seconds_to_wavelength(self) -> np.ndarray: + wavelength = self.planck_h / (self.neutron_mass * self.velocity) + wavelength_angstroms = wavelength / self.angstrom + return wavelength_angstroms + + def tof_seconds_to_energy(self) -> np.ndarray: + energy = self.neutron_mass * self.velocity / 2 + energy_evs = energy / self.mega_electro_volt + return energy_evs diff --git a/mantidimaging/gui/windows/spectrum_viewer/model.py b/mantidimaging/gui/windows/spectrum_viewer/model.py index 8749f266eb6..3498286b22b 100644 --- a/mantidimaging/gui/windows/spectrum_viewer/model.py +++ b/mantidimaging/gui/windows/spectrum_viewer/model.py @@ -16,6 +16,7 @@ from mantidimaging.core.io.instrument_log import LogColumn from mantidimaging.core.utility.sensible_roi import SensibleROI from mantidimaging.core.utility.progress_reporting import Progress +from mantidimaging.core.utility.unit_conversion import UnitConversion if TYPE_CHECKING: from mantidimaging.gui.windows.spectrum_viewer.presenter import SpectrumViewerWindowPresenter @@ -32,6 +33,13 @@ class SpecType(Enum): SAMPLE_NORMED = 3 +class ToFUnitMode(Enum): + IMAGE_NUMBER = 1 + TOF_US = 2 + WAVELENGTH = 3 + ENERGY = 4 + + class ErrorMode(Enum): STANDARD_DEVIATION = "Standard Deviation" PROPAGATED = "Propagated" @@ -55,7 +63,11 @@ class SpectrumViewerWindowModel: _stack: ImageStack | None = None _normalise_stack: ImageStack | None = None tof_range: tuple[int, int] = (0, 0) + tof_plot_range: tuple[float, float] | tuple[int, int] = (0, 0) _roi_ranges: dict[str, SensibleROI] + tof_mode: ToFUnitMode + tof_data: np.ndarray | None = None + tof_range_full: tuple[int, int] = (0, 0) def __init__(self, presenter: SpectrumViewerWindowPresenter): self.presenter = presenter @@ -63,6 +75,12 @@ def __init__(self, presenter: SpectrumViewerWindowPresenter): self._roi_ranges = {} self.special_roi_list = [ROI_ALL] + self.tof_data = self.get_stack_time_of_flight() + if self.tof_data is None: + self.tof_mode = ToFUnitMode.IMAGE_NUMBER + else: + self.tof_mode = ToFUnitMode.WAVELENGTH + def roi_name_generator(self) -> str: """ Returns a new Unique ID for newly created ROIs @@ -94,6 +112,8 @@ def set_stack(self, stack: ImageStack | None) -> None: return self._roi_id_counter = 0 self.tof_range = (0, stack.data.shape[0] - 1) + self.tof_range_full = self.tof_range + self.tof_data = self.get_stack_time_of_flight() self.set_new_roi(ROI_ALL) def set_new_roi(self, name: str) -> None: @@ -441,3 +461,21 @@ def remove_all_roi(self) -> None: Remove all ROIs from the model """ self._roi_ranges = {} + + def set_relevant_tof_units(self) -> None: + if self._stack is not None: + self.tof_data = self.get_stack_time_of_flight() + if self.tof_mode == ToFUnitMode.IMAGE_NUMBER or self.tof_data is None: + self.tof_plot_range = (0, self._stack.data.shape[0] - 1) + self.tof_range = (0, self._stack.data.shape[0] - 1) + self.tof_data = np.arange(self.tof_range[0], self.tof_range[1] + 1) + else: + units = UnitConversion(self.tof_data) + if self.tof_mode == ToFUnitMode.TOF_US: + self.tof_data = self.tof_data * 1e6 + elif self.tof_mode == ToFUnitMode.WAVELENGTH: + self.tof_data = units.tof_seconds_to_wavelength() + elif self.tof_mode == ToFUnitMode.ENERGY: + self.tof_data = units.tof_seconds_to_energy() + self.tof_plot_range = (self.tof_data.min(), self.tof_data.max()) + self.tof_range = (0, self.tof_data.size) diff --git a/mantidimaging/gui/windows/spectrum_viewer/presenter.py b/mantidimaging/gui/windows/spectrum_viewer/presenter.py index 8384294c6f5..fc7bf405d67 100644 --- a/mantidimaging/gui/windows/spectrum_viewer/presenter.py +++ b/mantidimaging/gui/windows/spectrum_viewer/presenter.py @@ -7,10 +7,15 @@ from typing import TYPE_CHECKING from logging import getLogger + +import numpy as np +from PyQt5.QtCore import QSignalBlocker + from mantidimaging.core.data.dataset import StrictDataset from mantidimaging.gui.dialogs.async_task import start_async_task_view, TaskWorkerThread from mantidimaging.gui.mvp_base import BasePresenter -from mantidimaging.gui.windows.spectrum_viewer.model import SpectrumViewerWindowModel, SpecType, ROI_RITS, ErrorMode +from mantidimaging.gui.windows.spectrum_viewer.model import SpectrumViewerWindowModel, SpecType, ROI_RITS, ErrorMode, \ + ToFUnitMode if TYPE_CHECKING: from mantidimaging.gui.windows.spectrum_viewer.view import SpectrumViewerWindowView # pragma: no cover @@ -62,6 +67,8 @@ def handle_stack_changed(self) -> None: except RuntimeError: norm_stack = None self.model.set_normalise_stack(norm_stack) + self.reset_units_menu() + self.handle_tof_unit_change() self.show_new_sample() self.redraw_all_rois() @@ -81,9 +88,12 @@ def handle_sample_change(self, uuid: UUID | None) -> None: if uuid is None: self.model.set_stack(None) self.view.clear() + self.view.tof_mode_select_group.setEnabled(False) return self.model.set_stack(self.main_window.get_stack(uuid)) + self.reset_units_menu() + self.handle_tof_unit_change() normalise_uuid = self.view.get_normalise_stack() if normalise_uuid is not None: try: @@ -98,6 +108,19 @@ def handle_sample_change(self, uuid: UUID | None) -> None: self.show_new_sample() self.view.on_visibility_change() + def reset_units_menu(self): + self.model.tof_mode = ToFUnitMode.IMAGE_NUMBER + for action in self.view.tof_mode_select_group.actions(): + with QSignalBlocker(action): + if action.objectName() == 'Image Index': + action.setChecked(True) + else: + action.setChecked(False) + if self.model.tof_data is None: + self.view.tof_mode_select_group.setEnabled(False) + else: + self.view.tof_mode_select_group.setEnabled(True) + def handle_normalise_stack_change(self, normalise_uuid: UUID | None) -> None: if normalise_uuid == self.current_norm_stack_uuid: return @@ -135,13 +158,22 @@ def show_new_sample(self) -> None: averaged_image = self.model.get_averaged_image() assert averaged_image is not None self.view.set_image(averaged_image) - self.view.spectrum_widget.spectrum_plot_widget.add_range(*self.model.tof_range) + self.view.spectrum_widget.spectrum_plot_widget.add_range(*self.model.tof_plot_range) + self.view.spectrum_widget.spectrum_plot_widget.set_image_index_range_label(*self.model.tof_range) self.view.auto_range_image() if self.view.get_roi_properties_spinboxes(): self.view.set_roi_properties() def handle_range_slide_moved(self, tof_range) -> None: - self.model.tof_range = tof_range + self.model.tof_plot_range = tof_range + if self.model.tof_mode == ToFUnitMode.IMAGE_NUMBER: + self.model.tof_range = (int(tof_range[0]), int(tof_range[1])) + else: + image_index_min = np.abs(self.model.tof_data - tof_range[0]).argmin() + image_index_max = np.abs(self.model.tof_data - tof_range[1]).argmin() + self.model.tof_range = tuple(sorted((image_index_min, image_index_max))) + self.view.spectrum_widget.spectrum_plot_widget.set_image_index_range_label(*self.model.tof_range) + self.view.spectrum_widget.spectrum_plot_widget.set_tof_range_label(*self.model.tof_plot_range) averaged_image = self.model.get_averaged_image() assert averaged_image is not None self.view.set_image(averaged_image, autoLevels=False) @@ -312,3 +344,16 @@ def do_remove_roi(self, roi_name: str | None = None) -> None: def handle_export_tab_change(self, index: int) -> None: self.export_mode = ExportMode(index) self.view.on_visibility_change() + + def handle_tof_unit_change(self) -> None: + selected_mode = self.view.tof_mode_select_group.checkedAction().text() + self.model.tof_mode = self.view.allowed_modes[selected_mode]["mode"] + self.model.set_relevant_tof_units() + self.view.spectrum_widget.spectrum_plot_widget.set_tof_axis_label( + self.view.allowed_modes[selected_mode]["label"]) + self.view.spectrum_widget.spectrum.clearPlots() + self.view.spectrum_widget.spectrum.update() + self.view.show_visible_spectrums() + self.view.spectrum_widget.spectrum_plot_widget.add_range(*self.model.tof_plot_range) + self.view.spectrum_widget.spectrum_plot_widget.set_image_index_range_label(*self.model.tof_range) + self.view.auto_range_image() diff --git a/mantidimaging/gui/windows/spectrum_viewer/spectrum_widget.py b/mantidimaging/gui/windows/spectrum_viewer/spectrum_widget.py index 8d8e8a75275..5eb4303c939 100644 --- a/mantidimaging/gui/windows/spectrum_viewer/spectrum_widget.py +++ b/mantidimaging/gui/windows/spectrum_viewer/spectrum_widget.py @@ -7,7 +7,7 @@ from PyQt5.QtCore import pyqtSignal, Qt, QSignalBlocker from PyQt5.QtGui import QColor from PyQt5.QtWidgets import QColorDialog, QAction, QMenu, QSplitter, QWidget, QVBoxLayout -from pyqtgraph import ROI, GraphicsLayoutWidget, LinearRegionItem, PlotItem, mkPen +from pyqtgraph import ROI, GraphicsLayoutWidget, LinearRegionItem, PlotItem, mkPen, ViewBox from mantidimaging.core.utility.close_enough_point import CloseEnoughPoint from mantidimaging.core.utility.sensible_roi import SensibleROI @@ -121,7 +121,6 @@ def __init__(self) -> None: self.image = self.image_widget.image self.spectrum_plot_widget = SpectrumPlotWidget() self.spectrum = self.spectrum_plot_widget.spectrum - self.splitter = QSplitter(Qt.Vertical) self.splitter.addWidget(self.image_widget) self.splitter.addWidget(self.spectrum_plot_widget) @@ -269,31 +268,40 @@ class SpectrumPlotWidget(GraphicsLayoutWidget): def __init__(self) -> None: super().__init__() - self.spectrum = self.addPlot() + self.vb = ViewBox() + self.spectrum = self.addPlot(viewbox=self.vb) self.nextRow() self._tof_range_label = self.addLabel() + self.nextRow() + self._image_index_range_label = self.addLabel() self.range_control = LinearRegionItem() self.range_control.sigRegionChangeFinished.connect(self._handle_tof_range_changed) self.ci.layout.setRowStretchFactor(0, 1) - def get_tof_range(self) -> tuple[int, int]: + def get_tof_range(self) -> tuple[float, float]: r_min, r_max = self.range_control.getRegion() - return int(r_min), int(r_max) + return r_min, r_max def _handle_tof_range_changed(self) -> None: tof_range = self.get_tof_range() - self._set_tof_range_label(tof_range[0], tof_range[1]) + self.set_tof_range_label(tof_range[0], tof_range[1]) self.range_changed.emit(tof_range) - def add_range(self, range_min: int, range_max: int) -> None: + def add_range(self, range_min: int | float, range_max: int | float) -> None: with QSignalBlocker(self.range_control): self.range_control.setBounds((range_min, range_max)) self.range_control.setRegion((range_min, range_max)) self.spectrum.addItem(self.range_control) - self._set_tof_range_label(range_min, range_max) + self.set_tof_range_label(range_min, range_max) + + def set_tof_range_label(self, range_min: float, range_max: float) -> None: + self._tof_range_label.setText(f'ToF range: {range_min:.3f} - {range_max:.3f}') + + def set_image_index_range_label(self, range_min: int, range_max: int) -> None: + self._image_index_range_label.setText(f'Image index range: {range_min} - {range_max}') - def _set_tof_range_label(self, range_min: int, range_max: int) -> None: - self._tof_range_label.setText(f'ToF range: {range_min} - {range_max}') + def set_tof_axis_label(self, tof_axis_label: str) -> None: + self.spectrum.setLabel('bottom', text=tof_axis_label) class SpectrumProjectionWidget(GraphicsLayoutWidget): diff --git a/mantidimaging/gui/windows/spectrum_viewer/test/presenter_test.py b/mantidimaging/gui/windows/spectrum_viewer/test/presenter_test.py index b5e501a38d2..970c279c69a 100644 --- a/mantidimaging/gui/windows/spectrum_viewer/test/presenter_test.py +++ b/mantidimaging/gui/windows/spectrum_viewer/test/presenter_test.py @@ -6,7 +6,7 @@ from pathlib import Path from unittest import mock -from PyQt5.QtWidgets import QPushButton +from PyQt5.QtWidgets import QPushButton, QActionGroup from parameterized import parameterized from mantidimaging.core.data.dataset import StrictDataset, MixedDataset @@ -34,6 +34,8 @@ def setUp(self) -> None: self.view.exportButton = mock.create_autospec(QPushButton) self.view.exportButtonRITS = mock.create_autospec(QPushButton) self.view.addBtn = mock.create_autospec(QPushButton) + self.view.tof_mode_select_group = mock.create_autospec(QActionGroup) + self.view.allowed_modes = mock.create_autospec(dict) self.presenter = SpectrumViewerWindowPresenter(self.view, self.main_window) def test_get_dataset_id_for_stack_no_stack_id(self): @@ -53,6 +55,7 @@ def test_handle_sample_change_has_flat_before(self): self.presenter.main_window.get_stack = mock.Mock(return_value=generate_images()) self.presenter.show_new_sample = mock.Mock() self.view.try_to_select_relevant_normalise_stack = mock.Mock() + self.presenter.handle_tof_unit_change = mock.Mock() self.presenter.handle_sample_change(uuid.uuid4()) self.view.try_to_select_relevant_normalise_stack.assert_called_once_with('Flat_before') @@ -67,6 +70,7 @@ def test_handle_sample_change_has_flat_after(self): self.presenter.show_new_sample = mock.Mock() self.view.try_to_select_relevant_normalise_stack = mock.Mock() + self.presenter.handle_tof_unit_change = mock.Mock() self.presenter.handle_sample_change(uuid.uuid4()) self.view.try_to_select_relevant_normalise_stack.assert_called_once_with('Flat_after') self.presenter.show_new_sample.assert_called_once() @@ -88,6 +92,7 @@ def test_handle_sample_change_dataset_unchanged(self): self.presenter.main_window.get_dataset = mock.Mock() self.presenter.main_window.get_stack = mock.Mock(return_value=generate_images()) self.presenter.show_new_sample = mock.Mock() + self.presenter.handle_tof_unit_change = mock.Mock() self.presenter.handle_sample_change(uuid.uuid4()) self.presenter.main_window.get_dataset.assert_not_called() @@ -100,6 +105,7 @@ def test_handle_sample_change_to_MixedDataset(self): self.presenter.main_window.get_stack = mock.Mock(return_value=generate_images()) self.presenter.show_new_sample = mock.Mock() self.view.try_to_select_relevant_normalise_stack = mock.Mock() + self.presenter.handle_tof_unit_change = mock.Mock() self.presenter.handle_sample_change(uuid.uuid4()) self.presenter.main_window.get_dataset.assert_called_once() @@ -112,6 +118,7 @@ def test_handle_sample_change_no_flat(self): self.presenter.main_window.get_stack = mock.Mock(return_value=generate_images()) self.presenter.show_new_sample = mock.Mock() self.view.try_to_select_relevant_normalise_stack = mock.Mock() + self.presenter.handle_tof_unit_change = mock.Mock() self.presenter.handle_sample_change(uuid.uuid4()) self.presenter.main_window.get_dataset.assert_called_once() @@ -158,7 +165,7 @@ def test_WHEN_has_stack_has_bad_norm_THEN_buttons_set(self, normalise_issue, has def test_WHEN_show_sample_call_THEN_add_range_set(self): self.presenter.model.set_stack(generate_images([10, 5, 5])) - self.presenter.model.tof_range = (0, 9) + self.presenter.model.tof_plot_range = (0, 9) self.presenter.show_new_sample() self.view.spectrum_widget.spectrum_plot_widget.add_range.assert_called_once_with(0, 9) diff --git a/mantidimaging/gui/windows/spectrum_viewer/test/spectrum_test.py b/mantidimaging/gui/windows/spectrum_viewer/test/spectrum_test.py index 3a103fa5fab..20ff64f7d12 100644 --- a/mantidimaging/gui/windows/spectrum_viewer/test/spectrum_test.py +++ b/mantidimaging/gui/windows/spectrum_viewer/test/spectrum_test.py @@ -104,7 +104,8 @@ def test_WHEN_set_roi_alpha_called_THEN_set_roi_visibility_flags_called(self, _, def test_WHEN_add_range_called_THEN_region_and_label_set_correctly(self, _, range_min, range_max): self.spectrum_plot_widget.add_range(range_min, range_max) self.assertEqual(self.spectrum_plot_widget.range_control.getRegion(), (range_min, range_max)) - self.assertEqual(self.spectrum_plot_widget._tof_range_label.text, f"ToF range: {range_min} - {range_max}") + self.assertEqual(self.spectrum_plot_widget._tof_range_label.text, + f"ToF range: {range_min:.3f} - {range_max:.3f}") def test_WHEN_get_roi_called_THEN_SensibleROI_returned(self): spectrum_roi = SpectrumROI("roi", @@ -136,8 +137,8 @@ def test_WHEN_remove_roi_called_THEN_roi_removed_from_roi_dict(self): self.assertNotIn(spectrum_roi, self.spectrum_widget.image.vb.addedItems) def test_WHEN_set_tof_range_called_THEN_range_control_set_correctly(self): - self.spectrum_plot_widget._set_tof_range_label(0, 100) - self.assertEqual(self.spectrum_plot_widget._tof_range_label.text, "ToF range: 0 - 100") + self.spectrum_plot_widget.set_tof_range_label(0, 100) + self.assertEqual(self.spectrum_plot_widget._tof_range_label.text, "ToF range: 0.000 - 100.000") def test_WHEN_rename_roi_called_THEN_roi_renamed(self): spectrum_roi = SpectrumROI("roi_1", diff --git a/mantidimaging/gui/windows/spectrum_viewer/view.py b/mantidimaging/gui/windows/spectrum_viewer/view.py index a1519d7772d..329fc8a2d58 100644 --- a/mantidimaging/gui/windows/spectrum_viewer/view.py +++ b/mantidimaging/gui/windows/spectrum_viewer/view.py @@ -3,17 +3,17 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypedDict from PyQt5.QtGui import QPixmap from PyQt5.QtWidgets import QCheckBox, QVBoxLayout, QFileDialog, QPushButton, QLabel, QAbstractItemView, QHeaderView, \ - QTabWidget, QComboBox, QSpinBox, QTableWidget, QTableWidgetItem, QGroupBox + QTabWidget, QComboBox, QSpinBox, QTableWidget, QTableWidgetItem, QGroupBox, QActionGroup, QAction from PyQt5.QtCore import QSignalBlocker, Qt from mantidimaging.core.utility import finder from mantidimaging.gui.mvp_base import BaseMainWindowView from mantidimaging.gui.widgets.dataset_selector import DatasetSelectorWidgetView -from .model import ROI_RITS +from .model import ROI_RITS, ToFUnitMode from .presenter import SpectrumViewerWindowPresenter, ExportMode from mantidimaging.gui.widgets import RemovableRowTableView from .spectrum_widget import SpectrumWidget @@ -27,6 +27,11 @@ from uuid import UUID +class AllowedModesTypedDict(TypedDict): + mode: ToFUnitMode + label: str + + class SpectrumViewerWindowView(BaseMainWindowView): tableView: RemovableRowTableView sampleStackSelector: DatasetSelectorWidgetView @@ -81,6 +86,39 @@ def __init__(self, main_window: MainWindowView): self.spectrum_widget.roi_changed.connect(self.presenter.handle_roi_moved) self.spectrum_widget.roiColorChangeRequested.connect(self.presenter.change_roi_colour) + self.spectrum_right_click_menu = self.spectrum_widget.spectrum_plot_widget.spectrum.vb.menu + self.units_menu = self.spectrum_right_click_menu.addMenu("Units") + self.tof_mode_select_group = QActionGroup(self) + + self.allowed_modes: dict[str, AllowedModesTypedDict] = { + "Image Index": { + "mode": ToFUnitMode.IMAGE_NUMBER, + "label": "Image index" + }, + "Wavelength": { + "mode": ToFUnitMode.WAVELENGTH, + "label": "Neutron Wavelength (\u212B)" + }, + "Energy": { + "mode": ToFUnitMode.ENERGY, + "label": "Neutron Energy (MeV)" + }, + "Time of Flight (\u03BCs)": { + "mode": ToFUnitMode.TOF_US, + "label": "Time of Flight (\u03BCs)" + } + } + for mode in self.allowed_modes.keys(): + action = QAction(mode, self.tof_mode_select_group) + action.setCheckable(True) + action.setObjectName(mode) + self.units_menu.addAction(action) + action.triggered.connect(self.presenter.handle_tof_unit_change) + if mode == "Image Index": + action.setChecked(True) + if self.presenter.model.tof_data is None: + self.tof_mode_select_group.setEnabled(False) + self._current_dataset_id = None self.sampleStackSelector.stack_selected_uuid.connect(self.presenter.handle_sample_change) self.sampleStackSelector.stack_selected_uuid.connect(self.presenter.handle_button_enabled) @@ -103,6 +141,7 @@ def __init__(self, main_window: MainWindowView): self.sampleStackSelector.select_eligible_stack() self.try_to_select_relevant_normalise_stack("Flat") + self.presenter.handle_tof_unit_change() self.exportButton.clicked.connect(self.presenter.handle_export_csv) self.exportButtonRITS.clicked.connect(self.presenter.handle_rits_export) @@ -400,7 +439,10 @@ def set_roi_alpha(self, alpha: float, roi_name: str) -> None: def show_visible_spectrums(self): for key, value in self.spectrum_widget.spectrum_data_dict.items(): if value is not None and key in self.spectrum_widget.roi_dict: - self.spectrum_widget.spectrum.plot(value, name=key, pen=self.spectrum_widget.roi_dict[key].colour) + self.spectrum_widget.spectrum.plot(self.presenter.model.tof_data, + value, + name=key, + pen=self.spectrum_widget.roi_dict[key].colour) def add_roi_table_row(self, name: str, colour: tuple[int, int, int]): """