Files
x01dc 68a56607b8
CI for csaxs_bec / test (push) Successful in 1m54s
fix(omny): fix scan hang -- RT flyer typo, add missing tracker-signal guard
Two bugs found by comparing omny's RT-flyer code against flomni's (per
Mirko, since flomni/lamni's fermat scans already work fine in sim).

1. Root cause of the reported scan hang (tomo_scan_projection() starts a
   scan but the progress bar freezes at 0% forever, no error):
   RtOMNYFlyer.read_positions_from_sampler() called self.get_scan_status()
   instead of self.controller.get_scan_status(). get_scan_status() only
   exists on RtOMNYController, never on RtOMNYFlyer -- so this line raised
   an AttributeError inside the background thread complete() spawns for
   readout. An uncaught exception in a thread target doesn't propagate;
   the thread just dies silently, before ever calling status.set_finished().
   scan_core()'s `while not status.done` loop then spins forever, and since
   the thread died before ever updating progress, the bar stays at 0%.
   Confirmed correct in both RtFlomniFlyer's and RtLamniFlyer's equivalents
   already; fixed to match.

2. RtOMNYSetpointSignal._socket_set() never had the laser-tracker-signal-
   strength guard RtFlomniSetpointSignal._socket_set() got in commit
   68320e1 ("check tracker signal before rt move, which is otherwise stuck
   forever") -- a real-hardware failure mode (a move issued with a weak
   interferometer signal never converges). OMNY has its own duplicate
   setpoint-signal class, so the fix never propagated. Ported using OMNY's
   own existing laser_tracker_check_and_wait_for_signalstrength() (a more
   capable, retrying/auto-readjusting check) rather than flomni's
   single-shot version, which OMNY doesn't have. Lower confidence this one
   is the actual cause of the reported *simulated* hang specifically --
   the sim doesn't model signal-dependent convergence -- but it's a real,
   confirmed gap worth closing for real-hardware safety regardless.

4 new regression tests in tests/tests_devices/test_rt_omny.py, including
one that directly confirms the fake stand-in used for bug #1's test would
have caught the original bug (raises AttributeError the same way the real
class would).
2026-09-01 21:27:43 +02:00

91 lines
3.8 KiB
Python

"""Regression tests for two RT-flyer bugs in rt_omny_ophyd.py, found by comparing
against flomni's (working) equivalents:
1. RtOMNYFlyer.read_positions_from_sampler() called self.get_scan_status() instead
of self.controller.get_scan_status() -- get_scan_status() only exists on
RtOMNYController, never on RtOMNYFlyer itself. This raised an AttributeError
inside the background readout thread started by complete(); since an
uncaught exception in a thread target doesn't propagate, the thread died
silently before ever calling status.set_finished(), hanging the scan
forever at 0% progress (scan_core()'s `while not status.done` loop never
exits). flomni's and lamni's equivalents were already correct.
2. RtOMNYSetpointSignal._socket_set() never checked the interferometer signal
strength before moving, unlike RtFlomniSetpointSignal._socket_set() (added
in commit 68320e1, "check tracker signal before rt move, which is
otherwise stuck forever") -- a real-hardware failure mode where a move
issued with a weak signal never converges. OMNY has its own duplicate
setpoint-signal class, so the fix never propagated.
"""
from unittest import mock
import pytest
from csaxs_bec.devices.omny.rt.rt_omny_ophyd import RtOMNYFlyer, RtOMNYSetpointSignal
class _FakeFlyerSelf:
"""Minimal stand-in for RtOMNYFlyer -- deliberately has no get_scan_status of
its own (matching the real class), so a regression to `self.get_scan_status()`
(instead of `self.controller.get_scan_status()`) raises AttributeError here too,
the same way it would on the real class.
"""
def __init__(self):
self.controller = mock.MagicMock()
self.shutdown_event = mock.MagicMock()
self.average_stdeviations_x_st_fzp = 0
self.average_stdeviations_y_st_fzp = 0
self.progress = mock.MagicMock()
self.data = mock.MagicMock()
def test_read_positions_from_sampler_uses_controller_get_scan_status():
fake_self = _FakeFlyerSelf()
# mode=0 -> the `while mode > 0` loop body never runs; falls straight to the
# shutdown-event check below it
fake_self.controller.get_scan_status.return_value = (0, 0, 0)
fake_self.shutdown_event.wait.return_value = True
status = mock.MagicMock()
RtOMNYFlyer.read_positions_from_sampler(fake_self, status)
fake_self.controller.get_scan_status.assert_called_once()
status.set_finished.assert_called_once()
def test_read_positions_from_sampler_would_have_raised_on_the_old_bug():
"""Sanity-check that _FakeFlyerSelf actually catches the regression: calling
the buggy `self.get_scan_status()` form directly must raise AttributeError."""
fake_self = _FakeFlyerSelf()
with pytest.raises(AttributeError):
fake_self.get_scan_status()
def test_socket_set_checks_laser_tracker_before_moving():
fake_self = mock.MagicMock()
fake_self.parent.controller.feedback_is_running.return_value = True
call_order = []
fake_self.parent.controller.laser_tracker_check_and_wait_for_signalstrength.side_effect = (
lambda: call_order.append("check")
)
fake_self.set_with_feedback_disabled.side_effect = lambda val: call_order.append("move")
RtOMNYSetpointSignal._socket_set(fake_self, 10.0)
assert call_order == ["check", "move"]
fake_self.parent.controller.laser_tracker_check_and_wait_for_signalstrength.assert_called_once()
fake_self.set_with_feedback_disabled.assert_called_once_with(10.0)
def test_socket_set_raises_when_feedback_not_running():
fake_self = mock.MagicMock()
fake_self.parent.controller.feedback_is_running.return_value = False
with pytest.raises(Exception):
RtOMNYSetpointSignal._socket_set(fake_self, 10.0)
fake_self.parent.controller.laser_tracker_check_and_wait_for_signalstrength.assert_not_called()
fake_self.set_with_feedback_disabled.assert_not_called()