feat(sequencer): start empty, check every step and run on the beamline in all concepts

This commit is contained in:
wyzula_j committed 2026-10-24 09:34:55 +00:00
1 parent 149e628822
commit 086d629650
17 files changed
+2358 -780

No files matched your search

+1 -1
View File
@@ -111,7 +111,7 @@ class BECMainApp(BECMainWindow):
widget=self.device_manager,
mini_text="DM",
)
self.sequencer_view = SequencerView(self)
self.sequencer_view = SequencerView(self, keep_drafts=True)
self.add_view(
icon="account_tree",
title="Sequencer",
@@ -40,12 +40,17 @@ class SequencerView(ViewBase):
parent (QWidget | None): Parent widget.
concept (str | None): Concept to show right away. Without it, the view waits until it
is opened and then shows ``BEC_SEQUENCER_CONCEPT`` or the first concept.
keep_drafts (bool): Keep each concept's work in a draft file, so it is still there
after the application was closed.
"""
RPC = False
def __init__(self, parent=None, *, concept: str | None = None, **kwargs):
def __init__(
self, parent=None, *, concept: str | None = None, keep_drafts: bool = False, **kwargs
):
super().__init__(parent=parent, view_id="sequencer", title="Sequencer", **kwargs)
self.keep_drafts = keep_drafts
content = QWidget(self)
layout = QVBoxLayout(content)
layout.setContentsMargins(0, 0, 0, 0)
@@ -54,10 +59,10 @@ class SequencerView(ViewBase):
self.top = QWidget(content)
self.top.setObjectName("SequencerTop")
self.top.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
bar = QHBoxLayout(self.top)
bar.setContentsMargins(16, 10, 16, 10)
bar.setSpacing(14)
bar.addWidget(heading("Sequencer", self.top, 16))
top_row = QHBoxLayout(self.top)
top_row.setContentsMargins(16, 10, 16, 10)
top_row.setSpacing(14)
top_row.addWidget(heading("Sequencer", self.top, 16))
self.switcher = QButtonGroup(self.top)
self.switcher.setExclusive(True)
segments = QHBoxLayout()
@@ -69,11 +74,11 @@ class SequencerView(ViewBase):
button.setToolTip(cls.CONCEPT_TAGLINE)
self.switcher.addButton(button)
segments.addWidget(button)
bar.addLayout(segments)
top_row.addLayout(segments)
self.tagline = hint("", self.top)
bar.addWidget(self.tagline, 1)
self.prototype_note = QLabel("Prototype · 5 concepts to compare", self.top)
bar.addWidget(self.prototype_note)
top_row.addWidget(self.tagline, 1)
self.prototype_note = QLabel(f"{len(CONCEPTS)} layouts to compare", self.top)
top_row.addWidget(self.prototype_note)
layout.addWidget(self.top)
self._style_top()
@@ -111,9 +116,7 @@ class SequencerView(ViewBase):
f" border: 1px solid {primary.name()}; color: {primary.name()}; font-weight: 600; }}"
)
self.tagline.setStyleSheet(f"color: {rgba(theme('FG', '#333333'), 150)};")
self.prototype_note.setStyleSheet(
f"color: {theme('ACCENT_WARNING', '#eda200').name()}; font-weight: 600;"
)
self.prototype_note.setStyleSheet(f"color: {rgba(theme('FG', '#333333'), 150)};")
def apply_theme(self, theme_name: str) -> None:
"""Restyle the view and rebuild the editors in the new colors, keeping their work."""
@@ -135,9 +138,8 @@ class SequencerView(ViewBase):
self.show_concept(current)
def _create(self, concept: str) -> QWidget:
editor = CONCEPTS[concept](self.stack)
if hasattr(editor, "open_in_recipe"):
editor.open_in_recipe.connect(self._open_in_recipe)
editor = CONCEPTS[concept](self.stack, draft_name=concept if self.keep_drafts else None)
editor.open_in_recipe.connect(self._open_in_recipe)
self.editors[concept] = editor
self.stack.addWidget(editor)
return editor
@@ -191,7 +193,7 @@ def main(): # pragma: no cover - manual entry point
apply_theme(args.theme)
window = BECMainWindow()
window.setWindowTitle("BEC Sequencer (prototype)")
view = SequencerView(window, concept=args.concept or next(iter(CONCEPTS)))
view = SequencerView(window, concept=args.concept or next(iter(CONCEPTS)), keep_drafts=True)
window.setCentralWidget(view)
window.resize(1400, 860)
window.show()
+151 -15
View File
@@ -1,21 +1,27 @@
"""Bridge between the sequencer editors and ``bec_lib.scan_sequencer``.
The backend lives in bec (draft PR bec-project/bec#1128). When the installed bec_lib does not
ship it yet, editing still works and ``available`` is False, so the GUI can say why checking
and running are disabled.
The backend lives in bec (bec-project/bec#1128). When the installed bec_lib does not ship it
yet, editing still works and ``available`` is False, so the GUI can say why checking and
running are disabled.
"""
from __future__ import annotations
import math
import re
from dataclasses import dataclass
from pathlib import Path
from bec_lib.device import Positioner
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_widgets.widgets.control.scan_control.scan_info_adapter import ScanInfoAdapter
logger = bec_logger.logger
try: # the backend is not released yet
# pylint: disable=ungrouped-imports
from bec_lib.scan_sequencer import DefinitionLibrary, ScanCatalog, Schedule
from bec_lib.scan_sequencer.core.description import describe as _describe
from bec_lib.scan_sequencer.core.python_export import export_python as _export_python
@@ -39,11 +45,12 @@ _HIDDEN_SCANS = {
}
_NODE_PREFIX = re.compile(r"^Node (?P<path>[^:]+): (?P<message>.*)$", re.S)
_EXECUTION_ERROR = re.compile(r"ExecutionError: Node (?P<path>[^:]+): (?P<message>.*)$", re.S)
@dataclass
class Problem:
"""A validation problem, attached to a step when the backend names one."""
"""A problem in a sequence, attached to a step when one is known."""
node_id: str
message: str
@@ -58,11 +65,63 @@ def friendly_message(message: str) -> str:
return f"“{name}” is only known inside its “For each {name}” loop"
if message.startswith("Unknown device or signal: "):
return f"There is no device called “{message.split(': ', 1)[1]}”"
if message.startswith("Non-settable device or signal: "):
return f"“{message.split(': ', 1)[1]}” cannot be moved or set"
if message.startswith("Unknown scan: "):
return f"This beamline has no scan called “{message.split(': ', 1)[1]}”"
device = re.match(r"Device '(.*)' specified for scan argument", message)
if device:
name = device.group(1)
return f"There is no device called “{name}”" if name else "Choose a motor for the scan"
return message.strip().splitlines()[0] if message.strip() else "Unknown problem"
def run_failure(error: str | None) -> Problem:
"""Turn the error of a failed sequence into the step that failed and a short reason.
Args:
error (str | None): Error text of the procedure, usually a traceback.
Returns:
Problem: The failed step (empty id when unknown) and the last line of the reason.
"""
text = (error or "").strip()
match = _EXECUTION_ERROR.search(text)
node_id = ""
if match:
node_id = match.group("path").split("/")[-1]
text = match.group("message")
lines = [line.strip() for line in text.splitlines() if line.strip()]
return Problem(node_id, lines[-1] if lines else "The sequence failed")
def sequences_dir(client) -> Path | None:
"""Folder where sequences and drafts are kept, next to the widget profiles.
Args:
client: BEC client.
Returns:
Path | None: The folder, or None when the client has no widget settings folder.
"""
# the same settings folder the widget profiles use
# pylint: disable=protected-access
try:
settings = client._service_config.config.get("widgets_settings") or {}
base = settings.get("base_path")
except Exception: # pylint: disable=broad-except
return None
if not isinstance(base, str) or not base:
return None
return Path(base).expanduser() / "sequences"
def _number(value) -> float | None:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
return float(value) if math.isfinite(value) else None
class SequencerBackend:
"""Check, describe, export and run sequence documents for one BEC client.
@@ -76,6 +135,7 @@ class SequencerBackend:
self.client = client
self.definitions: list[dict] = []
self._helper = None
self._entries: dict[str, dict] | None = None
@property
def available(self) -> bool:
@@ -83,26 +143,87 @@ class SequencerBackend:
return BACKEND_AVAILABLE
# --- scans -----------------------------------------------------------------------------
def reload(self) -> None:
"""Forget the cached scan list, e.g. after the beamline configuration changed."""
self._entries = None
def scan_entries(self) -> dict[str, dict]:
"""Advertised scans usable as a sequence step, by name."""
"""Advertised scans that can be a sequence step, by name (read once, then cached)."""
if self._entries is None:
try:
msg = self.client.connector.get(MessageEndpoints.available_scans())
except Exception: # pylint: disable=broad-except
msg = None
resource = getattr(msg, "resource", None) or {}
self._entries = {
name: info
for name, info in sorted(resource.items())
if name not in _HIDDEN_SCANS
and not name.startswith("_")
and not info.get("is_internal")
and ScanInfoAdapter.has_scan_ui_config(info)
}
return self._entries
# --- devices ---------------------------------------------------------------------------
def _devices(self):
try:
msg = self.client.connector.get(MessageEndpoints.available_scans())
return self.client.device_manager.devices
except Exception: # pylint: disable=broad-except
return {}
resource = getattr(msg, "resource", None) or {}
return {
name: info
for name, info in resource.items()
if name not in _HIDDEN_SCANS and not info.get("is_internal")
}
def device_names(self) -> list[str]:
"""Names of devices that can be moved or set."""
"""Names of all configured devices."""
try:
return sorted(self.client.device_manager.devices.keys())
return sorted(self._devices().keys())
except Exception: # pylint: disable=broad-except
return []
def device(self, name: str):
"""The device proxy for a name (``samx`` or ``samx.velocity``), or None."""
if not name:
return None
root, _, rest = name.partition(".")
try:
device = self._devices().get(root)
for part in [p for p in rest.split(".") if p]:
device = getattr(device, part)
return device
except Exception: # pylint: disable=broad-except
return None
def is_motor(self, name: str) -> bool:
"""Whether a name is a motor (a positioner), so its value must be a number."""
device = self.device(name)
if isinstance(device, Positioner):
return True
return getattr(device, "_info", {}).get("device_base_class") == "positioner"
def limits(self, name: str) -> tuple[float, float] | None:
"""Soft limits of a motor, or None when it has none."""
device = self.device(name)
if device is None or not self.is_motor(name):
return None
try:
low, high = (float(v) for v in device.limits)
except Exception: # pylint: disable=broad-except
return None
if low == high == 0 or low >= high:
return None
return low, high
def position(self, name: str) -> float | None:
"""Last known position of a motor (from the cache, no hardware access)."""
device = self.device(name)
if device is None or not self.is_motor(name):
return None
try:
reading = device.read(cached=True) or {}
except Exception: # pylint: disable=broad-except
return None
value = (reading.get(device.name) or reading.get(name) or {}).get("value")
return _number(value)
# --- checks and exports ----------------------------------------------------------------
def _tree(self, doc: dict):
schedule = Schedule.model_validate(doc)
@@ -113,7 +234,7 @@ class SequencerBackend:
return getattr(self.client, "device_manager", None)
def validate(self, doc: dict) -> list[Problem]:
"""Validate a document; returns an empty list when it can run.
"""Validate a document with the backend; returns an empty list when it can run.
The backend stops at the first problem, so at most one problem is returned.
"""
@@ -159,3 +280,18 @@ class SequencerBackend:
return self._helper.request.procedure(
"run_sequence", ((doc, list(self.definitions)), {}), self.QUEUE
)
def close(self) -> None:
"""Stop listening for procedure updates."""
helper, self._helper = self._helper, None
if helper is None:
return
connector = self.client.connector
# pylint: disable=protected-access
try:
connector.unregister(
MessageEndpoints.procedure_request_response(), cb=helper._request_cb
)
connector.unregister(MessageEndpoints.procedure_status_update(), cb=helper._update_cb)
except Exception: # pylint: disable=broad-except
logger.debug("Could not unregister the procedure helper callbacks")
@@ -12,10 +12,8 @@ from bec_qthemes import material_icon
from qtpy.QtCore import QMimeData, QPoint, QRectF, Qt, Signal
from qtpy.QtGui import QColor, QDrag, QPainter, QPainterPath, QPen
from qtpy.QtWidgets import (
QAbstractSpinBox,
QCheckBox,
QComboBox,
QDoubleSpinBox,
QFrame,
QHBoxLayout,
QLabel,
@@ -33,17 +31,32 @@ 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,
parse_values,
from bec_widgets.widgets.control.sequencer.fields import (
NumberEdit,
ScanPicker,
ValuesEdit,
device_picker,
values_text,
)
from bec_widgets.widgets.control.sequencer.style import heading, hint, kind_color, kind_icon, theme
from bec_widgets.widgets.control.sequencer.inspector import (
RangeDialog,
ScanSettingsDialog,
rename_variable,
scan_arguments,
)
from bec_widgets.widgets.control.sequencer.style import (
heading,
hint,
kind_color,
kind_icon,
problem_label,
theme,
)
MIME = "application/x-bec-sequence-block"
PALETTE = [
("set", "Move a motor", "Go to a position"),
("scan_call", "Run a scan", "Any scan from scan control"),
("scan_call", "Run a scan", "Any scan of this beamline"),
("repeat", "Repeat", "Do the inside N times"),
("for_each", "For each value", "Once per value in a list"),
("while", "While moving", "Repeat inside until a motor arrives"),
@@ -114,7 +127,7 @@ class PaletteTile(QFrame):
self._press = None
_start_drag(self, {"new": self.kind})
def mouseReleaseEvent(self, event): # pylint: disable=invalid-name
def mouseReleaseEvent(self, _event): # pylint: disable=invalid-name
if self._press is not None:
self.clicked.emit(self.kind)
self._press = None
@@ -166,13 +179,15 @@ class BlockBody(QWidget):
def dragEnterEvent(self, event): # pylint: disable=invalid-name
payload = _payload(event.mimeData())
if payload is None or payload.get("move") == self.parent_id:
return event.ignore()
event.ignore()
return
event.acceptProposedAction()
def dragMoveEvent(self, event): # pylint: disable=invalid-name
payload = _payload(event.mimeData())
if payload is None:
return event.ignore()
event.ignore()
return
moving = payload.get("move")
if moving and (
moving == self.parent_id
@@ -181,7 +196,8 @@ class BlockBody(QWidget):
for a in model.ancestors(self.editor.document.root, self.parent_id)
)
):
return event.ignore()
event.ignore()
return
self._drop_y = self._line_y(self._index_at(int(event.position().y())))
self.update()
event.acceptProposedAction()
@@ -195,7 +211,8 @@ class BlockBody(QWidget):
self._drop_y = None
self.update()
if payload is None:
return event.ignore()
event.ignore()
return
index = self._index_at(int(event.position().y()))
event.acceptProposedAction()
self.editor.drop(payload, self.parent_id if self.parent_id != "root" else None, index)
@@ -241,6 +258,9 @@ class Block(QFrame):
icon.setPixmap(kind_icon(self.kind, 20).pixmap(20, 20))
self.row.addWidget(icon)
self.outer.addWidget(self.header)
self.problem_text = problem_label(self)
self.problem_text.setContentsMargins(42, 0, 8, 6)
self.outer.addWidget(self.problem_text)
getattr(self, f"_build_{self.kind}", self._build_other)()
self.row.addStretch(1)
delete = QToolButton(self.header)
@@ -250,6 +270,7 @@ class Block(QFrame):
delete.clicked.connect(lambda: editor.document.delete(node["id"]))
self.row.addWidget(delete)
self.set_problem(self.problem)
if model.is_container(node):
self.body = BlockBody(editor, node["id"], self)
wrapper = QHBoxLayout()
@@ -260,6 +281,14 @@ class Block(QFrame):
footer.setFixedHeight(14)
self.outer.addWidget(footer)
def set_problem(self, message: str) -> None:
"""Show what this step still needs (or nothing)."""
self.problem = message
self.problem_text.setText(message)
self.problem_text.setVisible(bool(message))
self.setToolTip(message)
self.update()
# --- inline editors --------------------------------------------------------------------
def _word(self, text: str, bold: bool = False) -> QLabel:
label = QLabel(text, self.header)
@@ -276,19 +305,20 @@ class Block(QFrame):
self.row.addWidget(widget)
return widget
def _number(self, value, width: int = 80) -> QDoubleSpinBox:
spin = QDoubleSpinBox(self.header, decimals=3, minimum=-1e9, maximum=1e9)
spin.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.NoButtons)
spin.setValue(float(value) if isinstance(value, (int, float)) else 0.0)
return self._field(spin, width)
def _number(self, value, width: int = 90) -> NumberEdit:
edit = NumberEdit(self.header, "position")
edit.set_value(value)
return self._field(edit, width)
def _device_combo(self, current: str) -> QComboBox:
combo = QComboBox(self.header)
combo.setEditable(True)
names = self.editor.backend.device_names()
combo.addItems(names)
def _device_combo(self, current: str, settable: bool = True):
combo = device_picker(self.header, self.editor.client, settable=settable)
combo.setCurrentText(current)
return self._field(combo, 120)
return self._field(combo, 150)
@staticmethod
def _on_device(combo, commit) -> None:
combo.device_selected.connect(commit)
combo.lineEdit().editingFinished.connect(commit)
def _build_set(self) -> None:
node = self.node
@@ -298,18 +328,18 @@ class Block(QFrame):
self._word("to")
variables = model.visible_variables(self.editor.document.root, node["id"])
source = QComboBox(self.header)
source.addItem("value", None)
for name in variables:
source.addItem("a value", None)
for name in reversed(variables):
source.addItem(f"each {name}", name)
number = self._number(node.get("value"))
if model.is_ref(node.get("value")) and source.findData(node["value"]["ref"]) >= 0:
source.setCurrentIndex(source.findData(node["value"]["ref"]))
if variables:
self._field(source)
else:
source.hide()
number = self._number(node.get("value"))
number.setVisible(source.currentData() is None)
wait = QCheckBox("wait", self.header)
wait = QCheckBox("wait until it arrives", self.header)
wait.setChecked(bool(node.get("wait", True)))
wait.setVisible(not bound)
self.row.addWidget(wait)
@@ -317,53 +347,51 @@ class Block(QFrame):
def commit(*_):
number.setVisible(source.currentData() is None)
value = model.ref(source.currentData()) if source.currentData() else number.value()
if isinstance(value, float) and value.is_integer():
value = int(value)
self.editor.inline_update(
node["id"],
device=device.currentText().strip() or node.get("device"),
device=device.currentText().strip(),
value=value,
wait=True if bound else wait.isChecked(),
)
device.currentTextChanged.connect(commit)
self._on_device(device, commit)
source.currentIndexChanged.connect(commit)
number.valueChanged.connect(commit)
number.edited.connect(commit)
wait.toggled.connect(commit)
def _build_scan_call(self) -> None:
node = self.node
self._word("Run", True)
combo = QComboBox(self.header)
entries = self.editor.scan_entries()
for name in sorted(entries):
combo.addItem(model.scan_title(name).lower(), name)
if combo.findData(node.get("name")) < 0:
combo.addItem(node.get("name", ""), node.get("name"))
combo.setCurrentIndex(combo.findData(node.get("name")))
self._field(combo)
details = QLabel(model.scan_details(node, entries) or "no settings yet", self.header)
picker = ScanPicker(entries, self.header)
picker.set_scan(node.get("name", ""))
self._field(picker)
details = QLabel(model.scan_details(node, entries) if node.get("name") else "", self.header)
details.setStyleSheet(f"color: {rgba(theme('FG', '#333333'), 170)};")
self.row.addWidget(details)
settings = QPushButton("Settings…", self.header)
settings.setIcon(material_icon("tune", size=(16, 16), convert_to_pixmap=False))
settings.setEnabled(bool(node.get("name")))
self.row.addWidget(settings)
def change_scan(_index):
name = combo.currentData()
if name and name != node.get("name"):
self.editor.document.update(node["id"], name=name, positional=[], arguments={})
def open_settings():
dialog = ScanSettingsDialog(node, self.editor.client, self)
def open_settings(name: str | None = None):
shown = dict(node) if not name else {"name": name}
dialog = ScanSettingsDialog(shown, self.editor.client, entries, self)
if dialog.exec():
name, positional, arguments = dialog.values()
self.editor.document.update(
node["id"], name=name, positional=positional, arguments=arguments
)
chosen, positional, arguments = dialog.values()
if chosen:
self.editor.document.update(
node["id"], name=chosen, **scan_arguments(node, positional, arguments)
)
return
picker.set_scan(node.get("name", ""))
combo.currentIndexChanged.connect(change_scan)
settings.clicked.connect(open_settings)
def choose(name: str):
if name != node.get("name"):
open_settings(name)
picker.scan_chosen.connect(choose)
settings.clicked.connect(lambda _checked=False: open_settings())
def _build_repeat(self) -> None:
node = self.node
@@ -380,20 +408,28 @@ class Block(QFrame):
name = QLineEdit(node.get("variable", ""), self.header)
self._field(name, 90)
self._word("in")
values = QLineEdit(values_text(node.get("values") or []), self.header)
values.setPlaceholderText("1, 2, 3")
values = ValuesEdit(self.header)
values.set_values(node.get("values") or [])
self._field(values)
values.setMinimumWidth(160)
values.setMinimumWidth(180)
fill = QToolButton(self.header)
fill.setIcon(material_icon("linear_scale", size=(16, 16), convert_to_pixmap=False))
fill.setToolTip("Fill evenly spaced values")
fill.setAutoRaise(True)
self.row.addWidget(fill)
def commit(*_):
self.editor.inline_update(
node["id"],
variable=name.text().strip() or node.get("variable"),
values=parse_values(values.text()),
)
self.editor.inline_update(node["id"], values=values.values())
def fill_values():
dialog = RangeDialog(self)
if dialog.exec():
values.setText(values_text(dialog.values()))
commit()
name.editingFinished.connect(lambda: self.editor.rename_variable(node, name.text()))
values.textChanged.connect(commit)
values.textEdited.connect(commit)
fill.clicked.connect(fill_values)
def _build_while(self) -> None:
node = self.node
@@ -403,9 +439,7 @@ class Block(QFrame):
if owner is None:
self._word("the condition holds")
return
self._word(
f"{owner.get('device')} is still moving to {model.format_value(owner.get('value'))}"
)
self._word(f"{owner.get('device') or 'the motor'} is moving, keep repeating:")
def _build_other(self) -> None:
self._word(model.sentence(self.node, self.editor.document.root), True)
@@ -449,9 +483,11 @@ class Block(QFrame):
self._press = None
class BlocksEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors
class BlocksEditor(SequenceEditorBase):
"""Scratch-style block editor."""
# pylint: disable=too-many-ancestors, attribute-defined-outside-init
CONCEPT_ID = "blocks"
CONCEPT_TITLE = "Blocks"
CONCEPT_TAGLINE = "Colored blocks you drag together and edit in place, like Scratch."
@@ -498,6 +534,7 @@ class BlocksEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors
def refresh(self) -> None:
if not hasattr(self, "scroll") or self._inline:
return
self._blocks: dict[str, Block] = {}
position = self.scroll.verticalScrollBar().value()
canvas = QWidget()
canvas.setObjectName("BlocksCanvas")
@@ -526,32 +563,28 @@ class BlocksEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors
)
for child in children:
block = Block(self, child, body)
self._blocks[child["id"]] = block
body.add_block(block)
if model.is_container(child):
self._fill(block.body, child)
def show_problems(self, problems) -> None:
ids = {p.node_id for p in problems}
if ids != getattr(self, "_problem_ids", set()):
self._problem_ids = ids
self.refresh()
"""Update the blocks in place, so typing in a block keeps its focus."""
messages = {p.node_id: p.message for p in problems}
for node_id, block in getattr(self, "_blocks", {}).items():
if block.problem != messages.get(node_id, ""):
block.set_problem(messages.get(node_id, ""))
# --- actions ---------------------------------------------------------------------------
@SafeSlot(str)
def append(self, kind: str) -> None:
"""Add a block at the end of the sequence."""
self.document.add(model.new_step(kind, device=self._default_device()))
def _default_device(self) -> str:
devices = self.backend.device_names()
return "samx" if "samx" in devices or not devices else devices[0]
self.document.add(self.document.new_nodes(kind))
def drop(self, payload: dict, parent_id: str | None, index: int) -> None:
"""Handle a dropped palette tile or block."""
if "new" in payload:
self.document.add(
model.new_step(payload["new"], device=self._default_device()), parent_id, index
)
self.document.add(self.document.new_nodes(payload["new"]), parent_id, index)
return
node_id = payload.get("move")
node, parent = model.find(self.document.root, node_id)
@@ -574,21 +607,7 @@ class BlocksEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors
def rename_variable(self, loop: dict, new_name: str) -> None:
"""Rename a for-each variable and every use of it, then redraw."""
new_name = new_name.strip()
old = loop.get("variable")
if not new_name or new_name == old:
if not new_name or new_name == loop.get("variable"):
return
def visit(value):
if model.is_ref(value) and value["ref"] == old:
return model.ref(new_name)
if isinstance(value, list):
return [visit(v) for v in value]
if isinstance(value, dict):
return {k: visit(v) for k, v in value.items()}
return value
for node, _depth, _parent in model.walk(loop):
for key in ("value", "positional", "arguments"):
if key in node:
node[key] = visit(node[key])
rename_variable(loop, loop.get("variable"), new_name)
self.document.update(loop["id"], variable=new_name)
@@ -0,0 +1,133 @@
"""Checks that run in the editor before the backend sees a sequence.
The backend validates scan arguments and devices but stops at the first error and does not
know about steps that are simply unfinished. These checks find every unfinished step at
once and also catch motor positions outside the soft limits, so every concept can mark each
step that needs attention.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from bec_widgets.widgets.control.sequencer import model
from bec_widgets.widgets.control.sequencer.backend import Problem
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.control.sequencer.backend import SequencerBackend
def _is_number(value) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool)
def _format_limit(value: float) -> str:
return f"{value:g}"
def _check_set(node: dict, root: dict, backend: SequencerBackend, known: set[str]) -> str:
device = (node.get("device") or "").strip()
if not device:
return "Choose a motor"
if known and device.split(".", maxsplit=1)[0] not in known:
return f"There is no device called “{device}”"
value = node.get("value")
if value is None or value == "":
return "Enter a position" if backend.is_motor(device) else "Enter a value"
if model.is_ref(value):
values = model.loop_values(root, node["id"], value["ref"])
if values is None:
return f"“{value['ref']}” is only known inside its “For each {value['ref']}” loop"
else:
values = [value]
if not backend.is_motor(device):
return ""
if any(not _is_number(v) for v in values):
return f"{device} needs a number"
limits = backend.limits(device)
if limits is not None:
low, high = limits
outside = [v for v in values if not low <= v <= high]
if outside:
return (
f"{model.format_value(outside[0])} is outside the limits of {device}"
f" ({_format_limit(low)} to {_format_limit(high)})"
)
return ""
def _is_device_input(kind) -> bool:
if isinstance(kind, dict): # annotated inputs: {"Annotated": {"type": "float", ...}}
kind = (kind.get("Annotated") or {}).get("type", "")
return "device" in str(kind).lower()
def _device_positions(info: dict) -> list[int]:
"""Positions inside one argument bundle that hold a device name."""
arg_input = info.get("arg_input") or {}
return [i for i, kind in enumerate(arg_input.values()) if _is_device_input(kind)]
def _check_scan(node: dict, entries: dict, known: set[str]) -> str:
name = node.get("name") or ""
if not name:
return "Choose a scan"
if entries and name not in entries:
return f"This beamline has no scan called “{name}”"
info = entries.get(name, {})
bundle = (info.get("arg_bundle_size") or {}).get("bundle") or 0
positional = node.get("positional") or []
if bundle and not positional:
return "Add at least one motor to the scan"
if bundle:
for offset in _device_positions(info):
for value in positional[offset::bundle]:
if model.is_ref(value):
continue
if not value:
return "Choose a motor for the scan"
if known and str(value).split(".", maxsplit=1)[0] not in known:
return f"There is no device called “{value}”"
return ""
def check(root: dict, backend: SequencerBackend) -> list[Problem]:
"""Find unfinished steps and positions outside the motor limits.
Args:
root (dict): Root group of the document.
backend (SequencerBackend): Gives the scans, devices and limits of the beamline.
Returns:
list[Problem]: One problem per step that needs attention, in document order.
"""
entries = backend.scan_entries()
known = set(backend.device_names())
problems: list[Problem] = []
for node, _depth, _parent in model.walk(root):
kind = node.get("type")
message = ""
if kind == "set":
message = _check_set(node, root, backend, known)
elif kind == "scan_call":
message = _check_scan(node, entries, known)
elif kind == "for_each":
if not (node.get("variable") or "").strip():
message = "Give the value a name"
elif not node.get("values"):
message = "Enter at least one value"
elif kind == "repeat":
count = node.get("count")
if not isinstance(count, int) or isinstance(count, bool) or count < 1:
message = "Repeat at least once"
elif kind == "while":
status = model.while_status(node)
if status is None or model.status_owner(root, status) is None:
message = "This loop has no motor to wait for"
elif kind == "component_call":
message = "Saved blocks cannot be used in this editor yet"
if not message and kind in model.LOOP_TYPES and not model.children_of(node):
message = "This loop has no steps inside"
if message:
problems.append(Problem(node["id"], message))
return problems
@@ -65,10 +65,23 @@ class SequenceDocument(QObject):
self._selected = node_id or ""
self.selection_changed.emit(self._selected)
def new_nodes(self, kind: str) -> list[dict]:
"""Blank nodes for a step kind, with a loop variable name not used yet."""
return model.new_step(kind, variable=model.unused_variable(self.root))
def add(self, nodes: list[dict], parent_id: str | None = None, index: int = -1) -> None:
"""Insert new steps and select the first one."""
"""Insert new steps and select the first one.
A new move inside a "For each" loop goes to each value of the loop by default.
"""
self._remember()
model.insert(self.root, nodes, parent_id, index)
for node in nodes:
for step in [node, *(n for n, _d, _p in model.walk(node))]:
if step.get("type") == "set" and step.get("value") is None:
variables = model.visible_variables(self.root, step["id"])
if variables and not step.get("bind_status"):
step["value"] = model.ref(variables[-1])
self.touch()
if nodes:
self.select(nodes[0]["id"])
@@ -156,7 +169,7 @@ class SequenceDocument(QObject):
self._remember()
siblings = model.children_of(parent)
index = siblings.index(node)
loop = model.new_step(kind)[0]
loop = self.new_nodes(kind)[0]
siblings[index] = loop
model.children_of(loop).append(node)
self.touch()
@@ -3,26 +3,33 @@
from __future__ import annotations
import copy
import json
from bec_lib.logger import bec_logger
from qtpy.QtCore import QTimer, Signal
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets import BECWidget
from bec_widgets.widgets.control.sequencer import model
from bec_widgets.widgets.control.sequencer.backend import Problem, SequencerBackend
from bec_widgets.widgets.control.sequencer.backend import Problem, SequencerBackend, sequences_dir
from bec_widgets.widgets.control.sequencer.document import SequenceDocument
from bec_widgets.widgets.control.sequencer.run_bar import RunBar
logger = bec_logger.logger
class SequenceEditorBase(BECWidget, QWidget):
"""An editor for one sequence document, with the shared run bar underneath.
Subclasses build their editing surface in ``build_editor`` and redraw in ``refresh``.
``show_problems`` receives the validation problems so the editor can mark the step.
``show_problems`` receives the problems so the editor can mark each step that needs a fix.
Args:
parent (QWidget | None): Parent widget.
client: BEC client.
doc (dict | None): Initial document; defaults to the shared example.
doc (dict | None): Initial document; an empty sequence by default.
draft_name (str | None): Keep the work in a draft file with this name, so it is still
there after the application was closed. No draft is kept without a name.
"""
RPC = False
@@ -31,12 +38,23 @@ class SequenceEditorBase(BECWidget, QWidget):
CONCEPT_TITLE = ""
CONCEPT_TAGLINE = ""
def __init__(self, parent=None, client=None, doc: dict | None = None, **kwargs):
# Asks the host to open a sequence in the Recipe editor, which can edit any sequence.
open_in_recipe = Signal(dict)
def __init__(
self,
parent=None,
client=None,
doc: dict | None = None,
draft_name: str | None = None,
**kwargs,
):
super().__init__(parent=parent, client=client, **kwargs)
self.get_bec_shortcuts()
self.backend = SequencerBackend(self.client)
self.document = SequenceDocument(doc if doc is not None else model.example_document(), self)
self.document = SequenceDocument(doc, self)
self.problems: list[Problem] = []
self.draft_name = draft_name
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
@@ -44,10 +62,18 @@ class SequenceEditorBase(BECWidget, QWidget):
self.editor = QWidget(self)
layout.addWidget(self.editor, 1)
self.build_editor(self.editor)
self.run_bar = RunBar(self.backend, self.document, self)
self.run_bar = RunBar(self.backend, self.document, self.bec_dispatcher, self)
layout.addWidget(self.run_bar)
self.run_bar.problems_changed.connect(self._on_problems)
self.run_bar.new_requested.connect(self.reset)
self.run_bar.opened.connect(self.open_document)
self.document.changed.connect(self.refresh)
self._draft_timer = QTimer(self, singleShot=True, interval=1000)
self._draft_timer.timeout.connect(self.save_draft)
if doc is None:
self.load_draft()
self.document.changed.connect(self._draft_timer.start)
self.refresh()
self.run_bar.check()
@@ -61,6 +87,15 @@ class SequenceEditorBase(BECWidget, QWidget):
def show_problems(self, problems: list[Problem]) -> None:
"""Mark steps that need a fix."""
def reset(self) -> None:
"""Start over with an empty sequence."""
self.document.set_document(model.empty_document())
def open_document(self, doc: dict) -> None:
"""Show a sequence opened from a file."""
self.document.set_document(doc)
# --- state and drafts ------------------------------------------------------------------
def save_state(self) -> dict:
"""What the editor needs to come back after being rebuilt (e.g. on a theme change)."""
return {"doc": copy.deepcopy(self.document.doc), "selected": self.document.selected}
@@ -71,8 +106,38 @@ class SequenceEditorBase(BECWidget, QWidget):
if state.get("selected"):
self.document.select(state["selected"])
def _draft_path(self):
folder = sequences_dir(self.client) if self.draft_name else None
return folder / "drafts" / f"{self.draft_name}.json" if folder else None
def save_draft(self) -> None:
"""Write the current work to the draft file."""
path = self._draft_path()
if path is None:
return
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(self.save_state(), indent=1), encoding="utf-8")
except (OSError, TypeError, ValueError) as exc:
logger.warning(f"Could not save the sequence draft {path}: {exc}")
def load_draft(self) -> None:
"""Bring back the work from the draft file, if there is one."""
path = self._draft_path()
if path is None or not path.exists():
return
try:
self.restore_state(json.loads(path.read_text(encoding="utf-8")))
self.document.select("")
except Exception as exc: # pylint: disable=broad-except
logger.warning(f"Could not restore the sequence draft {path}: {exc}")
# --- problems --------------------------------------------------------------------------
def _on_problems(self, problems: list) -> None:
self.problems = problems
inspector = getattr(self, "inspector", None)
if inspector is not None:
inspector.set_problems(problems)
self.show_problems(problems)
def problem_for(self, node_id: str) -> str:
@@ -84,9 +149,12 @@ class SequenceEditorBase(BECWidget, QWidget):
return self.backend.scan_entries()
def cleanup(self):
"""Stop timers of the shared parts."""
self.run_bar.cleanup()
"""Save the draft and stop timers of the shared parts."""
inspector = getattr(self, "inspector", None)
if inspector is not None:
inspector.cleanup()
if self._draft_timer.isActive():
self._draft_timer.stop()
self.save_draft()
self.run_bar.cleanup()
super().cleanup()
@@ -0,0 +1,165 @@
"""Input fields shared by the sequencer editors."""
from __future__ import annotations
from qtpy.QtCore import Qt, Signal
from qtpy.QtWidgets import QComboBox, QLineEdit, QWidget
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.control.sequencer import model
from bec_widgets.widgets.control.sequencer.style import theme
def parse_number(text: str):
"""Parse a typed number; an empty text is None and anything else stays text.
Args:
text (str): Typed text.
Returns:
int | float | str | None: The value.
"""
text = text.strip()
if not text:
return None
try:
number = float(text)
except ValueError:
return text
return int(number) if number.is_integer() and "." not in text and "e" not in text else number
def parse_values(text: str) -> list:
"""Parse ``1, -1, 2.5`` (or one value per line) into numbers, keeping words as text."""
values = []
for part in text.replace("\n", ",").replace(";", ",").split(","):
value = parse_number(part)
if value is not None:
values.append(value)
return values
def values_text(values: list) -> str:
"""Inverse of ``parse_values``."""
return ", ".join(model.format_value(v) for v in values)
class NumberEdit(QLineEdit):
"""A number field that can also be empty ("not entered yet").
``edited`` is emitted whenever the user changes the text.
"""
edited = Signal()
def __init__(self, parent: QWidget | None = None, placeholder: str = "number"):
super().__init__(parent)
self.setPlaceholderText(placeholder)
self.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self.textEdited.connect(self._on_edited)
self._style(True)
def value(self):
"""The number, None when empty, or the text when it is not a number."""
return parse_number(self.text())
def set_value(self, value) -> None:
"""Show a value (None clears the field)."""
self.setText("" if value is None or model.is_ref(value) else model.format_value(value))
self._style(True)
def _on_edited(self, text: str) -> None:
value = parse_number(text)
self._style(value is None or not isinstance(value, str))
self.edited.emit()
def _style(self, valid: bool) -> None:
red = theme("ACCENT_EMERGENCY", "#e0534b").name()
self.setStyleSheet("" if valid else f"QLineEdit {{ border: 1px solid {red}; }}")
class ValuesEdit(QLineEdit):
"""A comma separated list of values, e.g. ``1, -1, 2.5``."""
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self.setPlaceholderText("e.g. 1, -1, 2.5")
def values(self) -> list:
"""The parsed values."""
return parse_values(self.text())
def set_values(self, values: list) -> None:
"""Show values."""
self.setText(values_text(values or []))
class DevicePicker(DeviceComboBox):
"""Device combo box that only turns red for a name that is not a device.
An empty picker is not an error here: the editor says which step still needs a motor.
"""
def _update_validity_style(self, is_valid: bool) -> None:
super()._update_validity_style(is_valid or not self.currentText().strip())
def device_picker(
parent: QWidget | None, client, placeholder: str = "Choose a motor", settable: bool = False
) -> DevicePicker:
"""A device combo box listing the motors (and, optionally, settable signals).
Args:
parent (QWidget | None): Parent widget.
client: BEC client.
placeholder (str): Text shown while nothing is chosen.
settable (bool): Also list signals that can be written, e.g. a temperature setpoint.
Returns:
DevicePicker: The picker, empty until the user chooses.
"""
picker = DevicePicker(
parent,
client=client,
device_filter=["Positioner"],
autocomplete=True,
include_signals_with_write_access=settable,
)
picker.set_first_element_as_empty = True
picker.setCurrentText("")
picker.lineEdit().setPlaceholderText(placeholder)
picker.setMinimumContentsLength(10)
return picker
class ScanPicker(QComboBox):
"""Choose one of the beamline's scans by its readable name.
Args:
entries (dict): Advertised scans by name.
parent (QWidget | None): Parent widget.
"""
scan_chosen = Signal(str)
def __init__(self, entries: dict, parent: QWidget | None = None):
super().__init__(parent)
self.setPlaceholderText("Choose a scan…")
self.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents)
for name, info in entries.items():
self.addItem(model.scan_title(name), name)
doc = (info.get("doc") or "").strip().splitlines()
if doc:
self.setItemData(self.count() - 1, doc[0], Qt.ItemDataRole.ToolTipRole)
self.setCurrentIndex(-1)
self.activated.connect(lambda _index: self.scan_chosen.emit(self.scan()))
def scan(self) -> str:
"""Name of the chosen scan, or an empty string."""
return self.currentData() or ""
def set_scan(self, name: str) -> None:
"""Show a scan; an unknown name is listed so it is not lost."""
if name and self.findData(name) < 0:
self.addItem(model.scan_title(name), name)
self.setCurrentIndex(self.findData(name) if name else -1)
@@ -88,10 +88,10 @@ class StepBox(QGraphicsPathItem):
label.setBrush(QBrush(theme("FG", "#222222")))
label.setPos(rect.left() + 48, rect.center().y() - label.boundingRect().height() / 2)
if problem:
badge = QGraphicsSimpleTextItem("needs a fix", self)
badge = QGraphicsSimpleTextItem(self._elide(problem, rect.width() * 0.6), self)
badge.setFont(_font(8, True))
badge.setBrush(QBrush(theme("ACCENT_EMERGENCY", "#e0534b")))
badge.setPos(rect.right() - badge.boundingRect().width() - 10, rect.top() + 3)
badge.setPos(rect.right() - badge.boundingRect().width() - 10, rect.top() + 2)
@staticmethod
def _elide(text: str, width: float) -> str:
@@ -192,7 +192,7 @@ class FlowEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors
)
left_layout.addWidget(self.timeline)
self.inspector = StepInspector(self.document, splitter, client=self.client)
self.inspector = StepInspector(self.document, self.backend, splitter, client=self.client)
self.inspector.setMinimumWidth(340)
splitter.addWidget(left)
splitter.addWidget(self.inspector)
@@ -211,6 +211,12 @@ class FlowEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors
y = self._terminal("Start", 0, theme("PRIMARY", "#2f6fdf"))
y = self._column(root, 0, y, ROOT_WIDTH)
self._terminal("Finish", y, theme("ACCENT_SUCCESS", "#59a869"))
if not model.children_of(root):
note = QGraphicsSimpleTextItem("Click + to add the first step")
note.setFont(_font(10))
note.setBrush(QBrush(theme("FG", "#333333")))
note.setPos(ROOT_WIDTH / 2 + 20, 32 + GAP / 2 - note.boundingRect().height() / 2)
self.scene.addItem(note)
self.scene.setSceneRect(self.scene.itemsBoundingRect().adjusted(-20, -20, 20, 20))
self._fill_timeline()
@@ -303,8 +309,8 @@ class FlowEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors
items = model.timeline(self.document.root, self._entries)
for index, item in enumerate(items, 1):
short = item.text
if item.kind == "set":
short = short.replace("Move ", "").replace(" to ", " → ")
if item.kind == "set" and short.startswith("Move "):
short = short[len("Move ") :].replace(" to ", " → ")
elif item.kind == "scan_call":
short = short.split(":")[0].replace("Run ", "")
entry = QListWidgetItem(kind_icon(item.kind, 18), f"{index}. {short}\n{item.context}")
@@ -328,9 +334,7 @@ class FlowEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors
action.setData(kind)
chosen = menu.exec(screen_pos)
if chosen is not None:
devices = self.backend.device_names()
device = "samx" if "samx" in devices or not devices else devices[0]
self.document.add(model.new_step(chosen.data(), device=device), parent_id, index)
self.document.add(self.document.new_nodes(chosen.data()), parent_id, index)
def step_menu(self, node_id: str, screen_pos) -> None:
"""Right-click menu of a step."""
@@ -359,9 +363,3 @@ class FlowEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors
chosen = menu.exec(screen_pos)
if chosen in actions:
actions[chosen]()
def screenshot_setup(self) -> None:
"""Select the move step for screenshots."""
moves = [n for n, _d, _p in model.walk(self.document.root) if n["type"] == "set"]
if moves:
self.document.select(moves[0]["id"])
@@ -7,16 +7,14 @@ the result to the Recipe editor for anything the sentence cannot say.
from __future__ import annotations
import copy
from bec_qthemes import material_icon
from qtpy.QtCore import Qt, Signal
from qtpy.QtWidgets import (
QAbstractSpinBox,
QComboBox,
QDoubleSpinBox,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QPushButton,
@@ -29,13 +27,21 @@ 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 (
RangeDialog,
ScanSettingsDialog,
parse_values,
from bec_widgets.widgets.control.sequencer.fields import (
NumberEdit,
ValuesEdit,
device_picker,
values_text,
)
from bec_widgets.widgets.control.sequencer.style import card_qss, heading, hint, kind_icon, theme
from bec_widgets.widgets.control.sequencer.inspector import RangeDialog, ScanSettingsDialog
from bec_widgets.widgets.control.sequencer.style import (
card_qss,
heading,
hint,
kind_icon,
problem_label,
theme,
)
GOALS = [
(
@@ -140,20 +146,21 @@ class GoalCard(QFrame):
self.clicked.emit(self.key)
class GuidedEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors
class GuidedEditor(SequenceEditorBase):
"""Goal picker with fill-in-the-blanks sentences."""
# pylint: disable=too-many-ancestors, attribute-defined-outside-init
CONCEPT_ID = "guided"
CONCEPT_TITLE = "Guided"
CONCEPT_TAGLINE = "Pick a goal and fill in the blanks of a sentence; no loops to think about."
open_in_recipe = Signal(dict)
def build_editor(self, host: QWidget) -> None:
self.goal = "positions"
self.scan = model.scan_node(
"line_scan", ["samy", -2, 2], {"steps": 20, "exp_time": 0.1, "relative": True}
)
self.scan = model.scan_node("")
# What was typed, kept when switching goals: device, values, repeat, …
self.memory: dict = {}
self._field_nodes: dict[str, str] = {}
layout = QHBoxLayout(host)
layout.setContentsMargins(16, 14, 16, 8)
layout.setSpacing(16)
@@ -207,42 +214,45 @@ class GuidedEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors
for card in self.goal_cards:
card.set_selected(card.key == self.goal)
# --- sentences -------------------------------------------------------------------------
def _device_combo(self, current: str) -> QComboBox:
combo = QComboBox(self.sentence_card)
combo.setEditable(True)
combo.addItems(self.backend.device_names())
combo.setCurrentText(current)
combo.currentTextChanged.connect(self._compile)
return combo
# --- sentence fields -------------------------------------------------------------------
def _device(self, key: str, placeholder: str, settable: bool = False):
picker = device_picker(self.sentence_card, self.client, placeholder, settable=settable)
picker.setCurrentText(self.memory.get(key, ""))
picker.device_selected.connect(self._compile)
picker.lineEdit().editingFinished.connect(self._compile)
self.fields[key] = picker
return picker
def _spin(self, value: int, suffix: str = "") -> QSpinBox:
def _count_field(self, key: str, default: int) -> QSpinBox:
spin = QSpinBox(self.sentence_card, minimum=1, maximum=100000)
spin.setValue(value)
spin.setSuffix(suffix)
spin.setValue(int(self.memory.get(key, default)))
spin.setSuffix(" ×")
spin.valueChanged.connect(self._compile)
self.fields[key] = spin
return spin
def _number(self, value: float) -> QDoubleSpinBox:
spin = QDoubleSpinBox(self.sentence_card, decimals=3, minimum=-1e9, maximum=1e9)
spin.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.NoButtons)
spin.setValue(value)
spin.valueChanged.connect(self._compile)
return spin
def _values(self, values: list) -> QLineEdit:
edit = QLineEdit(values_text(values), self.sentence_card)
edit.setPlaceholderText("1, 2, 3")
edit.textChanged.connect(self._compile)
def _number(self, key: str) -> NumberEdit:
edit = NumberEdit(self.sentence_card, "position")
edit.set_value(self.memory.get(key))
edit.edited.connect(self._compile)
self.fields[key] = edit
return edit
def _fill_button(self, target: QLineEdit) -> QPushButton:
def _values(self, key: str) -> ValuesEdit:
edit = ValuesEdit(self.sentence_card)
edit.set_values(self.memory.get(key, []))
edit.textEdited.connect(self._compile)
self.fields[key] = edit
return edit
def _fill_button(self, target: ValuesEdit) -> QPushButton:
button = QPushButton("Evenly spaced…", self.sentence_card)
def fill():
dialog = RangeDialog(self)
if dialog.exec():
target.setText(values_text(dialog.values()))
self._compile()
button.clicked.connect(fill)
return button
@@ -250,20 +260,27 @@ class GuidedEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors
def _scan_button(self) -> QPushButton:
button = QPushButton(self.sentence_card)
button.setIcon(kind_icon("scan_call", 18))
button.setText(model.sentence(self.scan, None, self.scan_entries())[4:])
button.setToolTip("Change the scan and its settings")
button.setToolTip("Choose the scan and its settings")
self.fields["scan"] = button
def edit():
dialog = ScanSettingsDialog(self.scan, self.client, self)
dialog = ScanSettingsDialog(self.scan, self.client, self.scan_entries(), self)
if dialog.exec():
name, positional, arguments = dialog.values()
self.scan.update(name=name, positional=positional, arguments=arguments)
button.setText(model.sentence(self.scan, None, self.scan_entries())[4:])
self._compile()
if name:
self.scan.update(name=name, positional=positional, arguments=arguments)
self._compile()
button.clicked.connect(edit)
return button
def _scan_text(self) -> str:
if not self.scan.get("name"):
return "Choose a scan…"
details = model.scan_details(self.scan, self.scan_entries())
title = model.scan_title(self.scan["name"]).lower()
return f"{title}: {details}" if details else title
def _line(self) -> FlowRow:
line = FlowRow(self.sentence_card)
self.sentence_layout.addWidget(line)
@@ -273,89 +290,121 @@ class GuidedEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors
while self.sentence_layout.count():
item = self.sentence_layout.takeAt(0)
if item.widget():
item.widget().hide() # gone at once, deleted when Qt gets to it
item.widget().deleteLater()
f = self.fields = {}
self.fields = {}
title = next(g for g in GOALS if g[0] == self.goal)[2]
self.sentence_layout.addWidget(heading(title, self.sentence_card, 16))
if self.goal in ("positions", "values"):
device = "samx" if self.goal == "positions" else "samz"
f["device"] = self._device_combo(device)
f["values"] = self._values(
[1, -1] if self.goal == "positions" else [0.5, 1.0, 1.5, 2.0]
)
verb = "Move" if self.goal == "positions" else "Set"
noun = "positions" if self.goal == "positions" else "values"
self._line().words(verb).field(f["device"], 140).words(
f"to each of these {noun}:"
if self.goal == "positions":
self._line().words("Move").field(self._device("motor", "Choose a motor"), 170).words(
"to each of these positions:"
).end()
self._line().field(f["values"], 320).field(self._fill_button(f["values"])).end()
values = self._values("positions")
self._line().field(values, 320).field(self._fill_button(values)).end()
self._line().words("and there run a").field(self._scan_button()).end()
f["repeat"] = self._spin(2, " times")
self._line().words("Do all of this").field(f["repeat"], 100).end()
self._line().words("Do all of this").field(self._count_field("repeat", 1), 90).end()
elif self.goal == "values":
self._line().words("Set").field(
self._device("setting", "Choose a setting", settable=True), 170
).words("to each of these values:").end()
values = self._values("setting_values")
self._line().field(values, 320).field(self._fill_button(values)).end()
self._line().words("and at each value run a").field(self._scan_button()).end()
self._line().words("Do all of this").field(self._count_field("repeat", 1), 90).end()
elif self.goal == "grid":
f["fast"] = self._device_combo("samx")
f["fast_values"] = self._values([0, 1, 2])
f["slow"] = self._device_combo("samy")
f["slow_values"] = self._values([0, 1])
self._line().words("For each").field(f["slow"], 120).words("position").field(
f["slow_values"], 200
).field(self._fill_button(f["slow_values"])).end()
self._line().words("go through every").field(f["fast"], 120).words("position").field(
f["fast_values"], 200
).field(self._fill_button(f["fast_values"])).end()
slow_values = self._values("slow_values")
self._line().words("For each").field(self._device("slow", "Choose a motor"), 150).words(
"position"
).field(slow_values, 200).field(self._fill_button(slow_values)).end()
fast_values = self._values("fast_values")
self._line().words("go through every").field(
self._device("fast", "Choose a motor"), 150
).words("position").field(fast_values, 200).field(self._fill_button(fast_values)).end()
self._line().words("and run a").field(self._scan_button()).words("at each point.").end()
elif self.goal == "repeat":
f["repeat"] = self._spin(5, " times")
self._line().words("Run a").field(self._scan_button()).end()
self._line().field(f["repeat"], 100).words("in a row.").end()
self._line().field(self._count_field("count", 2), 90).words("in a row.").end()
elif self.goal == "during":
f["device"] = self._device_combo("samx")
f["target"] = self._number(10)
self._line().words("Start moving").field(f["device"], 140).words("to").field(
f["target"], 100
).end()
self._line().words("Start moving").field(
self._device("motor", "Choose a motor"), 170
).words("to").field(self._number("target"), 110).end()
self._line().words("and keep running a").field(self._scan_button()).end()
self._line().words("until it has arrived.").end()
self.problem = problem_label(self.sentence_card)
self.sentence_layout.addWidget(self.problem)
self.sentence_layout.addStretch(1)
self._compile()
# --- building the sequence -------------------------------------------------------------
def _remember(self) -> None:
for key, widget in self.fields.items():
if isinstance(widget, ValuesEdit):
self.memory[key] = widget.values()
elif isinstance(widget, NumberEdit):
self.memory[key] = widget.value()
elif isinstance(widget, QSpinBox):
self.memory[key] = widget.value()
elif hasattr(widget, "currentText"):
self.memory[key] = widget.currentText().strip()
def _move(self, key: str, value) -> dict:
node = model.move_node(self.memory.get(key, ""), value)
self._field_nodes[node["id"]] = key
return node
def _loop(self, variable: str, key: str, children: list[dict]) -> dict:
node = model.for_each_node(variable, self.memory.get(key, []), children)
self._field_nodes[node["id"]] = key
return node
def _scan(self) -> dict:
node = model.clone(self.scan)
self._field_nodes[node["id"]] = "scan"
return node
@SafeSlot()
def _compile(self, *_args) -> None:
f = self.fields
scan = model.clone(self.scan)
self._remember()
self._field_nodes = {}
memory = self.memory
doc = model.empty_document(f"guided_{self.goal}")
steps: list[dict] = []
if self.goal in ("positions", "values"):
variable = "position" if self.goal == "positions" else "value"
loop = model.for_each_node(
variable,
parse_values(f["values"].text()),
[model.move_node(f["device"].currentText(), model.ref(variable)), scan],
device_key, values_key = (
("motor", "positions")
if self.goal == "positions"
else ("setting", "setting_values")
)
repeat = f["repeat"].value()
variable = "position" if self.goal == "positions" else "value"
loop = self._loop(
variable, values_key, [self._move(device_key, model.ref(variable)), self._scan()]
)
repeat = int(memory.get("repeat", 1))
steps = [model.repeat_node(repeat, [loop])] if repeat > 1 else [loop]
elif self.goal == "grid":
fast, slow = f["fast"].currentText(), f["slow"].currentText()
inner = model.for_each_node(
fast,
parse_values(f["fast_values"].text()),
[model.move_node(fast, model.ref(fast)), scan],
inner = self._loop(
"fast_position",
"fast_values",
[self._move("fast", model.ref("fast_position")), self._scan()],
)
steps = [
model.for_each_node(
slow,
parse_values(f["slow_values"].text()),
[model.move_node(slow, model.ref(slow)), inner],
self._loop(
"slow_position",
"slow_values",
[self._move("slow", model.ref("slow_position")), inner],
)
]
elif self.goal == "repeat":
steps = [model.repeat_node(f["repeat"].value(), [scan])]
steps = [model.repeat_node(int(memory.get("count", 2)), [self._scan()])]
elif self.goal == "during":
target = f["target"].value()
steps = model.while_moving_nodes(
f["device"].currentText(), int(target) if target.is_integer() else target, [scan]
start, loop = model.while_moving_nodes(
memory.get("motor", ""), memory.get("target"), [self._scan()]
)
self._field_nodes[start["id"]] = "motor"
steps = [start, loop]
doc["root"]["children"] = steps
if "scan" in self.fields:
self.fields["scan"].setText(self._scan_text())
self.document.set_document(doc)
@SafeSlot()
@@ -363,34 +412,59 @@ class GuidedEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors
if not hasattr(self, "preview"):
return
self.preview.clear()
items = model.timeline(self.document.root, self.scan_entries(), limit=200)
items = model.timeline(self.document.root, self.scan_entries(), limit=500)
for index, item in enumerate(items, 1):
text = f"{index}. {item.text}"
if item.context:
text += f" — {item.context}"
text += f" ({item.context})"
self.preview.addItem(QListWidgetItem(kind_icon(item.kind, 16), text))
summary = model.summarize(self.document.root)
self.count.setText(summary.text())
if not items:
self.preview.addItem("Fill in the sentence to see every step here.")
self.count.setText(model.summarize(self.document.root).text())
@SafeSlot(str)
def _on_goal(self, key: str) -> None:
self._remember()
self.goal = key
self._style_goals()
self._build_sentence()
def reset(self) -> None:
self.memory = {}
self.scan = model.scan_node("")
self._build_sentence()
def open_document(self, doc: dict) -> None:
"""A sequence from a file can have any shape, so it opens in the Recipe editor."""
self.open_in_recipe.emit(doc)
def save_state(self) -> dict:
return {"goal": self.goal, "scan": self.scan}
self._remember()
return {"goal": self.goal, "scan": copy.deepcopy(self.scan), "memory": dict(self.memory)}
def restore_state(self, state: dict) -> None:
self.goal = state["goal"]
self.scan = state["scan"]
self.goal = state.get("goal", "positions")
self.scan = state.get("scan") or model.scan_node("")
self.memory = dict(state.get("memory", {}))
self._style_goals()
self._build_sentence()
def show_problems(self, problems) -> None:
red = theme("ACCENT_EMERGENCY", "#e0534b")
self.count.setStyleSheet(f"color: {red.name()};" if problems else "")
if problems:
self.count.setText(
f"{model.summarize(self.document.root).text()} · {problems[0].message}"
red = theme("ACCENT_EMERGENCY", "#e0534b").name()
marked = {self._field_nodes.get(p.node_id) for p in problems}
for key, widget in self.fields.items():
if key == "scan" or not hasattr(widget, "setStyleSheet"):
continue
if isinstance(widget, (ValuesEdit, QSpinBox)):
widget.setStyleSheet(
f"{type(widget).__name__} {{ border: 1px solid {red}; }}"
if key in marked
else ""
)
scan_button = self.fields.get("scan")
if scan_button is not None:
scan_button.setStyleSheet(
f"QPushButton {{ border: 1px solid {red}; }}" if "scan" in marked else ""
)
self.problem.setText(problems[0].message if problems else "")
self.problem.setVisible(bool(problems))
+270 -167
View File
@@ -7,9 +7,8 @@ form, so scientists see the same scan settings they know from the scan control w
from __future__ import annotations
import numpy as np
from qtpy.QtCore import QTimer
from qtpy.QtCore import QTimer, Signal
from qtpy.QtWidgets import (
QAbstractSpinBox,
QCheckBox,
QComboBox,
QDialog,
@@ -28,43 +27,30 @@ from qtpy.QtWidgets import (
)
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
from bec_widgets.widgets.control.scan_control.scan_control import ScanControl
from bec_widgets.widgets.control.sequencer import model
from bec_widgets.widgets.control.sequencer.backend import Problem, SequencerBackend
from bec_widgets.widgets.control.sequencer.document import SequenceDocument
from bec_widgets.widgets.control.sequencer.style import heading, hint, kind_icon
def parse_values(text: str) -> list:
"""Parse ``1, -1, 2.5`` (or one value per line) into numbers, keeping words as text."""
values = []
for part in text.replace("\n", ",").replace(";", ",").split(","):
part = part.strip()
if not part:
continue
try:
number = float(part)
values.append(int(number) if number.is_integer() and "." not in part else number)
except ValueError:
values.append(part)
return values
def values_text(values: list) -> str:
"""Inverse of ``parse_values``."""
return ", ".join(model.format_value(v) for v in values)
from bec_widgets.widgets.control.sequencer.fields import (
NumberEdit,
ScanPicker,
ValuesEdit,
device_picker,
values_text,
)
from bec_widgets.widgets.control.sequencer.style import heading, hint, kind_icon, problem_label
class RangeDialog(QDialog):
"""Fill a list of values from start, stop and number of points."""
def __init__(self, parent: QWidget | None = None):
def __init__(self, parent: QWidget | None = None, title: str = "Fill evenly spaced values"):
super().__init__(parent)
self.setWindowTitle("Fill evenly spaced values")
self.setWindowTitle(title)
form = QFormLayout(self)
self.start = QDoubleSpinBox(self, decimals=4, minimum=-1e6, maximum=1e6, value=0)
self.stop = QDoubleSpinBox(self, decimals=4, minimum=-1e6, maximum=1e6, value=10)
self.points = QSpinBox(self, minimum=1, maximum=10000, value=11)
self.start = QDoubleSpinBox(self, decimals=4, minimum=-1e6, maximum=1e6)
self.stop = QDoubleSpinBox(self, decimals=4, minimum=-1e6, maximum=1e6)
self.points = QSpinBox(self, minimum=1, maximum=10000, value=2)
form.addRow("From", self.start)
form.addRow("To", self.stop)
form.addRow("Number of values", self.points)
@@ -84,9 +70,10 @@ class RangeDialog(QDialog):
def make_scan_form(parent: QWidget | None, client) -> ScanControl:
"""The scan control form without its Start/Stop buttons and metadata."""
"""The scan control form without its scan selector, Start/Stop buttons and metadata."""
form = ScanControl(parent, client=client)
form.hide_scan_control_buttons = True
form.hide_scan_selection_combobox = True
form.hide_metadata = True
group = getattr(form, "_metadata_group", None)
if group is not None:
@@ -94,92 +81,127 @@ def make_scan_form(parent: QWidget | None, client) -> ScanControl:
return form
def scan_form_values(form: ScanControl) -> tuple[str, list, dict]:
"""Scan name, positional and keyword arguments shown in a scan form."""
def scan_form_values(form: ScanControl) -> tuple[list, dict]:
"""Positional and keyword arguments shown in a scan form."""
positional, arguments = form.get_scan_parameters(bec_object=False)
arguments.pop("metadata", None)
return form.current_scan, list(positional), arguments
return list(positional), arguments
def set_scan_form_values(form: ScanControl, node: dict) -> None:
"""Show a scan node in a scan form."""
form.set_current_scan(node.get("name", ""))
positional = [v for v in node.get("positional", []) if not isinstance(v, dict)]
if form.arg_box is not None and positional:
def show_scan_in_form(form: ScanControl, node: dict) -> None:
"""Show a scan node in a scan form; a fresh node gets the scan's own defaults."""
name = node.get("name", "")
# The form remembers values per scan; a step must only ever show its own values.
form.config.scans.clear()
form.previous_scan = None
if form.current_scan == name:
form.scan_select(name)
else:
form.set_current_scan(name)
positional = node.get("positional") or []
if form.arg_box is not None and positional and not any(isinstance(v, dict) for v in positional):
form.arg_box.set_parameters(positional)
arguments = node.get("arguments") if isinstance(node.get("arguments"), dict) else {}
for box in form.kwarg_boxes:
box.set_parameters(arguments)
box.set_parameters({k: v for k, v in arguments.items() if not isinstance(v, dict)})
def scan_arguments(node: dict, positional: list, arguments: dict) -> dict:
"""New scan fields for a node, keeping metadata the form does not show (e.g. sample name)."""
old = node.get("arguments") if isinstance(node.get("arguments"), dict) else {}
if "metadata" in old:
arguments = {**arguments, "metadata": old["metadata"]}
return {"positional": list(positional), "arguments": arguments}
class ScanSettingsDialog(QDialog):
"""Edit one scan step in the familiar scan control form."""
"""Choose a scan and edit its settings in the familiar scan control form."""
def __init__(self, node: dict, client, parent: QWidget | None = None):
def __init__(self, node: dict, client, entries: dict, parent: QWidget | None = None):
super().__init__(parent)
self.node = node
self.setWindowTitle("Scan settings")
self.resize(520, 620)
self.resize(540, 640)
layout = QVBoxLayout(self)
scroll = QScrollArea(self)
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.Shape.NoFrame)
self.form = make_scan_form(scroll, client)
scroll.setWidget(self.form)
layout.addWidget(scroll, 1)
set_scan_form_values(self.form, node)
buttons = QDialogButtonBox(
row = QHBoxLayout()
row.addWidget(QLabel("Scan", self))
self.picker = ScanPicker(entries, self)
self.picker.set_scan(node.get("name", ""))
row.addWidget(self.picker, 1)
layout.addLayout(row)
self.scroll = QScrollArea(self)
self.scroll.setWidgetResizable(True)
self.scroll.setFrameShape(QScrollArea.Shape.NoFrame)
self.form = make_scan_form(self.scroll, client)
self.scroll.setWidget(self.form)
layout.addWidget(self.scroll, 1)
self.empty = hint("Choose the scan to run at this step.", self)
layout.addWidget(self.empty)
self.picker.scan_chosen.connect(self._on_scan)
self._on_scan(node.get("name", ""), keep_values=True)
self.buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, self
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
self.buttons.accepted.connect(self.accept)
self.buttons.rejected.connect(self.reject)
layout.addWidget(self.buttons)
def _on_scan(self, name: str, keep_values: bool = False) -> None:
shown = dict(self.node) if keep_values else {"name": name}
self.scroll.setVisible(bool(name))
self.empty.setVisible(not name)
if name:
show_scan_in_form(self.form, shown)
def values(self) -> tuple[str, list, dict]:
"""Scan name, positional and keyword arguments."""
return scan_form_values(self.form)
name = self.picker.scan()
if not name:
return "", [], {}
positional, arguments = scan_form_values(self.form)
return name, positional, arguments
class ValueInput(QWidget):
"""Either a fixed number or "the current loop value"."""
"""Either a typed number or "each value from the loop"."""
edited = Signal()
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self.source = QComboBox(self)
self.number = QDoubleSpinBox(self, decimals=4, minimum=-1e9, maximum=1e9)
self.number.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.NoButtons)
self.number = NumberEdit(self, "position")
layout.addWidget(self.source, 1)
layout.addWidget(self.number, 1)
self.source.currentIndexChanged.connect(self._sync)
self.source.activated.connect(self._on_source)
self.number.edited.connect(self.edited)
def set_choices(self, variables: list[str]) -> None:
"""Offer the loop variables visible at this step."""
self.source.blockSignals(True)
self.source.clear()
self.source.addItem("a fixed value", None)
for name in variables:
for name in reversed(variables):
self.source.addItem(f"each {name} from the loop", name)
self.source.blockSignals(False)
def set_value(self, value) -> None:
"""Show a literal or a reference."""
if model.is_ref(value) and self.source.findData(value["ref"]) >= 0:
self.source.setCurrentIndex(self.source.findData(value["ref"]))
else:
self.source.setCurrentIndex(0)
self.number.setValue(float(value) if isinstance(value, (int, float)) else 0.0)
index = self.source.findData(value["ref"]) if model.is_ref(value) else 0
self.source.setCurrentIndex(max(index, 0))
self.number.set_value(None if model.is_ref(value) else value)
self._sync()
def value(self):
"""Literal number or reference expression."""
"""Typed value (None when empty) or reference expression."""
name = self.source.currentData()
if name:
return model.ref(name)
number = self.number.value()
return int(number) if number.is_integer() else number
return model.ref(name) if name else self.number.value()
def _sync(self):
def _on_source(self, _index: int) -> None:
self._sync()
self.edited.emit()
def _sync(self) -> None:
self.number.setVisible(self.source.currentData() is None)
self.source.setVisible(self.source.count() > 1)
@@ -187,9 +209,17 @@ class ValueInput(QWidget):
class StepInspector(QWidget):
"""Edit the step selected in a ``SequenceDocument``."""
def __init__(self, document: SequenceDocument, parent: QWidget | None = None, client=None):
def __init__(
self,
document: SequenceDocument,
backend: SequencerBackend,
parent: QWidget | None = None,
client=None,
):
super().__init__(parent)
self.document = document
self.backend = backend
self.problems: list[Problem] = []
self._node_id = ""
self._loading = False
self._commit_timer = QTimer(self, singleShot=True, interval=250)
@@ -206,38 +236,51 @@ class StepInspector(QWidget):
layout.addLayout(title_row)
self.subtitle = hint("Select a step to change it.", self)
layout.addWidget(self.subtitle)
self.problem = problem_label(self)
layout.addWidget(self.problem)
self.pages = QStackedWidget(self)
layout.addWidget(self.pages, 1)
self.pages.addWidget(QWidget(self)) # empty
self.pages.addWidget(QWidget(self)) # nothing selected
# move / set
self.move_page = QWidget(self)
form = QFormLayout(self.move_page)
self.device = DeviceComboBox(
self.move_page, client=client, device_filter=["Positioner"], autocomplete=True
)
self.device.setEditable(True)
self.device = device_picker(self.move_page, client, settable=True)
self.move_value = ValueInput(self.move_page)
self.move_info = hint("", self.move_page)
self.move_wait = QCheckBox("Wait until it has arrived", self.move_page)
form.addRow("Motor", self.device)
form.addRow("Go to", self.move_value)
form.addRow("", self.move_info)
form.addRow("", self.move_wait)
self.pages.addWidget(self.move_page)
# scan
self.scan_page = QScrollArea(self)
self.scan_page.setWidgetResizable(True)
self.scan_page.setFrameShape(QScrollArea.Shape.NoFrame)
self.scan_control = make_scan_form(self.scan_page, client)
self.scan_page.setWidget(self.scan_control)
self.scan_page = QWidget(self)
scan_layout = QVBoxLayout(self.scan_page)
scan_layout.setContentsMargins(0, 0, 0, 0)
picker_row = QFormLayout()
self.scan_picker = ScanPicker(backend.scan_entries(), self.scan_page)
picker_row.addRow("Scan", self.scan_picker)
scan_layout.addLayout(picker_row)
self.scan_scroll = QScrollArea(self.scan_page)
self.scan_scroll.setWidgetResizable(True)
self.scan_scroll.setFrameShape(QScrollArea.Shape.NoFrame)
self.scan_control = make_scan_form(self.scan_scroll, client)
self.scan_scroll.setWidget(self.scan_control)
scan_layout.addWidget(self.scan_scroll, 1)
self.scan_empty = hint("Choose the scan to run at this step.", self.scan_page)
scan_layout.addWidget(self.scan_empty)
scan_layout.addStretch(0)
self.pages.addWidget(self.scan_page)
# repeat
self.repeat_page = QWidget(self)
form = QFormLayout(self.repeat_page)
self.repeat_count = QSpinBox(self.repeat_page, minimum=1, maximum=100000)
form.addRow("How many times", self.repeat_count)
self.repeat_count.setSuffix(" times")
form.addRow("Repeat", self.repeat_count)
form.addRow(hint("Everything inside the loop runs this many times.", self.repeat_page))
self.pages.addWidget(self.repeat_page)
@@ -245,18 +288,18 @@ class StepInspector(QWidget):
self.each_page = QWidget(self)
form = QFormLayout(self.each_page)
self.each_name = QLineEdit(self.each_page)
self.each_values = QLineEdit(self.each_page)
self.each_values.setPlaceholderText("e.g. 1, -1, 2.5")
self.each_values = ValuesEdit(self.each_page)
fill = QPushButton("Fill evenly spaced…", self.each_page)
fill.clicked.connect(self._fill_range)
self.each_count = hint("", self.each_page)
form.addRow("Name of the value", self.each_name)
form.addRow("Values (in order)", self.each_values)
form.addRow("Values, in order", self.each_values)
form.addRow("", fill)
form.addRow(self.each_count)
form.addRow("", self.each_count)
form.addRow(
hint(
"Steps inside the loop can use this value, e.g. “Move samx to each position”.",
"Steps inside the loop run once for every value. A move inside the loop can go"
" to “each value from the loop”.",
self.each_page,
)
)
@@ -265,13 +308,12 @@ class StepInspector(QWidget):
# while moving
self.while_page = QWidget(self)
form = QFormLayout(self.while_page)
self.while_device = DeviceComboBox(
self.while_page, client=client, device_filter=["Positioner"], autocomplete=True
)
self.while_device.setEditable(True)
self.while_target = QDoubleSpinBox(self.while_page, decimals=4, minimum=-1e9, maximum=1e9)
self.while_device = device_picker(self.while_page, client)
self.while_target = NumberEdit(self.while_page, "position")
self.while_info = hint("", self.while_page)
form.addRow("Motor", self.while_device)
form.addRow("Moves to", self.while_target)
form.addRow("", self.while_info)
form.addRow(
hint(
"The motor starts moving, and the steps inside keep repeating until it arrives.",
@@ -282,21 +324,20 @@ class StepInspector(QWidget):
# group / other
self.info_page = QWidget(self)
QVBoxLayout(self.info_page).addWidget(
hint("A group just keeps steps together. Drag steps in or out.", self.info_page)
)
self.info_text = hint("", self.info_page)
QVBoxLayout(self.info_page).addWidget(self.info_text)
self.pages.addWidget(self.info_page)
for widget in (self.move_value.number, self.while_target):
widget.valueChanged.connect(self._schedule)
self.move_value.edited.connect(self._schedule)
self.while_target.edited.connect(self._schedule)
self.repeat_count.valueChanged.connect(self._schedule)
self.move_value.source.currentIndexChanged.connect(self._schedule)
self.move_wait.toggled.connect(self._schedule)
self.each_name.textChanged.connect(self._schedule)
self.each_values.textChanged.connect(self._schedule)
self.each_name.textEdited.connect(self._schedule)
self.each_values.textEdited.connect(self._schedule)
for combo in (self.device, self.while_device):
combo.currentTextChanged.connect(self._schedule)
self.scan_control.scan_selected.connect(self._on_scan_selected)
combo.device_selected.connect(self._schedule)
combo.lineEdit().editingFinished.connect(self._schedule)
self.scan_picker.scan_chosen.connect(self._on_scan_chosen)
document.selection_changed.connect(self.load)
document.changed.connect(self._on_document_changed)
@@ -305,7 +346,9 @@ class StepInspector(QWidget):
@SafeSlot(str)
def load(self, node_id: str) -> None:
"""Show the editor for a step."""
self._commit_timer.stop()
if self._commit_timer.isActive():
self._commit_timer.stop()
self._commit()
self._node_id = node_id
node = self.document.node(node_id) if node_id else None
self._loading = True
@@ -315,33 +358,53 @@ class StepInspector(QWidget):
self.title.setText("Step details")
self.subtitle.setText("Select a step to change it.")
self.pages.setCurrentIndex(0)
self._show_problem()
return
kind = node["type"]
self.icon.setPixmap(kind_icon(kind, 22).pixmap(22, 22))
title = model.KIND_TITLES.get(kind, kind)
if kind == "set" and "." not in node.get("device", ""):
title = "Start moving" if node.get("bind_status") else "Move"
if kind == "set" and node.get("bind_status"):
title = "Start moving"
self.title.setText(title)
self.subtitle.setText(model.sentence(node, self.document.root, self._entries()))
self._update_subtitle(node)
getattr(self, f"_load_{kind}", self._load_other)(node)
self._show_problem()
finally:
self._loading = False
def _entries(self) -> dict:
return getattr(self.scan_control, "available_scans", None) or {}
def _update_subtitle(self, node: dict) -> None:
self.subtitle.setText(model.sentence(node, self.document.root, self.backend.scan_entries()))
def _motor_info(self, device: str) -> str:
if not device or self.backend.device(device) is None:
return ""
parts = []
position = self.backend.position(device)
if position is not None:
parts.append(f"Now at {position:.4g}")
limits = self.backend.limits(device)
if limits is not None:
parts.append(f"limits {limits[0]:g} to {limits[1]:g}")
return " · ".join(parts)
def _load_set(self, node: dict) -> None:
self.device.setCurrentText(node.get("device", ""))
self.move_value.set_choices(model.visible_variables(self.document.root, node["id"]))
self.move_value.set_value(node.get("value"))
self.move_info.setText(self._motor_info(node.get("device", "")))
self.move_wait.setChecked(bool(node.get("wait", True)))
self.move_wait.setEnabled(not node.get("bind_status"))
self.move_wait.setVisible(not node.get("bind_status"))
self.pages.setCurrentWidget(self.move_page)
def _load_scan_call(self, node: dict) -> None:
self.pages.setCurrentWidget(self.scan_page)
set_scan_form_values(self.scan_control, node)
self._hook_scan_inputs()
name = node.get("name", "")
self.scan_picker.set_scan(name)
self.scan_scroll.setVisible(bool(name))
self.scan_empty.setVisible(not name)
if name:
show_scan_in_form(self.scan_control, node)
self._hook_scan_inputs()
def _load_repeat(self, node: dict) -> None:
self.repeat_count.setValue(int(node.get("count", 1)))
@@ -351,30 +414,65 @@ class StepInspector(QWidget):
self.each_name.setText(node.get("variable", ""))
values = node.get("values") if isinstance(node.get("values"), list) else []
self.each_values.setText(values_text(values))
self.each_count.setText(f"{len(values)} value{'s' if len(values) != 1 else ''}")
self._count_values(values)
self.pages.setCurrentWidget(self.each_page)
def _count_values(self, values: list) -> None:
self.each_count.setText(f"{len(values)} value{'s' if len(values) != 1 else ''}")
def _load_while(self, node: dict) -> None:
status = model.while_status(node)
owner = model.status_owner(self.document.root, status) if status else None
if owner is not None:
self.while_device.setCurrentText(owner.get("device", ""))
value = owner.get("value")
self.while_target.setValue(float(value) if isinstance(value, (int, float)) else 0)
owner = self._while_owner(node)
device = owner.get("device", "") if owner else ""
self.while_device.setCurrentText(device)
self.while_target.set_value(owner.get("value") if owner else None)
self.while_info.setText(self._motor_info(device))
self.pages.setCurrentWidget(self.while_page)
def _load_other(self, _node: dict) -> None:
def _while_owner(self, node: dict) -> dict | None:
status = model.while_status(node)
return model.status_owner(self.document.root, status) if status else None
def _load_other(self, node: dict) -> None:
if node.get("type") == "component_call":
text = "A saved block from another tool. It can be moved or deleted here."
else:
text = "A group keeps steps together. Drag steps in or out of it."
self.info_text.setText(text)
self.pages.setCurrentWidget(self.info_page)
# --- problems --------------------------------------------------------------------------
def set_problems(self, problems: list[Problem]) -> None:
"""Show the problem of the selected step, if any."""
self.problems = problems
self._show_problem()
def _show_problem(self) -> None:
message = next((p.message for p in self.problems if p.node_id == self._node_id), "")
self.problem.setText(message)
self.problem.setVisible(bool(message and self._node_id))
# --- committing ------------------------------------------------------------------------
def _schedule(self, *_args) -> None:
if not self._loading and self._node_id:
self._commit_timer.start()
@SafeSlot(str)
def _on_scan_selected(self, _name: str) -> None:
self._hook_scan_inputs()
self._schedule()
def _on_scan_chosen(self, name: str) -> None:
node = self.document.node(self._node_id)
if node is None or node.get("type") != "scan_call" or name == node.get("name"):
return
self._loading = True
try:
self.scan_picker.set_scan(name)
self.scan_scroll.setVisible(True)
self.scan_empty.setVisible(False)
show_scan_in_form(self.scan_control, {"name": name})
self._hook_scan_inputs()
finally:
self._loading = False
positional, arguments = scan_form_values(self.scan_control)
self.document.update(node["id"], name=name, **scan_arguments(node, positional, arguments))
self._update_subtitle(node)
def _hook_scan_inputs(self) -> None:
for widget in self.scan_control.findChildren(QWidget):
@@ -383,10 +481,7 @@ class StepInspector(QWidget):
widget.setProperty("_sequencer_hooked", True)
if isinstance(widget, (QSpinBox, QDoubleSpinBox)):
widget.valueChanged.connect(self._schedule)
elif (
isinstance(widget, QComboBox)
and widget is not self.scan_control.comboBox_scan_selection
):
elif isinstance(widget, QComboBox):
widget.currentTextChanged.connect(self._schedule)
elif isinstance(widget, QCheckBox):
widget.toggled.connect(self._schedule)
@@ -400,56 +495,35 @@ class StepInspector(QWidget):
return
kind = node["type"]
if kind == "set":
device = self.device.currentText().strip()
self.document.update(
node["id"],
device=self.device.currentText().strip() or node.get("device"),
device=device,
value=self.move_value.value(),
wait=True if node.get("bind_status") else self.move_wait.isChecked(),
)
self.move_info.setText(self._motor_info(device))
elif kind == "scan_call":
name, positional, arguments = scan_form_values(self.scan_control)
if not name:
if not node.get("name"):
return
self.document.update(
node["id"], name=name, positional=list(positional), arguments=arguments
)
positional, arguments = scan_form_values(self.scan_control)
self.document.update(node["id"], **scan_arguments(node, positional, arguments))
elif kind == "repeat":
self.document.update(node["id"], count=self.repeat_count.value())
elif kind == "for_each":
values = parse_values(self.each_values.text())
self.each_count.setText(f"{len(values)} value{'s' if len(values) != 1 else ''}")
new_name = self.each_name.text().strip() or node.get("variable")
self._rename_variable(node, node.get("variable"), new_name)
values = self.each_values.values()
self._count_values(values)
new_name = self.each_name.text().strip()
if new_name:
rename_variable(node, node.get("variable"), new_name)
self.document.update(node["id"], variable=new_name, values=values)
elif kind == "while":
status = model.while_status(node)
owner = model.status_owner(self.document.root, status) if status else None
owner = self._while_owner(node)
if owner is not None:
target = self.while_target.value()
self.document.update(
owner["id"],
device=self.while_device.currentText().strip() or owner.get("device"),
value=int(target) if target.is_integer() else target,
)
self.subtitle.setText(model.sentence(node, self.document.root, self._entries()))
def _rename_variable(self, loop: dict, old: str, new: str) -> None:
if not old or old == new:
return
def visit(value):
if model.is_ref(value) and value["ref"] == old:
return model.ref(new)
if isinstance(value, list):
return [visit(v) for v in value]
if isinstance(value, dict):
return {k: visit(v) for k, v in value.items()}
return value
for node, _depth, _parent in model.walk(loop):
for key in ("value", "positional", "arguments", "values"):
if key in node:
node[key] = visit(node[key])
device = self.while_device.currentText().strip()
self.document.update(owner["id"], device=device, value=self.while_target.value())
self.while_info.setText(self._motor_info(device))
self._update_subtitle(node)
@SafeSlot()
def _on_document_changed(self) -> None:
@@ -461,7 +535,36 @@ class StepInspector(QWidget):
dialog = RangeDialog(self)
if dialog.exec():
self.each_values.setText(values_text(dialog.values()))
self._schedule()
def cleanup(self) -> None:
"""Stop the commit timer."""
self._commit_timer.stop()
"""Save a pending edit and stop the commit timer."""
if self._commit_timer.isActive():
self._commit_timer.stop()
self._commit()
def rename_variable(loop: dict, old: str | None, new: str) -> None:
"""Rename a loop variable in every step inside the loop that uses it.
Args:
loop (dict): The for-each node; its own ``variable`` is not changed.
old (str | None): Current name.
new (str): New name.
"""
if not old or old == new:
return
def visit(value):
if model.is_ref(value) and value["ref"] == old:
return model.ref(new)
if isinstance(value, list):
return [visit(v) for v in value]
if isinstance(value, dict):
return {k: visit(v) for k, v in value.items()}
return value
for node, _depth, _parent in model.walk(loop):
for key in ("value", "values", "positional", "arguments"):
if key in node:
node[key] = visit(node[key])
+41 -25
View File
@@ -47,21 +47,6 @@ def empty_document(doc_id: str = "new_sequence") -> dict:
}
def example_document() -> dict:
"""Return the shared example: two rounds over two sample positions, one scan at each.
Returns:
dict: Sequence document.
"""
doc = empty_document("alternating_positions")
loop = for_each_node("position", [1, -1], [move_node("samx", ref("position"))])
loop["body"]["children"].append(
scan_node("line_scan", ["samy", -2, 2], {"steps": 20, "exp_time": 0.1, "relative": True})
)
doc["root"]["children"] = [repeat_node(2, [loop])]
return normalize(doc)
# ---------------------------------------------------------------------------------------------
# Node construction
# ---------------------------------------------------------------------------------------------
@@ -152,32 +137,42 @@ def while_moving_nodes(device: str, value: Any, children: list[dict] | None = No
return [start, loop]
def new_step(kind: str, *, device: str = "samx", scan: str = "line_scan") -> list[dict]:
"""Create default nodes for a step kind picked in an editor.
def new_step(kind: str, variable: str = "position") -> list[dict]:
"""Create blank nodes for a step kind picked in an editor.
Nothing is pre-filled: the motor, its position and the scan are chosen by the user, and
the checks point out what is still missing.
Args:
kind (str): One of the keys of ``KIND_TITLES`` (``while`` creates two nodes).
device (str): Default device for moves.
scan (str): Default scan name.
variable (str): Name for the value of a new "For each" loop.
Returns:
list[dict]: New nodes to insert.
"""
if kind == "set":
return [move_node(device, 0)]
return [move_node("", None)]
if kind == "scan_call":
return [scan_node(scan)]
return [scan_node("")]
if kind == "repeat":
return [repeat_node(2)]
if kind == "for_each":
return [for_each_node("position", [0, 1])]
return [for_each_node(variable, [])]
if kind == "while":
return while_moving_nodes(device, 5)
return while_moving_nodes("", None)
if kind == "sequence":
return [group_node()]
raise ValueError(f"Unknown step kind: {kind}")
def unused_variable(root: dict, base: str = "position") -> str:
"""A loop variable name not used yet in the document, e.g. ``position2``."""
used = {n.get("variable") for n, _d, _p in walk(root) if n.get("type") == "for_each"}
if base not in used:
return base
return next(f"{base}{i}" for i in itertools.count(2) if f"{base}{i}" not in used)
# ---------------------------------------------------------------------------------------------
# Tree access
# ---------------------------------------------------------------------------------------------
@@ -280,6 +275,15 @@ def visible_variables(root: dict, node_id: str) -> list[str]:
return [n["variable"] for n in ancestors(root, node_id) if n.get("type") == "for_each"]
def loop_values(root: dict, node_id: str, variable: str) -> list | None:
"""Values a loop variable takes at a node (from the innermost loop that defines it)."""
for loop in reversed(ancestors(root, node_id)):
if loop.get("type") == "for_each" and loop.get("variable") == variable:
values = loop.get("values")
return values if isinstance(values, list) else None
return None
def status_owner(root: dict, status: str) -> dict | None:
"""Return the Set node that binds a status name."""
for node, _depth, _parent in walk(root):
@@ -328,6 +332,8 @@ def scan_title(name: str) -> str:
def format_value(value: Any) -> str:
"""Show a value the way a scientist would write it."""
if value is None or value == "":
return "…"
if is_ref(value):
return f"each {value['ref']}"
if isinstance(value, bool):
@@ -365,9 +371,17 @@ def scan_details(node: dict, entries: dict | None = None) -> str:
elif positional:
parts.append(", ".join(format_value(v) for v in positional))
args = node.get("arguments") if isinstance(node.get("arguments"), dict) else {}
# leave out settings that are at the scan's own default, they only add noise
defaults = {
param.get("name"): param.get("default")
for param in info.get("signature") or []
if isinstance(param, dict) and param.get("default") != "_empty"
}
for key, value in args.items():
if key == "metadata" or value in (None, ""):
continue
if key in defaults and not is_ref(value) and value == defaults[key]:
continue
if key == "steps":
parts.append(f"{format_value(value)} steps")
elif key in ("exp_time", "interval") and isinstance(value, (int, float)):
@@ -394,13 +408,15 @@ def sentence(node: dict, root: dict | None = None, entries: dict | None = None)
"""
kind = node.get("type")
if kind == "set":
target = node.get("device", "?")
target = node.get("device") or "a motor"
value = format_value(node.get("value"))
verb = "Set" if "." in target else "Move"
if node.get("wait", True):
return f"{verb} {target} to {value}"
return f"Start moving {target} to {value} (don't wait)"
if kind == "scan_call":
if not node.get("name"):
return "Run a scan"
details = scan_details(node, entries)
return f"Run {scan_title(node.get('name', 'scan')).lower()}" + (
f": {details}" if details else ""
@@ -414,7 +430,7 @@ def sentence(node: dict, root: dict | None = None, entries: dict | None = None)
status = while_status(node)
owner = status_owner(root, status) if (root and status) else None
if owner is not None:
return f"While {owner.get('device')} is still moving"
return f"While {owner.get('device') or 'the motor'} is still moving"
return "While the condition holds"
if kind == "component_call":
definition = node.get("definition", {})
@@ -8,12 +8,13 @@ from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtGui import QBrush, QColor
from qtpy.QtGui import QBrush, QColor, QFontMetrics, QKeySequence, QShortcut
from qtpy.QtWidgets import (
QAbstractItemView,
QHBoxLayout,
QPushButton,
QSplitter,
QStackedWidget,
QToolButton,
QTreeWidget,
QTreeWidgetItem,
@@ -26,7 +27,14 @@ 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 StepInspector
from bec_widgets.widgets.control.sequencer.style import heading, hint, kind_color, kind_icon, theme
from bec_widgets.widgets.control.sequencer.style import (
empty_state,
heading,
hint,
kind_color,
kind_icon,
theme,
)
ID_ROLE = Qt.ItemDataRole.UserRole + 1
@@ -37,6 +45,7 @@ ADD_BUTTONS = [
("for_each", "For each value"),
("while", "While moving"),
]
PROBLEM_WIDTH = 260
class RecipeTree(QTreeWidget):
@@ -60,7 +69,8 @@ class RecipeTree(QTreeWidget):
"""Translate a drop into a document move."""
source = self.currentItem()
if source is None:
return event.ignore()
event.ignore()
return
node_id = source.data(0, ID_ROLE)
target = self.itemAt(event.position().toPoint())
indicator = self.dropIndicatorPosition()
@@ -77,8 +87,9 @@ class RecipeTree(QTreeWidget):
else:
siblings = model.children_of(target_parent)
index = siblings.index(target_node)
if indicator == QAbstractItemView.DropIndicatorPosition.BelowItem or (
indicator == QAbstractItemView.DropIndicatorPosition.OnItem
if indicator in (
QAbstractItemView.DropIndicatorPosition.BelowItem,
QAbstractItemView.DropIndicatorPosition.OnItem,
):
index += 1
source_node, source_parent = model.find(doc.root, node_id)
@@ -123,9 +134,17 @@ class RecipeEditor(SequenceEditorBase):
add_row.addStretch(1)
left_layout.addLayout(add_row)
self.stack = QStackedWidget(left)
self.empty = empty_state(
"No steps yet",
"Add a move, a scan or a loop with the buttons above. Steps run from top to bottom.",
self.stack,
)
self.stack.addWidget(self.empty)
self.tree = RecipeTree(self)
self.tree.itemSelectionChanged.connect(self._on_tree_selection)
left_layout.addWidget(self.tree, 1)
self.stack.addWidget(self.tree)
left_layout.addWidget(self.stack, 1)
action_row = QHBoxLayout()
action_row.setSpacing(2)
@@ -148,14 +167,23 @@ class RecipeEditor(SequenceEditorBase):
self.action_buttons[key] = btn
action_row.addStretch(1)
left_layout.addLayout(action_row)
for keys, key in (
(QKeySequence.StandardKey.Delete, "delete"),
(QKeySequence.StandardKey.Undo, "undo"),
(QKeySequence("Ctrl+D"), "duplicate"),
):
shortcut = QShortcut(QKeySequence(keys), self.tree)
shortcut.setContext(Qt.ShortcutContext.WidgetShortcut)
shortcut.activated.connect(lambda k=key: self._action(k))
self.inspector = StepInspector(self.document, splitter, client=self.client)
self.inspector = StepInspector(self.document, self.backend, splitter, client=self.client)
self.inspector.setMinimumWidth(340)
splitter.addWidget(left)
splitter.addWidget(self.inspector)
splitter.setStretchFactor(0, 3)
splitter.setStretchFactor(1, 2)
self.document.selection_changed.connect(self._select_in_tree)
self.document.selection_changed.connect(self._update_actions)
self._style_tree()
def _style_tree(self) -> None:
@@ -214,13 +242,14 @@ class RecipeEditor(SequenceEditorBase):
for index, child in enumerate(model.children_of(root), 1):
add(child, None, f"{index}.")
self.tree.resizeColumnToContents(1)
self.tree.setColumnWidth(1, max(self.tree.columnWidth(1), 110))
self.stack.setCurrentWidget(self.tree if model.children_of(root) else self.empty)
self.tree.setColumnWidth(1, PROBLEM_WIDTH)
self.tree.header().setStretchLastSection(False)
self.tree.header().setSectionResizeMode(0, self.tree.header().ResizeMode.Stretch)
self.tree.blockSignals(False)
self._select_in_tree(self.document.selected)
self.show_problems(self.problems)
self._update_actions()
@staticmethod
def _side_text(node: dict) -> str:
@@ -234,6 +263,9 @@ class RecipeEditor(SequenceEditorBase):
return "until it arrives"
if kind == "set":
return "waits" if node.get("wait", True) else "keeps going"
if kind == "scan_call":
metadata = (node.get("arguments") or {}).get("metadata") or {}
return str(metadata.get("sample_name", ""))
return ""
def show_problems(self, problems) -> None:
@@ -247,18 +279,35 @@ class RecipeEditor(SequenceEditorBase):
if node_id in bad:
item.setBackground(0, QBrush(QColor(red.red(), red.green(), red.blue(), 45)))
item.setToolTip(0, bad[node_id])
item.setText(1, "needs a fix")
item.setToolTip(1, bad[node_id])
metrics = QFontMetrics(item.font(1))
item.setText(
1,
metrics.elidedText(
bad[node_id], Qt.TextElideMode.ElideRight, PROBLEM_WIDTH - 16
),
)
item.setForeground(1, QBrush(red))
elif node_id:
node = self.document.node(node_id)
item.setBackground(0, QBrush())
item.setToolTip(0, "")
item.setToolTip(1, "")
if node is not None:
item.setText(1, self._side_text(node))
item.setForeground(1, QBrush(kind_color(node["type"])))
# --- interaction -----------------------------------------------------------------------
def add_step(self, kind: str) -> None:
"""Add a new step next to the selection."""
devices = self.backend.device_names()
device = "samx" if "samx" in devices or not devices else devices[0]
self.document.add_next_to_selection(model.new_step(kind, device=device))
"""Add a new step next to the selection (inside it when a loop is selected)."""
self.document.add_next_to_selection(self.document.new_nodes(kind))
@SafeSlot()
@SafeSlot(str)
def _update_actions(self, *_args) -> None:
selected = bool(self.document.selected)
for key, button in self.action_buttons.items():
button.setEnabled(self.document.can_undo() if key == "undo" else selected)
@SafeSlot()
def _on_tree_selection(self) -> None:
@@ -299,9 +348,3 @@ class RecipeEditor(SequenceEditorBase):
doc.wrap(node_id, "for_each")
elif key == "delete":
doc.delete(node_id)
def screenshot_setup(self) -> None:
"""Select the scan step (used for screenshots and the demo)."""
scans = [n for n, _d, _p in model.walk(self.document.root) if n["type"] == "scan_call"]
if scans:
self.document.select(scans[0]["id"])
+265 -67
View File
@@ -3,32 +3,58 @@
from __future__ import annotations
import json
import re
import time
from pathlib import Path
from bec_lib.endpoints import MessageEndpoints
from bec_qthemes import material_icon
from qtpy.QtCore import Qt, QTimer, Signal
from qtpy.QtGui import QFont
from qtpy.QtWidgets import (
QDialog,
QDialogButtonBox,
QFileDialog,
QHBoxLayout,
QLabel,
QListWidget,
QListWidgetItem,
QMessageBox,
QPlainTextEdit,
QPushButton,
QSizePolicy,
QTabWidget,
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.backend import Problem, SequencerBackend
from bec_widgets.widgets.control.sequencer.backend import (
Problem,
SequencerBackend,
run_failure,
sequences_dir,
)
from bec_widgets.widgets.control.sequencer.checks import check
from bec_widgets.widgets.control.sequencer.document import SequenceDocument
from bec_widgets.widgets.control.sequencer.style import Chip, kind_color, kind_icon, theme
_MOVE_SCANS = ("mv", "umv")
def _mono() -> QFont:
font = QFont("Menlo")
font.setStyleHint(QFont.StyleHint.Monospace)
return font
def _clock(seconds: float) -> str:
seconds = int(seconds)
hours, rest = divmod(seconds, 3600)
return f"{hours}:{rest // 60:02d}:{rest % 60:02d}" if hours else f"{rest // 60}:{rest % 60:02d}"
class PreviewDialog(QDialog):
"""Show a sequence step by step, as the backend describes it, and as Python."""
@@ -36,48 +62,87 @@ class PreviewDialog(QDialog):
def __init__(self, backend: SequencerBackend, doc: dict, parent: QWidget | None = None):
super().__init__(parent)
self.setWindowTitle("Preview sequence")
self.resize(720, 520)
self.resize(760, 540)
layout = QVBoxLayout(self)
tabs = QTabWidget(self)
layout.addWidget(tabs)
steps = QListWidget(tabs)
entries = backend.scan_entries()
for index, item in enumerate(model.timeline(doc["root"], entries), 1):
row = QListWidgetItem(kind_icon(item.kind, 16), f"{index:>3}. {item.text}")
items = model.timeline(doc["root"], backend.scan_entries(), limit=2000)
for index, item in enumerate(items, 1):
row = QListWidgetItem(kind_icon(item.kind, 16), f"{index:>4}. {item.text}")
if item.context:
row.setToolTip(item.context)
row.setText(f"{row.text()} ({item.context})")
steps.addItem(row)
if len(items) >= 2000:
steps.addItem("… and more")
tabs.addTab(steps, "Step by step")
mono = QFont("Menlo")
mono.setStyleHint(QFont.StyleHint.Monospace)
for title, producer in (("In words", backend.describe), ("Python", backend.python)):
text = QPlainTextEdit(tabs)
text.setReadOnly(True)
text.setFont(mono)
text.setFont(_mono())
try:
text.setPlainText(producer(doc))
except Exception as exc: # pylint: disable=broad-except
text.setPlainText(f"Not available until the problems are fixed:\n{exc}")
tabs.addTab(text, title)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close, self)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
class DetailsDialog(QDialog):
"""The full error report of a failed run."""
def __init__(self, text: str, parent: QWidget | None = None):
super().__init__(parent)
self.setWindowTitle("Why the sequence failed")
self.resize(760, 460)
layout = QVBoxLayout(self)
view = QPlainTextEdit(self)
view.setReadOnly(True)
view.setFont(_mono())
view.setPlainText(text)
layout.addWidget(view)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close, self)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
class RunBar(QWidget):
"""Summary, validation status and the Run/Stop buttons for one document."""
"""Summary, check result and the Run/Stop buttons for one document.
Args:
backend (SequencerBackend): Checks and runs the sequence.
document (SequenceDocument): The sequence.
dispatcher: BEC dispatcher used to follow the scans while the sequence runs.
parent (QWidget | None): Parent widget.
"""
problems_changed = Signal(list)
new_requested = Signal()
opened = Signal(dict)
def __init__(
self, backend: SequencerBackend, document: SequenceDocument, parent: QWidget | None = None
self,
backend: SequencerBackend,
document: SequenceDocument,
dispatcher=None,
parent: QWidget | None = None,
):
super().__init__(parent)
self.backend = backend
self.document = document
self.dispatcher = dispatcher
self.problems: list[Problem] = []
self._status = None
self._started = 0.0
self.problems: list[Problem] = []
self._planned = model.Summary()
self._scans_done = 0
self._scan_open = False
self._following = False
self._error_text = ""
self.setObjectName("SequencerRunBar")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
@@ -91,6 +156,7 @@ class RunBar(QWidget):
self.state = Chip("", parent=self)
self.message = QLabel(self)
self.message.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self.message.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
layout.addWidget(self.summary)
layout.addWidget(self.state)
layout.addWidget(self.message, 1)
@@ -102,6 +168,9 @@ class RunBar(QWidget):
layout.addWidget(btn)
return btn
self.details_button = button("Details", "description", "Show the full error report")
self.details_button.setVisible(False)
self.new_button = button("New", "note_add", "Start a new, empty sequence")
self.open_button = button("Open", "folder_open", "Open a saved sequence")
self.save_button = button("Save", "save", "Save this sequence to a file")
self.preview_button = button("Preview", "visibility", "See every step that will run")
@@ -111,6 +180,8 @@ class RunBar(QWidget):
self.stop_button.setProperty("variant", "danger")
self.stop_button.setVisible(False)
self.details_button.clicked.connect(self.show_details)
self.new_button.clicked.connect(self.new)
self.open_button.clicked.connect(self.open_file)
self.save_button.clicked.connect(self.save_file)
self.preview_button.clicked.connect(self.preview)
@@ -123,99 +194,201 @@ class RunBar(QWidget):
self._poll_timer.timeout.connect(self._poll)
document.changed.connect(self._check_timer.start)
self._apply_style()
self.check()
def _apply_style(self) -> None:
self.setStyleSheet(
f"#SequencerRunBar {{ background: {theme('CARD_BG', '#ffffff').name()};"
f" border-top: 1px solid {theme('BORDER', '#d0d0d0').name()}; }}"
# the theme keeps a disabled Run button green, which reads as "ready"
'#SequencerRunBar QPushButton[variant="success"]:disabled {'
f" background: transparent; color: {rgba(theme('FG', '#333333'), 110)};"
f" border: 1px solid {theme('BORDER', '#d0d0d0').name()}; }}"
)
@property
def running(self) -> bool:
"""Whether a sequence from this bar is queued or running."""
return self._status is not None
# --- checking --------------------------------------------------------------------------
@SafeSlot()
def check(self) -> None:
"""Count steps and validate with the backend."""
"""Count steps, find unfinished steps and validate with the backend."""
summary = model.summarize(self.document.root)
self.summary.setText(summary.text())
if self._status is not None:
if self.running:
return
if not self.backend.available:
self.problems = []
self.details_button.setVisible(False)
if not summary.scans and not summary.moves and not model.children_of(self.document.root):
self._set_problems([])
self._set_state("Empty", theme("FG", "#666666"))
self._say("Add a step to get started.")
self.run_button.setEnabled(False)
return
problems = check(self.document.root, self.backend)
if not problems:
problems = self.backend.validate(self.document.doc)
self._set_problems(problems)
if problems:
count = len(problems)
self._set_state(
f"{count} step{'s' if count != 1 else ''} to finish",
theme("ACCENT_WARNING", "#eda200"),
)
self._say(self._where(problems[0]) + problems[0].message)
elif not self.backend.available:
self._set_state("Editing only", theme("ACCENT_WARNING", "#eda200"))
self.message.setText("Install bec with the scan sequencer (bec PR #1128) to run.")
self.run_button.setEnabled(False)
self.problems_changed.emit([])
return
if not summary.scans and not summary.moves:
self.problems = []
self._set_state("Empty", theme("BORDER", "#999999"))
self.message.setText("Add a step to get started.")
self.run_button.setEnabled(False)
self.problems_changed.emit([])
return
self.problems = self.backend.validate(self.document.doc)
if summary.empty_loops and not self.problems:
self.problems = [Problem(summary.empty_loops[0], "This loop has no steps inside")]
if self.problems:
problem = self.problems[0]
self._set_state("Needs a fix", theme("ACCENT_EMERGENCY", "#e0534b"))
node = self.document.node(problem.node_id) if problem.node_id else None
where = f"{model.KIND_TITLES.get(node['type'], 'Step')}: " if node else ""
self.message.setText(where + problem.message)
self._say("This BEC version cannot run sequences yet.")
else:
self._set_state("Ready to run", theme("ACCENT_SUCCESS", "#59a869"))
self.message.setText("")
self.run_button.setEnabled(not self.problems)
self.problems_changed.emit(self.problems)
self._say("")
self.run_button.setEnabled(not problems and self.backend.available)
def _where(self, problem: Problem) -> str:
node = self.document.node(problem.node_id) if problem.node_id else None
if node is None or node is self.document.root:
return ""
title = model.KIND_TITLES.get(node["type"], "Step")
if node["type"] == "set" and node.get("bind_status"):
title = "Start moving"
return f"{title}: "
def _say(self, text: str) -> None:
self.message.setText(text)
self.message.setToolTip(text)
def _set_problems(self, problems: list[Problem]) -> None:
self.problems = problems
self.problems_changed.emit(problems)
def _set_state(self, text: str, color) -> None:
self.state.setText(text)
self.state.set_color(color)
# --- files -----------------------------------------------------------------------------
@SafeSlot()
def open_file(self) -> None:
"""Load a sequence JSON file."""
path, _ = QFileDialog.getOpenFileName(self, "Open sequence", "", "Sequence (*.json)")
if path:
self.document.set_document(json.loads(Path(path).read_text(encoding="utf-8")))
def _folder(self) -> str:
folder = sequences_dir(self.backend.client)
if folder is None:
return ""
try:
folder.mkdir(parents=True, exist_ok=True)
except OSError:
return ""
return str(folder)
@SafeSlot()
def new(self) -> None:
"""Ask to start over with an empty sequence."""
if model.children_of(self.document.root):
answer = QMessageBox.question(
self,
"New sequence",
"Start a new, empty sequence? Changes that are not saved are lost.",
)
if answer != QMessageBox.StandardButton.Yes:
return
self.new_requested.emit()
@SafeSlot(popup_error=True)
def open_file(self) -> None:
"""Load a sequence file."""
path, _ = QFileDialog.getOpenFileName(
self, "Open sequence", self._folder(), "Sequence (*.json)"
)
if not path:
return
doc = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(doc, dict) or doc.get("type") != "sequence" or "root" not in doc:
raise ValueError(f"{Path(path).name} is not a sequence file.")
self.opened.emit(doc)
@SafeSlot(popup_error=True)
def save_file(self) -> None:
"""Save the sequence as JSON."""
"""Save the sequence as JSON; the file name becomes the sequence name."""
name = f"{self.document.doc.get('id', 'sequence')}.json"
path, _ = QFileDialog.getSaveFileName(self, "Save sequence", name, "Sequence (*.json)")
if path:
Path(path).write_text(json.dumps(self.document.doc, indent=2), encoding="utf-8")
start = str(Path(self._folder()) / name) if self._folder() else name
path, _ = QFileDialog.getSaveFileName(self, "Save sequence", start, "Sequence (*.json)")
if not path:
return
path = Path(path).with_suffix(".json")
self.document.doc["id"] = re.sub(r"\W+", "_", path.stem).strip("_") or "sequence"
path.write_text(json.dumps(self.document.doc, indent=2), encoding="utf-8")
self._say(f"Saved as {path.name}")
@SafeSlot()
def preview(self) -> None:
"""Open the preview dialog."""
PreviewDialog(self.backend, self.document.doc, self).exec()
@SafeSlot()
def show_details(self) -> None:
"""Show the full error of the last failed run."""
DetailsDialog(self._error_text, self).exec()
# --- running ---------------------------------------------------------------------------
@SafeSlot(popup_error=True)
def run(self) -> None:
"""Queue the sequence."""
self.check()
if self.problems:
if self.problems or not self.backend.available:
return
doc = self.document.doc
doc["revision"] = int(doc.get("revision", 1)) + 1
self._planned = model.summarize(doc["root"])
self._scans_done = 0
self._scan_open = False
self._status = self.backend.submit(doc)
self._started = time.monotonic()
self._follow_scans(True)
self.run_button.setVisible(False)
self.stop_button.setVisible(True)
self.stop_button.setEnabled(True)
self.new_button.setEnabled(False)
self.open_button.setEnabled(False)
self._set_state("Queued", kind_color("set"))
self.message.setText("Waiting for the procedure worker…")
self._say("Waiting for the sequence to start…")
self._poll_timer.start()
@SafeSlot(popup_error=True)
def stop(self) -> None:
"""Ask the worker to stop the sequence."""
"""Ask BEC to stop the sequence; the scan that is running is aborted."""
if self._status is not None and not self._status.done:
self._status.cancel()
self.message.setText("Stopping… (a move that already started may finish first)")
self.stop_button.setEnabled(False)
self._set_state("Stopping", theme("ACCENT_WARNING", "#eda200"))
self._say("Stopping… a move that already started may still finish.")
def _follow_scans(self, follow: bool) -> None:
if self.dispatcher is None or follow == self._following:
return
if follow:
self.dispatcher.connect_slot(self._on_scan_status, MessageEndpoints.scan_status())
else:
self.dispatcher.disconnect_slot(self._on_scan_status, MessageEndpoints.scan_status())
self._following = follow
@SafeSlot(dict, dict)
def _on_scan_status(self, content: dict, _metadata: dict) -> None:
if self._status is None:
return
name = (content.get("info") or {}).get("scan_name", "")
if name in _MOVE_SCANS:
return
status = content.get("status")
if status == "open":
self._scan_open = True
elif status == "closed" and self._scan_open:
self._scan_open = False
self._scans_done += 1
def _progress(self) -> str:
total = self._planned.scans
current = self._scans_done + (1 if self._scan_open else 0)
if not total and not self._planned.open_ended:
return ""
if self._planned.open_ended:
return f"scan {max(current, 1)}"
return f"scan {min(max(current, 1), total)} of {total}"
@SafeSlot()
def _poll(self) -> None:
@@ -224,27 +397,52 @@ class RunBar(QWidget):
self._poll_timer.stop()
return
state = status.state.value
elapsed = int(time.monotonic() - self._started)
if state in ("Running", "Scheduled", "Requested"):
label = {"Running": "Running", "Scheduled": "Queued", "Requested": "Queued"}[state]
self._set_state(label, kind_color("set"))
self.message.setText(f"{elapsed // 60}:{elapsed % 60:02d} elapsed")
elapsed = _clock(time.monotonic() - self._started)
if state in ("Requested", "Scheduled"):
self._set_state("Queued", kind_color("set"))
self._say(f"Waiting for the sequence to start… {elapsed}")
return
if state == "Running":
if self.stop_button.isEnabled():
self._set_state("Running", kind_color("set"))
progress = self._progress()
self._say(f"{progress.capitalize()} · {elapsed}" if progress else elapsed)
return
self._finish(status, state, elapsed)
def _finish(self, status, state: str, elapsed: str) -> None:
self._poll_timer.stop()
self._follow_scans(False)
self._status = None
self.run_button.setVisible(True)
self.stop_button.setVisible(False)
self.new_button.setEnabled(True)
self.open_button.setEnabled(True)
scans = f"{self._scans_done} scan{'s' if self._scans_done != 1 else ''}"
if state == "Success":
self._set_state("Finished", theme("ACCENT_SUCCESS", "#59a869"))
self.message.setText(f"Done after {elapsed // 60}:{elapsed % 60:02d}")
else:
self._set_state(
"Stopped" if "Aborted" in str(status.error) else "Failed",
theme("ACCENT_EMERGENCY", "#e0534b"),
)
self.message.setText(str(status.error or state).splitlines()[-1][:200])
self._say(f"Done: {scans} in {elapsed}")
self.run_button.setEnabled(not self.problems)
return
error = str(status.error or "")
if "Aborted by user" in error:
self._set_state("Stopped", theme("ACCENT_WARNING", "#eda200"))
where = f"during {self._progress()}" if self._scan_open else f"after {scans}"
self._say(f"Stopped {where}, {elapsed}")
self.run_button.setEnabled(not self.problems)
return
failure = run_failure(error) if error else Problem("", f"The sequence was {state.lower()}")
self._error_text = error or failure.message
self._set_state("Failed", theme("ACCENT_EMERGENCY", "#e0534b"))
self._say(self._where(failure) + failure.message)
self.details_button.setVisible(bool(error))
self.run_button.setEnabled(True)
if failure.node_id:
self._set_problems([Problem(failure.node_id, f"Failed here: {failure.message}")])
def cleanup(self) -> None:
"""Stop timers."""
"""Stop timers and listeners."""
self._check_timer.stop()
self._poll_timer.stop()
self._follow_scans(False)
self.backend.close()
+54 -4
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QApplication, QFrame, QLabel, QWidget
from qtpy.QtWidgets import QApplication, QFrame, QHBoxLayout, QLabel, QVBoxLayout, QWidget
from bec_widgets.utils.colors import get_theme_name, rgba
@@ -65,10 +65,14 @@ def kind_icon(kind: str, size: int = 18, color: QColor | str | None = None):
def theme(key: str, fallback: str) -> QColor:
"""Theme color by key, e.g. ``CARD_BG`` or ``BORDER``."""
"""Theme color by key, e.g. ``CARD_BG`` or ``BORDER``.
Returns a copy: the theme hands out shared colors, and changing one (e.g. its alpha) would
change it for every widget that uses it later.
"""
app = QApplication.instance()
if app is not None and hasattr(app, "theme"):
return app.theme.color(key, fallback)
return QColor(app.theme.color(key, fallback))
return QColor(fallback)
@@ -82,8 +86,10 @@ def card_qss(selector: str, radius: int = 8) -> str:
def chip_qss(color: QColor, selector: str = "QLabel") -> str:
"""Style sheet for a small tinted pill."""
# bright accents (yellow, green) need darker text to stay readable on a light background
text = color if is_dark() else QColor(color).darker(150)
return (
f"{selector} {{ background: {rgba(color, 38)}; color: {color.name()};"
f"{selector} {{ background: {rgba(color, 38)}; color: {text.name()};"
" border-radius: 9px; padding: 1px 8px; font-weight: 600; }"
)
@@ -126,3 +132,47 @@ def hint(text: str, parent: QWidget | None = None) -> QLabel:
label.setStyleSheet(f"color: {rgba(fg, 150)};")
label.setTextInteractionFlags(Qt.TextInteractionFlag.NoTextInteraction)
return label
def problem_label(parent: QWidget | None = None) -> QLabel:
"""Red text that explains what a step still needs; hidden while empty."""
label = QLabel(parent)
label.setWordWrap(True)
label.setStyleSheet(f"color: {theme('ACCENT_EMERGENCY', '#e0534b').name()}; font-weight: 600;")
label.setVisible(False)
return label
def empty_state(title: str, text: str, parent: QWidget | None = None, icon: str = "playlist_add"):
"""A centered message for an empty sequence: icon, title and a hint."""
widget = QWidget(parent)
widget.setObjectName("SequencerEmptyState")
widget.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
widget.setStyleSheet(
f"#SequencerEmptyState {{ background: {theme('CARD_BG', '#ffffff').name()};"
f" border: 2px dashed {theme('BORDER', '#d0d0d0').name()}; border-radius: 10px; }}"
)
layout = QVBoxLayout(widget)
layout.addStretch(1)
picture = QLabel(widget)
muted = theme("FG", "#333333")
muted.setAlpha(110)
picture.setPixmap(
material_icon(icon, size=(44, 44), color=muted.name(QColor.NameFormat.HexArgb))
)
picture.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(picture)
name = heading(title, widget, 14)
name.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(name)
explanation = hint(text, widget)
explanation.setAlignment(Qt.AlignmentFlag.AlignCenter)
explanation.setMaximumWidth(420)
# stretches instead of an alignment flag, so the wrapped text gets the height it needs
row = QHBoxLayout()
row.addStretch(1)
row.addWidget(explanation, 4)
row.addStretch(1)
layout.addLayout(row)
layout.addStretch(1)
return widget
@@ -16,17 +16,18 @@ from qtpy.QtCore import Qt
from qtpy.QtGui import QBrush, QColor, QKeySequence
from qtpy.QtWidgets import (
QAbstractItemView,
QApplication,
QComboBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QGridLayout,
QHBoxLayout,
QHeaderView,
QLabel,
QPushButton,
QSpinBox,
QStackedWidget,
QTableWidget,
QTableWidgetItem,
QToolButton,
@@ -38,24 +39,20 @@ 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.fields import device_picker, parse_number
from bec_widgets.widgets.control.sequencer.inspector import ScanSettingsDialog
from bec_widgets.widgets.control.sequencer.style import (
Chip,
card_qss,
empty_state,
heading,
hint,
kind_color,
kind_icon,
problem_label,
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]),
]
FIRST_MOTOR_COLUMN = 2
class GridDialog(QDialog):
@@ -63,7 +60,7 @@ class GridDialog(QDialog):
def __init__(self, motors: list[str], parent: QWidget | None = None):
super().__init__(parent)
self.setWindowTitle("Fill table with a grid")
self.setWindowTitle("Fill the table with a grid")
self.motors = motors
layout = QVBoxLayout(self)
layout.addWidget(hint("One row is created for every combination of positions.", self))
@@ -72,13 +69,15 @@ class GridDialog(QDialog):
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)
start = QDoubleSpinBox(self, decimals=4, minimum=-1e6, maximum=1e6)
stop = QDoubleSpinBox(self, decimals=4, minimum=-1e6, maximum=1e6)
points = QSpinBox(self, minimum=1, maximum=1000, value=2)
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.count = hint("", self)
layout.addWidget(self.count)
self.replace = QComboBox(self)
self.replace.addItems(["Replace the rows", "Add below the rows"])
layout.addWidget(self.replace)
@@ -88,6 +87,13 @@ class GridDialog(QDialog):
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
for _start, _stop, points in self.inputs:
points.valueChanged.connect(self._count)
self._count()
def _count(self, *_args) -> None:
total = int(np.prod([points.value() for _a, _b, points in self.inputs]))
self.count.setText(f"{total} row{'s' if total != 1 else ''}")
def rows(self) -> list[list[float]]:
"""All combinations, first motor changing slowest."""
@@ -98,20 +104,75 @@ class GridDialog(QDialog):
return [list(combo) for combo in itertools.product(*axes)]
class TableEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors
def _scan_settings(node: dict) -> tuple:
arguments = {k: v for k, v in (node.get("arguments") or {}).items() if k != "metadata"}
return node.get("name"), node.get("positional") or [], arguments
def table_state(doc: dict) -> dict | None:
"""Read a sequence back as a table, if it has the shape the table creates.
Args:
doc (dict): Sequence document.
Returns:
dict | None: Table state for ``TableEditor.restore_state``, or None when the sequence
is not a list of "move, move, …, scan" rows with the same scan.
"""
steps = model.children_of(doc.get("root", {}))
repeats = 1
if len(steps) == 1 and steps[0].get("type") == "repeat":
repeats = int(steps[0].get("count", 1))
steps = model.children_of(steps[0])
motors: list[str] = []
rows = []
scan = None
pending: dict = {}
for node in steps:
kind = node.get("type")
if kind == "set" and node.get("wait", True) and not node.get("bind_status"):
device = node.get("device") or ""
if not device or model.is_ref(node.get("value")) or device in pending:
return None
pending[device] = node.get("value")
if device not in motors:
motors.append(device)
elif kind == "scan_call":
if scan is None:
scan = node
elif _scan_settings(node) != _scan_settings(scan):
return None
name = ((node.get("arguments") or {}).get("metadata") or {}).get("sample_name", "")
rows.append((str(name), pending))
pending = {}
else:
return None
if pending or scan is None:
return None
name, positional, arguments = _scan_settings(scan)
return {
"motors": motors,
"rows": [[name_, [values.get(m) for m in motors], True] for name_, values in rows],
"scan": model.scan_node(name, positional, arguments),
"repeats": repeats,
}
class TableEditor(SequenceEditorBase):
"""Spreadsheet of measurement points."""
# pylint: disable=too-many-ancestors, attribute-defined-outside-init
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.motors: list[str] = []
self.scan = model.scan_node("")
self.repeats = 1
self._row_nodes: dict[str, int] = {}
self._cells: dict[str, tuple[int, int]] = {}
self._scan_ids: set[str] = set()
self._building = False
layout = QVBoxLayout(host)
@@ -120,8 +181,9 @@ class TableEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors
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).",
"Each row is one place to measure: the motors go to the row's positions, then the"
" scan below runs. Leave a cell empty to keep that motor where it is. Paste rows"
" from Excel with Ctrl+V.",
host,
)
)
@@ -130,86 +192,114 @@ class TableEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors
card = QWidget(host)
card.setObjectName("TableScanCard")
card.setStyleSheet(card_qss("#TableScanCard"))
row = QHBoxLayout(card)
row.setContentsMargins(12, 10, 12, 10)
card_layout = QVBoxLayout(card)
card_layout.setContentsMargins(12, 10, 12, 10)
row = QHBoxLayout()
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))
row.addWidget(QLabel("<b>At every row run</b>", card))
self.scan_button = QPushButton(card)
self.scan_button.setIcon(material_icon("tune", size=(16, 16), convert_to_pixmap=False))
self.scan_button.setToolTip("Choose the scan and its settings")
self.scan_button.clicked.connect(self._edit_scan)
row.addWidget(self.scan_button)
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)
card_layout.addLayout(row)
self.scan_problem = problem_label(card)
card_layout.addWidget(self.scan_problem)
layout.addWidget(card)
# toolbar
# motor columns and rows
tools = QHBoxLayout()
tools.setSpacing(6)
tools.addWidget(QLabel("Motors:", host))
tools.addWidget(QLabel("Motor columns:", 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)
self.motor_picker = device_picker(host, self.client, "Add a motor column…")
self.motor_picker.setMinimumWidth(190)
self.motor_picker.activated.connect(self._on_motor_activated)
self.motor_picker.lineEdit().returnPressed.connect(self._on_motor_entered)
tools.addWidget(self.motor_picker)
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),
self.add_row_button = QPushButton("Add row", host)
self.add_row_button.setIcon(material_icon("add", size=(16, 16), convert_to_pixmap=False))
self.add_row_button.clicked.connect(self._add_row)
tools.addWidget(self.add_row_button)
self.grid_button = QPushButton("Fill grid…", host)
self.grid_button.setIcon(material_icon("grid_on", size=(16, 16), convert_to_pixmap=False))
self.grid_button.setToolTip("Create a row for every combination of motor positions")
self.grid_button.clicked.connect(self._fill_grid)
tools.addWidget(self.grid_button)
self.row_tools: list[QToolButton] = []
for icon_name, tip, slot in (
("arrow_upward", "Move the selected rows up", lambda: self._shift_rows(-1)),
("arrow_downward", "Move the selected rows down", lambda: self._shift_rows(1)),
("delete", "Remove the selected rows", self._remove_rows),
):
btn = QPushButton(text, host)
btn.setIcon(material_icon(icon_name, size=(16, 16), convert_to_pixmap=False))
btn = QToolButton(host)
btn.setIcon(material_icon(icon_name, size=(18, 18), convert_to_pixmap=False))
btn.setToolTip(tip)
btn.setAutoRaise(True)
btn.clicked.connect(slot)
tools.addWidget(btn)
self.row_tools.append(btn)
layout.addLayout(tools)
self.stack = QStackedWidget(host)
self.empty = empty_state(
"No measurement points yet",
"Add a motor column, then add rows, fill a grid or paste rows from Excel. When the"
" first pasted line holds motor names, they become the columns.",
self.stack,
icon="table_rows",
)
self.empty.setFocusPolicy(Qt.FocusPolicy.ClickFocus)
self.stack.addWidget(self.empty)
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.itemSelectionChanged.connect(self._update_buttons)
self.table.setStyleSheet(
f"QTableWidget {{ border: 1px solid {theme('BORDER', '#d0d0d0').name()};"
" border-radius: 8px; gridline-color: transparent; }"
)
layout.addWidget(self.table, 1)
self.stack.addWidget(self.table)
self.empty.installEventFilter(self)
self.table.installEventFilter(self)
layout.addWidget(self.stack, 1)
self.footer = hint("", host)
layout.addWidget(self.footer)
self._rebuild_table(EXAMPLE_ROWS)
self._rebuild_table([])
# --- table <-> data --------------------------------------------------------------------
def _columns(self) -> list[str]:
return ["Measure", "Sample name", *self.motors]
def _rebuild_table(self, rows: list[tuple[str, list]]) -> None:
def _rebuild_table(self, rows: list[tuple]) -> None:
"""Show rows of ``(name, positions)`` or ``(name, positions, measure)``."""
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.setColumnCount(FIRST_MOTOR_COLUMN + len(self.motors))
self.table.setHorizontalHeaderLabels(["", "Sample name", *self.motors])
self.table.setRowCount(0)
for name, positions in rows:
self._append_row(name, positions)
for row in rows:
self._append_row(*row)
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()):
for column in range(FIRST_MOTOR_COLUMN, self.table.columnCount()):
header.setSectionResizeMode(column, QHeaderView.ResizeMode.Interactive)
self.table.setColumnWidth(column, 170)
self.table.setColumnWidth(column, 150)
self._building = False
self._refresh_motor_chips()
self._compile()
@@ -223,10 +313,16 @@ class TableEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors
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))
for column in range(len(self.motors)):
value = positions[column] if column < len(positions) else None
self._set_cell(row, FIRST_MOTOR_COLUMN + column, value)
def _set_cell(self, row: int, column: int, value) -> None:
text = "" if value is None else model.format_value(value)
item = QTableWidgetItem(str(value).strip() if isinstance(value, str) else text)
if column >= FIRST_MOTOR_COLUMN:
item.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self.table.setItem(row, column, item)
self.table.setItem(row, column, item)
def _rows(self) -> list[tuple[bool, str, list]]:
rows = []
@@ -234,17 +330,13 @@ class TableEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors
check = self.table.item(row, 0)
name_item = self.table.item(row, 1)
positions = []
for column in range(2, self.table.columnCount()):
for column in range(FIRST_MOTOR_COLUMN, 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)
positions.append(parse_number(item.text()) if item else None)
rows.append(
(
check is None or check.checkState() == Qt.CheckState.Checked,
name_item.text() if name_item else "",
name_item.text().strip() if name_item else "",
positions,
)
)
@@ -253,67 +345,96 @@ class TableEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors
def _compile(self) -> None:
"""Turn the table into the sequence document."""
steps = []
self._row_nodes = {}
for index, (measure, name, positions) in enumerate(self._rows()):
self._cells = {}
self._scan_ids = set()
rows = self._rows()
for index, (measure, name, positions) in enumerate(rows):
if not measure:
continue
for motor, value in zip(self.motors, positions):
for column, (motor, value) in enumerate(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
self._cells[node["id"]] = (index, FIRST_MOTOR_COLUMN + column)
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
self._cells[scan["id"]] = (index, 1)
self._scan_ids.add(scan["id"])
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
doc = model.empty_document(self.document.doc.get("id", "measurement_table"))
if self.repeats > 1 and steps:
steps = [model.repeat_node(self.repeats, steps)]
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._update_labels(rows)
def _update_labels(self, rows: list) -> None:
if self.scan.get("name"):
self.scan_button.setText(model.scan_title(self.scan["name"]))
self.scan_label.setText(model.scan_details(self.scan, self.scan_entries()))
else:
self.scan_button.setText("Choose a scan…")
self.scan_label.setText("")
measured = sum(1 for r in rows if r[0])
self.footer.setText(
f"{self.table.rowCount()} rows, {measured} measured"
f"{len(rows)} row{'s' if len(rows) != 1 else ''}, {measured} measured"
+ (f", whole table {self.repeats}×" if self.repeats > 1 else "")
+ ". Rows are measured from top to bottom."
)
self.stack.setCurrentWidget(self.table if self.motors or rows else self.empty)
self._update_buttons()
@SafeSlot()
def _update_buttons(self) -> None:
self.grid_button.setEnabled(bool(self.motors))
selected = bool(self.table.selectedIndexes())
for button in self.row_tools:
button.setEnabled(selected)
def _refresh_motor_chips(self) -> None:
while self.motor_chips.count():
item = self.motor_chips.takeAt(0)
if item.widget():
item.widget().hide() # gone at once, deleted when Qt gets to it
item.widget().deleteLater()
color = kind_color("set")
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."""
def reset(self) -> None:
self.motors = []
self.scan = model.scan_node("")
self.repeats = 1
self.repeat_spin.blockSignals(True)
self.repeat_spin.setValue(1)
self.repeat_spin.blockSignals(False)
self._rebuild_table([])
def open_document(self, doc: dict) -> None:
"""Show an opened sequence as a table, or hand it to the Recipe editor."""
state = table_state(doc)
if state is None:
self.open_in_recipe.emit(doc)
return
self.document.doc["id"] = doc.get("id", "measurement_table")
self.restore_state(state)
@SafeSlot(QTableWidgetItem)
def _on_item_changed(self, _item) -> None:
if not self._building:
@@ -325,127 +446,174 @@ class TableEditor(SequenceEditorBase): # pylint: disable=too-many-ancestors
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 _on_motor_activated(self, index: int) -> None:
self.add_motor(self.motor_picker.itemText(index))
self.motor_picker.setCurrentText("")
@SafeSlot()
def _on_motor_entered(self) -> None:
if self.motor_picker.is_valid_input:
self.add_motor(self.motor_picker.currentText())
self.motor_picker.setCurrentText("")
def add_motor(self, name: str) -> None:
"""Add a motor column."""
name = name.strip()
if not name or name in self.motors:
return
rows = [(n, p + [None], m) 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()]
rows = [(n, p[:index] + p[index + 1 :], m) 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._append_row(f"Point {self.table.rowCount() + 1}", [])
self._building = False
self._compile()
self.table.editItem(self.table.item(self.table.rowCount() - 1, 1))
row = self.table.rowCount() - 1
self.table.setCurrentCell(row, FIRST_MOTOR_COLUMN if self.motors else 1)
self.table.editItem(self.table.currentItem())
def _selected_rows(self) -> list[int]:
return sorted({i.row() for i in self.table.selectedIndexes()})
@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:
for row in reversed(self._selected_rows()):
self.table.removeRow(row)
self._building = False
self._compile()
def _shift_rows(self, offset: int) -> None:
selected = self._selected_rows()
rows = self._rows()
if not selected or min(selected) + offset < 0 or max(selected) + offset >= len(rows):
return
order = list(range(len(rows)))
for row in selected if offset < 0 else reversed(selected):
order[row], order[row + offset] = order[row + offset], order[row]
self._rebuild_table([(rows[i][1], rows[i][2], rows[i][0]) for i in order])
for row in selected:
self.table.selectRow(row + offset)
@SafeSlot()
def _fill_grid(self) -> None:
if not self.motors:
return
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()]
keep = [] if dialog.replace.currentIndex() == 0 else [(n, p, m) 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)
dialog = ScanSettingsDialog(self.scan, self.client, self.scan_entries(), self)
if dialog.exec():
name, positional, arguments = dialog.values()
self.scan.update(name=name, positional=positional, arguments=arguments)
self._compile()
if name:
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:
"""Paste rows from Excel and clear cells with the Delete key."""
if obj in (self.table, self.empty) and event.type() == event.Type.KeyPress:
if event.matches(QKeySequence.StandardKey.Paste):
self._paste()
self.paste(QApplication.clipboard().text())
return True
editing = self.table.state() == QAbstractItemView.State.EditingState
if event.matches(QKeySequence.StandardKey.Delete) and not editing:
self._building = True
for item in self.table.selectedItems():
if item.column() >= 1:
item.setText("")
self._building = False
self._compile()
return True
return super().eventFilter(obj, event)
def _paste(self) -> None:
# pylint: disable=import-outside-toplevel
from qtpy.QtWidgets import QApplication
def paste(self, text: str) -> None:
"""Paste tab-separated rows, e.g. copied from Excel.
text = QApplication.clipboard().text()
lines = [line.split("\t") for line in text.strip().splitlines() if line.strip()]
When the first line holds motor names after the sample name ("name, samx, samy"),
those motors become columns and every following line becomes a new row.
"""
lines = [line.split("\t") for line in text.strip("\n").splitlines() if line.strip()]
if not lines:
return
start_row = max(self.table.currentRow(), 0)
start_col = max(self.table.currentColumn(), 1)
known = set(self.backend.device_names())
header = [cell.strip() for cell in lines[0][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()))
if header and all(cell in known for cell in header):
self._building = False
for name in header:
self.add_motor(name)
self._building = True
columns = [FIRST_MOTOR_COLUMN + self.motors.index(name) for name in header]
first = self.table.rowCount()
for offset, cells in enumerate(lines[1:]):
name = cells[0].strip() if cells else ""
self._append_row(name or f"Point {first + offset + 1}", [])
for column, value in zip(columns, cells[1:]):
self._set_cell(first + offset, column, value)
else:
start_row = max(self.table.currentRow(), 0)
start_col = max(self.table.currentColumn(), 1)
for r, cells in enumerate(lines):
row = start_row + r
if row >= self.table.rowCount():
self._append_row(f"Point {row + 1}", [])
for c, value in enumerate(cells):
if start_col + c < self.table.columnCount():
self._set_cell(row, start_col + c, value)
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")
tint = QBrush(QColor(red.red(), red.green(), red.blue(), 50))
bad: dict[tuple[int, int], str] = {}
general = ""
for problem in problems:
if problem.node_id in self._scan_ids or problem.node_id not in self._cells:
general = general or problem.message
else:
bad.setdefault(self._cells[problem.node_id], problem.message)
self.scan_problem.setText(general)
self.scan_problem.setVisible(bool(general))
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("")
message = bad.get((row, column), "")
item.setBackground(tint if message else QBrush())
item.setToolTip(message)
self._building = False
def save_state(self) -> dict:
return {
"motors": list(self.motors),
"rows": [(name, positions, measure) for measure, name, positions in self._rows()],
"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.motors = list(state.get("motors", []))
self.scan = state.get("scan") or model.scan_node("")
self.repeats = int(state.get("repeats", 1))
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)
self._rebuild_table([tuple(row) for row in state.get("rows", [])])
+484 -92
View File
@@ -1,15 +1,32 @@
# pylint: disable=missing-function-docstring, redefined-outer-name, protected-access
import json
from copy import deepcopy
import pytest
from bec_lib.endpoints import MessageEndpoints
from bec_lib.messages import AvailableResourceMessage
from bec_lib.procedures.helper import ProcedureState
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QToolButton
from bec_widgets.applications.views.sequencer_view.concepts import CONCEPTS
from bec_widgets.applications.views.sequencer_view.sequencer_view import SequencerView
from bec_widgets.tests.client_mocks import mocked_client # noqa: F401
from bec_widgets.tests.utils import create_widget
from bec_widgets.widgets.control.sequencer import model
from bec_widgets.widgets.control.sequencer.backend import BACKEND_AVAILABLE, friendly_message
from bec_widgets.widgets.control.sequencer import backend as backend_module
from bec_widgets.widgets.control.sequencer import editor_base, model, style
from bec_widgets.widgets.control.sequencer.backend import (
BACKEND_AVAILABLE,
SequencerBackend,
friendly_message,
run_failure,
sequences_dir,
)
from bec_widgets.widgets.control.sequencer.checks import check
from bec_widgets.widgets.control.sequencer.document import SequenceDocument
from bec_widgets.widgets.control.sequencer.fields import device_picker, parse_number, parse_values
from bec_widgets.widgets.control.sequencer.table.table_editor import table_state
from .test_scan_control import available_scans_message
TIME_SCAN = {
"class": "TimeScan",
@@ -21,41 +38,49 @@ TIME_SCAN = {
"arg_bundle_size": {"bundle": 0, "min": None, "max": None},
"doc": "Time scan",
"signature": [
{
"name": "points",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "int",
},
{
"name": "interval",
"kind": "POSITIONAL_OR_KEYWORD",
"default": "_empty",
"annotation": "float",
},
{"name": "points", "kind": "POSITIONAL_OR_KEYWORD", "default": 1, "annotation": "int"},
{"name": "interval", "kind": "POSITIONAL_OR_KEYWORD", "default": 1, "annotation": "float"},
],
"gui_visibility": {},
}
HIDDEN = {**TIME_SCAN, "doc": "Move"}
@pytest.fixture
def client(mocked_client):
resource = deepcopy(available_scans_message.resource)
resource.update({"time_scan": TIME_SCAN, "umv": HIDDEN, "_internal_scan": TIME_SCAN})
mocked_client.connector.set_and_publish(
MessageEndpoints.available_scans(),
AvailableResourceMessage(resource={"time_scan": TIME_SCAN}),
MessageEndpoints.available_scans(), AvailableResourceMessage(resource=resource)
)
yield mocked_client
def test_summary_multiplies_through_loops():
doc = model.example_document()
@pytest.fixture
def backend(client):
return SequencerBackend(client)
def two_positions() -> dict:
"""Twice: for samx at 1 and -1, move samx and run a time scan."""
doc = model.empty_document("two_positions")
loop = model.for_each_node(
"position", [1, -1], [model.move_node("samx", model.ref("position"))]
)
model.children_of(loop).append(model.scan_node("time_scan", [], {"points": 2, "interval": 1}))
doc["root"]["children"] = [model.repeat_node(2, [loop])]
return model.normalize(doc)
def root_with(*children) -> dict:
return {"id": "root", "type": "sequence", "children": list(children)}
# --- model ---------------------------------------------------------------------------------
def test_summary_and_timeline_unroll_loops():
doc = two_positions()
summary = model.summarize(doc["root"])
assert (summary.scans, summary.moves) == (4, 4)
assert summary.text() == "4 scans · 4 moves"
def test_timeline_substitutes_loop_values():
doc = model.example_document()
items = model.timeline(doc["root"])
assert [i.text for i in items if i.kind == "set"] == [
"Move samx to 1",
@@ -66,19 +91,42 @@ def test_timeline_substitutes_loop_values():
assert items[0].context == "round 1/2 · position = 1"
def test_while_moving_sentence_names_the_motor():
start, loop = model.while_moving_nodes("samx", 5, [model.scan_node("time_scan")])
root = {"id": "root", "type": "sequence", "children": [start, loop]}
assert model.sentence(start) == "Start moving samx to 5 (don't wait)"
assert model.sentence(loop, root) == "While samx is still moving"
assert model.summarize(root).open_ended
def test_new_steps_are_blank_and_read_well():
move, scan = model.new_step("set")[0], model.new_step("scan_call")[0]
assert (move["device"], move["value"], scan["name"]) == ("", None, "")
assert model.sentence(move) == "Move a motor to …"
assert model.sentence(scan) == "Run a scan"
start, loop = model.new_step("while")
assert model.sentence(loop, root_with(start, loop)) == "While the motor is still moving"
def test_scan_details_leave_out_default_settings():
entries = {"time_scan": TIME_SCAN}
node = model.scan_node("time_scan", [], {"points": 5, "interval": 1})
assert model.scan_details(node, entries) == "5 points"
assert model.scan_details(node) == "5 points · 1 s interval"
def test_unused_variable_names():
root = root_with(model.for_each_node("position", [1]))
assert model.unused_variable(root) == "position2"
assert model.unused_variable(root, "energy") == "energy"
def test_parse_numbers_and_values():
assert parse_number("") is None
assert parse_number("2") == 2 and isinstance(parse_number("2"), int)
assert parse_number("2.0") == 2.0 and isinstance(parse_number("2.0"), float)
assert parse_number("1e-3") == 0.001
assert parse_number("abc") == "abc"
assert parse_values("1, -1; 2.5\n3") == [1, -1, 2.5, 3]
# --- document ------------------------------------------------------------------------------
def test_document_delete_while_also_removes_its_move(qtbot):
doc = SequenceDocument()
start, loop = model.while_moving_nodes("samx", 5)
doc.add([start, loop])
doc.delete(loop["id"])
doc.add(model.while_moving_nodes("samx", 5))
doc.delete(model.children_of(doc.root)[1]["id"])
assert model.children_of(doc.root) == []
@@ -97,64 +145,381 @@ def test_document_refuses_moving_a_loop_into_itself(qtbot):
doc = SequenceDocument()
outer = model.repeat_node(2, [model.repeat_node(2)])
doc.add([outer])
inner = model.children_of(outer)[0]
doc.move(outer["id"], inner["id"], 0)
doc.move(outer["id"], model.children_of(outer)[0]["id"], 0)
assert model.children_of(doc.root)[0]["id"] == outer["id"]
def test_new_move_inside_a_loop_goes_to_each_value(qtbot):
doc = SequenceDocument()
doc.add(doc.new_nodes("for_each"))
loop = model.children_of(doc.root)[0]
doc.select(loop["id"])
doc.add_next_to_selection(doc.new_nodes("set"))
assert model.children_of(loop)[0]["value"] == {"ref": "position"}
assert doc.new_nodes("for_each")[0]["variable"] == "position2"
# --- checks --------------------------------------------------------------------------------
def test_checks_find_every_unfinished_step(backend):
root = root_with(
model.move_node("", None),
model.move_node("samx", None),
model.move_node("nope", 1),
model.move_node("samx", "fast"),
model.scan_node(""),
model.scan_node("line_scan"),
model.scan_node("line_scan", ["", 0, 1]),
model.for_each_node("position", []),
model.repeat_node(2),
model.scan_node("time_scan", [], {"points": 1, "interval": 1}),
)
messages = [p.message for p in check(root, backend)]
assert messages == [
"Choose a motor",
"Enter a position",
"There is no device called “nope”",
"samx needs a number",
"Choose a scan",
"Add at least one motor to the scan",
"Choose a motor for the scan",
"Enter at least one value",
"This loop has no steps inside",
]
def test_checks_use_the_motor_limits_for_every_loop_value(backend):
loop = model.for_each_node(
"position", [1, 12], [model.move_node("samx", model.ref("position"))]
)
problems = check(root_with(loop), backend)
assert [p.message for p in problems] == ["12 is outside the limits of samx (-10 to 10)"]
assert problems[0].node_id == model.children_of(loop)[0]["id"]
assert check(root_with(model.move_node("aptrx", 1e6)), backend) == []
def test_checks_pass_a_finished_sequence(backend):
assert check(two_positions()["root"], backend) == []
# --- backend -------------------------------------------------------------------------------
def test_friendly_messages():
assert (
friendly_message("Undefined Reference: pos")
== "“pos” is only known inside its “For each pos” loop"
)
assert friendly_message("Unknown device or signal: foo") == "There is no device called “foo”"
assert (
friendly_message("Non-settable device or signal: eiger") == "“eiger” cannot be moved or set"
)
assert (
friendly_message("Device '' specified for scan argument at position 0 was not found")
== "Choose a motor for the scan"
)
@pytest.mark.parametrize("concept", list(CONCEPTS))
def test_concepts_build_a_valid_document(qtbot, client, concept):
def test_run_failure_names_the_step_and_the_reason():
error = (
"Traceback (most recent call last):\n File ...\n"
"bec_lib.scan_sequencer.core.runtime.ExecutionError: Node root/move_1: An alarm.\n"
"\tAn error occurred during 'set' on device 'samx'.\n\n"
"LimitError: position=1000 not within limits (-50, 50)\n"
)
failure = run_failure(error)
assert failure.node_id == "move_1"
assert failure.message == "LimitError: position=1000 not within limits (-50, 50)"
assert run_failure("").message == "The sequence failed"
def test_scan_entries_hide_moves_and_internal_scans(backend, client):
assert list(backend.scan_entries()) == ["grid_scan", "line_scan", "time_scan"]
client.connector.set_and_publish(
MessageEndpoints.available_scans(), AvailableResourceMessage(resource={})
)
assert "time_scan" in backend.scan_entries() # cached until reload
backend.reload()
assert backend.scan_entries() == {}
def test_backend_device_information(backend):
assert backend.is_motor("samx") and not backend.is_motor("eiger")
assert backend.limits("samx") == (-10.0, 10.0)
assert backend.limits("aptrx") is None
assert backend.device("missing") is None
assert backend.position("samx") == 2.0
def test_sequences_dir_is_next_to_the_widget_settings(client):
assert sequences_dir(client).name == "sequences"
assert sequences_dir(object()) is None
# --- concepts ------------------------------------------------------------------------------
@pytest.mark.parametrize("concept", [c for c in CONCEPTS if c != "guided"])
def test_concepts_start_empty(qtbot, client, concept):
editor = create_widget(qtbot, CONCEPTS[concept], client=client)
doc = editor.document.doc
assert doc["schema_version"] == 1
assert model.summarize(doc["root"]).scans > 0
if BACKEND_AVAILABLE:
from bec_lib.scan_sequencer import Schedule
Schedule.model_validate(doc)
assert model.children_of(editor.document.root) == []
assert editor.run_bar.state.text() == "Empty"
assert not editor.run_bar.run_button.isEnabled()
def test_table_compiles_one_scan_per_measured_row(qtbot, client):
def test_guided_starts_with_an_unfilled_sentence(qtbot, client):
editor = create_widget(qtbot, CONCEPTS["guided"], client=client)
assert editor.run_bar.state.text().endswith("to finish")
assert not editor.run_bar.run_button.isEnabled()
nodes = [n for n, _d, _p in model.walk(editor.document.root)]
assert all(n.get("device", "") == "" for n in nodes if n["type"] == "set")
assert all(n.get("name") == "" for n in nodes if n["type"] == "scan_call")
@pytest.mark.parametrize("concept", ["recipe", "blocks", "flow"])
def test_tree_concepts_mark_unfinished_steps_and_get_ready(qtbot, client, concept):
editor = create_widget(qtbot, CONCEPTS[concept], client=client)
editor.document.add(editor.document.new_nodes("set"))
editor.run_bar.check()
move = model.children_of(editor.document.root)[0]
assert editor.problem_for(move["id"]) == "Choose a motor"
assert editor.run_bar.state.text() == "1 step to finish"
editor.document.update(move["id"], device="samx", value=1)
editor.run_bar.check()
assert editor.problems == []
assert editor.run_bar.state.text() in ("Ready to run", "Editing only")
def test_recipe_inspector_edits_the_selected_move(qtbot, client):
editor = create_widget(qtbot, CONCEPTS["recipe"], client=client)
editor.add_step("set")
move = model.children_of(editor.document.root)[0]
inspector = editor.inspector
assert inspector.pages.currentWidget() is inspector.move_page
inspector.device.setCurrentText("samx")
inspector.move_value.number.setText("2.5")
inspector._commit()
assert (move["device"], move["value"]) == ("samx", 2.5)
assert "Now at 2" in inspector.move_info.text() and "-10 to 10" in inspector.move_info.text()
def test_theme_colors_are_copies(qtbot):
color = style.theme("FG", "#333333")
color.setAlpha(10)
assert style.theme("FG", "#333333").alpha() == 255
def test_device_picker_is_not_red_while_empty(qtbot, client):
picker = device_picker(None, client)
qtbot.addWidget(picker)
picker.check_validity("")
assert picker.styleSheet() == ""
picker.setCurrentText("nodevice")
picker.check_validity("nodevice")
assert "red" in picker.styleSheet()
def test_recipe_inspector_picks_a_scan_with_its_defaults(qtbot, client):
editor = create_widget(qtbot, CONCEPTS["recipe"], client=client)
editor.add_step("scan_call")
scan = model.children_of(editor.document.root)[0]
editor.inspector._on_scan_chosen("line_scan")
assert scan["name"] == "line_scan"
assert editor.inspector.scan_picker.scan() == "line_scan"
assert scan["positional"] == ["", 0.0, 0.0]
assert "metadata" not in scan["arguments"]
editor.run_bar.check()
assert editor.problem_for(scan["id"]) == "Choose a motor for the scan"
def test_inspector_keeps_metadata_of_a_scan(qtbot, client):
editor = create_widget(qtbot, CONCEPTS["recipe"], client=client)
scan = model.scan_node("time_scan", [], {"points": 2, "interval": 1, "metadata": {"a": 1}})
editor.document.add([scan])
editor.inspector._commit()
assert scan["arguments"]["metadata"] == {"a": 1}
def test_recipe_tree_shows_the_problem_next_to_the_step(qtbot, client):
editor = create_widget(qtbot, CONCEPTS["recipe"], client=client)
editor.add_step("set")
editor.run_bar.check()
item = editor.tree.topLevelItem(0)
assert item.text(1) == "Choose a motor"
def test_blocks_show_problems_without_rebuilding(qtbot, client):
editor = create_widget(qtbot, CONCEPTS["blocks"], client=client)
editor.append("set")
editor.run_bar.check()
move = model.children_of(editor.document.root)[0]
block = editor._blocks[move["id"]]
assert block.problem == "Choose a motor"
editor.inline_update(move["id"], device="samx", value=1)
editor.run_bar.check()
assert editor._blocks[move["id"]] is block
assert block.problem == ""
def test_table_rows_become_moves_and_scans(qtbot, client):
editor = create_widget(qtbot, CONCEPTS["table"], client=client)
rows = editor.table.rowCount()
assert model.summarize(editor.document.root).scans == rows
editor.table.item(0, 0).setCheckState(editor.table.item(0, 0).checkState().Unchecked)
assert model.summarize(editor.document.root).scans == rows - 1
editor.add_motor("samx")
editor.add_motor("samy")
editor.paste("A\t1\t2\nB\t3\t\nC\t4\t5")
editor.scan.update(name="time_scan", positional=[], arguments={"points": 1, "interval": 1})
editor._compile()
summary = model.summarize(editor.document.root)
assert (summary.scans, summary.moves) == (3, 5)
scans = [n for n, _d, _p in model.walk(editor.document.root) if n["type"] == "scan_call"]
assert scans[0]["arguments"]["metadata"] == {"sample_name": "A"}
editor.table.item(1, 0).setCheckState(Qt.CheckState.Unchecked)
editor.repeat_spin.setValue(2)
assert model.summarize(editor.document.root).scans == 4
def test_table_motor_chips_follow_the_columns(qtbot, client):
editor = create_widget(qtbot, CONCEPTS["table"], client=client)
editor.add_motor("samx")
editor.add_motor("samy")
editor._remove_motor("samx")
chips = [
editor.motor_chips.itemAt(i).widget().text() for i in range(editor.motor_chips.count())
]
assert chips == ["samy ✕"]
# removed chips must disappear at once, not when Qt gets round to deleting them
shown = [b for b in editor.findChildren(QToolButton) if "✕" in b.text() and not b.isHidden()]
assert len(shown) == 1
def test_table_paste_with_a_header_adds_the_motor_columns(qtbot, client):
editor = create_widget(qtbot, CONCEPTS["table"], client=client)
editor.paste("name\tsamx\tsamy\nfoil\t1\t2\nempty\t3\t4\n")
assert editor.motors == ["samx", "samy"]
assert [r[1] for r in editor._rows()] == ["foil", "empty"]
def test_table_marks_the_cell_outside_the_limits(qtbot, client):
editor = create_widget(qtbot, CONCEPTS["table"], client=client)
editor.add_motor("samx")
editor.paste("A\t50")
editor.run_bar.check()
item = editor.table.item(0, 2)
assert "outside the limits" in item.toolTip()
def test_table_reads_its_own_sequence_back(qtbot, client):
editor = create_widget(qtbot, CONCEPTS["table"], client=client)
editor.add_motor("samx")
editor.paste("A\t1\nB\t2")
editor.scan.update(name="time_scan", positional=[], arguments={"points": 1, "interval": 1})
editor.repeat_spin.setValue(3)
assert model.summarize(editor.document.root).scans == 3 * (rows - 1)
state = table_state(json.loads(json.dumps(editor.document.doc)))
assert state["motors"] == ["samx"]
assert state["rows"] == [["A", [1], True], ["B", [2], True]]
assert state["repeats"] == 3
assert table_state(two_positions()) is None
def test_guided_goals_compile(qtbot, client):
editor = create_widget(qtbot, CONCEPTS["guided"], client=client)
expected = {"positions": 4, "values": 8, "grid": 6, "repeat": 5}
scan = model.scan_node("time_scan", [], {"points": 1, "interval": 1})
memory = dict(
motor="samx",
positions=[1, 2],
repeat=2,
setting="samz",
setting_values=[0.5, 1, 1.5],
slow="samy",
slow_values=[0, 1],
fast="samx",
fast_values=[0, 1, 2],
count=5,
target=3,
)
expected = {"positions": 4, "values": 6, "grid": 6, "repeat": 5}
for goal, scans in expected.items():
editor._on_goal(goal)
editor.restore_state({"goal": goal, "scan": scan, "memory": memory})
assert model.summarize(editor.document.root).scans == scans, goal
editor._on_goal("during")
editor.run_bar.check()
assert editor.problems == [], goal
editor.restore_state({"goal": "during", "scan": scan, "memory": memory})
assert model.summarize(editor.document.root).open_ended
editor.run_bar.check()
assert editor.problems == []
def test_view_switches_concepts_and_hands_guided_result_to_recipe(qtbot, client):
def test_guided_points_at_the_missing_field_and_keeps_typed_values(qtbot, client):
editor = create_widget(qtbot, CONCEPTS["guided"], client=client)
editor.run_bar.check()
assert editor.problem.text() == "Enter at least one value"
editor.fields["positions"].setText("1, 2")
editor._compile()
editor._on_goal("during")
editor._on_goal("positions")
assert editor.fields["positions"].values() == [1, 2]
def test_flow_inserts_steps_at_a_plus(qtbot, client):
editor = create_widget(qtbot, CONCEPTS["flow"], client=client)
editor.document.add(editor.document.new_nodes("repeat"))
loop = model.children_of(editor.document.root)[0]
editor.document.add(editor.document.new_nodes("scan_call"), loop["id"], 0)
assert model.children_of(loop)[0]["type"] == "scan_call"
assert editor.timeline.count() == 2
# --- view ----------------------------------------------------------------------------------
def test_view_creates_editors_only_when_opened(qtbot, client):
view = create_widget(qtbot, SequencerView, client=client)
assert view.editors == {}
view.on_enter()
assert list(view.editors) == [next(iter(CONCEPTS))]
def test_view_hands_guided_and_foreign_files_to_recipe(qtbot, client):
view = create_widget(qtbot, SequencerView, client=client, concept="guided")
guided = view.editors["guided"]
guided._on_goal("repeat")
guided.restore_state(
{"goal": "repeat", "scan": model.scan_node("time_scan"), "memory": {"count": 5}}
)
guided.open_in_recipe.emit(guided.document.doc)
assert view.stack.currentWidget() is view.editors["recipe"]
assert model.summarize(view.editors["recipe"].document.root).scans == 5
table = view.show_concept("table")
table.open_document(two_positions())
assert view.stack.currentWidget() is view.editors["recipe"]
assert model.summarize(view.editors["recipe"].document.root).scans == 4
def test_theme_change_rebuilds_editors_and_keeps_their_work(qtbot, client):
from bec_widgets.utils.colors import apply_theme
view = create_widget(qtbot, SequencerView, client=client, concept="table")
table = view.editors["table"]
table.add_motor("samx")
table.paste("A\t1\nB\t2")
table.repeat_spin.setValue(2)
view.show_concept("recipe").document.set_document(two_positions())
view.show_concept("table")
apply_theme("light")
apply_theme("dark")
qtbot.wait(50)
new_table = view.editors["table"]
assert new_table is not table
assert view.stack.currentWidget() is new_table
assert new_table.motors == ["samx"] and new_table.table.rowCount() == 2
assert model.summarize(new_table.document.root).scans == 4
assert model.summarize(view.editors["recipe"].document.root).scans == 4
def test_drafts_keep_the_work_after_closing(qtbot, client, tmp_path, monkeypatch):
monkeypatch.setattr(editor_base, "sequences_dir", lambda _client: tmp_path)
first = create_widget(qtbot, CONCEPTS["recipe"], client=client, draft_name="recipe")
first.document.set_document(two_positions())
first.save_draft()
assert (tmp_path / "drafts" / "recipe.json").exists()
second = create_widget(qtbot, CONCEPTS["recipe"], client=client, draft_name="recipe")
assert model.summarize(second.document.root).scans == 4
# --- run bar -------------------------------------------------------------------------------
class FakeStatus:
def __init__(self):
from bec_lib.procedures.helper import ProcedureState
self.state = ProcedureState.SCHEDULED
self.error = None
self.done = False
@@ -164,53 +529,80 @@ class FakeStatus:
self.cancelled = True
def test_run_bar_runs_and_reports_the_procedure_state(qtbot, client):
from bec_lib.procedures.helper import ProcedureState
editor = create_widget(qtbot, CONCEPTS["guided"], client=client)
editor._on_goal("repeat")
bar = editor.run_bar
@pytest.fixture
def ready_editor(qtbot, client, monkeypatch):
monkeypatch.setattr(backend_module, "BACKEND_AVAILABLE", True)
editor = create_widget(qtbot, CONCEPTS["recipe"], client=client)
editor.document.set_document(two_positions())
status = FakeStatus()
bar.backend.submit = lambda doc: status
bar.backend.validate = lambda doc: []
editor.backend.submit = lambda doc: status
editor.backend.validate = lambda doc: []
editor.run_bar.check()
assert editor.run_bar.run_button.isEnabled()
return editor, status
def test_run_bar_follows_the_scans_until_finished(ready_editor):
editor, status = ready_editor
bar = editor.run_bar
bar.run()
assert bar.state.text() == "Queued"
status.state = ProcedureState.RUNNING
bar._on_scan_status({"status": "open", "info": {"scan_name": "time_scan"}}, {})
bar._poll()
assert bar.state.text() == "Running"
bar.stop()
assert status.cancelled
assert bar.message.text().startswith("Scan 1 of 4")
bar._on_scan_status({"status": "closed", "info": {"scan_name": "time_scan"}}, {})
bar._on_scan_status({"status": "open", "info": {"scan_name": "umv"}}, {})
bar._on_scan_status({"status": "open", "info": {"scan_name": "time_scan"}}, {})
bar._poll()
assert bar.message.text().startswith("Scan 2 of 4")
status.state = ProcedureState.SUCCESS
bar._poll()
assert bar.state.text() == "Finished"
assert not bar._poll_timer.isActive()
assert bar.run_button.isVisible() or not bar.isVisible()
def test_view_creates_editors_only_when_opened(qtbot, client):
view = create_widget(qtbot, SequencerView, client=client)
assert view.editors == {}
view.on_enter()
assert list(view.editors) == [next(iter(CONCEPTS))]
def test_run_bar_stop(ready_editor):
editor, status = ready_editor
bar = editor.run_bar
bar.run()
status.state = ProcedureState.RUNNING
bar.stop()
assert status.cancelled
assert bar.state.text() == "Stopping"
status.state, status.error = ProcedureState.FAILED, "Aborted by user."
bar._poll()
assert bar.state.text() == "Stopped"
def test_theme_change_rebuilds_editors_and_keeps_their_work(qtbot, client):
from bec_widgets.utils.colors import apply_theme
def test_run_bar_marks_the_step_that_failed(ready_editor):
editor, status = ready_editor
bar = editor.run_bar
move = next(n for n, _d, _p in model.walk(editor.document.root) if n["type"] == "set")
bar.run()
status.state = ProcedureState.FAILED
status.error = f"ExecutionError: Node root/{move['id']}: An alarm.\nLimitError: too far"
bar._poll()
assert bar.state.text() == "Failed"
assert bar.message.text() == "Move: LimitError: too far"
assert editor.problem_for(move["id"]) == "Failed here: LimitError: too far"
assert not bar.details_button.isHidden()
view = create_widget(qtbot, SequencerView, client=client, concept="table")
table = view.editors["table"]
table.repeat_spin.setValue(2)
table._add_row()
rows = table.table.rowCount()
view.show_concept("recipe").document.delete(
model.children_of(view.editors["recipe"].document.root)[0]["id"]
)
view.show_concept("table")
apply_theme("light")
apply_theme("dark")
qtbot.wait(50)
new_table = view.editors["table"]
assert new_table is not table
assert view.stack.currentWidget() is new_table
assert new_table.table.rowCount() == rows
assert model.summarize(new_table.document.root).scans == 2 * rows
assert model.children_of(view.editors["recipe"].document.root) == []
def test_run_bar_without_the_backend_only_edits(qtbot, client, monkeypatch):
monkeypatch.setattr(backend_module, "BACKEND_AVAILABLE", False)
editor = create_widget(qtbot, CONCEPTS["recipe"], client=client)
editor.document.set_document(two_positions())
editor.run_bar.check()
assert editor.run_bar.state.text() == "Editing only"
assert not editor.run_bar.run_button.isEnabled()
@pytest.mark.skipif(not BACKEND_AVAILABLE, reason="needs the scan sequencer backend")
def test_backend_accepts_what_the_concepts_build(qtbot, client):
from bec_lib.scan_sequencer import Schedule
for doc in (two_positions(),):
Schedule.model_validate(doc)