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]