From b6cd93672e737e6ec2d421fa60547e855099ec4e Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 23 Oct 2026 23:32:58 +0000 Subject: [PATCH] feat(sequencer): add measurement table concept --- .../views/sequencer_view/concepts.py | 3 +- .../control/sequencer/table/__init__.py | 0 .../control/sequencer/table/table_editor.py | 451 ++++++++++++++++++ 3 files changed, 453 insertions(+), 1 deletion(-) create mode 100644 bec_widgets/widgets/control/sequencer/table/__init__.py create mode 100644 bec_widgets/widgets/control/sequencer/table/table_editor.py diff --git a/bec_widgets/applications/views/sequencer_view/concepts.py b/bec_widgets/applications/views/sequencer_view/concepts.py index d123b659..6b22090d 100644 --- a/bec_widgets/applications/views/sequencer_view/concepts.py +++ b/bec_widgets/applications/views/sequencer_view/concepts.py @@ -2,5 +2,6 @@ from bec_widgets.widgets.control.sequencer.blocks.blocks_editor import BlocksEditor from bec_widgets.widgets.control.sequencer.recipe.recipe_editor import RecipeEditor +from bec_widgets.widgets.control.sequencer.table.table_editor import TableEditor -CONCEPTS = {cls.CONCEPT_ID: cls for cls in (RecipeEditor, BlocksEditor)} +CONCEPTS = {cls.CONCEPT_ID: cls for cls in (RecipeEditor, BlocksEditor, TableEditor)} diff --git a/bec_widgets/widgets/control/sequencer/table/__init__.py b/bec_widgets/widgets/control/sequencer/table/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/control/sequencer/table/table_editor.py b/bec_widgets/widgets/control/sequencer/table/table_editor.py new file mode 100644 index 00000000..fbbc15ee --- /dev/null +++ b/bec_widgets/widgets/control/sequencer/table/table_editor.py @@ -0,0 +1,451 @@ +"""Concept 3, Measurement table: a spreadsheet of points to measure. + +Each row is one place to measure: the motor positions to go to and a sample name. The same +scan runs at every row; the whole table can be repeated. Rows can be pasted from Excel or +generated as a grid. The table is turned into a sequence (move, move, scan per row). +""" + +from __future__ import annotations + +import copy +import itertools + +import numpy as np +from bec_qthemes import material_icon +from qtpy.QtCore import Qt +from qtpy.QtGui import QBrush, QColor, QKeySequence +from qtpy.QtWidgets import ( + QAbstractItemView, + QComboBox, + QDialog, + QDialogButtonBox, + QDoubleSpinBox, + QFormLayout, + QGridLayout, + QHBoxLayout, + QHeaderView, + QLabel, + QPushButton, + QSpinBox, + QTableWidget, + QTableWidgetItem, + QToolButton, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.colors import rgba +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.widgets.control.sequencer import model +from bec_widgets.widgets.control.sequencer.editor_base import SequenceEditorBase +from bec_widgets.widgets.control.sequencer.inspector import ScanSettingsDialog +from bec_widgets.widgets.control.sequencer.style import ( + Chip, + card_qss, + heading, + hint, + kind_color, + kind_icon, + theme, +) + +EXAMPLE_ROWS = [ + ("Sample A", [0.0, 0.0]), + ("Sample B", [2.5, 0.0]), + ("Sample C", [5.0, 0.0]), + ("Reference foil", [0.0, 3.0]), + ("Empty holder", [2.5, 3.0]), +] + + +class GridDialog(QDialog): + """Generate rows for every combination of evenly spaced motor positions.""" + + def __init__(self, motors: list[str], parent: QWidget | None = None): + super().__init__(parent) + self.setWindowTitle("Fill table with a grid") + self.motors = motors + layout = QVBoxLayout(self) + layout.addWidget(hint("One row is created for every combination of positions.", self)) + grid = QGridLayout() + for column, text in enumerate(("Motor", "From", "To", "Points")): + grid.addWidget(QLabel(f"{text}", self), 0, column) + self.inputs = [] + for row, motor in enumerate(motors, 1): + start = QDoubleSpinBox(self, decimals=3, minimum=-1e6, maximum=1e6, value=0) + stop = QDoubleSpinBox(self, decimals=3, minimum=-1e6, maximum=1e6, value=5) + points = QSpinBox(self, minimum=1, maximum=1000, value=3) + for column, widget in enumerate((QLabel(motor, self), start, stop, points)): + grid.addWidget(widget, row, column) + self.inputs.append((start, stop, points)) + layout.addLayout(grid) + self.replace = QComboBox(self) + self.replace.addItems(["Replace the rows", "Add below the rows"]) + layout.addWidget(self.replace) + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, self + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def rows(self) -> list[list[float]]: + """All combinations, first motor changing slowest.""" + axes = [ + [round(float(v), 6) for v in np.linspace(a.value(), b.value(), n.value())] + for a, b, n in self.inputs + ] + return [list(combo) for combo in itertools.product(*axes)] + + +class TableEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors + """Spreadsheet of measurement points.""" + + CONCEPT_ID = "table" + CONCEPT_TITLE = "Table" + CONCEPT_TAGLINE = "A spreadsheet of points: one row per place to measure, paste from Excel." + + def build_editor(self, host: QWidget) -> None: + self.motors: list[str] = ["samx", "samy"] + self.scan = model.scan_node( + "line_scan", ["samz", -1, 1], {"steps": 10, "exp_time": 0.1, "relative": True} + ) + self.repeats = 1 + self._row_nodes: dict[str, int] = {} + self._building = False + + layout = QVBoxLayout(host) + layout.setContentsMargins(16, 14, 16, 8) + layout.setSpacing(10) + layout.addWidget(heading("Measurement points", host)) + layout.addWidget( + hint( + "Each row is one place to measure. The motors go to the row's positions, then " + "the scan below runs. You can paste rows from Excel (Ctrl+V).", + host, + ) + ) + + # what to do at each point + card = QWidget(host) + card.setObjectName("TableScanCard") + card.setStyleSheet(card_qss("#TableScanCard")) + row = QHBoxLayout(card) + row.setContentsMargins(12, 10, 12, 10) + icon = QLabel(card) + icon.setPixmap(kind_icon("scan_call", 22).pixmap(22, 22)) + row.addWidget(icon) + row.addWidget(QLabel("At every point run", card)) + self.scan_label = QLabel(card) + row.addWidget(self.scan_label, 1) + change = QPushButton("Change scan…", card) + change.setIcon(material_icon("tune", size=(16, 16), convert_to_pixmap=False)) + change.clicked.connect(self._edit_scan) + row.addWidget(change) + row.addSpacing(16) + row.addWidget(QLabel("Measure the whole table", card)) + self.repeat_spin = QSpinBox(card, minimum=1, maximum=1000) + self.repeat_spin.setSuffix(" ×") + self.repeat_spin.valueChanged.connect(self._on_repeats) + row.addWidget(self.repeat_spin) + layout.addWidget(card) + + # toolbar + tools = QHBoxLayout() + tools.setSpacing(6) + tools.addWidget(QLabel("Motors:", host)) + self.motor_chips = QHBoxLayout() + self.motor_chips.setSpacing(4) + tools.addLayout(self.motor_chips) + self.add_motor = QComboBox(host) + self.add_motor.setPlaceholderText("+ add motor column") + self.add_motor.activated.connect(self._on_add_motor) + tools.addWidget(self.add_motor) + tools.addStretch(1) + for text, icon_name, slot in ( + ("Add row", "add", self._add_row), + ("Fill grid…", "grid_on", self._fill_grid), + ("Remove rows", "delete", self._remove_rows), + ): + btn = QPushButton(text, host) + btn.setIcon(material_icon(icon_name, size=(16, 16), convert_to_pixmap=False)) + btn.clicked.connect(slot) + tools.addWidget(btn) + layout.addLayout(tools) + + self.table = QTableWidget(host) + self.table.setAlternatingRowColors(True) + self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectItems) + self.table.verticalHeader().setDefaultSectionSize(30) + self.table.itemChanged.connect(self._on_item_changed) + self.table.installEventFilter(self) + self.table.setStyleSheet( + f"QTableWidget {{ border: 1px solid {theme('BORDER', '#d0d0d0').name()};" + " border-radius: 8px; gridline-color: transparent; }" + ) + layout.addWidget(self.table, 1) + self.footer = hint("", host) + layout.addWidget(self.footer) + + self._rebuild_table(EXAMPLE_ROWS) + + # --- table <-> data -------------------------------------------------------------------- + def _columns(self) -> list[str]: + return ["Measure", "Sample name", *self.motors] + + def _rebuild_table(self, rows: list[tuple[str, list]]) -> None: + self._building = True + self.table.clear() + self.table.setColumnCount(len(self._columns())) + self.table.setHorizontalHeaderLabels( + ["", "Sample name", *[f"{m} position" for m in self.motors]] + ) + self.table.setRowCount(0) + for name, positions in rows: + self._append_row(name, positions) + header = self.table.horizontalHeader() + header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) + header.setStretchLastSection(False) + header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + for column in range(2, self.table.columnCount()): + header.setSectionResizeMode(column, QHeaderView.ResizeMode.Interactive) + self.table.setColumnWidth(column, 170) + self._building = False + self._refresh_motor_chips() + self._compile() + + def _append_row(self, name: str, positions: list, measure: bool = True) -> None: + row = self.table.rowCount() + self.table.insertRow(row) + check = QTableWidgetItem() + check.setFlags(Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled) + check.setCheckState(Qt.CheckState.Checked if measure else Qt.CheckState.Unchecked) + check.setToolTip("Untick to skip this row") + self.table.setItem(row, 0, check) + self.table.setItem(row, 1, QTableWidgetItem(name)) + for column, value in enumerate(positions, 2): + item = QTableWidgetItem(model.format_value(value)) + item.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + self.table.setItem(row, column, item) + + def _rows(self) -> list[tuple[bool, str, list]]: + rows = [] + for row in range(self.table.rowCount()): + check = self.table.item(row, 0) + name_item = self.table.item(row, 1) + positions = [] + for column in range(2, self.table.columnCount()): + item = self.table.item(row, column) + text = item.text().strip() if item else "" + try: + positions.append(float(text) if text else None) + except ValueError: + positions.append(text) + rows.append( + ( + check is None or check.checkState() == Qt.CheckState.Checked, + name_item.text() if name_item else "", + positions, + ) + ) + return rows + + def _compile(self) -> None: + """Turn the table into the sequence document.""" + steps = [] + self._row_nodes = {} + for index, (measure, name, positions) in enumerate(self._rows()): + if not measure: + continue + for motor, value in zip(self.motors, positions): + if value is None: + continue + if isinstance(value, float) and value.is_integer(): + value = int(value) + node = model.move_node(motor, value) + self._row_nodes[node["id"]] = index + steps.append(node) + scan = model.clone(self.scan) + arguments = dict(scan.get("arguments") or {}) + if name: + arguments["metadata"] = {"sample_name": name} + scan["arguments"] = arguments + self._row_nodes[scan["id"]] = index + steps.append(scan) + doc = model.empty_document("measurement_table") + if self.repeats > 1: + loop = model.repeat_node(self.repeats, steps) + doc["root"]["children"] = [loop] + else: + doc["root"]["children"] = steps + self.document.set_document(doc) + entries = self.scan_entries() + self.scan_label.setText(model.sentence(self.scan, None, entries)[4:].capitalize()) + measured = sum(1 for r in self._rows() if r[0]) + self.footer.setText( + f"{self.table.rowCount()} rows, {measured} measured" + + (f", whole table {self.repeats}×" if self.repeats > 1 else "") + + ". Rows are measured from top to bottom." + ) + + def _refresh_motor_chips(self) -> None: + while self.motor_chips.count(): + item = self.motor_chips.takeAt(0) + if item.widget(): + item.widget().deleteLater() + for motor in self.motors: + chip = QToolButton(self) + chip.setText(f"{motor} ✕") + chip.setToolTip(f"Remove the {motor} column") + color = kind_color("set") + chip.setStyleSheet( + f"QToolButton {{ background: {rgba(color, 38)}; color: {color.name()};" + " border: none; border-radius: 10px; padding: 3px 10px; font-weight: 600; }" + ) + chip.clicked.connect(lambda _=False, m=motor: self._remove_motor(m)) + self.motor_chips.addWidget(chip) + self.add_motor.clear() + for name in self.backend.device_names(): + if name not in self.motors: + self.add_motor.addItem(name) + self.add_motor.setCurrentIndex(-1) + + # --- interaction ----------------------------------------------------------------------- + def refresh(self) -> None: + """The table owns the data; the document is derived from it.""" + + @SafeSlot(QTableWidgetItem) + def _on_item_changed(self, _item) -> None: + if not self._building: + self._compile() + + @SafeSlot(int) + def _on_repeats(self, value: int) -> None: + self.repeats = value + self._compile() + + @SafeSlot(int) + def _on_add_motor(self, index: int) -> None: + name = self.add_motor.itemText(index) + if name and name not in self.motors: + rows = [(n, p + [None]) for _m, n, p in self._rows()] + self.motors.append(name) + self._rebuild_table(rows) + + def _remove_motor(self, motor: str) -> None: + index = self.motors.index(motor) + rows = [(n, p[:index] + p[index + 1 :]) for _m, n, p in self._rows()] + self.motors.remove(motor) + self._rebuild_table(rows) + + @SafeSlot() + def _add_row(self) -> None: + self._building = True + last = self._rows()[-1][2] if self.table.rowCount() else [0] * len(self.motors) + self._append_row(f"Point {self.table.rowCount() + 1}", last) + self._building = False + self._compile() + self.table.editItem(self.table.item(self.table.rowCount() - 1, 1)) + + @SafeSlot() + def _remove_rows(self) -> None: + rows = sorted({i.row() for i in self.table.selectedIndexes()}, reverse=True) + self._building = True + for row in rows: + self.table.removeRow(row) + self._building = False + self._compile() + + @SafeSlot() + def _fill_grid(self) -> None: + dialog = GridDialog(self.motors, self) + if not dialog.exec(): + return + keep = [] if dialog.replace.currentIndex() == 0 else [(n, p) for _m, n, p in self._rows()] + new = [(f"Point {len(keep) + i + 1}", r) for i, r in enumerate(dialog.rows())] + self._rebuild_table(keep + new) + + @SafeSlot() + def _edit_scan(self) -> None: + dialog = ScanSettingsDialog(self.scan, self.client, self) + if dialog.exec(): + name, positional, arguments = dialog.values() + self.scan.update(name=name, positional=positional, arguments=arguments) + self._compile() + + def eventFilter(self, obj, event): # pylint: disable=invalid-name + """Paste tab-separated rows (from Excel) into the table.""" + if obj is self.table and event.type() == event.Type.KeyPress: + if event.matches(QKeySequence.StandardKey.Paste): + self._paste() + return True + return super().eventFilter(obj, event) + + def _paste(self) -> None: + # pylint: disable=import-outside-toplevel + from qtpy.QtWidgets import QApplication + + text = QApplication.clipboard().text() + lines = [line.split("\t") for line in text.strip().splitlines() if line.strip()] + if not lines: + return + start_row = max(self.table.currentRow(), 0) + start_col = max(self.table.currentColumn(), 1) + self._building = True + for r, cells in enumerate(lines): + row = start_row + r + if row >= self.table.rowCount(): + self._append_row("", [None] * len(self.motors)) + for c, value in enumerate(cells): + column = start_col + c + if column < self.table.columnCount(): + self.table.setItem(row, column, QTableWidgetItem(value.strip())) + self._building = False + self._compile() + + def show_problems(self, problems) -> None: + bad_rows = { + self._row_nodes[p.node_id]: p.message for p in problems if p.node_id in self._row_nodes + } + red = theme("ACCENT_EMERGENCY", "#e0534b") + self._building = True + for row in range(self.table.rowCount()): + for column in range(1, self.table.columnCount()): + item = self.table.item(row, column) + if item is None: + continue + if row in bad_rows: + item.setBackground(QBrush(QColor(red.red(), red.green(), red.blue(), 45))) + item.setToolTip(bad_rows[row]) + else: + item.setBackground(QBrush()) + item.setToolTip("") + self._building = False + + def save_state(self) -> dict: + return { + "motors": list(self.motors), + "rows": [(name, positions, measure) for measure, name, positions in self._rows()], + "scan": copy.deepcopy(self.scan), + "repeats": self.repeats, + } + + def restore_state(self, state: dict) -> None: + self.motors = list(state["motors"]) + self.scan = state["scan"] + self.repeats = state["repeats"] + self.repeat_spin.blockSignals(True) + self.repeat_spin.setValue(self.repeats) + self.repeat_spin.blockSignals(False) + self._rebuild_table([(name, positions) for name, positions, _m in state["rows"]]) + self._building = True + for row, (_name, _positions, measure) in enumerate(state["rows"]): + self.table.item(row, 0).setCheckState( + Qt.CheckState.Checked if measure else Qt.CheckState.Unchecked + ) + self._building = False + self._compile() + + def screenshot_setup(self) -> None: + """Select a cell for screenshots.""" + self.table.setCurrentCell(1, 2)