diff --git a/debye_bec/bec_widgets/widgets/client.py b/debye_bec/bec_widgets/widgets/client.py index c82894c..af97cf7 100644 --- a/debye_bec/bec_widgets/widgets/client.py +++ b/debye_bec/bec_widgets/widgets/client.py @@ -4,7 +4,6 @@ from __future__ import annotations from bec_lib.logger import bec_logger - from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call, rpc_timeout logger = bec_logger.logger @@ -15,6 +14,7 @@ logger = bec_logger.logger _Widgets = { "DataViewer": "DataViewer", "DigitalTwin": "DigitalTwin", + "ScanControlXAS": "ScanControlXAS", } @@ -64,3 +64,26 @@ class DigitalTwin(RPCBase): """ Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. """ + + +class ScanControlXAS(RPCBase): + _IMPORT_MODULE = "debye_bec.bec_widgets.widgets.scan_control_xas.scan_control_xas" + + @rpc_call + def attach(self): + """ + None + """ + + @rpc_call + def detach(self): + """ + Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. + """ + + @rpc_timeout(None) + @rpc_call + def screenshot(self, file_name: "str | None" = None): + """ + Take a screenshot of the dock area and save it to a file. + """ diff --git a/debye_bec/bec_widgets/widgets/designer_plugins.py b/debye_bec/bec_widgets/widgets/designer_plugins.py index a571172..b098ee3 100644 --- a/debye_bec/bec_widgets/widgets/designer_plugins.py +++ b/debye_bec/bec_widgets/widgets/designer_plugins.py @@ -7,6 +7,10 @@ from __future__ import annotations designer_plugins = { "DataViewer": ("debye_bec.bec_widgets.widgets.data_viewer.data_viewer", "DataViewer"), "DigitalTwin": ("debye_bec.bec_widgets.widgets.digital_twin.digital_twin", "DigitalTwin"), + "ScanControlXAS": ( + "debye_bec.bec_widgets.widgets.scan_control_xas.scan_control_xas", + "ScanControlXAS", + ), } -widget_icons = {"DataViewer": "find_in_page", "DigitalTwin": "lightbulb"} +widget_icons = {"DataViewer": "find_in_page", "DigitalTwin": "lightbulb", "ScanControlXAS": "tune"} diff --git a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py index b8ff05f..f5fcbfe 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/digital_twin.py @@ -35,6 +35,7 @@ from qtpy.QtWidgets import ( QWidget, ) +from ..edge_selector import EdgeSelector from .beamline import get_beamline_id from .calculations.calc_positions import calc_positions from .calculations.calc_sideview import calc_sideview @@ -66,6 +67,11 @@ logger = bec_logger.logger OFFSET_FILE_X01DA = Path(__file__).with_name("x01da_offsets.yaml") OFFSET_FILE_X10DA = Path(__file__).with_name("x10da_offsets.yaml") +X01DA_E_MIN = 4500 +X01DA_E_MAX = 60000 +X10DA_E_MIN = 4500 +X10DA_E_MAX = 35000 + class DigitalTwin(BECWidget, QWidget): """ @@ -140,6 +146,7 @@ class DigitalTwin(BECWidget, QWidget): self.setWindowTitle("Digital Twin") self.resize(1450, 950) + self.input.edge_selector.clicked_connect(self.open_edge_selector) self.input.energy.value_changed_connect(self.calc_assistant) self.input.sldi_hacc.value_changed_connect(self.calc_assistant) self.input.sldi_vacc.value_changed_connect(self.calc_assistant) @@ -162,6 +169,7 @@ class DigitalTwin(BECWidget, QWidget): self.settings.load_offsets.clicked_connect(self.load_offsets) self.settings.show_offsets.clicked_connect(self.show_offsets) + self.edge_selector_energy = 0.0 self.bragg_angle = 0.0 self.qy = 0.0 self.offsets = {} @@ -326,6 +334,7 @@ class DigitalTwin(BECWidget, QWidget): self.calc_fm_ideal_pitch() self.calc_mo1_energy_resolution() case "energy": + self.update_edge_selected() self.calc_mo1_bragg_angle() self.calc_cm_crit_pitch() self.calc_cm_reflectivity() @@ -789,6 +798,24 @@ class DigitalTwin(BECWidget, QWidget): surfaces = calc_surfaces(config) self.surface_plots.update_surfaces(scene="reality", data=surfaces) + @SafeSlot() + def open_edge_selector(self, *_): + match self.beamline: + case "x01da": + dlg = EdgeSelector(self, llim=X01DA_E_MIN, hlim=X01DA_E_MAX) + case "x10da": + dlg = EdgeSelector(self, llim=X10DA_E_MIN, hlim=X10DA_E_MAX) + case _: + dlg = EdgeSelector(self) + if dlg.exec_(): + self.input.edge_selected.setText(f"{dlg.selected_element}, {dlg.selected_edge}-edge") + self.edge_selector_energy = dlg.selected_energy + self.input.energy.set_number(dlg.selected_energy) + + def update_edge_selected(self): + if self.input.energy.value() != self.edge_selector_energy: + self.input.edge_selected.setText("-") + def calc_mo1_energy_resolution(self): """ Calculates the energy resolution of the monochromator diff --git a/debye_bec/bec_widgets/widgets/digital_twin/panels/input_panel.py b/debye_bec/bec_widgets/widgets/digital_twin/panels/input_panel.py index c0ae458..8bebec6 100644 --- a/debye_bec/bec_widgets/widgets/digital_twin/panels/input_panel.py +++ b/debye_bec/bec_widgets/widgets/digital_twin/panels/input_panel.py @@ -5,10 +5,17 @@ Panel for user inputs of the digital twin widget from typing import Union # pylint: disable=E0611 -from qtpy.QtWidgets import QVBoxLayout, QWidget +from qtpy.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget from ..types import BeamlineId -from ..widgets.qt_widgets import Button, ComboBox, Group, InputNumberField, NumberIndicator +from ..widgets.qt_widgets import ( + Button, + ComboBox, + Group, + InputNumberField, + NumberIndicator, + TextIndicator, +) class InputPanel(QWidget): @@ -28,11 +35,28 @@ class InputPanel(QWidget): # Adapt to reality self.adapt_reality = Button(label_button="Adapt to reality", enabled=True) - # Energy + # edge_selector_layout = QVBoxLayout() + # edge_selector_label = QLabel("Absorption edge:") + # self.edge_selector_button = QPushButton("Choose") + # self.edge_label = QLabel("No edge selected") + # edge_selector_layout.addWidget(edge_selector_label) + # edge_selector_layout.addWidget(self.edge_label) + # edge_selector_layout.addWidget(self.edge_selector_button) + # edge_selector_layout.addStretch() + + # Edge selector + self.edge_selector = Button("Absorption edge", "Choose", enabled=True) + + # Chosen absorption edge + self.edge_selected = TextIndicator("Chosen edge") + + # Direct Energy Input self.energy = InputNumberField( "energy", "Energy", unit="eV", init=8979, decimals=0, single_step=100, ll=4000, hl=65000 ) + self.energy_group = Group("Energy", [self.edge_selector, self.edge_selected, self.energy]) + # FE Slits Acceptance self.sldi_hacc = InputNumberField( "h_acc", @@ -156,7 +180,7 @@ class InputPanel(QWidget): "User Input", [ self.adapt_reality, - self.energy, + self.energy_group, self.sldi_ass_group, self.cm_ass_group, self.mo1_ass_group, diff --git a/debye_bec/bec_widgets/widgets/edge_selector.py b/debye_bec/bec_widgets/widgets/edge_selector.py new file mode 100644 index 0000000..e2d62cc --- /dev/null +++ b/debye_bec/bec_widgets/widgets/edge_selector.py @@ -0,0 +1,287 @@ +""" +Edge Selector: Custom BEC dialog to select an absorption edge. +""" + +import sys + +import xraydb +from bec_lib import bec_logger +from bec_widgets.utils.bec_dispatcher import BECDispatcher +from bec_widgets.utils.colors import apply_theme, get_accent_colors +from bec_widgets.utils.error_popups import SafeSlot + +# pylint: disable=E0611 +from qtpy.QtWidgets import ( + QApplication, + QComboBox, + QDialog, + QGridLayout, + QHBoxLayout, + QLabel, + QPushButton, + QVBoxLayout, +) + +logger = bec_logger.logger + +ELEMENTS = { + # period 1 + "H": (1, 1), + "He": (1, 18), + # period 2 + "Li": (2, 1), + "Be": (2, 2), + "B": (2, 13), + "C": (2, 14), + "N": (2, 15), + "O": (2, 16), + "F": (2, 17), + "Ne": (2, 18), + # period 3 + "Na": (3, 1), + "Mg": (3, 2), + "Al": (3, 13), + "Si": (3, 14), + "P": (3, 15), + "S": (3, 16), + "Cl": (3, 17), + "Ar": (3, 18), + # period 4 + "K": (4, 1), + "Ca": (4, 2), + "Sc": (4, 3), + "Ti": (4, 4), + "V": (4, 5), + "Cr": (4, 6), + "Mn": (4, 7), + "Fe": (4, 8), + "Co": (4, 9), + "Ni": (4, 10), + "Cu": (4, 11), + "Zn": (4, 12), + "Ga": (4, 13), + "Ge": (4, 14), + "As": (4, 15), + "Se": (4, 16), + "Br": (4, 17), + "Kr": (4, 18), + # period 5 + "Rb": (5, 1), + "Sr": (5, 2), + "Y": (5, 3), + "Zr": (5, 4), + "Nb": (5, 5), + "Mo": (5, 6), + "Tc": (5, 7), + "Ru": (5, 8), + "Rh": (5, 9), + "Pd": (5, 10), + "Ag": (5, 11), + "Cd": (5, 12), + "In": (5, 13), + "Sn": (5, 14), + "Sb": (5, 15), + "Te": (5, 16), + "I": (5, 17), + "Xe": (5, 18), + # period 6 + "Cs": (6, 1), + "Ba": (6, 2), + "La": (6, 3), + # lanthanides (row 8) + "Ce": (8, 4), + "Pr": (8, 5), + "Nd": (8, 6), + "Pm": (8, 7), + "Sm": (8, 8), + "Eu": (8, 9), + "Gd": (8, 10), + "Tb": (8, 11), + "Dy": (8, 12), + "Ho": (8, 13), + "Er": (8, 14), + "Tm": (8, 15), + "Yb": (8, 16), + "Lu": (8, 17), + # period 6 continued + "Hf": (6, 4), + "Ta": (6, 5), + "W": (6, 6), + "Re": (6, 7), + "Os": (6, 8), + "Ir": (6, 9), + "Pt": (6, 10), + "Au": (6, 11), + "Hg": (6, 12), + "Tl": (6, 13), + "Pb": (6, 14), + "Bi": (6, 15), + "Po": (6, 16), + "At": (6, 17), + "Rn": (6, 18), + # period 7 + "Fr": (7, 1), + "Ra": (7, 2), + "Ac": (7, 3), + # actinides (row 9) + "Th": (9, 4), + "Pa": (9, 5), + "U": (9, 6), + "Np": (9, 7), + "Pu": (9, 8), + "Am": (9, 9), + "Cm": (9, 10), + "Bk": (9, 11), + "Cf": (9, 12), +} + + +class EdgeSelector(QDialog): + """BEC widget to display a selection dialog where the user can select and edge and an element""" + + def __init__(self, parent=None, llim: int | float = 0, hlim: int | float = 1e6): + super().__init__(parent) + self.setWindowTitle("Absorption Edge Selector") + self._buttons = {} + self.selected_edge = None + self.selected_element = None + self.selected_energy = None + self._llim = llim + self._hlim = hlim + + layout = QVBoxLayout(self) + + text = QLabel("Select edge and element.") + + edge_layout = QHBoxLayout() + edge_label = QLabel("Edge") + self._edge = QComboBox() + self._edge.addItems(["K", "L1", "L2", "L3"]) + edge_layout.addWidget(edge_label) + edge_layout.addWidget(self._edge) + edge_layout.addStretch() + + grid_layout = QGridLayout() + grid_layout.setContentsMargins(0, 0, 0, 0) + grid_layout.setSpacing(1) + + for element, (row, col) in ELEMENTS.items(): + button = QPushButton(element) + button.setFixedSize(42, 32) + button.setCheckable(True) + button.setAutoDefault(False) + button.setDefault(False) + button.clicked.connect(lambda checked=False, e=element: self._element_changed(e)) + grid_layout.addWidget(button, row - 1, col - 1) + self._buttons[element] = button + + res_layout = QHBoxLayout() + self._res = QLabel("") + res_layout.addStretch() + res_layout.addWidget(self._res) + res_layout.addStretch() + + button_layout = QHBoxLayout() + self._cancel_button = QPushButton("Cancel") + self._cancel_button.clicked.connect(self.reject) + self._cancel_button.setStyleSheet( + f"QPushButton {{background-color: {get_accent_colors().default.name()}; color: white;}}" + ) + self._select_button = QPushButton("Select") + self._select_button.setEnabled(False) + self._select_button.clicked.connect(self.accept) + self._select_button.setStyleSheet( + f"QPushButton {{background-color: {get_accent_colors().default.name()}; color: white;}}" + ) + + button_layout.addStretch() + button_layout.addWidget(self._cancel_button) + button_layout.addWidget(self._select_button) + button_layout.addStretch() + + layout.addWidget(text) + layout.addLayout(edge_layout) + layout.addLayout(grid_layout) + layout.addLayout(res_layout) + layout.addLayout(button_layout) + + self.adjustSize() + self.setFixedSize(self.sizeHint()) + + self._edge.activated.connect(self._edge_changed) + self._edge_changed() + + @SafeSlot() + def _edge_changed(self, *_): + self.selected_edge = self._edge.currentText() + for element, button in self._buttons.items(): + if "L" in self.selected_edge and element in ["H", "He"]: + button.setDisabled(True) + button.setChecked(False) + elif self.selected_edge in ["L2", "L3"] and element == "Li": + button.setDisabled(True) + button.setChecked(False) + else: + energy = xraydb.xray_edge(element, self.selected_edge, True) + if self.selected_element == element: + self.selected_energy = energy + if energy is not None: + if energy < self._llim or energy > self._hlim: + button.setChecked(False) + button.setDisabled(True) + else: + button.setEnabled(True) + else: + button.setChecked(False) + button.setDisabled(True) + self._set_select_text() + + @SafeSlot() + def _element_changed(self, element: str | None = None): + if element is None: + if self.selected_element is None: + return + element = self.selected_element + for e, btn in self._buttons.items(): + if e != element: + btn.setChecked(False) + self._buttons[element].setChecked(True) + self.selected_element = element + self.selected_energy = xraydb.xray_edge(element, self.selected_edge, True) + self._select_button.setEnabled(True) + self._set_select_text() + + def _set_select_text(self): + if self.selected_energy is not None: + if self._buttons[self.selected_element].isChecked(): + self._enable_button(self._select_button, True) + self._select_button.setText( + f"Selected {self.selected_element}, {self.selected_edge}-edge, {self.selected_energy:0.1f} eV" + ) + else: + self._enable_button(self._select_button, False) + self._select_button.setText("Select") + else: + self._enable_button(self._select_button, False) + + @staticmethod + def _enable_button(button: QPushButton, enable: bool): + if enable: + button.setEnabled(True) + button.setStyleSheet( + f"QPushButton {{background-color: {get_accent_colors().default.name()}; color: white;}}" + ) + else: + button.setDisabled(True) + button.setStyleSheet( + "QPushButton {{background-color: rgb(120, 120, 120); color: white;}}" + ) + + +if __name__ == "__main__": + app = QApplication(sys.argv) + apply_theme("light") + dispatcher = BECDispatcher(gui_id="edge_selector") + win = EdgeSelector() + win.show() + sys.exit(app.exec_()) diff --git a/debye_bec/bec_widgets/widgets/scan_control_xas/__init__,py b/debye_bec/bec_widgets/widgets/scan_control_xas/__init__,py new file mode 100644 index 0000000..e69de29 diff --git a/debye_bec/bec_widgets/widgets/scan_control_xas/qt_widgets.py b/debye_bec/bec_widgets/widgets/scan_control_xas/qt_widgets.py new file mode 100644 index 0000000..0f4f1ef --- /dev/null +++ b/debye_bec/bec_widgets/widgets/scan_control_xas/qt_widgets.py @@ -0,0 +1,264 @@ +from typing import Literal, Optional + +from bec_widgets.utils.colors import get_accent_colors +from bec_widgets.utils.error_popups import SafeSlot +from qtpy.QtCore import Signal + +# pylint: disable=E0611 +from qtpy.QtWidgets import ( + QApplication, + QComboBox, + QDialog, + QGridLayout, + QHBoxLayout, + QLabel, + QPushButton, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +LABEL_WIDTH = 118 +ROW_MARGINS = (4, 0, 4, 0) +ROW_SPACING = 6 + + +class Button(QWidget): + def __init__(self, label=None, label_button: str = "", enabled=False): + super().__init__() + layout = QHBoxLayout(self) + layout.setContentsMargins(*ROW_MARGINS) + layout.setSpacing(ROW_SPACING) + if label is not None: + self.label = QLabel(label) + self.label.setFixedWidth(LABEL_WIDTH) + layout.addWidget(self.label) + self.button = QPushButton(label_button) + self.enable_button(enabled) + layout.addWidget(self.button) + + def clicked_connect(self, func): + """Connect a function to the button press.""" + self.button.clicked.connect(func) + + def enable_button(self, enable: bool = False): + if enable: + self.button.setStyleSheet( + f"QPushButton {{background-color: {get_accent_colors().default.name()}; color: white;}}" + ) + self.button.setEnabled(True) + else: # disabled + self.button.setStyleSheet( + "QPushButton {{background-color: rgb(120, 120, 120); color: white;}}" + ) + self.button.setDisabled(True) + + def setText(self, text): + self.button.setText(text) + + +class ComboBox(QWidget): + def __init__(self, identifier="", label="", enums=None): + super().__init__() + + layout = QVBoxLayout(self) + layout.setContentsMargins(*ROW_MARGINS) + layout.setSpacing(ROW_SPACING) + + self.identifier = identifier + + self.label = QLabel(label) + self.label.setFixedWidth(LABEL_WIDTH) + self.label.setWordWrap(True) + self.label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred) + layout.addWidget(self.label) + + self.value = QComboBox() + self.value.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + + for entry in enums or []: + self.value.addItem(entry) + + layout.addWidget(self.value) + + def set_current_text(self, text): + self.value.setCurrentText(text) + + def currentText(self) -> str: + return self.value.currentText() + + def has_focus(self) -> bool: + return QApplication.focusWidget() is self.value.view() + + def activated_connect(self, func): + """Connect a function to the Enter/Return key press.""" + self.value.activated.connect(func) + + def setDisabled(self, disable): + self.value.setDisabled(disable) + + +class TextIndicator(QWidget): + def __init__(self, identifier="", label="", text=""): + super().__init__() + + layout = QVBoxLayout(self) + layout.setContentsMargins(*ROW_MARGINS) + layout.setSpacing(ROW_SPACING) + + self.identifier = identifier + + self.label = QLabel(label) + self.label.setFixedWidth(LABEL_WIDTH) + self.label.setWordWrap(True) + self.label.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred) + layout.addWidget(self.label) + + self.value = QLabel(text) + self.value.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + + layout.addWidget(self.value) + + def setText(self, text): + self.value.setText(text) + + def text(self) -> str: + return self.value.text() + + +ELEMENTS = { + # period 4 + "Sc": (4, 3), + "Ti": (4, 4), + "V": (4, 5), + "Cr": (4, 6), + "Mn": (4, 7), + "Fe": (4, 8), + "Co": (4, 9), + "Ni": (4, 10), + "Cu": (4, 11), + "Zn": (4, 12), + "Ga": (4, 13), + "Ge": (4, 14), + "As": (4, 15), + "Se": (4, 16), + "Br": (4, 17), + "Kr": (4, 18), + # period 5 + "Rb": (5, 1), + "Sr": (5, 2), + "Y": (5, 3), + "Zr": (5, 4), + "Nb": (5, 5), + "Mo": (5, 6), + "Tc": (5, 7), + "Ru": (5, 8), + "Rh": (5, 9), + "Pd": (5, 10), + "Ag": (5, 11), + "Cd": (5, 12), + "In": (5, 13), + "Sn": (5, 14), + "Sb": (5, 15), + "Te": (5, 16), + "I": (5, 17), + "Xe": (5, 18), + # period 6 + "Cs": (6, 1), + "Ba": (6, 2), + "La": (8, 3), + "Ce": (8, 4), + "Pr": (8, 5), + "Nd": (8, 6), + "Pm": (8, 7), + "Sm": (8, 8), + "Eu": (8, 9), + "Gd": (8, 10), + "Tb": (8, 11), + "Dy": (8, 12), + "Ho": (8, 13), + "Er": (8, 14), + "Tm": (8, 15), + "Pb": (6, 14), + "Bi": (6, 15), + "Po": (6, 16), + "At": (6, 17), + "Rn": (6, 18), + # period 7 + "Fr": (7, 1), + "Ra": (7, 2), + "Ac": (9, 3), + "Th": (9, 4), + "Pa": (9, 5), + "U": (9, 6), +} + + +class PeriodicTableDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.selected = None + + layout = QGridLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(1) + + for element, (row, col) in ELEMENTS.items(): + button = QPushButton(element) + button.setFixedSize(42, 32) + button.clicked.connect(lambda checked=False, e=element: self.select(e)) + layout.addWidget(button, row - 1, col - 1) + + @SafeSlot() + def select(self, element): + self.selected = element + self.accept() + + +class ElementSelector(QWidget): + + dialogClosed = Signal() + + def __init__(self): + super().__init__() + self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + layout = QVBoxLayout(self) + layout.setContentsMargins(*ROW_MARGINS) + layout.setSpacing(ROW_SPACING) + self.label = QLabel("Element") + layout_selection = QHBoxLayout(self) + self.button = QPushButton("Select") + self.button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + self.button.setStyleSheet( + f"QPushButton {{background-color: {get_accent_colors().default.name()}; color: white;}}" + ) + layout_selection.addWidget(self.button) + layout.addWidget(self.label) + layout.addLayout(layout_selection) + self.current_element = "" + + self.button.clicked.connect(self.open_table) + + def open_table(self): + dialog = PeriodicTableDialog(self) + if dialog.exec_(): + self.current_element = dialog.selected + self.dialogClosed.emit() + + def currentElement(self): + return self.current_element + + def apply_theme(self, theme: Optional[Literal["dark", "light"]] = None): + """ + Apply the theme + + Args: + theme (Optional[str]): Theme, either "dark", "light", or None. Defaults to None. + """ + if theme is None: + app = QApplication.instance() + theme = app.theme.theme # type: ignore + + self.button.setStyleSheet( + f"QPushButton {{background-color: {get_accent_colors().default.name()}; color: white;}}" + ) diff --git a/debye_bec/bec_widgets/widgets/scan_control_xas/register_scan_control_xas.py b/debye_bec/bec_widgets/widgets/scan_control_xas/register_scan_control_xas.py new file mode 100644 index 0000000..c3fb9e7 --- /dev/null +++ b/debye_bec/bec_widgets/widgets/scan_control_xas/register_scan_control_xas.py @@ -0,0 +1,15 @@ +def main(): # pragma: no cover + from qtpy import PYSIDE6 + + if not PYSIDE6: + print("PYSIDE6 is not available in the environment. Cannot patch designer.") + return + from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection + + from .scan_control_xas_plugin import ScanControlXASPlugin + + QPyDesignerCustomWidgetCollection.addCustomWidget(ScanControlXASPlugin()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/debye_bec/bec_widgets/widgets/scan_control_xas/scan_control_xas.py b/debye_bec/bec_widgets/widgets/scan_control_xas/scan_control_xas.py new file mode 100644 index 0000000..995ea76 --- /dev/null +++ b/debye_bec/bec_widgets/widgets/scan_control_xas/scan_control_xas.py @@ -0,0 +1,421 @@ +""" +Scan Control XAS: Custom BEC widget for Scan Control XAS scans. +""" + +import sys +from typing import Literal, Optional + +import numpy as np +import pyqtgraph as pg +from bec_lib import bec_logger +from bec_lib.endpoints import MessageEndpoints +from bec_widgets.utils.bec_dispatcher import BECDispatcher +from bec_widgets.utils.colors import Colors, apply_theme, get_accent_colors +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.scan_control.scan_control import ScanControl +from qtpy.QtCore import Qt + +# pylint: disable=E0611 +from qtpy.QtWidgets import ( + QApplication, + QCheckBox, + QFrame, + QGroupBox, + QHBoxLayout, + QLabel, + QPushButton, + QScrollArea, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +# pylint: disable=E0402 +from ....devices.mo1_bragg.mo1_bragg_utils import compute_spline +from ..edge_selector import EdgeSelector + +logger = bec_logger.logger + +ALLOWED_SCANS = [ + "xas_simple_scan", + "xas_simple_scan_with_xrd", + "xas_advanced_scan", + "xas_advanced_scan_with_xrd", +] + +PLOT_RESOLUTION = 100 +H = 6.62606957e-34 +E = 1.602176634e-19 +C = 299792458 + +X01DA_E_MIN = 4500 +X01DA_E_MAX = 60000 +X10DA_E_MIN = 4500 +X10DA_E_MAX = 35000 + + +class ScanControlXAS(ScanControl): + """ + Main widget of Scan Control XAS + """ + + def __init__(self, *args, **kwargs): + self._xas_scans_helper_group = QGroupBox("XAS scan helper") + self._xas_scans_helper_group.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + xas_scans_helper_layout = QVBoxLayout(self._xas_scans_helper_group) + self.xas_scans_helper_widget = XASScansHelper(self) + xas_scans_helper_layout.addWidget(self.xas_scans_helper_widget) + super().__init__( + allowed_scans=ALLOWED_SCANS, default_scan="xas_simple_scan", *args, **kwargs + ) + self._show_motion_profile(self._selected_scan) + + self.d_spacing = 0 + self.bec_dispatcher.connect_slot( + self._update_d_spacing, MessageEndpoints.device_readback("mo1_bragg") + ) + + @SafeSlot(dict, dict) + def _update_d_spacing(self, msg: dict, _: dict): + d_spacing = msg["signals"].get("mo1_bragg_crystal_current_d_spacing")["value"] + if d_spacing != self.d_spacing: + self.d_spacing = d_spacing + self.xas_scans_helper_widget.update_plot(d_spacing=d_spacing) + + def _add_metadata_form(self): + self.layout.addWidget(self._xas_scans_helper_group) # type: ignore + super()._add_metadata_form() + self._connect_scan_parameter_signals() + + def on_scan_selection_changed(self, scan_name: str): + super().on_scan_selection_changed(scan_name) + self._show_motion_profile(scan_name) + if scan_name in ALLOWED_SCANS: + self._connect_scan_parameter_signals() + self._scan_parameter_changed() + + def _show_motion_profile(self, scan_name): + if scan_name in ALLOWED_SCANS: + self.xas_scans_helper_widget.setVisible(True) + else: + self.xas_scans_helper_widget.setVisible(False) + + def _connect_scan_parameter_signals(self): + for box in self.kwarg_boxes: + for widget in box.findChildren(QWidget): + try: + if hasattr(widget, "valueChanged"): + widget.valueChanged.connect( + self._scan_parameter_changed, Qt.ConnectionType.UniqueConnection + ) + elif hasattr(widget, "textChanged"): + widget.textChanged.connect( + self._scan_parameter_changed, Qt.ConnectionType.UniqueConnection + ) + except TypeError: + # Raised if a connection would not be unique anymore, i.e. if + # connect_scan_parameter_signals is called more than once with + # the same kwarg_boxes + pass + + @SafeSlot() + def _scan_parameter_changed(self, *_): + params = self.get_scan_parameters()[1] + self.xas_scans_helper_widget.update_plot( + scan_name=self._selected_scan, scan_parameters=params + ) + + def apply_theme(self, theme: Literal["dark", "light"]): + """ + Apply the theme + + Args: + theme (str): Theme, either "dark" or "light" + """ + self.xas_scans_helper_widget.apply_theme(theme) + + +class XASScansHelper(QWidget): + """ + Additional widget specifically for XAS scans + """ + + def __init__(self, parent): + super().__init__() + self._parent_widget = parent + self.beamline = "" + + outer_layout = QVBoxLayout(self) + outer_layout.setContentsMargins(0, 0, 0, 0) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + edge_selector_layout = QHBoxLayout() + edge_selector_label = QLabel("Absorption edge:") + self.edge_selector_button = QPushButton("Choose") + self.edge_label = QLabel("No edge selected") + edge_selector_layout.addWidget(edge_selector_label) + edge_selector_layout.addWidget(self.edge_label) + edge_selector_layout.addWidget(self.edge_selector_button) + edge_selector_layout.addStretch() + + scan_rng_layout = QHBoxLayout() + self.scan_rng_ckbox = QCheckBox(self) + self.scan_rng_ckbox.setCheckState(Qt.CheckState.Checked) + scan_rng_label = QLabel("Auto adjust scan range to absorption edge") + scan_rng_layout.addWidget(self.scan_rng_ckbox) + scan_rng_layout.addWidget(scan_rng_label) + scan_rng_layout.addStretch() + + plot_layout = QHBoxLayout() + plot_layout.setContentsMargins(0, 0, 0, 0) + self.plot_pos = self._make_plot(left_label="Energy
[eV]", bottom_label="Time [s]") + self.plot_vel = self._make_plot( + left_label="Scan Velocity
[eV/s]", bottom_label="Energy [eV]" + ) + plot_layout.addWidget(self.plot_pos) + plot_layout.addWidget(self.plot_vel) + + self.edge_energy = None + + self.edge_selection = QHBoxLayout() + self.edge_selection.addLayout(edge_selector_layout) + self.edge_selection.addStretch(1) + + outer_layout.addLayout(self.edge_selection) + outer_layout.addLayout(scan_rng_layout) + outer_layout.addLayout(plot_layout) + + self.scan_name = None + self.scan_parameters = None + self.d_spacing = None + + self.edge_selector_button.clicked.connect(self._update_edge) + self.scan_rng_ckbox.stateChanged.connect(self._update_auto_scan_rng) + + self.apply_theme() + + def _make_plot(self, left_label: str, bottom_label: str) -> pg.PlotWidget: + plot = pg.PlotWidget() + plot.setLabel("left", left_label) + plot.setLabel("bottom", bottom_label) + plot.setMouseEnabled(x=False, y=False) + plot.setMenuEnabled(False) + plot.hideButtons() + plot.getAxis("left").enableAutoSIPrefix(False) + plot.getAxis("bottom").enableAutoSIPrefix(False) + plot.setMinimumHeight(150) + plot.setMaximumHeight(150) + plot.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + return plot + + def _add_edge_marker(self, plot, x_arr, y_arr, energy_arr, edge_marker): + if self.edge_energy is None: + return + + idx = int(np.argmin(np.abs(np.asarray(energy_arr) - self.edge_energy))) + if idx == 0 or idx == len(energy_arr) - 1: + return + x_marker = x_arr[idx] + y_marker = y_arr[idx] + + scatter = pg.ScatterPlotItem( + [x_marker], + [y_marker], + symbol=edge_marker, + size=20, + brush=pg.mkBrush("r"), + pen=pg.mkPen("r"), + ) + plot.addItem(scatter, ignoreBounds=True) + + label = pg.TextItem(self.edge_label.text(), anchor=(0, 1.5), color="r") + label.setPos(x_marker, y_marker) + plot.addItem(label, ignoreBounds=True) + + @SafeSlot() + def _update_edge(self, *_): + if self.beamline == "": + hostname = self._parent_widget.client._hostname + start = hostname.find("x") + if start != -1: + self.beamline = hostname[start : start + 5] + match self.beamline: + case "x01da": + dlg = EdgeSelector(self, llim=X01DA_E_MIN, hlim=X01DA_E_MAX) + case "x10da": + dlg = EdgeSelector(self, llim=X10DA_E_MIN, hlim=X10DA_E_MAX) + case _: + dlg = EdgeSelector(self) + if dlg.exec_(): + self.edge_energy = dlg.selected_energy + self.edge_label.setText( + f"{dlg.selected_element}, {dlg.selected_edge}-edge, {dlg.selected_energy:0.1f} eV" + ) + self._update_auto_scan_rng() + self.update_plot() + + @SafeSlot() + def _update_auto_scan_rng(self, *_): + if self.scan_rng_ckbox.isChecked() and self.edge_energy is not None: + self._parent_widget._restore_kwargs( + { + "start": self.edge_energy - 200, + "stop": self.edge_energy + 1000, + "e_kink": self.edge_energy + 1000, + } + ) + + @SafeSlot() + def update_plot(self, *_, scan_name=None, scan_parameters=None, d_spacing=None, **__): + """Update the plots. Parameters which are not defined will default to None, + in which case it will use the internal values if available. + + Args: + scan_name(str): Scan name, e.g. "xas_simple_scan" + Defaults to None + scan_parameters(dict): Scan parameters from ScanControl + Defaults to None + d_spacing(float): d-spacing in Angstrom + Defaults to None + """ + if scan_name is not None: + self.scan_name = scan_name + if scan_parameters is not None: + self.scan_parameters = scan_parameters + if d_spacing is not None: + self.d_spacing = d_spacing + if self.scan_name is None: + return + if self.scan_parameters is None: + return + if self.d_spacing is None: + return + if d_spacing == 0: + return + + x_time = np.linspace(0, self.scan_parameters["scan_time"], PLOT_RESOLUTION) + + self.plot_pos.clear() + self.plot_vel.clear() + + start_angle = self._energy_to_angle(self.scan_parameters["stop"]) + stop_angle = self._energy_to_angle(self.scan_parameters["start"]) + if start_angle == stop_angle: + return + if "xas_simple_scan" in self.scan_name: + amp = (stop_angle - start_angle) / 2 + two_pi_f = 2 * np.pi * 1 / (2 * self.scan_parameters["scan_time"]) + pos = (start_angle + stop_angle) / 2 + amp * np.cos(x_time * two_pi_f) + vel = two_pi_f * amp * np.sin(x_time * two_pi_f) + energy = self._angle_to_energy(pos) + vel_e = self._velocity_to_energy_per_s(pos, vel) + + self.plot_pos.plot(x_time, energy) + self._add_edge_marker(self.plot_pos, x_time, energy, energy, "_") + + self.plot_vel.plot(energy, -vel_e) + self._add_edge_marker(self.plot_vel, energy, -vel_e, energy, "|") + + if "xas_advanced_scan" in self.scan_name: + if ( + self.scan_parameters["e_kink"] < self.scan_parameters["start"] + or self.scan_parameters["e_kink"] > self.scan_parameters["stop"] + ): + return + if self.scan_parameters["scan_time"] == 0: + return + e_kink_deg = self._energy_to_angle(self.scan_parameters["e_kink"]) + pos, vel, t = compute_spline( + start_angle, + stop_angle, + self.scan_parameters["p_kink"], + e_kink_deg, + self.scan_parameters["scan_time"], + ) + x_time = np.cumsum(t) / 1000 + energy = self._angle_to_energy(pos) + vel_e = self._velocity_to_energy_per_s(pos, vel) + + time_flipped = -np.flip(x_time) + x_time[-1] + energy_flipped = np.flip(energy) + self.plot_pos.plot(time_flipped, energy_flipped) + self._add_edge_marker(self.plot_pos, time_flipped, energy_flipped, energy_flipped, "_") + + self.plot_vel.plot(energy, -vel_e) + self._add_edge_marker(self.plot_vel, energy, -vel_e, energy, "|") + + self.apply_theme() + + def apply_theme(self, theme: Optional[Literal["dark", "light"]] = None): + """ + Apply the theme + + Args: + theme (Optional[str]): Theme, either "dark", "light", or None. Defaults to None. + """ + if theme is None: + app = QApplication.instance() + theme = app.theme.theme # type: ignore + + self.edge_selector_button.setStyleSheet( + f"QPushButton {{background-color: {get_accent_colors().default.name()}; color: white;}}" + ) + + bg_color = pg.getConfigOption("background") + fg_color = pg.getConfigOption("foreground") + + for plot in (self.plot_pos, self.plot_vel): + plot.setBackground(bg_color) + for axis in ("left", "bottom", "right", "top"): + ax = plot.getAxis(axis) + ax.setPen(pg.mkPen(color=fg_color)) + ax.setTextPen(pg.mkPen(color=fg_color)) + + curves = plot.listDataItems() + colors = Colors.golden_angle_color(colormap="plasma", num=len(curves), format="HEX") + for curve, color in zip(curves, colors): + if not isinstance(curve, pg.ScatterPlotItem): + curve.setPen(pg.mkPen(color=color, width=2)) + + def _energy_to_angle(self, energy: int | float): + if self.d_spacing is None: + return 0 + if energy <= 0: + return 0 + wl = C * H / (E * energy) + val = wl / (2 * self.d_spacing * 1e-10) + return np.asin(val) / np.pi * 180 + + def _angle_to_energy(self, angle: np.ndarray | int | float): + if self.d_spacing is None: + return 0 + if isinstance(angle, np.ndarray): + if angle.any() <= 0: + return 0 + else: + if angle <= 0: + return 0 + wl = 2 * self.d_spacing * 1e-10 * np.sin(angle / 180 * np.pi) + return C * H / (E * wl) + + def _velocity_to_energy_per_s(self, angle, velocity): + if self.d_spacing is None: + return 0 + return ( + -C + * H + * (np.pi / 180) + * np.cos(angle / 180 * np.pi) + / (E * 2 * self.d_spacing * 1e-10 * np.sin(angle / 180 * np.pi) ** 2) + ) * velocity + + +if __name__ == "__main__": + app = QApplication(sys.argv) + apply_theme("light") + dispatcher = BECDispatcher(gui_id="scan_control_advanced") + win = ScanControlXAS() + win.show() + sys.exit(app.exec_()) diff --git a/debye_bec/bec_widgets/widgets/scan_control_xas/scan_control_xas.pyproject b/debye_bec/bec_widgets/widgets/scan_control_xas/scan_control_xas.pyproject new file mode 100644 index 0000000..8ba607b --- /dev/null +++ b/debye_bec/bec_widgets/widgets/scan_control_xas/scan_control_xas.pyproject @@ -0,0 +1 @@ +{'files': ['scan_control_xas.py']} \ No newline at end of file diff --git a/debye_bec/bec_widgets/widgets/scan_control_xas/scan_control_xas_plugin.py b/debye_bec/bec_widgets/widgets/scan_control_xas/scan_control_xas_plugin.py new file mode 100644 index 0000000..0647be1 --- /dev/null +++ b/debye_bec/bec_widgets/widgets/scan_control_xas/scan_control_xas_plugin.py @@ -0,0 +1,57 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from bec_widgets.utils.bec_designer import designer_material_icon +from qtpy.QtDesigner import QDesignerCustomWidgetInterface +from qtpy.QtWidgets import QWidget + +from .scan_control_xas import ScanControlXAS + +DOM_XML = """ + + + + +""" + + +class ScanControlXASPlugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + if parent is None: + return QWidget() + t = ScanControlXAS(parent) + return t + + def domXml(self): + return DOM_XML + + def group(self): + return "" + + def icon(self): + return designer_material_icon(ScanControlXAS.ICON_NAME) + + def includeFile(self): + return "scan_control_xas" + + def initialize(self, form_editor): + self._form_editor = form_editor + + def isContainer(self): + return False + + def isInitialized(self): + return self._form_editor is not None + + def name(self): + return "ScanControlXAS" + + def toolTip(self): + return "ScanControlXAS" + + def whatsThis(self): + return self.toolTip() diff --git a/debye_bec/scans/xas_simple_scan.py b/debye_bec/scans/xas_simple_scan.py index d6a313f..b3be65d 100644 --- a/debye_bec/scans/xas_simple_scan.py +++ b/debye_bec/scans/xas_simple_scan.py @@ -244,7 +244,7 @@ class XasAdvancedScan(XasSimpleScan): stop: Annotated[float, ScanArgument(display_name="Stop Energy", description="Stop energy.", units=Units.eV)], scan_time: Annotated[float, ScanArgument(display_name="Scan Time", description="Time for one scan cycle.", units=Units.s, ge=0)], scan_duration: Annotated[float, ScanArgument(display_name="Scan Duration", description="Total scan duration.", units=Units.s, ge=0)], - p_kink: Annotated[float, ScanArgument(display_name="P Kink", description="Position of the kink.", ge=0)], + p_kink: Annotated[float, ScanArgument(display_name="P Kink", description="Position of the kink.", ge=0, le=100, units=Units.percent)], e_kink: Annotated[float, ScanArgument(display_name="E Kink", description="Energy of the kink.", units=Units.eV)], motor: Annotated[DeviceBase | None, ScanArgument(display_name="Motor", description="Bragg motor device.")] = None, daq: Annotated[DeviceBase | None, ScanArgument(display_name="DAQ", description="NIDAQ device.")] = None, diff --git a/pyproject.toml b/pyproject.toml index 813b6b4..792d73b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "ophyd_devices", "opencv-python==4.11.0.86", "xrt", + "xraydb", ] [project.optional-dependencies]