From ac6b58197e99bf03374147248dab18ad64f6ebf1 Mon Sep 17 00:00:00 2001 From: x01da Date: Tue, 1 Sep 2026 15:37:36 +0200 Subject: [PATCH] wip --- .../widgets/scheduler/schedule_logic.py | 17 ++- .../widgets/scheduler/scheduler.py | 107 +++++++----------- 2 files changed, 54 insertions(+), 70 deletions(-) diff --git a/debye_bec/bec_widgets/widgets/scheduler/schedule_logic.py b/debye_bec/bec_widgets/widgets/scheduler/schedule_logic.py index 29553d9..4073793 100644 --- a/debye_bec/bec_widgets/widgets/scheduler/schedule_logic.py +++ b/debye_bec/bec_widgets/widgets/scheduler/schedule_logic.py @@ -18,8 +18,12 @@ from __future__ import annotations from typing import Literal +from bec_lib.logger import bec_logger + from .schedule_item import ScheduleItem, ScheduleItemStatus +logger = bec_logger.logger + def protected_prefix_length(items: list[ScheduleItem]) -> int: """How many items, from the start, are no longer PENDING.""" @@ -41,7 +45,9 @@ def index_of(items: list[ScheduleItem], item_id: str | None) -> int | None: return None -def pick_next_runnable(items: list[ScheduleItem]) -> ScheduleItem | Literal["stop"] | None: +def pick_next_runnable( + items: list[ScheduleItem], repeat_aborted_item +) -> ScheduleItem | Literal["stop"] | None: """ What the execution loop should do next: always re-derived from scratch (never a remembered index/object) so edits made to the PENDING suffix @@ -51,10 +57,15 @@ def pick_next_runnable(items: list[ScheduleItem]) -> ScheduleItem | Literal["sto earlier item failed/was aborted (execution stays parked there until the operator intervenes), or `None` if every item is COMPLETED. """ + logger.info(f"pick next runnable, repeat abort item is {repeat_aborted_item}") for item in items: + logger.info(f"item: {item}") if item.status == ScheduleItemStatus.COMPLETED: continue - if item.status in (ScheduleItemStatus.FAILED, ScheduleItemStatus.ABORTED): + if item.status == ScheduleItemStatus.ABORTED: + if not repeat_aborted_item: + continue + if item.status == ScheduleItemStatus.FAILED: return "stop" - return item # PENDING, or RUNNING (reconciled as still active) + return item # PENDING, RUNNING or ABORTED if repeat_aborted_item is set (reconciled as still active) return None diff --git a/debye_bec/bec_widgets/widgets/scheduler/scheduler.py b/debye_bec/bec_widgets/widgets/scheduler/scheduler.py index d7fdbe2..8caf677 100644 --- a/debye_bec/bec_widgets/widgets/scheduler/scheduler.py +++ b/debye_bec/bec_widgets/widgets/scheduler/scheduler.py @@ -64,21 +64,15 @@ DEFAULT_COMMANDS = [ _ACTIVE_QUEUE_STATES = ("PENDING", "RUNNING") _ITEM_ID_ROLE = Qt.UserRole + 1 # QListWidgetItem data role used to map a row back to an item_id -_STATUS_ICON = { - ScheduleItemStatus.PENDING: "\u23f3", # hourglass - ScheduleItemStatus.RUNNING: "\u25b6", # play - ScheduleItemStatus.COMPLETED: "\u2705", # check mark - ScheduleItemStatus.FAILED: "\u274c", # cross mark - ScheduleItemStatus.ABORTED: "\u23f9", # stop -} +# TODO: Colors should change when the item is selected, otherwise won't be readable ICON_SIZE = 20 _ICON_MAP = { ScheduleItemStatus.PENDING: ("hourglass", "#919090"), ScheduleItemStatus.RUNNING: ("cycle", "#2980b9"), ScheduleItemStatus.COMPLETED: ("check", "#27ae60"), ScheduleItemStatus.FAILED: ("warning", "#e74c3c"), - ScheduleItemStatus.ABORTED: ("cancel", "#e74c3c"), + ScheduleItemStatus.ABORTED: ("cancel", "#e6d922"), } @@ -224,6 +218,8 @@ class Scheduler(BECWidget, QWidget): # schedule (another instance of this widget, a script, ...). self.bec_dispatcher.connect_slot(self._on_remote_update, self._endpoint) + self._closing = threading.Event() + # ------------------------------------------------------------------ # # UI # ------------------------------------------------------------------ # @@ -378,8 +374,7 @@ class Scheduler(BECWidget, QWidget): # again shortly); accepting a wholesale replacement here # would orphan those objects. See module docstring. logger.info( - "Ignoring remote schedule update for '%s' while a local run is in progress.", - self.schedule_name, + f"Ignoring remote schedule update for {self.schedule_name} while a local run is in progress." ) return self.schedule = new_schedule @@ -438,27 +433,16 @@ class Scheduler(BECWidget, QWidget): def _refresh_ui(self): with self._lock: items = list(self.schedule.items) - is_running = self.schedule.is_running self.list_widget.blockSignals(True) self.list_widget.clear() selected_row = None for row, item in enumerate(items): - - # text = f"{_STATUS_ICON.get(item.status, '?')} {item.command}" - 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.status == ScheduleItemStatus.RUNNING: - # self._spin_anim.start() - # else: - # self._spin_anim.stop() - # self._label.setPixmap(icon) - if item.error: text += f" ({item.error.strip().splitlines()[-1]})" list_item = QListWidgetItem(text, self.list_widget) @@ -472,33 +456,6 @@ class Scheduler(BECWidget, QWidget): self._selected_item_id = None self.list_widget.blockSignals(False) - # idx = None - # selected_item = None - # if self._selected_item_id is not None: - # for i, item in enumerate(items): - # if item.item_id == self._selected_item_id: - # idx, selected_item = i, item - # break - - # # Editable = the selected item hasn't started yet. Thanks to the - # # protected-prefix invariant, everything after the first PENDING - # # item is guaranteed PENDING too, so a plain index/status check is - # # enough here - see module docstring. - # can_edit_selected = ( - # selected_item is not None and selected_item.status == ScheduleItemStatus.PENDING - # ) - - # self.run_btn.setEnabled(not is_running) - # # Adding is always safe: new items are clamped into the PENDING - # # suffix regardless of what's selected (see add_item()). - # self.add_btn.setEnabled(True) - # self.edit_btn.setEnabled(can_edit_selected) - # self.delete_btn.setEnabled(can_edit_selected) - # self.move_up_btn.setEnabled(can_edit_selected and idx not in (None, 0)) - # self.move_down_btn.setEnabled( - # can_edit_selected and idx is not None and idx < len(items) - 1 - # ) - if self._guard.enabled: state = "OK" if self._guard.is_clear() else "PAUSED - waiting to resume" value = self._guard.current_value @@ -540,7 +497,6 @@ class Scheduler(BECWidget, QWidget): @SafeSlot() def _on_selection_changed(self, row: int): - logger.info("On selection changed") 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_buttons() @@ -695,12 +651,26 @@ class Scheduler(BECWidget, QWidget): with self._lock: if self.schedule.is_running: return + repeat_aborted_item = False + if any(item.status == ScheduleItemStatus.ABORTED for item in self.schedule.items): + repeat_aborted_item = ( + QMessageBox.question( + self, + "Repeat aborted scan", + "Would you like to repeat the aborted scan?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + == QMessageBox.StandardButton.Yes + ) self._abort_requested = False self.schedule.is_running = True self._persist_locked() self.schedule_changed.emit() self.submit_task( - self._run_all, on_complete=self._on_run_finished, on_failed=self._on_run_failed + self._run_all, + repeat_aborted_item, + on_complete=self._on_run_finished, + on_failed=self._on_run_failed, ) @SafeSlot() @@ -869,16 +839,16 @@ class Scheduler(BECWidget, QWidget): # the `schedule_changed` Qt signal so they are marshalled back onto # the GUI thread instead of touching widgets directly) # ------------------------------------------------------------------ # - def _pick_next_runnable_locked(self): + def _pick_next_runnable_locked(self, repeat_aborted_item): """Must be called while holding `self._lock`; see `schedule_logic.pick_next_runnable`.""" - return pick_next_runnable(self.schedule.items) + return pick_next_runnable(self.schedule.items, repeat_aborted_item) - def _run_all(self): + def _run_all(self, repeat_aborted_item): logger.info("_run_all was called") namespace = {"scans": self.scans, "dev": self.dev} while not self._abort_requested: with self._lock: - next_item = self._pick_next_runnable_locked() + next_item = self._pick_next_runnable_locked(repeat_aborted_item) if next_item is None or next_item == "stop": logger.info("Next item is None or stop") break @@ -891,7 +861,7 @@ class Scheduler(BECWidget, QWidget): logger.info("in _run_all, set is_running to false") self.schedule.is_running = False self._persist_locked() - self.schedule_changed.emit() + self._emit_schedule_changed() def _execute_item(self, item: ScheduleItem, namespace: dict): if item.kind == "scan": @@ -899,7 +869,7 @@ class Scheduler(BECWidget, QWidget): # the guard is clear or the operator aborts. See "Guard # interaction" in the module docstring. cleared = self._guard.wait_until_clear(should_abort=lambda: self._abort_requested) - self.schedule_changed.emit() + self._emit_schedule_changed() if not cleared: return @@ -908,7 +878,7 @@ class Scheduler(BECWidget, QWidget): item.error = None item.started_at = time.time() self._persist_locked() - self.schedule_changed.emit() + self._emit_schedule_changed() try: report = eval( @@ -964,7 +934,7 @@ class Scheduler(BECWidget, QWidget): final_status = item.status self._current_report = None self._current_item_kind = None - self.schedule_changed.emit() + self._emit_schedule_changed() if final_status in (ScheduleItemStatus.COMPLETED, ScheduleItemStatus.FAILED): notify_item_finished( @@ -986,7 +956,7 @@ class Scheduler(BECWidget, QWidget): item.status = ScheduleItemStatus.COMPLETED item.finished_at = time.time() self._persist_locked() - self.schedule_changed.emit() + self._emit_schedule_changed() return time.sleep(0.5) @@ -1002,13 +972,16 @@ class Scheduler(BECWidget, QWidget): self._persist_locked() self._refresh_ui() - # ------------------------------------------------------------------ # - # cleanup - # ------------------------------------------------------------------ # + def _emit_schedule_changed(self): + if self._closing.is_set(): + return + + self.schedule_changed.emit() + def cleanup(self): - # Note: we deliberately do NOT abort a running schedule here. The - # whole point of the Redis-backed design is that closing this - # widget must not interrupt anything already submitted to the - # scan/device server - see the module docstring. + # The schedule itself must continue running after the widget is closed. + # We only tell the worker that the UI/Qt object is no longer available. + self._closing.set() + self._guard.cleanup() - super().cleanup() # also disconnects all bec_dispatcher subscriptions for us + super().cleanup()