diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/extra_tomo.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/extra_tomo.py index 5eac3a4..7a42fb6 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/extra_tomo.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/extra_tomo.py @@ -47,7 +47,7 @@ class MagLamNI(LamNI): for target_angle in np.linspace(current_angle, angle, steps, endpoint=True): umv(dev.lsamrot, target_angle) scans.lamni_move_to_scan_center( - self.align.tomo_fovx_offset, self.align.tomo_fovy_offset, target_angle + self.tomo_fovx_offset, self.tomo_fovy_offset, target_angle ) def _at_each_angle(self, angle: float) -> None: @@ -208,4 +208,4 @@ class DataDrivenLamNI(LamNI): shapes = [data.shape for data in self.tomo_data.values()] if len(set(shapes)) > 1: - raise ValueError(f"Tomo data file has entries of inconsistent lengths: {shapes}.") + raise ValueError(f"Tomo data file has entries of inconsistent lengths: {shapes}.") \ No newline at end of file diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index ac3ba2d..849a69a 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -32,6 +32,80 @@ if builtins.__dict__.get("bec") is not None: umvr = builtins.__dict__.get("umvr") +class _ProgressProxy: + """Dict-like proxy that persists the LamNI progress dict as a BEC global variable. + + Every read (`proxy["key"]`) fetches the current dict from the global var store, + and every write (`proxy["key"] = val`) fetches, updates, and saves it back. + This makes the progress state visible to all BEC client sessions via + ``client.get_global_var("tomo_progress")`` -- same key and shape as the + FlOMNI side, since only one tomography setup is ever live against a given + BEC session. + """ + + _GLOBAL_VAR_KEY = "tomo_progress" + _DEFAULTS: dict = { + "tomo_type": "Equally spaced sub-tomograms", + "subtomo": 0, + "subtomo_projection": 0, + "subtomo_total_projections": 1, + "projection": 0, + "total_projections": 1, + "angle": 0, + "tomo_start_time": None, + "estimated_remaining_time": None, + "estimated_finish_time": None, + "heartbeat": None, + "accumulated_idle_time": 0.0, + } + + def __init__(self, client): + self._client = client + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + def _load(self) -> dict: + val = self._client.get_global_var(self._GLOBAL_VAR_KEY) + if val is None: + return dict(self._DEFAULTS) + return val + + def _save(self, data: dict) -> None: + self._client.set_global_var(self._GLOBAL_VAR_KEY, data) + + # ------------------------------------------------------------------ + # Dict-like interface + # ------------------------------------------------------------------ + def __getitem__(self, key): + return self._load()[key] + + def __setitem__(self, key, value) -> None: + data = self._load() + data[key] = value + self._save(data) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self._load()!r})" + + def get(self, key, default=None): + return self._load().get(key, default) + + def update(self, *args, **kwargs) -> None: + """Update multiple fields in a single round-trip.""" + data = self._load() + data.update(*args, **kwargs) + self._save(data) + + def reset(self) -> None: + """Reset all progress fields to their default values.""" + self._save(dict(self._DEFAULTS)) + + def as_dict(self) -> dict: + """Return a plain copy of the current progress state.""" + return self._load() + + class LamNI(LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools): def __init__(self, client): super().__init__() @@ -40,13 +114,11 @@ class LamNI(LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools): self.device_manager = client.device_manager self.init = LaMNIInitStages(client) - # Correction state (owned by LamNIAlignmentMixin methods) - self.corr_pos_x = [] - self.corr_pos_y = [] - self.corr_angle = [] - self.corr_pos_x_2 = [] - self.corr_pos_y_2 = [] - self.corr_angle_2 = [] + # Correction state: corr_pos_x/y/angle (+ _2 iteration) are now + # global-var-backed properties defined in LamNIAlignmentMixin, so + # they are NOT initialized as plain lists here -- doing so would + # shadow the properties with instance attributes of the same name, + # silently breaking persistence. See lamni_alignment_mixin.py. # Extracted collaborators self.reconstructor = PtychoReconstructor(self.ptycho_reconstruct_foldername) @@ -59,15 +131,17 @@ class LamNI(LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools): self.special_angle_tolerance = 20 self._current_special_angles = [] - # Progress tracking - self.progress = {} - self.progress["tomo_type"] = "Equally spaced sub-tomograms" - self.progress["subtomo"] = 0 - self.progress["subtomo_projection"] = 0 - self.progress["subtomo_total_projections"] = 1 - self.progress["projection"] = 0 - self.progress["total_projections"] = 1 - self.progress["angle"] = 0 + # Progress tracking, persisted via the BEC global variable + # "tomo_progress" so it survives a client restart -- deliberately NOT + # reset here: this proxy is constructed every time LamNI() is + # instantiated (i.e. every new client session), and resetting + # unconditionally on every instantiation would wipe tomo_start_time + # (and everything else) on exactly the restarts where recovering + # scan state matters most. A genuinely new tomo_scan() call resets + # the relevant fields itself (see the "new scan" branch in + # tomo_scan()); use tomo_progress_reset() to explicitly clear stale + # progress without starting a new scan. + self._progress_proxy = _ProgressProxy(self.client) # ------------------------------------------------------------------ # Special angles @@ -90,6 +164,68 @@ class LamNI(LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools): self.special_angles = [] self.special_angle_repeats = 1 + # ------------------------------------------------------------------ + # Progress tracking + # ------------------------------------------------------------------ + + @property + def progress(self) -> _ProgressProxy: + """Proxy dict backed by the BEC global variable ``tomo_progress``. + + Readable from any BEC client session via:: + + client.get_global_var("tomo_progress") + + Individual fields can be read and written just like a regular dict:: + + lamni.progress["projection"] # read + lamni.progress["projection"] = 42 # write (persists immediately) + + To update multiple fields atomically use :py:meth:`_ProgressProxy.update`:: + + lamni.progress.update(projection=42, angle=90.0) + + To reset all fields to their defaults:: + + lamni.progress.reset() + """ + return self._progress_proxy + + @progress.setter + def progress(self, val: dict) -> None: + """Replace the entire progress dict. + + Accepts a plain :class:`dict` and persists it to the global var store. + """ + if not isinstance(val, dict): + raise TypeError(f"progress must be a dict, got {type(val).__name__!r}") + self._progress_proxy._save(val) + + def tomo_progress_reset(self) -> None: + """Explicitly clear the persisted tomo progress (start time, ETA, + current angle/subtomo/projection, accumulated idle time, ...). + + Not called automatically on LamNI() startup -- that would wipe an + in-progress scan's state on every kernel restart. Call this by hand + if you want a clean progress display without it being tied to + starting a new tomo_scan() (which already resets the relevant + fields itself). + """ + self._progress_proxy.reset() + print("Tomo progress reset.") + + @staticmethod + def _format_duration(seconds: float) -> str: + """Format a duration in seconds as a human-readable string, e.g. '2h 03m 15s'.""" + seconds = int(seconds) + 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" + # ------------------------------------------------------------------ # X-ray eye alignment entry points # ------------------------------------------------------------------ @@ -110,6 +246,10 @@ class LamNI(LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools): aligner.align(keep_shutter_open=keep_shutter_open) except KeyboardInterrupt as exc: print("Alignment interrupted by user.") + try: + aligner.gui.hide_crosshair() + except Exception as gui_exc: # pylint: disable=broad-except + logger.warning(f"Failed to hide XRayEye alignment crosshair: {gui_exc}") raise exc # ── Reset manual shifts if needed ───────────────────────────── @@ -303,6 +443,23 @@ class LamNI(LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools): def corridor_size(self, val: float): self.client.set_global_var("corridor_size", val) + @property + def frames_per_trigger(self): + """Number of burst frames acquired per point/projection. Used by + scans.lamni_fermat_scan (via tomo_scan_projection).""" + val = self.client.get_global_var("frames_per_trigger") + if val is None: + return 1 + return val + + @frames_per_trigger.setter + def frames_per_trigger(self, val: int): + if isinstance(val, bool) or not isinstance(val, int): + raise ValueError("frames_per_trigger must be a positive integer.") + if val <= 0: + raise ValueError("frames_per_trigger must be a positive integer.") + self.client.set_global_var("frames_per_trigger", val) + @property def lamni_stitch_x(self): val = self.client.get_global_var("lamni_stitch_x") @@ -508,6 +665,7 @@ class LamNI(LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools): angle=angle, scan_type="fly", exp_time=self.tomo_countingtime, + frames_per_trigger=self.frames_per_trigger, optim_trajectory_corridor=corridor_size, ) @@ -529,13 +687,38 @@ class LamNI(LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools): # ------------------------------------------------------------------ def _print_progress(self): + # --- compute and store estimated remaining time ----------------------- + start_str = self.progress.get("tomo_start_time") + projection = self.progress["projection"] + total = self.progress["total_projections"] + if start_str is not None and total > 0 and projection > 9: + now = datetime.datetime.now() + elapsed = (now - datetime.datetime.fromisoformat(start_str)).total_seconds() + # Exclude detected idle time (beamline-down pauses, a crash + + # restart gap, ...) so it doesn't make the scan look slower than + # it actually is while it's running. + elapsed -= self.progress.get("accumulated_idle_time", 0.0) + elapsed = max(elapsed, 1.0) # guard against a degenerate/negative denominator + rate = projection / elapsed # projections per second + remaining_s = (total - projection) / rate + self.progress["estimated_remaining_time"] = remaining_s + eta_str = self._format_duration(remaining_s) + finish_dt = now + datetime.timedelta(seconds=remaining_s) + self.progress["estimated_finish_time"] = finish_dt.isoformat() + finish_str = finish_dt.strftime("%Y-%m-%d %H:%M:%S") + else: + eta_str = "N/A" + finish_str = "N/A" + # ---------------------------------------------------------------------- print("\x1b[95mProgress report:") print(f"Tomo type: ....................... {self.progress['tomo_type']}") print(f"Projection: ...................... {self.progress['projection']}") print(f"Total projections expected ....... {self.progress['total_projections']}") print(f"Angle: ........................... {self.progress['angle']}") print(f"Current subtomo: ................. {self.progress['subtomo']}") - print(f"Current projection within subtomo: {self.progress['subtomo_projection']}\x1b[0m") + print(f"Current projection within subtomo: {self.progress['subtomo_projection']}") + print(f"Estimated remaining time: ........ {eta_str}") + print(f"Estimated finish time: ........... {finish_str}\x1b[0m") self._lamnigui_update_progress() # ------------------------------------------------------------------ @@ -578,6 +761,34 @@ class LamNI(LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools): successful = False error_caught = False if 0 <= angle < 360.05: + now = datetime.datetime.now() + prev_heartbeat_str = self.progress.get("heartbeat") + if prev_heartbeat_str is not None: + gap = (now - datetime.datetime.fromisoformat(prev_heartbeat_str)).total_seconds() + # Normal cadence between consecutive projections is roughly the + # acquisition time (times the number of stitch tiles per + # projection) plus motor/readout overhead. A gap well beyond + # that means something interrupted the scan in between + # (beamline-down interlock pause, a crash + manual restart, + # ...) -- attribute the excess to idle time so it doesn't drag + # down the apparent scan rate used for the ETA above. The + # 5x/60s margins are a heuristic, not a precise timing model -- + # tune if it over/under-triggers in practice. + n_tiles = (2 * self.lamni_stitch_x + 1) * (2 * self.lamni_stitch_y + 1) + normal_cadence = max( + 60.0, 5 * self.tomo_countingtime * self.frames_per_trigger * n_tiles + ) + if gap > normal_cadence: + idle = gap - normal_cadence + self.progress["accumulated_idle_time"] = ( + self.progress.get("accumulated_idle_time", 0.0) + idle + ) + print( + f"Detected a {self._format_duration(gap)} gap since the last projection" + f" -- excluding {self._format_duration(idle)} from the ETA estimate." + ) + self.progress["heartbeat"] = now.isoformat() + print(f"Starting LamNI scan for angle {angle} in subtomo {subtomo_number}") self._print_progress() while not successful: @@ -683,6 +894,14 @@ class LamNI(LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools): "BEC", ) self.write_pdf_report() + self.progress["tomo_start_time"] = datetime.datetime.now().isoformat() + # reset stale estimates from any previous scan, otherwise the GUI + # would keep showing a leftover ETA from before this scan has + # accumulated enough projections to compute a fresh one + self.progress["estimated_remaining_time"] = None + self.progress["estimated_finish_time"] = None + self.progress["accumulated_idle_time"] = 0.0 + self.progress["heartbeat"] = None with scans.dataset_id_on_hold: if self.tomo_type == 1: @@ -771,6 +990,14 @@ class LamNI(LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools): else: raise ValueError(f"Unknown tomo_type: {self.tomo_type}.") + self.progress["projection"] = self.progress["total_projections"] + self.progress["subtomo_projection"] = self.progress["subtomo_total_projections"] + self._print_progress() + print( + "Total measurement time lost to detected gaps:" + f" {self._format_duration(self.progress.get('accumulated_idle_time', 0.0))}" + ) + # ------------------------------------------------------------------ # Parameter display and interactive update # ------------------------------------------------------------------ @@ -788,6 +1015,7 @@ class LamNI(LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools): print(f"Stitching overlap = {self.tomo_stitch_overlap}") print(f"Circular FOV diam = {self.tomo_circfov}") print(f"Reconstruction queue name = {self.ptycho_reconstruct_foldername}") + print(f"Frames per trigger (burst) = {self.frames_per_trigger}") print("FOV offset rotates to find the ROI; initial values determined in Xrayeye alignment.") print("manual shift moves the rotation center.") print(f" _tomo_fovx_offset = {self.tomo_fovx_offset:.4f}") @@ -839,6 +1067,9 @@ class LamNI(LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools): self.ptycho_reconstruct_foldername = self._get_val( "Reconstruction queue", self.ptycho_reconstruct_foldername, str ) + self.frames_per_trigger = self._get_val( + "Frames per trigger (burst)", self.frames_per_trigger, int + ) print("Tomography type:") print(" 1: 8 equally spaced sub-tomograms (360 deg)") @@ -988,4 +1219,4 @@ class LamNI(LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools): print(f"{angle},{voltage1},{voltage2}") time.sleep(.3) - print("Finished") + print("Finished") \ No newline at end of file diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py index bda8c1d..b0c44d8 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_alignment_mixin.py @@ -7,9 +7,10 @@ the :class:`LamNI` class, mirroring the pattern used by The mixin assumes the hosting class provides: - ``self.client`` (BECClient) -State that must be initialised in the host ``__init__``: - - ``self.corr_pos_x``, ``self.corr_pos_y``, ``self.corr_angle`` - - ``self.corr_pos_x_2``, ``self.corr_pos_y_2``, ``self.corr_angle_2`` +``corr_pos_x``/``corr_pos_y``/``corr_angle`` (+ the ``_2`` iteration) are +global-var-backed properties defined below -- the host class's ``__init__`` +must NOT assign plain-list instance attributes of these names, as that would +shadow the properties and silently break persistence. """ from __future__ import annotations @@ -41,7 +42,7 @@ class LamNIAlignmentMixin: # ------------------------------------------------------------------ def reset_correction(self): - """Reset the look-up-table corrections to empty (iteration 1 and 2).""" + """Reset the look-up-table corrections to empty (iteration 1).""" self.corr_pos_x = [] self.corr_pos_y = [] self.corr_angle = [] @@ -56,6 +57,74 @@ class LamNIAlignmentMixin: """Delete the X-ray eye sinusoidal fit from the BEC global variable store.""" self.client.delete_global_var("tomo_fit_xray_eye") + # ------------------------------------------------------------------ + # Additional lookup-table correction properties -- global-var-backed so + # they survive a BEC client restart, same pattern as tomo_fovx_offset/ + # tomo_fovy_offset below and as FlomniAlignmentMixin's corr_pos_y/ + # corr_angle_y on the FlOMNI side. Keys are "lamni_"-prefixed because the + # shape here differs from the FlOMNI side (real x *and* y correction + # plus a shared angle grid, vs. FlOMNI's y-only correction) -- not meant + # to be the same data, just the same persistence pattern. + # + # No default correction file exists for LamNI (unlike FlOMNI's + # default_correction_file): reset_correction()/reset_correction_2() just + # clear to empty, nothing is auto-loaded. + # ------------------------------------------------------------------ + + @property + def corr_pos_x(self): + val = self.client.get_global_var("lamni_corr_pos_x") + return [] if val is None else val + + @corr_pos_x.setter + def corr_pos_x(self, val: list): + self.client.set_global_var("lamni_corr_pos_x", val) + + @property + def corr_pos_y(self): + val = self.client.get_global_var("lamni_corr_pos_y") + return [] if val is None else val + + @corr_pos_y.setter + def corr_pos_y(self, val: list): + self.client.set_global_var("lamni_corr_pos_y", val) + + @property + def corr_angle(self): + val = self.client.get_global_var("lamni_corr_angle") + return [] if val is None else val + + @corr_angle.setter + def corr_angle(self, val: list): + self.client.set_global_var("lamni_corr_angle", val) + + @property + def corr_pos_x_2(self): + val = self.client.get_global_var("lamni_corr_pos_x_2") + return [] if val is None else val + + @corr_pos_x_2.setter + def corr_pos_x_2(self, val: list): + self.client.set_global_var("lamni_corr_pos_x_2", val) + + @property + def corr_pos_y_2(self): + val = self.client.get_global_var("lamni_corr_pos_y_2") + return [] if val is None else val + + @corr_pos_y_2.setter + def corr_pos_y_2(self, val: list): + self.client.set_global_var("lamni_corr_pos_y_2", val) + + @property + def corr_angle_2(self): + val = self.client.get_global_var("lamni_corr_angle_2") + return [] if val is None else val + + @corr_angle_2.setter + def corr_angle_2(self, val: list): + self.client.set_global_var("lamni_corr_angle_2", val) + # ------------------------------------------------------------------ # FOV offset properties (backed by BEC global variable) # ------------------------------------------------------------------ @@ -279,4 +348,4 @@ class LamNIAlignmentMixin: shift_y = corr_pos_y[-1] print(f"Additional correction {label}: x={shift_x}, y={shift_y}") - return (shift_x, shift_y) + return (shift_x, shift_y) \ No newline at end of file diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py index e96cf4f..f4e3134 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/x_ray_eye_align.py @@ -5,6 +5,7 @@ import os import time from typing import TYPE_CHECKING +import h5py import numpy as np from bec_lib import bec_logger @@ -50,9 +51,17 @@ class XrayEyeAlign: self.lamni = lamni self.device_manager = client.device_manager self.scans = client.scans - # Reset correction state on the lamni object (mirrors FlOMNI pattern) - self.lamni.reset_correction() - self.lamni.reset_xray_eye_correction() + # Deliberately NOT calling self.lamni.reset_correction()/ + # reset_xray_eye_correction() here: a fresh XrayEyeAlign is + # constructed on *every* call to xrayeye_alignment_start() AND + # xrayeye_update_frame() (see lamni.py) -- the latter is meant to be + # a lightweight "just grab a frame to look at" utility, not the start + # of a new alignment run. Now that corr_pos_x/y/angle and + # tomo_fit_xray_eye are persisted via BEC global vars, resetting them + # here would silently wipe a valid, already-fitted alignment every + # time someone calls xrayeye_update_frame() to check the sample. + # These resets belong in align() instead, matching what an actual + # fresh alignment run should do -- see the start of align() below. # alignment_values[k] = [x_mm, y_mm] # k=0 : FZP centre # k=1 : sample at 0° (reference; shift_xy computed from k=0 vs k=1) @@ -60,6 +69,7 @@ class XrayEyeAlign: # ... # k=8 : sample at 315° self.alignment_values: dict[int, list[float]] = {} + self.alignment_images = [] # ------------------------------------------------------------------ # GUI shortcut @@ -77,6 +87,11 @@ class XrayEyeAlign: def _reset_init_values(self): self.shift_xy = [0.0, 0.0] # base shift to bring sample to beam centre [µm] self._xray_fov_xy = [0.0, 0.0] + # Raw pixel coords + ROI size collected at each submit: + # [[step_k, x_px, y_px, w_px, h_px, image_idx], ...] + # image_idx refers to alignment_images[image_idx], i.e. the last + # frame captured before that submit (shutter is closed at submit time). + self.roi_pixel_data = [] def tomo_rotate(self, val: float): umv(self.device_manager.devices.lsamrot, val) @@ -102,12 +117,16 @@ class XrayEyeAlign: if not dev.cam_xeye.live_mode_enabled.get(): dev.cam_xeye.live_mode_enabled.put(True) self.gui.on_live_view_enabled(True) - dev.omnyfsh.fshopen() + dev.fsh.fshopen() time.sleep(0.5) + # store the image: the relevant frame for any submit that follows, + # since the shutter is closed again (unless keep_shutter_open) by + # the time the user actually clicks submit + self.alignment_images.append(dev.cam_xeye.get_last_image()) if not keep_shutter_open: self.gui.on_live_view_enabled(False) time.sleep(0.1) - dev.omnyfsh.fshclose() + dev.fsh.fshclose() print("Received new frame.") else: print("Received new frame. Shutter remains open and live view active.") @@ -160,6 +179,11 @@ class XrayEyeAlign: self.lamni.lamnigui_show_xeyealign() self.send_message("Getting things ready. Please wait...") + # Actual start of a fresh alignment run -- see the comment in + # __init__ for why this can't live there. + self.lamni.reset_correction() + self.lamni.reset_xray_eye_correction() + self.gui.enable_submit_button(False) # Initialise EPICS GUI device state @@ -169,6 +193,7 @@ class XrayEyeAlign: self.movement_buttons_enabled(False, False) self._reset_init_values() + self.alignment_images = [] # --- Step 0: FZP centre ------------------------------------------ #self._disable_rt_feedback() @@ -200,6 +225,20 @@ class XrayEyeAlign: ) dev.omny_xray_gui.submit.set(0) + # Raw pixel position and ROI size at submit time. The + # relevant image is the last captured frame (shutter is + # closed by the time the user clicks submit). + _raw_x = getattr(dev.omny_xray_gui, f"xval_x_{k}").get() + _raw_y = getattr(dev.omny_xray_gui, f"yval_y_{k}").get() + _raw_w = getattr(dev.omny_xray_gui, f"width_x_{k}").get() + _raw_h = getattr(dev.omny_xray_gui, f"width_y_{k}").get() + _img_idx = len(self.alignment_images) - 1 + print( + f" Submit k={k}: px x={_raw_x:.1f} y={_raw_y:.1f} " + f"w={_raw_w:.1f} h={_raw_h:.1f} img={_img_idx}" + ) + self.roi_pixel_data.append([k, _raw_x, _raw_y, _raw_w, _raw_h, _img_idx]) + # --- k=0: received FZP centre ---------------------------- if k == 0: self.send_message("Please wait - moving sample in...") @@ -212,6 +251,15 @@ class XrayEyeAlign: #self._enable_rt_feedback() self.update_frame(keep_shutter_open) + + # Mark the FZP centre on the live view: it stays visible + # as a fixed reference while the sample is aligned at + # each subsequent rotation angle below. + fzp_center_x = dev.omny_xray_gui.xval_x_0.get() + fzp_center_y = dev.omny_xray_gui.yval_y_0.get() + self.gui.set_crosshair_position(fzp_center_x, fzp_center_y) + self.gui.show_crosshair() + self.send_message("Find the sample and submit its centre.") self.gui.enable_submit_button(True) self.movement_buttons_enabled(True, True) @@ -295,6 +343,7 @@ class XrayEyeAlign: self.gui.enable_submit_button(False) self.movement_buttons_enabled(False, False) self.update_fov(k) + self.gui.hide_crosshair() break k += 1 @@ -357,7 +406,7 @@ class XrayEyeAlign: if keep_shutter_open: answer = input("Close the shutter now? [Y/n]: ").strip().lower() if answer in ("", "y", "yes"): - dev.omnyfsh.fshclose() + dev.fsh.fshclose() self.gui.on_live_view_enabled(False) print("Shutter closed.") else: @@ -371,6 +420,25 @@ class XrayEyeAlign: "Fine alignment: lamni.tomo_parameters() with offset 0, then lamni.sub_tomo_scan(1,0)" ) + # ------------------------------------------------------------------ + # HDF5 output + # ------------------------------------------------------------------ + + def _save_alignment_data(self, file_path: str, fit_data: np.ndarray | None = None): + expanded = os.path.expanduser(file_path) + os.makedirs(os.path.dirname(expanded), exist_ok=True) + with h5py.File(expanded, "w") as f: + f.create_dataset( + "alignment_values", data=np.array(list(self.alignment_values.values())) + ) + f.create_dataset("alignment_images", data=np.array(self.alignment_images)) + if self.roi_pixel_data: + ds = f.create_dataset("roi_pixel_data", data=np.array(self.roi_pixel_data)) + ds.attrs["columns"] = ["step_k", "x_px", "y_px", "w_px", "h_px", "image_idx"] + if fit_data is not None: + ds = f.create_dataset("alignment_fit", data=fit_data) + ds.attrs["rows"] = ["angles_deg", "fovx_offsets_um", "fovy_offsets_um"] + # ------------------------------------------------------------------ # Fit data preparation and submission # ------------------------------------------------------------------ @@ -387,6 +455,12 @@ class XrayEyeAlign: row 0: angles [0, 45, ..., 315] row 1: x offsets [um] row 2: y offsets [um] + + Also writes a timestamped HDF5 file alongside the archival text file, + containing the full raw record of the alignment run: alignment_values + (FZP centre + all 8 angle clicks, in mm), alignment_images (one frame + per update_frame() call), roi_pixel_data (raw pixel coords/size at + each submit), and this same fit array as alignment_fit. """ # Archival text file (backward compatible with any external scripts) file = os.path.expanduser("~/Data10/specES1/internal/xrayeye_alignmentvalues") @@ -422,6 +496,11 @@ class XrayEyeAlign: ) data = np.array([angles, x_offsets, y_offsets]) + # Timestamped HDF5 archive of the full raw alignment run + timestamp = time.strftime("%Y%m%d_%H%M%S") + file_h5 = f"~/data/raw/logs/xrayeye_alignmentvalues/xrayeye_alignmentvalues_{timestamp}.h5" + self._save_alignment_data(file_h5, fit_data=data) + # Push to XRayEye widget: feeds waveform_x (row 1) and waveform_y (row 2) self.gui.submit_fit_array(data) - print(f"Fit data submitted with shape {data.shape}:\n{data}") + print(f"Fit data submitted with shape {data.shape}:\n{data}") \ No newline at end of file