wip
CI for debye_bec / test (push) Successful in 1m33s
CI for debye_bec / test (pull_request) Successful in 1m43s

This commit is contained in:
x01da
2026-09-02 09:40:20 +02:00
parent 02205b43c5
commit ed18193809
4 changed files with 83 additions and 72 deletions
+1
View File
@@ -17,6 +17,7 @@ _Widgets = {
"DigitalTwin": "DigitalTwin",
"RestartServer": "RestartServer",
"ScanControlXAS": "ScanControlXAS",
"Scheduler": "Scheduler",
}
@@ -15,6 +15,7 @@ designer_plugins = {
"debye_bec.bec_widgets.widgets.scan_control_xas.scan_control_xas",
"ScanControlXAS",
),
"Scheduler": ("debye_bec.bec_widgets.widgets.scheduler.scheduler", "Scheduler"),
}
widget_icons = {
@@ -22,4 +23,5 @@ widget_icons = {
"DigitalTwin": "lightbulb",
"RestartServer": "restart_alt",
"ScanControlXAS": "tune",
"Scheduler": "assignment_add",
}
@@ -15,6 +15,7 @@ only place that connects those signals to schedule-specific behavior
from __future__ import annotations
import threading
import time
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
@@ -23,6 +24,8 @@ from qtpy.QtCore import QObject, Signal
logger = bec_logger.logger
MIN_UPDATE_INTERVAL = 1
class GuardSettings(BaseModel):
"""Persisted configuration for one `SignalGuard`."""
@@ -54,11 +57,13 @@ class SignalGuard(QObject):
on - no extra locking needed on the receiving end.
"""
value_update = Signal(float)
paused = Signal(float)
resumed = Signal(float)
def __init__(self, connector, parent=None):
def __init__(self, connector, parent=None, dev=None):
super().__init__(parent)
self.dev = dev
self._connector = connector
self._lock = threading.Lock()
self._clear_event = threading.Event()
@@ -69,9 +74,13 @@ class SignalGuard(QObject):
self.pause_below: float | None = None
self.resume_above: float | None = None
self.current_value: float | None = None
self.units: str = ""
self.prec: int = 3
self._subscribed_endpoint = None
self.last_val_update = time.time()
def configure(self, settings: GuardSettings):
"""(Re)configure and (re)subscribe. Safe to call repeatedly, e.g. after editing settings."""
self._unsubscribe()
@@ -83,16 +92,26 @@ class SignalGuard(QObject):
self.current_value = None
self._clear_event.set()
if self.enabled and self.device_name and self.pause_below is not None and self.resume_above is not None:
if (
self.enabled
and self.device_name
and self.pause_below is not None
and self.resume_above is not None
):
self._subscribed_endpoint = MessageEndpoints.device_readback(self.device_name)
self._connector.register(topics=self._subscribed_endpoint, cb=self._on_readback)
description = self.dev[self.device_name].describe()[self.device_name]
self.units = description["units"]
self.prec = description["precision"]
def _unsubscribe(self):
if self._subscribed_endpoint is not None:
try:
self._connector.unregister(topics=self._subscribed_endpoint, cb=self._on_readback)
except Exception: # pylint: disable=broad-except
logger.exception("Failed to unsubscribe SignalGuard from %s", self._subscribed_endpoint)
logger.exception(
f"Failed to unsubscribe SignalGuard from {self._subscribed_endpoint}"
)
self._subscribed_endpoint = None
def _on_readback(self, msg):
@@ -115,12 +134,21 @@ class SignalGuard(QObject):
crossed_resume = True
if crossed_pause:
logger.info("SignalGuard: %s dropped to %s (below %s) - pausing.", self.device_name, value, self.pause_below)
logger.info(
f"SignalGuard: {self.device_name} dropped to {value} (below {self.pause_below}) - pausing."
)
self.paused.emit(value)
elif crossed_resume:
logger.info("SignalGuard: %s recovered to %s (above %s) - resuming.", self.device_name, value, self.resume_above)
logger.info(
f"SignalGuard: {self.device_name} recovered to {value} (above {self.resume_above}) - resuming."
)
self.resumed.emit(value)
current_time = time.time()
if current_time - self.last_val_update > MIN_UPDATE_INTERVAL:
self.last_val_update = current_time
self.value_update.emit(value)
def is_clear(self) -> bool:
"""True if not currently blocking (disabled, or value above the resume threshold)."""
return (not self.enabled) or self._clear_event.is_set()
@@ -20,14 +20,15 @@ from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.messages import VariableMessage
from bec_qthemes import material_icon
from bec_qthemes._theme import AccentColors
from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeSlot
from pydantic import ValidationError
# pylint: disable=E0611
from qtpy.QtCore import Qt, QTimer, Signal
from qtpy.QtGui import QKeySequence, QShortcut
from qtpy.QtGui import QKeySequence
from qtpy.QtWidgets import (
QApplication,
QDialog,
@@ -45,7 +46,7 @@ from qtpy.QtWidgets import (
)
from .endpoints import schedule as schedule_endpoint
from .guard import GuardSettings, SignalGuard
from .guard import SignalGuard
from .guard_dialog import GuardSettingsDialog
from .item_dialog import ScheduleItemDialog
from .notification_dialog import NotificationSettingsDialog
@@ -55,21 +56,9 @@ from .schedule_logic import index_of, pick_next_runnable, protected_prefix_lengt
logger = bec_logger.logger
# The static, hard-coded example schedule requested for this plugin. A
# real deployment would more likely let the user build this list purely
# through the UI - the persistence/execution/reconciliation logic below
# does not care where the commands came from.
DEFAULT_COMMANDS = [
"scans.xas_simple_scan(12000, 14000, 2, 10)",
"dev.samx.move(12.3, relative=True)",
"scans.xas_simple_scan(13000, 17000, 1, 60)",
]
_ACTIVE_QUEUE_STATES = ("PENDING", "RUNNING")
_ITEM_ID_ROLE = Qt.UserRole + 1 # QListWidgetItem data role used to map a row back to an item_id
# TODO: Colors should change when the item is selected, otherwise won't be readable
ICON_SIZE = 20
_ICON_MAP = {
ScheduleItemStatus.PENDING: ("hourglass", "#919090"),
@@ -158,7 +147,7 @@ class MyButton(QPushButton):
class Scheduler(BECWidget, QWidget):
"""Schedule, persist and execute a sequence of BEC scan/device commands."""
# BUG: Icon is not recognized, also no entry is created in designer_plugins.py
PLUGIN = True
ICON_NAME = "assignment_add"
USER_ACCESS = [
"run_schedule",
@@ -222,7 +211,7 @@ class Scheduler(BECWidget, QWidget):
# keystroke - see _on_notes_changed/_persist_notes.
self._notes_save_timer = QTimer(self)
self._notes_save_timer.setSingleShot(True)
self._notes_save_timer.setInterval(600)
self._notes_save_timer.setInterval(200)
self._notes_save_timer.timeout.connect(self._persist_notes)
self.schedule_changed.connect(self._refresh_ui)
@@ -230,9 +219,10 @@ class Scheduler(BECWidget, QWidget):
self.schedule: Schedule = self._load_or_seed_schedule()
self._reconcile_with_live_state()
self._guard = SignalGuard(self.connector, parent=self)
self._guard = SignalGuard(self.connector, parent=self, dev=self.dev)
self._guard.paused.connect(self._on_guard_paused)
self._guard.resumed.connect(self._on_guard_resumed)
self._guard.value_update.connect(self._update_guard_label)
self._guard.configure(self.schedule.guard)
self._build_ui()
@@ -325,13 +315,18 @@ class Scheduler(BECWidget, QWidget):
settings_group = QGroupBox("Settings")
settings_layout = QVBoxLayout(settings_group)
settings_row = QHBoxLayout()
self.notifications_btn = MyButton("Notifications", "default")
guard_row = QHBoxLayout()
self.guard_btn = MyButton("Auto-pause", "default")
settings_row.addWidget(self.notifications_btn)
settings_row.addWidget(self.guard_btn)
settings_row.addStretch()
settings_layout.addLayout(settings_row)
self.guard_status_label = QLabel()
guard_row.addWidget(self.guard_btn)
guard_row.addWidget(self.guard_status_label)
guard_row.addStretch()
notifications_row = QHBoxLayout()
self.notifications_btn = MyButton("Notifications", "default")
notifications_row.addWidget(self.notifications_btn)
notifications_row.addStretch()
settings_layout.addLayout(guard_row)
settings_layout.addLayout(notifications_row)
self.notifications_btn.clicked.connect(self._on_notifications_clicked)
self.guard_btn.clicked.connect(self._on_guard_clicked)
@@ -351,10 +346,6 @@ class Scheduler(BECWidget, QWidget):
layout.addWidget(file_group)
self.guard_status_label = QLabel()
self.guard_status_label.setStyleSheet("color: gray;")
layout.addWidget(self.guard_status_label)
self.apply_theme()
def apply_theme(self, theme: Optional[Literal["dark", "light"]] = None):
@@ -397,12 +388,7 @@ class Scheduler(BECWidget, QWidget):
return Schedule.model_validate(msg.value)
logger.info("No schedule found, create one now")
schedule = Schedule(
schedule_name=self.schedule_name,
items=[
ScheduleItem(item_id=str(uuid.uuid4()), command=cmd) for cmd in DEFAULT_COMMANDS
],
)
schedule = Schedule(schedule_name=self.schedule_name, items=[])
self.connector.set_and_publish(
self._endpoint, VariableMessage(value=schedule.model_dump(mode="json"))
)
@@ -419,7 +405,7 @@ class Scheduler(BECWidget, QWidget):
self._persist_locked()
@SafeSlot(dict, dict)
def _on_remote_update(self, msg_content: dict, metadata: dict):
def _on_remote_update(self, msg_content: dict, _):
value = msg_content.get("value") if msg_content else None
if not value or value.get("schedule_name") != self.schedule_name:
return
@@ -497,17 +483,13 @@ class Scheduler(BECWidget, QWidget):
selected_row = None
for row, item in enumerate(items):
text = f"{item.command}"
icon_name, color = _ICON_MAP[item.status]
icon = material_icon(
icon_name, size=(ICON_SIZE, ICON_SIZE), color=color, convert_to_pixmap=True
)
if item.error:
text += f" ({item.error.strip().splitlines()[-1]})"
list_item = QListWidgetItem(text, self.list_widget)
list_item.setData(_ITEM_ID_ROLE, item.item_id)
list_item.setIcon(icon)
if item.item_id == self._selected_item_id:
selected_row = row
self._update_icons()
if selected_row is not None:
self.list_widget.setCurrentRow(selected_row)
else:
@@ -523,17 +505,18 @@ class Scheduler(BECWidget, QWidget):
self.notes_edit.setPlainText(notes)
self.notes_edit.blockSignals(False)
def _update_guard_label(self, *_):
if self._guard.enabled:
state = "OK" if self._guard.is_clear() else "PAUSED - waiting to resume"
value = self._guard.current_value
value_text = f"{value:g}" if value is not None else "?"
units = self._guard.units
value_text = f"{value:.{self._guard.prec}f}" if value is not None else "?"
self.guard_status_label.setText(
f"Auto-pause: {self._guard.device_name} = {value_text} "
f"Enabled: {self._guard.device_name} = {value_text} {units} "
f"(pause < {self._guard.pause_below:g}, resume > {self._guard.resume_above:g}) [{state}]"
)
self.guard_status_label.show()
else:
self.guard_status_label.hide()
self.guard_status_label.setText("Disabled")
def _update_buttons(self):
with self._lock:
@@ -568,10 +551,23 @@ class Scheduler(BECWidget, QWidget):
self.copy_btn.setEnabled(selected_item is not None)
self.paste_btn.setEnabled(self._clipboard_item is not None)
def _update_icons(self):
items = list(self.schedule.items)
for row, item in enumerate(items):
icon_name, color = _ICON_MAP[item.status]
if self._selected_item_id == item.item_id:
color = "#FFFFFF"
icon = material_icon(
icon_name, size=(ICON_SIZE, ICON_SIZE), color=color, convert_to_pixmap=True
)
list_item = self.list_widget.item(row)
list_item.setIcon(icon)
@SafeSlot()
def _on_selection_changed(self, row: int):
item = self.list_widget.item(row) if row >= 0 else None
self._selected_item_id = item.data(_ITEM_ID_ROLE) if item is not None else None
self._update_icons()
self._update_buttons()
@SafeSlot()
@@ -717,17 +713,17 @@ class Scheduler(BECWidget, QWidget):
self._persist_locked()
self._guard.configure(self.schedule.guard)
self._refresh_ui()
self._update_guard_label()
def _apply_guard_settings(self):
"""Re-subscribe the guard after `self.schedule.guard` changes (e.g. from a remote update)."""
self._guard.configure(self.schedule.guard)
self._update_guard_label()
@SafeSlot(float)
def _on_guard_paused(self, value: float):
logger.info(
"Auto-pause guard tripped for schedule '%s' (value=%s); interrupting the running scan, if any.",
self.schedule_name,
value,
f"Auto-pause guard tripped for schedule {self.schedule_name} (value = {value}); interrupting the running scan, if any."
)
if self._current_report is not None and self._current_item_kind == "scan":
self._guard_interrupt_requested = True
@@ -740,10 +736,8 @@ class Scheduler(BECWidget, QWidget):
@SafeSlot(float)
def _on_guard_resumed(self, value: float):
logger.info(
"Auto-pause guard cleared for schedule '%s' (value=%s); the execution loop will "
"resume the next scan item automatically.",
self.schedule_name,
value,
f"Auto-pause guard cleared for schedule {self.schedule_name} (value = {value}); the execution loop will "
"resume the next scan item automatically."
)
self.schedule_changed.emit()
@@ -765,7 +759,7 @@ class Scheduler(BECWidget, QWidget):
self._update_buttons()
@SafeSlot()
def _on_edit_clicked(self, row=None):
def _on_edit_clicked(self, _):
if self._selected_item_id is None:
return
with self._lock:
@@ -1168,8 +1162,7 @@ class Scheduler(BECWidget, QWidget):
item.request_id = getattr(request, "requestID", None)
self._persist_locked()
# Wait for completion without blocking the thread indefinitely
self._wait_for_report(report)
report.wait()
if self._closing.is_set():
# The widget was closed mid-item; exit without changing status in Redis.
@@ -1227,19 +1220,6 @@ class Scheduler(BECWidget, QWidget):
error=item.error,
)
def _wait_for_report(self, report, timeout: float = 0.5):
"""Poll report status to allow thread exit if the widget closes."""
while not self._closing.is_set() and not self._abort_requested:
# Check if scan report has finished via its internal event or status
if (
getattr(report, "status", None) == "completed"
or getattr(report, "event", None)
and report.event.is_set()
):
break
# Non-blocking poll interval
time.sleep(timeout)
def _await_running_item(self, item: ScheduleItem):
queue_storage = self.client.queue.queue_storage
while not self._abort_requested and not self._closing.is_set():