From 723070794c41b92e3d5bb1f940d7800eef408136 Mon Sep 17 00:00:00 2001 From: x12sa Date: Fri, 3 Jul 2026 07:02:30 +0200 Subject: [PATCH] block entry while queue or tomo heartbeat --- .../widgets/tomo_params/tomo_params.py | 131 +++++++++++++++--- 1 file changed, 114 insertions(+), 17 deletions(-) diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py index b18e5f1..778928c 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -14,6 +14,22 @@ sync with changes made in a parallel CLI session. The parameter panel is refreshed on demand (when the user opens it or submits changes) rather than on every poll, to avoid clobbering in-progress edits. +Beamline-busy detection +------------------------ +Two independent "something is running" signals gate parameter submission and +drive an advisory banner: + +* ``tomo_progress["heartbeat"]`` — set by ``_tomo_scan_at_angle()`` at every + projection, whether the scan was queue- or CLI-started. +* the BEC scan queue — catches a single ptycho projection (or any other scan) + run manually outside a tomo run, which writes no heartbeat. The busy logic + mirrors bec_widgets' ``BECQueue`` widget so it stays consistent with the + queue display. + +The banner is advisory (soft-warn): editing while busy is still allowed — +property-backed params such as counting time take effect on the next +projection — but Submit asks for confirmation. + Queue execution --------------- ``tomo_queue_execute()`` is a blocking Flomni CLI method that cannot be @@ -28,6 +44,7 @@ import datetime from typing import Any, Optional from bec_lib import bec_logger +from bec_lib.endpoints import MessageEndpoints from bec_widgets import BECWidget, SafeSlot from qtpy.QtCore import Qt, QTimer from qtpy.QtWidgets import ( @@ -70,6 +87,10 @@ STATUS_COLORS = { "done": "#4CAF50", } +# Scan-queue item statuses that mean "not occupying hardware". +# Mirrors bec_widgets' BECQueue.update_queue busy logic verbatim. +_QUEUE_IDLE_STATUSES = ("STOPPED", "COMPLETED", "IDLE") + # Exact tuple from Flomni._TOMO_QUEUE_PARAM_NAMES (source of truth in flomni.py) QUEUE_PARAM_NAMES = ( "tomo_countingtime", @@ -132,6 +153,8 @@ class TomoParamsWidget(BECWidget, QWidget): Layout ------ + Top: + - Advisory beamline-busy banner (hidden unless a scan/queue is active) Top half (scrollable): - Read-only sample-name header - Tomo-type dropdown (always visible) @@ -148,13 +171,7 @@ class TomoParamsWidget(BECWidget, QWidget): PLUGIN = True - USER_ACCESS = [ - "refresh", - "enter_edit_mode", - "cancel_edit", - "submit_params", - "add_to_queue", - ] + USER_ACCESS = ["refresh", "enter_edit_mode", "cancel_edit", "submit_params", "add_to_queue"] _POLL_INTERVAL_MS = 2000 @@ -164,6 +181,7 @@ class TomoParamsWidget(BECWidget, QWidget): self._edit_mode = False self._pw: dict[str, QWidget] = {} # key -> input widget self._queue_dlg: Optional["TomoQueueDialog"] = None + self._busy_banner: Optional[QLabel] = None # check whether the FlOMNI setup is active before building the UI self._flomni_available = self._check_flomni_available() self._build_ui() @@ -173,6 +191,15 @@ class TomoParamsWidget(BECWidget, QWidget): if self._flomni_available: self._poll_timer.start() self.refresh() + # live beamline-busy banner: react to scan-queue changes as they + # happen (the tomo heartbeat has no event and is covered by _on_poll) + try: + self.bec_dispatcher.connect_slot( + self._on_queue_status, MessageEndpoints.scan_queue_status() + ) + except Exception as exc: + logger.warning(f"TomoParamsWidget: could not subscribe to scan queue: {exc}") + self._refresh_busy_banner() # ── setup detection ────────────────────────────────────────────────────── @@ -226,9 +253,26 @@ class TomoParamsWidget(BECWidget, QWidget): root = QVBoxLayout(self) root.setSpacing(6) + # advisory busy banner sits at the very top, present regardless of + # setup so it can warn before the user starts editing (hidden default) + self._busy_banner = QLabel("") + self._busy_banner.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._busy_banner.setWordWrap(True) + self._busy_banner.setStyleSheet( + "QLabel {" + " background-color: #FF9800;" + " color: black;" + " font-weight: bold;" + " border-radius: 4px;" + " padding: 6px;" + "}" + ) + self._busy_banner.setVisible(False) + root.addWidget(self._busy_banner) + if not self._flomni_available: lbl = QLabel( - "⚠ FlOMNI setup not detected in this BEC session.\n\n" + "⚠ FlOMNI setup not detected in this BEC session.\n\n" "This widget is only meaningful when the FlOMNI\n" "BEC session is active (fsamroy device present)." ) @@ -367,11 +411,7 @@ class TomoParamsWidget(BECWidget, QWidget): form.setLabelAlignment(Qt.AlignmentFlag.AlignRight) self._add_int( - form, - "_numprj_type3", - "Projections per sub-tomo (type 3)", - min_=1, - max_=10000, + form, "_numprj_type3", "Projections per sub-tomo (type 3)", min_=1, max_=10000 ) self._add_int( form, "golden_ratio_bunch_size", "Bunch size (type 2 only)", min_=1, max_=10000 @@ -624,6 +664,62 @@ class TomoParamsWidget(BECWidget, QWidget): """Periodic refresh: params (guarded against edit mode) + sample name.""" self._refresh_params() self._refresh_sample_name() + self._refresh_busy_banner() + + # ── beamline-busy detection ─────────────────────────────────────────────── + + def _is_scan_queue_busy(self) -> bool: + """ + Return True if BEC's primary scan queue is actively running or paused + mid-scan. + + Catches a single ptycho projection run manually (outside a tomo scan): + it writes no ``tomo_progress`` heartbeat but still occupies the + beamline. Mirrors the busy logic in bec_widgets' BECQueue widget so + it stays consistent with what the queue display shows. + """ + try: + msg = self.client.connector.get(MessageEndpoints.scan_queue_status()) + if msg is None: + return False # no scan has run yet this session + primary = msg.content.get("queue", {}).get("primary") + if not primary: + return False + queue_info = getattr(primary, "info", None) + if not queue_info: + return False + return not all(item.status in _QUEUE_IDLE_STATUSES for item in queue_info) + except Exception as exc: + logger.warning(f"TomoParamsWidget: scan-queue busy check failed: {exc}") + return False + + def _beamline_busy_reason(self) -> Optional[str]: + """Return a short human reason if the beamline is busy, else None.""" + if self._is_tomo_running(): + return "tomo scan running" + if self._is_scan_queue_busy(): + return "scan queue active (manual projection or other scan)" + return None + + @SafeSlot(dict, dict) + def _on_queue_status(self, _content: dict, _meta: dict) -> None: + """Dispatcher slot: scan-queue status changed -> refresh the banner.""" + self._refresh_busy_banner() + + def _refresh_busy_banner(self) -> None: + """Show/hide the advisory busy banner based on current beamline state.""" + banner = getattr(self, "_busy_banner", None) + if banner is None: + return + reason = self._beamline_busy_reason() + if reason: + banner.setText( + f"\u26a0 Beamline busy — {reason}.\n" + "Editing is allowed, but submitting changes will ask for confirmation." + ) + banner.setVisible(True) + else: + banner.setVisible(False) # ── public refresh API ──────────────────────────────────────────────────── @@ -723,15 +819,16 @@ class TomoParamsWidget(BECWidget, QWidget): def submit_params(self) -> None: """Validate, write to global vars, and re-lock fields.""" - if self._is_tomo_running(): + busy_reason = self._beamline_busy_reason() + if busy_reason: reply = QMessageBox.warning( self, "Scan in progress", - "A tomo scan appears to be running.\n\n" + f"A scan appears to be running ({busy_reason}).\n\n" "Parameters read via properties (e.g. counting time) take effect " "immediately on the next projection. Parameters used to build the " - "scan trajectory (FOV, angle step) are already fixed for this run " - "but will affect any subsequent queue job.\n\n" + "scan trajectory (FOV, angle step) are already fixed for the " + "current run but will affect any subsequent queue job.\n\n" "Apply changes now?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, )