1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-10 18:50:55 +02:00

Compare commits

...

15 Commits

Author SHA1 Message Date
2f1526182b docs(waveform): plotting ruleset 2025-07-02 23:03:01 +02:00
semantic-release
f10140e0f3 2.21.2
Automatically generated by python-semantic-release
2025-06-30 11:53:00 +00:00
09c5a443aa fix(waveform): fix waveform categorisation for aborted scans 2025-06-30 13:52:19 +02:00
3f5ab142a3 test: assert config for equality, not identity 2025-06-29 11:52:14 +02:00
semantic-release
422d06d141 2.21.1
Automatically generated by python-semantic-release
2025-06-29 09:49:32 +00:00
371bc485d0 fix(sbb monitor): add missing pyproject file 2025-06-29 11:48:47 +02:00
semantic-release
70970ecf00 2.21.0
Automatically generated by python-semantic-release
2025-06-28 17:36:16 +00:00
3d59c25aa9 feat(sbb monitor): add sbb monitor widget 2025-06-28 19:35:36 +02:00
semantic-release
70a06c5fd1 2.20.1
Automatically generated by python-semantic-release
2025-06-28 14:23:36 +00:00
7ba8863d6a fix(signal input base): unregister callback to avoid accessing deleted qt objects 2025-06-28 16:22:55 +02:00
semantic-release
00ea8bb6c6 2.20.0
Automatically generated by python-semantic-release
2025-06-26 13:03:28 +00:00
e841468892 refactor(curve settings): move signal logic to SignalCombobox 2025-06-26 15:02:31 +02:00
48a0e5831f fix(curve_settings): larger minimalWidth for the x device combobox selection 2025-06-26 15:02:31 +02:00
1e9dd4cd25 test(curve settings): add curve tree elements to the dialog test 2025-06-26 15:02:31 +02:00
d10328cb5c feat(waveform): move x axis selection to a combobox 2025-06-26 15:02:31 +02:00
19 changed files with 365 additions and 34 deletions

View File

@@ -1,6 +1,66 @@
# CHANGELOG
## v2.21.2 (2025-06-30)
### Bug Fixes
- **waveform**: Fix waveform categorisation for aborted scans
([`09c5a44`](https://github.com/bec-project/bec_widgets/commit/09c5a443aac675f02fa1e38179deb9863af152e2))
### Testing
- Assert config for equality, not identity
([`3f5ab14`](https://github.com/bec-project/bec_widgets/commit/3f5ab142a3cb5446261c4faebdc7b13f10ef4a80))
## v2.21.1 (2025-06-29)
### Bug Fixes
- **sbb monitor**: Add missing pyproject file
([`371bc48`](https://github.com/bec-project/bec_widgets/commit/371bc485d060404433082c9e3e00780961ce6ae3))
## v2.21.0 (2025-06-28)
### Features
- **sbb monitor**: Add sbb monitor widget
([`3d59c25`](https://github.com/bec-project/bec_widgets/commit/3d59c25aa93590a62ab4d31a4ab08589402bf407))
## v2.20.1 (2025-06-28)
### Bug Fixes
- **signal input base**: Unregister callback to avoid accessing deleted qt objects
([`7ba8863`](https://github.com/bec-project/bec_widgets/commit/7ba8863d6a0c21f772e4ef8a5d4180c2a7ab49cb))
## v2.20.0 (2025-06-26)
### Bug Fixes
- **curve_settings**: Larger minimalWidth for the x device combobox selection
([`48a0e58`](https://github.com/bec-project/bec_widgets/commit/48a0e5831feccd30f24218821bbc9d73f8c47933))
### Features
- **waveform**: Move x axis selection to a combobox
([`d10328c`](https://github.com/bec-project/bec_widgets/commit/d10328cb5c775a9b7b40ed4e9f2889e63eb039ff))
### Refactoring
- **curve settings**: Move signal logic to SignalCombobox
([`e841468`](https://github.com/bec-project/bec_widgets/commit/e84146889210165de1c4e63eb20b39f30cc5c623))
### Testing
- **curve settings**: Add curve tree elements to the dialog test
([`1e9dd4c`](https://github.com/bec-project/bec_widgets/commit/1e9dd4cd2561d37bdda1cd86b511295c259b2831))
## v2.19.4 (2025-06-26)
### Bug Fixes

View File

@@ -49,6 +49,7 @@ _Widgets = {
"ResetButton": "ResetButton",
"ResumeButton": "ResumeButton",
"RingProgressBar": "RingProgressBar",
"SBBMonitor": "SBBMonitor",
"ScanControl": "ScanControl",
"ScatterWaveform": "ScatterWaveform",
"SignalComboBox": "SignalComboBox",
@@ -3249,6 +3250,12 @@ class RingProgressBar(RPCBase):
"""
class SBBMonitor(RPCBase):
"""A widget to display the SBB monitor website."""
...
class ScanControl(RPCBase):
"""Widget to submit new scans to the queue."""

View File

@@ -169,6 +169,9 @@ class BECDockArea(BECWidget, QWidget):
tooltip="Add LogPanel - Disabled",
filled=True,
),
"sbb_monitor": MaterialIconAction(
icon_name="train", tooltip="Add SBB Monitor", filled=True
),
},
),
"separator_2": SeparatorAction(),
@@ -238,6 +241,9 @@ class BECDockArea(BECWidget, QWidget):
# self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect(
# lambda: self._create_widget_from_toolbar(widget_name="LogPanel")
# )
self.toolbar.widgets["menu_utils"].widgets["sbb_monitor"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="SBBMonitor")
)
# Icons
self.toolbar.widgets["attach_all"].action.triggered.connect(self.attach_all)

View File

@@ -55,7 +55,7 @@ class DeviceSignalInputBase(BECWidget):
self._hinted_signals = []
self._normal_signals = []
self._config_signals = []
self.bec_dispatcher.client.callbacks.register(
self._device_update_register = self.bec_dispatcher.client.callbacks.register(
EventType.DEVICE_UPDATE, self.update_signals_from_filters
)
@@ -289,3 +289,10 @@ class DeviceSignalInputBase(BECWidget):
if config is None:
return DeviceSignalInputBaseConfig(widget_class=self.__class__.__name__)
return DeviceSignalInputBaseConfig.model_validate(config)
def cleanup(self):
"""
Cleanup the widget.
"""
self.bec_dispatcher.client.callbacks.remove(self._device_update_register)
super().cleanup()

View File

@@ -90,6 +90,36 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
self.insertItem(0, "Hinted Signals")
self.model().item(0).setEnabled(False)
def set_to_obj_name(self, obj_name: str) -> bool:
"""
Set the combobox to the object name of the signal.
Args:
obj_name (str): Object name of the signal.
Returns:
bool: True if the object name was found and set, False otherwise.
"""
for i in range(self.count()):
signal_data = self.itemData(i)
if signal_data and signal_data.get("obj_name") == obj_name:
self.setCurrentIndex(i)
return True
return False
def set_to_first_enabled(self) -> bool:
"""
Set the combobox to the first enabled item.
Returns:
bool: True if an enabled item was found and set, False otherwise.
"""
for i in range(self.count()):
if self.model().item(i).isEnabled():
self.setCurrentIndex(i)
return True
return False
@SafeSlot()
def reset_selection(self):
"""Reset the selection of the combobox."""

View File

@@ -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 bec_widgets.widgets.editors.sbb_monitor.sbb_monitor_plugin import SBBMonitorPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(SBBMonitorPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -0,0 +1,15 @@
from bec_widgets.widgets.editors.website.website import WebsiteWidget
class SBBMonitor(WebsiteWidget):
"""
A widget to display the SBB monitor website.
"""
PLUGIN = True
ICON_NAME = "train"
USER_ACCESS = []
def __init__(self, parent=None, **kwargs):
url = "https://free.oevplus.ch/monitor/?viewType=splitView&layout=1&showClock=true&showPerron=true&stationGroup1Title=Villigen%2C%20PSI%20West&stationGroup2Title=Siggenthal-Würenlingen&station_1_id=85%3A3592&station_1_name=Villigen%2C%20PSI%20West&station_1_quantity=5&station_1_group=1&station_2_id=85%3A3502&station_2_name=Siggenthal-Würenlingen&station_2_quantity=5&station_2_group=2"
super().__init__(parent=parent, url=url, **kwargs)

View File

@@ -0,0 +1 @@
{'files': ['sbb_monitor.py']}

View File

@@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.editors.sbb_monitor.sbb_monitor import SBBMonitor
DOM_XML = """
<ui language='c++'>
<widget class='SBBMonitor' name='sbb_monitor'>
</widget>
</ui>
"""
class SBBMonitorPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = SBBMonitor(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return ""
def icon(self):
return designer_material_icon(SBBMonitor.ICON_NAME)
def includeFile(self):
return "sbb_monitor"
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 "SBBMonitor"
def toolTip(self):
return ""
def whatsThis(self):
return self.toolTip()

View File

@@ -2,13 +2,12 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from qtpy.QtCore import QSize
from qtpy.QtCore import QSize, Qt
from qtpy.QtWidgets import (
QComboBox,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QSizePolicy,
QVBoxLayout,
QWidget,
@@ -16,9 +15,8 @@ from qtpy.QtWidgets import (
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.settings_dialog import SettingWidget
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
DeviceLineEdit,
)
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.control.device_input.signal_combobox.signal_combobox import SignalComboBox
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_tree import CurveTree
if TYPE_CHECKING: # pragma: no cover
@@ -30,6 +28,7 @@ class CurveSetting(SettingWidget):
super().__init__(parent=parent, *args, **kwargs)
self.setProperty("skip_settings", True)
self.target_widget = target_widget
self._x_settings_connected = False
self.layout = QVBoxLayout(self)
@@ -51,15 +50,23 @@ class CurveSetting(SettingWidget):
self.mode_combo_label = QLabel("Mode")
self.mode_combo = QComboBox()
self.mode_combo.addItems(["auto", "index", "timestamp", "device"])
self.mode_combo.setMinimumWidth(120)
self.spacer = QWidget()
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.device_x_label = QLabel("Device")
self.device_x = DeviceLineEdit(parent=self)
self.device_x = DeviceComboBox(parent=self)
self.device_x.insertItem(0, "")
self.device_x.setEditable(True)
self.device_x.setMinimumWidth(180)
self.signal_x_label = QLabel("Signal")
self.signal_x = QLineEdit()
self.signal_x = SignalComboBox(parent=self)
self.signal_x.include_config_signals = False
self.signal_x.insertItem(0, "")
self.signal_x.setEditable(True)
self.signal_x.setMinimumWidth(180)
self._get_x_mode_from_waveform()
self.switch_x_device_selection()
@@ -85,11 +92,32 @@ class CurveSetting(SettingWidget):
def switch_x_device_selection(self):
if self.mode_combo.currentText() == "device":
self._x_settings_connected = True
self.device_x.currentTextChanged.connect(self.signal_x.set_device)
self.device_x.device_reset.connect(self.signal_x.reset_selection)
self.device_x.setEnabled(True)
self.device_x.setText(self.target_widget.x_axis_mode["name"])
self.signal_x.setText(self.target_widget.x_axis_mode["entry"])
self.signal_x.setEnabled(True)
item = self.device_x.findText(self.target_widget.x_axis_mode["name"])
self.device_x.setCurrentIndex(item if item != -1 else 0)
signal_x = self.target_widget.x_axis_mode.get("entry", "")
if signal_x:
self.signal_x.set_to_obj_name(signal_x)
else:
# If no match is found, set to the first enabled item
if not self.signal_x.set_to_first_enabled():
# If no enabled item is found, set to the first item
self.signal_x.setCurrentIndex(0)
else:
self.device_x.setEnabled(False)
self.signal_x.setEnabled(False)
self.device_x.setCurrentIndex(0)
self.signal_x.setCurrentIndex(0)
if self._x_settings_connected:
self._x_settings_connected = False
self.device_x.currentTextChanged.disconnect(self.signal_x.set_device)
self.device_x.device_reset.disconnect(self.signal_x.reset_selection)
def _init_y_box(self):
self.y_axis_box = QGroupBox("Y Axis")
@@ -108,10 +136,11 @@ class CurveSetting(SettingWidget):
Accepts the changes made in the settings widget and applies them to the target widget.
"""
if self.mode_combo.currentText() == "device":
self.target_widget.x_mode = self.device_x.text()
signal_x = self.signal_x.text()
self.target_widget.x_mode = self.device_x.currentText()
signal_x = self.signal_x.currentText()
signal_data = self.signal_x.itemData(self.signal_x.currentIndex())
if signal_x != "":
self.target_widget.x_entry = signal_x
self.target_widget.x_entry = signal_data.get("obj_name", signal_x)
else:
self.target_widget.x_mode = self.mode_combo.currentText()
self.curve_manager.send_curve_json()
@@ -126,5 +155,7 @@ class CurveSetting(SettingWidget):
"""Cleanup the widget."""
self.device_x.close()
self.device_x.deleteLater()
self.signal_x.close()
self.signal_x.deleteLater()
self.curve_manager.close()
self.curve_manager.deleteLater()

View File

@@ -142,20 +142,10 @@ class CurveRow(QTreeWidgetItem):
# If the device name is not found, set the first enabled item
self.device_edit.setCurrentIndex(0)
for i in range(self.entry_edit.count()):
entry_data = self.entry_edit.itemData(i)
if entry_data and entry_data.get("obj_name") == self.config.signal.entry:
# If the device name matches an object name, set it
self.entry_edit.setCurrentIndex(i)
break
else:
# If no match found, set the first enabled item
for i in range(self.entry_edit.count()):
model = self.entry_edit.model()
if model.flags(model.index(i, 0)) & Qt.ItemIsEnabled:
self.entry_edit.setCurrentIndex(i)
break
else:
if not self.entry_edit.set_to_obj_name(self.config.signal.entry):
# If the entry is not found, try to set it to the first enabled item
if not self.entry_edit.set_to_first_enabled():
# If no enabled item is found, set to the first item
self.entry_edit.setCurrentIndex(0)
self.tree.setItemWidget(self, 1, self.device_edit)

View File

@@ -1683,9 +1683,14 @@ class Waveform(PlotBase):
return None
if hasattr(self.scan_item, "live_data"):
readout_priority = self.scan_item.status_message.info["readout_priority"] # live data
readout_priority = self.scan_item.status_message.info.get(
"readout_priority"
) # live data
else:
readout_priority = self.scan_item.metadata["bec"]["readout_priority"] # history
readout_priority = self.scan_item.metadata["bec"].get("readout_priority") # history
if readout_priority is None:
return None
# Reset sync/async curve lists
self._async_curves.clear()

View File

@@ -0,0 +1,94 @@
# Waveform Widget Plotting Ruleset
The Waveform widget allows plotting data from scans generated within the BEC framework. Each scan produces a **scan item
**, which includes measurement data from various devices, as well as additional metadata (e.g., devices driving the
scan, such as `motor_x`, `motor_y`, etc.).
The rules described below define how data from different devices and scans can be plotted together, depending on the
selected `x_mode`.
---
## Types of Curves
- **Live Curves**
- Continuously updated from the currently running scan.
- Dynamically adapt based on live data.
- **History Curves**
- Static curves representing data from a specific completed scan (e.g., scan 110).
- Displayed only if compatible with the currently selected x-axis device.
---
## General Compatibility Rules
- Data from devices within **the same scan item** can always be plotted against each other, provided they have the same
number of data points.
- Example: plotting `detector_1` against `detector_2` from scan 110 to check signal correlation is valid.
- Data from **different scan items** cannot be plotted against each other.
- Example: plotting x-data from `motor_x` in scan 110 against y-data from `detector_1` in scan 111 is not allowed.
---
## Specific Rules for Each `x_mode`
### 1. `x_mode='auto'` (default)
- Automatically determines the device for x-axis scaling from the currently running (live) scan.
- The primary device used for scaling is the first device listed in the current scan's `scan_report_devices` (usually a
positioner in the case of step scans).
- **Live Curves**:
- Always plotted against the selected x-axis device from the current scan.
- **History Curves**:
- Each history curve is linked to a specific scan item with scan ID.
- Waveform widget checks compatibility with the currently selected x-axis device from the live scan.
- If the selected x-axis device data exists in the history curves original scan item, the curve is displayed.
- If the selected x-axis device data does not exist in the original scan item, the history curve remains **hidden**
until a compatible device is selected again.
_Example Scenario_:
- The live scan currently uses `motor_x` as the x-axis device. Any history curve will only be displayed if its original
scan item contains data for `motor_x`. If not, the history curve is hidden.
---
### 2. `x_mode='timestamp'`
- X-axis scaling is based on timestamps from each data point.
- All curves, both live and history, are always compatible, as timestamps provide a universal and absolute reference for
the x-axis.
- Curves from different scan items can appear simultaneously, regardless of the devices measured.
---
### 3. `x_mode='index'`
- X-axis scaling uses data point indices (0, 1, 2, ..., N-1).
- Allows plotting multiple curves of varying lengths in the same view.
- All curves are always compatible, as indices represent relative positions, independent of device or timestamp, it is
up to the user to interpret the x-axis.
---
### 4. `x_mode='device'`
- User explicitly selects a device to scale the x-axis.
- The chosen device must exist in each curves respective scan item.
- **Live Curves**:
- Continuously displayed if the selected device data is being measured in the current scan.
- **History Curves**:
- Displayed only if the selected device exists in the scan item from which the history curve originates.
- Remain **hidden** if the selected device is not present in the original scan item, reappearing only when a
compatible device is chosen again.
---
## Key Technical Points
- Each curve stores its own independent x and y data sets (as it is defined by `pg.PlotDataItem`, allowing simultaneous
plotting of multiple curves with different data lengths.
- Compatibility checks ensure that plotting meaningful comparisons is always possible, avoiding combinations of
unrelated or non-compatible datasets.

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "2.19.4"
version = "2.21.2"
description = "BEC Widgets"
requires-python = ">=3.10"
classifiers = [

View File

@@ -93,7 +93,7 @@ def test_curve_setting_switch_device_mode(curve_setting_fixture, qtbot):
assert curve_setting.device_x.isEnabled()
# This line edit should reflect the waveform.x_axis_mode["name"], or be blank if none
assert curve_setting.device_x.text() == wf.x_axis_mode["name"]
assert curve_setting.device_x.currentText() == ""
def test_curve_setting_refresh(curve_setting_fixture, qtbot):
@@ -127,8 +127,8 @@ def test_change_device_from_target_widget(curve_setting_fixture, qtbot):
assert curve_setting.mode_combo.currentText() == "device"
assert curve_setting.device_x.isEnabled()
assert curve_setting.device_x.text() == wf.x_axis_mode["name"]
assert curve_setting.signal_x.text() == wf.x_axis_mode["entry"]
assert curve_setting.device_x.currentText() == wf.x_axis_mode["name"]
assert curve_setting.signal_x.currentText() == f"{wf.x_axis_mode['entry']} (readback)"
##################################################

View File

@@ -144,3 +144,12 @@ def test_signal_lineedit(device_signal_line_edit):
assert device_signal_line_edit._is_valid_input is True
device_signal_line_edit.setText("invalid")
assert device_signal_line_edit._is_valid_input is False
def test_device_signal_input_base_cleanup(qtbot, mocked_client):
widget = DeviceInputWidget(client=mocked_client)
widget.close()
widget.deleteLater()
mocked_client.callbacks.remove.assert_called_once_with(widget._device_update_register)

View File

@@ -29,4 +29,4 @@ def test_gui_server_get_service_config(gui_server):
"""
Test that the server is started with the correct arguments.
"""
assert gui_server._get_service_config().config is ServiceConfig().config
assert gui_server._get_service_config().config == ServiceConfig().config

View File

@@ -806,6 +806,13 @@ def test_show_curve_settings_popup(qtbot, mocked_client):
assert wf.curve_settings_dialog.isVisible()
assert curve_action.isChecked()
# add a new row to the curve tree
wf.curve_settings_dialog.widget.curve_manager.toolbar.widgets["add"].action.trigger()
wf.curve_settings_dialog.widget.curve_manager.toolbar.widgets["add"].action.trigger()
qtbot.wait(100)
# Check that the new row is added
assert wf.curve_settings_dialog.widget.curve_manager.tree.model().rowCount() == 2
wf.curve_settings_dialog.close()
assert wf.curve_settings_dialog is None
assert not curve_action.isChecked(), "Should be unchecked after closing dialog"