diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py index 103acaf..7613c76 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py @@ -284,21 +284,27 @@ def _derive_status( last_active_time, had_activity: bool, queue_locks: list | None = None, + beamline_blocking: bool = False, ) -> str: """ Returns one of: scanning -- tomo heartbeat fresh (< _TOMO_HEARTBEAT_STALE_S), OR heartbeat recently seen AND queue still has active scan (handles long individual projections > heartbeat timeout) - blocked -- queue has active scan, heartbeat stale (beyond the long- - projection grace window), AND the primary queue has at - least one lock applied (e.g. by BEC's ScanInterlockActor - when a watched beamline state goes out of spec). Only - reported when it is actually preventing a queued/active - scan from progressing. + blocked -- the experiment is being held up externally: EITHER a + watched beamline state is out of its accepted range while + the scan interlock would act on it (beamline_blocking), + OR the primary queue has at least one explicit lock + applied (queue_locks). Reported regardless of whether a + scan is currently executing: when a block hits, the + running scan is interrupted and its repeat waits for the + block to clear, so active_request_block (and hence + queue_has_active_scan) may already be cleared even though + the beamline is still blocking. Only 'scanning' (fresh + heartbeat = data still flowing) takes precedence. running -- queue has active scan but heartbeat has never been seen, - and the queue is not locked - idle -- not scanning, last_active_time known + and nothing is blocking + idle -- not scanning, not blocked, last_active_time known unknown -- no activity ever seen since generator started 'unknown' is ONLY returned before any scan activity has been observed. @@ -309,14 +315,19 @@ def _derive_status( hb_age = _heartbeat_age_s(progress.get("heartbeat")) if hb_age < _TOMO_HEARTBEAT_STALE_S: return "scanning" + # External block takes precedence over the remaining scan/idle states. + # A blocked scan is interrupted (active_request_block cleared) while its + # repeat waits for the block to clear, so we must NOT require an active + # scan here — that was the previous behaviour and it made a blocked-but- + # interrupted experiment fall through to 'idle'. + if beamline_blocking or queue_locks: + return "blocked" # Heartbeat stale but queue still active and heartbeat was seen recently # enough (within 10× the stale window) — likely a long projection, not idle. # Report as 'scanning' so audio system does not trigger a false alarm. if queue_has_active_scan and hb_age < _TOMO_HEARTBEAT_STALE_S * 10: return "scanning" if queue_has_active_scan: - if queue_locks: - return "blocked" return "running" if last_active_time is not None or had_activity: return "idle" @@ -1003,8 +1014,13 @@ class WebpageGeneratorBase: queue_has_active_scan = False queue_locks = [] - # ── Beamline states (diagnostic display only; not used for the - # 'blocked' experiment_status decision — see _read_queue_locks) ── + # ── Beamline states ────────────────────────────────────────── + # Diagnostic display AND (via beamline_blocking below) one of the two + # signals that drive the 'blocked' experiment_status; the other is + # queue_locks. A watched state that is out of its accepted range + # (mismatched) means the scan interlock is / would be holding the + # queue, even if the running scan has already been interrupted and + # active_request_block cleared. try: beamline_states_info = _read_beamline_states_info(self._bec) except Exception as exc: @@ -1012,6 +1028,14 @@ class WebpageGeneratorBase: f"beamline_states read error: {exc}", level="warning") beamline_states_info = {"enabled": None, "states": []} + # True if any watched state is out of spec. Defined identically to the + # "N watched states currently blocking" count shown on the beamline + # states card (renderBeamlineStates: states.filter(s => s.mismatched)), + # so the top status pill and that card can never disagree. + beamline_blocking = any( + s.get("mismatched") for s in beamline_states_info.get("states", []) + ) + # Current scan number (the BEC scan in progress right now, e.g. for # comparison against ptycho reconstruction filenames like S06770). # Only meaningful while a scan is actually active; None otherwise. @@ -1040,14 +1064,15 @@ class WebpageGeneratorBase: ) self._last_queue_id = latest_queue_id - if tomo_active or queue_has_active_scan or history_changed: + if (tomo_active or queue_has_active_scan or history_changed + or beamline_blocking): self._last_active_time = _epoch() self._had_activity = True exp_status = _derive_status( progress, queue_has_active_scan, self._last_active_time, self._had_activity, - queue_locks, + queue_locks, beamline_blocking, ) idle_for_s = ( None if self._last_active_time is None @@ -2099,7 +2124,7 @@ def _render_html(phone_numbers: list, has_tomo_queue: bool = True, tomo_types: d