webpage_generator: make tomo-queue and tomo-type formula setup-configurable

webpage_generator: report blocked on interlock even when scan is interrupted

- Add HAS_TOMO_QUEUE and TOMO_TYPES capability flags to
  WebpageGeneratorBase, defaulting to flomni's current behavior
  (type1 grid-8, type2/3 golden-capped) so this is a no-op for flomni.
- Gate the tomo_queue global-var read in _cycle() behind
  HAS_TOMO_QUEUE; unaffected: queue_status/queue_locks/beamline_states,
  which come from BEC's own primary scan queue and stay common to
  all setups.
- _render_html() now conditionally emits the 'Tomo queue' card and
  its DEFAULT_ORDER slot, and drives the projection-count formula
  from a TOMO_TYPES JSON blob (generic calcProjections()) instead of
  a hardcoded per-instrument JS branch.
- LamniWebpageGenerator: HAS_TOMO_QUEUE=False, TOMO_TYPES with
  2-subtomo and 8-subtomo equally_spaced_grid entries (placeholder
  keys pending real tomo_type values from the LamNI producer side).

A block interrupts the running scan, clearing active_request_block, so
the old _derive_status() (which only returned 'blocked' inside the
queue_has_active_scan branch) fell through to 'idle' with the blocked
LED off, despite watched beamline states being out of spec.

- _derive_status: return 'blocked' on beamline_blocking OR queue_locks,
  independent of active scan; only fresh-heartbeat 'scanning' outranks it.
- _cycle: compute beamline_blocking = any(state mismatched), defined
  identically to the beamline-states card so pill and card can't disagree;
  also count it as activity so the idle clock restarts once cleared.
- status-detail JS: describe blocking beamline states when no explicit
  queue lock is present. Update help text."
This commit is contained in:
x12sa
2026-07-07 10:41:40 +02:00
committed by holler
parent a20508c878
commit 524c26f706
@@ -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
<ul>
<li><strong>Scanning</strong> &mdash; a tomography scan is actively running (a heartbeat from the scan loop has been seen recently).</li>
<li><strong>Running</strong> &mdash; the scan queue has an active item, but it is not a tomo scan with a heartbeat (e.g. an alignment scan).</li>
<li><strong>Blocked</strong> &mdash; a scan is queued or active, but the scan queue itself has been locked (for example by BEC's scan interlock when a watched beamline condition, such as the shutter or ring current, is out of spec). This is usually external and self-resolves once the condition clears.</li>
<li><strong>Blocked</strong> &mdash; the experiment is being held up by a watched beamline condition that is out of spec (for example the shutter or ring current, see the Beamline states card), or by an explicit lock on the scan queue. A running scan is interrupted when this happens and its repeat waits for the condition to clear, so this shows even when no scan is momentarily executing. Usually external and self-resolves once the condition clears.</li>
<li><strong>Idle</strong> &mdash; nothing is running or queued right now.</li>
<li><strong>Unknown</strong> &mdash; shown only before any activity has been observed since the generator started.</li>
</ul>
@@ -2599,8 +2624,17 @@ const DETAILS={{
scanning: d=>'Tomo scan in progress &middot; projection '+(d.progress.projection||0)+' of '+(d.progress.total_projections||0)+' &middot; '+(d.progress.tomo_type||''),
running: d=>'Queue active &middot; outside tomo heartbeat window',
idle: d=>'Idle for <strong>'+d.idle_for_human+'</strong>',
blocked: d=>'Queue locked'+((d.queue_locks&&d.queue_locks.length>1)?' by multiple locks':'')+': <strong>'
+(d.queue_locks||[]).map(l=>esc(l.reason||l.identifier)).join('; ')+'</strong>',
blocked: d=>{{
const locks=d.queue_locks||[];
if(locks.length>0)
return 'Queue locked'+(locks.length>1?' by multiple locks':'')+': <strong>'
+locks.map(l=>esc(l.reason||l.identifier)).join('; ')+'</strong>';
const bad=(((d.beamline_states||{{}}).states)||[]).filter(s=>s.mismatched)
.map(s=>esc(s.label||s.name));
if(bad.length>0)
return 'Beamline interlock blocking: <strong>'+bad.join('; ')+'</strong>';
return 'Blocked';
}},
error: d=>'Queue stopped unexpectedly &middot; idle for <strong>'+(d.idle_for_human||'?')+'</strong>',
unknown: d=>'Waiting for first data\u2026',
}};