feat(lamni): persist tomo scan progress via BEC global var

Replace the plain self.progress dict with a global-var-backed
_ProgressProxy (same shape and key as FlOMNI's), so progress survives
a client restart. Adds heartbeat + idle-time-gap detection in
_tomo_scan_at_angle, ETA computation in _print_progress, and
tomo_start_time/reset handling in tomo_scan(). Adds
tomo_progress_reset() and _format_duration() for parity.

gui_tools.py's progress display already read these fields; no GUI
change needed.

feat(lamni): persist alignment lookup-table corrections

corr_pos_x/corr_pos_y/corr_angle (+ _2 iteration) become
global-var-backed properties (lamni_corr_pos_x, lamni_corr_pos_y,
lamni_corr_angle, ..._2) instead of plain instance lists that reset
on every client restart. No default correction file exists for
LamNI, so reset_correction()/reset_correction_2() just clear to
empty.

feat(lamni): add frames_per_trigger (burst mode) support

LamNIFermatScan already inherits from AsyncFlyScanBase and already
accepts/passes through frames_per_trigger -- only the client-side
plumbing was missing. Adds a validated, global-var-backed
frames_per_trigger property, threads it into
tomo_scan_projection()'s lamni_fermat_scan() call, and exposes it in
tomo_parameters(). Also folds it into the idle-time cadence estimate
alongside stitch tile count.

feat(lamni): add crosshair and structured HDF5 output to X-ray eye alignment

Set the alignment crosshair at the FZP centre (step k=0, raw pixel
coords), hidden on normal completion and on Ctrl-C abort.

update_frame() now actually captures and stores each frame
(previously not stored at all); roi_pixel_data is now collected at
every submit. write_output() additionally writes a timestamped HDF5
file (alignment_values, alignment_images, roi_pixel_data,
alignment_fit) alongside the existing plain-text archival file.

fix(lamni): stop resetting correction state on every XrayEyeAlign instantiation

A fresh XrayEyeAlign is constructed on every call to both
xrayeye_alignment_start() and xrayeye_update_frame(); resetting
corr_pos_x/y/angle and tomo_fit_xray_eye in __init__ meant the
latter -- meant as a lightweight frame check -- silently wiped valid
persisted correction data every time. Reset moved to the start of
align() itself.

fix(lamni): use dev.fsh instead of dev.omnyfsh for the alignment shutter

The shared XRayEye widget monitors and controls dev.fsh; the
alignment script was opening/closing a different device, which
would desync the GUI's shutter toggle from the actual hardware
state.

fix(lamni): correct stale self.align reference in MagLamNI.rotate_slowly

self.align no longer exists as an attribute now that
LamNIAlignmentMixin is mixed directly into LamNI. Use
self.tomo_fovx_offset/self.tomo_fovy_offset directly.
This commit is contained in:
x01dc
2026-07-12 08:07:50 +02:00
parent 610bb6bac5
commit e3ff5ffd48
4 changed files with 411 additions and 32 deletions
@@ -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}.")
@@ -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 <microns> = {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 <mm> = {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")
@@ -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)
@@ -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}")