mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-29 16:21:27 +01:00
feat(sequencer): start empty, check every step and run on the beamline in all concepts
This commit is contained in:
17 files changed
+2358
-780
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
@@ -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])
|
||||
@@ -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"])
|
||||
@@ -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()
|
||||
@@ -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", [])])
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user