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 6ea3b10..e6694d1 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 @@ -179,6 +179,102 @@ def _gvar(bec_client, key, fmt=None, suffix=""): except Exception: return str(val) +def _read_beamline_states_info(bec_client) -> dict: + """Return a diagnostic snapshot of all configured beamline states, for + display only (NOT used to derive the 'blocked' experiment status — that + is computed from the queue's own locks via _read_queue_locks(), which is + the authoritative source; see its docstring for why). + + Returns: + { + "enabled": bool | None, # bec.builtin_actors.scan_interlock.enabled + "states": [ + { + "name": str, + "status": str, # 'valid' | 'invalid' | 'warning' | 'unknown' + "label": str, + "watched": bool, # True if present in states_watched + "accepted": list[str] | None, # accepted statuses if watched + "mismatched": bool, # True if watched AND status not in accepted + }, + ... + ], + } + + Beamline states are registered dynamically and can differ from + experiment to experiment, so names are read from the manager's _states + dict (the same one show_all() iterates over internally — there is no + public "list names" method as of this BEC version). states_watched and + enabled are read through the public scan_interlock HLI properties. + """ + manager = getattr(bec_client, "beamline_states", None) + if manager is None: + return {"enabled": None, "states": []} + + try: + names = list(getattr(manager, "_states", {}).keys()) + except Exception: + names = [] + + try: + interlock = bec_client.builtin_actors.scan_interlock + enabled = interlock.enabled + states_watched = interlock.states_watched or {} + except Exception: + enabled = None + states_watched = {} + + states = [] + for name in names: + try: + state_obj = getattr(manager, name, None) + if state_obj is None: + continue + info = state_obj.get() # {"status": ..., "label": ...} + status = info.get("status", "unknown") + label = info.get("label", name) + except Exception: + status, label = "unknown", name + + accepted = states_watched.get(name) + watched = accepted is not None + mismatched = watched and status not in accepted + + states.append({ + "name": name, + "status": status, + "label": label, + "watched": watched, + "accepted": accepted, + "mismatched": mismatched, + }) + + return {"enabled": enabled, "states": states} + + +def _read_queue_locks(primary) -> list: + """Return a list of {"identifier": str, "reason": str} for every lock + currently applied to the primary scan queue. + + BEC's scan_queue.status becomes "LOCKED" (saving the prior status to + restore later) whenever any lock is added, e.g. by the ScanInterlockActor + when a watched beamline state goes out of spec (see scan_interlock.py: + add_queue_lock(queue="primary", reason=..., lock_id="ScanInterlockActor")). + Reading the lock list directly is more robust than re-deriving "blocked" + from beamline states ourselves: it reflects whatever is actually holding + the queue, regardless of which actor (interlock or otherwise) caused it, + and survives changes to which beamline states are configured/watched. + """ + if primary is None: + return [] + try: + return [ + {"identifier": lock.identifier, "reason": lock.reason} + for lock in (primary.locks or []) + ] + except Exception: + return [] + # --------------------------------------------------------------------------- # Status derivation @@ -189,19 +285,27 @@ def _derive_status( queue_has_active_scan: bool, last_active_time, had_activity: bool, + queue_locks: list | None = None, ) -> 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) - running -- queue has active scan but heartbeat has never been seen + 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. + running -- queue has active scan but heartbeat has never been seen, + and the queue is not locked idle -- not scanning, last_active_time known unknown -- no activity ever seen since generator started 'unknown' is ONLY returned before any scan activity has been observed. Once activity has been seen the status goes directly: - scanning/running -> idle + scanning/running/blocked -> idle never through 'unknown'. """ hb_age = _heartbeat_age_s(progress.get("heartbeat")) @@ -213,6 +317,8 @@ def _derive_status( 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" @@ -852,7 +958,10 @@ class WebpageGeneratorBase: progress = self._bec.get_global_var("tomo_progress") or {} # ── Queue ──────────────────────────────────────────────────── - # queue_status is always 'RUNNING' while BEC is alive. + # queue_status reflects BEC's ScanQueueStatus: 'RUNNING', 'PAUSED', + # or 'LOCKED' (the latter set whenever any lock — e.g. from + # ScanInterlockActor — is applied to the queue; the prior status is + # restored once the lock is released). # A scan is actually executing only when info is non-empty AND # active_request_block is set on the first entry. try: @@ -864,9 +973,30 @@ class WebpageGeneratorBase: and len(primary.info) > 0 and primary.info[0].active_request_block is not None ) + queue_locks = _read_queue_locks(primary) except Exception: queue_status = "unknown" queue_has_active_scan = False + queue_locks = [] + + # ── Beamline states (diagnostic display only; not used for the + # 'blocked' experiment_status decision — see _read_queue_locks) ── + try: + beamline_states_info = _read_beamline_states_info(self._bec) + except Exception as exc: + self._log(VERBOSITY_VERBOSE, + f"beamline_states read error: {exc}", level="warning") + beamline_states_info = {"enabled": None, "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. + current_scan_number = None + if queue_has_active_scan: + try: + current_scan_number = primary.info[0].active_request_block.scan_number + except Exception: + current_scan_number = None # ── Idle tracking ──────────────────────────────────────────── hb_age = _heartbeat_age_s(progress.get("heartbeat")) @@ -893,6 +1023,7 @@ class WebpageGeneratorBase: exp_status = _derive_status( progress, queue_has_active_scan, self._last_active_time, self._had_activity, + queue_locks, ) idle_for_s = ( None if self._last_active_time is None @@ -928,6 +1059,8 @@ class WebpageGeneratorBase: "queue_has_active_scan": queue_has_active_scan, "idle_for_s": idle_for_s, "idle_for_human": _format_duration(idle_for_s), + "queue_locks": queue_locks, + "beamline_states": beamline_states_info, "progress": { "tomo_type": progress.get("tomo_type", "N/A"), "projection": progress.get("projection", 0), @@ -937,10 +1070,13 @@ class WebpageGeneratorBase: "subtomo_total_projections": progress.get("subtomo_total_projections", 1), "angle": progress.get("angle", 0), "tomo_start_time": progress.get("tomo_start_time"), + "tomo_start_scan_number": progress.get("tomo_start_scan_number"), + "current_scan_number": current_scan_number, "estimated_remaining_s": progress.get("estimated_remaining_time"), "estimated_remaining_human": _format_duration( progress.get("estimated_remaining_time") ), + "estimated_finish_time": progress.get("estimated_finish_time"), "tomo_heartbeat": progress.get("heartbeat"), "tomo_heartbeat_age_s": round(hb_age, 1) if hb_age != float("inf") else None, }, @@ -985,12 +1121,16 @@ class WebpageGeneratorBase: self._uploader.upload_changed_async(self._output_dir) # ── Console feedback ────────────────────────────────────────── + blocked_summary = ( + f" locked_by={','.join(l['identifier'] for l in queue_locks)}" + if queue_locks else "" + ) self._log(VERBOSITY_VERBOSE, f"[{_now_iso()}] {exp_status:<12} active={queue_has_active_scan} " f"proj={payload['progress']['projection']}/" f"{payload['progress']['total_projections']} " f"hb={payload['progress']['tomo_heartbeat_age_s']}s " - f"idle={_format_duration(idle_for_s)}") + f"idle={_format_duration(idle_for_s)}" + blocked_summary) self._log(VERBOSITY_DEBUG, f" payload:\n{json.dumps(payload, indent=4, default=str)}") @@ -1331,6 +1471,8 @@ def _render_html(phone_numbers: list) -> str: --c-scanning: #89dceb; --c-running: #a6e3a1; --c-idle-short: #f9e2af; + --c-idle-long: #f38ba8; + --c-blocked: #fab387; --c-error: #f38ba8; --status-color: #6c7a9c; --ring-blend: #4a5568; @@ -1373,6 +1515,7 @@ def _render_html(phone_numbers: list) -> str: body.scanning {{ --status-color: var(--c-scanning); --ring-blend: #3a6b74; }} body.running {{ --status-color: var(--c-running); --ring-blend: #3a6644; }} body.idle {{ --status-color: var(--c-idle-short); --ring-blend: #7a6e44; }} + body.blocked {{ --status-color: var(--c-blocked); --ring-blend: #7a5a3a; }} body.error {{ --status-color: var(--c-error); --ring-blend: #7a3a44; }} body.unknown {{ --status-color: var(--text-dim); --ring-blend: #4a5568; }} @@ -1585,6 +1728,36 @@ def _render_html(phone_numbers: list) -> str: border-radius: 4px; box-shadow: 0 8px 40px rgba(0,0,0,0.6); }} + /* Help dialog */ + #help-dialog {{ + background: var(--surface); color: var(--text); + border: 1px solid var(--border); border-radius: 8px; + padding: 1.5rem 1.75rem; max-width: 560px; width: 90vw; + max-height: 80vh; overflow-y: auto; + font-family: var(--sans); font-weight: 300; + }} + #help-dialog::backdrop {{ background: rgba(0,0,0,0.6); }} + .help-header {{ + display: flex; align-items: center; justify-content: space-between; + border-bottom: 1px solid var(--border); padding-bottom: 0.75rem; margin-bottom: 1rem; + }} + .help-close {{ + background: none; border: none; color: var(--text-dim); + font-size: 1.4rem; line-height: 1; padding: 0 0.3rem; min-height: auto; + cursor: pointer; + }} + .help-close:hover {{ color: var(--text); background: none; }} + .help-body h3 {{ + font-family: var(--mono); font-size: 0.7rem; font-weight: 700; + letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-dim); + margin: 1.1rem 0 0.5rem; + }} + .help-body h3:first-child {{ margin-top: 0; }} + .help-body p {{ font-size: 0.85rem; line-height: 1.6; color: var(--text-dim); margin-bottom: 0.5rem; }} + .help-body ul {{ margin: 0 0 0.5rem 1.1rem; padding: 0; }} + .help-body li {{ font-size: 0.85rem; line-height: 1.6; color: var(--text-dim); margin-bottom: 0.4rem; }} + .help-body strong {{ color: var(--text); font-weight: 600; }} + /* ── Instrument details ── */ .instrument-grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem 2rem; @@ -1602,6 +1775,38 @@ def _render_html(phone_numbers: list) -> str: .kv-table td:last-child {{ color: var(--text); font-weight: 600; text-align: right; }} .temp-null {{ color: var(--text-dim) !important; font-weight: 400 !important; }} + /* ── Beamline states ── */ + .blstates-summary {{ + font-size: 0.82rem; color: var(--text-dim); margin-bottom: 0.9rem; + }} + .blstates-summary strong {{ color: var(--text); font-weight: 600; }} + .blstates-table {{ width: 100%; border-collapse: collapse; }} + .blstates-table th {{ + font-family: var(--mono); font-size: 0.6rem; font-weight: 700; + letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-dim); + text-align: left; padding: 0 0.6rem 0.5rem 0; border-bottom: 1px solid var(--border); + }} + .blstates-table td {{ + padding: 0.45rem 0.6rem 0.45rem 0; font-size: 0.82rem; + border-bottom: 1px solid var(--border); vertical-align: middle; + }} + .blstates-table tr:last-child td {{ border-bottom: none; }} + .blstates-table tr.mismatched td {{ color: var(--c-blocked); }} + .bl-name {{ font-family: var(--mono); font-size: 0.78rem; color: var(--text); white-space: nowrap; }} + .bl-label {{ color: var(--text-dim); }} + .bl-badge {{ + display: inline-block; font-family: var(--mono); font-size: 0.6rem; font-weight: 700; + letter-spacing: 0.06em; text-transform: uppercase; padding: 0.15rem 0.5rem; + border-radius: 100px; white-space: nowrap; + }} + .bl-badge.bl-valid {{ background: color-mix(in srgb, var(--c-running) 22%, transparent); color: var(--c-running); }} + .bl-badge.bl-invalid {{ background: color-mix(in srgb, var(--c-error) 22%, transparent); color: var(--c-error); }} + .bl-badge.bl-warning {{ background: color-mix(in srgb, var(--c-idle-short) 25%, transparent); color: var(--c-idle-short); }} + .bl-badge.bl-unknown {{ background: var(--surface2); color: var(--text-dim); }} + .bl-badge.bl-watched-yes {{ background: color-mix(in srgb, var(--c-scanning) 18%, transparent); color: var(--c-scanning); }} + .bl-badge.bl-watched-no {{ background: var(--surface2); color: var(--text-dim); }} + .blstates-none {{ font-family: var(--mono); font-size: 0.75rem; color: var(--text-dim); padding: 0.5rem 0; }} + /* ── Audio card ── */ .audio-card {{ display: flex; align-items: center; @@ -1628,6 +1833,9 @@ def _render_html(phone_numbers: list) -> str: .led.led-warning {{ background: var(--c-idle-long); border-color: var(--c-idle-long); box-shadow: 0 0 8px var(--c-idle-long); animation: led-pulse 1s ease-in-out infinite; }} + .led.led-blocked {{ background: var(--c-blocked); border-color: var(--c-blocked); + box-shadow: 0 0 8px var(--c-blocked); + animation: led-pulse 1s ease-in-out infinite; }} @keyframes led-pulse {{ 0%,100% {{ opacity: 1; }} 50% {{ opacity: 0.4; }} }} @@ -1716,6 +1924,7 @@ def _render_html(phone_numbers: list) -> str: · STATUS
| State | Status | Watched | Label |
|---|