diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/LamNI_webpage_generator.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/LamNI_webpage_generator.py index a77e77c..a5a1708 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/LamNI_webpage_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/LamNI_webpage_generator.py @@ -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 - } + } \ No newline at end of file diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 2dbca15..44401cc 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -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 = {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 = {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( + " 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", diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py index 1b825e7..a7caa26 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py @@ -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") + \ No newline at end of file diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py index 9e1f9ac..4c59eff 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py @@ -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 . # --------------------------------------------------------------------------- -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'
' f'{label}' @@ -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 = """ + +
+
⋮⋮
+
Tomo queue
+
+
+""" + 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""" @@ -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;"> · STATUS +
@@ -1964,6 +2129,7 @@ def _render_html(phone_numbers: list) -> str:
Remaining-
Finish time-
Started-
+
@@ -1986,7 +2152,7 @@ def _render_html(phone_numbers: list) -> str:
  • Scanning — a tomography scan is actively running (a heartbeat from the scan loop has been seen recently).
  • Running — the scan queue has an active item, but it is not a tomo scan with a heartbeat (e.g. an alignment scan).
  • -
  • Blocked — 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.
  • +
  • Blocked — 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.
  • Idle — nothing is running or queued right now.
  • Unknown — shown only before any activity has been observed since the generator started.
@@ -1995,7 +2161,7 @@ def _render_html(phone_numbers: list) -> str:

Audio warnings

  • System LED — on when audio is enabled on this device.
  • -
  • Watch LED — armed (green) while a scan is running; pulses orange if a scan stops unexpectedly, with a chime every 30s until confirmed.
  • +
  • Watch LED — armed (green) while a scan is running; pulses orange when a scan stops, with a chime every 30s until confirmed. A normal finish (scan → 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.
  • Blocked LED — pulses orange while the queue is locked. A chime fires automatically after 60 continuous minutes blocked, then repeats hourly until cleared. No confirmation needed — it clears itself once the block resolves.
  • Live LED — green while this page's data feed is fresh; pulses orange if the feed goes stale or a fetch fails.
@@ -2081,7 +2247,8 @@ def _render_html(phone_numbers: list) -> str: - +{tomo_queue_card_html} +
⋮⋮
Contacts
@@ -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 · projection '+(d.progress.projection||0)+' of '+(d.progress.total_projections||0)+' · '+(d.progress.tomo_type||''), running: d=>'Queue active · outside tomo heartbeat window', idle: d=>'Idle for '+d.idle_for_human+'', - blocked: d=>'Queue locked'+((d.queue_locks&&d.queue_locks.length>1)?' by multiple locks':'')+': ' - +(d.queue_locks||[]).map(l=>esc(l.reason||l.identifier)).join('; ')+'', + blocked: d=>{{ + const locks=d.queue_locks||[]; + if(locks.length>0) + return 'Queue locked'+(locks.length>1?' by multiple locks':'')+': ' + +locks.map(l=>esc(l.reason||l.identifier)).join('; ')+''; + 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: '+bad.join('; ')+''; + return 'Blocked'; + }}, error: d=>'Queue stopped unexpectedly · idle for '+(d.idle_for_human||'?')+'', unknown: d=>'Waiting for first data\u2026', }}; @@ -2504,12 +2690,18 @@ function esc(s){{return String(s==null?'N/A':s).replace(/&/g,'&').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+='

Measurement settings

'; - for(const[k,v] of Object.entries(setup.settings)) html+=''; + 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+='

Current measurement

'+esc(k)+''+esc(v)+'
'; + if(setup.sample_name) html+=''; + html+=buildParamRows(cp); html+='
Sample name'+esc(setup.sample_name)+'
'; }} if(setup.temperatures){{ @@ -2536,10 +2728,19 @@ function renderBeamlineStates(bl){{ return; }} - summary.innerHTML = 'Scan interlock '+enabledTxt+'' - + (mismatched.length>0 - ? ' · '+mismatched.length+' watched state'+(mismatched.length>1?'s':'')+' currently blocking' - : ' · 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=' · all watched states OK'; + }} else if(bl&&bl.enabled===true){{ + blockNote=' · '+mismatched.length+' watched state'+(mismatched.length>1?'s':'')+' currently blocking'; + }} else {{ + blockNote=' · '+mismatched.length+' watched state'+(mismatched.length>1?'s':'')+' out of spec (interlock '+enabledTxt+', not blocking)'; + }} + summary.innerHTML = 'Scan interlock '+enabledTxt+''+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+=''+dispLabel+''+esc(fmtTqParam(key,params[key]))+''; + }} + if(key==='tomo_angle_stepsize'){{ + const nproj=calcProjections(params); + if(nproj!=null) rows+='Projections'+nproj+''; + }} + }}); + 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='
No jobs queued
'; + 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+='
' + +'' + +'#'+(i+1)+'' + +''+esc(label)+'' + +''+esc(status)+'' + +'' + +'
' + +(paramRows?''+paramRows+'
':'') + +(addedAt?'
'+esc(addedAt)+'
':'') + +'
' + +'
'; + }}); + // 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; diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py b/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py index f1fc522..eb0ff80 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/gui_tools.py @@ -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): diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py index 422046f..fe057f4 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py @@ -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() diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py index 1f9d276..ca05a82 100644 --- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py +++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_general_tools.py @@ -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 \ No newline at end of file diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_webpage_generator.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_webpage_generator.py index 1a04a77..b1e74f7 100644 --- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_webpage_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_webpage_generator.py @@ -1,96 +1,2972 @@ """ -omny/webpage_generator.py -========================== -OMNY-specific webpage generator subclass. +flomni/webpage_generator.py +============================ +Background thread that reads tomo progress from the BEC global variable store +and writes status.json (every cycle) + status.html (once at startup) to a +staging directory. An optional HttpUploader sends those files to a web host +after every cycle, running in a separate daemon thread so uploads never block +the generator cycle. A built-in LocalHttpServer always serves the output +directory locally (default port 8080) so the page can be accessed on the +lab network without any extra setup. -Integration (inside the OMNY __init__ / startup): --------------------------------------------------- - from csaxs_bec.bec_ipython_client.plugins.omny.webpage_generator import ( - OmnyWebpageGenerator, +Architecture +------------ +LocalHttpServer -- built-in HTTP server; serves output_dir on port 8080. + Always started at _launch(); URL printed to console. +HttpUploader -- non-blocking HTTP uploader (fire-and-forget thread). + Tracks file mtimes; only uploads changed files. + Sends a cleanup request to the server when the + ptycho scan ID changes (removes old S*_*.png/jpg). +WebpageGeneratorBase -- all common logic: queue, progress, idle detection, + reconstruction queue, HTML, audio, phone numbers, + outdated-page warning. Import this in subclasses. +FlomniWebpageGenerator -- flomni-specific: temperatures, sample name, settings, + flOMNI logo. +make_webpage_generator() -- factory; selects the right class by session name. + Lazy-imports LamNI / omny subclasses to avoid + circular dependencies. + +Integration (inside Flomni.__init__, after self._progress_proxy.reset()): +-------------------------------------------------------------------------- + from csaxs_bec.bec_ipython_client.plugins.flomni.webpage_generator import ( + FlomniWebpageGenerator, ) - self._webpage_gen = OmnyWebpageGenerator( + self._webpage_gen = FlomniWebpageGenerator( bec_client=client, output_dir="~/data/raw/webpage/", + upload_url="http://omny.online/upload.php", # optional + local_port=8080, # optional, default 8080 ) self._webpage_gen.start() + # On start(), the console prints: + # ➜ Status page: http://hostname:8080/status.html -Or use the factory (auto-selects by session name "omny"): ---------------------------------------------------------- - from csaxs_bec.bec_ipython_client.plugins.flomni.webpage_generator import ( - make_webpage_generator, - ) - self._webpage_gen = make_webpage_generator(bec, output_dir="~/data/raw/webpage/") - self._webpage_gen.start() +Interactive helpers (optional, in the iPython session): +------------------------------------------------------- + flomni._webpage_gen.status() # print current status + local URL + flomni._webpage_gen.verbosity = 2 # VERBOSE: one-line summary per cycle + flomni._webpage_gen.verbosity = 3 # DEBUG: full JSON per cycle + flomni._webpage_gen.stop() # release lock, stop local server + flomni._webpage_gen.start() # restart after stop() + flomni.webpage_gen.set_web_password("pw") # set web password for current e-account -Interactive helpers: --------------------- - omny._webpage_gen.status() - omny._webpage_gen.verbosity = 2 - omny._webpage_gen.stop() - omny._webpage_gen.start() +Session authentication: +----------------------- +Two htpasswd files are used on the remote server: + users.htpasswd -- static, managed manually with htpasswd -B + session.htpasswd -- managed by the generator; one entry for the current e-account + +At _launch(), the generator queries session_query.php to find which account was +previously active. If the account has changed the session.htpasswd is cleared +immediately (old user loses access) and uploaded. The new user calls +set_web_password() to set their own password and gain access. """ +import datetime +import functools +import http.server +import json +import os +import shutil +import socket +import threading +import time from pathlib import Path -from csaxs_bec.bec_ipython_client.plugins.flomni.webpage_generator import ( - WebpageGeneratorBase, - _safe_get, - _safe_float, - _gvar, -) +try: + from PIL import Image as _PILImage + _PIL_AVAILABLE = True +except ImportError: + _PIL_AVAILABLE = False + +from bec_lib import bec_logger + +logger = bec_logger.logger + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_LOCK_VAR_KEY = "webpage_generator_lock" +_LOCK_STALE_AFTER_S = 35 # just over 2 missed cycles (2×15s+5s margin) +_CYCLE_INTERVAL_S = 15 +_TOMO_HEARTBEAT_STALE_S = 90 + +# Path where online ptycho reconstructions are written by the analysis pipeline. +# Subfolders follow the pattern dset_NNNNN and contain S#####_*_phase.png etc. +_PTYCHO_ONLINE_DIR = "~/data/raw/analysis/online/ptycho" +_PTYCHO_THUMB_PX = 200 # longest edge of generated thumbnail in pixels + +VERBOSITY_SILENT = 0 +VERBOSITY_NORMAL = 1 +VERBOSITY_VERBOSE = 2 +VERBOSITY_DEBUG = 3 + +_PHONE_NUMBERS = [ + ("Beamline", "+41 56 310 5845"), + ("Control room", "+41 56 310 5503"), +] -class OmnyWebpageGenerator(WebpageGeneratorBase): +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _now_iso() -> str: + return datetime.datetime.now().isoformat(timespec="seconds") + +def _epoch() -> float: + return time.time() + +def _heartbeat_age_s(iso_str) -> float: + if iso_str is None: + return float("inf") + try: + ts = datetime.datetime.fromisoformat(iso_str) + return (datetime.datetime.now() - ts).total_seconds() + except Exception: + return float("inf") + +def _format_duration(seconds) -> str: + if seconds is None: + return "N/A" + try: + seconds = int(float(seconds)) + except (TypeError, ValueError): + return "N/A" + h, remainder = divmod(seconds, 3600) + m, s = divmod(remainder, 60) + if h > 0: + return f"{h}h {m:02d}m {s:02d}s" + if m > 0: + return f"{m}m {s:02d}s" + return f"{s}s" + +def _check_account_match(bec_client) -> bool: + try: + active = bec_client.active_account + system_user = os.getenv("USER") or os.getlogin() + return active[1:] == system_user[1:] + except Exception: + return True + +def _safe_get(device_manager, dotpath: str): + """Navigate a dotted device path and call .get(), returning None on any error.""" + try: + obj = device_manager.devices + for part in dotpath.split("."): + obj = getattr(obj, part) + return obj.get(cached=True) + except Exception: + return None + +def _safe_float(val, ndigits: int = 2): + try: + return round(float(val), ndigits) + except (TypeError, ValueError): + return None + +def _gvar(bec_client, key, fmt=None, suffix=""): + val = bec_client.get_global_var(key) + if fmt is None: + return val + if val is None: + return "N/A" + try: + return f"{val:{fmt}}{suffix}" + except Exception: + return str(val) + +def _read_beamline_states_info(bec_client) -> dict: + """Return a diagnostic snapshot of all configured beamline states, for + display only (NOT used to derive the 'blocked' experiment status — that + is computed from the queue's own locks via _read_queue_locks(), which is + the authoritative source; see its docstring for why). + + Returns: + { + "enabled": bool | None, # bec.builtin_actors.scan_interlock.enabled + "states": [ + { + "name": str, + "status": str, # 'valid' | 'invalid' | 'warning' | 'unknown' + "label": str, + "watched": bool, # True if present in states_watched + "accepted": list[str] | None, # accepted statuses if watched + "mismatched": bool, # True if watched AND status not in accepted + }, + ... + ], + } + + Beamline states are registered dynamically and can differ from + experiment to experiment, so names are read from the manager's _states + dict (the same one show_all() iterates over internally — there is no + public "list names" method as of this BEC version). states_watched and + enabled are read through the public scan_interlock HLI properties. """ - OMNY-specific webpage generator. - Logo: OMNY.png from the same directory as this file. + manager = getattr(bec_client, "beamline_states", None) + if manager is None: + return {"enabled": None, "states": []} - Override _collect_setup_data() to add OMNY-specific temperatures, - sample name, and measurement settings. + try: + names = list(getattr(manager, "_states", {}).keys()) + except Exception: + names = [] - The old OMNY spec webpage showed: - - Cryo temperatures (XOMNY-TEMP-CRYO-A/B) - - Per-channel temperatures (XOMNY-TEMP1..48) - - Dewar pressure / LN2 flow - - Interferometer strengths (OINTERF) - Map these to BEC device paths below once available. + try: + interlock = bec_client.builtin_actors.scan_interlock + enabled = interlock.enabled + states_watched = interlock.states_watched or {} + except Exception: + enabled = None + states_watched = {} + + states = [] + for name in names: + try: + state_obj = getattr(manager, name, None) + if state_obj is None: + continue + info = state_obj.get() # {"status": ..., "label": ...} + status = info.get("status", "unknown") + label = info.get("label", name) + except Exception: + status, label = "unknown", name + + accepted = states_watched.get(name) + watched = accepted is not None + mismatched = watched and status not in accepted + + states.append({ + "name": name, + "status": status, + "label": label, + "watched": watched, + "accepted": accepted, + "mismatched": mismatched, + }) + + return {"enabled": enabled, "states": states} + + +def _read_queue_locks(primary) -> list: + """Return a list of {"identifier": str, "reason": str} for every lock + currently applied to the primary scan queue. + + BEC's scan_queue.status becomes "LOCKED" (saving the prior status to + restore later) whenever any lock is added, e.g. by the ScanInterlockActor + when a watched beamline state goes out of spec (see scan_interlock.py: + add_queue_lock(queue="primary", reason=..., lock_id="ScanInterlockActor")). + Reading the lock list directly is more robust than re-deriving "blocked" + from beamline states ourselves: it reflects whatever is actually holding + the queue, regardless of which actor (interlock or otherwise) caused it, + and survives changes to which beamline states are configured/watched. + """ + if primary is None: + return [] + try: + return [ + {"identifier": lock.identifier, "reason": lock.reason} + for lock in (primary.locks or []) + ] + except Exception: + return [] + + +# --------------------------------------------------------------------------- +# Status derivation +# --------------------------------------------------------------------------- + +def _derive_status( + progress: dict, + queue_has_active_scan: bool, + 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 -- 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 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. + Once activity has been seen the status goes directly: + scanning/running/blocked -> idle + never through 'unknown'. + """ + 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: + return "running" + if last_active_time is not None or had_activity: + return "idle" + return "unknown" + + +# --------------------------------------------------------------------------- +# HTTP Uploader (non-blocking, fire-and-forget) +# --------------------------------------------------------------------------- + +class HttpUploader: + """ + Uploads files from a local directory to a remote server via HTTP POST. + + Uploads run in a daemon thread so they never block the generator cycle. + If an upload is already in progress when the next cycle fires, the new + upload request is dropped (logged at DEBUG level) rather than queuing up. + + File tracking: + - Tracks mtime of each file; only re-uploads files that have changed. + - upload_dir() -- uploads ALL eligible files (called once at start). + - upload_changed() -- uploads only files whose mtime has changed. + + Scan change cleanup: + - cleanup_ptycho_images() -- sends action=cleanup POST to the server, + asking it to delete all S*_*.png and S*_*.jpg files. + Called automatically by the generator when the ptycho scan ID changes. + + The server-side upload.php must: + - Accept POST with multipart file upload (field name 'file'). + - Accept POST with action=cleanup to delete ptycho image files. + - Enforce IP-based access control (129.129.122.x). + - Validate filename with regex to block path traversal. """ - # TODO: fill in OMNY-specific device paths + _UPLOAD_SUFFIXES = {".html", ".json", ".jpg", ".png", ".pdf", ".txt"} + + def __init__(self, url: str, timeout: float = 20.0): + self._url = url + self._timeout = timeout + self._uploaded: dict[str, float] = {} # abs path -> mtime at last upload + self._lock = threading.Lock() + self._busy = False # True while an upload thread is running + self._warn_at: dict[str, float] = {} # key -> epoch of last warning + + _WARN_COOLDOWN_S = 600 # only repeat the same warning once per minute + + def _warn(self, key: str, msg: str) -> None: + """Log a warning at most once per _WARN_COOLDOWN_S for a given key.""" + now = _epoch() + if now - self._warn_at.get(key, 0) >= self._WARN_COOLDOWN_S: + self._warn_at[key] = now + logger.warning(msg) + + # ── Public API ────────────────────────────────────────────────────────── + + def upload_dir_async(self, directory: Path) -> None: + """Upload ALL eligible files in directory, in a background thread.""" + files = self._eligible_files(directory) + self._dispatch(self._upload_files, files, True) + + def upload_changed_async(self, directory: Path) -> None: + """Upload only changed files in directory, in a background thread.""" + files = self._changed_files(directory) + if files: + self._dispatch(self._upload_files, files, False) + + def upload_file_async(self, path: Path) -> None: + """Upload a single specific file, bypassing suffix whitelist and mtime check.""" + self._dispatch(self._upload_files, [Path(path)], True) + + def cleanup_ptycho_images_async(self) -> None: + """Ask the server to delete all S*_*.png / S*_*.jpg files (background).""" + self._dispatch(self._do_cleanup) + + # ── Internal ──────────────────────────────────────────────────────────── + + def _dispatch(self, fn, *args) -> None: + """Run fn(*args) in a daemon thread. Drops the request if already busy.""" + with self._lock: + if self._busy: + logger.debug("HttpUploader: previous upload still running, skipping") + return + self._busy = True + + def _run(): + try: + fn(*args) + finally: + with self._lock: + self._busy = False + + t = threading.Thread(target=_run, name="HttpUploader", daemon=True) + t.start() + + def _eligible_files(self, directory: Path) -> list: + result = [] + for path in Path(directory).iterdir(): + if path.is_file() and path.suffix in self._UPLOAD_SUFFIXES: + result.append(path) + return result + + def _changed_files(self, directory: Path) -> list: + result = [] + for path in self._eligible_files(directory): + try: + mtime = path.stat().st_mtime + except OSError: + continue + if self._uploaded.get(str(path)) != mtime: + result.append(path) + return result + + def _upload_files(self, files: list, force: bool = False) -> None: + try: + import requests as _requests + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + except ImportError: + self._warn("no_requests", "HttpUploader: 'requests' library not installed") + return + + for path in files: + try: + mtime = path.stat().st_mtime + except OSError: + continue + # Double-check mtime unless forced (upload_dir uses force=True) + if not force and self._uploaded.get(str(path)) == mtime: + continue + try: + with open(path, "rb") as f: + r = _requests.post( + self._url, + files={"file": (path.name, f)}, + timeout=self._timeout, + verify=False, # accept self-signed / untrusted certs + ) + if r.status_code == 200: + self._uploaded[str(path)] = mtime + self._warn_at.pop(f"upload_{path.name}", None) # clear on success + logger.debug(f"HttpUploader: OK {path.name}") + else: + self._warn( + f"upload_{path.name}", + f"HttpUploader: {path.name} -> HTTP {r.status_code}: " + f"{r.text[:120]}" + ) + except Exception as exc: + self._warn(f"upload_{path.name}", f"HttpUploader: {path.name} failed: {exc}") + + def _do_cleanup(self) -> None: + try: + import requests as _requests + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + except ImportError: + return + try: + r = _requests.post( + self._url, + data={"action": "cleanup"}, + timeout=self._timeout, + verify=False, # accept self-signed / untrusted certs + ) + logger.info(f"HttpUploader cleanup: {r.text[:120]}") + self._warn_at.pop("cleanup", None) # clear on success + # Forget mtime records for ptycho files so they get re-uploaded + with self._lock: + to_remove = [k for k in self._uploaded if "/S" in k or "\\S" in k] + for k in to_remove: + self._uploaded.pop(k, None) + except Exception as exc: + self._warn("cleanup", f"HttpUploader cleanup failed: {exc}") + + +# --------------------------------------------------------------------------- +# Local HTTP server (serves output_dir over http://hostname:port/) +# --------------------------------------------------------------------------- + +class LocalHttpServer: + """ + Serves the generator's output directory over plain HTTP in a daemon thread. + + Uses Python's built-in http.server — no extra dependencies. + Request logging is suppressed so the BEC console stays clean. + The server survives stop()/start() cycles: _launch() creates a fresh + instance each time start() is called. + + Usage: + srv = LocalHttpServer(output_dir, port=8080) + srv.start() + print(srv.url) # http://hostname:8080/status.html + srv.stop() + """ + + def __init__(self, directory: Path, port: int = 8080): + self._directory = Path(directory) + self._port = port + self._server = None + self._thread = None + + # ── silence the per-request log lines in the iPython console ────────── + class _QuietHandler(http.server.SimpleHTTPRequestHandler): + def log_message(self, *args): + pass + # handle_error here does nothing — wrong class + + class _QuietHTTPServer(http.server.HTTPServer): + def handle_error(self, request, client_address): + pass # suppress BrokenPipeError and all other per-connection noise + + def start(self) -> None: + Handler = functools.partial( + self._QuietHandler, + directory=str(self._directory), + ) + try: + self._server = self._QuietHTTPServer(("", self._port), Handler) + except OSError as exc: + raise RuntimeError( + f"LocalHttpServer: cannot bind port {self._port}: {exc}" + ) from exc + self._thread = threading.Thread( + target=self._server.serve_forever, + name="LocalHttpServer", + daemon=True, + ) + self._thread.start() + + def stop(self) -> None: + if self._server is not None: + self._server.shutdown() # blocks until serve_forever() returns + self._server = None + + def is_alive(self) -> bool: + return self._thread is not None and self._thread.is_alive() + + @property + def port(self) -> int: + return self._port + + @property + def url(self) -> str: + """Best-guess URL for printing. Uses the machine's hostname.""" + return f"http://{socket.gethostname()}:{self._port}/status.html" + + +# --------------------------------------------------------------------------- +# Base generator +# --------------------------------------------------------------------------- + +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, + output_dir: str = "~/data/raw/webpage/", + cycle_interval: float = _CYCLE_INTERVAL_S, + verbosity: int = VERBOSITY_NORMAL, + upload_url: str = None, + local_port: int = 8080, + ): + self._bec = bec_client + self._output_dir = Path(output_dir).expanduser().resolve() + self._cycle_interval = cycle_interval + self._verbosity = verbosity + self._uploader = HttpUploader(upload_url) if upload_url else None + self._local_port = local_port + self._local_server = None # created fresh each _launch() + + # Derive companion URLs from upload_url + if upload_url is not None: + self._session_query_url = upload_url.replace("upload.php", "session_query.php") + self._set_password_url = upload_url.replace("upload.php", "set_password.php") + else: + self._session_query_url = None + self._set_password_url = None + + self._thread = None + self._stop_event = threading.Event() + self._last_active_time = None # epoch of last tomo/queue activity + self._last_ptycho_scan = None # scan id of last copied ptycho images + self._had_activity = False # True once any activity has been observed + self._last_queue_id = None # tracks queue history changes between cycles + self._owner_id = f"{socket.gethostname()}:{os.getpid()}" + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def start(self) -> None: + """Start the generator. + + If this session already holds the thread, does nothing. + If another session holds a fresh lock, a background watcher thread + is launched that polls every 3 s and takes over as soon as the lock + goes stale — no blocking, no second call to start() needed. + Progress messages during the wait are printed at verbosity >= 2 only. + """ + if not _check_account_match(self._bec): + self._log(VERBOSITY_NORMAL, + "WebpageGenerator: BEC account does not match system user. " + "Not starting.", level="warning") + return + + if self._thread is not None and self._thread.is_alive(): + self._log(VERBOSITY_NORMAL, "WebpageGenerator already running in this session.") + return + + if not self._acquire_lock(): + # Lock is fresh — _acquire_lock() has spawned a background watcher + # that will call _launch() automatically once the lock expires. + return + + self._launch() + + def _launch(self) -> None: + """Actually start the worker thread and write static files. Called by + start() on the normal path, or by the lock-watcher thread after takeover.""" + self._output_dir.mkdir(parents=True, exist_ok=True) + + # 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.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 + # web access immediately. The new user then calls set_web_password(). + self._check_session_user() + + # Start local HTTP server (always on; a fresh instance per _launch). + if self._local_server is not None and self._local_server.is_alive(): + self._local_server.stop() + self._local_server = LocalHttpServer(self._output_dir, self._local_port) + try: + self._local_server.start() + local_url_msg = f" local={self._local_server.url}" + except RuntimeError as exc: + local_url_msg = f" local=ERROR({exc})" + self._log(VERBOSITY_NORMAL, str(exc), level="warning") + + # Upload static files (html + logo) once at startup + if self._uploader is not None: + self._uploader.upload_dir_async(self._output_dir) + + self._stop_event.clear() + self._thread = threading.Thread( + target=self._run, name="WebpageGenerator", daemon=True + ) + self._thread.start() + self._log(VERBOSITY_NORMAL, + f"WebpageGenerator started owner={self._owner_id} " + f"output={self._output_dir} interval={self._cycle_interval}s" + + (f" upload={self._uploader._url}" if self._uploader else " upload=disabled") + + f"\n ➜ Status page:{local_url_msg}") + + def stop(self) -> None: + """Stop the generator thread, local HTTP server, and release the singleton lock.""" + self._stop_event.set() + if self._thread is not None: + self._thread.join(timeout=self._cycle_interval + 5) + # Always clear the reference so start() gets a clean slate, + # even if the thread did not exit within the join timeout. + self._thread = None + if self._local_server is not None: + self._local_server.stop() + self._local_server = None + self._release_lock() + self._log(VERBOSITY_NORMAL, "WebpageGenerator stopped.") + + @property + def verbosity(self) -> int: + return self._verbosity + + @verbosity.setter + def verbosity(self, val: int) -> None: + self._verbosity = val + self._log(VERBOSITY_NORMAL, f"WebpageGenerator verbosity -> {val}") + + def status(self) -> None: + """Print a human-readable status summary to the console.""" + lock = self._read_lock() + running = self._thread is not None and self._thread.is_alive() + local = self._local_server.url if (self._local_server and self._local_server.is_alive()) else "stopped" + remote_user = self._get_remote_session_user() if self._session_query_url else "n/a" + try: + active_account = self._bec.active_account + except Exception: + active_account = "unknown" + print( + f"WebpageGenerator\n" + f" This session running : {running}\n" + f" Lock owner : {lock.get('owner_id', 'none')}\n" + f" Lock heartbeat : {lock.get('heartbeat', 'never')}\n" + f" Output dir : {self._output_dir}\n" + f" Local URL : {local}\n" + f" Cycle interval : {self._cycle_interval}s\n" + f" Upload URL : {self._uploader._url if self._uploader else 'disabled'}\n" + f" Session query URL : {self._session_query_url or 'disabled'}\n" + f" BEC active account : {active_account}\n" + f" Server session user : {remote_user}\n" + f" Verbosity : {self._verbosity}\n" + ) + + # ------------------------------------------------------------------ + # Logo + # ------------------------------------------------------------------ + + def _logo_path(self): + """ + Return a Path to the logo PNG for this setup, or None for text fallback. + Override in subclasses. + """ + return None + + def _copy_logo(self) -> None: + """Copy the setup logo to output_dir/logo.png if available.""" + src = self._logo_path() + if src is None: + return + src = Path(src) + if not src.exists(): + self._log(VERBOSITY_VERBOSE, + f"Logo not found at {src}, using text fallback.", level="warning") + return + try: + shutil.copy2(src, self._output_dir / "logo.png") + self._log(VERBOSITY_VERBOSE, f"Logo copied from {src}") + except Exception as exc: + self._log(VERBOSITY_NORMAL, + f"Failed to copy logo: {exc}", level="warning") + + # ------------------------------------------------------------------ + # Session authentication + # ------------------------------------------------------------------ + + def _get_remote_session_user(self) -> "str | None": + """GET session_query.php and return the username in session.htpasswd, or None.""" + if self._session_query_url is None: + return None + try: + import requests as _requests + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + r = _requests.get(self._session_query_url, timeout=10, verify=False) + if r.status_code == 200: + return r.json().get("user") # str or None + self._log(VERBOSITY_VERBOSE, + f"session_query: HTTP {r.status_code}", level="warning") + except Exception as exc: + self._log(VERBOSITY_VERBOSE, + f"session_query failed: {exc}", level="warning") + return None + + def _check_session_user(self) -> None: + """Compare BEC active account with the server session user. + + - Match: silent, nothing to do. + - No remote user: current user has no web auth yet — print reminder. + - Mismatch: clear session.htpasswd immediately (old user loses access), + then print reminder that current user has no web auth yet. + """ + if self._session_query_url is None: + return # no remote server configured + + try: + current_account = self._bec.active_account # e.g. 'p23092' + except Exception as exc: + self._log(VERBOSITY_VERBOSE, + f"session check: cannot read active_account: {exc}", level="warning") + return + + remote_user = self._get_remote_session_user() + + if remote_user == current_account: + return # all good, stay silent + + if remote_user is not None: + # Different user still has web access — remove it immediately. + print(f"WebpageGenerator: account changed " + f"'{remote_user}' -> '{current_account}' " + f"— removing web access for '{remote_user}'") + self._clear_session_htpasswd() + + # Either no previous user or a different user — either way the + # current account has no web authentication yet. + _BOLD_RED = "\033[1;31m" + _RESET = "\033[0m" + print(f"{_BOLD_RED}***\nWebpageGenerator: '{current_account}' has no web authentication yet.{_RESET}") + print(f"{_BOLD_RED} To set a password run: flomni.set_web_password(\"your_password\")\n***{_RESET}") + + def _clear_session_htpasswd(self) -> None: + """Write an empty session.htpasswd locally and upload it.""" + session_path = self._output_dir / "session.htpasswd" + session_path.write_text("") + if self._uploader is not None: + self._uploader.upload_file_async(session_path) + + def set_web_password(self, password: str) -> None: + """Set the web password for the current BEC account. + + Sends the plaintext password to set_password.php on the server + (IP-restricted, HTTPS). PHP generates the bcrypt hash and writes + session.htpasswd directly — no Python bcrypt package needed. + + The username is taken from bec.active_account (e.g. 'p23092'). + + Example:: + flomni.webpage_gen.set_web_password("my_secret_password") + """ + if self._set_password_url is None: + print("set_web_password: no upload URL configured — cannot reach server") + return + + try: + account = self._bec.active_account + except Exception as exc: + print(f"set_web_password: cannot determine active account: {exc}") + return + + try: + import requests as _requests + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + r = _requests.post( + self._set_password_url, + data={"username": account, "password": password}, + timeout=15, + verify=False, + ) + if r.status_code == 200: + resp = r.json() + if resp.get("ok"): + print(f"set_web_password: password set for '{account}' on server") + else: + print(f"set_web_password: server error: {resp.get('error', '?')}") + else: + print(f"set_web_password: HTTP {r.status_code}: {r.text[:120]}") + except Exception as exc: + print(f"set_web_password: request failed: {exc}") + + # ------------------------------------------------------------------ + # Singleton lock + # ------------------------------------------------------------------ + + def _acquire_lock(self) -> bool: + """Try to acquire the singleton lock. + + Returns True immediately if the lock is free or stale. + Returns False and launches a background watcher thread if the lock is + held by another session — the watcher will call _launch() automatically + once the lock expires (no user action needed). + """ + _POLL = 3 # seconds between retries in the watcher thread + + lock = self._read_lock() + owner = lock.get("owner_id", "unknown") if lock else "none" + age = _heartbeat_age_s(lock.get("heartbeat")) if lock else float("inf") + + if age >= _LOCK_STALE_AFTER_S: + if lock: + self._log(VERBOSITY_NORMAL, + f"Stale lock (owner: '{owner}', {age:.0f}s ago). Taking over.") + self._write_lock() + return True + + # Lock is fresh — spin up a background watcher and return immediately. + self._log(VERBOSITY_NORMAL, + f"WebpageGenerator: lock held by '{owner}' ({age:.0f}s ago). " + f"Will take over automatically when it expires.") + + def _watcher(): + while True: + time.sleep(_POLL) + lk = self._read_lock() + age = _heartbeat_age_s(lk.get("heartbeat")) if lk else float("inf") + self._log(VERBOSITY_VERBOSE, + f" …waiting for lock, age {age:.0f}s / {_LOCK_STALE_AFTER_S}s") + if age >= _LOCK_STALE_AFTER_S: + break + owner2 = lk.get("owner_id", "unknown") if lk else "none" + self._log(VERBOSITY_VERBOSE, + f"Lock expired (was '{owner2}'). Taking over.") + self._write_lock() + self._launch() + + t = threading.Thread(target=_watcher, name="WebpageGeneratorWatcher", daemon=True) + t.start() + return False + + def _write_lock(self) -> None: + self._bec.set_global_var(_LOCK_VAR_KEY, { + "owner_id": self._owner_id, + "heartbeat": _now_iso(), + "pid": os.getpid(), + "hostname": socket.gethostname(), + }) + + def _read_lock(self) -> dict: + val = self._bec.get_global_var(_LOCK_VAR_KEY) + return val if isinstance(val, dict) else {} + + def _release_lock(self) -> None: + lock = self._read_lock() + if lock.get("owner_id") == self._owner_id: + self._bec.delete_global_var(_LOCK_VAR_KEY) + + # ------------------------------------------------------------------ + # Main loop + # ------------------------------------------------------------------ + + def _run(self) -> None: + while not self._stop_event.is_set(): + t0 = _epoch() + try: + self._cycle() + except Exception as exc: + self._log(VERBOSITY_NORMAL, + f"WebpageGenerator cycle error: {exc}", level="warning") + try: + self._write_lock() + except Exception: + pass + self._stop_event.wait(max(0.0, self._cycle_interval - (_epoch() - t0))) + + def _cycle(self) -> None: + """One generator cycle: read state -> derive status -> write files -> upload.""" + + # ── Progress ───────────────────────────────────────────────── + progress = self._bec.get_global_var("tomo_progress") or {} + + # ── Queue ──────────────────────────────────────────────────── + # queue_status reflects BEC's ScanQueueStatus: 'RUNNING', 'PAUSED', + # or 'LOCKED' (the latter set whenever any lock — e.g. from + # ScanInterlockActor — is applied to the queue; the prior status is + # restored once the lock is released). + # A scan is actually executing only when info is non-empty AND + # active_request_block is set on the first entry. + try: + qi = self._bec.queue.queue_storage.current_scan_queue + primary = qi.get("primary") + queue_status = primary.status if primary is not None else "unknown" + queue_has_active_scan = ( + primary is not None + and len(primary.info) > 0 + and primary.info[0].active_request_block is not None + ) + queue_locks = _read_queue_locks(primary) + except Exception: + queue_status = "unknown" + queue_has_active_scan = False + queue_locks = [] + + # ── Beamline states ────────────────────────────────────────── + # Diagnostic display 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: + self._log(VERBOSITY_VERBOSE, + 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. + current_scan_number = None + if queue_has_active_scan: + try: + current_scan_number = primary.info[0].active_request_block.scan_number + except Exception: + current_scan_number = None + + # ── Idle tracking ──────────────────────────────────────────── + hb_age = _heartbeat_age_s(progress.get("heartbeat")) + tomo_active = hb_age < _TOMO_HEARTBEAT_STALE_S + + # Detect scans that start and finish entirely between two polls + # by watching for a new queue_id at the top of history. + try: + history = self._bec.queue.queue_storage.queue_history + latest_queue_id = history[0].info.queue_id if history else None + except Exception: + latest_queue_id = None + + history_changed = ( + latest_queue_id is not None + and latest_queue_id != self._last_queue_id + ) + self._last_queue_id = latest_queue_id + + 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, 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() + + # ── Ptychography images ─────────────────────────────────────── + # Snapshot scan id BEFORE collecting so we can detect changes + prev_ptycho_scan = self._last_ptycho_scan + + ptycho = {} + try: + ptycho = self._collect_ptycho_images() + except Exception as exc: + self._log(VERBOSITY_VERBOSE, f"ptycho image error: {exc}", level="warning") + + # ── Setup-specific data (subclass hook) ─────────────────────── + setup = {} + try: + setup = self._collect_setup_data() + except Exception as exc: + self._log(VERBOSITY_VERBOSE, f"setup data error: {exc}", level="warning") + + # ── Payload ─────────────────────────────────────────────────── + payload = { + "generated_at": _now_iso(), + "generated_at_epoch": _epoch(), + "experiment_status": exp_status, + "queue_status": queue_status, + "queue_has_active_scan": queue_has_active_scan, + "idle_for_s": idle_for_s, + "idle_for_human": _format_duration(idle_for_s), + "queue_locks": queue_locks, + "beamline_states": beamline_states_info, + "tomo_queue": tomo_queue, + "progress": { + "tomo_type": progress.get("tomo_type", "N/A"), + "projection": progress.get("projection", 0), + "total_projections": progress.get("total_projections", 0), + "subtomo": progress.get("subtomo", 0), + "subtomo_projection": progress.get("subtomo_projection", 0), + "subtomo_total_projections": progress.get("subtomo_total_projections", 1), + "angle": progress.get("angle", 0), + "tomo_start_time": progress.get("tomo_start_time"), + "tomo_start_scan_number": progress.get("tomo_start_scan_number"), + "current_scan_number": current_scan_number, + "estimated_remaining_s": progress.get("estimated_remaining_time"), + "estimated_remaining_human": _format_duration( + progress.get("estimated_remaining_time") + ), + "estimated_finish_time": progress.get("estimated_finish_time"), + "tomo_heartbeat": progress.get("heartbeat"), + "tomo_heartbeat_age_s": round(hb_age, 1) if hb_age != float("inf") else None, + }, + "recon": recon, + "ptycho": ptycho, + "setup": setup, + "generator": { + "owner_id": self._owner_id, + "cycle_interval_s": self._cycle_interval, + "active_account": getattr(self._bec, "active_account", None) or "", + }, + } + + # ── Write status.json ───────────────────────────────────────── + # (HTML is static, written once at _launch()) + (self._output_dir / "status.json").write_text( + json.dumps(payload, indent=2, default=str) + ) + + # ── Upload ──────────────────────────────────────────────────── + # Uploads run in a background daemon thread — never blocks this cycle. + # If the server is unreachable the cycle continues normally; failures + # are logged as warnings. If an upload is still in progress from the + # previous cycle the new request is dropped (logged at DEBUG level). + if self._uploader is not None: + new_scan = ptycho.get("scan_id") + if new_scan and new_scan != prev_ptycho_scan: + # Scan changed: ask server to delete old S*_*.png/jpg first, + # then upload all current files (including new ptycho images). + self._log(VERBOSITY_VERBOSE, + f"Ptycho scan changed {prev_ptycho_scan} -> {new_scan}, " + f"triggering remote cleanup + full upload") + self._uploader.cleanup_ptycho_images_async() + # Small delay so cleanup runs before the file uploads start. + # Both are async, so we schedule the full upload on a short timer. + def _delayed_full_upload(): + time.sleep(2) + self._uploader.upload_dir_async(self._output_dir) + threading.Thread(target=_delayed_full_upload, + name="HttpUploaderDelayed", daemon=True).start() + else: + # Normal cycle: only upload files that have changed since last cycle. + self._uploader.upload_changed_async(self._output_dir) + + # ── Console feedback ────────────────────────────────────────── + blocked_summary = ( + f" locked_by={','.join(l['identifier'] for l in queue_locks)}" + if queue_locks else "" + ) + self._log(VERBOSITY_VERBOSE, + f"[{_now_iso()}] {exp_status:<12} active={queue_has_active_scan} " + f"proj={payload['progress']['projection']}/" + f"{payload['progress']['total_projections']} " + f"hb={payload['progress']['tomo_heartbeat_age_s']}s " + f"idle={_format_duration(idle_for_s)}" + blocked_summary) + self._log(VERBOSITY_DEBUG, + f" payload:\n{json.dumps(payload, indent=4, default=str)}") + + # ------------------------------------------------------------------ + # Reconstruction queue + # ------------------------------------------------------------------ + + def _collect_recon_data(self) -> dict: + foldername = ( + self._bec.get_global_var("ptycho_reconstruct_foldername") or "ptycho_reconstruct" + ) + base = Path("~/data/raw/logs/reconstruction_queue").expanduser() + queue_dir = base / foldername + failed_dir = queue_dir / "failed" + + waiting = failed = 0 + try: + if queue_dir.exists(): + waiting = len(list(queue_dir.glob("*.dat"))) + except Exception: + pass + try: + if failed_dir.exists(): + failed = len(list(failed_dir.glob("*.dat"))) + except Exception: + pass + + return { + "foldername": foldername, + "folder_path": str(queue_dir), + "waiting": waiting, + "failed": failed, + } + + # ------------------------------------------------------------------ + # Ptychography images + # ------------------------------------------------------------------ + + def _collect_ptycho_images(self) -> dict: + """Find the latest ptycho reconstruction, copy/thumbnail changed images. + + Scans _PTYCHO_ONLINE_DIR for the most-recently-modified subfolder whose + name contains an underscore (e.g. dset_00005). Within that folder looks + for PNG files matching S*_phase.png, S*_probe.png, S*_amplitude.png, + S*_err.png, S*_spectrum.png. + + When the scan changes (new S##### prefix detected): + - Thumbnails are generated with Pillow and saved to output_dir + - Full-size PNGs are also copied to output_dir (for click-to-full) + - Old ptycho files from previous scans are removed from output_dir + + Returns a dict consumed by the JS ptycho renderer: + scan_id str e.g. "S06666" + images list [{"role": "phase", "thumb": "..._thumb.jpg", + "full": "..._phase.png"}, ...] + folder str source folder path (for diagnostics) + """ + base = Path(_PTYCHO_ONLINE_DIR).expanduser() + if not base.exists(): + return {} + + # Find latest subfolder by modification time. + candidates = sorted( + [d for d in base.iterdir() if d.is_dir()], + key=lambda d: d.stat().st_mtime, + ) + if not candidates: + return {} + latest_dir = candidates[-1] + + # Roles we care about — phase and probe are primary (always shown), + # amplitude/err/spectrum are secondary (shown on expand) + _ROLES = [ + ("phase", "S*phase.png", True), + ("probe", "S*probe.png", True), + ("amplitude", "S*amplitude.png", False), + ("error", "S*err.png", False), + ("spectrum", "S*spectrum.png", False), + ] + + # Collect available source files + found = {} + scan_id = None + for role, pattern, _primary in _ROLES: + matches = sorted(latest_dir.glob(pattern)) + if matches: + src = matches[-1] + stem = src.stem # e.g. S06666_400x400_b0_run_1_phase + sid = stem.split("_")[0] + if scan_id is None: + scan_id = sid + found[role] = src + + if not scan_id: + return {} + + # If scan unchanged, thumbs exist, and source hasn't changed, skip all work + thumb_key = f"{scan_id}_phase_thumb.jpg" + thumb_path = self._output_dir / thumb_key + src_phase = found.get("phase") + thumb_fresh = ( + thumb_path.exists() + and src_phase is not None + and thumb_path.stat().st_mtime >= src_phase.stat().st_mtime + ) + if (scan_id == self._last_ptycho_scan and thumb_fresh): + # Rebuild result dict from existing files without doing I/O + images = [] + for role, _pat, primary in _ROLES: + thumb = self._output_dir / f"{scan_id}_{role}_thumb.jpg" + full = self._output_dir / f"{scan_id}_{role}.png" + if thumb.exists() and full.exists(): + images.append({ + "role": role, + "thumb": thumb.name, + "full": full.name, + "primary": primary, + }) + return {"scan_id": scan_id, "images": images, "folder": str(latest_dir)} + + # Scan changed (or first run) — clean up old ptycho files in output_dir + self._log(VERBOSITY_VERBOSE, + f"New ptycho scan detected: {scan_id} in {latest_dir.name}") + for old_file in self._output_dir.glob("S*_*_thumb.jpg"): + if not old_file.name.startswith(scan_id + "_"): + old_file.unlink(missing_ok=True) + for old_file in self._output_dir.glob("S*_*.png"): + if not old_file.name.startswith(scan_id + "_"): + for role, _, _ in _ROLES: + if f"_{role}." in old_file.name or f"_{role}_" in old_file.name: + old_file.unlink(missing_ok=True) + break + + # Generate thumbnails and copy full-size for each found role + images = [] + for role, _pat, primary in _ROLES: + src = found.get(role) + if src is None: + continue + thumb_name = f"{scan_id}_{role}_thumb.jpg" + full_name = f"{scan_id}_{role}.png" + thumb_path = self._output_dir / thumb_name + full_path = self._output_dir / full_name + try: + if _PIL_AVAILABLE: + img = _PILImage.open(src) + img.thumbnail((_PTYCHO_THUMB_PX, _PTYCHO_THUMB_PX)) + img.save(thumb_path, "JPEG", quality=85) + else: + shutil.copy2(src, thumb_path) + shutil.copy2(src, full_path) + images.append({ + "role": role, + "thumb": thumb_name, + "full": full_name, + "primary": primary, + }) + self._log(VERBOSITY_VERBOSE, f" ptycho {role}: thumb={thumb_name}") + except Exception as exc: + self._log(VERBOSITY_VERBOSE, + f" ptycho {role} failed: {exc}", level="warning") + + self._last_ptycho_scan = scan_id + return {"scan_id": scan_id, "images": images, "folder": str(latest_dir)} + + # ------------------------------------------------------------------ + # Subclass hooks + # ------------------------------------------------------------------ + + def _collect_setup_data(self) -> dict: + """ + Override in subclasses to return a dict of setup-specific data. + The dict is embedded in the JSON payload as 'setup' and rendered + 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 + 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 {} + + # ------------------------------------------------------------------ + # Logging + # ------------------------------------------------------------------ + + def _log(self, min_verbosity: int, msg: str, level: str = "info") -> None: + if self._verbosity < min_verbosity: + return + if level == "warning": + logger.warning(msg) + elif level == "error": + logger.error(msg) + else: + print(msg) + + +# --------------------------------------------------------------------------- +# flOMNI subclass +# --------------------------------------------------------------------------- + +class FlomniWebpageGenerator(WebpageGeneratorBase): + """ + flOMNI-specific webpage generator. + 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 = { - # "Sample (cryo A)": "omny_temp.cryo_a", - # "Cryo head (B)": "omny_temp.cryo_b", + "Heater": "flomni_temphum.temperature_heater", + "Heater setpoint": "flomni_temphum.temperature_heaterset_rb", + "OSA": "flomni_temphum.temperature_osa", + "Mirror": "flomni_temphum.temperature_mirror", } def _logo_path(self): - return Path(__file__).parent / "OMNY.png" + return Path(__file__).parent / "flOMNI.png" def _collect_setup_data(self) -> dict: - # ── OMNY-specific data goes here ────────────────────────────── - # Uncomment and adapt when device names are known: - # - # dm = self._bec.device_manager - # sample_name = _safe_get(dm, "omny_samples.sample_names.sample0") or "N/A" - # temperatures = { - # label: _safe_float(_safe_get(dm, path)) - # for label, path in self._TEMP_MAP.items() - # } - # settings = { - # "Sample name": sample_name, - # "FOV x / y": ..., - # "Exposure time": _gvar(self._bec, "tomo_countingtime", ".3f", " s"), - # "Angle step": _gvar(self._bec, "tomo_angle_stepsize", ".2f", "\u00b0"), - # } - # return { - # "type": "omny", - # "sample_name": sample_name, - # "temperatures": temperatures, - # "settings": settings, - # } + dm = self._bec.device_manager - # Placeholder — returns minimal info until implemented - return { - "type": "omny", - # OMNY-specific data here + # ── Sample name ─────────────────────────────────────────────── + sample_name = _safe_get(dm, "flomni_samples.sample_names.sample0") or "N/A" + + # ── Temperatures ────────────────────────────────────────────── + temperatures = { + label: _safe_float(_safe_get(dm, path)) + for label, path in self._TEMP_MAP.items() } + + # ── 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 + current_params = {} + for key in self._CURRENT_PARAM_KEYS: + try: + val = g.get_global_var(key) + except Exception: + val = None + if val is not None: + current_params[key] = val + + return { + "type": "flomni", + "sample_name": sample_name, + "temperatures": temperatures, + "current_params": current_params, + } + + +# --------------------------------------------------------------------------- +# Factory +# --------------------------------------------------------------------------- + +def make_webpage_generator(bec_client, **kwargs): + """ + Select the appropriate WebpageGenerator subclass based on the BEC session + name (bec._ip.prompts.session_name) and return a ready-to-start instance. + + Usage (generic startup script): + from csaxs_bec.bec_ipython_client.plugins.flomni.webpage_generator import ( + make_webpage_generator, + ) + gen = make_webpage_generator(bec, output_dir="~/data/raw/webpage/", + upload_url="http://omny.online/upload.php") + gen.start() + """ + try: + session = bec_client._ip.prompts.session_name + except Exception: + session = "unknown" + + if session == "flomni": + return FlomniWebpageGenerator(bec_client, **kwargs) + + elif session == "lamni": + from csaxs_bec.bec_ipython_client.plugins.LamNI.webpage_generator import ( + LamniWebpageGenerator, + ) + return LamniWebpageGenerator(bec_client, **kwargs) + + elif session == "omny": + from csaxs_bec.bec_ipython_client.plugins.omny.webpage_generator import ( + OmnyWebpageGenerator, + ) + return OmnyWebpageGenerator(bec_client, **kwargs) + + else: + return WebpageGeneratorBase(bec_client, **kwargs) + + +# --------------------------------------------------------------------------- +# HTML template (written once at start(), not every cycle) +# Three themes: dark (default), light, auto (follows OS preference). +# Theme is stored in localStorage and applied via data-theme on . +# --------------------------------------------------------------------------- + +def _render_html(phone_numbers: list, has_tomo_queue: bool = True, tomo_types: dict = None) -> str: + phones_html = "\n".join( + f'
' + f'{label}' + f'{num}
' + 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 = """ + +
+
⋮⋮
+
Tomo queue
+
+
+""" + 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""" + + + + + Experiment Status + + + +
+ +
+ ⚠ Status page data is outdated — generator may have stopped +
+ +
+
+ + + · STATUS + +
+
+ +
+ Theme + + + +
+
updating…
+
+
+ + +
+
-
+
Loading…
+
+ +
+
Tomography progress
+
+ + + + + + +
+ 0% + OVERALL +
+
+
+
Projection-
+
Sub-tomo-
+
Angle-
+
Tomo type-
+
Remaining-
+
Finish time-
+
Started-
+ +
+ +
+ + + +
+ +
+ + + +
+ About this page + +
+
+

Measurement status

+

The coloured pill at the top reflects what the instrument is currently doing:

+
    +
  • Scanning — a tomography scan is actively running (a heartbeat from the scan loop has been seen recently).
  • +
  • Running — the scan queue has an active item, but it is not a tomo scan with a heartbeat (e.g. an alignment scan).
  • +
  • Blocked — 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.
  • +
  • Idle — nothing is running or queued right now.
  • +
  • Unknown — shown only before any activity has been observed since the generator started.
  • +
+

Tomography progress

+

The rings show overall progress (outer) and progress within the current sub-tomogram (inner). Remaining is the estimated duration left; Finish time is the estimated wall-clock time the measurement will complete. The projection count is followed by the BEC scan number(s) in parentheses, e.g. (S06650→S06770), for direct comparison with reconstruction filenames.

+

Audio warnings

+
    +
  • System LED — on when audio is enabled on this device.
  • +
  • Watch LED — armed (green) while a scan is running; pulses orange when a scan stops, with a chime every 30s until confirmed. A normal finish (scan → idle) plays a rising success tone; other stops play the falling warning tone. A transition into blocked stays silent (see Blocked LED) since it is external and self-resolving.
  • +
  • Blocked LED — pulses orange while the queue is locked. A chime fires automatically after 60 continuous minutes blocked, then repeats hourly until cleared. No confirmation needed — it clears itself once the block resolves.
  • +
  • Live LED — green while this page's data feed is fresh; pulses orange if the feed goes stale or a fetch fails.
  • +
+

On iPhone/iPad, tap Enable once to unlock audio — this is a browser requirement and only needs to be done once per visit.

+

Beamline states

+

Lists every beamline condition currently registered with BEC, its live status (valid/invalid/warning/unknown), and whether the scan interlock is watching it. A watched state that is not in its accepted status is what actually causes the Blocked status above — states that are not watched (like an unrelated simulated shutter) can be invalid without blocking anything.

+

Other features

+

Cards below the fixed status/progress section can be dragged by their ⋮⋮ handle to reorder; the order is remembered on this device. The theme switcher (top right) follows your OS by default, or can be forced to light/dark.

+
+
+ + +
+ +
+
⋮⋮
+
+
+
+
+ System +
+
+
+ Watch +
+
+
+ Blocked +
+
+
+ Live +
+
+ Audio disabled +
+
+ + + + +
+
+ + +
+
⋮⋮
+
Reconstruction queue
+
+
Waiting-
+
Failed-
+
Queue name-
+
+
+
+ +
+
⋮⋮
+
Ptychography reconstructions
+
+
No reconstruction found
+
+
+ + + + + +
+
⋮⋮
+
Beamline states
+
+ + + + + +
StateStatusWatched
+
+ +{tomo_queue_card_html} + +
+
⋮⋮
+
Contacts
+
+{phones_html} +
+
+ +
+ +
+ generator: - + tomo_heartbeat: - +
+ +
+ + + +""" \ No newline at end of file diff --git a/csaxs_bec/bec_widgets/widgets/slit_control/slit_control.py b/csaxs_bec/bec_widgets/widgets/slit_control/slit_control.py index 7e2c661..3dc3658 100644 --- a/csaxs_bec/bec_widgets/widgets/slit_control/slit_control.py +++ b/csaxs_bec/bec_widgets/widgets/slit_control/slit_control.py @@ -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'{msg}') + 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.005–2.0 mm).""" diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py index 5b46715..201050a 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -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 diff --git a/csaxs_bec/device_configs/ptycho_flomni.yaml b/csaxs_bec/device_configs/ptycho_flomni.yaml index c7d8d7b..103c3a8 100644 --- a/csaxs_bec/device_configs/ptycho_flomni.yaml +++ b/csaxs_bec/device_configs/ptycho_flomni.yaml @@ -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 diff --git a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py index b4455a2..05d1054 100644 --- a/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py +++ b/csaxs_bec/devices/epics/delay_generator_csaxs/ddg_1.py @@ -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 diff --git a/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py b/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py index 4f8f183..c482ceb 100644 --- a/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py +++ b/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py @@ -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() \ No newline at end of file diff --git a/docs/user/ptychography/flomni.md b/docs/user/ptychography/flomni.md index ebee457..7b365af 100644 --- a/docs/user/ptychography/flomni.md +++ b/docs/user/ptychography/flomni.md @@ -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 , 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 , 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.