mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-28 08:01:29 +01:00
feat: add scan scheduler editor
This commit is contained in:
1 parent
a6181c81f5
commit
2bf6f56d7f
21 files changed
+2883
-4
No files matched your search
@@ -24,6 +24,7 @@ from bec_widgets.utils.screen_utils import (
|
||||
)
|
||||
from bec_widgets.widgets.containers.dock_area.profile_utils import is_experimental_features_enabled
|
||||
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
|
||||
from bec_widgets.widgets.control.scan_scheduler import ScanScheduler
|
||||
|
||||
startup_profiler.mark("module imports")
|
||||
|
||||
@@ -94,6 +95,14 @@ class BECMainApp(BECMainWindow):
|
||||
widget=self.device_manager,
|
||||
mini_text="DM",
|
||||
)
|
||||
self.scan_scheduler = ScanScheduler(parent=self)
|
||||
self.add_view(
|
||||
icon="account_tree",
|
||||
title="Scan Scheduler",
|
||||
widget=self.scan_scheduler,
|
||||
mini_text="Scans",
|
||||
view_id="scan_scheduler",
|
||||
)
|
||||
if is_experimental_features_enabled():
|
||||
self.developer_view = DeveloperView(self)
|
||||
self.add_view(
|
||||
|
||||
@@ -54,6 +54,7 @@ _Widgets = {
|
||||
"SBBMonitor": "SBBMonitor",
|
||||
"ScanControl": "ScanControl",
|
||||
"ScanProgressBar": "ScanProgressBar",
|
||||
"ScanScheduler": "ScanScheduler",
|
||||
"ScatterWaveform": "ScatterWaveform",
|
||||
"SignalLabel": "SignalLabel",
|
||||
"TextBox": "TextBox",
|
||||
@@ -5426,6 +5427,43 @@ class ScanProgressBar(RPCBase):
|
||||
"""
|
||||
|
||||
|
||||
class ScanScheduler(RPCBase):
|
||||
"""Edit nested components and execute validated sequences through procedures."""
|
||||
|
||||
_IMPORT_MODULE = "bec_widgets.widgets.control.scan_scheduler.scan_scheduler"
|
||||
|
||||
@rpc_call
|
||||
def load_json(self, document: "str", definitions: "str" = "[]") -> "None":
|
||||
"""
|
||||
Load a structurally valid document and reusable definitions into the editor.
|
||||
|
||||
Args:
|
||||
document (str): Sequence document JSON.
|
||||
definitions (str): JSON list of reusable definitions. Defaults to an empty library.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def document_json(self) -> "str":
|
||||
"""
|
||||
Return the current edited document with structural schema validation.
|
||||
|
||||
Returns:
|
||||
str: Authoritative sequence JSON for saving or submission.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def validate_sequence(self) -> "None":
|
||||
"""
|
||||
Validate the entire draft and refresh all exports on a background task.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def execute_sequence(self) -> "None":
|
||||
"""
|
||||
Validate and execute the current sequence through the existing procedure API.
|
||||
"""
|
||||
|
||||
|
||||
class ScatterCurve(RPCBase):
|
||||
"""Scatter curve item for the scatter waveform widget."""
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ designer_plugins = {
|
||||
"bec_widgets.widgets.progress.scan_progressbar.scan_progressbar",
|
||||
"ScanProgressBar",
|
||||
),
|
||||
"ScanScheduler": ("bec_widgets.widgets.control.scan_scheduler.scan_scheduler", "ScanScheduler"),
|
||||
"ScatterWaveform": (
|
||||
"bec_widgets.widgets.plots.scatter_waveform.scatter_waveform",
|
||||
"ScatterWaveform",
|
||||
@@ -163,6 +164,7 @@ widget_icons = {
|
||||
"ScanIndexComboBox": "history",
|
||||
"ScanMetadata": "list_alt",
|
||||
"ScanProgressBar": "timelapse",
|
||||
"ScanScheduler": "account_tree",
|
||||
"ScatterWaveform": "scatter_plot",
|
||||
"SignalComboBox": "list_alt",
|
||||
"SignalLabel": "scoreboard",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Open a scheduler with an editable example using a running BEC deployment.
|
||||
|
||||
Run with ``python -m bec_widgets.examples.scan_scheduler``. Opening the example does
|
||||
not execute it; inspect the description before choosing Execute.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from bec_lib.scan_sequencer.components import ForEach, Repeat, ScanCall, Schedule, Sequence, Set
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.widgets.control.scan_scheduler import ScanScheduler
|
||||
|
||||
|
||||
def example_sequence() -> Schedule:
|
||||
"""Compose four time scans interleaved with alternating device moves.
|
||||
|
||||
Returns:
|
||||
Schedule: Editable example requiring samx and the advertised time_scan scan.
|
||||
"""
|
||||
return Schedule(
|
||||
id="alternating_positions",
|
||||
revision=1,
|
||||
root=Repeat(
|
||||
id="repeat",
|
||||
count=2,
|
||||
body=ForEach(
|
||||
id="positions",
|
||||
variable="position",
|
||||
values=[1, -1],
|
||||
body=Sequence(
|
||||
id="steps",
|
||||
children=[
|
||||
Set(id="move", device="samx", value={"ref": "position"}, wait=True),
|
||||
ScanCall(id="scan", name="time_scan", positional=[1, 0.1]),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Open the scheduler; execution requires an explicit Execute action."""
|
||||
app = QApplication.instance() or QApplication(sys.argv)
|
||||
widget = ScanScheduler()
|
||||
widget.setWindowTitle("BEC Scan Scheduler")
|
||||
widget.resize(1400, 1000)
|
||||
widget.load_json(example_sequence().model_dump_json())
|
||||
widget.show()
|
||||
app.exec()
|
||||
widget.cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -80,8 +80,23 @@ class ScanControl(BECWidget, QWidget):
|
||||
gui_id: str | None = None,
|
||||
allowed_scans: list | None = None,
|
||||
default_scan: str | None = None,
|
||||
scan_info: dict | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize scan controls from subscribed metadata or the available-scans endpoint.
|
||||
|
||||
Args:
|
||||
parent (QWidget | None): Owning widget.
|
||||
client (BECClient | None): Existing BEC client.
|
||||
config (ScanControlConfig | dict | None): Widget configuration.
|
||||
gui_id (str | None): RPC widget identity.
|
||||
allowed_scans (list | None): Optional scan-selection filter.
|
||||
default_scan (str | None): Initial selected scan.
|
||||
scan_info (dict | None): Already subscribed scan metadata, avoiding a Redis read
|
||||
during embedded editor construction. Defaults to reading the existing endpoint.
|
||||
**kwargs (object): Additional BECWidget arguments.
|
||||
"""
|
||||
self._available_scans_override = scan_info
|
||||
if config is None:
|
||||
config = ScanControlConfig(
|
||||
widget_class=self.__class__.__name__, allowed_scans=allowed_scans
|
||||
@@ -254,9 +269,11 @@ class ScanControl(BECWidget, QWidget):
|
||||
|
||||
def populate_scans(self):
|
||||
"""Populates the scan selection combo box with available scans from BEC session."""
|
||||
self.available_scans = self.client.connector.get(
|
||||
MessageEndpoints.available_scans()
|
||||
).resource
|
||||
self.available_scans = (
|
||||
self._available_scans_override
|
||||
if self._available_scans_override is not None
|
||||
else self.client.connector.get(MessageEndpoints.available_scans()).resource
|
||||
)
|
||||
self._update_scan_selector()
|
||||
|
||||
def _supported_scan_names(self) -> list[str]:
|
||||
@@ -768,7 +785,7 @@ class ScanControl(BECWidget, QWidget):
|
||||
hide(bool): Hide or show the metadata form.
|
||||
"""
|
||||
self._hide_metadata = hide
|
||||
self._metadata_form.setVisible(not hide)
|
||||
self._metadata_group.setVisible(not hide)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def hide_optional_metadata(self):
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Sequence composition and procedure submission widget."""
|
||||
|
||||
from .scan_scheduler import ScanScheduler
|
||||
|
||||
__all__ = ["ScanScheduler"]
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Editable sequence trees owned by the scheduler interface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from uuid import uuid4
|
||||
|
||||
from bec_lib.scan_sequencer.components import Schedule
|
||||
|
||||
|
||||
def component(kind: str) -> dict:
|
||||
"""Create an editable component with a unique identity.
|
||||
|
||||
Args:
|
||||
kind (str): Backend component discriminator.
|
||||
|
||||
Returns:
|
||||
dict: Component template. Scan and device targets require user selection.
|
||||
"""
|
||||
node = {"id": f"{kind}_{uuid4().hex[:12]}", "type": kind}
|
||||
if kind == "sequence":
|
||||
node["children"] = []
|
||||
elif kind in {"repeat", "for_each", "while"}:
|
||||
node["body"] = component("sequence")
|
||||
node.update(
|
||||
{
|
||||
"repeat": {"count": 1},
|
||||
"for_each": {"variable": "value", "values": []},
|
||||
"while": {"condition": False},
|
||||
}[kind]
|
||||
)
|
||||
elif kind == "set":
|
||||
node.update(device="", value=0, wait=True, bind_status=None)
|
||||
elif kind == "scan_call":
|
||||
node.update(name="", positional=[], arguments={})
|
||||
elif kind == "component_call":
|
||||
node.update(definition={"id": "", "version": 1}, arguments={})
|
||||
else:
|
||||
raise ValueError(f"Unknown component: {kind}")
|
||||
return node
|
||||
|
||||
|
||||
class SequenceDraft:
|
||||
"""Hold an editable tree independently of submitted procedure payloads.
|
||||
|
||||
Args:
|
||||
document (dict | None): Initial sequence document or a new empty sequence.
|
||||
"""
|
||||
|
||||
def __init__(self, document: dict | None = None) -> None:
|
||||
self.document = (
|
||||
deepcopy(document)
|
||||
if document is not None
|
||||
else {
|
||||
"schema_version": 1,
|
||||
"type": "sequence",
|
||||
"id": "new_sequence",
|
||||
"revision": 1,
|
||||
"root": component("sequence"),
|
||||
}
|
||||
)
|
||||
|
||||
def node(self, path: tuple) -> dict:
|
||||
"""Look up a component by its structural path.
|
||||
|
||||
Args:
|
||||
path (tuple): Keys and child indices relative to the root.
|
||||
|
||||
Returns:
|
||||
dict: Mutable component in this draft.
|
||||
"""
|
||||
value = self.document["root"]
|
||||
for key in path:
|
||||
value = value[key]
|
||||
return value
|
||||
|
||||
def replace(self, path: tuple, node: dict) -> None:
|
||||
"""Replace one component.
|
||||
|
||||
Args:
|
||||
path (tuple): Component path.
|
||||
node (dict): Replacement component.
|
||||
"""
|
||||
if not path:
|
||||
self.document["root"] = node
|
||||
else:
|
||||
parent = self.node(path[:-1])
|
||||
parent[path[-1]] = node
|
||||
|
||||
def add(self, path: tuple, kind: str) -> tuple:
|
||||
"""Append inside a sequence or loop; insert after a selected sequence child.
|
||||
|
||||
Args:
|
||||
path (tuple): Selected component path.
|
||||
kind (str): Component type to insert.
|
||||
|
||||
Returns:
|
||||
tuple: Inserted component's path.
|
||||
"""
|
||||
node = self.node(path)
|
||||
if "body" in node:
|
||||
if node["body"]["type"] != "sequence":
|
||||
wrapper = component("sequence")
|
||||
wrapper["children"] = [node["body"]]
|
||||
node["body"] = wrapper
|
||||
path += ("body",)
|
||||
node = node["body"]
|
||||
if node["type"] == "sequence":
|
||||
index = len(node["children"])
|
||||
node["children"].append(component(kind))
|
||||
return path + ("children", index)
|
||||
if len(path) >= 2 and path[-2] == "children":
|
||||
index = path[-1] + 1
|
||||
self.node(path[:-2])["children"].insert(index, component(kind))
|
||||
return path[:-1] + (index,)
|
||||
raise ValueError("Select a sequence, or wrap this step in a sequence first.")
|
||||
|
||||
def wrap(self, path: tuple, kind: str) -> None:
|
||||
"""Wrap the selected component in a sequence or loop.
|
||||
|
||||
Args:
|
||||
path (tuple): Selected component path.
|
||||
kind (str): Sequence, repeat, for_each or while.
|
||||
"""
|
||||
if kind not in {"sequence", "repeat", "for_each", "while"}:
|
||||
raise ValueError("Only a sequence or loop can wrap a step.")
|
||||
wrapper = component(kind)
|
||||
if kind == "sequence":
|
||||
wrapper["children"] = [self.node(path)]
|
||||
else:
|
||||
wrapper["body"] = self.node(path)
|
||||
self.replace(path, wrapper)
|
||||
|
||||
def remove(self, path: tuple) -> None:
|
||||
"""Remove a child, replacing a required root or body with an empty sequence.
|
||||
|
||||
Args:
|
||||
path (tuple): Selected component path.
|
||||
"""
|
||||
if len(path) >= 2 and path[-2] == "children":
|
||||
self.node(path[:-2])["children"].pop(path[-1])
|
||||
else:
|
||||
self.replace(path, component("sequence"))
|
||||
|
||||
def move(self, path: tuple, offset: int) -> tuple:
|
||||
"""Move a component amongst its sequence siblings.
|
||||
|
||||
Args:
|
||||
path (tuple): Selected child path.
|
||||
offset (int): Relative sibling position.
|
||||
|
||||
Returns:
|
||||
tuple: New component path.
|
||||
"""
|
||||
if len(path) < 2 or path[-2] != "children":
|
||||
return path
|
||||
children = self.node(path[:-2])["children"]
|
||||
target = path[-1] + offset
|
||||
if 0 <= target < len(children):
|
||||
children.insert(target, children.pop(path[-1]))
|
||||
return path[:-1] + (target,)
|
||||
return path
|
||||
|
||||
def validated(self) -> Schedule:
|
||||
"""Validate the document's structural schema.
|
||||
|
||||
Returns:
|
||||
Schedule: Typed document; semantic validation is performed by the sequencer.
|
||||
"""
|
||||
return Schedule.model_validate(self.document)
|
||||
|
||||
def variables(self, path: tuple) -> list[str]:
|
||||
"""Return visible loop variables, with inner bindings shadowing outer ones.
|
||||
|
||||
Args:
|
||||
path (tuple): Selected component path.
|
||||
|
||||
Returns:
|
||||
list[str]: Names visible inside enclosing for_each bodies.
|
||||
"""
|
||||
names = []
|
||||
for length, part in enumerate(path):
|
||||
if part == "body":
|
||||
ancestor = self.node(path[:length])
|
||||
if ancestor["type"] == "for_each":
|
||||
name = ancestor["variable"]
|
||||
if name in names:
|
||||
names.remove(name)
|
||||
names.append(name)
|
||||
return names
|
||||
@@ -0,0 +1,386 @@
|
||||
"""Component field editors with explicit literal and scoped-variable inputs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QSpinBox,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.widgets.control.scan_control.scan_info_adapter import ScanInfoAdapter
|
||||
|
||||
from .presentation import LABELS, Section
|
||||
from .scan_editor import ScanDefinitionEditor
|
||||
|
||||
# Qt signal descriptors are not understood by pylint.
|
||||
# pylint: disable=no-member
|
||||
|
||||
|
||||
class ValueInput(QWidget):
|
||||
"""Edit a JSON value or an explicit loop-variable reference.
|
||||
|
||||
Args:
|
||||
value (object): Initial JSON value.
|
||||
variables (list[str]): Visible loop variable names.
|
||||
parent (QWidget | None): Owning widget.
|
||||
"""
|
||||
|
||||
changed = Signal()
|
||||
|
||||
def __init__(self, value: object, variables: list[str], parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.mode = QComboBox(self)
|
||||
self.mode.addItem("Value", None)
|
||||
for name in variables:
|
||||
self.mode.addItem(f"Variable: {name}", name)
|
||||
self.allow_encoding = True
|
||||
self.encoding = QComboBox(self)
|
||||
for label, kind in (
|
||||
("Text", "text"),
|
||||
("Number", "number"),
|
||||
("Boolean", "boolean"),
|
||||
("JSON", "json"),
|
||||
):
|
||||
self.encoding.addItem(label, kind)
|
||||
kind = (
|
||||
"text"
|
||||
if isinstance(value, str)
|
||||
else (
|
||||
"boolean"
|
||||
if isinstance(value, bool)
|
||||
else "number" if isinstance(value, (int, float)) else "json"
|
||||
)
|
||||
)
|
||||
self.encoding.setCurrentIndex(self.encoding.findData(kind))
|
||||
self.value_edit = QLineEdit(
|
||||
value if kind == "text" else json.dumps(value, ensure_ascii=False, allow_nan=False),
|
||||
self,
|
||||
)
|
||||
self.value_edit.setPlaceholderText('e.g. 1.0, "Circ+", [1, 2], {"type": "status_field", …}')
|
||||
layout.addWidget(self.mode)
|
||||
layout.addWidget(self.value_edit, 1)
|
||||
layout.addWidget(self.encoding)
|
||||
self.mode.currentIndexChanged.connect(self._mode_changed)
|
||||
self.mode.currentIndexChanged.connect(lambda *_: self.changed.emit())
|
||||
self.encoding.currentIndexChanged.connect(lambda *_: self.changed.emit())
|
||||
self.value_edit.editingFinished.connect(self.changed.emit)
|
||||
if isinstance(value, dict) and set(value) == {"ref"}:
|
||||
index = self.mode.findData(value["ref"])
|
||||
if index >= 0:
|
||||
self.mode.setCurrentIndex(index)
|
||||
|
||||
def _mode_changed(self) -> None:
|
||||
self.value_edit.setVisible(self.mode.currentData() is None)
|
||||
self.encoding.setVisible(self.mode.currentData() is None and self.allow_encoding)
|
||||
|
||||
def value(self) -> object:
|
||||
"""Read the explicit reference or parsed literal/expression.
|
||||
|
||||
Returns:
|
||||
object: JSON value for the backend.
|
||||
"""
|
||||
name = self.mode.currentData()
|
||||
if name is not None:
|
||||
return {"ref": name}
|
||||
text = self.value_edit.text()
|
||||
kind = self.encoding.currentData()
|
||||
if kind == "text":
|
||||
return text
|
||||
value = json.loads(text)
|
||||
if kind == "number" and (isinstance(value, bool) or not isinstance(value, (int, float))):
|
||||
raise ValueError("Enter a number, or change the input type.")
|
||||
if kind == "boolean" and not isinstance(value, bool):
|
||||
raise ValueError("Enter true or false.")
|
||||
return value
|
||||
|
||||
|
||||
class ValuesInput(QWidget):
|
||||
"""Edit ordered loop values as individual typed inputs.
|
||||
|
||||
Args:
|
||||
values (list): Initial loop values.
|
||||
variables (list[str]): Visible outer loop bindings.
|
||||
parent (QWidget | None): Owning widget.
|
||||
"""
|
||||
|
||||
changed = Signal()
|
||||
|
||||
def __init__(self, values: list, variables: list[str], parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.variables = variables
|
||||
self.rows = []
|
||||
self.rows_layout = QVBoxLayout(self)
|
||||
self.rows_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.add_button = QPushButton("+ Add value", self)
|
||||
self.add_button.clicked.connect(lambda: self._add(""))
|
||||
self.rows_layout.addWidget(self.add_button)
|
||||
for value in values:
|
||||
self._add(value)
|
||||
|
||||
def _add(self, value):
|
||||
row = QWidget(self)
|
||||
layout = QHBoxLayout(row)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
editor = ValueInput(value, self.variables, row)
|
||||
remove = QPushButton("×", row)
|
||||
remove.setFixedWidth(28)
|
||||
layout.addWidget(editor, 1)
|
||||
layout.addWidget(remove)
|
||||
self.rows_layout.insertWidget(len(self.rows), row)
|
||||
self.rows.append((row, editor))
|
||||
remove.clicked.connect(lambda: self._remove(row, editor))
|
||||
editor.changed.connect(self.changed.emit)
|
||||
self.changed.emit()
|
||||
|
||||
def _remove(self, row, editor):
|
||||
self.rows.remove((row, editor))
|
||||
self.rows_layout.removeWidget(row)
|
||||
row.deleteLater()
|
||||
self.changed.emit()
|
||||
|
||||
def value(self) -> list:
|
||||
"""Read loop values in displayed order.
|
||||
|
||||
Returns:
|
||||
list: JSON loop values.
|
||||
"""
|
||||
return [editor.value() for _, editor in self.rows]
|
||||
|
||||
|
||||
class ComponentInspector(QWidget):
|
||||
"""Show the backend's component documentation and editable fields.
|
||||
|
||||
Args:
|
||||
parent (QWidget | None): Owning widget.
|
||||
client (BECClient | None): Shared BEC client for the embedded ScanControl.
|
||||
"""
|
||||
|
||||
changed = Signal()
|
||||
|
||||
def __init__(self, parent=None, client=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.client = client
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setAlignment(Qt.AlignTop)
|
||||
self.breadcrumb = QLabel(self)
|
||||
self.help = QLabel(self)
|
||||
self.help.setWordWrap(True)
|
||||
self.scope = QLabel(self)
|
||||
self.scope.setWordWrap(True)
|
||||
self.breadcrumb.setObjectName("breadcrumb")
|
||||
self.help.setObjectName("stepHeading")
|
||||
self.scope.setObjectName("scopeChip")
|
||||
layout.addWidget(self.breadcrumb)
|
||||
layout.addWidget(self.help)
|
||||
layout.addWidget(self.scope)
|
||||
for label in (self.help, self.scope, self.breadcrumb):
|
||||
label.setTextFormat(Qt.PlainText)
|
||||
self.form = QFormLayout()
|
||||
self.form.setVerticalSpacing(14)
|
||||
self.form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
|
||||
layout.addLayout(self.form)
|
||||
self.metadata = QPlainTextEdit(self)
|
||||
self.metadata.setReadOnly(True)
|
||||
self.metadata.setMaximumHeight(200)
|
||||
self.metadata.setPlaceholderText("Advertised scan signature and documentation")
|
||||
self.documentation = Section("Component documentation", self.metadata, self)
|
||||
layout.addWidget(self.documentation)
|
||||
layout.addStretch()
|
||||
self.fields = {}
|
||||
self.node = {}
|
||||
self.scan_arguments = None
|
||||
self.raw_arguments = None
|
||||
|
||||
# The inspector receives independently owned editor metadata.
|
||||
# pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
def display(
|
||||
self,
|
||||
node: dict,
|
||||
schema: dict,
|
||||
variables: list[str],
|
||||
scans: dict,
|
||||
devices: list[str],
|
||||
breadcrumb: str,
|
||||
) -> None:
|
||||
"""Populate fields using component schema and subscribed scan metadata.
|
||||
|
||||
Args:
|
||||
node (dict): Selected component.
|
||||
schema (dict): Component JSON schema.
|
||||
variables (list[str]): Visible loop bindings.
|
||||
scans (dict): Advertised scan metadata.
|
||||
devices (list[str]): Available device names; dotted signal names remain editable.
|
||||
breadcrumb (str): Selected component's nesting path.
|
||||
"""
|
||||
self.cleanup()
|
||||
self.node = node
|
||||
self.scan_arguments = None
|
||||
self.raw_arguments = None
|
||||
self.fields = {}
|
||||
while self.form.rowCount():
|
||||
self.form.removeRow(0)
|
||||
self.breadcrumb.setText(breadcrumb)
|
||||
self.help.setText(LABELS[node["type"]])
|
||||
self.help.setToolTip(schema.get("description", "").split("Args:")[0].strip())
|
||||
self.scope.setText(
|
||||
"Available in this step " + " ".join(f"${name}" for name in variables)
|
||||
)
|
||||
self.scope.setVisible(bool(variables))
|
||||
advanced = QWidget(self)
|
||||
advanced_form = QFormLayout(advanced)
|
||||
advanced_form.setContentsMargins(0, 8, 0, 8)
|
||||
for name, field in schema["properties"].items():
|
||||
if name in {"type", "children", "body"}:
|
||||
continue
|
||||
value = node.get(name, field.get("default"))
|
||||
editor = self._field_editor(name, value, variables, scans, devices)
|
||||
editor.setToolTip(field.get("description", ""))
|
||||
self.fields[name] = editor
|
||||
label = {
|
||||
"id": "Step name",
|
||||
"name": "Scan type",
|
||||
"device": "Target",
|
||||
"value": "Set to",
|
||||
"variable": "Variable name",
|
||||
"count": "Repeat group",
|
||||
"wait": "",
|
||||
"bind_status": "Status name",
|
||||
}.get(name, name.replace("_", " ").capitalize())
|
||||
target_form = (
|
||||
advanced_form
|
||||
if name in {"bind_status", "arguments", "positional"}
|
||||
or (name == "name" and node["type"] == "scan_call")
|
||||
else self.form
|
||||
)
|
||||
target_form.addRow(label, editor)
|
||||
self.documentation.setVisible(node["type"] == "scan_call")
|
||||
if node["type"] == "scan_call":
|
||||
self._scan_editor(node, variables, scans, advanced_form)
|
||||
self.form.addRow(Section("Advanced settings", advanced, self))
|
||||
for editor in self.fields.values():
|
||||
self._connect_editor(editor)
|
||||
if self.scan_arguments is not None:
|
||||
self.scan_arguments.changed.connect(self.changed.emit)
|
||||
|
||||
def _field_editor(self, name, value, variables, scans, devices):
|
||||
if name == "count":
|
||||
editor = QSpinBox(self)
|
||||
editor.setRange(1, 2_147_483_647)
|
||||
editor.setValue(value)
|
||||
editor.setSuffix(" times")
|
||||
elif name == "wait":
|
||||
editor = QCheckBox("Wait for completion", self)
|
||||
editor.setChecked(value)
|
||||
elif name in {"name", "device"}:
|
||||
editor = QComboBox(self)
|
||||
editor.setEditable(True)
|
||||
editor.addItems(sorted(scans) if name == "name" else devices)
|
||||
editor.setCurrentText(value or "")
|
||||
elif name in {"id", "variable", "bind_status"}:
|
||||
editor = QLineEdit(value or "", self)
|
||||
elif name == "values":
|
||||
editor = ValuesInput(value, variables, self)
|
||||
else:
|
||||
editor = ValueInput(value, variables, self)
|
||||
return editor
|
||||
|
||||
def _scan_editor(self, node, variables, scans, advanced_form):
|
||||
self.fields["name"].currentTextChanged.connect(
|
||||
lambda name: self._scan_metadata(scans.get(name, {}))
|
||||
)
|
||||
self._scan_metadata(scans.get(node["name"], {}))
|
||||
if (
|
||||
isinstance(node.get("arguments"), dict)
|
||||
and not any(key in node["arguments"] for key in ("ref", "param", "type"))
|
||||
and bool(scans)
|
||||
and (not node["name"] or node["name"] in scans)
|
||||
):
|
||||
self.scan_arguments = ScanDefinitionEditor(node, variables, scans, self.client, self)
|
||||
self.form.addRow(self.scan_arguments)
|
||||
for name in ("arguments", "positional"):
|
||||
self.fields[name].setToolTip(
|
||||
"Raw JSON inputs, including metadata and structured expressions."
|
||||
)
|
||||
self.raw_arguments = QCheckBox("Edit arguments as raw JSON", self)
|
||||
advanced_form.addRow(self.raw_arguments)
|
||||
self.raw_arguments.toggled.connect(self._argument_mode)
|
||||
self._argument_mode(False)
|
||||
self.fields["name"].currentTextChanged.connect(
|
||||
lambda _: self.raw_arguments.setChecked(True)
|
||||
)
|
||||
|
||||
def _connect_editor(self, editor):
|
||||
if isinstance(editor, (ValueInput, ValuesInput)):
|
||||
editor.changed.connect(self.changed.emit)
|
||||
elif isinstance(editor, QLineEdit):
|
||||
editor.editingFinished.connect(self.changed.emit)
|
||||
elif isinstance(editor, QComboBox):
|
||||
if editor.isEditable():
|
||||
editor.lineEdit().editingFinished.connect(self.changed.emit)
|
||||
editor.activated.connect(lambda *_: self.changed.emit())
|
||||
elif isinstance(editor, QSpinBox):
|
||||
editor.editingFinished.connect(self.changed.emit)
|
||||
elif isinstance(editor, QCheckBox):
|
||||
editor.toggled.connect(lambda *_: self.changed.emit())
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Release the embedded scan editor before changing the selected component."""
|
||||
if self.scan_arguments is not None:
|
||||
self.scan_arguments.cleanup()
|
||||
|
||||
def _scan_metadata(self, info: dict) -> None:
|
||||
lines = [info.get("doc", ""), "", "Advertised inputs:"]
|
||||
for entry in info.get("signature", []):
|
||||
annotation, _ = ScanInfoAdapter.parse_annotation(entry.get("annotation"))
|
||||
default = entry.get("default", "_empty")
|
||||
requirement = "required" if default == "_empty" else f"default: {default!r}"
|
||||
lines.append(f"{entry['name']} ({annotation}; {entry['kind']}; {requirement})")
|
||||
self.metadata.setPlainText("\n".join(lines))
|
||||
|
||||
def _argument_mode(self, raw: bool) -> None:
|
||||
self.scan_arguments.setEnabled(not raw)
|
||||
for name in ("name", "arguments", "positional"):
|
||||
self.fields[name].setEnabled(raw)
|
||||
|
||||
def values(self) -> dict:
|
||||
"""Read component fields without modifying nested children.
|
||||
|
||||
Returns:
|
||||
dict: Edited component preserving its body or children.
|
||||
"""
|
||||
node = self.node.copy()
|
||||
for name, editor in self.fields.items():
|
||||
if (
|
||||
name in {"name", "arguments", "positional"}
|
||||
and self.scan_arguments is not None
|
||||
and not self.raw_arguments.isChecked()
|
||||
):
|
||||
continue
|
||||
if isinstance(editor, (ValueInput, ValuesInput)):
|
||||
value = editor.value()
|
||||
elif isinstance(editor, QComboBox):
|
||||
value = editor.currentText()
|
||||
elif isinstance(editor, QSpinBox):
|
||||
value = editor.value()
|
||||
elif isinstance(editor, QCheckBox):
|
||||
value = editor.isChecked()
|
||||
else:
|
||||
value = editor.text()
|
||||
node[name] = None if name == "bind_status" and not value else value
|
||||
if self.scan_arguments is not None and not self.raw_arguments.isChecked():
|
||||
node["name"], node["positional"], node["arguments"] = self.scan_arguments.values()
|
||||
return node
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Compact step cards and collapsible inspector sections."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import QEvent, QRect, QSize, Qt
|
||||
from qtpy.QtGui import QColor, QFont, QPainter, QPen
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QStyle,
|
||||
QStyledItemDelegate,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.colors import theme_color
|
||||
|
||||
# Qt signal descriptors are not understood by pylint.
|
||||
# pylint: disable=no-member
|
||||
|
||||
LABELS = {
|
||||
"sequence": "Sequence group",
|
||||
"repeat": "Repeat group",
|
||||
"for_each": "For each value",
|
||||
"while": "While condition",
|
||||
"set": "Set device or signal",
|
||||
"scan_call": "Scan",
|
||||
"component_call": "Reusable component",
|
||||
}
|
||||
|
||||
|
||||
def step_title(node: dict) -> str:
|
||||
"""Return the supplied step name or a readable name for a generated identity.
|
||||
|
||||
Args:
|
||||
node (dict): Sequence component.
|
||||
|
||||
Returns:
|
||||
str: Human-readable tree title.
|
||||
"""
|
||||
identity = node["id"]
|
||||
if identity.startswith(node["type"] + "_"):
|
||||
if node["type"] == "scan_call" and node.get("name"):
|
||||
return node["name"].replace("_", " ").capitalize()
|
||||
return LABELS[node["type"]]
|
||||
return identity.replace("_", " ").capitalize()
|
||||
|
||||
|
||||
class StepDelegate(QStyledItemDelegate):
|
||||
"""Paint two-line nested step cards using the application's palette."""
|
||||
|
||||
def sizeHint(self, _option, _index) -> QSize:
|
||||
"""Reserve a compact card row.
|
||||
|
||||
Args:
|
||||
_option (QStyleOptionViewItem): Item style options.
|
||||
_index (QModelIndex): Tree item index.
|
||||
|
||||
Returns:
|
||||
QSize: Card dimensions.
|
||||
"""
|
||||
return QSize(240, 66)
|
||||
|
||||
def paint(self, painter, option, index) -> None:
|
||||
"""Render a step title and its execution behavior.
|
||||
|
||||
Args:
|
||||
painter (QPainter): Active Qt painter.
|
||||
option (QStyleOptionViewItem): Item style options.
|
||||
index (QModelIndex): Tree item index.
|
||||
"""
|
||||
painter.save()
|
||||
rect = option.rect.adjusted(2, 3, -8, -3)
|
||||
selected = bool(option.state & QStyle.State_Selected)
|
||||
palette = QApplication.palette()
|
||||
theme = getattr(QApplication.instance(), "theme", None)
|
||||
accent = theme_color(theme, "PRIMARY", palette.highlight().color())
|
||||
background = theme_color(theme, "CARD_BG", palette.window().color())
|
||||
foreground = theme_color(theme, "FG", palette.text().color())
|
||||
border = theme_color(theme, "BORDER", palette.midlight().color())
|
||||
if selected:
|
||||
background = QColor(
|
||||
int(background.red() * 0.9 + accent.red() * 0.1),
|
||||
int(background.green() * 0.9 + accent.green() * 0.1),
|
||||
int(background.blue() * 0.9 + accent.blue() * 0.1),
|
||||
)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
painter.setPen(QPen(accent if selected else border, 1))
|
||||
painter.setBrush(background)
|
||||
painter.drawRoundedRect(rect, 5, 5)
|
||||
title = QFont(option.font)
|
||||
title.setBold(True)
|
||||
painter.setFont(title)
|
||||
painter.setPen(foreground)
|
||||
painter.drawText(
|
||||
rect.adjusted(30, 8, -32, -28),
|
||||
Qt.AlignVCenter,
|
||||
option.fontMetrics.elidedText(str(index.data()), Qt.ElideRight, rect.width() - 62),
|
||||
)
|
||||
if index.data(Qt.DecorationRole) is not None:
|
||||
index.data(Qt.DecorationRole).paint(
|
||||
painter, QRect(rect.left() + 10, rect.top() + 11, 14, 14)
|
||||
)
|
||||
painter.drawText(rect.adjusted(rect.width() - 26, 5, -6, -30), Qt.AlignCenter, "⋯")
|
||||
painter.setFont(option.font)
|
||||
muted = QColor(foreground)
|
||||
muted.setAlpha(160)
|
||||
painter.setPen(muted)
|
||||
subtitle = str(index.data(Qt.UserRole + 1) or "")
|
||||
painter.drawText(
|
||||
rect.adjusted(12, 30, -12, -7),
|
||||
Qt.AlignVCenter,
|
||||
option.fontMetrics.elidedText(subtitle, Qt.ElideRight, rect.width() - 24),
|
||||
)
|
||||
painter.restore()
|
||||
|
||||
def editorEvent(self, event, model, option, index) -> bool:
|
||||
"""Open step actions from the card's overflow control.
|
||||
|
||||
Args:
|
||||
event (QEvent): Item input event.
|
||||
model (QAbstractItemModel): Tree model.
|
||||
option (QStyleOptionViewItem): Item geometry.
|
||||
index (QModelIndex): Target item.
|
||||
|
||||
Returns:
|
||||
bool: Whether the overflow click was handled.
|
||||
"""
|
||||
if (
|
||||
event.type() == QEvent.MouseButtonRelease
|
||||
and event.button() == Qt.LeftButton
|
||||
and event.pos().x() > option.rect.right() - 35
|
||||
):
|
||||
self.parent().customContextMenuRequested.emit(event.pos())
|
||||
return True
|
||||
return super().editorEvent(event, model, option, index)
|
||||
|
||||
|
||||
class Section(QWidget):
|
||||
"""Hold optional inspector controls behind a disclosure button.
|
||||
|
||||
Args:
|
||||
title (str): Section label.
|
||||
content (QWidget): Controls to show when expanded.
|
||||
parent (QWidget | None): Owning widget.
|
||||
"""
|
||||
|
||||
def __init__(self, title: str, content: QWidget, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.toggle = QToolButton(self)
|
||||
self.toggle.setText(title)
|
||||
self.toggle.setCheckable(True)
|
||||
self.toggle.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
|
||||
self.toggle.setArrowType(Qt.RightArrow)
|
||||
content.setVisible(False)
|
||||
layout.addWidget(self.toggle)
|
||||
layout.addWidget(content)
|
||||
self.toggle.toggled.connect(content.setVisible)
|
||||
self.toggle.toggled.connect(
|
||||
lambda checked: self.toggle.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from bec_widgets.widgets.control.scan_scheduler.scan_scheduler_plugin import ScanSchedulerPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(ScanSchedulerPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,358 @@
|
||||
"""Embed ScanControl, adding scoped references and preserving JSON calling semantics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from copy import deepcopy
|
||||
|
||||
from qtpy.QtCore import QTimer, Signal
|
||||
from qtpy.QtGui import QValidator
|
||||
from qtpy.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QCompleter,
|
||||
QDoubleSpinBox,
|
||||
QLineEdit,
|
||||
QSpinBox,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
|
||||
from bec_widgets.widgets.control.scan_control import ScanControl
|
||||
from bec_widgets.widgets.control.scan_control.scan_group_box import ScanLiteralsComboBox
|
||||
|
||||
# Qt signal descriptors are not understood by pylint.
|
||||
# pylint: disable=no-member
|
||||
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
class ScanInputValidator(QValidator):
|
||||
"""Permit visible variables or literals accepted by the native scan input.
|
||||
|
||||
Args:
|
||||
widget (QWidget): Native input supplying type, bounds and literal validation.
|
||||
variables (list[str]): Loop variables visible at this component.
|
||||
parent (QWidget): Owning input.
|
||||
"""
|
||||
|
||||
def __init__(self, widget: QWidget, variables: list[str], parent: QWidget) -> None:
|
||||
super().__init__(parent)
|
||||
self.widget = widget
|
||||
self.variables = {f"${name}" for name in variables}
|
||||
|
||||
def validate(self, text: str, position: int) -> tuple:
|
||||
"""Validate a scoped variable or defer to the scan's native literal rules.
|
||||
|
||||
Args:
|
||||
text (str): Current input text.
|
||||
position (int): Cursor position.
|
||||
|
||||
Returns:
|
||||
tuple: Qt validation state, unchanged text and cursor position.
|
||||
"""
|
||||
if text in self.variables:
|
||||
return QValidator.State.Acceptable, text, position
|
||||
if text.startswith("$"):
|
||||
state = (
|
||||
QValidator.State.Intermediate
|
||||
if any(name.startswith(text) for name in self.variables)
|
||||
else QValidator.State.Invalid
|
||||
)
|
||||
return state, text, position
|
||||
if isinstance(self.widget, (QSpinBox, QDoubleSpinBox)):
|
||||
return self.widget.validate(text, position)
|
||||
if isinstance(self.widget, QCheckBox):
|
||||
valid = text.lower() in {"true", "false"}
|
||||
elif isinstance(self.widget, DeviceComboBox):
|
||||
valid = self.widget.validate_device(text)
|
||||
elif isinstance(self.widget, ScanLiteralsComboBox):
|
||||
valid = self.widget.findText(text) >= 0
|
||||
else:
|
||||
validator = self.widget.validator() if isinstance(self.widget, QLineEdit) else None
|
||||
if validator is not None:
|
||||
return validator.validate(text, position)
|
||||
valid = True
|
||||
return (
|
||||
(QValidator.State.Acceptable if valid else QValidator.State.Intermediate),
|
||||
text,
|
||||
position,
|
||||
)
|
||||
|
||||
|
||||
class BoundScanInput(QLineEdit):
|
||||
"""Edit a native scan value or a visible ``$variable`` in one input.
|
||||
|
||||
The validator keeps the native input's literal rules and additionally permits scoped
|
||||
loop variables. Variables are serialized as JSON references, never as Python expressions.
|
||||
|
||||
Args:
|
||||
widget (QWidget): Native scan input supplying literal conversion and validation.
|
||||
value (object): Loaded JSON value or a missing-value sentinel.
|
||||
variables (list[str]): Visible loop bindings.
|
||||
parent (QWidget | None): Owning scan-input tile.
|
||||
"""
|
||||
|
||||
changed = Signal()
|
||||
|
||||
def __init__(self, widget: QWidget, value: object, variables: list[str], parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.widget = widget
|
||||
self.variables = variables
|
||||
self.setValidator(ScanInputValidator(widget, variables, self))
|
||||
choices = [f"${name}" for name in variables]
|
||||
if isinstance(widget, QComboBox):
|
||||
choices += [widget.itemText(index) for index in range(widget.count())]
|
||||
if choices:
|
||||
self.setCompleter(QCompleter(choices, self))
|
||||
self.setToolTip(
|
||||
widget.toolTip()
|
||||
+ (
|
||||
"\nAvailable variables: " + ", ".join(f"${name}" for name in variables)
|
||||
if variables
|
||||
else ""
|
||||
)
|
||||
)
|
||||
if isinstance(value, dict):
|
||||
self.setText(f"${value['ref']}" if set(value) == {"ref"} else json.dumps(value))
|
||||
else:
|
||||
if value is not _MISSING:
|
||||
if isinstance(widget, ScanLiteralsComboBox):
|
||||
widget.setCurrentText(str(value) if value is not None else "")
|
||||
else:
|
||||
WidgetIO.set_value(widget, value)
|
||||
self.load_native_value()
|
||||
widget.hide()
|
||||
self.textEdited.connect(lambda *_: self.changed.emit())
|
||||
self.editingFinished.connect(self.changed.emit)
|
||||
|
||||
def load_native_value(self) -> None:
|
||||
"""Display a literal restored by ScanControl."""
|
||||
if isinstance(self.widget, (QSpinBox, QDoubleSpinBox)):
|
||||
text = self.widget.text()
|
||||
elif isinstance(self.widget, QCheckBox):
|
||||
text = "true" if self.widget.isChecked() else "false"
|
||||
elif isinstance(self.widget, QComboBox):
|
||||
text = self.widget.currentText()
|
||||
else:
|
||||
text = self.widget.text()
|
||||
self.setText(text)
|
||||
|
||||
def value(self) -> object:
|
||||
"""Read a checked literal or scoped JSON reference.
|
||||
|
||||
Returns:
|
||||
object: JSON-compatible scan argument.
|
||||
|
||||
Raises:
|
||||
ValueError: Input is neither a valid literal nor an available variable.
|
||||
"""
|
||||
text = self.text()
|
||||
if text.startswith("$"):
|
||||
name = text[1:]
|
||||
if name not in self.variables:
|
||||
raise ValueError(f"Variable {text} is not available in this step")
|
||||
return {"ref": name}
|
||||
if not self.hasAcceptableInput():
|
||||
raise ValueError(f"Invalid {self.widget.arg_name}: {text}")
|
||||
if isinstance(self.widget, (QSpinBox, QDoubleSpinBox)):
|
||||
return self.widget.valueFromText(text)
|
||||
if isinstance(self.widget, QCheckBox):
|
||||
return text.lower() == "true"
|
||||
return text
|
||||
|
||||
|
||||
class ScanDefinitionEditor(QWidget):
|
||||
"""Use ScanControl for scan selection, argument groups and device bundles.
|
||||
|
||||
The scheduler permits scoped variables in the inputs and translates edits back to
|
||||
their original positional/keyword locations. Opening a node does not supply missing
|
||||
arguments or replace its JSON values with widget defaults.
|
||||
|
||||
Args:
|
||||
node (dict): Scan-call component.
|
||||
variables (list[str]): Visible loop variables.
|
||||
scan_info (dict): Already subscribed scan metadata.
|
||||
client (BECClient): Existing BEC client.
|
||||
parent (QWidget | None): Owning inspector.
|
||||
"""
|
||||
|
||||
changed = Signal()
|
||||
|
||||
def __init__(
|
||||
self, node: dict, variables: list[str], scan_info: dict, client, parent=None
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.original = deepcopy(node)
|
||||
self.variables = variables
|
||||
self.inputs = {}
|
||||
self.dirty = set()
|
||||
self._name_changed = False
|
||||
self._closed = False
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.control = ScanControl(
|
||||
parent=self, client=client, scan_info=scan_info, default_scan=node.get("name") or None
|
||||
)
|
||||
self.control.hide_scan_control_buttons = True
|
||||
self.control.scan_control_group.setEnabled(False)
|
||||
if self.control.arg_box is not None and node.get("positional"):
|
||||
defaults, _ = self.control.get_scan_parameters(bec_object=False)
|
||||
values = [
|
||||
defaults[index % len(defaults)] if isinstance(value, dict) else value
|
||||
for index, value in enumerate(node["positional"])
|
||||
]
|
||||
self.control.arg_box.set_parameters(values)
|
||||
# Metadata can contain expressions; it remains in the scheduler's advanced JSON editor.
|
||||
self.control.hide_metadata = True
|
||||
layout.addWidget(self.control)
|
||||
self.refresh_timer = QTimer(self)
|
||||
self.refresh_timer.setSingleShot(True)
|
||||
self.refresh_timer.timeout.connect(self._refresh_inputs)
|
||||
self._refresh_inputs()
|
||||
self.control.scan_selected.connect(self._selected)
|
||||
self.control.comboBox_scan_selection.activated.connect(self._confirmed)
|
||||
# ScanControl applies this result first; the wrapper then refreshes replaced inputs.
|
||||
# pylint: disable=protected-access
|
||||
self.control._last_scan_parameters_received.connect(self._restored)
|
||||
|
||||
def _restored(self, generation: int, name: str, parameters: tuple[list, dict]) -> None:
|
||||
# Match ScanControl's stale-response guard before reading the restored inputs.
|
||||
# pylint: disable=protected-access
|
||||
if self._closed or generation != self.control._last_scan_fetch_generation:
|
||||
return
|
||||
if name != self.control.current_scan:
|
||||
return
|
||||
positional, keywords = parameters
|
||||
self.original = {
|
||||
"name": name,
|
||||
"positional": deepcopy(positional),
|
||||
"arguments": deepcopy(keywords),
|
||||
}
|
||||
self._name_changed = False
|
||||
self.dirty.clear()
|
||||
for binding in self.inputs.values():
|
||||
binding.load_native_value()
|
||||
self.refresh_timer.start(0)
|
||||
self.changed.emit()
|
||||
|
||||
def _positional_names(self) -> list[str]:
|
||||
signature = self.control.available_scans.get(self.control.current_scan, {}).get(
|
||||
"signature", []
|
||||
)
|
||||
return [
|
||||
entry["name"]
|
||||
for entry in signature
|
||||
if entry["kind"] in {"POSITIONAL_ONLY", "POSITIONAL_OR_KEYWORD"}
|
||||
]
|
||||
|
||||
def _selected(self, _name):
|
||||
self._name_changed = True
|
||||
self.original = {"name": self.control.current_scan, "positional": [], "arguments": {}}
|
||||
self.inputs.clear()
|
||||
self.dirty.clear()
|
||||
# Native ScanControl restores its inputs after emitting scan_selected.
|
||||
self.refresh_timer.start(0)
|
||||
self.changed.emit()
|
||||
|
||||
def _confirmed(self, _index):
|
||||
if not self.original.get("name"):
|
||||
self._name_changed = True
|
||||
self.changed.emit()
|
||||
|
||||
def _input_changed(self, path):
|
||||
if not self.original.get("name"):
|
||||
self._name_changed = True
|
||||
self.dirty.add(path)
|
||||
self.changed.emit()
|
||||
|
||||
def _refresh_inputs(self):
|
||||
if self._closed:
|
||||
return
|
||||
current = {}
|
||||
if self.control.arg_box is not None:
|
||||
for index, widget in enumerate(self.control.arg_box.widgets):
|
||||
current[("positional", index)] = widget
|
||||
box = self.control.arg_box
|
||||
if not box.property("sequence_bindings_connected"):
|
||||
box.setProperty("sequence_bindings_connected", True)
|
||||
for button in (box.button_add_bundle, box.button_remove_bundle):
|
||||
button.clicked.connect(self._bundles_changed)
|
||||
for box in self.control.kwarg_boxes:
|
||||
for widget in box.widgets:
|
||||
name = widget.arg_name
|
||||
positional_names = self._positional_names()
|
||||
path = (
|
||||
("positional", positional_names.index(name))
|
||||
if (
|
||||
self.control.arg_box is None
|
||||
and name in positional_names
|
||||
and positional_names.index(name) < len(self.original.get("positional", []))
|
||||
)
|
||||
else ("arguments", name)
|
||||
)
|
||||
current[path] = widget
|
||||
existing = {id(binding.widget): binding for binding in self.inputs.values()}
|
||||
self.inputs = {}
|
||||
for path, widget in current.items():
|
||||
if id(widget) in existing:
|
||||
binding = existing[id(widget)]
|
||||
binding.changed.disconnect()
|
||||
binding.changed.connect(lambda path=path: self._input_changed(path))
|
||||
self.inputs[path] = binding
|
||||
continue
|
||||
values = self.original.get(path[0], {} if path[0] == "arguments" else [])
|
||||
value = (
|
||||
values.get(path[1], _MISSING)
|
||||
if isinstance(values, dict)
|
||||
else (values[path[1]] if path[1] < len(values) else _MISSING)
|
||||
)
|
||||
tile = widget.parentWidget()
|
||||
tile.layout().removeWidget(widget)
|
||||
binding = BoundScanInput(widget, value, self.variables, tile)
|
||||
tile.layout().addWidget(binding)
|
||||
binding.changed.connect(lambda path=path: self._input_changed(path))
|
||||
self.inputs[path] = binding
|
||||
self.changed.emit()
|
||||
|
||||
def _bundles_changed(self, *_):
|
||||
self._name_changed = True
|
||||
self.refresh_timer.start(0)
|
||||
|
||||
def values(self) -> tuple[str, list, dict]:
|
||||
"""Read native edits while retaining untouched arguments and positional calls.
|
||||
|
||||
Returns:
|
||||
tuple[str, list, dict]: Scan name, positional arguments and keyword arguments.
|
||||
"""
|
||||
if self.refresh_timer.isActive():
|
||||
self.refresh_timer.stop()
|
||||
self._refresh_inputs()
|
||||
name = self.control.current_scan if self._name_changed else self.original.get("name", "")
|
||||
positional = deepcopy(self.original.get("positional", []))
|
||||
keywords = deepcopy(self.original.get("arguments", {}))
|
||||
if self._name_changed:
|
||||
positional, defaults = self.control.get_scan_parameters(bec_object=False)
|
||||
keywords.update({key: value for key, value in defaults.items() if key != "metadata"})
|
||||
for path, binding in self.inputs.items():
|
||||
if path not in self.dirty and not self._name_changed:
|
||||
continue
|
||||
value = binding.value()
|
||||
if path[0] == "arguments":
|
||||
keywords[path[1]] = value
|
||||
else:
|
||||
if path[1] >= len(positional):
|
||||
native, _ = self.control.get_scan_parameters(bec_object=False)
|
||||
positional.extend(native[len(positional) :])
|
||||
positional[path[1]] = value
|
||||
return name, positional, keywords
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Release embedded ScanControl resources and pending input refreshes."""
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
self.refresh_timer.stop()
|
||||
self.control.cleanup()
|
||||
@@ -0,0 +1,570 @@
|
||||
"""Compose JSON sequences, inspect exports and submit through BEC procedures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
# Qt signal descriptors are not understood by pylint.
|
||||
# pylint: disable=no-member
|
||||
from pathlib import Path
|
||||
|
||||
from bec_lib.scan_sequencer import (
|
||||
BECAdapter,
|
||||
DefinitionLibrary,
|
||||
ScanCatalog,
|
||||
Schedule,
|
||||
Sequencer,
|
||||
available_components,
|
||||
)
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import QSignalBlocker, Qt, QTimer
|
||||
from qtpy.QtWidgets import (
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFileDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMenu,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSplitter,
|
||||
QTabWidget,
|
||||
QTreeWidget,
|
||||
QTreeWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets import BECWidget, SafeSlot
|
||||
|
||||
from .document import SequenceDraft
|
||||
from .inspector import ComponentInspector
|
||||
from .presentation import LABELS, StepDelegate, step_title
|
||||
|
||||
|
||||
class ScanScheduler(BECWidget, QWidget):
|
||||
"""Edit nested components and execute validated sequences through procedures.
|
||||
|
||||
Description, Python and JSON representations share the backend's resolver. Execution
|
||||
uses the existing run_sequence procedure without keeping submission records.
|
||||
|
||||
Args:
|
||||
parent (QWidget | None): Owning widget.
|
||||
**kwargs (object): BECWidget connection and configuration arguments.
|
||||
"""
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "account_tree"
|
||||
USER_ACCESS = ["load_json", "document_json", "validate_sequence", "execute_sequence"]
|
||||
|
||||
def __init__(self, parent=None, **kwargs) -> None:
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.draft = SequenceDraft()
|
||||
self.schema = available_components()
|
||||
self.catalog = ScanCatalog.from_client(self.client)
|
||||
self._path = ()
|
||||
self._busy = False
|
||||
self._closed = False
|
||||
self._result = {}
|
||||
self.exports = None
|
||||
self.description = None
|
||||
self.python = None
|
||||
self.json = None
|
||||
self.definitions = None
|
||||
self.title = None
|
||||
self.tree = None
|
||||
self.apply_json_button = None
|
||||
self._build_ui()
|
||||
self.edit_timer = QTimer(self)
|
||||
self.edit_timer.setSingleShot(True)
|
||||
self.edit_timer.setInterval(250)
|
||||
self.edit_timer.timeout.connect(self._apply)
|
||||
self.inspector.changed.connect(self.edit_timer.start)
|
||||
self.title.editingFinished.connect(self.edit_timer.start)
|
||||
self.render_timer = QTimer(self)
|
||||
self.render_timer.setSingleShot(True)
|
||||
self.render_timer.setInterval(250)
|
||||
self.render_timer.timeout.connect(self.validate_sequence)
|
||||
self._rebuild_tree()
|
||||
|
||||
def _button(self, text, slot, layout):
|
||||
button = QPushButton(text, self)
|
||||
button.clicked.connect(slot)
|
||||
layout.addWidget(button)
|
||||
return button
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
self.setObjectName("scanScheduler")
|
||||
self.setStyleSheet("""
|
||||
QWidget#scanScheduler { background: palette(base); }
|
||||
QWidget#sequenceOutline { background: palette(alternate-base); border-radius: 5px; }
|
||||
QLabel#sectionHeading, QLabel#brand { font-weight: 600; }
|
||||
QLabel#stepHeading { font-size: 16px; font-weight: 600; margin-top: 12px; }
|
||||
QLabel#breadcrumb { color: palette(placeholder-text); font-size: 11px; }
|
||||
QLabel#scopeChip { background: palette(alternate-base); padding: 8px; border-radius: 4px; }
|
||||
QTreeWidget { background: transparent; border: none; }
|
||||
QLineEdit, QComboBox, QSpinBox { min-height: 24px; }
|
||||
QLineEdit { padding-left: 7px; }
|
||||
QPushButton#primary { background: palette(highlight); color: palette(highlighted-text); padding: 7px 16px; }
|
||||
QTabWidget::pane { border: 1px solid palette(midlight); }
|
||||
QTabBar { qproperty-expanding: false; }
|
||||
QTabBar::tab { min-width: 80px; padding: 8px 12px; margin-right: 5px; }
|
||||
QTreeWidget::item:selected, QTreeWidget::branch:selected { background: transparent; }
|
||||
""")
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(16, 12, 16, 12)
|
||||
brand = QLabel("BEC Scan scheduler", self)
|
||||
brand.setObjectName("brand")
|
||||
layout.addWidget(brand)
|
||||
self.editor = QWidget(self)
|
||||
layout.addWidget(self.editor)
|
||||
editor_layout = QVBoxLayout(self.editor)
|
||||
editor_layout.setContentsMargins(0, 12, 0, 0)
|
||||
self._build_toolbar(editor_layout)
|
||||
splitter = QSplitter(self)
|
||||
splitter.setChildrenCollapsible(False)
|
||||
editor_layout.addWidget(splitter, 1)
|
||||
self._build_outline(splitter)
|
||||
scroll = QScrollArea(self)
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QScrollArea.NoFrame)
|
||||
self.inspector = ComponentInspector(self, client=self.client)
|
||||
scroll.setWidget(self.inspector)
|
||||
splitter.addWidget(scroll)
|
||||
splitter.setStretchFactor(0, 1)
|
||||
splitter.setStretchFactor(1, 2)
|
||||
splitter.setSizes([380, 760])
|
||||
self._build_exports(editor_layout)
|
||||
self.message = QLabel("Compose steps and execute the validated sequence.", self)
|
||||
self.message.setWordWrap(True)
|
||||
self.message.setTextFormat(Qt.PlainText)
|
||||
self.message.setObjectName("breadcrumb")
|
||||
layout.addWidget(self.message)
|
||||
|
||||
def _build_toolbar(self, editor_layout):
|
||||
toolbar = QHBoxLayout()
|
||||
self.title = QLineEdit(self.draft.document["id"], self)
|
||||
self.title.setPlaceholderText("Sequence name")
|
||||
toolbar.addWidget(self.title, 1)
|
||||
toolbar.addWidget(QLabel("Draft", self))
|
||||
options = QMenu(self)
|
||||
for text, slot in (
|
||||
("New sequence", self._new),
|
||||
("Open sequence…", self._open),
|
||||
("Save sequence…", self._save),
|
||||
("Reusable definitions…", self._definitions_dialog),
|
||||
):
|
||||
options.addAction(text, slot)
|
||||
more = QPushButton("Sequence options ⋯", self)
|
||||
more.setMenu(options)
|
||||
toolbar.addWidget(more)
|
||||
editor_layout.addLayout(toolbar)
|
||||
|
||||
def _build_outline(self, splitter):
|
||||
outline = QWidget(self)
|
||||
outline.setObjectName("sequenceOutline")
|
||||
outline_layout = QVBoxLayout(outline)
|
||||
outline_layout.setContentsMargins(10, 12, 6, 10)
|
||||
actions = QHBoxLayout()
|
||||
heading = QLabel("Sequence", self)
|
||||
heading.setObjectName("sectionHeading")
|
||||
actions.addWidget(heading)
|
||||
actions.addStretch()
|
||||
self.palette = QComboBox(self)
|
||||
self.palette.addItems(list(self.schema["discriminator"]["mapping"]))
|
||||
self.palette.hide()
|
||||
add_menu = QMenu(self)
|
||||
for kind in self.schema["discriminator"]["mapping"]:
|
||||
add_menu.addAction(
|
||||
LABELS[kind], lambda _checked=False, kind=kind: self._choose_add(kind)
|
||||
)
|
||||
add = QPushButton("+ Add step", self)
|
||||
add.setMenu(add_menu)
|
||||
actions.addWidget(add)
|
||||
outline_layout.addLayout(actions)
|
||||
self.tree = QTreeWidget(self)
|
||||
self.tree.setColumnCount(2)
|
||||
self.tree.setHeaderHidden(True)
|
||||
self.tree.setIndentation(14)
|
||||
self.tree.setItemDelegate(StepDelegate(self.tree))
|
||||
self.tree.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.tree.customContextMenuRequested.connect(self._step_menu)
|
||||
self.tree.currentItemChanged.connect(self._selected)
|
||||
outline_layout.addWidget(self.tree)
|
||||
hint = QLabel("Groups run in order. Repeats apply to their children.", self)
|
||||
hint.setWordWrap(True)
|
||||
hint.setObjectName("breadcrumb")
|
||||
outline_layout.addWidget(hint)
|
||||
splitter.addWidget(outline)
|
||||
|
||||
def _build_exports(self, editor_layout):
|
||||
self.exports = QTabWidget(self)
|
||||
self.exports.tabBar().setExpanding(False)
|
||||
self.exports.setMaximumHeight(210)
|
||||
self.description = self._text_tab(self.exports, "Description", readonly=True)
|
||||
self.python = self._text_tab(self.exports, "Python", readonly=True)
|
||||
self.json = self._text_tab(self.exports, "JSON", readonly=False)
|
||||
self.definitions = QPlainTextEdit(self)
|
||||
self.definitions.setPlainText("[]")
|
||||
self.definitions.hide()
|
||||
editor_layout.addWidget(self.exports)
|
||||
footer = QHBoxLayout()
|
||||
self.apply_json_button = self._button("Apply JSON edits", self._apply_json, footer)
|
||||
self.apply_json_button.hide()
|
||||
self.exports.currentChanged.connect(
|
||||
lambda index: self.apply_json_button.setVisible(index == 2)
|
||||
)
|
||||
footer.addStretch()
|
||||
execute = self._button("Execute", self.execute_sequence, footer)
|
||||
execute.setObjectName("primary")
|
||||
editor_layout.addLayout(footer)
|
||||
|
||||
def _choose_add(self, kind):
|
||||
self.palette.setCurrentText(kind)
|
||||
self._add()
|
||||
|
||||
@SafeSlot(object)
|
||||
def _step_menu(self, point):
|
||||
item = self.tree.itemAt(point)
|
||||
if item is None:
|
||||
return
|
||||
self.tree.setCurrentItem(item)
|
||||
menu = QMenu(self)
|
||||
add = menu.addMenu("Add step")
|
||||
for kind in self.schema["discriminator"]["mapping"]:
|
||||
add.addAction(LABELS[kind], lambda _checked=False, kind=kind: self._choose_add(kind))
|
||||
wrap = menu.addMenu("Wrap in")
|
||||
for kind in ("sequence", "repeat", "for_each", "while"):
|
||||
|
||||
def wrap_step(_checked=False, kind=kind):
|
||||
self.palette.setCurrentText(kind)
|
||||
self._wrap()
|
||||
|
||||
wrap.addAction(LABELS[kind], wrap_step)
|
||||
menu.addSeparator()
|
||||
menu.addAction("Move up", lambda: self._move(-1))
|
||||
menu.addAction("Move down", lambda: self._move(1))
|
||||
menu.addAction("Remove step", self._remove)
|
||||
menu.exec(self.tree.viewport().mapToGlobal(point))
|
||||
|
||||
@SafeSlot()
|
||||
def _definitions_dialog(self):
|
||||
dialog = QDialog(self)
|
||||
dialog.setWindowTitle("Reusable definitions")
|
||||
dialog.resize(700, 500)
|
||||
layout = QVBoxLayout(dialog)
|
||||
layout.addWidget(QLabel("Versioned component definitions (JSON list)", dialog))
|
||||
text = QPlainTextEdit(self.definitions.toPlainText(), dialog)
|
||||
layout.addWidget(text)
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, dialog)
|
||||
buttons.accepted.connect(dialog.accept)
|
||||
buttons.rejected.connect(dialog.reject)
|
||||
layout.addWidget(buttons)
|
||||
if dialog.exec() == QDialog.Accepted:
|
||||
self.definitions.setPlainText(text.toPlainText())
|
||||
self._sync_json()
|
||||
dialog.deleteLater()
|
||||
|
||||
def _text_tab(self, tabs, title, readonly):
|
||||
text = QPlainTextEdit(self)
|
||||
text.setReadOnly(readonly)
|
||||
tabs.addTab(text, title)
|
||||
return text
|
||||
|
||||
def _schema(self, kind):
|
||||
ref = self.schema["discriminator"]["mapping"][kind].rsplit("/", 1)[-1]
|
||||
return self.schema["$defs"][ref]
|
||||
|
||||
def _show_inspector(self):
|
||||
node = self.draft.node(self._path)
|
||||
self.inspector.display(
|
||||
node,
|
||||
self._schema(node["type"]),
|
||||
self.draft.variables(self._path),
|
||||
self.catalog.entries,
|
||||
sorted(self.client.device_manager.devices),
|
||||
self._breadcrumb(),
|
||||
)
|
||||
|
||||
def _breadcrumb(self):
|
||||
titles = [self.title.text().replace("_", " ")]
|
||||
for length in range(len(self._path) + 1):
|
||||
node = self.draft.node(self._path[:length])
|
||||
if isinstance(node, dict) and node.get("type") != "sequence":
|
||||
titles.append(step_title(node))
|
||||
return " / ".join(titles)
|
||||
|
||||
def _rebuild_tree(self, selected=()):
|
||||
if hasattr(self, "edit_timer"):
|
||||
self.edit_timer.stop()
|
||||
blocker = QSignalBlocker(self.tree)
|
||||
self.tree.clear()
|
||||
target = None
|
||||
|
||||
def add(node, path, parent):
|
||||
nonlocal target
|
||||
kind = node["type"]
|
||||
detail = {
|
||||
"repeat": f"Repeat whole group ×{node.get('count')}",
|
||||
"for_each": f"For each ${node.get('variable')} · {len(node.get('values', []))} values",
|
||||
"set": f"Set {node.get('device')}"
|
||||
+ (" and wait" if node.get("wait") else " asynchronously"),
|
||||
"scan_call": node.get("name", "").replace("_", " "),
|
||||
"while": "Check before each iteration",
|
||||
"sequence": "Children execute in order",
|
||||
"component_call": str(node.get("definition", "")),
|
||||
}[kind]
|
||||
item = QTreeWidgetItem(parent, [step_title(node), detail])
|
||||
item.setIcon(
|
||||
0,
|
||||
material_icon(
|
||||
{
|
||||
"sequence": "account_tree",
|
||||
"repeat": "repeat",
|
||||
"for_each": "format_list_bulleted",
|
||||
"while": "sync",
|
||||
"set": "tune",
|
||||
"scan_call": "filter_center_focus",
|
||||
"component_call": "extension",
|
||||
}[kind]
|
||||
),
|
||||
)
|
||||
item.setData(0, Qt.UserRole + 1, detail)
|
||||
item.setData(0, Qt.UserRole, path)
|
||||
if path == selected:
|
||||
target = item
|
||||
for index, child in enumerate(node.get("children", [])):
|
||||
add(child, path + ("children", index), item)
|
||||
if "body" in node:
|
||||
body = node["body"]
|
||||
if body["type"] == "sequence":
|
||||
for index, child in enumerate(body["children"]):
|
||||
add(child, path + ("body", "children", index), item)
|
||||
else:
|
||||
add(body, path + ("body",), item)
|
||||
|
||||
root = self.draft.document["root"]
|
||||
if root["type"] == "sequence" and root["children"]:
|
||||
for index, child in enumerate(root["children"]):
|
||||
add(child, ("children", index), self.tree)
|
||||
else:
|
||||
add(root, (), self.tree)
|
||||
self.tree.expandAll()
|
||||
self.tree.setCurrentItem(target or self.tree.topLevelItem(0))
|
||||
self._path = tuple(self.tree.currentItem().data(0, Qt.UserRole))
|
||||
del blocker
|
||||
self._show_inspector()
|
||||
self.tree.setColumnHidden(1, True)
|
||||
self.tree.header().setStretchLastSection(True)
|
||||
self._sync_json()
|
||||
|
||||
def _sync_json(self):
|
||||
self.json.setPlainText(json.dumps(self.draft.document, indent=2, ensure_ascii=False))
|
||||
self.description.clear()
|
||||
self.python.clear()
|
||||
self.message.setText("Sequence changed.")
|
||||
if hasattr(self, "render_timer"):
|
||||
self.render_timer.start()
|
||||
|
||||
def _commit(self):
|
||||
node = self.inspector.values()
|
||||
changed = (
|
||||
node != self.draft.node(self._path) or self.draft.document["id"] != self.title.text()
|
||||
)
|
||||
self.draft.replace(self._path, node)
|
||||
self.draft.document["id"] = self.title.text()
|
||||
return changed
|
||||
|
||||
@SafeSlot(object, object)
|
||||
def _selected(self, current, previous):
|
||||
if current is None:
|
||||
return
|
||||
try:
|
||||
changed = self._commit()
|
||||
except (ValueError, TypeError) as exc:
|
||||
blocker = QSignalBlocker(self.tree)
|
||||
self.tree.setCurrentItem(previous)
|
||||
del blocker
|
||||
self.message.setText(f"Step not applied: {exc}")
|
||||
return
|
||||
self._path = tuple(current.data(0, Qt.UserRole))
|
||||
self._show_inspector()
|
||||
if changed:
|
||||
self._sync_json()
|
||||
|
||||
@SafeSlot()
|
||||
def _apply(self):
|
||||
if not self._busy and not self._closed:
|
||||
self._edit(lambda: self._path)
|
||||
|
||||
def _edit(self, action):
|
||||
try:
|
||||
self._commit()
|
||||
selected = action()
|
||||
self._rebuild_tree(selected or ())
|
||||
except (ValueError, TypeError, KeyError) as exc:
|
||||
self.message.setText(str(exc))
|
||||
|
||||
@SafeSlot()
|
||||
def _add(self):
|
||||
self._edit(lambda: self.draft.add(self._path, self.palette.currentText()))
|
||||
|
||||
@SafeSlot()
|
||||
def _wrap(self):
|
||||
def wrap():
|
||||
self.draft.wrap(self._path, self.palette.currentText())
|
||||
return self._path
|
||||
|
||||
self._edit(wrap)
|
||||
|
||||
@SafeSlot()
|
||||
def _remove(self):
|
||||
self._edit(lambda: self.draft.remove(self._path))
|
||||
|
||||
def _move(self, offset):
|
||||
self._edit(lambda: self.draft.move(self._path, offset))
|
||||
|
||||
@SafeSlot()
|
||||
def _new(self):
|
||||
self.draft = SequenceDraft()
|
||||
self.title.setText(self.draft.document["id"])
|
||||
self._rebuild_tree()
|
||||
|
||||
def load_json(self, document: str, definitions: str = "[]") -> None:
|
||||
"""Load a structurally valid document and reusable definitions into the editor.
|
||||
|
||||
Args:
|
||||
document (str): Sequence document JSON.
|
||||
definitions (str): JSON list of reusable definitions. Defaults to an empty library.
|
||||
"""
|
||||
if self._busy:
|
||||
raise RuntimeError("Wait for the current validation or execution to finish.")
|
||||
schedule = Schedule.model_validate_json(document)
|
||||
DefinitionLibrary(json.loads(definitions))
|
||||
self.draft = SequenceDraft(schedule.model_dump(mode="json"))
|
||||
self.title.setText(schedule.id)
|
||||
self.definitions.setPlainText(definitions)
|
||||
self._rebuild_tree()
|
||||
|
||||
def document_json(self) -> str:
|
||||
"""Return the current edited document with structural schema validation.
|
||||
|
||||
Returns:
|
||||
str: Authoritative sequence JSON for saving or submission.
|
||||
"""
|
||||
self._commit()
|
||||
return self.draft.validated().model_dump_json(indent=2)
|
||||
|
||||
@SafeSlot()
|
||||
def _apply_json(self):
|
||||
try:
|
||||
self.load_json(self.json.toPlainText(), self.definitions.toPlainText())
|
||||
except (ValueError, TypeError) as exc:
|
||||
self.message.setText(str(exc))
|
||||
|
||||
@SafeSlot()
|
||||
def _open(self):
|
||||
filename, _ = QFileDialog.getOpenFileName(self, "Open sequence", filter="JSON (*.json)")
|
||||
if filename:
|
||||
try:
|
||||
self.load_json(
|
||||
Path(filename).read_text(encoding="utf-8"), self.definitions.toPlainText()
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
self.message.setText(str(exc))
|
||||
|
||||
@SafeSlot()
|
||||
def _save(self):
|
||||
filename, _ = QFileDialog.getSaveFileName(self, "Save sequence", filter="JSON (*.json)")
|
||||
if filename:
|
||||
try:
|
||||
Path(filename).write_text(self.document_json(), encoding="utf-8")
|
||||
except (OSError, ValueError) as exc:
|
||||
self.message.setText(str(exc))
|
||||
|
||||
@SafeSlot()
|
||||
def validate_sequence(self) -> None:
|
||||
"""Validate the entire draft and refresh all exports on a background task."""
|
||||
self._start(False)
|
||||
|
||||
@SafeSlot()
|
||||
def execute_sequence(self) -> None:
|
||||
"""Validate and execute the current sequence through the existing procedure API."""
|
||||
self._start(True)
|
||||
|
||||
def _start(self, execute):
|
||||
self.edit_timer.stop()
|
||||
self.render_timer.stop()
|
||||
if self._busy:
|
||||
return
|
||||
try:
|
||||
payload = self.document_json()
|
||||
definitions = json.loads(self.definitions.toPlainText())
|
||||
except (ValueError, TypeError) as exc:
|
||||
self.message.setText(str(exc))
|
||||
return
|
||||
self.description.clear()
|
||||
self.python.clear()
|
||||
self.json.setPlainText(payload)
|
||||
self._busy = True
|
||||
self.editor.setEnabled(False)
|
||||
self.message.setText("Validating and executing…" if execute else "Validating…")
|
||||
self._result = {}
|
||||
client = self.client
|
||||
result = self._result
|
||||
|
||||
def prepare():
|
||||
catalog = ScanCatalog.from_client(client)
|
||||
library = DefinitionLibrary(definitions)
|
||||
sequencer = Sequencer(catalog, library)
|
||||
sequencer.validate(payload, BECAdapter(client) if execute else None)
|
||||
description = sequencer.export(payload, format="description")
|
||||
python = sequencer.export(payload, format="python")
|
||||
_, closure = library.resolve(Schedule.model_validate_json(payload).root)
|
||||
result.update(description=description, python=python, payload=payload)
|
||||
if execute:
|
||||
client.proc.request_new(
|
||||
"run_sequence",
|
||||
((payload, [entry.model_dump(mode="json") for entry in closure]), {}),
|
||||
)
|
||||
result["executed"] = True
|
||||
|
||||
self.submit_task(prepare, on_complete=self._prepared, on_failed=self._failed)
|
||||
|
||||
@SafeSlot()
|
||||
def _prepared(self):
|
||||
if self._closed:
|
||||
return
|
||||
self._busy = False
|
||||
self.editor.setEnabled(True)
|
||||
self.description.setPlainText(self._result["description"])
|
||||
self.python.setPlainText(self._result["python"])
|
||||
self.json.setPlainText(self._result["payload"])
|
||||
self.message.setText(
|
||||
"Execution requested through run_sequence."
|
||||
if self._result.get("executed")
|
||||
else "Sequence valid."
|
||||
)
|
||||
|
||||
@SafeSlot(str)
|
||||
def _failed(self, error):
|
||||
if self._closed:
|
||||
return
|
||||
self._busy = False
|
||||
self.editor.setEnabled(True)
|
||||
self.message.setText(error.strip().splitlines()[-1])
|
||||
self.message.setToolTip(error)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Stop pending edits and disconnect widget resources."""
|
||||
self._closed = True
|
||||
if hasattr(self, "edit_timer"):
|
||||
self.edit_timer.stop()
|
||||
if hasattr(self, "render_timer"):
|
||||
self.render_timer.stop()
|
||||
if hasattr(self, "inspector"):
|
||||
self.inspector.cleanup()
|
||||
super().cleanup()
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['scan_scheduler.py']}
|
||||
@@ -0,0 +1,57 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from bec_widgets.widgets.control.scan_scheduler.scan_scheduler import ScanScheduler
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='ScanScheduler' name='scan_scheduler'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class ScanSchedulerPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
if parent is None:
|
||||
return QWidget()
|
||||
t = ScanScheduler(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return ""
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(ScanScheduler.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "scan_scheduler"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "ScanScheduler"
|
||||
|
||||
def toolTip(self):
|
||||
return "Edit nested components and execute validated sequences through procedures."
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -0,0 +1,62 @@
|
||||
# Scan scheduler
|
||||
|
||||
The scheduler composes nested BEC sequence components and executes their JSON to the existing
|
||||
`run_sequence` procedure. It requires the BEC `feature/scan_sequencer` branch, installed in both
|
||||
this GUI environment and the procedure worker environment. There is no new execution service.
|
||||
|
||||
Open **Scan Scheduler** in `bec-app`, add a ScanScheduler through the GUI server/Designer, or run:
|
||||
|
||||
```bash
|
||||
python -m bec_widgets.examples.scan_scheduler
|
||||
```
|
||||
|
||||
Opening the example does not move hardware or acquire scans. Its `samx` and `time_scan` inputs
|
||||
must be available on your deployment before validation/execution succeeds.
|
||||
|
||||
## Compose and inspect
|
||||
|
||||
- Select a sequence or loop, choose a component and click **Add step**. A selected sequence child
|
||||
inserts a sibling after that child. **Wrap** surrounds the selected step with a sequence or loop.
|
||||
- Edit the selected component in the inspector. Changes save automatically. Repeat counts must
|
||||
be positive. The card menu provides wrap, reorder and remove actions.
|
||||
- Set targets accept dotted device/signal names. **Wait for completion** controls native blocking
|
||||
set/move behavior. Visible for_each variables appear in the **Variable** selector.
|
||||
- Scan definitions use the existing **ScanControl** widget: its scan selector, argument groups,
|
||||
device bundles, input bounds, units, tooltips and **Restore last** behavior. The scheduler hides
|
||||
its execution buttons; composing a step does not submit a scan. Inputs accept a visible loop variable directly, for example `$exposure`.
|
||||
Autocompletion offers the current scope; numeric literals retain their native bounds and units. Existing positional inputs remain positional, and untouched missing
|
||||
arguments remain missing so backend validation can reject them.
|
||||
- **Advanced settings → Edit arguments as raw JSON** supports metadata, whole argument-mapping
|
||||
references and structured expressions. For example `{"ref": "pol"}`. Use
|
||||
`{"type": "literal", "value": ...}` to protect dictionaries resembling reserved expressions.
|
||||
- **Description**, **Python** and **JSON** update automatically after editing. Description is
|
||||
selected first. Validation covers the complete tree, references and definitions. Execution also
|
||||
checks live scan/device inputs before requesting the procedure. Invalid sequences cannot execute.
|
||||
- **JSON → Apply JSON** imports edits made to the document JSON. **Sequence options → Open/Save**
|
||||
uses standalone sequence documents. **Reusable definitions** accepts a JSON list of versioned
|
||||
backend definitions; save/share that list separately alongside a sequence that needs it.
|
||||
This prototype does not provide a reusable-definition authoring form.
|
||||
|
||||
## Execute
|
||||
|
||||
Choose **Execute** to validate the sequence and request the existing `run_sequence` procedure.
|
||||
Validation and the request run off the Qt thread. The request contains the current JSON document
|
||||
and the transitive reusable definitions it needs. The worker independently validates these inputs
|
||||
before using native IPython scan/device calls. Scan implementations must be available in that worker.
|
||||
|
||||
The widget does not keep submission records, queue views or history. Procedure execution and
|
||||
interruption remain owned by BEC. Closing this editor does not interrupt a running procedure.
|
||||
|
||||
## Programmatic use
|
||||
|
||||
```python
|
||||
from bec_widgets.widgets.control.scan_scheduler import ScanScheduler
|
||||
|
||||
scheduler = ScanScheduler(client=bec)
|
||||
scheduler.load_json(sequence_json, definitions_json) # definitions defaults to "[]"
|
||||
scheduler.show()
|
||||
scheduler.validate_sequence() # optional: refresh immediately instead of waiting for automatic updates
|
||||
# scheduler.execute_sequence() # explicit execution action
|
||||
```
|
||||
|
||||

|
||||
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
@@ -0,0 +1,472 @@
|
||||
{
|
||||
"time_scan": {
|
||||
"class": "TimeScan",
|
||||
"base_class": "ScanBaseV4",
|
||||
"is_scan": true,
|
||||
"is_internal": false,
|
||||
"arg_input": {},
|
||||
"required_kwargs": [],
|
||||
"arg_bundle_size": {
|
||||
"bundle": 0,
|
||||
"min": null,
|
||||
"max": null
|
||||
},
|
||||
"signature": [
|
||||
{
|
||||
"name": "points",
|
||||
"kind": "POSITIONAL_OR_KEYWORD",
|
||||
"default": "_empty",
|
||||
"annotation": {
|
||||
"Annotated": {
|
||||
"type": "int",
|
||||
"metadata": {
|
||||
"ScanArgument": {
|
||||
"display_name": null,
|
||||
"description": null,
|
||||
"tooltip": null,
|
||||
"expert": false,
|
||||
"hidden": false,
|
||||
"example": null,
|
||||
"units": null,
|
||||
"reference_units": null,
|
||||
"gt": null,
|
||||
"ge": null,
|
||||
"lt": null,
|
||||
"le": null,
|
||||
"reference_limits": null,
|
||||
"precision": null,
|
||||
"alternative_group": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "interval",
|
||||
"kind": "POSITIONAL_OR_KEYWORD",
|
||||
"default": "_empty",
|
||||
"annotation": {
|
||||
"Annotated": {
|
||||
"type": "float",
|
||||
"metadata": {
|
||||
"ScanArgument": {
|
||||
"display_name": "Interval",
|
||||
"description": null,
|
||||
"tooltip": null,
|
||||
"expert": false,
|
||||
"hidden": false,
|
||||
"example": null,
|
||||
"units": "s",
|
||||
"reference_units": null,
|
||||
"gt": null,
|
||||
"ge": 0.0,
|
||||
"lt": null,
|
||||
"le": null,
|
||||
"reference_limits": null,
|
||||
"precision": null,
|
||||
"alternative_group": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "exp_time",
|
||||
"kind": "POSITIONAL_OR_KEYWORD",
|
||||
"default": 0,
|
||||
"annotation": {
|
||||
"Annotated": {
|
||||
"type": "float",
|
||||
"metadata": {
|
||||
"ScanArgument": {
|
||||
"display_name": "Exposure Time",
|
||||
"description": "Exposure time in seconds",
|
||||
"tooltip": null,
|
||||
"expert": false,
|
||||
"hidden": false,
|
||||
"example": null,
|
||||
"units": "s",
|
||||
"reference_units": null,
|
||||
"gt": null,
|
||||
"ge": 0.0,
|
||||
"lt": null,
|
||||
"le": null,
|
||||
"reference_limits": null,
|
||||
"precision": null,
|
||||
"alternative_group": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "settling_time",
|
||||
"kind": "POSITIONAL_OR_KEYWORD",
|
||||
"default": 0,
|
||||
"annotation": {
|
||||
"Annotated": {
|
||||
"type": "float",
|
||||
"metadata": {
|
||||
"ScanArgument": {
|
||||
"display_name": "Settling Time",
|
||||
"description": "Time to settle before trigger and readout.",
|
||||
"tooltip": null,
|
||||
"expert": false,
|
||||
"hidden": false,
|
||||
"example": null,
|
||||
"units": "s",
|
||||
"reference_units": null,
|
||||
"gt": null,
|
||||
"ge": 0.0,
|
||||
"lt": null,
|
||||
"le": null,
|
||||
"reference_limits": null,
|
||||
"precision": null,
|
||||
"alternative_group": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "kwargs",
|
||||
"kind": "VAR_KEYWORD",
|
||||
"default": "_empty",
|
||||
"annotation": "_empty"
|
||||
}
|
||||
],
|
||||
"doc": "Trigger and readout devices at a fixed interval.\nNote that the interval time cannot be less than the exposure time.\nThe effective sleep time between points is:\n sleep_time = max(interval - exp_time, 0)\n\nArgs:\n points (int): points.\n interval (float [s]): interval. s\n exp_time (float [s]): Exposure time in seconds. Default: 0 s\n settling_time (float [s]): Time to settle before trigger and readout. Default: 0 s\n\nReturns:\n ScanReport\n\nExamples:\n Minimum:\n >>> scans.time_scan(1, 1.0)\n Full:\n >>> scans.time_scan(1, 1.0, exp_time=0, settling_time=0)",
|
||||
"gui_visibility": {
|
||||
"Scan Parameters": [
|
||||
"points",
|
||||
"interval",
|
||||
"exp_time",
|
||||
"settling_time"
|
||||
]
|
||||
}
|
||||
},
|
||||
"line_scan": {
|
||||
"class": "LineScan",
|
||||
"base_class": "ScanBaseV4",
|
||||
"is_scan": true,
|
||||
"is_internal": false,
|
||||
"arg_input": {
|
||||
"device": "DeviceBase",
|
||||
"start": {
|
||||
"Annotated": {
|
||||
"type": "float",
|
||||
"metadata": {
|
||||
"ScanArgument": {
|
||||
"display_name": "Start Position",
|
||||
"description": null,
|
||||
"tooltip": null,
|
||||
"expert": false,
|
||||
"hidden": false,
|
||||
"example": null,
|
||||
"units": null,
|
||||
"reference_units": "device",
|
||||
"gt": null,
|
||||
"ge": null,
|
||||
"lt": null,
|
||||
"le": null,
|
||||
"reference_limits": null,
|
||||
"precision": null,
|
||||
"alternative_group": null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"stop": {
|
||||
"Annotated": {
|
||||
"type": "float",
|
||||
"metadata": {
|
||||
"ScanArgument": {
|
||||
"display_name": "Stop Position",
|
||||
"description": null,
|
||||
"tooltip": null,
|
||||
"expert": false,
|
||||
"hidden": false,
|
||||
"example": null,
|
||||
"units": null,
|
||||
"reference_units": "device",
|
||||
"gt": null,
|
||||
"ge": null,
|
||||
"lt": null,
|
||||
"le": null,
|
||||
"reference_limits": null,
|
||||
"precision": null,
|
||||
"alternative_group": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required_kwargs": [],
|
||||
"arg_bundle_size": {
|
||||
"bundle": 3,
|
||||
"min": 1,
|
||||
"max": null
|
||||
},
|
||||
"signature": [
|
||||
{
|
||||
"name": "args",
|
||||
"kind": "VAR_POSITIONAL",
|
||||
"default": "_empty",
|
||||
"annotation": "_empty"
|
||||
},
|
||||
{
|
||||
"name": "steps",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": "_empty",
|
||||
"annotation": {
|
||||
"Annotated": {
|
||||
"type": "int",
|
||||
"metadata": {
|
||||
"ScanArgument": {
|
||||
"display_name": "Number of Steps",
|
||||
"description": null,
|
||||
"tooltip": null,
|
||||
"expert": false,
|
||||
"hidden": false,
|
||||
"example": null,
|
||||
"units": null,
|
||||
"reference_units": null,
|
||||
"gt": 0.0,
|
||||
"ge": null,
|
||||
"lt": null,
|
||||
"le": null,
|
||||
"reference_limits": null,
|
||||
"precision": null,
|
||||
"alternative_group": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "relative",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": "_empty",
|
||||
"annotation": {
|
||||
"Annotated": {
|
||||
"type": "bool",
|
||||
"metadata": {
|
||||
"ScanArgument": {
|
||||
"display_name": "Relative",
|
||||
"description": "Whether the positions are relative to the current position",
|
||||
"tooltip": null,
|
||||
"expert": false,
|
||||
"hidden": false,
|
||||
"example": null,
|
||||
"units": null,
|
||||
"reference_units": null,
|
||||
"gt": null,
|
||||
"ge": null,
|
||||
"lt": null,
|
||||
"le": null,
|
||||
"reference_limits": null,
|
||||
"precision": null,
|
||||
"alternative_group": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "exp_time",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": 0,
|
||||
"annotation": {
|
||||
"Annotated": {
|
||||
"type": "float",
|
||||
"metadata": {
|
||||
"ScanArgument": {
|
||||
"display_name": "Exposure Time",
|
||||
"description": "Exposure time in seconds",
|
||||
"tooltip": null,
|
||||
"expert": false,
|
||||
"hidden": false,
|
||||
"example": null,
|
||||
"units": "s",
|
||||
"reference_units": null,
|
||||
"gt": null,
|
||||
"ge": 0.0,
|
||||
"lt": null,
|
||||
"le": null,
|
||||
"reference_limits": null,
|
||||
"precision": null,
|
||||
"alternative_group": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "frames_per_trigger",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": 1,
|
||||
"annotation": {
|
||||
"Annotated": {
|
||||
"type": "int",
|
||||
"metadata": {
|
||||
"ScanArgument": {
|
||||
"display_name": "Frames per Trigger",
|
||||
"description": "Number of frames per trigger for devices that support configurable frame counts per trigger.",
|
||||
"tooltip": null,
|
||||
"expert": false,
|
||||
"hidden": false,
|
||||
"example": null,
|
||||
"units": null,
|
||||
"reference_units": null,
|
||||
"gt": null,
|
||||
"ge": 1.0,
|
||||
"lt": null,
|
||||
"le": null,
|
||||
"reference_limits": null,
|
||||
"precision": null,
|
||||
"alternative_group": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "settling_time",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": 0,
|
||||
"annotation": {
|
||||
"Annotated": {
|
||||
"type": "float",
|
||||
"metadata": {
|
||||
"ScanArgument": {
|
||||
"display_name": "Settling Time",
|
||||
"description": "Time to settle before trigger and readout.",
|
||||
"tooltip": null,
|
||||
"expert": false,
|
||||
"hidden": false,
|
||||
"example": null,
|
||||
"units": "s",
|
||||
"reference_units": null,
|
||||
"gt": null,
|
||||
"ge": 0.0,
|
||||
"lt": null,
|
||||
"le": null,
|
||||
"reference_limits": null,
|
||||
"precision": null,
|
||||
"alternative_group": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "settling_time_after_trigger",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": 0,
|
||||
"annotation": {
|
||||
"Annotated": {
|
||||
"type": "float",
|
||||
"metadata": {
|
||||
"ScanArgument": {
|
||||
"display_name": "Settling Time After Trigger",
|
||||
"description": "Time to settle after the trigger but before the readout.",
|
||||
"tooltip": null,
|
||||
"expert": false,
|
||||
"hidden": false,
|
||||
"example": null,
|
||||
"units": "s",
|
||||
"reference_units": null,
|
||||
"gt": null,
|
||||
"ge": 0.0,
|
||||
"lt": null,
|
||||
"le": null,
|
||||
"reference_limits": null,
|
||||
"precision": null,
|
||||
"alternative_group": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "readout_time",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": 0,
|
||||
"annotation": {
|
||||
"Annotated": {
|
||||
"type": "float",
|
||||
"metadata": {
|
||||
"ScanArgument": {
|
||||
"display_name": "Readout Time",
|
||||
"description": "Configuration for devices that support configurable readout times.",
|
||||
"tooltip": null,
|
||||
"expert": false,
|
||||
"hidden": false,
|
||||
"example": null,
|
||||
"units": "s",
|
||||
"reference_units": null,
|
||||
"gt": null,
|
||||
"ge": 0.0,
|
||||
"lt": null,
|
||||
"le": null,
|
||||
"reference_limits": null,
|
||||
"precision": null,
|
||||
"alternative_group": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "burst_at_each_point",
|
||||
"kind": "KEYWORD_ONLY",
|
||||
"default": 1,
|
||||
"annotation": {
|
||||
"Annotated": {
|
||||
"type": "int",
|
||||
"metadata": {
|
||||
"ScanArgument": {
|
||||
"display_name": "Burst at Each Point",
|
||||
"description": "Number of triggers and readouts at each point.",
|
||||
"tooltip": null,
|
||||
"expert": false,
|
||||
"hidden": false,
|
||||
"example": null,
|
||||
"units": null,
|
||||
"reference_units": null,
|
||||
"gt": null,
|
||||
"ge": 1.0,
|
||||
"lt": null,
|
||||
"le": null,
|
||||
"reference_limits": null,
|
||||
"precision": null,
|
||||
"alternative_group": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "kwargs",
|
||||
"kind": "VAR_KEYWORD",
|
||||
"default": "_empty",
|
||||
"annotation": "_empty"
|
||||
}
|
||||
],
|
||||
"doc": "A line scan for one or more motors.\n\nArgs:\n *args (device: DeviceBase, start: float, stop: float): repeated scan argument bundles.\n steps (int): steps.\n relative (bool): Whether the positions are relative to the current position.\n exp_time (float [s]): Exposure time in seconds. Default: 0 s\n frames_per_trigger (int): Number of frames per trigger for devices that support configurable frame counts per trigger. Default: 1\n settling_time (float [s]): Time to settle before trigger and readout. Default: 0 s\n settling_time_after_trigger (float [s]): Time to settle after the trigger but before the readout. Default: 0 s\n readout_time (float [s]): Configuration for devices that support configurable readout times. Default: 0 s\n burst_at_each_point (int): Number of triggers and readouts at each point. Default: 1\n\nReturns:\n ScanReport\n\nExamples:\n Minimum:\n >>> scans.line_scan(dev.device1, -1.0, 1.0, steps=10, relative=False)\n Full:\n >>> scans.line_scan(dev.device1, -1.0, 1.0, steps=10, relative=False, exp_time=0, frames_per_trigger=1, settling_time=0, settling_time_after_trigger=0, readout_time=0, burst_at_each_point=1)",
|
||||
"gui_visibility": {
|
||||
"Movement Parameters": [
|
||||
"steps",
|
||||
"relative"
|
||||
],
|
||||
"Acquisition Parameters": [
|
||||
"exp_time",
|
||||
"frames_per_trigger",
|
||||
"settling_time",
|
||||
"settling_time_after_trigger",
|
||||
"readout_time",
|
||||
"burst_at_each_point"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
"""Scheduler composition and authoritative procedure integration tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from threading import Event, get_ident
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from bec_widgets.tests.utils import create_widget
|
||||
from bec_widgets.widgets.control.scan_scheduler import ScanScheduler
|
||||
from bec_widgets.widgets.control.scan_scheduler.document import SequenceDraft
|
||||
from bec_widgets.widgets.control.scan_scheduler.inspector import ValueInput
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scheduler(qtbot, mocked_client):
|
||||
info = json.loads(Path(__file__).with_name("scan_info.json").read_text())
|
||||
mocked_client.scans._available_scans = {
|
||||
name: SimpleNamespace(scan_info=entry) for name, entry in info.items()
|
||||
}
|
||||
mocked_client.proc = MagicMock()
|
||||
return create_widget(qtbot, ScanScheduler, client=mocked_client)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sequence():
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"type": "sequence",
|
||||
"id": "alternating_positions",
|
||||
"revision": 1,
|
||||
"root": {
|
||||
"id": "repeat",
|
||||
"type": "repeat",
|
||||
"count": 2,
|
||||
"body": {
|
||||
"id": "positions",
|
||||
"type": "for_each",
|
||||
"variable": "position",
|
||||
"values": [1, -1],
|
||||
"body": {
|
||||
"id": "steps",
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{
|
||||
"id": "move",
|
||||
"type": "set",
|
||||
"device": "samx",
|
||||
"value": {"ref": "position"},
|
||||
"wait": True,
|
||||
},
|
||||
{
|
||||
"id": "scan",
|
||||
"type": "scan_call",
|
||||
"name": "time_scan",
|
||||
"positional": [1, 0.1],
|
||||
"arguments": {},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_tree_and_scope(scheduler, sequence):
|
||||
scheduler.load_json(json.dumps(sequence))
|
||||
assert scheduler.exports.tabText(0) == "Description"
|
||||
scheduler._rebuild_tree(("body", "body", "children", 0))
|
||||
assert scheduler.inspector.fields["value"].mode.currentData() == "position"
|
||||
assert "position" in scheduler.inspector.scope.text()
|
||||
assert "and wait" in scheduler.tree.currentItem().text(1)
|
||||
assert json.loads(scheduler.document_json())["root"] == scheduler.draft.document["root"]
|
||||
|
||||
|
||||
def test_validate_exports_in_worker(scheduler, sequence, qtbot):
|
||||
scheduler.load_json(json.dumps(sequence))
|
||||
scheduler.validate_sequence()
|
||||
qtbot.waitUntil(lambda: not scheduler._busy)
|
||||
assert "Sequence valid" in scheduler.message.text()
|
||||
assert "wait" in scheduler.description.toPlainText()
|
||||
assert "time_scan" in scheduler.python.toPlainText()
|
||||
assert scheduler.client.proc.request_new.call_count == 0
|
||||
|
||||
|
||||
def test_invalid_scan_never_submitted(scheduler, sequence, qtbot):
|
||||
sequence["root"]["body"]["body"]["children"][1]["positional"] = []
|
||||
scheduler.load_json(json.dumps(sequence))
|
||||
scheduler.execute_sequence()
|
||||
qtbot.waitUntil(lambda: not scheduler._busy)
|
||||
assert scheduler.client.proc.request_new.call_count == 0
|
||||
assert "points" in scheduler.message.text()
|
||||
|
||||
|
||||
def test_execute_uses_procedure_without_retaining_records(scheduler, sequence, qtbot):
|
||||
scheduler.load_json(json.dumps(sequence))
|
||||
scheduler.execute_sequence()
|
||||
qtbot.waitUntil(lambda: not scheduler._busy)
|
||||
request = scheduler.client.proc.request_new.call_args
|
||||
assert request.args[0] == "run_sequence"
|
||||
document, _definitions = request.args[1][0]
|
||||
assert json.loads(document)["revision"] == 1
|
||||
assert scheduler.draft.document["revision"] == 1
|
||||
assert not hasattr(scheduler, "records")
|
||||
assert not hasattr(scheduler, "queue_table")
|
||||
assert not hasattr(scheduler, "history_table")
|
||||
|
||||
|
||||
def test_submission_does_not_block_event_loop(scheduler, sequence, qtbot):
|
||||
started, release = Event(), Event()
|
||||
threads = []
|
||||
|
||||
def request(*args, **kwargs):
|
||||
threads.append(get_ident())
|
||||
started.set()
|
||||
assert release.wait(5)
|
||||
return SimpleNamespace(done=True, state=SimpleNamespace(value="SUCCESS"), error=None)
|
||||
|
||||
scheduler.client.proc.request_new.side_effect = request
|
||||
scheduler.load_json(json.dumps(sequence))
|
||||
scheduler.execute_sequence()
|
||||
try:
|
||||
qtbot.waitUntil(started.is_set)
|
||||
assert scheduler._busy
|
||||
assert threads == [threads[0]] and threads[0] != get_ident()
|
||||
finally:
|
||||
release.set()
|
||||
qtbot.waitUntil(lambda: not scheduler._busy)
|
||||
|
||||
|
||||
def test_json_errors_preserve_draft(scheduler, sequence):
|
||||
scheduler.load_json(json.dumps(sequence))
|
||||
original = scheduler.document_json()
|
||||
scheduler.json.setPlainText('{"type":"scan"}')
|
||||
scheduler._apply_json()
|
||||
assert scheduler.document_json() == original
|
||||
|
||||
|
||||
def test_edit_add_wrap_move_remove(scheduler):
|
||||
scheduler.palette.setCurrentText("set")
|
||||
scheduler._add()
|
||||
scheduler.inspector.fields["device"].setCurrentText("samx.velocity")
|
||||
scheduler.inspector.fields["value"].value_edit.setText("2")
|
||||
scheduler._apply()
|
||||
scheduler.palette.setCurrentText("repeat")
|
||||
scheduler._wrap()
|
||||
assert scheduler.draft.node(scheduler._path)["count"] == 1
|
||||
assert scheduler.inspector.fields["count"].minimum() == 1
|
||||
scheduler._remove()
|
||||
assert scheduler.draft.document["root"]["children"] == []
|
||||
|
||||
|
||||
def test_document_nested_scope_and_reordering(sequence):
|
||||
draft = SequenceDraft(sequence)
|
||||
path = ("body", "body", "children", 0)
|
||||
assert draft.variables(path) == ["position"]
|
||||
assert draft.variables(("body",)) == []
|
||||
assert draft.move(path, 1)[-1] == 1
|
||||
draft.remove(("body", "body"))
|
||||
assert draft.node(("body", "body"))["children"] == []
|
||||
|
||||
|
||||
def test_value_reference_and_safe_literal(qtbot):
|
||||
widget = ValueInput({"ref": "pol"}, ["pol"])
|
||||
qtbot.addWidget(widget)
|
||||
assert widget.value() == {"ref": "pol"}
|
||||
widget.mode.setCurrentIndex(0)
|
||||
widget.value_edit.setText(json.dumps("'\n__import__('os')"))
|
||||
assert widget.value() == "'\n__import__('os')"
|
||||
|
||||
|
||||
def test_close_stops_pending_edits(scheduler):
|
||||
scheduler.render_timer.start()
|
||||
scheduler.close()
|
||||
assert not scheduler.render_timer.isActive()
|
||||
|
||||
|
||||
def test_named_scan_arguments_preserve_semantics(scheduler, sequence, qtbot):
|
||||
sequence["root"]["body"]["values"] = [0.1, 0.2]
|
||||
scheduler.load_json(json.dumps(sequence))
|
||||
path = ("body", "body", "children", 1)
|
||||
scheduler._rebuild_tree(path)
|
||||
inputs = scheduler.inspector.scan_arguments
|
||||
assert inputs.values() == ("time_scan", [1, 0.1], {})
|
||||
# Bind exposure time to the visible variable without rewriting positional arguments.
|
||||
editor = inputs.inputs[("arguments", "exp_time")]
|
||||
editor.setText("$position")
|
||||
editor.editingFinished.emit()
|
||||
scheduler._apply()
|
||||
scan = scheduler.draft.node(path)
|
||||
assert scan["positional"] == [1, 0.1]
|
||||
assert scan["arguments"]["exp_time"] == {"ref": "position"}
|
||||
scheduler.validate_sequence()
|
||||
qtbot.waitUntil(lambda: not scheduler._busy)
|
||||
assert "time_scan" in scheduler.description.toPlainText(), scheduler.message.text()
|
||||
|
||||
|
||||
def test_execution_passes_transitive_definitions(scheduler, qtbot):
|
||||
definitions = [
|
||||
{
|
||||
"id": "inner",
|
||||
"version": 1,
|
||||
"parameters": {"type": "object", "additionalProperties": False},
|
||||
"body": {
|
||||
"id": "scan",
|
||||
"type": "scan_call",
|
||||
"name": "time_scan",
|
||||
"positional": [1, 0.1],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "outer",
|
||||
"version": 1,
|
||||
"body": {
|
||||
"id": "call",
|
||||
"type": "component_call",
|
||||
"definition": {"id": "inner", "version": 1},
|
||||
},
|
||||
},
|
||||
{"id": "unused", "version": 1, "body": {"id": "empty", "type": "sequence", "children": []}},
|
||||
]
|
||||
document = {
|
||||
"id": "reusable",
|
||||
"revision": 1,
|
||||
"root": {
|
||||
"id": "call",
|
||||
"type": "component_call",
|
||||
"definition": {"id": "outer", "version": 1},
|
||||
},
|
||||
}
|
||||
scheduler.client.proc.request_new.return_value = SimpleNamespace(
|
||||
done=False, state=SimpleNamespace(value="RUNNING"), error=None
|
||||
)
|
||||
scheduler.load_json(json.dumps(document), json.dumps(definitions))
|
||||
scheduler.execute_sequence()
|
||||
qtbot.waitUntil(lambda: not scheduler._busy)
|
||||
request = scheduler.client.proc.request_new.call_args
|
||||
assert request is not None, scheduler.message.text()
|
||||
definitions = request.args[1][0][1]
|
||||
assert [entry["id"] for entry in definitions] == ["inner", "outer"]
|
||||
|
||||
|
||||
def test_invalid_reference_rejected_before_submission(scheduler, sequence, qtbot):
|
||||
sequence["root"]["body"]["body"]["children"][0]["value"] = {"ref": "missing"}
|
||||
scheduler.load_json(json.dumps(sequence))
|
||||
scheduler.execute_sequence()
|
||||
qtbot.waitUntil(lambda: not scheduler._busy)
|
||||
assert "missing" in scheduler.message.text()
|
||||
scheduler.client.proc.request_new.assert_not_called()
|
||||
|
||||
|
||||
def test_repeat_zero_and_unknown_types_rejected(scheduler, sequence):
|
||||
sequence["root"]["count"] = 0
|
||||
with pytest.raises(ValueError):
|
||||
scheduler.load_json(json.dumps(sequence))
|
||||
sequence["root"]["count"] = 1
|
||||
sequence["type"] = "scan"
|
||||
with pytest.raises(ValueError):
|
||||
scheduler.load_json(json.dumps(sequence))
|
||||
|
||||
|
||||
def test_description_capture(scheduler, qtbot):
|
||||
import os
|
||||
|
||||
from bec_widgets.examples.scan_scheduler import example_sequence
|
||||
|
||||
scheduler.load_json(example_sequence().model_dump_json())
|
||||
scheduler.resize(1400, 1000)
|
||||
scheduler._rebuild_tree(("body", "body", "children", 0))
|
||||
scheduler.show()
|
||||
scheduler.validate_sequence()
|
||||
qtbot.waitUntil(lambda: not scheduler._busy)
|
||||
assert scheduler.description.toPlainText()
|
||||
if filename := os.environ.get("BEC_SCHEDULER_SCREENSHOT"):
|
||||
qtbot.wait(100)
|
||||
assert scheduler.grab().save(filename)
|
||||
if filename := os.environ.get("BEC_SCHEDULER_PYTHON_SCREENSHOT"):
|
||||
scheduler.exports.setCurrentIndex(1)
|
||||
qtbot.wait(100)
|
||||
assert scheduler.grab().save(filename)
|
||||
|
||||
|
||||
def test_nested_scan_inspector_capture(scheduler, qtbot):
|
||||
import os
|
||||
|
||||
def scan(identity, exposure):
|
||||
return {
|
||||
"id": identity,
|
||||
"type": "scan_call",
|
||||
"name": "line_scan",
|
||||
"positional": ["samx", -1.0, 1.0],
|
||||
"arguments": {
|
||||
"steps": 30,
|
||||
"relative": False,
|
||||
"exp_time": exposure,
|
||||
"metadata": {"sample_name": "sample1"},
|
||||
},
|
||||
}
|
||||
|
||||
document = {
|
||||
"id": "Position & exposure",
|
||||
"revision": 1,
|
||||
"root": {
|
||||
"id": "root",
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{
|
||||
"id": "Position cycle",
|
||||
"type": "repeat",
|
||||
"count": 3,
|
||||
"body": {
|
||||
"id": "Position sweep",
|
||||
"type": "for_each",
|
||||
"variable": "position",
|
||||
"values": [-1, 1],
|
||||
"body": {
|
||||
"id": "position_steps",
|
||||
"type": "sequence",
|
||||
"children": [
|
||||
{
|
||||
"id": "Set position",
|
||||
"type": "set",
|
||||
"device": "samx",
|
||||
"value": {"ref": "position"},
|
||||
"wait": True,
|
||||
},
|
||||
{
|
||||
"id": "Repeat acquisition",
|
||||
"type": "repeat",
|
||||
"count": 2,
|
||||
"body": scan("Position line", 0.1),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "Exposure series",
|
||||
"type": "for_each",
|
||||
"variable": "exposure",
|
||||
"values": [0.1, 0.2],
|
||||
"body": {
|
||||
"id": "Repeat exposure acquisition",
|
||||
"type": "repeat",
|
||||
"count": 2,
|
||||
"body": scan("Exposure line", {"ref": "exposure"}),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
scheduler.load_json(json.dumps(document))
|
||||
scheduler.resize(1250, 1050)
|
||||
scheduler._rebuild_tree(("children", 1, "body", "body"))
|
||||
scheduler.show()
|
||||
scheduler.validate_sequence()
|
||||
qtbot.waitUntil(lambda: not scheduler._busy)
|
||||
assert scheduler.description.toPlainText(), scheduler.message.text()
|
||||
assert "$exposure" in scheduler.inspector.scope.text()
|
||||
if filename := os.environ.get("BEC_SCHEDULER_REVISED_SCREENSHOT"):
|
||||
qtbot.wait(100)
|
||||
assert scheduler.grab().save(filename)
|
||||
|
||||
|
||||
def test_scan_control_is_embedded_without_fetching_metadata(scheduler, sequence, monkeypatch):
|
||||
from bec_widgets.widgets.control.scan_control import ScanControl
|
||||
|
||||
def unexpected_fetch(*args, **kwargs):
|
||||
raise AssertionError("Scan metadata is already subscribed")
|
||||
|
||||
monkeypatch.setattr(scheduler.client.connector, "get", unexpected_fetch)
|
||||
scheduler.load_json(json.dumps(sequence))
|
||||
scheduler._rebuild_tree(("body", "body", "children", 1))
|
||||
editor = scheduler.inspector.scan_arguments
|
||||
assert isinstance(editor.control, ScanControl)
|
||||
assert editor.control.hide_scan_control_buttons
|
||||
assert not editor.control.scan_control_group.isEnabled()
|
||||
assert editor.values() == ("time_scan", [1, 0.1], {})
|
||||
|
||||
|
||||
def test_native_restore_refreshes_bindings(scheduler, sequence, qtbot):
|
||||
scheduler.load_json(json.dumps(sequence))
|
||||
scheduler._rebuild_tree(("body", "body", "children", 1))
|
||||
editor = scheduler.inspector.scan_arguments
|
||||
binding = editor.inputs[("arguments", "exp_time")]
|
||||
binding.setText("$position")
|
||||
binding.editingFinished.emit()
|
||||
control = editor.control
|
||||
control._last_scan_parameters_received.emit(
|
||||
control._last_scan_fetch_generation, "time_scan", ([], {"points": 5, "interval": 0.2})
|
||||
)
|
||||
qtbot.waitUntil(lambda: not editor.refresh_timer.isActive())
|
||||
assert editor.values() == ("time_scan", [], {"points": 5, "interval": 0.2})
|
||||
assert binding.text() == "0.00 s"
|
||||
|
||||
|
||||
def test_missing_scan_arguments_stay_missing(scheduler, sequence):
|
||||
scan = sequence["root"]["body"]["body"]["children"][1]
|
||||
scan["positional"] = []
|
||||
scheduler.load_json(json.dumps(sequence))
|
||||
scheduler._rebuild_tree(("body", "body", "children", 1))
|
||||
assert scheduler.inspector.scan_arguments.values() == ("time_scan", [], {})
|
||||
|
||||
|
||||
def test_new_scan_uses_native_selector(scheduler, sequence):
|
||||
scan = sequence["root"]["body"]["body"]["children"][1]
|
||||
scheduler.load_json(json.dumps(sequence))
|
||||
scan = scheduler.draft.document["root"]["body"]["body"]["children"][1]
|
||||
scan["name"] = ""
|
||||
scan["positional"] = []
|
||||
scheduler._rebuild_tree(("body", "body", "children", 1))
|
||||
editor = scheduler.inspector.scan_arguments
|
||||
assert editor is not None
|
||||
editor.control.current_scan = "time_scan"
|
||||
editor._confirmed(0)
|
||||
assert editor.values()[0] == "time_scan"
|
||||
|
||||
|
||||
def test_native_scan_device_bundles_preserve_positional_inputs(scheduler, sequence, qtbot):
|
||||
node = sequence["root"]["body"]["body"]["children"][1]
|
||||
node.update(
|
||||
name="line_scan",
|
||||
positional=["samx", -1, 1, "samy", -2, 2],
|
||||
arguments={"steps": 3, "relative": False},
|
||||
)
|
||||
scheduler.load_json(json.dumps(sequence))
|
||||
scheduler._rebuild_tree(("body", "body", "children", 1))
|
||||
editor = scheduler.inspector.scan_arguments
|
||||
assert editor.control.arg_box.count_arg_rows() == 2
|
||||
assert editor.values() == ("line_scan", node["positional"], node["arguments"])
|
||||
editor.control.arg_box.button_remove_bundle.click()
|
||||
qtbot.waitUntil(lambda: not editor.refresh_timer.isActive())
|
||||
name, positional, keywords = editor.values()
|
||||
assert name == "line_scan"
|
||||
assert positional == ["samx", -1, 1]
|
||||
assert keywords["steps"] == 3
|
||||
|
||||
|
||||
def test_direct_variable_input_respects_scope_and_literal_bounds(scheduler, sequence):
|
||||
from qtpy.QtGui import QValidator
|
||||
|
||||
sequence["root"]["body"]["body"]["children"][1].update(
|
||||
name="line_scan", positional=["samx", -1, 1], arguments={"steps": 3, "relative": False}
|
||||
)
|
||||
scheduler.load_json(json.dumps(sequence))
|
||||
scheduler._rebuild_tree(("body", "body", "children", 1))
|
||||
field = scheduler.inspector.scan_arguments.inputs[("arguments", "steps")]
|
||||
validator = field.validator()
|
||||
assert validator.validate("$position", 9)[0] == QValidator.State.Acceptable
|
||||
field.setText("$position")
|
||||
assert field.value() == {"ref": "position"}
|
||||
field.setText("$missing")
|
||||
with pytest.raises(ValueError, match="not available"):
|
||||
field.value()
|
||||
field.setText("0")
|
||||
with pytest.raises(ValueError, match="Invalid steps"):
|
||||
field.value()
|
||||
field.setText("5")
|
||||
assert field.value() == 5
|
||||
|
||||
|
||||
def test_description_updates_without_preview_action(scheduler, sequence, qtbot):
|
||||
from qtpy.QtWidgets import QPushButton
|
||||
|
||||
scheduler.load_json(json.dumps(sequence))
|
||||
qtbot.waitUntil(lambda: bool(scheduler.description.toPlainText()))
|
||||
assert scheduler.exports.count() == 3
|
||||
buttons = [button.text() for button in scheduler.findChildren(QPushButton)]
|
||||
assert "Execute" in buttons
|
||||
assert "Preview execution" not in buttons
|
||||
assert "Submit sequence" not in buttons
|
||||
scheduler.client.proc.request_new.assert_not_called()
|
||||
Reference in new issue
Block a user