From ed181938095e1d2e5db21c825c0a79cec2a05de2 Mon Sep 17 00:00:00 2001 From: x01da Date: Wed, 2 Sep 2026 09:40:20 +0200 Subject: [PATCH] wip --- debye_bec/bec_widgets/widgets/client.py | 1 + .../bec_widgets/widgets/designer_plugins.py | 2 + .../bec_widgets/widgets/scheduler/guard.py | 38 +++++- .../widgets/scheduler/scheduler.py | 114 ++++++++---------- 4 files changed, 83 insertions(+), 72 deletions(-) diff --git a/debye_bec/bec_widgets/widgets/client.py b/debye_bec/bec_widgets/widgets/client.py index 0fc46e6..94aa2c0 100644 --- a/debye_bec/bec_widgets/widgets/client.py +++ b/debye_bec/bec_widgets/widgets/client.py @@ -17,6 +17,7 @@ _Widgets = { "DigitalTwin": "DigitalTwin", "RestartServer": "RestartServer", "ScanControlXAS": "ScanControlXAS", + "Scheduler": "Scheduler", } diff --git a/debye_bec/bec_widgets/widgets/designer_plugins.py b/debye_bec/bec_widgets/widgets/designer_plugins.py index 77ddd10..aaf7251 100644 --- a/debye_bec/bec_widgets/widgets/designer_plugins.py +++ b/debye_bec/bec_widgets/widgets/designer_plugins.py @@ -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", } diff --git a/debye_bec/bec_widgets/widgets/scheduler/guard.py b/debye_bec/bec_widgets/widgets/scheduler/guard.py index 5fd69d5..2f0dd11 100644 --- a/debye_bec/bec_widgets/widgets/scheduler/guard.py +++ b/debye_bec/bec_widgets/widgets/scheduler/guard.py @@ -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() diff --git a/debye_bec/bec_widgets/widgets/scheduler/scheduler.py b/debye_bec/bec_widgets/widgets/scheduler/scheduler.py index cf7385c..0b44652 100644 --- a/debye_bec/bec_widgets/widgets/scheduler/scheduler.py +++ b/debye_bec/bec_widgets/widgets/scheduler/scheduler.py @@ -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():