mirror of
https://github.com/bec-project/bec_widgets.git
synced 2025-07-13 19:21:50 +02:00
feat: add metadata widget to scan control
This commit is contained in:
@ -1,8 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bec_qthemes import material_icon
|
||||
from PySide6.QtWidgets import QHBoxLayout, QSizePolicy, QToolButton
|
||||
from qtpy.QtWidgets import QFrame, QLabel, QLayout, QVBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLayout,
|
||||
QSizePolicy,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
|
||||
|
||||
|
@ -1,10 +1,10 @@
|
||||
from collections import defaultdict
|
||||
from types import SimpleNamespace
|
||||
from types import NoneType, SimpleNamespace
|
||||
from typing import Optional
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from pydantic import BaseModel, Field
|
||||
from qtpy.QtCore import Property, Signal, Slot
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
@ -18,12 +18,13 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot
|
||||
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.colors import get_accent_colors
|
||||
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
|
||||
from bec_widgets.widgets.control.scan_control.scan_group_box import ScanGroupBox
|
||||
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
|
||||
from bec_widgets.widgets.utility.toggle.toggle import ToggleSwitch
|
||||
|
||||
|
||||
@ -42,6 +43,7 @@ class ScanControlConfig(ConnectionConfig):
|
||||
class ScanControl(BECWidget, QWidget):
|
||||
PLUGIN = True
|
||||
ICON_NAME = "tune"
|
||||
ARG_BOX_POSITION: int = 2
|
||||
|
||||
scan_started = Signal()
|
||||
scan_selected = Signal(str)
|
||||
@ -83,6 +85,8 @@ class ScanControl(BECWidget, QWidget):
|
||||
self.config.default_scan = default_scan
|
||||
self.config.allowed_scans = allowed_scans
|
||||
|
||||
self._scan_metadata: dict | None = None
|
||||
|
||||
# Create and set main layout
|
||||
self._init_UI()
|
||||
|
||||
@ -152,6 +156,20 @@ class ScanControl(BECWidget, QWidget):
|
||||
# Initialize scan selection
|
||||
self.populate_scans()
|
||||
|
||||
# Append metadata form
|
||||
self._add_metadata_form()
|
||||
|
||||
self.layout.addStretch()
|
||||
|
||||
def _add_metadata_form(self):
|
||||
self._metadata_form = ScanMetadata()
|
||||
self.layout.addWidget(self._metadata_form)
|
||||
self._metadata_form.update_with_new_scan(self.comboBox_scan_selection.currentText())
|
||||
self.scan_selected.connect(self._metadata_form.update_with_new_scan)
|
||||
self._metadata_form.metadata_updated.connect(self.update_scan_metadata)
|
||||
self._metadata_form.metadata_cleared.connect(self.update_scan_metadata)
|
||||
self._metadata_form.validate_form()
|
||||
|
||||
def populate_scans(self):
|
||||
"""Populates the scan selection combo box with available scans from BEC session."""
|
||||
self.available_scans = self.client.connector.get(
|
||||
@ -176,8 +194,9 @@ class ScanControl(BECWidget, QWidget):
|
||||
self.request_last_executed_scan_parameters()
|
||||
self.restore_scan_parameters(selected_scan_name)
|
||||
|
||||
@Slot()
|
||||
def request_last_executed_scan_parameters(self):
|
||||
@SafeSlot()
|
||||
@SafeSlot(bool)
|
||||
def request_last_executed_scan_parameters(self, *_):
|
||||
"""
|
||||
Requests the last executed scan parameters from BEC and restores them to the scan control widget.
|
||||
"""
|
||||
@ -211,7 +230,7 @@ class ScanControl(BECWidget, QWidget):
|
||||
else:
|
||||
self.last_scan_found = False
|
||||
|
||||
@Property(str)
|
||||
@SafeProperty(str)
|
||||
def current_scan(self):
|
||||
"""Returns the scan name for the currently selected scan."""
|
||||
return self.comboBox_scan_selection.currentText()
|
||||
@ -227,7 +246,7 @@ class ScanControl(BECWidget, QWidget):
|
||||
return
|
||||
self.comboBox_scan_selection.setCurrentText(scan_name)
|
||||
|
||||
@Slot(str)
|
||||
@SafeSlot(str)
|
||||
def set_current_scan(self, scan_name: str):
|
||||
"""Slot for setting the current scan to the given scan name.
|
||||
|
||||
@ -236,7 +255,7 @@ class ScanControl(BECWidget, QWidget):
|
||||
"""
|
||||
self.current_scan = scan_name
|
||||
|
||||
@Property(bool)
|
||||
@SafeProperty(bool)
|
||||
def hide_arg_box(self):
|
||||
"""Property to hide the argument box."""
|
||||
if self.arg_box is None:
|
||||
@ -253,7 +272,7 @@ class ScanControl(BECWidget, QWidget):
|
||||
if self.arg_box is not None:
|
||||
self.arg_box.setVisible(not hide)
|
||||
|
||||
@Property(bool)
|
||||
@SafeProperty(bool)
|
||||
def hide_kwarg_boxes(self):
|
||||
"""Property to hide the keyword argument boxes."""
|
||||
if len(self.kwarg_boxes) == 0:
|
||||
@ -274,7 +293,7 @@ class ScanControl(BECWidget, QWidget):
|
||||
for box in self.kwarg_boxes:
|
||||
box.setVisible(not hide)
|
||||
|
||||
@Property(bool)
|
||||
@SafeProperty(bool)
|
||||
def hide_scan_control_buttons(self):
|
||||
"""Property to hide the scan control buttons."""
|
||||
return not self.button_run_scan.isVisible()
|
||||
@ -288,12 +307,40 @@ class ScanControl(BECWidget, QWidget):
|
||||
"""
|
||||
self.show_scan_control_buttons(not hide)
|
||||
|
||||
@Slot(bool)
|
||||
@SafeProperty(bool)
|
||||
def hide_metadata(self):
|
||||
"""Property to hide the metadata form."""
|
||||
return not self._metadata_form.isVisible()
|
||||
|
||||
@hide_metadata.setter
|
||||
def hide_metadata(self, hide: bool):
|
||||
"""Setter for the hide_metadata property.
|
||||
|
||||
Args:
|
||||
hide(bool): Hide or show the metadata form.
|
||||
"""
|
||||
self._metadata_form.setVisible(not hide)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def hide_optional_metadata(self):
|
||||
"""Property to hide the optional metadata form."""
|
||||
return self._metadata_form.hide_optional_metadata
|
||||
|
||||
@hide_optional_metadata.setter
|
||||
def hide_optional_metadata(self, hide: bool):
|
||||
"""Setter for the hide_optional_metadata property.
|
||||
|
||||
Args:
|
||||
hide(bool): Hide or show the optional metadata form.
|
||||
"""
|
||||
self._metadata_form.hide_optional_metadata = hide
|
||||
|
||||
@SafeSlot(bool)
|
||||
def show_scan_control_buttons(self, show: bool):
|
||||
"""Shows or hides the scan control buttons."""
|
||||
self.scan_control_group.setVisible(show)
|
||||
|
||||
@Property(bool)
|
||||
@SafeProperty(bool)
|
||||
def hide_scan_selection_combobox(self):
|
||||
"""Property to hide the scan selection combobox."""
|
||||
return not self.comboBox_scan_selection.isVisible()
|
||||
@ -307,12 +354,12 @@ class ScanControl(BECWidget, QWidget):
|
||||
"""
|
||||
self.show_scan_selection_combobox(not hide)
|
||||
|
||||
@Slot(bool)
|
||||
@SafeSlot(bool)
|
||||
def show_scan_selection_combobox(self, show: bool):
|
||||
"""Shows or hides the scan selection combobox."""
|
||||
self.scan_selection_group.setVisible(show)
|
||||
|
||||
@Slot(str)
|
||||
@SafeSlot(str)
|
||||
def scan_select(self, scan_name: str):
|
||||
"""
|
||||
Slot for scan selection. Updates the scan control layout based on the selected scan.
|
||||
@ -335,7 +382,7 @@ class ScanControl(BECWidget, QWidget):
|
||||
self.update()
|
||||
self.adjustSize()
|
||||
|
||||
@Property(bool)
|
||||
@SafeProperty(bool)
|
||||
def hide_add_remove_buttons(self):
|
||||
"""Property to hide the add_remove buttons."""
|
||||
return self._hide_add_remove_buttons
|
||||
@ -358,10 +405,11 @@ class ScanControl(BECWidget, QWidget):
|
||||
Args:
|
||||
groups(list): List of dictionaries containing the gui_group information.
|
||||
"""
|
||||
position = self.ARG_BOX_POSITION + (1 if self.arg_box is not None else 0)
|
||||
for group in groups:
|
||||
box = ScanGroupBox(box_type="kwargs", config=group)
|
||||
box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
self.layout.addWidget(box)
|
||||
self.layout.insertWidget(position + len(self.kwarg_boxes), box)
|
||||
self.kwarg_boxes.append(box)
|
||||
|
||||
def add_arg_group(self, group: dict):
|
||||
@ -374,9 +422,9 @@ class ScanControl(BECWidget, QWidget):
|
||||
self.arg_box.device_selected.connect(self.emit_device_selected)
|
||||
self.arg_box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
self.arg_box.hide_add_remove_buttons = self._hide_add_remove_buttons
|
||||
self.layout.addWidget(self.arg_box)
|
||||
self.layout.insertWidget(self.ARG_BOX_POSITION, self.arg_box)
|
||||
|
||||
@Slot(str)
|
||||
@SafeSlot(str)
|
||||
def emit_device_selected(self, dev_names):
|
||||
"""
|
||||
Emit the signal to inform about selected device(s)
|
||||
@ -454,10 +502,20 @@ class ScanControl(BECWidget, QWidget):
|
||||
scan_params = ScanParameterConfig(name=scan_name, args=args, kwargs=kwargs)
|
||||
self.config.scans[scan_name] = scan_params
|
||||
|
||||
@SafeSlot(dict)
|
||||
@SafeSlot(NoneType)
|
||||
def update_scan_metadata(self, md: dict | None):
|
||||
self._scan_metadata = md
|
||||
if md is None:
|
||||
self.button_run_scan.setEnabled(False)
|
||||
else:
|
||||
self.button_run_scan.setEnabled(True)
|
||||
|
||||
@SafeSlot(popup_error=True)
|
||||
def run_scan(self):
|
||||
"""Starts the selected scan with the given parameters."""
|
||||
args, kwargs = self.get_scan_parameters()
|
||||
kwargs["metadata"] = self._scan_metadata
|
||||
self.scan_args.emit(args)
|
||||
scan_function = getattr(self.scans, self.comboBox_scan_selection.currentText())
|
||||
if callable(scan_function):
|
||||
|
@ -2,13 +2,12 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtWidgets import QSizePolicy
|
||||
from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal # type: ignore
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QTreeView,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
@ -111,6 +110,7 @@ class AdditionalMetadataTable(QWidget):
|
||||
self._table_view.setSizePolicy(
|
||||
QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
|
||||
)
|
||||
self._table_view.setAlternatingRowColors(True)
|
||||
self._layout.addWidget(self._table_view)
|
||||
|
||||
self._buttons = QVBoxLayout()
|
||||
|
@ -1,18 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from types import NoneType
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from PySide6.QtWidgets import QHBoxLayout, QToolButton
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.metadata_schema import get_metadata_schema_for_scan
|
||||
from bec_qthemes import material_icon
|
||||
from pydantic import Field, ValidationError
|
||||
from qtpy.QtCore import Signal # type: ignore
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLayout,
|
||||
QVBoxLayout,
|
||||
@ -20,7 +21,7 @@ from qtpy.QtWidgets import (
|
||||
)
|
||||
|
||||
from bec_widgets.qt_utils.compact_popup import CompactPopupWidget
|
||||
from bec_widgets.qt_utils.error_popups import SafeSlot
|
||||
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.qt_utils.expandable_frame import ExpandableGroupFrame
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.widgets.editors.scan_metadata._metadata_widgets import widget_from_type
|
||||
@ -39,6 +40,9 @@ class ScanMetadata(BECWidget, QWidget):
|
||||
metadata schema registry supplied in the plugin repo to find pydantic models
|
||||
associated with the scan type. Sets limits for numerical values if specified."""
|
||||
|
||||
metadata_updated = Signal(dict)
|
||||
metadata_cleared = Signal(NoneType)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
@ -55,7 +59,7 @@ class ScanMetadata(BECWidget, QWidget):
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self._layout)
|
||||
|
||||
self._required_md_box = ExpandableGroupFrame("Required scan metadata")
|
||||
self._required_md_box = ExpandableGroupFrame("Scan schema metadata")
|
||||
self._layout.addWidget(self._required_md_box)
|
||||
self._required_md_box_layout = QHBoxLayout()
|
||||
self._required_md_box.set_layout(self._required_md_box_layout)
|
||||
@ -93,14 +97,20 @@ class ScanMetadata(BECWidget, QWidget):
|
||||
self.populate()
|
||||
self.validate_form()
|
||||
|
||||
def validate_form(self, *_):
|
||||
def validate_form(self, *_) -> bool:
|
||||
"""validate the currently entered metadata against the pydantic schema.
|
||||
If successful, returns on metadata_emitted and returns true.
|
||||
Otherwise, emits on metadata_cleared and returns false."""
|
||||
try:
|
||||
self._md_schema.model_validate(self.get_full_model_dict())
|
||||
metadata_dict = self.get_full_model_dict()
|
||||
self._md_schema.model_validate(metadata_dict)
|
||||
self._validity.set_global_state("success")
|
||||
self._validity_message.setText("No errors!")
|
||||
self.metadata_updated.emit(metadata_dict)
|
||||
except ValidationError as e:
|
||||
self._validity.set_global_state("emergency")
|
||||
self._validity_message.setText(str(e))
|
||||
self.metadata_cleared.emit(None)
|
||||
|
||||
def get_full_model_dict(self):
|
||||
"""Get the entered metadata as a dict"""
|
||||
@ -153,6 +163,20 @@ class ScanMetadata(BECWidget, QWidget):
|
||||
self._md_grid_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._md_grid_layout.setSizeConstraint(QLayout.SizeConstraint.SetFixedSize)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def hide_optional_metadata(self): # type: ignore
|
||||
"""Property to hide the optional metadata table."""
|
||||
return not self._additional_md_box.isVisible()
|
||||
|
||||
@hide_optional_metadata.setter
|
||||
def hide_optional_metadata(self, hide: bool):
|
||||
"""Setter for the hide_optional_metadata property.
|
||||
|
||||
Args:
|
||||
hide(bool): Hide or show the optional metadata table.
|
||||
"""
|
||||
self._additional_md_box.setVisible(not hide)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
from unittest.mock import patch
|
||||
|
@ -7,6 +7,7 @@ from bec_lib.messages import AvailableResourceMessage, ScanQueueHistoryMessage,
|
||||
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
from bec_widgets.widgets.control.scan_control import ScanControl
|
||||
from bec_widgets.widgets.editors.scan_metadata._metadata_widgets import StrMetadataField
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
@ -403,7 +404,7 @@ def test_run_line_scan_with_parameters(scan_control, mocked_client):
|
||||
expected_device = mocked_client.device_manager.devices.samx
|
||||
expected_args_list = [expected_device, args["start"], args["stop"]]
|
||||
assert called_args == tuple(expected_args_list)
|
||||
assert called_kwargs == kwargs
|
||||
assert called_kwargs == kwargs | {"metadata": {"sample_name": ""}}
|
||||
|
||||
# Check the emitted signal
|
||||
mock_slot.assert_called_once()
|
||||
@ -479,7 +480,7 @@ def test_run_grid_scan_with_parameters(scan_control, mocked_client):
|
||||
args_row2["steps"],
|
||||
]
|
||||
assert called_args == tuple(expected_args_list)
|
||||
assert called_kwargs == kwargs
|
||||
assert called_kwargs == kwargs | {"metadata": {"sample_name": ""}}
|
||||
|
||||
# Check the emitted signal
|
||||
mock_slot.assert_called_once()
|
||||
@ -532,3 +533,22 @@ def test_get_scan_parameters_from_redis(scan_control, mocked_client):
|
||||
|
||||
assert args == ["samx", 0.0, 2.0]
|
||||
assert kwargs == {"steps": 10, "relative": False, "exp_time": 2.0, "burst_at_each_point": 1}
|
||||
|
||||
|
||||
def test_scan_metadata_is_connected(scan_control):
|
||||
assert scan_control._metadata_form._scan_name == "line_scan"
|
||||
scan_control.comboBox_scan_selection.setCurrentText("grid_scan")
|
||||
assert scan_control._metadata_form._scan_name == "grid_scan"
|
||||
sample_name = scan_control._metadata_form._md_grid_layout.itemAtPosition(0, 1).widget()
|
||||
assert isinstance(sample_name, StrMetadataField)
|
||||
sample_name._main_widget.setText("Test Sample")
|
||||
scan_control._metadata_form._additional_metadata._table_model._data = [
|
||||
["test key 1", "test value 1"],
|
||||
["test key 2", "test value 2"],
|
||||
]
|
||||
scan_control._metadata_form.validate_form()
|
||||
assert scan_control._scan_metadata == {
|
||||
"sample_name": "Test Sample",
|
||||
"test key 1": "test value 1",
|
||||
"test key 2": "test value 2",
|
||||
}
|
||||
|
@ -5,11 +5,9 @@ import pytest
|
||||
from bec_lib.metadata_schema import BasicScanMetadata
|
||||
from pydantic import Field
|
||||
from pydantic.types import Json
|
||||
from PySide6.QtCore import QItemSelectionModel, QModelIndex, QRect
|
||||
from qtpy.QtCore import QPoint, Qt
|
||||
from qtpy.QtWidgets import QCheckBox, QDoubleSpinBox, QLineEdit, QSpinBox, QWidget
|
||||
from qtpy.QtCore import QItemSelectionModel, QPoint, Qt
|
||||
|
||||
from bec_widgets.widgets.editors.scan_metadata import AdditionalMetadataTableModel, ScanMetadata
|
||||
from bec_widgets.widgets.editors.scan_metadata import ScanMetadata
|
||||
from bec_widgets.widgets.editors.scan_metadata._metadata_widgets import (
|
||||
BoolMetadataField,
|
||||
FloatDecimalMetadataField,
|
||||
@ -191,7 +189,8 @@ def test_additional_metadata_table_add_row(table: AdditionalMetadataTable):
|
||||
def test_additional_metadata_table_delete_row(table: AdditionalMetadataTable):
|
||||
assert table._table_model.rowCount() == 3
|
||||
m = table._table_view.selectionModel()
|
||||
m.select(table._table_view.indexAt(QPoint(40, 30)), QItemSelectionModel.SelectionFlag.Select)
|
||||
item = table._table_view.indexAt(QPoint(0, 0)).siblingAtRow(1)
|
||||
m.select(item, QItemSelectionModel.SelectionFlag.Select)
|
||||
table.delete_selected_rows()
|
||||
assert table._table_model.rowCount() == 2
|
||||
assert list(table.dump_dict().keys()) == ["key1", "key3"]
|
||||
|
Reference in New Issue
Block a user