From 688a2fa89d647ca0d66b1dc980589c4fd76b5eb0 Mon Sep 17 00:00:00 2001 From: x01da Date: Thu, 6 Aug 2026 15:06:41 +0200 Subject: [PATCH] wip widget --- .../scan_control_advanced.py | 339 ++++++++++++++++-- 1 file changed, 313 insertions(+), 26 deletions(-) diff --git a/debye_bec/bec_widgets/widgets/scan_control_advanced/scan_control_advanced.py b/debye_bec/bec_widgets/widgets/scan_control_advanced/scan_control_advanced.py index 6c8d72d..44a4020 100644 --- a/debye_bec/bec_widgets/widgets/scan_control_advanced/scan_control_advanced.py +++ b/debye_bec/bec_widgets/widgets/scan_control_advanced/scan_control_advanced.py @@ -1,17 +1,34 @@ import sys +from functools import partial +from typing import Literal, Optional import numpy as np import pyqtgraph as pg +import xraydb 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 apply_theme +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, QTimer, Signal +from qtpy.QtCore import Qt # pylint: disable=E0611 -from qtpy.QtWidgets import QApplication, QDoubleSpinBox, QPushButton, QVBoxLayout, QWidget +from qtpy.QtWidgets import ( + QApplication, + QComboBox, + QDialog, + QFrame, + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QPushButton, + QScrollArea, + QSizePolicy, + QVBoxLayout, + QWidget, +) from ....devices.mo1_bragg.mo1_bragg_utils import compute_spline @@ -24,11 +41,16 @@ SHOW_MOTION_PROFILE = [ "xas_advanced_scan_with_xrd", ] +# TODO: Changing edge trigger change self.edge_energy. Theme change edge marker. Name of edge marker label should be element and edge + class ScanControlAdvanced(ScanControl): def __init__(self, *args, **kwargs): + self._motion_profile_group = QGroupBox("Motion Profile Preview") + motion_profile_layout = QVBoxLayout(self._motion_profile_group) self.motion_profile_widget = MotionProfileWidget(self) + motion_profile_layout.addWidget(self.motion_profile_widget) super().__init__(*args, **kwargs) self.show_motion_profile(self._selected_scan) @@ -45,7 +67,7 @@ class ScanControlAdvanced(ScanControl): self.motion_profile_widget.update_plot(d_spacing=d_spacing) def _add_metadata_form(self): - self.layout.addWidget(self.motion_profile_widget) + self.layout.addWidget(self._motion_profile_group, stretch=1) super()._add_metadata_form() self.connect_scan_parameter_signals() @@ -81,48 +103,222 @@ class ScanControlAdvanced(ScanControl): pass def scan_parameter_changed(self, *_): + # logger.info(f"Fe K edge: {xraydb.xray_edge("Fe", "K", energy_only=True)}") params = self.get_scan_parameters()[1] - logger.info(f"Scan parameters: {params}") + # logger.info(f"Scan parameters: {params}") self.motion_profile_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.motion_profile_widget.apply_theme(theme) + PLOT_RESOLUTION = 100 H = 6.62606957e-34 E = 1.602176634e-19 C = 299792458 +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": (6, 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) + + 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) + + def select(self, element): + self.selected = element + self.accept() + + +class ElementSelector(QWidget): + def __init__(self): + super().__init__() + self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.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.Fixed, QSizePolicy.Fixed) + self.button.setStyleSheet( + f"QPushButton {{background-color: {get_accent_colors().default.name()}; color: white;}}" + ) + self.selection = QLabel("") + layout_selection.addWidget(self.button) + layout_selection.addWidget(self.selection) + layout.addWidget(self.label) + layout.addLayout(layout_selection) + + self.button.clicked.connect(self.open_table) + + def open_table(self): + dialog = PeriodicTableDialog(self) + if dialog.exec_(): + self.selection.setText(dialog.selected) + + def currentText(self): + return self.selection.text() + class MotionProfileWidget(QWidget): - parameters_changed = Signal(dict) - def __init__(self, parent): super().__init__() - self.parent = parent - layout = QVBoxLayout(self) + self._parent_widget = parent + outer_layout = QVBoxLayout(self) + outer_layout.setContentsMargins(0, 0, 0, 0) - self.plot_pos = pg.PlotWidget() - self.plot_pos.setLabel("left", "Energy [eV]") - self.plot_pos.setLabel("bottom", "Time [s]") - self.plot_pos.setMouseEnabled(x=False, y=False) - self.plot_pos.setMenuEnabled(False) - self.plot_pos.hideButtons() + self.element = ElementSelector() + self.edge = ComboBox("Edge", "Edge", ["K", "L1", "L2", "L3"]) - self.plot_vel = pg.PlotWidget() - self.plot_vel.setLabel("left", "Scan Velocity [eV/s]") - self.plot_vel.setLabel("bottom", "Energy [eV]") - self.plot_vel.setMouseEnabled(x=False, y=False) - self.plot_vel.setMenuEnabled(False) - self.plot_vel.hideButtons() + plot_container = QWidget() + plot_layout = QVBoxLayout(plot_container) + 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]" + ) + + self.edge_energy = 8000 + + plot_layout.addWidget(self.plot_pos) + plot_layout.addWidget(self.plot_vel) + + plot_container.setMinimumHeight( + self.plot_pos.minimumHeight() + self.plot_vel.minimumHeight() + ) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.NoFrame) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + scroll.setWidget(plot_container) + + self.edge_selection = QHBoxLayout() + self.edge_selection.addWidget(self.element) + self.edge_selection.addWidget(self.edge) + self.edge_selection.addStretch(1) + outer_layout.addLayout(self.edge_selection) + + outer_layout.addWidget(scroll, stretch=1) - layout.addWidget(self.plot_pos) - layout.addWidget(self.plot_vel) self.scan_name = None self.scan_parameters = None self.d_spacing = None + @staticmethod + def _make_plot(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(220) + plot.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.MinimumExpanding) + return plot + + def _add_edge_marker(self, plot, x_arr, y_arr, energy_arr): + if self.edge_energy is None: + return + + idx = int(np.argmin(np.abs(np.asarray(energy_arr) - self.edge_energy))) + x_marker = x_arr[idx] + y_marker = y_arr[idx] + + scatter = pg.ScatterPlotItem( + [x_marker], [y_marker], symbol="star", size=14, brush=pg.mkBrush("r"), pen=pg.mkPen("r") + ) + plot.addItem(scatter, ignoreBounds=True) + + label = pg.TextItem("edge", anchor=(0, 1), color="r") + label.setPos(x_marker, y_marker) + plot.addItem(label, ignoreBounds=True) + def update_plot(self, scan_name=None, scan_parameters=None, d_spacing=None): if scan_name is not None: self.scan_name = scan_name @@ -146,17 +342,20 @@ class MotionProfileWidget(QWidget): start_angle = self.energy_to_angle(self.scan_parameters["stop"]) stop_angle = self.energy_to_angle(self.scan_parameters["start"]) - logger.info(f"start: {start_angle}, stop: {stop_angle}") 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) - logger.info(f"pos: {pos}") 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"] @@ -176,8 +375,43 @@ class MotionProfileWidget(QWidget): x_time = np.cumsum(t) / 1000 energy = self.angle_to_energy(pos) vel_e = self.velocity_to_energy_per_s(pos, vel) - self.plot_pos.plot(-np.flip(x_time) + x_time[-1], np.flip(energy)) + + 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. + """ + logger.info(f"theme update called with theme: {theme}") + if theme is None: + app = QApplication.instance() + theme = app.theme.theme # type: ignore + + 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): + curve.setPen(pg.mkPen(color=color, width=2)) def energy_to_angle(self, energy: int | float): if self.d_spacing is None: @@ -212,6 +446,59 @@ class MotionProfileWidget(QWidget): ) * velocity +LABEL_WIDTH = 118 +ROW_MARGINS = (4, 0, 4, 0) +ROW_SPACING = 6 + + +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.Fixed, QSizePolicy.Preferred) + layout.addWidget(self.label) + + self.value = QComboBox() + self.value.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.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( + partial( + func, + identifier=self.identifier, + value_obj=self.value, + value=lambda: self.value.currentText(), + ) + ) + + def setDisabled(self, disable): + self.value.setDisabled(disable) + + if __name__ == "__main__": app = QApplication(sys.argv) apply_theme("light")