From 7309c1dede2ec93bf08f84f13596ce18dfdb1476 Mon Sep 17 00:00:00 2001 From: David Perl Date: Mon, 17 Feb 2025 16:27:41 +0100 Subject: [PATCH] feat: add metadata widget to scan control --- bec_widgets/qt_utils/expandable_frame.py | 12 ++- .../control/scan_control/scan_control.py | 94 +++++++++++++++---- .../additional_metadata_table.py | 4 +- .../editors/scan_metadata/scan_metadata.py | 36 +++++-- tests/unit_tests/test_scan_control.py | 24 ++++- tests/unit_tests/test_scan_metadata.py | 9 +- 6 files changed, 144 insertions(+), 35 deletions(-) diff --git a/bec_widgets/qt_utils/expandable_frame.py b/bec_widgets/qt_utils/expandable_frame.py index b9dc9487..68b8d024 100644 --- a/bec_widgets/qt_utils/expandable_frame.py +++ b/bec_widgets/qt_utils/expandable_frame.py @@ -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 diff --git a/bec_widgets/widgets/control/scan_control/scan_control.py b/bec_widgets/widgets/control/scan_control/scan_control.py index 03f685e2..89e18ad6 100644 --- a/bec_widgets/widgets/control/scan_control/scan_control.py +++ b/bec_widgets/widgets/control/scan_control/scan_control.py @@ -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): diff --git a/bec_widgets/widgets/editors/scan_metadata/additional_metadata_table.py b/bec_widgets/widgets/editors/scan_metadata/additional_metadata_table.py index d5f1d3e0..83f48cf2 100644 --- a/bec_widgets/widgets/editors/scan_metadata/additional_metadata_table.py +++ b/bec_widgets/widgets/editors/scan_metadata/additional_metadata_table.py @@ -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() diff --git a/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py b/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py index b625b120..c8d5112c 100644 --- a/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py +++ b/bec_widgets/widgets/editors/scan_metadata/scan_metadata.py @@ -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 diff --git a/tests/unit_tests/test_scan_control.py b/tests/unit_tests/test_scan_control.py index ee1488c4..cbf1c195 100644 --- a/tests/unit_tests/test_scan_control.py +++ b/tests/unit_tests/test_scan_control.py @@ -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", + } diff --git a/tests/unit_tests/test_scan_metadata.py b/tests/unit_tests/test_scan_metadata.py index c509471a..1d0ab148 100644 --- a/tests/unit_tests/test_scan_metadata.py +++ b/tests/unit_tests/test_scan_metadata.py @@ -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"]