0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-14 11:41:49 +02:00

fix(scan_control): adapted widget to scan BEC gui config

This commit is contained in:
2024-06-11 14:00:01 +02:00
parent 67d398caf7
commit 8b822e0fa8

View File

@ -1,3 +1,4 @@
import qdarktheme
from bec_lib.endpoints import MessageEndpoints from bec_lib.endpoints import MessageEndpoints
from qtpy.QtWidgets import ( from qtpy.QtWidgets import (
QApplication, QApplication,
@ -13,6 +14,7 @@ from qtpy.QtWidgets import (
QLayout, QLayout,
QLineEdit, QLineEdit,
QPushButton, QPushButton,
QSizePolicy,
QSpinBox, QSpinBox,
QTableWidget, QTableWidget,
QTableWidgetItem, QTableWidgetItem,
@ -20,8 +22,12 @@ from qtpy.QtWidgets import (
QWidget, QWidget,
) )
from bec_widgets.utils.bec_dispatcher import BECDispatcher from bec_widgets.utils import BECConnector
from bec_widgets.utils.widget_io import WidgetIO from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets import StopButton
# TODO GENERAL
# - extract
class ScanArgType: class ScanArgType:
@ -30,24 +36,52 @@ class ScanArgType:
INT = "int" INT = "int"
BOOL = "bool" BOOL = "bool"
STR = "str" STR = "str"
DEVICEBASE = "DeviceBase"
LITERALS = "dict"
class ScanControl(QWidget): class ArgLabel(QLabel):
def __init__(self, text: str, parent=None, arg_name: str = None, *args, **kwargs):
super().__init__(text, parent=parent, *args, **kwargs)
self.arg_name = arg_name
class DeviceLineEdit(BECConnector, QLineEdit):
def __init__(self, parent=None, client=None, gui_id: str | None = None):
super().__init__(client=client, gui_id=gui_id)
QLineEdit.__init__(self, parent=parent)
self.get_bec_shortcuts()
def get_device(self):
return getattr(self.dev, self.text().lower())
class ScanControl(BECConnector, QWidget):
WIDGET_HANDLER = { WIDGET_HANDLER = {
ScanArgType.DEVICE: QLineEdit, ScanArgType.DEVICE: DeviceLineEdit,
ScanArgType.DEVICEBASE: DeviceLineEdit,
ScanArgType.FLOAT: QDoubleSpinBox, ScanArgType.FLOAT: QDoubleSpinBox,
ScanArgType.INT: QSpinBox, ScanArgType.INT: QSpinBox,
ScanArgType.BOOL: QCheckBox, ScanArgType.BOOL: QCheckBox,
ScanArgType.STR: QLineEdit, ScanArgType.STR: QLineEdit,
ScanArgType.LITERALS: QComboBox, # TODO figure out combobox logic
} }
def __init__(self, parent=None, client=None, allowed_scans=None): def __init__(
super().__init__(parent) self, parent=None, client=None, gui_id: str | None = None, allowed_scans: list | None = None
):
super().__init__(client=client, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
# Client from BEC + shortcuts to device manager and scans # Client from BEC + shortcuts to device manager and scans
self.client = BECDispatcher().client if client is None else client self.get_bec_shortcuts()
self.dev = self.client.device_manager.devices
self.scans = self.client.scans # Main layout
self.layout = QVBoxLayout(self)
self.arg_box = None
self.kwarg_boxes = []
self.expert_mode = False # TODO implement in the future versions
# Scan list - allowed scans for the GUI # Scan list - allowed scans for the GUI
self.allowed_scans = allowed_scans self.allowed_scans = allowed_scans
@ -56,69 +90,55 @@ class ScanControl(QWidget):
self._init_UI() self._init_UI()
def _init_UI(self): def _init_UI(self):
self.verticalLayout = QVBoxLayout(self) """
Initializes the UI of the scan control widget. Create the top box for scan selection and populate scans to main combobox.
"""
# Scan selection group box # Scan selection group box
self.scan_selection_group = QGroupBox("Scan Selection", self) self.scan_selection_group = self.create_scan_selection_group()
self.scan_selection_layout = QVBoxLayout(self.scan_selection_group) self.scan_selection_group.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.comboBox_scan_selection = QComboBox(self.scan_selection_group) self.layout.addWidget(self.scan_selection_group)
self.button_run_scan = QPushButton("Run Scan", self.scan_selection_group)
self.scan_selection_layout.addWidget(self.comboBox_scan_selection)
self.scan_selection_layout.addWidget(self.button_run_scan)
self.verticalLayout.addWidget(self.scan_selection_group)
# Scan control group box
self.scan_control_group = QGroupBox("Scan Control", self)
self.scan_control_layout = QVBoxLayout(self.scan_control_group)
self.verticalLayout.addWidget(self.scan_control_group)
# Kwargs layout - just placeholder
self.kwargs_layout = QGridLayout()
self.scan_control_layout.addLayout(self.kwargs_layout)
# 1st Separator
self.add_horizontal_separator(self.scan_control_layout)
# Buttons
self.button_layout = QHBoxLayout()
self.pushButton_add_bundle = QPushButton("Add Bundle", self.scan_control_group)
self.pushButton_add_bundle.clicked.connect(self.add_bundle)
self.pushButton_remove_bundle = QPushButton("Remove Bundle", self.scan_control_group)
self.pushButton_remove_bundle.clicked.connect(self.remove_bundle)
self.button_layout.addWidget(self.pushButton_add_bundle)
self.button_layout.addWidget(self.pushButton_remove_bundle)
self.scan_control_layout.addLayout(self.button_layout)
# 2nd Separator
self.add_horizontal_separator(self.scan_control_layout)
# Initialize the QTableWidget for args
self.args_table = QTableWidget()
self.args_table.verticalHeader().setSectionResizeMode(QHeaderView.Fixed)
self.scan_control_layout.addWidget(self.args_table)
# Connect signals # Connect signals
self.comboBox_scan_selection.currentIndexChanged.connect(self.on_scan_selected) self.comboBox_scan_selection.currentIndexChanged.connect(self.on_scan_selected)
self.button_run_scan.clicked.connect(self.run_scan) self.button_run_scan.clicked.connect(self.run_scan)
self.button_add_bundle.clicked.connect(self.add_arg_bundle)
self.button_remove_bundle.clicked.connect(self.remove_arg_bundle)
# Initialize scan selection # Initialize scan selection
self.populate_scans() self.populate_scans()
def add_horizontal_separator(self, layout) -> None: def create_scan_selection_group(self) -> QGroupBox:
""" """
Adds a horizontal separator to the given layout Creates the scan selection group box with combobox to select the scan and start/stop button.
Args: Returns:
layout: Layout to add the separator to QGroupBox: Group box containing the scan selection widgets.
""" """
separator = QFrame(self.scan_control_group)
separator.setFrameShape(QFrame.HLine) scan_selection_group = QGroupBox("Scan Selection", self)
separator.setFrameShadow(QFrame.Sunken) self.scan_selection_layout = QGridLayout(scan_selection_group)
layout.addWidget(separator) self.comboBox_scan_selection = QComboBox(scan_selection_group)
# Run button
self.button_run_scan = QPushButton("Start", scan_selection_group)
self.button_run_scan.setStyleSheet("background-color: #559900; color: white")
# Stop button
self.button_stop_scan = StopButton(parent=scan_selection_group)
# Add bundle button
self.button_add_bundle = QPushButton("Add Bundle", scan_selection_group)
# Remove bundle button
self.button_remove_bundle = QPushButton("Remove Bundle", scan_selection_group)
self.scan_selection_layout.addWidget(self.comboBox_scan_selection, 0, 0, 1, 2)
self.scan_selection_layout.addWidget(self.button_run_scan, 1, 0)
self.scan_selection_layout.addWidget(self.button_stop_scan, 1, 1)
self.scan_selection_layout.addWidget(self.button_add_bundle, 2, 0)
self.scan_selection_layout.addWidget(self.button_remove_bundle, 2, 1)
return scan_selection_group
def populate_scans(self): def populate_scans(self):
"""Populates the scan selection combo box with available scans""" """Populates the scan selection combo box with available scans from BEC session."""
self.available_scans = self.client.connector.get( self.available_scans = self.client.connector.get(
MessageEndpoints.available_scans() MessageEndpoints.available_scans()
).resource ).resource
@ -132,38 +152,141 @@ class ScanControl(QWidget):
else: else:
allowed_scans = self.allowed_scans allowed_scans = self.allowed_scans
# TODO check parent class is ScanBase -> filter out the scans not relevant for GUI
self.comboBox_scan_selection.addItems(allowed_scans) self.comboBox_scan_selection.addItems(allowed_scans)
def on_scan_selected(self): def on_scan_selected(self):
"""Callback for scan selection combo box""" """Callback for scan selection combo box"""
self.reset_layout()
selected_scan_name = self.comboBox_scan_selection.currentText() selected_scan_name = self.comboBox_scan_selection.currentText()
selected_scan_info = self.available_scans.get(selected_scan_name, {}) selected_scan_info = self.available_scans.get(selected_scan_name, {})
print(selected_scan_info) # TODO remove when widget will be more mature gui_config = selected_scan_info.get("gui_config", {})
# Generate kwargs input self.arg_group = gui_config.get("arg_group", None)
self.generate_kwargs_input_fields(selected_scan_info) self.kwarg_groups = gui_config.get("kwarg_groups", None)
# Args section if len(self.arg_group["arg_inputs"]) > 0:
self.generate_args_input_fields(selected_scan_info) self.add_arg_group(self.arg_group)
if len(self.kwarg_groups) > 0:
self.add_kwargs_boxes(self.kwarg_groups)
def add_labels_to_layout(self, labels: list, grid_layout: QGridLayout) -> None: self.update()
self.adjustSize()
def add_input_labels(self, group_inputs: dict, grid_layout: QGridLayout, row: int) -> None:
""" """
Adds labels to the given grid layout as a separate row. Adds the given arg_group from arg_bundle to the scan control layout. The input labels are always added to the first row.
Args: Args:
labels (list): List of label names to add. group(dict): Dictionary containing the arg_group information.
grid_layout (QGridLayout): The grid layout to which labels will be added. grid_layout(QGridLayout): The grid layout to which the arg_group will be added.
""" """
row_index = grid_layout.rowCount() # Get the next available row for column_index, item in enumerate(group_inputs):
for column_index, label_name in enumerate(labels): arg_name = item.get("name", None)
label = QLabel(label_name["name"].capitalize(), self.scan_control_group) display_name = item.get("display_name", arg_name)
# Add the label to the grid layout at the calculated row and current column tooltip = item.get("tooltip", None)
grid_layout.addWidget(label, row_index, column_index) label = ArgLabel(text=display_name, arg_name=arg_name)
if tooltip is not None:
label.setToolTip(item["tooltip"])
grid_layout.addWidget(label, row, column_index)
def add_input_widgets(self, group_inputs: dict, grid_layout: QGridLayout, row) -> None:
for column_index, item in enumerate(group_inputs):
widget = self.WIDGET_HANDLER.get(item["type"], None)
if widget is None:
print(f"Unsupported annotation '{item['type']}' for parameter '{item['name']}'")
continue
if isinstance(widget, (QSpinBox, QDoubleSpinBox)):
widget.setRange(-9999, 9999)
# if item["default"] != "_empty": # TODO fix for comboboxes and spinboxes
# WidgetIO.set_value(widget, item["default"])
# widget.setValue(item["default"])
grid_layout.addWidget(widget(), row, column_index)
def add_input_box(self, group: dict, rows: int = 1):
"""
Adds the given gui_group to the scan control layout.
Args:
group(dict): Dictionary containing the gui_group information.
rows(int): Number of input rows to add to the layout.
"""
input_box = QGroupBox(group["name"])
group_layout = QGridLayout(input_box)
self.add_input_labels(group["inputs"], group_layout, 0)
for i in range(rows):
self.add_input_widgets(group["inputs"], group_layout, i + 1)
input_box.setLayout(group_layout)
self.layout.addWidget(input_box)
input_box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
return input_box
def add_kwargs_boxes(self, groups: list):
"""
Adds the given gui_groups to the scan control layout.
Args:
groups(list): List of dictionaries containing the gui_group information.
"""
for group in groups:
box = self.add_input_box(group)
self.layout.addWidget(box)
self.kwarg_boxes.append(box)
def add_arg_group(self, group: dict):
"""
Adds the given gui_groups to the scan control layout.
Args:
"""
self.arg_box = self.add_input_box(group, rows=group["min"])
self.real_arg_box_row_count = group["min"]
self.layout.addWidget(self.arg_box)
def add_arg_bundle(self):
if self.arg_box is not None:
current_row_count = self.arg_box.layout().rowCount()
if self.arg_group["max"] is not None and current_row_count >= self.arg_group["max"]:
return
self.add_input_widgets(
self.arg_group["inputs"], self.arg_box.layout(), self.arg_box.layout().rowCount()
)
self.real_arg_box_row_count += 1
print(f"row count REAL: {self.real_arg_box_row_count}")
print(f"row count QT: {self.arg_box.layout().rowCount()}")
def remove_arg_bundle(self):
if self.arg_box is not None:
current_row_count = self.real_arg_box_row_count # self.arg_box.layout().rowCount()
layout = self.arg_box.layout()
if current_row_count > self.arg_group["min"]:
for i in range(layout.columnCount()):
widget = layout.itemAtPosition(current_row_count, i).widget()
layout.removeWidget(widget)
widget.deleteLater()
self.real_arg_box_row_count -= 1
print(f"row count REAL: {self.real_arg_box_row_count}")
print(f"row count QT: {self.arg_box.layout().rowCount()}")
# self.arg_box.layout().removeRow(current_row_count - 1)
def reset_layout(self):
"""Clears the scan control layout from GuiGroups and ArgGroups boxes."""
if self.arg_box is not None:
self.layout.removeWidget(self.arg_box)
self.arg_box = None
if self.kwarg_boxes != []:
self.remove_kwarg_boxes()
def remove_kwarg_boxes(self):
for box in self.kwarg_boxes:
self.layout.removeWidget(box)
box.deleteLater()
self.kwarg_boxes = []
def add_labels_to_table( def add_labels_to_table(
self, labels: list, table: QTableWidget self, labels: list, table: QTableWidget
) -> None: # TODO could be moved to BECTable ) -> None: # TODO could be moved to BECTable -> not needed
""" """
Adds labels to the given table widget as a header row. Adds labels to the given table widget as a header row.
@ -174,7 +297,9 @@ class ScanControl(QWidget):
table.setColumnCount(len(labels)) table.setColumnCount(len(labels))
table.setHorizontalHeaderLabels(labels) table.setHorizontalHeaderLabels(labels)
def generate_args_input_fields(self, scan_info: dict) -> None: def generate_args_input_fields(
self, scan_info: dict
) -> None: # TODO decide how to deal with arg bundles
""" """
Generates input fields for args. Generates input fields for args.
@ -196,7 +321,9 @@ class ScanControl(QWidget):
for i in range(self.arg_size_min): for i in range(self.arg_size_min):
self.add_bundle() self.add_bundle()
def generate_kwargs_input_fields(self, scan_info: dict) -> None: def generate_kwargs_input_fields(
self, scan_info: dict, inputs: dict = {}, hidden: list = []
) -> None: # TODO can be removed
""" """
Generates input fields for kwargs Generates input fields for kwargs
@ -212,7 +339,11 @@ class ScanControl(QWidget):
signature = scan_info.get("signature", []) signature = scan_info.get("signature", [])
# Extract kwargs from the converted signature # Extract kwargs from the converted signature
parameters = [param for param in signature if param["annotation"] != "_empty"] parameters = [
param
for param in signature
if param["annotation"] != "_empty" and param["name"] not in hidden
]
# Add labels # Add labels
self.add_labels_to_layout(parameters, self.kwargs_layout) self.add_labels_to_layout(parameters, self.kwargs_layout)
@ -222,7 +353,25 @@ class ScanControl(QWidget):
self.add_widgets_row_to_layout(self.kwargs_layout, widgets) self.add_widgets_row_to_layout(self.kwargs_layout, widgets)
def generate_widgets_from_signature(self, parameters: list) -> list: def create_widget_group(self, title: str, widgets: list) -> QGroupBox: # TODO to be removed
"""
Creates a group box containing the given widgets.
Args:
title(str): Title of the group box.
widgets(list): List of widgets to add to the group box.
Returns:
QGroupBox: Group box containing the given widgets.
"""
group_box = QGroupBox(title)
group_layout = QVBoxLayout(group_box)
for widget in widgets:
group_layout.addWidget(widget)
group_box.setLayout(group_layout)
return group_box
def generate_widgets_from_signature(self, parameters: list) -> list: # TODO to be removed
""" """
Generates widgets from the given list of items. Generates widgets from the given list of items.
@ -259,15 +408,17 @@ class ScanControl(QWidget):
# set high default range for spin boxes #TODO can be linked to motor/device limits from BEC # set high default range for spin boxes #TODO can be linked to motor/device limits from BEC
if isinstance(widget, (QSpinBox, QDoubleSpinBox)): if isinstance(widget, (QSpinBox, QDoubleSpinBox)):
widget.setRange(-9999, 9999) widget.setRange(-9999, 9999)
if item_default is not None: # if item_default is not None:
WidgetIO.set_value(widget, item_default) # WidgetIO.set_value(widget, item_default)
# Add the widget to the list # Add the widget to the list
widgets.append(widget) widgets.append(widget)
return widgets return widgets
def set_args_table_limits(self, table: QTableWidget, scan_info: dict) -> None: def set_args_table_limits(
self, table: QTableWidget, scan_info: dict
) -> None: # TODO can be removed
# Get bundle info # Get bundle info
arg_bundle_size = scan_info.get("arg_bundle_size", {}) arg_bundle_size = scan_info.get("arg_bundle_size", {})
self.arg_size_min = arg_bundle_size.get("min", 1) self.arg_size_min = arg_bundle_size.get("min", 1)
@ -278,7 +429,7 @@ class ScanControl(QWidget):
def add_widgets_row_to_layout( def add_widgets_row_to_layout(
self, grid_layout: QGridLayout, widgets: list, row_index: int = None self, grid_layout: QGridLayout, widgets: list, row_index: int = None
) -> None: ) -> None: # TODO to be removed
""" """
Adds a row of widgets to the given grid layout. Adds a row of widgets to the given grid layout.
@ -297,7 +448,7 @@ class ScanControl(QWidget):
def add_widgets_row_to_table( def add_widgets_row_to_table(
self, table_widget: QTableWidget, widgets: list, row_index: int = None self, table_widget: QTableWidget, widgets: list, row_index: int = None
) -> None: ) -> None: # TODO to be removed
""" """
Adds a row of widgets to the given QTableWidget. Adds a row of widgets to the given QTableWidget.
@ -329,7 +480,7 @@ class ScanControl(QWidget):
max(widget.sizeHint().height() for widget in widgets if isinstance(widget, QWidget)), max(widget.sizeHint().height() for widget in widgets if isinstance(widget, QWidget)),
) )
def remove_last_row_from_table(self, table_widget: QTableWidget) -> None: def remove_last_row_from_table(self, table_widget: QTableWidget) -> None: # TODO to be removed
""" """
Removes the last row from the given QTableWidget until only one row is left. Removes the last row from the given QTableWidget until only one row is left.
@ -342,12 +493,12 @@ class ScanControl(QWidget):
): # Check to ensure there is a minimum number of rows remaining ): # Check to ensure there is a minimum number of rows remaining
table_widget.removeRow(row_count - 1) table_widget.removeRow(row_count - 1)
def create_new_grid_layout(self): def create_new_grid_layout(self): # TODO to be removed
new_layout = QGridLayout() new_layout = QGridLayout()
# TODO maybe setup other layouts properties here? # TODO maybe setup other layouts properties here?
return new_layout return new_layout
def clear_and_delete_layout(self, layout: QLayout): def clear_and_delete_layout(self, layout: QLayout): # TODO can be removed
""" """
Clears and deletes the given layout and all its child widgets. Clears and deletes the given layout and all its child widgets.
@ -366,7 +517,7 @@ class ScanControl(QWidget):
self.clear_and_delete_layout(sub_layout) self.clear_and_delete_layout(sub_layout)
layout.deleteLater() layout.deleteLater()
def add_bundle(self) -> None: def add_bundle(self) -> None: # TODO can be removed
"""Adds a new bundle to the scan control layout""" """Adds a new bundle to the scan control layout"""
# Get widgets used for particular scan and save them to be able to use for adding bundles # Get widgets used for particular scan and save them to be able to use for adding bundles
args_widgets = self.generate_widgets_from_signature( args_widgets = self.generate_widgets_from_signature(
@ -376,7 +527,7 @@ class ScanControl(QWidget):
# Add first widgets row to the table # Add first widgets row to the table
self.add_widgets_row_to_table(self.args_table, args_widgets) self.add_widgets_row_to_table(self.args_table, args_widgets)
def remove_bundle(self) -> None: def remove_bundle(self) -> None: # TODO can be removed
"""Removes the last bundle from the scan control layout""" """Removes the last bundle from the scan control layout"""
self.remove_last_row_from_table(self.args_table) self.remove_last_row_from_table(self.args_table)
@ -424,35 +575,65 @@ class ScanControl(QWidget):
args.extend(row_args) args.extend(row_args)
return args return args
def extract_kwargs(self, box: QGroupBox) -> dict:
"""
Extracts the parameters from the given group box.
Args:
box(QGroupBox): Group box from which to extract the parameters.
Returns:
dict: Dictionary containing the extracted parameters.
"""
parameters = {}
keys = [label.arg_name for label in box.findChildren(ArgLabel)]
layout = box.layout()
for i in range(layout.columnCount()):
key = keys[i]
widget = layout.itemAtPosition(1, i).widget()
if isinstance(widget, DeviceLineEdit):
value = widget.get_device()
else:
value = WidgetIO.get_value(widget)
parameters[key] = value
return parameters
def extract_args(self, box):
args = []
layout = box.layout()
for i in range(layout.columnCount()):
widget = layout.itemAtPosition(1, i).widget()
if isinstance(widget, DeviceLineEdit):
value = widget.get_device()
else:
value = WidgetIO.get_value(widget)
args.append(value)
return args
def run_scan(self): def run_scan(self):
# Extract kwargs for the scan args = []
kwargs = { kwargs = {}
k.lower(): v if self.arg_box is not None:
for k, v in self.extract_kwargs_from_grid_row(self.kwargs_layout, 1).items() args = self.extract_args(self.arg_box)
} for box in self.kwarg_boxes:
box_kwargs = self.extract_kwargs(box)
# Extract args from the table kwargs.update(box_kwargs)
args = self.extract_args_from_table(self.args_table) print(kwargs)
# Convert args to lowercase if they are strings
args = [arg.lower() if isinstance(arg, str) else arg for arg in args]
# Execute the scan
scan_function = getattr(self.scans, self.comboBox_scan_selection.currentText()) scan_function = getattr(self.scans, self.comboBox_scan_selection.currentText())
print(f"called with args: {args} and kwargs: {kwargs}") print(f"called with args: {args} and kwargs: {kwargs}")
if callable(scan_function): if callable(scan_function):
scan_function(*args, **kwargs) scan_function(*args, **kwargs)
def close(self):
super().close()
# Application example # Application example
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
# BECclient global variables
client = BECDispatcher().client
client.start()
app = QApplication([]) app = QApplication([])
scan_control = ScanControl(client=client, allowed_scans=["line_scan", "grid_scan"]) scan_control = ScanControl(allowed_scans=["fermat_scan", "round_scan", "line_scan"])
qdarktheme.setup_theme("auto")
window = scan_control window = scan_control
window.show() window.show()
app.exec() app.exec()