Merge branch 'main' into feat/widget-development
CI for debye_bec / test (pull_request) Failing after 40s
CI for debye_bec / test (push) Successful in 1m17s

This commit is contained in:
2026-08-11 10:00:28 +02:00
13 changed files with 1131 additions and 7 deletions
+24 -1
View File
@@ -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.
"""
@@ -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"}
@@ -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
@@ -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,
@@ -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_())
@@ -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;}}"
)
@@ -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()
@@ -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<br>[eV]", bottom_label="Time [s]")
self.plot_vel = self._make_plot(
left_label="Scan Velocity<br>[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_())
@@ -0,0 +1 @@
{'files': ['scan_control_xas.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 = """
<ui language='c++'>
<widget class='ScanControlXAS' name='scan_control_xas'>
</widget>
</ui>
"""
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()
+1 -1
View File
@@ -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,
+1
View File
@@ -20,6 +20,7 @@ dependencies = [
"ophyd_devices",
"opencv-python==4.11.0.86",
"xrt",
"xraydb",
]
[project.optional-dependencies]