From a91c639aca66c85dcf13eeddf2f706fd87ddf680 Mon Sep 17 00:00:00 2001 From: x01da Date: Wed, 5 Aug 2026 14:33:24 +0200 Subject: [PATCH 01/15] wip --- debye_bec/bec_widgets/widgets/client.py | 24 ++++ .../bec_widgets/widgets/designer_plugins.py | 10 +- .../widgets/scan_control_advanced/__init__,py | 0 .../register_scan_control_advanced.py | 15 +++ .../scan_control_advanced.py | 103 ++++++++++++++++++ .../scan_control_advanced.pyproject | 1 + .../scan_control_advanced_plugin.py | 57 ++++++++++ 7 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 debye_bec/bec_widgets/widgets/scan_control_advanced/__init__,py create mode 100644 debye_bec/bec_widgets/widgets/scan_control_advanced/register_scan_control_advanced.py create mode 100644 debye_bec/bec_widgets/widgets/scan_control_advanced/scan_control_advanced.py create mode 100644 debye_bec/bec_widgets/widgets/scan_control_advanced/scan_control_advanced.pyproject create mode 100644 debye_bec/bec_widgets/widgets/scan_control_advanced/scan_control_advanced_plugin.py diff --git a/debye_bec/bec_widgets/widgets/client.py b/debye_bec/bec_widgets/widgets/client.py index c82894c..436a68e 100644 --- a/debye_bec/bec_widgets/widgets/client.py +++ b/debye_bec/bec_widgets/widgets/client.py @@ -15,6 +15,7 @@ logger = bec_logger.logger _Widgets = { "DataViewer": "DataViewer", "DigitalTwin": "DigitalTwin", + "ScanControlAdvanced": "ScanControlAdvanced", } @@ -64,3 +65,26 @@ class DigitalTwin(RPCBase): """ Detach the widget from its parent dock widget (if widget is in the dock), making it a floating widget. """ + + +class ScanControlAdvanced(RPCBase): + _IMPORT_MODULE = "debye_bec.bec_widgets.widgets.scan_control_advanced.scan_control_advanced" + + @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..43dffd4 100644 --- a/debye_bec/bec_widgets/widgets/designer_plugins.py +++ b/debye_bec/bec_widgets/widgets/designer_plugins.py @@ -7,6 +7,14 @@ 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"), + "ScanControlAdvanced": ( + "debye_bec.bec_widgets.widgets.scan_control_advanced.scan_control_advanced", + "ScanControlAdvanced", + ), } -widget_icons = {"DataViewer": "find_in_page", "DigitalTwin": "lightbulb"} +widget_icons = { + "DataViewer": "find_in_page", + "DigitalTwin": "lightbulb", + "ScanControlAdvanced": "tune", +} diff --git a/debye_bec/bec_widgets/widgets/scan_control_advanced/__init__,py b/debye_bec/bec_widgets/widgets/scan_control_advanced/__init__,py new file mode 100644 index 0000000..e69de29 diff --git a/debye_bec/bec_widgets/widgets/scan_control_advanced/register_scan_control_advanced.py b/debye_bec/bec_widgets/widgets/scan_control_advanced/register_scan_control_advanced.py new file mode 100644 index 0000000..70bf187 --- /dev/null +++ b/debye_bec/bec_widgets/widgets/scan_control_advanced/register_scan_control_advanced.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_advanced_plugin import ScanControlAdvancedPlugin + + QPyDesignerCustomWidgetCollection.addCustomWidget(ScanControlAdvancedPlugin()) + + +if __name__ == "__main__": # pragma: no cover + main() 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 new file mode 100644 index 0000000..b3bbabe --- /dev/null +++ b/debye_bec/bec_widgets/widgets/scan_control_advanced/scan_control_advanced.py @@ -0,0 +1,103 @@ +import sys + +import numpy as np +import pyqtgraph as pg +from bec_lib import bec_logger +from bec_widgets.utils.bec_dispatcher import BECDispatcher +from bec_widgets.utils.colors import apply_theme +from bec_widgets.widgets.control.scan_control.scan_control import ScanControl +from qtpy.QtCore import Qt, Signal + +# pylint: disable=E0611 +from qtpy.QtWidgets import QApplication, QDoubleSpinBox, QPushButton, QVBoxLayout, QWidget + +logger = bec_logger.logger + +SHOW_MOTION_PROFILE = [ + "xas_simple_scan", + "xas_simple_scan_with_xrd", + "xas_advanced_scan", + "xas_advanced_scan_with_xrd", +] + + +class ScanControlAdvanced(ScanControl): + + def __init__(self, *args, **kwargs): + self.motion_profile_widget = MotionProfileWidget(self) + super().__init__(*args, **kwargs) + self.show_motion_profile(self._selected_scan) + + def _add_metadata_form(self): + self.layout.addWidget(self.motion_profile_widget) + 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 SHOW_MOTION_PROFILE: + self.connect_scan_parameter_signals() + self.scan_parameter_changed() + + def show_motion_profile(self, scan_name): + if scan_name in SHOW_MOTION_PROFILE: + self.motion_profile_widget.setVisible(True) + else: + self.motion_profile_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 + + def scan_parameter_changed(self, *_): + params = self.get_scan_parameters()[1] + logger.info(f"Scan parameters: {params}") + self.motion_profile_widget.update_plot(params) + + +PLOT_RESOLUTION = 100 + + +class MotionProfileWidget(QWidget): + + parameters_changed = Signal(dict) + + def __init__(self, parent): + super().__init__() + self.parent = parent + layout = QVBoxLayout(self) + self.plot = pg.PlotWidget() + self.plot.enableAutoRange() + layout.addWidget(self.plot) + + def update_plot(self, scan_parameters): + x = np.linspace(scan_parameters["start"], scan_parameters["stop"], PLOT_RESOLUTION) + x_sin = np.linspace(0, np.pi, PLOT_RESOLUTION) + y = 1 * np.sin(x_sin) + + self.plot.clear() + self.plot.plot(x, y) + + +if __name__ == "__main__": + app = QApplication(sys.argv) + apply_theme("light") + dispatcher = BECDispatcher(gui_id="scan_control_advanced") + win = ScanControlAdvanced() + win.show() + sys.exit(app.exec_()) diff --git a/debye_bec/bec_widgets/widgets/scan_control_advanced/scan_control_advanced.pyproject b/debye_bec/bec_widgets/widgets/scan_control_advanced/scan_control_advanced.pyproject new file mode 100644 index 0000000..480639d --- /dev/null +++ b/debye_bec/bec_widgets/widgets/scan_control_advanced/scan_control_advanced.pyproject @@ -0,0 +1 @@ +{'files': ['scan_control_advanced.py']} \ No newline at end of file diff --git a/debye_bec/bec_widgets/widgets/scan_control_advanced/scan_control_advanced_plugin.py b/debye_bec/bec_widgets/widgets/scan_control_advanced/scan_control_advanced_plugin.py new file mode 100644 index 0000000..78c1b32 --- /dev/null +++ b/debye_bec/bec_widgets/widgets/scan_control_advanced/scan_control_advanced_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_advanced import ScanControlAdvanced + +DOM_XML = """ + + + + +""" + + +class ScanControlAdvancedPlugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + if parent is None: + return QWidget() + t = ScanControlAdvanced(parent) + return t + + def domXml(self): + return DOM_XML + + def group(self): + return "" + + def icon(self): + return designer_material_icon(ScanControlAdvanced.ICON_NAME) + + def includeFile(self): + return "scan_control_advanced" + + 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 "ScanControlAdvanced" + + def toolTip(self): + return "ScanControlAdvanced" + + def whatsThis(self): + return self.toolTip() From a74720dc4218a7d965b35db553e6bf3cbd655ca8 Mon Sep 17 00:00:00 2001 From: x01da Date: Thu, 6 Aug 2026 09:35:22 +0200 Subject: [PATCH 02/15] Add unit percent to xas advanced scan p_kink --- debye_bec/scans/xas_simple_scan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From 5492f0f4d1acfcd66519ef5bb8a963f3c18bae57 Mon Sep 17 00:00:00 2001 From: x01da Date: Thu, 6 Aug 2026 09:35:31 +0200 Subject: [PATCH 03/15] wip widget --- .../scan_control_advanced.py | 120 ++++++++++++++++-- 1 file changed, 109 insertions(+), 11 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 b3bbabe..500bc64 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 @@ -3,14 +3,17 @@ import sys 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 apply_theme from bec_widgets.widgets.control.scan_control.scan_control import ScanControl -from qtpy.QtCore import Qt, Signal +from qtpy.QtCore import Qt, QTimer, Signal # pylint: disable=E0611 from qtpy.QtWidgets import QApplication, QDoubleSpinBox, QPushButton, QVBoxLayout, QWidget +from ....devices.mo1_bragg.mo1_bragg_utils import compute_spline + logger = bec_logger.logger SHOW_MOTION_PROFILE = [ @@ -28,6 +31,25 @@ class ScanControlAdvanced(ScanControl): super().__init__(*args, **kwargs) self.show_motion_profile(self._selected_scan) + # self.bec_dispatcher.connect_slot( + # self.mo1_update, MessageEndpoints.device_readback("mo1_bragg_crystal_current_d_spacing") + # ) + + # Timer: update d_spacing every 1 second + self.d_spacing = 0 + self._timer = QTimer(self) + self._timer.setInterval(1000) + self._timer.timeout.connect(self.update_d_spacing) + self._timer.start() + + def update_d_spacing(self): + d_spacing = self.dev.mo1_bragg.crystal.current_d_spacing.read(cached=True)[ + "mo1_bragg_crystal_current_d_spacing" + ]["value"] + if d_spacing != self.d_spacing: + self.d_spacing = d_spacing + self.motion_profile_widget.update_plot(d_spacing=d_spacing) + def _add_metadata_form(self): self.layout.addWidget(self.motion_profile_widget) super()._add_metadata_form() @@ -67,10 +89,15 @@ class ScanControlAdvanced(ScanControl): def scan_parameter_changed(self, *_): params = self.get_scan_parameters()[1] logger.info(f"Scan parameters: {params}") - self.motion_profile_widget.update_plot(params) + self.motion_profile_widget.update_plot( + scan_name=self._selected_scan, scan_parameters=params + ) PLOT_RESOLUTION = 100 +H = 6.62606957e-34 +E = 1.602176634e-19 +C = 299792458 class MotionProfileWidget(QWidget): @@ -81,17 +108,88 @@ class MotionProfileWidget(QWidget): super().__init__() self.parent = parent layout = QVBoxLayout(self) - self.plot = pg.PlotWidget() - self.plot.enableAutoRange() - layout.addWidget(self.plot) + self.plot_pos = pg.PlotWidget() + self.plot_vel = pg.PlotWidget() + layout.addWidget(self.plot_pos) + layout.addWidget(self.plot_vel) + self.scan_name = None + self.scan_parameters = None + self.d_spacing = None - def update_plot(self, scan_parameters): - x = np.linspace(scan_parameters["start"], scan_parameters["stop"], PLOT_RESOLUTION) - x_sin = np.linspace(0, np.pi, PLOT_RESOLUTION) - y = 1 * np.sin(x_sin) + def update_plot(self, scan_name=None, scan_parameters=None, d_spacing=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 - self.plot.clear() - self.plot.plot(x, y) + 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"]) + 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) + self.plot_pos.plot(x_time, energy) + self.plot_vel.plot(energy, vel) + 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) + self.plot_pos.plot(-np.flip(x_time) + x_time[-1], np.flip(energy)) + self.plot_vel.plot(energy, vel) + + 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) if __name__ == "__main__": From d5c8b53f9a9e7e97469896c8dc90ed120c66b4e4 Mon Sep 17 00:00:00 2001 From: x01da Date: Thu, 6 Aug 2026 10:27:57 +0200 Subject: [PATCH 04/15] wip widget --- .../scan_control_advanced.py | 50 +++++++++++++------ 1 file changed, 35 insertions(+), 15 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 500bc64..6c8d72d 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 @@ -6,6 +6,7 @@ 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.error_popups import SafeSlot from bec_widgets.widgets.control.scan_control.scan_control import ScanControl from qtpy.QtCore import Qt, QTimer, Signal @@ -31,21 +32,14 @@ class ScanControlAdvanced(ScanControl): super().__init__(*args, **kwargs) self.show_motion_profile(self._selected_scan) - # self.bec_dispatcher.connect_slot( - # self.mo1_update, MessageEndpoints.device_readback("mo1_bragg_crystal_current_d_spacing") - # ) - - # Timer: update d_spacing every 1 second self.d_spacing = 0 - self._timer = QTimer(self) - self._timer.setInterval(1000) - self._timer.timeout.connect(self.update_d_spacing) - self._timer.start() + self.bec_dispatcher.connect_slot( + self.update_d_spacing, MessageEndpoints.device_readback("mo1_bragg") + ) - def update_d_spacing(self): - d_spacing = self.dev.mo1_bragg.crystal.current_d_spacing.read(cached=True)[ - "mo1_bragg_crystal_current_d_spacing" - ]["value"] + @SafeSlot(dict, dict) + def update_d_spacing(self, msg: dict, metadata: 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.motion_profile_widget.update_plot(d_spacing=d_spacing) @@ -108,8 +102,21 @@ class MotionProfileWidget(QWidget): super().__init__() self.parent = parent layout = QVBoxLayout(self) + 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.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() + layout.addWidget(self.plot_pos) layout.addWidget(self.plot_vel) self.scan_name = None @@ -147,8 +154,9 @@ class MotionProfileWidget(QWidget): 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.plot_vel.plot(energy, vel) + self.plot_vel.plot(energy, -vel_e) if "xas_advanced_scan" in self.scan_name: if ( self.scan_parameters["e_kink"] < self.scan_parameters["start"] @@ -167,8 +175,9 @@ 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)) - self.plot_vel.plot(energy, vel) + self.plot_vel.plot(energy, -vel_e) def energy_to_angle(self, energy: int | float): if self.d_spacing is None: @@ -191,6 +200,17 @@ class MotionProfileWidget(QWidget): 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) From 76cde8903a89fbe197489e8b29eae4ca8d96151b Mon Sep 17 00:00:00 2001 From: x01da Date: Thu, 6 Aug 2026 15:06:26 +0200 Subject: [PATCH 05/15] add xraydb to get edge position --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) 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] From 688a2fa89d647ca0d66b1dc980589c4fd76b5eb0 Mon Sep 17 00:00:00 2001 From: x01da Date: Thu, 6 Aug 2026 15:06:41 +0200 Subject: [PATCH 06/15] 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") From e6e3104410b76ab0250c2fbc31012e85ce9e8889 Mon Sep 17 00:00:00 2001 From: x01da Date: Mon, 10 Aug 2026 07:46:44 +0200 Subject: [PATCH 07/15] wip widget --- .../scan_control_advanced.py | 140 ++++++++++++++---- 1 file changed, 109 insertions(+), 31 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 44a4020..ddf3659 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 @@ -11,7 +11,7 @@ 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 +from qtpy.QtCore import Qt, Signal # pylint: disable=E0611 from qtpy.QtWidgets import ( @@ -41,7 +41,7 @@ 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 +# TODO: Theme change edge marker. Show edge energy next to edge selector. Bugfix edge selector class ScanControlAdvanced(ScanControl): @@ -51,7 +51,9 @@ class ScanControlAdvanced(ScanControl): 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) + super().__init__( + allowed_scans=SHOW_MOTION_PROFILE, default_scan="xas_simple_scan", *args, **kwargs + ) self.show_motion_profile(self._selected_scan) self.d_spacing = 0 @@ -165,7 +167,7 @@ ELEMENTS = { # period 6 "Cs": (6, 1), "Ba": (6, 2), - "La": (6, 3), + "La": (8, 3), "Ce": (8, 4), "Pr": (8, 5), "Nd": (8, 6), @@ -199,6 +201,8 @@ class PeriodicTableDialog(QDialog): 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) @@ -212,6 +216,9 @@ class PeriodicTableDialog(QDialog): class ElementSelector(QWidget): + + dialogClosed = Signal() + def __init__(self): super().__init__() self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) @@ -225,21 +232,39 @@ class ElementSelector(QWidget): self.button.setStyleSheet( f"QPushButton {{background-color: {get_accent_colors().default.name()}; color: white;}}" ) - self.selection = QLabel("") + # self.selection = QLabel("") layout_selection.addWidget(self.button) - layout_selection.addWidget(self.selection) + # layout_selection.addWidget(self.selection) 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.selection.setText(dialog.selected) + self.current_element = dialog.selected + # self.selection.setText(dialog.selected) + self.dialogClosed.emit() - def currentText(self): - return self.selection.text() + 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;}}" + ) class MotionProfileWidget(QWidget): @@ -252,9 +277,10 @@ class MotionProfileWidget(QWidget): self.element = ElementSelector() self.edge = ComboBox("Edge", "Edge", ["K", "L1", "L2", "L3"]) + self.edge_text = TextIndicator("", "Selected Edge", "No edge selected") plot_container = QWidget() - plot_layout = QVBoxLayout(plot_container) + plot_layout = QHBoxLayout(plot_container) plot_layout.setContentsMargins(0, 0, 0, 0) self.plot_pos = self._make_plot(left_label="Energy
[eV]", bottom_label="Time [s]") @@ -262,13 +288,13 @@ class MotionProfileWidget(QWidget): left_label="Scan Velocity
[eV/s]", bottom_label="Energy [eV]" ) - self.edge_energy = 8000 + self.edge_energy = None plot_layout.addWidget(self.plot_pos) plot_layout.addWidget(self.plot_vel) plot_container.setMinimumHeight( - self.plot_pos.minimumHeight() + self.plot_vel.minimumHeight() + int(np.max([self.plot_pos.minimumHeight(), self.plot_vel.minimumHeight()])) ) scroll = QScrollArea() scroll.setWidgetResizable(True) @@ -279,6 +305,7 @@ class MotionProfileWidget(QWidget): self.edge_selection = QHBoxLayout() self.edge_selection.addWidget(self.element) self.edge_selection.addWidget(self.edge) + self.edge_selection.addWidget(self.edge_text) self.edge_selection.addStretch(1) outer_layout.addLayout(self.edge_selection) @@ -288,6 +315,9 @@ class MotionProfileWidget(QWidget): self.scan_parameters = None self.d_spacing = None + self.element.dialogClosed.connect(self._update_edge_selection) + self.edge.activated_connect(self._update_edge_selection) + @staticmethod def _make_plot(left_label: str, bottom_label: str) -> pg.PlotWidget: plot = pg.PlotWidget() @@ -298,28 +328,54 @@ class MotionProfileWidget(QWidget): plot.hideButtons() plot.getAxis("left").enableAutoSIPrefix(False) plot.getAxis("bottom").enableAutoSIPrefix(False) - plot.setMinimumHeight(220) + plot.setMinimumHeight(180) plot.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.MinimumExpanding) return plot - def _add_edge_marker(self, plot, x_arr, y_arr, energy_arr): + 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="star", size=14, brush=pg.mkBrush("r"), pen=pg.mkPen("r") + [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("edge", anchor=(0, 1), color="r") + text = f"{self.element.currentElement()}, {self.edge.currentText()}-edge, {self.edge_energy:0.1f} eV" + label = pg.TextItem(text, anchor=(0.5, 1.5), 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): + @SafeSlot() + def _update_edge_selection(self, *_, **__): + if self.element.currentElement() == "": + return + if self.edge.currentText() == "": + return + self.edge_energy = xraydb.xray_edge( + self.element.currentElement(), self.edge.currentText(), True + ) + if self.edge_energy is not None: + self.edge_text.setText( + f"{self.element.currentElement()}, {self.edge.currentText()}-edge, {self.edge_energy:0.1f} eV" + ) + else: + self.edge_text.setText("No valid edge selected!") + self.update_plot() + + @SafeSlot() + def update_plot(self, scan_name=None, scan_parameters=None, d_spacing=None, *_, **__): if scan_name is not None: self.scan_name = scan_name if scan_parameters is not None: @@ -351,10 +407,10 @@ class MotionProfileWidget(QWidget): 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._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) + self._add_edge_marker(self.plot_vel, energy, -vel_e, energy, "|") if "xas_advanced_scan" in self.scan_name: if ( @@ -379,10 +435,10 @@ class MotionProfileWidget(QWidget): 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._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._add_edge_marker(self.plot_vel, energy, -vel_e, energy, "|") self.apply_theme() @@ -393,7 +449,6 @@ class MotionProfileWidget(QWidget): 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 @@ -411,7 +466,9 @@ class MotionProfileWidget(QWidget): 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)) + if not isinstance(curve, pg.ScatterPlotItem): + curve.setPen(pg.mkPen(color=color, width=2)) + self.element.apply_theme(theme) def energy_to_angle(self, energy: int | float): if self.d_spacing is None: @@ -486,19 +543,40 @@ class ComboBox(QWidget): 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(), - ) - ) + 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.Fixed, QSizePolicy.Preferred) + layout.addWidget(self.label) + + self.value = QLabel(text) + self.value.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + + layout.addWidget(self.value) + + def setText(self, text): + self.value.setText(text) + + def text(self) -> str: + return self.value.text() + + if __name__ == "__main__": app = QApplication(sys.argv) apply_theme("light") From 1ff5c47b7a727a0c56cac771c63fd5e94de90ad1 Mon Sep 17 00:00:00 2001 From: x01da Date: Mon, 10 Aug 2026 09:19:46 +0200 Subject: [PATCH 08/15] wip widget --- debye_bec/bec_widgets/widgets/client.py | 7 +- .../bec_widgets/widgets/designer_plugins.py | 12 +- .../scan_control_advanced.pyproject | 1 - .../__init__,py | 0 .../widgets/scan_control_xas/qt_widgets.py | 230 ++++++++++++ .../register_scan_control_xas.py} | 4 +- .../scan_control_xas.py} | 326 ++++-------------- .../scan_control_xas.pyproject | 1 + .../scan_control_xas_plugin.py} | 16 +- 9 files changed, 313 insertions(+), 284 deletions(-) delete mode 100644 debye_bec/bec_widgets/widgets/scan_control_advanced/scan_control_advanced.pyproject rename debye_bec/bec_widgets/widgets/{scan_control_advanced => scan_control_xas}/__init__,py (100%) create mode 100644 debye_bec/bec_widgets/widgets/scan_control_xas/qt_widgets.py rename debye_bec/bec_widgets/widgets/{scan_control_advanced/register_scan_control_advanced.py => scan_control_xas/register_scan_control_xas.py} (80%) rename debye_bec/bec_widgets/widgets/{scan_control_advanced/scan_control_advanced.py => scan_control_xas/scan_control_xas.py} (57%) create mode 100644 debye_bec/bec_widgets/widgets/scan_control_xas/scan_control_xas.pyproject rename debye_bec/bec_widgets/widgets/{scan_control_advanced/scan_control_advanced_plugin.py => scan_control_xas/scan_control_xas_plugin.py} (69%) diff --git a/debye_bec/bec_widgets/widgets/client.py b/debye_bec/bec_widgets/widgets/client.py index 436a68e..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,7 +14,7 @@ logger = bec_logger.logger _Widgets = { "DataViewer": "DataViewer", "DigitalTwin": "DigitalTwin", - "ScanControlAdvanced": "ScanControlAdvanced", + "ScanControlXAS": "ScanControlXAS", } @@ -67,8 +66,8 @@ class DigitalTwin(RPCBase): """ -class ScanControlAdvanced(RPCBase): - _IMPORT_MODULE = "debye_bec.bec_widgets.widgets.scan_control_advanced.scan_control_advanced" +class ScanControlXAS(RPCBase): + _IMPORT_MODULE = "debye_bec.bec_widgets.widgets.scan_control_xas.scan_control_xas" @rpc_call def attach(self): diff --git a/debye_bec/bec_widgets/widgets/designer_plugins.py b/debye_bec/bec_widgets/widgets/designer_plugins.py index 43dffd4..b098ee3 100644 --- a/debye_bec/bec_widgets/widgets/designer_plugins.py +++ b/debye_bec/bec_widgets/widgets/designer_plugins.py @@ -7,14 +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"), - "ScanControlAdvanced": ( - "debye_bec.bec_widgets.widgets.scan_control_advanced.scan_control_advanced", - "ScanControlAdvanced", + "ScanControlXAS": ( + "debye_bec.bec_widgets.widgets.scan_control_xas.scan_control_xas", + "ScanControlXAS", ), } -widget_icons = { - "DataViewer": "find_in_page", - "DigitalTwin": "lightbulb", - "ScanControlAdvanced": "tune", -} +widget_icons = {"DataViewer": "find_in_page", "DigitalTwin": "lightbulb", "ScanControlXAS": "tune"} diff --git a/debye_bec/bec_widgets/widgets/scan_control_advanced/scan_control_advanced.pyproject b/debye_bec/bec_widgets/widgets/scan_control_advanced/scan_control_advanced.pyproject deleted file mode 100644 index 480639d..0000000 --- a/debye_bec/bec_widgets/widgets/scan_control_advanced/scan_control_advanced.pyproject +++ /dev/null @@ -1 +0,0 @@ -{'files': ['scan_control_advanced.py']} \ No newline at end of file diff --git a/debye_bec/bec_widgets/widgets/scan_control_advanced/__init__,py b/debye_bec/bec_widgets/widgets/scan_control_xas/__init__,py similarity index 100% rename from debye_bec/bec_widgets/widgets/scan_control_advanced/__init__,py rename to debye_bec/bec_widgets/widgets/scan_control_xas/__init__,py 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..d16d011 --- /dev/null +++ b/debye_bec/bec_widgets/widgets/scan_control_xas/qt_widgets.py @@ -0,0 +1,230 @@ +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 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_advanced/register_scan_control_advanced.py b/debye_bec/bec_widgets/widgets/scan_control_xas/register_scan_control_xas.py similarity index 80% rename from debye_bec/bec_widgets/widgets/scan_control_advanced/register_scan_control_advanced.py rename to debye_bec/bec_widgets/widgets/scan_control_xas/register_scan_control_xas.py index 70bf187..c3fb9e7 100644 --- a/debye_bec/bec_widgets/widgets/scan_control_advanced/register_scan_control_advanced.py +++ b/debye_bec/bec_widgets/widgets/scan_control_xas/register_scan_control_xas.py @@ -6,9 +6,9 @@ def main(): # pragma: no cover return from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection - from .scan_control_advanced_plugin import ScanControlAdvancedPlugin + from .scan_control_xas_plugin import ScanControlXASPlugin - QPyDesignerCustomWidgetCollection.addCustomWidget(ScanControlAdvancedPlugin()) + QPyDesignerCustomWidgetCollection.addCustomWidget(ScanControlXASPlugin()) if __name__ == "__main__": # pragma: no cover diff --git a/debye_bec/bec_widgets/widgets/scan_control_advanced/scan_control_advanced.py b/debye_bec/bec_widgets/widgets/scan_control_xas/scan_control_xas.py similarity index 57% rename from debye_bec/bec_widgets/widgets/scan_control_advanced/scan_control_advanced.py rename to debye_bec/bec_widgets/widgets/scan_control_xas/scan_control_xas.py index ddf3659..4b7f4bf 100644 --- a/debye_bec/bec_widgets/widgets/scan_control_advanced/scan_control_advanced.py +++ b/debye_bec/bec_widgets/widgets/scan_control_xas/scan_control_xas.py @@ -1,5 +1,8 @@ +""" +Scan Control XAS: Custom BEC widget for Scan Control XAS scans. +""" + import sys -from functools import partial from typing import Literal, Optional import numpy as np @@ -8,43 +11,41 @@ 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 Colors, apply_theme, get_accent_colors +from bec_widgets.utils.colors import Colors, apply_theme from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.widgets.control.scan_control.scan_control import ScanControl -from qtpy.QtCore import Qt, Signal +from qtpy.QtCore import Qt # pylint: disable=E0611 from qtpy.QtWidgets import ( QApplication, - QComboBox, - QDialog, QFrame, - QGridLayout, QGroupBox, QHBoxLayout, - QLabel, - QPushButton, QScrollArea, QSizePolicy, QVBoxLayout, QWidget, ) +# pylint: disable=E0402 from ....devices.mo1_bragg.mo1_bragg_utils import compute_spline +from .qt_widgets import ComboBox, ElementSelector, TextIndicator logger = bec_logger.logger -SHOW_MOTION_PROFILE = [ +ALLOWED_SCANS = [ "xas_simple_scan", "xas_simple_scan_with_xrd", "xas_advanced_scan", "xas_advanced_scan_with_xrd", ] -# TODO: Theme change edge marker. Show edge energy next to edge selector. Bugfix edge selector - -class ScanControlAdvanced(ScanControl): +class ScanControlXAS(ScanControl): + """ + Main widget of Scan Control XAS + """ def __init__(self, *args, **kwargs): self._motion_profile_group = QGroupBox("Motion Profile Preview") @@ -52,51 +53,51 @@ class ScanControlAdvanced(ScanControl): self.motion_profile_widget = MotionProfileWidget(self) motion_profile_layout.addWidget(self.motion_profile_widget) super().__init__( - allowed_scans=SHOW_MOTION_PROFILE, default_scan="xas_simple_scan", *args, **kwargs + allowed_scans=ALLOWED_SCANS, default_scan="xas_simple_scan", *args, **kwargs ) - self.show_motion_profile(self._selected_scan) + 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") + self._update_d_spacing, MessageEndpoints.device_readback("mo1_bragg") ) @SafeSlot(dict, dict) - def update_d_spacing(self, msg: dict, metadata: 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.motion_profile_widget.update_plot(d_spacing=d_spacing) def _add_metadata_form(self): - self.layout.addWidget(self._motion_profile_group, stretch=1) + self.layout.addWidget(self._motion_profile_group, stretch=1) # type: ignore super()._add_metadata_form() - self.connect_scan_parameter_signals() + 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 SHOW_MOTION_PROFILE: - self.connect_scan_parameter_signals() - self.scan_parameter_changed() + 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 SHOW_MOTION_PROFILE: + def _show_motion_profile(self, scan_name): + if scan_name in ALLOWED_SCANS: self.motion_profile_widget.setVisible(True) else: self.motion_profile_widget.setVisible(False) - def connect_scan_parameter_signals(self): + 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 + self._scan_parameter_changed, Qt.ConnectionType.UniqueConnection ) elif hasattr(widget, "textChanged"): widget.textChanged.connect( - self.scan_parameter_changed, Qt.ConnectionType.UniqueConnection + self._scan_parameter_changed, Qt.ConnectionType.UniqueConnection ) except TypeError: # Raised if a connection would not be unique anymore, i.e. if @@ -104,7 +105,8 @@ class ScanControlAdvanced(ScanControl): # the same kwarg_boxes pass - def scan_parameter_changed(self, *_): + @SafeSlot() + 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}") @@ -127,147 +129,11 @@ 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": (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) - - def select(self, element): - self.selected = element - self.accept() - - -class ElementSelector(QWidget): - - dialogClosed = Signal() - - 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.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.selection.setText(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;}}" - ) - class MotionProfileWidget(QWidget): + """ + Additional widget specifically for XAS scans + """ def __init__(self, parent): super().__init__() @@ -298,8 +164,8 @@ class MotionProfileWidget(QWidget): ) scroll = QScrollArea() scroll.setWidgetResizable(True) - scroll.setFrameShape(QFrame.NoFrame) - scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + scroll.setFrameShape(QFrame.Shape.NoFrame) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) scroll.setWidget(plot_container) self.edge_selection = QHBoxLayout() @@ -318,8 +184,7 @@ class MotionProfileWidget(QWidget): self.element.dialogClosed.connect(self._update_edge_selection) self.edge.activated_connect(self._update_edge_selection) - @staticmethod - def _make_plot(left_label: str, bottom_label: str) -> pg.PlotWidget: + 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) @@ -329,7 +194,7 @@ class MotionProfileWidget(QWidget): plot.getAxis("left").enableAutoSIPrefix(False) plot.getAxis("bottom").enableAutoSIPrefix(False) plot.setMinimumHeight(180) - plot.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.MinimumExpanding) + plot.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.MinimumExpanding) return plot def _add_edge_marker(self, plot, x_arr, y_arr, energy_arr, edge_marker): @@ -353,7 +218,7 @@ class MotionProfileWidget(QWidget): plot.addItem(scatter, ignoreBounds=True) text = f"{self.element.currentElement()}, {self.edge.currentText()}-edge, {self.edge_energy:0.1f} eV" - label = pg.TextItem(text, anchor=(0.5, 1.5), color="r") + label = pg.TextItem(text, anchor=(0, 1.5), color="r") label.setPos(x_marker, y_marker) plot.addItem(label, ignoreBounds=True) @@ -375,7 +240,18 @@ class MotionProfileWidget(QWidget): self.update_plot() @SafeSlot() - def update_plot(self, scan_name=None, scan_parameters=None, d_spacing=None, *_, **__): + 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: @@ -396,15 +272,17 @@ class MotionProfileWidget(QWidget): 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"]) + 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) + 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, "_") @@ -420,7 +298,7 @@ class MotionProfileWidget(QWidget): return if self.scan_parameters["scan_time"] == 0: return - e_kink_deg = self.energy_to_angle(self.scan_parameters["e_kink"]) + e_kink_deg = self._energy_to_angle(self.scan_parameters["e_kink"]) pos, vel, t = compute_spline( start_angle, stop_angle, @@ -429,8 +307,8 @@ class MotionProfileWidget(QWidget): 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) + 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) @@ -470,7 +348,7 @@ class MotionProfileWidget(QWidget): curve.setPen(pg.mkPen(color=color, width=2)) self.element.apply_theme(theme) - def energy_to_angle(self, energy: int | float): + def _energy_to_angle(self, energy: int | float): if self.d_spacing is None: return 0 if energy <= 0: @@ -479,7 +357,7 @@ class MotionProfileWidget(QWidget): 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): + def _angle_to_energy(self, angle: np.ndarray | int | float): if self.d_spacing is None: return 0 if isinstance(angle, np.ndarray): @@ -491,7 +369,7 @@ class MotionProfileWidget(QWidget): 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): + def _velocity_to_energy_per_s(self, angle, velocity): if self.d_spacing is None: return 0 return ( @@ -503,84 +381,10 @@ 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(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.Fixed, QSizePolicy.Preferred) - layout.addWidget(self.label) - - self.value = QLabel(text) - self.value.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) - - layout.addWidget(self.value) - - def setText(self, text): - self.value.setText(text) - - def text(self) -> str: - return self.value.text() - - if __name__ == "__main__": app = QApplication(sys.argv) apply_theme("light") dispatcher = BECDispatcher(gui_id="scan_control_advanced") - win = ScanControlAdvanced() + 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_advanced/scan_control_advanced_plugin.py b/debye_bec/bec_widgets/widgets/scan_control_xas/scan_control_xas_plugin.py similarity index 69% rename from debye_bec/bec_widgets/widgets/scan_control_advanced/scan_control_advanced_plugin.py rename to debye_bec/bec_widgets/widgets/scan_control_xas/scan_control_xas_plugin.py index 78c1b32..0647be1 100644 --- a/debye_bec/bec_widgets/widgets/scan_control_advanced/scan_control_advanced_plugin.py +++ b/debye_bec/bec_widgets/widgets/scan_control_xas/scan_control_xas_plugin.py @@ -5,17 +5,17 @@ from bec_widgets.utils.bec_designer import designer_material_icon from qtpy.QtDesigner import QDesignerCustomWidgetInterface from qtpy.QtWidgets import QWidget -from .scan_control_advanced import ScanControlAdvanced +from .scan_control_xas import ScanControlXAS DOM_XML = """ - + """ -class ScanControlAdvancedPlugin(QDesignerCustomWidgetInterface): # pragma: no cover +class ScanControlXASPlugin(QDesignerCustomWidgetInterface): # pragma: no cover def __init__(self): super().__init__() self._form_editor = None @@ -23,7 +23,7 @@ class ScanControlAdvancedPlugin(QDesignerCustomWidgetInterface): # pragma: no c def createWidget(self, parent): if parent is None: return QWidget() - t = ScanControlAdvanced(parent) + t = ScanControlXAS(parent) return t def domXml(self): @@ -33,10 +33,10 @@ class ScanControlAdvancedPlugin(QDesignerCustomWidgetInterface): # pragma: no c return "" def icon(self): - return designer_material_icon(ScanControlAdvanced.ICON_NAME) + return designer_material_icon(ScanControlXAS.ICON_NAME) def includeFile(self): - return "scan_control_advanced" + return "scan_control_xas" def initialize(self, form_editor): self._form_editor = form_editor @@ -48,10 +48,10 @@ class ScanControlAdvancedPlugin(QDesignerCustomWidgetInterface): # pragma: no c return self._form_editor is not None def name(self): - return "ScanControlAdvanced" + return "ScanControlXAS" def toolTip(self): - return "ScanControlAdvanced" + return "ScanControlXAS" def whatsThis(self): return self.toolTip() From ab84d7fb7ad95100c6af31e51168dce5437a7132 Mon Sep 17 00:00:00 2001 From: x01da Date: Mon, 10 Aug 2026 09:20:52 +0200 Subject: [PATCH 09/15] bugfix --- .../bec_widgets/widgets/scan_control_xas/scan_control_xas.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 4b7f4bf..5ac47bd 100644 --- 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 @@ -74,8 +74,8 @@ class ScanControlXAS(ScanControl): 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) + 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() From 5d21216f63549e9f78e72b6dfbf36f3a397db624 Mon Sep 17 00:00:00 2001 From: x01da Date: Mon, 10 Aug 2026 14:56:19 +0200 Subject: [PATCH 10/15] wip widget --- .../bec_widgets/widgets/edge_selector.py | 339 ++++++++++++++++++ .../widgets/scan_control_xas/qt_widgets.py | 34 ++ .../scan_control_xas/scan_control_xas.py | 107 ++++-- 3 files changed, 452 insertions(+), 28 deletions(-) create mode 100644 debye_bec/bec_widgets/widgets/edge_selector.py 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..e578616 --- /dev/null +++ b/debye_bec/bec_widgets/widgets/edge_selector.py @@ -0,0 +1,339 @@ +import sys +from typing import Literal, Optional + +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 +from qtpy.QtCore import Signal + +# pylint: disable=E0611 +from qtpy.QtWidgets import ( + QApplication, + QComboBox, + QDialog, + QGridLayout, + QHBoxLayout, + QLabel, + QPushButton, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +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.active_edge = None + self.active_element = None + self.active_energy = None + self.llim = llim + self.hlim = hlim + + layout = QVBoxLayout(self) + + text = QLabel("Select edge and element.") + + edge_layout = QHBoxLayout(self) + 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(self) + 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) + 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.edge.activated.connect(self._edge_changed) + self._edge_changed() + + @SafeSlot() + def _edge_changed(self, *_): + self.active_edge = self.edge.currentText() + for element, button in self.buttons.items(): + # logger.info(f"{element}") + if "L" in self.active_edge and element in ["H", "He"]: + button.setDisabled(True) + button.setChecked(False) + elif self.active_edge in ["L2", "L3"] and element == "Li": + button.setDisabled(True) + button.setChecked(False) + else: + energy = xraydb.xray_edge(element, self.active_edge, True) + if self.active_element == element: + self.active_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.active_element is None: + return + element = self.active_element + # Uncheck all other buttons so only the picked one stays highlighted + for e, btn in self.buttons.items(): + if e != element: + btn.setChecked(False) + self.buttons[element].setChecked(True) + self.active_element = element + self.active_energy = xraydb.xray_edge(element, self.active_edge, True) + self.select_button.setEnabled(True) + self._set_select_text() + + def _set_select_text(self): + if self.active_energy is not None: + if self.buttons[self.active_element].isChecked(): + self._enable_button(self.select_button, True) + self.select_button.setText( + f"Selected {self.active_element}, {self.active_edge}-edge, {self.active_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;}}" + ) + + +ROW_MARGINS = (4, 0, 4, 0) +ROW_SPACING = 6 + + +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 = EdgeSelector(self, llim=4500, hlim=60000) + 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;}}" + ) + + +if __name__ == "__main__": + app = QApplication(sys.argv) + apply_theme("light") + dispatcher = BECDispatcher(gui_id="scan_control_advanced") + win = ElementSelector() + win.show() + sys.exit(app.exec_()) 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 index d16d011..0f4f1ef 100644 --- a/debye_bec/bec_widgets/widgets/scan_control_xas/qt_widgets.py +++ b/debye_bec/bec_widgets/widgets/scan_control_xas/qt_widgets.py @@ -23,6 +23,40 @@ 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__() 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 index 5ac47bd..ee6f4a8 100644 --- 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 @@ -11,7 +11,7 @@ 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 Colors, 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 @@ -19,9 +19,12 @@ from qtpy.QtCore import Qt # pylint: disable=E0611 from qtpy.QtWidgets import ( QApplication, + QCheckBox, QFrame, QGroupBox, QHBoxLayout, + QLabel, + QPushButton, QScrollArea, QSizePolicy, QVBoxLayout, @@ -30,7 +33,8 @@ from qtpy.QtWidgets import ( # pylint: disable=E0402 from ....devices.mo1_bragg.mo1_bragg_utils import compute_spline -from .qt_widgets import ComboBox, ElementSelector, TextIndicator +from ..edge_selector import EdgeSelector +from .qt_widgets import Button, ComboBox, ElementSelector, TextIndicator logger = bec_logger.logger @@ -141,9 +145,26 @@ class MotionProfileWidget(QWidget): outer_layout = QVBoxLayout(self) outer_layout.setContentsMargins(0, 0, 0, 0) - self.element = ElementSelector() - self.edge = ComboBox("Edge", "Edge", ["K", "L1", "L2", "L3"]) - self.edge_text = TextIndicator("", "Selected Edge", "No edge selected") + # self.element = ElementSelector() + # self.edge = ComboBox("Edge", "Edge", ["K", "L1", "L2", "L3"]) + # self.edge_text = TextIndicator("", "Selected Edge", "No edge selected") + + edge_selector_layout = QHBoxLayout(self) + 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) + 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_container = QWidget() plot_layout = QHBoxLayout(plot_container) @@ -169,20 +190,25 @@ class MotionProfileWidget(QWidget): scroll.setWidget(plot_container) self.edge_selection = QHBoxLayout() - self.edge_selection.addWidget(self.element) - self.edge_selection.addWidget(self.edge) - self.edge_selection.addWidget(self.edge_text) + # self.edge_selection.addWidget(self.element) + # self.edge_selection.addWidget(self.edge) + # self.edge_selection.addWidget(self.edge_text) + 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.addWidget(scroll, stretch=1) self.scan_name = None self.scan_parameters = None self.d_spacing = None - self.element.dialogClosed.connect(self._update_edge_selection) - self.edge.activated_connect(self._update_edge_selection) + self.edge_selector_button.clicked.connect(self._update_edge) + self.scan_rng_ckbox.stateChanged.connect(self._update_auto_scan_rng) + # self.element.dialogClosed.connect(self._update_edge_selection) + # self.edge.activated_connect(self._update_edge_selection) + + self.apply_theme() def _make_plot(self, left_label: str, bottom_label: str) -> pg.PlotWidget: plot = pg.PlotWidget() @@ -217,27 +243,48 @@ class MotionProfileWidget(QWidget): ) plot.addItem(scatter, ignoreBounds=True) - text = f"{self.element.currentElement()}, {self.edge.currentText()}-edge, {self.edge_energy:0.1f} eV" - label = pg.TextItem(text, anchor=(0, 1.5), color="r") + 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_selection(self, *_, **__): - if self.element.currentElement() == "": - return - if self.edge.currentText() == "": - return - self.edge_energy = xraydb.xray_edge( - self.element.currentElement(), self.edge.currentText(), True - ) - if self.edge_energy is not None: - self.edge_text.setText( - f"{self.element.currentElement()}, {self.edge.currentText()}-edge, {self.edge_energy:0.1f} eV" + def _update_edge(self, *_): + dlg = EdgeSelector(self, llim=4500, hlim=60000) + if dlg.exec_(): + self.edge_energy = dlg.active_energy + self.edge_label.setText( + f"{dlg.active_element}, {dlg.active_edge}-edge, {dlg.active_energy:0.1f} eV" ) - else: - self.edge_text.setText("No valid edge selected!") - self.update_plot() + 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_edge_selection(self, *_, **__): + # if self.element.currentElement() == "": + # return + # if self.edge.currentText() == "": + # return + # self.edge_energy = xraydb.xray_edge( + # self.element.currentElement(), self.edge.currentText(), True + # ) + # if self.edge_energy is not None: + # self.edge_text.setText( + # f"{self.element.currentElement()}, {self.edge.currentText()}-edge, {self.edge_energy:0.1f} eV" + # ) + # else: + # self.edge_text.setText("No valid edge selected!") + # self.update_plot() @SafeSlot() def update_plot(self, *_, scan_name=None, scan_parameters=None, d_spacing=None, **__): @@ -331,6 +378,10 @@ class MotionProfileWidget(QWidget): 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") @@ -346,7 +397,7 @@ class MotionProfileWidget(QWidget): for curve, color in zip(curves, colors): if not isinstance(curve, pg.ScatterPlotItem): curve.setPen(pg.mkPen(color=color, width=2)) - self.element.apply_theme(theme) + # self.element.apply_theme(theme) def _energy_to_angle(self, energy: int | float): if self.d_spacing is None: From 747c9df3c6aab7353254723451173e739fb4cb19 Mon Sep 17 00:00:00 2001 From: x01da Date: Mon, 10 Aug 2026 15:36:59 +0200 Subject: [PATCH 11/15] wip --- .../bec_widgets/widgets/scan_control_xas/scan_control_xas.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index ee6f4a8..c58202b 100644 --- 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 @@ -52,7 +52,7 @@ class ScanControlXAS(ScanControl): """ def __init__(self, *args, **kwargs): - self._motion_profile_group = QGroupBox("Motion Profile Preview") + self._motion_profile_group = QGroupBox("XAS scan helper") motion_profile_layout = QVBoxLayout(self._motion_profile_group) self.motion_profile_widget = MotionProfileWidget(self) motion_profile_layout.addWidget(self.motion_profile_widget) From f214062e36df51737193ed4d9c5e685ef6ea1a6d Mon Sep 17 00:00:00 2001 From: x01da Date: Mon, 10 Aug 2026 21:19:57 +0200 Subject: [PATCH 12/15] wip widget --- .../scan_control_xas/scan_control_xas.py | 61 +++++++------------ 1 file changed, 23 insertions(+), 38 deletions(-) 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 index c58202b..45ad514 100644 --- 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 @@ -7,7 +7,6 @@ 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 @@ -34,7 +33,6 @@ from qtpy.QtWidgets import ( # pylint: disable=E0402 from ....devices.mo1_bragg.mo1_bragg_utils import compute_spline from ..edge_selector import EdgeSelector -from .qt_widgets import Button, ComboBox, ElementSelector, TextIndicator logger = bec_logger.logger @@ -45,6 +43,16 @@ ALLOWED_SCANS = [ "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): """ @@ -111,9 +119,7 @@ class ScanControlXAS(ScanControl): @SafeSlot() 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}") self.motion_profile_widget.update_plot( scan_name=self._selected_scan, scan_parameters=params ) @@ -128,12 +134,6 @@ class ScanControlXAS(ScanControl): self.motion_profile_widget.apply_theme(theme) -PLOT_RESOLUTION = 100 -H = 6.62606957e-34 -E = 1.602176634e-19 -C = 299792458 - - class MotionProfileWidget(QWidget): """ Additional widget specifically for XAS scans @@ -142,13 +142,15 @@ class MotionProfileWidget(QWidget): def __init__(self, parent): super().__init__() self._parent_widget = parent + hostname = self.parent.client._hostname + start = hostname.find("x") + self.beamline = "" + if start != -1: + self.beamline = hostname[start : start + 5] + outer_layout = QVBoxLayout(self) outer_layout.setContentsMargins(0, 0, 0, 0) - # self.element = ElementSelector() - # self.edge = ComboBox("Edge", "Edge", ["K", "L1", "L2", "L3"]) - # self.edge_text = TextIndicator("", "Selected Edge", "No edge selected") - edge_selector_layout = QHBoxLayout(self) edge_selector_label = QLabel("Absorption edge:") self.edge_selector_button = QPushButton("Choose") @@ -190,9 +192,6 @@ class MotionProfileWidget(QWidget): scroll.setWidget(plot_container) self.edge_selection = QHBoxLayout() - # self.edge_selection.addWidget(self.element) - # self.edge_selection.addWidget(self.edge) - # self.edge_selection.addWidget(self.edge_text) self.edge_selection.addLayout(edge_selector_layout) self.edge_selection.addStretch(1) outer_layout.addLayout(self.edge_selection) @@ -205,8 +204,6 @@ class MotionProfileWidget(QWidget): self.edge_selector_button.clicked.connect(self._update_edge) self.scan_rng_ckbox.stateChanged.connect(self._update_auto_scan_rng) - # self.element.dialogClosed.connect(self._update_edge_selection) - # self.edge.activated_connect(self._update_edge_selection) self.apply_theme() @@ -249,7 +246,13 @@ class MotionProfileWidget(QWidget): @SafeSlot() def _update_edge(self, *_): - dlg = EdgeSelector(self, llim=4500, hlim=60000) + 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.active_energy self.edge_label.setText( @@ -269,23 +272,6 @@ class MotionProfileWidget(QWidget): } ) - # @SafeSlot() - # def _update_edge_selection(self, *_, **__): - # if self.element.currentElement() == "": - # return - # if self.edge.currentText() == "": - # return - # self.edge_energy = xraydb.xray_edge( - # self.element.currentElement(), self.edge.currentText(), True - # ) - # if self.edge_energy is not None: - # self.edge_text.setText( - # f"{self.element.currentElement()}, {self.edge.currentText()}-edge, {self.edge_energy:0.1f} eV" - # ) - # else: - # self.edge_text.setText("No valid edge selected!") - # self.update_plot() - @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, @@ -397,7 +383,6 @@ class MotionProfileWidget(QWidget): for curve, color in zip(curves, colors): if not isinstance(curve, pg.ScatterPlotItem): curve.setPen(pg.mkPen(color=color, width=2)) - # self.element.apply_theme(theme) def _energy_to_angle(self, energy: int | float): if self.d_spacing is None: From 4bf8952789b2e7b47206e264876c6ac6cbff19ad Mon Sep 17 00:00:00 2001 From: x01da Date: Tue, 11 Aug 2026 07:00:54 +0200 Subject: [PATCH 13/15] wip widget --- .../bec_widgets/widgets/edge_selector.py | 32 ++++++------- .../scan_control_xas/scan_control_xas.py | 47 +++++++++---------- 2 files changed, 37 insertions(+), 42 deletions(-) diff --git a/debye_bec/bec_widgets/widgets/edge_selector.py b/debye_bec/bec_widgets/widgets/edge_selector.py index e578616..3d974d5 100644 --- a/debye_bec/bec_widgets/widgets/edge_selector.py +++ b/debye_bec/bec_widgets/widgets/edge_selector.py @@ -143,9 +143,9 @@ class EdgeSelector(QDialog): super().__init__(parent) self.setWindowTitle("Absorption Edge Selector") self.buttons = {} - self.active_edge = None - self.active_element = None - self.active_energy = None + self.selected_edge = None + self.selected_element = None + self.selected_energy = None self.llim = llim self.hlim = hlim @@ -210,19 +210,19 @@ class EdgeSelector(QDialog): @SafeSlot() def _edge_changed(self, *_): - self.active_edge = self.edge.currentText() + self.selected_edge = self.edge.currentText() for element, button in self.buttons.items(): # logger.info(f"{element}") - if "L" in self.active_edge and element in ["H", "He"]: + if "L" in self.selected_edge and element in ["H", "He"]: button.setDisabled(True) button.setChecked(False) - elif self.active_edge in ["L2", "L3"] and element == "Li": + elif self.selected_edge in ["L2", "L3"] and element == "Li": button.setDisabled(True) button.setChecked(False) else: - energy = xraydb.xray_edge(element, self.active_edge, True) - if self.active_element == element: - self.active_energy = energy + 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) @@ -237,25 +237,25 @@ class EdgeSelector(QDialog): @SafeSlot() def _element_changed(self, element: str | None = None): if element is None: - if self.active_element is None: + if self.selected_element is None: return - element = self.active_element + element = self.selected_element # Uncheck all other buttons so only the picked one stays highlighted for e, btn in self.buttons.items(): if e != element: btn.setChecked(False) self.buttons[element].setChecked(True) - self.active_element = element - self.active_energy = xraydb.xray_edge(element, self.active_edge, 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.active_energy is not None: - if self.buttons[self.active_element].isChecked(): + 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.active_element}, {self.active_edge}-edge, {self.active_energy:0.1f} eV" + f"Selected {self.selected_element}, {self.selected_edge}-edge, {self.selected_energy:0.1f} eV" ) else: self._enable_button(self.select_button, False) 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 index 45ad514..be3c4cf 100644 --- 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 @@ -61,6 +61,9 @@ class ScanControlXAS(ScanControl): def __init__(self, *args, **kwargs): self._motion_profile_group = QGroupBox("XAS scan helper") + self._motion_profile_group.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) motion_profile_layout = QVBoxLayout(self._motion_profile_group) self.motion_profile_widget = MotionProfileWidget(self) motion_profile_layout.addWidget(self.motion_profile_widget) @@ -82,7 +85,7 @@ class ScanControlXAS(ScanControl): self.motion_profile_widget.update_plot(d_spacing=d_spacing) def _add_metadata_form(self): - self.layout.addWidget(self._motion_profile_group, stretch=1) # type: ignore + self.layout.addWidget(self._motion_profile_group) # type: ignore super()._add_metadata_form() self._connect_scan_parameter_signals() @@ -142,16 +145,13 @@ class MotionProfileWidget(QWidget): def __init__(self, parent): super().__init__() self._parent_widget = parent - hostname = self.parent.client._hostname - start = hostname.find("x") self.beamline = "" - if start != -1: - self.beamline = hostname[start : start + 5] outer_layout = QVBoxLayout(self) outer_layout.setContentsMargins(0, 0, 0, 0) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - edge_selector_layout = QHBoxLayout(self) + edge_selector_layout = QHBoxLayout() edge_selector_label = QLabel("Absorption edge:") self.edge_selector_button = QPushButton("Choose") self.edge_label = QLabel("No edge selected") @@ -160,7 +160,7 @@ class MotionProfileWidget(QWidget): edge_selector_layout.addWidget(self.edge_selector_button) edge_selector_layout.addStretch() - scan_rng_layout = QHBoxLayout(self) + 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") @@ -168,35 +168,24 @@ class MotionProfileWidget(QWidget): scan_rng_layout.addWidget(scan_rng_label) scan_rng_layout.addStretch() - plot_container = QWidget() - plot_layout = QHBoxLayout(plot_container) + 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]" ) - - self.edge_energy = None - plot_layout.addWidget(self.plot_pos) plot_layout.addWidget(self.plot_vel) - plot_container.setMinimumHeight( - int(np.max([self.plot_pos.minimumHeight(), self.plot_vel.minimumHeight()])) - ) - scroll = QScrollArea() - scroll.setWidgetResizable(True) - scroll.setFrameShape(QFrame.Shape.NoFrame) - scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - scroll.setWidget(plot_container) + 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.addWidget(scroll, stretch=1) + outer_layout.addLayout(plot_layout) self.scan_name = None self.scan_parameters = None @@ -216,8 +205,9 @@ class MotionProfileWidget(QWidget): plot.hideButtons() plot.getAxis("left").enableAutoSIPrefix(False) plot.getAxis("bottom").enableAutoSIPrefix(False) - plot.setMinimumHeight(180) - plot.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.MinimumExpanding) + 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): @@ -246,6 +236,11 @@ class MotionProfileWidget(QWidget): @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) @@ -254,9 +249,9 @@ class MotionProfileWidget(QWidget): case _: dlg = EdgeSelector(self) if dlg.exec_(): - self.edge_energy = dlg.active_energy + self.edge_energy = dlg.selected_energy self.edge_label.setText( - f"{dlg.active_element}, {dlg.active_edge}-edge, {dlg.active_energy:0.1f} eV" + f"{dlg.selected_element}, {dlg.selected_edge}-edge, {dlg.selected_energy:0.1f} eV" ) self._update_auto_scan_rng() self.update_plot() From 734639dc4085e292f53a7512f0ea3debf0f28c78 Mon Sep 17 00:00:00 2001 From: x01da Date: Tue, 11 Aug 2026 08:09:10 +0200 Subject: [PATCH 14/15] refactoring --- .../bec_widgets/widgets/edge_selector.py | 138 ++++++------------ .../scan_control_xas/scan_control_xas.py | 24 +-- 2 files changed, 55 insertions(+), 107 deletions(-) diff --git a/debye_bec/bec_widgets/widgets/edge_selector.py b/debye_bec/bec_widgets/widgets/edge_selector.py index 3d974d5..e2d62cc 100644 --- a/debye_bec/bec_widgets/widgets/edge_selector.py +++ b/debye_bec/bec_widgets/widgets/edge_selector.py @@ -1,12 +1,14 @@ +""" +Edge Selector: Custom BEC dialog to select an absorption edge. +""" + import sys -from typing import Literal, Optional 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 -from qtpy.QtCore import Signal # pylint: disable=E0611 from qtpy.QtWidgets import ( @@ -17,9 +19,7 @@ from qtpy.QtWidgets import ( QHBoxLayout, QLabel, QPushButton, - QSizePolicy, QVBoxLayout, - QWidget, ) logger = bec_logger.logger @@ -142,26 +142,26 @@ class EdgeSelector(QDialog): def __init__(self, parent=None, llim: int | float = 0, hlim: int | float = 1e6): super().__init__(parent) self.setWindowTitle("Absorption Edge Selector") - self.buttons = {} + self._buttons = {} self.selected_edge = None self.selected_element = None self.selected_energy = None - self.llim = llim - self.hlim = hlim + self._llim = llim + self._hlim = hlim layout = QVBoxLayout(self) text = QLabel("Select edge and element.") - edge_layout = QHBoxLayout(self) + edge_layout = QHBoxLayout() edge_label = QLabel("Edge") - self.edge = QComboBox() - self.edge.addItems(["K", "L1", "L2", "L3"]) + self._edge = QComboBox() + self._edge.addItems(["K", "L1", "L2", "L3"]) edge_layout.addWidget(edge_label) - edge_layout.addWidget(self.edge) + edge_layout.addWidget(self._edge) edge_layout.addStretch() - grid_layout = QGridLayout(self) + grid_layout = QGridLayout() grid_layout.setContentsMargins(0, 0, 0, 0) grid_layout.setSpacing(1) @@ -173,30 +173,30 @@ class EdgeSelector(QDialog): 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 + self._buttons[element] = button - res_layout = QHBoxLayout(self) - self.res = QLabel("") + res_layout = QHBoxLayout() + self._res = QLabel("") res_layout.addStretch() - res_layout.addWidget(self.res) + 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( + 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( + 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.addWidget(self._cancel_button) + button_layout.addWidget(self._select_button) button_layout.addStretch() layout.addWidget(text) @@ -205,14 +205,16 @@ class EdgeSelector(QDialog): layout.addLayout(res_layout) layout.addLayout(button_layout) - self.edge.activated.connect(self._edge_changed) + 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(): - # logger.info(f"{element}") + 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) @@ -224,7 +226,7 @@ class EdgeSelector(QDialog): if self.selected_element == element: self.selected_energy = energy if energy is not None: - if energy < self.llim or energy > self.hlim: + if energy < self._llim or energy > self._hlim: button.setChecked(False) button.setDisabled(True) else: @@ -240,28 +242,27 @@ class EdgeSelector(QDialog): if self.selected_element is None: return element = self.selected_element - # Uncheck all other buttons so only the picked one stays highlighted - for e, btn in self.buttons.items(): + for e, btn in self._buttons.items(): if e != element: btn.setChecked(False) - self.buttons[element].setChecked(True) + 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._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( + 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") + self._enable_button(self._select_button, False) + self._select_button.setText("Select") else: - self._enable_button(self.select_button, False) + self._enable_button(self._select_button, False) @staticmethod def _enable_button(button: QPushButton, enable: bool): @@ -277,63 +278,10 @@ class EdgeSelector(QDialog): ) -ROW_MARGINS = (4, 0, 4, 0) -ROW_SPACING = 6 - - -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 = EdgeSelector(self, llim=4500, hlim=60000) - 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;}}" - ) - - if __name__ == "__main__": app = QApplication(sys.argv) apply_theme("light") - dispatcher = BECDispatcher(gui_id="scan_control_advanced") - win = ElementSelector() + 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/scan_control_xas.py b/debye_bec/bec_widgets/widgets/scan_control_xas/scan_control_xas.py index be3c4cf..995ea76 100644 --- 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 @@ -60,13 +60,13 @@ class ScanControlXAS(ScanControl): """ def __init__(self, *args, **kwargs): - self._motion_profile_group = QGroupBox("XAS scan helper") - self._motion_profile_group.setSizePolicy( + self._xas_scans_helper_group = QGroupBox("XAS scan helper") + self._xas_scans_helper_group.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed ) - motion_profile_layout = QVBoxLayout(self._motion_profile_group) - self.motion_profile_widget = MotionProfileWidget(self) - motion_profile_layout.addWidget(self.motion_profile_widget) + 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 ) @@ -82,10 +82,10 @@ class ScanControlXAS(ScanControl): d_spacing = msg["signals"].get("mo1_bragg_crystal_current_d_spacing")["value"] if d_spacing != self.d_spacing: self.d_spacing = d_spacing - self.motion_profile_widget.update_plot(d_spacing=d_spacing) + self.xas_scans_helper_widget.update_plot(d_spacing=d_spacing) def _add_metadata_form(self): - self.layout.addWidget(self._motion_profile_group) # type: ignore + self.layout.addWidget(self._xas_scans_helper_group) # type: ignore super()._add_metadata_form() self._connect_scan_parameter_signals() @@ -98,9 +98,9 @@ class ScanControlXAS(ScanControl): def _show_motion_profile(self, scan_name): if scan_name in ALLOWED_SCANS: - self.motion_profile_widget.setVisible(True) + self.xas_scans_helper_widget.setVisible(True) else: - self.motion_profile_widget.setVisible(False) + self.xas_scans_helper_widget.setVisible(False) def _connect_scan_parameter_signals(self): for box in self.kwarg_boxes: @@ -123,7 +123,7 @@ class ScanControlXAS(ScanControl): @SafeSlot() def _scan_parameter_changed(self, *_): params = self.get_scan_parameters()[1] - self.motion_profile_widget.update_plot( + self.xas_scans_helper_widget.update_plot( scan_name=self._selected_scan, scan_parameters=params ) @@ -134,10 +134,10 @@ class ScanControlXAS(ScanControl): Args: theme (str): Theme, either "dark" or "light" """ - self.motion_profile_widget.apply_theme(theme) + self.xas_scans_helper_widget.apply_theme(theme) -class MotionProfileWidget(QWidget): +class XASScansHelper(QWidget): """ Additional widget specifically for XAS scans """ From 86121bcc7f3ed1c4af9e9015a84cca6e44b947ec Mon Sep 17 00:00:00 2001 From: x01da Date: Tue, 11 Aug 2026 08:09:28 +0200 Subject: [PATCH 15/15] Add new edge selector to digital twin --- .../widgets/digital_twin/digital_twin.py | 27 ++++++++++++++++ .../digital_twin/panels/input_panel.py | 32 ++++++++++++++++--- 2 files changed, 55 insertions(+), 4 deletions(-) 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,