Flomni commissioning 4 #250

Merged
holler merged 33 commits from flomni_commissioning_4 into main 2026-07-07 10:41:41 +02:00
14 changed files with 4381 additions and 270 deletions
@@ -49,6 +49,22 @@ class LamniWebpageGenerator(WebpageGeneratorBase):
sample name, and measurement settings.
"""
# LamNI has no persisted parameter-snapshot job queue (unlike flomni
# and, later, omny) — the "Tomo queue" card and its global-var read
# are skipped entirely for this setup.
HAS_TOMO_QUEUE = False
# LamNI's tomo scans come in two flavours: a 2-subtomo and an
# 8-subtomo variant, both using the same equally-spaced-grid formula
# as flomni's type 1 (just with a different sub-tomo count).
# TODO: replace the placeholder keys (1, 2) with whatever values
# progress["tomo_type"] actually takes for LamNI once the producer
# side (LamNI equivalent of flomni.py) defines them.
TOMO_TYPES = {
1: {"kind": "equally_spaced_grid", "n_subtomos": 2},
2: {"kind": "equally_spaced_grid", "n_subtomos": 8},
}
# TODO: fill in LamNI-specific device paths
# label -> dotpath under device_manager.devices
_TEMP_MAP = {
@@ -86,4 +102,4 @@ class LamniWebpageGenerator(WebpageGeneratorBase):
return {
"type": "lamni",
# LamNI-specific data here
}
}
@@ -1,6 +1,8 @@
import builtins
import datetime
import json
import os
import random
import subprocess
import time
from pathlib import Path
@@ -463,7 +465,117 @@ class FlomniSampleTransferMixin:
def laser_tracker_off(self):
dev.rtx.controller.laser_tracker_off()
def umvr_fsamy_tracked(self, shift: float, step: float = 0.01):
def laser_parameters_show_all(self):
dev.rtx.controller.emitter_show_all()
def laser_parameters_set_targety(self, val: float):
dev.rtx.controller.emitter_set_tracking_target_y(val)
def laser_parameters_set_targetz(self, val: float):
dev.rtx.controller.emitter_set_tracking_target_z(val)
def laser_parameters_set_intensity_threshold_laser(self, val: float):
dev.rtx.controller.emitter_set_intensity_threshold_laser(val)
def laser_parameters_set_psd_intensity_threshold_tracking_low(self, val: float):
dev.rtx.controller.emitter_set_psd_intensity_threshold_tracking_low(val)
def laser_tweak(self):
"""Interactive tweak of the two laser tracking target positions.
Arrow keys nudge the ConstEmitter tracking targets by a fixed step of
0.01 (absolute set of current +/- step):
left / right : target y -/+
up / down : target z +/-
Keys:
s : return both targets to the positions they had at launch
q : quit
After every change the current y/z targets and the fzp x interferometer
signal strength (channel 1) are printed, so one can watch the signal
respond while tweaking.
"""
import fcntl
import sys
import termios
import tty
step = 0.01
controller = dev.rtx.controller
def read_targets():
values = controller.emitter_get()
return (
values["targetpos_tracking_y"],
values["targetpos_tracking_z"],
)
def signal_fzpx():
# second channel (index 1) of show_signal_strength_interferometer,
# i.e. the fzp x channel / tracker signal
return controller.read_ssi_interferometer(1)
def print_status():
target_y, target_z = read_targets()
print(
f"\rtarget y: {target_y:.4f} target z: {target_z:.4f} "
f"fzp x signal: {signal_fzpx():.1f}\r"
)
# capture launch positions for the 's' (return to start) command
start_y, start_z = read_targets()
print(
"Laser tweak. "
+ self.OMNYTools.BOLD
+ self.OMNYTools.OKBLUE
+ "left/right: target y, up/down: target z, s: start pos, q: quit\r"
+ self.OMNYTools.ENDC
)
print_status()
fd = sys.stdin.fileno()
old_term = termios.tcgetattr(fd)
try:
tty.setraw(fd)
old_flags = fcntl.fcntl(fd, fcntl.F_GETFL)
fcntl.fcntl(fd, fcntl.F_SETFL, old_flags | os.O_NONBLOCK)
while True:
try:
key = sys.stdin.read(1)
if key == "q":
print("\n\rExiting tweak mode\r")
break
elif key == "s":
controller.emitter_set_tracking_target_y(start_y)
controller.emitter_set_tracking_target_z(start_z)
print("\rReturned to start positions\r")
print_status()
elif key == "\x1b": # escape sequences for arrow keys
next1, next2 = sys.stdin.read(2)
if next1 == "[":
target_y, target_z = read_targets()
if next2 == "A": # up: target z +
controller.emitter_set_tracking_target_z(target_z + step)
print_status()
elif next2 == "B": # down: target z -
controller.emitter_set_tracking_target_z(target_z - step)
print_status()
elif next2 == "C": # right: target y +
controller.emitter_set_tracking_target_y(target_y + step)
print_status()
elif next2 == "D": # left: target y -
controller.emitter_set_tracking_target_y(target_y - step)
print_status()
except IOError:
pass
time.sleep(0.02)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_term)
fcntl.fcntl(fd, fcntl.F_SETFL, old_flags)
def umvr_fsamy_tracked(self, shift: float, step: float = 0.01, _internal: bool = False):
"""
Relative move of fsamy by `shift` mm, broken into steps of at most
`step` mm (default 0.01 mm = 10 um), calling laser_tracker_on()
@@ -485,10 +597,22 @@ class FlomniSampleTransferMixin:
other direction).
step: maximum step size in mm per tracker re-acquisition.
Must be positive. Default 0.01 mm (10 um).
_internal: set True by internal callers that already manage the
feedback lifecycle themselves, to skip the feedback guard
below. User/CLI calls leave this False.
"""
if step <= 0:
raise ValueError("step must be a positive number of mm.")
if not _internal and dev.rtx.controller.feedback_is_running():
print(
"\x1b[91mThis move requires rt feedback OFF; any active alignment "
"may be lost.\x1b[0m"
)
if not self.OMNYTools.yesno("Disable feedback and continue?", "y"):
return
self.feedback_disable()
direction = 1 if shift >= 0 else -1
remaining = abs(shift)
while remaining > 0:
@@ -497,7 +621,7 @@ class FlomniSampleTransferMixin:
dev.rtx.controller.laser_tracker_on()
remaining -= this_step
def umv_fsamy_tracked(self, target: float, step: float = 0.01):
def umv_fsamy_tracked(self, target: float, step: float = 0.01, _internal: bool = False):
"""
Absolute move of fsamy to `target` mm (same units as dev.fsamy's
readback / dev.fsamy.user_parameter.get("in")), broken into steps of
@@ -508,10 +632,12 @@ class FlomniSampleTransferMixin:
step: maximum step size in mm per tracker re-acquisition.
Must be positive (validated in umvr_fsamy_tracked).
Default 0.01 mm (10 um).
_internal: forwarded to umvr_fsamy_tracked() to skip the feedback
guard for callers that manage feedback themselves.
"""
current = dev.fsamy.readback.get()
shift_mm = target - current
self.umvr_fsamy_tracked(shift_mm, step)
self.umvr_fsamy_tracked(shift_mm, step, _internal=_internal)
def show_signal_strength_interferometer(self):
dev.rtx.controller.show_signal_strength_interferometer()
@@ -565,7 +691,7 @@ class FlomniSampleTransferMixin:
"Could not find an 'IN' position for fsamy. Please check your config."
)
self.umv_fsamy_tracked(fsamy_in)
self.umv_fsamy_tracked(fsamy_in, _internal=True)
time.sleep(0.05)
self.laser_tracker_off()
time.sleep(0.05)
@@ -1597,6 +1723,25 @@ class Flomni(
def manual_shift_y(self, val: float):
self.client.set_global_var("manual_shift_y", val)
@property
def single_point_random_shift_max(self):
"""Maximum absolute random shift [um] applied independently in x and y
at each single-point acquisition (tomo_acquire_at_angle). At each
angle a fresh random offset drawn uniformly from
[-single_point_random_shift_max, +single_point_random_shift_max] is
added to both x and y. 0 disables the random shift. Only takes effect
when single_point_instead_of_fermat_scan is active."""
val = self.client.get_global_var("single_point_random_shift_max")
if val is None:
return 0.0
return val
@single_point_random_shift_max.setter
def single_point_random_shift_max(self, val: float):
if not 0 <= val <= 10:
raise ValueError("single_point_random_shift_max must be between 0 and 10 um.")
self.client.set_global_var("single_point_random_shift_max", val)
@property
def fovx(self):
val = self.client.get_global_var("fovx")
@@ -1847,7 +1992,7 @@ class Flomni(
while not successful:
try:
start_scan_number = bec.queue.next_scan_number
self.tomo_scan_projection(angle)
self.tomo_scan_projection(angle, _internal=True)
error_caught = False
except AlarmBase as exc:
if exc.alarm_type == "TimeoutError":
@@ -2083,13 +2228,25 @@ class Flomni(
else:
num_repeats = 1
start_scan_number = bec.queue.next_scan_number
projection_start = time.perf_counter()
for i in range(num_repeats):
self._at_each_angle(angle)
projection_duration = time.perf_counter() - projection_start
end_scan_number = bec.queue.next_scan_number
for scan_nr in range(start_scan_number, end_scan_number):
self._write_tomo_scan_number(scan_nr, angle, subtomo_number)
# Only reached when the projection completed without @scan_repeat
# re-raising, so this is the final successful attempt's duration.
self._log_projection_timing(
angle=angle,
subtomo_number=subtomo_number,
duration_s=projection_duration,
start_scan_number=start_scan_number,
end_scan_number=end_scan_number,
)
def tomo_scan(self, subtomo_start=1, start_angle=None, projection_number=None):
"""start a tomo scan"""
@@ -2264,6 +2421,7 @@ class Flomni(
self.progress["projection"] = self.progress["total_projections"]
self.progress["subtomo_projection"] = self.progress["subtomo_total_projections"]
self._print_progress()
self._log_tomogram_timing()
self.OMNYTools.printgreenbold("Tomoscan finished")
print(
f"Total measurement time lost to detected gaps: {self._format_duration(self.progress.get('accumulated_idle_time', 0.0))}"
@@ -2389,7 +2547,7 @@ class Flomni(
self.tomo_acquire_at_angle(angle)
return
self.tomo_scan_projection(angle)
self.tomo_scan_projection(angle, _internal=True)
def _golden(self, ii, howmany_sorted, maxangle, reverse=False):
"""returns the iis golden ratio angle of sorted bunches of howmany_sorted and its subtomo number"""
@@ -2443,6 +2601,8 @@ class Flomni(
self,
base_path="~/data/raw/logs/reconstruction_queue",
probe_propagation: float | None = None,
random_offset_x: float | None = None,
random_offset_y: float | None = None,
):
"""write the tomo reconstruct file for the reconstruction queue"""
bec = builtins.__dict__.get("bec")
@@ -2451,17 +2611,152 @@ class Flomni(
next_scan_number=bec.queue.next_scan_number,
base_path=base_path,
probe_file_propagation=probe_propagation,
random_offset_x=random_offset_x,
random_offset_y=random_offset_y,
)
_TIMING_LOG_DIR = "~/data/raw/logs/timing_statistics"
_TIMING_SETUP = "flomni"
_PROJECTION_TIMING_LOG = "projection_timing_log.jsonl"
_TOMOGRAM_TIMING_LOG = "tomogram_timing_log.jsonl"
def _append_timing_record(self, filename: str, record: dict) -> None:
"""Append one JSON record as a line to a timing-statistics log file.
Deliberately best-effort: a failure to write a timing record must
never abort or interfere with an in-progress measurement, so any
exception here is caught and logged rather than propagated. The
files live in a dedicated subfolder (self._TIMING_LOG_DIR), separate
from tomography_scannumbers.txt and any other existing log, and are
pure append-only JSONL (one JSON object per line) so they are
trivial to load later with pandas.read_json(..., lines=True) for the
eventual scan-time prediction model.
"""
try:
log_dir = os.path.expanduser(self._TIMING_LOG_DIR)
os.makedirs(log_dir, exist_ok=True)
log_file = os.path.join(log_dir, filename)
with open(log_file, "a+") as out_file:
out_file.write(json.dumps(record) + "\n")
except Exception as exc: # pylint: disable=broad-except
logger.warning(f"Failed to write timing record to {filename}: {exc}")
def _log_projection_timing(
self,
angle: float,
subtomo_number: int,
duration_s: float,
start_scan_number: int,
end_scan_number: int,
) -> None:
"""Write one per-projection timing record.
A "projection" here is one completed _at_each_angle() call (all the
stitch tiles / the single-point acquisition for one angle). Only
successfully completed projections reach this point: because
_tomo_scan_at_angle is wrapped in @scan_repeat, a failed attempt
re-invokes the whole method and never returns to the logging call,
so retried attempts are naturally excluded and only the final
successful duration is recorded.
The parameters logged are exactly the scan settings that plausibly
drive the duration (FOV, step, counting time, burst frames, stitch
tiling, corridor, single-point mode) -- the raw feature set for the
prediction model. The actual fermat-scan point count is deliberately
not resolved here; if it turns out to be needed it can be added
later once real data shows the raw settings aren't enough.
"""
record = {
"setup": self._TIMING_SETUP,
"timestamp": datetime.datetime.now().isoformat(),
"duration_s": duration_s,
"angle": angle,
"subtomo_number": subtomo_number,
"start_scan_number": start_scan_number,
"end_scan_number": end_scan_number,
"n_scans": end_scan_number - start_scan_number,
"tomo_type": self.tomo_type,
"single_point_instead_of_fermat_scan": self.single_point_instead_of_fermat_scan,
"fovx": self.fovx,
"fovy": self.fovy,
"tomo_shellstep": self.tomo_shellstep,
"tomo_countingtime": self.tomo_countingtime,
"frames_per_trigger": self.frames_per_trigger,
"stitch_x": self.stitch_x,
"stitch_y": self.stitch_y,
"tomo_stitch_overlap": self.tomo_stitch_overlap,
"corridor_size": self.corridor_size,
}
self._append_timing_record(self._PROJECTION_TIMING_LOG, record)
def _log_tomogram_timing(self) -> None:
"""Write one per-tomogram timing record at the end of a tomo_scan().
Captures a full snapshot of the scan parameters (reusing
_TOMO_QUEUE_PARAM_NAMES -- the single source of truth for "every
parameter that affects how the scan runs") together with the
tomogram-level wall-clock timing already tracked in self.progress:
total elapsed, the accumulated idle time detected from inter-
projection gaps, and the active (elapsed-minus-idle) measurement
time. Standalone by design -- no tomo_id / sample-database cross-
reference for now.
"""
start_str = self.progress.get("tomo_start_time")
now = datetime.datetime.now()
elapsed_s = None
if start_str is not None:
try:
elapsed_s = (now - datetime.datetime.fromisoformat(start_str)).total_seconds()
except (ValueError, TypeError):
elapsed_s = None
idle_s = self.progress.get("accumulated_idle_time", 0.0)
active_s = elapsed_s - idle_s if elapsed_s is not None else None
record = {
"setup": self._TIMING_SETUP,
"timestamp": now.isoformat(),
"tomo_start_time": start_str,
"elapsed_s": elapsed_s,
"accumulated_idle_time_s": idle_s,
"active_s": active_s,
"total_projections": self.progress.get("total_projections"),
"tomo_type_label": self.progress.get("tomo_type"),
"params": {name: getattr(self, name) for name in self._TOMO_QUEUE_PARAM_NAMES},
}
self._append_timing_record(self._TOMOGRAM_TIMING_LOG, record)
def _write_tomo_scan_number(self, scan_number: int, angle: float, subtomo_number: int) -> None:
tomo_scan_numbers_file = os.path.expanduser("~/data/raw/logs/tomography_scannumbers.txt")
with open(tomo_scan_numbers_file, "a+") as out_file:
# pylint: disable=undefined-variable
out_file.write(
f"{scan_number} {angle} {dev.fsamroy.read()['fsamroy']['value']:.3f} {self.tomo_id} {subtomo_number} {0} {self.sample_name}\n"
f"{scan_number} {angle} {dev.fsamroy.read()['fsamroy']['value']:.5f} {self.tomo_id} {subtomo_number} {0} {self.sample_name}\n"
)
def tomo_scan_projection(self, angle: float):
def tomo_scan_projection(self, angle: float, _internal: bool = False):
"""Acquire one fermat-scan projection at `angle`.
Note: this ALWAYS runs a fermat scan, regardless of
single_point_instead_of_fermat_scan. That flag is honored by the
normal tomo flow (tomo_scan -> _at_each_angle) and by
tomo_acquire_at_angle, not here -- a single named command does one
kind of acquisition. If you call this directly at the CLI with the
single-point flag set, you probably meant tomo_acquire_at_angle();
the guard below points that out. Internal callers that legitimately
want the fermat path regardless (the fermat branch of
_at_each_angle, and tomo_alignment_scan, which needs real ptycho
data) pass _internal=True to skip the prompt.
"""
if not _internal and self.single_point_instead_of_fermat_scan:
print(
"\x1b[93mWarning: single_point_instead_of_fermat_scan is set, but"
" tomo_scan_projection always runs a FERMAT scan. For a single-point"
" acquisition at this angle use tomo_acquire_at_angle(angle) instead.\x1b[0m"
)
if not self.OMNYTools.yesno("Run a fermat scan anyway?", "n"):
print("Aborted. Use tomo_acquire_at_angle(angle) for a single-point acquisition.")
return
dev.rtx.controller.laser_tracker_check_signalstrength()
@@ -2550,17 +2845,30 @@ class Flomni(
else:
print("No rotation required")
# --- optional random shift in x and y (single-point acquisitions) ---
# A fresh offset is drawn per angle from a uniform distribution over
# [-single_point_random_shift_max, +single_point_random_shift_max] and
# added independently to x and y. 0 (the default) disables it.
random_shift_max = self.single_point_random_shift_max
if random_shift_max > 0:
random_shift_x = random.uniform(-random_shift_max, random_shift_max)
random_shift_y = random.uniform(-random_shift_max, random_shift_max)
else:
random_shift_x = 0.0
random_shift_y = 0.0
# --- alignment offset (same as tomo_scan_projection, no stitching) ---
offsets = self.get_alignment_offset(angle)
sum_offset_x = offsets[0]
sum_offset_x = offsets[0] + random_shift_x
sum_offset_y = (
offsets[1]
- self.compute_additional_correction_y(angle)
- self.compute_additional_correction_y_2(angle)
+ self.manual_shift_y
+ random_shift_y
)
sum_offset_z = offsets[2]
# TODO this fix is while the tracker z is broken
probe_propagation = -sum_offset_z * 1e-6
sum_offset_z = 0
@@ -2583,8 +2891,13 @@ class Flomni(
# --- acquire ---
n_frames = frames_per_trigger if frames_per_trigger is not None else self.frames_per_trigger
self._current_scan_list = [bec.queue.next_scan_number]
scans.acquire(exp_time=self.tomo_countingtime, frames_per_trigger=n_frames)
self.tomo_reconstruct(probe_propagation=probe_propagation)
self.tomo_reconstruct(
probe_propagation=probe_propagation,
random_offset_x=random_shift_x,
random_offset_y=random_shift_y,
)
def _tomo_type1_actual_grid(self) -> tuple[int, float, int]:
"""Compute the actual (achievable) tomo_type==1 grid from the
@@ -2622,6 +2935,8 @@ class Flomni(
print(f" _manual_shift_y <um> = {self.manual_shift_y}")
print(f"Frames per trigger (burst) = {self.frames_per_trigger}")
print(f"Single point instead of fermat = {self.single_point_instead_of_fermat_scan}")
if self.single_point_instead_of_fermat_scan:
print(f"Single point random shift max <um> = {self.single_point_random_shift_max}")
print("")
if self.tomo_type == 1:
@@ -2709,6 +3024,12 @@ class Flomni(
)
self.stitch_x = 0
self.stitch_y = 0
if self.single_point_instead_of_fermat_scan:
self.single_point_random_shift_max = self._get_val(
"<single point random shift max> um (0 = off)",
self.single_point_random_shift_max,
float,
)
print("Tomography type:")
print(" 1: 8 equally spaced sub-tomograms")
@@ -2821,6 +3142,7 @@ class Flomni(
"manual_shift_y",
"frames_per_trigger",
"single_point_instead_of_fermat_scan",
"single_point_random_shift_max",
"tomo_type",
"tomo_angle_range",
"tomo_angle_stepsize",
@@ -261,10 +261,34 @@ class FlomniOpticsMixin:
console.print(table)
fosaz_val = dev.fosaz.readback.get()
foptz_val = dev.foptz.readback.get()
fosaz_in = self._get_user_param_safe("fosaz", "in")
tol_osa = 0.010 # mm, 10 microns
remaining_current = -fosaz_val + (33 - foptz_val)
remaining_at_in = -fosaz_in + (33 - foptz_val)
print("\nOSA Information:")
print(f" Current fosaz {fosaz_val:.1f}")
print(
f" The OSA will collide with a normal OMNY pin at fosaz \033[1m{(33-fosaz_val):.1f}\033[0m"
)
print(f" Remaining space: \033[1m{-fosaz_val+(33-foptz_val):.1f}\033[0m")
print(f" The OSA will collide with a normal OMNY pin at fosaz \033[1m{(33-fosaz_val):.1f}\033[0m")
print(f" Remaining space (right now): \033[1m{remaining_current:.1f}\033[0m")
diff = fosaz_val - fosaz_in # >0: OSA is currently more "in" than nominal -> worse
if abs(diff) > tol_osa:
if diff > 0:
# current position is already more collision-prone than the defined IN position
print(
f" \033[91mWarning: current fosaz ({fosaz_val:.4f}) is {diff*1000:.1f} um "
f"further IN than the defined IN position ({fosaz_in:.4f}) -- closer to collision "
f"than normal operation.\033[0m"
)
else:
# OSA is out (parked further away than its nominal in-position) -- not urgent now,
# but flag what clearance will be once it's moved to IN
print(
f" Note: OSA is {(-diff)*1000:.1f} um away from its IN position (likely parked OUT)."
)
print(f" Remaining space if OSA is moved to its IN position: \033[1m{remaining_at_in:.1f}\033[0m")
@@ -103,10 +103,8 @@ VERBOSITY_VERBOSE = 2
VERBOSITY_DEBUG = 3
_PHONE_NUMBERS = [
("Beamline", "+41 56 310 5845"),
("Beamline mobile", "+41 56 310 5844"),
("Local contact", "+41 79 343 9230"),
("Control room", "+41 56 310 5503"),
("Beamline", "+41 56 310 5845"),
("Control room", "+41 56 310 5503"),
]
@@ -286,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.
@@ -311,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"
@@ -576,8 +585,32 @@ class WebpageGeneratorBase:
Common webpage generator. Subclass and override:
_collect_setup_data() -- return dict of instrument-specific data
_logo_path() -- return Path to logo PNG, or None for text fallback
Setup capability flags (override on subclass as needed):
HAS_TOMO_QUEUE -- whether this instrument exposes the persisted
parameter-snapshot job queue (global var
"tomo_queue" + the "Tomo queue" UI card).
NOT to be confused with BEC's own primary scan
queue (queue_status / queue_locks in _cycle()),
which every setup has and which stays common.
TOMO_TYPES -- dict describing this instrument's tomography
scan types, keyed by the value progress["tomo_type"]
takes. Used by the UI to compute total-projection
counts for queued jobs without hardcoding a
per-instrument formula in JS. Recognised "kind"s:
"equally_spaced_grid" (params: n_subtomos)
total = floor(angle_range / angle_stepsize) * n_subtomos
"golden_capped"
total = golden_max_number_of_projections
"""
HAS_TOMO_QUEUE = True
TOMO_TYPES = {
1: {"kind": "equally_spaced_grid", "n_subtomos": 8},
2: {"kind": "golden_capped"},
3: {"kind": "golden_capped"},
}
def __init__(
self,
bec_client,
@@ -649,7 +682,9 @@ class WebpageGeneratorBase:
# Copy logo once at startup — HTML is also written once here,
# not every cycle, since it is a static shell that only loads status.json.
self._copy_logo()
(self._output_dir / "status.html").write_text(_render_html(_PHONE_NUMBERS))
(self._output_dir / "status.html").write_text(
_render_html(_PHONE_NUMBERS, self.HAS_TOMO_QUEUE, self.TOMO_TYPES)
)
# Check whether the active BEC account matches the session user on the
# remote server. If not, clear session.htpasswd so the old user loses
@@ -979,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:
@@ -988,6 +1028,18 @@ class WebpageGeneratorBase:
f"beamline_states read error: {exc}", level="warning")
beamline_states_info = {"enabled": None, "states": []}
# True if the scan interlock is enabled AND at least one watched state
# is out of its accepted range. Gated on `enabled`: a mismatched state
# while the interlock is disabled does not actually block a scan, so it
# must not raise the 'blocked' status. Kept in lock-step with the
# beamline-states card summary (renderBeamlineStates), which likewise
# only calls mismatched states "blocking" when the interlock is enabled,
# so the top status pill and that card can never disagree.
beamline_blocking = bool(
beamline_states_info.get("enabled") is True
and 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.
@@ -1016,20 +1068,32 @@ 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
else max(0.0, _epoch() - self._last_active_time)
)
# ── Tomo queue (setup-specific; see HAS_TOMO_QUEUE) ─────────────
# Persisted list of parameter-snapshot jobs (global var "tomo_queue").
# Each job: {"label": str, "params": {...},
# "status": pending|running|incomplete|done, "added_at": ISO str}
# Instruments without this feature (e.g. LamNI) never touch the
# global var and always report an empty queue.
if self.HAS_TOMO_QUEUE:
tomo_queue = self._bec.get_global_var("tomo_queue") or []
else:
tomo_queue = []
# ── Reconstruction queue ──────────────────────────────────────
recon = self._collect_recon_data()
@@ -1061,6 +1125,7 @@ class WebpageGeneratorBase:
"idle_for_human": _format_duration(idle_for_s),
"queue_locks": queue_locks,
"beamline_states": beamline_states_info,
"tomo_queue": tomo_queue,
"progress": {
"tomo_type": progress.get("tomo_type", "N/A"),
"projection": progress.get("projection", 0),
@@ -1086,6 +1151,7 @@ class WebpageGeneratorBase:
"generator": {
"owner_id": self._owner_id,
"cycle_interval_s": self._cycle_interval,
"active_account": getattr(self._bec, "active_account", None) or "",
},
}
@@ -1307,10 +1373,21 @@ class WebpageGeneratorBase:
by the HTML page as the 'Instrument details' section.
Expected keys (all optional):
type (str) -- setup identifier, e.g. "flomni"
sample_name (str) -- current sample name
temperatures (dict) -- label -> float (degrees C) or None
settings (dict) -- label -> formatted string
type (str) -- setup identifier, e.g. "flomni"
sample_name (str) -- current sample name
temperatures (dict) -- label -> float (degrees C) or None
current_params (dict) -- raw tomo parameters read live (keyed by
the same global-var names the tomo queue
stores in job.params, e.g. tomo_countingtime,
fovx, tomo_angle_stepsize, ...). Rendered by
the page through the same param-table path
as a queue job (buildParamRows), so the
'Current measurement' section always matches
the queue's level of detail -- and shows the
details even for a scan started outside the
queue. Values are left raw; the page formats
them (fmtTqParam) and computes the projection
count (calcProjections).
"""
return {}
@@ -1336,10 +1413,31 @@ class WebpageGeneratorBase:
class FlomniWebpageGenerator(WebpageGeneratorBase):
"""
flOMNI-specific webpage generator.
Adds: temperatures, sample name, measurement settings from global vars.
Adds: temperatures, sample name, and live current-measurement parameters
from global vars.
Logo: flOMNI.png from the same directory as this file.
"""
# Global-var names of the tomo parameters to read live for the
# 'Current measurement' section. Kept in the same order as, and matching,
# the tomo queue's TQ_PARAM_DISPLAY keys so the two render identically.
# (Any name that is not actually a global var is skipped silently and just
# does not appear as a row.)
_CURRENT_PARAM_KEYS = [
"tomo_type",
"tomo_countingtime",
"fovx",
"fovy",
"tomo_angle_stepsize",
"tomo_angle_range",
"tomo_shellstep",
"stitch_x",
"stitch_y",
"frames_per_trigger",
"single_point_instead_of_fermat_scan",
"ptycho_reconstruct_foldername",
]
# label -> dotpath under device_manager.devices
_TEMP_MAP = {
"Heater": "flomni_temphum.temperature_heater",
@@ -1363,40 +1461,28 @@ class FlomniWebpageGenerator(WebpageGeneratorBase):
for label, path in self._TEMP_MAP.items()
}
# ── Settings from global vars ─────────────────────────────────
# ── Current measurement parameters (read live) ────────────────
# Same keys the tomo queue stores in job.params, so the 'Current
# measurement' section on the page renders through the identical
# param-table path (buildParamRows -> fmtTqParam / calcProjections)
# and always matches the queue's level of detail. Reading these live
# means a tomo started OUTSIDE the queue still shows full parameters.
# Values are left raw; the page does the formatting.
g = self._bec
def _fmt2(v):
current_params = {}
for key in self._CURRENT_PARAM_KEYS:
try:
return f"{float(v):.2f}"
val = g.get_global_var(key)
except Exception:
return "N/A"
fovx = g.get_global_var("fovx")
fovy = g.get_global_var("fovy")
stx = g.get_global_var("stitch_x")
sty = g.get_global_var("stitch_y")
def _fmt_int(v):
try:
return str(int(v))
except (TypeError, ValueError):
return "N/A"
settings = {
"Sample name": sample_name,
"FOV x / y": f"{_fmt2(fovx)} / {_fmt2(fovy)} \u00b5m",
"Step size": _gvar(g, "tomo_shellstep", ".2f", " \u00b5m"),
"Exposure time": _gvar(g, "tomo_countingtime", ".3f", " s"),
"Angle step": _gvar(g, "tomo_angle_stepsize", ".2f", "\u00b0"),
"Stitch x / y": f"{_fmt_int(stx)} / {_fmt_int(sty)}",
}
val = None
if val is not None:
current_params[key] = val
return {
"type": "flomni",
"sample_name": sample_name,
"temperatures": temperatures,
"settings": settings,
"type": "flomni",
"sample_name": sample_name,
"temperatures": temperatures,
"current_params": current_params,
}
@@ -1447,7 +1533,7 @@ def make_webpage_generator(bec_client, **kwargs):
# Theme is stored in localStorage and applied via data-theme on <html>.
# ---------------------------------------------------------------------------
def _render_html(phone_numbers: list) -> str:
def _render_html(phone_numbers: list, has_tomo_queue: bool = True, tomo_types: dict = None) -> str:
phones_html = "\n".join(
f' <div class="phone-row">'
f'<span class="phone-label">{label}</span>'
@@ -1455,6 +1541,30 @@ def _render_html(phone_numbers: list) -> str:
for label, num in phone_numbers
)
# Tomo queue card + its slot in the default card order are only emitted
# for setups that actually have the feature (see HAS_TOMO_QUEUE).
if has_tomo_queue:
tomo_queue_card_html = """
<!-- 5. Tomo queue -->
<div class="card draggable-card" data-card-id="tomo-queue">
<div class="drag-handle" title="Drag to reorder">&#8942;&#8942;</div>
<div class="card-title">Tomo queue</div>
<div id="tomo-queue-content"></div>
</div>
"""
else:
tomo_queue_card_html = ""
default_order = ['audio', 'recon-queue', 'ptycho', 'instrument', 'blstates', 'contacts']
if has_tomo_queue:
default_order.insert(default_order.index('contacts'), 'tomo-queue')
default_order_json = json.dumps(default_order)
# Declarative tomo-type -> projection-count formula, consumed by the
# generic calcProjections() in JS instead of a hardcoded per-instrument
# branch. See WebpageGeneratorBase.TOMO_TYPES for the schema.
tomo_types_json = json.dumps(tomo_types or {})
return f"""<!DOCTYPE html>
<html lang="en" data-theme="auto">
<head>
@@ -1523,7 +1633,11 @@ def _render_html(phone_numbers: list) -> str:
body {{
background: var(--bg); color: var(--text);
font-family: var(--sans); font-weight: 300;
min-height: 100vh; padding: 1.5rem;
min-height: 100vh; /* fallback for browsers without dvh */
min-height: 100dvh; /* dynamic viewport: avoids extra scroll room
below content on mobile (iOS 100vh counts the
retracted-toolbar height and overshoots) */
padding: 1.5rem;
transition: background 0.4s, color 0.4s;
}}
body::before {{
@@ -1568,6 +1682,10 @@ def _render_html(phone_numbers: list) -> str:
font-family: var(--mono); font-size: 0.7rem; font-weight: 700;
letter-spacing: 0.15em; color: var(--text-dim);
}}
.logo-account {{
font-family: var(--mono); font-size: 0.7rem; font-weight: 400;
letter-spacing: 0.06em; color: var(--text-dim);
}}
/* header right: update time + theme switcher */
.header-right {{ display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }}
@@ -1806,6 +1924,52 @@ def _render_html(phone_numbers: list) -> str:
.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; }}
/* ── Tomo queue card ── */
.tq-empty {{
font-family: var(--mono); font-size: 0.78rem; color: var(--text-dim);
padding: 0.25rem 0;
}}
.tq-job {{ border-bottom: 1px solid var(--border); }}
.tq-job:last-child {{ border-bottom: none; }}
.tq-summary {{
display: flex; align-items: center; gap: 0.75rem;
padding: 0.55rem 0; cursor: pointer; list-style: none;
font-size: 0.85rem;
}}
.tq-summary::-webkit-details-marker {{ display: none; }}
.tq-summary::before {{
content: ''; font-family: var(--mono); font-size: 0.55rem;
color: var(--text-dim); transition: transform 0.2s; flex-shrink: 0;
}}
details.tq-job[open] > .tq-summary::before {{ transform: rotate(90deg); }}
.tq-num {{
font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim);
min-width: 1.8rem; flex-shrink: 0;
}}
.tq-label {{ flex: 1; font-weight: 600; color: var(--text); }}
.tq-badge {{
font-family: var(--mono); font-size: 0.58rem; font-weight: 700;
letter-spacing: 0.06em; text-transform: uppercase;
padding: 0.12rem 0.45rem; border-radius: 100px; white-space: nowrap;
flex-shrink: 0;
}}
.tq-badge.tq-pending {{ background: var(--surface2); color: var(--text-dim); }}
.tq-badge.tq-running {{ background: color-mix(in srgb, var(--c-scanning) 20%, transparent); color: var(--c-scanning); }}
.tq-badge.tq-incomplete {{ background: color-mix(in srgb, var(--c-blocked) 20%, transparent); color: var(--c-blocked); }}
.tq-badge.tq-done {{ background: color-mix(in srgb, var(--c-running) 20%, transparent); color: var(--c-running); }}
.tq-details {{ padding: 0.4rem 0 0.7rem 2.4rem; }}
.tq-params {{ width: 100%; border-collapse: collapse; }}
.tq-params td {{ padding: 0.18rem 0; font-size: 0.78rem; vertical-align: top; }}
.tq-params td:first-child {{
font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim);
padding-right: 1rem; white-space: nowrap; width: 40%;
}}
.tq-params td:last-child {{ color: var(--text); font-weight: 600; }}
.tq-added {{
font-family: var(--mono); font-size: 0.6rem; color: var(--text-dim);
margin-top: 0.4rem;
}}
/* ── Audio card ── */
.audio-card {{
display: flex; align-items: center;
@@ -1921,6 +2085,7 @@ def _render_html(phone_numbers: list) -> str:
onerror="document.getElementById('logo-img').hidden=true; document.getElementById('logo-text').hidden=false;">
<span id="logo-text" class="logo-text" hidden>STATUS</span>
<span class="logo-suffix">&middot;&nbsp;STATUS</span>
<span class="logo-account" id="active-account"></span>
</div>
<div class="header-right">
<button class="theme-btn" id="help-btn" onclick="openHelp()" title="What does this page show?">?</button>
@@ -1964,6 +2129,7 @@ def _render_html(phone_numbers: list) -> str:
<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 class="info-item" id="pi-queue-item" style="display:none"><span class="label">Queue job</span><span class="value" id="pi-queue-label">-</span></div>
</div>
</div>
@@ -1986,7 +2152,7 @@ def _render_html(phone_numbers: list) -> str:
<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>
@@ -1995,7 +2161,7 @@ def _render_html(phone_numbers: list) -> str:
<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>Watch LED</strong> &mdash; armed (green) while a scan is running; pulses orange when a scan stops, with a chime every 30s until confirmed. A normal finish (scan &rarr; idle) plays a rising success tone; a stop to any other state plays the falling warning tone, so you can tell them apart by ear.</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>
@@ -2081,7 +2247,8 @@ def _render_html(phone_numbers: list) -> str:
</table>
</div>
<!-- 5. Contacts -->
{tomo_queue_card_html}
<!-- 6. 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>
@@ -2119,7 +2286,7 @@ function setTheme(t) {{
// ── Drag-and-drop card ordering ──────────────────────────────────────────
const CARD_ORDER_KEY = 'cardOrder';
const DEFAULT_ORDER = ['audio','recon-queue','ptycho','instrument','blstates','contacts'];
const DEFAULT_ORDER = {default_order_json};
let _dragSrc = null;
function savedOrder() {{
@@ -2190,6 +2357,7 @@ initDrag();
let audioCtx=null, audioEnabled=false;
let audioArmed=false, warningActive=false, warningTimer=null, lastStatus=null;
let warningWasSuccess=false; // true when the active warning is a normal finish (scanning -> idle)
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
@@ -2224,6 +2392,13 @@ function beep(freq,dur,vol){{
function warningChime(){{
beep(660,0.3,0.4); setTimeout(()=>beep(440,0.5,0.4),350);
}}
function successChime(){{
// Reverse of warningChime: a rising major arpeggio (C5-E5-G5) so a normal
// finish (scanning -> idle) is audibly a happy "done" rather than an alert.
beep(523,0.22,0.4);
setTimeout(()=>beep(659,0.22,0.4),240);
setTimeout(()=>beep(784,0.45,0.4),480);
}}
function staleChime(){{
beep(1200,0.12,0.35);
setTimeout(()=>beep(1200,0.12,0.35),180);
@@ -2269,12 +2444,14 @@ function confirmWarning(){{
updateAudioUI();
}}
function startWarning(){{
function startWarning(finished){{
// Not a gesture handler — context already unlocked by Enable button click.
if(warningActive) return;
warningActive=true;
if(audioEnabled) warningChime();
warningTimer=setInterval(()=>{{ if(audioEnabled) warningChime(); }},30000);
warningWasSuccess=!!finished; // scanning -> idle == successful finish
const chime = warningWasSuccess ? successChime : warningChime;
if(audioEnabled) chime();
warningTimer=setInterval(()=>{{ if(audioEnabled) chime(); }},30000);
document.getElementById('btn-confirm').style.display='inline-block';
updateAudioUI();
}}
@@ -2374,10 +2551,10 @@ function updateAudioUI(){{
txt.textContent='Audio disabled \u2014 enable to receive warnings';
}}else if(warningActive && staleActive){{
ledWatch.className='led led-warning';
txt.textContent='Measurement stopped & live feed lost \u2014 confirm each';
txt.textContent=(warningWasSuccess?'Measurement finished':'Measurement stopped')+' & live feed lost \u2014 confirm each';
}}else if(warningActive){{
ledWatch.className='led led-warning';
txt.textContent='Measurement stopped \u2014 confirm to silence';
txt.textContent=(warningWasSuccess?'Measurement finished':'Measurement stopped')+' \u2014 confirm to silence';
}}else if(staleActive){{
ledWatch.className='led';
txt.textContent='Live feed lost \u2014 confirm to silence';
@@ -2410,7 +2587,7 @@ function handleAudioForStatus(status, prevStatus){{
}}
if(audioArmed && wasScanning && !isScanning){{
startWarning();
startWarning(status==='idle'); // idle == finished successfully -> success chime
}}
if(isScanning && warningActive){{
@@ -2485,8 +2662,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',
}};
@@ -2504,12 +2690,18 @@ function esc(s){{return String(s==null?'N/A':s).replace(/&/g,'&amp;').replace(/<
function renderInstrument(setup){{
const card=document.getElementById('instrument-card'),grid=document.getElementById('instrument-grid');
if(!setup||(!setup.temperatures&&!setup.settings)){{card.style.display='none';return;}}
const cp=(setup&&setup.current_params)||null;
const hasParams=cp&&Object.keys(cp).length>0;
if(!setup||(!setup.temperatures&&!hasParams)){{card.style.display='none';return;}}
card.style.display='';
let html='';
if(setup.settings){{
html+='<div class="instrument-section"><h3>Measurement settings</h3><table class="kv-table">';
for(const[k,v] of Object.entries(setup.settings)) html+='<tr><td>'+esc(k)+'</td><td>'+esc(v)+'</td></tr>';
if(hasParams){{
// Same param-table path as a tomo queue job (buildParamRows), so the live
// 'Current measurement' matches the queue's level of detail — and shows
// full parameters even for a scan started outside the queue.
html+='<div class="instrument-section"><h3>Current measurement</h3><table class="kv-table tq-params">';
if(setup.sample_name) html+='<tr><td>Sample name</td><td>'+esc(setup.sample_name)+'</td></tr>';
html+=buildParamRows(cp);
html+='</table></div>';
}}
if(setup.temperatures){{
@@ -2536,10 +2728,19 @@ function renderBeamlineStates(bl){{
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');
// "Blocking" only applies when the interlock is enabled — a mismatched
// state while disabled/unknown is out of spec but does not block a scan.
// Kept in lock-step with the Python beamline_blocking gate so this card and
// the top status pill never disagree.
let blockNote;
if(mismatched.length===0){{
blockNote=' &middot; all watched states OK';
}} else if(bl&&bl.enabled===true){{
blockNote=' &middot; <strong>'+mismatched.length+'</strong> watched state'+(mismatched.length>1?'s':'')+' currently blocking';
}} else {{
blockNote=' &middot; <strong>'+mismatched.length+'</strong> watched state'+(mismatched.length>1?'s':'')+' out of spec (interlock '+enabledTxt+', not blocking)';
}}
summary.innerHTML = 'Scan interlock <strong>'+enabledTxt+'</strong>'+blockNote;
let html='';
states.forEach(s=>{{
@@ -2555,6 +2756,127 @@ function renderBeamlineStates(bl){{
tbody.innerHTML=html;
}}
// Key params to show in expanded job detail, in display order.
const TQ_PARAM_DISPLAY = [
['tomo_type', 'Type'],
['tomo_countingtime', 'Exposure (s)'],
['fovx', 'FOV x (\u00b5m)'],
['fovy', 'FOV y (\u00b5m)'],
['tomo_angle_stepsize', 'Angle step (\u00b0)'],
['tomo_angle_range', 'Angle range (\u00b0)'],
['tomo_shellstep', 'Shell step (\u00b5m)'],
['stitch_x', 'Stitch x'],
['stitch_y', 'Stitch y'],
['frames_per_trigger', 'Frames/trigger'],
['single_point_instead_of_fermat_scan','Single point'],
['ptycho_reconstruct_foldername', 'Recon folder'],
];
function fmtTqParam(key, val){{
if(val==null) return '-';
if(typeof val==='number') return Number.isInteger(val)?String(val):val.toFixed(3).replace(/\\.?0+$/,'');
return String(val);
}}
// Declarative per-instrument tomo-type definitions (see
// WebpageGeneratorBase.TOMO_TYPES on the Python side for the schema).
// Adding or changing a tomo type on any setup is a config change here,
// not a new branch of formula code.
const TOMO_TYPES = {tomo_types_json};
function calcProjections(params){{
const def=TOMO_TYPES[params.tomo_type];
if(!def) return null;
if(def.kind==='golden_capped'){{
const gmax=params.golden_max_number_of_projections;
return (gmax!=null&&gmax>0)?gmax:null;
}}
if(def.kind==='equally_spaced_grid'){{
const range=params.tomo_angle_range, step=params.tomo_angle_stepsize;
if(range>0&&step>0){{
const N=Math.floor(range/step);
return N*(def.n_subtomos||1);
}}
}}
return null;
}}
// Build the tomo-parameter table rows for a params object, in TQ_PARAM_DISPLAY
// order, injecting the computed Projections row after the angle step. Shared by
// the tomo queue (per job) and the instrument 'Current measurement' section, so
// both always show the same parameters, formatted the same way.
function buildParamRows(params){{
let rows='';
TQ_PARAM_DISPLAY.forEach(([key,dispLabel])=>{{
if(key in params){{
rows+='<tr><td>'+dispLabel+'</td><td>'+esc(fmtTqParam(key,params[key]))+'</td></tr>';
}}
if(key==='tomo_angle_stepsize'){{
const nproj=calcProjections(params);
if(nproj!=null) rows+='<tr><td>Projections</td><td>'+nproj+'</td></tr>';
}}
}});
return rows;
}}
function renderTomoQueue(jobs){{
const el=document.getElementById('tomo-queue-content');
if(!el) return; // setup has no tomo-queue card (HAS_TOMO_QUEUE=False)
// Snapshot which jobs are currently open, keyed by a stable identity
// (added_at|label) rather than positional index, so add/remove/reorder
// does not carry the open flag onto the wrong job. This reflects the
// user's manual expand/collapse since the last render (the DOM persists
// across refreshes; only innerHTML is replaced).
const openKeys=new Set();
el.querySelectorAll('details.tq-job').forEach(det=>{{
if(det.open && det.dataset.key) openKeys.add(det.dataset.key);
}});
if(!jobs||jobs.length===0){{
el.innerHTML='<div class="tq-empty">No jobs queued</div>';
return;
}}
// Running jobs are auto-opened once, on first appearance. We remember which
// we have already auto-opened so a manual collapse is not undone on the next
// refresh (previously, collapsing the only open job made the snapshot empty
// and re-triggered the auto-open).
const autoOpened=window.__tqAutoOpened||(window.__tqAutoOpened=new Set());
const presentKeys=new Set();
let html='';
jobs.forEach((job,i)=>{{
const status=job.status||'pending';
const label=job.label||('Job '+(i+1));
const params=job.params||{{}};
const key=(job.added_at||'')+'|'+label;
presentKeys.add(key);
// Open if the user currently has it open, or it is a running job we have
// not auto-opened before (its first appearance). Auto-open fires once.
let shouldOpen=openKeys.has(key);
if(status==='running' && !autoOpened.has(key)){{
shouldOpen=true;
autoOpened.add(key);
}}
const paramRows=buildParamRows(params);
const addedAt=job.added_at
?'Added '+new Date(job.added_at).toLocaleString([],{{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}})
:'';
html+='<details class="tq-job" data-key="'+esc(key)+'"'+(shouldOpen?' open':'')+'>'
+'<summary class="tq-summary">'
+'<span class="tq-num">#'+(i+1)+'</span>'
+'<span class="tq-label">'+esc(label)+'</span>'
+'<span class="tq-badge tq-'+esc(status)+'">'+esc(status)+'</span>'
+'</summary>'
+'<div class="tq-details">'
+(paramRows?'<table class="tq-params">'+paramRows+'</table>':'')
+(addedAt?'<div class="tq-added">'+esc(addedAt)+'</div>':'')
+'</div>'
+'</details>';
}});
// Drop remembered auto-open keys for jobs that are gone, so the set stays
// small and a job that later reappears is treated as new.
autoOpened.forEach(k=>{{ if(!presentKeys.has(k)) autoOpened.delete(k); }});
el.innerHTML=html;
}}
function render(d){{
const s=d.experiment_status||'unknown',p=d.progress||{{}};
document.body.className=s;
@@ -2576,6 +2898,14 @@ function render(d){{
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);
const runningJob=(d.tomo_queue||[]).find(j=>j.status==='running');
const queueItem=document.getElementById('pi-queue-item');
if(runningJob){{
document.getElementById('pi-queue-label').textContent=runningJob.label||'-';
queueItem.style.display='';
}}else{{
queueItem.style.display='none';
}}
if(d.recon){{
document.getElementById('recon-waiting').textContent=d.recon.waiting;
@@ -2586,6 +2916,7 @@ function render(d){{
}}
renderInstrument(d.setup);
renderBeamlineStates(d.beamline_states);
renderTomoQueue(d.tomo_queue);
renderPtycho(d.ptycho);
document.getElementById('last-update').textContent='updated '+new Date(d.generated_at).toLocaleTimeString();
const ageS=(Date.now()/1000)-d.generated_at_epoch;
@@ -2594,6 +2925,8 @@ function render(d){{
handleStale(isStale);
handleBlocked(s==='blocked');
document.getElementById('footer-gen').textContent='generator: '+((d.generator||{{}}).owner_id||'-');
const acct=(d.generator||{{}}).active_account||'';
document.getElementById('active-account').textContent=acct?'\u00b7\u00a0'+acct:'';
document.getElementById('footer-hb').textContent='tomo_heartbeat: '+(p.tomo_heartbeat_age_s!=null?p.tomo_heartbeat_age_s+'s ago':'none');
const prevStatus=lastStatus;
@@ -90,9 +90,9 @@ class flomniGuiTools:
def flomnigui_show_cameras(self):
self.flomnigui_show_gui()
if self._flomnigui_check_attribute_not_exists(
"cam_flomni_gripper"
) or self._flomnigui_check_attribute_not_exists("cam_flomni_overview"):
if self._flomnigui_is_missing("camera_gripper_image") or self._flomnigui_is_missing(
"camera_overview_image"
):
self.flomnigui_remove_all_docks()
self.camera_gripper_image = self.gui.flomni.new("Image")
if self._flomnicam_check_device_exists(dev.cam_flomni_gripper):
@@ -278,12 +278,7 @@ class XrayEyeAlign:
self.gui.show_crosshair()
self.send_message(
<<<<<<< Updated upstream
"Submit height. Use arrows if far off."
=======
"Adjust sample height with the arrows if needed, then mark "
"the sample and submit - height will be centered automatically"
>>>>>>> Stashed changes
)
self.gui.enable_submit_button(True)
self.movement_buttons_enabled(True, True)
@@ -312,7 +307,7 @@ class XrayEyeAlign:
self.flomni.feedback_disable()
if not self.test_wo_movements and delta_y_mm != 0:
self.flomni.umvr_fsamy_tracked(delta_y_mm)
self.flomni.umvr_fsamy_tracked(delta_y_mm, _internal=True)
time.sleep(2)
self.flomni.feedback_enable_with_reset()
@@ -364,7 +359,7 @@ class XrayEyeAlign:
if _xrayeyalignmvy != 0:
self.flomni.feedback_disable()
if not self.test_wo_movements:
self.flomni.umvr_fsamy_tracked(_xrayeyalignmvy / 1000)
self.flomni.umvr_fsamy_tracked(_xrayeyalignmvy / 1000, _internal=True)
time.sleep(2)
dev.omny_xray_gui.mvy.set(0)
self.flomni.feedback_enable_with_reset()
@@ -1,6 +1,7 @@
import builtins
import datetime
import fcntl
import json
import os
import socket
import subprocess
@@ -28,9 +29,12 @@ if builtins.__dict__.get("bec") is not None:
def umv(*args):
return scans.umv(*args, relative=False)
def umvr(*args):
return scans.umv(*args, relative=True)
class OMNYToolsError(Exception):
pass
@@ -157,8 +161,10 @@ class OMNYTools:
termios.tcsetattr(fd, termios.TCSADRAIN, old_term)
fcntl.fcntl(fd, fcntl.F_SETFL, old_flags)
import socket
class PtychoReconstructor:
"""Writes ptychography reconstruction queue files after each scan projection.
@@ -195,6 +201,8 @@ class PtychoReconstructor:
next_scan_number: int,
base_path: str = "~/data/raw/analysis/",
probe_file_propagation: float | None = None,
random_offset_x: float | None = None,
random_offset_y: float | None = None,
):
"""Write a reconstruction queue file for the given scan list.
@@ -211,19 +219,25 @@ class PtychoReconstructor:
omitted entirely otherwise, matching the old reconstruction.mac
behavior of only writing this parameter when
progagateprobeinsteadofsample==1.
random_offset_x (float, optional): Random x shift [um] applied for
this single-point acquisition. Written to the JSON sidecar as
``random_offset_x`` when given; omitted otherwise.
random_offset_y (float, optional): Random y shift [um] applied for
this single-point acquisition. Written to the JSON sidecar as
``random_offset_y`` when given; omitted otherwise.
"""
if not self._accounts_match():
logger.warning("Active BEC account does not match system user — skipping queue file write.")
logger.warning(
"Active BEC account does not match system user — skipping queue file write."
)
return
base_path = os.path.expanduser(base_path)
queue_path = Path(os.path.join(base_path, self.folder_name))
queue_path.mkdir(parents=True, exist_ok=True)
last_scan_number = next_scan_number - 1
queue_file = os.path.abspath(
os.path.join(queue_path, f"scan_{last_scan_number:05d}.dat")
)
queue_file = os.path.abspath(os.path.join(queue_path, f"scan_{last_scan_number:05d}.dat"))
with open(queue_file, "w") as f:
scans = " ".join(str(s) for s in scan_list)
f.write(f"p.scan_number {scans}\n")
@@ -231,6 +245,17 @@ class PtychoReconstructor:
f.write(f"p.probe_file_propagation {probe_file_propagation:.6f}\n")
f.write("p.check_nextscan_started 1\n")
json_file = os.path.abspath(os.path.join(queue_path, f"{last_scan_number:06d}.json"))
json_content = {"scan_id": last_scan_number}
if probe_file_propagation is not None:
json_content["probe_file_propagation"] = round(probe_file_propagation, 6)
if random_offset_x is not None:
json_content["random_offset_x"] = round(random_offset_x, 6)
if random_offset_y is not None:
json_content["random_offset_y"] = round(random_offset_y, 6)
with open(json_file, "w") as f:
json.dump(json_content, f, indent=4)
class TomoIDManager:
"""Registers a tomography measurement in the OMNY sample database
@@ -249,7 +274,7 @@ class TomoIDManager:
)
"""
#OMNY_URL = "https://omny.web.psi.ch/samples/newmeasurement.php"
# OMNY_URL = "https://omny.web.psi.ch/samples/newmeasurement.php"
OMNY_URL = "https://v1p0zyg2w9n2k9c1.myfritz.net/samples/newmeasurement.php"
OMNY_USER = ""
OMNY_PASSWORD = ""
@@ -294,12 +319,9 @@ class TomoIDManager:
# f" -q -O {self.TMP_FILE} '{url}'",
# shell=True,
# )
#print(url)
# print(url)
tmp_file = os.path.expanduser(self.TMP_FILE)
result = subprocess.run(
f"wget -q -O {tmp_file} '{url}'",
shell=True,
)
result = subprocess.run(f"wget -q -O {tmp_file} '{url}'", shell=True)
if result.returncode != 0:
raise OMNYToolsError(
f"wget failed (exit code {result.returncode}) fetching tomo ID from {self.OMNY_URL}"
@@ -313,4 +335,4 @@ class TomoIDManager:
except ValueError as exc:
raise OMNYToolsError(
f"Unexpected response from tomo ID server, got: {content!r}"
) from exc
) from exc
File diff suppressed because it is too large Load Diff
@@ -1,13 +1,18 @@
from __future__ import annotations
import threading
from typing import Optional
from bec_lib import bec_logger
from bec_widgets import BECWidget
from bec_widgets.utils.rpc_decorator import rpc_timeout
from qtpy.QtCore import Qt, QTimer
from qtpy.QtCore import Signal
from qtpy.QtGui import QKeySequence
from qtpy.QtWidgets import (
QButtonGroup,
QDoubleSpinBox,
QFormLayout,
QFrame,
QGridLayout,
QGroupBox,
@@ -92,6 +97,9 @@ class SlitControlWidget(BECWidget, QWidget):
PLUGIN = True
# carries fetched slit data from the background thread to the main thread
_slit_data_ready = Signal(object)
USER_ACCESS = [
"set_slit",
"move_center",
@@ -122,8 +130,14 @@ class SlitControlWidget(BECWidget, QWidget):
self.step = self._STEP_DEFAULT
self._ov_items: dict[tuple[int, int], QTableWidgetItem] = {}
self._shortcuts: list[QShortcut] = []
# rotating index through non-selected slits for slow background polling
self._slow_poll_idx: int = 0
self._fetch_thread: Optional[threading.Thread] = None
self._slit_data_ready.connect(self._apply_slit_data)
self._locked: bool = False
self._move_btns: list[QPushButton] = [] # populated by _build_* methods
self._lockout_timer = QTimer(self)
self._lockout_timer.setSingleShot(True)
self._lockout_timer.timeout.connect(self._release_lockout)
self._build_ui()
self._build_shortcuts() # created once, enabled/disabled by toggle
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
@@ -162,7 +176,28 @@ class SlitControlWidget(BECWidget, QWidget):
root.addWidget(_separator())
# 5. keyboard toggle + legend
# 5. absolute target entry + stop button
target_row = QHBoxLayout()
target_row.addWidget(self._build_target_panel(), stretch=1)
self._btn_stop = QPushButton("■ Stop")
self._btn_stop.setToolTip("Stop all axes of the selected slit")
self._btn_stop.clicked.connect(self._stop_slit)
self._btn_stop.setMinimumWidth(70)
target_row.addWidget(self._btn_stop, stretch=0)
root.addLayout(target_row)
root.addWidget(_separator())
# 6. status label (move errors, limit hits)
self._status_lbl = QLabel("")
self._status_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._status_lbl.setMinimumHeight(18)
root.addWidget(self._status_lbl)
self._status_timer = QTimer(self)
self._status_timer.setSingleShot(True)
self._status_timer.timeout.connect(lambda: self._status_lbl.setText(""))
# 7. keyboard toggle + legend
self._btn_kb = QPushButton("\u2328 Keyboard tweaking")
self._btn_kb.setCheckable(True)
self._btn_kb.toggled.connect(self._on_kb_toggled)
@@ -172,12 +207,58 @@ class SlitControlWidget(BECWidget, QWidget):
self._kb_legend.setVisible(False)
root.addWidget(self._kb_legend)
def _build_target_panel(self) -> QGroupBox:
"""Four spinboxes to move the selected slit to an absolute position."""
box = QGroupBox("Move to absolute position (mm)")
form = QFormLayout(box)
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
self._target_spins: dict[str, QDoubleSpinBox] = {}
labels = {"xc": "x center", "xs": "x size", "yc": "y center", "ys": "y size"}
for suffix, label in labels.items():
spin = QDoubleSpinBox()
spin.setDecimals(4)
spin.setRange(-500.0, 500.0)
spin.setSingleStep(0.001)
self._target_spins[suffix] = spin
go_btn = QPushButton("")
go_btn.setFixedWidth(28)
go_btn.setToolTip(f"Move {label} to entered value")
# capture suffix in closure
go_btn.clicked.connect(lambda checked=False, s=suffix: self._move_absolute(s))
spin.returnPressed = None # QDoubleSpinBox has no returnPressed
row_widget = QWidget()
row_layout = QHBoxLayout(row_widget)
row_layout.setContentsMargins(0, 0, 0, 0)
row_layout.addWidget(spin)
row_layout.addWidget(go_btn)
form.addRow(f"{label}:", row_widget)
return box
def _build_overview(self) -> QGroupBox:
box = QGroupBox("All slits (mm) select to tweak")
vbox = QVBoxLayout(box)
tbl = QTableWidget(len(self._SLIT_LABELS), 6)
tbl.setHorizontalHeaderLabels(["", "Slit", "x center", "x size", "y center", "y size"])
_R = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
_L = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
for col, (text, align) in enumerate(
[
("", _L),
("Slit", _L),
("x center", _R),
("x size", _R),
("y center", _R),
("y size", _R),
]
):
hdr_item = QTableWidgetItem(text)
hdr_item.setTextAlignment(align)
tbl.setHorizontalHeaderItem(col, hdr_item)
# col 0 (radio) and col 1 (name) sized to content;
# value cols 2-5 share remaining width equally → evenly distributed
tbl.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
@@ -243,6 +324,7 @@ class SlitControlWidget(BECWidget, QWidget):
btn_shrink_y.clicked.connect(lambda: self.move_size("down"))
btn_shrink_x.clicked.connect(lambda: self.move_size("left"))
btn_grow_x.clicked.connect(lambda: self.move_size("right"))
self._move_btns += [btn_grow_y, btn_shrink_y, btn_shrink_x, btn_grow_x]
grid.addWidget(btn_grow_y, 0, 1)
grid.addWidget(btn_shrink_x, 1, 0)
@@ -275,6 +357,7 @@ class SlitControlWidget(BECWidget, QWidget):
btn_down.clicked.connect(lambda: self.move_center("down"))
btn_left.clicked.connect(lambda: self.move_center("left"))
btn_right.clicked.connect(lambda: self.move_center("right"))
self._move_btns += [btn_up, btn_down, btn_left, btn_right]
grid.addWidget(btn_up, 0, 1)
grid.addWidget(btn_left, 1, 0)
@@ -374,40 +457,131 @@ class SlitControlWidget(BECWidget, QWidget):
else:
self._btn_kb.setText("\u2328 Keyboard tweaking")
def _engage_lockout(self, ms: int = 2000) -> None:
"""Disable move buttons and keyboard move shortcuts for ``ms`` ms."""
self._locked = True
for btn in self._move_btns:
btn.setEnabled(False)
# disable only the movement shortcuts (indices 6+); keep slit-select (0-5)
for sc in self._shortcuts[6:]:
sc.setEnabled(False)
self._lockout_timer.start(ms)
def _release_lockout(self) -> None:
"""Re-enable move buttons and shortcuts (called by the lockout timer)."""
self._locked = False
for btn in self._move_btns:
btn.setEnabled(True)
# only re-enable movement shortcuts if keyboard mode is active
if self._btn_kb.isChecked():
for sc in self._shortcuts[6:]:
sc.setEnabled(True)
def _populate_target_spins(self) -> None:
"""Pre-fill target spinboxes with current device values."""
for suffix, spin in self._target_spins.items():
try:
device = getattr(self.dev, self._dev_name(suffix))
reading = device.read()
value = float(reading[self._dev_name(suffix)]["value"])
spin.setValue(value)
except Exception:
pass
def _move_absolute(self, axis_suffix: str) -> None:
"""Move one axis to the value currently entered in its target spinbox."""
if self._locked:
return
self._engage_lockout(3000) # absolute moves may cover more distance
dev_name = self._dev_name(axis_suffix)
target = self._target_spins[axis_suffix].value()
try:
device = getattr(self.dev, dev_name)
device.move(target)
except Exception as exc:
msg = str(exc) or type(exc).__name__
logger.warning(f"SlitControlWidget: absolute move {dev_name} failed: {msg}")
self._show_status(f"{dev_name}: {msg}")
def _stop_slit(self) -> None:
"""Stop all four axes of the currently selected slit."""
for suffix in ("xc", "xs", "yc", "ys"):
try:
device = getattr(self.dev, self._dev_name(suffix))
device.stop()
except Exception as exc:
msg = str(exc) or type(exc).__name__
logger.warning(f"SlitControlWidget: stop {self._dev_name(suffix)} failed: {msg}")
self._show_status(f"Stop failed: {msg}")
def _show_status(self, msg: str, error: bool = True) -> None:
"""Show a brief status message below the controls (auto-clears after 4 s)."""
color = "#e53935" if error else "#43a047"
self._status_lbl.setText(f'<span style="color:{color}">{msg}</span>')
self._status_timer.start(4000)
# ── overview polling ──────────────────────────────────────────────────────
def _refresh_slit_row(self, slit_num: int) -> None:
"""Read all four axis values for one slit and update its table row."""
row = list(self._SLIT_LABELS).index(slit_num)
for col_off, suffix in enumerate(("xc", "xs", "yc", "ys")):
dev_name = f"sl{slit_num}{suffix}"
item = self._ov_items.get((row, col_off + 2))
if item is None:
continue
try:
device = getattr(self.dev, dev_name)
reading = device.read()
value = float(reading[dev_name]["value"])
item.setText(f"{value:.4f}")
except Exception:
item.setText("---")
# ── background polling ────────────────────────────────────────────────────
def _refresh_overview(self) -> None:
"""
Per-tick (2 s) refresh strategy:
- Selected slit: every tick → updated every 2 s.
- Other slits: one per tick in rotation → each updated every ~10 s
(5 other slits × 2 s). At most 8 device.read() calls per tick.
"""
# always refresh the selected slit
self._refresh_slit_row(self.slit)
Kick off a background thread to fetch slit values without blocking the
Qt main thread. device.read() on virtual slit devices triggers RPC
calls to the device server; doing those in the main thread stalls the UI.
The thread emits _slit_data_ready when done; _apply_slit_data updates
the table and target spinboxes safely from the main thread.
# advance through the other slits one per tick
Per-tick strategy (2 s timer):
- Selected slit: every tick.
- Other slits: one per tick in rotation (~10 s per non-selected slit).
"""
if self._fetch_thread is not None and self._fetch_thread.is_alive():
return # previous fetch still running skip this tick
self._fetch_thread = threading.Thread(target=self._fetch_slit_data, daemon=True)
self._fetch_thread.start()
def _fetch_slit_data(self) -> None:
"""Runs in background thread NO Qt widget access here."""
data: dict[tuple[int, str], Optional[float]] = {}
self._fetch_one_slit(self.slit, data)
others = [n for n in self._SLIT_LABELS if n != self.slit]
if others:
self._slow_poll_idx = self._slow_poll_idx % len(others)
self._refresh_slit_row(others[self._slow_poll_idx])
self._fetch_one_slit(others[self._slow_poll_idx], data)
self._slow_poll_idx += 1
self._slit_data_ready.emit(data) # thread-safe: Qt queues it to main thread
def _fetch_one_slit(self, slit_num: int, data: dict[tuple[int, str], Optional[float]]) -> None:
"""Fetch four axis values for one slit into data (background thread)."""
for suffix in ("xc", "xs", "yc", "ys"):
dev_name = f"sl{slit_num}{suffix}"
try:
device = getattr(self.dev, dev_name)
reading = device.read()
data[(slit_num, suffix)] = float(reading[dev_name]["value"])
except Exception:
data[(slit_num, suffix)] = None
def _apply_slit_data(self, data: dict) -> None:
"""Update table and target spinboxes in the main thread (slot for signal)."""
suffix_order = ("xc", "xs", "yc", "ys")
for (slit_num, suffix), value in data.items():
row = list(self._SLIT_LABELS).index(slit_num)
col_off = suffix_order.index(suffix)
item = self._ov_items.get((row, col_off + 2))
if item is None:
continue
if value is not None:
item.setText(f"{value:.3f}")
if slit_num == self.slit and hasattr(self, "_target_spins"):
spin = self._target_spins.get(suffix)
if spin is not None and not spin.hasFocus():
spin.blockSignals(True)
spin.setValue(value)
spin.blockSignals(False)
else:
item.setText("---")
def _update_row_bold(self):
for row, (slit_num, _) in enumerate(self._SLIT_LABELS.items()):
@@ -436,6 +610,7 @@ class SlitControlWidget(BECWidget, QWidget):
btn.setChecked(True)
self._tweaking_lbl.setText(f"Tweaking: {self._SLIT_LABELS[slit]}")
self._update_row_bold()
self._populate_target_spins()
def move_center(self, direction: str):
"""Move the slit center. direction: up / down / left / right."""
@@ -464,6 +639,9 @@ class SlitControlWidget(BECWidget, QWidget):
self._move_relative(suffix, sign * self.step)
def _move_relative(self, axis_suffix: str, delta: float):
if self._locked:
return
self._engage_lockout(2000)
dev_name = self._dev_name(axis_suffix)
try:
device = getattr(self.dev, dev_name)
@@ -471,7 +649,9 @@ class SlitControlWidget(BECWidget, QWidget):
current = float(reading[dev_name]["value"])
device.move(current + delta)
except Exception as exc:
logger.warning(f"SlitControlWidget: failed to move {dev_name}: {exc}")
msg = str(exc) or type(exc).__name__
logger.warning(f"SlitControlWidget: failed to move {dev_name}: {msg}")
self._show_status(f"{dev_name}: {msg}")
def set_step(self, value: float):
"""Set step size in mm (clamped to 0.0052.0 mm)."""
@@ -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",
@@ -83,6 +104,7 @@ QUEUE_PARAM_NAMES = (
"manual_shift_y",
"frames_per_trigger",
"single_point_instead_of_fermat_scan",
"single_point_random_shift_max",
"tomo_type",
"tomo_angle_range",
"tomo_angle_stepsize",
@@ -105,6 +127,7 @@ DEFAULTS: dict[str, Any] = {
"manual_shift_y": 0.0,
"frames_per_trigger": 1,
"single_point_instead_of_fermat_scan": False,
"single_point_random_shift_max": 0.0,
"tomo_type": 1,
"corridor_size": -1.0,
"tomo_angle_range": 180,
@@ -132,6 +155,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 +173,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
@@ -162,16 +181,40 @@ class TomoParamsWidget(BECWidget, QWidget):
super().__init__(parent=parent, **kwargs)
self.get_bec_shortcuts()
self._edit_mode = False
# widget refs populated by _build_params_panel
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()
# polling timer does NOT refresh params (avoid overwriting edits)
self._poll_timer = QTimer(self)
self._poll_timer.setInterval(self._POLL_INTERVAL_MS)
self._poll_timer.timeout.connect(self._on_poll)
self._poll_timer.start()
# initial load
self.refresh()
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 ──────────────────────────────────────────────────────
def _check_flomni_available(self) -> bool:
"""
Return True if the FlOMNI setup is active in this BEC session.
Uses ``fsamroy`` (the FlOMNI-specific rotation stage) as the
discriminator — it is absent in OMNY and LamNI sessions.
"""
try:
return getattr(self.dev, "fsamroy", None) is not None
except Exception:
return False
# ── global-var I/O ───────────────────────────────────────────────────────
@@ -212,6 +255,36 @@ 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"
"This widget is only meaningful when the FlOMNI\n"
"BEC session is active (fsamroy device present)."
)
lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
lbl.setWordWrap(True)
root.addStretch()
root.addWidget(lbl)
root.addStretch()
return
# scrollable params panel
params_scroll = QScrollArea()
params_scroll.setWidgetResizable(True)
@@ -230,7 +303,6 @@ class TomoParamsWidget(BECWidget, QWidget):
self._btn_queue.setToolTip("Open the tomo queue manager")
self._btn_queue.clicked.connect(self._show_queue_dialog)
root.addWidget(self._btn_queue)
self._queue_dlg: Optional["TomoQueueDialog"] = None
def _build_header(self) -> QGroupBox:
box = QGroupBox("Sample")
@@ -292,16 +364,16 @@ class TomoParamsWidget(BECWidget, QWidget):
decimals=3,
)
self._add_int(common_form, "frames_per_trigger", "Frames / trigger", min_=1, max_=100)
self._add_double(
common_form,
"corridor_size",
"Corridor size (µm, 1=off)",
min_=-1.0,
max_=200.0,
decimals=2,
)
sp = self._add_bool(common_form, "single_point_instead_of_fermat_scan", "Single-point scan")
sp.stateChanged.connect(self._on_single_point_changed)
self._add_double(
common_form,
"single_point_random_shift_max",
"Random shift max (µm)",
min_=0.0,
max_=10.0,
decimals=3,
)
vbox.addLayout(common_form)
# type-1 section
@@ -348,6 +420,9 @@ class TomoParamsWidget(BECWidget, QWidget):
form = QFormLayout(box)
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
self._add_int(
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
)
@@ -456,6 +531,15 @@ class TomoParamsWidget(BECWidget, QWidget):
self._pw["_requested_total"].blockSignals(False)
self._update_projection_preview()
# type-3: derive projections-per-subtomo from stored tomo_angle_stepsize
# (flomni uses 180 / tomo_angle_stepsize for type 3, hardcoded to 180)
stepsize = float(params.get("tomo_angle_stepsize", 10.0))
numprj_t3 = int(180.0 / stepsize) if stepsize > 0 else 18
self._pw["_numprj_type3"].blockSignals(True)
self._pw["_numprj_type3"].setValue(numprj_t3)
self._pw["_numprj_type3"].blockSignals(False)
self._update_type_visibility(type_val)
def _read_fields(self) -> dict[str, Any]:
@@ -478,12 +562,19 @@ class TomoParamsWidget(BECWidget, QWidget):
elif isinstance(widget, QComboBox):
params[key] = widget.currentData()
# derive tomo_angle_stepsize from requested_total + angle_range
# derive tomo_angle_stepsize from requested_total + angle_range (type 1)
angle_range = int(params.get("tomo_angle_range", 180))
requested = self._pw["_requested_total"].value()
stepsize = _requested_to_stepsize(angle_range, requested)
params["tomo_angle_stepsize"] = stepsize
# type 3: override tomo_angle_stepsize from projections-per-subtomo
# (flomni uses 180 / numprj for type 3)
if params.get("tomo_type") == 3:
numprj = self._pw["_numprj_type3"].value()
if numprj > 0:
params["tomo_angle_stepsize"] = 180.0 / numprj
# single_point forces stitch to 0
if params.get("single_point_instead_of_fermat_scan"):
params["stitch_x"] = 0
@@ -509,6 +600,9 @@ class TomoParamsWidget(BECWidget, QWidget):
return "tomo_type must be 1, 2, or 3"
if params.get("tomo_angle_range") not in (180, 360):
return "tomo_angle_range must be 180 or 360"
rshift = params.get("single_point_random_shift_max", 0.0)
if not 0 <= rshift <= 10:
return "single_point_random_shift_max must be between 0 and 10 µm"
return None
# ── type visibility ───────────────────────────────────────────────────────
@@ -516,19 +610,30 @@ class TomoParamsWidget(BECWidget, QWidget):
def _update_type_visibility(self, tomo_type: int) -> None:
self._sec_type1.setVisible(tomo_type == 1)
self._sec_type23.setVisible(tomo_type in (2, 3))
# bunch_size is type-2 only; find its row in the form layout
bunch_widget = self._pw.get("golden_ratio_bunch_size")
if bunch_widget is not None:
bunch_widget.setVisible(tomo_type == 2)
# also hide the label in the form layout
form = self._sec_type23.layout()
if isinstance(form, QFormLayout):
idx = form.indexOf(bunch_widget)
if idx >= 0:
row, role = form.getItemPosition(idx)
label_item = form.itemAt(row, QFormLayout.ItemRole.LabelRole)
if label_item and label_item.widget():
label_item.widget().setVisible(tomo_type == 2)
# _numprj_type3: only visible for type 3
_w__numprj_type3 = self._pw.get("_numprj_type3")
if _w__numprj_type3 is not None:
_w__numprj_type3.setVisible(tomo_type == 3)
_form = self._sec_type23.layout()
if isinstance(_form, QFormLayout):
_idx = _form.indexOf(_w__numprj_type3)
if _idx >= 0:
_row, _role = _form.getItemPosition(_idx)
_lbl = _form.itemAt(_row, QFormLayout.ItemRole.LabelRole)
if _lbl and _lbl.widget():
_lbl.widget().setVisible(tomo_type == 3)
# golden_ratio_bunch_size: only visible for type 2
_w_golden_ratio_bunch_size = self._pw.get("golden_ratio_bunch_size")
if _w_golden_ratio_bunch_size is not None:
_w_golden_ratio_bunch_size.setVisible(tomo_type == 2)
_form = self._sec_type23.layout()
if isinstance(_form, QFormLayout):
_idx = _form.indexOf(_w_golden_ratio_bunch_size)
if _idx >= 0:
_row, _role = _form.getItemPosition(_idx)
_lbl = _form.itemAt(_row, QFormLayout.ItemRole.LabelRole)
if _lbl and _lbl.widget():
_lbl.widget().setVisible(tomo_type == 2)
# ── type-1 projection preview ─────────────────────────────────────────────
@@ -567,11 +672,69 @@ class TomoParamsWidget(BECWidget, QWidget):
self._pw["stitch_y"].setValue(0)
self._pw["stitch_x"].setEnabled(self._edit_mode and not checked)
self._pw["stitch_y"].setEnabled(self._edit_mode and not checked)
# random shift only applies to single-point acquisitions
self._pw["single_point_random_shift_max"].setEnabled(self._edit_mode and checked)
def _on_poll(self) -> None:
"""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 ────────────────────────────────────────────────────
@@ -610,11 +773,14 @@ class TomoParamsWidget(BECWidget, QWidget):
job.get("label", f"job_{row}"),
status,
str(params.get("tomo_type", "?")),
f"{params.get('fovx', '?')} × {params.get('fovy', '?')}",
_format_projections(params),
_fmt_num(params.get("tomo_countingtime")),
_fmt_num(params.get("tomo_shellstep")),
job.get("added_at", ""),
]
for col, text in enumerate(cells):
item = QTableWidgetItem(text)
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
item.setForeground(table.palette().text() if col != 2 else _color_from_hex(color))
table.setItem(row, col, item)
@@ -642,8 +808,50 @@ class TomoParamsWidget(BECWidget, QWidget):
params = self._load_params()
self._populate_fields(params)
def _is_tomo_running(self) -> bool:
"""
Return True if a tomo scan is likely active.
Uses the ``tomo_progress["heartbeat"]`` timestamp written by
``_tomo_scan_at_angle()`` at the start of every projection — this is
updated whether the scan was started via the queue or directly via
``flomni.tomo_scan()``, so it's more reliable than queue job status.
A heartbeat fresher than 2 minutes indicates a scan in progress;
older than that it's either done, or stuck long enough that editing
parameters is intentional.
"""
import datetime
try:
prog = self._gv_get("tomo_progress")
if not isinstance(prog, dict):
return False
heartbeat_str = prog.get("heartbeat")
if heartbeat_str is None:
return False
heartbeat = datetime.datetime.fromisoformat(heartbeat_str)
age_s = (datetime.datetime.now() - heartbeat).total_seconds()
return age_s < 120 # 2-minute window
except Exception:
return False
def submit_params(self) -> None:
"""Validate, write to global vars, and re-lock fields."""
busy_reason = self._beamline_busy_reason()
if busy_reason:
reply = QMessageBox.warning(
self,
"Scan in progress",
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 the "
"current run but will affect any subsequent queue job.\n\n"
"Apply changes now?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
)
if reply != QMessageBox.StandardButton.Yes:
return
params = self._read_fields()
error = self._validate(params)
if error:
@@ -689,10 +897,13 @@ class TomoParamsWidget(BECWidget, QWidget):
if key in skip:
continue
widget.setEnabled(enabled)
# stitch locked when single_point is active
if enabled and self._pw["single_point_instead_of_fermat_scan"].isChecked():
# stitch locked when single_point is active; random shift is the
# inverse -- only editable while single_point is active
single_point = self._pw["single_point_instead_of_fermat_scan"].isChecked()
if enabled and single_point:
self._pw["stitch_x"].setEnabled(False)
self._pw["stitch_y"].setEnabled(False)
self._pw["single_point_random_shift_max"].setEnabled(enabled and single_point)
# projection requested total only meaningful in edit mode
self._pw["_requested_total"].setEnabled(enabled)
@@ -786,9 +997,9 @@ class TomoQueueDialog(QDialog):
vbox = QVBoxLayout(self)
vbox.setSpacing(6)
self._table = QTableWidget(0, 6)
self._table = QTableWidget(0, 8)
self._table.setHorizontalHeaderLabels(
["#", "Label", "Status", "Type", "FOV x×y (µm)", "Added at"]
["#", "Label", "Status", "Type", "Projections", "Exp (s)", "Step (µm)", "Added at"]
)
self._table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
@@ -825,11 +1036,14 @@ class TomoQueueDialog(QDialog):
job.get("label", f"job_{row}"),
status,
str(params.get("tomo_type", "?")),
f"{params.get('fovx', '?')} × {params.get('fovy', '?')}",
_format_projections(params),
_fmt_num(params.get("tomo_countingtime")),
_fmt_num(params.get("tomo_shellstep")),
job.get("added_at", ""),
]
for col, text in enumerate(cells):
item = QTableWidgetItem(text)
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
if col == 2:
item.setForeground(_color_from_hex(color))
self._table.setItem(row, col, item)
@@ -936,6 +1150,53 @@ def _compute_type1(angle_range: int, stepsize: float) -> tuple[int, float, float
return actual_total, achievable_step, stepsize
def _format_projections(params: dict) -> str:
"""
Human-readable projection count for a queued job, mirroring the CLI's
per-type logic:
- type 1: int(angle_range / stepsize) * 8 (8 equally spaced sub-tomos)
- type 3: int(180 / stepsize) * 8 (equally spaced, golden start)
- type 2: golden ratio has no fixed count -> configured max, or ∞ if unset
"""
try:
tomo_type = int(params.get("tomo_type", 1))
stepsize = float(params.get("tomo_angle_stepsize", 0) or 0)
if tomo_type == 1:
angle_range = int(params.get("tomo_angle_range", 180))
actual_total, _, _ = _compute_type1(angle_range, stepsize)
return str(actual_total)
if tomo_type == 3:
if stepsize <= 0:
return "?"
return str(int(180.0 / stepsize) * 8)
if tomo_type == 2:
max_prj = params.get("golden_max_number_of_projections", 0) or 0
try:
max_prj = int(float(max_prj))
except (TypeError, ValueError):
return ""
return str(max_prj) if max_prj > 0 else ""
return "?"
except Exception:
return "?"
def _fmt_num(val) -> str:
"""Compact numeric formatting for table cells; blank for None."""
if val is None:
return ""
try:
f = float(val)
except (TypeError, ValueError):
return str(val)
# trim trailing zeros: 0.100 -> 0.1, 1.0 -> 1
return f"{f:g}"
def _color_from_hex(hex_color: str):
from qtpy.QtGui import QColor
+24 -8
View File
@@ -58,6 +58,7 @@ fheater:
readOnly: false
readoutPriority: baseline
connectionTimeout: 20
foptx:
description: Optics X
deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor
@@ -80,6 +81,10 @@ foptx:
#250 micron, 30 nm, Abe structures
in: -13.8809375
out: -14.1809
#250 micron, 30 nm, Tomas structures
# in: -14.5490625
# out: -14.1809
fopty:
description: Optics Y
deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor
@@ -103,6 +108,10 @@ fopty:
#250 micron, 30 nm, Abe structures
in: 2.8299
out: 2.8299
#250 micron, 30 nm, Tomas structures
# in: 2.8419921875
# out: 2.8419921875
foptz:
description: Optics Z
deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor
@@ -121,6 +130,7 @@ foptz:
connectionTimeout: 20
userParameter:
in: 23
fsamroy:
description: Sample rotation
deviceClass: csaxs_bec.devices.omny.galil.fupr_ophyd.FuprGalilMotor
@@ -154,7 +164,7 @@ fsamx:
readoutPriority: baseline
connectionTimeout: 20
userParameter:
in: -1.1
in: -1.14
fsamy:
description: Sample coarse Y
deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor
@@ -314,8 +324,12 @@ fosax:
# in: 8.731922
# out: 5.1
#250 micron, 30 nm, Abe structures
in: 8.755141
in: 8.7392
out: 5.1
#250 micron, 30 nm, Tomas structures
# in: 9.420798
# out: 5.1
fosay:
description: OSA Y
deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor
@@ -338,7 +352,9 @@ fosay:
#170 micron, 60 nm, 7.6 kev
#in: -0.0422
#250 micron, 30 nm, Abe structures
in: -2.357436
in: -2.3684
#250 micron, 30 nm, Tomas structures
# in: -2.383993
fosaz:
description: OSA Z
deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor
@@ -362,9 +378,9 @@ fosaz:
#170 micron, 60 nm, 7.9 kev, foptz 15.9
# in: 11.9
# out: 6
# micron, 30 nm, 7.9 kev, foptz 32 //abe's fzp's
in: -2
out: -5
# micron, 30 nm, 7.9 kev, very close to the sample. make sure foptz is 32.02 or smaller //abe's fzp's
in: 0.5
out: -5
############################################################
#################### flOMNI RT motors ######################
@@ -387,8 +403,8 @@ rtx:
readoutPriority: on_request
connectionTimeout: 20
userParameter:
low_signal: 10000
min_signal: 9000
low_signal: 8500
min_signal: 8000
rt_pid_voltage: -0.06219
rty:
description: flomni rt
@@ -383,14 +383,14 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS):
# NOTE First we wait that the MCS card is not acquiring. We add here a timeout of 5s to avoid
# a deadlock in case the MCS card is stuck for some reason. This should not happen normally.
status = CompareStatus(mcs.acquiring, ACQUIRING.DONE)
status = CompareStatus(mcs.acquiring, ACQUIRING.DONE, timeout=5)
self.cancel_on_stop(status)
status.wait(timeout=5)
status.wait()
# NOTE Clear the '_omit_mca_callbacks' flag. This makes sure that data received from the mca1...mca3
# counters are forwarded to BEC. Once the flag is set, we create a TransitionStatus DONE->ACQUIRING
# and start the acquisition through erase_start.put(1). Finally, we wait for the card to go to ACQUIRING state.
status_acquiring = CompareStatus(mcs.acquiring, ACQUIRING.ACQUIRING)
status_acquiring = CompareStatus(mcs.acquiring, ACQUIRING.ACQUIRING, timeout=5)
with suppress_mca_callbacks(mcs):
mcs.erase_start.put(1)
mcs._omit_mca_callbacks.clear() # pylint: disable=protected-access
@@ -614,7 +614,7 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS):
# )
if not status_mcs.done:
mcs.acquiring.get(use_monitor=False)
status_mcs.wait(timeout=3)
status_mcs.wait()
except Exception as exc:
if (
mcs.acquiring.get(use_monitor=False) != ACQUIRING.ACQUIRING
@@ -625,7 +625,8 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS):
raise exc
# mcs.erase_start.put(1)
# status_mcs.wait(timeout=3)
status = CompareStatus(mcs.acquiring, ACQUIRING.DONE)
timeout = 5 + self.scan_parameters.exp_time * self.scan_parameters.frames_per_trigger
status = CompareStatus(mcs.acquiring, ACQUIRING.DONE, timeout=timeout)
logger.info(f"Finished preparing mcs card {time.time()-start_time}")
# Send trigger
+64 -1
View File
@@ -45,6 +45,12 @@ class RtFlomniController(Controller):
"laser_tracker_check_signalstrength",
"laser_tracker_check_enabled",
"is_axis_moving",
"emitter_show_all",
"emitter_get",
"emitter_set_tracking_target_y",
"emitter_set_tracking_target_z",
"emitter_set_intensity_threshold_laser",
"emitter_set_psd_intensity_threshold_tracking_low",
]
def __init__(
@@ -391,6 +397,63 @@ class RtFlomniController(Controller):
return True
return False
def emitter_get(self) -> dict:
"""Read the runtime constants served by the ConstEmitter Orchestra module.
These are values that used to live in individual module parameter files
(tracking targets and intensity thresholds) and are now emitted centrally
so they can be tweaked live. The rotation-loop laser threshold
(intensity_threshold_rot_laser) is a pure startup constant and is
intentionally not reported here.
Returns:
dict: current emitter values.
"""
ret = self.socket_put_and_receive("es")
# remove trailing \n
ret = ret.split("\n")[0]
values = [float(val) for val in ret.split(",")]
if len(values) != 4:
raise RtCommunicationError(
f"Expected to receive 4 emitter values. Instead received {values}"
)
return {
"targetpos_tracking_y": values[0],
"targetpos_tracking_z": values[1],
"intensity_threshold_laser": values[2],
"PSD_intensity_threshold_tracking_low": values[3],
}
def emitter_show_all(self):
"""Print all ConstEmitter runtime constants as a table."""
t = PrettyTable()
t.title = "ConstEmitter Runtime Constants"
t.field_names = ["Name", "Value"]
for key, val in self.emitter_get().items():
t.add_row([key, val])
print(t)
def emitter_set_tracking_target_y(self, val: float) -> None:
"""Set the y tracking target position (PID_tracking_y)."""
self.socket_put(f"ey{val:.4f}")
def emitter_set_tracking_target_z(self, val: float) -> None:
"""Set the z tracking target position (PID_tracking_z)."""
self.socket_put(f"ez{val:.4f}")
def emitter_set_intensity_threshold_laser(self, val: float) -> None:
"""Set the shared laser intensity threshold.
Wired into the slew-rate limiters (x/y/z) and the PID loops
(x/y/z and the fast FZP x/y loops). Does not affect the
rotation loops, which use a separate fixed threshold.
"""
self.socket_put(f"et{val:.4f}")
def emitter_set_psd_intensity_threshold_tracking_low(self, val: float) -> None:
"""Set the low PSD intensity threshold used by the tracking PIDs (y and z)."""
self.socket_put(f"el{val:.4f}")
def pid_y(self) -> float:
ret = float(self.socket_put_and_receive("G").strip())
return ret
@@ -929,4 +992,4 @@ if __name__ == "__main__":
socket_cls=SocketIO, socket_host="mpc2844.psi.ch", socket_port=2222, device_manager=None
)
rtcontroller.on()
rtcontroller.laser_tracker_on()
rtcontroller.laser_tracker_on()
+9 -7
View File
@@ -47,13 +47,15 @@ Manually move the gripper to a transfer position
#### Coarse alignment
After the sample transfer the sample stage moved to the measurement position with your new sample. The Xray eye will automatically move in and the shutter will open. You may already see the sample in the omny xeye interface running on the windows computer.
If you see your sample already at the approximately correct height, you can skip steps 1 to 3. Otherwise adjust the height:
After the sample transfer the sample stage moved to the measurement position with your new sample. The Xray eye will automatically move in, the alignment GUI opens and shows a current snapshot.
If you see your sample already at the approximately correct height:
1. `flomni.feedback_disable()` disable the closed loop operation to allow movement of coarse stages
1. `umvr(dev.fsamy, 0.01)`, attention: unit <mm>, move the sample stage relative up (positive) or down (negative) until the sample is approximately vertically centered in xray eye screen
1. `flomni.xrayeye_alignment_start()` start the coarse alignment of the sample by measuring (clicking in the X-ray eye software) the sample position at its height and then angles of 0, 45, 90, 135, 180 degrees. The GUI will present a fit of this data, which is automatically loaded to BEC for aligning the sample.
Otherwise adjust the height manually:
1. `umvr_fsamy_tracked(0.01)`, attention: unit <mm>, move the sample stage relative up (positive) or down (negative) until the sample is approximately vertically centered in xray eye screen
1. `flomni.xrayeye_update_frame()` will update the current image on the xray eye screen
1. `flomni.xrayeye_alignment_start()` start the coarse alignment of the sample by measuring (clicking in the X-ray eye software) the sample position at its height and angles of 0, 45, 90, 135, 180 degrees. The GUI will present a fit of this data, which is automatically loaded to BEC for aligning the sample.
#### Fine alignment
@@ -90,7 +92,7 @@ A __single projection__ is to be repeated use
To continue an __interrupted tomography scan__, run
`flomni.tomo_scan_resume()`
It picks up automatically from wherever the scan was interrupted, no need to look up the exact subtomogram/angle by hand.
It picks up automatically from wherever the scan was interrupted, no need to look up the exact subtomogram/angle by hand. Do not use this command when running from the tomo queuing system. Instead use `flomni.tomo_queue_execute()`.
Alternatively, the same kind of parameters used internally by `tomo_scan_resume()` can be given to `flomni.tomo_scan()` directly, e.g. to resume from a different point than where it actually stopped:
| tomo type | parameters and their defaults |
@@ -322,7 +324,7 @@ For the "8 sub-tomograms" mode, `flomni.tomo_parameters()` also offers a `zero_d
The parameters above can be used to __restart an interrupted acquisition__ manually, or - more conveniently - by running
`flomni.tomo_scan_resume()`
which reads the last recorded progress and resumes automatically at the exact point (subtomogram/angle, or projection for the golden ratio modes) the scan was interrupted at, without needing to look up the values by hand.
which reads the last recorded progress and resumes automatically at the exact point (subtomogram/angle, or projection for the golden ratio modes) the scan was interrupted at, without needing to look up the values by hand. When running from the tomo scan queuing system use `flomni.tomo_queue_execute()` instead!
In case of eight equally spaced sub-tomograms, an individual sub tomogram can be scanned by flomni.sub_tomo_scan(subtomo_number, start_angle). If the start angle is not specified, it will be computed depending on the subtomo_number, which is ranging from 1 to 8.