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:
@@ -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}")
|
||||
Reference in New Issue
Block a user