fix(lamni): tolerate transient GUI heartbeat race, fix sample-name prompt order
CI for csaxs_bec / test (pull_request) Successful in 1m46s
Read the Docs Deploy Trigger / trigger-rtd-webhook (push) Successful in 1s
CI for csaxs_bec / test (push) Successful in 1m46s

xrayeye_rotation_center_calibration_isolated/smear_experimental() reproducibly
crashed with "RuntimeError: GUI is not alive" when keep_shutter_open=True, right
after a long blocking device move (interferometer feedback reset, live rotation
sweep). Root cause: bec_widgets' GUI liveness check is a Redis heartbeat with a
10s TTL refreshed from the same Qt event loop that renders live-view frames;
with live view left on continuously, a long blocking move can starve that
heartbeat past its TTL even though the GUI process is still alive. Add
_gui_call_with_retry() and use it at the on_live_view_enabled(True) call sites
that follow these blocking waits, so the transient false negative is retried
instead of crashing the calibration.

Also reorder the sample-name prompt in find_rotation_center() and
find_rotation_center_smear_experimental() to run before the alignment GUI is
shown -- showing the GUI first steals OS focus, forcing the operator to click
back to the terminal to answer the prompt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit was merged in pull request #289.
This commit is contained in:
x01dc
2026-07-30 16:01:10 +02:00
co-authored by Claude Sonnet 5
parent fa322bfd43
commit fa30e11b3c
@@ -153,6 +153,25 @@ class XrayEyeAlign:
def _enable_rt_feedback(self):
self.device_manager.devices.rtx.controller.feedback_enable_with_reset()
def _gui_call_with_retry(self, func, *args, retries: int = 8, retry_delay: float = 1.5, **kwargs):
"""Call a bec_widgets GUI RPC method, retrying briefly on "GUI is not alive".
The GUI's liveness check is a Redis heartbeat with a 10 s TTL, refreshed
every 200 ms from the same Qt event loop that renders live-view frames.
A long blocking device move (e.g. interferometer feedback reset, a live
rotation sweep) with live view left on can starve that heartbeat past
its TTL even though the GUI process is still alive -- it catches up and
resumes the heartbeat shortly after the block ends, so a short retry
loop recovers without needing any change to bec_widgets itself.
"""
for attempt in range(retries):
try:
return func(*args, **kwargs)
except RuntimeError as exc:
if "GUI is not alive" not in str(exc) or attempt == retries - 1:
raise
time.sleep(retry_delay)
def update_frame(self, keep_shutter_open: bool = False):
"""Capture a fresh camera frame.
@@ -170,7 +189,7 @@ class XrayEyeAlign:
self.lamni.lamnigui_show_xeyealign()
if not dev.cam_xeye.live_mode_enabled.get():
dev.cam_xeye.live_mode_enabled.put(True)
self.gui.on_live_view_enabled(True)
self._gui_call_with_retry(self.gui.on_live_view_enabled, True)
dev.fsh.fshopen()
time.sleep(1)
# store the image: the relevant frame for any submit that follows,
@@ -200,19 +219,18 @@ class XrayEyeAlign:
print(f"Alignment GUI: {msg}")
self.gui.user_message = msg
def _sync_sample_name(self, prompt: bool = False):
"""Push lamni.sample_name into the XRayEye GUI's sample_name field.
def _prompt_sample_name(self):
"""Ask for the sample name via the same _get_val() pattern
tomo_parameters() uses (Enter keeps the current value).
If prompt=True, first ask for it via the same _get_val() pattern
tomo_parameters() uses (Enter keeps the current value) -- used at
the rotation-center steps, which are often the first alignment
action for a new sample, before tomo_parameters() has necessarily
run.
Must run before the alignment GUI is shown: showing the GUI first
raises/focuses its window, so a terminal prompt run afterwards
forces the operator to click back to the terminal to answer it.
"""
if prompt:
self.lamni.sample_name = self.lamni._get_val(
"sample name", self.lamni.sample_name, str
)
self.lamni.sample_name = self.lamni._get_val("sample name", self.lamni.sample_name, str)
def _sync_sample_name(self):
"""Push lamni.sample_name into the XRayEye GUI's sample_name field."""
self.gui.sample_name = self.lamni.sample_name
# ------------------------------------------------------------------
@@ -695,7 +713,7 @@ class XrayEyeAlign:
print(f"[rotation-center] starting live sweep through {angles} deg (shutter/live-view open)")
if not dev.cam_xeye.live_mode_enabled.get():
dev.cam_xeye.live_mode_enabled.put(True)
self.gui.on_live_view_enabled(True)
self._gui_call_with_retry(self.gui.on_live_view_enabled, True)
dev.fsh.fshopen()
self._disable_rt_feedback()
for target in angles:
@@ -772,7 +790,7 @@ 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)
self._gui_call_with_retry(self.gui.on_live_view_enabled, True)
self.gui.set_live_view_signal("smear_preview")
self.gui.set_smear_active(True)
dev.fsh.fshopen()
@@ -869,8 +887,9 @@ class XrayEyeAlign:
Returns:
tuple: (new_lsamx_center, new_lsamy_center) in mm.
"""
self._prompt_sample_name()
self.lamni.lamnigui_show_xeyealign()
self._sync_sample_name(prompt=True)
self._sync_sample_name()
self.gui.set_dap_params_forwarding(False)
self._reset_init_values()
self.alignment_images = []
@@ -1180,8 +1199,9 @@ class XrayEyeAlign:
f"sample_type must be one of {self.ROTATION_CENTER_SAMPLE_TYPES}, got {sample_type!r}"
)
self._prompt_sample_name()
self.lamni.lamnigui_show_xeyealign()
self._sync_sample_name(prompt=True)
self._sync_sample_name()
self.gui.set_dap_params_forwarding(False)
self._reset_init_values()
self.alignment_images = []