feat(sequencer): add measurement table concept

This commit is contained in:
wyzula_j committed 2026-10-23 23:32:58 +00:00
1 parent c275c1270a
commit b6cd93672e
3 files changed
+453 -1

No files matched your search

@@ -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)}
Whitespace-only changes.
@@ -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"<b>{text}</b>", 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("<b>At every point run</b>", 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)