Add scan number display and beamline states diagnostic card

- Append the current BEC scan number to the Projection info-item,
  e.g. "120 / 400 (S06650→S06770)", for direct comparison against
  ptycho reconstruction filenames (which use the same S##### scan
  number convention). Read from
  primary.info[0].active_request_block.scan_number — already
  available on the queue object, no new global var needed.

- Add tomo_start_scan_number to the progress payload, mirroring the
  existing tomo_start_time pattern: a new tomo_progress key to be
  written once by the scan loop when a tomogram begins. Producer side
  (flomni.py) not yet implemented — falls back to showing only the
  current scan number until then.

- New "Beamline states" draggable card (positioned just above
  Contacts): lists every beamline state currently registered with
  BEC, its live status (valid/invalid/warning/unknown), and whether
  the scan interlock is watching it. Cross-references
  bec.builtin_actors.scan_interlock.states_watched/.enabled against
  each state's live status to flag which watched states are
  currently mismatched (i.e. actually contributing to a queue lock),
  vs. states that are invalid but not watched and therefore not
  blocking anything (e.g. a simulated/test shutter).

  This is a display-only diagnostic; the "blocked" experiment_status
  itself is unchanged and still derived from the queue's own locks
  (_read_queue_locks), not from beamline states directly.

- Add fmtScan() JS helper (S##### zero-padded formatting, matching
  the existing ptycho filename convention) and renderBeamlineStates().

- Help panel updated to explain both additions.
This commit is contained in:
x12sa
2026-06-30 14:31:36 +02:00
committed by holler
co-authored by holler
parent 88a320fd59
commit c4fa9b2eb7
@@ -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:
<span class="logo-suffix">&middot;&nbsp;STATUS</span>
</div>
<div class="header-right">
<button class="theme-btn" id="help-btn" onclick="openHelp()" title="What does this page show?">?</button>
<div class="theme-switcher">
<span class="ts-label">Theme</span>
<button class="theme-btn" id="theme-auto" onclick="setTheme('auto')" >Auto</button>
@@ -1753,7 +1962,8 @@ def _render_html(phone_numbers: list) -> str:
<div class="info-item"><span class="label">Sub-tomo</span><span class="value" id="pi-subtomo">-</span></div>
<div class="info-item"><span class="label">Angle</span><span class="value" id="pi-angle">-</span></div>
<div class="info-item"><span class="label">Tomo type</span><span class="value" id="pi-type">-</span></div>
<div class="info-item"><span class="label">ETA</span><span class="value" id="pi-eta">-</span></div>
<div class="info-item"><span class="label">Remaining</span><span class="value" id="pi-remaining">-</span></div>
<div class="info-item"><span class="label">Finish time</span><span class="value" id="pi-finish">-</span></div>
<div class="info-item"><span class="label">Started</span><span class="value" id="pi-start">-</span></div>
</div>
@@ -1765,6 +1975,39 @@ def _render_html(phone_numbers: list) -> str:
<img id="ptycho-lightbox-img" src="" alt="">
</div>
<!-- Help dialog -->
<dialog id="help-dialog">
<div class="help-header">
<span class="card-title" style="border-bottom:none;margin-bottom:0;padding-bottom:0">About this page</span>
<button class="help-close" onclick="closeHelp()" aria-label="Close">&times;</button>
</div>
<div class="help-body">
<h3>Measurement status</h3>
<p>The coloured pill at the top reflects what the instrument is currently doing:</p>
<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>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>
<h3>Tomography progress</h3>
<p>The rings show overall progress (outer) and progress within the current sub-tomogram (inner). <strong>Remaining</strong> is the estimated duration left; <strong>Finish time</strong> is the estimated wall-clock time the measurement will complete. The projection count is followed by the BEC scan number(s) in parentheses, e.g. (S06650&rarr;S06770), for direct comparison with reconstruction filenames.</p>
<h3>Audio warnings</h3>
<ul>
<li><strong>System LED</strong> &mdash; on when audio is enabled on this device.</li>
<li><strong>Watch LED</strong> &mdash; armed (green) while a scan is running; pulses orange if a scan stops unexpectedly, with a chime every 30s until confirmed.</li>
<li><strong>Blocked LED</strong> &mdash; pulses orange while the queue is locked. A chime fires automatically after 60 continuous minutes blocked, then repeats hourly until cleared. No confirmation needed &mdash; it clears itself once the block resolves.</li>
<li><strong>Live LED</strong> &mdash; green while this page's data feed is fresh; pulses orange if the feed goes stale or a fetch fails.</li>
</ul>
<p>On iPhone/iPad, tap <strong>Enable</strong> once to unlock audio &mdash; this is a browser requirement and only needs to be done once per visit.</p>
<h3>Beamline states</h3>
<p>Lists every beamline condition currently registered with BEC, its live status (valid/invalid/warning/unknown), and whether the scan interlock is <strong>watching</strong> it. A watched state that is not in its accepted status is what actually causes the <strong>Blocked</strong> status above &mdash; states that are not watched (like an unrelated simulated shutter) can be invalid without blocking anything.</p>
<h3>Other features</h3>
<p>Cards below the fixed status/progress section can be dragged by their &#8942;&#8942; handle to reorder; the order is remembered on this device. The theme switcher (top right) follows your OS by default, or can be forced to light/dark.</p>
</div>
</dialog>
<!-- Draggable cards container -->
<div id="card-container">
@@ -1780,6 +2023,10 @@ def _render_html(phone_numbers: list) -> str:
<div class="led" id="led-watch"></div>
<span class="led-label">Watch</span>
</div>
<div class="led-group">
<div class="led" id="led-blocked"></div>
<span class="led-label">Blocked</span>
</div>
<div class="led-group">
<div class="led" id="led-conn"></div>
<span class="led-label">Live</span>
@@ -1822,7 +2069,20 @@ def _render_html(phone_numbers: list) -> str:
<div class="instrument-grid" id="instrument-grid"></div>
</div>
<!-- 4. Contacts -->
<!-- 4. Beamline states -->
<div class="card draggable-card" id="blstates-card" data-card-id="blstates">
<div class="drag-handle" title="Drag to reorder">&#8942;&#8942;</div>
<div class="card-title">Beamline states</div>
<div class="blstates-summary" id="blstates-summary"></div>
<table class="blstates-table" id="blstates-table">
<thead>
<tr><th>State</th><th>Status</th><th>Watched</th><th>Label</th></tr>
</thead>
<tbody id="blstates-tbody"></tbody>
</table>
</div>
<!-- 5. Contacts -->
<div class="card draggable-card" data-card-id="contacts">
<div class="drag-handle" title="Drag to reorder">&#8942;&#8942;</div>
<div class="card-title">Contacts</div>
@@ -1860,7 +2120,7 @@ function setTheme(t) {{
// ── Drag-and-drop card ordering ──────────────────────────────────────────
const CARD_ORDER_KEY = 'cardOrder';
const DEFAULT_ORDER = ['audio','recon-queue','ptycho','instrument','contacts'];
const DEFAULT_ORDER = ['audio','recon-queue','ptycho','instrument','blstates','contacts'];
let _dragSrc = null;
function savedOrder() {{
@@ -1932,6 +2192,9 @@ initDrag();
let audioCtx=null, audioEnabled=false;
let audioArmed=false, warningActive=false, warningTimer=null, lastStatus=null;
let staleActive=false, staleTimer=null, staleConfirmed=false;
let blockedSince=null, blockedWarningActive=false, blockedChimeTimer=null;
const BLOCKED_WARNING_DELAY_MS=60*60*1000; // first chime after 60 min continuously blocked
const BLOCKED_CHIME_REPEAT_MS=60*60*1000; // repeat every 60 min while still blocked
function getCtx(){{
if(!audioCtx) audioCtx=new(window.AudioContext||window.webkitAudioContext)();
@@ -1967,6 +2230,12 @@ function staleChime(){{
setTimeout(()=>beep(1200,0.12,0.35),180);
setTimeout(()=>beep(1200,0.25,0.35),360);
}}
function blockedChime(){{
// Distinct from warningChime/staleChime: a slow low->low->high triple knock.
beep(330,0.25,0.4);
setTimeout(()=>beep(330,0.25,0.4),300);
setTimeout(()=>beep(523,0.4, 0.4),600);
}}
function testSound(){{
// Gesture handler — unlock first, then delay beeps 80ms for resume().
@@ -1986,8 +2255,10 @@ function toggleAudio(){{
document.getElementById('btn-confirm').style.display='none';
stopStaleWarning(); staleActive=false; staleConfirmed=false;
document.getElementById('btn-confirm-stale').style.display='none';
stopBlockedChime(); blockedWarningActive=false;
}} else {{
if(lastStatus==='scanning' && !audioArmed) audioArmed=true;
if(blockedSince!==null && blockedChimeTimer===null) scheduleBlockedChime();
}}
updateAudioUI();
}}
@@ -2048,9 +2319,45 @@ function handleStale(isStale){{
}}
}}
function stopBlockedChime(){{
if(blockedChimeTimer){{clearTimeout(blockedChimeTimer);blockedChimeTimer=null;}}
}}
function scheduleBlockedChime(){{
// Fires once after BLOCKED_WARNING_DELAY_MS of continuous 'blocked' status,
// then repeats every BLOCKED_CHIME_REPEAT_MS for as long as it stays blocked.
// No confirm button — purely informational, auto-clears on recovery, since
// blocked states are external (beamline) and typically self-resolve.
blockedChimeTimer=setTimeout(()=>{{
if(audioEnabled) blockedChime();
blockedWarningActive=true;
updateAudioUI();
scheduleBlockedChime();
}}, blockedWarningActive ? BLOCKED_CHIME_REPEAT_MS : BLOCKED_WARNING_DELAY_MS);
}}
function handleBlocked(isBlocked){{
if(isBlocked){{
if(blockedSince===null){{
blockedSince=Date.now();
blockedWarningActive=false;
stopBlockedChime();
if(audioEnabled) scheduleBlockedChime();
}}
}}else{{
if(blockedSince!==null){{
blockedSince=null;
blockedWarningActive=false;
stopBlockedChime();
updateAudioUI();
}}
}}
}}
function updateAudioUI(){{
const ledSys=document.getElementById('led-system'),
ledWatch=document.getElementById('led-watch'),
ledBlocked=document.getElementById('led-blocked'),
ledConn=document.getElementById('led-conn'),
btn=document.getElementById('btn-toggle'),
txt=document.getElementById('audio-text');
@@ -2060,6 +2367,7 @@ function updateAudioUI(){{
btn.classList.toggle('active',audioEnabled);
ledConn.className='led'+(staleActive?' led-warning':' led-live');
ledBlocked.className='led'+(blockedSince!==null?' led-blocked':'');
const scanRunning=(lastStatus==='scanning');
if(!audioEnabled){{
@@ -2074,6 +2382,11 @@ function updateAudioUI(){{
}}else if(staleActive){{
ledWatch.className='led';
txt.textContent='Live feed lost \u2014 confirm to silence';
}}else if(blockedSince!==null){{
ledWatch.className='led';
txt.textContent=blockedWarningActive
? 'Beamline blocked \u2014 chiming hourly until cleared'
: 'Beamline blocked \u2014 will chime after 60 min if unresolved';
}}else if(audioArmed && scanRunning){{
ledWatch.className='led led-armed';
txt.textContent='Armed \u2014 will warn when measurement stops';
@@ -2107,6 +2420,17 @@ function handleAudioForStatus(status, prevStatus){{
}}
}}
// ── Help dialog ──────────────────────────────────────────────────────────
function openHelp(){{
document.getElementById('help-dialog').showModal();
}}
function closeHelp(){{
document.getElementById('help-dialog').close();
}}
document.getElementById('help-dialog').addEventListener('click', e=>{{
if(e.target.id==='help-dialog') closeHelp(); // click on backdrop area
}});
// ── Ptychography images ────────────────────────────────────────────
let _ptychoScanId = null;
@@ -2157,11 +2481,13 @@ function renderPtycho(ptycho){{
}}
// ── Status rendering ──────────────────────────────────────────────────────
const LABELS={{scanning:'SCANNING',running:'RUNNING',idle:'IDLE',error:'STOPPED',unknown:'UNKNOWN'}};
const LABELS={{scanning:'SCANNING',running:'RUNNING',idle:'IDLE',blocked:'BLOCKED',error:'STOPPED',unknown:'UNKNOWN'}};
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>',
error: d=>'Queue stopped unexpectedly &middot; idle for <strong>'+(d.idle_for_human||'?')+'</strong>',
unknown: d=>'Waiting for first data\u2026',
}};
@@ -2169,6 +2495,12 @@ const DETAILS={{
function setRing(id,circ,pct){{document.getElementById(id).style.strokeDashoffset=circ*(1-Math.min(Math.max(pct,0),1));}}
function fmtAngle(v){{const n=parseFloat(v);return isNaN(n)?'-':n.toFixed(2)+'\u00b0';}}
function fmtTime(iso){{if(!iso)return'-';try{{return new Date(iso).toLocaleTimeString([],{{hour:'2-digit',minute:'2-digit'}});}}catch{{return iso;}}}}
function fmtScan(n){{
if(n==null) return null;
const i=parseInt(n,10);
if(isNaN(i)) return null;
return 'S'+String(i).padStart(5,'0');
}}
function esc(s){{return String(s==null?'N/A':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
function renderInstrument(setup){{
@@ -2192,6 +2524,39 @@ function renderInstrument(setup){{
grid.innerHTML=html;
}}
function renderBeamlineStates(bl){{
const summary=document.getElementById('blstates-summary'),
tbody=document.getElementById('blstates-tbody');
const states=(bl&&bl.states)||[];
const enabledTxt = bl&&bl.enabled===true ? 'enabled' : (bl&&bl.enabled===false ? 'disabled' : 'unknown');
const mismatched = states.filter(s=>s.mismatched);
if(states.length===0){{
summary.innerHTML='Scan interlock <strong>'+enabledTxt+'</strong> &middot; no beamline states configured';
tbody.innerHTML='<tr><td colspan="4"><div class="blstates-none">No beamline states configured</div></td></tr>';
return;
}}
summary.innerHTML = 'Scan interlock <strong>'+enabledTxt+'</strong>'
+ (mismatched.length>0
? ' &middot; <strong>'+mismatched.length+'</strong> watched state'+(mismatched.length>1?'s':'')+' currently blocking'
: ' &middot; all watched states OK');
let html='';
states.forEach(s=>{{
const statusCls='bl-badge bl-'+(s.status||'unknown');
const watchedCls='bl-badge '+(s.watched?'bl-watched-yes':'bl-watched-no');
const rowCls=s.mismatched?' class="mismatched"':'';
html+='<tr'+rowCls+'>'
+'<td class="bl-name">'+esc(s.name)+'</td>'
+'<td><span class="'+statusCls+'">'+esc(s.status)+'</span></td>'
+'<td><span class="'+watchedCls+'">'+(s.watched?'watched':'not watched')+'</span></td>'
+'<td class="bl-label">'+esc(s.label)+'</td>'
+'</tr>';
}});
tbody.innerHTML=html;
}}
function render(d){{
const s=d.experiment_status||'unknown',p=d.progress||{{}};
document.body.className=s;
@@ -2201,11 +2566,17 @@ function render(d){{
const sPct=p.subtomo_total_projections>0?p.subtomo_projection/p.subtomo_total_projections:0;
setRing('ring-outer',289.03,oPct); setRing('ring-inner',213.63,sPct);
document.getElementById('ring-pct').textContent=Math.round(oPct*100)+'%';
document.getElementById('pi-proj').textContent=(p.projection||0)+' / '+(p.total_projections||0);
let projText=(p.projection||0)+' / '+(p.total_projections||0);
const startScan=fmtScan(p.tomo_start_scan_number), curScan=fmtScan(p.current_scan_number);
if(startScan && curScan && startScan!==curScan) projText+=' ('+startScan+'\u2192'+curScan+')';
else if(curScan) projText+=' ('+curScan+')';
else if(startScan) projText+=' ('+startScan+')';
document.getElementById('pi-proj').textContent=projText;
document.getElementById('pi-subtomo').textContent=p.subtomo||'-';
document.getElementById('pi-angle').textContent=fmtAngle(p.angle);
document.getElementById('pi-type').textContent=p.tomo_type||'-';
document.getElementById('pi-eta').textContent=p.estimated_remaining_human||'-';
document.getElementById('pi-remaining').textContent=p.estimated_remaining_human||'-';
document.getElementById('pi-finish').textContent=fmtTime(p.estimated_finish_time);
document.getElementById('pi-start').textContent=fmtTime(p.tomo_start_time);
if(d.recon){{
@@ -2216,18 +2587,21 @@ function render(d){{
document.getElementById('recon-path').textContent=d.recon.folder_path||'';
}}
renderInstrument(d.setup);
renderBeamlineStates(d.beamline_states);
renderPtycho(d.ptycho);
document.getElementById('last-update').textContent='updated '+new Date(d.generated_at).toLocaleTimeString();
const ageS=(Date.now()/1000)-d.generated_at_epoch;
const isStale=ageS>STALE_S;
document.getElementById('outdated-banner').classList.toggle('visible',isStale);
handleStale(isStale);
handleBlocked(s==='blocked');
document.getElementById('footer-gen').textContent='generator: '+((d.generator||{{}}).owner_id||'-');
document.getElementById('footer-hb').textContent='tomo_heartbeat: '+(p.tomo_heartbeat_age_s!=null?p.tomo_heartbeat_age_s+'s ago':'none');
const prevStatus=lastStatus;
lastStatus=s;
handleAudioForStatus(s, prevStatus);
updateAudioUI();
}}
let _fetchFailCount=0;